Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,12 @@ describe('primary key alter', () => {
expect(sql[0]).toMatch(/DROP PRIMARY KEY/i);
expect(sql[1]).toBe('ALTER TABLE t ADD PRIMARY KEY (a, b);');
});

it('reviews a ClickHouse composite key change instead of ALTER ADD PRIMARY KEY', () => {
const sql = generatePkAlterSql('order_coupons', 'clickhouse', ['order_id'], ['order_id', 'coupon_id']);
expect(sql.join('\n')).toMatch(/-- review:/);
expect(sql.join('\n')).not.toMatch(/ALTER TABLE .* ADD .*PRIMARY KEY/i);
});
});

describe('create / drop table', () => {
Expand All @@ -283,6 +289,22 @@ describe('create / drop table', () => {
expect(sql[0]).not.toMatch(/INTEGER NOT NULL PRIMARY KEY/);
});

it('maps a ClickHouse composite PK to MergeTree ORDER BY, not a constraint', () => {
const sql = generateCreateTableSql(
'order_coupons',
[
col({ name: 'order_id', type: 'Int32', nullable: false, primaryKey: true }),
col({ name: 'coupon_id', type: 'Int32', nullable: false, primaryKey: true }),
col({ name: 'applied_at', type: 'DateTime', nullable: false }),
],
['order_id', 'coupon_id'],
'clickhouse'
);
expect(sql[0]).toMatch(/ENGINE = MergeTree ORDER BY \(order_id, coupon_id\)/);
expect(sql[0]).not.toMatch(/CONSTRAINT/);
expect(sql[0]).not.toContain('PRIMARY KEY (order_id, coupon_id)');
});

it('uses OBJECT_ID guard for sqlserver create', () => {
const sql = generateCreateTableSql(
't',
Expand Down Expand Up @@ -345,6 +367,10 @@ describe('foreign keys & triggers', () => {
expect(quoteTableRef('My Schema.Odd-Table', 'postgres')).toBe('"My Schema"."Odd-Table"');
expect(qualifyTableName('test1', 'mcve', 'postgres')).toBe('mcve.test1');
expect(qualifyTableName('mcve.test1', 'other', 'postgres')).toBe('mcve.test1');
expect(qualifyTableName('legacy_audit_log', 'demo_b', 'clickhouse')).toBe(
'demo_b.legacy_audit_log'
);
expect(qualifyTableName('t', 'demo', 'sqlite')).toBe('t');

const sql = generateAddForeignKeySql(
'test2',
Expand Down
61 changes: 48 additions & 13 deletions apps/web/src/frontend/features/sql-editor/lib/tableBlueprintSql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export function qualifyTableName(
const sch = schema?.trim();
if (!sch) return name;
const d = dialectName.toLowerCase();
if (d === 'sqlite' || d === 'clickhouse') return name;
// SQLite is file-scoped and has no schema. ClickHouse databases *are* the
// schema — qualify so DROP/CREATE hit demo_b.t instead of the client default.
if (d === 'sqlite') return name;
return `${sch}.${name}`;
}

Expand Down Expand Up @@ -727,12 +729,22 @@ export function generatePkAlterSql(
stmts.push(...dialect.dropPrimaryKeyStatements(qTable, qPk));
}
if (next.length > 0) {
const cols = next.map((c) => quoteIdent(c, dialectName)).join(', ');
const constraint =
pkName && pkName.toUpperCase() !== 'PRIMARY'
? `CONSTRAINT ${quoteIdent(pkName, dialectName)} `
: '';
stmts.push(`ALTER TABLE ${qTable} ADD ${constraint}PRIMARY KEY (${cols});`);
const quoted = next.map((c) => quoteIdent(c, dialectName));
if (dialect.addPrimaryKeyStatements) {
stmts.push(
...dialect.addPrimaryKeyStatements(
qTable,
quoted,
pkName && pkName.toUpperCase() !== 'PRIMARY' ? quoteIdent(pkName, dialectName) : undefined
)
);
} else {
const constraint =
pkName && pkName.toUpperCase() !== 'PRIMARY'
? `CONSTRAINT ${quoteIdent(pkName, dialectName)} `
: '';
stmts.push(`ALTER TABLE ${qTable} ADD ${constraint}PRIMARY KEY (${quoted.join(', ')});`);
}
}
return stmts;
}
Expand Down Expand Up @@ -845,7 +857,8 @@ function createTableBody(
}
return ` ${buildColumnDef(c, dialectName)}`;
});
if (pkColumns.length > 0 && !sqliteIdentityPkCol) {
const dialect = resolveDialect(dialectName);
if (pkColumns.length > 0 && !sqliteIdentityPkCol && dialect.inlinePrimaryKeyConstraint !== false) {
const cols = pkColumns.map((c) => quoteIdent(c, dialectName)).join(', ');
const constraint =
pkName && pkName.toUpperCase() !== 'PRIMARY'
Expand All @@ -860,6 +873,27 @@ function createTableBody(
return `(\n${lines.join(',\n')}\n)`;
}

/** ENGINE / ORDER BY (and similar) after the column list. Empty on most dialects. */
function createTableSuffixSql(
tableName: string,
columns: ColumnInfo[],
pkColumns: string[],
dialectName: string
): string {
const dialect = resolveDialect(dialectName);
if (!dialect.createTableSuffix) return '';
const table: TableSchema = {
name: tableName,
objectType: 'TABLE',
columns,
indices: [],
foreignKeys: [],
...(pkColumns.length ? { primaryKey: { columns: pkColumns } } : {}),
};
const quoted = pkColumns.map((c) => (/[()]/.test(c) ? c : quoteIdent(c, dialectName)));
return dialect.createTableSuffix(table, quoted);
}

/**
* CREATE TABLE with existence guard (IF NOT EXISTS or dialect equivalent).
* Optional `foreignKeys` are inlined when the dialect supports CREATE-table FKs
Expand All @@ -881,31 +915,32 @@ export function generateCreateTableSql(
const support = dialectFkConstraintSupport(dialectName);
const inlineFks = support.createInline ? foreignKeys : undefined;
const body = createTableBody(columns, pkColumns, dialectName, pkName, inlineFks, schema);
const suffix = createTableSuffixSql(name, columns, pkColumns, dialectName);
const d = dialectName.toLowerCase();
const objectIdName = qualifyTableName(name, schema, dialectName);
const ifNotExists = opts?.ifNotExists !== false;

if (!ifNotExists) {
return [`CREATE TABLE ${qTable} ${body};`];
return [`CREATE TABLE ${qTable} ${body}${suffix};`];
}
if (dialectFamily(d) === 'sqlserver') {
const escaped = objectIdName.replace(/'/g, "''");
return [
`IF OBJECT_ID(N'${escaped}', N'U') IS NULL\nCREATE TABLE ${qTable} ${body};`,
`IF OBJECT_ID(N'${escaped}', N'U') IS NULL\nCREATE TABLE ${qTable} ${body}${suffix};`,
];
}
if (d === 'oracle') {
// Oracle 23c+ supports IF NOT EXISTS; older versions ignore and may error on re-run.
return [`CREATE TABLE IF NOT EXISTS ${qTable} ${body};`];
return [`CREATE TABLE IF NOT EXISTS ${qTable} ${body}${suffix};`];
}
if (d === 'db2') {
// DB2 has no CREATE TABLE IF NOT EXISTS — emit plain CREATE (Apply fails if exists).
return [
`-- review: DB2 has no CREATE TABLE IF NOT EXISTS — fails if ${name} already exists`,
`CREATE TABLE ${qTable} ${body};`,
`CREATE TABLE ${qTable} ${body}${suffix};`,
];
}
return [`CREATE TABLE IF NOT EXISTS ${qTable} ${body};`];
return [`CREATE TABLE IF NOT EXISTS ${qTable} ${body}${suffix};`];
}

/**
Expand Down
8 changes: 8 additions & 0 deletions docker/init/clickhouse/01_seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ CREATE TABLE demo_a.coupons (
valid_until Nullable(Date)
) ENGINE = MergeTree ORDER BY id;

-- Composite sorting key. ClickHouse has no table-level PRIMARY KEY constraint;
-- ORDER BY (order_id, coupon_id) is the MergeTree equivalent of a composite PK.
CREATE TABLE demo_a.order_coupons (
order_id Int32,
coupon_id Int32,
applied_at DateTime DEFAULT now()
) ENGINE = MergeTree ORDER BY (order_id, coupon_id);

CREATE VIEW demo_a.v_active_products AS
SELECT id, name, price, sku
FROM demo_a.products
Expand Down
41 changes: 32 additions & 9 deletions packages/db/src/providers/clickHouse/clickhouse.provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ConnectionFactory } from '../../cores/connection-factory.js';
import { dbSchemaToTableSchemas, errorMessage } from '@foxschema/sql';
import { dbSchemaToTableSchemas, errorMessage, parseClickHouseKeyList } from '@foxschema/sql';
import {
type SchemaProvider,
type ConnectionOptions,
Expand All @@ -15,14 +15,15 @@ import {
type TableSchema,
} from '@foxschema/sql';

interface ChTableRaw { name: string; engine: string; }
interface ChTableRaw { name: string; engine: string; sorting_key: string; primary_key: string; }
interface ChViewRaw { name: string; definition: string; }
interface ChColumnRaw {
table: string;
name: string;
position: number;
type: string;
is_in_primary_key: number | boolean;
is_in_sorting_key: number | boolean;
default_kind: string;
default_expression: string;
}
Expand Down Expand Up @@ -74,7 +75,7 @@ export class ClickHouseProvider implements SchemaProvider {

const [rawTables, rawViews, rawColumns] = await Promise.all([
exec<ChTableRaw>(
`SELECT name, engine FROM system.tables
`SELECT name, engine, sorting_key, primary_key FROM system.tables
WHERE database = $1 AND engine NOT IN ('View', 'MaterializedView', 'LiveView', 'Window', 'Merge', 'Distributed')
ORDER BY name`,
[db]
Expand All @@ -86,7 +87,7 @@ export class ClickHouseProvider implements SchemaProvider {
[db]
),
exec<ChColumnRaw>(
`SELECT table, name, position, type, is_in_primary_key, default_kind, default_expression
`SELECT table, name, position, type, is_in_primary_key, is_in_sorting_key, default_kind, default_expression
FROM system.columns WHERE database = $1
ORDER BY table, position`,
[db]
Expand All @@ -102,11 +103,30 @@ export class ClickHouseProvider implements SchemaProvider {
const indexes: Record<string, DbIndex[]> = {};
const indexColumns: Record<string, DbIndexColumn[]> = {};

// Init tables
// Catalog sorting_key / primary_key win: expressions like cityHash64(id)
// live only there. Column flags fill in when the catalog strings are empty.
const catalogPk = new Set<string>();
for (const t of rawTables) {
tables[t.name] = { name: t.name, columns: {}, primaryKey: [], foreignKeys: [], uniqueConstraints: [], indexes: [] };
const orderBy = parseClickHouseKeyList(t.sorting_key);
const fromPk = parseClickHouseKeyList(t.primary_key);
tables[t.name] = {
name: t.name,
columns: {},
primaryKey: fromPk,
foreignKeys: [],
uniqueConstraints: [],
indexes: [],
...(t.engine ? { engine: t.engine } : {}),
...(orderBy.length ? { orderBy } : {}),
};
columns[t.name] = [];
primaryKeys[t.name] = [];
primaryKeys[t.name] = fromPk.map((column, i) => ({
name: column,
constName: 'PRIMARY_KEY',
column,
colSeq: i + 1,
}));
if (fromPk.length) catalogPk.add(t.name);
foreignKeys[t.name] = [];
uniqueConstraints[t.name] = [];
indexes[t.name] = [];
Expand All @@ -125,12 +145,15 @@ export class ClickHouseProvider implements SchemaProvider {
};
if (tables[c.table]) {
tables[c.table].columns[c.name] = col;
if (isPk) tables[c.table].primaryKey.push(c.name);
if (isPk && !catalogPk.has(c.table)) tables[c.table].primaryKey.push(c.name);
}
(columns[c.table] ??= []).push(col);
if (isPk) {
if (isPk && !catalogPk.has(c.table)) {
(primaryKeys[c.table] ??= []).push({ name: c.name, constName: 'PRIMARY_KEY', column: c.name, colSeq: c.position });
}
if (c.is_in_sorting_key && tables[c.table] && !tables[c.table].orderBy?.length) {
(tables[c.table].orderBy ??= []).push(c.name);
}
}

// Views
Expand Down
33 changes: 33 additions & 0 deletions packages/sql/src/cores/schema-to-tables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,36 @@ describe('groupForeignKeyRows', () => {
expect(out[0]!.fk.referencedColumns).toEqual([]);
});
});

describe('dbSchemaToTableSchemas ClickHouse engine / ORDER BY', () => {
it('copies engine and sorting key onto TableSchema', () => {
const coupons: DbTable = {
name: 'coupons',
columns: { id: { name: 'id', type: 'Int32', nullable: false } },
primaryKey: ['id'],
foreignKeys: [],
uniqueConstraints: [],
indexes: [],
engine: 'MergeTree',
orderBy: ['id'],
};
const [t] = dbSchemaToTableSchemas(emptySchema({ coupons }));
expect(t?.engine).toBe('MergeTree');
expect(t?.orderBy).toEqual(['id']);
expect(t?.primaryKey?.columns).toEqual(['id']);
});

it('omits engine / ORDER BY when the catalog did not send them', () => {
const orders: DbTable = {
name: 'orders',
columns: { id: { name: 'id', type: 'int', nullable: false } },
primaryKey: ['id'],
foreignKeys: [],
uniqueConstraints: [],
indexes: [],
};
const [t] = dbSchemaToTableSchemas(emptySchema({ orders }));
expect(t?.engine).toBeUndefined();
expect(t?.orderBy).toBeUndefined();
});
});
2 changes: 2 additions & 0 deletions packages/sql/src/cores/schema-to-tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ export function dbSchemaToTableSchemas(dbSchema: DbSchema): TableSchema[] {
objectType: table.isMqt ? 'MQT' : 'TABLE',
definition: table.definition,
tablespace: table.tablespace,
...(table.engine ? { engine: table.engine } : {}),
...(table.orderBy?.length ? { orderBy: table.orderBy } : {}),
...(table.storeType ? { storeType: table.storeType } : {}),
triggers: triggersByTable.get(table.name) ?? [],
primaryKey: pkColumns.length > 0
Expand Down
1 change: 1 addition & 0 deletions packages/sql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ export type {
} from './modules/sql-editor/code-cell-types.js';
export { isCodeCellLast, isCodeCellVars, CODE_CELL_KIND_LABEL } from './modules/sql-editor/code-cell-types.js';
export type { SqlDialect, CanonicalType, CanonicalBase, RenderedType } from './modules/dialect/sql-dialect.interface.js';
export { parseClickHouseKeyList } from './providers/clickHouse/clickhouse.sql-dialect.js';
export { resolveDialect, tryResolveDialect, DIALECT_MAP } from './modules/dialect/registry.js';
export {
dialectFeatures,
Expand Down
16 changes: 16 additions & 0 deletions packages/sql/src/interfaces/schema.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ export interface TableSchema {
functionKind?: 'scalar' | 'table';
/** Storage tablespace (tables/MQTs). */
tablespace?: string;
/**
* Table engine, when the catalog has one. ClickHouse MergeTree family
* (`MergeTree`, `ReplacingMergeTree`, …). Absent for engines that do not
* name a storage engine on the table.
*/
engine?: string;
/**
* Sorting / ORDER BY expressions, in key order. ClickHouse MergeTree
* requires this; it is usually the logical primary key and may be a
* prefix-plus-extra of `primaryKey.columns`. Absent elsewhere.
*/
orderBy?: string[];
/**
* What a non-SQL store keeps behind this "table" — for Redis `hash`, `json`,
* `string`, `mixed`, `list`, `set`, `zset` or `stream`. Shown as a badge;
Expand Down Expand Up @@ -150,6 +162,10 @@ export interface DbTable {
isMqt?: boolean;
/** Materialized view's backing query (Postgres matviews). Undefined for plain tables and for dialects that don't expose it (e.g. DB2 MQTs). */
definition?: string;
/** See `TableSchema.engine`. */
engine?: string;
/** See `TableSchema.orderBy`. */
orderBy?: string[];
/** See `TableSchema.storeType`. */
storeType?: string;
}
Expand Down
28 changes: 28 additions & 0 deletions packages/sql/src/modules/dialect/sql-dialect.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,34 @@ export interface SqlDialect {
/** Statement(s) to drop an existing primary key before adding a new one. */
dropPrimaryKeyStatements(tableName: string, pkName: string | undefined): string[];

/**
* Add a primary key. Default: `ALTER TABLE t ADD [CONSTRAINT name] PRIMARY KEY (cols)`.
* Override when that form is invalid (ClickHouse: `-- review:` recreate).
*/
addPrimaryKeyStatements?(tableName: string, quotedColumns: string[], pkName: string | undefined): string[];

/**
* Emit `PRIMARY KEY (cols)` inside CREATE TABLE. Default true.
* ClickHouse only allows CHECK/ASSUME there.
*/
inlinePrimaryKeyConstraint?: boolean;

/**
* Clauses after the CREATE TABLE column list. ClickHouse: ` ENGINE = MergeTree ORDER BY …`.
* `quotedOrderBy` is identifier-quoted, or left raw for a function expression.
*/
createTableSuffix?(table: TableSchema, quotedOrderBy: string[]): string;

/**
* ENGINE / ORDER BY changed. Omit when the dialect has no table engine.
* ClickHouse cannot ALTER those in place — returns `-- review:` to recreate.
*/
engineChangeStatements?(
tableName: string,
source: TableSchema,
target: TableSchema
): string[];

/** Parse a native type string (e.g. "VARCHAR(255)") into the canonical model. */
parseType(raw: string): CanonicalType;

Expand Down
Loading
Loading