From ae74bcf3ecec0c35dedd6aa8a1e9ee32138ce944 Mon Sep 17 00:00:00 2001 From: huyplb Date: Sun, 8 Nov 2026 14:47:05 -0600 Subject: [PATCH 1/4] feat(utilities): Rename Column changes a type in place, on every SQL engine A type change is now one in-place ALTER by default; the add / copy / switch-over plan becomes an opt-in Large table mode on the eight engines that have it. A size warning from the catalog estimate points at it from about a million rows or 1 GB. A type change alone lists routines as one-line warnings; the line-by-line references are for a rename. - PostgreSQL family: ALTER ... TYPE, with USING only across kinds (CockroachDB refused to rewrite an indexed column for an explicit USING). - MySQL family: MODIFY restating NOT NULL, default, collation and the comment read from the catalog. - SQL Server: one transaction that scripts the column's indexes from sys.indexes at run time (filters, INCLUDE, DESC kept, INCLUDE-only and filter-only indexes found) and puts them back; large-table mode moves filtered indexes at the switch-over, since sp_rename refuses a column a filter names. - New: Oracle, Db2 (+ REORG), SQLite (rename only), DuckDB (refused while the table has an index), Redshift (VARCHAR lengthening), ClickHouse (Nullable kept, sorting key refused). A dialect can refuse a change with its reason before any SQL is shown. Run live on PostgreSQL, MySQL, MariaDB, TiDB, SQL Server, CockroachDB, Oracle, Db2, SQLite, DuckDB and ClickHouse. Feature map refreshed (163 routes, counts re-measured). Co-Authored-By: Claude Opus 5.5 --- apps/e2e/src/generated/test-ids.ts | 5 +- .../components/ColumnChangePanel.test.tsx | 106 +++- .../components/ColumnChangePanel.tsx | 115 ++++- .../src/frontend/shared/lib/column-change.ts | 3 +- docs/FEATURE_MAP.md | 6 +- docs/feature-map.html | 26 +- docs/feature-map/app.js | 2 +- docs/feature-map/data.js | 12 +- docs/feature-map/template.html | 10 +- docs/plans/2026-11-01-safe-column-change.md | 112 +++++ docs/testing/TEST_IDS.md | 5 +- docs/user-guide.md | 43 +- packages/sql/src/index.ts | 3 +- .../utilities/column-change.registry.ts | 19 + .../modules/utilities/column-change.test.ts | 455 ++++++++++++++++-- .../src/modules/utilities/column-change.ts | 152 +++--- .../modules/utilities/column-change.types.ts | 59 ++- .../modules/utilities/column-type-shape.ts | 68 +++ packages/sql/src/providers/DIALECTS.md | 5 + .../clickHouse/clickhouse.column-change.ts | 74 +++ .../cockroachDb/cockroachdb.column-change.ts | 10 +- .../src/providers/db2/db2.column-change.ts | 53 ++ .../providers/duckDb/duckdb.column-change.ts | 57 +++ .../providers/mysql/mysql.column-change.ts | 36 +- .../providers/oracle/oracle.column-change.ts | 48 ++ .../postgres/postgres.column-change.ts | 42 +- .../redshift/redshift.column-change.ts | 65 +++ .../providers/sqlLite/sqlite.column-change.ts | 39 ++ .../sqlServer/sqlserver.column-change.ts | 206 ++++++-- packages/ui-shared/src/index.ts | 3 +- 30 files changed, 1611 insertions(+), 228 deletions(-) create mode 100644 packages/sql/src/modules/utilities/column-type-shape.ts create mode 100644 packages/sql/src/providers/clickHouse/clickhouse.column-change.ts create mode 100644 packages/sql/src/providers/db2/db2.column-change.ts create mode 100644 packages/sql/src/providers/duckDb/duckdb.column-change.ts create mode 100644 packages/sql/src/providers/oracle/oracle.column-change.ts create mode 100644 packages/sql/src/providers/redshift/redshift.column-change.ts create mode 100644 packages/sql/src/providers/sqlLite/sqlite.column-change.ts diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 0ff3bad1d..75e8a6525 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -2997,6 +2997,8 @@ export const TestIds = { columnChangeHint: 'column-change-hint', /** section · What it touches */ columnChangeImpact: 'column-change-impact', + /** input */ + columnChangeLargeTable: 'column-change-large-table', /** p */ columnChangeLoadError: 'column-change-load-error', /** input */ @@ -3029,7 +3031,7 @@ export const TestIds = { columnChangeStep: (value: string | number) => `column-change-step-${value}`, /** button */ columnChangeStepCopy: (value: string | number) => `column-change-step-${value}-copy`, - /** section · ( steps) */ + /** section · ( ) */ columnChangeSteps: 'column-change-steps', /** select */ columnChangeTable: 'column-change-table', @@ -4184,6 +4186,7 @@ export type TestId = | 'column-change-copy-all' | 'column-change-hint' | 'column-change-impact' + | 'column-change-large-table' | 'column-change-load-error' | 'column-change-new-name' | 'column-change-new-type' diff --git a/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.test.tsx b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.test.tsx index d36b74e38..48d3c899b 100644 --- a/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.test.tsx +++ b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.test.tsx @@ -8,8 +8,19 @@ vi.mock('@/shared/api/sqlApi', () => ({ executeSql: (...args: unknown[]) => executeSql(...args), })); +const fetchTableInsight = vi.hoisted(() => vi.fn()); +vi.mock('@/shared/api/schemaApi', async (importOriginal) => ({ + ...(await importOriginal>()), + fetchTableInsight: (...args: unknown[]) => fetchTableInsight(...args), +})); + vi.mock('@/features/compare', () => { - const state = { connections: [{ id: 'pg', name: 'Shop', dialect: 'postgres', schema: 'shop', hasPassword: true }] }; + const state = { + connections: [ + { id: 'pg', name: 'Shop', dialect: 'postgres', schema: 'shop', hasPassword: true }, + { id: 'ora', name: 'Ledger', dialect: 'oracle', schema: 'shop', hasPassword: true }, + ], + }; const useSyncStore = (sel: (s: typeof state) => unknown) => sel(state); useSyncStore.getState = () => state; return { useSyncStore }; @@ -45,8 +56,13 @@ const pick = (testId: string, value: string) => fireEvent.change(screen.getByTes describe('ColumnChangePanel', () => { beforeEach(() => { executeSql.mockReset(); + fetchTableInsight.mockReset(); + fetchTableInsight.mockResolvedValue({ estimatedRows: 120, sizeBytes: 16384 }); useSqlEditorStore.setState({ - schemaCache: { pg: { status: 'ready', tables: [orders, statusFn] } }, + schemaCache: { + pg: { status: 'ready', tables: [orders, statusFn] }, + ora: { status: 'ready', tables: [orders] }, + }, ensureSchema: async () => undefined, ensureConnectionSelected: () => undefined, } as never); @@ -59,6 +75,7 @@ describe('ColumnChangePanel', () => { pick('column-change-column', 'status'); expect(screen.getByTestId('column-change-hint')).toBeTruthy(); pick('column-change-new-type', 'varchar(40)'); + fireEvent.click(screen.getByTestId('column-change-large-table')); expect(screen.getByTestId('column-change-impact').textContent).toContain('orders_status_idx'); expect(screen.getByTestId('column-change-impact').textContent).toContain('orders_status_label'); @@ -110,6 +127,7 @@ describe('ColumnChangePanel', () => { pick('column-change-table', 'orders'); pick('column-change-column', 'status'); pick('column-change-new-type', 'varchar(40)'); + fireEvent.click(screen.getByTestId('column-change-large-table')); fireEvent.click(screen.getByTestId('column-change-run-add')); await waitFor(() => expect(executeSql).toHaveBeenCalled()); expect(executeSql.mock.calls[0]![0]).toMatchObject({ connectionId: 'pg' }); @@ -133,6 +151,7 @@ describe('ColumnChangePanel', () => { pick('column-change-table', 'orders'); pick('column-change-column', 'status'); pick('column-change-new-type', 'varchar(40)'); + fireEvent.click(screen.getByTestId('column-change-large-table')); expect(screen.getByTestId('column-change-step-3').textContent).toContain('DO $fox_copy$'); fireEvent.click(screen.getByTestId('column-change-run-copy')); await waitFor(() => expect(screen.getByTestId('column-change-progress').textContent).toMatch(/10/)); @@ -194,6 +213,7 @@ describe('ColumnChangePanel', () => { pick('column-change-table', 'orders'); pick('column-change-column', 'status'); pick('column-change-new-type', 'varchar(40)'); + fireEvent.click(screen.getByTestId('column-change-large-table')); fireEvent.click(screen.getByTestId('column-change-run-all')); await waitFor(() => expect(screen.getByTestId('column-change-run-status').textContent).toMatch(/All parts finished/)); const batches = executeSql.mock.calls.map((c) => (c[1] as string[]).join('\n')); @@ -207,4 +227,86 @@ describe('ColumnChangePanel', () => { expect(addAt).toBeGreaterThanOrEqual(0); expect(dropAt).toBeGreaterThan(addAt); }); + + describe('a type change in place (the default)', () => { + const typeIn = (type: string) => { + render(); + pick('column-change-table', 'orders'); + pick('column-change-column', 'status'); + pick('column-change-new-type', type); + }; + + it('is one ALTER, with no copy part and no batch size until large-table mode is on', () => { + typeIn('varchar(40)'); + expect(screen.getByTestId('column-change-steps').textContent).toContain('Change in place (1 step)'); + expect(screen.getByTestId('column-change-step-1').textContent).toContain( + 'ALTER TABLE "shop"."orders" ALTER COLUMN "status" TYPE varchar(40);' + ); + expect(screen.queryByTestId('column-change-part-copy')).toBeNull(); + expect(screen.queryByTestId('column-change-batch')).toBeNull(); + expect((screen.getByTestId('column-change-large-table') as HTMLInputElement).checked).toBe(false); + + fireEvent.click(screen.getByTestId('column-change-large-table')); + expect(screen.getByTestId('column-change-part-copy')).toBeTruthy(); + expect(screen.getByTestId('column-change-batch')).toBeTruthy(); + }); + + it('offers large-table mode only for a type change', () => { + render(); + pick('column-change-table', 'orders'); + pick('column-change-column', 'status'); + pick('column-change-new-name', 'state'); + expect(screen.queryByTestId('column-change-large-table')).toBeNull(); + }); + + it('shows no line-by-line references for a type change, only the one-line warnings', () => { + typeIn('varchar(40)'); + expect(screen.queryByTestId('column-change-references')).toBeNull(); + expect(screen.getByTestId('column-change-warnings').textContent).toContain( + 'Functions that may use the column: orders_status_label' + ); + }); + + it('warns on a large table, from the catalog estimate, and not in large-table mode', async () => { + fetchTableInsight.mockResolvedValue({ estimatedRows: 4_000_000, sizeBytes: 2 * 1024 ** 3 }); + typeIn('varchar(40)'); + await waitFor(() => + expect(screen.getByTestId('column-change-warnings').textContent).toMatch( + /orders is large \(about 4\.0 million rows, 2\.0 GB\)/ + ) + ); + expect(fetchTableInsight.mock.calls[0]).toEqual([{ connectionId: 'pg', password: undefined }, { table: 'orders', schema: 'shop' }]); + fireEvent.click(screen.getByTestId('column-change-large-table')); + expect(screen.getByTestId('column-change-warnings').textContent).not.toMatch(/is large/); + }); + + it('says nothing about size when the estimate cannot be read', async () => { + fetchTableInsight.mockRejectedValue(new Error('no stats')); + typeIn('varchar(40)'); + await waitFor(() => expect(fetchTableInsight).toHaveBeenCalled()); + expect(screen.getByTestId('column-change-warnings').textContent).not.toMatch(/is large/); + }); + + it('runs the ALTER against this connection and says the column changed', async () => { + useSqlEditorStore.setState({ safeMode: false } as never); + executeSql.mockResolvedValue({ results: [{ ok: true, columns: [], rows: [], rowCount: 0, truncated: false, durationMs: 1 }] }); + typeIn('varchar(40)'); + fireEvent.click(screen.getByTestId('column-change-run-all')); + await waitFor(() => expect(screen.getByTestId('column-change-run-status').textContent).toBe('Column changed.')); + expect(executeSql).toHaveBeenCalledTimes(1); + expect((executeSql.mock.calls[0]![1] as string[])[0]).toContain('ALTER COLUMN "status" TYPE varchar(40)'); + }); + }); + + it('changes the type in place on an engine without large-table mode, and does not offer it', () => { + render(); + pick('column-change-table', 'orders'); + pick('column-change-column', 'status'); + pick('column-change-new-type', 'VARCHAR2(40)'); + expect(screen.getByTestId('column-change-step-1').textContent).toContain( + 'ALTER TABLE "SHOP"."orders" MODIFY ("status" VARCHAR2(40));' + ); + expect(screen.queryByTestId('column-change-large-table')).toBeNull(); + expect(screen.queryByTestId('column-change-batch')).toBeNull(); + }); }); diff --git a/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.tsx b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.tsx index 623351cdc..e1eed2687 100644 --- a/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.tsx +++ b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.tsx @@ -3,11 +3,12 @@ * Copyright 2024-2026 Huy Phan * SPDX-License-Identifier: Apache-2.0 * - * Utilities → Rename Column: rename a column or change its type the safe way. - * A type change is three parts — add a nullable column, copy rows in a loop - * (with progress), then constraints / triggers / description — each with - * preview SQL to copy, run, or execute all. Lossy type conversions warn - * and must be acknowledged before Run or Execute all. + * Utilities → Rename Column: rename a column or change its type. + * A type change is one in-place ALTER by default. Large-table mode turns it + * into three parts — add a nullable column, copy rows in a loop (with + * progress), then constraints / triggers / description — so a big table stays + * writable. Each part has preview SQL to copy, run, or execute all. Lossy type + * conversions warn and must be acknowledged before Run or Execute all. */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Check, Copy, Loader2, Play, SquareTerminal } from 'lucide-react'; @@ -15,10 +16,13 @@ import { useSyncStore } from '@/features/compare'; import { useSqlEditorStore } from '@/features/sql-editor/state'; import { useUiStore } from '@/platform/uiStore'; import { executeSql, type SqlStatementResult } from '@/shared/api/sqlApi'; +import { fetchTableInsight } from '@/shared/api/schemaApi'; import { COLUMN_CHANGE_PHASES, DEFAULT_COLUMN_CHANGE_BATCH, + columnChangeLargeTable, planColumnChange, + type ColumnChangeMode, type ColumnChangePhase, type ColumnChangePlan, type ColumnChangeProgressSql, @@ -39,13 +43,17 @@ type RunTarget = ColumnChangePhase | 'all'; const PHASE_ORDER: ColumnChangePhase[] = ['add', 'copy', 'finish']; -function statusFor(phase: ColumnChangePhase): string { +function statusFor(phase: ColumnChangePhase, mode: ColumnChangeMode): string { + if (mode === 'rename') return 'Rename finished.'; + if (mode === 'direct') return 'Column changed.'; if (phase === 'add') return 'New column is in place.'; if (phase === 'copy') return 'Copy finished. Check the mismatch count is 0 before the last part.'; return 'Constraints, triggers and description are applied.'; } -function progressFor(phase: ColumnChangePhase): string { +function progressFor(phase: ColumnChangePhase, mode: ColumnChangeMode): string { + if (mode === 'rename') return 'Renaming…'; + if (mode === 'direct') return 'Changing the column…'; if (phase === 'add') return 'Adding the new column…'; if (phase === 'copy') return 'Copying data…'; return 'Applying constraints, triggers and description…'; @@ -133,6 +141,9 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) const [newName, setNewName] = useState(''); const [newType, setNewType] = useState(''); const [batchSize, setBatchSize] = useState(DEFAULT_COLUMN_CHANGE_BATCH); + const [largeTable, setLargeTable] = useState(false); + /** The catalog's row estimate and size for the picked table, for the size warning. */ + const [tableSize, setTableSize] = useState<{ rows: number | null; bytes: number | null } | null>(null); const [passwordDraft, setPasswordDraft] = useState(''); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); @@ -184,10 +195,31 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) // Reload when the credential changes, or once its password is given. }, [lockedConnectionId, needsPassword, load]); + // The table's size, for the warning that points at large-table mode. A + // catalog estimate, so no COUNT(*) on a big table; unknown when it fails. + const password = sessionPasswords[lockedConnectionId]; + useEffect(() => { + setTableSize(null); + if (!lockedConnectionId || !tableName || needsPassword) return; + let cancelled = false; + fetchTableInsight( + { connectionId: lockedConnectionId, password: password || undefined }, + { table: tableName, schema: conn?.schema?.trim() || undefined } + ) + .then((res) => { + if (!cancelled) setTableSize({ rows: res.estimatedRows, bytes: res.sizeBytes }); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [lockedConnectionId, tableName, needsPassword, password, conn?.schema]); + // A new table or column starts a new change. useEffect(() => { setNewName(''); setNewType(''); + setLargeTable(false); setRunError(null); setRunStatus(null); setConfirmTarget(null); @@ -209,18 +241,27 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) tables: all, column: column.name, change: { newName: newName.trim() || undefined, newType: newType.trim() || undefined }, + largeTable: largeTable && columnChangeLargeTable(conn.dialect), batchSize, + estimatedRows: tableSize?.rows, + sizeBytes: tableSize?.bytes, }); - }, [conn, table, column, newName, newType, batchSize, all]); + }, [conn, table, column, newName, newType, largeTable, batchSize, tableSize, all]); const script = plan?.ok ? columnChangeScript(plan.steps) : ''; - const typeChange = Boolean(plan?.ok && plan.progress); + const mode: ColumnChangeMode | undefined = plan?.ok ? plan.mode : undefined; + // Offered as soon as a type is typed in that is not the column's own. + const typeRequested = Boolean(column && newType.trim() && newType.trim().toLowerCase() !== column.type.toLowerCase()); + const largeTableOffered = typeRequested && Boolean(conn && columnChangeLargeTable(conn.dialect)); const conversionWarning = plan?.ok ? plan.impact.lossy : undefined; const runDisabled = Boolean(runningTarget) || needsPassword || Boolean(conversionWarning && !ackLossy); - const parts = typeChange - ? COLUMN_CHANGE_PHASES - : [{ id: 'add' as const, title: 'Rename', blurb: 'Instant catalog rename. Indexes follow; application SQL that names the column does not.' }]; + const parts = + mode === 'copy' + ? COLUMN_CHANGE_PHASES + : mode === 'direct' + ? [{ id: 'add' as const, title: 'Change in place', blurb: 'One ALTER on the column. Fine for small and medium tables.' }] + : [{ id: 'add' as const, title: 'Rename', blurb: 'Instant catalog rename. Indexes follow; application SQL that names the column does not.' }]; const runStatements = useCallback( async (sql: string): Promise => { @@ -273,7 +314,7 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) setConfirmTarget(null); try { await executePhase(phase, live, true); - setRunStatus(statusFor(phase)); + setRunStatus(statusFor(phase, live.mode)); } catch (e: unknown) { setRunError(e instanceof Error ? e.message : String(e)); } finally { @@ -285,7 +326,7 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) const runAll = useCallback( async (live: Extract) => { - const phases = live.progress ? PHASE_ORDER : (['add'] as ColumnChangePhase[]); + const phases = live.mode === 'copy' ? PHASE_ORDER : (['add'] as ColumnChangePhase[]); setRunningTarget('all'); setRunError(null); setRunStatus(null); @@ -293,10 +334,10 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) try { for (let i = 0; i < phases.length; i++) { const phase = phases[i]!; - setRunStatus(progressFor(phase)); + setRunStatus(progressFor(phase, live.mode)); await executePhase(phase, live, i === phases.length - 1); } - setRunStatus(live.progress ? 'All parts finished.' : 'Rename finished.'); + setRunStatus(live.mode === 'copy' ? 'All parts finished.' : statusFor('add', live.mode)); } catch (e: unknown) { setRunError(e instanceof Error ? e.message : String(e)); } finally { @@ -423,18 +464,40 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) ))} -