From 0d03e73ab109914db0fa737aef90f993dbf50d48 Mon Sep 17 00:00:00 2001 From: huyplb Date: Tue, 28 Jul 2026 12:11:31 -0600 Subject: [PATCH] feat(sql-editor): data peek, safe SQL bridge for Node cells, FK fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three strands of work, all in the SQL editor / core provider layer. **Data peek (new).** Cmd/Ctrl-click a table in the schema explorer to see its rows without writing a query. Foreign-key cells render as links; clicking one appends the parent's rows as another grid below, so a relationship can be followed a couple of hops. Breadcrumb navigates back. Answers "I just want a quick look before I write the real query". **`sql` bridge for `-- @node` cells (new).** Node code cells can now run SQL against the run's credential via a tagged template whose interpolations become bind parameters, never text: await sql`INSERT INTO ${sql.id('accounts')} ${sql.values(rows)}`; String interpolation was considered and rejected: a value containing an apostrophe breaks the statement, null becomes the bare word null, Date becomes a locale string, and an object becomes "[object Object]". Cells run in an isolated worker with no DB handle, so `sql` proxies to the API process over a request/response bridge. Three consequences worth knowing: - The cell timeout pauses while a bridged query is in flight — database time is not the cell's time, and without this any real migration was killed mid-statement. - Safe mode is enforced server-side per statement, since a cell cannot be statically analyzed for the SQL it will build at runtime. - Each `await sql` is its own round trip on a pooled connection, so transactions and temp tables do not carry across calls. **Foreign-key correctness (fixes).** `resolveFkReferencedColumns` now requires every catalog entry to be a real identifier, not just matching arity — SQLite's PRAGMA reports NULL for `to`, which arrived as `[null]` and produced `REFERENCES parent ()`. A shared `groupForeignKeyRows` replaces the same hand-rolled fold in five providers so that rule lives in one place. The generator's new `referencesClause` emits a bare `REFERENCES parent` when the parent columns are unknown, which every engine reads as "the parent's PK". Also fixed along the way: - SQLite adapter could never execute a write: it called `.all()` for every statement and opened the file read-only. No SQLite connection could run DDL or DML through any path. - Blueprint modal: silent wrong-column FK guess, stripped schema qualifier, self-referencing FKs unauthorable, misleading "applied" on skipped statements, Oracle trigger timing missing EACH ROW. - Ten sample bookmarks crashed with a raw TypeError when run standalone from the statement strip (`last` is null there). Samples gained five `sql`-bridge examples and a show/hide toggle. Every sample is executed against a real database by its test, and `/sql/execute` now accepts bind parameters. Co-Authored-By: Claude Opus 5 --- .gitignore | 6 + apps/web/src/backend/api/code-cell-bridge.ts | 47 +++++ .../src/backend/api/code-cell-execute.test.ts | 91 +++++++++ apps/web/src/backend/api/code-cell-execute.ts | 91 ++++++++- .../src/backend/api/code-cell-node-exec.ts | 10 +- apps/web/src/backend/api/code-cell-query.ts | 45 +++++ apps/web/src/backend/api/code-cell-thread.ts | 71 ++++++- apps/web/src/backend/api/routes.ts | 33 +++- apps/web/src/backend/api/sql-execute.ts | 15 +- apps/web/src/frontend/api/sqlApi.ts | 17 +- .../components/sql-editor/DataGrid.tsx | 25 ++- .../components/sql-editor/DataPeekPanel.tsx | 182 +++++++++++++++++ .../sql-editor/SqlBookmarksPanel.tsx | 61 +++++- .../sql-editor/SqlSchemaExplorer.tsx | 36 +++- .../sql-editor/TableBlueprintModal.tsx | 38 ++-- .../sql-editor/tableBlueprintSql.ts | 14 +- apps/web/src/frontend/lib/codeCellRunner.ts | 10 + apps/web/src/frontend/lib/sql-splitter.ts | 10 + .../src/frontend/lib/sqlEditorSamples.test.ts | 38 ++++ apps/web/src/frontend/lib/sqlEditorSamples.ts | 139 +++++++++++-- .../web/src/frontend/lib/tablePreview.test.ts | 98 +++++++++ apps/web/src/frontend/lib/tablePreview.ts | 115 +++++++++++ .../src/frontend/store/useSqlEditorStore.ts | 141 +++++++++++++ docs/USER_GUIDE.md | 36 ++++ foxflow.sqlite | Bin 344064 -> 0 bytes packages/core/src/browser.ts | 11 +- .../core/src/cores/schema-to-tables.test.ts | 68 ++++++- packages/core/src/cores/schema-to-tables.ts | 67 +++++-- packages/core/src/index.ts | 11 +- packages/core/src/modules/code-cell-exec.ts | 25 ++- .../core/src/modules/sql-template.test.ts | 125 ++++++++++++ packages/core/src/modules/sql-template.ts | 186 ++++++++++++++++++ .../providers/azureSql/azuresql.provider.ts | 39 ++-- .../providers/postgres/postgres.provider.ts | 39 ++-- .../providers/redshift/redshift.provider.ts | 69 ++++--- .../src/providers/sqlLite/sqlLite.adapter.ts | 13 +- .../src/providers/sqlLite/sqlLite.provider.ts | 33 ++-- .../providers/sqlServer/sqlserver.provider.ts | 39 ++-- 38 files changed, 1885 insertions(+), 209 deletions(-) create mode 100644 apps/web/src/backend/api/code-cell-bridge.ts create mode 100644 apps/web/src/backend/api/code-cell-query.ts create mode 100644 apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx create mode 100644 apps/web/src/frontend/lib/tablePreview.test.ts create mode 100644 apps/web/src/frontend/lib/tablePreview.ts delete mode 100644 foxflow.sqlite create mode 100644 packages/core/src/modules/sql-template.test.ts create mode 100644 packages/core/src/modules/sql-template.ts diff --git a/.gitignore b/.gitignore index a642b70..a049806 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,9 @@ apps/cli/npm-pack/ !apps/cli/README.md !docs/homebrew.md + +# FoxFlow's local SQLite DB (sibling project) — never belongs in this repo. +# Its credentials/variables tables can hold real secrets. +foxflow.sqlite +foxflow.sqlite-shm +foxflow.sqlite-wal diff --git a/apps/web/src/backend/api/code-cell-bridge.ts b/apps/web/src/backend/api/code-cell-bridge.ts new file mode 100644 index 0000000..e8daf78 --- /dev/null +++ b/apps/web/src/backend/api/code-cell-bridge.ts @@ -0,0 +1,47 @@ +/** + * Message protocol between a Node code-cell worker and the API process. + * + * The worker is deliberately isolated — no DB handle, no `process.env` — so a + * cell that wants to run SQL asks the parent to do it. The parent owns the + * connection, the write policy, and the row cap; the worker only sees rows. + */ + +/** Worker → parent: run this statement on the cell's connection. */ +export interface CellQueryRequest { + type: 'cell-query'; + id: number; + text: string; + params: unknown[]; +} + +/** Parent → worker: the outcome of one `cell-query`. */ +export type CellQueryResponse = + | { type: 'cell-query-result'; id: number; ok: true; rows: Record[]; rowCount: number } + | { type: 'cell-query-result'; id: number; ok: false; error: string }; + +/** Worker → parent: the cell finished (existing single-result message). */ +export interface CellDoneMessage { + type: 'cell-done'; + result: unknown; +} + +export type WorkerToParent = CellQueryRequest | CellDoneMessage; + +export function isCellQueryRequest(msg: unknown): msg is CellQueryRequest { + return ( + typeof msg === 'object' && + msg !== null && + (msg as { type?: unknown }).type === 'cell-query' && + typeof (msg as { id?: unknown }).id === 'number' && + typeof (msg as { text?: unknown }).text === 'string' + ); +} + +export function isCellDoneMessage(msg: unknown): msg is CellDoneMessage { + return ( + typeof msg === 'object' && msg !== null && (msg as { type?: unknown }).type === 'cell-done' + ); +} + +/** Rows a bridged query may hand back to a cell before it is truncated. */ +export const MAX_CELL_QUERY_ROWS = 10_000; diff --git a/apps/web/src/backend/api/code-cell-execute.test.ts b/apps/web/src/backend/api/code-cell-execute.test.ts index 5f21d62..19c11af 100644 --- a/apps/web/src/backend/api/code-cell-execute.test.ts +++ b/apps/web/src/backend/api/code-cell-execute.test.ts @@ -150,3 +150,94 @@ describe('runCodeCellOnServer (worker_threads sandbox)', () => { if (!result.ok) expect(result.error).toMatch(/timed out/i); }, 15_000); }); + +describe('code cell SQL bridge', () => { + /** Capture what the parent was asked to run, and reply with canned rows. */ + function stubRunner(rows: Record[] = []) { + const calls: { text: string; params: unknown[] }[] = []; + const runQuery = async (text: string, params: unknown[]) => { + calls.push({ text, params }); + return rows; + }; + return { calls, runQuery }; + } + + async function runCell( + body: string, + opts: Parameters[1], + timeoutMs = 20_000 + ) { + const v = validateCodeCellRequest({ + body, + kind: 'js', + last: null, + vars: {}, + maxRows: 50, + timeoutMs, + }); + if (!v.ok) throw new Error(v.error); + return runCodeCellOnServer(v.value, opts); + } + + it('binds interpolations instead of inlining them', async () => { + const { calls, runQuery } = stubRunner([{ who: "O'Brien" }]); + const result = await runCell( + 'return await sql`SELECT * FROM t WHERE name = ${"O\'Brien"} AND id = ${7}`;', + { dialect: 'postgres', allowWrites: false, runQuery } + ); + if (!result.ok) throw new Error(result.error); + expect(calls).toHaveLength(1); + expect(calls[0]!.text).toBe('SELECT * FROM t WHERE name = $1 AND id = $2'); + expect(calls[0]!.params).toEqual(["O'Brien", 7]); + expect(result.rows).toEqual([["O'Brien"]]); + }, 30_000); + + it('builds a parameterized bulk INSERT from JS objects', async () => { + const { calls, runQuery } = stubRunner(); + const result = await runCell( + "const v=[{id:1,email:\"a'b\"},{id:2,email:'c'}];" + + 'await sql`INSERT INTO ${sql.id("accounts")} ${sql.values(v)}`;' + + 'return [{ done: true }];', + { dialect: 'sqlite', allowWrites: true, runQuery } + ); + if (!result.ok) throw new Error(result.error); + expect(calls[0]!.text).toBe('INSERT INTO "accounts" ("id", "email") VALUES (?, ?), (?, ?)'); + expect(calls[0]!.params).toEqual([1, "a'b", 2, 'c']); + }, 30_000); + + it('surfaces a query error to the cell as a catchable rejection', async () => { + const runQuery = async () => { + throw new Error('no such table: ghost'); + }; + const result = await runCell( + 'try { await sql`SELECT 1 FROM ghost`; return [{ caught: false }]; }' + + ' catch (e) { return [{ caught: true, msg: String(e.message) }]; }', + { dialect: 'sqlite', allowWrites: false, runQuery } + ); + if (!result.ok) throw new Error(result.error); + expect(result.rows[0]![0]).toBe(true); + expect(String(result.rows[0]![1])).toContain('no such table: ghost'); + }, 30_000); + + it('reports a missing connection instead of hanging', async () => { + const result = await runCell('return await sql`SELECT 1`;', { dialect: 'sqlite' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/no connection/i); + }, 30_000); + + it('does not count database time against the cell timeout', async () => { + // 3 × 2s of query time (6s) under a 4s cell budget. If the clock ran during + // bridged queries this would time out; the budget is still generous enough + // to absorb worker cold-start when the whole suite runs in parallel. + const runQuery = async () => { + await new Promise((r) => setTimeout(r, 2000)); + return [{ n: 1 }]; + }; + const result = await runCell( + 'for (let i=0;i<3;i++) { await sql`SELECT 1`; } return [{ ok: true }];', + { dialect: 'sqlite', allowWrites: false, runQuery }, + 4000 + ); + expect(result.ok).toBe(true); + }, 45_000); +}); diff --git a/apps/web/src/backend/api/code-cell-execute.ts b/apps/web/src/backend/api/code-cell-execute.ts index 45a9732..55a374a 100644 --- a/apps/web/src/backend/api/code-cell-execute.ts +++ b/apps/web/src/backend/api/code-cell-execute.ts @@ -8,6 +8,18 @@ import { fileURLToPath } from 'node:url'; import type { BrowserCodeCellKind } from '@foxschema/core'; import { isCodeCellLast, isCodeCellVars } from '@foxschema/core'; import type { CodeCellLast, CodeCellResult, CodeCellVars } from './code-cell-node-exec'; +import { + isCellDoneMessage, + isCellQueryRequest, + type CellQueryRequest, + type CellQueryResponse, +} from './code-cell-bridge'; + +/** Runs one bridged statement for a cell and returns rows as objects. */ +export type CellQueryRunner = ( + text: string, + params: unknown[] +) => Promise[]>; import { clampMaxRows } from './sql-execute'; export const MAX_CODE_CELL_LENGTH = 100_000; @@ -113,11 +125,42 @@ function runInWorkerThread(args: { vars: CodeCellVars; maxRows: number; timeoutMs: number; + dialect?: string; + allowWrites?: boolean; + /** Runs one bridged `sql` statement. Absent = the cell has no connection. */ + runQuery?: CellQueryRunner; }): Promise { return new Promise((resolve) => { let settled = false; let worker: Worker | undefined; let timer: ReturnType | undefined; + /** Bridged queries in flight; the cell clock is paused while > 0. */ + let inFlight = 0; + + const startTimer = () => { + timer = setTimeout(() => { + settle({ ok: false, error: `Code cell timed out after ${args.timeoutMs}ms` }); + }, args.timeoutMs); + }; + + // The timeout budgets the *cell's own* work. A bridged query is the + // database's time, not the cell's, and can legitimately outlast it — so + // stop the clock while one is outstanding. Without this, any cell doing + // real migration work is killed mid-statement. The budget restarts fresh + // after each query rather than resuming its remainder: total DB time is + // deliberately unbounded, while a runaway loop between queries is still + // caught within one full budget. + const pauseClock = () => { + inFlight += 1; + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + }; + const resumeClock = () => { + inFlight = Math.max(0, inFlight - 1); + if (inFlight === 0 && !settled && timer === undefined) startTimer(); + }; const settle = (result: CodeCellResult) => { if (settled) return; @@ -150,6 +193,8 @@ function runInWorkerThread(args: { last: args.last, vars: args.vars, maxRows: args.maxRows, + dialect: args.dialect, + allowWrites: args.allowWrites, }, execArgv, }); @@ -158,14 +203,40 @@ function runInWorkerThread(args: { return; } - timer = setTimeout(() => { - settle({ - ok: false, - error: `Code cell timed out after ${args.timeoutMs}ms`, - }); - }, args.timeoutMs); + startTimer(); + + const answerQuery = async (req: CellQueryRequest) => { + pauseClock(); + const reply = (res: CellQueryResponse) => { + try { + worker?.postMessage(res); + } catch { + /* worker already gone */ + } + }; + try { + if (!args.runQuery) throw new Error('This cell has no connection — select a credential first'); + const rows = await args.runQuery(req.text, req.params); + reply({ type: 'cell-query-result', id: req.id, ok: true, rows, rowCount: rows.length }); + } catch (error: unknown) { + reply({ type: 'cell-query-result', id: req.id, ok: false, error: errorMessage(error) }); + } finally { + resumeClock(); + } + }; - worker.on('message', (msg: CodeCellResult) => settle(msg)); + worker.on('message', (msg: unknown) => { + if (isCellQueryRequest(msg)) { + void answerQuery(msg); + return; + } + if (isCellDoneMessage(msg)) { + settle(msg.result as CodeCellResult); + return; + } + // Older shape (bare result) — keep accepting it. + settle(msg as CodeCellResult); + }); worker.on('error', (error) => { if (!settled) failed(errorMessage(error)); }); @@ -180,7 +251,8 @@ function runInWorkerThread(args: { * Transpile (if needed) and execute a code cell on Node (worker_threads + timeout). */ export async function runCodeCellOnServer( - validated: ValidatedCodeCell + validated: ValidatedCodeCell, + options?: { dialect?: string; allowWrites?: boolean; runQuery?: CellQueryRunner } ): Promise { const started = Date.now(); let body = validated.body; @@ -202,6 +274,9 @@ export async function runCodeCellOnServer( vars: validated.vars, maxRows: validated.maxRows, timeoutMs: validated.timeoutMs, + dialect: options?.dialect, + allowWrites: options?.allowWrites, + runQuery: options?.runQuery, }); return { ...result, durationMs: Date.now() - started }; } diff --git a/apps/web/src/backend/api/code-cell-node-exec.ts b/apps/web/src/backend/api/code-cell-node-exec.ts index 558ff96..a8273d1 100644 --- a/apps/web/src/backend/api/code-cell-node-exec.ts +++ b/apps/web/src/backend/api/code-cell-node-exec.ts @@ -51,6 +51,14 @@ export function executeCodeCellNode(args: { last: CodeCellLast; vars: CodeCellVars; maxRows: number; + /** Query bridge exposed to the cell as `sql`; omitted when unavailable. */ + sql?: unknown; }): Promise { - return runCodeCellBody({ ...args, preamble: SANDBOX_PREAMBLE, modules: MODULES }); + const { sql, ...rest } = args; + return runCodeCellBody({ + ...rest, + preamble: SANDBOX_PREAMBLE, + modules: MODULES, + extraBindings: sql ? { sql } : undefined, + }); } diff --git a/apps/web/src/backend/api/code-cell-query.ts b/apps/web/src/backend/api/code-cell-query.ts new file mode 100644 index 0000000..0f6d12e --- /dev/null +++ b/apps/web/src/backend/api/code-cell-query.ts @@ -0,0 +1,45 @@ +/** + * Executes the statements a `-- @node` cell sends over the bridge. + * + * This is the trust boundary for `sql` inside a cell: the write policy and the + * row cap live here, in the API process, not in the worker that runs user code. + */ + +import { ConnectionFactory, isWriteStatement, type ConnectionOptions } from '@foxschema/core'; +import { MAX_CELL_QUERY_ROWS } from './code-cell-bridge'; +import type { CellQueryRunner } from './code-cell-execute'; + +/** + * Build the bridge runner for one resolved connection. + * + * `allowWrites` mirrors the editor's Safe mode. A cell cannot be statically + * analyzed for what SQL it will build at runtime, so the check has to happen + * here, per statement, at the moment it is submitted. + */ +export function makeCellQueryRunner( + resolved: { dialect: string; option: ConnectionOptions }, + allowWrites: boolean +): CellQueryRunner { + return async (text: string, params: unknown[]) => { + const sql = text.trim(); + if (!sql) throw new Error('Empty statement'); + + if (!allowWrites && isWriteStatement(sql)) { + throw new Error( + 'Safe mode is on — this cell tried to run a write/DDL statement. ' + + 'Turn Safe mode off (or confirm the run) to allow writes from code cells.' + ); + } + + const rows = await ConnectionFactory.executeQuery>( + resolved.dialect, + resolved.option, + sql, + params + ); + if (!Array.isArray(rows)) return []; + // Cap what crosses back into the worker: a cell asking for a 10M-row table + // would otherwise serialize the whole thing through structured clone. + return rows.length > MAX_CELL_QUERY_ROWS ? rows.slice(0, MAX_CELL_QUERY_ROWS) : rows; + }; +} diff --git a/apps/web/src/backend/api/code-cell-thread.ts b/apps/web/src/backend/api/code-cell-thread.ts index e4d5446..baff987 100644 --- a/apps/web/src/backend/api/code-cell-thread.ts +++ b/apps/web/src/backend/api/code-cell-thread.ts @@ -1,9 +1,12 @@ /** * worker_threads entry for Node code cells. - * Receives workerData and posts a CodeCellResult. + * Receives workerData, optionally proxies SQL back to the parent, and posts a + * CodeCellResult. */ import { parentPort, workerData } from 'node:worker_threads'; +import { renderSqlQuery, sqlTag, isSqlQuery, type SqlQuery } from '@foxschema/core'; import { executeCodeCellNode, type CodeCellLast, type CodeCellVars } from './code-cell-node-exec'; +import type { CellQueryResponse } from './code-cell-bridge'; // The AsyncFunction sandbox shadows `process` lexically, but a cell can still // reach the real one via `(function(){}).constructor('return process')()` — the @@ -20,15 +23,75 @@ type Payload = { last: CodeCellLast; vars: CodeCellVars; maxRows: number; + /** Dialect of the cell's connection — decides placeholder + quoting style. */ + dialect?: string; + /** When false, the parent rejects write/DDL statements from `sql`. */ + allowWrites?: boolean; }; +/** In-flight bridged queries, keyed by request id. */ +const pending = new Map< + number, + { resolve: (rows: Record[]) => void; reject: (e: Error) => void } +>(); +let nextQueryId = 1; + +parentPort?.on('message', (msg: CellQueryResponse) => { + if (!msg || msg.type !== 'cell-query-result') return; + const waiter = pending.get(msg.id); + if (!waiter) return; + pending.delete(msg.id); + if (msg.ok) waiter.resolve(msg.rows); + else waiter.reject(new Error(msg.error)); +}); + +/** + * `sql` inside a cell. Renders the tagged template to `{ text, params }` for + * the connection's dialect, then asks the parent to run it — the worker never + * touches a driver itself. + */ +function makeSqlBinding(dialect: string) { + const run = (query: SqlQuery): Promise[]> => { + if (!isSqlQuery(query)) { + return Promise.reject( + new Error('sql`…` must be used as a tagged template: sql`SELECT 1`, not sql("SELECT 1")') + ); + } + if (!parentPort) return Promise.reject(new Error('No SQL bridge available in this context')); + const { text, params } = renderSqlQuery(query, dialect); + const id = nextQueryId++; + return new Promise[]>((resolve, reject) => { + pending.set(id, { resolve, reject }); + parentPort!.postMessage({ type: 'cell-query', id, text, params }); + }); + }; + + // Callable as a tag, and carrying the fragment helpers (sql.values, sql.id, …). + const tag = (strings: TemplateStringsArray, ...values: unknown[]) => + run(sqlTag(strings, ...values)); + return Object.assign(tag, { + raw: sqlTag.raw, + id: sqlTag.id, + values: sqlTag.values, + list: sqlTag.list, + /** Escape hatch for callers holding an already-built query. */ + run, + }); +} + async function main() { const data = workerData as Payload; - const result = await executeCodeCellNode(data); - parentPort?.postMessage(result); + const result = await executeCodeCellNode({ + body: data.body, + last: data.last, + vars: data.vars, + maxRows: data.maxRows, + sql: makeSqlBinding(data.dialect ?? 'postgres'), + }); + parentPort?.postMessage({ type: 'cell-done', result }); } main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); - parentPort?.postMessage({ ok: false, error: message }); + parentPort?.postMessage({ type: 'cell-done', result: { ok: false, error: message } }); }); diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index dd56ae3..e0d19f2 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -30,7 +30,9 @@ import { runCodeCellOnServer, validateCodeCellRequest, type CodeCellRequestBody, + type CellQueryRunner, } from './code-cell-execute'; +import { makeCellQueryRunner } from './code-cell-query'; import { getMetadataDbConfig, SUPPORTED_ENGINES, type DbEngine } from '../database/config'; import { createMetadataStore } from '../database/stores/registry'; import { keySchemeInfo } from '../cores/crypto'; @@ -381,10 +383,11 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt // caps only. Rate-limited: each call can hold a DB connection for a while. const sqlExecuteLimiter = rateLimit({ windowMs: 60 * 1000, max: 60 }); router.post('/sql/execute', sqlExecuteLimiter, async (req: Request, res: Response) => { - const { statements, maxRows, offset, ...ref } = req.body as ConnectionRef & { + const { statements, maxRows, offset, params, ...ref } = req.body as ConnectionRef & { statements?: unknown; maxRows?: unknown; offset?: unknown; + params?: unknown; }; if (!Array.isArray(statements) || statements.length === 0) { res.status(400).json({ error: 'statements[] is required.' }); @@ -398,6 +401,13 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt res.status(400).json({ error: `Every statement must be a non-empty string under ${MAX_STATEMENT_LENGTH} characters.` }); return; } + // Optional bind parameters, one array per statement. Anything else is a + // client bug — reject rather than silently dropping the values, which would + // send a statement whose placeholders have nothing to bind to. + if (params !== undefined && (!Array.isArray(params) || params.some((p) => !Array.isArray(p)))) { + res.status(400).json({ error: 'params must be an array of arrays (one per statement).' }); + return; + } let resolved; try { resolved = await resolveRef((req as AuthedRequest).userId, ref); @@ -414,7 +424,8 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt statements as string[], clampMaxRows(maxRows), resolved.schema, - clampOffset(offset) + clampOffset(offset), + (params as unknown[][] | undefined) ?? [] ); res.json({ results }); } catch (error: unknown) { @@ -427,13 +438,27 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt // runs allowlisted JS/TS with fetch in a worker_threads sandbox. const codeCellLimiter = rateLimit({ windowMs: 60 * 1000, max: 30 }); router.post('/sql/code-cell', codeCellLimiter, async (req: Request, res: Response) => { - const validated = validateCodeCellRequest(req.body as CodeCellRequestBody); + const body = req.body as CodeCellRequestBody & ConnectionRef & { allowWrites?: boolean }; + const validated = validateCodeCellRequest(body); if (!validated.ok) { res.status(400).json({ error: validated.error }); return; } try { - const result = await runCodeCellOnServer(validated.value); + // A cell only gets a `sql` bridge when it was run against a credential. + // Without one it still executes — it just cannot reach a database. + let dialect: string | undefined; + let runQuery: CellQueryRunner | undefined; + if (body.connectionId || (body.dialect && body.option)) { + const resolved = await resolveRef((req as AuthedRequest).userId, body); + dialect = resolved.dialect; + runQuery = makeCellQueryRunner(resolved, body.allowWrites === true); + } + const result = await runCodeCellOnServer(validated.value, { + dialect, + allowWrites: body.allowWrites === true, + runQuery, + }); res.json(result); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Code cell execution failed'; diff --git a/apps/web/src/backend/api/sql-execute.ts b/apps/web/src/backend/api/sql-execute.ts index 37131cb..34c7faf 100644 --- a/apps/web/src/backend/api/sql-execute.ts +++ b/apps/web/src/backend/api/sql-execute.ts @@ -89,7 +89,9 @@ export async function runStatements( statements: string[], maxRows: number, schema?: string, - offset = 0 + offset = 0, + /** Bind parameters per statement, aligned by index. Missing = no params. */ + paramsList: readonly (readonly unknown[])[] = [] ): Promise { const schemaName = (schema ?? option.schema)?.trim() || ''; const optionWithSchema: ConnectionOptions = schemaName @@ -103,8 +105,11 @@ export async function runStatements( } const results: StatementResult[] = []; - for (const sql of statements) { + for (const [index, sql] of statements.entries()) { const started = Date.now(); + // Placeholders survive the paging wrap (it only nests the SQL in a + // subquery), so the same positional params apply on either path. + const params = paramsList[index] ?? []; const pushErr = (message: string) => { results.push({ ok: false, error: message, durationMs: Date.now() - started }); }; @@ -112,7 +117,8 @@ export async function runStatements( const raw = await ConnectionFactory.executeOnConnection>( dialect, connection, - sql + sql, + params ); const shaped = shapeRows(raw, maxRows); results.push({ @@ -143,7 +149,8 @@ export async function runStatements( const raw = await ConnectionFactory.executeOnConnection>( dialect, connection, - paged + paged, + params ); const shaped = shapeRows(raw, maxRows + 1); const page = trimPageProbe(shaped, maxRows); diff --git a/apps/web/src/frontend/api/sqlApi.ts b/apps/web/src/frontend/api/sqlApi.ts index cac3a01..945980a 100644 --- a/apps/web/src/frontend/api/sqlApi.ts +++ b/apps/web/src/frontend/api/sqlApi.ts @@ -24,13 +24,15 @@ export async function executeSql( ref: ConnectionRef, statements: string[], maxRows?: number, - offset?: number + offset?: number, + /** Bind parameters per statement, aligned by index. */ + params?: unknown[][] ): Promise<{ results: SqlStatementResult[] }> { const res = await fetch(`${getApiBase()}/sql/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ ...ref, statements, maxRows, offset }), + body: JSON.stringify({ ...ref, statements, maxRows, offset, params }), }); const data = await parseJsonResponse<{ results?: SqlStatementResult[]; error?: string }>(res); if (!data.results) throw new Error(data.error || `Query failed (${res.status})`); @@ -45,6 +47,13 @@ export type ServerCodeCellPayload = { vars: CodeCellVars; maxRows: number; timeoutMs?: number; + /** + * Connection the cell's `sql` bridge runs against. Omit to run a cell with + * no database access (it still executes; `sql` just reports no connection). + */ + ref?: ConnectionRef; + /** Mirrors Safe mode — the server rejects write/DDL from `sql` when false. */ + allowWrites?: boolean; }; function responseError(data: unknown): string | undefined { @@ -109,11 +118,13 @@ export function parseSqlStatementResult( export async function runCodeCellOnServer( payload: ServerCodeCellPayload ): Promise { + const { ref, ...rest } = payload; const res = await fetch(`${getApiBase()}/sql/code-cell`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify(payload), + // The ref is flattened onto the request body (same shape /sql/execute takes). + body: JSON.stringify({ ...ref, ...rest }), }); const data = await parseJsonResponse(res); const parsed = parseSqlStatementResult(data); diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 441ef73..ff8e1b4 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -233,6 +233,12 @@ export const DataGrid: React.FC<{ pageLoading?: boolean; onPrevPage?: () => void; onNextPage?: () => void; + /** + * Column indexes whose cells act as drill-through links (foreign keys in the + * data peek). Value is the parent table name, used for the tooltip. + */ + linkColumns?: Map; + onLinkClick?: (colIdx: number, rowIdx: number) => void; }> = React.memo( ({ result, @@ -249,6 +255,8 @@ export const DataGrid: React.FC<{ pageLoading, onPrevPage, onNextPage, + linkColumns, + onLinkClick, }) => { const upsertVariable = useSqlEditorStore((s) => s.upsertVariable); const sourceColumns = result.ok ? result.columns : []; @@ -665,7 +673,22 @@ export const DataGrid: React.FC<{ }); }} > - {text} + {linkColumns?.has(colIdx) && !isNull ? ( + + ) : ( + text + )} ); })} diff --git a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx new file mode 100644 index 0000000..bbafd5a --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx @@ -0,0 +1,182 @@ +import React, { useCallback, useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ChevronRight, Loader2, X } from 'lucide-react'; +import { useSqlEditorStore, type DataPeekEntry } from '../../store/useSqlEditorStore'; +import { foreignKeyLinksFor } from '../../lib/tablePreview'; +import { DataGrid } from './DataGrid'; +import { SQL_ICON_STROKE } from './sqlIconStyle'; +import type { TableSchema } from '../../lib/types'; + +/** + * Quick data peek: Cmd/Ctrl-click a table in the schema explorer to see its + * rows without writing a query. Foreign-key cells are links — clicking one + * appends the parent's rows as another grid below, so you can follow a + * relationship a couple of hops without leaving the popup. + */ +const PeekGrid: React.FC<{ + entry: DataPeekEntry; + tables: TableSchema[] | undefined; + isLast: boolean; +}> = ({ entry, tables, isLast }) => { + const drillDataPeek = useSqlEditorStore((s) => s.drillDataPeek); + + // A drilled entry's table comes from `fk.referencedTable`, which several + // catalogs report schema-qualified ("public.customers") while the cache is + // keyed on the bare name. Match on both, or the second hop silently loses + // its own FK links. + const table = useMemo(() => { + if (!tables) return undefined; + const wanted = entry.tableName.toLowerCase(); + const bare = wanted.replace(/^.*\./, ''); + return ( + tables.find((t) => t.name.toLowerCase() === wanted) ?? + tables.find((t) => t.name.toLowerCase().replace(/^.*\./, '') === bare) + ); + }, [tables, entry.tableName]); + + const links = useMemo( + () => (entry.result?.ok ? foreignKeyLinksFor(table, entry.result.columns) : []), + [table, entry.result] + ); + + const linkColumns = useMemo(() => { + const map = new Map(); + for (const l of links) map.set(l.columnIndex, l.fk.referencedTable); + return map; + }, [links]); + + const onLinkClick = useCallback( + (colIdx: number, rowIdx: number) => { + const link = links.find((l) => l.columnIndex === colIdx); + if (!link || !entry.result?.ok) return; + const row = entry.result.rows[rowIdx]; + if (!row) return; + void drillDataPeek(entry.id, link.fk, link.valueIndexes.map((i) => row[i])); + }, + [links, entry, drillDataPeek] + ); + + if (entry.status === 'loading') { + return ( +
+ + Loading {entry.title}… +
+ ); + } + + if (entry.status === 'error' || !entry.result) { + return ( +
+ {entry.error ?? 'Preview failed'} +
+ ); + } + + return ( +
+ 0 ? linkColumns : undefined} + onLinkClick={onLinkClick} + /> + {isLast && linkColumns.size > 0 && ( +

+ Underlined cells are foreign keys — click one to open the related rows below. +

+ )} +
+ ); +}; + +export const DataPeekPanel: React.FC = () => { + const dataPeek = useSqlEditorStore((s) => s.dataPeek); + const closeDataPeek = useSqlEditorStore((s) => s.closeDataPeek); + const closeDataPeekFrom = useSqlEditorStore((s) => s.closeDataPeekFrom); + const schemaCache = useSqlEditorStore((s) => s.schemaCache); + + useEffect(() => { + if (!dataPeek) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeDataPeek(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [dataPeek, closeDataPeek]); + + if (!dataPeek) return null; + const tables = schemaCache[dataPeek.connectionId]?.tables; + + return createPortal( +
+
e.stopPropagation()} + > +
+ + Data peek + + {/* Breadcrumb of the drill path; click a crumb to go back to it. */} +
+ {dataPeek.entries.map((e, i) => ( + + {i > 0 && ( + + )} + + + ))} +
+ +
+ +
+ {dataPeek.entries.map((e, i) => ( + + ))} +
+
+
, + document.body + ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/SqlBookmarksPanel.tsx b/apps/web/src/frontend/components/sql-editor/SqlBookmarksPanel.tsx index 8e73b1a..38dfe9b 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlBookmarksPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlBookmarksPanel.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Pencil, Trash2, Sparkles } from 'lucide-react'; +import { Pencil, Trash2, Sparkles, Eye, EyeOff } from 'lucide-react'; import { useSqlEditorStore } from '../../store/useSqlEditorStore'; import { SQL_EDITOR_SAMPLE_BOOKMARKS } from '../../lib/sqlEditorSamples'; import { SQL_ICON_STROKE } from './sqlIconStyle'; @@ -7,7 +7,20 @@ import { SQL_ICON_STROKE } from './sqlIconStyle'; /** * Persist named SQL scripts. Save is on the sidebar section header; open loads * into a new tab (or focuses an already-linked tab). + * + * The built-in ★ samples can be hidden: they are useful once, then mostly get + * in the way of a user's own scripts. Hiding is a view filter — the bookmarks + * themselves stay installed, so nothing is lost and Refresh still works. */ +const HIDE_SAMPLES_KEY = 'foxschema-sql-bookmarks-hide-samples'; + +function readHideSamples(): boolean { + try { + return localStorage.getItem(HIDE_SAMPLES_KEY) === '1'; + } catch { + return false; + } +} export const SqlBookmarksPanel: React.FC = () => { const bookmarks = useSqlEditorStore((s) => s.bookmarks); const openBookmark = useSqlEditorStore((s) => s.openBookmark); @@ -18,8 +31,21 @@ export const SqlBookmarksPanel: React.FC = () => { const [editingId, setEditingId] = useState(null); const [draft, setDraft] = useState(''); const [sampleNote, setSampleNote] = useState(null); + const [hideSamples, setHideSamples] = useState(readHideSamples); const inputRef = useRef(null); + const toggleHideSamples = () => { + setHideSamples((prev) => { + const next = !prev; + try { + localStorage.setItem(HIDE_SAMPLES_KEY, next ? '1' : '0'); + } catch { + /* ignore */ + } + return next; + }); + }; + useEffect(() => { if (editingId) inputRef.current?.select(); }, [editingId]); @@ -32,6 +58,8 @@ export const SqlBookmarksPanel: React.FC = () => { const onInstallSamples = () => { const n = installSampleBookmarks(); + // Installing while hidden would look like nothing happened. + if (hideSamples) toggleHideSamples(); setSampleNote( n > 0 ? `Added/updated ${n} sample bookmark${n === 1 ? '' : 's'}` @@ -44,6 +72,8 @@ export const SqlBookmarksPanel: React.FC = () => { const hasAllSamples = SQL_EDITOR_SAMPLE_BOOKMARKS.every((s) => bookmarks.some((b) => b.id === s.id) ); + const installedSampleCount = bookmarks.filter((b) => sampleIds.has(b.id)).length; + const visible = hideSamples ? bookmarks.filter((b) => !sampleIds.has(b.id)) : bookmarks; return (
@@ -58,18 +88,41 @@ export const SqlBookmarksPanel: React.FC = () => { {hasAllSamples ? 'Refresh samples' : 'Add samples'} + {installedSampleCount > 0 && ( + + )} {sampleNote && ( {sampleNote} )}
- {bookmarks.length === 0 ? ( + {visible.length === 0 ? (

- Save a named script to reopen later, or add the built-in JS/TS/Node samples above. + {bookmarks.length === 0 + ? 'Save a named script to reopen later, or add the built-in JS/TS/Node samples above.' + : 'Only sample bookmarks here — use Samples above to show them, or save your own script.'}

) : (
    - {bookmarks.map((b) => ( + {visible.map((b) => (
  • (function Sq const activeTabId = useSqlEditorStore((s) => s.activeTabId); const schemaCache = useSqlEditorStore((s) => s.schemaCache); const ensureSchema = useSqlEditorStore((s) => s.ensureSchema); + const openDataPeek = useSqlEditorStore((s) => s.openDataPeek); const shareDestinations = useSqlEditorStore((s) => s.shareDestinations); const sharedConnectionIds = useSqlEditorStore((s) => s.sharedConnectionIds); const ensureConnectionSelected = useSqlEditorStore((s) => s.ensureConnectionSelected); @@ -315,6 +324,12 @@ export const SqlSchemaExplorer = forwardRef(function Sq } : undefined } + canPeek={ + t.objectType === 'TABLE' || + t.objectType === 'MQT' || + t.objectType === 'VIEW' + } + onPeek={(name) => explorerId && void openDataPeek(explorerId, name)} onOpenSource={ isScriptableObject(t.objectType) ? () => setSql(objectSourceScript(t, conn?.dialect ?? 'sql')) @@ -341,6 +356,8 @@ export const SqlSchemaExplorer = forwardRef(function Sq }} /> )} + + )} @@ -353,8 +370,11 @@ const ObjectNode: React.FC<{ onToggle: () => void; dialect: string; onOpenBlueprint?: () => void; + /** Data peek is only meaningful for row-bearing objects (not routines). */ + canPeek?: boolean; + onPeek?: (tableName: string) => void; onOpenSource?: () => void; -}> = ({ table, open, onToggle, dialect, onOpenBlueprint, onOpenSource }) => { +}> = ({ table, open, onToggle, dialect, onOpenBlueprint, onOpenSource, canPeek, onPeek }) => { const meta = TYPE_META[table.objectType] ?? TYPE_META.TABLE; const insertName = quoteIfNeeded(table.name, dialect); const isRoutine = table.objectType === 'PROCEDURE' || table.objectType === 'FUNCTION'; @@ -372,7 +392,13 @@ const ObjectNode: React.FC<{ insertAtCursor(`${insertName} `); }; - const onNameClick = () => { + const onNameClick = (e: React.MouseEvent) => { + // Cmd (macOS) / Ctrl (Windows, Linux) + click = quick data peek. + if (canPeek && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + onPeek?.(table.name); + return; + } if (onOpenSource) { onOpenSource(); return; @@ -401,13 +427,15 @@ const ObjectNode: React.FC<{