);
};
diff --git a/apps/web/src/frontend/features/sql-editor/lib/completion.ts b/apps/web/src/frontend/features/sql-editor/lib/completion.ts
index 294710858..979413b5b 100644
--- a/apps/web/src/frontend/features/sql-editor/lib/completion.ts
+++ b/apps/web/src/frontend/features/sql-editor/lib/completion.ts
@@ -25,6 +25,21 @@ let registered = false;
/** Rebuilt when schemaCache revision changes. */
let cachedTries: SchemaTrieBundle | null = null;
+function objectTypeOf(
+ schemas: ReturnType['schemas'],
+ name: string
+): string | undefined {
+ const key = name.toLowerCase();
+ for (const src of schemas) {
+ for (const table of src.tables) {
+ const full = table.name.toLowerCase();
+ const bare = full.includes('.') ? full.slice(full.lastIndexOf('.') + 1) : full;
+ if (full === key || bare === key) return table.objectType;
+ }
+ }
+ return undefined;
+}
+
function triesFor(schemas: ReturnType['schemas']): SchemaTrieBundle {
const rev = schemaRevision(schemas);
if (cachedTries && cachedTries.revision === rev) return cachedTries;
@@ -271,6 +286,18 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void {
return {
suggestions: names.map((name) => {
const bare = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : name;
+ const kind = objectTypeOf(schemas, name);
+ if (kind === 'FUNCTION' || kind === 'PROCEDURE') {
+ return {
+ label: name,
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: `${name}()`,
+ filterText: name,
+ detail: `${kind.toLowerCase()} · ${dot[1]!}`,
+ sortText: `1_${name}`,
+ range: colRange,
+ };
+ }
if (fromTablePos) {
const alias = suggestTableAlias(bare, takenAliases);
takenAliases.add(alias);
diff --git a/apps/web/src/frontend/features/sql-editor/lib/completionSchemaDot.test.ts b/apps/web/src/frontend/features/sql-editor/lib/completionSchemaDot.test.ts
index 77a3744c7..6170b5909 100644
--- a/apps/web/src/frontend/features/sql-editor/lib/completionSchemaDot.test.ts
+++ b/apps/web/src/frontend/features/sql-editor/lib/completionSchemaDot.test.ts
@@ -109,6 +109,22 @@ describe('schema. lists the tables in that schema', () => {
expect(orders?.detail).toBe('table · demo_a');
});
+ it('lists a function without inventing a table alias', () => {
+ context([
+ {
+ ...demoA,
+ tables: [
+ ...demoA.tables,
+ { name: 'tax_total', objectType: 'FUNCTION', columns: [], parameters: [] },
+ ] as never,
+ },
+ ]);
+ const fn = complete('SELECT * FROM demo_a.').find((s) => s.insertText.startsWith('tax_total'));
+ expect(fn?.insertText).toBe('tax_total()');
+ expect(fn?.detail).toMatch(/^function · demo_a/);
+ expect(fn?.insertText).not.toMatch(/tax_total \w+$/);
+ });
+
it('gives each FROM table a unique alias when several are listed', () => {
const items = complete('SELECT * FROM demo_a.');
const aliases = items.map((s) => s.insertText.split(/\s+/)[1]).filter(Boolean);
diff --git a/apps/web/src/frontend/features/sql-editor/lib/completionTrie.ts b/apps/web/src/frontend/features/sql-editor/lib/completionTrie.ts
index d02065127..bb1558536 100644
--- a/apps/web/src/frontend/features/sql-editor/lib/completionTrie.ts
+++ b/apps/web/src/frontend/features/sql-editor/lib/completionTrie.ts
@@ -124,10 +124,20 @@ export function buildSchemaTries(
if (schemaKey && !tablesBySchema.has(schemaKey)) tablesBySchema.set(schemaKey, createTrie());
for (const t of src.tables) {
const ot = t.objectType;
- if (ot && ot !== 'TABLE' && ot !== 'VIEW' && ot !== 'MQT') continue;
- trieInsert(tables, t.name);
+ if (ot && ot !== 'TABLE' && ot !== 'VIEW' && ot !== 'MQT' && ot !== 'FUNCTION' && ot !== 'PROCEDURE') {
+ continue;
+ }
const lower = t.name.toLowerCase();
const bare = lower.includes('.') ? lower.slice(lower.lastIndexOf('.') + 1) : lower;
+ // Routines belong in `schema.` suggestions. They are not tables: putting
+ // them on the table trie made Ctrl+Space invent an alias for a function.
+ if (ot === 'FUNCTION' || ot === 'PROCEDURE') {
+ const routineDot = t.name.lastIndexOf('.');
+ if (routineDot > 0) intoSchema(t.name.slice(0, routineDot), t.name.slice(routineDot + 1));
+ else intoSchema(src.schema, t.name);
+ continue;
+ }
+ trieInsert(tables, t.name);
// Two ways a table belongs to a schema: the connection loaded it from
// there (names come back bare), or the name itself is qualified. They
// are alternatives, not both — indexing a qualified name under the
diff --git a/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.test.ts b/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.test.ts
index 209ea1e5d..c2b4f1e48 100644
--- a/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.test.ts
+++ b/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.test.ts
@@ -156,6 +156,13 @@ describe('selectClauseEdit', () => {
expect(again.alias).toBe(empty.alias);
});
+ it('starts a new SELECT when the previous statement is finished', () => {
+ const next = addTableWithAliasToFrom('SELECT * FROM DEMO_A.CATEGORIES cat;', 'COUPONS');
+ expect(next.added).toBe(true);
+ expect(next.sql).toBe('SELECT * FROM DEMO_A.CATEGORIES cat;\nSELECT *\nFROM COUPONS cou');
+ expect(next.sql).not.toContain('cat;,');
+ });
+
it('ensureTableHasAlias upgrades bare FROM and rewrites SELECT quals', () => {
const { sql, alias, changed } = ensureTableHasAlias(
'SELECT orders.created_at, orders.customer_id from orders',
diff --git a/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.ts b/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.ts
index 4fc532797..1be8f04b7 100644
--- a/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.ts
+++ b/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.ts
@@ -399,6 +399,15 @@ export function addTableWithAliasToFrom(
const nextAlias = alias || suggestTableAlias(bare, sql);
const fragment = `${tableIdent} ${nextAlias}`;
const trimmed = sql.trim();
+ // A finished statement stays finished. Clicking another table starts the
+ // next SELECT instead of writing `… cat;, other o` into the one already done.
+ if (trimmed.endsWith(';')) {
+ return {
+ sql: `${trimmed}\nSELECT *\nFROM ${fragment}`,
+ alias: nextAlias,
+ added: true,
+ };
+ }
if (!trimmed) {
return { sql: `SELECT *\nFROM ${fragment}`, alias: nextAlias, added: 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 a8090f3b4..7afcf1862 100644
--- a/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts
+++ b/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts
@@ -307,6 +307,11 @@ export interface PendingPasswordPrompt {
connectionIds?: string[];
/** When set, resume runs only these statement indices (per-cell Play). */
statementIndices?: number[];
+ /**
+ * After the password, check this connection and uncheck the others.
+ * The schema browser sets it so the query follows the tables on screen.
+ */
+ onlyThisDestination?: boolean;
}
export interface ReadonlyWriteTarget {
@@ -404,6 +409,12 @@ interface SqlEditorState {
* May open the session-password prompt when the credential has no stored password.
*/
ensureConnectionSelected: (id: string) => void;
+ /**
+ * Check exactly this destination. The schema browser uses it so the query
+ * runs on the connection whose tables you are looking at. Check more
+ * destinations afterwards to run the same SQL on several.
+ */
+ selectOnlyConnection: (id: string) => void;
setShareDestinations: (share: boolean) => void;
setSafeMode: (on: boolean) => void;
setOneTransaction: (on: boolean) => void;
@@ -685,6 +696,32 @@ export const useSqlEditorStore = create()(
void get().ensureSchema(id);
},
+ selectOnlyConnection: (id) => {
+ const { tabs, activeTabId, sessionPasswords, shareDestinations } = get();
+ const tab = tabs.find((t) => t.id === activeTabId);
+ if (!tab || !id) return;
+ const conn = useSyncStore.getState().connections.find((c) => c.id === id);
+ if (
+ conn &&
+ !conn.hasPassword &&
+ connectionNeedsSecret(conn.dialect, conn.authMethod) &&
+ !sessionPasswordFor(id, sessionPasswords)
+ ) {
+ set({
+ pendingPassword: {
+ id,
+ name: conn.name || conn.dialect,
+ resumeExecute: false,
+ onlyThisDestination: true,
+ },
+ });
+ return;
+ }
+ set(destinationIdsPatch(shareDestinations, tabs, activeTabId, [id]));
+ void get().ensureSchema(id);
+ void get().ensureSchema(id, { scope: SCHEMA_ROUTINE_SCOPE });
+ },
+
toggleStatement: (index) => {
const { tabs, activeTabId } = get();
set({
@@ -767,9 +804,11 @@ export const useSqlEditorStore = create()(
const current = shareDestinations
? sharedConnectionIds
: (tabs.find((t) => t.id === activeTabId)?.selectedConnectionIds ?? []);
- const destPatch = current.includes(id)
- ? {}
- : destinationIdsPatch(shareDestinations, tabs, activeTabId, [...current, id]);
+ const destPatch = pendingPassword.onlyThisDestination
+ ? destinationIdsPatch(shareDestinations, tabs, activeTabId, [id])
+ : current.includes(id)
+ ? {}
+ : destinationIdsPatch(shareDestinations, tabs, activeTabId, [...current, id]);
setSessionPassword(id, trimmed);
set({
@@ -792,6 +831,9 @@ export const useSqlEditorStore = create()(
});
} else {
void get().ensureSchema(id, { force: true });
+ if (pendingPassword.onlyThisDestination) {
+ void get().ensureSchema(id, { scope: SCHEMA_ROUTINE_SCOPE });
+ }
}
},
diff --git a/apps/web/src/frontend/features/workflow/components/RunsPanel.tsx b/apps/web/src/frontend/features/workflow/components/RunsPanel.tsx
index 306ec8a6a..6c4d94c47 100644
--- a/apps/web/src/frontend/features/workflow/components/RunsPanel.tsx
+++ b/apps/web/src/frontend/features/workflow/components/RunsPanel.tsx
@@ -376,6 +376,7 @@ function RunSteering({
{field.type === 'boolean' ? (
setValues((current) => ({ ...current, [field.key]: ev.target.checked ? 'true' : 'false' }))}
/>
diff --git a/apps/web/src/frontend/shared/components/FilterPicker.tsx b/apps/web/src/frontend/shared/components/FilterPicker.tsx
index 0f5791afd..a8f316ac7 100644
--- a/apps/web/src/frontend/shared/components/FilterPicker.tsx
+++ b/apps/web/src/frontend/shared/components/FilterPicker.tsx
@@ -44,6 +44,8 @@ interface CommonProps {
placeholder?: string;
/** Prefix for the trigger, filter and backdrop test ids. */
testId?: string;
+ /** Accessible name when the summary is not enough on its own. */
+ ariaLabel?: string;
/** A leading cell per row, such as a destination's group number. */
renderLead?: (option: FilterPickerOption) => React.ReactNode;
}
@@ -141,6 +143,7 @@ export function FilterPicker(props: FilterPickerProps): React.ReactElement {
triggerClassName = TRIGGER,
placeholder = 'Filter…',
testId,
+ ariaLabel,
renderLead,
} = props;
const [open, setOpen] = useState(false);
@@ -242,6 +245,7 @@ export function FilterPicker(props: FilterPickerProps): React.ReactElement {
type="button"
title={title}
disabled={disabled}
+ aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
data-testid={part('trigger')}
diff --git a/docs/testing/TEST_IDS.md b/docs/testing/TEST_IDS.md
index 3ec0e5e14..33f72ebd2 100644
--- a/docs/testing/TEST_IDS.md
+++ b/docs/testing/TEST_IDS.md
@@ -33,6 +33,7 @@ Every control has a test ID: **987** buttons, text boxes, selects and textareas.
- `access-permission-account-name` · dd
- `access-permission-allow-all` · p
- `access-permission-alteration-{a}` · li
+ - `access-permission-apply` · button
- `access-permission-cancel` · button · Cancel
- `access-permission-confirm` · div
- `access-permission-confirm-run` · button
@@ -1238,6 +1239,7 @@ Every control has a test ID: **987** buttons, text boxes, selects and textareas.
- `sql-layout-side-by-side` · button · Side by side (per statement) — compare cell values across servers
- `sql-max-rows` · input
- `sql-multi-table-threshold` · input
+ - `sql-one-transaction` · input
- `sql-refresh-btn` · button · Refresh
- `sql-run-btn` · button
- `sql-runs-drawer-btn` · button · Recent runs
@@ -1263,9 +1265,6 @@ Every control has a test ID: **987** buttons, text boxes, selects and textareas.
- `sql-explorer-col-select-{name}-{name2}` · button · Sel
- `sql-explorer-columns-toggle-{name}` · button · Columns ( )
- `sql-explorer-from-{name}` · button · From
- - `sql-explorer-index-{name}-{name2}` · button
- - `sql-explorer-indexes-{name}` · div
- - `sql-explorer-indexes-toggle-{name}` · button · Indexes ( )
- `sql-explorer-object-{name}` · button
- `sql-explorer-param-insert-{name}-{name2}` · button
- `sql-explorer-table-stats-{name}` · span
@@ -1276,7 +1275,12 @@ Every control has a test ID: **987** buttons, text boxes, selects and textareas.
- `sql-open-object-source` · button
- `sql-open-object-source-btn` · button · Open source script in the editor (view only)
- `sql-reload-schema` · button · Reload schema
- - `sql-schema-connection` · select · Schema connection
+ - `sql-schema-connection-backdrop` · div in FilterPicker · Sets Destinations to this connection. Check more destinations to run the same SQL on several.
+ - `sql-schema-connection-clear` · button in FilterPicker · Sets Destinations to this connection. Check more destinations to run the same SQL on several.
+ - `sql-schema-connection-filter` · input in FilterPicker · Sets Destinations to this connection. Check more destinations to run the same SQL on several.
+ - `sql-schema-connection-group-{group}` · div in FilterPicker · Sets Destinations to this connection. Check more destinations to run the same SQL on several.
+ - `sql-schema-connection-option-{id}` · input in FilterPicker · Sets Destinations to this connection. Check more destinations to run the same SQL on several.
+ - `sql-schema-connection-trigger` · button in FilterPicker · Sets Destinations to this connection. Check more destinations to run the same SQL on several.
- `sql-schema-empty` · div
- `sql-schema-explorer` · div
- `sql-schema-group-{type}` · div
@@ -1767,6 +1771,10 @@ Every control has a test ID: **987** buttons, text boxes, selects and textareas.
- `workflow-palette-search-pipes` · input · Search pipes
- `workflow-palette-show-advanced` · input
- **RunsPanel** · `features/workflow/components/RunsPanel.tsx`
+ - `workflow-run-answer` · Button
+ - `workflow-run-answer-{key}` · input
+ - `workflow-run-cancel` · Button
+ - `workflow-run-resume` · Button
- `workflow-runs-live` · input
- **SqlPipeEditor** · `features/workflow/components/SqlPipeEditor.tsx`
- `sql-pipe-connection-backdrop` · div in FilterPicker · Filter by name, dialect, host…
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 4b593e0ad..cfd596481 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
@@ -287,10 +287,38 @@ describe('runStatements against a real SQLite file', () => {
}
});
- it('fails closed at offset 0 when page wrap fails (no raw fallback)', async () => {
+ it('reports the statement error when a LIMIT 1 probe fails', async () => {
const spy = vi
.spyOn(ConnectionFactory, 'executeOnConnection')
- .mockRejectedValueOnce(new Error('wrap boom'));
+ .mockRejectedValueOnce(new Error('wrap boom'))
+ .mockRejectedValueOnce(new Error("no such table: COUPONS"));
+ try {
+ const results = await runStatements(
+ 'sqlite',
+ { connectionString: dbPath },
+ ['SELECT * FROM COUPONS cou'],
+ 1,
+ undefined,
+ 0
+ );
+ expect(results[0]).toMatchObject({ ok: false });
+ if (!results[0]!.ok) {
+ expect(results[0].error).toBe('no such table: COUPONS');
+ expect(results[0].error).not.toMatch(/Paging failed/i);
+ }
+ 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');
+ } finally {
+ spy.mockRestore();
+ }
+ });
+
+ it('keeps the paging error when the statement itself runs', async () => {
+ const spy = vi
+ .spyOn(ConnectionFactory, 'executeOnConnection')
+ .mockRejectedValueOnce(new Error('wrap boom'))
+ .mockResolvedValueOnce([{ id: 1 }]);
try {
const results = await runStatements(
'sqlite',
@@ -305,7 +333,9 @@ describe('runStatements against a real SQLite file', () => {
expect(results[0].error).toMatch(/Paging failed/i);
expect(results[0].error).toMatch(/wrap boom/);
}
- expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy).toHaveBeenCalledTimes(2);
+ expect(String(spy.mock.calls[1]![2])).toContain('LIMIT 1');
+ expect(String(spy.mock.calls[1]![2])).not.toContain('fox_page');
} finally {
spy.mockRestore();
}
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 8a46971ec..a15bc30ab 100644
--- a/packages/server/src/features/sql-editor/sql-execute.service.ts
+++ b/packages/server/src/features/sql-editor/sql-execute.service.ts
@@ -1,6 +1,6 @@
import { ConnectionFactory, getAdapter, type ConnectionOptions } from '@foxschema/db';
import { autoAliasSelectColumns, errorMessage } from '@foxschema/sql';
-import { isPageableStatement, trimPageProbe, wrapSqlForPage, wrapSqlForSeek, type SqlSeek } from './sql-page-wrap.service';
+import { isPageableStatement, syntaxProbeSql, trimPageProbe, wrapSqlForPage, wrapSqlForSeek, type SqlSeek } from './sql-page-wrap.service';
/**
* Helpers behind POST /api/sql/execute (SQL Editor). One request = one
@@ -250,6 +250,28 @@ 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.
+ const probe = !seek && offset === 0 ? syntaxProbeSql(sql, dialect) : null;
+ if (probe) {
+ try {
+ const positional = ConnectionFactory.executePositional(dialect, connection, probe, params);
+ if (positional) await positional;
+ else {
+ await ConnectionFactory.executeOnConnection>(
+ dialect,
+ connection,
+ probe,
+ params
+ );
+ }
+ } catch (probeError: unknown) {
+ pushErr(errorMessage(probeError));
+ continue;
+ }
+ }
// Fail closed for all pageable statements: raw fallback can materialize
// an unbounded result set before shapeRows truncates, and ignores OFFSET.
// Include the wrapped SQL so DB2 alias / dialect issues are diagnosable.
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 c32d8e9e6..1f9a7d1ca 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,6 +120,37 @@ 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([
+ 'mysql',
+ 'mariadb',
+ 'tidb',
+ 'postgres',
+ 'postgresql',
+ 'sqlite',
+ 'duckdb',
+ 'clickhouse',
+ 'cockroach',
+ 'cockroachdb',
+ 'yugabyte',
+ 'yugabytedb',
+ 'redshift',
+]);
+
+/**
+ * The reader's statement with `LIMIT 1`, for dialects that accept it.
+ *
+ * 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.
+ */
+export function syntaxProbeSql(sql: string, dialect: string): string | null {
+ if (!LIMIT_ONE_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`;
+}
+
export function wrapSqlForPage(
sql: string,
dialect: string,
diff --git a/packages/sql/src/modules/utilities/index-fragmentation.test.ts b/packages/sql/src/modules/utilities/index-fragmentation.test.ts
index 066181384..9ff40bb04 100644
--- a/packages/sql/src/modules/utilities/index-fragmentation.test.ts
+++ b/packages/sql/src/modules/utilities/index-fragmentation.test.ts
@@ -790,14 +790,35 @@ describe('defrag statements the engines actually accept', () => {
// Db2 has no single-index REORG. And REORG is a command, not SQL, so over a
// driver connection it must be wrapped in SYSPROC.ADMIN_CMD (otherwise
// SQL0104N, "expected tokens may include: JOIN").
- const [stmt, note] = defrag('db2', 'DEMO_A', 'ORDERS', 'IDX_ORDERS_CUST');
+ const [stmt, stats, note] = defrag('db2', 'DEMO_A', 'ORDERS', 'IDX_ORDERS_CUST');
expect(stmt).toBe(
`CALL SYSPROC.ADMIN_CMD('REORG INDEXES ALL FOR TABLE "DEMO_A"."ORDERS"');`
);
+ // The % on screen is SYSCAT.INDEXES. REORG leaves it stale; RUNSTATS is
+ // what makes a refresh show the result.
+ expect(stats).toBe(
+ `CALL SYSPROC.ADMIN_CMD('RUNSTATS ON TABLE "DEMO_A"."ORDERS" AND INDEXES ALL');`
+ );
// The reader has to know one index was not the unit of work.
expect(note).toMatch(/no single-index REORG/i);
});
+ 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');
+ expect(lower).toBe(
+ `CALL SYSPROC.ADMIN_CMD('REORG INDEXES ALL FOR TABLE "DEMO_A"."ORDERS"');`
+ );
+ const [mixedSchema] = defrag('db2', 'App', 'ORDERS', 'IDX');
+ expect(mixedSchema).toBe(
+ `CALL SYSPROC.ADMIN_CMD('REORG INDEXES ALL FOR TABLE "APP"."ORDERS"');`
+ );
+ // A mixed-case table name is a delimited identifier from SYSCAT.
+ const [mixed] = defrag('db2', 'DEMO_A', 'MyTable', 'IDX');
+ expect(mixed).toContain('"DEMO_A"."MyTable"');
+ });
+
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/db2/db2.index-fragmentation.ts b/packages/sql/src/providers/db2/db2.index-fragmentation.ts
index 6524492b5..9a939f396 100644
--- a/packages/sql/src/providers/db2/db2.index-fragmentation.ts
+++ b/packages/sql/src/providers/db2/db2.index-fragmentation.ts
@@ -12,6 +12,25 @@ import {
type IndexFragmentationDialect,
} from '../../modules/utilities/index-fragmentation.types.js';
+/**
+ * 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.
+ */
+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);
+ return qualified.replace(/'/g, "''");
+}
+
const PROBE_SQL = `
SELECT
INDNAME AS index_name,
@@ -54,7 +73,7 @@ export const db2IndexFragmentation: IndexFragmentationDialect = {
return [];
},
defragSql(target) {
- // Two things make Db2 unlike every other dialect here, both verified
+ // Three things make Db2 unlike every other dialect here, each verified
// against a live server:
//
// 1. It cannot reorganise a single index. `REORG INDEX ` returns
@@ -64,12 +83,22 @@ 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.
//
- // The table 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.
- const literal = quoteIndexTarget(target).table.replace(/'/g, "''");
+ // 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.
+ // RUNSTATS follows because the percentage on this screen is read from
+ // SYSCAT.INDEXES, which REORG does not update by itself.
+ const literal = db2ReorgTarget(target.schema, target.table);
return [
`CALL SYSPROC.ADMIN_CMD('REORG INDEXES ALL FOR TABLE ${literal}');`,
+ `CALL SYSPROC.ADMIN_CMD('RUNSTATS ON TABLE ${literal} AND INDEXES ALL');`,
`-- Db2 has no single-index REORG: this reorganises every index on ${target.table}.`,
];
},