From 4096b2e3190c1a7da49a4482d2c30fead7d89bda Mon Sep 17 00:00:00 2001 From: huyplb Date: Sat, 17 Oct 2026 09:46:24 -0600 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20follow-ups=20from=20reviewing=20#531?= =?UTF-8?q?=E2=80=93#533,=20with=20the=20tests=20they=20lacked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One transaction (#531). A batch with its own BEGIN, COMMIT or ROLLBACK ended the wrapping transaction halfway: on SQLite a COMMIT made the statements before it permanent, the later failure rolled back nothing, and the editor reported "Not run — the transaction rolled back". The server now refuses a one-transaction batch that steers its own transaction (isTransactionControl, shared from @foxschema/sql), and the editor runs such a batch statement by statement with a warning. Savepoints stay allowed. Partial applies (#531). runAccessSql stops at the first failure, so the statements before it stay applied, but Access and Database Access kept showing the grants as they were. The outcome now carries `applied`; both re-read the catalog when it is above zero, and set the error after the reload, which used to clear it. Paging probe (#532). After a failed page wrap the probe appended LIMIT 1 and ran the statement, unbounded whenever the limit did not take: a trailing `-- comment` swallowed it, a `limit` elsewhere (a subquery, a string) skipped it. On MySQL the wrapper also fails for valid joins with two `id` columns, so a whole join was materialised to report an error. The probe is now plain EXPLAIN: same "no such table" in the database's words, nothing run. Workspace editor storage (#533). The editor's pointer was claimed once and never followed the server, so after a switch in another browser, removal from a workspace, or another account signing in here, the editor showed and saved into the old workspace's scripts. followServerWorkspace moves the pointer and the rail reloads once. Tests added for gaps: proxy refusals for the steering routes, import, extract-pipeline, run detail and failure, and a credential named in a pipe's config (#531); Db2 REORG/RUNSTATS escaping for both quote layers and the no-schema case (#532). Every new test fails on the code before it. Co-Authored-By: Claude Opus 5.5 --- .../components/AccessPermissionPanel.test.tsx | 20 ++++- .../components/AccessPermissionPanel.tsx | 4 + .../useSqlEditorStore.transaction.test.ts | 19 +++++ .../sql-editor/state/useSqlEditorStore.ts | 15 +++- .../components/DatabaseAccessModal.tsx | 7 ++ .../components/WorkspaceRailButton.test.tsx | 27 +++++++ .../components/WorkspaceRailButton.tsx | 20 ++++- .../src/frontend/shared/api/accessSql.test.ts | 4 +- apps/web/src/frontend/shared/api/accessSql.ts | 19 +++-- .../shared/lib/editorWorkspaceStorage.test.ts | 29 +++++++ .../shared/lib/editorWorkspaceStorage.ts | 28 +++++++ .../src/frontend/shared/lib/sql-splitter.ts | 1 + .../src/features/sql-editor/editor.routes.ts | 13 +++- .../sql-editor/sql-execute-gate.test.ts | 32 ++++++++ .../sql-editor/sql-execute.service.test.ts | 45 ++++++++++- .../sql-editor/sql-execute.service.ts | 6 +- .../sql-editor/sql-page-wrap.service.ts | 23 ++++-- .../features/workflow/workflow.routes.test.ts | 75 +++++++++++++++++++ packages/sql/src/index.ts | 1 + .../sql/src/modules/sql-text/sql-splitter.ts | 37 +++++++++ .../sql-text/transaction-control.test.ts | 56 ++++++++++++++ .../utilities/index-fragmentation.test.ts | 14 ++++ packages/ui-shared/src/index.ts | 2 +- 23 files changed, 467 insertions(+), 30 deletions(-) create mode 100644 packages/sql/src/modules/sql-text/transaction-control.test.ts 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..311e04cce 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 @@ -120,4 +120,23 @@ 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('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..fcebfa88b 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'; @@ -1285,15 +1286,23 @@ 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)); + 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.' + ); } - const asOneTransaction = wantsTransaction && !transactionBlocked; + const asOneTransaction = wantsTransaction && !hasCellOrSet && !steersOwnTransaction; 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/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/packages/server/src/features/sql-editor/editor.routes.ts b/packages/server/src/features/sql-editor/editor.routes.ts index 999c96f8c..ad85889ac 100644 --- a/packages/server/src/features/sql-editor/editor.routes.ts +++ b/packages/server/src/features/sql-editor/editor.routes.ts @@ -21,7 +21,7 @@ import { isDatagridAction, type Permission, } from '@foxschema/shared'; -import { sqlStatementCategories, statementVerb, errorMessage } from '@foxschema/sql'; +import { sqlStatementCategories, statementVerb, errorMessage, isTransactionControl } 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 +101,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. 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..80116f319 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,35 @@ 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('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..6af4180cf 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, 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..a9dfbc53c 100644 --- a/packages/sql/src/modules/utilities/index-fragmentation.test.ts +++ b/packages/sql/src/modules/utilities/index-fragmentation.test.ts @@ -803,6 +803,20 @@ 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. diff --git a/packages/ui-shared/src/index.ts b/packages/ui-shared/src/index.ts index a4cdcd7b5..46b1607a1 100644 --- a/packages/ui-shared/src/index.ts +++ b/packages/ui-shared/src/index.ts @@ -67,7 +67,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, From de663cab4cbd1346850cc27582e6fb59191ddf7a Mon Sep 17 00:00:00 2001 From: huyplb Date: Sat, 17 Oct 2026 10:49:49 -0600 Subject: [PATCH 2/2] fix: Db2 reorgs the table the catalog names, and One transaction refuses engines with none Db2. db2ReorgTarget folded any single-case table name to uppercase, and the probe always did. Every caller passes the name schema browse read from SYSCAT.TABLES, so `orders` there is a table created as `"orders"`: REORG and RUNSTATS hit ORDERS instead (SQL2211N, or another table when one by that name exists) and the percentage on screen was that table's too. The schema is still folded (it is what the user typed); the table is quoted as the catalog has it in the probe, REORG, RUNSTATS and the custom template, which also doubles quotes now. One transaction. ClickHouse, Redis and MongoDB adapters open no transaction, so a "one transaction" run committed each statement as it went and a failure left the earlier ones while the editor reported a rollback. Their settings now say `transactions: false` (read via dialectHasTransactions); POST /sql/execute refuses transaction:true there, and the editor runs the batch statement by statement with a warning naming the engine. Every new test fails with its piece removed (store fallback, route check, each engine's flag, the Db2 fold). Co-Authored-By: Claude Opus 5.5 --- .../useSqlEditorStore.transaction.test.ts | 16 ++++++- .../sql-editor/state/useSqlEditorStore.ts | 12 +++++- .../frontend/shared/lib/provider-settings.ts | 1 + docs/ARCHITECTURE.md | 3 ++ .../src/features/sql-editor/editor.routes.ts | 20 ++++++++- .../sql-editor/sql-execute-gate.test.ts | 21 +++++++++ packages/sql/src/index.ts | 1 + .../interfaces/schema-provider.interface.ts | 6 +++ .../utilities/index-fragmentation.test.ts | 25 ++++++++++- packages/sql/src/providers/DIALECTS.md | 4 ++ .../clickHouse/clickhouse.settings.ts | 3 ++ .../providers/db2/db2.index-fragmentation.ts | 43 +++++++++---------- .../src/providers/dialect-registry.test.ts | 24 ++++++++++- .../src/providers/mongodb/mongodb.settings.ts | 3 ++ .../sql/src/providers/provider-settings.ts | 11 +++++ .../sql/src/providers/redis/redis.settings.ts | 3 ++ packages/ui-shared/src/index.ts | 3 +- 17 files changed, 168 insertions(+), 31 deletions(-) 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 311e04cce..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], @@ -132,6 +133,17 @@ describe('SQL editor one transaction', () => { 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]); 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 fcebfa88b..cf259b769 100644 --- a/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts +++ b/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts @@ -83,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'; @@ -1293,6 +1293,10 @@ export const useSqlEditorStore = create()( // 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.' @@ -1301,8 +1305,12 @@ export const useSqlEditorStore = create()( 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 && !hasCellOrSet && !steersOwnTransaction; + 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/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/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 ad85889ac..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, isTransactionControl } 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'; @@ -123,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 80116f319..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 @@ -135,6 +135,27 @@ describe('POST /sql/execute as one transaction', () => { }, ); + 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)'], diff --git a/packages/sql/src/index.ts b/packages/sql/src/index.ts index 6af4180cf..db9ebe85e 100644 --- a/packages/sql/src/index.ts +++ b/packages/sql/src/index.ts @@ -415,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/utilities/index-fragmentation.test.ts b/packages/sql/src/modules/utilities/index-fragmentation.test.ts index a9dfbc53c..816cf3608 100644 --- a/packages/sql/src/modules/utilities/index-fragmentation.test.ts +++ b/packages/sql/src/modules/utilities/index-fragmentation.test.ts @@ -813,14 +813,14 @@ describe('defrag statements the engines actually accept', () => { }); it('Db2 names the table alone when there is no schema', () => { - const [reorg] = defrag('db2', ' ', 'orders', 'IDX'); + 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"');` ); @@ -833,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 46b1607a1..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';