diff --git a/apps/cli/src/tui/__tests__/ConnectionFormScreen.test.tsx b/apps/cli/src/tui/__tests__/ConnectionFormScreen.test.tsx index a4483fd..549c120 100644 --- a/apps/cli/src/tui/__tests__/ConnectionFormScreen.test.tsx +++ b/apps/cli/src/tui/__tests__/ConnectionFormScreen.test.tsx @@ -6,9 +6,9 @@ import { ConnectionFormScreen } from '../screens/ConnectionFormScreen'; // A real (not setTimeout(0)) delay: ink-text-input needs a render tick to commit each // character to its controlled `value` before Enter fires, or `onSubmit` receives '' -// (confirmed by tracing the actual value ink-text-input delivers). setTimeout(0) alone -// was intermittently too short under full-suite parallel load (flaky, not deterministic). -const wait = (ms = 40) => new Promise((r) => setTimeout(r, ms)); +// (confirmed by tracing the actual value ink-text-input delivers). 40ms was +// intermittently too short under full-suite parallel load. +const wait = (ms = 100) => new Promise((r) => setTimeout(r, ms)); /** Types a value then presses Enter, with a tick between so ink-text-input commits it first. */ async function type(stdin: { write: (s: string) => void }, value: string) { @@ -38,13 +38,13 @@ describe('ConnectionFormScreen', () => { await type(stdin, 'foxdb'); // database await type(stdin, 'foxuser'); // user await type(stdin, 'demo_c'); // schema - await vi.waitFor(() => expect(lastFrame()).toContain('Password')); + await vi.waitFor(() => expect(lastFrame()).toContain('Password'), { timeout: 10_000 }); await type(stdin, 's3cret'); // password - await vi.waitFor(() => expect(lastFrame()).toContain('Save this connection')); + await vi.waitFor(() => expect(lastFrame()).toContain('Save this connection'), { timeout: 10_000 }); stdin.write('\r'); // first item ("Yes, save it") is pre-selected — enter accepts it - await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled()); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled(), { timeout: 10_000 }); expect(ctx.connections.create).toHaveBeenCalledWith( 'u1', @@ -53,7 +53,7 @@ describe('ConnectionFormScreen', () => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ dialect: 'postgres', schema: 'demo_c', label: 'demo_c' }) ); - }); + }, 30_000); it('does not persist the connection when "No" is chosen', async () => { const ctx = { userId: 'u1', connections: { create: vi.fn() }, history: {} }; @@ -68,8 +68,8 @@ describe('ConnectionFormScreen', () => { stdin.write('\x1b[B'); // down arrow to "No, use it just for this session" await wait(); stdin.write('\r'); - await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled()); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled(), { timeout: 10_000 }); expect(ctx.connections.create).not.toHaveBeenCalled(); - }); + }, 30_000); }); diff --git a/apps/cli/src/tui/__tests__/ConnectionManageScreen.test.tsx b/apps/cli/src/tui/__tests__/ConnectionManageScreen.test.tsx index b810a90..2c353d8 100644 --- a/apps/cli/src/tui/__tests__/ConnectionManageScreen.test.tsx +++ b/apps/cli/src/tui/__tests__/ConnectionManageScreen.test.tsx @@ -4,7 +4,7 @@ import { render } from 'ink-testing-library'; import * as store from '../../runtime/store'; import { ConnectionManageScreen } from '../screens/ConnectionManageScreen'; -const wait = (ms = 40) => new Promise((r) => setTimeout(r, ms)); +const wait = (ms = 100) => new Promise((r) => setTimeout(r, ms)); function fakeCtx(rows: any[] = []) { return { diff --git a/apps/cli/src/tui/__tests__/MigrateProgressScreen.test.tsx b/apps/cli/src/tui/__tests__/MigrateProgressScreen.test.tsx index 709411f..a024208 100644 --- a/apps/cli/src/tui/__tests__/MigrateProgressScreen.test.tsx +++ b/apps/cli/src/tui/__tests__/MigrateProgressScreen.test.tsx @@ -9,7 +9,7 @@ import type { ConnRef } from '../types'; // A real (not setTimeout(0)) delay for the one wait that isn't checkable via vi.waitFor: // after the terminal outcome is already rendered, SelectInput still needs a tick to // attach its own input listener before it can receive a keypress. -const wait = (ms = 40) => new Promise((r) => setTimeout(r, ms)); +const wait = (ms = 100) => new Promise((r) => setTimeout(r, ms)); const source: ConnRef = { dialect: 'postgres', option: {}, schema: 'demo_c', label: 'demo_c' }; const target: ConnRef = { dialect: 'postgres', option: {}, schema: 'demo_d', label: 'demo_d' }; @@ -61,7 +61,7 @@ describe('MigrateProgressScreen', () => { const { lastFrame } = render( {}} onDone={() => {}} /> ); - await vi.waitFor(() => expect(lastFrame()).toContain('1 failure(s)')); + await vi.waitFor(() => expect(lastFrame()).toContain('1 failure(s)'), { timeout: 10_000 }); expect(lastFrame()).toContain('boom'); }); diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 3f8f41b..534ee47 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -11,6 +11,8 @@ "test:headed": "HEADLESS=false vitest run --config vitest.config.ts", "test:smoke": "vitest run --config vitest.config.ts src/tests/smoke.test.ts", "test:smoke:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/smoke.test.ts", + "test:sql-editor": "vitest run --config vitest.config.ts src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts", + "test:sql-editor:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts", "test:errors": "vitest run --config vitest.config.ts src/tests/auto-error-catch.test.ts", "test:errors:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/auto-error-catch.test.ts", "test:postgres": "vitest run --config vitest.config.ts src/tests/dialects/postgres.test.ts", diff --git a/apps/e2e/scripts/run-all.mjs b/apps/e2e/scripts/run-all.mjs index 18a0bb4..9d688ac 100644 --- a/apps/e2e/scripts/run-all.mjs +++ b/apps/e2e/scripts/run-all.mjs @@ -73,9 +73,41 @@ const results = []; const bar = '─'.repeat(60); console.log('\n' + bar); -console.log(` Fox E2E — running ${configured.length} dialect(s)`); +console.log(` Fox E2E — SQL Editor + ${configured.length} dialect(s)`); console.log(bar + '\n'); +// Always run SQL Editor suites first (self-seeding SQLite; no Docker required). +const ALWAYS = [ + { key: 'sql-editor', file: 'src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts', label: 'SQL Editor' }, +]; + +for (const suite of ALWAYS) { + const start = Date.now(); + const logFile = join(logDir, `${suite.key}.log`); + process.stdout.write(`▶ ${suite.label.padEnd(12)} `); + let passed = false; + let output = ''; + try { + output = execSync(`${HEADED}${VITEST} ${suite.file}`, { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 300_000, + env: { ...process.env }, + }).toString(); + passed = true; + process.stdout.write('✓ PASS'); + } catch (err) { + output = (err.stdout ?? '').toString() + '\n' + (err.stderr ?? '').toString(); + passed = false; + process.stdout.write('✗ FAIL'); + } + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + console.log(` (${elapsed}s) → log: logs/${suite.key}.log`); + writeFileSync(logFile, output, 'utf8'); + const failLines = output.split('\n').filter((l) => l.includes('FAIL') || l.includes('Error') || l.includes('✗')); + results.push({ ...suite, passed, elapsed, logFile, failLines }); +} + for (const dialect of configured) { const start = Date.now(); const logFile = join(logDir, `${dialect.key}.log`); diff --git a/apps/e2e/src/pages/AppPage.ts b/apps/e2e/src/pages/AppPage.ts index 7cf485c..15ba76e 100644 --- a/apps/e2e/src/pages/AppPage.ts +++ b/apps/e2e/src/pages/AppPage.ts @@ -11,6 +11,12 @@ export class AppPage { async open(): Promise { await this.page.goto(BASE_URL); await waitFor(this.page, '[data-testid="toolbar"]'); + // One-time signup wizard (if the local metadata DB has never seen it). + const skip = this.page.getByRole('button', { name: /skip for now/i }); + if (await skip.isVisible().catch(() => false)) { + await skip.click(); + await waitFor(this.page, '[data-testid="toolbar"]'); + } } // ── Source side ───────────────────────────────────────────────────────── diff --git a/apps/e2e/src/pages/SqlEditorPage.ts b/apps/e2e/src/pages/SqlEditorPage.ts new file mode 100644 index 0000000..33e3506 --- /dev/null +++ b/apps/e2e/src/pages/SqlEditorPage.ts @@ -0,0 +1,178 @@ +import type { Page } from 'playwright'; +import { clickWhen, waitFor, fillInput } from '../helpers/driver.js'; + +/** + * Page object for the SQL Editor workspace (view switcher + run against + * saved credentials). Selectors match data-testid attributes in the React UI. + */ +export class SqlEditorPage { + constructor(private page: Page) {} + + /** Dismiss session-password / write-confirm overlays that block clicks. */ + async dismissOverlays(): Promise { + const pwd = this.page.locator('[data-testid="sql-session-password"]'); + if (await pwd.isVisible().catch(() => false)) { + await this.page.click('[data-testid="sql-session-password-cancel"]'); + await pwd.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => undefined); + } + const write = this.page.locator('[data-testid="sql-write-confirm"]'); + if (await write.isVisible().catch(() => false)) { + // Cancel write confirm (backdrop click). + await write.click({ position: { x: 8, y: 8 } }); + await write.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => undefined); + } + // First-run signup wizard (full-page, not a z-100 overlay). + const skipSignup = this.page.getByRole('button', { name: /skip for now/i }); + if (await skipSignup.isVisible().catch(() => false)) { + await skipSignup.click(); + await this.page.waitForTimeout(300); + } + } + + async openView(): Promise { + await clickWhen(this.page, '[data-testid="view-sql-editor-btn"]'); + await waitFor(this.page, '[data-testid="sql-editor-view"]'); + // Schema explorer may prompt for a password on auto-load — clear it so + // later clicks aren't blocked. Callers that need the password should + // submit via checkConnection / submitSessionPassword instead. + await this.page.waitForTimeout(400); + await this.dismissOverlays(); + } + + async isEditorVisible(): Promise { + return this.page.locator('[data-testid="sql-editor-view"]').isVisible(); + } + + async openCredentials(): Promise { + await clickWhen(this.page, '[data-testid="credentials-btn"]'); + await waitFor(this.page, '[data-testid="cred-manager"]'); + } + + async closeCredentials(): Promise { + await clickWhen(this.page, '[data-testid="cred-close-btn"]'); + await this.page.waitForSelector('[data-testid="cred-manager"]', { state: 'detached', timeout: 10_000 }); + } + + /** Save a SQLite file path as a named credential (password saved so Run/schema don't re-prompt). */ + async addSqliteCredential(name: string, dbPath: string): Promise { + await this.openCredentials(); + await clickWhen(this.page, '[data-testid="cred-add-btn"]'); + await waitFor(this.page, '[data-testid="conn-modal"]'); + await fillInput(this.page, '[data-testid="conn-name-input"]', name); + await this.page.selectOption('[data-testid="conn-dialect-select"]', 'sqlite'); + await fillInput(this.page, '[data-testid="conn-database-input"]', dbPath); + // SQLite ignores the password, but hasPassword must be true so the SQL + // Editor doesn't open a session-password modal on schema warm / check. + await fillInput(this.page, '[data-testid="conn-password-input"]', 'unused'); + await this.page.locator('[data-testid="conn-save-password"]').check(); + await this.page.click('[data-testid="conn-load-schema-btn"]'); + await this.page.waitForSelector( + '[data-testid="conn-test-success"], [data-testid="conn-test-failed"]', + { timeout: 25_000 } + ); + const failed = await this.page.locator('[data-testid="conn-test-failed"]').isVisible(); + if (failed) { + const err = (await this.page.locator('[data-testid="conn-test-failed"]').textContent()) ?? 'load failed'; + throw new Error(`SQLite credential load failed for ${dbPath}: ${err}`); + } + await this.page.click('[data-testid="conn-save-btn"]'); + await this.page.waitForSelector('[data-testid="conn-modal"]', { state: 'detached', timeout: 10_000 }); + await this.closeCredentials(); + } + + async checkConnection(name: string): Promise { + const sel = `[data-testid="sql-conn-check-${name}"]`; + await waitFor(this.page, sel, 15_000); + const box = this.page.locator(sel); + if (!(await box.isChecked())) await box.check(); + } + + async setSql(sql: string): Promise { + await this.dismissOverlays(); + // Monaco uses a hidden textarea; focus then replace via select-all + type. + const editor = this.page.locator('.monaco-editor').first(); + await editor.click(); + const mod = process.platform === 'darwin' ? 'Meta' : 'Control'; + await this.page.keyboard.press(`${mod}+KeyA`); + await this.page.keyboard.press('Backspace'); + await this.page.keyboard.type(sql, { delay: 8 }); + // Debounced gutter + store onChange (~200ms). + await this.page.waitForTimeout(350); + } + + async run(): Promise { + await this.dismissOverlays(); + await clickWhen(this.page, '[data-testid="sql-run-btn"]'); + } + + async waitForResults(timeoutMs = 30_000): Promise { + await this.page.waitForSelector( + '[data-testid="sql-results-by-credential"], [data-testid="sql-results-side-by-side"]', + { timeout: timeoutMs } + ); + } + + async resultsText(): Promise { + const byCred = this.page.locator('[data-testid="sql-results-by-credential"]'); + const side = this.page.locator('[data-testid="sql-results-side-by-side"]'); + if (await byCred.isVisible()) return (await byCred.innerText()) ?? ''; + if (await side.isVisible()) return (await side.innerText()) ?? ''; + return ''; + } + + async addTab(): Promise { + await this.dismissOverlays(); + await clickWhen(this.page, '[data-testid="sql-tab-add"]'); + } + + async openSyncView(): Promise { + await this.dismissOverlays(); + await clickWhen(this.page, '[data-testid="view-sync-btn"]'); + } + + async setLayoutSideBySide(): Promise { + await this.dismissOverlays(); + await clickWhen(this.page, '[data-testid="sql-layout-side-by-side"]'); + await waitFor(this.page, '[data-testid="sql-results-side-by-side"]'); + } + + async setLayoutByCredential(): Promise { + await this.dismissOverlays(); + await clickWhen(this.page, '[data-testid="sql-layout-by-credential"]'); + } + + async tabCount(): Promise { + return this.page.locator('[data-testid="sql-editor-tabs"] [role="tab"]').count(); + } + + async statementStripVisible(): Promise { + return this.page.locator('[data-testid="sql-statement-strip"]').isVisible(); + } + + async confirmWriteIfShown(): Promise { + const dlg = this.page.locator('[data-testid="sql-write-confirm"]'); + try { + await dlg.waitFor({ state: 'visible', timeout: 5_000 }); + } catch { + return false; + } + await clickWhen(this.page, '[data-testid="sql-write-confirm-btn"]'); + await dlg.waitFor({ state: 'detached', timeout: 10_000 }); + return true; + } + + async writeConfirmReadonlyWarnVisible(): Promise { + return this.page.locator('[data-testid="sql-readonly-write-warn"]').isVisible(); + } + + async schemaExplorerVisible(): Promise { + return this.page.locator('[data-testid="sql-schema-explorer"]').isVisible(); + } + + /** Wipe persisted editor tabs so tests start from a clean Query 1. */ + async resetPersistedEditorState(): Promise { + await this.page.evaluate(() => { + localStorage.removeItem('foxschema-sql-editor'); + }); + } +} diff --git a/apps/e2e/src/tests/sql-editor-smoke.test.ts b/apps/e2e/src/tests/sql-editor-smoke.test.ts new file mode 100644 index 0000000..b36bdd6 --- /dev/null +++ b/apps/e2e/src/tests/sql-editor-smoke.test.ts @@ -0,0 +1,61 @@ +/** + * SQL Editor smoke tests — no DB required (dev server only). + * Covers view switcher, empty checklist copy, tabs, schema explorer chrome. + */ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import type { Page } from 'playwright'; +import { buildDriver, quitDriver, BASE_URL } from '../helpers/driver.js'; +import { AppPage } from '../pages/AppPage.js'; +import { SqlEditorPage } from '../pages/SqlEditorPage.js'; + +let driver: Page; +let app: AppPage; +let sql: SqlEditorPage; + +beforeAll(async () => { + driver = await buildDriver(); + app = new AppPage(driver); + sql = new SqlEditorPage(driver); +}); + +afterAll(async () => { + await quitDriver(driver); +}); + +describe('SQL Editor smoke', () => { + it('opens the SQL Editor view from the toolbar', async () => { + await app.open(); + await sql.resetPersistedEditorState(); + await driver.reload(); + await driver.waitForSelector('[data-testid="toolbar"]'); + await sql.openView(); + expect(await sql.isEditorVisible()).toBe(true); + }); + + it('shows the schema explorer and empty-connection hint', async () => { + expect(await sql.schemaExplorerVisible()).toBe(true); + const body = await driver.locator('[data-testid="sql-editor-view"]').innerText(); + // Either no saved connections yet, or leftovers from a prior local session — + // explorer chrome must still be present either way. + expect(body).toMatch(/Schema|Destination servers/i); + }); + + it('can add a second editor tab', async () => { + const before = await sql.tabCount(); + await sql.addTab(); + expect(await sql.tabCount()).toBe(before + 1); + }); + + it('Run stays disabled with no SQL / no checked connections', async () => { + const btn = driver.locator('[data-testid="sql-run-btn"]'); + expect(await btn.isDisabled()).toBe(true); + }); + + it('switches back to Sync view', async () => { + await sql.openSyncView(); + expect(await sql.isEditorVisible()).toBe(false); + expect(await driver.locator('[data-testid="toolbar"]').isVisible()).toBe(true); + // Keep BASE_URL sanity — sync view does not mount sql-editor-view. + expect(driver.url()).toContain(new URL(BASE_URL).host); + }); +}); diff --git a/apps/e2e/src/tests/sql-editor-sqlite.test.ts b/apps/e2e/src/tests/sql-editor-sqlite.test.ts new file mode 100644 index 0000000..13c9c45 --- /dev/null +++ b/apps/e2e/src/tests/sql-editor-sqlite.test.ts @@ -0,0 +1,138 @@ +/** + * SQL Editor against two local SQLite files (seeded in beforeAll). + * Requires the web app + API (`npm run dev`) and a `sqlite3` CLI on PATH. + * Skips when sqlite3 is unavailable so CI without the CLI stays green. + */ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { execFileSync, execSync } from 'node:child_process'; +import { mkdirSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Page } from 'playwright'; +import { buildDriver, quitDriver } from '../helpers/driver.js'; +import { AppPage } from '../pages/AppPage.js'; +import { SqlEditorPage } from '../pages/SqlEditorPage.js'; + +const DIR = '/tmp/foxschema-e2e-sql-editor'; +const DB_A = join(DIR, 'editor_a.db'); +const DB_B = join(DIR, 'editor_b.db'); +// Unique names per run so leftover passwordless credentials from prior runs +// don't collide / steal the checklist checkboxes. +const RUN = Date.now().toString(36); +const NAME_A = `E2E SQL A ${RUN}`; +const NAME_B = `E2E SQL B ${RUN}`; + +function hasSqlite3(): boolean { + try { + execSync('which sqlite3', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function seedDb(path: string, name: string): void { + execFileSync( + 'sqlite3', + [path], + { + input: ` +DROP TABLE IF EXISTS customers; +CREATE TABLE customers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + city TEXT +); +INSERT INTO customers (id, name, city) VALUES (1, '${name}', 'Denver'); +INSERT INTO customers (id, name, city) VALUES (2, 'Shared', 'Austin'); +`, + } + ); +} + +const ready = hasSqlite3(); + +describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => { + let driver: Page; + let app: AppPage; + let sql: SqlEditorPage; + + beforeAll(async () => { + rmSync(DIR, { recursive: true, force: true }); + mkdirSync(DIR, { recursive: true }); + seedDb(DB_A, 'Alice'); + seedDb(DB_B, 'Bob'); + expect(existsSync(DB_A)).toBe(true); + expect(existsSync(DB_B)).toBe(true); + + driver = await buildDriver(); + app = new AppPage(driver); + sql = new SqlEditorPage(driver); + + await app.open(); + await sql.resetPersistedEditorState(); + await driver.reload(); + await driver.waitForSelector('[data-testid="toolbar"]'); + + await sql.addSqliteCredential(NAME_A, DB_A); + await sql.addSqliteCredential(NAME_B, DB_B); + }, 120_000); + + afterAll(async () => { + if (driver) await quitDriver(driver); + rmSync(DIR, { recursive: true, force: true }); + }); + + it('runs SELECT against both credentials and shows divergent rows', async () => { + await sql.openView(); + await sql.checkConnection(NAME_A); + await sql.checkConnection(NAME_B); + await sql.setSql('SELECT id, name, city FROM customers ORDER BY id;'); + await sql.run(); + await sql.waitForResults(); + const text = await sql.resultsText(); + expect(text).toMatch(/Alice/); + expect(text).toMatch(/Bob/); + expect(text).toMatch(/Shared/); + }); + + it('toggles side-by-side results layout', async () => { + await sql.setLayoutSideBySide(); + const text = await sql.resultsText(); + expect(text).toMatch(/Alice|Bob/); + await sql.setLayoutByCredential(); + }); + + it('shows the statement strip for multi-statement SQL', async () => { + await sql.setSql('SELECT 1 AS n;\nSELECT name FROM customers WHERE id = 1;'); + expect(await sql.statementStripVisible()).toBe(true); + const strip = await driver.locator('[data-testid="sql-statement-strip"]').innerText(); + expect(strip).toMatch(/#1/); + expect(strip).toMatch(/#2/); + }); + + it('warns on write statements targeting sqlite (read-only adapter)', async () => { + await sql.setSql("UPDATE customers SET city = 'X' WHERE id = 1;"); + await sql.run(); + await driver.waitForSelector('[data-testid="sql-write-confirm"]', { timeout: 10_000 }); + expect(await sql.writeConfirmReadonlyWarnVisible()).toBe(true); + await sql.confirmWriteIfShown(); + await sql.waitForResults(); + const text = (await sql.resultsText()).toLowerCase(); + // better-sqlite3 opens readonly / uses .all() — UPDATE surfaces as a per-cell error. + expect(text).toMatch(/does not return data|readonly|read-only|only support select|attempt to write/i); + }); + + it('schema explorer lists customers after load', async () => { + expect(await sql.schemaExplorerVisible()).toBe(true); + // Wait for load / ready tree to include our seeded table. + await driver.waitForFunction( + () => { + const root = document.querySelector('[data-testid="sql-schema-explorer"]'); + return !!root && /customers/i.test(root.textContent ?? ''); + }, + { timeout: 30_000 } + ); + const explorer = await driver.locator('[data-testid="sql-schema-explorer"]').innerText(); + expect(explorer).toMatch(/customers/i); + }); +}); diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index b6cfa83..561b904 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -22,6 +22,7 @@ import { MigrationHistoryStore, type MigrationObjectResult, type MigrationRunSta import { AppSettingsStore } from '../modules/app-settings.module'; import { SignupModule } from '../modules/signup.module'; import { rateLimit } from './rate-limit'; +import { runStatements, clampMaxRows, MAX_STATEMENTS, MAX_STATEMENT_LENGTH } from './sql-execute'; import { getMetadataDbConfig, SUPPORTED_ENGINES, type DbEngine } from '../database/config'; import { createMetadataStore } from '../database/stores/registry'; import { keySchemeInfo } from '../cores/crypto'; @@ -357,6 +358,50 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt } }); + // SQL Editor: run ad-hoc statements against ONE credential and return shaped + // row results. The frontend fans out across selected credentials with one + // request each. The client splits the buffer (same trust model as + // /migration/execute's pre-split statements); the server validates shape and + // 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, ...ref } = req.body as ConnectionRef & { statements?: unknown; maxRows?: unknown }; + if (!Array.isArray(statements) || statements.length === 0) { + res.status(400).json({ error: 'statements[] is required.' }); + return; + } + if (statements.length > MAX_STATEMENTS) { + res.status(400).json({ error: `At most ${MAX_STATEMENTS} statements per request.` }); + return; + } + if (statements.some((s) => typeof s !== 'string' || !s.trim() || s.length > MAX_STATEMENT_LENGTH)) { + res.status(400).json({ error: `Every statement must be a non-empty string under ${MAX_STATEMENT_LENGTH} characters.` }); + return; + } + let resolved; + try { + resolved = await resolveRef((req as AuthedRequest).userId, ref); + } catch (error: unknown) { + res.status(400).json({ error: error instanceof Error ? error.message : 'Invalid connection' }); + return; + } + try { + // Apply the saved connection's schema (CURRENT SCHEMA / search_path) so + // unqualified names like ORDERS resolve to DEMO.ORDERS, not USER.ORDERS. + const results = await runStatements( + resolved.dialect, + resolved.option, + statements as string[], + clampMaxRows(maxRows), + resolved.schema + ); + res.json({ results }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Query execution failed'; + res.status(500).json({ error: message }); + } + }); + router.post('/compare', async (req: Request, res: Response) => { const { source, target, scope } = req.body as { source: ConnectionRef; diff --git a/apps/web/src/backend/api/sql-execute.test.ts b/apps/web/src/backend/api/sql-execute.test.ts new file mode 100644 index 0000000..53339c5 --- /dev/null +++ b/apps/web/src/backend/api/sql-execute.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { rmSync } from 'node:fs'; +import { clampMaxRows, shapeRows, runStatements } from './sql-execute'; +import { ConnectionFactory } from '@foxschema/core'; + +describe('clampMaxRows', () => { + it('defaults, clamps, and floors', () => { + expect(clampMaxRows(undefined)).toBe(200); + expect(clampMaxRows('abc')).toBe(200); + expect(clampMaxRows(0)).toBe(1); + expect(clampMaxRows(-5)).toBe(1); + expect(clampMaxRows(99999)).toBe(5000); + expect(clampMaxRows(42.9)).toBe(42); + }); +}); + +describe('shapeRows', () => { + it('derives columns from row keys and preserves order', () => { + const shaped = shapeRows([{ a: 1, b: 'x' }, { a: 2, b: 'y' }], 500); + expect(shaped).toMatchObject({ columns: ['a', 'b'], rowCount: 2, truncated: false }); + expect(shaped.rows).toEqual([[1, 'x'], [2, 'y']]); + }); + + it('unions keys across rows (drivers that omit null keys)', () => { + const shaped = shapeRows([{ a: 1 }, { b: 2 }], 500); + expect(shaped.columns).toEqual(['a', 'b']); + expect(shaped.rows).toEqual([[1, undefined], [undefined, 2]]); + }); + + it('truncates past maxRows and flags it', () => { + const raw = Array.from({ length: 10 }, (_, i) => ({ n: i })); + const shaped = shapeRows(raw, 3); + expect(shaped).toMatchObject({ rowCount: 3, truncated: true }); + expect(shaped.rows).toHaveLength(3); + }); + + it('serializes BigInt, Date, binary, and nested objects to JSON-safe cells', () => { + const shaped = shapeRows( + [{ big: 9007199254740993n, when: new Date('2026-01-02T03:04:05.000Z'), bin: new Uint8Array([0xde, 0xad]), obj: { x: 1 } }], + 500 + ); + expect(shaped.rows[0]).toEqual(['9007199254740993', '2026-01-02T03:04:05.000Z', '0xdead', '{"x":1}']); + expect(() => JSON.stringify(shaped)).not.toThrow(); + }); + + it('treats a non-array driver result (e.g. write OkPacket) as an empty result set', () => { + const shaped = shapeRows({ affectedRows: 3 }, 500); + expect(shaped).toMatchObject({ columns: [], rows: [], rowCount: 0, truncated: false }); + }); + + it('empty result set yields no column names (documented v1 limitation)', () => { + expect(shapeRows([], 500).columns).toEqual([]); + }); +}); + +describe('runStatements against a real SQLite file', () => { + const dbPath = join(tmpdir(), `fox-sql-exec-test-${process.pid}.db`); + + beforeAll(async () => { + // better-sqlite3 ships no type declarations (the sqlite adapter loads it + // untyped via createRequire too) — suppress for this seeding-only usage. + // @ts-expect-error no type declarations for better-sqlite3 + const mod = (await import('better-sqlite3')) as { default: new (path: string) => { exec(sql: string): void; close(): void } }; + const Database = mod.default; + const db = new Database(dbPath); + db.exec(`CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT); + INSERT INTO t (id, name) VALUES (1, 'alpha'), (2, 'beta'), (3, NULL);`); + db.close(); + }); + + afterAll(async () => { + await ConnectionFactory.closeAll().catch(() => {}); + rmSync(dbPath, { force: true }); + }); + + it('runs statements sequentially, isolating per-statement errors', async () => { + const results = await runStatements( + 'sqlite', + { connectionString: dbPath }, + ['SELECT id, name FROM t ORDER BY id;', 'SELECT nope FROM missing_table;', 'SELECT COUNT(*) AS n FROM t;'], + 500 + ); + expect(results).toHaveLength(3); + + expect(results[0]).toMatchObject({ ok: true, columns: ['id', 'name'], rowCount: 3, truncated: false }); + if (results[0].ok) expect(results[0].rows[0]).toEqual([1, 'alpha']); + + expect(results[1].ok).toBe(false); + if (!results[1].ok) expect(results[1].error).toMatch(/missing_table|no such table/i); + + expect(results[2]).toMatchObject({ ok: true, columns: ['n'] }); + if (results[2].ok) expect(results[2].rows).toEqual([[3]]); + }); + + it('applies the row cap with the truncated flag', async () => { + const results = await runStatements('sqlite', { connectionString: dbPath }, ['SELECT * FROM t;'], 2); + expect(results[0]).toMatchObject({ ok: true, rowCount: 2, truncated: true }); + }); +}); diff --git a/apps/web/src/backend/api/sql-execute.ts b/apps/web/src/backend/api/sql-execute.ts new file mode 100644 index 0000000..328910f --- /dev/null +++ b/apps/web/src/backend/api/sql-execute.ts @@ -0,0 +1,120 @@ +import { ConnectionFactory, getAdapter, type ConnectionOptions } from '@foxschema/core'; + +/** + * Helpers behind POST /api/sql/execute (SQL Editor). One request = one + * credential + N statements; the frontend fans out across credentials with + * parallel requests. Statements run sequentially on one connection (so + * SET CURRENT SCHEMA / search_path sticks); each result is isolated so one + * broken statement doesn't hide the others. + */ + +export interface StatementResultOk { + ok: true; + columns: string[]; + /** Rows as arrays in `columns` order, cells JSON-safe (BigInt→string, Date→ISO). */ + rows: unknown[][]; + rowCount: number; + /** True when the driver returned more rows than maxRows and the tail was dropped. */ + truncated: boolean; + durationMs: number; +} +export interface StatementResultErr { + ok: false; + error: string; + durationMs: number; +} +export type StatementResult = StatementResultOk | StatementResultErr; + +export const MAX_STATEMENTS = 25; +export const MAX_STATEMENT_LENGTH = 100_000; +const MAX_ROWS_CAP = 5000; +const DEFAULT_MAX_ROWS = 200; + +export function clampMaxRows(v: unknown): number { + const n = typeof v === 'number' ? Math.floor(v) : Number.NaN; + if (!Number.isFinite(n)) return DEFAULT_MAX_ROWS; + return Math.min(Math.max(n, 1), MAX_ROWS_CAP); +} + +/** One JSON-safe cell. Drivers hand back BigInt/Date/Buffer/objects that JSON.stringify either rejects or mangles. */ +function serializeCell(v: unknown): unknown { + if (typeof v === 'bigint') return v.toString(); + if (v instanceof Date) return v.toISOString(); + if (v instanceof Uint8Array) { + const hex = Buffer.from(v).toString('hex'); + return `0x${hex.length > 64 ? hex.slice(0, 64) + '…' : hex}`; + } + if (typeof v === 'object' && v !== null) { + try { + return JSON.stringify(v); + } catch { + return String(v); + } + } + return v; +} + +/** + * Shape raw driver rows into {columns, rows} for the grid. Column names are the + * union of keys over the first 50 kept rows (empty result ⇒ no column names — + * accepted v1 limitation, the UI shows "0 rows"). Non-array driver results + * (e.g. a mysql2 OkPacket from a write) are treated as an empty result set. + */ +export function shapeRows(raw: unknown, maxRows: number): Omit { + const all: Record[] = Array.isArray(raw) ? raw : []; + const truncated = all.length > maxRows; + const kept = truncated ? all.slice(0, maxRows) : all; + + const colSet = new Set(); + for (const row of kept.slice(0, 50)) { + if (row && typeof row === 'object') for (const k of Object.keys(row)) colSet.add(k); + } + const columns = [...colSet]; + const rows = kept.map((r) => columns.map((c) => serializeCell(r?.[c]))); + return { columns, rows, rowCount: kept.length, truncated }; +} + +/** + * Run statements in order against one credential, isolating failures per statement. + * Applies `schema` the same way migration does (connection option + adapter + * setCurrentSchema) so unqualified names resolve — e.g. DB2 CURRENT SCHEMA + * instead of the auth-id default (CARTER.ORDERS). + */ +export async function runStatements( + dialect: string, + option: ConnectionOptions, + statements: string[], + maxRows: number, + schema?: string +): Promise { + const schemaName = (schema ?? option.schema)?.trim() || ''; + const optionWithSchema: ConnectionOptions = schemaName + ? { ...option, schema: schemaName } + : option; + + const connection = await ConnectionFactory.create(dialect, optionWithSchema); + try { + if (schemaName) { + await getAdapter(dialect).setCurrentSchema(connection, schemaName); + } + + const results: StatementResult[] = []; + for (const sql of statements) { + const started = Date.now(); + try { + const raw = await ConnectionFactory.executeOnConnection>( + dialect, + connection, + sql + ); + results.push({ ok: true, ...shapeRows(raw, maxRows), durationMs: Date.now() - started }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + results.push({ ok: false, error: message, durationMs: Date.now() - started }); + } + } + return results; + } finally { + await ConnectionFactory.close(dialect, connection); + } +} diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx index d3374a7..5ba4859 100644 --- a/apps/web/src/frontend/App.tsx +++ b/apps/web/src/frontend/App.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from 'react'; import { TopToolbar } from './components/TopToolbar'; import { SchemaTreePanel } from './components/SchemaTreePanel'; import { ObjectDetailPanel } from './components/ObjectDetailPanel'; +import { SqlEditorView } from './components/sql-editor/SqlEditorView'; import { ErrorBoundary } from './components/ErrorBoundary'; import { AuthPage } from './components/AuthPage'; import { OnboardingWizard } from './components/OnboardingWizard'; @@ -13,6 +14,7 @@ import { AlertCircle, AlertTriangle, Loader2, X } from 'lucide-react'; const Workspace: React.FC = () => { const { errorMsg, warnings, dismissWarnings } = useSyncStore(); + const activeView = useUiStore((s) => s.activeView); return (
@@ -44,12 +46,20 @@ const Workspace: React.FC = () => { )}
- - - - - - + {activeView === 'sqlEditor' ? ( + + + + ) : ( + <> + + + + + + + + )}
); diff --git a/apps/web/src/frontend/api/sqlApi.ts b/apps/web/src/frontend/api/sqlApi.ts new file mode 100644 index 0000000..b6d174f --- /dev/null +++ b/apps/web/src/frontend/api/sqlApi.ts @@ -0,0 +1,34 @@ +import { getApiBase } from './apiBase'; +import type { ConnectionRef } from './schemaApi'; + +/** One statement's outcome from POST /sql/execute (mirrors backend sql-execute.ts). */ +export type SqlStatementResult = + | { ok: true; columns: string[]; rows: unknown[][]; rowCount: number; truncated: boolean; durationMs: number } + | { ok: false; error: string; durationMs: number }; + +/** + * Run statements against ONE credential. The SQL Editor fans out across + * selected credentials by calling this once per connection (Promise.allSettled + * in the store), so a dead database can't stall the others' results. + */ +export async function executeSql( + ref: ConnectionRef, + statements: string[], + maxRows?: number +): 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 }), + }); + const text = await res.text(); + let data: { results?: SqlStatementResult[]; error?: string }; + try { + data = JSON.parse(text); + } catch { + throw new Error(`Invalid response from server (${res.status}): ${text.slice(0, 200)}`); + } + if (!res.ok || !data.results) throw new Error(data.error || `Query failed (${res.status})`); + return { results: data.results }; +} diff --git a/apps/web/src/frontend/components/CredentialManager.tsx b/apps/web/src/frontend/components/CredentialManager.tsx index ca2bd7e..b83ec4b 100644 --- a/apps/web/src/frontend/components/CredentialManager.tsx +++ b/apps/web/src/frontend/components/CredentialManager.tsx @@ -70,7 +70,7 @@ export const CredentialManager: React.FC = ({ open, onClose }) => { if (!open) return null; return createPortal( -
+
e.stopPropagation()}> {/* Header */} @@ -88,7 +88,7 @@ export const CredentialManager: React.FC = ({ open, onClose }) => { {visible.length === connections.length ? `${connections.length} saved` : `${visible.length} of ${connections.length}`} -
@@ -254,6 +254,7 @@ export const CredentialManager: React.FC = ({ open, onClose }) => { {/* Footer */}
+ +
- {compareResult && ( + {compareResult && activeView === 'sync' && (
+ {/* Sync-only controls — the SQL Editor view brings its own left panel. */} + {activeView === 'sync' && ( + <> {/* Database Connection Control Grid */}
{/* Source Configuration */} @@ -420,6 +447,8 @@ export const TopToolbar: React.FC = () => {
+ + )} { + const connections = useSyncStore((s) => s.connections); + const tabs = useSqlEditorStore((s) => s.tabs); + const activeTabId = useSqlEditorStore((s) => s.activeTabId); + const shareDestinations = useSqlEditorStore((s) => s.shareDestinations); + const sharedConnectionIds = useSqlEditorStore((s) => s.sharedConnectionIds); + const setShareDestinations = useSqlEditorStore((s) => s.setShareDestinations); + const toggleConnection = useSqlEditorStore((s) => s.toggleConnection); + const pendingPassword = useSqlEditorStore((s) => s.pendingPassword); + const submitSessionPassword = useSqlEditorStore((s) => s.submitSessionPassword); + const cancelPasswordPrompt = useSqlEditorStore((s) => s.cancelPasswordPrompt); + + const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]!; + const selectedConnectionIds = effectiveConnectionIds( + tab, + shareDestinations, + sharedConnectionIds + ).filter((id) => connections.some((c) => c.id === id)); + + const [pendingValue, setPendingValue] = useState(''); + + const confirmPending = () => { + submitSessionPassword(pendingValue); + setPendingValue(''); + }; + + return ( +
+ + + {connections.length === 0 ? ( +

+ No saved connections yet — add one via the Credentials button in the toolbar. +

+ ) : ( +
+ {connections.map((c) => ( + + ))} +
+ )} + + {pendingPassword && + createPortal( +
{ + cancelPasswordPrompt(); + setPendingValue(''); + }} + > +
e.stopPropagation()} + > +

+ Password for “{pendingPassword.name}” +

+

+ This connection was saved without a password. Enter it for this session only — it is + never stored. + {pendingPassword.resumeExecute ? ' Run will continue after you confirm.' : ''} +

+ setPendingValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && confirmPending()} + className="bg-slate-950 border border-slate-800 focus:border-cyan-500 rounded-md px-3 py-2 text-xs outline-none" + placeholder="••••••••" + /> +
+ + +
+
+
, + document.body + )} +
+ ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx new file mode 100644 index 0000000..76a7550 --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -0,0 +1,454 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AlertTriangle, Download, GripVertical, RefreshCw } from 'lucide-react'; +import type { SqlStatementResult } from '../../api/sqlApi'; +import { downloadCsv } from '../../utils/exportCsv'; + +const CELL_MAX = 200; +const COL_MIN_PX = 96; +const COL_DEFAULT_PX = 128; +const COL_MAX_PX = 220; +const COL_LONG_TEXT_PX = 200; +const ROW_NUM_PX = 48; +/** Fixed row height for windowing (must match rendered row). */ +const ROW_H_PX = 28; +const OVERSCAN = 8; + +const LONG_TEXT_NAME = + /^(description|reason|comment|comments|note|notes|message|messages|remark|remarks|detail|details|summary|body|content|text|memo|explanation|error|errmsg|err_msg)$/i; + +type CellKind = 'null' | 'number' | 'boolean' | 'datetime' | 'binary' | 'string'; + +const KIND_CELL_CLASS: Record = { + null: 'italic text-[#94a3b8]', + number: 'text-[#1d4ed8]', + boolean: 'text-[#7c3aed]', + datetime: 'text-[#0f766e]', + binary: 'text-[#b45309]', + string: 'text-[#1e293b]', +}; + +const KIND_HEADER_CLASS: Record, string> = { + number: 'text-[#1d4ed8]', + boolean: 'text-[#7c3aed]', + datetime: 'text-[#0f766e]', + binary: 'text-[#b45309]', + string: 'text-[#64748b]', +}; + +const KIND_LABEL: Record, string> = { + number: 'num', + boolean: 'bool', + datetime: 'date', + binary: 'bin', + string: 'text', +}; + +const ISO_DATE_RE = + /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?)?$/; +const NUMERIC_STRING_RE = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/; +const BINARY_RE = /^0x[0-9a-fA-F…]+$/; + +function inferCellKind(value: unknown): CellKind { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'number' && Number.isFinite(value)) return 'number'; + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'string') { + const s = value.trim(); + if (s === '') return 'string'; + if (s === 'true' || s === 'false') return 'boolean'; + if (BINARY_RE.test(s)) return 'binary'; + if (ISO_DATE_RE.test(s)) return 'datetime'; + if (NUMERIC_STRING_RE.test(s) && s.length <= 40) return 'number'; + return 'string'; + } + return 'string'; +} + +function inferColumnKind(sampleValues: unknown[]): Exclude { + const counts: Record, number> = { + number: 0, + boolean: 0, + datetime: 0, + binary: 0, + string: 0, + }; + for (const v of sampleValues) { + const k = inferCellKind(v); + if (k === 'null') continue; + counts[k] += 1; + } + let best: Exclude = 'string'; + let bestN = -1; + for (const k of Object.keys(counts) as Exclude[]) { + if (counts[k] > bestN) { + best = k; + bestN = counts[k]; + } + } + return best; +} + +function identityOrder(n: number): number[] { + return Array.from({ length: n }, (_, i) => i); +} + +function reorder(order: number[], from: number, to: number): number[] { + if (from === to || from < 0 || to < 0 || from >= order.length || to >= order.length) { + return order; + } + const next = [...order]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved!); + return next; +} + +function defaultWidthFor(colName: string, sampleValues: unknown[]): number { + const headerPx = colName.length * 8 + 40; + let contentPx = 0; + for (const v of sampleValues) { + if (v === null || v === undefined) continue; + contentPx = Math.max(contentPx, Math.min(String(v).length, 40) * 7.2); + } + const isLongText = + LONG_TEXT_NAME.test(colName.trim()) || + /desc|reason|comment|message|remark|note|detail/i.test(colName); + const raw = Math.max(headerPx, contentPx, COL_DEFAULT_PX); + const cap = isLongText ? COL_LONG_TEXT_PX : COL_MAX_PX; + return Math.min(cap, Math.max(COL_MIN_PX, Math.round(raw))); +} + +function computeColWidths(columns: string[], rows: unknown[][]): number[] { + const sample = rows.slice(0, 40); + return columns.map((name, i) => defaultWidthFor(name, sample.map((r) => r[i]))); +} + +function computeColKinds(columns: string[], rows: unknown[][]): Exclude[] { + const sample = rows.slice(0, 40); + return columns.map((_, i) => inferColumnKind(sample.map((r) => r[i]))); +} + +/** Fast display text — no React element per cell. */ +function cellDisplay(value: unknown): { text: string; title: string; isNull: boolean } { + if (value === null || value === undefined) { + return { text: 'NULL', title: 'NULL', isNull: true }; + } + const raw = typeof value === 'string' ? value : String(value); + if (raw.length > CELL_MAX) { + return { text: `${raw.slice(0, CELL_MAX)}…`, title: raw, isNull: false }; + } + return { text: raw, title: raw, isNull: false }; +} + +/** + * Result grid — virtualized rows, column-level type colors (no per-cell regex). + */ +export const DataGrid: React.FC<{ + result: SqlStatementResult; + label?: string; + exportName?: string; + refreshing?: boolean; + onRefresh?: () => void; +}> = React.memo(({ result, label, exportName = 'query-result', refreshing, onRefresh }) => { + const sourceColumns = result.ok ? result.columns : []; + const sourceRows = result.ok ? result.rows : []; + const [colOrder, setColOrder] = useState(() => identityOrder(sourceColumns.length)); + const [dragFrom, setDragFrom] = useState(null); + const [dragOver, setDragOver] = useState(null); + + const scrollRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportH, setViewportH] = useState(320); + const rafRef = useRef(0); + + const colKey = sourceColumns.join('\0'); + const colWidths = useMemo( + () => computeColWidths(sourceColumns, sourceRows), + // eslint-disable-next-line react-hooks/exhaustive-deps -- colKey + rowCount capture result identity cheaply + [colKey, sourceRows.length, result.ok && result.ok ? result.rowCount : 0] + ); + const colKinds = useMemo( + () => computeColKinds(sourceColumns, sourceRows), + // eslint-disable-next-line react-hooks/exhaustive-deps + [colKey, sourceRows.length, result.ok && result.ok ? result.rowCount : 0] + ); + + useEffect(() => { + setColOrder(identityOrder(sourceColumns.length)); + setDragFrom(null); + setDragOver(null); + setScrollTop(0); + if (scrollRef.current) scrollRef.current.scrollTop = 0; + }, [colKey]); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const measure = () => setViewportH(el.clientHeight || 320); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [result.ok, sourceColumns.length]); + + const onScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => setScrollTop(el.scrollTop)); + }, []); + + useEffect(() => () => cancelAnimationFrame(rafRef.current), []); + + if (!result.ok) { + return ( +
+
+ {label && ( +
+ {label} +
+ )} + {onRefresh && ( + + )} +
+
+ + {result.error} +
+
+ ); + } + + const order = + colOrder.length === sourceColumns.length ? colOrder : identityOrder(sourceColumns.length); + const orderedColumns = order.map((i) => sourceColumns[i]!); + const tableWidth = + ROW_NUM_PX + order.reduce((sum, i) => sum + (colWidths[i] ?? COL_DEFAULT_PX), 0); + const colCount = 1 + order.length; + + const totalRows = sourceRows.length; + const start = Math.max(0, Math.floor(scrollTop / ROW_H_PX) - OVERSCAN); + const visibleCount = Math.ceil(viewportH / ROW_H_PX) + OVERSCAN * 2; + const end = Math.min(totalRows, start + visibleCount); + const padTop = start * ROW_H_PX; + const padBottom = Math.max(0, (totalRows - end) * ROW_H_PX); + + const exportOrdered = () => { + const orderedRows = sourceRows.map((row) => order.map((i) => row[i])); + downloadCsv(exportName, orderedColumns, orderedRows); + }; + + return ( +
+
+ {label && ( +
+ {label} +
+ )} + {onRefresh && ( + + )} + {sourceColumns.length > 0 && ( + + )} +
+
+ {sourceColumns.length === 0 ? ( +
+ 0 rows (column names unavailable for empty results) +
+ ) : ( + + + + {order.map((colIdx) => ( + + ))} + + + + + {order.map((colIdx, visualIdx) => { + const name = sourceColumns[colIdx]!; + const w = colWidths[colIdx] ?? COL_DEFAULT_PX; + const kind = colKinds[colIdx] ?? 'string'; + const isOver = dragOver === visualIdx && dragFrom !== visualIdx; + return ( + + ); + })} + + + + {padTop > 0 && ( + + + )} + {sourceRows.slice(start, end).map((row, offset) => { + const i = start + offset; + return ( + + + {order.map((colIdx) => { + const cell = row[colIdx]; + const w = colWidths[colIdx] ?? COL_DEFAULT_PX; + const { text, title, isNull } = cellDisplay(cell); + const kind = isNull ? 'null' : (colKinds[colIdx] ?? 'string'); + return ( + + ); + })} + + ); + })} + {padBottom > 0 && ( + + + )} + +
+ # + { + setDragFrom(visualIdx); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(visualIdx)); + if (e.currentTarget instanceof HTMLElement) { + e.dataTransfer.setDragImage(e.currentTarget, 12, 12); + } + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (dragOver !== visualIdx) setDragOver(visualIdx); + }} + onDragLeave={() => { + if (dragOver === visualIdx) setDragOver(null); + }} + onDrop={(e) => { + e.preventDefault(); + const from = + dragFrom ?? Number.parseInt(e.dataTransfer.getData('text/plain'), 10); + if (Number.isFinite(from)) { + setColOrder((prev) => + reorder( + prev.length === sourceColumns.length + ? prev + : identityOrder(sourceColumns.length), + from, + visualIdx + ) + ); + } + setDragFrom(null); + setDragOver(null); + }} + onDragEnd={() => { + setDragFrom(null); + setDragOver(null); + }} + style={{ width: w, minWidth: COL_MIN_PX, maxWidth: w }} + className={`px-2 py-1.5 font-bold tracking-wide text-left cursor-grab active:cursor-grabbing select-none overflow-hidden ${ + dragFrom === visualIdx ? 'opacity-50' : '' + } ${isOver ? 'bg-[#cbd5e1] ring-2 ring-inset ring-cyan-500/70' : ''}`} + > + + + + {name} + + {KIND_LABEL[kind]} + + + +
+
+ {i + 1} + + {text} +
+
+ )} +
+
+ + {result.rowCount} row{result.rowCount === 1 ? '' : 's'} · {result.durationMs} ms + + {result.truncated && ( + truncated — add a LIMIT for the full picture + )} +
+
+ ); +}); + +DataGrid.displayName = 'DataGrid'; + +export const PANE_MIN_PX = 240; +export const PANE_DEFAULT_PX = 420; diff --git a/apps/web/src/frontend/components/sql-editor/EditorTabBar.tsx b/apps/web/src/frontend/components/sql-editor/EditorTabBar.tsx new file mode 100644 index 0000000..205ddeb --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/EditorTabBar.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Pencil, Plus, X } from 'lucide-react'; +import type { SqlTab } from '../../store/sqlEditorTabLogic'; + +interface Props { + tabs: SqlTab[]; + activeTabId: string; + onSelect: (id: string) => void; + onClose: (id: string) => void; + onAdd: () => void; + onRename: (id: string, title: string) => void; +} + +/** + * Multi-tab bar for the SQL Editor. Rename via double-click or the pencil; + * middle-click or × closes. Always keeps at least one tab (store replaces the last). + */ +export const EditorTabBar: React.FC = ({ + tabs, + activeTabId, + onSelect, + onClose, + onAdd, + onRename, +}) => { + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (editingId) inputRef.current?.select(); + }, [editingId]); + + const startEdit = (tab: SqlTab, e?: React.MouseEvent) => { + e?.stopPropagation(); + setEditingId(tab.id); + setDraft(tab.title); + }; + + const commitEdit = () => { + if (!editingId) return; + onRename(editingId, draft); + setEditingId(null); + }; + + return ( +
+ {tabs.map((tab) => { + const active = tab.id === activeTabId; + return ( +
onSelect(tab.id)} + onDoubleClick={() => startEdit(tab)} + onAuxClick={(e) => { + if (e.button === 1) { + e.preventDefault(); + onClose(tab.id); + } + }} + title="Double-click or use pencil to rename" + className={`group flex items-center gap-1 pl-3 pr-1.5 py-1.5 text-[11px] font-semibold border-r border-slate-800/80 cursor-pointer shrink-0 max-w-[12rem] ${ + active + ? 'bg-slate-900 text-slate-100 border-b-2 border-b-cyan-500 -mb-px' + : 'text-slate-500 hover:text-slate-300 hover:bg-slate-900/40' + }`} + > + {editingId === tab.id ? ( + setDraft(e.target.value)} + onBlur={commitEdit} + onKeyDown={(e) => { + if (e.key === 'Enter') commitEdit(); + if (e.key === 'Escape') setEditingId(null); + }} + onClick={(e) => e.stopPropagation()} + className="bg-slate-950 border border-cyan-600/50 rounded px-1 py-0 text-[11px] outline-none w-28 text-slate-100" + /> + ) : ( + <> + + {tab.title} + + + + )} + +
+ ); + })} + +
+ ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx new file mode 100644 index 0000000..53a28f3 --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -0,0 +1,328 @@ +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { Loader2, Database, AlertCircle, GripVertical, RefreshCw } from 'lucide-react'; +import type { CredentialRun } from '../../store/useSqlEditorStore'; +import type { ResultsLayout } from '../../store/sqlEditorTabLogic'; +import { DataGrid, PANE_DEFAULT_PX, PANE_MIN_PX } from './DataGrid'; +import type { SqlStatementResult } from '../../api/sqlApi'; + +interface Props { + runs: CredentialRun[]; + /** The statements the run executed, for grid labels ("Query 1 · SELECT …"). */ + statements: string[]; + layout: ResultsLayout; + /** True while any execute is in flight for this tab. */ + refreshing?: boolean; + /** Re-run for one credential, or all when omitted. */ + onRefresh?: (connectionId?: string) => void; +} + +const GAP_PX = 6; // space between pane and its resize grip + +const statementLabel = (sql: string, index: number): string => { + const compact = sql.replace(/\s+/g, ' ').trim(); + return `Query ${index + 1} · ${compact.length > 48 ? compact.slice(0, 48) + '…' : compact}`; +}; + +const credentialLabel = (run: CredentialRun): string => `${run.name} [${run.dialect}]`; + +type PaneItem = + | { + key: string; + kind: 'grid'; + result: SqlStatementResult; + label: string; + exportName: string; + connectionId: string; + } + | { key: string; kind: 'running'; label: string; connectionId: string } + | { key: string; kind: 'error'; label: string; error: string; connectionId: string }; + +function equalWidths(count: number, containerW: number): number[] { + if (count <= 0) return []; + const gripTotal = count * (8 + GAP_PX); // grip + gap per pane + const avail = Math.max(count * PANE_MIN_PX, containerW - gripTotal); + const each = Math.max(PANE_MIN_PX, Math.floor(avail / count)); + return Array.from({ length: count }, () => each); +} + +/** + * Horizontal row of result panes. Every table has its own drag grip on the + * right edge — widths are independent (growing one does not crush neighbors; + * the row scrolls when panes exceed the viewport). + */ +const ResizablePaneRow: React.FC<{ + items: PaneItem[]; + rowKey: string; + refreshing?: boolean; + onRefresh?: (connectionId: string) => void; +}> = ({ items, rowKey, refreshing, onRefresh }) => { + const rowRef = useRef(null); + const [widths, setWidths] = useState(() => items.map(() => PANE_DEFAULT_PX)); + const sizedForKey = useRef(null); + + useLayoutEffect(() => { + const el = rowRef.current; + if (!el || items.length === 0) return; + + const applyEqual = () => { + const w = el.clientWidth; + if (w <= 0) return; + // Re-equalize when the row identity changes (new query / layout), not on every resize. + if (sizedForKey.current === rowKey) return; + sizedForKey.current = rowKey; + setWidths(equalWidths(items.length, w)); + }; + + applyEqual(); + const ro = new ResizeObserver(() => { + // If user hasn't customized yet for this rowKey, keep filling. + if (sizedForKey.current !== rowKey) applyEqual(); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [items.length, rowKey]); + + useEffect(() => { + setWidths((prev) => { + if (prev.length === items.length) return prev; + if (prev.length < items.length) { + return [...prev, ...Array.from({ length: items.length - prev.length }, () => PANE_DEFAULT_PX)]; + } + return prev.slice(0, items.length); + }); + }, [items.length]); + + const startPaneResize = useCallback((index: number, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const startW = widths[index] ?? PANE_DEFAULT_PX; + const onMove = (ev: MouseEvent) => { + const next = Math.max(PANE_MIN_PX, startW + (ev.clientX - startX)); + setWidths((prev) => { + if (prev[index] === next) return prev; + const copy = [...prev]; + copy[index] = next; + return copy; + }); + }; + const onUp = () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, [widths]); + + if (items.length === 0) return null; + + return ( +
+ {items.map((item, i) => ( + +
+ {item.kind === 'grid' && ( + onRefresh(item.connectionId) : undefined} + /> + )} + {item.kind === 'running' && ( +
+ {item.label} +
+ )} + {item.kind === 'error' && ( +
+
+
+ {item.label} +
+ {onRefresh && ( + + )} +
+
+ + {item.error} +
+
+ )} +
+ {/* Every pane — including the last — gets a resize grip */} +
startPaneResize(i, e)} + className="w-2 shrink-0 cursor-col-resize self-stretch mx-0.5 rounded-sm bg-slate-800 hover:bg-cyan-600/70 active:bg-cyan-500 flex items-center justify-center group" + > + +
+
+ ))} +
+ ); +}; + +/** + * Results for one tab. `byCredential` stacks credentials (each row's statement + * grids side by side). `sideBySide` stacks statements (credential grids as + * columns). Every result table is independently resizable. + */ +export const ResultsPanel: React.FC = ({ + runs, + statements, + layout, + refreshing, + onRefresh, +}) => { + if (runs.length === 0) { + return ( +
+ Run a query to see results here — one row per checked credential. +
+ ); + } + + if (layout === 'sideBySide') { + const stmtCount = Math.max(statements.length, ...runs.map((r) => r.results?.length ?? 0), 0); + return ( +
+ {Array.from({ length: stmtCount }, (_, i) => { + const items: PaneItem[] = []; + for (const run of runs) { + if (run.status === 'running') { + items.push({ + key: `${run.connectionId}-run`, + kind: 'running', + label: credentialLabel(run), + connectionId: run.connectionId, + }); + continue; + } + if (run.status === 'error') { + items.push({ + key: `${run.connectionId}-err`, + kind: 'error', + label: credentialLabel(run), + error: run.error ?? 'Error', + connectionId: run.connectionId, + }); + continue; + } + const result = run.results?.[i]; + if (!result) continue; + items.push({ + key: `${run.connectionId}-q${i}`, + kind: 'grid', + result, + label: credentialLabel(run), + exportName: `${run.name}-q${i + 1}`, + connectionId: run.connectionId, + }); + } + return ( +
+
+ {statementLabel(statements[i] ?? '', i)} +
+ x.key).join('|')}`} + refreshing={refreshing} + onRefresh={onRefresh} + /> +
+ ); + })} +
+ ); + } + + return ( +
+ {runs.map((run) => { + const items: PaneItem[] = + run.status === 'done' && run.results + ? run.results.map((result, i) => ({ + key: `${run.connectionId}-q${i}`, + kind: 'grid' as const, + result, + label: statementLabel(statements[i] ?? '', i), + exportName: `${run.name}-q${i + 1}`, + connectionId: run.connectionId, + })) + : []; + + return ( +
+
+ + {run.name} + [{run.dialect}] + {run.status === 'running' && } + {onRefresh && run.status !== 'running' && ( + + )} +
+ + {run.status === 'error' && ( +
+ + {run.error} +
+ )} + + {run.status === 'done' && items.length > 0 && ( + + )} +
+ ); + })} +
+ ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/SqlBookmarksPanel.tsx b/apps/web/src/frontend/components/sql-editor/SqlBookmarksPanel.tsx new file mode 100644 index 0000000..7f833bd --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/SqlBookmarksPanel.tsx @@ -0,0 +1,98 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Pencil, Trash2 } from 'lucide-react'; +import { useSqlEditorStore } from '../../store/useSqlEditorStore'; + +/** + * Persist named SQL scripts. Save is on the sidebar section header; open loads + * into a new tab (or focuses an already-linked tab). + */ +export const SqlBookmarksPanel: React.FC = () => { + const bookmarks = useSqlEditorStore((s) => s.bookmarks); + const openBookmark = useSqlEditorStore((s) => s.openBookmark); + const renameBookmark = useSqlEditorStore((s) => s.renameBookmark); + const deleteBookmark = useSqlEditorStore((s) => s.deleteBookmark); + + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (editingId) inputRef.current?.select(); + }, [editingId]); + + const commitRename = () => { + if (!editingId) return; + renameBookmark(editingId, draft); + setEditingId(null); + }; + + return ( +
+ {bookmarks.length === 0 ? ( +

+ Save a named script to reopen later. Rename the tab first so the bookmark keeps a clear + title. +

+ ) : ( +
    + {bookmarks.map((b) => ( +
  • + {editingId === b.id ? ( + setDraft(e.target.value)} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') setEditingId(null); + }} + className="flex-1 min-w-0 bg-slate-950 border border-cyan-600/50 rounded px-1.5 py-0.5 text-[11px] text-slate-100 outline-none" + /> + ) : ( + + )} + + +
  • + ))} +
+ )} +
+ ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorPane.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorPane.tsx new file mode 100644 index 0000000..dd0d8bf --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorPane.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useRef } from 'react'; +import Editor from '@monaco-editor/react'; +import { MONACO_THEME, MONACO_THEME_LIGHT, monacoLanguage } from '../../monaco-setup'; +import { useUiStore } from '../../store/uiStore'; +import { splitSqlStatements, checkStatement } from '../../lib/sql-splitter'; +import { ensureSqlCompletions } from './completion'; +import { setSqlInsertHandler } from './sqlEditorBridge'; + +// Mirrors SqlEditor.tsx's BASE_OPTIONS (that component stays read-only-oriented; +// this one is the editable editor with a glyph margin for statement status icons). +const EDITOR_OPTIONS = { + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 12, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + lineNumbersMinChars: 3, + renderLineHighlight: 'line' as const, + scrollbar: { alwaysConsumeMouseWheel: false }, + padding: { top: 8, bottom: 8 }, + automaticLayout: true, + glyphMargin: true, + tabSize: 2, + // Schema-aware suggestions from completion.ts (tables / columns / keywords). + quickSuggestions: { other: true, comments: false, strings: false }, + suggestOnTriggerCharacters: true, +}; + +export interface RevealRequest { + startLine: number; + endLine: number; + /** Bump to re-trigger reveal for the same range. */ + nonce: number; +} + +interface Props { + value: string; + dialect: string; + onChange: (value: string) => void; + /** Ctrl/Cmd+Enter shortcut → run. */ + onRun?: () => void; + /** Statement strip click → scroll/select that range. */ + reveal?: RevealRequest | null; +} + +/** + * Editable Monaco pane for the SQL Editor. Splits the buffer (debounced) and + * decorates each statement's first line with a gutter icon: green ✓ = looks + * complete, amber ⚠ = incomplete (unclosed quote/parens, missing final `;`, + * unknown leading keyword). Heuristic only — not validation. + */ +export const SqlEditorPane: React.FC = ({ value, dialect, onChange, onRun, reveal }) => { + const editorRef = useRef(null); + const monacoRef = useRef(null); + const decoRef = useRef(null); + const debounceRef = useRef | null>(null); + const onRunRef = useRef(onRun); + onRunRef.current = onRun; + const monacoTheme = useUiStore((s) => s.resolvedMode) === 'light' ? MONACO_THEME_LIGHT : MONACO_THEME; + + const decorate = (text: string) => { + const editor = editorRef.current; + if (!editor) return; + const statements = splitSqlStatements(text); + decoRef.current?.clear?.(); + if (!statements.length) { + decoRef.current = null; + return; + } + decoRef.current = editor.createDecorationsCollection( + statements.map((stmt) => { + const status = checkStatement(stmt); + const ok = status.level === 'ok'; + return { + range: { startLineNumber: stmt.startLine, startColumn: 1, endLineNumber: stmt.startLine, endColumn: 1 }, + options: { + glyphMarginClassName: ok ? 'fox-stmt-glyph-ok' : 'fox-stmt-glyph-warn', + glyphMarginHoverMessage: { + value: ok ? 'Statement looks complete' : status.reasons.join(' · '), + }, + }, + }; + }) + ); + }; + + // Re-decorate (debounced) whenever the buffer changes — including external + // resets like loading a persisted buffer after mount. + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => decorate(value), 200); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [value]); + + useEffect(() => { + const editor = editorRef.current; + if (!editor || !reveal) return; + const range = { + startLineNumber: reveal.startLine, + startColumn: 1, + endLineNumber: reveal.endLine, + endColumn: 1, + }; + editor.revealRangeInCenter(range); + editor.setSelection(range); + editor.focus(); + }, [reveal]); + + useEffect(() => { + return () => setSqlInsertHandler(null); + }, []); + + return ( + onChange(v ?? '')} + onMount={(editor, monaco) => { + editorRef.current = editor; + monacoRef.current = monaco; + ensureSqlCompletions(monaco); + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => onRunRef.current?.()); + setSqlInsertHandler((text) => { + const ed = editorRef.current; + const m = monacoRef.current; + if (!ed || !m) return; + const sel = ed.getSelection(); + const pos = ed.getPosition(); + const range = sel && !sel.isEmpty() + ? sel + : pos + ? new m.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column) + : null; + if (!range) return; + ed.executeEdits('schema-insert', [{ range, text, forceMoveMarkers: true }]); + ed.focus(); + }); + decorate(editor.getValue()); + }} + options={EDITOR_OPTIONS} + /> + ); +}; + +export default SqlEditorPane; diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx new file mode 100644 index 0000000..7228d0e --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx @@ -0,0 +1,390 @@ +import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Loader2, Play, Eraser, AlignLeft, Columns2, Rows3, RefreshCw, BookmarkPlus, Database, Bookmark, Network, Shield } from 'lucide-react'; +import { useSyncStore } from '../../store/useSyncStore'; +import { useSqlEditorStore } from '../../store/useSqlEditorStore'; +import { splitSqlStatements, type SplitStatement } from '../../lib/sql-splitter'; +import { formatSql } from '../../utils/formatSql'; +import { effectiveConnectionIds } from '../../store/sqlEditorTabLogic'; +import { setCompletionContextGetter } from './sqlEditorBridge'; +import { ConnectionChecklist } from './ConnectionChecklist'; +import { EditorTabBar } from './EditorTabBar'; +import { ResultsPanel } from './ResultsPanel'; +import { StatementStrip } from './StatementStrip'; +import { SqlBookmarksPanel } from './SqlBookmarksPanel'; +import { SqlSchemaExplorer } from './SqlSchemaExplorer'; +import { SqlSidebarSection, useSidebarSectionsOpen } from './SqlSidebarSection'; +import { WriteConfirmDialog } from './WriteConfirmDialog'; +import type { RevealRequest } from './SqlEditorPane'; + +const SqlEditorPane = lazy(() => import('./SqlEditorPane')); + +const EditorFallback: React.FC = () => ( +
+ +
+); + +const EDITOR_PCT_MIN = 15; +const EDITOR_PCT_MAX = 70; +const EDITOR_PCT_DEFAULT = 26; + +/** + * SQL Editor workspace: multi-tab buffers, destination servers, schema explorer, + * statement strip, layout toggle, Format/CSV. Results are per-tab and not persisted. + */ +export const SqlEditorView: React.FC = () => { + const connections = useSyncStore((s) => s.connections); + const tabs = useSqlEditorStore((s) => s.tabs); + const activeTabId = useSqlEditorStore((s) => s.activeTabId); + const resultsByTab = useSqlEditorStore((s) => s.resultsByTab); + const runningTabId = useSqlEditorStore((s) => s.runningTabId); + const pendingWriteConfirm = useSqlEditorStore((s) => s.pendingWriteConfirm); + const schemaCache = useSqlEditorStore((s) => s.schemaCache); + const setSql = useSqlEditorStore((s) => s.setSql); + const execute = useSqlEditorStore((s) => s.execute); + const cancelWriteConfirm = useSqlEditorStore((s) => s.cancelWriteConfirm); + const clearResults = useSqlEditorStore((s) => s.clearResults); + const toggleStatement = useSqlEditorStore((s) => s.toggleStatement); + const setLayout = useSqlEditorStore((s) => s.setLayout); + const addTab = useSqlEditorStore((s) => s.addTab); + const closeTab = useSqlEditorStore((s) => s.closeTab); + const setActiveTab = useSqlEditorStore((s) => s.setActiveTab); + const renameTab = useSqlEditorStore((s) => s.renameTab); + const ensureSchema = useSqlEditorStore((s) => s.ensureSchema); + const setMaxRows = useSqlEditorStore((s) => s.setMaxRows); + const maxRows = useSqlEditorStore((s) => s.maxRows); + const saveBookmark = useSqlEditorStore((s) => s.saveBookmark); + const shareDestinations = useSqlEditorStore((s) => s.shareDestinations); + const sharedConnectionIds = useSqlEditorStore((s) => s.sharedConnectionIds); + const safeMode = useSqlEditorStore((s) => s.safeMode); + const setSafeMode = useSqlEditorStore((s) => s.setSafeMode); + + const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]!; + // Drop connection ids that no longer exist in the saved list (persist-safe). + const liveSelectedIds = effectiveConnectionIds( + tab, + shareDestinations, + sharedConnectionIds + ).filter((id) => connections.some((c) => c.id === id)); + + const statements = useMemo(() => splitSqlStatements(tab.sql), [tab.sql]); + const results = resultsByTab[tab.id]; + const running = runningTabId === tab.id; + + const [reveal, setReveal] = useState(null); + const [editorPct, setEditorPct] = useState(EDITOR_PCT_DEFAULT); + const splitRef = useRef(null); + const [sidebarOpen, toggleSidebar] = useSidebarSectionsOpen(); + + // Completion provider reads active SQL + checked schemas via this getter. + useEffect(() => { + setCompletionContextGetter(() => { + const state = useSqlEditorStore.getState(); + const active = state.tabs.find((t) => t.id === state.activeTabId) ?? state.tabs[0]!; + const destIds = state.activeConnectionIds(); + const schemas = destIds + .map((id) => { + const entry = state.schemaCache[id]; + if (entry?.status !== 'ready' || !entry.tables) return null; + return { connectionId: id, tables: entry.tables }; + }) + .filter((x): x is NonNullable => x != null); + return { sql: active.sql, schemas }; + }); + }, []); + + // Warm schema cache for checked credentials (autocomplete). + useEffect(() => { + for (const id of liveSelectedIds) { + if (!schemaCache[id] || schemaCache[id]?.status === 'idle') { + void ensureSchema(id); + } + } + }, [liveSelectedIds.join(','), ensureSchema]); // eslint-disable-line react-hooks/exhaustive-deps + + const startEditorResize = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const root = splitRef.current; + if (!root) return; + const rect = root.getBoundingClientRect(); + const onMove = (ev: MouseEvent) => { + const pct = ((ev.clientY - rect.top) / rect.height) * 100; + setEditorPct(Math.min(EDITOR_PCT_MAX, Math.max(EDITOR_PCT_MIN, pct))); + }; + const onUp = () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, []); + + const firstSelected = connections.find((c) => liveSelectedIds.includes(c.id)); + const dialect = firstSelected?.dialect ?? 'sql'; + + const runCount = + tab.checkedStatements.length === 0 + ? statements.length > 0 + ? 1 + : 0 + : tab.checkedStatements.filter((i) => i >= 0 && i < statements.length).length || + (statements.length > 0 ? 1 : 0); + + const canRun = !runningTabId && runCount > 0 && liveSelectedIds.length > 0; + const runTitle = !liveSelectedIds.length + ? 'Check at least one destination server to run against' + : !runCount + ? 'Write a SQL statement first' + : `Run ${runCount} statement(s) against ${liveSelectedIds.length} server(s) (⌘/Ctrl+Enter)`; + + const onReveal = (stmt: SplitStatement) => { + setReveal({ startLine: stmt.startLine, endLine: stmt.endLine, nonce: Date.now() }); + }; + + const onFormat = () => { + const formatted = formatSql(tab.sql, dialect); + if (formatted !== tab.sql) setSql(formatted); + }; + + return ( +
+ + +
+ + +
+ + + + + +
+ + +
+ + + + + + + {runCount} statement{runCount === 1 ? '' : 's'} · {liveSelectedIds.length} server + {liveSelectedIds.length === 1 ? '' : 's'} + +
+ {results && results.runs.length > 0 && ( + + )} +
+ +
+
+ }> + execute()} + reveal={reveal} + /> + +
+ +
+ + + +
+ + execute(connectionId ? { connectionIds: [connectionId] } : undefined) + } + /> +
+
+
+ + {pendingWriteConfirm && pendingWriteConfirm.tabId === tab.id && ( + + execute({ + confirmedWrites: true, + connectionIds: pendingWriteConfirm.connectionIds, + }) + } + /> + )} +
+ ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx new file mode 100644 index 0000000..db51b5d --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx @@ -0,0 +1,179 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { ChevronDown, ChevronRight, Loader2, RefreshCw } from 'lucide-react'; +import { useSyncStore } from '../../store/useSyncStore'; +import { useSqlEditorStore } from '../../store/useSqlEditorStore'; +import { effectiveConnectionIds } from '../../store/sqlEditorTabLogic'; +import { TYPE_META } from '../SchemaTreePanel'; +import { insertAtCursor } from './sqlEditorBridge'; +import type { TableSchema } from '../../lib/types'; + +/** + * Slim schema tree for the SQL Editor (not SchemaTreePanel — that one is + * hard-wired to compare/diff state). Pick a connection, load TABLE/VIEW/MQT, + * click a name to insert at the Monaco cursor. + */ +export const SqlSchemaExplorer: React.FC = () => { + const connections = useSyncStore((s) => s.connections); + const tabs = useSqlEditorStore((s) => s.tabs); + const activeTabId = useSqlEditorStore((s) => s.activeTabId); + const schemaCache = useSqlEditorStore((s) => s.schemaCache); + const ensureSchema = useSqlEditorStore((s) => s.ensureSchema); + const shareDestinations = useSqlEditorStore((s) => s.shareDestinations); + const sharedConnectionIds = useSqlEditorStore((s) => s.sharedConnectionIds); + + const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]!; + const preferredIds = effectiveConnectionIds(tab, shareDestinations, sharedConnectionIds).filter( + (id) => connections.some((c) => c.id === id) + ); + + const [explorerId, setExplorerId] = useState(''); + const [expanded, setExpanded] = useState>({}); + + // Prefer first checked credential; fall back to first saved connection. + useEffect(() => { + if (explorerId && connections.some((c) => c.id === explorerId)) return; + const next = preferredIds[0] ?? connections[0]?.id ?? ''; + setExplorerId(next); + }, [connections, preferredIds, explorerId]); + + useEffect(() => { + if (!explorerId) return; + void ensureSchema(explorerId); + }, [explorerId, ensureSchema]); + + const entry = explorerId ? schemaCache[explorerId] : undefined; + const tables = useMemo(() => { + const list = entry?.tables ?? []; + return list + .filter((t) => t.objectType === 'TABLE' || t.objectType === 'VIEW' || t.objectType === 'MQT') + .slice() + .sort((a, b) => a.name.localeCompare(b.name)); + }, [entry?.tables]); + + const conn = connections.find((c) => c.id === explorerId); + + return ( +
+ {connections.length === 0 ? ( +

Save a connection to browse its tables.

+ ) : ( + <> +
+ + +
+ + {entry?.status === 'error' && ( +

{entry.error}

+ )} + {entry?.status === 'loading' && tables.length === 0 && ( +

+ Loading… +

+ )} + {entry?.status === 'ready' && tables.length === 0 && ( +

No tables or views in this schema.

+ )} + +
+ {tables.map((t) => ( + setExpanded((m) => ({ ...m, [t.name]: !m[t.name] }))} + dialect={conn?.dialect ?? 'sql'} + /> + ))} +
+ + )} +
+ ); +}; + +const TableNode: React.FC<{ + table: TableSchema; + open: boolean; + onToggle: () => void; + dialect: string; +}> = ({ table, open, onToggle, dialect }) => { + const meta = TYPE_META[table.objectType] ?? TYPE_META.TABLE; + const insertName = quoteIfNeeded(table.name, dialect); + + return ( +
+
+ + +
+ {open && ( +
    + {(table.columns ?? []).map((col) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +}; + +function quoteIfNeeded(name: string, dialect: string): string { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return name; + const d = dialect.toLowerCase(); + if (d === 'mysql' || d === 'mariadb' || d === 'clickhouse') { + return '`' + name.replace(/`/g, '``') + '`'; + } + if (d === 'sqlserver') { + return '[' + name.replace(/]/g, ']]') + ']'; + } + return '"' + name.replace(/"/g, '""') + '"'; +} diff --git a/apps/web/src/frontend/components/sql-editor/SqlSidebarSection.tsx b/apps/web/src/frontend/components/sql-editor/SqlSidebarSection.tsx new file mode 100644 index 0000000..315e62d --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/SqlSidebarSection.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useState } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +const STORAGE_KEY = 'foxschema-sql-sidebar-sections'; + +export type SidebarSectionId = 'destinations' | 'bookmarks' | 'schema'; + +const DEFAULT_OPEN: Record = { + destinations: true, + bookmarks: true, + schema: true, +}; + +function loadOpen(): Record { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULT_OPEN }; + const parsed = JSON.parse(raw) as Partial>; + return { + destinations: parsed.destinations ?? true, + bookmarks: parsed.bookmarks ?? true, + schema: parsed.schema ?? true, + }; + } catch { + return { ...DEFAULT_OPEN }; + } +} + +/** Persist which SQL-editor sidebar sections are expanded. */ +export function useSidebarSectionsOpen(): [ + Record, + (id: SidebarSectionId) => void, +] { + const [open, setOpen] = useState(loadOpen); + + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(open)); + } catch { + /* ignore quota */ + } + }, [open]); + + const toggle = (id: SidebarSectionId) => { + setOpen((prev) => ({ ...prev, [id]: !prev[id] })); + }; + + return [open, toggle]; +} + +/** + * Collapsible block for the SQL Editor left sidebar (Destinations / Bookmarks / Schema). + */ +export const SqlSidebarSection: React.FC<{ + id: SidebarSectionId; + title: string; + icon: React.ReactNode; + open: boolean; + onToggle: () => void; + /** Extra controls on the header row (e.g. Bookmark Save). */ + actions?: React.ReactNode; + /** When expanded and this is the flex-growing section. */ + grow?: boolean; + children: React.ReactNode; +}> = ({ id, title, icon, open, onToggle, actions, grow, children }) => { + return ( +
+
+ + {actions &&
{actions}
} +
+ {open && ( +
{children}
+ )} +
+ ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx b/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx new file mode 100644 index 0000000..4531d2a --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx @@ -0,0 +1,170 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + checkStatement, + dmlLacksWhere, + isMutatingDmlStatement, + statementVerb, + type SplitStatement, +} from '../../lib/sql-splitter'; +import { useSqlEditorStore } from '../../store/useSqlEditorStore'; + +interface Props { + statements: SplitStatement[]; + checked: number[]; + onToggle: (index: number) => void; + onReveal: (stmt: SplitStatement) => void; +} + +const STORAGE_KEY = 'foxschema-sql-statement-strip-h'; +const ROW_PX = 26; +const PAD_PX = 14; +const MIN_ROWS = 1; +const MAX_ROWS = 12; +const DEFAULT_ROWS = 2; + +const defaultHeight = () => ROW_PX * DEFAULT_ROWS + PAD_PX; +const minHeight = () => ROW_PX * MIN_ROWS + PAD_PX; +const maxHeight = () => ROW_PX * MAX_ROWS + PAD_PX; + +function loadHeight(): number { + try { + const n = Number(localStorage.getItem(STORAGE_KEY)); + if (Number.isFinite(n) && n >= minHeight()) { + return Math.min(maxHeight(), n); + } + } catch { + /* ignore */ + } + return defaultHeight(); +} + +const preview = (text: string, max = 120): string => { + const compact = text.replace(/\s+/g, ' ').trim(); + return compact.length > max ? compact.slice(0, max) + '…' : compact; +}; + +const DML_BADGE: Record = { + update: 'UPD', + delete: 'DEL', + merge: 'MRG', +}; + +/** + * Per-statement run strip between the editor and results. Resizable; defaults + * to ~2 statement rows. Safe mode badges UPDATE / DELETE / MERGE. + */ +export const StatementStrip: React.FC = ({ statements, checked, onToggle, onReveal }) => { + const [height, setHeight] = useState(loadHeight); + const safeMode = useSqlEditorStore((s) => s.safeMode); + + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, String(height)); + } catch { + /* ignore */ + } + }, [height]); + + const startResize = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const startY = e.clientY; + const startH = height; + const onMove = (ev: MouseEvent) => { + const next = Math.min(maxHeight(), Math.max(minHeight(), startH + (ev.clientY - startY))); + setHeight(next); + }; + const onUp = () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, + [height] + ); + + if (statements.length === 0) return null; + + return ( +
+
+ {statements.map((stmt, i) => { + const status = checkStatement(stmt); + const ok = status.level === 'ok'; + const isChecked = checked.includes(i); + const verb = statementVerb(stmt.text); + const dmlBadge = + safeMode && verb && isMutatingDmlStatement(stmt.text) ? DML_BADGE[verb] : null; + const noWhere = dmlBadge ? dmlLacksWhere(stmt.text) : false; + return ( +
+ onToggle(i)} + className="w-3 h-3 accent-cyan-500 cursor-pointer shrink-0 mt-0.5" + title={ + checked.length === 0 + ? 'None checked → Run uses the first statement' + : 'Include this statement when running' + } + aria-label={`Statement ${i + 1}`} + /> + +
+ ); + })} +
+
+
+ ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/WriteConfirmDialog.tsx b/apps/web/src/frontend/components/sql-editor/WriteConfirmDialog.tsx new file mode 100644 index 0000000..d7cf80f --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/WriteConfirmDialog.tsx @@ -0,0 +1,191 @@ +import React, { useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { AlertCircle, Play, Info, ShieldAlert } from 'lucide-react'; +import type { ReadonlyWriteTarget } from '../../store/useSqlEditorStore'; +import { + dmlLacksWhere, + isMutatingDmlStatement, + statementVerb, +} from '../../lib/sql-splitter'; + +interface Props { + writeStatements: string[]; + credentialCount: number; + /** sqlite / clickhouse targets that cannot execute writes. */ + readonlyTargets?: ReadonlyWriteTarget[]; + onCancel: () => void; + onConfirm: () => void; +} + +const DML_LABEL: Record = { + update: 'UPDATE', + delete: 'DELETE', + merge: 'MERGE', +}; + +/** + * Safe-mode confirmation before writes. Calls out UPDATE / DELETE / MERGE and + * flags UPDATE/DELETE with no WHERE clause. + */ +export const WriteConfirmDialog: React.FC = ({ + writeStatements, + credentialCount, + readonlyTargets = [], + onCancel, + onConfirm, +}) => { + const mutating = useMemo( + () => writeStatements.filter((s) => isMutatingDmlStatement(s)), + [writeStatements] + ); + const missingWhere = useMemo( + () => mutating.filter((s) => dmlLacksWhere(s)), + [mutating] + ); + const verbs = useMemo(() => { + const set = new Set(); + for (const s of mutating) { + const v = statementVerb(s); + if (v && DML_LABEL[v]) set.add(DML_LABEL[v]); + } + return [...set]; + }, [mutating]); + + const title = + mutating.length > 0 + ? `Safe mode: run ${verbs.join(' / ') || 'mutating'} statements?` + : 'Run write statements?'; + + return createPortal( +
+
e.stopPropagation()} + > +
+ +

{title}

+
+
+ {mutating.length > 0 && ( +
+ +
+

+ {mutating.length} UPDATE / DELETE / MERGE statement + {mutating.length === 1 ? '' : 's'} will modify data +

+

+ These run against{' '} + {credentialCount} database + {credentialCount === 1 ? '' : 's'}. Changes are not rolled back automatically. +

+
+
+ )} + + {missingWhere.length > 0 && ( +
+ +
+

+ {missingWhere.length} statement{missingWhere.length === 1 ? '' : 's'} without a + WHERE clause +

+

+ UPDATE or DELETE with no WHERE can affect every row in the table. Add a WHERE + filter unless that is intentional. +

+
+
+ )} + + {mutating.length === 0 && ( +

+ This run includes{' '} + {writeStatements.length} statement + {writeStatements.length === 1 ? '' : 's'} that modif + {writeStatements.length === 1 ? 'ies' : 'y'} data or schema, executing against{' '} + {credentialCount} database + {credentialCount === 1 ? '' : 's'}. +

+ )} + + {readonlyTargets.length > 0 && ( +
+ +
+

+ Read-only adapters will reject these writes +

+

+ {readonlyTargets.map((t) => `${t.name} [${t.dialect}]`).join(', ')} — SQLite and + ClickHouse connections in FoxSchema only support SELECT. Those cells will show a + friendly error; other dialects still run the writes. +

+
+
+ )} + +
+ {writeStatements.map((s, i) => { + const verb = statementVerb(s); + const isDml = verb !== null && Boolean(DML_LABEL[verb]); + const noWhere = dmlLacksWhere(s); + return ( +
+ {isDml && ( + + {DML_LABEL[verb!]} + {noWhere ? ' · no WHERE' : ''} + + )} + {s.replace(/\s+/g, ' ')} +
+ ); + })} +
+
+
+ + +
+
+
, + document.body + ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/completion.ts b/apps/web/src/frontend/components/sql-editor/completion.ts new file mode 100644 index 0000000..13dc092 --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/completion.ts @@ -0,0 +1,206 @@ +import type * as Monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import { extractTableAliases } from '../../lib/sql-splitter'; +import { getCompletionContext } from './sqlEditorBridge'; + +const LIGHT_KEYWORDS = [ + 'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'FULL', + 'ON', 'AND', 'OR', 'NOT', 'IN', 'EXISTS', 'BETWEEN', 'LIKE', 'IS', 'NULL', + 'GROUP', 'BY', 'ORDER', 'ASC', 'DESC', 'LIMIT', 'OFFSET', 'HAVING', 'UNION', + 'ALL', 'DISTINCT', 'AS', 'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', + 'CREATE', 'ALTER', 'DROP', 'TABLE', 'VIEW', 'INDEX', 'WITH', 'CASE', 'WHEN', + 'THEN', 'ELSE', 'END', +]; + +const LANG_IDS = ['sql', 'pgsql', 'mysql'] as const; + +let registered = false; + +/** + * Register completion providers once per Monaco language id. Suggestions read + * the active tab's SQL + schemaCache via {@link getCompletionContext} so we + * never re-register (duplicate-provider leak) on remount. + * + * Alias support: `alias.` / `alias.col…` resolves via {@link extractTableAliases} + * to the underlying table's columns. Aliases themselves are also suggested. + */ +export function ensureSqlCompletions(monaco: typeof Monaco): void { + if (registered) return; + registered = true; + + const provider: Monaco.languages.CompletionItemProvider = { + triggerCharacters: ['.'], + provideCompletionItems(model, position) { + const word = model.getWordUntilPosition(position); + const range: Monaco.IRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const linePrefix = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + const { sql, schemas } = getCompletionContext(); + // Prefer the live model text — context sql can lag one keystroke behind. + const aliases = extractTableAliases(model.getValue() || sql); + const tableIndex = buildTableIndex(schemas); + const prefix = (word.word || '').toLowerCase(); + + // `alias.` or `alias.partial` — keep matching after the user types past the dot. + const dot = /([A-Za-z_][\w$]*)\.([A-Za-z_\d$]*)$/.exec(linePrefix); + if (dot) { + const ref = dot[1]!.toLowerCase(); + const partial = (dot[2] ?? '').toLowerCase(); + const tableName = aliases[ref] ?? ref; + const cols = columnsForTable(tableIndex, tableName).filter( + (name) => !partial || name.toLowerCase().startsWith(partial) + ); + // Replace only the column fragment after the dot (not the alias). + const colRange: Monaco.IRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: position.column - partial.length, + endColumn: position.column, + }; + if (cols.length === 0 && aliases[ref]) { + // Alias resolved but schema has no columns yet — surface why. + return { + suggestions: [ + { + label: `(no columns for ${tableName})`, + kind: monaco.languages.CompletionItemKind.Text, + insertText: '', + detail: schemas.length === 0 + ? 'Load a destination schema (check a server)' + : 'Table not in loaded schema', + range: colRange, + }, + ], + }; + } + return { + suggestions: cols.map((name) => ({ + label: name, + kind: monaco.languages.CompletionItemKind.Field, + insertText: name, + detail: `column · ${tableName}`, + range: colRange, + })), + }; + } + + const seen = new Set(); + const suggestions: Monaco.languages.CompletionItem[] = []; + + // Aliases first so `u` suggests the alias before unrelated keywords. + for (const [alias, table] of Object.entries(aliases)) { + if (alias === table.toLowerCase()) continue; // skip bare table self-map + if (table.toLowerCase().endsWith('.' + alias)) continue; + const bare = table.toLowerCase().includes('.') + ? table.toLowerCase().slice(table.toLowerCase().lastIndexOf('.') + 1) + : table.toLowerCase(); + if (alias === bare) continue; + if (prefix && !alias.startsWith(prefix)) continue; + if (seen.has(alias)) continue; + seen.add(alias); + suggestions.push({ + label: alias, + kind: monaco.languages.CompletionItemKind.Variable, + insertText: alias, + detail: `alias → ${table}`, + sortText: `0_${alias}`, + range, + }); + } + + for (const src of schemas) { + for (const t of src.tables) { + if (t.objectType !== 'TABLE' && t.objectType !== 'VIEW' && t.objectType !== 'MQT') continue; + const key = t.name.toLowerCase(); + const bare = key.includes('.') ? key.slice(key.lastIndexOf('.') + 1) : key; + if (prefix && !key.startsWith(prefix) && !bare.startsWith(prefix)) continue; + if (seen.has(key) || seen.has(bare)) continue; + seen.add(key); + seen.add(bare); + suggestions.push({ + label: t.name, + kind: monaco.languages.CompletionItemKind.Class, + insertText: t.name, + detail: t.objectType.toLowerCase(), + sortText: `1_${t.name}`, + range, + }); + } + } + + for (const kw of LIGHT_KEYWORDS) { + const low = kw.toLowerCase(); + if (seen.has(low)) continue; + if (prefix && !low.startsWith(prefix)) continue; + suggestions.push({ + label: kw, + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: kw, + detail: 'keyword', + sortText: `2_${kw}`, + range, + }); + } + + return { suggestions }; + }, + }; + + for (const lang of LANG_IDS) { + monaco.languages.registerCompletionItemProvider(lang, provider); + } +} + +type TableIndex = Map>; // lower table name → column names + +function buildTableIndex(schemas: ReturnType['schemas']): TableIndex { + const index: TableIndex = new Map(); + for (const src of schemas) { + for (const t of src.tables) { + if (t.objectType !== 'TABLE' && t.objectType !== 'VIEW' && t.objectType !== 'MQT') continue; + const cols = new Set((t.columns ?? []).map((c: { name: string }) => c.name)); + const full = t.name; + const lower = full.toLowerCase(); + const bare = lower.includes('.') ? lower.slice(lower.lastIndexOf('.') + 1) : lower; + mergeCols(index, lower, cols); + mergeCols(index, bare, cols); + } + } + return index; +} + +function mergeCols(index: TableIndex, key: string, cols: Set): void { + const existing = index.get(key); + if (!existing) { + index.set(key, new Set(cols)); + return; + } + for (const c of cols) existing.add(c); +} + +function columnsForTable(index: TableIndex, tableName: string): string[] { + const lower = tableName.toLowerCase(); + const bare = lower.includes('.') ? lower.slice(lower.lastIndexOf('.') + 1) : lower; + let set = index.get(lower) ?? index.get(bare); + if (!set) { + // Schema may store SCHEMA.TABLE while the SQL used a bare/keyword table name. + for (const [key, cols] of index) { + if (key === bare || key.endsWith('.' + bare)) { + set = cols; + break; + } + } + } + if (!set) return []; + return [...set].sort((a, b) => a.localeCompare(b)); +} diff --git a/apps/web/src/frontend/components/sql-editor/sqlEditorBridge.ts b/apps/web/src/frontend/components/sql-editor/sqlEditorBridge.ts new file mode 100644 index 0000000..097a8b0 --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/sqlEditorBridge.ts @@ -0,0 +1,43 @@ +import type { TableSchema } from '../../lib/types'; + +/** One connection's cached tables for autocomplete / explorer. */ +export interface SchemaCacheEntry { + status: 'idle' | 'loading' | 'ready' | 'error'; + tables?: TableSchema[]; + error?: string; +} + +export interface CompletionSchemaSource { + connectionId: string; + tables: TableSchema[]; +} + +export interface CompletionContext { + sql: string; + /** Schemas for checked credentials that have been loaded. */ + schemas: CompletionSchemaSource[]; +} + +type ContextGetter = () => CompletionContext; +type InsertHandler = (text: string) => void; + +let contextGetter: ContextGetter = () => ({ sql: '', schemas: [] }); +let insertHandler: InsertHandler | null = null; + +/** Wired once from SqlEditorView so the completion provider stays leak-free. */ +export function setCompletionContextGetter(fn: ContextGetter): void { + contextGetter = fn; +} + +export function getCompletionContext(): CompletionContext { + return contextGetter(); +} + +/** Wired from SqlEditorPane onMount / disposed on unmount. */ +export function setSqlInsertHandler(fn: InsertHandler | null): void { + insertHandler = fn; +} + +export function insertAtCursor(text: string): void { + insertHandler?.(text); +} diff --git a/apps/web/src/frontend/lib/sql-splitter.ts b/apps/web/src/frontend/lib/sql-splitter.ts new file mode 100644 index 0000000..f9e430b --- /dev/null +++ b/apps/web/src/frontend/lib/sql-splitter.ts @@ -0,0 +1,5 @@ +// 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'; diff --git a/apps/web/src/frontend/store/sqlEditorTabLogic.test.ts b/apps/web/src/frontend/store/sqlEditorTabLogic.test.ts new file mode 100644 index 0000000..b3c5492 --- /dev/null +++ b/apps/web/src/frontend/store/sqlEditorTabLogic.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { + addTab, + checkedAfterSqlChange, + closeTab, + createTab, + effectiveConnectionIds, + hydrateTabs, + nextTabTitle, + persistableTabs, + statementsToRun, + toggleStatementCheck, +} from './sqlEditorTabLogic'; + +describe('sqlEditorTabLogic', () => { + it('addTab appends Query N and activates it', () => { + const t1 = createTab({ title: 'Query 1' }); + const next = addTab([t1]); + expect(next.tabs).toHaveLength(2); + expect(next.tabs[1]!.title).toBe('Query 2'); + expect(next.activeTabId).toBe(next.tabs[1]!.id); + }); + + it('nextTabTitle skips ahead of existing Query numbers', () => { + expect(nextTabTitle([createTab({ title: 'Query 1' }), createTab({ title: 'scratch' })])).toBe( + 'Query 2' + ); + expect(nextTabTitle([createTab({ title: 'Query 5' })])).toBe('Query 6'); + }); + + it('closeTab activates a neighbor and refuses an empty tab list', () => { + const a = createTab({ title: 'Query 1' }); + const b = createTab({ title: 'Query 2' }); + const closed = closeTab([a, b], a.id, a.id); + expect(closed.tabs.map((t) => t.id)).toEqual([b.id]); + expect(closed.activeTabId).toBe(b.id); + + const last = closeTab([b], b.id, b.id); + expect(last.tabs).toHaveLength(1); + expect(last.tabs[0]!.id).not.toBe(b.id); + expect(last.tabs[0]!.sql).toBe(''); + }); + + it('checkedAfterSqlChange resets when statement count changes', () => { + expect(checkedAfterSqlChange('SELECT 1;', 'SELECT 1; SELECT 2;', [0])).toEqual([]); + expect(checkedAfterSqlChange('SELECT 1; SELECT 2;', 'SELECT 1; SELECT 2;', [0, 1])).toEqual([ + 0, 1, + ]); + expect(checkedAfterSqlChange('SELECT 1; SELECT 2;', 'SELECT 1; SELECT 2;', [0, 9])).toEqual([0]); + }); + + it('statementsToRun uses first statement when none checked', () => { + const sql = 'SELECT 1; SELECT 2; SELECT 3;'; + expect(statementsToRun(sql, [])).toEqual(['SELECT 1;']); + expect(statementsToRun(sql, [2, 0])).toEqual(['SELECT 1;', 'SELECT 3;']); + expect(statementsToRun(sql, [99])).toEqual(['SELECT 1;']); + expect(statementsToRun('', [])).toEqual([]); + }); + + it('toggleStatementCheck adds/removes sorted', () => { + expect(toggleStatementCheck([0], 2)).toEqual([0, 2]); + expect(toggleStatementCheck([0, 2], 0)).toEqual([2]); + }); + + it('persistableTabs drops checkedStatements; hydrate restores empty checks', () => { + const tab = createTab({ + title: 'Q', + sql: 'SELECT 1', + checkedStatements: [0, 1], + layout: 'sideBySide', + }); + const persisted = persistableTabs([tab]); + expect(persisted[0]).not.toHaveProperty('checkedStatements'); + expect(persisted[0]!.layout).toBe('sideBySide'); + + const hydrated = hydrateTabs(persisted as any); + expect(hydrated[0]!.checkedStatements).toEqual([]); + expect(hydrated[0]!.layout).toBe('sideBySide'); + }); + + it('effectiveConnectionIds respects shareDestinations', () => { + const tab = createTab({ selectedConnectionIds: ['a'] }); + expect(effectiveConnectionIds(tab, false, ['b'])).toEqual(['a']); + expect(effectiveConnectionIds(tab, true, ['b', 'c'])).toEqual(['b', 'c']); + }); +}); diff --git a/apps/web/src/frontend/store/sqlEditorTabLogic.ts b/apps/web/src/frontend/store/sqlEditorTabLogic.ts new file mode 100644 index 0000000..e22f9b7 --- /dev/null +++ b/apps/web/src/frontend/store/sqlEditorTabLogic.ts @@ -0,0 +1,148 @@ +import { splitSqlStatements } from '../lib/sql-splitter'; + +export type ResultsLayout = 'byCredential' | 'sideBySide'; + +export interface SqlTab { + id: string; + title: string; + sql: string; + selectedConnectionIds: string[]; + /** + * Indices into the current split of `sql`. Empty means "run the first + * statement only" (agreed default). Not persisted. + */ + checkedStatements: number[]; + layout: ResultsLayout; + /** When set, renaming the tab also renames this bookmark (and vice versa). */ + bookmarkId?: string; +} + +export function newTabId(): string { + return typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function createTab(partial?: Partial): SqlTab { + return { + id: partial?.id ?? newTabId(), + title: partial?.title ?? 'Query 1', + sql: partial?.sql ?? '', + selectedConnectionIds: partial?.selectedConnectionIds ?? [], + checkedStatements: partial?.checkedStatements ?? [], + layout: partial?.layout ?? 'byCredential', + bookmarkId: partial?.bookmarkId, + }; +} + +/** Next title like Query 2, Query 3 — based on existing Query N titles. */ +export function nextTabTitle(tabs: SqlTab[]): string { + let max = 0; + for (const t of tabs) { + const m = /^Query\s+(\d+)$/i.exec(t.title.trim()); + if (m) max = Math.max(max, Number(m[1])); + } + return `Query ${max + 1}`; +} + +export function addTab(tabs: SqlTab[]): { tabs: SqlTab[]; activeTabId: string } { + const tab = createTab({ title: nextTabTitle(tabs) }); + return { tabs: [...tabs, tab], activeTabId: tab.id }; +} + +/** + * Close a tab. Refuses to leave zero tabs (replaces the last with a fresh empty + * one). Activates a neighbor when the closed tab was active. + */ +export function closeTab( + tabs: SqlTab[], + activeTabId: string, + id: string +): { tabs: SqlTab[]; activeTabId: string } { + if (tabs.length <= 1) { + const alone = createTab({ title: 'Query 1' }); + return { tabs: [alone], activeTabId: alone.id }; + } + const idx = tabs.findIndex((t) => t.id === id); + if (idx < 0) return { tabs, activeTabId }; + const nextTabs = tabs.filter((t) => t.id !== id); + if (activeTabId !== id) return { tabs: nextTabs, activeTabId }; + const neighbor = nextTabs[Math.min(idx, nextTabs.length - 1)]!; + return { tabs: nextTabs, activeTabId: neighbor.id }; +} + +/** + * When the statement count changes, clear checks so we don't keep stale + * indices. Same count → keep checks, pruning any out-of-range index. + */ +export function checkedAfterSqlChange( + prevSql: string, + nextSql: string, + prevChecked: number[] +): number[] { + const prevCount = splitSqlStatements(prevSql).length; + const nextCount = splitSqlStatements(nextSql).length; + if (prevCount !== nextCount) return []; + return prevChecked.filter((i) => i >= 0 && i < nextCount); +} + +/** + * Statements to send on Run. Empty check set → first statement only. + * Checked indices are sorted and de-duplicated. + */ +export function statementsToRun(sql: string, checkedStatements: number[]): string[] { + const all = splitSqlStatements(sql); + if (all.length === 0) return []; + if (checkedStatements.length === 0) return [all[0]!.text]; + const uniq = [...new Set(checkedStatements)] + .filter((i) => i >= 0 && i < all.length) + .sort((a, b) => a - b); + if (uniq.length === 0) return [all[0]!.text]; + return uniq.map((i) => all[i]!.text); +} + +export function toggleStatementCheck(checked: number[], index: number): number[] { + return checked.includes(index) ? checked.filter((i) => i !== index) : [...checked, index].sort((a, b) => a - b); +} + +/** + * Destination servers for a tab: either the shared list (all queries) or this + * tab's own selection. + */ +export function effectiveConnectionIds( + tab: SqlTab, + shareDestinations: boolean, + sharedConnectionIds: string[] +): string[] { + return shareDestinations ? sharedConnectionIds : tab.selectedConnectionIds; +} + +/** Persistable tab slice (no checkedStatements / results). */ +export function persistableTabs(tabs: SqlTab[]): Array> { + return tabs.map(({ id, title, sql, selectedConnectionIds, layout, bookmarkId }) => ({ + id, + title, + sql, + selectedConnectionIds, + layout, + ...(bookmarkId ? { bookmarkId } : {}), + })); +} + +/** Rehydrate persisted tabs — restore empty checkedStatements. */ +export function hydrateTabs( + raw: Array & Pick> +): SqlTab[] { + if (!raw.length) return [createTab({ title: 'Query 1' })]; + return raw.map((t, i) => + createTab({ + id: t.id, + title: t.title ?? `Query ${i + 1}`, + sql: t.sql ?? '', + selectedConnectionIds: Array.isArray(t.selectedConnectionIds) ? t.selectedConnectionIds : [], + layout: t.layout === 'sideBySide' ? 'sideBySide' : 'byCredential', + checkedStatements: [], + bookmarkId: typeof t.bookmarkId === 'string' ? t.bookmarkId : undefined, + }) + ); +} diff --git a/apps/web/src/frontend/store/uiStore.ts b/apps/web/src/frontend/store/uiStore.ts index eecebb8..219da50 100644 --- a/apps/web/src/frontend/store/uiStore.ts +++ b/apps/web/src/frontend/store/uiStore.ts @@ -157,6 +157,9 @@ function applyToDocument(themeMode: ThemeMode, tone: ToneId, fontSize: FontSize, return mode; } +/** Top-level workspace views: schema sync (compare/browse) vs the SQL Editor. */ +export type ActiveView = 'sync' | 'sqlEditor'; + interface UiState { themeMode: ThemeMode; tone: ToneId; @@ -164,7 +167,10 @@ interface UiState { accent: AccentId; /** Resolved (system → dark|light) — for Monaco theme selection. */ resolvedMode: 'dark' | 'light'; + /** Which workspace view is showing (persisted; purely local, not synced to server prefs). */ + activeView: ActiveView; + setActiveView: (view: ActiveView) => void; setThemeMode: (mode: ThemeMode) => void; setTone: (tone: ToneId) => void; setFontSize: (size: FontSize) => void; @@ -207,7 +213,9 @@ export const useUiStore = create()( return { ...DEFAULTS, resolvedMode: 'dark', + activeView: 'sync' as ActiveView, + setActiveView: (activeView) => set({ activeView }), setThemeMode: (themeMode) => update({ themeMode }), setTone: (tone) => update({ tone }), setFontSize: (fontSize) => update({ fontSize }), diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts new file mode 100644 index 0000000..5f1d5ed --- /dev/null +++ b/apps/web/src/frontend/store/useSqlEditorStore.ts @@ -0,0 +1,753 @@ +import { create } from 'zustand'; +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 { useSyncStore } from './useSyncStore'; +import type { SchemaCacheEntry } from '../components/sql-editor/sqlEditorBridge'; +import { + addTab as addTabLogic, + checkedAfterSqlChange, + closeTab as closeTabLogic, + createTab, + effectiveConnectionIds, + hydrateTabs, + newTabId, + persistableTabs, + statementsToRun, + toggleStatementCheck, + type ResultsLayout, + type SqlTab, +} from './sqlEditorTabLogic'; + +/** Dialects whose adapters are SELECT-only — writes fail with a friendly error. */ +const READONLY_DIALECTS = new Set(['sqlite', 'clickhouse']); + +/** Saved SQL script bookmark (persisted). */ +export interface SqlBookmark { + id: string; + title: string; + sql: string; + selectedConnectionIds: string[]; + updatedAt: number; +} + +/** One checked credential's execution state for a tab's last run. */ +export interface CredentialRun { + connectionId: string; + name: string; + dialect: string; + status: 'running' | 'done' | 'error'; + /** Connection-level failure (unreachable, bad password) — statement results absent. */ + error?: string; + /** Per-statement outcomes, in statement order. */ + results?: SqlStatementResult[]; +} + +export interface TabResults { + ranStatements: string[]; + runs: CredentialRun[]; +} + +/** Password prompt for a connection saved without one (session-only). */ +export interface PendingPasswordPrompt { + id: string; + name: string; + /** When true, resume execute() after the password is submitted. */ + resumeExecute: boolean; + /** When set, resume only refreshes these credentials. */ + connectionIds?: string[]; +} + +export interface ReadonlyWriteTarget { + name: string; + dialect: string; +} + +interface SqlEditorState { + tabs: SqlTab[]; + activeTabId: string; + /** Results keyed by tab id — never persisted. */ + resultsByTab: Record; + /** Session-only passwords. NEVER persisted. */ + sessionPasswords: Record; + /** Loaded schemas by connection id — never persisted. */ + schemaCache: Record; + /** Tab currently executing (null when idle). */ + runningTabId: string | null; + pendingWriteConfirm: { + tabId: string; + writeStatements: string[]; + credentialCount: number; + /** Checked credentials whose dialect cannot execute writes. */ + readonlyTargets: ReadonlyWriteTarget[]; + /** When set, confirm resumes execute for only these credentials. */ + connectionIds?: string[]; + } | null; + pendingPassword: PendingPasswordPrompt | null; + maxRows: number; + /** + * When true, confirm before UPDATE / DELETE / MERGE (and other writes). + * Default on — turn off only when you intentionally want unguarded runs. + */ + safeMode: boolean; + /** + * When true, every query tab shares `sharedConnectionIds`. + * When false, each tab keeps its own `selectedConnectionIds`. + */ + shareDestinations: boolean; + sharedConnectionIds: string[]; + /** Named saved scripts — persisted. */ + bookmarks: SqlBookmark[]; + + activeTab: () => SqlTab; + /** Destination server ids for the active tab (respects shareDestinations). */ + activeConnectionIds: () => string[]; + setSql: (sql: string) => void; + toggleConnection: (id: string) => void; + setShareDestinations: (share: boolean) => void; + setSafeMode: (on: boolean) => void; + toggleStatement: (index: number) => void; + setLayout: (layout: ResultsLayout) => void; + addTab: () => void; + closeTab: (id: string) => void; + setActiveTab: (id: string) => void; + renameTab: (id: string, title: string) => void; + submitSessionPassword: (password: string) => void; + cancelPasswordPrompt: () => void; + setMaxRows: (n: number) => void; + ensureSchema: (connectionId: string, opts?: { force?: boolean }) => Promise; + /** Re-run SQL. Pass `connectionIds` to refresh only those credentials (keeps other panes). */ + execute: (opts?: { confirmedWrites?: boolean; connectionIds?: string[] }) => Promise; + cancelWriteConfirm: () => void; + clearResults: () => void; + saveBookmark: (opts?: { title?: string }) => void; + openBookmark: (id: string) => void; + renameBookmark: (id: string, title: string) => void; + deleteBookmark: (id: string) => void; +} + +const firstTab = createTab({ title: 'Query 1' }); + +export type { ResultsLayout, SqlTab }; + +export const useSqlEditorStore = create()( + persist( + (set, get) => ({ + tabs: [firstTab], + activeTabId: firstTab.id, + resultsByTab: {}, + sessionPasswords: {}, + schemaCache: {}, + runningTabId: null, + pendingWriteConfirm: null, + pendingPassword: null, + maxRows: 200, + safeMode: true, + shareDestinations: true, + sharedConnectionIds: [], + bookmarks: [], + + activeTab: () => { + const { tabs, activeTabId } = get(); + return tabs.find((t) => t.id === activeTabId) ?? tabs[0]!; + }, + + activeConnectionIds: () => { + const { tabs, activeTabId, shareDestinations, sharedConnectionIds } = get(); + const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]!; + return effectiveConnectionIds(tab, shareDestinations, sharedConnectionIds); + }, + + setSql: (sql) => { + const { tabs, activeTabId } = get(); + set({ + tabs: tabs.map((t) => { + if (t.id !== activeTabId) return t; + return { + ...t, + sql, + checkedStatements: checkedAfterSqlChange(t.sql, sql, t.checkedStatements), + }; + }), + }); + }, + + setShareDestinations: (share) => { + const { tabs, activeTabId, sharedConnectionIds } = get(); + const active = tabs.find((t) => t.id === activeTabId) ?? tabs[0]!; + if (share) { + // Adopt the active tab's destinations as the shared set. + set({ + shareDestinations: true, + sharedConnectionIds: [...active.selectedConnectionIds], + }); + return; + } + // Push shared destinations onto every tab so nothing is lost. + set({ + shareDestinations: false, + tabs: tabs.map((t) => ({ + ...t, + selectedConnectionIds: [...sharedConnectionIds], + })), + }); + }, + + toggleConnection: (id) => { + const { tabs, activeTabId, sessionPasswords, shareDestinations, sharedConnectionIds } = + get(); + const tab = tabs.find((t) => t.id === activeTabId); + if (!tab) return; + + const current = shareDestinations ? sharedConnectionIds : tab.selectedConnectionIds; + const selected = current.includes(id); + + const applyIds = (ids: string[]) => { + if (shareDestinations) { + set({ sharedConnectionIds: ids }); + } else { + set({ + tabs: tabs.map((t) => + t.id === activeTabId ? { ...t, selectedConnectionIds: ids } : t + ), + }); + } + }; + + if (selected) { + applyIds(current.filter((x) => x !== id)); + return; + } + + const conn = useSyncStore.getState().connections.find((c) => c.id === id); + if (conn && !conn.hasPassword && !sessionPasswords[id]) { + set({ + pendingPassword: { + id, + name: conn.name || conn.dialect, + resumeExecute: false, + }, + }); + return; + } + + applyIds([...current, id]); + void get().ensureSchema(id); + }, + + toggleStatement: (index) => { + const { tabs, activeTabId } = get(); + set({ + tabs: tabs.map((t) => + t.id === activeTabId + ? { ...t, checkedStatements: toggleStatementCheck(t.checkedStatements, index) } + : t + ), + }); + }, + + setLayout: (layout) => { + const { tabs, activeTabId } = get(); + set({ + tabs: tabs.map((t) => (t.id === activeTabId ? { ...t, layout } : t)), + }); + }, + + addTab: () => { + const next = addTabLogic(get().tabs); + set(next); + }, + + closeTab: (id) => { + const { tabs, activeTabId, resultsByTab } = get(); + const next = closeTabLogic(tabs, activeTabId, id); + const { [id]: _removed, ...restResults } = resultsByTab; + set({ ...next, resultsByTab: restResults }); + }, + + setActiveTab: (id) => { + if (get().tabs.some((t) => t.id === id)) set({ activeTabId: id }); + }, + + renameTab: (id, title) => { + const trimmed = title.trim() || 'Query'; + const { tabs, bookmarks } = get(); + const tab = tabs.find((t) => t.id === id); + if (!tab) return; + + // Prefer an explicit link; otherwise match the bookmark we saved from this SQL. + let linkId = tab.bookmarkId; + if (!linkId) { + const byTitleAndSql = bookmarks.find( + (b) => b.title === tab.title && b.sql === tab.sql + ); + const bySqlAlone = bookmarks.filter((b) => b.sql === tab.sql); + linkId = + byTitleAndSql?.id ?? + (bySqlAlone.length === 1 ? bySqlAlone[0]!.id : undefined); + } + + set({ + tabs: tabs.map((t) => + t.id === id + ? { ...t, title: trimmed, ...(linkId ? { bookmarkId: linkId } : {}) } + : t + ), + bookmarks: linkId + ? bookmarks.map((b) => + b.id === linkId ? { ...b, title: trimmed, updatedAt: Date.now() } : b + ) + : bookmarks, + }); + }, + + submitSessionPassword: (password) => { + const { pendingPassword, tabs, activeTabId, shareDestinations, sharedConnectionIds } = + get(); + if (!pendingPassword) return; + const { id, resumeExecute, connectionIds } = pendingPassword; + + let nextShared = sharedConnectionIds; + let nextTabs = tabs; + if (shareDestinations) { + if (!sharedConnectionIds.includes(id)) { + nextShared = [...sharedConnectionIds, id]; + } + } else { + nextTabs = tabs.map((t) => { + if (t.id !== activeTabId) return t; + if (t.selectedConnectionIds.includes(id)) return t; + return { ...t, selectedConnectionIds: [...t.selectedConnectionIds, id] }; + }); + } + + set({ + sessionPasswords: { ...get().sessionPasswords, [id]: password }, + tabs: nextTabs, + sharedConnectionIds: nextShared, + pendingPassword: null, + }); + if (resumeExecute) { + void get().execute(connectionIds?.length ? { connectionIds } : undefined); + } else { + void get().ensureSchema(id, { force: true }); + } + }, + + cancelPasswordPrompt: () => set({ pendingPassword: null }), + + setMaxRows: (n) => set({ maxRows: Math.min(5000, Math.max(1, Math.floor(n) || 200)) }), + + setSafeMode: (on) => set({ safeMode: on }), + + ensureSchema: async (connectionId, { force = false } = {}) => { + const existing = get().schemaCache[connectionId]; + if (!force && (existing?.status === 'ready' || existing?.status === 'loading')) return; + + const conn = useSyncStore.getState().connections.find((c) => c.id === connectionId); + if (!conn) { + set({ + schemaCache: { + ...get().schemaCache, + [connectionId]: { status: 'error', error: 'Connection not found' }, + }, + }); + return; + } + + const { sessionPasswords } = get(); + if (!conn.hasPassword && !sessionPasswords[connectionId]) { + set({ + pendingPassword: { + id: connectionId, + name: conn.name || conn.dialect, + resumeExecute: false, + }, + schemaCache: { + ...get().schemaCache, + [connectionId]: { + status: 'error', + error: 'Password required — enter it when prompted, then reload schema.', + }, + }, + }); + return; + } + + set({ + schemaCache: { + ...get().schemaCache, + [connectionId]: { status: 'loading', tables: existing?.tables }, + }, + }); + + try { + const { tables } = await loadSchema( + { connectionId, password: sessionPasswords[connectionId] || undefined }, + ['TABLE', 'VIEW', 'MQT'] + ); + set({ + schemaCache: { + ...get().schemaCache, + [connectionId]: { status: 'ready', tables }, + }, + }); + } catch (error: unknown) { + set({ + schemaCache: { + ...get().schemaCache, + [connectionId]: { + status: 'error', + error: error instanceof Error ? error.message : String(error), + }, + }, + }); + } + }, + + cancelWriteConfirm: () => set({ pendingWriteConfirm: null }), + + clearResults: () => { + const { activeTabId, resultsByTab } = get(); + const { [activeTabId]: _cleared, ...rest } = resultsByTab; + set({ resultsByTab: rest }); + }, + + execute: async ({ confirmedWrites = false, connectionIds } = {}) => { + const { + tabs, + activeTabId, + sessionPasswords, + maxRows, + runningTabId, + shareDestinations, + sharedConnectionIds, + safeMode, + } = get(); + if (runningTabId) return; + + const tab = tabs.find((t) => t.id === activeTabId); + if (!tab) return; + + const destIds = effectiveConnectionIds(tab, shareDestinations, sharedConnectionIds); + const selected = useSyncStore + .getState() + .connections.filter((c) => destIds.includes(c.id)); + const connections = + connectionIds && connectionIds.length > 0 + ? selected.filter((c) => connectionIds.includes(c.id)) + : selected; + if (connections.length === 0) return; + + const needingPassword = connections.find( + (c) => !c.hasPassword && !sessionPasswords[c.id] + ); + if (needingPassword) { + set({ + pendingPassword: { + id: needingPassword.id, + name: needingPassword.name || needingPassword.dialect, + resumeExecute: true, + connectionIds: connectionIds?.length ? connectionIds : undefined, + }, + }); + return; + } + + const statements = statementsToRun(tab.sql, tab.checkedStatements); + if (statements.length === 0) return; + + // Safe mode: confirm UPDATE / DELETE / MERGE (and other writes) before run. + const writeStatements = statements.filter((s) => isWriteStatement(s)); + const mutatingDml = writeStatements.filter((s) => isMutatingDmlStatement(s)); + const needsConfirm = + safeMode && + !confirmedWrites && + (mutatingDml.length > 0 || writeStatements.length > 0); + if (needsConfirm) { + const readonlyTargets = connections + .filter((c) => READONLY_DIALECTS.has(c.dialect.toLowerCase())) + .map((c) => ({ name: c.name || c.dialect, dialect: c.dialect })); + set({ + pendingWriteConfirm: { + tabId: tab.id, + writeStatements, + credentialCount: connections.length, + readonlyTargets, + connectionIds: connectionIds?.length ? connectionIds : undefined, + }, + }); + return; + } + + const tabId = tab.id; + const targetIds = new Set(connections.map((c) => c.id)); + const existing = get().resultsByTab[tabId]; + const partial = Boolean(connectionIds?.length && existing); + + const runningStub = (c: (typeof connections)[number]): CredentialRun => ({ + connectionId: c.id, + name: c.name || c.dialect, + dialect: c.dialect, + status: 'running', + }); + + let nextRuns: CredentialRun[]; + if (partial && existing) { + nextRuns = existing.runs.map((r) => + targetIds.has(r.connectionId) + ? { + connectionId: r.connectionId, + name: r.name, + dialect: r.dialect, + status: 'running' as const, + } + : r + ); + for (const c of connections) { + if (!nextRuns.some((r) => r.connectionId === c.id)) { + nextRuns.push(runningStub(c)); + } + } + } else { + nextRuns = connections.map(runningStub); + } + + set({ + pendingWriteConfirm: null, + runningTabId: tabId, + resultsByTab: { + ...get().resultsByTab, + [tabId]: { + ranStatements: statements, + runs: nextRuns, + }, + }, + }); + + const patchRun = (id: string, patch: Partial) => + set((state) => { + const current = state.resultsByTab[tabId]; + if (!current) return state; + return { + resultsByTab: { + ...state.resultsByTab, + [tabId]: { + ...current, + runs: current.runs.map((r) => (r.connectionId === id ? { ...r, ...patch } : r)), + }, + }, + }; + }); + + await Promise.allSettled( + connections.map(async (c) => { + try { + const { results } = await executeSql( + { connectionId: c.id, password: sessionPasswords[c.id] || undefined }, + statements, + maxRows + ); + patchRun(c.id, { status: 'done', results, error: undefined }); + } catch (error: unknown) { + patchRun(c.id, { + status: 'error', + error: error instanceof Error ? error.message : String(error), + results: undefined, + }); + } + }) + ); + + set({ runningTabId: null }); + }, + + saveBookmark: (opts) => { + const { tabs, activeTabId, bookmarks, shareDestinations, sharedConnectionIds } = get(); + const tab = tabs.find((t) => t.id === activeTabId); + if (!tab) return; + const title = (opts?.title ?? tab.title).trim() || 'Bookmark'; + const selectedConnectionIds = [ + ...effectiveConnectionIds(tab, shareDestinations, sharedConnectionIds), + ]; + const existing = + (tab.bookmarkId ? bookmarks.find((b) => b.id === tab.bookmarkId) : undefined) ?? + bookmarks.find((b) => b.title.toLowerCase() === title.toLowerCase()); + const entry: SqlBookmark = { + id: existing?.id ?? newTabId(), + title, + sql: tab.sql, + selectedConnectionIds, + updatedAt: Date.now(), + }; + const next = existing + ? bookmarks.map((b) => (b.id === existing.id ? entry : b)) + : [entry, ...bookmarks]; + set({ + bookmarks: next, + tabs: tabs.map((t) => + t.id === activeTabId ? { ...t, bookmarkId: entry.id, title } : t + ), + }); + }, + + openBookmark: (id) => { + const { bookmarks, tabs, shareDestinations } = get(); + const bm = bookmarks.find((b) => b.id === id); + if (!bm) return; + // Reuse an open tab already linked to this bookmark. + const existingTab = tabs.find((t) => t.bookmarkId === id); + if (existingTab) { + const patch: Partial = { activeTabId: existingTab.id }; + if (shareDestinations) { + patch.sharedConnectionIds = [...bm.selectedConnectionIds]; + } + set(patch); + return; + } + const tab = createTab({ + title: bm.title, + sql: bm.sql, + selectedConnectionIds: [...bm.selectedConnectionIds], + bookmarkId: bm.id, + }); + const patch: Partial = { + tabs: [...tabs, tab], + activeTabId: tab.id, + }; + if (shareDestinations) { + patch.sharedConnectionIds = [...bm.selectedConnectionIds]; + } + set(patch); + }, + + renameBookmark: (id, title) => { + const trimmed = title.trim() || 'Bookmark'; + const { bookmarks, tabs } = get(); + set({ + bookmarks: bookmarks.map((b) => + b.id === id ? { ...b, title: trimmed, updatedAt: Date.now() } : b + ), + // Keep any open tab linked to this bookmark in sync. + tabs: tabs.map((t) => (t.bookmarkId === id ? { ...t, title: trimmed } : t)), + }); + }, + + deleteBookmark: (id) => { + set({ + bookmarks: get().bookmarks.filter((b) => b.id !== id), + tabs: get().tabs.map((t) => + t.bookmarkId === id ? { ...t, bookmarkId: undefined } : t + ), + }); + }, + }), + { + name: 'foxschema-sql-editor', + version: 3, + // Persist tabs + destinations mode + bookmarks. Never passwords/results. + partialize: (state) => ({ + tabs: persistableTabs(state.tabs), + activeTabId: state.activeTabId, + maxRows: state.maxRows, + safeMode: state.safeMode, + shareDestinations: state.shareDestinations, + sharedConnectionIds: state.sharedConnectionIds, + bookmarks: state.bookmarks, + }), + migrate: (persisted, fromVersion) => { + const p = (persisted ?? {}) as Record; + // v1: flat { sql, selectedConnectionIds, maxRows } + if (fromVersion < 2) { + const tab = createTab({ + title: 'Query 1', + sql: typeof p.sql === 'string' ? p.sql : '', + selectedConnectionIds: Array.isArray(p.selectedConnectionIds) + ? (p.selectedConnectionIds as string[]) + : [], + }); + return { + tabs: [tab], + activeTabId: tab.id, + maxRows: typeof p.maxRows === 'number' ? p.maxRows : 200, + safeMode: true, + shareDestinations: true, + sharedConnectionIds: Array.isArray(p.selectedConnectionIds) + ? (p.selectedConnectionIds as string[]) + : [], + bookmarks: [], + }; + } + if (fromVersion < 3) { + const tabs = Array.isArray(p.tabs) ? p.tabs : []; + const first = tabs[0] as { selectedConnectionIds?: string[] } | undefined; + return { + ...p, + safeMode: true, + shareDestinations: true, + sharedConnectionIds: Array.isArray(first?.selectedConnectionIds) + ? first.selectedConnectionIds + : [], + bookmarks: [], + }; + } + return p; + }, + // Always rehydrate checkedStatements (not persisted) and drop malformed tabs. + merge: (persistedState, currentState) => { + if (!persistedState || typeof persistedState !== 'object') return currentState; + const p = persistedState as { + tabs?: Array & Pick>; + activeTabId?: string; + maxRows?: number; + safeMode?: boolean; + shareDestinations?: boolean; + sharedConnectionIds?: string[]; + bookmarks?: SqlBookmark[]; + }; + const tabs = hydrateTabs(Array.isArray(p.tabs) ? p.tabs : []); + const activeTabId = + typeof p.activeTabId === 'string' && tabs.some((t) => t.id === p.activeTabId) + ? p.activeTabId + : tabs[0]!.id; + const bookmarks = Array.isArray(p.bookmarks) + ? p.bookmarks.filter( + (b) => + b && + typeof b.id === 'string' && + typeof b.title === 'string' && + typeof b.sql === 'string' + ) + : []; + + // Relink tabs↔bookmarks by unique SQL match, and sync bookmark title to the tab. + const healedTabs = tabs.map((t) => { + if (t.bookmarkId && bookmarks.some((b) => b.id === t.bookmarkId)) return t; + const matches = bookmarks.filter((b) => b.sql === t.sql); + if (matches.length !== 1) return t; + return { ...t, bookmarkId: matches[0]!.id }; + }); + const healedBookmarks = bookmarks.map((b) => { + const tab = healedTabs.find((t) => t.bookmarkId === b.id); + if (!tab || tab.title === b.title) return b; + return { ...b, title: tab.title, updatedAt: Date.now() }; + }); + + return { + ...currentState, + tabs: healedTabs, + activeTabId, + maxRows: typeof p.maxRows === 'number' ? p.maxRows : currentState.maxRows, + safeMode: typeof p.safeMode === 'boolean' ? p.safeMode : true, + shareDestinations: + typeof p.shareDestinations === 'boolean' ? p.shareDestinations : true, + sharedConnectionIds: Array.isArray(p.sharedConnectionIds) + ? p.sharedConnectionIds.filter((id) => typeof id === 'string') + : [], + bookmarks: healedBookmarks, + }; + }, + } + ) +); diff --git a/apps/web/src/frontend/utils/exportCsv.test.ts b/apps/web/src/frontend/utils/exportCsv.test.ts new file mode 100644 index 0000000..ee99430 --- /dev/null +++ b/apps/web/src/frontend/utils/exportCsv.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { toCsv } from '../utils/exportCsv'; + +describe('exportCsv', () => { + it('escapes quotes, commas, and newlines', () => { + expect(toCsv(['a', 'b'], [['x', 'y']])).toBe('a,b\nx,y'); + expect(toCsv(['name'], [['say "hi"']])).toBe('name\n"say ""hi"""'); + expect(toCsv(['c'], [['a,b']])).toBe('c\n"a,b"'); + expect(toCsv(['a', 'b'], [[null, undefined]])).toBe('a,b\n,'); + }); +}); diff --git a/apps/web/src/frontend/utils/exportCsv.ts b/apps/web/src/frontend/utils/exportCsv.ts new file mode 100644 index 0000000..510eeaa --- /dev/null +++ b/apps/web/src/frontend/utils/exportCsv.ts @@ -0,0 +1,29 @@ +/** + * Quote-escaped CSV join + Blob download for SQL Editor result grids. + */ + +function escapeCell(value: unknown): string { + if (value === null || value === undefined) return ''; + const s = String(value); + if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; +} + +/** Build a CSV string from column headers + row arrays (server-shaped). */ +export function toCsv(columns: string[], rows: unknown[][]): string { + const header = columns.map(escapeCell).join(','); + const body = rows.map((row) => row.map(escapeCell).join(',')); + return [header, ...body].join('\n'); +} + +/** Trigger a browser download of the CSV. No-op when columns are empty. */ +export function downloadCsv(filename: string, columns: string[], rows: unknown[][]): void { + if (columns.length === 0) return; + const blob = new Blob([toCsv(columns, rows)], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 0869f83..9662aa4 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -323,3 +323,22 @@ mark.fox-search-hl { color: inherit; } + +/* SQL Editor gutter statement-status glyphs (Monaco glyph margin). The icon is + drawn with ::after content so no image assets are needed. */ +.fox-stmt-glyph-ok::after, +.fox-stmt-glyph-warn::after { + display: block; + text-align: center; + font-size: 11px; + line-height: inherit; + font-weight: 700; +} +.fox-stmt-glyph-ok::after { + content: '✓'; + color: #34d399; /* emerald-400 */ +} +.fox-stmt-glyph-warn::after { + content: '⚠'; + color: #fbbf24; /* amber-400 */ +} diff --git a/docs/plans/sql-editor.md b/docs/plans/sql-editor.md new file mode 100644 index 0000000..9ce90e6 --- /dev/null +++ b/docs/plans/sql-editor.md @@ -0,0 +1,163 @@ +# SQL Editor — multi-credential query workbench (3 phased PRs) + +## Context + +FoxSchema can compare/migrate schemas but has no way to run ad-hoc SQL and look at +**data**. The user wants a SQL Editor view **in web + desktop only** (not CLI): +browse-style layout (schema objects left, editor right), multi-tab, per-tab credential +checklist (run the same query against 1..N saved connections and compare data across +environments), per-statement syntax indicators + run checkboxes, schema-aware +autocomplete with alias support, and result grids groupable by credential (vertical) or +side-by-side (horizontal). + +**Decisions locked with the user:** any SQL allowed with a confirmation dialog when a +write/DDL statement is included (SQLite/ClickHouse writes fail with friendly per-cell +errors — their adapters are SELECT-only); per-statement checkboxes live in a +**statement strip** below the editor (gutter keeps the green-✓/amber-⚠ status icons); +delivery as **3 phased PRs**, each leaving gates green. + +**Verified reuse:** `ConnectionFactory.executeQuery()` returns rows for all 14 +dialects; `resolveRef` closure in `routes.ts` decrypts saved connections server-side; +`rateLimit()` in `api/rate-limit.ts`; Monaco via `@monaco-editor/react` + +`monaco-setup.ts` (`monacoLanguage()`, themes); `sql-formatter` + `utils/formatSql.ts`; +`SavedConnectionSummary` list in `useSyncStore.connections`; zustand `persist` + +`partialize` pattern (never persist credentials); vite aliases `@foxschema/core` → +`packages/core/src/browser.ts`, so pure core modules are shared frontend/backend. +**Gaps (net-new):** statement splitter, syntax heuristic, data-execution endpoint, +tabs UI, data grid, completion provider. No timeouts/cancel (deferred, documented). + +--- + +## PR 1 — core plumbing + usable single-tab editor + +**Ships:** switch to "SQL Editor" view, type SQL, check N credentials, Run → all +statements execute against every checked credential, results in by-credential layout. + +### 1a. Core: splitter + heuristics — NEW `packages/core/src/modules/sql-splitter.ts` +- `splitSqlStatements(sql): SplitStatement[]` where + `SplitStatement = { text, start, end, startLine, endLine, terminated }`. + Single-pass scanner aware of: `--`/`#` line comments, `/* */` block comments, + `'…'`/`"…"`/backtick/`[bracket]` quoting, Postgres `$tag$…$tag$` dollar quotes, + `;` terminators. NOT handled v1 (document): procedural BEGIN…END bodies with + internal semicolons. +- `checkStatement(stmt): { level: 'ok'|'warn'; reasons: string[] }` — heuristic only: + terminated with `;`, balanced quotes/parens, starts with known SQL keyword. + UI copy must say "looks complete", not "valid". +- `isWriteStatement(stmt): boolean` — leading-keyword check (INSERT/UPDATE/DELETE/ + CREATE/ALTER/DROP/TRUNCATE/MERGE/GRANT/REVOKE) for the confirm dialog. +- Export all from `packages/core/src/browser.ts` (and `index.ts` for backend). +- Tests: `packages/core/src/modules/sql-splitter.test.ts` — comments, dollar quotes, + quoted semicolons, unterminated final statement, CRLF, empty input, check matrix. + +### 1b. Backend: `POST /api/sql/execute` +- NEW `apps/web/src/backend/api/sql-execute.ts`: pure helpers + `runStatements(dialect, option, statements, maxRows)` + `shapeRows(rows, maxRows)`. + Route registered inside `createApiRoutes` (`routes.ts`) so it closes over `resolveRef`. +- Request: `{ ...ConnectionRef, statements: string[], maxRows?: number }`. + Clamp maxRows to [1, 5000], default 500; max 25 statements; reject empty statements. + Server trusts the client split (same as `/migration/execute`). +- **Fan-out is client-side** — one request per credential, `Promise.allSettled` in the + store. Progressive arrival for free; dead DB can't stall others; no new protocol. +- Statements run sequentially via `ConnectionFactory.executeQuery`; per-statement error + isolation: `results: Array<{ok:true, columns, rows, rowCount, truncated, durationMs} + | {ok:false, error, durationMs}>` (always 200 once ref resolves). +- Row shaping: columns = union of `Object.keys` over first 50 rows; rows as arrays in + column order; **BigInt → string, Date → ISO** server-side (JSON.stringify throws on + BigInt). Empty set → `columns: []` (v1 limitation, UI shows "0 rows"). +- Row cap = slice-after-fetch + `truncated: true` flag (no LIMIT injection — dialect- + variant, needs a parser). Truncation notice suggests adding LIMIT. +- `rateLimit({ windowMs: 60_000, max: 60 })`. +- Tests: shaping/clamping helpers pure-unit; `runStatements` against a seeded + better-sqlite3 file (no HTTP harness exists in repo — test the helper, not Express). + +### 1c. Frontend skeleton +- `activeView: 'sync' | 'sqlEditor'` in `apps/web/src/frontend/store/uiStore.ts` + (already persists); view switcher buttons in `TopToolbar.tsx`; `App.tsx` `Workspace` + renders `` instead of tree+detail panels when `sqlEditor`. +- NEW `apps/web/src/frontend/store/useSqlEditorStore.ts` (single tab in PR 1): + `{ sql, selectedConnectionIds, resultsByConnection, running }` + + `execute()` (split → confirm-if-`isWriteStatement` → fan out), reading + `useSyncStore.connections`. +- NEW `apps/web/src/frontend/components/sql-editor/`: + - `SqlEditorView.tsx` — left: `ConnectionChecklist`; right: editor pane + Run button + + `ResultsPanel`. + - `ConnectionChecklist.tsx` — checkboxes over `SavedConnectionSummary` (`[DIALECT] + name` styling + missing-password session-prompt pattern from TopToolbar). + - `SqlEditorPane.tsx` — NEW Monaco wrapper (copy BASE_OPTIONS/theme wiring from + `SqlEditor.tsx`, leave that file untouched), `glyphMargin: true`, editable; + on change (debounced ~200ms) split+check → `createDecorationsCollection` with + glyph classes for ✓/⚠ per statement start line. Lazy + Suspense like existing. + - `ResultsPanel.tsx` (byCredential layout only in PR 1) + `DataGrid.tsx` — plain + ``, sticky header, `NULL` dim-italic, ~200-char cell truncation w/ tooltip, + footer `N rows · X ms` + amber truncation notice; rose error card per failed cell. +- New frontend `api/sqlApi.ts` following `schemaApi.ts` conventions (`parseJson`, + `{error}` shape). +- Write-confirm dialog: portal modal listing the write statements + credential count, + styled like `DeployConfirmDialog.tsx`. + +## PR 2 — tabs, statement selection, layouts, persistence + +- Multi-tab: `tabs: SqlTab[]` (`{id, title, sql, selectedConnectionIds, checkedStatements, + layout}`), `activeTabId`, tab bar (add/close/rename via double-click), results keyed + per tab (not persisted). +- `StatementStrip.tsx` between editor and results: one row per split statement — + status icon, checkbox, truncated preview (`#1 ✓ SELECT * FROM users…`); click row → + reveal statement in editor. Execute runs checked statements; **none checked → first + statement** (the agreed default). `setSql` that changes statement count resets checks. +- Layout toggle per tab: `byCredential` (vertical stack of credential rows, each row's + statement results horizontally scrollable) | `sideBySide` (one section per statement, + credential results as columns). Same data, orientation flip. +- Persistence: `zustand/persist` key `foxschema-sql-editor`, partialize → + `{tabs: [{id,title,sql,selectedConnectionIds,layout}], activeTabId}`. Connection ids + are safe (not credentials); drop stale ids against `useSyncStore.connections` on + render. Never persist results/schemaCache/checkedStatements. +- CSV export per grid: `utils/exportCsv.ts` (quote-escaped join + Blob download). +- Format button reusing `utils/formatSql.ts`. +- Store reducer tests (tab add/close/toggle/check-reset). + +## PR 3 — schema intelligence + +- `SqlSchemaExplorer.tsx` — NEW slim left tree (do NOT reuse SchemaTreePanel — it's + hard-wired to compareResult/diff state): connection picker on top, tables → columns, + click-to-insert identifier at cursor; reuse `TYPE_META` icons. Data via existing + `loadSchema()` (POST /schema/load), cached in store `schemaCache[connectionId]`. +- `extractTableAliases(sql)` in `sql-splitter.ts` (+tests): regex over + `FROM|JOIN|UPDATE|INTO [AS] ` with keyword blacklist + (where/on/set/join/left/right/inner/outer/group/order/limit/using). +- `completion.ts` — one `monaco.languages.registerCompletionItemProvider` per language + id (mysql/pgsql/sql), registered once at first mount, reading active tab's + schemaCache via module-level getter (prevents duplicate-provider leaks). Logic: + `alias.` → that table's columns; `table.` → its columns; otherwise table names + + light keywords. Suggestions from all checked connections' schemas, deduped. +- Pre-flight friendly warning when write statements target sqlite/clickhouse + credentials (their adapters can't execute writes). + +## Verification (each PR) + +1. `cd apps/web && npx tsc --noEmit` and root `npx vitest run` green. +2. Browser (vite 5199 + API 3001, per-session `preview_start name:"web"` + tsx API): + seed two SQLite files with the same table but divergent rows + (`sqlite3 /tmp/foxa.db` / `/tmp/foxb.db` — pattern proven this session), save both + as connections, then: type 2 SELECTs + 1 broken statement → gutter shows ✓✓⚠; + check both credentials → Run → by-credential layout shows 2 rows × per-statement + grids with the divergent data visible; broken statement shows rose error card; + PR 2: toggle side-by-side, reload page → tabs/sql persist; PR 3: type `SELECT u.` + after `FROM t1 u` → column suggestions appear. +3. Write-confirm: type an UPDATE, Run → confirmation dialog appears; on the SQLite + credential the cell shows the friendly readonly error. + +## Out of scope + +- **CLI / TUI** — the interactive SQL editor is web + desktop (Tauri) only. No `foxschema sql` + command, Ink screen, or CLI execute path in this plan or follow-ups unless revisited. + +## Deferred / documented limitations + +- No query timeout/cancel (only clickhouse/sqlserver honor `timeout.queryMs`); runaway + queries hold a pooled connection. Needs its own follow-up. +- No cross-statement session state (each statement may get a different pooled + connection — temp tables/SET/transactions don't carry). `executeOnConnection` is the + later fix if needed. +- Empty result sets show no column names (rows-derived metadata). +- Syntax indicator is a heuristic ("looks complete"), not a parser. +- Row cap slices after fetch — driver still transfers the full result. \ No newline at end of file diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts index 46a25b3..fef1e60 100644 --- a/packages/core/src/browser.ts +++ b/packages/core/src/browser.ts @@ -23,6 +23,8 @@ 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 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 2fbd951..433552b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,8 @@ 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 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 new file mode 100644 index 0000000..95cfbf1 --- /dev/null +++ b/packages/core/src/modules/sql-splitter.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from 'vitest'; +import { splitSqlStatements, checkStatement, isWriteStatement, firstKeyword, extractTableAliases, isMutatingDmlStatement, dmlLacksWhere } from './sql-splitter'; + +describe('splitSqlStatements', () => { + it('splits simple semicolon-terminated statements with line numbers', () => { + const sql = 'SELECT 1;\nSELECT 2;\n'; + const stmts = splitSqlStatements(sql); + expect(stmts).toHaveLength(2); + expect(stmts[0]).toMatchObject({ text: 'SELECT 1;', startLine: 1, endLine: 1, terminated: true }); + expect(stmts[1]).toMatchObject({ text: 'SELECT 2;', startLine: 2, endLine: 2, terminated: true }); + }); + + it('marks an unterminated final statement', () => { + const stmts = splitSqlStatements('SELECT 1;\nSELECT 2'); + expect(stmts).toHaveLength(2); + expect(stmts[1]).toMatchObject({ text: 'SELECT 2', terminated: false }); + }); + + it('ignores semicolons inside single quotes (with doubling) and double quotes', () => { + const stmts = splitSqlStatements(`SELECT 'a;b', 'it''s;fine', ";x" FROM t;SELECT 2;`); + expect(stmts).toHaveLength(2); + expect(stmts[0].text).toContain(`'a;b'`); + }); + + it('ignores semicolons inside backticks, brackets, and dollar quotes', () => { + const stmts = splitSqlStatements('SELECT `a;b`, [c;d] FROM t;\nSELECT $tag$ x; y $tag$;'); + expect(stmts).toHaveLength(2); + expect(stmts[1].text).toContain('$tag$ x; y $tag$'); + }); + + it('ignores semicolons in line and block comments, and drops comment-only segments', () => { + const sql = '-- lead;ing\nSELECT 1; /* mid;dle */\n-- trailing only\n'; + const stmts = splitSqlStatements(sql); + expect(stmts).toHaveLength(1); + expect(stmts[0].text).toBe('SELECT 1;'); + }); + + it('puts startLine on the first code line, not a leading comment', () => { + const stmts = splitSqlStatements('-- comment\n\nSELECT 1;'); + expect(stmts).toHaveLength(1); + expect(stmts[0].startLine).toBe(3); + }); + + it('handles multi-line statements and CRLF input', () => { + const stmts = splitSqlStatements('SELECT a\r\nFROM t\r\nWHERE x = 1;\r\nSELECT 2;'); + expect(stmts).toHaveLength(2); + expect(stmts[0].startLine).toBe(1); + expect(stmts[0].endLine).toBe(3); + expect(stmts[1].startLine).toBe(4); + }); + + it('returns [] for empty and whitespace/comment-only input', () => { + expect(splitSqlStatements('')).toEqual([]); + expect(splitSqlStatements(' \n\t')).toEqual([]); + expect(splitSqlStatements('-- nothing\n/* here */')).toEqual([]); + }); + + it('handles MySQL # comments and backslash escapes in strings', () => { + const stmts = splitSqlStatements("# note;\nSELECT 'a\\';b' FROM t;"); + expect(stmts).toHaveLength(1); + expect(stmts[0].text).toBe("SELECT 'a\\';b' FROM t;"); + }); +}); + +describe('checkStatement', () => { + const stmt = (text: string, terminated = text.trimEnd().endsWith(';')) => ({ text, terminated }); + + it('ok for a complete terminated statement', () => { + expect(checkStatement(stmt('SELECT * FROM users WHERE id = 1;'))).toEqual({ level: 'ok', reasons: [] }); + }); + + it('warns on a missing final semicolon', () => { + const s = checkStatement(stmt('SELECT 1', false)); + expect(s.level).toBe('warn'); + expect(s.reasons).toContain('Missing terminating semicolon'); + }); + + it('warns on unclosed quote and unbalanced parentheses', () => { + expect(checkStatement(stmt("SELECT 'oops;")).reasons).toContain('Unclosed quote'); + expect(checkStatement(stmt('SELECT (1 + (2;')).reasons).toContain('Unbalanced parentheses'); + }); + + it('warns on an unknown leading keyword but accepts common ones', () => { + expect(checkStatement(stmt('FROBNICATE x;')).reasons.join()).toMatch(/FROBNICATE/); + for (const good of ['WITH x AS (SELECT 1) SELECT * FROM x;', 'EXPLAIN SELECT 1;', 'pragma table_info(t);']) { + expect(checkStatement(stmt(good)).level).toBe('ok'); + } + }); + + it('skips leading comments when finding the keyword', () => { + expect(checkStatement(stmt('/* hint */ SELECT 1;')).level).toBe('ok'); + }); +}); + +describe('isWriteStatement / firstKeyword', () => { + it('classifies writes', () => { + for (const w of ['INSERT INTO t VALUES (1);', 'update t set x=1;', 'DROP TABLE t;', 'Alter Table t ADD c int;', 'TRUNCATE t;']) { + expect(isWriteStatement(w)).toBe(true); + } + }); + it('classifies CTE-leading writes as writes', () => { + for (const w of [ + 'WITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x;', + 'WITH a AS (SELECT 1), b AS (SELECT 2) UPDATE t SET n = 1;', + 'with recursive cte as (select 1 as n union all select n+1 from cte where n < 3) DELETE FROM t WHERE id IN (SELECT n FROM cte);', + ]) { + expect(isWriteStatement(w)).toBe(true); + } + }); + it('classifies reads and no-keyword text as non-writes', () => { + for (const r of ['SELECT 1;', 'WITH x AS (SELECT 1) SELECT * FROM x;', 'EXPLAIN DELETE FROM t;', '???']) { + expect(isWriteStatement(r)).toBe(false); + } + }); + it('firstKeyword skips comments and parens', () => { + expect(firstKeyword('-- c\n(SELECT 1) UNION SELECT 2;')).toBe('select'); + expect(firstKeyword('/* x */ INSERT INTO t;')).toBe('insert'); + expect(firstKeyword('123')).toBe(null); + }); +}); + +describe('isMutatingDmlStatement / dmlLacksWhere', () => { + it('flags UPDATE DELETE MERGE including CTE wrappers', () => { + expect(isMutatingDmlStatement('UPDATE t SET x = 1 WHERE id = 1;')).toBe(true); + expect(isMutatingDmlStatement('DELETE FROM t WHERE id = 1;')).toBe(true); + expect( + isMutatingDmlStatement( + 'MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET x = 1;' + ) + ).toBe(true); + expect(isMutatingDmlStatement('WITH x AS (SELECT 1) DELETE FROM t;')).toBe(true); + expect(isMutatingDmlStatement('INSERT INTO t VALUES (1);')).toBe(false); + expect(isMutatingDmlStatement('SELECT 1;')).toBe(false); + }); + it('detects missing WHERE on UPDATE/DELETE', () => { + expect(dmlLacksWhere('UPDATE t SET x = 1;')).toBe(true); + expect(dmlLacksWhere('DELETE FROM t;')).toBe(true); + expect(dmlLacksWhere('UPDATE t SET x = 1 WHERE id = 1;')).toBe(false); + expect(dmlLacksWhere('DELETE FROM t WHERE id = 1;')).toBe(false); + expect(dmlLacksWhere('MERGE INTO t USING s ON 1=1 WHEN MATCHED THEN DELETE;')).toBe(false); + }); +}); + +describe('extractTableAliases', () => { + it('maps FROM/JOIN aliases and bare table names', () => { + const map = extractTableAliases('SELECT u.id FROM users u JOIN orders o ON o.uid = u.id'); + expect(map.u).toBe('users'); + expect(map.o).toBe('orders'); + expect(map.users).toBe('users'); + expect(map.orders).toBe('orders'); + }); + + it('accepts optional AS and UPDATE/INTO forms', () => { + expect(extractTableAliases('SELECT * FROM users AS u').u).toBe('users'); + expect(extractTableAliases('UPDATE users SET x = 1').users).toBe('users'); + expect(extractTableAliases('INSERT INTO t (a) VALUES (1)').t).toBe('t'); + }); + + it('ignores keyword blacklist aliases (FROM t WHERE …)', () => { + const map = extractTableAliases('SELECT * FROM t WHERE x = 1'); + expect(map.t).toBe('t'); + expect(map.where).toBeUndefined(); + }); + + it('handles schema-qualified and quoted identifiers', () => { + const map = extractTableAliases('SELECT * FROM public.users u JOIN "Odd Name" AS n ON true'); + expect(map.u).toBe('public.users'); + expect(map.users).toBe('public.users'); + expect(map.n).toBe('Odd Name'); + }); + + it('picks up comma-separated FROM aliases', () => { + const map = extractTableAliases('SELECT * FROM users u, orders o WHERE u.id = o.uid'); + expect(map.u).toBe('users'); + expect(map.o).toBe('orders'); + }); + + it('allows keyword table names with a short alias (FROM ORDER u)', () => { + const map = extractTableAliases('SELECT u.id FROM ORDER u'); + expect(map.u).toBe('ORDER'); + expect(map.order).toBe('ORDER'); + const quoted = extractTableAliases('SELECT u.x FROM "ORDER" AS u'); + expect(quoted.u).toBe('ORDER'); + }); +}); diff --git a/packages/core/src/modules/sql-splitter.ts b/packages/core/src/modules/sql-splitter.ts new file mode 100644 index 0000000..be6de5d --- /dev/null +++ b/packages/core/src/modules/sql-splitter.ts @@ -0,0 +1,535 @@ +/** + * Pragmatic SQL statement splitter + per-statement heuristics for the SQL + * Editor. Browser-safe (pure string logic) — exported via browser.ts so the + * frontend (gutter indicators, statement strip) and backend (request + * validation) share one implementation. + * + * This is a scanner, NOT a parser. It understands enough lexical structure to + * split reliably — `--`/`#` line comments, block comments, single/double/ + * backtick/[bracket] quoting (with '' doubling and backslash escapes), and + * PostgreSQL $tag$…$tag$ dollar quotes — and deliberately nothing more. + * 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. + */ + +export interface SplitStatement { + /** Statement text from its first non-whitespace character through its terminator. */ + text: string; + /** Character offset range in the original string ([start, end)). */ + start: number; + end: number; + /** 1-based line of the first code (non-comment) character — where a gutter icon belongs. */ + startLine: number; + /** 1-based line of the statement's last character. */ + endLine: number; + /** True when the statement ended with a `;`. */ + terminated: boolean; +} + +export interface StatementStatus { + /** 'ok' = looks complete; 'warn' = incomplete/suspect. A heuristic, not validation. */ + level: 'ok' | 'warn'; + reasons: string[]; +} + +type Mode = 'code' | 'line-comment' | 'block-comment' | 'single' | 'double' | 'backtick' | 'bracket' | 'dollar'; + +/** Leading keywords a statement is expected to start with (case-insensitive). */ +const KNOWN_KEYWORDS = new Set([ + 'select', 'insert', 'update', 'delete', 'with', 'create', 'alter', 'drop', 'truncate', + 'merge', 'grant', 'revoke', 'replace', 'rename', 'explain', 'show', 'describe', 'desc', + 'set', 'use', 'begin', 'commit', 'rollback', 'call', 'exec', 'execute', 'pragma', + 'analyze', 'vacuum', 'values', 'declare', 'comment', 'refresh', 'optimize', 'copy', +]); + +/** Leading keywords that modify data or schema — gate these behind a confirmation. */ +const WRITE_KEYWORDS = new Set([ + 'insert', 'update', 'delete', 'create', 'alter', 'drop', 'truncate', 'merge', + 'grant', 'revoke', 'replace', 'rename', +]); + +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 out: SplitStatement[] = []; + const len = sql.length; + + let mode: Mode = 'code'; + let dollarTag = ''; + let line = 1; + + // Statement text starts at the first CODE character — comments between + // statements act as separators (a leading `-- note` is not part of the + // statement; a comment after code has started is kept verbatim). + let codeStart = -1; + let codeStartLine = 1; + + const reset = () => { + codeStart = -1; + }; + + const markCode = (idx: number) => { + if (codeStart < 0) { + codeStart = idx; + codeStartLine = line; + } + }; + + const flush = (endIdx: number, terminated: boolean) => { + if (codeStart < 0) { + reset(); + return; // empty or comment-only segment — nothing executable + } + // Trim trailing whitespace off the captured range (relevant for the + // unterminated final statement, which otherwise drags trailing newlines). + let realEnd = endIdx; + while (realEnd > codeStart && /\s/.test(sql[realEnd - 1])) realEnd--; + let endLine = line; + for (let k = realEnd; k < endIdx; k++) if (sql[k] === '\n') endLine--; + out.push({ + text: sql.slice(codeStart, realEnd), + start: codeStart, + end: realEnd, + startLine: codeStartLine, + endLine, + terminated, + }); + reset(); + }; + + let i = 0; + while (i < len) { + const ch = sql[i]; + const next = i + 1 < len ? sql[i + 1] : ''; + + switch (mode) { + case 'code': { + if (ch === '-' && next === '-') { mode = 'line-comment'; i += 2; continue; } + if (ch === '#') { mode = 'line-comment'; i += 1; continue; } + if (ch === '/' && next === '*') { mode = 'block-comment'; i += 2; continue; } + if (ch === "'") { markCode(i); mode = 'single'; i += 1; continue; } + if (ch === '"') { markCode(i); mode = 'double'; i += 1; continue; } + if (ch === '`') { markCode(i); mode = 'backtick'; i += 1; continue; } + if (ch === '[') { markCode(i); mode = 'bracket'; i += 1; continue; } + if (ch === '$') { + const m = DOLLAR_TAG_RE.exec(sql.slice(i, i + 64)); + if (m) { markCode(i); dollarTag = m[0]; mode = 'dollar'; i += m[0].length; continue; } + } + if (ch === ';') { markCode(i); flush(i + 1, true); i += 1; continue; } + if (ch === '\n') { line++; i += 1; continue; } + if (!/\s/.test(ch)) markCode(i); + i += 1; + continue; + } + case 'line-comment': { + if (ch === '\n') { line++; mode = 'code'; } + i += 1; + continue; + } + case 'block-comment': { + if (ch === '*' && next === '/') { mode = 'code'; i += 2; continue; } + if (ch === '\n') line++; + i += 1; + continue; + } + case 'single': + case 'double': { + const q = mode === 'single' ? "'" : '"'; + if (ch === '\\') { i += 2; continue; } // MySQL-style escape; harmless merge risk elsewhere + if (ch === q) { + if (next === q) { i += 2; continue; } // '' / "" doubling + mode = 'code'; i += 1; continue; + } + if (ch === '\n') line++; + i += 1; + continue; + } + case 'backtick': { + if (ch === '`') { + if (next === '`') { i += 2; continue; } + mode = 'code'; i += 1; continue; + } + if (ch === '\n') line++; + i += 1; + continue; + } + case 'bracket': { + if (ch === ']') { + if (next === ']') { i += 2; continue; } + mode = 'code'; i += 1; continue; + } + if (ch === '\n') line++; + i += 1; + continue; + } + case 'dollar': { + if (sql.startsWith(dollarTag, i)) { i += dollarTag.length; mode = 'code'; continue; } + if (ch === '\n') line++; + i += 1; + continue; + } + } + } + flush(len, false); + 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'; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const next = i + 1 < text.length ? text[i + 1] : ''; + if (mode === 'code') { + if (ch === '-' && next === '-') { mode = 'line-comment'; i++; continue; } + if (ch === '#') { mode = 'line-comment'; continue; } + if (ch === '/' && next === '*') { mode = 'block-comment'; i++; continue; } + if (/[A-Za-z_]/.test(ch)) { + let j = i; + while (j < text.length && /[A-Za-z_0-9]/.test(text[j])) j++; + return text.slice(i, j).toLowerCase(); + } + if (ch === '(') continue; // e.g. `(SELECT …) UNION …` + if (/\s/.test(ch)) continue; + return null; // starts with something that isn't a word + } else if (mode === 'line-comment') { + if (ch === '\n') mode = 'code'; + } else if (mode === 'block-comment') { + if (ch === '*' && next === '/') { mode = 'code'; i++; } + } + } + return null; +} + +/** + * Cheap completeness signal for the gutter indicator. 'ok' means "looks + * complete" — balanced quoting/parens, known leading keyword, terminated with + * a semicolon — NOT that the statement will execute. + */ +export function checkStatement(stmt: Pick): StatementStatus { + const reasons: string[] = []; + const text = stmt.text; + + // Re-scan the (small) statement text for unclosed constructs + paren balance. + let mode: Mode = 'code'; + let dollarTag = ''; + let parens = 0; + let i = 0; + while (i < text.length) { + const ch = text[i]; + const next = i + 1 < text.length ? text[i + 1] : ''; + if (mode === 'code') { + if (ch === '-' && next === '-') { mode = 'line-comment'; i += 2; continue; } + if (ch === '#') { mode = 'line-comment'; i += 1; continue; } + if (ch === '/' && next === '*') { mode = 'block-comment'; i += 2; continue; } + if (ch === "'") { mode = 'single'; i += 1; continue; } + if (ch === '"') { mode = 'double'; i += 1; continue; } + if (ch === '`') { mode = 'backtick'; i += 1; continue; } + if (ch === '[') { mode = 'bracket'; i += 1; continue; } + if (ch === '$') { + const m = DOLLAR_TAG_RE.exec(text.slice(i, i + 64)); + if (m) { dollarTag = m[0]; mode = 'dollar'; i += m[0].length; continue; } + } + if (ch === '(') parens++; + if (ch === ')') parens--; + i += 1; + continue; + } + if (mode === 'line-comment') { if (ch === '\n') mode = 'code'; i += 1; continue; } + if (mode === 'block-comment') { if (ch === '*' && next === '/') { mode = 'code'; i += 2; continue; } i += 1; continue; } + if (mode === 'single' || mode === 'double') { + const q = mode === 'single' ? "'" : '"'; + if (ch === '\\') { i += 2; continue; } + if (ch === q) { if (next === q) { i += 2; continue; } mode = 'code'; i += 1; continue; } + i += 1; + continue; + } + if (mode === 'backtick') { if (ch === '`') { if (next === '`') { i += 2; continue; } mode = 'code'; i += 1; continue; } i += 1; continue; } + if (mode === 'bracket') { if (ch === ']') { if (next === ']') { i += 2; continue; } mode = 'code'; i += 1; continue; } i += 1; continue; } + if (mode === 'dollar') { if (text.startsWith(dollarTag, i)) { i += dollarTag.length; mode = 'code'; continue; } i += 1; continue; } + } + + if (mode === 'single' || mode === 'double' || mode === 'backtick' || mode === 'bracket') { + reasons.push('Unclosed quote'); + } else if (mode === 'block-comment') { + reasons.push('Unclosed block comment'); + } else if (mode === 'dollar') { + reasons.push('Unclosed dollar-quoted string'); + } + if (parens !== 0) reasons.push('Unbalanced parentheses'); + if (!stmt.terminated) reasons.push('Missing terminating semicolon'); + + const kw = firstKeyword(text); + if (!kw) reasons.push('Does not start with a SQL keyword'); + else if (!KNOWN_KEYWORDS.has(kw)) reasons.push(`Unrecognized leading keyword "${kw.toUpperCase()}"`); + + return { level: reasons.length ? 'warn' : 'ok', reasons }; +} + +/** + * True when the statement modifies data or schema (confirmation-worthy). + * Leading WRITE keywords count; so do CTE wrappers whose main verb is a write + * (`WITH … AS (…) INSERT …`). `EXPLAIN …` stays non-write even when it wraps a + * mutating verb — EXPLAIN does not apply the change. + */ +export function isWriteStatement(text: string): boolean { + const kw = firstKeyword(text); + if (!kw) return false; + if (WRITE_KEYWORDS.has(kw)) return true; + if (kw === 'explain') return false; + if (kw === 'with') { + const after = keywordAfterWithCtes(text); + return after !== null && WRITE_KEYWORDS.has(after); + } + return false; +} + +/** Leading verb after skipping WITH CTEs (lowercased), or null. */ +export function statementVerb(text: string): string | null { + const kw = firstKeyword(text); + if (!kw) return null; + if (kw === 'explain') return 'explain'; + if (kw === 'with') return keywordAfterWithCtes(text); + return kw; +} + +const MUTATING_DML = new Set(['update', 'delete', 'merge']); + +/** + * True for UPDATE / DELETE / MERGE (including `WITH … AS (…) UPDATE …`). + * Used by SQL Editor safe mode to warn before data-mutating DML. + */ +export function isMutatingDmlStatement(text: string): boolean { + const verb = statementVerb(text); + return verb !== null && MUTATING_DML.has(verb); +} + +/** + * Heuristic: UPDATE/DELETE with no WHERE clause (full-table mutation risk). + * Ignores WHERE inside strings/comments only loosely — strips simple quotes. + * MERGE is excluded (matched via ON, not WHERE). + */ +export function dmlLacksWhere(text: string): boolean { + const verb = statementVerb(text); + if (verb !== 'update' && verb !== 'delete') return false; + // Drop quoted literals so a string containing "where" does not count. + const stripped = text + .replace(/'(?:[^']|'')*'/g, "''") + .replace(/"(?:[^"]|"")*"/g, '""') + .replace(/`(?:[^`]|``)*`/g, '``') + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/--[^\n]*/g, ' ') + .replace(/#[^\n]*/g, ' '); + return !/\bwhere\b/i.test(stripped); +} + +/** + * Map lowercased alias (and bare table name) → table identifier as written. + * Heuristic regex over `FROM|JOIN|UPDATE|INTO [AS] ` — not a full + * parser. Alias candidates that match a SQL keyword blacklist are ignored + * (so `FROM t WHERE` does not treat WHERE as an alias). + */ +export function extractTableAliases(sql: string): Record { + const out: Record = {}; + if (!sql) return out; + + // Quoted ("…"/`…`/[…]) or dotted bare identifiers. + const ident = + '(?:"[^"]+"|`[^`]+`|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*(?:\\.[A-Za-z_][\\w$]*)*)'; + // FROM/JOIN/UPDATE/INTO
[AS] + // Plus comma-separated FROM items that include an alias (`FROM a x, b y`) — + // alias is required after `,` so `SELECT a, b FROM t` is not mistaken for tables. + const re = new RegExp( + `\\b(?:FROM|JOIN|UPDATE|INTO)\\s+(${ident})(?:\\s+(?:AS\\s+)?(${ident}))?|,\\s+(${ident})\\s+(?:AS\\s+)?(${ident})`, + 'gi' + ); + + let m: RegExpExecArray | null; + while ((m = re.exec(sql)) !== null) { + const tableRaw = m[1] ?? m[3]; + const aliasRaw = m[2] ?? m[4]; + if (!tableRaw) continue; + const table = stripIdentQuotes(tableRaw); + if (!table) continue; + const tableKey = table.toLowerCase(); + // Do NOT blacklist the table name — real tables are often keywords + // (ORDER, USER, GROUP, …). Only aliases are filtered below. + + const bare = tableKey.includes('.') ? tableKey.slice(tableKey.lastIndexOf('.') + 1) : tableKey; + out[tableKey] = table; + out[bare] = table; + + if (!aliasRaw) continue; + const alias = stripIdentQuotes(aliasRaw); + if (!alias) continue; + if (ALIAS_KEYWORD_BLACKLIST.has(alias.toLowerCase())) continue; + out[alias.toLowerCase()] = table; + } + return out; +} + +/** Keywords that must never be treated as a table alias. */ +const ALIAS_KEYWORD_BLACKLIST = new Set([ + 'where', 'on', 'set', 'join', 'left', 'right', 'inner', 'outer', 'full', 'cross', + 'group', 'order', 'limit', 'offset', 'using', 'and', 'or', 'as', 'by', 'into', + 'values', 'select', 'from', 'update', 'insert', 'delete', 'having', 'union', + 'except', 'intersect', 'returning', 'with', 'natural', 'lateral', +]); + +function stripIdentQuotes(raw: string): string { + const s = raw.trim(); + if (s.length >= 2) { + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith('`') && s.endsWith('`'))) { + return s.slice(1, -1).replace(/""/g, '"').replace(/``/g, '`'); + } + if (s.startsWith('[') && s.endsWith(']')) return s.slice(1, -1).replace(/\]\]/g, ']'); + } + return s; +} + +/** + * After a leading `WITH [RECURSIVE] cte AS (…) [, …]`, return the main-verb + * keyword (SELECT / INSERT / …). Returns null when the CTE list cannot be + * walked (malformed or still open). + */ +function keywordAfterWithCtes(text: string): string | null { + let i = 0; + // Skip to the end of the leading WITH keyword. + const lead = firstKeywordSpan(text); + if (!lead || lead.word !== 'with') return null; + i = lead.end; + + // Optional RECURSIVE. + const rec = nextWord(text, i); + if (rec && rec.word === 'recursive') i = rec.end; + + // Walk `name [(cols)] AS (…)` [, …] + while (i < text.length) { + const name = nextWord(text, i); + if (!name) return null; + i = name.end; + + // Optional column list. + const afterName = skipWsAndComments(text, i); + if (afterName < text.length && text[afterName] === '(') { + i = skipBalancedParens(text, afterName); + if (i < 0) return null; + } + + const asKw = nextWord(text, i); + if (!asKw || asKw.word !== 'as') return null; + i = asKw.end; + + const open = skipWsAndComments(text, i); + if (open >= text.length || text[open] !== '(') return null; + i = skipBalancedParens(text, open); + if (i < 0) return null; + + const afterCte = skipWsAndComments(text, i); + if (afterCte < text.length && text[afterCte] === ',') { + i = afterCte + 1; + continue; + } + // Main verb follows. + return firstKeyword(text.slice(afterCte)); + } + return null; +} + +function firstKeywordSpan(text: string): { word: string; end: number } | null { + let mode: Mode = 'code'; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const next = i + 1 < text.length ? text[i + 1] : ''; + if (mode === 'code') { + if (ch === '-' && next === '-') { mode = 'line-comment'; i++; continue; } + if (ch === '#') { mode = 'line-comment'; continue; } + if (ch === '/' && next === '*') { mode = 'block-comment'; i++; continue; } + if (/[A-Za-z_]/.test(ch)) { + let j = i; + while (j < text.length && /[A-Za-z_0-9]/.test(text[j])) j++; + return { word: text.slice(i, j).toLowerCase(), end: j }; + } + if (ch === '(' || /\s/.test(ch)) continue; + return null; + } else if (mode === 'line-comment') { + if (ch === '\n') mode = 'code'; + } else if (mode === 'block-comment') { + if (ch === '*' && next === '/') { mode = 'code'; i++; } + } + } + return null; +} + +function skipWsAndComments(text: string, from: number): number { + let mode: Mode = 'code'; + for (let i = from; i < text.length; i++) { + const ch = text[i]; + const next = i + 1 < text.length ? text[i + 1] : ''; + if (mode === 'code') { + if (ch === '-' && next === '-') { mode = 'line-comment'; i++; continue; } + if (ch === '#') { mode = 'line-comment'; continue; } + if (ch === '/' && next === '*') { mode = 'block-comment'; i++; continue; } + if (/\s/.test(ch)) continue; + return i; + } else if (mode === 'line-comment') { + if (ch === '\n') mode = 'code'; + } else if (mode === 'block-comment') { + if (ch === '*' && next === '/') { mode = 'code'; i++; } + } + } + return text.length; +} + +function nextWord(text: string, from: number): { word: string; end: number } | null { + const i = skipWsAndComments(text, from); + if (i >= text.length || !/[A-Za-z_]/.test(text[i])) return null; + let j = i; + while (j < text.length && /[A-Za-z_0-9]/.test(text[j])) j++; + return { word: text.slice(i, j).toLowerCase(), end: j }; +} + +/** Advance past a `(…)` group starting at `open` (must be '('). Returns index after `)` or -1. */ +function skipBalancedParens(text: string, open: number): number { + let depth = 0; + let mode: Mode = 'code'; + let dollarTag = ''; + for (let i = open; i < text.length; i++) { + const ch = text[i]; + const next = i + 1 < text.length ? text[i + 1] : ''; + if (mode === 'code') { + if (ch === '-' && next === '-') { mode = 'line-comment'; i++; continue; } + if (ch === '#') { mode = 'line-comment'; continue; } + if (ch === '/' && next === '*') { mode = 'block-comment'; i++; continue; } + if (ch === "'") { mode = 'single'; continue; } + if (ch === '"') { mode = 'double'; continue; } + if (ch === '`') { mode = 'backtick'; continue; } + if (ch === '[') { mode = 'bracket'; continue; } + if (ch === '$') { + const m = DOLLAR_TAG_RE.exec(text.slice(i, i + 64)); + if (m) { dollarTag = m[0]; mode = 'dollar'; i += m[0].length - 1; continue; } + } + if (ch === '(') { depth++; continue; } + if (ch === ')') { + depth--; + if (depth === 0) return i + 1; + continue; + } + continue; + } + if (mode === 'line-comment') { if (ch === '\n') mode = 'code'; continue; } + if (mode === 'block-comment') { if (ch === '*' && next === '/') { mode = 'code'; i++; } continue; } + if (mode === 'single' || mode === 'double') { + const q = mode === 'single' ? "'" : '"'; + if (ch === '\\') { i++; continue; } + if (ch === q) { if (next === q) { i++; continue; } mode = 'code'; } + continue; + } + if (mode === 'backtick') { if (ch === '`') { if (next === '`') { i++; continue; } mode = 'code'; } continue; } + if (mode === 'bracket') { if (ch === ']') { if (next === ']') { i++; continue; } mode = 'code'; } continue; } + if (mode === 'dollar') { if (text.startsWith(dollarTag, i)) { i += dollarTag.length - 1; mode = 'code'; } continue; } + } + return -1; +} diff --git a/vitest.config.ts b/vitest.config.ts index 08e0028..8debfa8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,21 +3,41 @@ import { fileURLToPath } from 'node:url'; const pkg = (p: string) => fileURLToPath(new URL(p, import.meta.url)); -// Root test runner for the whole workspace: the pure engine tests in -// packages/* plus the backend tests in apps/web. The @foxschema/core alias -// resolves to package source so tests run without a build step. +const aliases = [ + { find: '@foxschema/core', replacement: pkg('./packages/core/src/index.ts') }, + { find: '@foxschema/web/auth', replacement: pkg('./apps/web/src/backend/modules/auth.module.ts') }, + { find: '@foxschema/web/connection-store', replacement: pkg('./apps/web/src/backend/modules/connection-store.module.ts') }, + { find: '@foxschema/web/migration-history', replacement: pkg('./apps/web/src/backend/modules/migration-history.module.ts') }, + { find: '@foxschema/web/app-settings', replacement: pkg('./apps/web/src/backend/modules/app-settings.module.ts') }, + { find: '@foxschema/web/store', replacement: pkg('./apps/web/src/backend/database/store.ts') }, +]; + +// Root test runner for the whole workspace. CLI Ink TUI screens are isolated in +// their own project with fileParallelism off — under full-suite parallel load, +// ink-text-input / SelectInput stdin races flake even when the same tests pass +// in isolation (see apps/cli/src/tui/__tests__/README.md). export default defineConfig({ - resolve: { - alias: [ - { find: '@foxschema/core', replacement: pkg('./packages/core/src/index.ts') }, - { find: '@foxschema/web/auth', replacement: pkg('./apps/web/src/backend/modules/auth.module.ts') }, - { find: '@foxschema/web/connection-store', replacement: pkg('./apps/web/src/backend/modules/connection-store.module.ts') }, - { find: '@foxschema/web/migration-history', replacement: pkg('./apps/web/src/backend/modules/migration-history.module.ts') }, - { find: '@foxschema/web/app-settings', replacement: pkg('./apps/web/src/backend/modules/app-settings.module.ts') }, - { find: '@foxschema/web/store', replacement: pkg('./apps/web/src/backend/database/store.ts') }, - ], - }, + resolve: { alias: aliases }, test: { - include: ['packages/**/*.test.ts', 'apps/web/**/*.test.ts', 'apps/cli/**/*.test.{ts,tsx}'], + projects: [ + { + resolve: { alias: aliases }, + test: { + name: 'unit', + include: ['packages/**/*.test.ts', 'apps/web/**/*.test.ts', 'apps/cli/src/**/*.test.ts'], + exclude: ['apps/cli/src/tui/**'], + testTimeout: 15_000, + }, + }, + { + resolve: { alias: aliases }, + test: { + name: 'cli-tui', + include: ['apps/cli/src/tui/**/*.test.{ts,tsx}'], + fileParallelism: false, + testTimeout: 30_000, + }, + }, + ], }, });