sqlite-hub 0.9.12 → 0.10.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.
- package/frontend/js/api.js +7 -0
- package/frontend/js/app.js +75 -11
- package/frontend/js/components/modal.js +98 -1
- package/frontend/js/components/queryEditor.js +1 -8
- package/frontend/js/components/structureGraph.js +29 -1
- package/frontend/js/store.js +299 -39
- package/frontend/js/views/charts.js +2 -2
- package/frontend/js/views/data.js +39 -1
- package/frontend/js/views/editor.js +11 -6
- package/frontend/js/views/structure.js +43 -5
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/views.css +4 -0
- package/package.json +1 -1
- package/server/routes/data.js +14 -0
- package/server/services/sqlite/dataBrowserService.js +124 -44
- package/tests/sql-identifier-safety.test.js +48 -8
|
@@ -52,6 +52,34 @@ function normalizePaginationOptions(options = {}) {
|
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function formatPreviewValue(value) {
|
|
56
|
+
if (value && typeof value === "object" && value.__type === "blob") {
|
|
57
|
+
return `BLOB ${value.sizeBytes ?? 0} bytes`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (value === null) {
|
|
61
|
+
return "NULL";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (value === undefined) {
|
|
65
|
+
return "UNDEFINED";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof value === "object") {
|
|
69
|
+
return JSON.stringify(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isUnchangedSubmittedValue(currentValue, submittedValue) {
|
|
76
|
+
if (currentValue === null && submittedValue === "") {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return formatPreviewValue(currentValue) === formatPreviewValue(submittedValue);
|
|
81
|
+
}
|
|
82
|
+
|
|
55
83
|
class DataBrowserService {
|
|
56
84
|
constructor({ connectionManager }) {
|
|
57
85
|
this.connectionManager = connectionManager;
|
|
@@ -153,52 +181,11 @@ class DataBrowserService {
|
|
|
153
181
|
this.connectionManager.assertWritable();
|
|
154
182
|
|
|
155
183
|
const db = this.connectionManager.getActiveDatabase();
|
|
156
|
-
const
|
|
157
|
-
const values = payload.values ?? {};
|
|
158
|
-
const identity = payload.identity ?? null;
|
|
159
|
-
|
|
160
|
-
if (tableDetail.notSafelyUpdatable) {
|
|
161
|
-
throw new ValidationError(
|
|
162
|
-
`Table ${tableName} cannot be safely updated because it has no stable row identity.`
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const identityColumnSet = new Set(
|
|
167
|
-
tableDetail.identityStrategy?.type === "primaryKey"
|
|
168
|
-
? tableDetail.identityStrategy.columns
|
|
169
|
-
: []
|
|
170
|
-
);
|
|
171
|
-
const editableColumns = tableDetail.columns.filter(
|
|
172
|
-
(column) => column.visible && !column.generated && !identityColumnSet.has(column.name)
|
|
173
|
-
);
|
|
174
|
-
const columnsToUpdate = editableColumns.filter((column) =>
|
|
175
|
-
Object.prototype.hasOwnProperty.call(values, column.name)
|
|
176
|
-
);
|
|
184
|
+
const updatePlan = this.buildUpdatePlan(db, tableName, payload);
|
|
177
185
|
|
|
178
|
-
|
|
179
|
-
throw new ValidationError("No editable column values were provided.");
|
|
180
|
-
}
|
|
186
|
+
db.prepare(updatePlan.sql).run(...updatePlan.params);
|
|
181
187
|
|
|
182
|
-
const
|
|
183
|
-
const setClause = columnsToUpdate
|
|
184
|
-
.map((column) => `${quoteIdentifier(column.name)} = ?`)
|
|
185
|
-
.join(", ");
|
|
186
|
-
const setParams = columnsToUpdate.map((column) =>
|
|
187
|
-
deserializeSqliteValue(values[column.name])
|
|
188
|
-
);
|
|
189
|
-
|
|
190
|
-
db.prepare(
|
|
191
|
-
[
|
|
192
|
-
"UPDATE",
|
|
193
|
-
quoteIdentifier(tableName),
|
|
194
|
-
"SET",
|
|
195
|
-
setClause,
|
|
196
|
-
"WHERE",
|
|
197
|
-
where.clause,
|
|
198
|
-
].join(" ")
|
|
199
|
-
).run(...setParams, ...where.params);
|
|
200
|
-
|
|
201
|
-
const updatedRow = this.getRowByIdentity(db, tableDetail, where);
|
|
188
|
+
const updatedRow = this.getRowByIdentity(db, updatePlan.tableDetail, updatePlan.where);
|
|
202
189
|
|
|
203
190
|
if (!updatedRow) {
|
|
204
191
|
throw new NotFoundError(`Row not found in table: ${tableName}`);
|
|
@@ -210,6 +197,24 @@ class DataBrowserService {
|
|
|
210
197
|
};
|
|
211
198
|
}
|
|
212
199
|
|
|
200
|
+
previewTableRowUpdate(tableName, payload = {}) {
|
|
201
|
+
this.connectionManager.assertWritable();
|
|
202
|
+
|
|
203
|
+
const db = this.connectionManager.getActiveDatabase();
|
|
204
|
+
const updatePlan = this.buildUpdatePlan(db, tableName, payload);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
tableName: updatePlan.tableDetail.name,
|
|
208
|
+
sql: updatePlan.sql,
|
|
209
|
+
params: updatePlan.params.map((value, index) => ({
|
|
210
|
+
index: index + 1,
|
|
211
|
+
value: formatPreviewValue(value),
|
|
212
|
+
})),
|
|
213
|
+
changes: updatePlan.changes,
|
|
214
|
+
warnings: updatePlan.warnings,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
213
218
|
deleteTableRow(tableName, payload = {}) {
|
|
214
219
|
this.connectionManager.assertWritable();
|
|
215
220
|
|
|
@@ -279,6 +284,81 @@ class DataBrowserService {
|
|
|
279
284
|
`Table ${tableDetail.name} cannot be updated because it has no stable row identity.`
|
|
280
285
|
);
|
|
281
286
|
}
|
|
287
|
+
|
|
288
|
+
buildUpdatePlan(db, tableName, payload = {}) {
|
|
289
|
+
const tableDetail = getTableDetail(db, tableName, { includeRowCount: false });
|
|
290
|
+
const values = payload.values ?? {};
|
|
291
|
+
const identity = payload.identity ?? null;
|
|
292
|
+
|
|
293
|
+
if (tableDetail.notSafelyUpdatable) {
|
|
294
|
+
throw new ValidationError(
|
|
295
|
+
`Table ${tableName} cannot be safely updated because it has no stable row identity.`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const where = this.buildWhereClause(tableDetail, identity);
|
|
300
|
+
const currentRow = this.getRowByIdentity(db, tableDetail, where);
|
|
301
|
+
|
|
302
|
+
if (!currentRow) {
|
|
303
|
+
throw new NotFoundError(`Row not found in table: ${tableName}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const identityColumnSet = new Set(
|
|
307
|
+
tableDetail.identityStrategy?.type === "primaryKey"
|
|
308
|
+
? tableDetail.identityStrategy.columns
|
|
309
|
+
: []
|
|
310
|
+
);
|
|
311
|
+
const editableColumns = tableDetail.columns.filter(
|
|
312
|
+
(column) => column.visible && !column.generated && !identityColumnSet.has(column.name)
|
|
313
|
+
);
|
|
314
|
+
const providedColumns = editableColumns.filter((column) =>
|
|
315
|
+
Object.prototype.hasOwnProperty.call(values, column.name)
|
|
316
|
+
);
|
|
317
|
+
const columnsToUpdate = providedColumns.filter(
|
|
318
|
+
(column) => !isUnchangedSubmittedValue(currentRow[column.name], values[column.name])
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
if (!columnsToUpdate.length) {
|
|
322
|
+
throw new ValidationError(
|
|
323
|
+
providedColumns.length
|
|
324
|
+
? "No row values changed."
|
|
325
|
+
: "No editable column values were provided."
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const setClause = columnsToUpdate
|
|
330
|
+
.map((column) => `${quoteIdentifier(column.name)} = ?`)
|
|
331
|
+
.join(", ");
|
|
332
|
+
const setParams = columnsToUpdate.map((column) =>
|
|
333
|
+
deserializeSqliteValue(values[column.name])
|
|
334
|
+
);
|
|
335
|
+
const sql = [
|
|
336
|
+
"UPDATE",
|
|
337
|
+
quoteIdentifier(tableDetail.name),
|
|
338
|
+
"SET",
|
|
339
|
+
setClause,
|
|
340
|
+
"WHERE",
|
|
341
|
+
where.clause,
|
|
342
|
+
].join(" ");
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
tableDetail,
|
|
346
|
+
where,
|
|
347
|
+
sql,
|
|
348
|
+
params: [...setParams, ...where.params],
|
|
349
|
+
changes: columnsToUpdate.map((column) => ({
|
|
350
|
+
column: column.name,
|
|
351
|
+
oldValue: formatPreviewValue(currentRow[column.name]),
|
|
352
|
+
newValue: formatPreviewValue(values[column.name]),
|
|
353
|
+
})),
|
|
354
|
+
warnings:
|
|
355
|
+
tableDetail.identityStrategy?.type === "primaryKey" &&
|
|
356
|
+
(tableDetail.identityStrategy.columns?.length ?? 0) > 1
|
|
357
|
+
? ["This update targets a row through a composite primary key."]
|
|
358
|
+
: [],
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
282
362
|
getRowByIdentity(db, tableDetail, where) {
|
|
283
363
|
const selectExpression =
|
|
284
364
|
tableDetail.identityStrategy?.type === "rowid" ? "rowid AS __rowid__, *" : "*";
|
|
@@ -8,6 +8,7 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
|
|
|
8
8
|
const db = new Database(":memory:");
|
|
9
9
|
const tableName = 'items" archived';
|
|
10
10
|
const valueColumn = 'display"name';
|
|
11
|
+
const noteColumn = 'review"note';
|
|
11
12
|
|
|
12
13
|
try {
|
|
13
14
|
db.exec(
|
|
@@ -16,7 +17,9 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
|
|
|
16
17
|
quoteIdentifier(tableName),
|
|
17
18
|
"(",
|
|
18
19
|
quoteIdentifier(valueColumn),
|
|
19
|
-
"TEXT, status INTEGER",
|
|
20
|
+
"TEXT, status INTEGER,",
|
|
21
|
+
quoteIdentifier(noteColumn),
|
|
22
|
+
"TEXT",
|
|
20
23
|
")",
|
|
21
24
|
].join(" ")
|
|
22
25
|
);
|
|
@@ -24,10 +27,10 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
|
|
|
24
27
|
[
|
|
25
28
|
"INSERT INTO",
|
|
26
29
|
quoteIdentifier(tableName),
|
|
27
|
-
"(" + quoteIdentifier(valueColumn) + ", status)",
|
|
28
|
-
"VALUES (?, ?)",
|
|
30
|
+
"(" + quoteIdentifier(valueColumn) + ", status, " + quoteIdentifier(noteColumn) + ")",
|
|
31
|
+
"VALUES (?, ?, ?)",
|
|
29
32
|
].join(" ")
|
|
30
|
-
).run("before", 0);
|
|
33
|
+
).run("before", 0, null);
|
|
31
34
|
|
|
32
35
|
const service = new DataBrowserService({
|
|
33
36
|
connectionManager: {
|
|
@@ -37,19 +40,56 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
|
|
|
37
40
|
});
|
|
38
41
|
const tableData = service.getTableData(tableName, { limit: 10, offset: 0 });
|
|
39
42
|
const identity = tableData.rows[0].__identity;
|
|
43
|
+
const preview = service.previewTableRowUpdate(tableName, {
|
|
44
|
+
identity,
|
|
45
|
+
values: {
|
|
46
|
+
[valueColumn]: "after",
|
|
47
|
+
status: 0,
|
|
48
|
+
[noteColumn]: "",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.equal(
|
|
53
|
+
preview.sql,
|
|
54
|
+
[
|
|
55
|
+
"UPDATE",
|
|
56
|
+
quoteIdentifier(tableName),
|
|
57
|
+
"SET",
|
|
58
|
+
quoteIdentifier(valueColumn) + " = ?",
|
|
59
|
+
"WHERE",
|
|
60
|
+
"rowid IS ?",
|
|
61
|
+
].join(" ")
|
|
62
|
+
);
|
|
63
|
+
assert.deepEqual(preview.changes, [
|
|
64
|
+
{
|
|
65
|
+
column: valueColumn,
|
|
66
|
+
oldValue: "before",
|
|
67
|
+
newValue: "after",
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
40
70
|
|
|
41
71
|
const updated = service.updateTableRow(tableName, {
|
|
42
72
|
identity,
|
|
43
73
|
values: {
|
|
44
74
|
[valueColumn]: "after",
|
|
75
|
+
[noteColumn]: "",
|
|
45
76
|
},
|
|
46
77
|
});
|
|
47
78
|
|
|
48
79
|
assert.equal(updated.row[valueColumn], "after");
|
|
49
|
-
assert.equal(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
80
|
+
assert.equal(updated.row[noteColumn], null);
|
|
81
|
+
const persistedRow = db.prepare(
|
|
82
|
+
[
|
|
83
|
+
"SELECT",
|
|
84
|
+
quoteIdentifier(valueColumn) + ",",
|
|
85
|
+
quoteIdentifier(noteColumn),
|
|
86
|
+
"FROM",
|
|
87
|
+
quoteIdentifier(tableName),
|
|
88
|
+
].join(" ")
|
|
89
|
+
).get();
|
|
90
|
+
|
|
91
|
+
assert.equal(persistedRow[valueColumn], "after");
|
|
92
|
+
assert.equal(persistedRow[noteColumn], null);
|
|
53
93
|
|
|
54
94
|
const deleted = service.deleteTableRow(tableName, {
|
|
55
95
|
identity: updated.row.__identity,
|