Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/web/src/frontend/app/store/sqlEditorCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
truncatePersistedSql,
pruneSchemaCache,
MAX_PERSISTED_SQL_CHARS,
useSqlEditorStore,
} from './useSqlEditorStore';
import type { SqlStatementResult } from '@/shared/api/sqlApi';

Expand Down Expand Up @@ -53,3 +54,30 @@ describe('pruneSchemaCache', () => {
expect(pruned.fresh).toBeDefined();
});
});

describe('recent query destinations', () => {
it('restores the destinations used by the run instead of the current shared selection', () => {
const current = useSqlEditorStore.getState().tabs[0]!;
useSqlEditorStore.setState({
tabs: [{ ...current, id: 'current', selectedConnectionIds: ['staging'] }],
activeTabId: 'current',
shareDestinations: true,
sharedConnectionIds: ['staging'],
recentQueries: [
{
id: 'recent-prod',
sql: 'UPDATE orders SET status = 1 WHERE id = 42',
title: 'Production update',
selectedConnectionIds: ['production'],
ranAt: 1,
},
],
});

useSqlEditorStore.getState().openRecentQuery('recent-prod');

const state = useSqlEditorStore.getState();
expect(state.sharedConnectionIds).toEqual(['production']);
expect(state.activeTab().selectedConnectionIds).toEqual(['production']);
});
});
90 changes: 90 additions & 0 deletions apps/web/src/frontend/app/store/sqlEditorSchemaCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { DbObjectType, TableSchema } from '@/shared/lib/types';

const { loadSchemaMock } = vi.hoisted(() => ({
loadSchemaMock: vi.fn(),
}));

vi.mock('@/shared/api/schemaApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/shared/api/schemaApi')>();
return { ...actual, loadSchema: loadSchemaMock };
});

import { useSqlEditorStore } from './useSqlEditorStore';
import { useSyncStore } from './useSyncStore';

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}

function schemaObject(name: string, objectType: DbObjectType): TableSchema {
return {
name,
objectType,
columns: [],
indices: [],
foreignKeys: [],
};
}

describe('SQL Editor scoped schema cache', () => {
beforeEach(() => {
loadSchemaMock.mockReset();
useSyncStore.setState({
connections: [
{
id: 'schema-race',
name: 'Test database',
dialect: 'postgres',
database: 'app',
schema: 'public',
hasPassword: true,
},
],
} as never);
useSqlEditorStore.setState({
schemaCache: {},
pendingPassword: null,
sessionPasswords: {},
});
});

it('keeps both scopes when the slower concurrent load finishes last', async () => {
const warm = deferred<{ tables: TableSchema[] }>();
const routines = deferred<{ tables: TableSchema[] }>();
loadSchemaMock.mockImplementation(
(_ref: unknown, scope: DbObjectType[]) =>
(scope.includes('TABLE') ? warm.promise : routines.promise)
);

const warmLoad = useSqlEditorStore
.getState()
.ensureSchema('schema-race', { scope: ['TABLE', 'VIEW', 'MQT'] });
const routineLoad = useSqlEditorStore
.getState()
.ensureSchema('schema-race', { scope: ['PROCEDURE', 'FUNCTION'] });

warm.resolve({ tables: [schemaObject('ORDERS', 'TABLE')] });
await warmLoad;
routines.resolve({ tables: [schemaObject('REFRESH_ORDERS', 'PROCEDURE')] });
await routineLoad;

const cached = useSqlEditorStore.getState().schemaCache['schema-race'];
expect(cached?.tables?.map((table) => table.name).sort()).toEqual([
'ORDERS',
'REFRESH_ORDERS',
]);
expect(new Set(cached?.scope)).toEqual(
new Set(['TABLE', 'VIEW', 'MQT', 'PROCEDURE', 'FUNCTION'])
);
});
});
76 changes: 58 additions & 18 deletions apps/web/src/frontend/app/store/useSqlEditorStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export interface RecentQuery {
sql: string;
/** Tab title at run time (optional label). */
title: string;
/** Exact destinations used by this run; reopening must not follow today's selection. */
selectedConnectionIds: string[];
ranAt: number;
}

Expand Down Expand Up @@ -836,17 +838,23 @@ export const useSqlEditorStore = create<SqlEditorState>()(
}),

openRecentQuery: (id) => {
const { recentQueries, tabs, shareDestinations, sharedConnectionIds } = get();
const { recentQueries, tabs, shareDestinations } = get();
const entry = recentQueries.find((r) => r.id === id);
if (!entry) return;
const selectedConnectionIds = [...entry.selectedConnectionIds];
const tab = createTab({
title: entry.title?.trim() || 'Recent query',
sql: entry.sql,
selectedConnectionIds: shareDestinations
? [...sharedConnectionIds]
: (tabs.find((t) => t.id === get().activeTabId)?.selectedConnectionIds ?? []),
selectedConnectionIds,
});
set({
tabs: [...tabs, tab],
activeTabId: tab.id,
// Shared mode drives execution from this global list, so restore it
// as well as the tab-local copy. Otherwise the reopened SQL silently
// follows whichever database happens to be selected now.
...(shareDestinations ? { sharedConnectionIds: selectedConnectionIds } : {}),
});
set({ tabs: [...tabs, tab], activeTabId: tab.id });
},

clearRecentQueries: () => set({ recentQueries: [] }),
Expand Down Expand Up @@ -924,18 +932,27 @@ export const useSqlEditorStore = create<SqlEditorState>()(
wanted as DbObjectType[]
);
const incoming = new Set(wanted);
const kept = (existing?.tables ?? []).filter((t) => !incoming.has(t.objectType));
const mergedScope = [...new Set([...(existing?.scope ?? []), ...wanted])];
set({
schemaCache: pruneSchemaCache({
...get().schemaCache,
[connectionId]: {
status: 'ready',
tables: [...kept, ...loaded],
scope: mergedScope,
loadedAt: Date.now(),
},
}),
// Merge against the cache at completion time. Different scope loads
// intentionally use different in-flight keys and can overlap; using
// the snapshot from before either request awaited lets the slower
// response erase objects written by the faster one.
set((state) => {
const current = state.schemaCache[connectionId];
const kept = (current?.tables ?? []).filter(
(table) => !incoming.has(table.objectType)
);
const mergedScope = [...new Set([...(current?.scope ?? []), ...wanted])];
return {
schemaCache: pruneSchemaCache({
...state.schemaCache,
[connectionId]: {
status: 'ready',
tables: [...kept, ...loaded],
scope: mergedScope,
loadedAt: Date.now(),
},
}),
};
});
} catch (error: unknown) {
set({
Expand Down Expand Up @@ -1521,6 +1538,9 @@ export const useSqlEditorStore = create<SqlEditorState>()(
id: newTabId(),
sql: truncatePersistedSql(sqlSnippet),
title: tab.title || 'Query',
selectedConnectionIds: connections
.map((connection) => connection.id)
.filter((id) => id !== LOCAL_CODE_RUN_TARGET.id),
ranAt: Date.now(),
};
recentQueries = [
Expand Down Expand Up @@ -2281,7 +2301,7 @@ export const useSqlEditorStore = create<SqlEditorState>()(
}),
{
name: 'foxschema-sql-editor',
version: 7,
version: 8,
// Persist tabs + destinations mode + bookmarks + recent + variables. Never passwords/results.
// Secret variable payloads are stripped (session-only values).
partialize: (state) => {
Expand Down Expand Up @@ -2386,6 +2406,23 @@ export const useSqlEditorStore = create<SqlEditorState>()(
: 3,
};
}
// v8: recent runs remember the exact database destinations they used.
// Old entries fail closed with none selected instead of inheriting the
// current destination and risking a write to the wrong database.
if (fromVersion < 8) {
const recent = Array.isArray(p.recentQueries)
? (p.recentQueries as Array<Record<string, unknown>>)
: [];
return {
...p,
recentQueries: recent.map((entry) => ({
...entry,
selectedConnectionIds: Array.isArray(entry.selectedConnectionIds)
? entry.selectedConnectionIds.filter((id) => typeof id === 'string')
: [],
})),
};
}
return p;
},
// Always rehydrate checkedStatements (not persisted) and drop malformed tabs.
Expand Down Expand Up @@ -2462,6 +2499,9 @@ export const useSqlEditorStore = create<SqlEditorState>()(
id: r.id,
sql: truncatePersistedSql(r.sql),
title: typeof r.title === 'string' ? r.title : 'Query',
selectedConnectionIds: Array.isArray(r.selectedConnectionIds)
? r.selectedConnectionIds.filter((id) => typeof id === 'string')
: [],
ranAt: r.ranAt,
}))
.sort((a, b) => b.ranAt - a.ranAt)
Expand Down
50 changes: 49 additions & 1 deletion apps/web/src/frontend/features/sql-editor/lib/resultSeek.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { seekFromLastRow } from './resultSeek';
import { seekFromLastRow, tableForOrderBy } from './resultSeek';
import type { TableSchema } from '@/shared/lib/types';

const TABLE: TableSchema = {
Expand Down Expand Up @@ -41,4 +41,52 @@ describe('seekFromLastRow', () => {
})
).toBeNull();
});

it('does not use one side of a join to claim the result order is unique', () => {
expect(
tableForOrderBy(
'SELECT o.ID, i.ID AS ITEM_ID FROM ORDERS o JOIN ITEMS i ON i.ORDER_ID = o.ID ORDER BY o.ID',
[
TABLE,
{
...TABLE,
name: 'ITEMS',
columns: [
{ name: 'ID', type: 'int', nullable: false, primaryKey: true },
{ name: 'ORDER_ID', type: 'int', nullable: false, primaryKey: false },
],
},
]
)
).toBeUndefined();
});

it('matches a FROM table exactly instead of borrowing a substring table PK', () => {
const orderItems: TableSchema = {
...TABLE,
name: 'ORDER_ITEMS',
columns: [
{ name: 'ORDER_ID', type: 'int', nullable: false, primaryKey: false },
{ name: 'LINE', type: 'int', nullable: false, primaryKey: false },
],
primaryKey: undefined,
};
expect(
tableForOrderBy(
'SELECT ORDER_ID, LINE FROM ORDER_ITEMS ORDER BY ORDER_ID',
[TABLE, orderItems]
)
).toBe(orderItems);
expect(
seekFromLastRow({
sql: 'SELECT ORDER_ID, LINE FROM ORDER_ITEMS ORDER BY ORDER_ID',
table: tableForOrderBy(
'SELECT ORDER_ID, LINE FROM ORDER_ITEMS ORDER BY ORDER_ID',
[TABLE, orderItems]
),
resultColumns: ['ORDER_ID', 'LINE'],
lastRow: [1, 3],
})
).toBeNull();
});
});
31 changes: 29 additions & 2 deletions apps/web/src/frontend/features/sql-editor/lib/resultSeek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import {
uniqueKeysFromTable,
} from '@foxschema/sql';
import type { TableSchema } from '@/shared/lib/types';
import {
fromClauseIsMultiTable,
sqlHasSetOperation,
tableNamesFromSql,
} from '@/shared/lib/tablePreview';

export type ResultSeek = {
columns: string[];
Expand All @@ -25,8 +30,30 @@ export function tableForOrderBy(
tables: readonly TableSchema[] | undefined
): TableSchema | undefined {
if (!tables?.length) return undefined;
const names = sql.toLowerCase();
return tables.find((t) => names.includes(t.name.toLowerCase()));
// A unique key is only unique in the result while one source row can produce
// at most one output row. JOIN/APPLY/comma-FROM and set operations can repeat
// a primary key, so keyset paging would skip the remaining rows for that key.
if (
fromClauseIsMultiTable(sql) ||
sqlHasSetOperation(sql) ||
/\b(?:CROSS|OUTER)\s+APPLY\b/i.test(sql)
) {
return undefined;
}
// Match parsed FROM references, not substrings. With tables `order` and
// `order_items`, searching the SQL text returned whichever cache entry came
// first and could borrow the wrong table's uniqueness metadata.
const names = tableNamesFromSql(sql);
if (names.length !== 1) return undefined;
const wanted = names[0]!.toLowerCase();
if (wanted.includes('.')) {
return tables.find((table) => table.name.toLowerCase() === wanted);
}
const matched = tables.filter((table) => {
const name = table.name.toLowerCase();
return (name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : name) === wanted;
});
return matched.length === 1 ? matched[0] : undefined;
}

export function seekFromLastRow(opts: {
Expand Down
Loading