diff --git a/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx b/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx index 8146bcd..d049d3e 100644 --- a/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx +++ b/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx @@ -32,6 +32,7 @@ import { dialectFkActions, dialectFkConstraintSupport, dialectIdentitySupport, + dialectIndexSupport, dialectTriggerForm, diffBlueprintColumns, generateCreateTableSql, @@ -48,13 +49,16 @@ import { pkColumnsFromTable, sameStringList, suggestFkName, + suggestIndexName, suggestTriggerName, withAutoIncrement, type BlueprintFkDraft, + type BlueprintIndexDraft, type BlueprintTriggerDraft, type FkReferentialAction, + type IndexColumnOrder, } from './tableBlueprintSql'; -import type { ForeignKeyInfo, TriggerInfo } from '../../lib/types'; +import type { ForeignKeyInfo, IndexInfo, TriggerInfo } from '../../lib/types'; import { SQL_ICON_STROKE } from './sqlIconStyle'; import { TYPE_META } from '../SchemaTreePanel'; import { useSyncStore } from '../../store/useSyncStore'; @@ -81,6 +85,41 @@ function emptyFkDraft(): BlueprintFkDraft { }; } +function emptyIndexDraft(): BlueprintIndexDraft { + return { + name: '', + columns: [], + orders: [], + unique: false, + filter: '', + }; +} + +function indexInfoToDraft(idx: IndexInfo): BlueprintIndexDraft { + return { + name: idx.name, + columns: [...idx.columns], + orders: idx.columns.map(() => 'ASC' as IndexColumnOrder), + unique: !!idx.unique, + filter: idx.filter ?? '', + constraint: idx.constraint, + }; +} + +function normalizeOrders( + columns: string[], + orders: IndexColumnOrder[] | undefined +): IndexColumnOrder[] { + return columns.map((_, i) => (orders?.[i] === 'DESC' ? 'DESC' : 'ASC')); +} + +function formatIndexCols(idx: { columns: string[]; orders?: IndexColumnOrder[] }): string { + const orders = normalizeOrders(idx.columns, idx.orders); + return idx.columns + .map((c, i) => `${c} ${orders[i] ?? 'ASC'}`) + .join(', '); +} + function emptyTriggerDraft(dialect: string): BlueprintTriggerDraft { const meta = dialectTriggerForm(dialect); return { @@ -194,6 +233,16 @@ export const TableBlueprintModal: React.FC = ({ const [addingFk, setAddingFk] = useState(false); const [fkForm, setFkForm] = useState(() => emptyFkDraft()); + const [pendingIndexes, setPendingIndexes] = useState([]); + const [droppedIndexNames, setDroppedIndexNames] = useState>(() => new Set()); + const [addingIndex, setAddingIndex] = useState(false); + const [editingPendingIndex, setEditingPendingIndex] = useState(null); + const [replacingExistingIndex, setReplacingExistingIndex] = useState(null); + /** True when editing a live index whose WHERE filter was not loaded from catalog. */ + const [indexFilterUnknown, setIndexFilterUnknown] = useState(false); + const [confirmNoFilter, setConfirmNoFilter] = useState(false); + const [indexForm, setIndexForm] = useState(() => emptyIndexDraft()); + const [pendingTriggers, setPendingTriggers] = useState([]); const [droppedTriggerNames, setDroppedTriggerNames] = useState>( () => new Set() @@ -233,6 +282,14 @@ export const TableBlueprintModal: React.FC = ({ setDroppedFkNames(new Set()); setAddingFk(false); setFkForm(emptyFkDraft()); + setPendingIndexes([]); + setDroppedIndexNames(new Set()); + setAddingIndex(false); + setEditingPendingIndex(null); + setReplacingExistingIndex(null); + setIndexFilterUnknown(false); + setConfirmNoFilter(false); + setIndexForm(emptyIndexDraft()); setPendingTriggers([]); setDroppedTriggerNames(new Set()); setAddingTrigger(false); @@ -301,6 +358,11 @@ export const TableBlueprintModal: React.FC = ({ dropFkNames: [...droppedFkNames], addTriggers: pendingTriggers, dropTriggerNames: [...droppedTriggerNames], + addIndexes: pendingIndexes, + dropIndexes: [...droppedIndexNames].map((n) => { + const live = (liveTable?.indices ?? []).find((i) => i.name === n); + return { name: n, constraint: live?.constraint }; + }), }); }, [ mode, @@ -311,10 +373,13 @@ export const TableBlueprintModal: React.FC = ({ columnOps, originalPk, liveTable?.primaryKey?.name, + liveTable?.indices, pendingFks, droppedFkNames, pendingTriggers, droppedTriggerNames, + pendingIndexes, + droppedIndexNames, connectionSchema, ]); @@ -333,6 +398,7 @@ export const TableBlueprintModal: React.FC = ({ activeColumns.length > 0 && (statements.length > 0 || pendingFks.length > 0 || + pendingIndexes.length > 0 || pendingTriggers.length > 0) : statements.length > 0; const pkDirty = mode === 'edit' && !sameStringList(originalPk, draftPk); @@ -341,6 +407,9 @@ export const TableBlueprintModal: React.FC = ({ const existingFks = (liveTable?.foreignKeys ?? []).filter( (fk) => !droppedFkNames.has(fk.name) ); + const existingIndexes = (liveTable?.indices ?? []).filter( + (idx) => !droppedIndexNames.has(idx.name) + ); const existingTriggers = (liveTable?.triggers ?? []).filter( (trg) => !droppedTriggerNames.has(trg.name) ); @@ -349,6 +418,9 @@ export const TableBlueprintModal: React.FC = ({ const fkActions = dialectFkActions(dialect); const fkSupport = dialectFkConstraintSupport(dialect); const fkMulti = fkSupport.composite; + const indexSupport = dialectIndexSupport(dialect); + const indexFormOpen = + addingIndex || editingPendingIndex !== null || !!replacingExistingIndex; const toggleFkLocalColumn = (name: string) => { setFkForm((prev) => { @@ -437,6 +509,170 @@ export const TableBlueprintModal: React.FC = ({ setError(null); }; + const closeIndexForm = () => { + setAddingIndex(false); + setEditingPendingIndex(null); + setReplacingExistingIndex(null); + setIndexFilterUnknown(false); + setConfirmNoFilter(false); + setIndexForm(emptyIndexDraft()); + }; + + const openAddIndex = () => { + setAddingFk(false); + setAddingTrigger(false); + setEditingPendingIndex(null); + setReplacingExistingIndex(null); + setIndexFilterUnknown(false); + setConfirmNoFilter(false); + setAddingIndex(true); + const cols = activeColumns[0] ? [activeColumns[0].name] : []; + const unique = false; + setIndexForm({ + ...emptyIndexDraft(), + columns: cols, + orders: cols.map(() => 'ASC' as IndexColumnOrder), + unique, + name: suggestIndexName(tableName || 'table', cols, unique), + }); + }; + + const openEditExistingIndex = (idx: IndexInfo) => { + setAddingFk(false); + setAddingTrigger(false); + setAddingIndex(false); + setEditingPendingIndex(null); + setReplacingExistingIndex(idx.name); + // Catalog loaders rarely populate IndexInfo.filter yet — treat missing as unknown + // so we don't silently recreate a partial index without its WHERE clause. + const unknown = indexSupport.filter && idx.filter == null; + setIndexFilterUnknown(!!unknown); + setConfirmNoFilter(false); + setIndexForm(indexInfoToDraft(idx)); + }; + + const openEditPendingIndex = (i: number) => { + const draftIdx = pendingIndexes[i]; + if (!draftIdx) return; + setAddingFk(false); + setAddingTrigger(false); + setAddingIndex(false); + setReplacingExistingIndex(draftIdx.replaces ?? null); + // Pending drafts already carry an explicit filter (or none). + setIndexFilterUnknown(false); + setConfirmNoFilter(false); + setEditingPendingIndex(i); + setIndexForm({ + ...draftIdx, + columns: [...draftIdx.columns], + orders: normalizeOrders(draftIdx.columns, draftIdx.orders), + filter: draftIdx.filter ?? '', + }); + }; + + const toggleIndexColumn = (colName: string) => { + setIndexForm((prev) => { + if (prev.columns.includes(colName)) { + const i = prev.columns.indexOf(colName); + const columns = prev.columns.filter((c) => c !== colName); + return { + ...prev, + columns, + orders: prev.orders.filter((_, j) => j !== i), + name: + prev.name.trim() || + suggestIndexName(tableName || 'table', columns, prev.unique), + }; + } + const columns = [...prev.columns, colName]; + return { + ...prev, + columns, + orders: [...normalizeOrders(prev.columns, prev.orders), 'ASC'], + name: + prev.name.trim() || + suggestIndexName(tableName || 'table', columns, prev.unique), + }; + }); + }; + + const moveIndexColumn = (colName: string, dir: -1 | 1) => { + setIndexForm((prev) => { + const i = prev.columns.indexOf(colName); + if (i < 0) return prev; + const j = i + dir; + if (j < 0 || j >= prev.columns.length) return prev; + const columns = [...prev.columns]; + const orders = normalizeOrders(prev.columns, prev.orders); + [columns[i], columns[j]] = [columns[j]!, columns[i]!]; + [orders[i], orders[j]] = [orders[j]!, orders[i]!]; + return { ...prev, columns, orders }; + }); + }; + + const setIndexColumnOrder = (colName: string, order: IndexColumnOrder) => { + setIndexForm((prev) => { + const i = prev.columns.indexOf(colName); + if (i < 0) return prev; + const orders = normalizeOrders(prev.columns, prev.orders); + orders[i] = order; + return { ...prev, orders }; + }); + }; + + const saveIndex = () => { + if (indexForm.columns.length === 0) { + setError('Pick at least one column for the index'); + return; + } + if (indexForm.unique && !indexSupport.unique) { + setError(indexSupport.hint || 'This dialect does not support UNIQUE indexes'); + return; + } + if (!indexForm.unique && !indexSupport.acceptDuplicates) { + setError(indexSupport.hint || 'This dialect does not support non-unique indexes'); + return; + } + const filter = indexForm.filter?.trim() || ''; + if (filter && !indexSupport.filter) { + setError('This dialect does not support filtered/partial indexes'); + return; + } + if (indexFilterUnknown && !filter && !confirmNoFilter) { + setError( + 'Original WHERE filter is unknown from catalog. Enter the predicate, or confirm this index has no filter.' + ); + return; + } + const name = + indexForm.name.trim() || + suggestIndexName(tableName || 'table', indexForm.columns, indexForm.unique); + const replaces = + editingPendingIndex !== null + ? pendingIndexes[editingPendingIndex]?.replaces + : replacingExistingIndex ?? undefined; + const next: BlueprintIndexDraft = { + ...indexForm, + name, + orders: normalizeOrders(indexForm.columns, indexForm.orders), + filter: filter || undefined, + constraint: indexForm.unique ? indexForm.constraint : undefined, + replaces, + }; + if (editingPendingIndex !== null) { + setPendingIndexes((list) => + list.map((item, j) => (j === editingPendingIndex ? next : item)) + ); + } else { + if (replacingExistingIndex) { + setDroppedIndexNames((s) => new Set(s).add(replacingExistingIndex)); + } + setPendingIndexes((list) => [...list, next]); + } + closeIndexForm(); + setError(null); + }; + const saveTrigger = () => { const name = triggerForm.name.trim() || @@ -896,6 +1132,381 @@ export const TableBlueprintModal: React.FC = ({ + {/* Indexes */} +
+
+

+ + Indexes + + ({existingIndexes.length + pendingIndexes.length}) + +

+ {!indexFormOpen && indexSupport.create && ( + + )} +
+
+ {existingIndexes.length === 0 && + pendingIndexes.length === 0 && + !indexFormOpen ? ( +

+ {indexSupport.create + ? indexSupport.filter + ? 'No indexes — add UNIQUE or non-unique, ASC/DESC, and optional WHERE filter.' + : 'No indexes — add UNIQUE (reject duplicates) or non-unique (accept duplicates), with ASC/DESC per column.' + : indexSupport.hint} +

+ ) : ( +
    + {existingIndexes.map((idx: IndexInfo) => ( +
  • +
    + + {idx.name} + + · + + {idx.unique ? 'unique' : 'duplicates ok'} + + {idx.constraint ? ( + + constraint + + ) : null} +
    + ({idx.columns.join(', ')}) + {idx.filter?.trim() ? ( + WHERE {idx.filter.trim()} + ) : null} +
    +
    + {mode === 'edit' && ( +
    + {indexSupport.create && ( + + )} + {indexSupport.drop && ( + + )} +
    + )} +
  • + ))} + {[...droppedIndexNames].map((n) => ( +
  • + {n} + drop + +
  • + ))} + {pendingIndexes.map((idx, i) => ( +
  • +
    + + {idx.replaces ? 'replace' : 'new'} + + + {idx.name} + + · + + {idx.unique ? 'unique' : 'duplicates ok'} + +
    + ({formatIndexCols(idx)}) + {idx.filter?.trim() ? ( + WHERE {idx.filter.trim()} + ) : null} +
    +
    +
    + + +
    +
  • + ))} +
+ )} + + {indexFormOpen && ( +
+
+ +
+ {indexSupport.unique && indexSupport.acceptDuplicates ? ( + + ) : indexSupport.unique ? ( +

Unique only

+ ) : ( +

+ Non-unique only (accept duplicates) +

+ )} +
+
+ +
+ + Columns + +
+ {activeColumns.map((c) => { + const on = indexForm.columns.includes(c.name); + const ord = + indexForm.orders[indexForm.columns.indexOf(c.name)] ?? 'ASC'; + return ( +
+ + {on && ( + <> + {indexSupport.columnOrder && ( + + )} + + + + )} +
+ ); + })} +
+ {indexForm.columns.length > 0 && ( +

+ ({formatIndexCols(indexForm)}) + {indexForm.filter?.trim() && indexSupport.filter ? ( + + {' '} + WHERE {indexForm.filter.trim()} + + ) : null} +

+ )} + {!indexSupport.columnOrder && ( +

{indexSupport.hint}

+ )} +
+ + {indexSupport.filter && ( +
+ + {indexFilterUnknown && !(indexForm.filter ?? '').trim() && ( +
+

+ Catalog did not load a WHERE filter for this index. Enter the + predicate if it is partial, or confirm it has none before saving. +

+ +
+ )} +
+ )} + +
+ + +
+
+ )} +
+
+ {/* Foreign keys */}
@@ -912,6 +1523,7 @@ export const TableBlueprintModal: React.FC = ({ onClick={() => { setAddingFk(true); setAddingTrigger(false); + closeIndexForm(); const cols = activeColumns[0] ? [activeColumns[0].name] : []; setFkForm({ ...emptyFkDraft(), @@ -1274,6 +1886,7 @@ export const TableBlueprintModal: React.FC = ({ onClick={() => { setAddingTrigger(true); setAddingFk(false); + closeIndexForm(); const draft = emptyTriggerDraft(dialect); setTriggerForm({ ...draft, diff --git a/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.test.ts b/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.test.ts index 995726b..5642351 100644 --- a/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.test.ts +++ b/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.test.ts @@ -7,12 +7,15 @@ import { defaultDialectColumnType, dialectBooleanDefaultOptions, dialectIdentitySupport, + dialectIndexSupport, diffBlueprintColumns, generateAddForeignKeySql, generateBlueprintAlterSql, + generateCreateIndexSql, generateCreateTableSql, generateCreateTriggerSql, generateDropForeignKeySql, + generateDropIndexSql, generateDropTableSql, generateDropTriggerSql, generatePkAlterSql, @@ -27,6 +30,8 @@ import { parseTypeSize, quoteIdent, suggestFkName, + suggestIndexName, + appendFkTriggerSql, } from './tableBlueprintSql'; const col = (partial: Partial & Pick): ColumnInfo => ({ @@ -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({ diff --git a/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.ts b/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.ts index dec4a43..ab29c82 100644 --- a/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.ts +++ b/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.ts @@ -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 { @@ -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: { @@ -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]; } diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts index 5a323d0..7e6fbf3 100644 --- a/packages/core/src/browser.ts +++ b/packages/core/src/browser.ts @@ -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'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index deaab93..548f013 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/interfaces/schema.interface.ts b/packages/core/src/interfaces/schema.interface.ts index 1d18d4d..9e8782e 100644 --- a/packages/core/src/interfaces/schema.interface.ts +++ b/packages/core/src/interfaces/schema.interface.ts @@ -63,6 +63,11 @@ export interface IndexInfo { * tell the two apart (e.g. SQL Server `sys.indexes.is_unique_constraint`). */ constraint?: boolean; + /** + * Partial / filtered index predicate without the WHERE keyword (when known). + * Not all providers populate this yet. + */ + filter?: string; } export interface ForeignKeyInfo { diff --git a/packages/core/src/modules/dialect-index-support.test.ts b/packages/core/src/modules/dialect-index-support.test.ts new file mode 100644 index 0000000..550a64a --- /dev/null +++ b/packages/core/src/modules/dialect-index-support.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { dialectSupportsIndex } from './dialect-index-support'; +import { DIALECT_MAP } from './dialect-registry'; + +describe('dialectSupportsIndex', () => { + it('marks ClickHouse and Redshift as unsupported for traditional indexes', () => { + for (const d of ['clickhouse', 'redshift']) { + expect(dialectSupportsIndex(d)).toMatchObject({ + create: false, + drop: false, + unique: false, + acceptDuplicates: false, + columnOrder: false, + filter: false, + }); + } + }); + + it('supports unique, duplicates, and ASC/DESC on traditional dialects', () => { + for (const d of [ + 'postgres', + 'mysql', + 'mariadb', + 'tidb', + 'sqlite', + 'sqlserver', + 'azuresql', + 'oracle', + 'db2', + 'duckdb', + 'cockroachdb', + 'yugabytedb', + ]) { + expect(dialectSupportsIndex(d), d).toMatchObject({ + create: true, + drop: true, + unique: true, + acceptDuplicates: true, + columnOrder: true, + }); + } + }); + + it('supports filtered/partial WHERE only on dialects that allow it', () => { + for (const d of [ + 'postgres', + 'cockroachdb', + 'yugabytedb', + 'sqlserver', + 'azuresql', + 'sqlite', + ]) { + expect(dialectSupportsIndex(d).filter, d).toBe(true); + } + for (const d of [ + 'mysql', + 'mariadb', + 'tidb', + 'oracle', + 'db2', + 'duckdb', + 'clickhouse', + 'redshift', + ]) { + expect(dialectSupportsIndex(d).filter, d).toBe(false); + } + }); + + it('covers every registry dialect id', () => { + for (const key of Object.keys(DIALECT_MAP)) { + const support = dialectSupportsIndex(key); + expect(support.create || support.hint.length > 0, key).toBe(true); + expect(typeof support.columnOrder, key).toBe('boolean'); + expect(typeof support.filter, key).toBe('boolean'); + } + }); +}); diff --git a/packages/core/src/modules/dialect-index-support.ts b/packages/core/src/modules/dialect-index-support.ts new file mode 100644 index 0000000..8ceabd2 --- /dev/null +++ b/packages/core/src/modules/dialect-index-support.ts @@ -0,0 +1,132 @@ +/** + * Central dialect × index-feature bitmatrix used by generators and the + * table blueprint UI. + * + * Notes (verified against FoxSchema dialect registry + vendor docs): + * - Traditional engines (Postgres family, MySQL/MariaDB/TiDB, SQL Server, + * Oracle, DB2, SQLite, DuckDB) support CREATE/DROP INDEX, UNIQUE indexes, + * and non-unique indexes (accept duplicates). + * - ASC/DESC per index column is supported on those same engines (syntax + * accepted; MySQL 8+ honors DESC for InnoDB). + * - Filtered / partial indexes (`CREATE INDEX … WHERE predicate`): + * yes — Postgres, Cockroach, Yugabyte, SQL Server, Azure SQL, SQLite + * no — MySQL/MariaDB/TiDB, Oracle, DB2, DuckDB (no WHERE on CREATE INDEX) + * - ClickHouse: no traditional secondary indexes (data-skipping indexes use + * a different DDL shape — treat as unsupported in the blueprint). + * - Redshift: no CREATE INDEX / DROP INDEX for secondary indexes (sort/dist + * keys only) — treat as unsupported despite a leftover Postgres-like drop + * helper on the dialect object. + */ + +export type IndexFeatureSupport = { + /** `CREATE [UNIQUE] INDEX … ON table (cols…)` */ + create: boolean; + /** `DROP INDEX …` (dialect-specific ON table / IF EXISTS) */ + drop: boolean; + /** UNIQUE indexes / unique constraints that reject duplicates */ + unique: boolean; + /** Non-unique indexes that accept duplicate key values */ + acceptDuplicates: boolean; + /** Per-column ASC / DESC in the index column list */ + columnOrder: boolean; + /** + * Partial / filtered index: `CREATE INDEX … WHERE `. + * Indexes only the rows matching the predicate. + */ + filter: boolean; + hint: string; +}; + +const FULL: Omit = { + create: true, + drop: true, + unique: true, + acceptDuplicates: true, + columnOrder: true, + filter: true, +}; + +/** Traditional indexes without partial/filtered WHERE support. */ +const FULL_NO_FILTER: Omit = { + ...FULL, + filter: false, +}; + +const MATRIX: Record = { + clickhouse: { + create: false, + drop: false, + unique: false, + acceptDuplicates: false, + columnOrder: false, + filter: false, + hint: 'ClickHouse has no traditional CREATE INDEX — use table engine / skipping indexes outside this blueprint.', + }, + redshift: { + create: false, + drop: false, + unique: false, + acceptDuplicates: false, + columnOrder: false, + filter: false, + hint: 'Redshift has no secondary indexes — use SORTKEY / DISTKEY instead.', + }, + sqlite: { + ...FULL, + hint: 'SQLite: CREATE/DROP INDEX; UNIQUE or non-unique; ASC/DESC; partial WHERE filter.', + }, + duckdb: { + ...FULL_NO_FILTER, + hint: 'DuckDB: CREATE/DROP INDEX; UNIQUE or non-unique; ASC/DESC. No partial WHERE filter.', + }, + mysql: { + ...FULL_NO_FILTER, + hint: 'MySQL: CREATE/DROP INDEX … ON table; UNIQUE or non-unique; ASC/DESC. No partial WHERE filter.', + }, + mariadb: { + ...FULL_NO_FILTER, + hint: 'MariaDB: CREATE/DROP INDEX … ON table; UNIQUE or non-unique; ASC/DESC. No partial WHERE filter.', + }, + tidb: { + ...FULL_NO_FILTER, + hint: 'TiDB: CREATE/DROP INDEX … ON table; UNIQUE or non-unique; ASC/DESC. No partial WHERE filter.', + }, + postgres: { + ...FULL, + hint: 'PostgreSQL: CREATE/DROP INDEX; UNIQUE or non-unique; ASC/DESC; partial WHERE filter.', + }, + cockroachdb: { + ...FULL, + hint: 'CockroachDB: CREATE/DROP INDEX; UNIQUE or non-unique; ASC/DESC; partial WHERE filter.', + }, + yugabytedb: { + ...FULL, + hint: 'YugabyteDB: CREATE/DROP INDEX; UNIQUE or non-unique; ASC/DESC; partial WHERE filter.', + }, + sqlserver: { + ...FULL, + hint: 'SQL Server: CREATE/DROP INDEX … ON table; UNIQUE or non-unique; ASC/DESC; filtered WHERE. Unique constraints use ALTER TABLE.', + }, + azuresql: { + ...FULL, + hint: 'Azure SQL: CREATE/DROP INDEX … ON table; UNIQUE or non-unique; ASC/DESC; filtered WHERE. Unique constraints use ALTER TABLE.', + }, + oracle: { + ...FULL_NO_FILTER, + hint: 'Oracle: CREATE/DROP INDEX; UNIQUE or non-unique; ASC/DESC. No partial WHERE (use function-based indexes outside this form).', + }, + db2: { + ...FULL_NO_FILTER, + hint: 'DB2: CREATE/DROP INDEX; UNIQUE or non-unique; ASC/DESC. No partial WHERE filter.', + }, +}; + +const DEFAULT_SUPPORT: IndexFeatureSupport = { + ...FULL_NO_FILTER, + hint: 'CREATE/DROP INDEX; UNIQUE or non-unique; ASC/DESC per column.', +}; + +/** Look up index feature flags for a dialect id. */ +export function dialectSupportsIndex(dialectName: string): IndexFeatureSupport { + return MATRIX[dialectName.toLowerCase()] ?? DEFAULT_SUPPORT; +} diff --git a/packages/core/src/modules/sql-generator.module.ts b/packages/core/src/modules/sql-generator.module.ts index 1ea78c7..e2b014e 100644 --- a/packages/core/src/modules/sql-generator.module.ts +++ b/packages/core/src/modules/sql-generator.module.ts @@ -226,7 +226,8 @@ export class SqlGeneratorModule { private createIndexSql(idx: IndexInfo, qualifiedTable: string, dialect?: SqlDialect): string { if (dialect?.createIndexStatement) return dialect.createIndexStatement(idx, qualifiedTable); const uniqueStr = idx.unique ? ' UNIQUE' : ''; - return `CREATE${uniqueStr} INDEX ${idx.name} ON ${qualifiedTable} (${idx.columns.join(', ')});`; + const whereClause = idx.filter?.trim() ? ` WHERE ${idx.filter.trim()}` : ''; + return `CREATE${uniqueStr} INDEX ${idx.name} ON ${qualifiedTable} (${idx.columns.join(', ')})${whereClause};`; } /** diff --git a/packages/core/src/providers/sqlServer/sqlserver.sql-dialect.ts b/packages/core/src/providers/sqlServer/sqlserver.sql-dialect.ts index 6d330de..2cdfc09 100644 --- a/packages/core/src/providers/sqlServer/sqlserver.sql-dialect.ts +++ b/packages/core/src/providers/sqlServer/sqlserver.sql-dialect.ts @@ -114,7 +114,8 @@ export const sqlServerSqlDialect: SqlDialect = { return `ALTER TABLE ${qualifiedTable} ADD CONSTRAINT ${index.name} UNIQUE (${index.columns.join(', ')});`; } const uniqueStr = index.unique ? ' UNIQUE' : ''; - return `CREATE${uniqueStr} INDEX ${index.name} ON ${qualifiedTable} (${index.columns.join(', ')});`; + const whereClause = index.filter?.trim() ? ` WHERE ${index.filter.trim()}` : ''; + return `CREATE${uniqueStr} INDEX ${index.name} ON ${qualifiedTable} (${index.columns.join(', ')})${whereClause};`; }, dropTriggerStatement(triggerName: string, qualifiedTable: string): string {