diff --git a/apps/e2e/scripts/run-all.mjs b/apps/e2e/scripts/run-all.mjs index f9c879d89..81d0b1c8e 100644 --- a/apps/e2e/scripts/run-all.mjs +++ b/apps/e2e/scripts/run-all.mjs @@ -124,6 +124,7 @@ const ALWAYS = [ 'src/tests/lazy-loading.test.ts', 'src/tests/sql-editor-result-edit.test.ts', 'src/tests/sql-editor-run-rail.test.ts', + 'src/tests/redis-editor.test.ts', 'src/tests/sql-editor-peek-row-form.test.ts', 'src/tests/sql-editor-peek-stacking.test.ts', 'src/tests/sql-editor-compare-multi-target.test.ts', diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 8123afc03..54287370a 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -1875,6 +1875,8 @@ export const TestIds = { sqlGridCtxCopyValues: 'sql-grid-ctx-copy-values', /** button · Open row */ sqlGridCtxOpenRow: 'sql-grid-ctx-open-row', + /** div */ + sqlGridError: 'sql-grid-error', /** button · Export CSV */ sqlGridExportBtn: 'sql-grid-export-btn', /** button · Export CSV */ @@ -2255,6 +2257,8 @@ export const TestIds = { sqlResultCredMaximizedHint: (connectionId: string | number) => `sql-result-cred-maximized-hint-${connectionId}`, /** div */ sqlResultCredModal: (connectionId: string | number) => `sql-result-cred-modal-${connectionId}`, + /** div */ + sqlResultError: 'sql-result-error', /** button · Maximize results */ sqlResultMaximize: (statementIndex: string | number) => `sql-result-maximize-${statementIndex}`, /** p */ @@ -2287,6 +2291,8 @@ export const TestIds = { sqlResultsSideBySide: 'sql-results-side-by-side', /** div */ sqlResultsWarnings: 'sql-results-warnings', + /** div */ + sqlRunError: 'sql-run-error', }, SelectColumnPicker: { /** button · SELECT * */ @@ -2418,6 +2424,8 @@ export const TestIds = { /** button */ sqlExplorerParamInsert: (name: string | number, name2: string | number) => `sql-explorer-param-insert-${name}-${name2}`, /** span */ + sqlExplorerStoreType: (name: string | number) => `sql-explorer-store-type-${name}`, + /** span */ sqlExplorerTableStats: (name: string | number) => `sql-explorer-table-stats-${name}`, /** button */ sqlExplorerToggle: (name: string | number) => `sql-explorer-toggle-${name}`, @@ -4700,6 +4708,7 @@ export type TestId = | `sql-explorer-from-${string}` | `sql-explorer-object-${string}` | `sql-explorer-param-insert-${string}-${string}` + | `sql-explorer-store-type-${string}` | `sql-explorer-table-stats-${string}` | `sql-explorer-toggle-${string}` | `sql-filter-op-${string}` @@ -4734,6 +4743,7 @@ export type TestId = | 'sql-grid-ctx-copy-headers-only' | 'sql-grid-ctx-copy-values' | 'sql-grid-ctx-open-row' + | 'sql-grid-error' | 'sql-grid-export-btn' | 'sql-grid-export-csv' | 'sql-grid-export-json' @@ -4807,6 +4817,7 @@ export type TestId = | `sql-result-cred-maximize-${string}` | `sql-result-cred-maximized-hint-${string}` | `sql-result-cred-modal-${string}` + | 'sql-result-error' | `sql-result-maximize-${string}` | `sql-result-maximized-hint-${string}` | `sql-result-modal-${string}` @@ -4828,6 +4839,7 @@ export type TestId = | `sql-row-selected-${string}` | `sql-row-sync-check-${string}` | 'sql-run-btn' + | 'sql-run-error' | 'sql-runs-clear' | 'sql-runs-close' | 'sql-runs-drawer' diff --git a/apps/e2e/src/helpers/redis.ts b/apps/e2e/src/helpers/redis.ts new file mode 100644 index 000000000..ea68fa578 --- /dev/null +++ b/apps/e2e/src/helpers/redis.ts @@ -0,0 +1,66 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * The dev container's Redis (`foxschema-redis`), from a spec: seed keys, read + * them back, and clean up. Through `docker exec … redis-cli`, so a spec checks + * what Redis holds rather than what the app says it holds. + */ +import { execFileSync } from 'node:child_process'; + +const CONTAINER = 'foxschema-redis'; + +/** One redis-cli command in database `db`; its output, trimmed. */ +export function redisCli(db: number, ...args: string[]): string { + return execFileSync('docker', ['exec', '-i', CONTAINER, 'redis-cli', '-n', String(db), ...args], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); +} + +/** + * Many commands in database `db` through one process (`redis-cli --pipe`), + * sent as the wire protocol so a JSON value needs no quoting. Throws when + * Redis reports an error for any of them. + */ +export function redisBatch(db: number, commands: string[][]): void { + if (commands.length === 0) return; + const resp = commands + .map((args) => `*${args.length}\r\n${args.map((a) => `$${Buffer.byteLength(a)}\r\n${a}\r\n`).join('')}`) + .join(''); + const out = execFileSync('docker', ['exec', '-i', CONTAINER, 'redis-cli', '-n', String(db), '--pipe'], { + input: resp, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (!/errors: 0,/.test(out)) throw new Error(`redis-cli --pipe reported errors: ${out.trim()}`); +} + +/** Whether the container answers at all; specs skip without it. */ +export function redisReachable(): boolean { + try { + return redisCli(0, 'PING') === 'PONG'; + } catch { + return false; + } +} + +/** Every key in `db` matching `pattern` (SCAN, not KEYS), sorted. */ +export function redisKeys(db: number, pattern: string): string[] { + const out = redisCli(db, '--scan', '--pattern', pattern); + return out ? out.split('\n').filter(Boolean).sort() : []; +} + +/** A hash as an object. */ +export function redisHash(db: number, key: string): Record { + const flat = redisCli(db, 'HGETALL', key).split('\n').filter((l) => l !== ''); + const out: Record = {}; + for (let i = 0; i + 1 < flat.length; i += 2) out[flat[i]!] = flat[i + 1]!; + return out; +} + +/** Delete these keys from `db` in one command. */ +export function redisDel(db: number, keys: readonly string[]): void { + if (keys.length) redisBatch(db, [['DEL', ...keys]]); +} diff --git a/apps/e2e/src/pages/SqlEditorPage.ts b/apps/e2e/src/pages/SqlEditorPage.ts index 004bd2879..08f78bef6 100644 --- a/apps/e2e/src/pages/SqlEditorPage.ts +++ b/apps/e2e/src/pages/SqlEditorPage.ts @@ -505,7 +505,12 @@ export class SqlEditorPage { } /** Open Data Peek for a table from the Schema tree (modifier-click the row). */ - async openDataPeek(tableName: string): Promise { + /** + * Cmd/Ctrl-click a table in the explorer and wait for its Data Peek. By + * default waits for the row-editing controls, which render once rows load; + * a read-only object (a Redis list) has none, so wait for `rows` instead. + */ + async openDataPeek(tableName: string, opts: { waitFor?: 'crud' | 'rows' } = {}): Promise { await this.dismissOverlays(); // The v3 sidebar collapses every section, and a collapsed section renders // no explorer at all — open Schema before reaching into its tree. @@ -525,8 +530,12 @@ export class SqlEditorPage { const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; await label.click({ modifiers: [modifier] }); await waitFor(this.page, byTestId('data-peek'), 30_000); - // Wait for the grid, since the CRUD controls only render once rows load. - await this.page.waitForSelector('[data-testid^="data-peek-crud-"]', { timeout: 30_000 }); + await this.page.waitForSelector( + opts.waitFor === 'rows' + ? '[data-testid^="data-peek-grid-"] [data-testid="sql-row-num"]' + : '[data-testid^="data-peek-crud-"]', + { timeout: 30_000 } + ); } /** Click Add row on the first open Data Peek panel. */ diff --git a/apps/e2e/src/tests/redis-editor.test.ts b/apps/e2e/src/tests/redis-editor.test.ts new file mode 100644 index 000000000..08731086e --- /dev/null +++ b/apps/e2e/src/tests/redis-editor.test.ts @@ -0,0 +1,634 @@ +/** + * Redis in the SQL editor, end to end: basic SQL, every built-in sample, + * Compare data with Data migrate, and Data peek with its row editor. + * + * Three Redis databases in the dev container, all under a per-run key prefix: + * + * db 0 "Redis A" the main data set: a hash prefix, JSON documents, a list, + * a set, a sorted set and a stream + * db 1 "Redis B" the same hash prefix, differing from A, for Compare data + * db 2 "Redis S" a sandbox the samples run against, so a sample that writes + * cannot touch A or B + * + * Every assertion reads the rows the grid shows, or the keys Redis holds + * afterwards (`redis-cli`), never just the absence of an error. + * + * Requires the web app + API and the dev Redis container (`foxschema-redis`). + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { appendFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Page } from 'playwright'; +import { buildDriver, quitDriver, clickWhen, waitFor } from '../helpers/driver.js'; +import { sessionCookie } from '../helpers/app-session.js'; +import { redisBatch, redisCli as redis, redisDel, redisHash as hash, redisKeys as keys, redisReachable } from '../helpers/redis.js'; +import { saveScreenshot } from '../helpers/screenshot.js'; +import { byTestId } from '../helpers/test-ids.js'; +import { AppPage } from '../pages/AppPage.js'; +import { SqlEditorPage } from '../pages/SqlEditorPage.js'; + +const API_URL = process.env.E2E_API_URL ?? 'http://127.0.0.1:3210'; +const RUN = Date.now().toString(36); +const P = `rt${RUN}`; +const T = { + users: `${P}users`, + docs: `${P}docs`, + jobs: `${P}jobs`, + tags: `${P}tags`, + board: `${P}board`, + log: `${P}log`, +}; +const CONN = { a: `Redis A ${RUN}`, b: `Redis B ${RUN}`, s: `Redis S ${RUN}` }; +/** One line per sample as it finishes (see the samples test). */ +const SAMPLE_LOG = process.env.FOX_SAMPLE_LOG ?? join(tmpdir(), 'fox-redis-samples.log'); + +function seed(): void { + const users: Array<[string, string, string]> = [ + ['1', 'Ana', 'free'], + ['2', 'Bo', 'pro'], + ['3', 'Cy', 'pro'], + ['4', 'Di', 'free'], + ['5', 'Ed', 'team'], + ]; + redisBatch(0, [ + ...users.map(([id, name, plan]) => ['HSET', `${T.users}:${id}`, 'name', name, 'plan', plan]), + ['SET', `${T.docs}:a`, JSON.stringify({ title: 'Hello', tags: ['x'], n: 1 })], + ['SET', `${T.docs}:b`, JSON.stringify({ title: 'World', n: 2 })], + ['RPUSH', T.jobs, JSON.stringify({ job: 'send', to: 'ana' }), JSON.stringify({ job: 'bill', to: 'bo' }), 'plain-entry'], + ['SADD', T.tags, 'red', 'green', 'blue'], + ['ZADD', T.board, '30', 'cy', '10', 'al', '20', 'bo'], + ['XADD', T.log, '*', 'level', 'info', 'msg', 'started'], + ['XADD', T.log, '*', 'level', 'warn', 'msg', 'slow'], + ]); + // B: user 1 the same, 2 changed, 6 only here; 3–5 only in A. + redisBatch(1, [ + ['HSET', `${T.users}:1`, 'name', 'Ana', 'plan', 'free'], + ['HSET', `${T.users}:2`, 'name', 'Bo', 'plan', 'free'], + ['HSET', `${T.users}:6`, 'name', 'Fay', 'plan', 'pro'], + ]); +} + +function cleanup(): void { + for (const db of [0, 1, 2]) redisDel(db, keys(db, `${P}*`)); +} + +describe.skipIf(!redisReachable())('SQL Editor · Redis', () => { + let admin = ''; + const connectionIds: string[] = []; + let driver: Page; + let sql: SqlEditorPage; + /** Keys in the samples' sandbox before they ran, so only what they made is removed. */ + let sandboxBefore = new Set(); + + const api = async (method: string, path: string, body?: unknown) => { + const res = await fetch(`${API_URL}/api${path}`, { + method, + headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), cookie: admin }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + return { status: res.status, json: (await res.json().catch(() => null)) as any }; + }; + + /** Tick exactly these destinations. */ + async function destinations(...names: string[]): Promise { + await sql.ensureSidebarSectionOpen('destinations'); + for (const name of Object.values(CONN)) { + const box = driver.locator(byTestId(`sql-conn-check-${name}`)); + await box.waitFor({ timeout: 15_000 }); + const want = names.includes(name); + if ((await box.isChecked()) !== want) await box.click(); + // Plain polling: this also runs in beforeAll, where expect.poll is not allowed. + const deadline = Date.now() + 10_000; + while ((await box.isChecked()) !== want) { + if (Date.now() > deadline) throw new Error(`${name} did not become ${want ? 'checked' : 'unchecked'}`); + await driver.waitForTimeout(200); + } + } + } + + /** + * Run `text`; confirm the Safe mode prompt when it is a write. A statement + * with no WHERE also needs its acknowledgement ticked before Confirm enables. + */ + async function runSql(text: string, opts: { write?: boolean } = {}): Promise { + await sql.setSql(text); + await sql.run(); + if (opts.write) { + const ack = driver.locator(byTestId('sql-safe-no-where-ack-check')); + if (await ack.isVisible({ timeout: 2_000 }).catch(() => false)) await ack.check(); + await sql.confirmWriteIfShown(); + } + await sql.waitForResults(60_000); + } + + /** The rows of every result grid on screen, as cell text, without the # column. */ + const gridRows = (): Promise => + driver.evaluate(() => + [...document.querySelectorAll('[data-testid="sql-data-grid"] tbody tr')] + .filter((tr) => tr.querySelector('[data-testid="sql-row-num"]')) + .map((tr) => [...tr.querySelectorAll('td')].slice(1).map((td) => (td.textContent ?? '').trim())) + ); + + /** Column headers of the first result grid. */ + const gridColumns = (): Promise => + driver.evaluate(() => + [...(document.querySelector('[data-testid="sql-data-grid"]')?.querySelectorAll('[data-testid="sql-col-header"]') ?? [])].map( + // A header reads "▲nametext": sort arrow, name, type badge (uppercase by CSS only). + (th) => (th.textContent ?? '').trim().replace(/^[^A-Za-z_]+/, '').replace(/(text|num|bool|date|json)$/i, '') + ) + ); + + /** Error text the results show, statement errors and run errors alike. */ + const errors = (): Promise => + driver.evaluate(() => + [ + ...document.querySelectorAll( + '[data-testid="sql-grid-error"], [data-testid="sql-result-error"], [data-testid="sql-run-error"]' + ), + ].map((e) => + (e.textContent ?? '').trim() + ) + ); + + /** + * Wait for a run to finish, confirming a write when Safe mode asks (the + * sandbox database takes it). True when it settled within `timeoutMs`. + * The results container never mounts for a run refused before it starts, + * so waiting on it would sit out the whole timeout for every refusal. + */ + async function settle(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + const busy = () => + driver.evaluate(() => + Boolean(document.querySelector('[data-testid="sql-run-btn"] .animate-spin, [data-testid="sql-results-running"]')) + ); + // A run refused before it starts never spins; give it 3 s to show it began. + const startBy = Date.now() + 3_000; + while (!(await busy()) && Date.now() < startBy) { + if (await driver.locator(byTestId('sql-write-confirm')).isVisible()) break; + await driver.waitForTimeout(100); + } + while (Date.now() < deadline) { + if (await driver.locator(byTestId('sql-write-confirm')).isVisible()) { + const ack = driver.locator(byTestId('sql-safe-no-where-ack-check')); + if (await ack.isVisible()) await ack.check(); + await clickWhen(driver, byTestId('sql-write-confirm-btn')); + await driver.waitForTimeout(300); + continue; + } + if (!(await busy())) return true; + await driver.waitForTimeout(250); + } + return false; + } + + beforeAll(async () => { + cleanup(); + seed(); + sandboxBefore = new Set(keys(2, '*')); + admin = await sessionCookie(); + for (const [name, db] of [ + [CONN.a, '0'], + [CONN.b, '1'], + [CONN.s, '2'], + ] as const) { + const saved = await api('POST', '/connections', { name, dialect: 'redis', option: { host: '127.0.0.1', port: 6379, schema: db } }); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + connectionIds.push(saved.json.connection.id); + } + + driver = await buildDriver(); + sql = new SqlEditorPage(driver); + await new AppPage(driver).open(); + await sql.resetPersistedEditorState(); + await driver.reload(); + await driver.waitForSelector(byTestId('toolbar')); + await sql.openView(); + }, 180_000); + + afterAll(async () => { + if (driver) await quitDriver(driver); + for (const id of connectionIds) await api('DELETE', `/connections/${id}`); + cleanup(); + redisDel(2, keys(2, '*').filter((k) => !sandboxBefore.has(k))); + }); + + // ── Basic SQL ────────────────────────────────────────────────────────── + + describe('basic SQL', () => { + beforeAll(async () => { + await destinations(CONN.a); + }, 60_000); + + it('SELECT * reads every hash under a prefix, its fields as columns', async () => { + await runSql(`SELECT * FROM ${T.users};`); + expect(await errors()).toEqual([]); + const rows = await gridRows(); + expect(rows).toHaveLength(5); + expect(new Set(await gridColumns())).toEqual(new Set(['id', 'name', 'plan'])); + expect(rows.map((r) => r.join(' ')).join('\n')).toMatch(/Ana[\s\S]*Ed|Ed[\s\S]*Ana/); + }, 60_000); + + it('selects columns, filters by equality, and reads one key by id', async () => { + await runSql(`SELECT id, name FROM ${T.users} WHERE plan = 'pro';`); + expect(await gridColumns()).toEqual(['id', 'name']); + expect((await gridRows()).map((r) => r[1]).sort()).toEqual(['Bo', 'Cy']); + + await runSql(`SELECT * FROM ${T.users} WHERE id = '4';`); + const rows = await gridRows(); + expect(rows).toHaveLength(1); + expect(rows[0]).toContain('Di'); + }, 60_000); + + it('honours LIMIT and OFFSET', async () => { + await runSql(`SELECT * FROM ${T.users} LIMIT 2;`); + expect(await gridRows()).toHaveLength(2); + await runSql(`SELECT * FROM ${T.users} LIMIT 10 OFFSET 3;`); + expect(await gridRows()).toHaveLength(2); + }, 60_000); + + it('pages the grid with Rows/page, Next and Prev', async () => { + await driver.fill(byTestId('sql-max-rows'), '2'); + try { + await runSql(`SELECT * FROM ${T.users};`); + const seen = new Set(); + const page1 = await gridRows(); + expect(page1).toHaveLength(2); + page1.forEach((r) => seen.add(r[0]!)); + expect(await sql.pageNextEnabled()).toBe(true); + + await sql.clickPageNext(); + await expect.poll(async () => (await gridRows())[0]?.[0]).not.toBe(page1[0]![0]); + (await gridRows()).forEach((r) => seen.add(r[0]!)); + await sql.clickPageNext(); + await expect.poll(async () => (await gridRows()).length).toBe(1); + (await gridRows()).forEach((r) => seen.add(r[0]!)); + expect([...seen].sort()).toEqual(['1', '2', '3', '4', '5']); + expect(await sql.pageNextEnabled()).toBe(false); + + await sql.clickPagePrev(); + await expect.poll(async () => (await gridRows()).length).toBe(2); + } finally { + await driver.fill(byTestId('sql-max-rows'), '200'); + } + }, 90_000); + + it('INSERT, UPDATE and DELETE write the hash — and nothing else', async () => { + await runSql(`INSERT INTO ${T.users} (id, name, plan) VALUES ('9', 'Zed', 'free');`, { write: true }); + expect(await errors()).toEqual([]); + expect(hash(0, `${T.users}:9`)).toEqual({ name: 'Zed', plan: 'free' }); + + await runSql(`UPDATE ${T.users} SET plan = 'pro' WHERE id = '9';`, { write: true }); + expect(hash(0, `${T.users}:9`)).toEqual({ name: 'Zed', plan: 'pro' }); + + // A non-id predicate that does not hold leaves the row alone. + await runSql(`UPDATE ${T.users} SET plan = 'team' WHERE id = '9' AND name = 'Nobody';`, { write: true }); + expect(hash(0, `${T.users}:9`).plan).toBe('pro'); + + await runSql(`DELETE FROM ${T.users} WHERE id = '9';`, { write: true }); + expect(keys(0, `${T.users}:9`)).toEqual([]); + expect(keys(0, `${T.users}:*`)).toHaveLength(5); + }, 90_000); + + it.each([ + ['a DELETE with no WHERE', (t: typeof T) => `DELETE FROM ${t.users};`, true], + ['a range predicate', (t: typeof T) => `SELECT * FROM ${t.users} WHERE id > 1;`, false], + ['a join', (t: typeof T) => `SELECT * FROM ${t.users} u JOIN ${t.docs} d ON u.id = d.id;`, false], + ['an OR', (t: typeof T) => `SELECT * FROM ${t.users} WHERE id = '1' OR id = '2';`, false], + ['an aggregate', (t: typeof T) => `SELECT COUNT(*) FROM ${t.users};`, false], + ] as const)('refuses %s with a message, and changes nothing', async (_label, build, write) => { + await runSql(build(T), { write }); + const errs = await errors(); + expect(errs.length, 'no error shown').toBeGreaterThan(0); + expect(errs.join('\n')).not.toMatch(/Paging failed/); + expect(keys(0, `${T.users}:*`)).toHaveLength(5); + }, 60_000); + + it('reads a list, a set, a sorted set and a stream as tables of their elements', async () => { + await runSql(`SELECT * FROM "${T.jobs}";`); + expect(await gridColumns()).toEqual(['index', 'job', 'to', 'value']); + // A JSON element fills `job`; the plain one fills `value` (the grid prints NULL for the other). + const cell = (v?: string) => (v && v !== 'NULL' ? v : ''); + expect((await gridRows()).map((r) => cell(r[1]) || cell(r[3]))).toEqual(['send', 'bill', 'plain-entry']); + + await runSql(`SELECT * FROM "${T.tags}";`); + expect((await gridRows()).map((r) => r[0]).sort()).toEqual(['blue', 'green', 'red']); + + await runSql(`SELECT member, score FROM "${T.board}";`); + expect((await gridRows()).map((r) => r[0])).toEqual(['al', 'bo', 'cy']); + + await runSql(`SELECT level, msg FROM "${T.log}" WHERE level = 'warn';`); + expect(await gridRows()).toEqual([['warn', 'slow']]); + }, 90_000); + + it('reads JSON documents as their fields', async () => { + await runSql(`SELECT id, title, n, tags FROM ${T.docs};`); + const rows = await gridRows(); + expect(rows.map((r) => r[1]).sort()).toEqual(['Hello', 'World']); + expect(rows.find((r) => r[1] === 'Hello')?.[3]).toBe('["x"]'); + }, 60_000); + + it('refuses to write a collection or a JSON document, and leaves them unchanged', async () => { + await runSql(`INSERT INTO "${T.jobs}" (id, job) VALUES ('9', 'x');`, { write: true }); + expect((await errors()).join('\n')).toMatch(/read-only/); + expect(redis(0, 'LLEN', T.jobs)).toBe('3'); + + await runSql(`UPDATE ${T.docs} SET title = 'x' WHERE id = 'a';`, { write: true }); + expect((await errors()).join('\n')).toMatch(/only hash keys are updated/); + expect(JSON.parse(redis(0, 'GET', `${T.docs}:a`)).title).toBe('Hello'); + await saveScreenshot(driver, 'redis-editor-refusal'); + }, 60_000); + }); + + // ── Samples ─────────────────────────────────────────────────────────── + + describe('built-in samples', () => { + type Outcome = { id: string; title: string; ok: boolean; error: string }; + const outcomes: Outcome[] = []; + + beforeAll(async () => { + writeFileSync(SAMPLE_LOG, ''); + await destinations(CONN.s); + await sql.ensureSidebarSectionOpen('bookmarks'); + await clickWhen(driver, byTestId('sql-bookmark-install-samples')); + }, 60_000); + + it('runs every sample against Redis: each finishes, and a failure says why', async () => { + // The editor persists 400 ms after a change, under a key scoped to the + // open workspace (`foxschema-sql-editor:`); read whichever holds them. + await driver.waitForTimeout(600); + const samples: Array<{ id: string; title: string; sql: string }> = await driver.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + for (const key of Object.keys(localStorage).filter((k) => k.startsWith('foxschema-sql-editor'))) { + const found = (JSON.parse(localStorage.getItem(key) ?? '{}')?.state?.bookmarks ?? []).filter( + (b: { id: string }) => b.id.startsWith('sample-') + ); + if (found.length) return found; + } + return []; + }); + expect(samples.length).toBeGreaterThan(30); + + const mod = process.platform === 'darwin' ? 'Meta' : 'Control'; + for (const s of samples) { + // The sample's own text, not typed: typing runs Monaco's auto-indent + // and bracket closing over it, and takes a minute for the long ones. + await sql.waitForMonaco(); + await driver.locator('.monaco-editor').first().click(); + await driver.keyboard.press(`${mod}+KeyA`); + await driver.keyboard.insertText(s.sql); + await driver.waitForTimeout(350); + // With nothing selected, Run runs the statement at the caret — here the + // last cell. Select the whole buffer so every cell runs, in order. + await driver.keyboard.press(`${mod}+KeyA`); + await sql.run(); + const settled = await settle(45_000); + const errs = settled ? await errors() : ['HUNG: still running after 45 s']; + const o = { id: s.id, title: s.title, ok: errs.length === 0, error: errs.join(' | ').slice(0, 200) }; + outcomes.push(o); + // Written as it goes, so a run that times out still says how far it got. + appendFileSync(SAMPLE_LOG, `${o.ok ? 'OK ' : 'FAIL'} ${o.id}${o.ok ? '' : ` — ${o.error}`}\n`); + if (!settled) { + // Nothing stops a run from the toolbar: start the editor over. + await driver.reload(); + await driver.waitForSelector(byTestId('toolbar')); + await sql.openView(); + await destinations(CONN.s); + } + } + + // Printed for the report: which samples run on Redis, and why the rest stop. + console.log( + '\nSamples on Redis:\n' + + outcomes.map((o) => `${o.ok ? 'OK ' : 'FAIL'} ${o.id}${o.ok ? '' : ` — ${o.error}`}`).join('\n') + ); + + // None may hang, hit the paging bug, or stop without saying why. + for (const o of outcomes) { + expect(o.error, o.id).not.toMatch(/^HUNG|Paging failed|Unsupported clause: \* FROM \(/); + if (!o.ok) expect(o.error.length, `${o.id} failed silently`).toBeGreaterThan(0); + } + // Code-only samples (no SQL, no network) never touch the store: they must run. + for (const id of ['sample-js-pure-grid', 'sample-js-try-catch', 'sample-js-faker-random-data']) { + const o = outcomes.find((x) => x.id === id); + if (o) expect(o.ok, `${id}: ${o.error}`).toBe(true); + } + // The A/B databases were never touched by a sample. + expect(keys(0, `${T.users}:*`)).toHaveLength(5); + }, 1_800_000); + }); + + // ── Compare data ────────────────────────────────────────────────────── + + describe('Compare data and Data migrate', () => { + /** Pick the Compare Source by its Destination name. */ + async function chooseSource(name: string): Promise { + const select = sql.compareBaselineSelect(0); + const value = await select.locator('option', { hasText: name }).first().getAttribute('value'); + if (!value) throw new Error(`no Compare source named ${name}`); + await select.selectOption(value); + } + + beforeAll(async () => { + await destinations(CONN.a, CONN.b); + }, 60_000); + + it('aligns the two databases by key and counts each kind of difference', async () => { + await runSql(`SELECT * FROM ${T.users};`); + expect(await errors()).toEqual([]); + await sql.setLayoutSideBySide(); + await waitFor(driver, byTestId('sql-result-compare-toggle-0'), 20_000); + if (!(await sql.compareToggle(0).isChecked())) await sql.compareToggle(0).click(); + await waitFor(driver, byTestId('sql-data-migrate-bar-0'), 20_000); + // Source defaults to whichever Destination is listed first; make it A. + await chooseSource(CONN.a); + const direction = async () => { + const bar = await driver.locator(byTestId('sql-data-migrate-bar-0')).innerText(); + const arrow = bar.indexOf('→'); + return bar.indexOf(CONN.a) < arrow && bar.indexOf(CONN.b) > arrow ? 'A→B' : 'B→A'; + }; + await expect.poll(direction, { timeout: 10_000 }).toBe('A→B'); + + // Spelled out so the test-ID catalog can check each one exists. + const CHIPS = { + add: 'sql-data-migrate-chip-add-0', + edit: 'sql-data-migrate-chip-edit-0', + delete: 'sql-data-migrate-chip-delete-0', + } as const; + const chip = async (kind: keyof typeof CHIPS) => + Number(((await driver.locator(byTestId(CHIPS[kind])).innerText()).match(/\d+/) ?? ['0'])[0]); + // A → B: 3, 4 and 5 are only in A (add), 2 differs (edit), 6 is only in B (delete). + await expect.poll(() => chip('add'), { timeout: 15_000 }).toBe(3); + expect(await chip('edit')).toBe(1); + expect(await chip('delete')).toBe(1); + expect(await sql.diffCellCount()).toBeGreaterThan(0); + await saveScreenshot(driver, 'redis-compare'); + }, 90_000); + + it('applies the migrate to B, after which B holds exactly what A does', async () => { + await clickWhen(driver, byTestId('sql-data-migrate-apply-0')); + // Data migrate asks through its own Safe mode dialog, not the editor's. + await clickWhen(driver, byTestId('sql-data-migrate-safe-confirm-apply')); + await waitFor(driver, byTestId('sql-data-migrate-progress'), 20_000); + const failures = driver.locator(byTestId('sql-data-migrate-failures-0')); + await expect + .poll( + async () => + (await failures.count()) ? `FAILED: ${await failures.innerText()}` : keys(1, `${T.users}:*`).join(','), + { timeout: 30_000 } + ) + .toBe(keys(0, `${T.users}:*`).join(',')); + await saveScreenshot(driver, 'redis-migrate-applied'); + // The progress panel gives way to a toast once the run ends. + const progress = driver.locator(byTestId('sql-data-migrate-progress-close')); + if (await progress.isVisible()) await progress.click(); + for (const id of ['1', '2', '3', '4', '5']) { + expect(hash(1, `${T.users}:${id}`), id).toEqual(hash(0, `${T.users}:${id}`)); + } + expect(keys(1, `${T.users}:6`)).toEqual([]); + }, 90_000); + + it('compares clean afterwards', async () => { + await runSql(`SELECT * FROM ${T.users};`); + await waitFor(driver, byTestId('sql-result-compare-toggle-0'), 20_000); + if (!(await sql.compareToggle(0).isChecked())) await sql.compareToggle(0).click(); + await expect.poll(() => sql.diffCellCount(), { timeout: 15_000 }).toBe(0); + await sql.setLayoutByCredential(); + }, 60_000); + }); + + // ── Schema explorer ─────────────────────────────────────────────────── + + describe('schema explorer', () => { + beforeAll(async () => { + await destinations(CONN.a); + }, 60_000); + + it('lists each prefix and collection key with its type, and offers no table to create', async () => { + await sql.ensureSidebarSectionOpen('schema'); + for (const [name, type] of [ + [T.users, 'hash'], + [T.docs, 'json'], + [T.jobs, 'list'], + [T.tags, 'set'], + [T.board, 'zset'], + [T.log, 'stream'], + ] as const) { + const badge = byTestId(`sql-explorer-store-type-${name}`); + await waitFor(driver, badge, 30_000); + expect((await driver.locator(badge).innerText()).toLowerCase(), name).toBe(type); + } + // A key has no DDL: no blueprint for any of them, and none of the three + // ways to create a table (the blueprint would write Db2 DDL for Redis). + expect(await driver.locator(`${byTestId('sql-schema-explorer')} ${byTestId('sql-open-blueprint')}`).count()).toBe(0); + for (const id of ['sql-schema-new-table', 'sql-new-table', 'sql-new-table-empty'] as const) { + expect(await driver.locator(byTestId(id)).count(), id).toBe(0); + } + await saveScreenshot(driver, 'redis-explorer'); + }, 120_000); + }); + + // ── Data peek ───────────────────────────────────────────────────────── + + describe('Data peek', () => { + const peekRows = (): Promise => + driver.evaluate(() => + [...document.querySelectorAll('[data-testid^="data-peek-grid-"] [data-testid="sql-data-grid"] tbody tr')] + .filter((tr) => tr.querySelector('[data-testid="sql-row-num"]')) + .map((tr) => [...tr.querySelectorAll('td')].slice(1).map((td) => (td.textContent ?? '').trim())) + ); + + async function peek(table: string): Promise { + if (await driver.locator(byTestId('data-peek')).count()) { + await clickWhen(driver, byTestId('data-peek-close')); + await driver.locator(byTestId('data-peek')).waitFor({ state: 'detached' }); + } + // A list or a JSON prefix is read-only here: wait for its rows, not for row editing. + await sql.openDataPeek(table, { waitFor: 'rows' }); + } + + beforeAll(async () => { + await destinations(CONN.a); + }, 60_000); + + it('peeks a hash prefix, filters it with WHERE, and pages it', async () => { + await peek(T.users); + expect(await peekRows()).toHaveLength(5); + + const where = driver.locator('[data-testid^="data-peek-where-"]').first(); + await where.fill("plan = 'pro'"); + await where.press('Enter'); + await expect.poll(async () => (await peekRows()).map((r) => r[1]).sort(), { timeout: 15_000 }).toEqual(['Bo', 'Cy']); + await driver.locator('[data-testid^="data-peek-where-clear-"]').first().click(); + await expect.poll(async () => (await peekRows()).length, { timeout: 15_000 }).toBe(5); + + const limit = driver.locator('[data-testid^="data-peek-limit-"]').first(); + await limit.fill('2'); + await limit.press('Enter'); + await expect.poll(async () => (await peekRows()).length, { timeout: 15_000 }).toBe(2); + const first = (await peekRows())[0]![0]; + await driver.locator(`[data-testid^="data-peek-grid-"] ${byTestId('sql-page-next')}`).first().click(); + await expect.poll(async () => (await peekRows())[0]?.[0], { timeout: 15_000 }).not.toBe(first); + await limit.fill('50'); + await limit.press('Enter'); + await expect.poll(async () => (await peekRows()).length, { timeout: 15_000 }).toBe(5); + }, 120_000); + + it('adds, edits and deletes a hash row through the row editor', async () => { + // Add + await clickWhen(driver, '[data-testid^="data-peek-add-"]'); + const form = driver.locator(byTestId('peek-row-editor')); + await form.waitFor({ state: 'visible', timeout: 10_000 }); + await form.locator(byTestId('peek-row-field-id')).fill('8'); + await form.locator(byTestId('peek-row-field-name')).fill('Gus'); + await form.locator(byTestId('peek-row-field-plan')).fill('free'); + await clickWhen(driver, byTestId('peek-row-submit')); + await clickWhen(driver, byTestId('peek-row-save')); + await sql.confirmWriteIfShown(); + await form.waitFor({ state: 'detached', timeout: 15_000 }); + expect(hash(0, `${T.users}:8`)).toEqual({ name: 'Gus', plan: 'free' }); + await expect.poll(async () => (await peekRows()).length, { timeout: 15_000 }).toBe(6); + + // Edit: select Gus's row, change the plan. + const gusRow = async () => (await peekRows()).findIndex((r) => r[1] === 'Gus'); + await expect.poll(gusRow).toBeGreaterThanOrEqual(0); + await driver + .locator(`[data-testid^="data-peek-grid-"] [data-testid="sql-row-${await gusRow()}"] td`) + .nth(2) + .click(); + await clickWhen(driver, '[data-testid^="data-peek-edit-"]'); + await form.waitFor({ state: 'visible', timeout: 10_000 }); + await form.locator(byTestId('peek-row-field-plan')).fill('team'); + await clickWhen(driver, byTestId('peek-row-submit')); + await clickWhen(driver, byTestId('peek-row-save')); + await sql.confirmWriteIfShown(); + await form.waitFor({ state: 'detached', timeout: 15_000 }); + await expect.poll(() => hash(0, `${T.users}:8`).plan, { timeout: 15_000 }).toBe('team'); + + // Delete + await expect.poll(gusRow).toBeGreaterThanOrEqual(0); + await driver + .locator(`[data-testid^="data-peek-grid-"] [data-testid="sql-row-${await gusRow()}"] td`) + .nth(2) + .click(); + await clickWhen(driver, '[data-testid^="data-peek-delete-"]'); + await sql.confirmWriteIfShown(); + await expect.poll(() => keys(0, `${T.users}:8`).length, { timeout: 15_000 }).toBe(0); + await expect.poll(async () => (await peekRows()).length, { timeout: 15_000 }).toBe(5); + await saveScreenshot(driver, 'redis-peek-crud'); + }, 180_000); + + it('peeks a list and a JSON prefix read-only, with no row editing offered', async () => { + await peek(T.jobs); + expect((await peekRows()).length).toBe(3); + expect(await driver.locator('[data-testid^="data-peek-add-"]').count()).toBe(0); + expect(await driver.locator(byTestId('data-peek')).innerText()).toMatch(/Read-only/); + + await peek(T.docs); + expect((await peekRows()).map((r) => r.join(' ')).join('\n')).toMatch(/Hello[\s\S]*World|World[\s\S]*Hello/); + expect(await driver.locator('[data-testid^="data-peek-add-"]').count()).toBe(0); + await clickWhen(driver, byTestId('data-peek-close')); + }, 120_000); + }); +}); diff --git a/apps/web/src/frontend/features/compare/components/CompareToolbar.tsx b/apps/web/src/frontend/features/compare/components/CompareToolbar.tsx index 6a7b58f51..f68830236 100644 --- a/apps/web/src/frontend/features/compare/components/CompareToolbar.tsx +++ b/apps/web/src/frontend/features/compare/components/CompareToolbar.tsx @@ -21,7 +21,7 @@ import { toast } from '@/platform/toastStore'; import { loadConnectionModal } from '@/features/connections'; import { captureSchema } from '@/features/lokee-weave'; import { DiffBriefingChips, TYPE_META, TYPE_ORDER, diffBriefing } from '@/features/schema-diff'; -import { connectionNeedsSecret } from '@/shared/lib/provider-settings'; +import { needsSessionPassword } from '@/shared/lib/provider-settings'; import { schemaCompareBlocker } from '@/shared/lib/dialect-features'; import { MountWhenOpened } from '@/shared/components/MountWhenOpened'; import { PasswordInput } from '@/shared/components/PasswordInput'; @@ -117,7 +117,7 @@ export const CompareToolbarStart: React.FC = () => { const selectSavedConnection = (side: 'source' | 'target', id: string) => { const conn = connections.find((c) => c.id === id); - if (conn && !conn.hasPassword && connectionNeedsSecret(conn.dialect, conn.authMethod)) { + if (conn && needsSessionPassword(conn)) { const cfg = side === 'source' ? sourceConfig : targetConfig; const existing = getSessionPassword(id) || diff --git a/apps/web/src/frontend/features/compare/state/syncRetestGate.test.ts b/apps/web/src/frontend/features/compare/state/syncRetestGate.test.ts index 27fd6a85c..184470368 100644 --- a/apps/web/src/frontend/features/compare/state/syncRetestGate.test.ts +++ b/apps/web/src/frontend/features/compare/state/syncRetestGate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { connectionNeedsSecret } from '@/shared/lib/provider-settings'; +import { needsSessionPassword } from '@/shared/lib/provider-settings'; /** * Whether a saved connection can be silently re-tested after a credential @@ -12,12 +12,12 @@ import { connectionNeedsSecret } from '@/shared/lib/provider-settings'; * connection silently knocked the source offline and disabled Compare, with no * recovery but reconnecting by hand. * - * Mirrors the expression at useSyncStore.ts:143. + * The gate in useSyncStore.ts: `!needsSessionPassword(conn, sessionPassword)`. */ const canRetest = ( - conn: { hasPassword: boolean; dialect: string; authMethod?: string }, + conn: { hasPassword: boolean; dialect: string; authMethod?: string; username?: string }, sessionPassword?: string -) => !!(conn.hasPassword || sessionPassword || !connectionNeedsSecret(conn.dialect, conn.authMethod)); +) => !needsSessionPassword(conn, sessionPassword); describe('credential-reload retest gate', () => { it.each(['sqlite', 'duckdb'])( @@ -49,6 +49,11 @@ describe('credential-reload retest gate', () => { expect(canRetest({ hasPassword: false, dialect: 'postgres' }, 'hunter2')).toBe(true); }); + it('a passwordless Redis retests; one that signs in as a user needs its password', () => { + expect(canRetest({ hasPassword: false, dialect: 'redis' })).toBe(true); + expect(canRetest({ hasPassword: false, dialect: 'redis', username: 'app' })).toBe(false); + }); + it('does not quietly widen the gate to unknown dialects', () => { // An unrecognised dialect must behave like a server, not like a file — the // safe default is to ask rather than to auto-connect. diff --git a/apps/web/src/frontend/features/compare/state/useSyncStore.ts b/apps/web/src/frontend/features/compare/state/useSyncStore.ts index d473fe6a2..670a85441 100644 --- a/apps/web/src/frontend/features/compare/state/useSyncStore.ts +++ b/apps/web/src/frontend/features/compare/state/useSyncStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { buildBrowseResult } from '@/shared/lib/browse'; -import { connectionNeedsSecret } from '@/shared/lib/provider-settings'; +import { needsSessionPassword } from '@/shared/lib/provider-settings'; import { schemaCompareBlocker } from '@/shared/lib/dialect-features'; import { testConnection as apiTestConnection, @@ -282,11 +282,7 @@ export const useSyncStore = create()( // connected to disconnected on every credential reload — saving any // other connection knocked the source offline with no way back but // reconnecting by hand. - const canRetest = !!( - conn.hasPassword || - sessionPassword || - !connectionNeedsSecret(conn.dialect, conn.authMethod) - ); + const canRetest = !needsSessionPassword(conn, sessionPassword); if (side === 'source') { patch.selectedSourceConnectionId = id; patch.sourceConfig = config; diff --git a/apps/web/src/frontend/features/sql-editor/components/DataGrid.tsx b/apps/web/src/frontend/features/sql-editor/components/DataGrid.tsx index 5761b9035..a6b7a793d 100644 --- a/apps/web/src/frontend/features/sql-editor/components/DataGrid.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/DataGrid.tsx @@ -924,7 +924,10 @@ export const DataGrid: React.FC<{ onRefresh={onRefresh} toolbarExtra={toolbarExtra} /> -
+
{result.error}
diff --git a/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx b/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx index aceae5fb9..0c5204c2c 100644 --- a/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx @@ -621,7 +621,10 @@ const PaneBody: React.FC<{ )}
-
+
{item.error}
@@ -1882,7 +1885,10 @@ const CredentialResultsSection: React.FC<{ {run.status === 'error' && (
-
+
{run.error}
diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx index f28a3e545..19f15b0e1 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx @@ -175,6 +175,8 @@ export const SqlEditorView: React.FC = () => { const [dragOver, setDragOver] = useState(null); const secretsPanelRef = useRef(null); const schemaExplorerRef = useRef(null); + /** Whether the browsed connection has tables to create (not Redis or MongoDB). */ + const [explorerCanCreateTable, setExplorerCanCreateTable] = useState(true); const [secretsRefreshing, setSecretsRefreshing] = useState(false); const [runsOpen, setRunsOpen] = useState(false); @@ -522,20 +524,22 @@ export const SqlEditorView: React.FC = () => { height={sectionHeights.schema} onResizeHeight={(h) => setSectionHeight('schema', h)} actions={ - + !explorerCanCreateTable ? undefined : ( + + ) } {...drag} > - + ); default: @@ -547,6 +551,7 @@ export const SqlEditorView: React.FC = () => { canEditorBookmarks, canEditorVariables, canVariablesRead, + explorerCanCreateTable, canEditorSecrets, canSecretsView, canEditorSchema, diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx index 36a36110b..cb17a88f4 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { ChevronDown, ChevronRight, @@ -11,7 +11,7 @@ import { import { useSyncStore } from '@/features/compare'; import { SCHEMA_ROUTINE_SCOPE, useSqlEditorStore } from '@/features/sql-editor/state/useSqlEditorStore'; import { connectionPickerOption, FilterPicker } from '@/shared/components/FilterPicker'; -import { quoteIdentifierIfNeeded } from '@foxschema/ui-shared'; +import { hasSqlDialect, isSubsetDialect, quoteIdentifierIfNeeded } from '@foxschema/ui-shared'; import { getProviderSettings } from '@/shared/lib/provider-settings'; import { TYPE_META } from '@/features/schema-diff'; import { @@ -31,7 +31,7 @@ import { TableBlueprintModal, type BlueprintMode } from './TableBlueprintModal'; import { MissingDriverBanner } from './MissingDriverBanner'; import { isScriptableObject, objectSourceScript } from '../lib/objectSourceScript'; import { fetchDbaUtility } from '@/shared/api/schemaApi'; -import { connectionNeedsSecret } from '@/shared/lib/provider-settings'; +import { needsSessionPassword } from '@/shared/lib/provider-settings'; import { dialectSupportsDbaUtility, formatBytes, @@ -43,6 +43,7 @@ import { /** Imperative API for the Schema section header (New table). */ export interface SqlSchemaExplorerHandle { + /** Opens the create blueprint; does nothing for an engine without tables to create. */ openCreateTable: () => void; } @@ -100,10 +101,13 @@ function writeStoredExplorerId(id: string): void { * destinations moves the explorer onto a checked credential when the current * one is no longer checked. */ -export const SqlSchemaExplorer = forwardRef(function SqlSchemaExplorer( - _props, - ref -) { +export const SqlSchemaExplorer = forwardRef< + SqlSchemaExplorerHandle, + { + /** Whether the browsed connection has tables to create, so the header offers New table only then. */ + onCanCreateTableChange?: (canCreate: boolean) => void; + } +>(function SqlSchemaExplorer({ onCanCreateTableChange }, ref) { const connections = useSyncStore((s) => s.connections); const connectionsLoaded = useSyncStore((s) => s.connectionsLoaded); const tabs = useSqlEditorStore((s) => s.tabs); @@ -148,12 +152,6 @@ export const SqlSchemaExplorer = forwardRef(function Sq if (id) selectOnlyConnection(id); }; - const openCreateTable = () => { - setBlueprintMode('create'); - setBlueprintTable(null); - }; - - useImperativeHandle(ref, () => ({ openCreateTable }), []); useEffect(() => { if (!connectionsLoaded) return; @@ -226,6 +224,24 @@ export const SqlSchemaExplorer = forwardRef(function Sq const conn = connections.find((c) => c.id === explorerId); const sessionPassword = explorerId ? sessionPasswords[explorerId] : undefined; + /** + * Only an engine with SQL DDL has tables to create and a blueprint to edit. + * Redis and MongoDB do not: the blueprint would write DDL for an engine with + * none (`resolveDialect` answers Db2 for a name it does not know). + */ + const canCreateTable = !conn || hasSqlDialect(conn.dialect); + + const openCreateTable = useCallback(() => { + if (!canCreateTable) return; + setBlueprintMode('create'); + setBlueprintTable(null); + }, [canCreateTable]); + + useImperativeHandle(ref, () => ({ openCreateTable }), [openCreateTable]); + + useEffect(() => { + onCanCreateTableChange?.(canCreateTable); + }, [canCreateTable, onCanCreateTableChange]); useEffect(() => { if (!explorerId || entry?.status !== 'ready' || !conn) { @@ -237,7 +253,7 @@ export const SqlSchemaExplorer = forwardRef(function Sq return; } // File dialects carry no password; waiting for one never resolved. - if (!conn.hasPassword && connectionNeedsSecret(conn.dialect, conn.authMethod) && !sessionPassword) return; + if (needsSessionPassword(conn, sessionPassword)) return; let cancelled = false; void fetchDbaUtility( { connectionId: explorerId, password: sessionPassword }, @@ -292,17 +308,19 @@ export const SqlSchemaExplorer = forwardRef(function Sq placeholder="Filter connections…" title="Sets Destinations to this connection. Check more destinations to run the same SQL on several." /> - + {canCreateTable && ( + + )} + {canCreateTable && ( + + )}
)} @@ -413,7 +435,7 @@ export const SqlSchemaExplorer = forwardRef(function Sq schema={conn?.schema} sizeGroup={sizeOf(t.name, conn?.schema)} onOpenBlueprint={ - t.objectType === 'TABLE' || t.objectType === 'MQT' + (t.objectType === 'TABLE' || t.objectType === 'MQT') && canCreateTable ? () => { setBlueprintMode('edit'); setBlueprintTable(t); @@ -459,6 +481,18 @@ export const SqlSchemaExplorer = forwardRef(function Sq ); }); +/** What each Redis store type means, on the badge's tooltip. */ +const STORE_TYPE_HINT: Record = { + hash: 'Hashes: one key per row, its fields as columns', + json: 'JSON documents: one key per row, top-level fields as columns', + string: 'Strings: one key per row, the value in one column', + mixed: 'Keys of several types under this prefix', + list: 'A list: one element per row, in list order', + set: 'A set: one member per row', + zset: 'A sorted set: one member per row, with its score', + stream: 'A stream: one entry per row, by id', +}; + const ObjectNode: React.FC<{ table: TableSchema; open: boolean; @@ -485,6 +519,11 @@ const ObjectNode: React.FC<{ onPeek, }) => { const meta = TYPE_META[table.objectType] ?? TYPE_META.TABLE; + /** Offer "From": the object reads as rows (a table, an MQT, a Redis key). */ + const canInsertFrom = table.objectType === 'TABLE' || table.objectType === 'MQT'; + // Redis and MongoDB read SQL through a small parser: one bare table name, no + // alias and no `alias.column` (sql-subset.ts refuses both). + const subset = isSubsetDialect(dialect); const insertName = quoteIdentifierIfNeeded(table.name, dialect); const schemaName = schema?.trim(); const qualifiedName = @@ -504,6 +543,10 @@ const ObjectNode: React.FC<{ insertAtCursor(routineInsertText(qualifiedName, table.objectType, params)); return; } + if (subset) { + insertAtCursor(`${insertName} `); + return; + } // Tables/views: add into FROM with an auto alias (e.g. users u). const ok = mutateSql((sql) => addTableWithAliasToFrom(sql, qualifiedName).sql); if (!ok) insertAtCursor(`${qualifiedName} `); @@ -541,7 +584,7 @@ const ObjectNode: React.FC<{ /** Add `alias.column` into the active SELECT list (or insert at cursor). */ const insertColumnIntoSelect = (colName: string) => { const col = quoteIdentifierIfNeeded(colName, dialect); - const expr = `${resolveTableAlias()}.${col}`; + const expr = subset ? col : `${resolveTableAlias()}.${col}`; const ok = mutateSql((sql) => insertIntoSelectList(sql, expr)); if (!ok) insertAtCursor(`${expr}, `); }; @@ -594,6 +637,15 @@ const ObjectNode: React.FC<{ {table.name} + {table.storeType && ( + + {table.storeType} + + )} {isRoutine && params.length > 0 && ( ({params.length}) @@ -612,10 +664,10 @@ const ObjectNode: React.FC<{ {/* One group, so the actions wrap as a unit instead of splitting apart. */} - {onOpenBlueprint && ( + {canInsertFrom && (