From c882ef01ecfd95eb3c2fd90c59e493e2b7da6992 Mon Sep 17 00:00:00 2001 From: huyplb Date: Sat, 25 Jul 2026 22:40:07 -0600 Subject: [PATCH 1/8] fix(security): harden cloud secret resolve and clear CI eslint Require user cloud credentials on multi-user hosts, allowlist Azure vault URLs, and fix DataGrid/CodeQL quality issues from the scan. Co-authored-by: Cursor --- .../src/backend/modules/app-secrets.module.ts | 12 ++- .../src/backend/modules/cloud-secrets.test.ts | 48 +++++++++++- apps/web/src/backend/modules/cloud-secrets.ts | 73 ++++++++++++++++++- .../components/sql-editor/DataGrid.tsx | 3 +- .../components/sql-editor/SqlEditorPane.tsx | 2 +- .../components/sql-editor/SqlEditorView.tsx | 10 +-- .../sql-editor/SqlSidebarSection.tsx | 14 ++-- docs/DEPLOYMENT.md | 8 ++ 8 files changed, 152 insertions(+), 18 deletions(-) diff --git a/apps/web/src/backend/modules/app-secrets.module.ts b/apps/web/src/backend/modules/app-secrets.module.ts index 2d3d83f..1ce0ccb 100644 --- a/apps/web/src/backend/modules/app-secrets.module.ts +++ b/apps/web/src/backend/modules/app-secrets.module.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { getStore } from '../database/store'; import { encryptSecret, decryptSecret } from '../cores/crypto'; import { + assertAzureVaultUrl, parseCloudRef, resolveCloudSecret, serializeCloudRef, @@ -99,11 +100,14 @@ export class AppSecretsStore { if (input.source === 'azure' && !input.cloudRef.vaultUrl?.trim()) { throw new Error('Azure secrets require cloudRef.vaultUrl'); } + if (input.source === 'azure' && input.cloudRef.vaultUrl) { + assertAzureVaultUrl(input.cloudRef.vaultUrl.trim()); + } cloudRefJson = serializeCloudRef({ secretId: input.cloudRef.secretId.trim(), credentialId: input.cloudRef.credentialId, region: input.cloudRef.region, - vaultUrl: input.cloudRef.vaultUrl, + vaultUrl: input.cloudRef.vaultUrl?.trim(), version: input.cloudRef.version, }); } @@ -154,7 +158,7 @@ export class AppSecretsStore { } let encryptedValue = existing.encrypted_value; - let cloudRefJson = existing.cloud_ref; + let cloudRefJson: string | null; if (nextSource === 'local') { cloudRefJson = null; @@ -178,6 +182,10 @@ export class AppSecretsStore { if (nextSource === 'azure' && !merged.vaultUrl?.trim()) { throw new Error('Azure secrets require cloudRef.vaultUrl'); } + if (nextSource === 'azure' && merged.vaultUrl) { + assertAzureVaultUrl(merged.vaultUrl.trim()); + merged.vaultUrl = merged.vaultUrl.trim(); + } cloudRefJson = serializeCloudRef(merged); } diff --git a/apps/web/src/backend/modules/cloud-secrets.test.ts b/apps/web/src/backend/modules/cloud-secrets.test.ts index 5906bbc..91de791 100644 --- a/apps/web/src/backend/modules/cloud-secrets.test.ts +++ b/apps/web/src/backend/modules/cloud-secrets.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { parseCloudRef, serializeCloudRef } from './cloud-secrets'; +import { + assertAzureVaultUrl, + allowHostCloudCredentials, + parseCloudRef, + serializeCloudRef, +} from './cloud-secrets'; describe('cloud-secrets ref helpers', () => { it('round-trips cloud ref JSON', () => { @@ -13,3 +18,44 @@ describe('cloud-secrets ref helpers', () => { expect(parseCloudRef('not-json')).toBeNull(); }); }); + +describe('assertAzureVaultUrl', () => { + it('accepts public and sovereign Key Vault hostnames', () => { + expect(() => assertAzureVaultUrl('https://myvault.vault.azure.net/')).not.toThrow(); + expect(() => assertAzureVaultUrl('https://myvault.vault.azure.cn')).not.toThrow(); + expect(() => + assertAzureVaultUrl('https://myvault.vault.usgovcloudapi.net/secrets') + ).not.toThrow(); + }); + + it('rejects SSRF-prone URLs', () => { + expect(() => assertAzureVaultUrl('http://myvault.vault.azure.net/')).toThrow(/HTTPS/); + expect(() => assertAzureVaultUrl('https://evil.example/')).toThrow(/hostname/); + expect(() => assertAzureVaultUrl('https://127.0.0.1/')).toThrow(); + expect(() => assertAzureVaultUrl('https://169.254.169.254/')).toThrow(); + expect(() => assertAzureVaultUrl('https://attacker.vault.azure.net.evil.com/')).toThrow( + /hostname/ + ); + expect(() => assertAzureVaultUrl('https://user:pass@myvault.vault.azure.net/')).toThrow( + /credentials/ + ); + }); +}); + +describe('allowHostCloudCredentials', () => { + it('defaults to allowed (single-user)', () => { + const prevLocal = process.env.LOCAL_SINGLE_USER; + const prevAllow = process.env.ALLOW_HOST_CLOUD_CREDENTIALS; + delete process.env.LOCAL_SINGLE_USER; + delete process.env.ALLOW_HOST_CLOUD_CREDENTIALS; + expect(allowHostCloudCredentials()).toBe(true); + process.env.LOCAL_SINGLE_USER = 'false'; + expect(allowHostCloudCredentials()).toBe(false); + process.env.ALLOW_HOST_CLOUD_CREDENTIALS = 'true'; + expect(allowHostCloudCredentials()).toBe(true); + if (prevLocal === undefined) delete process.env.LOCAL_SINGLE_USER; + else process.env.LOCAL_SINGLE_USER = prevLocal; + if (prevAllow === undefined) delete process.env.ALLOW_HOST_CLOUD_CREDENTIALS; + else process.env.ALLOW_HOST_CLOUD_CREDENTIALS = prevAllow; + }); +}); diff --git a/apps/web/src/backend/modules/cloud-secrets.ts b/apps/web/src/backend/modules/cloud-secrets.ts index cccf255..c2813b8 100644 --- a/apps/web/src/backend/modules/cloud-secrets.ts +++ b/apps/web/src/backend/modules/cloud-secrets.ts @@ -1,7 +1,7 @@ /** * Resolve secrets from AWS Secrets Manager / GCP Secret Manager / Azure Key Vault. - * Uses stored per-user provider credentials when provided; otherwise the host’s - * default credential chain. SDKs are optionalDependencies. + * Prefer stored per-user provider credentials. Host default chains are only + * allowed in single-user mode (or when ALLOW_HOST_CLOUD_CREDENTIALS=true). */ export type CloudSecretSource = 'aws' | 'gcp' | 'azure'; @@ -52,6 +52,59 @@ export type CloudProviderCredentials = | GcpProviderCredentials | AzureProviderCredentials; +/** True when host IAM / ADC / DefaultAzureCredential may be used without user keys. */ +export function allowHostCloudCredentials(): boolean { + if (process.env.ALLOW_HOST_CLOUD_CREDENTIALS === 'true') return true; + // LOCAL_SINGLE_USER defaults to true (desktop / personal CLI). + return process.env.LOCAL_SINGLE_USER !== 'false'; +} + +/** + * Azure Key Vault URLs only — HTTPS + known vault hostnames (blocks SSRF / + * token exfiltration via attacker-controlled vaultUrl). + */ +export function assertAzureVaultUrl(vaultUrl: string): void { + let parsed: URL; + try { + parsed = new URL(vaultUrl); + } catch { + throw new Error('Invalid Azure Key Vault URL'); + } + if (parsed.protocol !== 'https:') { + throw new Error('Azure Key Vault URL must use HTTPS'); + } + if (parsed.username || parsed.password) { + throw new Error('Azure Key Vault URL must not include credentials'); + } + if (parsed.port && parsed.port !== '443') { + throw new Error('Azure Key Vault URL must use the default HTTPS port'); + } + const host = parsed.hostname.toLowerCase(); + if ( + host === 'localhost' || + host.endsWith('.localhost') || + host === '127.0.0.1' || + host === '::1' + ) { + throw new Error('Azure Key Vault URL hostname is not allowed'); + } + // Reject raw IPv4 / IPv6 + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':')) { + throw new Error('Azure Key Vault URL must use a vault hostname, not an IP'); + } + const allowedSuffixes = [ + '.vault.azure.net', + '.vault.azure.cn', + '.vault.usgovcloudapi.net', + '.vault.microsoftazure.de', + ]; + if (!allowedSuffixes.some((sfx) => host.endsWith(sfx) && host.length > sfx.length)) { + throw new Error( + 'Azure Key Vault URL hostname must be *.vault.azure.net (or a known sovereign-cloud vault endpoint)' + ); + } +} + export function parseCloudRef(raw: string | null | undefined): CloudSecretRef | null { if (!raw) return null; try { @@ -80,10 +133,23 @@ export function serializeCloudRef(ref: CloudSecretRef): string { }); } +function requireProviderCreds( + providerCreds: CloudProviderCredentials | undefined, + source: CloudSecretSource +): void { + if (providerCreds) return; + if (allowHostCloudCredentials()) return; + throw new Error( + `Cloud secret resolve for ${source} requires saved Credentials → Cloud providers credentials ` + + `(host IAM is disabled on multi-user hosts; set ALLOW_HOST_CLOUD_CREDENTIALS=true only if intentional)` + ); +} + async function resolveAws( ref: CloudSecretRef, providerCreds?: CloudProviderCredentials ): Promise { + requireProviderCreds(providerCreds, 'aws'); let SecretsManagerClient: typeof import('@aws-sdk/client-secrets-manager').SecretsManagerClient; let GetSecretValueCommand: typeof import('@aws-sdk/client-secrets-manager').GetSecretValueCommand; try { @@ -127,6 +193,7 @@ async function resolveGcp( ref: CloudSecretRef, providerCreds?: CloudProviderCredentials ): Promise { + requireProviderCreds(providerCreds, 'gcp'); let SecretManagerServiceClient: typeof import('@google-cloud/secret-manager').SecretManagerServiceClient; try { const mod = await import('@google-cloud/secret-manager'); @@ -169,6 +236,8 @@ async function resolveAzure( if (!ref.vaultUrl) { throw new Error('Azure Key Vault secrets require cloud_ref.vaultUrl'); } + assertAzureVaultUrl(ref.vaultUrl); + requireProviderCreds(providerCreds, 'azure'); let SecretClient: typeof import('@azure/keyvault-secrets').SecretClient; let DefaultAzureCredential: typeof import('@azure/identity').DefaultAzureCredential; let ClientSecretCredential: typeof import('@azure/identity').ClientSecretCredential; diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 3c28502..4cd2648 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -285,8 +285,7 @@ export const DataGrid: React.FC<{ const colKey = sourceColumns.join('\0'); const colKinds = useMemo( () => computeColKinds(sourceColumns, sourceRows), - // eslint-disable-next-line react-hooks/exhaustive-deps -- colKey captures column identity; rowCount covers data refresh - [colKey, sourceRows.length, result.ok ? result.rowCount : 0] + [colKey, sourceColumns, sourceRows] ); useEffect(() => { diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorPane.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorPane.tsx index 23288ea..539622e 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlEditorPane.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorPane.tsx @@ -10,7 +10,7 @@ import { import { ensureFoxschemaSqlLanguage } from '../../lib/foxschemaSqlLanguage'; import { MONACO_FONT_PX, useUiStore } from '../../store/uiStore'; import { useSqlEditorStore } from '../../store/useSqlEditorStore'; -import { splitSqlStatements, checkStatement, type SplitStatement } from '../../lib/sql-splitter'; +import { checkStatement, type SplitStatement } from '../../lib/sql-splitter'; import { ensureSqlCompletions } from './completion'; import { setSqlInsertHandler, setSqlSelectionGetter } from './sqlEditorBridge'; import { buildVariableHoverDecorations } from './variableHover'; diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx index 8e1cbcb..e7b85f9 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx @@ -342,13 +342,13 @@ export const SqlEditorView: React.FC = () => { } - open={sidebarOpen.secrets} - onToggle={() => toggleSidebar('secrets')} - height={sectionHeights.secrets} - onResizeHeight={(h) => setSectionHeight('secrets', h)} + open={sidebarOpen.vault} + onToggle={() => toggleSidebar('vault')} + height={sectionHeights.vault} + onResizeHeight={(h) => setSectionHeight('vault', h)} actions={ {open && ( @@ -167,6 +183,14 @@ export const SqlSchemaExplorer: React.FC = () => { }) } dialect={conn?.dialect ?? 'sql'} + onOpenBlueprint={ + t.objectType === 'TABLE' || t.objectType === 'MQT' + ? () => { + setBlueprintMode('edit'); + setBlueprintTable(t); + } + : undefined + } /> ))} @@ -175,6 +199,19 @@ export const SqlSchemaExplorer: React.FC = () => { ); })} + + {(blueprintMode === 'create' || blueprintTable) && explorerId && ( + { + setBlueprintTable(null); + setBlueprintMode('edit'); + }} + /> + )} )} @@ -186,7 +223,8 @@ const ObjectNode: React.FC<{ open: boolean; onToggle: () => void; dialect: string; -}> = ({ table, open, onToggle, dialect }) => { + onOpenBlueprint?: () => void; +}> = ({ table, open, onToggle, dialect, onOpenBlueprint }) => { const meta = TYPE_META[table.objectType] ?? TYPE_META.TABLE; const insertName = quoteIfNeeded(table.name, dialect); const isRoutine = table.objectType === 'PROCEDURE' || table.objectType === 'FUNCTION'; @@ -217,9 +255,9 @@ const ObjectNode: React.FC<{ aria-label={open ? 'Collapse' : 'Expand'} > {open ? ( - + ) : ( - + )} + {onOpenBlueprint && ( + + )} {open && isRoutine && params.length === 0 && ( -

No parameters

+

No parameters

)} {open && isRoutine && params.length > 0 && ( -
    +
      {params.map((p, i) => (
    • @@ -269,18 +321,18 @@ const ObjectNode: React.FC<{
    )} {open && !isRoutine && columns.length > 0 && ( -
      +
        {columns.map((col) => (
      • diff --git a/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx b/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx index 3e990db..6b5d37b 100644 --- a/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx +++ b/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx @@ -45,9 +45,16 @@ function loadHeight(): number { return defaultHeight(); } -const preview = (text: string, max = 120): string => { - const compact = text.replace(/\s+/g, ' ').trim(); - return compact.length > max ? compact.slice(0, max) + '…' : compact; +const preview = (text: string, max = 120, codeKind?: CodeCellKind | null): string => { + let source = text; + if (codeKind) { + // Drop fence markers so the strip shows the cell body, not `-- @node` / `-- @end`. + source = source + .replace(/^\s*--\s*@(?:js|ts|javascript|typescript|node|nodets|node-typescript)\b[^\n]*\n?/i, '') + .replace(/\n?\s*--\s*@end\s*$/i, ''); + } + const compact = source.replace(/\s+/g, ' ').trim(); + return compact.length > max ? compact.slice(0, max) + '…' : compact || text.replace(/\s+/g, ' ').trim(); }; const codeCellBadge = (kind: CodeCellKind) => ({ @@ -237,13 +244,20 @@ export const StatementStrip: React.FC = ({ statements, checked, onToggle, > {ok ? '✓' : '⚠'} - {codeBadge && ( + {codeBadge ? ( {codeBadge.label} + ) : ( + + SQL + )} {dmlBadge && ( = ({ statements, checked, onToggle, )} - {preview(stmt.text)} + {preview(stmt.text, 120, codeKind)} + + +
        + {/* Columns */} +
        +
        +

        + + Columns + ({draft.length}) +

        + {!adding && ( + + )} +
        + +
        + {draft.map((col) => { + const isDropped = dropped.has(col.name); + const isEditing = editingName === col.name; + const isNew = mode === 'create' || !original.some((c) => c.name === col.name); + const inPk = draftPk.includes(col.name); + const kind = classifyColumnType(col.type); + const kindUi = columnTypeKindClasses(kind); + const showAutoInc = isIntegerAutoIncrementType(col.type); + return ( +
        + {isEditing && editForm ? ( + { + setEditingName(null); + setEditForm(null); + }} + nameLocked={!isNew && mode === 'edit'} + dialect={dialect} + /> + ) : ( +
        + + {col.name} + + {inPk && ( + + )} + + {kindUi.label} + + + {col.type} + {col.identity && showAutoInc + ? resolveDialectIdentityPreview(col, dialect) + : ''} + {!col.nullable ? ' NOT NULL' : ''} + {col.unique ? ' UNIQUE' : ''} + {col.defaultValue ? ` DEFAULT ${col.defaultValue}` : ''} + + {!isDropped && showAutoInc && ( + + )} +
        + {isDropped ? ( + + ) : ( + <> + + + + )} +
        +
        + )} +
        + ); + })} + {draft.length === 0 && ( +

        No columns

        + )} + {adding && ( +
        + setAdding(false)} + nameLocked={false} + dialect={dialect} + /> +
        + )} +
        +
        + + {/* Primary key (composite) */} +
        +

        + + Primary key + {pkDirty && ( + changed + )} +

        +
        +

        + Check columns to build a single or composite key. Order matters — use arrows to + reorder. +

        + {activeColumns.length === 0 ? ( +

        Add columns first

        + ) : ( +
          + {activeColumns.map((col) => { + const on = draftPk.includes(col.name); + const pkIndex = draftPk.indexOf(col.name); + return ( +
        • + + {on && ( + + + {pkIndex + 1} + + + + + )} +
        • + ); + })} +
        + )} + {draftPk.length > 0 && ( +

        + PRIMARY KEY ({draftPk.join(', ')}) +

        + )} +
        +
        + + {/* FK / triggers — edit mode only */} + {mode === 'edit' && ( + <> +
        +

        + + Foreign keys + ({fks.length}) +

        +
        + {fks.length === 0 ? ( +

        No foreign keys

        + ) : ( +
          + {fks.map((fk) => ( +
        • + {fk.name} + · + + ({fk.columns.join(', ')}) → {fk.referencedTable}( + {fk.referencedColumns.join(', ')}) + +
        • + ))} +
        + )} +
        +
        + +
        +

        + + Triggers + ({triggers.length}) +

        +
        + {triggers.length === 0 ? ( +

        No triggers

        + ) : ( +
          + {triggers.map((trg) => { + const open = !!expandedTrig[trg.name]; + return ( +
        • + + {open && ( +
          +                                  {trg.definition?.trim() || '(no definition)'}
          +                                
          + )} +
        • + ); + })} +
        + )} +
        +
        + + )} + + {identityUi.hint && ( +

        {identityUi.hint}

        + )} +
        + +
        + {error && ( +

        + {error} +

        + )} +
        + {hasOps ? ( +
        +                  {sqlText}
        +                
        + ) : ( +

        + {mode === 'create' + ? 'Enter a table name and at least one column' + : 'No pending changes'} +

        + )} +
        +
        + {mode === 'edit' && ( + + )} + + + +
        +
        + + + + {confirmWrite && ( + setConfirmWrite(false)} + onConfirm={() => void runApply()} + /> + )} + {confirmDrop && ( + setConfirmDrop(false)} + onConfirm={() => void runApply(dropStatements, true)} + /> + )} + , + document.body + ); +}; + +function resolveDialectIdentityPreview(col: ColumnInfo, dialect: string): string { + if (!col.identity || !isIntegerAutoIncrementType(col.type)) return ''; + const d = dialect.toLowerCase(); + if (d === 'mysql' || d === 'mariadb' || d === 'tidb') return ' AUTO_INCREMENT'; + if (d === 'sqlserver' || d === 'azuresql') return ' IDENTITY(1,1)'; + if (d === 'redshift') return ' IDENTITY(0,1)'; + if (d === 'sqlite') return ' AUTOINCREMENT'; + const gen = col.identityGeneration ?? 'ALWAYS'; + return ` GENERATED ${gen} AS IDENTITY`; +} + +const CUSTOM_TYPE = '__custom__'; + +const ColumnForm: React.FC<{ + value: BlueprintColumn; + onChange: (c: BlueprintColumn) => void; + onSave: () => void; + onCancel: () => void; + nameLocked: boolean; + dialect: string; +}> = ({ value, onChange, onSave, onCancel, nameLocked, dialect }) => { + const identityUi = dialectIdentitySupport(dialect); + const constraints = dialectColumnConstraints(dialect); + const typeOptions = useMemo(() => listDialectDataTypes(dialect), [dialect]); + const [forceCustom, setForceCustom] = useState(false); + const matched = typeOptions.find( + (t) => t.value.toLowerCase() === value.type.trim().toLowerCase() + ); + const selectValue = forceCustom || !matched ? CUSTOM_TYPE : matched.value; + const kind = classifyColumnType(value.type); + const kindUi = columnTypeKindClasses(kind); + const intOk = isIntegerAutoIncrementType(value.type); + const boolOk = isBooleanBitType(value.type); + const size = parseTypeSize(value.type); + const boolDefaults = useMemo( + () => dialectBooleanDefaultOptions(dialect), + [dialect] + ); + + const setType = (type: string) => { + let next: BlueprintColumn = { ...value, type }; + if (value.identity && !isIntegerAutoIncrementType(type)) { + next = withAutoIncrement(next, false); + } + // Clear incompatible boolean defaults when leaving boolean + if (!isBooleanBitType(type) && value.defaultValue) { + const allowed = new Set(dialectBooleanDefaultOptions(dialect).map((d) => d.value)); + if (allowed.has(value.defaultValue) && /^(TRUE|FALSE|1|0|NULL)$/i.test(value.defaultValue)) { + // keep numeric defaults for ints; only clear TRUE/FALSE when leaving bool + if (/^(TRUE|FALSE)$/i.test(value.defaultValue) && !isBooleanBitType(type)) { + next = { ...next, defaultValue: undefined }; + } + } + } + onChange(next); + }; + + const groups = useMemo(() => { + const map = new Map(); + for (const opt of typeOptions) { + const list = map.get(opt.group) ?? []; + list.push(opt); + map.set(opt.group, list); + } + return [...map.entries()]; + }, [typeOptions]); + + return ( +
        +
        + + {kindUi.label} + + {constraints.hint} +
        + +
        + + + + {boolOk ? ( + + ) : ( + + )} +
        + + {/* Length / precision */} + {size.kind === 'length' && ( + + )} + {size.kind === 'decimal' && ( +
        + + + {value.type} +
        + )} + + {/* Auto-inc only for integers */} + {intOk && ( +
        + + {value.identity && identityUi.generations && ( + + )} +
        + )} + + {/* Constraints */} +
        + {constraints.notNull && ( + + )} + {constraints.unique && ( + + )} +
        + + +
        +
        +
        + ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.test.ts b/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.test.ts new file mode 100644 index 0000000..3f9e782 --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from 'vitest'; +import type { ColumnInfo } from '../../lib/types'; +import { + applyTypeSize, + buildColumnDef, + classifyColumnType, + defaultDialectColumnType, + dialectBooleanDefaultOptions, + dialectIdentitySupport, + diffBlueprintColumns, + generateBlueprintAlterSql, + generateCreateTableSql, + generateDropTableSql, + generatePkAlterSql, + generateTableBlueprintSql, + isIntegerAutoIncrementType, + listDialectDataTypes, + parseTypeSize, + quoteIdent, +} from './tableBlueprintSql'; + +const col = (partial: Partial & Pick): ColumnInfo => ({ + nullable: true, + primaryKey: false, + ...partial, +}); + +describe('quoteIdent', () => { + it('leaves plain names unquoted', () => { + expect(quoteIdent('orders', 'postgres')).toBe('orders'); + }); + + it('quotes special names per dialect', () => { + expect(quoteIdent('my col', 'postgres')).toBe('"my col"'); + expect(quoteIdent('my col', 'mysql')).toBe('`my col`'); + expect(quoteIdent('my col', 'sqlserver')).toBe('[my col]'); + }); +}); + +describe('listDialectDataTypes', () => { + it('renders postgres-native names', () => { + const types = listDialectDataTypes('postgres'); + const values = types.map((t) => t.value); + expect(values).toContain('integer'); + expect(values).toContain('bigint'); + expect(values).toContain('varchar(255)'); + expect(values).toContain('boolean'); + expect(values).toContain('timestamptz'); + }); + + it('renders mysql-native names', () => { + const types = listDialectDataTypes('mysql'); + const values = types.map((t) => t.value); + expect(values).toContain('int'); + expect(values).toContain('bigint'); + expect(values).toContain('tinyint(1)'); + expect(values).toContain('varchar(255)'); + }); + + it('marks integer-like options for auto-inc', () => { + const intOpt = listDialectDataTypes('postgres').find((t) => t.value === 'integer'); + expect(intOpt?.integerLike).toBe(true); + const varchar = listDialectDataTypes('postgres').find((t) => t.value === 'varchar(255)'); + expect(varchar?.integerLike).toBe(false); + }); + + it('defaultDialectColumnType prefers varchar(255)', () => { + expect(defaultDialectColumnType('postgres')).toBe('varchar(255)'); + expect(defaultDialectColumnType('mysql')).toBe('varchar(255)'); + }); +}); + +describe('classifyColumnType / sizes / boolean defaults', () => { + it('classifies kinds', () => { + expect(classifyColumnType('integer')).toBe('integer'); + expect(classifyColumnType('varchar(50)')).toBe('text'); + expect(classifyColumnType('numeric(10,2)')).toBe('decimal'); + expect(classifyColumnType('boolean')).toBe('boolean'); + expect(classifyColumnType('tinyint(1)')).toBe('boolean'); + expect(classifyColumnType('timestamptz')).toBe('datetime'); + }); + + it('parses and applies varchar length', () => { + expect(parseTypeSize('varchar(255)')).toEqual({ kind: 'length', length: 255 }); + expect(applyTypeSize('varchar(255)', { length: 100 })).toBe('varchar(100)'); + }); + + it('parses and applies decimal precision/scale', () => { + expect(parseTypeSize('decimal(10,2)')).toEqual({ + kind: 'decimal', + precision: 10, + scale: 2, + }); + expect(applyTypeSize('numeric(10,2)', { precision: 18, scale: 4 })).toBe('numeric(18,4)'); + }); + + it('offers dialect boolean defaults', () => { + expect(dialectBooleanDefaultOptions('postgres').map((o) => o.value)).toEqual([ + '', + 'TRUE', + 'FALSE', + 'NULL', + ]); + expect(dialectBooleanDefaultOptions('mysql').some((o) => o.value === '1')).toBe(true); + expect(dialectBooleanDefaultOptions('sqlserver').map((o) => o.value)).toEqual([ + '', + '1', + '0', + 'NULL', + ]); + }); + + it('hides auto-inc for boolean and decimal', () => { + expect(isIntegerAutoIncrementType('boolean')).toBe(false); + expect(isIntegerAutoIncrementType('tinyint(1)')).toBe(false); + expect(isIntegerAutoIncrementType('decimal(10,2)')).toBe(false); + expect(isIntegerAutoIncrementType('bigint')).toBe(true); + }); + + it('emits UNIQUE when set', () => { + expect( + buildColumnDef( + { name: 'email', type: 'varchar(255)', nullable: false, primaryKey: false, unique: true }, + 'postgres' + ) + ).toContain('UNIQUE'); + }); +}); + +describe('dialectIdentitySupport', () => { + it('exposes AUTO_INCREMENT for mysql', () => { + expect(dialectIdentitySupport('mysql').label).toBe('Auto increment'); + expect(dialectIdentitySupport('mysql').clauseHint).toBe('AUTO_INCREMENT'); + expect(dialectIdentitySupport('mysql').supported).toBe(true); + }); + + it('exposes GENERATED options for postgres', () => { + expect(dialectIdentitySupport('postgres').generations).toEqual(['ALWAYS', 'BY DEFAULT']); + }); + + it('marks sqlite supported with AUTOINCREMENT', () => { + expect(dialectIdentitySupport('sqlite').supported).toBe(true); + expect(dialectIdentitySupport('sqlite').clauseHint).toBe('AUTOINCREMENT'); + }); +}); + +describe('buildColumnDef', () => { + it('builds postgres-style add column def', () => { + expect( + buildColumnDef( + col({ name: 'status', type: 'varchar(32)', nullable: false, defaultValue: `'open'` }), + 'postgres' + ) + ).toBe(`status varchar(32) DEFAULT 'open' NOT NULL`); + }); + + it('appends identity clause for postgres', () => { + expect( + buildColumnDef( + col({ + name: 'id', + type: 'integer', + nullable: false, + identity: true, + identityGeneration: 'BY DEFAULT', + }), + 'postgres' + ) + ).toContain('GENERATED BY DEFAULT AS IDENTITY'); + }); + + it('appends AUTO_INCREMENT for mysql', () => { + expect( + buildColumnDef(col({ name: 'id', type: 'int', nullable: false, identity: true }), 'mysql') + ).toContain('AUTO_INCREMENT'); + }); + + it('omits identity clause when type is not int/long', () => { + expect( + buildColumnDef( + col({ name: 'name', type: 'varchar(50)', nullable: false, identity: true }), + 'mysql' + ) + ).not.toContain('AUTO_INCREMENT'); + }); +}); + +describe('diffBlueprintColumns + generateBlueprintAlterSql', () => { + const original = [ + col({ name: 'id', type: 'integer', nullable: false, primaryKey: true }), + col({ name: 'name', type: 'varchar(100)', nullable: false }), + ]; + + it('generates ADD for postgres', () => { + const draft = [...original, col({ name: 'sku', type: 'varchar(50)', nullable: true })]; + const ops = diffBlueprintColumns(original, draft, new Set()); + const sql = generateBlueprintAlterSql('products', 'postgres', ops); + expect(sql).toEqual(['ALTER TABLE products ADD COLUMN sku varchar(50);']); + }); + + it('generates DROP for postgres', () => { + const ops = diffBlueprintColumns(original, original, new Set(['name'])); + const sql = generateBlueprintAlterSql('products', 'postgres', ops); + expect(sql).toEqual(['ALTER TABLE products DROP COLUMN name;']); + }); + + it('generates MODIFY for mysql', () => { + const draft = [original[0]!, col({ name: 'name', type: 'varchar(200)', nullable: false })]; + const ops = diffBlueprintColumns(original, draft, new Set()); + const sql = generateBlueprintAlterSql('products', 'mysql', ops); + expect(sql.some((s) => s.includes('MODIFY COLUMN name varchar(200)'))).toBe(true); + }); + + it('generates DROP then ADD order', () => { + const draft = [original[0]!, col({ name: 'code', type: 'text', nullable: true })]; + const ops = diffBlueprintColumns(original, draft, new Set(['name'])); + const sql = generateBlueprintAlterSql('products', 'postgres', ops); + expect(sql[0]).toContain('DROP COLUMN name'); + expect(sql[1]).toContain('ADD COLUMN code text'); + }); + + it('emits set default when only default changes (postgres)', () => { + const draft = [ + original[0]!, + col({ name: 'name', type: 'varchar(100)', nullable: false, defaultValue: `'x'` }), + ]; + const ops = diffBlueprintColumns(original, draft, new Set()); + const sql = generateBlueprintAlterSql('products', 'postgres', ops); + expect(sql).toEqual([`ALTER TABLE products ALTER COLUMN name SET DEFAULT 'x';`]); + }); +}); + +describe('primary key alter', () => { + it('adds composite primary key', () => { + const sql = generatePkAlterSql('order_lines', 'postgres', [], ['order_id', 'line_no']); + expect(sql).toEqual([ + 'ALTER TABLE order_lines ADD PRIMARY KEY (order_id, line_no);', + ]); + }); + + it('drops then recreates when key changes', () => { + const sql = generatePkAlterSql('t', 'mysql', ['id'], ['a', 'b'], 'PRIMARY'); + expect(sql[0]).toMatch(/DROP PRIMARY KEY/i); + expect(sql[1]).toBe('ALTER TABLE t ADD PRIMARY KEY (a, b);'); + }); +}); + +describe('create / drop table', () => { + it('creates with IF NOT EXISTS and composite PK (postgres)', () => { + const sql = generateCreateTableSql( + 'order_lines', + [ + col({ name: 'order_id', type: 'integer', nullable: false }), + col({ name: 'line_no', type: 'integer', nullable: false }), + col({ name: 'qty', type: 'integer', nullable: true }), + ], + ['order_id', 'line_no'], + 'postgres' + ); + expect(sql).toHaveLength(1); + expect(sql[0]).toMatch(/^CREATE TABLE IF NOT EXISTS order_lines/); + expect(sql[0]).toContain('PRIMARY KEY (order_id, line_no)'); + expect(sql[0]).not.toMatch(/INTEGER NOT NULL PRIMARY KEY/); + }); + + it('uses OBJECT_ID guard for sqlserver create', () => { + const sql = generateCreateTableSql( + 't', + [col({ name: 'id', type: 'int', nullable: false, identity: true })], + ['id'], + 'sqlserver' + ); + expect(sql[0]).toMatch(/IF OBJECT_ID\(N't', N'U'\) IS NULL/); + expect(sql[0]).toContain('IDENTITY(1,1)'); + }); + + it('drops with IF EXISTS for postgres', () => { + expect(generateDropTableSql('products', 'postgres')).toEqual([ + 'DROP TABLE IF EXISTS products;', + ]); + }); + + it('uses dialect drop hook for oracle', () => { + const sql = generateDropTableSql('products', 'oracle'); + expect(sql[0]).toMatch(/DROP TABLE/i); + }); +}); + +describe('generateTableBlueprintSql', () => { + it('emits column alter then pk add', () => { + const sql = generateTableBlueprintSql({ + tableName: 't', + dialect: 'postgres', + columnOps: [{ kind: 'add', column: col({ name: 'b', type: 'int', nullable: false }) }], + previousPk: ['a'], + nextPk: ['a', 'b'], + }); + expect(sql.some((s) => s.includes('ADD COLUMN b'))).toBe(true); + expect(sql.some((s) => s.includes('PRIMARY KEY (a, b)'))).toBe(true); + }); +}); diff --git a/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.ts b/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.ts new file mode 100644 index 0000000..0d811a8 --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/tableBlueprintSql.ts @@ -0,0 +1,799 @@ +import { resolveDialect } from '../../lib/migration-validation'; +import type { ColumnInfo, TableSchema } from '../../lib/types'; +import type { CanonicalBase, CanonicalType } from '@foxschema/core'; + +/** Quote an identifier when it is not a plain SQL name. */ +export function quoteIdent(name: string, dialect: string): string { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return name; + const d = dialect.toLowerCase(); + if (d === 'mysql' || d === 'mariadb' || d === 'clickhouse' || d === 'tidb') { + return '`' + name.replace(/`/g, '``') + '`'; + } + if (d === 'sqlserver' || d === 'azuresql') { + return '[' + name.replace(/]/g, ']]') + ']'; + } + return '"' + name.replace(/"/g, '""') + '"'; +} + +export type BlueprintColumnOp = + | { kind: 'add'; column: ColumnInfo } + | { kind: 'modify'; name: string; previous: ColumnInfo; next: ColumnInfo } + | { kind: 'drop'; name: string }; + +function toSpec(c: ColumnInfo) { + return { + type: c.type, + nullable: c.nullable, + defaultValue: c.defaultValue, + primaryKey: c.primaryKey, + identity: c.identity, + identityGeneration: c.identityGeneration, + collation: c.collation, + }; +} + +/** + * Auto-increment / identity is only valid on integer (and long/bigint) types. + * Matches common dialect spellings: int, integer, bigint, long, smallint, serial, … + * Excludes boolean/bit and decimal with scale. + */ +export function isIntegerAutoIncrementType(type: string): boolean { + if (isBooleanBitType(type)) return false; + const t = type.trim().toLowerCase(); + if (!t) return false; + const base = t.replace(/\s*\([^)]*\)\s*$/, '').trim(); + // Integers / longs only — not decimal, numeric, boolean, or floats + return ( + /^(tiny|small|medium|big)?int\d*$/.test(base) || + base === 'integer' || + base === 'bigint' || + base === 'long' || + base === 'longint' || + base === 'int2' || + base === 'int4' || + base === 'int8' || + base === 'serial' || + base === 'bigserial' || + base === 'smallserial' + ); +} + +/** Boolean / bit family (defaults are dialect-specific literals). */ +export function isBooleanBitType(type: string): boolean { + const t = type.trim().toLowerCase(); + const base = t.replace(/\s*\([^)]*\)\s*$/, '').trim(); + if (base === 'boolean' || base === 'bool' || base === 'bit') return true; + // MySQL conventional boolean + if (base === 'tinyint' && /\(\s*1\s*\)/.test(t)) return true; + return false; +} + +export type ColumnTypeKind = + | 'integer' + | 'decimal' + | 'boolean' + | 'text' + | 'datetime' + | 'binary' + | 'other'; + +/** Classify a native type string for coloring / editors. */ +export function classifyColumnType(type: string): ColumnTypeKind { + if (isBooleanBitType(type)) return 'boolean'; + if (isIntegerAutoIncrementType(type)) return 'integer'; + const t = type.trim().toLowerCase(); + const base = t.replace(/\s*\([^)]*\)\s*$/, '').trim(); + if ( + base === 'decimal' || + base === 'numeric' || + base === 'number' || + base === 'money' || + base === 'smallmoney' || + base === 'real' || + base === 'float' || + base === 'double' || + base === 'double precision' || + base === 'float4' || + base === 'float8' + ) { + return 'decimal'; + } + if ( + base === 'date' || + base === 'time' || + base === 'timestamp' || + base === 'timestamptz' || + base === 'datetime' || + base === 'datetime2' || + base === 'smalldatetime' || + base === 'datetimeoffset' || + base.includes('timestamp') || + base.includes('time') + ) { + return 'datetime'; + } + if ( + base === 'char' || + base === 'nchar' || + base === 'varchar' || + base === 'nvarchar' || + base === 'character' || + base === 'character varying' || + base === 'text' || + base === 'ntext' || + base === 'clob' || + base === 'bpchar' || + base.endsWith('text') + ) { + return 'text'; + } + if ( + base === 'binary' || + base === 'varbinary' || + base === 'blob' || + base === 'bytea' || + base === 'image' || + base.endsWith('blob') + ) { + return 'binary'; + } + return 'other'; +} + +/** Tailwind classes for type-kind badges / row accents. */ +export function columnTypeKindClasses(kind: ColumnTypeKind): { + badge: string; + bar: string; + label: string; +} { + switch (kind) { + case 'integer': + return { + badge: 'bg-sky-950/50 text-sky-300 border-sky-500/40', + bar: 'border-sky-500', + label: 'Integer', + }; + case 'decimal': + return { + badge: 'bg-cyan-950/50 text-cyan-300 border-cyan-500/40', + bar: 'border-cyan-500', + label: 'Decimal', + }; + case 'boolean': + return { + badge: 'bg-emerald-950/50 text-emerald-300 border-emerald-500/40', + bar: 'border-emerald-500', + label: 'Boolean', + }; + case 'text': + return { + badge: 'bg-amber-950/50 text-amber-200 border-amber-500/40', + bar: 'border-amber-500', + label: 'Text', + }; + case 'datetime': + return { + badge: 'bg-violet-950/50 text-violet-300 border-violet-500/40', + bar: 'border-violet-500', + label: 'Date/time', + }; + case 'binary': + return { + badge: 'bg-rose-950/50 text-rose-300 border-rose-500/40', + bar: 'border-rose-500', + label: 'Binary', + }; + default: + return { + badge: 'bg-slate-800 text-slate-400 border-slate-600', + bar: 'border-slate-500', + label: 'Other', + }; + } +} + +export type TypeSizeShape = + | { kind: 'none' } + | { kind: 'length'; length: number | undefined } + | { kind: 'decimal'; precision: number | undefined; scale: number | undefined }; + +/** Parse length or precision/scale from a type string. */ +export function parseTypeSize(type: string): TypeSizeShape { + const kind = classifyColumnType(type); + const m = type.match(/\(\s*([^)]+)\s*\)/); + if (kind === 'text' || kind === 'binary') { + if (!m) return { kind: 'length', length: undefined }; + if (/^max$/i.test(m[1].trim())) return { kind: 'length', length: undefined }; + const n = Number(m[1].trim()); + return { kind: 'length', length: Number.isFinite(n) ? n : undefined }; + } + if (kind === 'decimal') { + if (!m) return { kind: 'decimal', precision: undefined, scale: undefined }; + const parts = m[1].split(',').map((p) => p.trim()); + const precision = parts[0] && !Number.isNaN(Number(parts[0])) ? Number(parts[0]) : undefined; + const scale = + parts[1] !== undefined && !Number.isNaN(Number(parts[1])) ? Number(parts[1]) : undefined; + return { kind: 'decimal', precision, scale }; + } + return { kind: 'none' }; +} + +/** Rebuild type string with new length or precision/scale. */ +export function applyTypeSize( + type: string, + size: { length?: number; precision?: number; scale?: number } +): string { + const base = type.replace(/\s*\([^)]*\)\s*$/, '').trim() || type.trim(); + const shape = parseTypeSize(type); + if (shape.kind === 'length') { + if (size.length === undefined || size.length <= 0) return base; + return `${base}(${Math.floor(size.length)})`; + } + if (shape.kind === 'decimal') { + const p = size.precision; + if (p === undefined || p <= 0) return base; + if (size.scale === undefined) return `${base}(${Math.floor(p)})`; + return `${base}(${Math.floor(p)},${Math.floor(size.scale)})`; + } + return type; +} + +export type BooleanDefaultOption = { value: string; label: string }; + +/** + * Dialect-appropriate default literals for boolean / bit columns. + * Empty string in UI = no DEFAULT clause. + */ +export function dialectBooleanDefaultOptions(dialectName: string): BooleanDefaultOption[] { + const d = dialectName.toLowerCase(); + if (d === 'postgres' || d === 'cockroachdb' || d === 'yugabytedb') { + return [ + { value: '', label: '(none)' }, + { value: 'TRUE', label: 'TRUE' }, + { value: 'FALSE', label: 'FALSE' }, + { value: 'NULL', label: 'NULL' }, + ]; + } + if (d === 'mysql' || d === 'mariadb' || d === 'tidb') { + return [ + { value: '', label: '(none)' }, + { value: '1', label: '1 (true)' }, + { value: '0', label: '0 (false)' }, + { value: 'TRUE', label: 'TRUE' }, + { value: 'FALSE', label: 'FALSE' }, + { value: 'NULL', label: 'NULL' }, + ]; + } + if (d === 'sqlserver' || d === 'azuresql' || d === 'sqlite') { + return [ + { value: '', label: '(none)' }, + { value: '1', label: '1' }, + { value: '0', label: '0' }, + { value: 'NULL', label: 'NULL' }, + ]; + } + if (d === 'oracle' || d === 'db2') { + // Often NUMBER(1) / SMALLINT stand-ins; still offer 1/0 and NULL + return [ + { value: '', label: '(none)' }, + { value: '1', label: '1' }, + { value: '0', label: '0' }, + { value: 'NULL', label: 'NULL' }, + ]; + } + return [ + { value: '', label: '(none)' }, + { value: 'TRUE', label: 'TRUE' }, + { value: 'FALSE', label: 'FALSE' }, + { value: '1', label: '1' }, + { value: '0', label: '0' }, + { value: 'NULL', label: 'NULL' }, + ]; +} + +/** Column-level constraints this dialect can emit in CREATE / ADD. */ +export function dialectColumnConstraints(dialectName: string): { + notNull: boolean; + unique: boolean; + hint: string; +} { + const d = dialectName.toLowerCase(); + if (d === 'clickhouse') { + return { + notNull: true, + unique: false, + hint: 'ClickHouse: nullability is type-level (Nullable); no column UNIQUE.', + }; + } + return { + notNull: true, + unique: true, + hint: 'NOT NULL and UNIQUE are supported as column constraints.', + }; +} + +/** Apply identity only when the column type allows it. */ +export function withAutoIncrement( + column: ColumnInfo, + enabled: boolean +): ColumnInfo { + if (!enabled) { + return { ...column, identity: false, identityGeneration: undefined }; + } + if (!isIntegerAutoIncrementType(column.type)) { + return { ...column, identity: false, identityGeneration: undefined }; + } + return { + ...column, + identity: true, + nullable: false, + identityGeneration: column.identityGeneration ?? 'ALWAYS', + }; +} + +/** Dialect-specific identity / auto-increment UI + clause preview. */ +export type IdentitySupport = { + supported: boolean; + /** Short label for the checkbox */ + label: string; + /** GENERATED … options (Postgres / Oracle / DB2 / Yugabyte / Cockroach) */ + generations?: Array<'ALWAYS' | 'BY DEFAULT'>; + hint: string; + /** Clause fragment shown next to the control */ + clauseHint: string; +}; + +export function dialectIdentitySupport(dialectName: string): IdentitySupport { + const d = dialectName.toLowerCase(); + if (d === 'mysql' || d === 'mariadb' || d === 'tidb') { + return { + supported: true, + label: 'Auto increment', + clauseHint: 'AUTO_INCREMENT', + hint: 'Only for int / integer / bigint / long types; usually also the primary key.', + }; + } + if (d === 'sqlserver' || d === 'azuresql') { + return { + supported: true, + label: 'Auto increment', + clauseHint: 'IDENTITY(1,1)', + hint: 'Only for int / bigint / long. SQL Server IDENTITY(1,1).', + }; + } + if (d === 'redshift') { + return { + supported: true, + label: 'Auto increment', + clauseHint: 'IDENTITY(0,1)', + hint: 'Redshift identity column.', + }; + } + if ( + d === 'postgres' || + d === 'cockroachdb' || + d === 'yugabytedb' || + d === 'oracle' || + d === 'db2' + ) { + return { + supported: true, + label: 'Auto increment', + clauseHint: 'GENERATED … AS IDENTITY', + generations: ['ALWAYS', 'BY DEFAULT'], + hint: 'Only for int / bigint / long. GENERATED ALWAYS or BY DEFAULT AS IDENTITY.', + }; + } + if (d === 'sqlite') { + return { + supported: true, + label: 'Auto increment', + clauseHint: 'AUTOINCREMENT', + hint: 'Only for INTEGER. SQLite PRIMARY KEY AUTOINCREMENT.', + }; + } + return { + supported: true, + label: 'Auto increment', + clauseHint: resolveDialect(dialectName).identityClause({ type: 'integer', nullable: false, identity: true }) || 'identity', + hint: 'Toggles the dialect identity clause in generated SQL.', + }; +} + +/** Canonical templates rendered through each dialect's `renderType` for the dropdown. */ +const TYPE_TEMPLATES: CanonicalType[] = [ + { base: 'boolean', raw: 'boolean' }, + { base: 'smallint', raw: 'smallint' }, + { base: 'integer', raw: 'integer' }, + { base: 'bigint', raw: 'bigint' }, + { base: 'decimal', raw: 'decimal', precision: 10, scale: 2 }, + { base: 'decimal', raw: 'decimal', precision: 18, scale: 0 }, + { base: 'real', raw: 'real' }, + { base: 'double', raw: 'double' }, + { base: 'char', raw: 'char', length: 1 }, + { base: 'varchar', raw: 'varchar', length: 50 }, + { base: 'varchar', raw: 'varchar', length: 255 }, + { base: 'varchar', raw: 'varchar', length: 1000 }, + { base: 'text', raw: 'text' }, + { base: 'binary', raw: 'binary', length: 16 }, + { base: 'varbinary', raw: 'varbinary', length: 255 }, + { base: 'blob', raw: 'blob' }, + { base: 'date', raw: 'date' }, + { base: 'time', raw: 'time' }, + { base: 'timestamp', raw: 'timestamp' }, + { base: 'timestamptz', raw: 'timestamptz' }, + { base: 'uuid', raw: 'uuid' }, + { base: 'json', raw: 'json' }, + { base: 'xml', raw: 'xml' }, +]; + +const TYPE_GROUP: Partial> = { + boolean: 'Numeric / boolean', + smallint: 'Numeric / boolean', + integer: 'Numeric / boolean', + bigint: 'Numeric / boolean', + decimal: 'Numeric / boolean', + real: 'Numeric / boolean', + double: 'Numeric / boolean', + char: 'Text', + varchar: 'Text', + text: 'Text', + binary: 'Binary', + varbinary: 'Binary', + blob: 'Binary', + date: 'Date / time', + time: 'Date / time', + timestamp: 'Date / time', + timestamptz: 'Date / time', + uuid: 'Other', + json: 'Other', + xml: 'Other', +}; + +export type DialectDataTypeOption = { + value: string; + label: string; + group: string; + kind: ColumnTypeKind; + /** True when this type can take auto-increment. */ + integerLike: boolean; +}; + +/** + * Dialect-native data types for blueprint dropdowns, derived from each dialect's + * `renderType` map (so Postgres gets `integer`/`varchar`, MySQL `int`/`varchar(255)`, etc.). + */ +export function listDialectDataTypes(dialectName: string): DialectDataTypeOption[] { + const dialect = resolveDialect(dialectName); + const seen = new Set(); + const out: DialectDataTypeOption[] = []; + + for (const tmpl of TYPE_TEMPLATES) { + const rendered = dialect.renderType(tmpl); + const value = (rendered.sql || '').trim(); + if (!value) continue; + // Skip "left as-is" unknowns that didn't map. + if (rendered.warning && /no .+ equivalent/i.test(rendered.warning) && value === tmpl.raw) { + continue; + } + const key = value.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push({ + value, + label: value, + group: TYPE_GROUP[tmpl.base] ?? 'Other', + kind: classifyColumnType(value), + integerLike: isIntegerAutoIncrementType(value), + }); + } + + // Prefer integer-like types first within each group for create UX. + const groupOrder = ['Numeric / boolean', 'Text', 'Binary', 'Date / time', 'Other']; + out.sort((a, b) => { + const gi = groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group); + if (gi !== 0) return gi; + return a.label.localeCompare(b.label); + }); + return out; +} + +/** Default column type for a new column in this dialect (varchar(255) or closest). */ +export function defaultDialectColumnType(dialectName: string): string { + const types = listDialectDataTypes(dialectName); + const varchar = + types.find((t) => /^varchar\(255\)$/i.test(t.value)) || + types.find((t) => /^varchar/i.test(t.value)) || + types.find((t) => t.group === 'Text'); + return varchar?.value ?? 'varchar(255)'; +} + +/** Build `name type [identity] [COLLATE …] [DEFAULT …] [NOT NULL] [UNIQUE]` for ADD / CREATE. */ +export function buildColumnDef( + column: ColumnInfo & { unique?: boolean }, + dialectName: string +): string { + const dialect = resolveDialect(dialectName); + const name = quoteIdent(column.name, dialectName); + const d = dialectName.toLowerCase(); + const identityOk = !!column.identity && isIntegerAutoIncrementType(column.type); + const spec = { ...toSpec(column), identity: identityOk }; + let typeSql: string; + if (dialect.nullableTypeWrapper) { + typeSql = dialect.nullableTypeWrapper(column.type, column.nullable); + } else if (d === 'sqlite' && identityOk) { + typeSql = column.type; + } else { + typeSql = column.type + dialect.identityClause(spec); + } + let def = `${name} ${typeSql}`; + if (column.collation) { + def += dialect.columnCollateClause?.(column.collation) ?? ` COLLATE ${column.collation}`; + } + if (!dialect.nullableTypeWrapper) { + if (column.defaultValue) { + const defVal = column.defaultValue.trim().toUpperCase() === 'NULL' ? 'NULL' : column.defaultValue; + def += ` DEFAULT ${defVal}`; + } + if (!column.nullable) def += ` NOT NULL`; + } + if (column.unique && dialectColumnConstraints(dialectName).unique) { + def += ` UNIQUE`; + } + if (d === 'sqlite' && identityOk) { + def += ' AUTOINCREMENT'; + } + return def; +} + +function columnCoreChanged(a: ColumnInfo, b: ColumnInfo): boolean { + return ( + a.type !== b.type || + a.nullable !== b.nullable || + !!a.identity !== !!b.identity || + (a.identityGeneration ?? '') !== (b.identityGeneration ?? '') || + (a.collation ?? '') !== (b.collation ?? '') + ); +} + +function defaultChanged(a: ColumnInfo, b: ColumnInfo): boolean { + return (a.defaultValue ?? '') !== (b.defaultValue ?? ''); +} + +export function pkColumnsFromTable(table: TableSchema): string[] { + if (table.primaryKey?.columns?.length) return [...table.primaryKey.columns]; + return (table.columns ?? []).filter((c) => c.primaryKey).map((c) => c.name); +} + +export function sameStringList(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +/** + * DROP + ADD PRIMARY KEY when the column set (or order) changes. + * Supports single and composite keys. + */ +export function generatePkAlterSql( + tableName: string, + dialectName: string, + previous: string[], + next: string[], + pkName?: string +): string[] { + if (sameStringList(previous, next)) return []; + const dialect = resolveDialect(dialectName); + const qTable = quoteIdent(tableName, dialectName); + const stmts: string[] = []; + if (previous.length > 0) { + const qPk = + pkName && pkName.toUpperCase() !== 'PRIMARY' + ? quoteIdent(pkName, dialectName) + : pkName; + 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});`); + } + return stmts; +} + +/** + * Generate same-dialect ALTER statements for pending blueprint column ops. + * Order: drops, modifies, adds. + */ +export function generateBlueprintAlterSql( + tableName: string, + dialectName: string, + ops: BlueprintColumnOp[] +): string[] { + if (ops.length === 0) return []; + const dialect = resolveDialect(dialectName); + const qTable = quoteIdent(tableName, dialectName); + const stmts: string[] = []; + + for (const op of ops) { + if (op.kind !== 'drop') continue; + stmts.push(dialect.dropColumnStatement(qTable, quoteIdent(op.name, dialectName))); + } + + for (const op of ops) { + if (op.kind !== 'modify') continue; + const qName = quoteIdent(op.name, dialectName); + const core = columnCoreChanged(op.previous, op.next); + const def = defaultChanged(op.previous, op.next); + if (core) { + stmts.push( + ...dialect.modifyColumnStatements(qTable, qName, toSpec(op.next), op.previous.nullable) + ); + if (op.next.defaultValue) { + stmts.push(...dialect.setDefaultStatements(qTable, qName, op.next.defaultValue)); + } else if (op.previous.defaultValue) { + stmts.push(...dialect.setDefaultStatements(qTable, qName, undefined)); + } + } else if (def) { + stmts.push(...dialect.setDefaultStatements(qTable, qName, op.next.defaultValue)); + } + } + + for (const op of ops) { + if (op.kind !== 'add') continue; + stmts.push(dialect.addColumnStatement(qTable, buildColumnDef(op.column, dialectName))); + } + + return stmts; +} + +/** Diff original columns vs draft to produce pending ops (excludes unchanged). */ +export function diffBlueprintColumns( + original: ColumnInfo[], + draft: ColumnInfo[], + droppedNames: Set +): BlueprintColumnOp[] { + const ops: BlueprintColumnOp[] = []; + const origByName = new Map(original.map((c) => [c.name, c])); + const draftNames = new Set(draft.map((c) => c.name)); + + for (const name of droppedNames) { + if (origByName.has(name)) ops.push({ kind: 'drop', name }); + } + + for (const col of draft) { + const prev = origByName.get(col.name); + if (!prev) { + if (!droppedNames.has(col.name)) ops.push({ kind: 'add', column: col }); + continue; + } + if (droppedNames.has(col.name)) continue; + if (columnCoreChanged(prev, col) || defaultChanged(prev, col)) { + ops.push({ kind: 'modify', name: col.name, previous: prev, next: col }); + } + } + + for (const prev of original) { + if (!draftNames.has(prev.name) && !droppedNames.has(prev.name)) { + ops.push({ kind: 'drop', name: prev.name }); + } + } + + return ops; +} + +function createTableBody( + columns: ColumnInfo[], + pkColumns: string[], + dialectName: string, + pkName?: string +): string { + const lines = columns.map((c) => ` ${buildColumnDef(c, dialectName)}`); + if (pkColumns.length > 0) { + const cols = pkColumns.map((c) => quoteIdent(c, dialectName)).join(', '); + const constraint = + pkName && pkName.toUpperCase() !== 'PRIMARY' + ? `CONSTRAINT ${quoteIdent(pkName, dialectName)} ` + : ''; + lines.push(` ${constraint}PRIMARY KEY (${cols})`); + } + return `(\n${lines.join(',\n')}\n)`; +} + +/** + * CREATE TABLE with existence guard (IF NOT EXISTS or dialect equivalent). + */ +export function generateCreateTableSql( + tableName: string, + columns: ColumnInfo[], + pkColumns: string[], + dialectName: string, + pkName?: string +): string[] { + const name = tableName.trim(); + if (!name || columns.length === 0) return []; + const qTable = quoteIdent(name, dialectName); + const body = createTableBody(columns, pkColumns, dialectName, pkName); + const d = dialectName.toLowerCase(); + + if (d === 'sqlserver' || d === 'azuresql') { + const escaped = name.replace(/'/g, "''"); + return [ + `IF OBJECT_ID(N'${escaped}', N'U') IS NULL\nCREATE TABLE ${qTable} ${body};`, + ]; + } + 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};`]; + } + 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};`, + ]; + } + return [`CREATE TABLE IF NOT EXISTS ${qTable} ${body};`]; +} + +/** + * DROP TABLE with existence guard via dialect hook when available. + */ +export function generateDropTableSql(tableName: string, dialectName: string): string[] { + const name = tableName.trim(); + if (!name) return []; + const dialect = resolveDialect(dialectName); + const qTable = quoteIdent(name, dialectName); + if (dialect.dropTableStatement) { + return [dialect.dropTableStatement(qTable)]; + } + return [`DROP TABLE IF EXISTS ${qTable};`]; +} + +/** Combine column ALTERs + PK change for an existing table. */ +export function generateTableBlueprintSql(args: { + tableName: string; + dialect: string; + columnOps: BlueprintColumnOp[]; + previousPk: string[]; + nextPk: string[]; + pkName?: string; +}): string[] { + const colStmts = generateBlueprintAlterSql(args.tableName, args.dialect, args.columnOps); + const pkStmts = generatePkAlterSql( + args.tableName, + args.dialect, + args.previousPk, + args.nextPk, + args.pkName + ); + // PK drop before column drops that remove PK cols; PK add after column adds. + // Practical order: column drops that aren't PK-only → drop PK → column mods/adds → add PK + // Simpler stable order used here: column ops first, then PK (matches common ALTER scripts). + // When dropping a PK column, user should drop PK first — we emit PK alter after columns + // only when nextPk is non-empty or previous changed; if nextPk empty and previous had PK, + // put drop PK before column drops. + if (args.previousPk.length > 0 && args.nextPk.length === 0) { + return [...pkStmts, ...colStmts]; + } + if ( + args.previousPk.length > 0 && + !sameStringList(args.previousPk, args.nextPk) && + args.columnOps.some((o) => o.kind === 'drop' && args.previousPk.includes(o.name)) + ) { + const dropPk = generatePkAlterSql( + args.tableName, + args.dialect, + args.previousPk, + [], + args.pkName + ); + const addPk = + args.nextPk.length > 0 + ? generatePkAlterSql(args.tableName, args.dialect, [], args.nextPk, args.pkName) + : []; + return [...dropPk, ...colStmts, ...addPk]; + } + return [...colStmts, ...pkStmts]; +} From bd614e1605ff7ede07410c4217f795f3fb21ba66 Mon Sep 17 00:00:00 2001 From: huyplb Date: Sun, 26 Jul 2026 17:44:13 -0600 Subject: [PATCH 3/8] Cursor: Apply local changes for cloud agent --- apps/cli/src/format/diffPresentation.ts | 2 +- apps/web/src/backend/api/routes.ts | 6 +- .../frontend/components/ObjectDetailPanel.tsx | 2 +- .../sql-editor/SchemaAutocomplete.tsx | 174 +++ .../sql-editor/SqlSchemaExplorer.tsx | 44 +- .../sql-editor/TableBlueprintModal.tsx | 1035 +++++++++++++++-- .../sql-editor/tableBlueprintSql.test.ts | 221 ++++ .../sql-editor/tableBlueprintSql.ts | 550 ++++++++- .../src/frontend/store/useSqlEditorStore.ts | 21 +- foxflow.sqlite | Bin 0 -> 344064 bytes packages/core/src/browser.ts | 6 + .../core/src/cores/schema-to-tables.test.ts | 142 +++ packages/core/src/cores/schema-to-tables.ts | 92 +- packages/core/src/index.ts | 10 + .../core/src/interfaces/schema.interface.ts | 14 +- .../core/src/modules/sql-generator.module.ts | 3 +- .../providers/azureSql/azuresql.provider.ts | 36 +- .../core/src/providers/db2/db2.interface.ts | 9 +- .../core/src/providers/db2/db2.provider.ts | 38 +- .../src/providers/duckDb/duckdb.provider.ts | 1 + .../src/providers/mysql/mysql.provider.ts | 35 +- .../src/providers/oracle/oracle.provider.ts | 11 +- .../providers/postgres/postgres.provider.ts | 37 +- .../providers/redshift/redshift.provider.ts | 49 +- .../src/providers/sqlLite/sqlLite.provider.ts | 16 +- .../providers/sqlServer/sqlserver.provider.ts | 38 +- 26 files changed, 2399 insertions(+), 193 deletions(-) create mode 100644 apps/web/src/frontend/components/sql-editor/SchemaAutocomplete.tsx create mode 100644 foxflow.sqlite create mode 100644 packages/core/src/cores/schema-to-tables.test.ts diff --git a/apps/cli/src/format/diffPresentation.ts b/apps/cli/src/format/diffPresentation.ts index 0368b78..0f53f51 100644 --- a/apps/cli/src/format/diffPresentation.ts +++ b/apps/cli/src/format/diffPresentation.ts @@ -78,7 +78,7 @@ export function describeIndex(i: IndexDiff): string { export function describeFk(f: ForeignKeyDiff): string { const side = f.target ?? f.source; - return side ? `(${side.columns.join(', ')}) → ${side.referencedTable}(${side.referencedColumns.join(', ')})` : ''; + return side ? `(${side.columns.join(', ')}) → ${side.referencedTable}(${(side.referencedColumns ?? []).join(', ')})` : ''; } export function describeTrigger(t: TriggerDiff): string { diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index f4032f9..4da1dd5 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -9,6 +9,7 @@ import { SqlGeneratorModule, DriverDetector, buildConnectionString, + normalizeTableSchemas, type MigrationStep, type ConnectionOptions, type DbObjectType, @@ -170,7 +171,8 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt } const scoped = scope?.length ? tables.filter((t) => scope.includes(t.objectType)) : tables; - return { tables: scoped, warnings }; + // Upgrade path: older providers / cached shapes may omit FK referencedColumns. + return { tables: normalizeTableSchemas(scoped), warnings }; } router.get('/health', (_req: Request, res: Response) => { @@ -532,7 +534,7 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt // 1. Snapshot the target schema DDL before touching anything const provider = connectionModule.getProvider(dialect); if (provider.getTables) { - const targetObjects = await provider.getTables(option, schema); + const targetObjects = normalizeTableSchemas(await provider.getTables(option, schema)); let snapshot = `-- =========================================================================\n`; snapshot += `-- Target schema snapshot (pre-migration)\n`; snapshot += `-- Schema: ${schema} | Taken At: ${new Date().toISOString()}\n`; diff --git a/apps/web/src/frontend/components/ObjectDetailPanel.tsx b/apps/web/src/frontend/components/ObjectDetailPanel.tsx index 190ed3b..5b87690 100644 --- a/apps/web/src/frontend/components/ObjectDetailPanel.tsx +++ b/apps/web/src/frontend/components/ObjectDetailPanel.tsx @@ -1125,7 +1125,7 @@ export const ObjectDetailPanel: React.FC = () => { {highlightMatch(fk.name, query)} {info?.columns.join(', ')} - {info?.referencedTable} ({info?.referencedColumns.join(', ')}) + {info?.referencedTable} ({(info?.referencedColumns ?? []).join(', ')}) {opBadge} ); diff --git a/apps/web/src/frontend/components/sql-editor/SchemaAutocomplete.tsx b/apps/web/src/frontend/components/sql-editor/SchemaAutocomplete.tsx new file mode 100644 index 0000000..698a5a9 --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/SchemaAutocomplete.tsx @@ -0,0 +1,174 @@ +import React, { useEffect, useId, useMemo, useRef, useState } from 'react'; + +export type AutocompleteOption = { + value: string; + /** Shown instead of value when present. */ + label?: string; + hint?: string; +}; + +type Props = { + value: string; + onChange: (value: string) => void; + options: AutocompleteOption[]; + placeholder?: string; + disabled?: boolean; + className?: string; + 'data-testid'?: string; +}; + +function filterOptions( + options: AutocompleteOption[], + query: string +): AutocompleteOption[] { + const q = query.trim().toLowerCase(); + if (!q) return options.slice(0, 60); + const scored: { o: AutocompleteOption; score: number }[] = []; + for (const o of options) { + const v = o.value.toLowerCase(); + const l = (o.label ?? o.value).toLowerCase(); + let score = 0; + if (v === q) score = 100; + else if (v.startsWith(q)) score = 80; + else if (v.includes(q)) score = 50; + else if (l.includes(q)) score = 30; + else continue; + scored.push({ o, score }); + } + scored.sort( + (a, b) => b.score - a.score || a.o.value.localeCompare(b.o.value) + ); + return scored.slice(0, 60).map((x) => x.o); +} + +/** Typeahead over schema names (tables / columns) with keyboard support. */ +export const SchemaAutocomplete: React.FC = ({ + value, + onChange, + options, + placeholder, + disabled, + className, + 'data-testid': testId, +}) => { + const [open, setOpen] = useState(false); + const [highlight, setHighlight] = useState(0); + const wrapRef = useRef(null); + const listId = useId(); + const filtered = useMemo(() => filterOptions(options, value), [options, value]); + + useEffect(() => { + setHighlight(0); + }, [value, open]); + + useEffect(() => { + const onDoc = (e: MouseEvent) => { + if (!wrapRef.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, []); + + const pick = (v: string) => { + onChange(v); + setOpen(false); + }; + + const resolveExact = () => { + const trimmed = value.trim(); + if (!trimmed) return; + const exact = options.find( + (o) => o.value.toLowerCase() === trimmed.toLowerCase() + ); + if (exact && exact.value !== value) onChange(exact.value); + }; + + return ( +
        + { + if (!disabled) setOpen(true); + }} + onBlur={() => { + window.setTimeout(() => { + resolveExact(); + setOpen(false); + }, 140); + }} + onChange={(e) => { + onChange(e.target.value); + setOpen(true); + }} + onKeyDown={(e) => { + if (disabled) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setOpen(true); + setHighlight((h) => Math.min(h + 1, Math.max(filtered.length - 1, 0))); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlight((h) => Math.max(h - 1, 0)); + } else if (e.key === 'Enter' && open && filtered[highlight]) { + e.preventDefault(); + pick(filtered[highlight]!.value); + } else if (e.key === 'Escape') { + setOpen(false); + } + }} + className={ + className ?? + 'w-full bg-white/5 border border-violet-400/35 rounded-lg px-2.5 py-1.5 text-[12px] font-mono text-slate-100 outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-400/20 disabled:opacity-50 placeholder:text-slate-500' + } + /> + {open && !disabled && filtered.length > 0 && ( +
          + {filtered.map((o, i) => { + const active = i === highlight; + return ( +
        • + +
        • + ); + })} +
        + )} + {open && !disabled && value.trim() && filtered.length === 0 && ( +
        + No matches +
        + )} +
        + ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx index e4e0ac4..ad04550 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx @@ -18,6 +18,25 @@ const EXPLORER_GROUPS: { type: DbObjectType; title: string }[] = [ { type: 'FUNCTION', title: 'Functions' }, ]; +const EXPLORER_CONN_KEY = 'foxschema-sql-schema-explorer-connection'; + +function readStoredExplorerId(): string { + try { + return localStorage.getItem(EXPLORER_CONN_KEY)?.trim() || ''; + } catch { + return ''; + } +} + +function writeStoredExplorerId(id: string): void { + try { + if (id) localStorage.setItem(EXPLORER_CONN_KEY, id); + else localStorage.removeItem(EXPLORER_CONN_KEY); + } catch { + /* ignore quota / private mode */ + } +} + /** * Slim schema tree for the SQL Editor. Categorized TABLE / VIEW / MQT / * PROCEDURE / FUNCTION — click a name to insert at the Monaco cursor. @@ -36,7 +55,7 @@ export const SqlSchemaExplorer: React.FC = () => { (id) => connections.some((c) => c.id === id) ); - const [explorerId, setExplorerId] = useState(''); + const [explorerId, setExplorerId] = useState(() => readStoredExplorerId()); const [expandedObj, setExpandedObj] = useState>({}); const [expandedGroup, setExpandedGroup] = useState>({ TABLE: true, @@ -48,10 +67,25 @@ export const SqlSchemaExplorer: React.FC = () => { const [blueprintTable, setBlueprintTable] = useState(null); const [blueprintMode, setBlueprintMode] = useState('edit'); + const selectExplorerId = (id: string) => { + setExplorerId(id); + writeStoredExplorerId(id); + }; + useEffect(() => { - if (explorerId && connections.some((c) => c.id === explorerId)) return; - const next = preferredIds[0] ?? connections[0]?.id ?? ''; - setExplorerId(next); + if (explorerId && connections.some((c) => c.id === explorerId)) { + // Keep a valid selection in storage (e.g. after first hydrate). + writeStoredExplorerId(explorerId); + return; + } + const stored = readStoredExplorerId(); + const next = + (stored && connections.some((c) => c.id === stored) ? stored : '') || + preferredIds[0] || + connections[0]?.id || + ''; + if (next !== explorerId) setExplorerId(next); + if (next) writeStoredExplorerId(next); }, [connections, preferredIds, explorerId]); useEffect(() => { @@ -90,7 +124,7 @@ export const SqlSchemaExplorer: React.FC = () => {
        = ({ onChange={(e) => setCreateName(e.target.value)} placeholder="new_table_name" data-testid="blueprint-table-name" - className="w-full bg-transparent border-b border-slate-700 focus:border-cyan-500 outline-none text-slate-100 font-bold text-base font-mono py-0.5" + className="w-full bg-transparent border-b border-cyan-400/35 focus:border-cyan-300 outline-none text-slate-50 font-bold text-base font-mono py-0.5" /> ) : ( -

        +

        {liveTable?.name}

        )} -

        +

        {mode === 'create' ? 'Create table' : 'Table blueprint'} · {dialect}

        {toast && ( - + {toast} )} @@ -387,20 +668,20 @@ export const TableBlueprintModal: React.FC = ({ type="button" title="Close" onClick={onClose} - className="p-1.5 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800 transition" + className="p-1.5 rounded-lg text-slate-400 hover:text-slate-100 hover:bg-white/10 transition" > -
        +
        {/* Columns */}
        -

        - +

        + Columns - ({draft.length}) + ({draft.length})

        {!adding && ( )}

        -
        +
        {draft.map((col) => { const isDropped = dropped.has(col.name); const isEditing = editingName === col.name; @@ -545,15 +826,15 @@ export const TableBlueprintModal: React.FC = ({ {/* Primary key (composite) */}
        -

        +

        Primary key {pkDirty && ( - changed + changed )}

        -
        -

        +

        +

        Check columns to build a single or composite key. Order matters — use arrows to reorder.

        @@ -610,118 +891,644 @@ export const TableBlueprintModal: React.FC = ({
      )} {draftPk.length > 0 && ( -

      +

      PRIMARY KEY ({draftPk.join(', ')})

      )} - {/* FK / triggers — edit mode only */} - {mode === 'edit' && ( - <> -
      -

      - - Foreign keys - ({fks.length}) -

      -
      - {fks.length === 0 ? ( -

      No foreign keys

      - ) : ( -
        - {fks.map((fk) => ( -
      • - {fk.name} - · - - ({fk.columns.join(', ')}) → {fk.referencedTable}( - {fk.referencedColumns.join(', ')}) + {/* Foreign keys */} +
        +
        +

        + + Foreign keys + + ({existingFks.length + pendingFks.length}) + +

        + {!addingFk && (fkSupport.alterAdd || fkSupport.createInline) && ( + + )} +
        +
        + {existingFks.length === 0 && pendingFks.length === 0 && !addingFk ? ( +

        + {fkSupport.createInline || fkSupport.alterAdd + ? 'No foreign keys — link one or more columns to another table’s key.' + : fkSupport.hint} +

        + ) : ( +
          + {existingFks.map((fk: ForeignKeyInfo) => ( +
        • +
          + {fk.name} + · + + ({fk.columns.join(', ')}) → {fk.referencedTable}( + {(fk.referencedColumns ?? []).join(', ')}) + +
          + {mode === 'edit' && ( + + )} +
        • + ))} + {[...droppedFkNames].map((n) => ( +
        • + {n} + drop + +
        • + ))} + {pendingFks.map((fk, i) => ( +
        • +
          + + new + + {fk.name} + · + + ({fk.columns.join(', ')}) → {fk.referencedTable}( + {(fk.referencedColumns ?? []).join(', ')}) + +
          + +
        • + ))} +
        + )} + {addingFk && ( +
        +

        + Select one or more local columns and matching parent columns (order + matters for composite keys).{' '} + {fkSupport.hint} +

        +
        + +
        + + Local column{fkMulti ? 's' : ''} + + {activeColumns.length === 0 ? ( +

        Add columns first

        + ) : ( +
          + {activeColumns.map((c) => { + const on = fkForm.columns.includes(c.name); + const idx = fkForm.columns.indexOf(c.name); + return ( +
        • + + {on && fkMulti && ( + + + {idx + 1} + + + + + )} +
        • + ); + })} +
        + )} +
        +
        + + References table + + { + const t = findTableByName(refTableOptions, name); + const referencedColumns = syncFkRefColumns( + fkForm.columns, + t + ); + setFkForm({ + ...fkForm, + referencedTable: t?.name ?? name.trim().replace(/^.*\./, ''), + referencedColumns, + }); + }} + /> + {fkForm.referencedTable.trim() && ( + <> + + References column{fkMulti ? 's' : ''} -
      • - ))} -
      - )} + {fkRefColumns.length === 0 ? ( +

      + No columns cached for this table +

      + ) : ( +
        + {fkRefColumns.map((c) => { + const on = fkForm.referencedColumns.includes(c.name); + const idx = fkForm.referencedColumns.indexOf(c.name); + return ( +
      • + + {on && fkMulti && ( + + + {idx + 1} + + + + + )} +
      • + ); + })} +
      + )} + + )} +
      + {fkActions.length > 0 && ( + <> + + + + )} + +
      + + +
      -
      - -
      -

      - - Triggers - ({triggers.length}) -

      -
      - {triggers.length === 0 ? ( -

      No triggers

      - ) : ( -
        - {triggers.map((trg) => { - const open = !!expandedTrig[trg.name]; - return ( -
      • + )} +
      +
      + + {/* Triggers */} +
      +
      +

      + + Triggers + + ({existingTriggers.length + pendingTriggers.length}) + +

      + {!addingTrigger && ( + + )} +
      +
      + {existingTriggers.length === 0 && + pendingTriggers.length === 0 && + !addingTrigger ? ( +

      + No triggers — run logic before/after INSERT, UPDATE, or DELETE. +

      + ) : ( +
        + {existingTriggers.map((trg: TriggerInfo) => { + const open = !!expandedTrig[trg.name]; + return ( +
      • +
        + + {mode === 'edit' && ( - {open && ( -
        -                                  {trg.definition?.trim() || '(no definition)'}
        -                                
        - )} -
      • - ); - })} -
      - )} + )} +
      + {open && ( +
      +                              {trg.definition?.trim() || '(no definition)'}
      +                            
      + )} + + ); + })} + {[...droppedTriggerNames].map((n) => ( +
    • + {n} + drop + +
    • + ))} + {pendingTriggers.map((trg, i) => ( +
    • +
      + + new + + {trg.name} + + {trg.timing} {trg.event} + +
      + +
    • + ))} +
    + )} + {addingTrigger && ( +
    +

    {triggerMeta.hint}

    +
    + + + +
    +