sqlite-hub 1.1.0 → 1.1.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.
- package/README.md +17 -196
- package/bin/sqlite-hub.js +7 -2
- package/frontend/js/api.js +60 -0
- package/frontend/js/app.js +140 -10
- package/frontend/js/components/dropdownButton.js +92 -0
- package/frontend/js/components/modal.js +991 -876
- package/frontend/js/components/queryEditor.js +29 -18
- package/frontend/js/components/queryHistoryDetail.js +20 -1
- package/frontend/js/components/queryHistoryHeader.js +3 -2
- package/frontend/js/components/rowEditorPanel.js +1 -0
- package/frontend/js/components/sidebar.js +1 -0
- package/frontend/js/components/structureGraph.js +1 -0
- package/frontend/js/components/tableDesignerEditor.js +23 -42
- package/frontend/js/components/tableDesignerSidebar.js +7 -31
- package/frontend/js/components/tableDesignerSqlPreview.js +2 -1
- package/frontend/js/router.js +2 -0
- package/frontend/js/store.js +407 -38
- package/frontend/js/utils/riskySql.js +165 -0
- package/frontend/js/views/backups.js +176 -0
- package/frontend/js/views/charts.js +12 -11
- package/frontend/js/views/data.js +37 -35
- package/frontend/js/views/documents.js +231 -162
- package/frontend/js/views/mediaTagging.js +6 -5
- package/frontend/js/views/structure.js +47 -43
- package/frontend/js/views/tableDesigner.js +45 -1
- package/frontend/styles/components.css +237 -59
- package/frontend/styles/structure-graph.css +10 -2
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/tokens.css +2 -0
- package/frontend/styles/views.css +84 -41
- package/package.json +2 -1
- package/server/routes/backups.js +120 -0
- package/server/routes/connections.js +26 -3
- package/server/routes/externalApi.js +6 -2
- package/server/server.js +3 -1
- package/server/services/databaseCommandService.js +4 -3
- package/server/services/sqlite/backupService.js +497 -66
- package/server/services/sqlite/connectionManager.js +2 -2
- package/server/services/sqlite/dataBrowserService.js +11 -3
- package/server/services/sqlite/importService.js +25 -0
- package/server/services/sqlite/sqlExecutor.js +2 -0
- package/server/services/storage/appStateStore.js +379 -88
- package/tests/api-token-auth.test.js +45 -2
- package/tests/backup-manager.test.js +140 -0
- package/tests/backups-view.test.js +64 -0
- package/tests/charts-height-preset-storage.test.js +60 -0
- package/tests/charts-route-state.test.js +144 -0
- package/tests/cli-service-delegation.test.js +97 -0
- package/tests/connection-removal.test.js +52 -0
- package/tests/database-command-service.test.js +38 -3
- package/tests/documents-view.test.js +132 -0
- package/tests/dropdown-button.test.js +75 -0
- package/tests/query-editor.test.js +28 -0
- package/tests/query-history-detail.test.js +37 -0
- package/tests/query-history-header.test.js +30 -0
- package/tests/risky-sql.test.js +30 -0
- package/tests/row-editor-null-values.test.js +24 -0
- package/tests/structure-view.test.js +56 -0
|
@@ -59,6 +59,29 @@ function createFixture(t, options = {}) {
|
|
|
59
59
|
new Date().toISOString(),
|
|
60
60
|
new Date().toISOString()
|
|
61
61
|
);
|
|
62
|
+
store.db
|
|
63
|
+
.prepare(
|
|
64
|
+
`
|
|
65
|
+
INSERT INTO query_history (
|
|
66
|
+
database_key,
|
|
67
|
+
normalized_sql,
|
|
68
|
+
raw_sql,
|
|
69
|
+
query_type,
|
|
70
|
+
tables_detected,
|
|
71
|
+
is_saved,
|
|
72
|
+
first_executed_at,
|
|
73
|
+
last_used_at
|
|
74
|
+
)
|
|
75
|
+
VALUES (?, ?, ?, 'other', '[]', 0, ?, ?)
|
|
76
|
+
`
|
|
77
|
+
)
|
|
78
|
+
.run(
|
|
79
|
+
connection.id,
|
|
80
|
+
"company list",
|
|
81
|
+
"Company List",
|
|
82
|
+
new Date().toISOString(),
|
|
83
|
+
new Date().toISOString()
|
|
84
|
+
);
|
|
62
85
|
store.createDatabaseDocument(connection.id, {
|
|
63
86
|
filename: "Readme.md",
|
|
64
87
|
content: "# Sample\n",
|
|
@@ -77,10 +100,10 @@ function createFixture(t, options = {}) {
|
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
test("database command service provides shared CLI and API operations", (t) => {
|
|
80
|
-
const { connection, service } = createFixture(t);
|
|
103
|
+
const { connection, service, store } = createFixture(t);
|
|
81
104
|
|
|
82
105
|
assert.equal(service.getDatabase("sample").id, connection.id);
|
|
83
|
-
assert.deepEqual(service.listTables(connection.id), [{ name: "companies" }]);
|
|
106
|
+
assert.deepEqual(service.listTables(connection.id), [{ name: "companies", columnCount: 2 }]);
|
|
84
107
|
assert.equal(service.getTable(connection.id, "companies").rowCount, 2);
|
|
85
108
|
|
|
86
109
|
const row = service.getTableRow(connection.id, "companies", "1");
|
|
@@ -89,10 +112,18 @@ test("database command service provides shared CLI and API operations", (t) => {
|
|
|
89
112
|
|
|
90
113
|
const queries = service.listSavedQueries(connection.id);
|
|
91
114
|
assert.equal(queries.total, 1);
|
|
92
|
-
|
|
115
|
+
const savedQuery = service.getSavedQuery(connection.id, "Company List");
|
|
116
|
+
assert.equal(savedQuery.notes, "Used by CLI and API");
|
|
117
|
+
assert.equal(savedQuery.rawSql, "SELECT id, name FROM companies ORDER BY id");
|
|
93
118
|
|
|
94
119
|
const execution = service.executeSavedQuery(connection.id, "Company List");
|
|
95
120
|
assert.equal(execution.result.statements[0].rowCount, 2);
|
|
121
|
+
assert.equal(execution.result.historyId, savedQuery.id);
|
|
122
|
+
|
|
123
|
+
const savedRun = store.db
|
|
124
|
+
.prepare("SELECT executed_by, status FROM query_runs WHERE history_id = ? ORDER BY id DESC LIMIT 1")
|
|
125
|
+
.get(savedQuery.id);
|
|
126
|
+
assert.deepEqual(savedRun, { executed_by: "user", status: "success" });
|
|
96
127
|
|
|
97
128
|
const exported = service.exportSavedQuery(connection.id, "Company List", "csv");
|
|
98
129
|
assert.equal(exported.result.rowCount, 2);
|
|
@@ -118,6 +149,9 @@ test("raw query execution writes SQL Editor query history", (t) => {
|
|
|
118
149
|
const historyRow = store.db
|
|
119
150
|
.prepare("SELECT raw_sql, query_type, title, is_saved FROM query_history WHERE id = ?")
|
|
120
151
|
.get(result.historyId);
|
|
152
|
+
const runRow = store.db
|
|
153
|
+
.prepare("SELECT executed_by FROM query_runs WHERE history_id = ? ORDER BY id DESC LIMIT 1")
|
|
154
|
+
.get(result.historyId);
|
|
121
155
|
|
|
122
156
|
assert.equal(result.affectedRowCount, 1);
|
|
123
157
|
assert.equal(afterCount, beforeCount + 1);
|
|
@@ -125,6 +159,7 @@ test("raw query execution writes SQL Editor query history", (t) => {
|
|
|
125
159
|
assert.equal(historyRow.query_type, "insert");
|
|
126
160
|
assert.equal(historyRow.title, "Add Initech");
|
|
127
161
|
assert.equal(historyRow.is_saved, 1);
|
|
162
|
+
assert.equal(runRow.executed_by, "user");
|
|
128
163
|
assert.equal(storedQuery.title, "Add Initech");
|
|
129
164
|
assert.equal(storedQuery.isSaved, true);
|
|
130
165
|
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { pathToFileURL } = require("node:url");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
let documentsViewModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadDocumentsViewModule() {
|
|
9
|
+
if (!documentsViewModulePromise) {
|
|
10
|
+
documentsViewModulePromise = import(
|
|
11
|
+
pathToFileURL(path.resolve(__dirname, "../frontend/js/views/documents.js")).href
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return documentsViewModulePromise;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function buildDocumentsState() {
|
|
19
|
+
return {
|
|
20
|
+
connections: {
|
|
21
|
+
active: { id: "db-one", label: "Database One" },
|
|
22
|
+
},
|
|
23
|
+
documents: {
|
|
24
|
+
items: [],
|
|
25
|
+
selectedId: null,
|
|
26
|
+
selected: null,
|
|
27
|
+
searchQuery: "",
|
|
28
|
+
draftFilename: "",
|
|
29
|
+
draftContent: "",
|
|
30
|
+
dirty: false,
|
|
31
|
+
documentsVisible: true,
|
|
32
|
+
editorVisible: true,
|
|
33
|
+
previewVisible: true,
|
|
34
|
+
loading: false,
|
|
35
|
+
detailLoading: false,
|
|
36
|
+
saving: false,
|
|
37
|
+
deleting: false,
|
|
38
|
+
error: null,
|
|
39
|
+
saveError: null,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildSelectedDocumentsState(overrides = {}) {
|
|
45
|
+
const state = buildDocumentsState();
|
|
46
|
+
state.documents.items = [
|
|
47
|
+
{
|
|
48
|
+
contentLength: 11,
|
|
49
|
+
filename: "notes.md",
|
|
50
|
+
id: "doc-one",
|
|
51
|
+
title: "Notes",
|
|
52
|
+
updatedAt: "2026-06-21T10:00:00.000Z",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
state.documents.selectedId = "doc-one";
|
|
56
|
+
state.documents.selected = state.documents.items[0];
|
|
57
|
+
state.documents.draftFilename = "notes.md";
|
|
58
|
+
state.documents.draftContent = "# Notes";
|
|
59
|
+
Object.assign(state.documents, overrides);
|
|
60
|
+
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test("documents view uses a new document dropdown for blank and markdown import", async () => {
|
|
65
|
+
const { renderDocumentsView } = await loadDocumentsViewModule();
|
|
66
|
+
const { main } = renderDocumentsView(buildDocumentsState());
|
|
67
|
+
|
|
68
|
+
assert.match(main, /data-dropdown-button/);
|
|
69
|
+
assert.match(main, /New Document/);
|
|
70
|
+
assert.match(main, /Blank Page/);
|
|
71
|
+
assert.match(main, /data-action="create-document"/);
|
|
72
|
+
assert.match(main, /Import \.md/);
|
|
73
|
+
assert.match(main, /data-action="import-document-markdown"/);
|
|
74
|
+
assert.match(main, /data-bind="document-import-file"/);
|
|
75
|
+
assert.doesNotMatch(main, /data-form="new-document"/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("documents panes keep both headers visible and move pane toggles into headers", async () => {
|
|
79
|
+
const { renderDocumentsView } = await loadDocumentsViewModule();
|
|
80
|
+
const { main } = renderDocumentsView(
|
|
81
|
+
buildSelectedDocumentsState({
|
|
82
|
+
editorVisible: false,
|
|
83
|
+
previewVisible: true,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
assert.match(main, /documents-workspace--editor-collapsed/);
|
|
88
|
+
assert.match(main, /documents-pane--editor documents-pane--collapsed/);
|
|
89
|
+
assert.match(main, /data-pane="editor"/);
|
|
90
|
+
assert.match(main, /Show Editor/);
|
|
91
|
+
assert.match(main, /data-pane="preview"/);
|
|
92
|
+
assert.match(main, /Hide Preview/);
|
|
93
|
+
assert.doesNotMatch(main, />Editor<\/span>/);
|
|
94
|
+
assert.doesNotMatch(main, />Preview<\/span>/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("documents subnavi can be hidden while the show documents button stays available", async () => {
|
|
98
|
+
const { renderDocumentsView } = await loadDocumentsViewModule();
|
|
99
|
+
const { main } = renderDocumentsView(
|
|
100
|
+
buildSelectedDocumentsState({
|
|
101
|
+
documentsVisible: false,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
assert.doesNotMatch(main, /documents-view__sidebar/);
|
|
106
|
+
assert.match(main, /data-action="toggle-documents-panel"/);
|
|
107
|
+
assert.match(main, /Show Documents/);
|
|
108
|
+
assert.match(main, /aria-pressed="true"/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("selected document actions render in one titlebar without a save button", async () => {
|
|
112
|
+
const { renderDocumentsView } = await loadDocumentsViewModule();
|
|
113
|
+
const { main } = renderDocumentsView(buildSelectedDocumentsState());
|
|
114
|
+
const orderedFragments = [
|
|
115
|
+
'data-action="toggle-documents-panel"',
|
|
116
|
+
'data-bind="document-field"',
|
|
117
|
+
"New Document",
|
|
118
|
+
"Insert",
|
|
119
|
+
"Export .md",
|
|
120
|
+
"Delete",
|
|
121
|
+
];
|
|
122
|
+
const indexes = orderedFragments.map(fragment => main.indexOf(fragment));
|
|
123
|
+
|
|
124
|
+
indexes.forEach(index => assert.notEqual(index, -1));
|
|
125
|
+
|
|
126
|
+
for (let index = 1; index < indexes.length; index += 1) {
|
|
127
|
+
assert.ok(indexes[index - 1] < indexes[index]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
assert.doesNotMatch(main, /documents-toolbar/);
|
|
131
|
+
assert.doesNotMatch(main, /data-action="save-document"/);
|
|
132
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { pathToFileURL } = require("node:url");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
let dropdownButtonModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadDropdownButtonModule() {
|
|
9
|
+
if (!dropdownButtonModulePromise) {
|
|
10
|
+
dropdownButtonModulePromise = import(
|
|
11
|
+
pathToFileURL(path.resolve(__dirname, "../frontend/js/components/dropdownButton.js")).href
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return dropdownButtonModulePromise;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("dropdown button renders reusable action items", async () => {
|
|
19
|
+
const { renderDropdownButton } = await loadDropdownButtonModule();
|
|
20
|
+
const markup = renderDropdownButton({
|
|
21
|
+
icon: "add_box",
|
|
22
|
+
label: "Insert",
|
|
23
|
+
title: "Insert content",
|
|
24
|
+
items: [
|
|
25
|
+
{
|
|
26
|
+
action: "open-document-insert-table-modal",
|
|
27
|
+
dataAttributes: { sourceView: "documents" },
|
|
28
|
+
icon: "table_chart",
|
|
29
|
+
label: "Insert Table",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
action: "open-document-insert-note-modal",
|
|
33
|
+
icon: "note_add",
|
|
34
|
+
label: "Insert Note",
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
assert.match(markup, /data-dropdown-button/);
|
|
40
|
+
assert.match(markup, /dropdown-button__toggle/);
|
|
41
|
+
assert.match(markup, /dropdown-button__panel/);
|
|
42
|
+
assert.match(markup, /data-action="open-document-insert-table-modal"/);
|
|
43
|
+
assert.match(markup, /data-source-view="documents"/);
|
|
44
|
+
assert.match(markup, /Insert Note/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("dropdown button renders disabled as a real disabled button", async () => {
|
|
48
|
+
const { renderDropdownButton } = await loadDropdownButtonModule();
|
|
49
|
+
const markup = renderDropdownButton({
|
|
50
|
+
disabled: true,
|
|
51
|
+
label: "Insert",
|
|
52
|
+
items: [{ action: "noop", label: "Noop" }],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
assert.doesNotMatch(markup, /<details/);
|
|
56
|
+
assert.match(markup, /disabled/);
|
|
57
|
+
assert.match(markup, /aria-disabled="true"/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("dropdown button supports local action attributes", async () => {
|
|
61
|
+
const { renderDropdownButton } = await loadDropdownButtonModule();
|
|
62
|
+
const markup = renderDropdownButton({
|
|
63
|
+
label: "Format",
|
|
64
|
+
items: [
|
|
65
|
+
{
|
|
66
|
+
action: "fit",
|
|
67
|
+
actionAttribute: "data-structure-graph-action",
|
|
68
|
+
label: "Fit Graph",
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
assert.match(markup, /data-structure-graph-action="fit"/);
|
|
74
|
+
assert.doesNotMatch(markup, /data-action="fit"/);
|
|
75
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { pathToFileURL } = require("node:url");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
let queryEditorModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadQueryEditorModule() {
|
|
9
|
+
if (!queryEditorModulePromise) {
|
|
10
|
+
queryEditorModulePromise = import(
|
|
11
|
+
pathToFileURL(path.resolve(__dirname, "../frontend/js/components/queryEditor.js")).href
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return queryEditorModulePromise;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("query editor groups editor actions in a reusable dropdown", async () => {
|
|
19
|
+
const { renderQueryEditor } = await loadQueryEditorModule();
|
|
20
|
+
const markup = renderQueryEditor({ query: "select * from companies;" });
|
|
21
|
+
|
|
22
|
+
assert.match(markup, /data-dropdown-button/);
|
|
23
|
+
assert.match(markup, /Editor actions/);
|
|
24
|
+
assert.match(markup, /data-action="format-current-query"/);
|
|
25
|
+
assert.match(markup, /data-action="clear-query"/);
|
|
26
|
+
assert.match(markup, /data-action="copy-current-query"/);
|
|
27
|
+
assert.match(markup, /Copy to Clipboard/);
|
|
28
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { pathToFileURL } = require("node:url");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
let queryHistoryDetailModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadQueryHistoryDetailModule() {
|
|
9
|
+
if (!queryHistoryDetailModulePromise) {
|
|
10
|
+
queryHistoryDetailModulePromise = import(
|
|
11
|
+
pathToFileURL(path.resolve(__dirname, "../frontend/js/components/queryHistoryDetail.js")).href
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return queryHistoryDetailModulePromise;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("query history detail delete button renders a trash icon", async () => {
|
|
19
|
+
const { renderQueryHistoryDetail } = await loadQueryHistoryDetailModule();
|
|
20
|
+
const html = renderQueryHistoryDetail({
|
|
21
|
+
item: {
|
|
22
|
+
id: 42,
|
|
23
|
+
displayTitle: "Recent Query",
|
|
24
|
+
rawSql: "select * from companies;",
|
|
25
|
+
queryType: "select",
|
|
26
|
+
isSaved: false,
|
|
27
|
+
isDestructive: false,
|
|
28
|
+
executionCount: 1,
|
|
29
|
+
createdAt: "2026-06-21T10:00:00.000Z",
|
|
30
|
+
updatedAt: "2026-06-21T10:00:00.000Z",
|
|
31
|
+
},
|
|
32
|
+
runs: [],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.match(html, /data-action="open-delete-query-history-modal"/);
|
|
36
|
+
assert.match(html, /<span class="material-symbols-outlined text-sm">delete<\/span>Delete/);
|
|
37
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { pathToFileURL } = require("node:url");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
let queryHistoryHeaderModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadQueryHistoryHeaderModule() {
|
|
9
|
+
if (!queryHistoryHeaderModulePromise) {
|
|
10
|
+
queryHistoryHeaderModulePromise = import(
|
|
11
|
+
pathToFileURL(path.resolve(__dirname, "../frontend/js/components/queryHistoryHeader.js")).href
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return queryHistoryHeaderModulePromise;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("query history search renders a magnifier icon", async () => {
|
|
19
|
+
const { renderQueryHistorySearch } = await loadQueryHistoryHeaderModule();
|
|
20
|
+
const markup = renderQueryHistorySearch({
|
|
21
|
+
bind: "charts-history-search",
|
|
22
|
+
value: "revenue",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
assert.match(markup, /query-history-search/);
|
|
26
|
+
assert.match(markup, /query-history-search__icon/);
|
|
27
|
+
assert.match(markup, />search<\/span>/);
|
|
28
|
+
assert.match(markup, /data-bind="charts-history-search"/);
|
|
29
|
+
assert.match(markup, /value="revenue"/);
|
|
30
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { pathToFileURL } = require("node:url");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
async function loadModule() {
|
|
7
|
+
return import(pathToFileURL(path.resolve(__dirname, "../frontend/js/utils/riskySql.js")).href);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test("risky SQL detector ignores selects and detects schema changes", async () => {
|
|
11
|
+
const { detectRiskySqlOperations } = await loadModule();
|
|
12
|
+
|
|
13
|
+
assert.deepEqual(detectRiskySqlOperations("SELECT * FROM companies"), []);
|
|
14
|
+
assert.equal(detectRiskySqlOperations(" ALTER TABLE companies ADD COLUMN ticker TEXT")[0].type, "schema_change");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("risky SQL detector handles comments, case, multiple statements, and drop table names", async () => {
|
|
18
|
+
const { detectRiskySqlOperations } = await loadModule();
|
|
19
|
+
const operations = detectRiskySqlOperations(`
|
|
20
|
+
-- DROP TABLE ignored;
|
|
21
|
+
select 1;
|
|
22
|
+
/* comment */ drop table if exists "users";
|
|
23
|
+
create index idx_users_name on users(name);
|
|
24
|
+
`);
|
|
25
|
+
|
|
26
|
+
assert.equal(operations.length, 2);
|
|
27
|
+
assert.equal(operations[0].type, "drop_table");
|
|
28
|
+
assert.equal(operations[0].tableName, "users");
|
|
29
|
+
assert.equal(operations[1].type, "migration");
|
|
30
|
+
});
|
|
@@ -82,6 +82,30 @@ test("row editor renders visible NULL and empty-string states", async () => {
|
|
|
82
82
|
assert.doesNotMatch(html, /name="field:nullable_text"[\s\S]*?disabled/);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
test("row editor delete button renders a trash icon", async () => {
|
|
86
|
+
const { renderRowEditorPanel } = await loadFrontendModule(
|
|
87
|
+
"../frontend/js/components/rowEditorPanel.js"
|
|
88
|
+
);
|
|
89
|
+
const html = renderRowEditorPanel({
|
|
90
|
+
title: "Values",
|
|
91
|
+
closeAction: "close",
|
|
92
|
+
formName: "save-data-row",
|
|
93
|
+
deleteAction: "delete-data-row",
|
|
94
|
+
deleteEnabled: true,
|
|
95
|
+
deleteRowIndex: 0,
|
|
96
|
+
editableFields: [
|
|
97
|
+
{
|
|
98
|
+
name: "title",
|
|
99
|
+
label: "title",
|
|
100
|
+
value: "Acme",
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.match(html, /class="delete-button"[^>]*data-action="delete-data-row"/);
|
|
106
|
+
assert.match(html, /<span class="material-symbols-outlined text-sm">delete<\/span>\s*Delete Row/);
|
|
107
|
+
});
|
|
108
|
+
|
|
85
109
|
test("data updates can change NULL to empty string and empty string to NULL", () => {
|
|
86
110
|
const db = new Database(":memory:");
|
|
87
111
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { pathToFileURL } = require("node:url");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
let structureViewModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadStructureViewModule() {
|
|
9
|
+
if (!structureViewModulePromise) {
|
|
10
|
+
structureViewModulePromise = import(
|
|
11
|
+
pathToFileURL(path.resolve(__dirname, "../frontend/js/views/structure.js")).href
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return structureViewModulePromise;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function buildStructureState() {
|
|
19
|
+
return {
|
|
20
|
+
structure: {
|
|
21
|
+
data: {
|
|
22
|
+
grouped: {
|
|
23
|
+
tables: [{ name: "companies", type: "table" }],
|
|
24
|
+
views: [],
|
|
25
|
+
indexes: [],
|
|
26
|
+
triggers: [],
|
|
27
|
+
},
|
|
28
|
+
graph: {
|
|
29
|
+
relationshipCount: 0,
|
|
30
|
+
tables: [{ name: "companies", type: "table", columns: [], foreignKeys: [] }],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
detail: null,
|
|
34
|
+
detailLoading: false,
|
|
35
|
+
error: null,
|
|
36
|
+
loading: false,
|
|
37
|
+
selectedName: "companies",
|
|
38
|
+
tableSearchQuery: "",
|
|
39
|
+
tablesVisible: true,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("structure toolbar groups graph format actions in a dropdown", async () => {
|
|
45
|
+
const { renderStructureView } = await loadStructureViewModule();
|
|
46
|
+
const { main } = renderStructureView(buildStructureState());
|
|
47
|
+
|
|
48
|
+
assert.match(main, /data-dropdown-button/);
|
|
49
|
+
assert.match(main, /Format graph/);
|
|
50
|
+
assert.match(main, /Fit Graph/);
|
|
51
|
+
assert.match(main, /data-structure-graph-action="fit"/);
|
|
52
|
+
assert.match(main, /Recalculate Layout/);
|
|
53
|
+
assert.match(main, /data-structure-graph-action="relayout"/);
|
|
54
|
+
assert.match(main, /Clear Selection/);
|
|
55
|
+
assert.match(main, /data-structure-graph-action="clear"/);
|
|
56
|
+
});
|