Public: anyone can read this; the crew can change it.

A `.sql` file in a repository opens as its schema: each CREATE TABLE as its columns, other statements one per row #11200

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/11187-a-sql-file-in-a-repository-opens-as-its → main 7 commits fork 2 threads · 2 unresolved

  • Merged. mason/11187-a-sql-file-in-a-repository-opens-as-its was merged into main as 1b8b3ba.

Review

Sign in to review this change.
9 files changed +458 −0
docs/design/areas/render.md +4
@@ -47,3 +47,7 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis
4747 ## OpenAPI operations (openapi.json, *.openapi.yaml)
4848
4949 `src/web/render/openapi.ts` draws one `h3` per tag and under it one existing `.table` (`data-dense`) of method, path, summary and a `deprecated` mark; the cap line is a `muted small` paragraph. `.openapi-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-openapi.md`.
50
51 ## SQL schema (*.sql)
52
53 `src/web/render/sql.ts` draws, per statement, either an `h3` with the table name and one existing `.table` (`data-dense`) of its columns, or a one-row `.table` with the statement's first line. `.sql-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-sql.md`.
docs/features/file-sql.md added +12
@@ -0,0 +1,12 @@
1 # A `.sql` file opens as its schema
2
3 A file ending `.sql` opens as one block per statement instead of raw text. Source is one tap away (`?view=source`).
4
5 - **`CREATE TABLE`:** the table name, then a row per column: **name**, **type**, **NOT NULL**, **PRIMARY KEY** and **DEFAULT** (`—` when absent). A table-level `PRIMARY KEY (a, b)` marks those columns. Other table-level constraints (`FOREIGN KEY`, `UNIQUE`, `CHECK`) are listed under the table.
6 - **Anything else** (`CREATE INDEX`, `ALTER TABLE`, `INSERT` …): one row with the statement's first line.
7 - **Comments** (`-- …`, `/* … */`) are not statements; a `;` inside a quote or comment does not end one.
8 - **Cannot be read:** a file with no recognisable statement, or an unterminated quote or comment, shows Source with the reason, never an error. A `CREATE TABLE` it cannot parse is one row like any other statement.
9 - **Cap:** at most 500 statements and 500 columns a table, then `N more not shown`. Files over 1 MB keep Source.
10 - Every name is escaped and the page is drawn on the server. No script, no new CSS (`.sql-view` is a hook with no rule).
11
12 Code: `src/web/render/sql.ts`, `src/web/render/kinds.ts`. Tests: `test/render-sql.test.ts`.
src/tui/screens/workspace.ts +24
@@ -61,6 +61,7 @@ import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts";
6161 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
6262 import { CASE_CAP, parseJunit } from "../../web/render/junit.ts";
6363 import { parseOpenApi } from "../../web/render/openapi.ts";
64 import { parseSql } from "../../web/render/sql.ts";
6465 import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts";
6566 import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/lcov.ts";
6667 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
@@ -468,6 +469,24 @@ function openapiTable(text: string, path: string): Table | string | null {
468469 return { header: ["Tag", "Method", "Path", "Summary"], rows, more: parsed.more, delimiter: ",", columns: 4 };
469470 }
470471
472 /** A `.sql` file by name. */
473 function isSqlPath(path: string): boolean {
474 return kindByName(path) === "sql";
475 }
476
477 /** A SQL file as a table: a row per column of each CREATE TABLE, one row per other statement; the reason when it is not drawn. Shares the web's parser. */
478 function sqlTable(source: string): Table | string {
479 const doc = parseSql(source);
480 if (doc === null) return "not drawn as a schema: this file holds no SQL statements to draw";
481 const dash = (v: string): string => (v === "" ? "-" : v);
482 const rows = doc.statements.flatMap((s) =>
483 s.kind === "other"
484 ? [["-", s.line, "-", "-", "-", "-"]]
485 : s.columns.map((c) => [s.name, c.name, dash(c.type), c.notNull ? "NOT NULL" : "-", c.primaryKey ? "PRIMARY KEY" : "-", dash(c.default)]),
486 );
487 return { header: ["Table", "Column", "Type", "Null", "Key", "Default"], rows, more: doc.total - doc.statements.length, delimiter: ",", columns: 6 };
488 }
489
471490 /** A JUnit report, by the web's own rule (file name, or root element). */
472491 function isJunitPath(path: string, source: Uint8Array): boolean {
473492 return /\.xml$/i.test(path) && fileKind(path, source.subarray(0, 4096)) === "junit";
@@ -722,6 +741,11 @@ class Tab {
722741 this.table = typeof api === "string" ? null : api;
723742 this.fault = typeof api === "string" ? api : "";
724743 if (this.table !== null) this.grid = layoutTable(this.table);
744 } else if (isSqlPath(this.path)) {
745 const schema = sqlTable(new TextDecoder().decode(source));
746 this.table = typeof schema === "string" ? null : schema;
747 this.fault = typeof schema === "string" ? schema : "";
748 if (this.table !== null) this.grid = layoutTable(this.table);
725749 } else if (isJunitPath(this.path, source)) {
726750 const results = junitTable(new TextDecoder().decode(source));
727751 this.table = typeof results === "string" ? null : results;
src/web/render/kinds.ts +3
@@ -42,6 +42,7 @@ export type FileKind =
4242 | "junit"
4343 | "env"
4444 | "openapi"
45 | "sql"
4546 | "text"
4647 | "binary";
4748
@@ -82,6 +83,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
8283 junit: 5 * MB,
8384 env: 1 * MB,
8485 openapi: 1 * MB,
86 sql: 1 * MB,
8587 text: 1 * MB,
8688 binary: 0,
8789 };
@@ -205,6 +207,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
205207 gco: "gcode",
206208 sarif: "sarif",
207209 lcov: "lcov",
210 sql: "sql",
208211 };
209212
210213 /** The three example names; a plain `.env` is never one. */
src/web/render/renderers.ts +2
@@ -11,6 +11,7 @@ import { audio } from "./audio.ts";
1111 import { config } from "./config.ts";
1212 import { isFormConfig } from "./config-format.ts";
1313 import { envView } from "./env.ts";
14 import { sqlView } from "./sql.ts";
1415 import { eml } from "./eml.ts";
1516 import { csv } from "./csv.ts";
1617 import { geojson } from "./geojson.ts";
@@ -100,6 +101,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
100101 lcov,
101102 junit,
102103 env: envView,
104 sql: sqlView,
103105 openapi,
104106 mail: eml,
105107 archive,
src/web/render/sql.ts added +284
@@ -0,0 +1,284 @@
1 /**
2 * A `.sql` file as its schema: one block per statement. A `CREATE TABLE` shows
3 * the table name and a row per column (name, type, NOT NULL / PRIMARY KEY /
4 * DEFAULT); every other statement is one row with its first line.
5 * The reader is its own and small: it splits on `;` outside quotes, comments
6 * and parentheses, and reads column definitions at the table's top level.
7 * A file with no statement it can recognise, or an unterminated quote or
8 * comment, is not drawn: the source view shows with the reason.
9 * Postgres `$tag$ … $tag$` bodies are one piece. MySQL backslash escapes inside
10 * quotes are not read (standard SQL has none): a `\'` there may split wrongly.
11 * Every name is escaped by the template; at most `ROW_CAP` statements are drawn.
12 */
13
14 import { html, type Html } from "../html.ts";
15 import { fileSize } from "../ui.ts";
16 import type { Rendered, RenderInput } from "./renderers.ts";
17
18 export const ROW_CAP = 500;
19 const COLUMN_CAP = 500;
20 const LINE_CAP = 200;
21
22 export interface SqlColumn {
23 readonly name: string;
24 readonly type: string;
25 readonly notNull: boolean;
26 readonly primaryKey: boolean;
27 readonly default: string;
28 }
29
30 export type SqlStatement =
31 | { readonly kind: "table"; readonly name: string; readonly columns: readonly SqlColumn[]; readonly extras: readonly string[] }
32 | { readonly kind: "other"; readonly line: string };
33
34 export interface SqlDoc {
35 readonly statements: readonly SqlStatement[];
36 /** Statements in the file, drawn or not. */
37 readonly total: number;
38 }
39
40 const VERB = /^(create|alter|drop|insert|update|delete|pragma|begin|commit|rollback|with|select|replace|vacuum|attach|detach|reindex|analyze|grant|revoke|set|comment|truncate|explain|savepoint|release)\b/i;
41
42 /** Split into statements with comments removed. Null when a quote or comment never closes. */
43 function split(source: string): string[] | null {
44 const out: string[] = [];
45 let cur = "";
46 let depth = 0;
47 for (let i = 0; i < source.length; i++) {
48 const ch = source[i]!;
49 if (ch === "-" && source[i + 1] === "-") {
50 const nl = source.indexOf("\n", i);
51 i = nl === -1 ? source.length : nl - 1;
52 } else if (ch === "/" && source[i + 1] === "*") {
53 const end = source.indexOf("*/", i + 2);
54 if (end === -1) return null;
55 i = end + 1;
56 cur += " ";
57 } else if (ch === "'" || ch === '"' || ch === "`" || ch === "[") {
58 const close = ch === "[" ? "]" : ch;
59 let j = i + 1;
60 for (; j < source.length; j++) {
61 if (source[j] === close) {
62 if (close !== "]" && source[j + 1] === close) j++; // doubled quote
63 else break;
64 }
65 }
66 if (j >= source.length) return null;
67 cur += source.slice(i, j + 1);
68 i = j;
69 } else if (ch === "$" && /^\$(?:[A-Za-z_]\w*)?\$/.test(source.slice(i, i + 64)) && (i === 0 || !/\w/.test(source[i - 1]!))) {
70 const tag = /^\$(?:[A-Za-z_]\w*)?\$/.exec(source.slice(i, i + 64))![0];
71 const end = source.indexOf(tag, i + tag.length);
72 if (end === -1) return null;
73 cur += source.slice(i, end + tag.length);
74 i = end + tag.length - 1;
75 } else if (ch === ";" && depth === 0) {
76 if (cur.trim() !== "") out.push(cur.trim());
77 cur = "";
78 } else {
79 if (ch === "(") depth++;
80 else if (ch === ")" && depth > 0) depth--;
81 cur += ch;
82 }
83 }
84 if (cur.trim() !== "") out.push(cur.trim());
85 return out;
86 }
87
88 /** Split on commas outside quotes and parentheses. */
89 function parts(body: string): string[] {
90 const out: string[] = [];
91 let cur = "";
92 let depth = 0;
93 for (let i = 0; i < body.length; i++) {
94 const ch = body[i]!;
95 if (ch === "'" || ch === '"' || ch === "`" || ch === "[") {
96 const close = ch === "[" ? "]" : ch;
97 let j = i + 1;
98 while (j < body.length && !(body[j] === close && (close === "]" || body[j + 1] !== close))) j += body[j] === close ? 2 : 1;
99 cur += body.slice(i, j + 1);
100 i = j;
101 } else if (ch === "," && depth === 0) {
102 out.push(cur.trim());
103 cur = "";
104 } else {
105 if (ch === "(") depth++;
106 else if (ch === ")") depth--;
107 cur += ch;
108 }
109 }
110 if (cur.trim() !== "") out.push(cur.trim());
111 return out;
112 }
113
114 const unquote = (id: string): string => {
115 const q = id[0];
116 if (q === "[" && id.endsWith("]")) return id.slice(1, -1);
117 if ((q === '"' || q === "`" || q === "'") && id.length >= 2 && id.endsWith(q)) return id.slice(1, -1).replaceAll(q + q, q);
118 return id;
119 };
120
121 /** One identifier, possibly quoted, from the start of `s`; then what follows. */
122 const IDENT = /^(\[[^\]]*\]|"(?:[^"]|"")*"|`(?:[^`]|``)*`|[^\s(),]+)\s*/;
123
124 /** A possibly schema-qualified name: `main."my table"`. */
125 function qualified(s: string): { name: string; rest: string } | null {
126 let name = "";
127 let rest = s;
128 for (let n = 0; n < 3; n++) {
129 const m = IDENT.exec(rest);
130 if (m === null) return null;
131 name += (name === "" ? "" : ".") + unquote(m[1]!);
132 rest = rest.slice(m[0].length);
133 if (rest[0] === ".") rest = rest.slice(1);
134 else break;
135 }
136 return { name, rest };
137 }
138
139 const STOP = /^(not|null|primary|default|unique|references|check|constraint|collate|generated|autoincrement|auto_increment|as|on|comment)$/i;
140 const TABLE_LEVEL = /^(constraint|primary|foreign|unique|check|key|index|fulltext|exclude|like)\b/i;
141
142 /** A word-boundary-aware first token and the rest, quotes kept whole. */
143 function tokens(s: string): string[] {
144 const out: string[] = [];
145 let i = 0;
146 while (i < s.length) {
147 if (/\s/.test(s[i]!)) {
148 i++;
149 continue;
150 }
151 const start = i;
152 const q = s[i]!;
153 if (q === "'" || q === '"' || q === "`" || q === "[") {
154 const close = q === "[" ? "]" : q;
155 i++;
156 while (i < s.length && !(s[i] === close && (close === "]" || s[i + 1] !== close))) i += s[i] === close ? 2 : 1;
157 i++;
158 } else if (q === "(") {
159 let d = 0;
160 for (; i < s.length; i++) {
161 if (s[i] === "(") d++;
162 else if (s[i] === ")" && --d === 0) {
163 i++;
164 break;
165 }
166 }
167 } else {
168 while (i < s.length && !/[\s(]/.test(s[i]!)) i++;
169 if (s[i] === "(") {
170 // a type like varchar(20): keep its arguments with it
171 let d = 0;
172 for (; i < s.length; i++) {
173 if (s[i] === "(") d++;
174 else if (s[i] === ")" && --d === 0) {
175 i++;
176 break;
177 }
178 }
179 }
180 }
181 out.push(s.slice(start, i));
182 }
183 return out;
184 }
185
186 function column(def: string): SqlColumn | null {
187 const t = tokens(def);
188 if (t.length === 0) return null;
189 const name = unquote(t[0]!);
190 const type: string[] = [];
191 let i = 1;
192 while (i < t.length && !STOP.test(t[i]!)) type.push(t[i++]!);
193 let notNull = false;
194 let primaryKey = false;
195 let dflt = "";
196 for (; i < t.length; i++) {
197 const w = t[i]!.toLowerCase();
198 if (w === "not" && t[i + 1]?.toLowerCase() === "null") notNull = true;
199 else if (w === "primary" && t[i + 1]?.toLowerCase() === "key") primaryKey = true;
200 else if (w === "default" && t[i + 1] !== undefined) dflt = t[i + 1]!;
201 }
202 return { name, type: type.join(" "), notNull: notNull || primaryKey, primaryKey, default: dflt };
203 }
204
205 /** Column names listed in a table-level `PRIMARY KEY (a, b)`. */
206 function pkColumns(def: string): string[] {
207 const m = /^(?:constraint\s+\S+\s+)?primary\s+key\s*\(([^)]*)\)/i.exec(def);
208 return m === null ? [] : parts(m[1]!).map((c) => unquote(c.trim().split(/\s+/)[0]!));
209 }
210
211 function table(stmt: string): SqlStatement | null {
212 const m = /^create\s+(?:(?:global\s+|local\s+)?(?:temp|temporary)\s+|virtual\s+|unlogged\s+)?table\s+(?:if\s+not\s+exists\s+)?/i.exec(stmt);
213 if (m === null) return null;
214 const q = qualified(stmt.slice(m[0].length));
215 if (q === null || q.rest[0] !== "(") return null;
216 let depth = 0;
217 let end = -1;
218 for (let i = 0; i < q.rest.length && end === -1; i++) {
219 const ch = q.rest[i]!;
220 if (ch === "'" || ch === '"' || ch === "`" || ch === "[") {
221 const close = ch === "[" ? "]" : ch;
222 let j = i + 1;
223 while (j < q.rest.length && !(q.rest[j] === close && (close === "]" || q.rest[j + 1] !== close))) j += q.rest[j] === close ? 2 : 1;
224 i = j;
225 } else if (ch === "(") depth++;
226 else if (ch === ")" && --depth === 0) end = i;
227 }
228 if (end === -1) return null;
229 const columns: SqlColumn[] = [];
230 const extras: string[] = [];
231 const keys: string[] = [];
232 for (const def of parts(q.rest.slice(1, end))) {
233 if (TABLE_LEVEL.test(def)) {
234 keys.push(...pkColumns(def));
235 extras.push(def.replace(/\s+/g, " "));
236 } else {
237 const c = column(def);
238 if (c !== null) columns.push(c);
239 }
240 }
241 if (columns.length === 0) return null;
242 const done = columns.map((c) => (keys.includes(c.name) ? { ...c, primaryKey: true, notNull: true } : c));
243 return { kind: "table", name: q.name, columns: done, extras };
244 }
245
246 /** Read a SQL file. Pure; never throws; null when it is not SQL it can read. */
247 export function parseSql(source: string): SqlDoc | null {
248 const stmts = split(source.replace(/^/, ""));
249 if (stmts === null || !stmts.some((s) => VERB.test(s))) return null;
250 const statements: SqlStatement[] = [];
251 for (const s of stmts) {
252 if (statements.length >= ROW_CAP) break;
253 const t = table(s);
254 if (t !== null) {
255 statements.push(t.kind === "table" ? { ...t, columns: t.columns.slice(0, COLUMN_CAP) } : t);
256 } else {
257 const line = (s.split(/\r?\n/)[0] ?? "").trim();
258 statements.push({ kind: "other", line: line.length > LINE_CAP ? line.slice(0, LINE_CAP) + "…" : line });
outdated

Both doors: add a sql branch in src/tui/screens/workspace.ts using parseSql.

259 }
outdated

Principle 3/layout at 360px: tables are not in a .table-scroll wrapper and this one-row table has no accessible name. Wrap and add aria-label.

260 }
261 return { statements, total: stmts.length };
262 }
263
264 const cell = (v: string): string => (v === "" ? "—" : v);
265
266 function block(s: SqlStatement): Html {
267 if (s.kind === "other") return html`<div class="table-scroll"><table class="table" data-dense aria-label="Statement"><tbody><tr><td><code>${s.line}</code></td></tr></tbody></table></div>`;
268 const rows = s.columns.map(
269 (c) =>
270 html`<tr><th scope="row"><code>${c.name}</code></th><td>${cell(c.type)}</td><td>${c.notNull ? "NOT NULL" : "—"}</td><td>${c.primaryKey ? "PRIMARY KEY" : "—"}</td><td>${cell(c.default)}</td></tr>`,
271 );
272 const extras = s.extras.length === 0 ? html`` : html`<p class="muted small">${s.extras.join(" · ")}</p>`;
273 return html`<h3><code>${s.name}</code></h3><div class="table-scroll"><table class="table" data-dense aria-label="${s.name} columns"><thead><tr><th scope="col">Column</th><th scope="col">Type</th><th scope="col">Null</th><th scope="col">Key</th><th scope="col">Default</th></tr></thead><tbody>${rows}</tbody></table></div>${extras}`;
274 }
275
276 export function sqlView(input: RenderInput): Rendered {
277 const doc = parseSql(new TextDecoder().decode(input.body));
278 if (doc === null) return { content: html``, lines: 0, fallback: "This file holds no SQL statements to draw." };
279 const more = doc.total - doc.statements.length;
280 const foot = more > 0 ? html`<p class="muted small">${more.toLocaleString()} more not shown</p>` : html``;
281 const content = html`<div class="sql-view">${doc.statements.map(block)}${foot}</div>`;
282 const n = doc.total;
283 return { content, lines: 0, summary: `${n.toLocaleString()} ${n === 1 ? "statement" : "statements"} · ${fileSize(input.size)}` };
284 }
test/render-kinds.test.ts +1
@@ -81,6 +81,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
8181 ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"],
8282 gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"],
8383 gco: [enc("G28\n"), "gcode", "application/octet-stream"],
84 sql: [enc("CREATE TABLE a (id int);\n"), "sql", "application/octet-stream"],
8485 sarif: [enc('{"runs":[]}'), "sarif", "application/octet-stream"],
8586 lcov: [enc("SF:a.ts\nend_of_record\n"), "lcov", "application/octet-stream"],
8687 };
test/render-sql.test.ts added +107
@@ -0,0 +1,107 @@
1 import { describe, expect, test } from "bun:test";
2 import { fileKind, NO_BYTES } from "../src/web/render/kinds.ts";
3 import { RENDERERS } from "../src/web/render/renderers.ts";
4 import { parseSql, ROW_CAP, sqlView } from "../src/web/render/sql.ts";
5
6 const draw = (text: string) => {
7 const body = new TextEncoder().encode(text);
8 const r = sqlView({ path: "db/001.sql", size: body.length, body, media: "", download: "" });
9 return { html: String(r.content), summary: r.summary, fallback: r.fallback };
10 };
11
12 describe("sql", () => {
13 test("a .sql file is its own kind with a renderer", () => {
14 expect(fileKind("db/001.sql", NO_BYTES)).toBe("sql");
15 expect(RENDERERS.sql).toBe(sqlView);
16 });
17
18 test("a table with constraints", () => {
19 const doc = parseSql(
20 `CREATE TABLE IF NOT EXISTS users (
21 id INTEGER PRIMARY KEY AUTOINCREMENT,
22 email TEXT NOT NULL UNIQUE,
23 name VARCHAR(20, 2) DEFAULT 'a,b',
24 org_id INT,
25 created TEXT NOT NULL DEFAULT (datetime('now')),
26 FOREIGN KEY (org_id) REFERENCES orgs(id)
27 );
28 CREATE INDEX idx ON users(email);`,
29 )!;
30 expect(doc.total).toBe(2);
31 const t = doc.statements[0]!;
32 if (t.kind !== "table") throw new Error("table");
33 expect(t.name).toBe("users");
34 expect(t.columns.map((c) => [c.name, c.type, c.notNull, c.primaryKey, c.default])).toEqual([
35 ["id", "INTEGER", true, true, ""],
36 ["email", "TEXT", true, false, ""],
37 ["name", "VARCHAR(20, 2)", false, false, "'a,b'"],
38 ["org_id", "INT", false, false, ""],
39 ["created", "TEXT", true, false, "(datetime('now'))"],
40 ]);
41 expect(t.extras).toEqual(["FOREIGN KEY (org_id) REFERENCES orgs(id)"]);
42 expect(doc.statements[1]).toEqual({ kind: "other", line: "CREATE INDEX idx ON users(email)" });
43 });
44
45 test("a table-level primary key marks its columns", () => {
46 const t = parseSql("CREATE TABLE m (a INT, b INT, PRIMARY KEY (a, b));")!.statements[0]!;
47 if (t.kind !== "table") throw new Error("table");
48 expect(t.columns.map((c) => c.primaryKey)).toEqual([true, true]);
49 });
50
51 test("comments are not statements and may hide semicolons", () => {
52 const doc = parseSql("-- first; second\n/* CREATE TABLE no (x int); */\nALTER TABLE a ADD COLUMN b INT; -- done\n")!;
53 expect(doc.statements).toEqual([{ kind: "other", line: "ALTER TABLE a ADD COLUMN b INT" }]);
54 });
55
56 test("a quoted name with < is escaped", () => {
57 const out = draw('CREATE TABLE "<img src=x onerror=alert(1)>" ("<b>" TEXT);\nALTER TABLE "<i>" ADD x INT;').html;
58 expect(out).not.toContain("<img");
59 expect(out).not.toContain("<b>");
60 expect(out).not.toContain("<i>");
61 expect(out).toContain("&lt;img src=x onerror=alert(1)&gt;");
62 });
63
64 test("renders a block per statement with a summary", () => {
65 const r = draw("CREATE TABLE t (id INT NOT NULL);\nDROP TABLE u;");
66 expect(r.html).toContain("<h3>");
67 expect(r.html).toContain("NOT NULL");
68 expect(r.summary).toContain("2 statements");
69 expect(r.fallback).toBeUndefined();
70 });
71
72 test("an unparsable file falls back to the source", () => {
73 for (const text of ["just some prose, nothing here", "", "SELECT 'unterminated;", "/* never closed\nCREATE TABLE a (x int);"]) {
74 const r = draw(text);
75 expect(r.fallback).toBeDefined();
76 }
77 });
78
79 test("a malformed CREATE TABLE is one row, not an error", () => {
80 const doc = parseSql("CREATE TABLE broken (a INT, ;\nCREATE TABLE t2;")!;
81 expect(doc.statements.every((s) => s.kind === "other")).toBe(true);
82 });
83
84 test("the cap", () => {
85 const text = Array.from({ length: ROW_CAP + 25 }, (_, i) => `CREATE INDEX i${i} ON t(a);`).join("\n");
86 const r = draw(text);
87 expect(r.html).toContain("25 more not shown");
88 expect(r.html.match(/<table/g)?.length).toBe(ROW_CAP);
89 });
90
91 test("every table sits in a scroll wrapper and is named", () => {
92 const r = draw("CREATE TABLE t (a INT);\nDROP TABLE x;");
93 expect(r.html.match(/<div class="table-scroll"><table/g)?.length).toBe(2);
94 expect(r.html).toContain('aria-label="Statement"');
95 });
96
97 test("the column cap", () => {
98 const cols = Array.from({ length: 520 }, (_, i) => `c${i} INT`).join(",");
99 const doc = parseSql(`CREATE TABLE wide (${cols});`)!;
100 expect((doc.statements[0] as unknown as { columns: unknown[] }).columns.length).toBe(500);
101 });
102
103 test("a $$ body is one statement", () => {
104 const doc = parseSql("CREATE FUNCTION f() RETURNS int AS $$ SELECT 1; SELECT 2; $$ LANGUAGE sql;\nCREATE TABLE t (a INT);")!;
105 expect(doc.total).toBe(2);
106 });
107 });
test/tui-workspace.test.ts +21
@@ -616,6 +616,27 @@ describe("the terminal workspace", () => {
616616 }
617617 });
618618
619 test("a .sql file opens as its columns, and s shows the source", async () => {
620 const made = await createRepo(forge, alice, { name: "sqlrepo", visibility: "public", description: "db" });
621 if (!made.ok) throw new Error("repo failed");
622 const sql = "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL);\nCREATE INDEX idx ON users(email);";
623 await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "db/schema.sql", content: sql }] });
624 const h = boot();
625 try {
626 await h.settle();
627 await open(h, "sqlrepo");
628 await openFile(h, "db/schema.sql");
629 expect(h.text()).toContain("users");
630 expect(h.text()).toContain("PRIMARY KEY");
631 expect(h.text()).toContain("NOT NULL");
632 expect(h.text()).toContain("CREATE INDEX idx");
633 await h.press("s");
634 expect(h.text()).toContain("CREATE TABLE users");
635 } finally {
636 h.stop();
637 }
638 });
639
619640 test("a JUnit report opens as its totals and failing cases, sharing the web parser", async () => {
620641 const made = await createRepo(forge, alice, { name: "junitrepo", visibility: "public", description: "tests" });
621642 if (!made.ok) throw new Error("repo failed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs