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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11187-a-sql-file-in-a-repository-opens-as-its → main 7 commits fork 2 threads · 2 unresolved
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 | ||
| 47 | 47 | ## OpenAPI operations (openapi.json, *.openapi.yaml) |
| 48 | 48 | |
| 49 | 49 | `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"; | ||
| 61 | 61 | import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; |
| 62 | 62 | import { CASE_CAP, parseJunit } from "../../web/render/junit.ts"; |
| 63 | 63 | import { parseOpenApi } from "../../web/render/openapi.ts"; |
| 64 | import { parseSql } from "../../web/render/sql.ts"; | |
| 64 | 65 | import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts"; |
| 65 | 66 | import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/lcov.ts"; |
| 66 | 67 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| @@ -468,6 +469,24 @@ function openapiTable(text: string, path: string): Table | string | null { | ||
| 468 | 469 | return { header: ["Tag", "Method", "Path", "Summary"], rows, more: parsed.more, delimiter: ",", columns: 4 }; |
| 469 | 470 | } |
| 470 | 471 | |
| 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 | ||
| 471 | 490 | /** A JUnit report, by the web's own rule (file name, or root element). */ |
| 472 | 491 | function isJunitPath(path: string, source: Uint8Array): boolean { |
| 473 | 492 | return /\.xml$/i.test(path) && fileKind(path, source.subarray(0, 4096)) === "junit"; |
| @@ -722,6 +741,11 @@ class Tab { | ||
| 722 | 741 | this.table = typeof api === "string" ? null : api; |
| 723 | 742 | this.fault = typeof api === "string" ? api : ""; |
| 724 | 743 | 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); | |
| 725 | 749 | } else if (isJunitPath(this.path, source)) { |
| 726 | 750 | const results = junitTable(new TextDecoder().decode(source)); |
| 727 | 751 | this.table = typeof results === "string" ? null : results; |
src/web/render/kinds.ts +3
| @@ -42,6 +42,7 @@ export type FileKind = | ||
| 42 | 42 | | "junit" |
| 43 | 43 | | "env" |
| 44 | 44 | | "openapi" |
| 45 | | "sql" | |
| 45 | 46 | | "text" |
| 46 | 47 | | "binary"; |
| 47 | 48 | |
| @@ -82,6 +83,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 82 | 83 | junit: 5 * MB, |
| 83 | 84 | env: 1 * MB, |
| 84 | 85 | openapi: 1 * MB, |
| 86 | sql: 1 * MB, | |
| 85 | 87 | text: 1 * MB, |
| 86 | 88 | binary: 0, |
| 87 | 89 | }; |
| @@ -205,6 +207,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 205 | 207 | gco: "gcode", |
| 206 | 208 | sarif: "sarif", |
| 207 | 209 | lcov: "lcov", |
| 210 | sql: "sql", | |
| 208 | 211 | }; |
| 209 | 212 | |
| 210 | 213 | /** 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"; | ||
| 11 | 11 | import { config } from "./config.ts"; |
| 12 | 12 | import { isFormConfig } from "./config-format.ts"; |
| 13 | 13 | import { envView } from "./env.ts"; |
| 14 | import { sqlView } from "./sql.ts"; | |
| 14 | 15 | import { eml } from "./eml.ts"; |
| 15 | 16 | import { csv } from "./csv.ts"; |
| 16 | 17 | import { geojson } from "./geojson.ts"; |
| @@ -100,6 +101,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 100 | 101 | lcov, |
| 101 | 102 | junit, |
| 102 | 103 | env: envView, |
| 104 | sql: sqlView, | |
| 103 | 105 | openapi, |
| 104 | 106 | mail: eml, |
| 105 | 107 | 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 }); | |
| 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]> = { | ||
| 81 | 81 | ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"], |
| 82 | 82 | gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"], |
| 83 | 83 | gco: [enc("G28\n"), "gcode", "application/octet-stream"], |
| 84 | sql: [enc("CREATE TABLE a (id int);\n"), "sql", "application/octet-stream"], | |
| 84 | 85 | sarif: [enc('{"runs":[]}'), "sarif", "application/octet-stream"], |
| 85 | 86 | lcov: [enc("SF:a.ts\nend_of_record\n"), "lcov", "application/octet-stream"], |
| 86 | 87 | }; |
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("<img src=x onerror=alert(1)>"); | |
| 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", () => { | ||
| 616 | 616 | } |
| 617 | 617 | }); |
| 618 | 618 | |
| 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 | ||
| 619 | 640 | test("a JUnit report opens as its totals and failing cases, sharing the web parser", async () => { |
| 620 | 641 | const made = await createRepo(forge, alice, { name: "junitrepo", visibility: "public", description: "tests" }); |
| 621 | 642 | if (!made.ok) throw new Error("repo failed"); |
Both doors: add a sql branch in src/tui/screens/workspace.ts using parseSql.