Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/e2e/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ E2E_POSTGRES_TARGET_DB=foxdb
E2E_POSTGRES_TARGET_USER=foxuser
E2E_POSTGRES_TARGET_PASS=foxpass
E2E_POSTGRES_TARGET_SCHEMA=demo_b

# Optional extra dialects (see docker-compose.yml + scripts/seed/seed-all.sh):
# ClickHouse :8123 default/foxpass db=demo_a vs demo_b
# TiDB :4000 foxuser/foxpass db=demo_a vs demo_b
# Redshift* :5439 foxuser/foxpass db=foxdb schema=demo_a vs demo_b (*Postgres stand-in)
# DuckDB /tmp/foxschema-duckdb/demo_{a,b}.duckdb
3 changes: 2 additions & 1 deletion apps/e2e/src/helpers/db-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ function readConfig(envPrefix: string, dialect: string): DbConfig | null {
const host = process.env[`${envPrefix}_HOST`];
const db = process.env[`${envPrefix}_DB`];
const user = process.env[`${envPrefix}_USER`];
// Empty password is valid (e.g. local TiDB root) — only missing key skips.
const pass = process.env[`${envPrefix}_PASS`];
if (!host || !db || !user || !pass) return null;
if (!host || !db || !user || pass === undefined) return null;
return {
dialect,
host,
Expand Down
12 changes: 11 additions & 1 deletion apps/e2e/src/pages/ConnectionModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ export class ConnectionModal {
}
}

async uncheckSavePassword(): Promise<void> {
const box = this.page.locator('[data-testid="conn-save-password"]');
if ((await box.count()) > 0 && (await box.isChecked())) {
await box.uncheck();
}
}

/** Fill all fields then save. */
async connect(fields: ConnectionFields): Promise<void> {
await this.selectDialect(fields.dialect);
Expand All @@ -93,7 +100,10 @@ export class ConnectionModal {
if (fields.port !== undefined) await this.fillPort(fields.port);
await this.fillUsername(fields.username ?? '');
await this.fillPassword(fields.password ?? '');
await this.checkSavePassword();
// Empty password (e.g. local TiDB root) cannot be stored while “Save
// password” is ticked — the modal blocks save. Untick in that case.
if (fields.password?.trim()) await this.checkSavePassword();
else await this.uncheckSavePassword();
}
await this.fillDatabase(fields.database);
await this.loadSchemas();
Expand Down
3 changes: 2 additions & 1 deletion apps/e2e/src/pages/SqlEditorPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ export class SqlEditorPage {
if (cfg.port !== undefined) await modal.fillPort(cfg.port);
if (cfg.username !== undefined) await modal.fillUsername(cfg.username);
if (cfg.password !== undefined) await modal.fillPassword(cfg.password);
if (cfg.password !== undefined) await modal.checkSavePassword();
if (cfg.password?.trim()) await modal.checkSavePassword();
else await modal.uncheckSavePassword();
}
await modal.fillDatabase(cfg.database);
// loadSchemas throws on conn-test-failed — never persist a bad credential.
Expand Down
5 changes: 4 additions & 1 deletion apps/e2e/src/tests/dialects/clickhouse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { runDialectFlow } from './shared-flow.js';
const DIALECT = 'clickhouse';

describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => {
// ClickHouse DDL from the migrate planner still emits PostgreSQL-style
// PRIMARY KEY clauses that MergeTree rejects — cover connect + compare.
runDialectFlow(
DIALECT,
() => getSourceConfig(DIALECT)!,
() => getTargetConfig(DIALECT)!
() => getTargetConfig(DIALECT)!,
{ skipMigration: true }
);
});
6 changes: 5 additions & 1 deletion apps/e2e/src/tests/dialects/duckdb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ const DIALECT = 'duckdb';
// embedded/file-based (like SQLite): set _DB to a .duckdb file path, and dummy
// values for host/user/pass. Exercises the DuckDB provider + DDL strategy.
describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => {
// DuckDB shares schema name `main` across source/target files; the Sync
// connect flow is covered, but migrate against two file handles is still
// flaky in headed CI. Mirror SQLite: connect + compare only for now.
runDialectFlow(
DIALECT,
() => getSourceConfig(DIALECT)!,
() => getTargetConfig(DIALECT)!
() => getTargetConfig(DIALECT)!,
{ skipMigration: true }
);
});
5 changes: 4 additions & 1 deletion apps/e2e/src/tests/dialects/redshift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { runDialectFlow } from './shared-flow.js';
const DIALECT = 'redshift';

describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => {
// Local service is Postgres-shaped; Redshift-specific DDL (e.g. DISTSTYLE /
// SORTKEY expectations in the planner) still fails migrate on the stand-in.
runDialectFlow(
DIALECT,
() => getSourceConfig(DIALECT)!,
() => getTargetConfig(DIALECT)!
() => getTargetConfig(DIALECT)!,
{ skipMigration: true }
);
});
10 changes: 8 additions & 2 deletions apps/web/src/frontend/components/ConnectionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ export const ConnectionModal: React.FC<Props> = ({
...prev,
port: defaultPorts[d],
schema: getProviderSettings(d).defaultSchema || '',
// Redshift (and Azure SQL) expect TLS; keep the checkbox in sync with
// connection-string sslmode=require so local stand-ins can opt out.
ssl: {
...prev.ssl,
enabled: d === 'redshift' || d === 'azuresql' ? true : !!prev.ssl?.enabled,
},
}));
};

Expand Down Expand Up @@ -493,9 +499,9 @@ export const ConnectionModal: React.FC<Props> = ({
<select data-testid="conn-schema-select" value={form.schema} onChange={(e) => updateField('schema', e.target.value)} className={`${inputCls} !mt-0 flex-1`}>
{!schemaRequired && <option value="">— all schemas —</option>}
{form.schema && !schemaList.includes(form.schema) && <option value={form.schema}>{form.schema}</option>}
{schemaList.map((s) => (
{[...new Set(schemaList)].map((s) => (
<option key={s} value={s}>{s}</option>
))}
))}
</select>
) : (
<input
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/frontend/lib/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,28 @@ const tidbSettings: ProviderSettings = {
defaultPort: 4000,
};

const clickhouseSettings: ProviderSettings = {
dialect: 'clickhouse',
label: 'ClickHouse',
defaultPort: 8123,
defaultSchema: 'default',
schemaRequired: true,
buildConnectionString(o) {
return coreBuildConnectionString('clickhouse', o);
},
};

const redshiftSettings: ProviderSettings = {
dialect: 'redshift',
label: 'Amazon Redshift',
defaultPort: 5439,
defaultSchema: 'public',
schemaRequired: true,
buildConnectionString(o) {
return coreBuildConnectionString('redshift', o);
},
};

const duckdbSettings: ProviderSettings = {
dialect: 'duckdb',
label: 'DuckDB',
Expand Down Expand Up @@ -226,6 +248,8 @@ export const PROVIDER_SETTINGS: Record<string, ProviderSettings> = {
oracle: oracleSettings,
sqlite: sqliteSettings,
duckdb: duckdbSettings,
clickhouse: clickhouseSettings,
redshift: redshiftSettings,
redis: redisSettings,
mongodb: mongodbSettings,
};
Expand Down
74 changes: 74 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,78 @@ services:
retries: 30
start_period: 60s

# ── ClickHouse (HTTP interface on 8123 — matches @clickhouse/client) ────────
# Seeded via scripts/seed/seed-all.sh (clickhouse-client). Databases demo_a /
# demo_b act as schemas for the FoxSchema ClickHouse provider.
clickhouse:
image: clickhouse/clickhouse-server:24.8
container_name: foxschema-clickhouse
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: default
CLICKHOUSE_PASSWORD: foxpass
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
ports:
- "8123:8123"
- "9000:9000"
volumes:
- clickhouse_data:/var/lib/clickhouse
ulimits:
nofile:
soft: 262144
hard: 262144
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --user default --password foxpass -q 'SELECT 1' >/dev/null 2>&1 || exit 1"]
interval: 5s
timeout: 5s
retries: 20
start_period: 20s

# ── TiDB (MySQL protocol on 4000, unistore single-node) ─────────────────────
# Seeded via seed-all.sh with docker/init/tidb/01_seed.sql (demo_a / demo_b).
tidb:
image: pingcap/tidb:v8.5.0
container_name: foxschema-tidb
command: ["--store=unistore", "--path=/tmp/tidb", "-P=4000", "--status=10080"]
ports:
- "4000:4000"
- "10080:10080"
healthcheck:
# Image has no mysql client — probe the status HTTP port.
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/10080'"]
interval: 5s
timeout: 5s
retries: 30
start_period: 30s

# ── Redshift local stand-in (Postgres 16 on host port 5439) ─────────────────
# Amazon Redshift is cloud-only; this Postgres image lets the Redshift
# provider / e2e path run locally. Catalog parity is Postgres-shaped, not a
# full Redshift clone. SSL is enabled because the Redshift connection string
# defaults to sslmode=require. Seeds reuse docker/init/postgres/01_seed.sql.
redshift:
image: postgres:16
container_name: foxschema-redshift
environment:
POSTGRES_USER: foxuser
POSTGRES_PASSWORD: foxpass
POSTGRES_DB: foxdb
ports:
- "5439:5432"
volumes:
- redshift_data:/var/lib/postgresql/data
- ./docker/init/postgres/01_seed.sql:/docker-entrypoint-initdb.d/01_seed.sql:ro
- ./docker/init/redshift/entrypoint.sh:/usr/local/bin/foxschema-redshift-entrypoint.sh:ro
entrypoint: ["/bin/bash", "/usr/local/bin/foxschema-redshift-entrypoint.sh"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U foxuser -d foxdb"]
interval: 5s
timeout: 5s
retries: 10

# DuckDB is file-based (like SQLite) — no server container. seed-all.sh writes
# /tmp/foxschema-duckdb/demo_{a,b}.duckdb via scripts/seed/seed-duckdb.mjs.

volumes:
pg_data:
mysql_data:
Expand All @@ -193,3 +265,5 @@ volumes:
db2_data:
cockroach_data:
yugabyte_data:
clickhouse_data:
redshift_data:
135 changes: 135 additions & 0 deletions docker/init/clickhouse/01_seed.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
-- FoxSchema demo seed — ClickHouse
-- Two databases: demo_a (source, newer) vs demo_b (target, older).
-- MergeTree only — no FKs / traditional indexes / routines.

DROP DATABASE IF EXISTS demo_a;
DROP DATABASE IF EXISTS demo_b;
CREATE DATABASE demo_a;
CREATE DATABASE demo_b;

-- ============================================================
-- DATABASE A (source — more complete)
-- ============================================================

CREATE TABLE demo_a.categories (
id Int32,
name String,
slug String,
parent_id Nullable(Int32)
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE demo_a.customers (
id Int32,
name String,
email String,
phone Nullable(String),
tier String DEFAULT 'standard',
created_at DateTime DEFAULT now()
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE demo_a.products (
id Int32,
name String,
sku String,
price Decimal(10, 2),
stock Int32 DEFAULT 0,
category_id Nullable(Int32),
active UInt8 DEFAULT 1,
created_at DateTime DEFAULT now()
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE demo_a.orders (
id Int32,
customer_id Int32,
total Decimal(12, 2),
status String DEFAULT 'pending',
notes Nullable(String),
created_at DateTime DEFAULT now()
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE demo_a.order_items (
id Int32,
order_id Int32,
product_id Int32,
qty Int32 DEFAULT 1,
unit_price Decimal(10, 2)
) ENGINE = MergeTree ORDER BY id;

CREATE VIEW demo_a.v_customer_orders AS
SELECT
c.id AS customer_id,
c.name,
c.email,
c.tier,
count(o.id) AS order_count,
coalesce(sum(o.total), 0) AS total_spent
FROM demo_a.customers AS c
LEFT JOIN demo_a.orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name, c.email, c.tier;

CREATE VIEW demo_a.v_low_stock AS
SELECT id, name, sku, stock, category_id
FROM demo_a.products
WHERE stock < 10 AND active = 1;

CREATE TABLE demo_a.coupons (
id Int32,
code String,
discount_pct Decimal(5, 2) DEFAULT 0,
valid_until Nullable(Date)
) ENGINE = MergeTree ORDER BY id;

CREATE VIEW demo_a.v_active_products AS
SELECT id, name, price, sku
FROM demo_a.products
WHERE stock > 0 AND active = 1;

-- ============================================================
-- DATABASE B (target — older / thinner)
-- ============================================================

CREATE TABLE demo_b.customers (
id Int32,
name String,
email String
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE demo_b.products (
id Int32,
name String,
price Int32,
stock Int32 DEFAULT 0
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE demo_b.orders (
id Int32,
customer_id Int32,
total Decimal(12, 2),
status String DEFAULT 'pending',
created_at DateTime DEFAULT now()
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE demo_b.order_items (
id Int32,
order_id Int32,
product_id Int32,
qty Int32 DEFAULT 0,
unit_price Nullable(Decimal(10, 2))
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE demo_b.legacy_audit_log (
id Int32,
action Nullable(String),
table_name Nullable(String),
logged_at DateTime DEFAULT now()
) ENGINE = MergeTree ORDER BY id;

CREATE VIEW demo_b.v_order_summary AS
SELECT o.id, o.total, o.status, o.created_at, oi.qty, oi.unit_price
FROM demo_b.orders AS o
INNER JOIN demo_b.order_items AS oi ON oi.order_id = o.id;

CREATE VIEW demo_b.v_active_products AS
SELECT id, name, price
FROM demo_b.products
WHERE stock > 0;
Loading
Loading