diff --git a/apps/web/package.json b/apps/web/package.json index 01b04ad..77ccf9a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,7 +33,9 @@ "@tailwindcss/vite": "^4.3.0", "better-sqlite3": "^12.11.1", "cors": "^2.8.6", + "date-fns": "^4.4.0", "express": "^5.2.1", + "lodash-es": "^4.18.1", "lucide-react": "^1.16.0", "monaco-editor": "0.55.1", "mssql": "^12.6.0", @@ -52,6 +54,7 @@ "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", + "@types/lodash-es": "^4.17.12", "@types/node": "^26.1.0", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 81a65b8..6d30716 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -478,7 +478,7 @@ export const DataGrid: React.FC<{
{ @@ -677,7 +677,7 @@ export const DataGrid: React.FC<{ )}
-
+
{result.rowCount} row{result.rowCount === 1 ? '' : 's'} {pageSize ? ` / page of ${pageSize}` : ''} @@ -686,7 +686,7 @@ export const DataGrid: React.FC<{ {result.durationMs} ms {result.truncated && ( - + truncated — use Next page or raise Rows/page )} @@ -697,7 +697,7 @@ export const DataGrid: React.FC<{ data-testid="sql-page-prev" disabled={!hasPrevPage || pageLoading} onClick={onPrevPage} - className="px-1.5 py-0.5 rounded border border-slate-300 text-slate-600 font-semibold hover:bg-slate-100 disabled:opacity-40" + className="px-1.5 py-0.5 rounded border border-slate-600 text-slate-300 font-semibold hover:bg-slate-800 disabled:opacity-40" > Prev @@ -706,7 +706,7 @@ export const DataGrid: React.FC<{ data-testid="sql-page-next" disabled={!hasNextPage || pageLoading} onClick={onNextPage} - className="px-1.5 py-0.5 rounded border border-slate-300 text-slate-600 font-semibold hover:bg-slate-100 disabled:opacity-40" + className="px-1.5 py-0.5 rounded border border-slate-600 text-slate-300 font-semibold hover:bg-slate-800 disabled:opacity-40" > {pageLoading ? '…' : 'Next'} diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index ad45169..f4b4370 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -4,6 +4,7 @@ import type { CredentialRun } from '../../store/useSqlEditorStore'; import type { ResultsLayout } from '../../store/sqlEditorTabLogic'; import { DataGrid, PANE_DEFAULT_H_PX, PANE_DEFAULT_PX, PANE_MIN_H_PX, PANE_MIN_PX } from './DataGrid'; import type { SqlStatementResult } from '../../api/sqlApi'; +import { detectCodeCell } from '../../lib/codeCellRunner'; interface Props { runs: CredentialRun[]; @@ -45,6 +46,10 @@ function bindAxisDrag(cursor: string, onMove: (ev: MouseEvent) => void): void { } const statementLabel = (sql: string, index: number): string => { + const cell = detectCodeCell(sql); + if (cell) { + return `Query ${index + 1} · ${cell.kind === 'ts' ? 'TypeScript' : 'JavaScript'}`; + } const compact = sql.replace(/\s+/g, ' ').trim(); return `Query ${index + 1} · ${compact.length > 48 ? compact.slice(0, 48) + '…' : compact}`; }; diff --git a/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx b/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx index f6e3e71..a273b19 100644 --- a/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx +++ b/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx @@ -183,7 +183,8 @@ export const StatementStrip: React.FC = ({ statements, checked, onToggle, const status = checkStatement(stmt); const ok = status.level === 'ok'; const isChecked = checked.includes(i); - const verb = statementVerb(stmt.text); + const codeKind = stmt.kind === 'js' || stmt.kind === 'ts' ? stmt.kind : null; + const verb = codeKind ? null : statementVerb(stmt.text); const dmlBadge = safeMode && verb && isMutatingDmlStatement(stmt.text) ? DML_BADGE[verb] : null; const noWhere = dmlBadge ? dmlLacksWhere(stmt.text) : false; @@ -226,6 +227,14 @@ export const StatementStrip: React.FC = ({ statements, checked, onToggle, > {ok ? '✓' : '⚠'} + {codeKind && ( + + {codeKind === 'ts' ? 'TS' : 'JS'} + + )} {dmlBadge && ( +import { + executeCodeCellSync, + type CodeCellLast, + type CodeCellResult, + type CodeCellVars, +} from './codeCellExec'; + +export type CodeCellWorkerRequest = { + id: number; + body: string; + last: CodeCellLast; + vars: CodeCellVars; + maxRows: number; +}; + +export type CodeCellWorkerResponse = { id: number } & CodeCellResult; + +const ctx: DedicatedWorkerGlobalScope = self as unknown as DedicatedWorkerGlobalScope; + +ctx.onmessage = (ev: MessageEvent) => { + const msg = ev.data; + const result = executeCodeCellSync({ + body: msg.body, + last: msg.last, + vars: msg.vars, + maxRows: msg.maxRows, + }); + ctx.postMessage({ id: msg.id, ...result } satisfies CodeCellWorkerResponse); +}; diff --git a/apps/web/src/frontend/lib/codeCellExec.test.ts b/apps/web/src/frontend/lib/codeCellExec.test.ts new file mode 100644 index 0000000..3e9ab63 --- /dev/null +++ b/apps/web/src/frontend/lib/codeCellExec.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { + executeCodeCellSync, + normalizeCodeCellReturn, + sanitizeVarsForCodeCell, + codeCellHasReturn, +} from './codeCellExec'; +import { prepareCodeCellSource } from './codeCellRunner'; + +describe('sanitizeVarsForCodeCell', () => { + it('omits secret variables', () => { + const vars = sanitizeVarsForCodeCell([ + { name: 'token', kind: 'scalar', value: 'secret', secret: true }, + { name: 'n', kind: 'scalar', value: 3 }, + { name: 'ids', kind: 'list', values: [1, 2] }, + { + name: 't', + kind: 'table', + columns: ['a'], + rows: [[1]], + }, + ]); + expect(vars.token).toBeUndefined(); + expect(vars.n).toEqual({ kind: 'scalar', value: 3 }); + expect(vars.ids).toEqual({ kind: 'list', values: [1, 2] }); + expect(vars.t).toEqual({ kind: 'table', columns: ['a'], rows: [[1]] }); + }); +}); + +describe('normalizeCodeCellReturn', () => { + it('accepts columns/rows and caps maxRows', () => { + const r = normalizeCodeCellReturn( + { columns: ['a'], rows: [[1], [2], [3]] }, + 2 + ); + expect(r).toMatchObject({ ok: true, rowCount: 2, truncated: true }); + if (r.ok) expect(r.rows).toEqual([[1], [2]]); + }); + + it('accepts array of objects', () => { + const r = normalizeCodeCellReturn([{ id: 1, name: 'a' }, { id: 2, name: 'b' }], 50); + expect(r).toMatchObject({ ok: true, columns: ['id', 'name'], rowCount: 2 }); + if (r.ok) expect(r.rows).toEqual([ + [1, 'a'], + [2, 'b'], + ]); + }); + + it('rejects unsupported returns', () => { + expect(normalizeCodeCellReturn(42, 10).ok).toBe(false); + }); +}); + +describe('executeCodeCellSync', () => { + it('maps last.rows into a new grid', () => { + const r = executeCodeCellSync({ + body: `return { + columns: ['id', 'n'], + rows: last.rows.map((r) => [r[0], Number(r[0]) * 2]), + };`, + last: { columns: ['id'], rows: [[1], [2]], rowCount: 2 }, + vars: {}, + maxRows: 100, + }); + expect(r).toMatchObject({ ok: true, rowCount: 2 }); + if (r.ok) expect(r.rows).toEqual([ + [1, 2], + [2, 4], + ]); + }); + + it('allows local variables and for…of loops', () => { + const r = executeCodeCellSync({ + body: ` + const out = []; + const factor = 2; + for (const row of last.rows) { + const id = Number(row[0]); + out.push({ id, n: id * factor }); + } + return out; + `, + last: { columns: ['id'], rows: [[3], [5]], rowCount: 2 }, + vars: {}, + maxRows: 100, + }); + expect(r).toMatchObject({ ok: true, rowCount: 2, columns: ['id', 'n'] }); + if (r.ok) expect(r.rows).toEqual([ + [3, 6], + [5, 10], + ]); + }); + + it('rejects cells without a return statement', () => { + const r = executeCodeCellSync({ + body: `const rows = last.rows.map((r) => ({ id: r[0] }));`, + last: { columns: ['id'], rows: [[1]], rowCount: 1 }, + vars: {}, + maxRows: 10, + }); + expect(r).toMatchObject({ ok: false }); + if (!r.ok) expect(r.error).toMatch(/return statement/i); + }); + + it('ignores the word return inside strings when checking', () => { + expect(codeCellHasReturn(`const s = "return nothing";`)).toBe(false); + expect(codeCellHasReturn(`const s = "return nothing";\nreturn [];`)).toBe(true); + }); + + it('reads vars and surfaces throws', () => { + const ok = executeCodeCellSync({ + body: `return { columns: ['v'], rows: [[vars.n.value]] };`, + last: null, + vars: { n: { kind: 'scalar', value: 9 } }, + maxRows: 10, + }); + expect(ok).toMatchObject({ ok: true }); + if (ok.ok) expect(ok.rows).toEqual([[9]]); + + const bad = executeCodeCellSync({ + body: `throw new Error('boom'); return null;`, + last: null, + vars: {}, + maxRows: 10, + }); + expect(bad).toMatchObject({ ok: false }); + if (!bad.ok) expect(bad.error).toMatch(/boom/); + }); +}); + + +describe('prepareCodeCellSource', () => { + it('strips fence markers and @set lines', () => { + const prepared = prepareCodeCellSource(`-- @js +-- @set out = table +return last; +-- @end`); + expect(prepared).toMatchObject({ + kind: 'js', + body: 'return last;', + }); + if (!('error' in prepared)) { + expect(prepared.directives).toEqual([{ mode: 'table', name: 'out' }]); + } + }); + + it('accepts leading @set above the fence', () => { + const prepared = prepareCodeCellSource(`-- @set src = table +-- @js +return last; +-- @end`); + expect(prepared).toMatchObject({ kind: 'js', body: 'return last;' }); + if (!('error' in prepared)) { + expect(prepared.directives).toEqual([{ mode: 'table', name: 'src' }]); + } + }); +}); diff --git a/apps/web/src/frontend/lib/codeCellExec.ts b/apps/web/src/frontend/lib/codeCellExec.ts new file mode 100644 index 0000000..2a898bc --- /dev/null +++ b/apps/web/src/frontend/lib/codeCellExec.ts @@ -0,0 +1,222 @@ +/** + * Pure helpers for SQL Editor code cells (`-- @js` / `-- @ts`). + * The Worker and unit tests both call `executeCodeCellSync`. + * + * Cells may use local `let`/`const`/`var`, functions, loops, and allowlisted + * `import`s (`lodash` / `lodash-es` / `date-fns`). Each cell is isolated + * (no shared helpers across cells). Must **return** a grid value. + */ + +import { codeCellHasReturn } from './sql-splitter'; +import { prepareCodeCellImports } from './codeCellPackages'; + +export { codeCellHasReturn } from './sql-splitter'; + +export type CodeCellLast = { + columns: string[]; + rows: unknown[][]; + rowCount: number; +} | null; + +/** Structured-cloneable variable bag passed into cells (secrets omitted). */ +export type CodeCellVars = Record< + string, + | { kind: 'scalar'; value: unknown } + | { kind: 'list'; values: unknown[] } + | { kind: 'table'; columns: string[]; rows: unknown[][] } +>; + +export type CodeCellOk = { + ok: true; + columns: string[]; + rows: unknown[][]; + rowCount: number; + truncated: boolean; +}; + +export type CodeCellErr = { ok: false; error: string }; + +export type CodeCellResult = CodeCellOk | CodeCellErr; + +type VarLike = { + name: string; + kind: 'scalar' | 'list' | 'table'; + secret?: boolean; + value?: unknown; + values?: unknown[]; + columns?: string[]; + rows?: unknown[][]; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Drop secret variables; shape the rest for the cell scope. */ +export function sanitizeVarsForCodeCell(variables: VarLike[]): CodeCellVars { + const out: CodeCellVars = {}; + for (const v of variables) { + if (v.secret) continue; + if (v.kind === 'scalar') { + out[v.name] = { kind: 'scalar', value: v.value }; + } else if (v.kind === 'list') { + out[v.name] = { kind: 'list', values: [...(v.values ?? [])] }; + } else { + out[v.name] = { + kind: 'table', + columns: [...(v.columns ?? [])], + rows: (v.rows ?? []).map((r) => [...r]), + }; + } + } + return out; +} + +function normalizeObjectRows( + value: Record[], + maxRows: number +): CodeCellOk { + const colSet = new Set(); + for (const row of value.slice(0, 50)) { + for (const k of Object.keys(row)) colSet.add(k); + } + const columns = [...colSet]; + const truncated = value.length > maxRows; + const kept = truncated ? value.slice(0, maxRows) : value; + const rows = kept.map((r) => columns.map((c) => r[c])); + return { ok: true, columns, rows, rowCount: rows.length, truncated }; +} + +function normalizeColumnsRows( + columnsRaw: unknown[], + rowsRaw: unknown[], + maxRows: number +): CodeCellOk { + const columns = columnsRaw.map((c) => String(c)); + const truncated = rowsRaw.length > maxRows; + const kept = truncated ? rowsRaw.slice(0, maxRows) : rowsRaw; + const rows: unknown[][] = kept.map((r) => { + if (Array.isArray(r)) return columns.map((_, i) => r[i]); + if (isPlainObject(r)) return columns.map((c) => r[c]); + return columns.map(() => undefined); + }); + return { ok: true, columns, rows, rowCount: rows.length, truncated }; +} + +/** + * Normalize a cell return value into columns/rows. + * Accepts `{ columns, rows }` or an array of plain objects. + */ +export function normalizeCodeCellReturn( + value: unknown, + maxRows: number +): CodeCellOk | CodeCellErr { + if (value === null || value === undefined) { + return { + ok: false, + error: + 'Code cell must return a value — use return { columns, rows } or return [...objects]', + }; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return { ok: true, columns: [], rows: [], rowCount: 0, truncated: false }; + } + if (value.every(isPlainObject)) { + return normalizeObjectRows(value, maxRows); + } + return { + ok: false, + error: 'Array return must be an array of plain objects (or return { columns, rows })', + }; + } + + if (isPlainObject(value)) { + const { columns, rows } = value as { columns?: unknown; rows?: unknown }; + if (!Array.isArray(columns) || !Array.isArray(rows)) { + return { + ok: false, + error: 'Return { columns: string[], rows: unknown[][] } or an array of objects', + }; + } + return normalizeColumnsRows(columns, rows, maxRows); + } + + return { ok: false, error: `Unsupported return type: ${typeof value}` }; +} + +const SANDBOX_PREAMBLE = ` +"use strict"; +var fetch = undefined; +var XMLHttpRequest = undefined; +var WebSocket = undefined; +var indexedDB = undefined; +var localStorage = undefined; +var sessionStorage = undefined; +var importScripts = undefined; +var self = undefined; +var window = undefined; +var document = undefined; +var globalThis = undefined; +`; + +/** + * Run JS cell body with `last`, `vars`, and allowlisted import bindings in scope. + * Callers must transpile TypeScript to JS before invoking. + */ +export function executeCodeCellSync(args: { + body: string; + last: CodeCellLast; + vars: CodeCellVars; + maxRows: number; +}): CodeCellResult { + const rawBody = args.body.trim(); + if (!rawBody) { + return { ok: false, error: 'Code cell is empty' }; + } + + const prepared = prepareCodeCellImports(rawBody); + if (!prepared.ok) { + return { ok: false, error: prepared.error }; + } + + const body = prepared.body.trim(); + if (!body) { + return { ok: false, error: 'Code cell is empty after imports' }; + } + if (!codeCellHasReturn(body)) { + return { + ok: false, + error: + 'Code cell must include a return statement — e.g. return { columns, rows } or return rows.map(...)', + }; + } + + const bindingNames = Object.keys(prepared.bindings); + for (const name of bindingNames) { + if (!/^[A-Za-z_$][\w$]*$/.test(name)) { + return { ok: false, error: `Invalid import binding name: ${name}` }; + } + } + const bindingValues = bindingNames.map((n) => prepared.bindings[n]); + + try { + // Sandboxed Function: last/vars + allowlisted import bindings only. + const fn = new Function('last', 'vars', ...bindingNames, `${SANDBOX_PREAMBLE}${body}`); + const raw = fn(args.last, args.vars, ...bindingValues); + if (raw != null && typeof (raw as Promise).then === 'function') { + return { + ok: false, + error: 'Async code cells are not supported yet (do not return a Promise)', + }; + } + return normalizeCodeCellReturn(raw, args.maxRows); + } catch (error: unknown) { + return { ok: false, error: errorMessage(error) }; + } +} diff --git a/apps/web/src/frontend/lib/codeCellPackages.test.ts b/apps/web/src/frontend/lib/codeCellPackages.test.ts new file mode 100644 index 0000000..07483be --- /dev/null +++ b/apps/web/src/frontend/lib/codeCellPackages.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { parseCodeCellImports } from './codeCellPackages'; +import { executeCodeCellSync } from './codeCellExec'; + +describe('parseCodeCellImports', () => { + it('accepts allowlisted default / named / namespace imports', () => { + const parsed = parseCodeCellImports(`import _ from 'lodash'; +import { format } from 'date-fns'; +import * as df from 'date-fns'; + +return []; +`); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.specs).toHaveLength(3); + expect(parsed.bodyWithoutImports).toBe('return [];'); + }); + + it('rejects unknown packages', () => { + const parsed = parseCodeCellImports(`import fs from 'fs';\nreturn [];`); + expect(parsed.ok).toBe(false); + if (!parsed.ok) expect(parsed.error).toMatch(/allowlisted/i); + }); + + it('rejects mid-body imports', () => { + const parsed = parseCodeCellImports(`const x = 1;\nimport _ from 'lodash';\nreturn [];`); + expect(parsed.ok).toBe(false); + if (!parsed.ok) expect(parsed.error).toMatch(/top of the code cell/i); + }); +}); + +describe('executeCodeCellSync with imports + functions', () => { + it('runs lodash + local function', () => { + const body = `import _ from 'lodash'; +function doubleRow(r) { + return { id: r[0], n: Number(r[0]) * 2 }; +} +return _.map(last.rows, doubleRow); +`; + const r = executeCodeCellSync({ + body, + last: { columns: ['id'], rows: [[4], [6]], rowCount: 2 }, + vars: {}, + maxRows: 100, + }); + expect(r).toMatchObject({ ok: true, rowCount: 2 }); + if (r.ok) { + expect(r.columns).toEqual(['id', 'n']); + expect(r.rows).toEqual([ + [4, 8], + [6, 12], + ]); + } + }); + + it('runs date-fns named import', () => { + const r = executeCodeCellSync({ + body: `import { format } from 'date-fns'; +return [{ stamp: format(new Date(Date.UTC(2020, 0, 2)), 'yyyy-MM-dd', { useAdditionalDayOfYearTokens: false }) }]; +`, + last: null, + vars: {}, + maxRows: 10, + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(String(r.rows[0]?.[0])).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); +}); diff --git a/apps/web/src/frontend/lib/codeCellPackages.ts b/apps/web/src/frontend/lib/codeCellPackages.ts new file mode 100644 index 0000000..d2ddd6f --- /dev/null +++ b/apps/web/src/frontend/lib/codeCellPackages.ts @@ -0,0 +1,205 @@ +/** + * Allowlisted packages for SQL Editor JS/TS code cells. + * Only these names may appear in `import … from '…'`. + * Cells stay isolated — imports are resolved per cell (no shared function registry). + */ + +import * as lodash from 'lodash-es'; +import * as dateFns from 'date-fns'; + +export const CODE_CELL_ALLOWED_PACKAGES = ['lodash', 'lodash-es', 'date-fns'] as const; +export type CodeCellAllowedPackage = (typeof CODE_CELL_ALLOWED_PACKAGES)[number]; + +const ALLOWED = new Set(CODE_CELL_ALLOWED_PACKAGES); + +/** Bundled module namespaces keyed by import specifier. */ +export const CODE_CELL_PACKAGE_MODULES: Record = { + lodash: lodash as object, + 'lodash-es': lodash as object, + 'date-fns': dateFns as object, +}; + +type NamedBinding = { imported: string; local: string }; + +type ImportSpec = + | { kind: 'default'; local: string; pkg: string } + | { kind: 'namespace'; local: string; pkg: string } + | { kind: 'named'; names: NamedBinding[]; pkg: string }; + +const DEFAULT_IMPORT_RE = + /^import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"]\s*;?\s*$/; +const NAMESPACE_IMPORT_RE = + /^import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"]\s*;?\s*$/; +const NAMED_IMPORT_RE = + /^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]\s*;?\s*$/; +const MIXED_IMPORT_RE = + /^import\s+[A-Za-z_$][\w$]*\s*,\s*\{/; + +function parseNamedList(inner: string): NamedBinding[] | { error: string } { + const names: NamedBinding[] = []; + for (const part of inner.split(',')) { + const bit = part.trim(); + if (!bit) continue; + const asMatch = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(bit); + if (asMatch) { + names.push({ imported: asMatch[1]!, local: asMatch[2]! }); + continue; + } + if (/^[A-Za-z_$][\w$]*$/.test(bit)) { + names.push({ imported: bit, local: bit }); + continue; + } + return { error: `Invalid named import: ${bit}` }; + } + return names; +} + +function parseImportLine(trimmed: string): ImportSpec | { error: string } { + if (MIXED_IMPORT_RE.test(trimmed)) { + return { + error: 'Mixed default+named imports are not supported — use separate import lines', + }; + } + + const ns = NAMESPACE_IMPORT_RE.exec(trimmed); + if (ns) return { kind: 'namespace', local: ns[1]!, pkg: ns[2]! }; + + const named = NAMED_IMPORT_RE.exec(trimmed); + if (named) { + const names = parseNamedList(named[1]!); + if ('error' in names) return names; + return { kind: 'named', names, pkg: named[2]! }; + } + + const def = DEFAULT_IMPORT_RE.exec(trimmed); + if (def) return { kind: 'default', local: def[1]!, pkg: def[2]! }; + + return { error: `Unsupported import syntax: ${trimmed.slice(0, 80)}` }; +} + +/** + * Parse leading `import` lines, validate against the allowlist, and return the + * remaining body. Imports must be at the top (blank lines / // comments ok). + */ +export function parseCodeCellImports(body: string): + | { ok: true; specs: ImportSpec[]; bodyWithoutImports: string } + | { ok: false; error: string } { + const lines = body.split(/\r?\n/); + const specs: ImportSpec[] = []; + let i = 0; + + while (i < lines.length) { + const trimmed = lines[i]!.trim(); + if (trimmed === '' || trimmed.startsWith('//')) { + i++; + continue; + } + if (!trimmed.startsWith('import')) break; + + const parsed = parseImportLine(trimmed); + if ('error' in parsed) return { ok: false, error: parsed.error }; + if (!ALLOWED.has(parsed.pkg)) { + return { + ok: false, + error: `Package "${parsed.pkg}" is not allowlisted. Allowed: ${[...ALLOWED].join(', ')}`, + }; + } + specs.push(parsed); + i++; + } + + // Reject mid-body imports (after first non-import code line). + for (let j = i; j < lines.length; j++) { + const t = lines[j]!.trim(); + if (t.startsWith('import ') || t.startsWith('import{')) { + return { + ok: false, + error: 'import statements must appear at the top of the code cell', + }; + } + } + + return { + ok: true, + specs, + bodyWithoutImports: lines.slice(i).join('\n').trim(), + }; +} + +function moduleDefault(mod: object): unknown { + if (mod && typeof mod === 'object' && 'default' in mod) { + const def = (mod as { default: unknown }).default; + if (def != null) return def; + } + return mod; +} + +function setBinding( + bindings: Record, + local: string, + value: unknown +): { error: string } | null { + if (bindings[local] !== undefined) { + return { error: `Duplicate binding "${local}"` }; + } + bindings[local] = value; + return null; +} + +/** + * Build identifier → value bindings for `new Function` parameters. + */ +export function resolveCodeCellImportBindings( + specs: ImportSpec[], + modules: Record = CODE_CELL_PACKAGE_MODULES +): { ok: true; bindings: Record } | { ok: false; error: string } { + const bindings: Record = {}; + + for (const spec of specs) { + const mod = modules[spec.pkg]; + if (!mod) { + return { ok: false, error: `Package "${spec.pkg}" is not loaded` }; + } + + if (spec.kind === 'default') { + const err = setBinding(bindings, spec.local, moduleDefault(mod)); + if (err) return { ok: false, error: err.error }; + continue; + } + + if (spec.kind === 'namespace') { + const err = setBinding(bindings, spec.local, mod); + if (err) return { ok: false, error: err.error }; + continue; + } + + const rec = mod as Record; + for (const { imported, local } of spec.names) { + if (!(imported in rec)) { + return { + ok: false, + error: `"${imported}" is not exported by "${spec.pkg}"`, + }; + } + const err = setBinding(bindings, local, rec[imported]); + if (err) return { ok: false, error: err.error }; + } + } + + return { ok: true, bindings }; +} + +/** Parse + resolve imports in one step. */ +export function prepareCodeCellImports(body: string): + | { ok: true; body: string; bindings: Record } + | { ok: false; error: string } { + const parsed = parseCodeCellImports(body); + if (!parsed.ok) return parsed; + const resolved = resolveCodeCellImportBindings(parsed.specs); + if (!resolved.ok) return resolved; + return { + ok: true, + body: parsed.bodyWithoutImports, + bindings: resolved.bindings, + }; +} diff --git a/apps/web/src/frontend/lib/codeCellRunner.ts b/apps/web/src/frontend/lib/codeCellRunner.ts new file mode 100644 index 0000000..68f4dfe --- /dev/null +++ b/apps/web/src/frontend/lib/codeCellRunner.ts @@ -0,0 +1,213 @@ +/** + * Run a SQL Editor JS/TS code cell. Prefers a Web Worker; falls back to sync + * execution when Worker is unavailable (unit tests / Node). + */ + +import type { SqlStatementResult } from '../api/sqlApi'; +import type { SetDirective, SqlVariable } from './sql-variables'; +import { parseSetDirectives } from './sql-variables'; +import { parseCodeCell } from './sql-splitter'; +import { + executeCodeCellSync, + sanitizeVarsForCodeCell, + type CodeCellLast, + type CodeCellResult, + type CodeCellVars, +} from './codeCellExec'; + +const DEFAULT_TIMEOUT_MS = 5_000; + +let nextId = 1; +let workerCtorPromise: Promise<(new () => Worker) | null> | null = null; + +export type RunCodeCellArgs = { + /** Full statement text including `-- @js` / `-- @end` (and optional `@set` lines). */ + statement: string; + last: CodeCellLast; + variables: SqlVariable[]; + maxRows: number; + timeoutMs?: number; +}; + +/** + * Detect a fenced JS/TS cell, allowing leading `-- @set` above `-- @js`/`-- @ts`. + */ +export function detectCodeCell( + statement: string +): { kind: 'js' | 'ts'; body: string; closed: boolean } | null { + const afterSets = parseSetDirectives(statement).sql; + return parseCodeCell(afterSets) ?? parseCodeCell(statement); +} + +/** Prepare body + kind from a fenced statement (strips @set + fence markers). */ +export function prepareCodeCellSource(statement: string): + | { kind: 'js' | 'ts'; body: string; directives: SetDirective[] } + | { error: string } { + // Leading `-- @set` may sit above `-- @js` (reattachSetComments). + const leading = parseSetDirectives(statement); + const cell = parseCodeCell(leading.sql); + if (!cell) return { error: 'Not a code cell' }; + const inner = parseSetDirectives(cell.body); + return { + kind: cell.kind, + body: inner.sql, + directives: [...leading.directives, ...inner.directives], + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function transpileTs(body: string): Promise { + const ts = await import('typescript'); + const out = ts.transpileModule(body, { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.None, + strict: false, + }, + reportDiagnostics: true, + }); + const errs = (out.diagnostics ?? []).filter((d) => d.category === ts.DiagnosticCategory.Error); + if (errs.length > 0) { + const msg = errs + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')) + .join('; '); + throw new Error(msg || 'TypeScript transpile failed'); + } + return out.outputText; +} + +function toStatementResult(result: CodeCellResult, started: number): SqlStatementResult { + const durationMs = Date.now() - started; + if (!result.ok) { + return { ok: false, error: result.error, durationMs }; + } + return { + ok: true, + columns: result.columns, + rows: result.rows, + rowCount: result.rowCount, + truncated: result.truncated, + hasNext: false, + durationMs, + }; +} + +async function loadWorkerCtor(): Promise<(new () => Worker) | null> { + if (typeof Worker === 'undefined') return null; + if (!workerCtorPromise) { + workerCtorPromise = import('./codeCell.worker.ts?worker') + .then((mod) => (mod as { default?: new () => Worker }).default ?? null) + .catch(() => null); + } + return workerCtorPromise; +} + +function runInWorker(args: { + WorkerCtor: new () => Worker; + body: string; + last: CodeCellLast; + vars: CodeCellVars; + maxRows: number; + timeoutMs: number; + started: number; +}): Promise { + const { WorkerCtor, body, last, vars, maxRows, timeoutMs, started } = args; + + return new Promise((resolve) => { + let settled = false; + const id = nextId++; + let worker: Worker; + try { + worker = new WorkerCtor(); + } catch { + resolve(toStatementResult(executeCodeCellSync({ body, last, vars, maxRows }), started)); + return; + } + + const finish = (result: SqlStatementResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + worker.terminate(); + } catch { + /* ignore */ + } + resolve(result); + }; + + const timer = setTimeout(() => { + finish({ + ok: false, + error: `Code cell timed out after ${timeoutMs}ms`, + durationMs: Date.now() - started, + }); + }, timeoutMs); + + worker.onmessage = (ev: MessageEvent) => { + const data = ev.data as { id: number } & CodeCellResult; + if (data.id !== id) return; + finish(toStatementResult(data, started)); + }; + worker.onerror = (err) => { + finish({ + ok: false, + error: err.message || 'Code cell worker error', + durationMs: Date.now() - started, + }); + }; + + worker.postMessage({ id, body, last, vars, maxRows }); + }); +} + +/** + * Execute a fenced JS/TS cell. Returns the same shape as SQL statement results. + * Also returns parsed `@set` directives so the store can apply them. + */ +export async function runCodeCell( + args: RunCodeCellArgs +): Promise<{ result: SqlStatementResult; directives: SetDirective[] }> { + const started = Date.now(); + const prepared = prepareCodeCellSource(args.statement); + if ('error' in prepared) { + return { + result: { ok: false, error: prepared.error, durationMs: Date.now() - started }, + directives: [], + }; + } + + let body = prepared.body; + try { + if (prepared.kind === 'ts') { + body = await transpileTs(body); + } + } catch (error: unknown) { + return { + result: { + ok: false, + error: `TypeScript: ${errorMessage(error)}`, + durationMs: Date.now() - started, + }, + directives: prepared.directives, + }; + } + + const vars = sanitizeVarsForCodeCell(args.variables); + const timeoutMs = args.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const execArgs = { body, last: args.last, vars, maxRows: args.maxRows }; + + const WorkerCtor = await loadWorkerCtor(); + if (!WorkerCtor) { + return { + result: toStatementResult(executeCodeCellSync(execArgs), started), + directives: prepared.directives, + }; + } + + const result = await runInWorker({ WorkerCtor, ...execArgs, timeoutMs, started }); + return { result, directives: prepared.directives }; +} diff --git a/apps/web/src/frontend/lib/sql-splitter.ts b/apps/web/src/frontend/lib/sql-splitter.ts index f9e430b..8484039 100644 --- a/apps/web/src/frontend/lib/sql-splitter.ts +++ b/apps/web/src/frontend/lib/sql-splitter.ts @@ -1,5 +1,15 @@ -// Re-export from core browser entry — single source of truth for statement -// splitting and the editor's per-statement heuristics (same code the backend -// uses to validate /sql/execute requests). -export { splitSqlStatements, checkStatement, isWriteStatement, firstKeyword, extractTableAliases, statementVerb, isMutatingDmlStatement, dmlLacksWhere } from '@foxschema/core'; -export type { SplitStatement, StatementStatus } from '@foxschema/core'; +export { + splitSqlStatements, + checkStatement, + isWriteStatement, + firstKeyword, + extractTableAliases, + statementVerb, + isMutatingDmlStatement, + dmlLacksWhere, + parseCodeCell, + findCodeFences, + stripCodeFenceMarkers, + codeCellHasReturn, +} from '@foxschema/core'; +export type { SplitStatement, StatementStatus, StatementKind } from '@foxschema/core'; diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts index 571178e..1fa9a78 100644 --- a/apps/web/src/frontend/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/store/useSqlEditorStore.ts @@ -3,6 +3,8 @@ import { persist } from 'zustand/middleware'; import { executeSql, type SqlStatementResult } from '../api/sqlApi'; import { loadSchema } from '../api/schemaApi'; import { isMutatingDmlStatement, isWriteStatement } from '../lib/sql-splitter'; +import type { CodeCellLast } from '../lib/codeCellExec'; +import { detectCodeCell, runCodeCell } from '../lib/codeCellRunner'; import { applySetDirectives, exportVariables, @@ -13,6 +15,7 @@ import { prepareStatement, resolveVariablesForConnection, stripSecretsForPersist, + type SetDirective, type SqlVariable, type SqlVariableExport, type SqlVariableKind, @@ -698,8 +701,81 @@ export const useSqlEditorStore = create()( const ranDisplay: string[] = []; let aborted: string | null = null; + const lastGridFrom = (prev: SqlStatementResult[]): CodeCellLast => { + const prior = prev[prev.length - 1]; + return prior?.ok + ? { columns: prior.columns, rows: prior.rows, rowCount: prior.rowCount } + : null; + }; + + const setPageSql = (connectionId: string, index: number, sql: string) => { + const sqlList = pageSqlByConn.get(connectionId) ?? []; + while (sqlList.length < index) sqlList.push(''); + sqlList[index] = sql; + pageSqlByConn.set(connectionId, sqlList); + }; + + const applySetsFromFirstOk = (directives: SetDirective[], index: number) => { + if (directives.length === 0) return; + for (const c of connections) { + const res = resultsByConn.get(c.id)?.[index]; + if (!res?.ok) continue; + const sets = applySetDirectives(directives, res); + if (sets.ok) { + for (const u of sets.updates) get().upsertVariable(u); + } else { + appendWarning(sets.error); + } + break; + } + }; + for (let si = 0; si < rawStatements.length; si++) { const raw = rawStatements[si]!; + + if (detectCodeCell(raw)) { + ranDisplay.push(raw); + setRanStatements([...ranDisplay]); + + let codeDirectives: SetDirective[] = []; + const isLastStmt = si === rawStatements.length - 1; + + await Promise.allSettled( + connections.map(async (c) => { + const prev = resultsByConn.get(c.id) ?? []; + try { + const { result, directives } = await runCodeCell({ + statement: raw, + last: lastGridFrom(prev), + variables: resolveVariablesForConnection(get().variables, c.id), + maxRows, + }); + codeDirectives = directives; + prev.push(result); + resultsByConn.set(c.id, prev); + setPageSql(c.id, si, ''); // code cells are not re-pageable + patchRun(c.id, { + status: isLastStmt ? 'done' : 'running', + results: [...prev], + error: result.ok ? undefined : result.error, + }); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + prev.push({ ok: false, error: msg, durationMs: 0 }); + resultsByConn.set(c.id, prev); + patchRun(c.id, { + status: 'error', + error: msg, + results: [...prev], + }); + } + }) + ); + + applySetsFromFirstOk(codeDirectives, si); + continue; + } + const { directives } = parseSetDirectives(raw); type Prep = @@ -774,10 +850,7 @@ export const useSqlEditorStore = create()( }; prev.push(one); resultsByConn.set(c.id, prev); - const sqlList = pageSqlByConn.get(c.id) ?? []; - while (sqlList.length < si) sqlList.push(''); - sqlList[si] = prep.sql; - pageSqlByConn.set(c.id, sqlList); + setPageSql(c.id, si, prep.sql); const last = si === rawStatements.length - 1; patchRun(c.id, { status: last ? 'done' : 'running', @@ -799,22 +872,7 @@ export const useSqlEditorStore = create()( // @set from the first successful credential result (connection list order). // Writes the global base value (not a per-connection override). - if (directives.length > 0) { - for (const c of connections) { - const res = resultsByConn.get(c.id)?.[si]; - if (res && res.ok) { - const sets = applySetDirectives(directives, res); - if (sets.ok) { - for (const u of sets.updates) { - get().upsertVariable(u); - } - } else { - appendWarning(sets.error); - } - break; - } - } - } + applySetsFromFirstOk(directives, si); } // Mark any still-running connections as done. @@ -840,10 +898,11 @@ export const useSqlEditorStore = create()( results.forEach((res, si) => { const key = pageMetaKey(c.id, si); pageCache[pageCacheKey(c.id, si, 0)] = res; + const pageable = Boolean(pageSqlByConnection[c.id]?.[si]); pageMeta[key] = { pageIndex: 0, pageSize: maxRows, - hasNext: Boolean(res.ok && (res.hasNext || res.truncated)), + hasNext: pageable && Boolean(res.ok && (res.hasNext || res.truncated)), }; }); } diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 0016c23..f2932bc 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -1,7 +1,7 @@ @import "tailwindcss"; :root { - /* User-tunable accent (Appearance settings). Default: cyan → indigo. */ + /* User-tunable accent (Appearance settings). Default: cyan ? indigo. */ --accent: #06b6d4; --accent-strong: #4f46e5; } @@ -58,7 +58,7 @@ /* Colored palette families used in the UI. Defined explicitly so the light-mode inversion (e.g. --color-cyan-50 -> var(--color-cyan-950)) - resolves — Tailwind tree-shakes unused shades. */ + resolves ? Tailwind tree-shakes unused shades. */ :root { --color-orange-50: oklch(98% 0.016 73.684); --color-orange-100: oklch(95.4% 0.038 75.164); @@ -214,10 +214,10 @@ .accent-grad:hover { filter: brightness(1.07); } /* Fixed, non-inverting near-black for text/icons that sit ON the bright accent - gradient — so it stays dark (readable) in both light and dark themes. */ + gradient ? so it stays dark (readable) in both light and dark themes. */ .on-accent-fg { color: #020617; } -/* In light mode the accent (tuned for dark backgrounds) is too pale as text — +/* In light mode the accent (tuned for dark backgrounds) is too pale as text ? darken + saturate it so labels stay legible on near-white surfaces. */ [data-theme="light"] .accent-text { filter: brightness(0.62) saturate(1.3); } @@ -313,7 +313,7 @@ animation: indeterminateProgress 1.2s ease-in-out infinite; } -/* Search keyword highlight — used both by the object browser / Schema Blueprint +/* Search keyword highlight ? used both by the object browser / Schema Blueprint () and inside Monaco. Accent-tinted so it stands out in light & dark. */ .fox-search-hl { background-color: color-mix(in oklab, var(--accent) 40%, transparent); @@ -335,11 +335,11 @@ mark.fox-search-hl { font-weight: 700; } .fox-stmt-glyph-ok::after { - content: '✓'; + content: '?'; color: #34d399; /* emerald-400 */ } .fox-stmt-glyph-warn::after { - content: '⚠'; + content: '?'; color: #fbbf24; /* amber-400 */ } @@ -348,3 +348,54 @@ mark.fox-search-hl { color: #34d399; /* emerald-400 */ font-weight: 600; } + +/* + * Result DataGrid is a light "paper" surface. Soft-dark remaps --color-slate-50/100/200 + * toward gray, which made tables muddy with dark cell ink. Pin literal colors here. + */ +.fox-sql-grid { + --fox-grid-bg: #f8fafc; + --fox-grid-bg-header: #e2e8f0; + --fox-grid-bg-hover: #f1f5f9; + --fox-grid-border: #cbd5e1; + --fox-grid-border-soft: #e2e8f0; + --fox-grid-ink: #1e293b; + --fox-grid-muted: #64748b; + background-color: var(--fox-grid-bg) !important; + border-color: var(--fox-grid-border) !important; + color: var(--fox-grid-ink); +} +.fox-sql-grid thead tr, +.fox-sql-grid thead th { + background-color: var(--fox-grid-bg-header) !important; + border-color: var(--fox-grid-border) !important; + color: var(--fox-grid-ink) !important; +} +.fox-sql-grid thead th .text-slate-800 { + color: inherit; +} +.fox-sql-grid thead th .text-slate-500, +.fox-sql-grid thead th .text-slate-400 { + color: var(--fox-grid-muted) !important; +} +.fox-sql-grid tbody { + background-color: var(--fox-grid-bg) !important; +} +.fox-sql-grid tbody tr { + border-color: var(--fox-grid-border-soft) !important; +} +.fox-sql-grid tbody tr:hover { + background-color: var(--fox-grid-bg-hover) !important; +} +.fox-sql-grid tbody td { + border-color: var(--fox-grid-border-soft) !important; +} +.fox-sql-grid tbody td.bg-slate-50, +.fox-sql-grid tbody td[data-testid='sql-row-num'] { + background-color: var(--fox-grid-bg) !important; + color: var(--fox-grid-muted) !important; + border-color: var(--fox-grid-border-soft) !important; +} +.fox-sql-grid tbody tr:hover td[data-testid='sql-row-num'] { + background-color: var(--fox-grid-bg-hover) !important; +} diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 21ebd68..df061cb 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -179,6 +179,29 @@ Tips: 200). Use **Next** / **Prev** on a result grid to page through more rows; visited pages stay cached in memory so going back does not re-query the server. Sibling result grids from the same Run sync vertical scroll by row index. +- **Code cells (JS / TS)** — mix SQL with local transforms in the same buffer. Fence + a cell with `-- @js` / `-- @ts` … `-- @end` (inner semicolons are fine). You can use + local `let`/`const`/`var`, **functions**, and loops (`for`, `while`, `for…of`). + Allowlisted **imports** (bundled, no CDN): `lodash`, `lodash-es`, `date-fns` — + put `import` lines at the top of the cell. Cells are **isolated** (imports/functions + do not carry to the next cell; use `last` / `vars` to pass data). The cell receives + `last` (previous statement’s grid) and `vars` (non-secret Variables). **You must + `return`** either `{ columns, rows }` or an array of plain objects. Python is not + available yet. + + ```sql + SELECT id, email FROM user; + + -- @js + import _ from 'lodash'; + + function doubleRow(r) { + return { id: r[0], name: r[1], n: Number(r[0]) * 2 }; + } + return _.map(last.rows, doubleRow); + -- @end + ``` + - **Safe mode** — when on, write/DDL statements need an extra confirmation before run. Writes and DDL are allowed when you confirm them. Some dialects (e.g. SQLite / diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts index fef1e60..9d7ab14 100644 --- a/packages/core/src/browser.ts +++ b/packages/core/src/browser.ts @@ -23,8 +23,21 @@ export type { ValidationIssue, ValidationSeverity, ValidationCode } from './modu export { CROSS_DIALECT_READINESS } from './modules/cross-dialect-readiness'; export type { ObjectTypeReadiness, ReadinessLevel } from './modules/cross-dialect-readiness'; export { buildBrowseResult } from './modules/browse'; -export { splitSqlStatements, checkStatement, isWriteStatement, firstKeyword, extractTableAliases, statementVerb, isMutatingDmlStatement, dmlLacksWhere } from './modules/sql-splitter'; -export type { SplitStatement, StatementStatus } from './modules/sql-splitter'; +export { + splitSqlStatements, + checkStatement, + isWriteStatement, + firstKeyword, + extractTableAliases, + statementVerb, + isMutatingDmlStatement, + dmlLacksWhere, + parseCodeCell, + findCodeFences, + stripCodeFenceMarkers, + codeCellHasReturn, +} from './modules/sql-splitter'; +export type { SplitStatement, StatementStatus, StatementKind } from './modules/sql-splitter'; export type { SqlDialect, CanonicalType, CanonicalBase, RenderedType } from './modules/sql-dialect.interface'; export { resolveDialect, DIALECT_MAP } from './modules/dialect-registry'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 433552b..321fc64 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,8 +26,21 @@ export type { ValidationIssue, ValidationSeverity, ValidationCode } from './modu export { CROSS_DIALECT_READINESS } from './modules/cross-dialect-readiness'; export type { ObjectTypeReadiness, ReadinessLevel } from './modules/cross-dialect-readiness'; export { buildBrowseResult } from './modules/browse'; -export { splitSqlStatements, checkStatement, isWriteStatement, firstKeyword, extractTableAliases, statementVerb, isMutatingDmlStatement, dmlLacksWhere } from './modules/sql-splitter'; -export type { SplitStatement, StatementStatus } from './modules/sql-splitter'; +export { + splitSqlStatements, + checkStatement, + isWriteStatement, + firstKeyword, + extractTableAliases, + statementVerb, + isMutatingDmlStatement, + dmlLacksWhere, + parseCodeCell, + findCodeFences, + stripCodeFenceMarkers, + codeCellHasReturn, +} from './modules/sql-splitter'; +export type { SplitStatement, StatementStatus, StatementKind } from './modules/sql-splitter'; export type { SqlDialect, CanonicalType, CanonicalBase, RenderedType } from './modules/sql-dialect.interface'; export { resolveDialect, DIALECT_MAP } from './modules/dialect-registry'; diff --git a/packages/core/src/modules/sql-splitter.test.ts b/packages/core/src/modules/sql-splitter.test.ts index 95cfbf1..9103e53 100644 --- a/packages/core/src/modules/sql-splitter.test.ts +++ b/packages/core/src/modules/sql-splitter.test.ts @@ -60,6 +60,71 @@ describe('splitSqlStatements', () => { expect(stmts).toHaveLength(1); expect(stmts[0].text).toBe("SELECT 'a\\';b' FROM t;"); }); + + it('keeps JS/TS fences as one statement (inner semicolons ok)', () => { + const sql = `SELECT 1 AS id; +-- @js +const x = 1; const y = 2; +return { columns: ['n'], rows: [[x + y]] }; +-- @end +SELECT 2;`; + const stmts = splitSqlStatements(sql); + expect(stmts).toHaveLength(3); + expect(stmts[0]).toMatchObject({ kind: 'sql', text: 'SELECT 1 AS id;' }); + expect(stmts[1]?.kind).toBe('js'); + expect(stmts[1]?.terminated).toBe(true); + expect(stmts[1]?.text).toContain('const x = 1;'); + expect(stmts[1]?.text).toContain('-- @end'); + expect(stmts[2]).toMatchObject({ kind: 'sql', text: 'SELECT 2;' }); + }); + + it('supports -- @ts / -- @typescript fences', () => { + const stmts = splitSqlStatements(`-- @ts +const n: number = 1; +return { columns: ['n'], rows: [[n]] }; +-- @end`); + expect(stmts).toHaveLength(1); + expect(stmts[0]?.kind).toBe('ts'); + }); + + it('warns when -- @end is missing', () => { + const stmts = splitSqlStatements(`-- @js +return last;`); + expect(stmts).toHaveLength(1); + expect(stmts[0]?.terminated).toBe(false); + expect(checkStatement(stmts[0]!).level).toBe('warn'); + expect(checkStatement(stmts[0]!).reasons.join()).toMatch(/@end/); + }); + + it('warns when a closed code cell has no return', () => { + const stmts = splitSqlStatements(`-- @js +const x = 1; +-- @end`); + expect(checkStatement(stmts[0]!).level).toBe('warn'); + expect(checkStatement(stmts[0]!).reasons.join()).toMatch(/return/i); + }); + + it('ok for a closed code cell with return + locals/loop', () => { + const stmts = splitSqlStatements(`-- @js +const out = []; +for (const r of last.rows) out.push(r); +return out; +-- @end`); + expect(checkStatement(stmts[0]!)).toEqual({ level: 'ok', reasons: [] }); + }); + + it('preserves @set comments before a code fence via offsets', () => { + const sql = `SELECT 1; +-- @set src = table +-- @js +return last; +-- @end`; + const stmts = splitSqlStatements(sql); + expect(stmts).toHaveLength(2); + expect(stmts[1]?.kind).toBe('js'); + // Gap comments live before the fence start; reattachSetComments restores them. + expect(sql.slice(stmts[0]!.end, stmts[1]!.start)).toContain('-- @set src = table'); + }); }); describe('checkStatement', () => { diff --git a/packages/core/src/modules/sql-splitter.ts b/packages/core/src/modules/sql-splitter.ts index 25e06a5..8e8ef00 100644 --- a/packages/core/src/modules/sql-splitter.ts +++ b/packages/core/src/modules/sql-splitter.ts @@ -11,8 +11,13 @@ * Known v1 limitation: procedural bodies (CREATE PROCEDURE … BEGIN … END) * with internal semicolons split at each `;`; routine DDL belongs in the * migration flow, not the editor. + * + * Code cells: `-- @js` / `-- @ts` … `-- @end` fences are opaque (inner `;` + * do not split) and emit a single statement with kind `'js'` | `'ts'`. */ +export type StatementKind = 'sql' | 'js' | 'ts'; + export interface SplitStatement { /** Statement text from its first non-whitespace character through its terminator. */ text: string; @@ -23,8 +28,10 @@ export interface SplitStatement { startLine: number; /** 1-based line of the statement's last character. */ endLine: number; - /** True when the statement ended with a `;`. */ + /** True when the statement ended with a `;` (SQL) or `-- @end` (code cell). */ terminated: boolean; + /** `'sql'` (default), or a fenced code cell. */ + kind?: StatementKind; } export interface StatementStatus { @@ -52,8 +59,177 @@ const WRITE_KEYWORDS = new Set([ // eslint-disable-next-line security/detect-unsafe-regex -- false positive: anchored at ^; optional bounded identifier const DOLLAR_TAG_RE = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/; -/** Split a SQL buffer into `;`-terminated statements, skipping comment-only segments. */ -export function splitSqlStatements(sql: string): SplitStatement[] { +const FENCE_START_RE = /^[ \t]*--[ \t]*@(js|javascript|ts|typescript)[ \t]*$/i; +const FENCE_END_RE = /^[ \t]*--[ \t]*@end[ \t]*$/i; + +function kindFromFenceTag(tag: string): 'js' | 'ts' { + const t = tag.toLowerCase(); + return t === 'ts' || t === 'typescript' ? 'ts' : 'js'; +} + +/** 1-based line number of the character at `index` (0 = start of file). */ +function lineAtOffset(sql: string, index: number): number { + let line = 1; + const end = Math.min(Math.max(0, index), sql.length); + for (let i = 0; i < end; i++) { + if (sql[i] === '\n') line++; + } + return line; +} + +/** Next line bounds starting at `from` (`end` exclusive of newline; `next` after it). */ +function lineBounds(sql: string, from: number): { start: number; end: number; next: number } { + const len = sql.length; + const end = sql.indexOf('\n', from); + if (end < 0) return { start: from, end: len, next: len }; + return { start: from, end, next: end + 1 }; +} + +interface CodeFenceRange { + kind: 'js' | 'ts'; + start: number; + end: number; + closed: boolean; +} + +/** + * Line-oriented scan for `-- @js` / `-- @ts` … `-- @end` fences. + * Fences are only recognized when the whole line matches (so SQL string + * literals containing the text are unlikely to false-positive). + */ +export function findCodeFences(sql: string): CodeFenceRange[] { + const fences: CodeFenceRange[] = []; + let i = 0; + const len = sql.length; + while (i < len) { + const { start: lineStart, end: lineEnd, next } = lineBounds(sql, i); + const startMatch = FENCE_START_RE.exec(sql.slice(lineStart, lineEnd)); + if (!startMatch) { + i = next; + continue; + } + + const kind = kindFromFenceTag(startMatch[1]!); + let j = next; + let closed = false; + let fenceEnd = len; + while (j < len) { + const inner = lineBounds(sql, j); + const line = sql.slice(inner.start, inner.end); + if (FENCE_END_RE.test(line)) { + closed = true; + fenceEnd = inner.next; + break; + } + if (FENCE_START_RE.test(line)) { + // Nested start without @end — close previous at this line (unterminated). + fenceEnd = inner.start; + break; + } + j = inner.next; + } + fences.push({ kind, start: lineStart, end: fenceEnd, closed }); + i = fenceEnd; + } + return fences; +} + +/** + * True when `text` is (or starts as) a fenced JS/TS code cell. + * Returns kind + body (fence markers stripped; leading `-- @set` may still + * remain in body — callers typically run parseSetDirectives first). + */ +export function parseCodeCell( + statement: string +): { kind: 'js' | 'ts'; body: string; closed: boolean } | null { + const trimmed = statement.replace(/^\s+/, ''); + const nl = trimmed.search(/\r?\n/); + const firstLine = nl < 0 ? trimmed : trimmed.slice(0, nl); + const startMatch = FENCE_START_RE.exec(firstLine); + if (!startMatch) return null; + + const kind = kindFromFenceTag(startMatch[1]!); + if (nl < 0) return { kind, body: '', closed: false }; + + const lines = trimmed.slice(nl).replace(/^\r?\n/, '').split(/\r?\n/); + let closed = false; + if (lines.length > 0 && FENCE_END_RE.test(lines[lines.length - 1]!)) { + closed = true; + lines.pop(); + } + return { kind, body: lines.join('\n'), closed }; +} + +/** Strip fence open/close lines from a full cell statement (after @set parse). */ +export function stripCodeFenceMarkers( + statement: string +): { kind: 'js' | 'ts'; body: string } | null { + const parsed = parseCodeCell(statement); + return parsed ? { kind: parsed.kind, body: parsed.body } : null; +} + +/** Strip JS line/block comments and quoted strings for keyword scans. */ +function stripJsStringsAndComments(src: string): string { + let out = ''; + let i = 0; + const n = src.length; + while (i < n) { + const ch = src[i]!; + const next = src[i + 1] ?? ''; + if (ch === '/' && next === '/') { + i += 2; + while (i < n && src[i] !== '\n') i++; + continue; + } + if (ch === '/' && next === '*') { + i += 2; + while (i < n - 1 && !(src[i] === '*' && src[i + 1] === '/')) i++; + i = Math.min(n, i + 2); + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + const q = ch; + out += ' '; + i++; + while (i < n) { + if (src[i] === '\\') { + i += 2; + continue; + } + if (src[i] === q) { + i++; + break; + } + if (q === '`' && src[i] === '$' && src[i + 1] === '{') { + i += 2; + let depth = 1; + while (i < n && depth > 0) { + if (src[i] === '{') depth++; + else if (src[i] === '}') depth--; + i++; + } + continue; + } + i++; + } + continue; + } + out += ch; + i++; + } + return out; +} + +/** + * True when a JS/TS cell body contains a `return` statement (not inside a + * string/comment). Code cells must return a grid value. + */ +export function codeCellHasReturn(body: string): boolean { + return /\breturn\b/.test(stripJsStringsAndComments(body)); +} + +/** Split a SQL-only buffer into `;`-terminated statements (no code fences). */ +function splitSqlOnly(sql: string): SplitStatement[] { const out: SplitStatement[] = []; const len = sql.length; @@ -96,6 +272,7 @@ export function splitSqlStatements(sql: string): SplitStatement[] { startLine: codeStartLine, endLine, terminated, + kind: 'sql', }); reset(); }; @@ -177,6 +354,53 @@ export function splitSqlStatements(sql: string): SplitStatement[] { return out; } +function remapSqlGap( + gap: string, + baseOffset: number, + baseLine: number +): SplitStatement[] { + return splitSqlOnly(gap).map((s) => ({ + ...s, + kind: 'sql' as const, + start: s.start + baseOffset, + end: s.end + baseOffset, + startLine: baseLine + s.startLine - 1, + endLine: baseLine + s.endLine - 1, + })); +} + +/** Split a SQL buffer into `;`-terminated statements, skipping comment-only segments. */ +export function splitSqlStatements(sql: string): SplitStatement[] { + const fences = findCodeFences(sql); + if (fences.length === 0) return splitSqlOnly(sql); + + const out: SplitStatement[] = []; + let cursor = 0; + for (const f of fences) { + if (f.start > cursor) { + const gap = sql.slice(cursor, f.start); + out.push(...remapSqlGap(gap, cursor, lineAtOffset(sql, cursor))); + } + // Trim trailing whitespace from fence end for display, keep closed flag. + let realEnd = f.end; + while (realEnd > f.start && /\s/.test(sql[realEnd - 1]!)) realEnd--; + out.push({ + text: sql.slice(f.start, realEnd), + start: f.start, + end: realEnd, + startLine: lineAtOffset(sql, f.start), + endLine: lineAtOffset(sql, Math.max(f.start, realEnd - 1)), + terminated: f.closed, + kind: f.kind, + }); + cursor = f.end; + } + if (cursor < sql.length) { + out.push(...remapSqlGap(sql.slice(cursor), cursor, lineAtOffset(sql, cursor))); + } + return out; +} + /** First code word of a statement (lowercased), skipping leading comments/whitespace/parens. */ export function firstKeyword(text: string): string | null { let mode: Mode = 'code'; @@ -209,7 +433,24 @@ export function firstKeyword(text: string): string | null { * complete" — balanced quoting/parens, known leading keyword, terminated with * a semicolon — NOT that the statement will execute. */ -export function checkStatement(stmt: Pick): StatementStatus { +export function checkStatement( + stmt: Pick & { kind?: StatementKind } +): StatementStatus { + const cell = parseCodeCell(stmt.text); + const kind = stmt.kind ?? cell?.kind ?? 'sql'; + if (kind === 'js' || kind === 'ts') { + const reasons: string[] = []; + if (!stmt.terminated) { + reasons.push('Missing -- @end for code cell'); + } + // Strip leading `-- @set` lines inside the fence before the return check. + const body = (cell?.body ?? '').replace(/^(?:--\s*@set[^\n]*\n)+/i, ''); + if (body.trim() && !codeCellHasReturn(body)) { + reasons.push('Code cell must include a return statement'); + } + return { level: reasons.length ? 'warn' : 'ok', reasons }; + } + const reasons: string[] = []; const text = stmt.text; @@ -276,6 +517,7 @@ export function checkStatement(stmt: Pick * mutating verb — EXPLAIN does not apply the change. */ export function isWriteStatement(text: string): boolean { + if (parseCodeCell(text)) return false; const kw = firstKeyword(text); if (!kw) return false; if (WRITE_KEYWORDS.has(kw)) return true; @@ -289,6 +531,8 @@ export function isWriteStatement(text: string): boolean { /** Leading verb after skipping WITH CTEs (lowercased), or null. */ export function statementVerb(text: string): string | null { + const cell = parseCodeCell(text); + if (cell) return cell.kind; const kw = firstKeyword(text); if (!kw) return null; if (kw === 'explain') return 'explain';