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
615 changes: 614 additions & 1 deletion apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx

Large diffs are not rendered by default.

125 changes: 125 additions & 0 deletions apps/web/src/frontend/components/sql-editor/tableBlueprintSql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import {
defaultDialectColumnType,
dialectBooleanDefaultOptions,
dialectIdentitySupport,
dialectIndexSupport,
diffBlueprintColumns,
generateAddForeignKeySql,
generateBlueprintAlterSql,
generateCreateIndexSql,
generateCreateTableSql,
generateCreateTriggerSql,
generateDropForeignKeySql,
generateDropIndexSql,
generateDropTableSql,
generateDropTriggerSql,
generatePkAlterSql,
Expand All @@ -27,6 +30,8 @@ import {
parseTypeSize,
quoteIdent,
suggestFkName,
suggestIndexName,
appendFkTriggerSql,
} from './tableBlueprintSql';

const col = (partial: Partial<ColumnInfo> & Pick<ColumnInfo, 'name' | 'type'>): ColumnInfo => ({
Expand Down Expand Up @@ -508,6 +513,126 @@ describe('foreign keys & triggers', () => {
});
});

describe('blueprint indexes', () => {
it('suggests unique vs non-unique index names', () => {
expect(suggestIndexName('orders', ['customer_id'], false)).toBe('ix_orders_customer_id');
expect(suggestIndexName('orders', ['customer_id'], true)).toBe('ux_orders_customer_id');
});

it('emits CREATE INDEX with ASC/DESC and UNIQUE', () => {
const sql = generateCreateIndexSql('orders', 'postgres', {
name: 'ix_orders_created',
columns: ['created_at', 'id'],
orders: ['DESC', 'ASC'],
unique: false,
});
expect(sql[0]).toBe(
'CREATE INDEX ix_orders_created ON orders (created_at DESC, id ASC);'
);

const ux = generateCreateIndexSql('orders', 'mysql', {
name: 'ux_orders_email',
columns: ['email'],
orders: ['ASC'],
unique: true,
});
expect(ux[0]).toBe('CREATE UNIQUE INDEX ux_orders_email ON orders (email ASC);');
});

it('emits WHERE filter on dialects that support partial indexes', () => {
const sql = generateCreateIndexSql('orders', 'postgres', {
name: 'ix_orders_active',
columns: ['customer_id'],
orders: ['ASC'],
unique: false,
filter: "status = 'active'",
});
expect(sql[0]).toBe(
"CREATE INDEX ix_orders_active ON orders (customer_id ASC) WHERE status = 'active';"
);

const mysql = generateCreateIndexSql('orders', 'mysql', {
name: 'ix_orders_active',
columns: ['customer_id'],
orders: ['ASC'],
unique: false,
filter: "status = 'active'",
});
expect(mysql[0]).toMatch(/^-- review:/);
expect(dialectIndexSupport('sqlite').filter).toBe(true);
expect(dialectIndexSupport('mysql').filter).toBe(false);
});

it('emits SQL Server unique constraint when constraint flag is set', () => {
const sql = generateCreateIndexSql('orders', 'sqlserver', {
name: 'UQ_orders_code',
columns: ['code'],
orders: ['ASC'],
unique: true,
constraint: true,
});
expect(sql[0]).toMatch(/ALTER TABLE orders ADD CONSTRAINT UQ_orders_code UNIQUE \(code\);/);
});

it('emits DROP INDEX via dialect (MySQL ON table, SQL Server constraint)', () => {
const mysql = generateDropIndexSql('orders', 'mysql', 'ix_orders_created');
expect(mysql[0]).toMatch(/DROP INDEX ix_orders_created ON orders/i);

const ss = generateDropIndexSql('orders', 'sqlserver', 'UQ_orders_code', undefined, {
constraint: true,
});
expect(ss[0]).toMatch(/ALTER TABLE orders DROP CONSTRAINT UQ_orders_code/i);
});

it('reviews ClickHouse / Redshift create and drop', () => {
for (const d of ['clickhouse', 'redshift']) {
expect(dialectIndexSupport(d).create).toBe(false);
const create = generateCreateIndexSql('t', d, {
name: 'ix_t_a',
columns: ['a'],
orders: ['ASC'],
unique: false,
});
expect(create[0]).toMatch(/^-- review:/);
const drop = generateDropIndexSql('t', d, 'ix_t_a');
expect(drop[0]).toMatch(/^-- review:/);
}
});

it('appends drop then create index around FKs', () => {
const sql = appendFkTriggerSql(['-- base'], {
tableName: 'orders',
dialect: 'postgres',
dropIndexes: [{ name: 'ix_old' }],
addIndexes: [
{
name: 'ix_new',
columns: ['a'],
orders: ['DESC'],
unique: false,
},
],
});
expect(sql[0]).toMatch(/DROP INDEX/i);
expect(sql[0]).toContain('ix_old');
expect(sql[1]).toBe('-- base');
expect(sql.some((s) => s.includes('CREATE INDEX ix_new') && s.includes('a DESC'))).toBe(
true
);
});

it('emits DROP INDEX before column alters when both are present', () => {
const sql = appendFkTriggerSql(['ALTER TABLE orders DROP COLUMN stale;'], {
tableName: 'orders',
dialect: 'postgres',
dropIndexes: [{ name: 'ix_stale' }],
});
expect(sql[0]).toMatch(/DROP INDEX/i);
expect(sql[0]).toContain('ix_stale');
expect(sql[1]).toContain('DROP COLUMN stale');
});
});

describe('generateTableBlueprintSql', () => {
it('emits column alter then pk add', () => {
const sql = generateTableBlueprintSql({
Expand Down
197 changes: 189 additions & 8 deletions apps/web/src/frontend/components/sql-editor/tableBlueprintSql.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { resolveDialect } from '../../lib/migration-validation';
import type { ColumnInfo, TableSchema } from '../../lib/types';
import { dialectSupportsFk, type CanonicalBase, type CanonicalType } from '@foxschema/core';
import {
dialectSupportsFk,
dialectSupportsIndex,
type CanonicalBase,
type CanonicalType,
type IndexFeatureSupport,
} from '@foxschema/core';

/** Quote an identifier when it is not a plain SQL name. */
export function quoteIdent(name: string, dialect: string): string {
Expand Down Expand Up @@ -1263,7 +1269,168 @@ export function generateDropTriggerSql(
return [`DROP TRIGGER IF EXISTS ${qTrg};`];
}

/** Append FK / trigger statements after column + PK alters. */
// ── Indexes ───────────────────────────────────────────────────────────────────

export type IndexColumnOrder = 'ASC' | 'DESC';

export type BlueprintIndexDraft = {
name: string;
columns: string[];
/** Parallel to `columns`; defaults to ASC when missing. */
orders: IndexColumnOrder[];
/** true = UNIQUE (reject duplicates); false = accept duplicates */
unique: boolean;
/**
* Partial / filtered index predicate (without the WHERE keyword), e.g.
* `status = 'active'`. Emitted only when the dialect supports `filter`.
*/
filter?: string;
/**
* SQL Server / Azure SQL: unique-constraint-backed index must use
* ALTER TABLE ADD/DROP CONSTRAINT rather than CREATE/DROP INDEX.
*/
constraint?: boolean;
/**
* When this pending create replaces an existing index, the live name that
* will be dropped. Used so Undo-drop / Remove-pending stay consistent.
*/
replaces?: string;
};

/** Suggest an index name from table + first column. */
export function suggestIndexName(tableName: string, columns: string[], unique: boolean): string {
const col = columns[0] || 'col';
const bare = tableName.replace(/^.*\./, '').replace(/[^A-Za-z0-9_]/g, '_');
const c = col.replace(/[^A-Za-z0-9_]/g, '_');
const prefix = unique ? 'ux' : 'ix';
return `${prefix}_${bare}_${c}`.toLowerCase().slice(0, 60);
}

/** Delegates to the core dialect × index bitmatrix. */
export function dialectIndexSupport(dialectName: string): IndexFeatureSupport {
return dialectSupportsIndex(dialectName);
}

function normalizeIndexOrders(
columns: string[],
orders: IndexColumnOrder[] | undefined
): IndexColumnOrder[] {
return columns.map((_, i) => (orders?.[i] === 'DESC' ? 'DESC' : 'ASC'));
}

function formatIndexColumnList(
draft: BlueprintIndexDraft,
dialectName: string,
support: IndexFeatureSupport
): string {
const orders = normalizeIndexOrders(draft.columns, draft.orders);
return draft.columns
.map((c, i) => {
const q = quoteIdent(c, dialectName);
if (!support.columnOrder) return q;
return `${q} ${orders[i] ?? 'ASC'}`;
})
.join(', ');
}

function indexDraftIsComplete(idx: BlueprintIndexDraft): boolean {
return !!idx.name.trim() && idx.columns.length > 0;
}

export function generateCreateIndexSql(
tableName: string,
dialectName: string,
idx: BlueprintIndexDraft,
schema?: string
): string[] {
const name = tableName.trim();
if (!name || !indexDraftIsComplete(idx)) return [];

const support = dialectIndexSupport(dialectName);
if (!support.create) {
return [
`-- review: ${dialectName} does not support CREATE INDEX (${idx.name.trim()}) — ${support.hint}`,
];
}
if (idx.unique && !support.unique) {
return [
`-- review: ${dialectName} does not support UNIQUE indexes (${idx.name.trim()})`,
];
}
if (!idx.unique && !support.acceptDuplicates) {
return [
`-- review: ${dialectName} does not support non-unique indexes (${idx.name.trim()})`,
];
}

const filterPred = idx.filter?.trim() ?? '';
if (filterPred && !support.filter) {
return [
`-- review: ${dialectName} does not support filtered/partial indexes (${idx.name.trim()}) — omit WHERE or recreate without a filter`,
];
}

const qTable = qualifiedQuotedTable(name, schema, dialectName);
const qName = quoteIdent(idx.name.trim(), dialectName);
const d = dialectName.toLowerCase();

// SQL Server unique constraints must round-trip as constraints, not indexes.
// Constraints cannot carry a WHERE filter — use a unique filtered index instead.
if (idx.unique && idx.constraint && (d === 'sqlserver' || d === 'azuresql')) {
if (filterPred) {
return [
`-- review: ${idx.name.trim()}: unique constraints cannot include a WHERE filter — create a UNIQUE INDEX with filter instead`,
];
}
const cols = idx.columns.map((c) => quoteIdent(c, dialectName)).join(', ');
return [`ALTER TABLE ${qTable} ADD CONSTRAINT ${qName} UNIQUE (${cols});`];
}

const colList = formatIndexColumnList(idx, dialectName, support);
const uniqueStr = idx.unique ? ' UNIQUE' : '';
const whereClause = filterPred ? ` WHERE ${filterPred}` : '';
return [`CREATE${uniqueStr} INDEX ${qName} ON ${qTable} (${colList})${whereClause};`];
}

export function generateDropIndexSql(
tableName: string,
dialectName: string,
indexName: string,
schema?: string,
opts?: { constraint?: boolean }
): string[] {
const name = tableName.trim();
if (!name || !indexName.trim()) return [];

const support = dialectIndexSupport(dialectName);
if (!support.drop) {
return [
`-- review: ${dialectName} does not support DROP INDEX (${indexName.trim()}) — ${support.hint}`,
];
}

const dialect = resolveDialect(dialectName);
const qTable = qualifiedQuotedTable(name, schema, dialectName);
const qIdx = quoteIdent(indexName.trim(), dialectName);
if (dialect.dropIndexStatement) {
return [
dialect.dropIndexStatement(qIdx, qTable, {
name: qIdx,
columns: [],
unique: !!opts?.constraint,
constraint: opts?.constraint,
}),
];
}
return [`DROP INDEX IF EXISTS ${qIdx};`];
}

/**
* Compose column/PK alters with FK / trigger / index DDL.
* Drops (indexes, FKs, triggers) run *before* `base` so DROP COLUMN cannot
* precede DROP INDEX / DROP FK that still reference the column.
* Creates run after `base`.
*/
export function appendFkTriggerSql(
base: string[],
args: {
Expand All @@ -1274,20 +1441,34 @@ export function appendFkTriggerSql(
dropFkNames?: string[];
addTriggers?: BlueprintTriggerDraft[];
dropTriggerNames?: string[];
addIndexes?: BlueprintIndexDraft[];
dropIndexes?: Array<{ name: string; constraint?: boolean }>;
}
): string[] {
const out = [...base];
const drops: string[] = [];
for (const idx of args.dropIndexes ?? []) {
drops.push(
...generateDropIndexSql(args.tableName, args.dialect, idx.name, args.schema, {
constraint: idx.constraint,
})
);
}
for (const n of args.dropFkNames ?? []) {
out.push(...generateDropForeignKeySql(args.tableName, args.dialect, n, args.schema));
drops.push(...generateDropForeignKeySql(args.tableName, args.dialect, n, args.schema));
}
for (const n of args.dropTriggerNames ?? []) {
out.push(...generateDropTriggerSql(args.tableName, args.dialect, n, args.schema));
drops.push(...generateDropTriggerSql(args.tableName, args.dialect, n, args.schema));
}

const creates: string[] = [];
for (const idx of args.addIndexes ?? []) {
creates.push(...generateCreateIndexSql(args.tableName, args.dialect, idx, args.schema));
}
for (const fk of args.addFks ?? []) {
out.push(...generateAddForeignKeySql(args.tableName, args.dialect, fk, args.schema));
creates.push(...generateAddForeignKeySql(args.tableName, args.dialect, fk, args.schema));
}
for (const trg of args.addTriggers ?? []) {
out.push(...generateCreateTriggerSql(args.tableName, args.dialect, trg, args.schema));
creates.push(...generateCreateTriggerSql(args.tableName, args.dialect, trg, args.schema));
}
return out;
return [...drops, ...base, ...creates];
}
2 changes: 2 additions & 0 deletions packages/core/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export { findDropDependencies } from './modules/dependency-scan';
export type { DropDependency, DropDependencyOptions } from './modules/dependency-scan';
export { dialectSupportsFk } from './modules/dialect-fk-support';
export type { FkFeatureSupport } from './modules/dialect-fk-support';
export { dialectSupportsIndex } from './modules/dialect-index-support';
export type { IndexFeatureSupport } from './modules/dialect-index-support';
export { findMissingFkTargets, findNarrowingTypeChanges, extractReviewNotices, validateMigrationPlan } from './modules/migration-validation';
export type { ValidationIssue, ValidationSeverity, ValidationCode } from './modules/migration-validation';
export { CROSS_DIALECT_READINESS } from './modules/cross-dialect-readiness';
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export { findDropDependencies } from './modules/dependency-scan';
export type { DropDependency, DropDependencyOptions } from './modules/dependency-scan';
export { dialectSupportsFk } from './modules/dialect-fk-support';
export type { FkFeatureSupport } from './modules/dialect-fk-support';
export { dialectSupportsIndex } from './modules/dialect-index-support';
export type { IndexFeatureSupport } from './modules/dialect-index-support';
export { findMissingFkTargets, findNarrowingTypeChanges, extractReviewNotices, validateMigrationPlan } from './modules/migration-validation';
export type { ValidationIssue, ValidationSeverity, ValidationCode } from './modules/migration-validation';
export { CROSS_DIALECT_READINESS } from './modules/cross-dialect-readiness';
Expand Down
Loading
Loading