diff --git a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx index c5eabf9cd..59f2c29ee 100644 --- a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx +++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx @@ -198,7 +198,7 @@ describe('AccessPermissionPanel — one session', () => { }); it('keeps the confirmation open when apply fails', async () => { - runAccessSql.mockResolvedValue({ ok: false, error: 'role "r" does not exist' }); + runAccessSql.mockResolvedValue({ ok: false, error: 'role "r" does not exist', applied: 0 }); await grantsStageFor('alice'); const loads = fetchDbAccess.mock.calls.length; fireEvent.click(await screen.findByTestId('access-grants-preset-read-write')); @@ -209,6 +209,24 @@ describe('AccessPermissionPanel — one session', () => { expect(fetchDbAccess.mock.calls.length).toBe(loads); }); + it('re-reads the catalog when part of the script was applied before the failure', async () => { + // Statements run in order and stop at the failure; the ones before it stay + // applied, so the grid must not keep showing the grants as they were. + runAccessSql.mockResolvedValue({ + ok: false, + error: 'Statement 2 of 2 failed: permission denied. The 1 before it was applied.', + applied: 1, + }); + await grantsStageFor('alice'); + const loads = fetchDbAccess.mock.calls.length; + fireEvent.click(await screen.findByTestId('access-grants-preset-read-write')); + fireEvent.click(await screen.findByTestId('access-grants-open-sql')); + fireEvent.click(await screen.findByTestId('access-permission-apply')); + await waitFor(() => expect(fetchDbAccess.mock.calls.length).toBeGreaterThan(loads)); + expect(screen.getByTestId('access-permission-error').textContent).toMatch(/The 1 before it was applied/); + expect(screen.getByTestId('access-permission-confirm')).toBeTruthy(); + }); + it('hides Apply without the Grant privileges permission', async () => { useAuthStore.setState({ user: { diff --git a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx index 21337e9ef..281aad539 100644 --- a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx +++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx @@ -180,6 +180,10 @@ export const AccessPermissionPanel: React.FC<{ confirm.sql ); if (!outcome.ok) { + // The statements before the failure stay applied, so re-read the + // catalog to show them — then say what failed, since a reload starts + // by clearing the error. The confirmation stays open either way. + if (outcome.applied > 0) await load({ force: true }); setError(outcome.error); return; } diff --git a/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.transaction.test.ts b/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.transaction.test.ts index 65520cbc1..09f7c6e52 100644 --- a/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.transaction.test.ts +++ b/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.transaction.test.ts @@ -40,7 +40,7 @@ const ok = (n: number) => ({ durationMs: 1, }); -async function arm(sql: string, checked: number[]) { +async function arm(sql: string, checked: number[], dialect = 'sqlite') { await useSqlEditorStore.persist.rehydrate(); const tab = createTab({ id: 't1', @@ -50,7 +50,8 @@ async function arm(sql: string, checked: number[]) { selectedConnectionIds: ['c1'], }); useSyncStore.setState({ - connections: [{ id: 'c1', name: 'Lite', dialect: 'sqlite', hasPassword: false } as never], + // A stored password, so a server engine runs without asking for one. + connections: [{ id: 'c1', name: 'Lite', dialect, hasPassword: true } as never], }); useSqlEditorStore.setState({ tabs: [tab], @@ -120,4 +121,34 @@ describe('SQL editor one transaction', () => { expect(results[1]).toMatchObject({ ok: false }); expect(results[1] && !results[1].ok ? results[1].error : '').toMatch(/rolled back/); }); + + it('runs a batch that steers its own transaction statement by statement, and says why', async () => { + // Wrapped, its COMMIT would make what ran before it permanent and a later + // failure would roll back nothing — while the panel reported a rollback. + executeSql.mockResolvedValue({ results: [ok(1)] }); + const tabId = await arm('INSERT INTO t VALUES (1);\nCOMMIT;\nINSERT INTO t VALUES (2);', [0, 1, 2]); + await useSqlEditorStore.getState().execute(); + expect(executeSql.mock.calls.length).toBe(3); + expect(executeSql.mock.calls.every((call) => !(call[5] as { transaction?: boolean } | undefined)?.transaction)).toBe(true); + expect(useSqlEditorStore.getState().resultsByTab[tabId]?.warnings?.join('\n')).toMatch(/BEGIN, COMMIT or ROLLBACK/); + }); + + it('runs statement by statement on an engine with no transactions, and says why', async () => { + // ClickHouse commits each statement as it runs; wrapped, a failure would + // leave the earlier ones in place while the panel reported a rollback. + executeSql.mockResolvedValue({ results: [ok(1)] }); + const tabId = await arm('INSERT INTO t VALUES (1);\nINSERT INTO t VALUES (2);', [0, 1], 'clickhouse'); + await useSqlEditorStore.getState().execute(); + expect(executeSql.mock.calls.length).toBe(2); + expect(executeSql.mock.calls.every((call) => !(call[5] as { transaction?: boolean } | undefined)?.transaction)).toBe(true); + expect(useSqlEditorStore.getState().resultsByTab[tabId]?.warnings?.join('\n')).toMatch(/ClickHouse has no transactions/); + }); + + it('still batches savepoints, which stay inside the transaction', async () => { + executeSql.mockResolvedValue({ results: [ok(1), ok(2), ok(3)] }); + await arm('SAVEPOINT s1;\nINSERT INTO t VALUES (1);\nROLLBACK TO SAVEPOINT s1;', [0, 1, 2]); + await useSqlEditorStore.getState().execute(); + expect(executeSql).toHaveBeenCalledTimes(1); + expect(executeSql.mock.calls[0]![5]).toEqual({ transaction: true, stopOnError: true }); + }); }); diff --git a/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts b/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts index d441081af..cf259b769 100644 --- a/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts +++ b/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts @@ -34,6 +34,7 @@ import { isInsertWriteStatement, isMutatingDmlStatement, isPageableStatement, + isTransactionControl, isWriteStatement, splitSqlStatements, } from '@/shared/lib/sql-splitter'; @@ -82,7 +83,7 @@ import { type SqlVariableKind, type VariableOverride, } from '@/shared/lib/sql-variables'; -import { connectionNeedsSecret } from '@/shared/lib/provider-settings'; +import { connectionNeedsSecret, dialectHasTransactions, getProviderSettings } from '@/shared/lib/provider-settings'; import { useSyncStore } from '@/features/compare'; import { SQL_EDITOR_PERSIST_KEY, SQL_EDITOR_PERSIST_VERSION, useRecentQueries } from './recentQueries'; import { workspaceEditorStorage } from '@/shared/lib/editorWorkspaceStorage'; @@ -1285,15 +1286,31 @@ export const useSqlEditorStore = create()( }; const wantsTransaction = get().oneTransaction && rawStatements.length > 1; - const transactionBlocked = rawStatements.some( + const hasCellOrSet = rawStatements.some( (statement) => detectCodeCell(statement) || parseSetDirectives(statement).directives.length > 0 ); - if (wantsTransaction && transactionBlocked) { + // A BEGIN or COMMIT of the batch's own would end the wrapping + // transaction halfway: what ran before it stays, and a later failure + // rolls back nothing. The server refuses such a batch; run it as typed. + const steersOwnTransaction = rawStatements.some((statement) => isTransactionControl(statement)); + // ClickHouse, Redis and MongoDB open no transaction: each statement + // commits as it runs, so a failure cannot take the earlier ones back. + // The server refuses the batch; run it as typed and say so. + const noTransaction = connections.find((c) => !dialectHasTransactions(c.dialect)); + if (wantsTransaction && hasCellOrSet) { appendWarning( 'One transaction applies to plain SQL. This run has a code cell or an @set, so each statement runs on its own.' ); + } else if (wantsTransaction && steersOwnTransaction) { + appendWarning( + 'This run has its own BEGIN, COMMIT or ROLLBACK, so it runs statement by statement as written rather than as one transaction.' + ); + } else if (wantsTransaction && noTransaction) { + appendWarning( + `${getProviderSettings(noTransaction.dialect).label} has no transactions, so this run goes statement by statement and nothing rolls back on a failure.` + ); } - const asOneTransaction = wantsTransaction && !transactionBlocked; + const asOneTransaction = wantsTransaction && !hasCellOrSet && !steersOwnTransaction && !noTransaction; if (asOneTransaction) { type Prep = { ok: true; sql: string } | { ok: false; error: string }; const prepared: Array> = []; diff --git a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx index 00e367dba..b6854f763 100644 --- a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx +++ b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx @@ -466,6 +466,13 @@ export const DatabaseAccessModal: React.FC = ({ sql ); if (!outcome.ok) { + // Statements before the failure stay applied; re-read so the list + // shows them rather than the grants as they were before — then set + // the error, which a reload starts by clearing. + if (outcome.applied > 0) { + invalidateDbAccessCache(connectionId); + await load(); + } setError(outcome.error); } else { setStatus(kind === 'grant' ? 'Granted.' : 'Revoked.'); diff --git a/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.test.tsx b/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.test.tsx index 64d2a2ec8..27509e58d 100644 --- a/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.test.tsx +++ b/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.test.tsx @@ -72,3 +72,30 @@ describe('WorkspaceRailButton', () => { expect(screen.queryByTestId('workspace-rail-btn')).toBeNull(); }); }); + +describe('WorkspaceRailButton follows the server’s workspace', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('reloads once when the editor was on another workspace than the server opened', async () => { + // Switched elsewhere, or another account signed in on this browser: the + // editor hydrated from the old workspace and would save its scripts into + // the one that is open now. + localStorage.setItem('foxschema-active-workspace', 'team-a'); + const reload = vi.fn(); + render(); + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + expect(localStorage.getItem('foxschema-active-workspace')).toBe('own'); + }); + + it('does not reload when they already agree, or on the first run', async () => { + const reload = vi.fn(); + render(); + expect(await screen.findByTestId('workspace-rail-btn')).toBeTruthy(); + expect(localStorage.getItem('foxschema-active-workspace')).toBe('own'); + render(); + await screen.findAllByTestId('workspace-rail-btn'); + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.tsx b/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.tsx index 003688ba1..3147c9501 100644 --- a/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.tsx +++ b/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.tsx @@ -16,7 +16,7 @@ import { Layers } from 'lucide-react'; import { useAuthStore } from '@/platform/authStore'; import { MountWhenOpened } from '@/shared/components/MountWhenOpened'; import { useAnchoredPopover } from '@/shared/lib/useAnchoredPopover'; -import { claimEditorWorkspace } from '@/shared/lib/editorWorkspaceStorage'; +import { followServerWorkspace } from '@/shared/lib/editorWorkspaceStorage'; import { apiListWorkspaces, apiMyInvites, type WorkspaceList } from '../api/workspacesApi'; const WorkspaceMenu = lazy(() => import('./WorkspaceMenu').then((m) => ({ default: m.WorkspaceMenu }))); @@ -29,7 +29,15 @@ function mark(name: string): string { return (word.charAt(0) || 'W').toUpperCase(); } -export function WorkspaceRailButton(): React.ReactElement | null { +/** Module-level, so the effect that may call it does not re-run on every render. */ +const reloadPage = () => window.location.reload(); + +export function WorkspaceRailButton({ + reload = reloadPage, +}: { + /** Replaced in tests; the app reloads when the editor follows the server. */ + reload?: () => void; +} = {}): React.ReactElement | null { const launch = useAuthStore((s) => s.launch); const [open, setOpen] = useState(false); const [list, setList] = useState(null); @@ -43,7 +51,11 @@ export function WorkspaceRailButton(): React.ReactElement | null { apiListWorkspaces() .then((next) => { if (!alive) return; - claimEditorWorkspace(next.currentId); + // The server decides which workspace is open; the editor follows it. + if (followServerWorkspace(next.currentId) === 'moved') { + reload(); + return; + } setList(next); }) .catch(() => undefined); @@ -53,7 +65,7 @@ export function WorkspaceRailButton(): React.ReactElement | null { return () => { alive = false; }; - }, [launch]); + }, [launch, reload]); useEffect(() => { if (!open) return; diff --git a/apps/web/src/frontend/shared/api/accessSql.test.ts b/apps/web/src/frontend/shared/api/accessSql.test.ts index 1250e638d..f10c8b857 100644 --- a/apps/web/src/frontend/shared/api/accessSql.test.ts +++ b/apps/web/src/frontend/shared/api/accessSql.test.ts @@ -68,12 +68,13 @@ describe('runAccessSql', () => { ).resolves.toEqual({ ok: false, error: 'Statement 2 of 3 failed: role "r" does not exist. The 1 before it was applied. The 1 after it did not run.', + applied: 1, }); }); it('reports a lone statement’s error as it is', async () => { executeSql.mockResolvedValue({ results: [{ ok: false, error: 'permission denied' }] }); - await expect(runAccessSql(ref, 'GRANT SELECT ON t TO r;')).resolves.toEqual({ ok: false, error: 'permission denied' }); + await expect(runAccessSql(ref, 'GRANT SELECT ON t TO r;')).resolves.toEqual({ ok: false, error: 'permission denied', applied: 0 }); }); it('does not report ok when fewer results came back than statements were sent', async () => { @@ -81,6 +82,7 @@ describe('runAccessSql', () => { await expect(runAccessSql(ref, 'GRANT SELECT ON t TO r;\nGRANT INSERT ON t TO r;')).resolves.toEqual({ ok: false, error: 'Only 1 of 2 statements ran.', + applied: 1, }); }); diff --git a/apps/web/src/frontend/shared/api/accessSql.ts b/apps/web/src/frontend/shared/api/accessSql.ts index 1bcf8b2a4..d81b5a8c4 100644 --- a/apps/web/src/frontend/shared/api/accessSql.ts +++ b/apps/web/src/frontend/shared/api/accessSql.ts @@ -20,8 +20,13 @@ import { splitSqlStatements } from '@foxschema/ui-shared'; import type { ConnectionRef } from './schemaApi'; import { executeSql } from './sqlApi'; -/** Every statement succeeded, or which one failed and what ran around it. */ -export type AccessSqlOutcome = { ok: true } | { ok: false; error: string }; +/** + * Every statement succeeded, or which one failed and what ran around it. + * `applied` counts the statements that ran before the failure and stay + * applied — a caller re-reads the catalog when it is above zero, or it shows + * grants as they were before part of the script landed. + */ +export type AccessSqlOutcome = { ok: true } | { ok: false; error: string; applied: number }; /** * Execute a grant/revoke script statement by statement. @@ -46,14 +51,18 @@ export async function runAccessSql( const failedAt = results.findIndex((r) => !r.ok); if (failedAt === -1 && results.length === statements.length) return { ok: true }; if (failedAt === -1) { - return { ok: false, error: `Only ${results.length} of ${statements.length} statements ran.` }; + return { ok: false, error: `Only ${results.length} of ${statements.length} statements ran.`, applied: results.length }; } const failure = results[failedAt]!; const error = 'error' in failure ? failure.error : 'failed'; - if (statements.length === 1) return { ok: false, error }; + if (statements.length === 1) return { ok: false, error, applied: 0 }; const notRun = statements.length - failedAt - 1; const before = failedAt === 0 ? '' : ` The ${failedAt} before it ${failedAt === 1 ? 'was' : 'were'} applied.`; const after = notRun === 0 ? '' : ` The ${notRun} after it did not run.`; const reason = error.trim().replace(/\.$/, ''); - return { ok: false, error: `Statement ${failedAt + 1} of ${statements.length} failed: ${reason}.${before}${after}` }; + return { + ok: false, + error: `Statement ${failedAt + 1} of ${statements.length} failed: ${reason}.${before}${after}`, + applied: failedAt, + }; } diff --git a/apps/web/src/frontend/shared/lib/editorWorkspaceStorage.test.ts b/apps/web/src/frontend/shared/lib/editorWorkspaceStorage.test.ts index 860397117..dc3d37a69 100644 --- a/apps/web/src/frontend/shared/lib/editorWorkspaceStorage.test.ts +++ b/apps/web/src/frontend/shared/lib/editorWorkspaceStorage.test.ts @@ -10,6 +10,7 @@ import { activeEditorWorkspaceId, claimEditorWorkspace, editorStorageKey, + followServerWorkspace, prepareWorkspaceSwitch, } from './editorWorkspaceStorage'; @@ -50,3 +51,31 @@ describe('editor workspace storage', () => { expect(activeEditorWorkspaceId()).toBe('ws-a'); }); }); + +describe('following the workspace the server opened', () => { + it('claims on the first run, and is a no-op while they agree', () => { + localStorage.setItem(SQL_EDITOR_PERSIST_KEY, snapshot('select 1')); + expect(followServerWorkspace('ws-a')).toBe('claimed'); + expect(editorStorageKey()).toBe(`${SQL_EDITOR_PERSIST_KEY}:ws-a`); + expect(followServerWorkspace('ws-a')).toBe('same'); + }); + + it('moves to the server’s workspace when it is not the one this browser last used', () => { + // Switched in another browser, taken out of ws-a, or another account + // signed in here: the editor must not keep showing — and saving into — ws-a. + claimEditorWorkspace('ws-a'); + localStorage.setItem(`${SQL_EDITOR_PERSIST_KEY}:ws-a`, snapshot('a secret query')); + expect(followServerWorkspace('ws-b')).toBe('moved'); + expect(activeEditorWorkspaceId()).toBe('ws-b'); + expect(editorStorageKey()).toBe(`${SQL_EDITOR_PERSIST_KEY}:ws-b`); + // ws-a keeps its scripts; nothing was copied across. + expect(localStorage.getItem(`${SQL_EDITOR_PERSIST_KEY}:ws-a`)).toBe(snapshot('a secret query')); + expect(localStorage.getItem(`${SQL_EDITOR_PERSIST_KEY}:ws-b`)).toBeNull(); + }); + + it('ignores an empty id rather than unscoping the editor', () => { + claimEditorWorkspace('ws-a'); + expect(followServerWorkspace(' ')).toBe('same'); + expect(activeEditorWorkspaceId()).toBe('ws-a'); + }); +}); diff --git a/apps/web/src/frontend/shared/lib/editorWorkspaceStorage.ts b/apps/web/src/frontend/shared/lib/editorWorkspaceStorage.ts index e6a82f45a..7add972b4 100644 --- a/apps/web/src/frontend/shared/lib/editorWorkspaceStorage.ts +++ b/apps/web/src/frontend/shared/lib/editorWorkspaceStorage.ts @@ -87,6 +87,34 @@ export function claimEditorWorkspace(workspaceId: string): void { write(ACTIVE_WORKSPACE_KEY, id); } +/** + * Keep the editor on the workspace the server opened, which is not always the + * one this browser last used: a switch in another browser or tab, being taken + * out of a workspace, or another account signing in here all move the server + * without moving the pointer. The editor hydrated from the pointer, so on a + * mismatch it was showing — and saving into — another workspace's scripts. + * + * `'claimed'`: first run, the original blob now belongs to `workspaceId` and + * is what the editor already holds. `'moved'`: the pointer now names + * `workspaceId`; the caller reloads, so the editor hydrates from it rather + * than writing what it holds into the wrong workspace. `'same'`: nothing to do. + */ +export function followServerWorkspace(workspaceId: string): 'same' | 'claimed' | 'moved' { + const id = workspaceId.trim(); + if (!id) return 'same'; + const active = activeEditorWorkspaceId(); + if (!active) { + claimEditorWorkspace(id); + return 'claimed'; + } + if (active === id) return 'same'; + // Pending writes are keyed when made, so they still land in the workspace + // they were typed in. + flushDeferredStorage(); + write(ACTIVE_WORKSPACE_KEY, id); + return 'moved'; +} + /** * Point the next page load at `nextId`, after the current blob is saved. * diff --git a/apps/web/src/frontend/shared/lib/provider-settings.ts b/apps/web/src/frontend/shared/lib/provider-settings.ts index 92c0c6fa8..a026030c3 100644 --- a/apps/web/src/frontend/shared/lib/provider-settings.ts +++ b/apps/web/src/frontend/shared/lib/provider-settings.ts @@ -33,6 +33,7 @@ export { withConnectionString, isFileDialect, dialectFamily, + dialectHasTransactions, DEFAULT_PORTS, connectionNeedsSecret, authMethodsForDialect, diff --git a/apps/web/src/frontend/shared/lib/sql-splitter.ts b/apps/web/src/frontend/shared/lib/sql-splitter.ts index 60fa5a600..b66919486 100644 --- a/apps/web/src/frontend/shared/lib/sql-splitter.ts +++ b/apps/web/src/frontend/shared/lib/sql-splitter.ts @@ -11,6 +11,7 @@ export { isMutatingDmlStatement, isInsertWriteStatement, isPageableStatement, + isTransactionControl, dmlLacksWhere, parseCodeCell, findCodeFences, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b9c130556..71e342da2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -377,6 +377,9 @@ Constraints: `packages/sql/src/providers/provider-settings.ts` (`dialect-registry.test.ts` fails until they match `PROVIDER_SETTINGS`). Nothing in `apps/web` needs editing — the frontend re-exports this registry. + If the adapter's `beginTransaction` / `rollbackTransaction` do nothing, set + `transactions: false` in `.settings.ts`: the editor then refuses + One transaction there instead of reporting a rollback that never happened. 4. Add `parseType`/`renderType` round-trip tests in `type-mapping.test.ts` 5. Verify each optional hook against real DDL — the generic fallbacks are often wrong for DROP INDEX/TRIGGER/FK 6. Say how the engine lexes SQL in `modules/sql-text/sql-readings.ts` (comments, diff --git a/packages/server/src/features/sql-editor/editor.routes.ts b/packages/server/src/features/sql-editor/editor.routes.ts index 999c96f8c..f1d24320f 100644 --- a/packages/server/src/features/sql-editor/editor.routes.ts +++ b/packages/server/src/features/sql-editor/editor.routes.ts @@ -21,7 +21,14 @@ import { isDatagridAction, type Permission, } from '@foxschema/shared'; -import { sqlStatementCategories, statementVerb, errorMessage } from '@foxschema/sql'; +import { + dialectHasTransactions, + errorMessage, + getProviderSettings, + isTransactionControl, + sqlStatementCategories, + statementVerb, +} from '@foxschema/sql'; import { isSingleSqlStatement } from '../../platform/statements/single-statement'; import { clampOffset, parseSqlSeek } from './sql-page-wrap.service'; import { makeBeamCellQueryRunner, makeCellQueryRunner } from './code-cell-query.service'; @@ -101,6 +108,17 @@ export function createEditorRoutes(deps: EditorRouteDeps): Router { sendError(res, 'invalid_input', `Every statement must be a non-empty string under ${deps.MAX_STATEMENT_LENGTH} characters.`); return; } + // A batch run as one transaction cannot steer the transaction itself: a + // COMMIT inside it made the statements before it permanent, and a later + // failure then rolled back nothing while the editor said it had. + if (transaction === true && (statements as string[]).some((sql) => isTransactionControl(sql))) { + sendError( + res, + 'invalid_input', + 'One transaction cannot include BEGIN, COMMIT or ROLLBACK. Run this batch without One transaction.', + ); + return; + } // Resolve the connection before classifying: what a statement does depends // on how its database reads the text (a backslash, a `#`, a `$$` mean // different things to MySQL and PostgreSQL), so the gate needs the dialect. @@ -112,6 +130,17 @@ export function createEditorRoutes(deps: EditorRouteDeps): Router { return; } const { dialect } = resolved; + // On an engine with no transaction, BEGIN and ROLLBACK do nothing: each + // statement commits as it runs, and a failure would leave the earlier ones + // in place while the editor said the batch had rolled back. + if (transaction === true && !dialectHasTransactions(dialect)) { + sendError( + res, + 'invalid_input', + `${getProviderSettings(dialect).label} has no transactions, so One transaction cannot roll this batch back. Run it without One transaction.`, + ); + return; + } // Scan for writes only once the statements are known to be bounded strings. // Fail-closed: anything not provably a read needs `editor.write`, so a verb // the classifier doesn't recognize is denied instead of executed. diff --git a/packages/server/src/features/sql-editor/sql-execute-gate.test.ts b/packages/server/src/features/sql-editor/sql-execute-gate.test.ts index c5ca8ef61..d5fb667d2 100644 --- a/packages/server/src/features/sql-editor/sql-execute-gate.test.ts +++ b/packages/server/src/features/sql-editor/sql-execute-gate.test.ts @@ -118,3 +118,56 @@ describe('POST /sql/execute stopOnError and transaction', () => { expect(flags()).toEqual({ stopOnError: false, transaction: false }); }); }); + +describe('POST /sql/execute as one transaction', () => { + // A COMMIT inside the batch made what came before it permanent; a later + // failure then rolled back nothing, and the editor reported a rollback. + it.each(['COMMIT', 'BEGIN', 'ROLLBACK;', 'START TRANSACTION', 'END'])( + 'refuses a batch that runs %s itself, before touching the database', + async (control) => { + const { sent, ran } = await execute('postgres', ['editor.run', 'editor.dml', 'editor.ddl'], { + statements: ['INSERT INTO t VALUES (1)', control, 'INSERT INTO t VALUES (2)'], + transaction: true, + }); + expect(sent.status).toBe(400); + expect(sent.body.error).toMatch(/One transaction/); + expect(ran).toBe(false); + }, + ); + + it.each([ + ['clickhouse', 'ClickHouse'], + ['redis', 'Redis'], + ['mongodb', 'MongoDB'], + ])('refuses one transaction on %s, which has nothing to roll back', async (dialect, label) => { + // Its adapter opens no transaction: each statement commits as it runs, + // so a failure leaves the earlier ones while the editor said rolled back. + const { sent, ran } = await execute(dialect, ['editor.run', 'editor.dml', 'editor.ddl'], { + statements: ['SELECT 1', 'SELECT 2'], + transaction: true, + }); + expect(sent.status).toBe(400); + expect(sent.body.error).toContain(`${label} has no transactions`); + expect(ran).toBe(false); + + const plain = await execute(dialect, ['editor.run', 'editor.dml', 'editor.ddl'], { + statements: ['SELECT 1', 'SELECT 2'], + }); + expect(plain.ran).toBe(true); + }); + + it('runs the same batch statement by statement when not asked for one transaction', async () => { + const { ran } = await execute('postgres', ['editor.run', 'editor.dml', 'editor.ddl'], { + statements: ['INSERT INTO t VALUES (1)', 'COMMIT', 'INSERT INTO t VALUES (2)'], + }); + expect(ran).toBe(true); + }); + + it('keeps savepoints, which stay inside the transaction', async () => { + const { ran } = await execute('postgres', ['editor.run', 'editor.dml', 'editor.ddl'], { + statements: ['SAVEPOINT s1', 'INSERT INTO t VALUES (1)', 'ROLLBACK TO SAVEPOINT s1'], + transaction: true, + }); + expect(ran).toBe(true); + }); +}); diff --git a/packages/server/src/features/sql-editor/sql-execute.service.test.ts b/packages/server/src/features/sql-editor/sql-execute.service.test.ts index cfd596481..50e931ff8 100644 --- a/packages/server/src/features/sql-editor/sql-execute.service.test.ts +++ b/packages/server/src/features/sql-editor/sql-execute.service.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { rmSync } from 'node:fs'; import { clampMaxRows, shapePositional, shapeRows, runStatements } from './sql-execute.service'; +import { syntaxProbeSql } from './sql-page-wrap.service'; import { ConnectionFactory } from '@foxschema/db'; describe('clampMaxRows', () => { @@ -287,7 +288,7 @@ describe('runStatements against a real SQLite file', () => { } }); - it('reports the statement error when a LIMIT 1 probe fails', async () => { + it('reports the statement error when the EXPLAIN probe fails', async () => { const spy = vi .spyOn(ConnectionFactory, 'executeOnConnection') .mockRejectedValueOnce(new Error('wrap boom')) @@ -308,7 +309,7 @@ describe('runStatements against a real SQLite file', () => { } expect(spy).toHaveBeenCalledTimes(2); expect(String(spy.mock.calls[0]![2])).toContain('fox_page'); - expect(String(spy.mock.calls[1]![2])).toBe('SELECT * FROM COUPONS cou LIMIT 1'); + expect(String(spy.mock.calls[1]![2])).toBe('EXPLAIN SELECT * FROM COUPONS cou'); } finally { spy.mockRestore(); } @@ -334,7 +335,7 @@ describe('runStatements against a real SQLite file', () => { expect(results[0].error).toMatch(/wrap boom/); } expect(spy).toHaveBeenCalledTimes(2); - expect(String(spy.mock.calls[1]![2])).toContain('LIMIT 1'); + expect(String(spy.mock.calls[1]![2])).toMatch(/^EXPLAIN /); expect(String(spy.mock.calls[1]![2])).not.toContain('fox_page'); } finally { spy.mockRestore(); @@ -342,6 +343,44 @@ describe('runStatements against a real SQLite file', () => { }); }); +describe('the probe after a failed page wrap', () => { + // Paging fails closed so nothing is read unbounded. The probe that explains + // a failed wrap must not reopen that: these all ran in full under LIMIT 1. + it.each([ + ['a trailing comment swallowed the limit', 'SELECT o.id, c.id FROM orders o JOIN customers c ON c.id = o.customer_id -- this week', 'mysql'], + ['a LIMIT inside a subquery skipped it', 'SELECT * FROM (SELECT * FROM recent LIMIT 5) r JOIN events e ON e.rid = r.id', 'mysql'], + ['the word in a string skipped it', "SELECT * FROM notes WHERE body = 'no limit'", 'postgres'], + ])('plans rather than runs the statement when %s', (_label, sql, dialect) => { + expect(syntaxProbeSql(sql, dialect)).toBe(`EXPLAIN ${sql}`); + }); + + it('has no probe where EXPLAIN is not the plain parse-and-plan', () => { + expect(syntaxProbeSql('SELECT 1 FROM dual', 'oracle')).toBeNull(); + expect(syntaxProbeSql('SELECT 1', 'sqlserver')).toBeNull(); + }); + + it('gets the database’s own words for a missing table, from a real EXPLAIN', async () => { + const dbPath = join(tmpdir(), `fox-probe-test-${process.pid}.db`); + // @ts-expect-error no type declarations for better-sqlite3 + const mod = (await import('better-sqlite3')) as { default: new (path: string) => { exec(sql: string): void; close(): void } }; + const db = new mod.default(dbPath); + db.exec('CREATE TABLE present (id INTEGER);'); + db.close(); + // Only the wrap is made to fail; the probe reaches SQLite for real. + const spy = vi.spyOn(ConnectionFactory, 'executeOnConnection').mockRejectedValueOnce(new Error('wrap boom')); + try { + const results = await runStatements('sqlite', { connectionString: dbPath }, ['SELECT * FROM coupons'], 10, undefined, 0); + expect(results[0]).toMatchObject({ ok: false }); + if (!results[0]!.ok) expect(results[0].error).toMatch(/no such table: coupons/); + expect(String(spy.mock.calls[1]![2])).toBe('EXPLAIN SELECT * FROM coupons'); + } finally { + spy.mockRestore(); + await ConnectionFactory.closeAll().catch(() => {}); + rmSync(dbPath, { force: true }); + } + }); +}); + describe('shapePositional', () => { it('keeps every column when a join repeats a name', () => { // The defect this exists for: rows arrive keyed by column name, so a join diff --git a/packages/server/src/features/sql-editor/sql-execute.service.ts b/packages/server/src/features/sql-editor/sql-execute.service.ts index a15bc30ab..f9a743f9d 100644 --- a/packages/server/src/features/sql-editor/sql-execute.service.ts +++ b/packages/server/src/features/sql-editor/sql-execute.service.ts @@ -251,9 +251,9 @@ export async function runStatements( } catch (error: unknown) { const wrapMsg = errorMessage(error); // A missing table fails inside the page wrapper (`SELECT * FROM (…) AS - // fox_page`). Probe the statement itself with LIMIT 1 so the error is - // the one the reader can act on. The probe runs only after the wrap - // fails, and only at the first page — a successful SELECT is unchanged. + // fox_page`). EXPLAIN the statement itself so the error is the one the + // reader can act on — planned, never run, so it reads no rows. Only + // after the wrap fails, and only at the first page. const probe = !seek && offset === 0 ? syntaxProbeSql(sql, dialect) : null; if (probe) { try { diff --git a/packages/server/src/features/sql-editor/sql-page-wrap.service.ts b/packages/server/src/features/sql-editor/sql-page-wrap.service.ts index 1f9a7d1ca..b50abca10 100644 --- a/packages/server/src/features/sql-editor/sql-page-wrap.service.ts +++ b/packages/server/src/features/sql-editor/sql-page-wrap.service.ts @@ -120,7 +120,8 @@ export function hasTopLevelOrderBy(sql: string): boolean { * Best-effort page wrap. Dialects without OFFSET still get a subquery + LIMIT * when offset is 0; non-zero offset uses the closest dialect syntax. */ -const LIMIT_ONE_DIALECTS = new Set([ +/** Dialects whose plain `EXPLAIN` parses and plans a statement without running it. */ +const EXPLAIN_PROBE_DIALECTS = new Set([ 'mysql', 'mariadb', 'tidb', @@ -137,18 +138,24 @@ const LIMIT_ONE_DIALECTS = new Set([ ]); /** - * The reader's statement with `LIMIT 1`, for dialects that accept it. + * A probe of the reader's statement that never runs it, used only after a + * page wrap fails: plain `EXPLAIN`, which parses and plans — and so reports a + * missing table or column in the statement's own words, rather than as a + * failure of `SELECT * FROM (…) AS fox_page` — without fetching a row. * - * Used only after a page wrap fails. A missing table then comes back as the - * statement they wrote, instead of as a failure of `SELECT * FROM (…) AS fox_page`. - * Statements that already limit themselves are probed as written. + * It used to append `LIMIT 1` and run the statement. That ran it unbounded + * whenever the limit did not take: a trailing `-- comment` swallowed it, and + * a `limit` anywhere in the text (a subquery, a string) skipped it. On MySQL + * the wrapper also fails for valid joins — two `id` columns in a derived + * table — so that was a whole join materialised to report an error the + * wrapper had already given. Paging fails closed so nothing is ever read + * unbounded; a probe must not reopen that. */ export function syntaxProbeSql(sql: string, dialect: string): string | null { - if (!LIMIT_ONE_DIALECTS.has(dialect.toLowerCase())) return null; + if (!EXPLAIN_PROBE_DIALECTS.has(dialect.toLowerCase())) return null; const trimmed = sql.trim().replace(/;+\s*$/, ''); if (!trimmed) return null; - if (/\blimit\b/i.test(trimmed)) return trimmed; - return `${trimmed} LIMIT 1`; + return `EXPLAIN ${trimmed}`; } export function wrapSqlForPage( diff --git a/packages/server/src/features/workflow/workflow.routes.test.ts b/packages/server/src/features/workflow/workflow.routes.test.ts index 1e22475d3..40a37dff2 100644 --- a/packages/server/src/features/workflow/workflow.routes.test.ts +++ b/packages/server/src/features/workflow/workflow.routes.test.ts @@ -604,3 +604,78 @@ describe('engine proxy — saved connections stay with their workspace', () => { expect(res.statusCode).toBe(200); }); }); + +describe('engine proxy — every route that reaches a foreign connection refuses', () => { + const usesForeign = { + id: 'wf-1', + pipelines: [{ id: 'main', pipes: [{ id: 'q', type: 'source.db.sql', credentialId: 'foxschema-conn-a', config: {} }] }], + }; + const engineWith = (workflow: unknown) => (call: Call) => { + const path = new URL(call.url).pathname; + if (call.init.method === 'GET' && (path === '/api/workflows/wf-1' || path === '/api/runs/r1/snapshot')) { + return Response.json(workflow); + } + return Response.json({ ok: true }); + }; + const all = ['workflow.access', 'workflow.design', 'workflow.run'] as Permission[]; + + // Steering a run reaches what the run's connection returned (a human gate's + // question can quote rows), so it is held to the run's snapshot like reading. + it.each([ + ['cancel', 'POST', '/api/workflow/engine/runs/r1/cancel'], + ['resume', 'POST', '/api/workflow/engine/runs/r1/resume'], + ['read the gate questions of', 'GET', '/api/workflow/engine/runs/r1/inputs'], + ['answer the gate of', 'POST', '/api/workflow/engine/runs/r1/inputs'], + ['read the detail of', 'GET', '/api/workflow/engine/runs/r1'], + ['read the failure summary of', 'GET', '/api/workflow/engine/runs/r1/failure'], + ['extract a pipeline out of', 'POST', '/api/workflow/engine/workflows/wf-1/extract-pipeline'], + ] as const)('refuses to %s a run or workflow using it', async (_label, method, url) => { + const { server, calls } = await serve(all, { foreignConnections: ['conn-a'], respond: engineWith(usesForeign) }); + const res = await server.inject({ method, url, payload: method === 'POST' ? {} : undefined }); + expect(res.statusCode, `${method} ${url}`).toBe(403); + expect(calls.every((call) => call.init.method === 'GET')).toBe(true); + expect(calls.map((call) => new URL(call.url).pathname)).not.toContain(url.replace('/api/workflow/engine', '/api')); + }); + + it('refuses an import whose package names the connection, without asking the engine', async () => { + const { server, calls } = await serve(all, { foreignConnections: ['conn-a'] }); + const res = await server.inject({ + method: 'POST', + url: '/api/workflow/engine/workflows/import', + payload: { package: { format: 'foxflow.workflow', workflow: usesForeign } }, + }); + expect(res.statusCode).toBe(403); + expect(calls).toHaveLength(0); + }); + + it('finds the connection wherever the document names it — in a pipe’s config too', async () => { + const { server, calls } = await serve(all, { foreignConnections: ['conn-a'] }); + const res = await server.inject({ + method: 'PUT', + url: '/api/workflow/engine/workflows/wf-2', + payload: { + id: 'wf-2', + pipelines: [{ id: 'main', pipes: [{ id: 'q', type: 'sink.db.sql', config: { credentialId: 'foxschema-conn-a' } }] }], + }, + }); + expect(res.statusCode).toBe(403); + expect(calls).toHaveLength(0); + }); + + it('lets a member steer and read a run using the connection', async () => { + const { server, calls } = await serve(all, { respond: engineWith(usesForeign) }); + for (const [method, url] of [ + ['POST', '/api/workflow/engine/runs/r1/cancel'], + ['GET', '/api/workflow/engine/runs/r1'], + ] as const) { + const res = await server.inject({ method, url, payload: method === 'POST' ? {} : undefined }); + expect(res.statusCode, `${method} ${url}`).toBe(200); + } + expect(calls.map((call) => `${call.init.method} ${new URL(call.url).pathname}`)).toEqual([ + 'GET /api/runs/r1/snapshot', + 'POST /api/runs/r1/cancel', + 'GET /api/runs/r1/snapshot', + 'GET /api/runs/r1', + ]); + }); +}); diff --git a/packages/sql/src/index.ts b/packages/sql/src/index.ts index 71dbe53ef..db9ebe85e 100644 --- a/packages/sql/src/index.ts +++ b/packages/sql/src/index.ts @@ -286,6 +286,7 @@ export { splitSqlStatements, checkStatement, isWriteStatement, + isTransactionControl, requiresWritePermission, sqlStatementCategory, sqlStatementCategories, @@ -414,6 +415,7 @@ export { getProviderSettings, isFileDialect, dialectFamily, + dialectHasTransactions, DIALECTS, type Dialect, } from './providers/provider-settings.js'; diff --git a/packages/sql/src/interfaces/schema-provider.interface.ts b/packages/sql/src/interfaces/schema-provider.interface.ts index 5ce57a917..30f5cfc0b 100644 --- a/packages/sql/src/interfaces/schema-provider.interface.ts +++ b/packages/sql/src/interfaces/schema-provider.interface.ts @@ -83,6 +83,12 @@ export interface ProviderConnectionSettings { defaultPort: number; defaultSchema?: string; schemaRequired: boolean; + /** + * `false` when the engine has no transaction to roll back: its adapter's + * begin, commit and rollback do nothing. Absent means it has one. Read it + * through `dialectHasTransactions`. + */ + transactions?: boolean; buildConnectionString(option: ConnectionOptions): string; } diff --git a/packages/sql/src/modules/sql-text/sql-splitter.ts b/packages/sql/src/modules/sql-text/sql-splitter.ts index 3c102b049..790013f82 100644 --- a/packages/sql/src/modules/sql-text/sql-splitter.ts +++ b/packages/sql/src/modules/sql-text/sql-splitter.ts @@ -831,6 +831,43 @@ export function isWriteStatement(text: string, dialect?: string): boolean { return someReading(text, dialect, (statements) => statements.some((s) => !s.cell && sqlTextIsWrite(s.text))); } +/** + * A statement that opens, ends or abandons a transaction: `BEGIN` (bare, or + * with TRANSACTION/WORK/TRAN and SQLite's DEFERRED/IMMEDIATE/EXCLUSIVE), + * `START TRANSACTION`, `COMMIT`, `END` (Postgres' COMMIT), and `ROLLBACK` — + * but not `ROLLBACK TO SAVEPOINT`, which stays inside the transaction. A + * procedural `BEGIN … END` block is not one. + * + * A caller that wraps a batch in a transaction of its own must not run these + * inside it: a COMMIT halfway makes everything before it permanent, so a later + * failure can no longer roll the batch back — while the caller reports that + * it did. + */ +export function isTransactionControl(text: string): boolean { + const keyword = firstKeyword(text); + if (keyword === 'commit' || keyword === 'end') return true; + if (keyword !== 'begin' && keyword !== 'start' && keyword !== 'rollback') return false; + const words = stripSqlStringsAndComments(text) + .replace(/;/g, ' ') + .trim() + .toLowerCase() + .split(/\s+/); + if (keyword === 'start') return words[1] === 'transaction'; + if (keyword === 'rollback') { + // ROLLBACK [WORK | TRANSACTION | TRAN] TO [SAVEPOINT] s stays inside it. + const next = TRANSACTION_NOUNS.has(words[1] ?? '') ? words[2] : words[1]; + return next !== 'to'; + } + // BEGIN [DEFERRED | IMMEDIATE | EXCLUSIVE | DISTRIBUTED] [TRANSACTION | TRAN | WORK] … + // Anything else after BEGIN is a procedural block. + let at = 1; + if (BEGIN_MODES.has(words[at] ?? '')) at++; + return at >= words.length || TRANSACTION_NOUNS.has(words[at] ?? ''); +} + +const TRANSACTION_NOUNS = new Set(['transaction', 'tran', 'work']); +const BEGIN_MODES = new Set(['deferred', 'immediate', 'exclusive', 'distributed']); + /** Recursive write check used for nested WITH bodies (no code-cell gate). */ function sqlTextIsWrite(text: string): boolean { const kw = firstKeyword(text); diff --git a/packages/sql/src/modules/sql-text/transaction-control.test.ts b/packages/sql/src/modules/sql-text/transaction-control.test.ts new file mode 100644 index 000000000..396f25685 --- /dev/null +++ b/packages/sql/src/modules/sql-text/transaction-control.test.ts @@ -0,0 +1,56 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Which statements open, end or abandon a transaction — the ones a batch run + * as one transaction must not contain. + */ +import { describe, expect, it } from 'vitest'; +import { isTransactionControl } from './sql-splitter.js'; + +describe('isTransactionControl', () => { + it.each([ + 'BEGIN', + 'begin;', + 'BEGIN TRANSACTION', + 'BEGIN WORK;', + 'BEGIN TRAN', + 'BEGIN TRANSACTION payroll', + 'BEGIN DISTRIBUTED TRANSACTION', + 'BEGIN IMMEDIATE', + 'BEGIN EXCLUSIVE TRANSACTION;', + 'BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE', + 'START TRANSACTION', + 'start transaction read only;', + 'COMMIT', + 'COMMIT WORK;', + 'END;', + 'ROLLBACK', + 'ROLLBACK TRANSACTION;', + '-- leave it here\nCOMMIT;', + '/* done */ ROLLBACK', + ])('%j controls the transaction', (sql) => { + expect(isTransactionControl(sql)).toBe(true); + }); + + it.each([ + // Stays inside the transaction. + 'ROLLBACK TO SAVEPOINT before_load', + 'ROLLBACK TO before_load', + 'ROLLBACK WORK TO SAVEPOINT s1', + 'SAVEPOINT before_load', + 'RELEASE SAVEPOINT before_load', + // Procedural blocks, not transaction control. + 'BEGIN EXECUTE IMMEDIATE \'DROP TABLE t\'; END', + 'BEGIN\n UPDATE t SET x = 1;\nEND;', + 'DECLARE n INTEGER; BEGIN NULL; END;', + // Ordinary statements, including ones that only mention the words. + "SELECT 'COMMIT' AS word", + 'INSERT INTO log (msg) VALUES (\'begin transaction\')', + 'START SLAVE', + 'UPDATE t SET ended = 1', + ])('%j does not', (sql) => { + expect(isTransactionControl(sql)).toBe(false); + }); +}); diff --git a/packages/sql/src/modules/utilities/index-fragmentation.test.ts b/packages/sql/src/modules/utilities/index-fragmentation.test.ts index 9ff40bb04..816cf3608 100644 --- a/packages/sql/src/modules/utilities/index-fragmentation.test.ts +++ b/packages/sql/src/modules/utilities/index-fragmentation.test.ts @@ -803,10 +803,24 @@ describe('defrag statements the engines actually accept', () => { expect(note).toMatch(/no single-index REORG/i); }); + it('Db2 escapes a name for both the identifier and the ADMIN_CMD string it sits in', () => { + // `"` doubles inside the delimited identifier; `'` then doubles because the + // whole command is one string literal. Missing either layer ends a quote + // early — and lets the rest of the name run as part of the command. + const [reorg, stats] = defrag('db2', 'DEMO_A', `O'Brien"s`, 'IDX'); + expect(reorg).toBe(`CALL SYSPROC.ADMIN_CMD('REORG INDEXES ALL FOR TABLE "DEMO_A"."O''Brien""s"');`); + expect(stats).toBe(`CALL SYSPROC.ADMIN_CMD('RUNSTATS ON TABLE "DEMO_A"."O''Brien""s" AND INDEXES ALL');`); + }); + + it('Db2 names the table alone when there is no schema', () => { + const [reorg] = defrag('db2', ' ', 'ORDERS', 'IDX'); + expect(reorg).toBe(`CALL SYSPROC.ADMIN_CMD('REORG INDEXES ALL FOR TABLE "ORDERS"');`); + }); + it('Db2 folds a typed schema to the uppercase name the catalog stores', () => { // Live server: quoting the typed case (`"demo_a"."ORDERS"`, `"App"."ORDERS"`) // returns SQL2211N. The probe had already found the table as DEMO_A / APP. - const [lower] = defrag('db2', 'demo_a', 'orders', 'IDX'); + const [lower] = defrag('db2', 'demo_a', 'ORDERS', 'IDX'); expect(lower).toBe( `CALL SYSPROC.ADMIN_CMD('REORG INDEXES ALL FOR TABLE "DEMO_A"."ORDERS"');` ); @@ -819,6 +833,27 @@ describe('defrag statements the engines actually accept', () => { expect(mixed).toContain('"DEMO_A"."MyTable"'); }); + it('Db2 keeps a table name the catalog stores in lowercase', () => { + // The table comes from schema browse, which reads SYSCAT.TABLES: `orders` + // there is a table created as `"orders"`. Folding it to ORDERS reorganised + // a different table when one by that name existed, and SQL2211N when not. + // The probe has to look up the same name, or the % on screen belongs to + // that other table too. + const [reorg, stats] = defrag('db2', 'demo_a', 'orders', 'IDX'); + expect(reorg).toBe(`CALL SYSPROC.ADMIN_CMD('REORG INDEXES ALL FOR TABLE "DEMO_A"."orders"');`); + expect(stats).toBe(`CALL SYSPROC.ADMIN_CMD('RUNSTATS ON TABLE "DEMO_A"."orders" AND INDEXES ALL');`); + const probe = buildIndexFragmentationQuery({ dialect: 'db2', schema: 'demo_a', table: 'orders' }); + if ('error' in probe) throw new Error(probe.error); + expect(probe.params).toEqual(['DEMO_A', 'orders']); + const template = buildIndexFragmentationCustomTemplate({ dialect: 'db2', schema: 'demo_a', table: 'orders' }); + expect(template).toContain(`WHERE TABSCHEMA = 'DEMO_A' AND TABNAME = 'orders'`); + }); + + it('Db2 custom template doubles a quote in the names it embeds', () => { + const template = buildIndexFragmentationCustomTemplate({ dialect: 'db2', schema: 'DEMO_A', table: `O'Brien` }); + expect(template).toContain(`TABNAME = 'O''Brien'`); + }); + it('Db2 doubles a quote inside the ADMIN_CMD literal', () => { // The table name sits inside a string literal; an unescaped quote would // end it early and change the command. diff --git a/packages/sql/src/providers/DIALECTS.md b/packages/sql/src/providers/DIALECTS.md index 0f568077d..930188c32 100644 --- a/packages/sql/src/providers/DIALECTS.md +++ b/packages/sql/src/providers/DIALECTS.md @@ -137,6 +137,10 @@ Each dialect lives in `providers//.sql-dialect.ts`. ### ClickHouse (`clickhouse.sql-dialect.ts`) - `nullableTypeWrapper` ⇒ `Nullable(T)`; generator skips `NULL`/`NOT NULL` keywords. - No traditional indexes/triggers/FKs — those hooks are absent by design. +- No transactions: the adapter's begin/commit/rollback are no-ops, so + `clickhouse.settings.ts` sets `transactions: false` (as Redis and MongoDB do). + `POST /sql/execute` refuses `transaction: true` there and the editor runs the + batch statement by statement with a warning. ### Wire-compatible variants — CockroachDB / YugabyteDB / TiDB - **CockroachDB** (`cockroachDb/`) and **YugabyteDB** (`yugabyteDb/`) are PostgreSQL diff --git a/packages/sql/src/providers/clickHouse/clickhouse.settings.ts b/packages/sql/src/providers/clickHouse/clickhouse.settings.ts index 2813fa8e2..727c76448 100644 --- a/packages/sql/src/providers/clickHouse/clickhouse.settings.ts +++ b/packages/sql/src/providers/clickHouse/clickhouse.settings.ts @@ -6,6 +6,9 @@ export const clickHouseSettings: ProviderConnectionSettings = { defaultPort: 8123, defaultSchema: 'default', schemaRequired: true, + // Transactions are experimental in ClickHouse and the adapter does not open + // one: every statement commits as it runs, so there is nothing to roll back. + transactions: false, buildConnectionString(option: ConnectionOptions): string { if (option.connectionString?.trim()) return option.connectionString.trim(); diff --git a/packages/sql/src/providers/db2/db2.index-fragmentation.ts b/packages/sql/src/providers/db2/db2.index-fragmentation.ts index 9a939f396..41c8ec0a8 100644 --- a/packages/sql/src/providers/db2/db2.index-fragmentation.ts +++ b/packages/sql/src/providers/db2/db2.index-fragmentation.ts @@ -15,19 +15,17 @@ import { /** * Qualified table for a REORG/RUNSTATS command. * - * Schema is always folded: the connection field is what the user typed, and - * schema browse looks that name up with `toUpperCase()`. A table name that is - * already one case is folded the same way. Mixed case is kept, because that - * is how a delimited name comes back from SYSCAT. + * The schema is folded: it is what the user typed in the connection field, + * and schema browse looks it up with `toUpperCase()`. The table is not: every + * caller passes the name schema browse read from SYSCAT.TABLES, which is + * already what the catalog stores. Folding it too would aim a table created + * as `"orders"` at `ORDERS` — SQL2211N if there is none, or someone else's + * table if there is. */ function db2ReorgTarget(schema: string, table: string): string { - const quote = (name: string, fold: boolean) => { - const trimmed = name.trim(); - const singleCase = trimmed === trimmed.toLowerCase() || trimmed === trimmed.toUpperCase(); - const raw = fold || singleCase ? trimmed.toUpperCase() : trimmed; - return '"' + raw.replace(/"/g, '""') + '"'; - }; - const qualified = schema.trim() ? `${quote(schema, true)}.${quote(table, false)}` : quote(table, false); + const quote = (name: string) => '"' + name.replace(/"/g, '""') + '"'; + const folded = schema.trim().toUpperCase(); + const qualified = folded ? `${quote(folded)}.${quote(table.trim())}` : quote(table.trim()); return qualified.replace(/'/g, "''"); } @@ -64,7 +62,8 @@ export const db2IndexFragmentation: IndexFragmentationDialect = { if (!target.schema) return { error: 'DB2 fragmentation probe needs a schema name.' }; return { mode: 'estimated', - params: [target.schema.toUpperCase(), target.table.toUpperCase()], + // The same names REORG uses, below: schema folded, table as the catalog has it. + params: [target.schema.toUpperCase(), target.table.trim()], sql: PROBE_SQL, }; }, @@ -83,13 +82,12 @@ export const db2IndexFragmentation: IndexFragmentationDialect = { // 2. REORG is a command, not SQL. Sent over a driver connection it is // rejected by the statement parser (SQL0104N, "expected tokens may // include: JOIN"). It has to be handed to SYSPROC.ADMIN_CMD. - // 3. Unquoted names are stored in uppercase. Schema browse and the - // fragmentation probe already look them up that way, but quoting the - // typed case (`"demo_a"."ORDERS"`, `"App"."ORDERS"`) makes REORG answer - // SQL2211N "The specified table does not exist" for a table the probe - // just listed. Fold the schema, and a single-case table name, before - // quoting. A mixed-case table name came back from the catalog as a - // delimited identifier and must keep that case. + // 3. Unquoted names are stored in uppercase. Schema browse looks the + // typed schema up that way, but quoting the typed case + // (`"demo_a"."ORDERS"`, `"App"."ORDERS"`) makes REORG answer SQL2211N + // "The specified table does not exist" for a table the probe just + // listed. Fold the schema before quoting. The table name came from + // the catalog and keeps its case (see db2ReorgTarget). // // The name is embedded in a string literal, so any single quote in an // identifier has to be doubled or it would end the literal early. @@ -106,13 +104,14 @@ export const db2IndexFragmentation: IndexFragmentationDialect = { return [`DROP INDEX ${quoteIndexTarget(target).indexQualified(indexName)};`]; }, customTemplate(target) { - const sch = target.schema || 'schema'; - const tbl = target.table || 'table'; + const literal = (name: string) => name.replace(/'/g, "''"); + const sch = literal((target.schema || 'schema').toUpperCase()); + const tbl = literal(target.table || 'table'); return `SELECT INDNAME AS index_name, DECIMAL(100.0 * FLOAT(COALESCE(NUM_EMPTY_LEAFS,0)) / NULLIF(FLOAT(NLEAF),0), 5, 2) AS fragmentation_percent, CASE WHEN LASTUSED IS NULL OR LASTUSED <= DATE('1971-01-01') THEN NULL ELSE LASTUSED END AS last_used FROM SYSCAT.INDEXES -WHERE TABSCHEMA = '${sch.toUpperCase()}' AND TABNAME = '${tbl.toUpperCase()}';`; +WHERE TABSCHEMA = '${sch}' AND TABNAME = '${tbl}';`; }, }; diff --git a/packages/sql/src/providers/dialect-registry.test.ts b/packages/sql/src/providers/dialect-registry.test.ts index 28621c267..a53de56ac 100644 --- a/packages/sql/src/providers/dialect-registry.test.ts +++ b/packages/sql/src/providers/dialect-registry.test.ts @@ -13,7 +13,13 @@ * reads like a guard while guarding nothing. */ import { describe, expect, it } from 'vitest'; -import { DIALECTS, PROVIDER_SETTINGS, getProviderSettings, isFileDialect } from './provider-settings.js'; +import { + DIALECTS, + PROVIDER_SETTINGS, + dialectHasTransactions, + getProviderSettings, + isFileDialect, +} from './provider-settings.js'; describe('the dialect union matches the registry', () => { it('lists exactly the registered dialects', () => { @@ -50,3 +56,19 @@ describe('isFileDialect', () => { expect(isFileDialect('SQLite')).toBe(true); }); }); + +describe('dialectHasTransactions', () => { + // Their adapters open no transaction, so "one transaction" there commits + // each statement as it runs and a failure rolls nothing back. + const NONE = ['clickhouse', 'mongodb', 'redis']; + + it('is false for exactly the engines with nothing to roll back', () => { + expect(DIALECTS.filter((dialect) => !dialectHasTransactions(dialect)).sort()).toEqual(NONE); + }); + + it('is case-insensitive, and true for a dialect it does not know', () => { + expect(dialectHasTransactions('ClickHouse')).toBe(false); + expect(dialectHasTransactions('editor')).toBe(true); + expect(dialectHasTransactions('')).toBe(true); + }); +}); diff --git a/packages/sql/src/providers/mongodb/mongodb.settings.ts b/packages/sql/src/providers/mongodb/mongodb.settings.ts index b6a4cedc1..07e25481c 100644 --- a/packages/sql/src/providers/mongodb/mongodb.settings.ts +++ b/packages/sql/src/providers/mongodb/mongodb.settings.ts @@ -22,6 +22,9 @@ export const mongoDbSettings: ProviderConnectionSettings = { label: 'MongoDB', defaultPort: 27017, schemaRequired: false, + // MongoDB has sessions with transactions on a replica set, but the adapter + // does not start one: each translated statement is final as it runs. + transactions: false, buildConnectionString(option: ConnectionOptions): string { // mongodb+srv:// URLs carry their own host list and no port, so a diff --git a/packages/sql/src/providers/provider-settings.ts b/packages/sql/src/providers/provider-settings.ts index 4efa45146..a4ffa35ef 100644 --- a/packages/sql/src/providers/provider-settings.ts +++ b/packages/sql/src/providers/provider-settings.ts @@ -134,6 +134,17 @@ export function dialectFamily(dialect: string): string { return Object.hasOwn(DIALECT_FAMILY, d) ? DIALECT_FAMILY[d as Dialect]! : d; } +/** + * Whether a batch on this engine can be rolled back. ClickHouse, Redis and + * MongoDB answer `false`: their adapters open no transaction, so a "one + * transaction" run there commits each statement as it goes, and a failure + * leaves the earlier ones in place while the editor reported a rollback. + * An unknown dialect answers `true`; it is refused elsewhere. + */ +export function dialectHasTransactions(dialect: string): boolean { + return PROVIDER_SETTINGS[(dialect || '').toLowerCase()]?.transactions !== false; +} + export function isFileDialect(dialect: string): boolean { const name = dialect.toLowerCase(); return name === 'sqlite' || name === 'duckdb'; diff --git a/packages/sql/src/providers/redis/redis.settings.ts b/packages/sql/src/providers/redis/redis.settings.ts index cd5117a1c..d81700943 100644 --- a/packages/sql/src/providers/redis/redis.settings.ts +++ b/packages/sql/src/providers/redis/redis.settings.ts @@ -20,6 +20,9 @@ export const redisSettings: ProviderConnectionSettings = { defaultPort: 6379, defaultSchema: '0', schemaRequired: false, + // MULTI/EXEC queues commands; it cannot undo one that ran. The adapter opens + // nothing, so every command is final as it runs. + transactions: false, buildConnectionString(option: ConnectionOptions): string { if (option.connectionString?.trim()) return option.connectionString.trim(); diff --git a/packages/ui-shared/src/index.ts b/packages/ui-shared/src/index.ts index a4cdcd7b5..fb8690427 100644 --- a/packages/ui-shared/src/index.ts +++ b/packages/ui-shared/src/index.ts @@ -34,7 +34,8 @@ export type { // Engines: connection settings, the registry, and wire-compatible families. export { - DIALECTS, IBM_DB_VERSION, PROVIDER_SETTINGS, dialectFamily, getProviderSettings, isFileDialect, + DIALECTS, IBM_DB_VERSION, PROVIDER_SETTINGS, dialectFamily, dialectHasTransactions, getProviderSettings, + isFileDialect, } from '@foxschema/sql'; export type { Dialect } from '@foxschema/sql'; @@ -67,7 +68,7 @@ export { checkStatement, codeCellHasReturn, codeCellNeedsTs, collectMultiTableWriteWarnings, countReferencedTables, dmlLacksWhere, extractTableAliases, findCodeFences, firstKeyword, isCodeCellKind, isInsertWriteStatement, isMutatingDmlStatement, isNodeCodeCellKind, - isPageableStatement, isSqlQuery, isWriteStatement, makeSqlQuery, nodeCodeCellWireKind, + isPageableStatement, isSqlQuery, isTransactionControl, isWriteStatement, makeSqlQuery, nodeCodeCellWireKind, parseCodeCell, placeholderStyleFor, qualifiedNameParts, quoteIdentifierIfNeeded, quoteSqlIdentifier, referencedTableNames, renderSqlQuery, requiresWritePermission, splitSqlStatements, sqlTag, statementVerb, stripCodeFenceMarkers, stripFullLineSqlComments,