sqlite-hub 0.17.0 → 0.17.2

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 (34) hide show
  1. package/frontend/js/api.js +6 -0
  2. package/frontend/js/app.js +119 -26
  3. package/frontend/js/components/modal.js +26 -6
  4. package/frontend/js/components/queryResults.js +18 -1
  5. package/frontend/js/components/rowEditorPanel.js +42 -34
  6. package/frontend/js/store.js +14 -2
  7. package/frontend/js/utils/jsonPreview.js +31 -0
  8. package/frontend/js/utils/rowEditorValues.js +41 -0
  9. package/frontend/js/utils/tableScrollState.js +39 -0
  10. package/frontend/js/views/charts.js +8 -0
  11. package/frontend/js/views/data.js +4 -1
  12. package/frontend/js/views/editor.js +2 -0
  13. package/frontend/styles/components.css +6 -0
  14. package/frontend/styles/tailwind.generated.css +1 -1
  15. package/package.json +1 -1
  16. package/server/middleware/localRequestSecurity.js +84 -0
  17. package/server/routes/connections.js +18 -1
  18. package/server/server.js +12 -1
  19. package/server/services/nativeFileDialogService.js +168 -0
  20. package/server/services/sqlite/dataBrowserService.js +0 -4
  21. package/server/services/sqlite/exportService.js +5 -1
  22. package/server/services/sqlite/sqlExecutor.js +51 -2
  23. package/server/utils/sqliteTypes.js +17 -8
  24. package/tests/connections-file-dialog-route.test.js +46 -0
  25. package/tests/copy-column-modal.test.js +14 -0
  26. package/tests/export-blob.test.js +46 -0
  27. package/tests/json-preview.test.js +49 -0
  28. package/tests/local-request-security.test.js +85 -0
  29. package/tests/native-file-dialog.test.js +78 -0
  30. package/tests/query-results-truncation.test.js +20 -0
  31. package/tests/row-editor-null-values.test.js +131 -0
  32. package/tests/sql-identifier-safety.test.js +9 -3
  33. package/tests/sql-result-limit.test.js +66 -0
  34. package/tests/table-scroll-state.test.js +70 -0
@@ -0,0 +1,131 @@
1
+ const Database = require("better-sqlite3");
2
+ const assert = require("node:assert/strict");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+ const { pathToFileURL } = require("node:url");
6
+ const { DataBrowserService } = require("../server/services/sqlite/dataBrowserService");
7
+
8
+ async function loadFrontendModule(relativePath) {
9
+ return import(pathToFileURL(path.resolve(__dirname, relativePath)).href);
10
+ }
11
+
12
+ test("row editor values preserve NULL and empty string as distinct values", async () => {
13
+ const {
14
+ buildRowEditorSubmittedValues,
15
+ getRowEditorValueState,
16
+ getRowEditorValueStateLabel,
17
+ } = await loadFrontendModule("../frontend/js/utils/rowEditorValues.js");
18
+ const formData = new FormData();
19
+
20
+ formData.append("field:title", "");
21
+ formData.append("field:description", "");
22
+ formData.append("field:count", "42");
23
+
24
+ assert.deepEqual(buildRowEditorSubmittedValues(formData, {
25
+ title: { initialState: "empty", dirty: false },
26
+ description: { initialState: "null", dirty: false },
27
+ }), {
28
+ title: "",
29
+ description: null,
30
+ count: "42",
31
+ });
32
+ assert.equal(
33
+ buildRowEditorSubmittedValues(formData, {
34
+ description: { initialState: "null", dirty: true },
35
+ }).description,
36
+ ""
37
+ );
38
+ assert.equal(getRowEditorValueState(null), "null");
39
+ assert.equal(getRowEditorValueState(""), "empty");
40
+ assert.equal(getRowEditorValueState("text"), "value");
41
+ assert.equal(getRowEditorValueStateLabel("null"), "NULL");
42
+ assert.equal(getRowEditorValueStateLabel("empty"), "EMPTY STRING");
43
+ });
44
+
45
+ test("row editor renders visible NULL and empty-string states", async () => {
46
+ const { renderRowEditorPanel } = await loadFrontendModule(
47
+ "../frontend/js/components/rowEditorPanel.js"
48
+ );
49
+ const html = renderRowEditorPanel({
50
+ title: "Values",
51
+ closeAction: "close",
52
+ formName: "save-data-row",
53
+ editableFields: [
54
+ {
55
+ name: "nullable_text",
56
+ label: "nullable_text",
57
+ rawValue: null,
58
+ value: "",
59
+ notNull: false,
60
+ },
61
+ {
62
+ name: "empty_text",
63
+ label: "empty_text",
64
+ rawValue: "",
65
+ value: "",
66
+ notNull: false,
67
+ },
68
+ {
69
+ name: "required_text",
70
+ label: "required_text",
71
+ rawValue: "value",
72
+ value: "value",
73
+ notNull: true,
74
+ },
75
+ ],
76
+ });
77
+
78
+ assert.match(html, /data-value-state="null"[^>]*>NULL</s);
79
+ assert.match(html, /data-value-state="empty"[^>]*>EMPTY STRING</s);
80
+ assert.match(html, /data-value-state="value"[^>]*>VALUE</s);
81
+ assert.doesNotMatch(html, /field-null:|row-editor-null-toggle|Set NULL/);
82
+ assert.doesNotMatch(html, /name="field:nullable_text"[\s\S]*?disabled/);
83
+ });
84
+
85
+ test("data updates can change NULL to empty string and empty string to NULL", () => {
86
+ const db = new Database(":memory:");
87
+
88
+ try {
89
+ db.exec(`
90
+ CREATE TABLE values_test (
91
+ id INTEGER PRIMARY KEY,
92
+ nullable_text TEXT,
93
+ empty_text TEXT
94
+ );
95
+ INSERT INTO values_test (id, nullable_text, empty_text) VALUES (1, NULL, '');
96
+ `);
97
+ const service = new DataBrowserService({
98
+ connectionManager: {
99
+ assertWritable() {},
100
+ getActiveDatabase: () => db,
101
+ },
102
+ });
103
+ const identity = service.getTableData("values_test", { limit: 10 }).rows[0].__identity;
104
+ const preview = service.previewTableRowUpdate("values_test", {
105
+ identity,
106
+ values: {
107
+ nullable_text: "",
108
+ empty_text: null,
109
+ },
110
+ });
111
+
112
+ assert.deepEqual(preview.changes, [
113
+ { column: "nullable_text", oldValue: "NULL", newValue: "" },
114
+ { column: "empty_text", oldValue: "", newValue: "NULL" },
115
+ ]);
116
+
117
+ service.updateTableRow("values_test", {
118
+ identity,
119
+ values: {
120
+ nullable_text: "",
121
+ empty_text: null,
122
+ },
123
+ });
124
+ const row = db.prepare("SELECT nullable_text, empty_text FROM values_test WHERE id = 1").get();
125
+
126
+ assert.equal(row.nullable_text, "");
127
+ assert.equal(row.empty_text, null);
128
+ } finally {
129
+ db.close();
130
+ }
131
+ });
@@ -114,7 +114,8 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
114
114
  "UPDATE",
115
115
  quoteIdentifier(tableName),
116
116
  "SET",
117
- quoteIdentifier(valueColumn) + " = ?",
117
+ quoteIdentifier(valueColumn) + " = ?,",
118
+ quoteIdentifier(noteColumn) + " = ?",
118
119
  "WHERE",
119
120
  "rowid IS ?",
120
121
  ].join(" ")
@@ -125,6 +126,11 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
125
126
  oldValue: "before",
126
127
  newValue: "after",
127
128
  },
129
+ {
130
+ column: noteColumn,
131
+ oldValue: "NULL",
132
+ newValue: "",
133
+ },
128
134
  ]);
129
135
 
130
136
  const updated = service.updateTableRow(tableName, {
@@ -136,7 +142,7 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
136
142
  });
137
143
 
138
144
  assert.equal(updated.row[valueColumn], "after");
139
- assert.equal(updated.row[noteColumn], null);
145
+ assert.equal(updated.row[noteColumn], "");
140
146
  const persistedRow = db.prepare(
141
147
  [
142
148
  "SELECT",
@@ -148,7 +154,7 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
148
154
  ).get();
149
155
 
150
156
  assert.equal(persistedRow[valueColumn], "after");
151
- assert.equal(persistedRow[noteColumn], null);
157
+ assert.equal(persistedRow[noteColumn], "");
152
158
 
153
159
  const deleted = service.deleteTableRow(tableName, {
154
160
  identity: updated.row.__identity,
@@ -0,0 +1,66 @@
1
+ const Database = require("better-sqlite3");
2
+ const assert = require("node:assert/strict");
3
+ const test = require("node:test");
4
+ const {
5
+ DEFAULT_RESULT_ROW_LIMIT,
6
+ SqlExecutor,
7
+ } = require("../server/services/sqlite/sqlExecutor");
8
+
9
+ function createExecutor(db) {
10
+ return new SqlExecutor({
11
+ connectionManager: {
12
+ getActiveDatabase: () => db,
13
+ getActiveConnection: () => ({ id: "result-limit-test" }),
14
+ },
15
+ appStateStore: {
16
+ recordQueryExecution: () => 1,
17
+ },
18
+ });
19
+ }
20
+
21
+ test("interactive SQL results stop at the configured row limit", () => {
22
+ const db = new Database(":memory:");
23
+
24
+ try {
25
+ db.exec(`
26
+ CREATE TABLE items (id INTEGER PRIMARY KEY, label TEXT);
27
+ INSERT INTO items (label) VALUES ('one'), ('two'), ('three'), ('four'), ('five');
28
+ `);
29
+
30
+ const result = createExecutor(db).execute("SELECT id, label FROM items ORDER BY id", {
31
+ maxRows: 3,
32
+ });
33
+
34
+ assert.equal(DEFAULT_RESULT_ROW_LIMIT, 5000);
35
+ assert.equal(result.rows.length, 3);
36
+ assert.deepEqual(result.rows.map((row) => row.id), [1, 2, 3]);
37
+ assert.equal(result.truncated, true);
38
+ assert.equal(result.rowLimit, 3);
39
+ assert.equal(result.statements[0].truncated, true);
40
+ assert.equal(result.statements[0].rowCount, 3);
41
+ } finally {
42
+ db.close();
43
+ }
44
+ });
45
+
46
+ test("unlimited SQL execution remains available for export services", () => {
47
+ const db = new Database(":memory:");
48
+
49
+ try {
50
+ db.exec(`
51
+ CREATE TABLE items (id INTEGER PRIMARY KEY);
52
+ INSERT INTO items VALUES (1), (2), (3), (4), (5);
53
+ `);
54
+
55
+ const result = createExecutor(db).execute("SELECT id FROM items ORDER BY id", {
56
+ maxRows: null,
57
+ persistHistory: false,
58
+ });
59
+
60
+ assert.equal(result.rows.length, 5);
61
+ assert.equal(result.truncated, false);
62
+ assert.equal(result.rowLimit, null);
63
+ } finally {
64
+ db.close();
65
+ }
66
+ });
@@ -0,0 +1,70 @@
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 tableScrollStateModulePromise = null;
7
+
8
+ function loadTableScrollStateModule() {
9
+ if (!tableScrollStateModulePromise) {
10
+ const source = readFileSync(
11
+ path.resolve(__dirname, "../frontend/js/utils/tableScrollState.js"),
12
+ "utf8"
13
+ );
14
+ const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
15
+
16
+ tableScrollStateModulePromise = import(url);
17
+ }
18
+
19
+ return tableScrollStateModulePromise;
20
+ }
21
+
22
+ test("table scroll state restores horizontal position for the matching grid", async () => {
23
+ const { captureTableHorizontalScrollState, restoreTableHorizontalScrollState } =
24
+ await loadTableScrollStateModule();
25
+ const key = 'data:events "archive"';
26
+ const previousGrid = { dataset: { tableScrollKey: key }, scrollLeft: 640 };
27
+ const nextGrid = { dataset: { tableScrollKey: key }, scrollLeft: 0 };
28
+ const snapshot = captureTableHorizontalScrollState({
29
+ routeName: "data",
30
+ scrollNodes: [previousGrid],
31
+ });
32
+
33
+ assert.equal(
34
+ restoreTableHorizontalScrollState({
35
+ snapshot,
36
+ routeName: "data",
37
+ scrollNodes: [nextGrid],
38
+ }),
39
+ true
40
+ );
41
+ assert.equal(nextGrid.scrollLeft, 640);
42
+ });
43
+
44
+ test("table scroll state is not restored across routes or different grids", async () => {
45
+ const { captureTableHorizontalScrollState, restoreTableHorizontalScrollState } =
46
+ await loadTableScrollStateModule();
47
+ const snapshot = captureTableHorizontalScrollState({
48
+ routeName: "editorResults",
49
+ scrollNodes: [{ dataset: { tableScrollKey: "editor:12" }, scrollLeft: 320 }],
50
+ });
51
+ const nextGrid = { dataset: { tableScrollKey: "editor:13" }, scrollLeft: 0 };
52
+
53
+ assert.equal(
54
+ restoreTableHorizontalScrollState({
55
+ snapshot,
56
+ routeName: "data",
57
+ scrollNodes: [nextGrid],
58
+ }),
59
+ false
60
+ );
61
+ assert.equal(
62
+ restoreTableHorizontalScrollState({
63
+ snapshot,
64
+ routeName: "editorResults",
65
+ scrollNodes: [nextGrid],
66
+ }),
67
+ false
68
+ );
69
+ assert.equal(nextGrid.scrollLeft, 0);
70
+ });