Skip to content
Merged
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
91 changes: 89 additions & 2 deletions apps/web/src/backend/api/sql-execute.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rmSync } from 'node:fs';
Expand Down Expand Up @@ -96,6 +96,93 @@ describe('runStatements against a real SQLite file', () => {

it('applies the row cap with the truncated flag', async () => {
const results = await runStatements('sqlite', { connectionString: dbPath }, ['SELECT * FROM t;'], 2);
expect(results[0]).toMatchObject({ ok: true, rowCount: 2, truncated: true });
expect(results[0]).toMatchObject({ ok: true, rowCount: 2, truncated: true, hasNext: true });
});

it('pages SELECT with OFFSET (page 1 is not page 0)', async () => {
const page0 = await runStatements(
'sqlite',
{ connectionString: dbPath },
['SELECT id, name FROM t ORDER BY id;'],
1,
undefined,
0
);
const page1 = await runStatements(
'sqlite',
{ connectionString: dbPath },
['SELECT id, name FROM t ORDER BY id;'],
1,
undefined,
1
);
expect(page0[0]).toMatchObject({ ok: true, hasNext: true });
expect(page1[0]).toMatchObject({ ok: true });
if (page0[0]!.ok && page1[0]!.ok) {
expect(page0[0].rows[0]).toEqual([1, 'alpha']);
expect(page1[0].rows[0]).toEqual([2, 'beta']);
}
});

it('runs non-SELECT unwrapped at offset 0 (no wrap attempt)', async () => {
const spy = vi.spyOn(ConnectionFactory, 'executeOnConnection').mockResolvedValueOnce([]);
try {
const results = await runStatements(
'sqlite',
{ connectionString: dbPath },
[`UPDATE t SET name = 'x' WHERE id = 1;`],
10
);
expect(results[0]).toMatchObject({ ok: true, hasNext: false, rowCount: 0 });
expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0]![2])).toMatch(/^UPDATE t/i);
expect(String(spy.mock.calls[0]![2])).not.toContain('_fox_page');
} finally {
spy.mockRestore();
}
});

it('rejects paging a non-SELECT when offset > 0', async () => {
const spy = vi.spyOn(ConnectionFactory, 'executeOnConnection');
try {
const results = await runStatements(
'sqlite',
{ connectionString: dbPath },
[`UPDATE t SET name = 'x' WHERE id = 1;`],
10,
undefined,
10
);
expect(results[0]).toMatchObject({ ok: false });
if (!results[0]!.ok) expect(results[0].error).toMatch(/non-SELECT/i);
expect(spy).not.toHaveBeenCalled();
} finally {
spy.mockRestore();
}
});

it('does not fall back to unwrapped SQL when offset > 0 and wrap fails', async () => {
const spy = vi
.spyOn(ConnectionFactory, 'executeOnConnection')
.mockRejectedValueOnce(new Error('wrap boom'));
try {
const results = await runStatements(
'sqlite',
{ connectionString: dbPath },
['SELECT id FROM t ORDER BY id;'],
1,
undefined,
1
);
expect(results[0]).toMatchObject({ ok: false });
if (!results[0]!.ok) {
expect(results[0].error).toMatch(/Paging failed/i);
expect(results[0].error).toMatch(/wrap boom/);
}
expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0]![2])).toContain('_fox_page');
} finally {
spy.mockRestore();
}
});
});
64 changes: 44 additions & 20 deletions apps/web/src/backend/api/sql-execute.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ConnectionFactory, getAdapter, type ConnectionOptions } from '@foxschema/core';
import { trimPageProbe, wrapSqlForPage } from './sql-page-wrap';
import { isPageableStatement, trimPageProbe, wrapSqlForPage } from './sql-page-wrap';

/**
* Helpers behind POST /api/sql/execute (SQL Editor). One request = one
Expand Down Expand Up @@ -105,6 +105,39 @@ export async function runStatements(
const results: StatementResult[] = [];
for (const sql of statements) {
const started = Date.now();
const pushErr = (message: string) => {
results.push({ ok: false, error: message, durationMs: Date.now() - started });
};
const pushUnwrappedOk = async () => {
const raw = await ConnectionFactory.executeOnConnection<Record<string, unknown>>(
dialect,
connection,
sql
);
const shaped = shapeRows(raw, maxRows);
results.push({
ok: true,
...shaped,
hasNext: false,
durationMs: Date.now() - started,
});
};

// Non-SELECT/DDL/utility: never wrap (avoids a failed subquery attempt).
// Paging requires a wrap — fail closed when offset > 0.
if (!isPageableStatement(sql)) {
if (offset > 0) {
pushErr('Cannot page a non-SELECT statement');
continue;
}
try {
await pushUnwrappedOk();
} catch (error: unknown) {
pushErr(error instanceof Error ? error.message : String(error));
}
continue;
}

try {
const paged = wrapSqlForPage(sql, dialect, offset, maxRows);
const raw = await ConnectionFactory.executeOnConnection<Record<string, unknown>>(
Expand All @@ -124,28 +157,19 @@ export async function runStatements(
durationMs: Date.now() - started,
});
} catch (error: unknown) {
// Fallback: run unwrapped (e.g. non-SELECT / dialect wrap rejection).
const wrapMsg = error instanceof Error ? error.message : String(error);
// Fail closed for Next/Prev: unwrapped SQL ignores OFFSET and would
// re-show page 0 while the UI advances pageIndex.
if (offset > 0) {
pushErr(`Paging failed: ${wrapMsg}`);
continue;
}
// offset === 0 only: dialect rejected the wrap (rare) — try raw SQL.
try {
const raw = await ConnectionFactory.executeOnConnection<Record<string, unknown>>(
dialect,
connection,
sql
);
const shaped = shapeRows(raw, maxRows);
results.push({
ok: true,
...shaped,
hasNext: false,
durationMs: Date.now() - started,
});
await pushUnwrappedOk();
} catch (inner: unknown) {
const message = inner instanceof Error ? inner.message : String(inner);
const wrapMsg = error instanceof Error ? error.message : String(error);
results.push({
ok: false,
error: offset > 0 ? `${message} (page wrap: ${wrapMsg})` : message,
durationMs: Date.now() - started,
});
pushErr(`${message} (page wrap: ${wrapMsg})`);
}
}
}
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/backend/api/sql-page-wrap.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { trimPageProbe, wrapSqlForPage } from './sql-page-wrap';
import { isPageableStatement, trimPageProbe, wrapSqlForPage } from './sql-page-wrap';

describe('sql-page-wrap', () => {
it('wraps postgres-style with LIMIT/OFFSET and +1 probe', () => {
Expand All @@ -26,4 +26,15 @@ describe('sql-page-wrap', () => {
expect(t.hasNext).toBe(true);
expect(t.truncated).toBe(true);
});

it('isPageableStatement accepts SELECT / WITH…SELECT / VALUES only', () => {
expect(isPageableStatement('SELECT 1')).toBe(true);
expect(isPageableStatement('WITH c AS (SELECT 1 AS n) SELECT * FROM c')).toBe(true);
expect(isPageableStatement('VALUES (1), (2)')).toBe(true);
expect(isPageableStatement('INSERT INTO t VALUES (1)')).toBe(false);
expect(isPageableStatement('UPDATE t SET a = 1')).toBe(false);
expect(isPageableStatement('SET search_path TO public')).toBe(false);
expect(isPageableStatement('EXPLAIN SELECT 1')).toBe(false);
expect(isPageableStatement('WITH c AS (SELECT 1) INSERT INTO t SELECT * FROM c')).toBe(false);
});
});
15 changes: 15 additions & 0 deletions apps/web/src/backend/api/sql-page-wrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,27 @@
* Fetches `limit + 1` rows so the caller can detect `hasNext` without a COUNT.
*/

import { statementVerb } from '@foxschema/core';

/** Verbs that can appear as a subquery in `SELECT * FROM (…)` for paging. */
const PAGEABLE_VERBS = new Set(['select', 'values']);

export function clampOffset(v: unknown): number {
const n = typeof v === 'number' ? Math.floor(v) : Number.NaN;
if (!Number.isFinite(n) || n < 0) return 0;
return Math.min(n, 1_000_000);
}

/**
* True when the statement is safe to wrap for OFFSET/LIMIT paging
* (SELECT / VALUES, including `WITH … AS (…) SELECT …`).
* Writes, DDL, SET, SHOW, EXPLAIN, CALL, etc. are not pageable.
*/
export function isPageableStatement(sql: string): boolean {
const verb = statementVerb(sql);
return verb !== null && PAGEABLE_VERBS.has(verb);
}

/**
* Best-effort page wrap. Dialects without OFFSET still get a subquery + LIMIT
* when offset is 0; non-zero offset uses the closest dialect syntax.
Expand Down
9 changes: 4 additions & 5 deletions apps/web/src/frontend/components/ProfileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,11 @@ export const ProfileMenu: React.FC = () => {
return;
}
placeMenu();
const onReposition = () => placeMenu();
window.addEventListener('resize', onReposition);
window.addEventListener('scroll', onReposition, true);
window.addEventListener('resize', placeMenu);
window.addEventListener('scroll', placeMenu, true);
return () => {
window.removeEventListener('resize', onReposition);
window.removeEventListener('scroll', onReposition, true);
window.removeEventListener('resize', placeMenu);
window.removeEventListener('scroll', placeMenu, true);
};
}, [open]);

Expand Down
Loading
Loading