sqlite-hub 0.11.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +52 -13
  2. package/database.sqlite +0 -0
  3. package/frontend/js/api.js +64 -12
  4. package/frontend/js/app.js +723 -37
  5. package/frontend/js/components/emptyState.js +42 -46
  6. package/frontend/js/components/modal.js +526 -2
  7. package/frontend/js/components/queryEditor.js +12 -3
  8. package/frontend/js/components/queryResults.js +79 -17
  9. package/frontend/js/components/rowEditorPanel.js +345 -12
  10. package/frontend/js/components/sidebar.js +106 -23
  11. package/frontend/js/components/tableDesignerEditor.js +69 -11
  12. package/frontend/js/store.js +437 -26
  13. package/frontend/js/utils/copyColumnExport.js +117 -0
  14. package/frontend/js/utils/exportFilenames.js +32 -0
  15. package/frontend/js/utils/filePathPreview.js +315 -0
  16. package/frontend/js/utils/format.js +1 -1
  17. package/frontend/js/utils/rowEditorJson.js +65 -0
  18. package/frontend/js/utils/sqlFormatter.js +691 -0
  19. package/frontend/js/utils/tableDesigner.js +178 -6
  20. package/frontend/js/utils/textCellStats.js +20 -0
  21. package/frontend/js/utils/timestampPreview.js +264 -0
  22. package/frontend/js/views/charts.js +3 -1
  23. package/frontend/js/views/data.js +39 -4
  24. package/frontend/js/views/editor.js +50 -1
  25. package/frontend/js/views/settings.js +1 -4
  26. package/frontend/js/views/structure.js +154 -212
  27. package/frontend/styles/base.css +6 -0
  28. package/frontend/styles/components.css +463 -2
  29. package/frontend/styles/structure-graph.css +0 -3
  30. package/frontend/styles/tailwind.generated.css +1 -1
  31. package/frontend/styles/tokens.css +95 -95
  32. package/package.json +2 -3
  33. package/server/routes/export.js +97 -12
  34. package/server/services/sqlite/dataBrowserService.js +2 -68
  35. package/server/services/sqlite/exportService.js +74 -15
  36. package/server/services/sqlite/introspection.js +209 -1
  37. package/server/services/sqlite/sqlExecutor.js +31 -0
  38. package/server/services/sqlite/tableDesigner/changeAnalysis.js +25 -1
  39. package/server/services/sqlite/tableDesigner/schemaMapping.js +105 -10
  40. package/server/services/sqlite/tableDesigner/validation.js +60 -2
  41. package/server/services/sqlite/tableDesignerService.js +1 -1
  42. package/server/services/sqlite/tableFilter.js +75 -0
  43. package/server/utils/csv.js +30 -4
  44. package/tests/check-constraint-options.test.js +90 -0
  45. package/tests/export-filenames.test.js +34 -0
  46. package/tests/file-path-preview.test.js +165 -0
  47. package/tests/row-editor-json.test.js +82 -0
  48. package/tests/row-editor-timestamp-preview.test.js +192 -0
  49. package/tests/sql-formatter.test.js +173 -0
  50. package/tests/sql-highlight.test.js +38 -0
  51. package/tests/table-designer-v2-unique-constraints.test.js +78 -0
  52. package/tests/text-cell-stats.test.js +38 -0
@@ -0,0 +1,165 @@
1
+ const assert = require("node:assert/strict");
2
+ const { readFileSync } = require("node:fs");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+
6
+ let filePathPreviewModulePromise = null;
7
+
8
+ function loadFilePathPreviewModule() {
9
+ if (!filePathPreviewModulePromise) {
10
+ const timestampPreviewSource = readFileSync(
11
+ path.resolve(__dirname, "../frontend/js/utils/timestampPreview.js"),
12
+ "utf8"
13
+ );
14
+ const timestampPreviewUrl = `data:text/javascript;base64,${Buffer.from(timestampPreviewSource).toString("base64")}`;
15
+ const filePathPreviewSource = readFileSync(
16
+ path.resolve(__dirname, "../frontend/js/utils/filePathPreview.js"),
17
+ "utf8"
18
+ ).replace(
19
+ 'import { isProtectedKeyColumn } from "./timestampPreview.js";',
20
+ `import { isProtectedKeyColumn } from "${timestampPreviewUrl}";`
21
+ );
22
+ const filePathPreviewUrl = `data:text/javascript;base64,${Buffer.from(filePathPreviewSource).toString("base64")}`;
23
+
24
+ filePathPreviewModulePromise = import(filePathPreviewUrl);
25
+ }
26
+
27
+ return filePathPreviewModulePromise;
28
+ }
29
+
30
+ function createTableMeta() {
31
+ return {
32
+ columns: [
33
+ { name: "id", primaryKeyPosition: 1 },
34
+ { name: "parent_id", primaryKeyPosition: 0 },
35
+ { name: "file_path", primaryKeyPosition: 0 },
36
+ { name: "filename", primaryKeyPosition: 0 },
37
+ { name: "note", primaryKeyPosition: 0 },
38
+ ],
39
+ foreignKeys: [
40
+ {
41
+ mappings: [{ from: "parent_id", to: "id" }],
42
+ },
43
+ ],
44
+ };
45
+ }
46
+
47
+ test("filepath preview detects supported path styles", async () => {
48
+ const { detectFilePathValue } = await loadFilePathPreviewModule();
49
+ const tableMeta = createTableMeta();
50
+ const cases = [
51
+ {
52
+ value: "/Users/oli/project/file.txt",
53
+ pathType: "unix",
54
+ fileName: "file.txt",
55
+ directory: "/Users/oli/project",
56
+ extension: "txt",
57
+ },
58
+ {
59
+ value: "/home/oli/data/app.sqlite",
60
+ pathType: "unix",
61
+ fileName: "app.sqlite",
62
+ directory: "/home/oli/data",
63
+ extension: "sqlite",
64
+ },
65
+ {
66
+ value: "C:\\Users\\Oliver\\Desktop\\file.pdf",
67
+ pathType: "windows",
68
+ fileName: "file.pdf",
69
+ directory: "C:\\Users\\Oliver\\Desktop",
70
+ extension: "pdf",
71
+ },
72
+ {
73
+ value: "./data/export.csv",
74
+ pathType: "relative",
75
+ fileName: "export.csv",
76
+ directory: "./data",
77
+ extension: "csv",
78
+ },
79
+ {
80
+ value: "../logs/app.log",
81
+ pathType: "relative",
82
+ fileName: "app.log",
83
+ directory: "../logs",
84
+ extension: "log",
85
+ },
86
+ {
87
+ value: "~/Documents/report.md",
88
+ pathType: "home",
89
+ fileName: "report.md",
90
+ directory: "~/Documents",
91
+ extension: "md",
92
+ },
93
+ {
94
+ value: "data/images/avatar.png",
95
+ pathType: "relative",
96
+ fileName: "avatar.png",
97
+ directory: "data/images",
98
+ extension: "png",
99
+ },
100
+ ];
101
+
102
+ for (const item of cases) {
103
+ const preview = detectFilePathValue(item.value, "file_path", tableMeta);
104
+
105
+ assert.equal(preview?.type, "filepath");
106
+ assert.equal(preview.pathType, item.pathType);
107
+ assert.equal(preview.fileName, item.fileName);
108
+ assert.equal(preview.directory, item.directory);
109
+ assert.equal(preview.extension, item.extension);
110
+ assert.ok(preview.confidence >= 0.7);
111
+ }
112
+ });
113
+
114
+ test("filepath preview does not detect URLs, primitive text, JSON, emails, or weak filenames", async () => {
115
+ const { detectFilePathValue } = await loadFilePathPreviewModule();
116
+ const tableMeta = createTableMeta();
117
+ const rejectedValues = [
118
+ "https://example.com/file.txt",
119
+ "http://localhost:3000/test",
120
+ "mailto:oliver@example.com",
121
+ "tel:+431234567",
122
+ "ftp://example.com/file.txt",
123
+ "oliver@example.com",
124
+ "1717682400",
125
+ "true",
126
+ "false",
127
+ '{"path":"/tmp/file.txt"}',
128
+ "hello world",
129
+ "file",
130
+ "invoice.pdf",
131
+ ];
132
+
133
+ for (const value of rejectedValues) {
134
+ assert.equal(detectFilePathValue(value, "note", tableMeta), null);
135
+ }
136
+ });
137
+
138
+ test("filepath preview protects primary keys and foreign keys", async () => {
139
+ const { detectFilePathValue } = await loadFilePathPreviewModule();
140
+ const tableMeta = createTableMeta();
141
+
142
+ assert.equal(detectFilePathValue("/Users/oli/project/file.txt", "id", tableMeta), null);
143
+ assert.equal(detectFilePathValue("/Users/oli/project/file.txt", "parent_id", tableMeta), null);
144
+ });
145
+
146
+ test("filepath preview allows bare filenames only for strong file columns", async () => {
147
+ const { detectFilePathValue } = await loadFilePathPreviewModule();
148
+ const tableMeta = createTableMeta();
149
+
150
+ assert.equal(detectFilePathValue("invoice.pdf", "note", tableMeta), null);
151
+
152
+ const preview = detectFilePathValue("invoice.pdf", "filename", tableMeta);
153
+
154
+ assert.equal(preview?.pathType, "relative");
155
+ assert.equal(preview.fileName, "invoice.pdf");
156
+ assert.equal(preview.directory, null);
157
+ assert.equal(preview.extension, "pdf");
158
+ });
159
+
160
+ test("filepath preview compacts long paths while preserving filename", async () => {
161
+ const { compactPathForDisplay } = await loadFilePathPreviewModule();
162
+ const display = compactPathForDisplay("/Users/oli/projects/sqlite-hub/data/app.sqlite", 24);
163
+
164
+ assert.equal(display, ".../data/app.sqlite");
165
+ });
@@ -0,0 +1,82 @@
1
+ const assert = require("node:assert/strict");
2
+ const { readFileSync } = require("node:fs");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+
6
+ let rowEditorJsonModulePromise = null;
7
+
8
+ function loadRowEditorJsonModule() {
9
+ if (!rowEditorJsonModulePromise) {
10
+ const source = readFileSync(
11
+ path.resolve(__dirname, "../frontend/js/utils/rowEditorJson.js"),
12
+ "utf8"
13
+ );
14
+ const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
15
+
16
+ rowEditorJsonModulePromise = import(url);
17
+ }
18
+
19
+ return rowEditorJsonModulePromise;
20
+ }
21
+
22
+ test("row editor JSON for data rows preserves column order and omits identity metadata", async () => {
23
+ const { buildDataRowEditorJsonObject, stringifyRowEditorJson } = await loadRowEditorJsonModule();
24
+ const row = {
25
+ id: 1,
26
+ title: "Event",
27
+ created_at: 1717682400,
28
+ payload: { __type: "blob", sizeBytes: 4, hexPreview: "ffff" },
29
+ __identity: { kind: "primaryKey", values: { id: 1 } },
30
+ };
31
+ const rowObject = buildDataRowEditorJsonObject({
32
+ row,
33
+ columns: ["id", "title", "created_at", "payload"],
34
+ });
35
+
36
+ assert.deepEqual(Object.keys(rowObject), ["id", "title", "created_at", "payload"]);
37
+ assert.deepEqual(rowObject, {
38
+ id: 1,
39
+ title: "Event",
40
+ created_at: 1717682400,
41
+ payload: { __type: "blob", sizeBytes: 4, hexPreview: "ffff" },
42
+ });
43
+ assert.equal(
44
+ stringifyRowEditorJson(rowObject),
45
+ [
46
+ "{",
47
+ ' "id": 1,',
48
+ ' "title": "Event",',
49
+ ' "created_at": 1717682400,',
50
+ ' "payload": {',
51
+ ' "__type": "blob",',
52
+ ' "sizeBytes": 4,',
53
+ ' "hexPreview": "ffff"',
54
+ " }",
55
+ "}",
56
+ ].join("\n")
57
+ );
58
+ });
59
+
60
+ test("row editor JSON for SQL result rows maps visible source columns once", async () => {
61
+ const { buildEditorRowEditorJsonObject } = await loadRowEditorJsonModule();
62
+ const rowObject = buildEditorRowEditorJsonObject({
63
+ row: {
64
+ id: 1,
65
+ title_alias: "Event",
66
+ duplicate_title: "Ignored",
67
+ hidden_note: "Hidden",
68
+ __identity: { kind: "primaryKey", values: { id: 1 } },
69
+ },
70
+ editingColumns: [
71
+ { resultName: "id", sourceColumn: "id", visible: true },
72
+ { resultName: "title_alias", sourceColumn: "title", visible: true },
73
+ { resultName: "duplicate_title", sourceColumn: "title", visible: true },
74
+ { resultName: "hidden_note", sourceColumn: "note", visible: false },
75
+ ],
76
+ });
77
+
78
+ assert.deepEqual(rowObject, {
79
+ id: 1,
80
+ title: "Event",
81
+ });
82
+ });
@@ -0,0 +1,192 @@
1
+ const Database = require("better-sqlite3");
2
+ const assert = require("node:assert/strict");
3
+ const { readFileSync } = require("node:fs");
4
+ const path = require("node:path");
5
+ const test = require("node:test");
6
+ const { DataBrowserService } = require("../server/services/sqlite/dataBrowserService");
7
+
8
+ let timestampPreviewModulePromise = null;
9
+
10
+ function loadTimestampPreviewModule() {
11
+ if (!timestampPreviewModulePromise) {
12
+ const source = readFileSync(
13
+ path.resolve(__dirname, "../frontend/js/utils/timestampPreview.js"),
14
+ "utf8"
15
+ );
16
+ const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
17
+
18
+ timestampPreviewModulePromise = import(url);
19
+ }
20
+
21
+ return timestampPreviewModulePromise;
22
+ }
23
+
24
+ function createTableMeta() {
25
+ return {
26
+ columns: [
27
+ { name: "id", primaryKeyPosition: 1 },
28
+ { name: "parent_id", primaryKeyPosition: 0 },
29
+ { name: "created_at", primaryKeyPosition: 0 },
30
+ { name: "updated_at", primaryKeyPosition: 0 },
31
+ { name: "published_at", primaryKeyPosition: 0 },
32
+ { name: "price", primaryKeyPosition: 0 },
33
+ ],
34
+ foreignKeys: [
35
+ {
36
+ mappings: [{ from: "parent_id", to: "id" }],
37
+ },
38
+ ],
39
+ };
40
+ }
41
+
42
+ test("row editor timestamp preview protects primary and foreign keys", async () => {
43
+ const {
44
+ getTimestampPreviewForField,
45
+ isProtectedKeyColumn,
46
+ } = await loadTimestampPreviewModule();
47
+ const tableMeta = createTableMeta();
48
+
49
+ assert.equal(isProtectedKeyColumn("id", tableMeta), true);
50
+ assert.equal(isProtectedKeyColumn("parent_id", tableMeta), true);
51
+
52
+ assert.equal(
53
+ getTimestampPreviewForField({
54
+ columnName: "id",
55
+ value: "1717682400",
56
+ tableMeta,
57
+ }).kind,
58
+ "protected-key"
59
+ );
60
+ assert.equal(
61
+ getTimestampPreviewForField({
62
+ columnName: "parent_id",
63
+ value: "1717682400",
64
+ tableMeta,
65
+ }).kind,
66
+ "protected-key"
67
+ );
68
+ });
69
+
70
+ test("row editor timestamp preview formats plausible non-key timestamp values", async () => {
71
+ const { getTimestampPreviewForField } = await loadTimestampPreviewModule();
72
+ const tableMeta = createTableMeta();
73
+
74
+ const createdAtPreview = getTimestampPreviewForField({
75
+ columnName: "created_at",
76
+ value: "1717682400",
77
+ tableMeta,
78
+ });
79
+ const updatedAtPreview = getTimestampPreviewForField({
80
+ columnName: "updated_at",
81
+ value: "1717682400000",
82
+ tableMeta,
83
+ });
84
+ const pricePreview = getTimestampPreviewForField({
85
+ columnName: "price",
86
+ value: "1717682400",
87
+ tableMeta,
88
+ });
89
+ const publishedAtPreview = getTimestampPreviewForField({
90
+ columnName: "published_at",
91
+ value: "1717682400000000",
92
+ tableMeta,
93
+ });
94
+
95
+ assert.equal(createdAtPreview.kind, "timestamp");
96
+ assert.equal(createdAtPreview.sourceFormat, "unix-seconds");
97
+ assert.equal(updatedAtPreview.kind, "timestamp");
98
+ assert.equal(updatedAtPreview.sourceFormat, "unix-milliseconds");
99
+ assert.equal(publishedAtPreview.kind, "timestamp");
100
+ assert.equal(publishedAtPreview.sourceFormat, "unix-microseconds");
101
+ assert.equal(pricePreview.kind, "timestamp");
102
+ assert.match(createdAtPreview.formatted, /^\d{2}\.\d{2}\.\d{4}, \d{2}:\d{2}:\d{2}$/);
103
+ });
104
+
105
+ test("row editor timestamp preview ignores ids and invalid timestamps", async () => {
106
+ const { getTimestampPreviewForField } = await loadTimestampPreviewModule();
107
+ const tableMeta = createTableMeta();
108
+
109
+ assert.equal(
110
+ getTimestampPreviewForField({
111
+ columnName: "external_id",
112
+ value: "1717682400",
113
+ tableMeta,
114
+ }).kind,
115
+ "none"
116
+ );
117
+ assert.equal(
118
+ getTimestampPreviewForField({
119
+ columnName: "created_at",
120
+ value: "42",
121
+ tableMeta,
122
+ }).kind,
123
+ "none"
124
+ );
125
+ assert.equal(
126
+ getTimestampPreviewForField({
127
+ columnName: "created_at",
128
+ value: "not a timestamp",
129
+ tableMeta,
130
+ }).kind,
131
+ "none"
132
+ );
133
+ });
134
+
135
+ test("row editor timestamp preview accepts ISO and SQLite datetime strings", async () => {
136
+ const { getTimestampPreviewForField } = await loadTimestampPreviewModule();
137
+ const tableMeta = createTableMeta();
138
+
139
+ assert.equal(
140
+ getTimestampPreviewForField({
141
+ columnName: "created_at",
142
+ value: "2026-06-06T18:49:00Z",
143
+ tableMeta,
144
+ }).kind,
145
+ "timestamp"
146
+ );
147
+ assert.equal(
148
+ getTimestampPreviewForField({
149
+ columnName: "updated_at",
150
+ value: "2026-06-06 18:49:00",
151
+ tableMeta,
152
+ }).kind,
153
+ "timestamp"
154
+ );
155
+ });
156
+
157
+ test("row update stores the submitted raw value without date conversion", () => {
158
+ const db = new Database(":memory:");
159
+
160
+ try {
161
+ db.exec(`
162
+ CREATE TABLE events (
163
+ id INTEGER PRIMARY KEY,
164
+ created_at TEXT
165
+ );
166
+ INSERT INTO events (id, created_at) VALUES (1, 'before');
167
+ `);
168
+
169
+ const service = new DataBrowserService({
170
+ connectionManager: {
171
+ assertWritable() {},
172
+ getActiveDatabase: () => db,
173
+ },
174
+ });
175
+ const tableData = service.getTableData("events", { limit: 10, offset: 0 });
176
+ const identity = tableData.rows[0].__identity;
177
+
178
+ service.updateTableRow("events", {
179
+ identity,
180
+ values: {
181
+ created_at: "1717682400",
182
+ },
183
+ });
184
+
185
+ assert.equal(
186
+ db.prepare("SELECT created_at FROM events WHERE id = 1").get().created_at,
187
+ "1717682400"
188
+ );
189
+ } finally {
190
+ db.close();
191
+ }
192
+ });
@@ -0,0 +1,173 @@
1
+ const assert = require("node:assert/strict");
2
+ const { readFileSync } = require("node:fs");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+
6
+ let sqlFormatterModulePromise = null;
7
+
8
+ function loadSqlFormatterModule() {
9
+ if (!sqlFormatterModulePromise) {
10
+ const source = readFileSync(
11
+ path.resolve(__dirname, "../frontend/js/utils/sqlFormatter.js"),
12
+ "utf8"
13
+ );
14
+ const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
15
+
16
+ sqlFormatterModulePromise = import(url);
17
+ }
18
+
19
+ return sqlFormatterModulePromise;
20
+ }
21
+
22
+ test("SQL formatter uppercases keywords and formats common clauses", async () => {
23
+ const { formatSqlQuery } = await loadSqlFormatterModule();
24
+
25
+ assert.equal(
26
+ formatSqlQuery("select id,name from users where status='active' and age>=18 order by name desc;"),
27
+ [
28
+ "SELECT id,",
29
+ " name",
30
+ "",
31
+ "FROM users",
32
+ "",
33
+ "WHERE status = 'active'",
34
+ " AND age >= 18",
35
+ "",
36
+ "ORDER BY name DESC;",
37
+ ].join("\n")
38
+ );
39
+ });
40
+
41
+ test("SQL formatter keeps string literals and comments untouched", async () => {
42
+ const { formatSqlQuery } = await loadSqlFormatterModule();
43
+
44
+ assert.equal(
45
+ formatSqlQuery("-- select from comment\nselect 'from where' as note, name from users"),
46
+ [
47
+ "-- select from comment",
48
+ "SELECT 'from where' AS note,",
49
+ " name",
50
+ "",
51
+ "FROM users",
52
+ ].join("\n")
53
+ );
54
+ });
55
+
56
+ test("SQL formatter formats joins on separate readable lines", async () => {
57
+ const { formatSqlQuery } = await loadSqlFormatterModule();
58
+
59
+ assert.equal(
60
+ formatSqlQuery("select * from users left join posts on posts.user_id=users.id where users.id=1"),
61
+ [
62
+ "SELECT *",
63
+ "",
64
+ "FROM users",
65
+ "",
66
+ "LEFT JOIN posts",
67
+ " ON posts.user_id = users.id",
68
+ "",
69
+ "WHERE users.id = 1",
70
+ ].join("\n")
71
+ );
72
+ });
73
+
74
+ test("SQL formatter does not break function arguments as select columns", async () => {
75
+ const { formatSqlQuery } = await loadSqlFormatterModule();
76
+
77
+ assert.equal(
78
+ formatSqlQuery("select coalesce(first_name,last_name) as display_name, id from users"),
79
+ [
80
+ "SELECT coalesce(first_name, last_name) AS display_name,",
81
+ " id",
82
+ "",
83
+ "FROM users",
84
+ ].join("\n")
85
+ );
86
+ });
87
+
88
+ test("SQL formatter indents AS parenthesis blocks", async () => {
89
+ const { formatSqlQuery } = await loadSqlFormatterModule();
90
+
91
+ assert.equal(
92
+ formatSqlQuery("with recent as(select id,name from users where active=1) select * from recent"),
93
+ [
94
+ "WITH recent AS (",
95
+ " SELECT id,",
96
+ " name",
97
+ "",
98
+ " FROM users",
99
+ "",
100
+ " WHERE active = 1",
101
+ ")",
102
+ "",
103
+ "SELECT *",
104
+ "",
105
+ "FROM recent",
106
+ ].join("\n")
107
+ );
108
+ });
109
+
110
+ test("SQL formatter does not treat non-parenthesized AS keywords as blocks", async () => {
111
+ const { formatSqlQuery } = await loadSqlFormatterModule();
112
+
113
+ assert.equal(
114
+ formatSqlQuery("select cast(value as integer) as value_int from metrics"),
115
+ [
116
+ "SELECT CAST(value AS INTEGER) AS value_int",
117
+ "",
118
+ "FROM metrics",
119
+ ].join("\n")
120
+ );
121
+ });
122
+
123
+ test("SQL formatter expands CASE expressions into readable blocks", async () => {
124
+ const { formatSqlQuery } = await loadSqlFormatterModule();
125
+
126
+ assert.equal(
127
+ formatSqlQuery(
128
+ "select upload_date,case when upload_date is null then null else substr(cast(upload_date as text),1,4)||'-'||substr(cast(upload_date as text),5,2)||'-'||substr(cast(upload_date as text),7,2) end as upload_date_readable from white_house_live_streams"
129
+ ),
130
+ [
131
+ "SELECT upload_date,",
132
+ " CASE",
133
+ " WHEN upload_date IS NULL THEN NULL",
134
+ " ELSE",
135
+ " substr(CAST(upload_date AS TEXT), 1, 4) || '-' ||",
136
+ " substr(CAST(upload_date AS TEXT), 5, 2) || '-' ||",
137
+ " substr(CAST(upload_date AS TEXT), 7, 2)",
138
+ " END AS upload_date_readable",
139
+ "",
140
+ "FROM white_house_live_streams",
141
+ ].join("\n")
142
+ );
143
+ });
144
+
145
+ test("SQL formatter separates window functions and join blocks", async () => {
146
+ const { formatSqlQuery } = await loadSqlFormatterModule();
147
+
148
+ assert.equal(
149
+ formatSqlQuery(
150
+ "with winners as(select w.window_label,row_number() over(partition by w.window_label order by w.delta_return desc) as winner_rank from windowed w join mention_impact mi on mi.id=w.mention_impact_id order by w.window_label,winner_rank) select * from winners"
151
+ ),
152
+ [
153
+ "WITH winners AS (",
154
+ " SELECT w.window_label,",
155
+ " ROW_NUMBER() OVER (",
156
+ " PARTITION BY w.window_label",
157
+ " ORDER BY w.delta_return DESC",
158
+ " ) AS winner_rank",
159
+ "",
160
+ " FROM windowed w",
161
+ "",
162
+ " JOIN mention_impact mi",
163
+ " ON mi.id = w.mention_impact_id",
164
+ "",
165
+ " ORDER BY w.window_label, winner_rank",
166
+ ")",
167
+ "",
168
+ "SELECT *",
169
+ "",
170
+ "FROM winners",
171
+ ].join("\n")
172
+ );
173
+ });
@@ -0,0 +1,38 @@
1
+ const assert = require("node:assert/strict");
2
+ const { readFileSync } = require("node:fs");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+
6
+ let formatModulePromise = null;
7
+
8
+ function loadFormatModule() {
9
+ if (!formatModulePromise) {
10
+ const source = readFileSync(
11
+ path.resolve(__dirname, "../frontend/js/utils/format.js"),
12
+ "utf8"
13
+ );
14
+ const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
15
+
16
+ formatModulePromise = import(url);
17
+ }
18
+
19
+ return formatModulePromise;
20
+ }
21
+
22
+ test("SQL highlighting marks CASE and null-check keywords", async () => {
23
+ const { highlightSql } = await loadFormatModule();
24
+ const highlighted = highlightSql("CASE WHEN upload_date IS NULL THEN NULL ELSE 1 END");
25
+
26
+ for (const keyword of ["CASE", "WHEN", "IS", "NULL", "THEN", "ELSE", "END"]) {
27
+ assert.match(highlighted, new RegExp(`<span class="sql-keyword">${keyword}</span>`));
28
+ }
29
+ });
30
+
31
+ test("SQL highlighting marks window function keywords", async () => {
32
+ const { highlightSql } = await loadFormatModule();
33
+ const highlighted = highlightSql("ROW_NUMBER() OVER (PARTITION BY window_label ORDER BY delta_return DESC)");
34
+
35
+ for (const keyword of ["ROW_NUMBER", "OVER", "PARTITION", "ORDER", "BY", "DESC"]) {
36
+ assert.match(highlighted, new RegExp(`<span class="sql-keyword">${keyword}</span>`));
37
+ }
38
+ });