From 4b934b9630f48528d89a74e46390784b55d70531 Mon Sep 17 00:00:00 2001 From: rahul-aot Date: Fri, 16 Oct 2026 08:39:34 -0700 Subject: [PATCH 1/8] feat(postgres-query-builder): add SQL identifier quoting, literal serialization, and project state management - Implemented SQL identifier quoting and safe literal serialization in sql.ts. - Added useProject hook for managing project state with undo/redo functionality and localStorage persistence. - Created index.ts to define the PostgreSQL Query Builder tool with metadata. - Registered the PostgreSQL Query Builder tool in the tools registry. - Added comprehensive tests for SQL generation, including identifier quoting, literal validation, and DDL generation. --- README.md | 5 +- e2e/gitignore-generator.spec.ts | 10 + e2e/postgres-query-builder.spec.ts | 99 +++++++ src/shared/ui/convert.tsx | 7 +- .../GitignoreGeneratorPage.tsx | 46 ++- src/tools/gitignore-generator/README.md | 4 +- src/tools/gitignore-generator/docs.ts | 2 +- .../features/gitignore-generator.ts | 9 + .../PostgresQueryBuilderPage.tsx | 265 +++++++++++++++++ src/tools/postgres-query-builder/README.md | 49 +++ .../components/ConditionBuilder.tsx | 160 ++++++++++ .../components/QueryBuilder.tsx | 244 +++++++++++++++ .../components/Reference.tsx | 83 ++++++ .../components/RelationshipPanel.tsx | 114 +++++++ .../components/SchemaCanvas.tsx | 257 ++++++++++++++++ .../components/SqlPanel.tsx | 120 ++++++++ .../components/TableEditor.tsx | 197 ++++++++++++ .../components/fields.tsx | 124 ++++++++ src/tools/postgres-query-builder/docs.ts | 61 ++++ .../postgres-query-builder/features/ddl.ts | 202 +++++++++++++ .../features/explain.ts | 68 +++++ .../postgres-query-builder/features/model.ts | 219 ++++++++++++++ .../features/project.ts | 262 ++++++++++++++++ .../postgres-query-builder/features/query.ts | 280 ++++++++++++++++++ .../features/samples.ts | 173 +++++++++++ .../postgres-query-builder/features/sql.ts | 98 ++++++ .../hooks/useProject.ts | 74 +++++ src/tools/postgres-query-builder/index.ts | 17 ++ src/tools/registry.ts | 2 + .../gitignore-generator.test.ts | 11 +- .../sql-generation.test.ts | 274 +++++++++++++++++ 31 files changed, 3523 insertions(+), 13 deletions(-) create mode 100644 e2e/postgres-query-builder.spec.ts create mode 100644 src/tools/postgres-query-builder/PostgresQueryBuilderPage.tsx create mode 100644 src/tools/postgres-query-builder/README.md create mode 100644 src/tools/postgres-query-builder/components/ConditionBuilder.tsx create mode 100644 src/tools/postgres-query-builder/components/QueryBuilder.tsx create mode 100644 src/tools/postgres-query-builder/components/Reference.tsx create mode 100644 src/tools/postgres-query-builder/components/RelationshipPanel.tsx create mode 100644 src/tools/postgres-query-builder/components/SchemaCanvas.tsx create mode 100644 src/tools/postgres-query-builder/components/SqlPanel.tsx create mode 100644 src/tools/postgres-query-builder/components/TableEditor.tsx create mode 100644 src/tools/postgres-query-builder/components/fields.tsx create mode 100644 src/tools/postgres-query-builder/docs.ts create mode 100644 src/tools/postgres-query-builder/features/ddl.ts create mode 100644 src/tools/postgres-query-builder/features/explain.ts create mode 100644 src/tools/postgres-query-builder/features/model.ts create mode 100644 src/tools/postgres-query-builder/features/project.ts create mode 100644 src/tools/postgres-query-builder/features/query.ts create mode 100644 src/tools/postgres-query-builder/features/samples.ts create mode 100644 src/tools/postgres-query-builder/features/sql.ts create mode 100644 src/tools/postgres-query-builder/hooks/useProject.ts create mode 100644 src/tools/postgres-query-builder/index.ts create mode 100644 tests/tools/postgres-query-builder/sql-generation.test.ts diff --git a/README.md b/README.md index cced1c3..39f882d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Live at [zykit.vercel.app](https://zykit.vercel.app). -115 tools in 12 categories. Each tool's **README** covers its code, limits and tests; the user guide (how to use, limits, privacy, FAQs) is on its page on the site. +116 tools in 12 categories. Each tool's **README** covers its code, limits and tests; the user guide (how to use, limits, privacy, FAQs) is on its page on the site. ### Images (13) @@ -32,7 +32,7 @@ Live at [zykit.vercel.app](https://zykit.vercel.app). | [**QR Code Reader**](https://zykit.vercel.app/tools/qr-code-reader) | Decode QR codes from an image, screenshot or paste and see the content, with links and Wi-Fi details shown safely. | [README](src/tools/qr-code-reader/README.md) | | [**Signature Pad**](https://zykit.vercel.app/tools/signature-pad) | Draw or type your signature and download it as a transparent PNG or SVG to place on documents. | [README](src/tools/signature-pad/README.md) | -### Data (18) +### Data (19) | Tool | What it does | Docs | |---|---|---| @@ -54,6 +54,7 @@ Live at [zykit.vercel.app](https://zykit.vercel.app). | [**JSON Tree Viewer**](https://zykit.vercel.app/tools/json-tree-viewer) | Browse big JSON files as an expandable tree with search, path copy, type badges and array counts, without uploading them. | [README](src/tools/json-tree-viewer/README.md) | | [**XML Formatter**](https://zykit.vercel.app/tools/xml-formatter) | Pretty-print or minify XML, find syntax errors by line and column, and test XPath expressions in your browser. | [README](src/tools/xml-formatter/README.md) | | [**Excel to CSV / JSON**](https://zykit.vercel.app/tools/excel-to-csv) | Open an Excel workbook, preview each sheet and export it as CSV, TSV or JSON, without uploading the file. | [README](src/tools/excel-to-csv/README.md) | +| [**PostgreSQL Query Builder**](https://zykit.vercel.app/tools/postgres-query-builder) | Draw tables and relationships, build SELECT queries with joins, filters and grouping without writing SQL, and generate CREATE TABLE statements for PostgreSQL. | [README](src/tools/postgres-query-builder/README.md) | ### Code (11) diff --git a/e2e/gitignore-generator.spec.ts b/e2e/gitignore-generator.spec.ts index c7e9673..780821b 100644 --- a/e2e/gitignore-generator.spec.ts +++ b/e2e/gitignore-generator.spec.ts @@ -27,6 +27,16 @@ test('gitignore Generator works locally', async ({ page }) => { await expect(results.getByRole('listitem').nth(2)).toContainText('Not ignored'); await expect(results.getByRole('listitem').nth(3)).toContainText('Matched line'); + // Hand edits are kept, used by the tester and the download, and survive a template change. + const editor = page.getByLabel('Edit .gitignore'); + await editor.fill('src/\n'); + await expect(results.getByRole('listitem').nth(1)).toContainText('Ignored'); + await page.getByLabel('Python', { exact: true }).uncheck(); + await expect(page.getByText('Templates changed since you edited')).toBeVisible(); + await expect(editor).toHaveValue('src/\n'); + await page.getByRole('button', { name: 'Reset to generated' }).click(); + await expect(editor).toHaveValue(/# === Node\.js ===/); + const download = page.waitForEvent('download'); await page.getByRole('button', { name: /Download \.gitignore/ }).click(); expect((await download).suggestedFilename()).toMatch(/gitignore/); diff --git a/e2e/postgres-query-builder.spec.ts b/e2e/postgres-query-builder.spec.ts new file mode 100644 index 0000000..bb55394 --- /dev/null +++ b/e2e/postgres-query-builder.spec.ts @@ -0,0 +1,99 @@ +import { expect, test, type Page } from '@playwright/test'; + +async function loadSample(page: Page, name: RegExp) { + await page.getByRole('combobox', { name: 'Sample' }).click(); + await page.getByRole('option', { name }).click(); +} + +test('builds queries and schema SQL from a sample, locally', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + const requests: { url: string; method: string }[] = []; + page.on('request', (r) => requests.push({ url: r.url(), method: r.method() })); + page.on('dialog', (d) => d.accept()); + + await page.goto('/tools/postgres-query-builder'); + await expect(page).toHaveTitle(/^PostgreSQL Query Builder: .+ · Zykit$/); + const origin = new URL(page.url()).origin; + await expect(page.getByText('No schema yet')).toBeVisible(); + + await loadSample(page, /E-commerce/); + const sql = page.locator('#panel-query pre'); + await expect(sql).toHaveText( + 'SELECT\n u.name,\n COUNT(o.id) AS order_count\nFROM users AS u\nLEFT JOIN orders AS o\n ON o.user_id = u.id\nGROUP BY\n u.id\nORDER BY\n order_count DESC\nLIMIT 10;', + ); + await expect(page.getByText('What this query does')).toBeVisible(); + + // Add a WHERE filter. + const where = page.getByRole('group', { name: 'WHERE conditions' }); + await where.getByRole('button', { name: '+ Condition' }).click(); + await where.getByLabel('Column').selectOption({ label: 'u.email' }); + await where.getByLabel('Operator').selectOption('ILIKE'); + await where.getByLabel('Value').fill('%@example.com'); + await expect(sql).toContainText("WHERE u.email ILIKE '%@example.com'\nGROUP BY"); + + // Invalid configuration shows problems instead of SQL. + await page.getByLabel('LIMIT').fill('-1'); + await expect(page.getByText('Fix these to generate SQL')).toBeVisible(); + await expect(sql).toHaveCount(0); + await page.getByLabel('LIMIT').fill('5'); + await expect(sql).toContainText('LIMIT 5;'); + + // Re-join orders: the suggestion comes from the relationship and must be confirmed. + await page.getByRole('button', { name: 'Remove orders from the query' }).click(); + await page.getByLabel('Join another table').selectOption({ label: 'orders' }); + const suggestion = page.getByRole('region', { name: 'Join suggestion' }); + await expect(suggestion).toContainText('u.id = orders.user_id'); + await suggestion.getByRole('button', { name: 'Use this join' }).click(); + await expect(sql).toContainText('INNER JOIN orders\n ON orders.user_id = u.id'); + + await page.getByRole('button', { name: 'Copy SQL' }).click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('INNER JOIN orders'); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download query.sql' }).click(); + expect((await download).suggestedFilename()).toBe('query.sql'); + + // Schema DDL, then add a table with a relationship. + await page.getByRole('tab', { name: 'Schema Designer' }).click(); + const ddl = page.locator('#panel-schema pre'); + await expect(ddl).toContainText('CREATE TABLE users (\n id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,'); + await expect(ddl).toContainText('CONSTRAINT order_items_order_id_fkey FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE'); + + await page.getByRole('button', { name: '+ Add table' }).click(); + await page.getByLabel('Table name', { exact: true }).fill('reviews'); + await page.getByRole('button', { name: '+ Add column' }).click(); + await page.getByLabel('Column name').last().fill('product_id'); + await page.getByLabel('Type').last().selectOption('BIGINT'); + await page.getByLabel('Child table').selectOption({ label: 'reviews' }); + await page.getByLabel('Foreign key column').selectOption({ label: 'product_id (bigint)' }); + await page.getByLabel('Parent table').selectOption({ label: 'products' }); + await page.getByRole('button', { name: 'Add relationship' }).click(); + await expect(ddl).toContainText('CREATE TABLE reviews ('); + await expect(ddl).toContainText('CONSTRAINT reviews_product_id_fkey FOREIGN KEY (product_id) REFERENCES products (id)'); + await expect(ddl).toContainText('CREATE INDEX reviews_product_id_idx ON reviews (product_id);'); + + await page.getByRole('button', { name: 'Undo' }).click(); + await expect(ddl).not.toContainText('reviews_product_id_fkey'); + await page.getByRole('button', { name: 'Redo' }).click(); + await expect(ddl).toContainText('reviews_product_id_fkey'); + + // The project survives a reload. + await page.reload(); + await page.getByRole('tab', { name: 'Schema Designer' }).click(); + await expect(page.locator('#panel-schema pre')).toContainText('reviews_product_id_fkey'); + + for (const r of requests) { + expect(r.method).toBe('GET'); + expect(r.url.startsWith(origin) || r.url.startsWith('blob:') || r.url.startsWith('data:')).toBe(true); + } +}); + +test('has no horizontal scroll at 320px', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 800 }); + await page.goto('/tools/postgres-query-builder'); + await loadSample(page, /Blog/); + await expect(page.locator('#panel-query pre')).toContainText('FROM posts AS p'); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320); + await page.getByRole('tab', { name: 'Schema Designer' }).click(); + await expect(page.getByRole('region', { name: 'Schema canvas' })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320); +}); diff --git a/src/shared/ui/convert.tsx b/src/shared/ui/convert.tsx index 7ac7593..6ad8ee8 100644 --- a/src/shared/ui/convert.tsx +++ b/src/shared/ui/convert.tsx @@ -119,6 +119,7 @@ export function OutputPanel({ mime, busy, kind, + children, }: { title: string; icon?: IconName; @@ -128,6 +129,8 @@ export function OutputPanel({ busy?: boolean; /** What the output is, for "Send to…"; inferred from `mime` when omitted. */ kind?: DataKind; + /** Replaces the read-only preview, e.g. with an editor bound to `text`. */ + children?: ReactNode; }) { const preview = text.length > MAX_PREVIEW_CHARS ? text.slice(0, MAX_PREVIEW_CHARS) : text; const ext = fileName.slice(fileName.lastIndexOf('.')); @@ -148,8 +151,8 @@ export function OutputPanel({
- {preview} - {preview.length < text.length && ( + {children ?? {preview}} + {!children && preview.length < text.length && (

Showing the first {formatBytes(MAX_PREVIEW_CHARS)} of the output. Copy or download to get all of it.

diff --git a/src/tools/gitignore-generator/GitignoreGeneratorPage.tsx b/src/tools/gitignore-generator/GitignoreGeneratorPage.tsx index 5f4d58c..9a12614 100644 --- a/src/tools/gitignore-generator/GitignoreGeneratorPage.tsx +++ b/src/tools/gitignore-generator/GitignoreGeneratorPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useId, useMemo, useState } from 'react'; import gitignoreGenerator from './index'; -import { GROUPS, merge, MAX_PATHS, parseRules, SAMPLE_PATHS, searchTemplates, templateById, testPath, type PathResult } from './features/gitignore-generator'; +import { GROUPS, merge, MAX_PATHS, parseRules, SAMPLE_PATHS, searchTemplates, STARTERS, templateById, testPath, type PathResult } from './features/gitignore-generator'; import { Checkbox, OutputPanel } from '../../shared/ui/convert'; import { useIncomingText } from '../../shared/hooks/useIncomingText'; import { useShareState } from '../../shared/hooks/useShareState'; @@ -20,11 +20,15 @@ export default function GitignoreGeneratorPage() { const [paths, setPaths] = useState(''); const [rulesText, setRulesText] = useState(''); const [ignoreCase, setIgnoreCase] = useState(false); + // Hand edits to the output, plus the generated text they started from (to warn when templates change underneath). + const [edit, setEdit] = useState<{ text: string; base: string } | null>(null); const searchId = useId(); const merged = useMemo(() => merge(selected, custom), [selected, custom]); const visible = useMemo(() => searchTemplates(query), [query]); - const effective = rulesText.trim() ? rulesText : merged.text; + const output = edit?.text ?? merged.text; + const outdated = edit !== null && edit.base !== merged.text; + const effective = rulesText.trim() ? rulesText : output; // Test results are computed after a short pause so typing in a long list stays responsive. const [results, setResults] = useState([]); @@ -63,7 +67,7 @@ export default function GitignoreGeneratorPage() {
Generate a -
@@ -76,6 +80,13 @@ export default function GitignoreGeneratorPage() { Search templates setQuery(e.target.value)} placeholder="Python, Next.js, JetBrains…" className={field} autoComplete="off" /> +
+ {STARTERS.map((st) => ( + + ))} +
{selected.length > 0 && (
    {selected.map((id) => ( @@ -117,13 +128,38 @@ export default function GitignoreGeneratorPage() {
- {merged.text ? ( + {output || edit ? (
- + + setEdit({ text: e.target.value, base: edit?.base ?? merged.text })} + rows={18} + /> + + {edit && ( +
+ + {outdated && ( +

+ Templates changed since you edited. Your edits are kept; reset to regenerate. +

+ )} +
+ )}

Browsers drop the leading dot from downloads, so the file may save as gitignore. Rename it to{' '} .gitignore in your project root.

+

+ A .gitignore only affects untracked files. To stop tracking something already committed, run{' '} + git rm -r --cached <path> and commit; this removes it from the repository (not from your disk), so + teammates will see it deleted. +

) : (

Your .gitignore will appear here.

diff --git a/src/tools/gitignore-generator/README.md b/src/tools/gitignore-generator/README.md index 47c6730..f5ef70e 100644 --- a/src/tools/gitignore-generator/README.md +++ b/src/tools/gitignore-generator/README.md @@ -15,7 +15,7 @@ Builds a `.gitignore` from ~75 bundled templates (languages, frameworks, editors ## Core Logic -`merge` joins sections under `# === Name ===` headers and drops repeated pattern lines. `parseRules` turns each line into a rule (negated, dirOnly, anchored, regex built by `globToRegex`). `testPath` checks each parent directory first (an excluded parent wins), then the path itself; the last matching rule decides. +`merge` joins sections under `# === Name ===` headers and drops repeated pattern lines. `parseRules` turns each line into a rule (negated, dirOnly, anchored, regex built by `globToRegex`). `testPath` checks each parent directory first (an excluded parent wins), then the path itself; the last matching rule decides. `STARTERS` lists one-click template combinations. The page keeps hand edits as `{ text, base }`: `text` replaces the generated output everywhere (copy, download, tester), and `base` is the generated text the edits started from, so a template change shows a notice instead of overwriting them. `OutputPanel` takes the editor as `children`. ## Limits @@ -28,4 +28,4 @@ Single root rule list only: no nested `.gitignore`, `.git/info/exclude` or globa ## Known Gaps -No directory detection from disk, no case-folding for non-ASCII, templates are hand-curated rather than synced from an upstream list. +No directory detection from disk, no case-folding for non-ASCII, templates are hand-curated rather than synced from an upstream list. Hand edits are not merged with later template changes (keep or reset) and are not included in share links. diff --git a/src/tools/gitignore-generator/docs.ts b/src/tools/gitignore-generator/docs.ts index 02b9462..10d6b8d 100644 --- a/src/tools/gitignore-generator/docs.ts +++ b/src/tools/gitignore-generator/docs.ts @@ -8,7 +8,7 @@ const docs: ToolDocs = { 'To check a rule, paste file paths in **Test paths** (end a directory with `/`). Each path shows whether it is ignored and which line decided it. Optionally paste your own rules to test those instead.', ], howItWorks: - 'About 75 templates are bundled with the page, written for this tool from common practice for each ecosystem. Merging joins the selected templates in the order you chose them, drops any pattern line that an earlier section (or your custom lines) already contains, and keeps comments.\n\n' + + 'About 75 templates are bundled with the page, written for this tool from common practice for each ecosystem. Merging joins the selected templates in the order you chose them, drops any pattern line that an earlier section (or your custom lines) already contains, and keeps comments. Starter sets pick a common combination in one click. You can edit the result directly: your edits are what you copy, download and test, and they are kept (with a notice) if you change templates afterwards, until you press Reset.\n\n' + 'The tester implements gitignore semantics in JavaScript: blank lines and `#` comments are skipped; a leading `!` re-includes; a pattern with a `/` at the start or middle is anchored to the repository root, otherwise it matches a name at any depth; a trailing `/` matches directories only; `*` and `?` never match `/`; `[abc]`, `[a-z]`, `[!x]` and `[[:digit:]]` classes work; `**` works as `**/x`, `x/**` and `a/**/b`; `\\` escapes `#`, `!`, spaces and wildcards; trailing unescaped spaces are ignored. The last matching rule wins. Parent directories are checked first, so a file inside an excluded directory stays ignored even if a later `!` rule names it, as in git.', limits: [ 'The tester reads one rule list as if it were the `.gitignore` at the repository root. Nested `.gitignore` files, `.git/info/exclude`, the global ignore file and files already tracked by git are not considered (git never ignores a tracked file).', diff --git a/src/tools/gitignore-generator/features/gitignore-generator.ts b/src/tools/gitignore-generator/features/gitignore-generator.ts index cf3dfb1..321f6d5 100644 --- a/src/tools/gitignore-generator/features/gitignore-generator.ts +++ b/src/tools/gitignore-generator/features/gitignore-generator.ts @@ -10,6 +10,15 @@ export type { Template } from './templates'; const byId = new Map(TEMPLATES.map((t) => [t.id, t])); export const templateById = (id: string): Template | undefined => byId.get(id); +/** One-click template combinations for common projects. */ +export const STARTERS: { name: string; ids: string[] }[] = [ + { name: 'React + Node + VS Code + macOS', ids: ['node', 'react', 'vite', 'vscode', 'macos', 'env'] }, + { name: 'Next.js', ids: ['node', 'nextjs', 'vscode', 'macos', 'env'] }, + { name: 'Python + Django', ids: ['python', 'django', 'jupyter', 'vscode', 'env'] }, + { name: 'Java + Spring', ids: ['java', 'maven', 'gradle', 'spring', 'jetbrains'] }, + { name: 'Go service + Docker', ids: ['go', 'docker', 'env', 'vscode'] }, +]; + export const MAX_RULE_LINES = 20_000; export const MAX_PATHS = 2_000; diff --git a/src/tools/postgres-query-builder/PostgresQueryBuilderPage.tsx b/src/tools/postgres-query-builder/PostgresQueryBuilderPage.tsx new file mode 100644 index 0000000..99012e6 --- /dev/null +++ b/src/tools/postgres-query-builder/PostgresQueryBuilderPage.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from 'react'; +import postgresQueryBuilder from './index'; +import { RelationshipPanel } from './components/RelationshipPanel'; +import { QueryBuilder } from './components/QueryBuilder'; +import { Reference } from './components/Reference'; +import { SchemaCanvas } from './components/SchemaCanvas'; +import { SqlPanel, type SqlView } from './components/SqlPanel'; +import { TableEditor } from './components/TableEditor'; +import { Section, SmallButton } from './components/fields'; +import { emptyProject, findTable, newColumn, newId, newTable, type Project, type Schema, type Table } from './features/model'; +import { MAX_IMPORT_BYTES, draftFromConnect, emptyDraft, exportProject, importProject, type RelationshipDraft } from './features/project'; +import { generateQuery } from './features/query'; +import { SAMPLES } from './features/samples'; +import { useProject } from './hooks/useProject'; +import { ErrorAlert, Headline, StatusStrip } from '../../shared/ui/page'; +import { FilePickerButton } from '../../shared/ui/ImageBatch'; +import { Select } from '../../shared/ui/Select'; +import { Tabs } from '../../shared/ui/Tabs'; +import { Breadcrumb } from '../../shared/ui/tool'; +import { Button, Icon } from '../../shared/ui/ui'; +import { downloadText } from '../../shared/utils/dom.utils'; +import { pluralize } from '../../shared/utils/format.utils'; + +type Tab = 'query' | 'schema' | 'reference'; +const TABS: { id: Tab; label: string }[] = [ + { id: 'query', label: 'Visual Query Builder' }, + { id: 'schema', label: 'Schema Designer' }, + { id: 'reference', label: 'SQL Reference' }, +]; + +const isEmpty = (p: Project) => !p.schema.tables.length; + +function duplicateTable(s: Schema, t: Table): Table { + const ids = new Map(t.columns.map((c) => [c.id, newId('c')])); + const taken = new Set(s.tables.map((x) => x.name)); + let name = `${t.name}_copy`; + for (let i = 2; taken.has(name); i++) name = `${t.name}_copy${i}`; + return { + ...newTable(name, t.x + 40, t.y + 40), + columns: t.columns.map((c) => ({ ...c, id: ids.get(c.id)! })), + checks: [...t.checks], + indexes: t.indexes.map((ix) => ({ id: newId('x'), unique: ix.unique, columnIds: ix.columnIds.map((c) => ids.get(c)!) })), + }; +} + +export default function PostgresQueryBuilderPage() { + const { project, apply, applyLive, endGesture, undo, redo, canUndo, canRedo } = useProject(); + const { schema, query } = project; + const [tab, setTab] = useState('query'); + const [sqlView, setSqlView] = useState('query'); + const [selectedTable, setSelectedTable] = useState(null); + const [selectedRel, setSelectedRel] = useState(null); + const [draft, setDraft] = useState(emptyDraft); + const [error, setError] = useState(''); + + const setSchema = (fn: (s: Schema) => Schema, mergeKey?: string) => apply((p) => ({ ...p, schema: fn(p.schema) }), mergeKey); + const table = selectedTable ? findTable(schema, selectedTable) : undefined; + + // Ctrl/⌘+Z and Ctrl/⌘+Shift+Z (or Ctrl+Y), except while typing in a field. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const el = e.target as HTMLElement; + if (!(e.ctrlKey || e.metaKey) || el.closest('input, textarea, select, [contenteditable]')) return; + const k = e.key.toLowerCase(); + if (k === 'z' && !e.shiftKey) undo(); + else if ((k === 'z' && e.shiftKey) || k === 'y') redo(); + else return; + e.preventDefault(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [undo, redo]); + + const replace = (next: Project, what: string) => { + if (!isEmpty(project) && !window.confirm(`${what} replaces your current tables and query. Continue?`)) return; + apply(() => next); + setSelectedTable(null); + setSelectedRel(null); + setDraft(emptyDraft()); + }; + const loadSample = (id: string) => { + const s = SAMPLES.find((x) => x.id === id); + if (s) replace(s.build(), 'Loading a sample'); + }; + const importFile = async (files: File[]) => { + const f = files[0]; + if (!f) return; + if (f.size > MAX_IMPORT_BYTES) return setError('The file is larger than 2 MB.'); + const res = importProject(await f.text()); + if (!res.ok) return setError(`Couldn't import ${f.name}: ${res.error}`); + setError(''); + replace(res.project, 'Importing a project'); + }; + const addTable = () => { + const taken = new Set(schema.tables.map((t) => t.name)); + let i = schema.tables.length + 1; + while (taken.has(`table_${i}`)) i++; + const n = schema.tables.length; + const t = newTable(`table_${i}`, 40 + (n % 3) * 320, 40 + Math.floor(n / 3) * 300, [newColumn('id', 'BIGINT', { primaryKey: true, identity: true })]); + setSchema((s) => ({ ...s, tables: [...s.tables, t] })); + setSelectedTable(t.id); + }; + const deleteTable = (t: Table) => { + const linked = schema.relationships.filter((r) => r.fromTableId === t.id || r.toTableId === t.id).length; + if (!window.confirm(`Delete table "${t.name}"${linked ? ` and its ${pluralize(linked, 'relationship')}` : ''}? You can undo this.`)) return; + setSchema((s) => ({ ...s, tables: s.tables.filter((x) => x.id !== t.id) })); + setSelectedTable(null); + }; + + const queryErrors = generateQuery(schema, query).errors; + const status = isEmpty(project) + ? 'Start with a sample schema or add your first table.' + : `${pluralize(schema.tables.length, 'table')} · ${pluralize(schema.relationships.length, 'relationship')} · ${query.sources.length ? (queryErrors.length ? `query has ${pluralize(queryErrors.length, 'issue')}` : 'query SQL ready') : 'no query yet'}`; + + return ( +
+ +
+ Build PostgreSQL queries +

+ Design tables and relationships, then pick tables, columns, joins and filters to generate PostgreSQL SQL. The tool only writes SQL text: it never connects to a database or runs + anything. Your project is saved in this browser. +

+
+ + +
+ ({ value: t.id, label: t.name || '(unnamed)' }))} + onChange={setSelectedTable} + /> + ) + } + > + {table ? ( + setSchema((s) => ({ ...s, tables: s.tables.map((t) => (t.id === table.id ? fn(t) : t)) }), k)} + onDuplicate={() => { + const copy = duplicateTable(schema, table); + setSchema((s) => ({ ...s, tables: [...s.tables, copy] })); + setSelectedTable(copy.id); + }} + onDelete={() => deleteTable(table)} + /> + ) : ( +

{schema.tables.length ? 'Click a table on the canvas or choose one above to edit it.' : 'Add a table or load a sample schema.'}

+ )} + + +
+
+ +
+
+
+ )} + + {tab === 'reference' && ( +
+ +
+ )} + + + ); +} + +function EmptyState({ onSample, onDesign }: { onSample: (id: string) => void; onDesign: () => void }) { + return ( +
+

No schema yet

+

Queries are built from your tables. Load a sample to explore, or design your own tables first.

+
+ {SAMPLES.map((s) => ( + onSample(s.id)}> + {s.label} + + ))} +
+ +
+ ); +} diff --git a/src/tools/postgres-query-builder/README.md b/src/tools/postgres-query-builder/README.md new file mode 100644 index 0000000..43d70cc --- /dev/null +++ b/src/tools/postgres-query-builder/README.md @@ -0,0 +1,49 @@ +# PostgreSQL Query Builder + +## Purpose + +Visual schema designer and `SELECT` builder that generates PostgreSQL SQL (query and `CREATE TABLE` DDL) from structured data, entirely in the browser. It never connects to a database. User-facing docs live in [`docs.ts`](docs.ts). + +## File Structure + +| File | Responsibility | +|---|---| +| `index.ts` | Tool definition | +| `PostgresQueryBuilderPage.tsx` | Toolbar (samples, undo/redo, import/export JSON, reset), tabs, empty state, table add/duplicate/delete with confirmation, keyboard undo | +| `hooks/useProject.ts` | Project state with undo/redo history (100 steps), drag gestures as one step, merged typing (`mergeKey`), debounced `localStorage` save, validated reload | +| `components/SchemaCanvas.tsx` | Pointer-event drag, pan, zoom (buttons, Ctrl/⌘ + wheel), fit; SVG relationship curves with 1/N markers; PK/FK/UQ/nullable indicators; connect dots; arrow-key moves | +| `components/TableEditor.tsx` | Table name, columns (type, length/precision/scale, PK, NOT NULL, UNIQUE, identity, default, CHECK, reorder), table CHECKs, indexes | +| `components/RelationshipPanel.tsx` | Relationship list/edit/remove, add form (prefilled from the canvas), junction-table creation | +| `components/QueryBuilder.tsx` | Sources and joins (suggestions need confirmation), select items, GROUP BY, ORDER BY, LIMIT/OFFSET | +| `components/ConditionBuilder.tsx` | Recursive AND/OR groups for WHERE and HAVING | +| `components/SqlPanel.tsx` | Query/DDL output, copy, download, manual edit with a stale-edit notice, `sql-formatter` for hand-edited SQL, plain-language explanation | +| `components/Reference.tsx` | Static PostgreSQL examples (joins, grouping, INSERT/UPSERT/UPDATE/DELETE, EXISTS, CTE) and the parameterized-query note | +| `components/fields.tsx` | Compact inputs, checkbox, native select, problem list, section | +| `features/model.ts` | Types, `PG_TYPES`, operators, factories (`newId`, `newTable`, …), lookups | +| `features/sql.ts` | `quoteIdent` (reserved words, case, 63-byte limit), `quoteString`, `literal` (per-type serialization and validation), `expressionError`, `typeSql`, `keyType` | +| `features/ddl.ts` | `validateSchema`, `relationshipErrors`, `creationOrder`, `generateDdl` | +| `features/query.ts` | `generateQuery` (validation + formatting), `suggestJoinConditions`, `columnOptions`, `refKey` | +| `features/explain.ts` | `explainQuery`: plain-language description from the query model | +| `features/samples.ts` | E-commerce, blog, clinic and project-management schemas with starter queries | +| `features/project.ts` | `exportProject` / `importProject` (strict, size-limited), `junctionTable`, `pruneProject`, relationship drafts | + +## Core Logic + +Everything references tables and columns by id. `pruneProject` runs after every change and removes relationships, indexes, joins, selected columns, conditions, GROUP BY and ORDER BY entries that point at deleted tables, columns or query sources. `generateDdl` validates first and returns no SQL while errors exist; it orders tables with a dependency sort (self-references ignored), writes foreign keys inline when the parent already exists, and defers the rest to `ALTER TABLE … ADD CONSTRAINT` so cycles work. Constraint and index names follow PostgreSQL's conventions (`__fkey`, `_idx`, `_key`) truncated to 63 bytes. `generateQuery` likewise validates (joins, grouping with primary-key functional dependency, aggregate types, HAVING/WHERE placement, DISTINCT + ORDER BY, LIMIT/OFFSET) and then prints a fixed, indented layout; columns are qualified only when the query has more than one table. Values go through `literal`, which validates and types them (`DATE '…'`, `'…'::uuid`, `'…'::jsonb`); identifiers through `quoteIdent`. Defaults are typed literals or an allow-listed expression; CHECK text passes `expressionError` (no `;`, comments, unbalanced quotes/brackets). + +## Limits + +- Imports: 2 MB, 200 tables × 200 columns, 1,000 relationships, 500 items per list, condition depth 8. +- Single-column foreign keys; join conditions are `=` between two columns. +- SELECT only in the builder; no migrations, no execution. + +## Tests + +- Unit: `tests/tools/postgres-query-builder/sql-generation.test.ts` (quoting, literals, CHECK validation, exact DDL including cycles/self-reference/composite keys/one-to-one, exact SELECT output with joins/aggregates/grouping/nested conditions, validation errors, join suggestions only from relationships, samples, JSON round-trip and rejection, junction tables, pruning). `npm test -- postgres-query-builder` +- E2E: `e2e/postgres-query-builder.spec.ts` (sample load, query SQL, building a join and filter, schema DDL, no network requests, 320 px layout). `npm run test:e2e -- postgres-query-builder` + +## Known Gaps + +- Hand-edited SQL isn't parsed back into the visual model. +- The canvas has no auto-layout; tables from imports keep their saved positions. +- No unit tests for the React components (covered by E2E). diff --git a/src/tools/postgres-query-builder/components/ConditionBuilder.tsx b/src/tools/postgres-query-builder/components/ConditionBuilder.tsx new file mode 100644 index 0000000..2feb14c --- /dev/null +++ b/src/tools/postgres-query-builder/components/ConditionBuilder.tsx @@ -0,0 +1,160 @@ +// Nested AND/OR condition groups for WHERE and HAVING. + +import { AGGREGATES, OPERATORS, emptyGroup, newId, type Aggregate, type CondTarget, type Condition, type ConditionGroup, type Operator } from '../features/model'; +import { refKey, type ColumnOption } from '../features/query'; +import { Choice, IconButton, SmallButton, TextField, inputClass } from './fields'; + +const MAX_DEPTH = 8; + +interface Props { + group: ConditionGroup; + columns: ColumnOption[]; + /** HAVING: conditions may test an aggregate such as COUNT(*). */ + aggregates?: boolean; + onChange: (fn: (g: ConditionGroup) => ConditionGroup, mergeKey?: string) => void; + label: string; + depth?: number; + onRemove?: () => void; +} + +export function ConditionBuilder({ group, columns, aggregates, onChange, label, depth = 0, onRemove }: Props) { + const setChild = (id: string, fn: (c: Condition | ConditionGroup) => Condition | ConditionGroup, mergeKey?: string) => + onChange((g) => ({ ...g, children: g.children.map((c) => (c.id === id ? fn(c) : c)) }), mergeKey); + const remove = (id: string) => onChange((g) => ({ ...g, children: g.children.filter((c) => c.id !== id) })); + const first = columns[0]?.ref; + const addCondition = () => + onChange((g) => ({ + ...g, + children: [...g.children, { id: newId('w'), kind: 'cond', target: aggregates ? { kind: 'aggregate', fn: 'COUNT', ref: null } : { kind: 'column', ref: first! }, op: aggregates ? '>' : '=', values: [''] }], + })); + + return ( +
+
+ {group.children.length > 1 || depth ? ( +
+ {(['AND', 'OR'] as const).map((op) => ( + + ))} +
+ ) : null} + {group.op === 'AND' ? 'All of these must be true' : 'At least one of these must be true'} + {onRemove && ( + + + + )} +
+ {group.children.map((c) => + c.kind === 'group' ? ( + setChild(c.id, (x) => fn(x as ConditionGroup), k)} + onRemove={() => remove(c.id)} + /> + ) : ( + setChild(c.id, (x) => fn(x as Condition), k)} onRemove={() => remove(c.id)} /> + ), + )} +
+ + + Condition + + {depth < MAX_DEPTH && ( + onChange((g) => ({ ...g, children: [...g.children, emptyGroup(g.op === 'AND' ? 'OR' : 'AND')] }))} disabled={!columns.length && !aggregates}> + + Group ({group.op === 'AND' ? 'OR' : 'AND'}) + + )} +
+
+ ); +} + +const COUNT_ALL = '*'; + +function ConditionRow({ + cond, + columns, + aggregates, + onChange, + onRemove, +}: { + cond: Condition; + columns: ColumnOption[]; + aggregates?: boolean; + onChange: (fn: (c: Condition) => Condition, mergeKey?: string) => void; + onRemove: () => void; +}) { + const t = cond.target; + const setTarget = (target: CondTarget) => onChange((c) => ({ ...c, target })); + const setOp = (op: Operator) => onChange((c) => ({ ...c, op, values: op === 'BETWEEN' ? [c.values[0] ?? '', c.values[1] ?? ''] : op.startsWith('IS') ? [] : c.values.length ? c.values : [''] })); + const setValue = (i: number, v: string) => onChange((c) => ({ ...c, values: c.values.map((x, j) => (j === i ? v : x)) }), `${cond.id}:v${i}`); + const colChoice = columns.map(({ value, label }) => ({ value, label })); + const refOf = (key: string) => columns.find((c) => c.value === key)?.ref ?? null; + + return ( +
+ {aggregates && ( + + label="Function" + value={t.kind === 'aggregate' ? t.fn : 'column'} + options={[{ value: 'column', label: '(column)' }, ...AGGREGATES.map((a) => ({ value: a, label: a }))]} + onChange={(fn) => { + const ref = t.ref ?? columns[0]?.ref ?? null; + if (fn === 'column') { + if (ref) setTarget({ kind: 'column', ref }); + } else setTarget({ kind: 'aggregate', fn, ref: fn === 'COUNT' ? t.ref : ref }); + }} + /> + )} + { + const ref = key === COUNT_ALL ? null : refOf(key); + if (t.kind === 'aggregate') setTarget({ ...t, ref }); + else if (ref) setTarget({ kind: 'column', ref }); + }} + /> + + {cond.op === 'IN' || cond.op === 'NOT IN' ? ( +
+ +