admindb 1.0.3 → 1.1.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/README.md +51 -30
- package/dist/db/database.d.ts +58 -37
- package/dist/db/database.js +239 -42
- package/dist/db/database.js.map +1 -1
- package/dist/db/manager.d.ts +2 -2
- package/dist/db/manager.js +2 -1
- package/dist/db/manager.js.map +1 -1
- package/dist/public/css/app.css +1 -1
- package/dist/public/css/input.css +53 -0
- package/dist/public/js/browse.js +840 -86
- package/dist/routes/api.js +261 -97
- package/dist/routes/api.js.map +1 -1
- package/dist/routes/pages.js +50 -30
- package/dist/routes/pages.js.map +1 -1
- package/dist/sql/generator.d.ts +0 -22
- package/dist/sql/generator.js +0 -48
- package/dist/sql/generator.js.map +1 -1
- package/dist/test/database.test.js +241 -63
- package/dist/test/database.test.js.map +1 -1
- package/dist/test/generator.test.js +0 -32
- package/dist/test/generator.test.js.map +1 -1
- package/dist/util.d.ts +13 -1
- package/dist/util.js +24 -2
- package/dist/util.js.map +1 -1
- package/dist/views/pages/table.hbs +81 -28
- package/dist/views/partials/sidebar.hbs +0 -12
- package/package.json +1 -1
- package/dist/public/js/triggers.js +0 -155
- package/dist/public/js/views.js +0 -176
- package/dist/views/pages/triggers.hbs +0 -162
- package/dist/views/pages/views.hbs +0 -134
package/README.md
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
A browser-based SQLite database administration tool. Manage a SQLite database
|
|
12
|
-
entirely from the browser: browse tables
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
entirely from the browser: browse tables with full CRUD, inline
|
|
13
|
+
spreadsheet-style grid editing, type-aware filters, bulk row operations, a
|
|
14
|
+
visual table designer, an arbitrary SQL query runner, saved named queries,
|
|
15
|
+
SQL-dump export, a "generate SQL" preview mode that never executes, a schema
|
|
16
|
+
editor, and multi-database support.
|
|
16
17
|
|
|
17
18
|
- **Backend:** Node.js + TypeScript (compiled to plain JS) on Express.
|
|
18
19
|
- **Frontend:** Handlebars server-rendered templates, Tailwind CSS + DaisyUI,
|
|
@@ -132,10 +133,18 @@ In multi-db mode:
|
|
|
132
133
|
## Features
|
|
133
134
|
|
|
134
135
|
- Browse every table and its rows (paginated, with primary-key awareness).
|
|
135
|
-
- **Filter rows by column** —
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
- **Filter rows by column** — type-aware, per-column filter controls: foreign-key
|
|
137
|
+
dropdowns, boolean toggles, date and numeric range inputs, plus the exact
|
|
138
|
+
(`=value`), comparison (`>5`, `<=10`), prefix (`pre*`) and substring (plain
|
|
139
|
+
text) operators on text columns. Filters survive sorting and pagination.
|
|
140
|
+
- **Inline (spreadsheet-style) editing** — double-click any cell to edit it in
|
|
141
|
+
place with a type-aware control (FK dropdown, boolean toggle, date picker,
|
|
142
|
+
number/text input). Changes are staged and highlighted in the grid, then
|
|
143
|
+
applied all at once in a single transaction, or discarded.
|
|
144
|
+
- Insert and delete rows (full CRUD). FK columns become dropdowns.
|
|
145
|
+
- **Bulk row operations** — select rows with checkboxes (or "select all"), then
|
|
146
|
+
**delete** them in one transaction (with a warning listing how many rows in
|
|
147
|
+
other tables reference them) or **export** only the selected rows as CSV/JSON.
|
|
139
148
|
- **Export** table rows or query results as **CSV or JSON**, and **import a CSV
|
|
140
149
|
file** (or pasted CSV) into a table.
|
|
141
150
|
- Create tables visually: name, type, primary key, not-null / unique,
|
|
@@ -151,11 +160,10 @@ In multi-db mode:
|
|
|
151
160
|
- **Indexes:** create indexes (plain or unique, on one or many columns — pick
|
|
152
161
|
columns in order, with a live `CREATE INDEX` SQL preview) and drop them from
|
|
153
162
|
the schema editor; automatic SQLite indexes are protected.
|
|
154
|
-
- **
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
inspect existing trigger SQL — from a dedicated Triggers page.
|
|
163
|
+
- **Related rows:** every table that has a foreign key pointing at a table gets
|
|
164
|
+
its own column at the end of that table's grid — each cell shows how many of
|
|
165
|
+
its rows reference that record; click it to open a nested table with all of
|
|
166
|
+
the referencing table's columns and data for that specific record.
|
|
159
167
|
- **Read-only mode:** open the database(s) without write access — the file is
|
|
160
168
|
opened `SQLITE_OPEN_READONLY` + `query_only`, every write route returns `403`,
|
|
161
169
|
and the UI hides/disables all write controls and shows a banner.
|
|
@@ -229,15 +237,19 @@ $env:DB_DIR='./db'; npm start # PowerShell
|
|
|
229
237
|
| GET | `/api/tables` | List tables |
|
|
230
238
|
| GET | `/api/tables/:table/info` | Column + FK metadata, PK columns |
|
|
231
239
|
| GET | `/api/tables/:table/fk-options` | Values for FK dropdowns |
|
|
232
|
-
| GET | `/api/tables/:table/rows?page&limit&f` | Paginated rows (`f` = URL-encoded JSON filters
|
|
240
|
+
| GET | `/api/tables/:table/rows?page&limit&f` | Paginated rows (`f` = URL-encoded JSON filters — legacy strings like `{"age":">35"}` or structured conditions like `{"balance":{"op":"gte","value":"100"}}`) |
|
|
233
241
|
| GET | `/api/tables/:table/row/:id` | Single row by (encoded) PK |
|
|
234
242
|
| POST | `/api/tables/:table/rows` | Insert row |
|
|
235
243
|
| POST | `/api/tables/:table/rows/generate` | Generate INSERT SQL (no execute) |
|
|
236
244
|
| POST | `/api/tables/:table/rows/import` | Import CSV (`{ csv }`, header row must match columns) |
|
|
237
245
|
| GET | `/api/tables/:table/export?format=` | Download all rows as `csv` or `json` |
|
|
238
|
-
| PUT | `/api/tables/:table/row/:id` | Update row
|
|
246
|
+
| PUT | `/api/tables/:table/row/:id` | Update row (`{ values, nulls? }` — `nulls` explicitly sets columns to NULL) |
|
|
239
247
|
| PUT | `/api/tables/:table/row/:id/generate` | Generate UPDATE SQL (no execute) |
|
|
240
248
|
| DELETE | `/api/tables/:table/row/:id` | Delete row |
|
|
249
|
+
| POST | `/api/tables/:table/rows/bulk-impact` | FK-impact preview: how many rows in other tables reference the selected rows |
|
|
250
|
+
| POST | `/api/tables/:table/rows/bulk-delete` | Delete selected rows (`{ ids, confirmImpact }`; transactional) |
|
|
251
|
+
| POST | `/api/tables/:table/rows/bulk-export` | Export only the selected rows as `csv`/`json` (`{ ids, format }`) |
|
|
252
|
+
| POST | `/api/tables/:table/rows/bulk-update` | Apply staged inline edits (`{ updates: [{ id, values?, nulls? }] }`; transactional) |
|
|
241
253
|
| POST | `/api/tables` | Create table |
|
|
242
254
|
| POST | `/api/tables/generate` | Generate CREATE SQL (no execute) |
|
|
243
255
|
| GET | `/api/tables/:table/schema` | Full schema (constraints, indexes, FK refs) |
|
|
@@ -248,15 +260,6 @@ $env:DB_DIR='./db'; npm start # PowerShell
|
|
|
248
260
|
| DELETE | `/api/tables/:table` | Drop the table (safety-checked) |
|
|
249
261
|
| POST | `/api/tables/:table/indexes` | Create an index (`{ name?, columns[], unique? }`) |
|
|
250
262
|
| DELETE | `/api/tables/:table/indexes/:index` | Drop an index (auto indexes refused) |
|
|
251
|
-
| GET | `/api/views` | List views |
|
|
252
|
-
| GET | `/api/views/:name/rows` | Preview a view's rows |
|
|
253
|
-
| POST | `/api/views/generate` | Generate CREATE VIEW SQL (no execute) |
|
|
254
|
-
| POST | `/api/views` | Create a view (`{ name, sql }`) |
|
|
255
|
-
| DELETE | `/api/views/:name` | Drop a view |
|
|
256
|
-
| GET | `/api/triggers` | List triggers |
|
|
257
|
-
| POST | `/api/triggers/generate` | Generate CREATE TRIGGER SQL (no execute) |
|
|
258
|
-
| POST | `/api/triggers` | Create a trigger (`{ name, table, timing, event, when?, body }`) |
|
|
259
|
-
| DELETE | `/api/triggers/:name` | Drop a trigger |
|
|
260
263
|
| POST | `/api/query` | Run arbitrary SQL |
|
|
261
264
|
| POST | `/api/query/export` | Run a SELECT and download as `csv`/`json` |
|
|
262
265
|
| GET | `/api/queries` | List saved queries |
|
|
@@ -274,10 +277,26 @@ In multi-db mode, every route is scoped under the database, e.g.
|
|
|
274
277
|
- **Insert forms pre-fill defaults.** On the "new row" form, columns that have
|
|
275
278
|
a schema default are pre-filled (string/number/boolean literals and
|
|
276
279
|
`CURRENT_TIMESTAMP`-style defaults) so you can see and adjust them.
|
|
277
|
-
- **Row filters.**
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
280
|
+
- **Row filters.** The filter panel adapts to each column's type: foreign keys
|
|
281
|
+
become dropdowns (with a "not set / NULL" option), booleans become
|
|
282
|
+
any/true/false toggles, and dates and numbers become min/max range inputs.
|
|
283
|
+
Text columns keep the operator syntax: exact match (`=value`), comparison
|
|
284
|
+
(`>5`, `>=5`, `<5`, `<=5`, `!=value`), prefix (`pre*`) and case-insensitive
|
|
285
|
+
substring (plain text). Filters are carried in the URL (as legacy strings or
|
|
286
|
+
structured conditions) and survive sorting and pagination.
|
|
287
|
+
- **Inline editing is staged, not instant.** Double-click a cell to edit it in
|
|
288
|
+
place; edits are buffered locally and highlighted in the grid rather than
|
|
289
|
+
written immediately. Press **Apply** to write every pending change to the
|
|
290
|
+
database in a single transaction (the page then reloads so related-row counts
|
|
291
|
+
stay accurate), or **Discard** to revert everything back to the saved values.
|
|
292
|
+
Clearing a text/date/number field stages a `NULL`.
|
|
293
|
+
- **Bulk operations.** Checkboxes select rows on the current page; "select all"
|
|
294
|
+
checks every visible row. **Delete** first shows a warning listing each table
|
|
295
|
+
that references the selected rows and how many rows point at them (these may
|
|
296
|
+
be cascaded away or orphaned depending on the foreign-key action, and the
|
|
297
|
+
delete can fail if a constraint blocks it). **Export CSV / JSON** downloads
|
|
298
|
+
only the selected rows. Both delete and export are capped at 1000 rows per
|
|
299
|
+
batch.
|
|
281
300
|
- **CSV import.** The first row must be a header whose names match existing
|
|
282
301
|
columns (unknown or duplicate names are rejected). Empty cells are treated as
|
|
283
302
|
"not set" so database defaults apply. The whole import runs in a single
|
|
@@ -311,8 +330,10 @@ npm test # builds and runs the unit tests
|
|
|
311
330
|
|
|
312
331
|
`npm test` compiles TypeScript to `dist/` and runs the `node:test` suite
|
|
313
332
|
covering the SQL generator (INSERT / UPDATE / CREATE output, type mapping,
|
|
314
|
-
quoting, rejection of unsupported types), the SQL classifier,
|
|
315
|
-
|
|
333
|
+
quoting, rejection of unsupported types), the SQL classifier, CSV parsing and
|
|
334
|
+
serialization, row filters (legacy and structured conditions), the database
|
|
335
|
+
layer (pagination, transactions, bulk update/delete, read-only enforcement),
|
|
336
|
+
and the database manager.
|
|
316
337
|
|
|
317
338
|
## Publishing to npm
|
|
318
339
|
|
package/dist/db/database.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Logger } from '../logger';
|
|
2
|
-
import { type ColumnDef, type IndexDef
|
|
2
|
+
import { type ColumnDef, type IndexDef } from '../sql/generator';
|
|
3
|
+
import { type FilterValue } from '../util';
|
|
4
|
+
/** Structured filter types are shared with the API layer. */
|
|
5
|
+
export type { FilterCondition, FilterValue } from '../util';
|
|
3
6
|
/** SQLite-compatible bind value accepted by better-sqlite3 statements. */
|
|
4
7
|
export type SQLInputValue = null | number | bigint | string | Uint8Array;
|
|
5
8
|
export interface Result<T = unknown> {
|
|
@@ -53,20 +56,8 @@ export interface IndexInfo {
|
|
|
53
56
|
/** The `CREATE INDEX` statement from sqlite_master, when available. */
|
|
54
57
|
sql?: string | null;
|
|
55
58
|
}
|
|
56
|
-
export interface ViewInfo {
|
|
57
|
-
name: string;
|
|
58
|
-
/** The `CREATE VIEW …` statement from sqlite_master. */
|
|
59
|
-
sql: string | null;
|
|
60
|
-
}
|
|
61
|
-
export interface TriggerInfo {
|
|
62
|
-
name: string;
|
|
63
|
-
/** The table (or view) the trigger fires on. */
|
|
64
|
-
table: string;
|
|
65
|
-
/** The `CREATE TRIGGER …` statement from sqlite_master. */
|
|
66
|
-
sql: string | null;
|
|
67
|
-
}
|
|
68
59
|
export interface RowFilters {
|
|
69
|
-
[column: string]:
|
|
60
|
+
[column: string]: FilterValue;
|
|
70
61
|
}
|
|
71
62
|
export interface ColumnDetail extends ColumnInfo {
|
|
72
63
|
unique: boolean;
|
|
@@ -93,16 +84,19 @@ export interface SchemaInfo {
|
|
|
93
84
|
to: string;
|
|
94
85
|
}[];
|
|
95
86
|
}
|
|
87
|
+
export interface ReferencingTableInfo {
|
|
88
|
+
table: string;
|
|
89
|
+
/** FK columns in `table` that point at this table (`from` → `to`). */
|
|
90
|
+
refs: {
|
|
91
|
+
from: string;
|
|
92
|
+
to: string;
|
|
93
|
+
}[];
|
|
94
|
+
/** Rows in `table` whose FK column(s) actually reference this table. */
|
|
95
|
+
refCount: number;
|
|
96
|
+
}
|
|
96
97
|
export declare const INTERNAL_TABLES: {
|
|
97
98
|
readonly savedQueries: "_saved_queries";
|
|
98
99
|
};
|
|
99
|
-
/**
|
|
100
|
-
* Build a parameterized WHERE clause from per-column filters.
|
|
101
|
-
* Supported syntax in a filter value:
|
|
102
|
-
* - `=value` exact match, `!=value` not-equal, `>value`, `>=value`, `<value`, `<=value`
|
|
103
|
-
* - `value*` prefix match
|
|
104
|
-
* - anything else → case-insensitive substring (LIKE '%value%')
|
|
105
|
-
*/
|
|
106
100
|
export declare function buildFilterClause(filters: RowFilters | undefined, availableCols: string[]): {
|
|
107
101
|
where: string;
|
|
108
102
|
params: SQLInputValue[];
|
|
@@ -137,6 +131,12 @@ export declare class SqliteDatabase {
|
|
|
137
131
|
execResult(sql: string): Promise<Result<{
|
|
138
132
|
changes?: number;
|
|
139
133
|
}>>;
|
|
134
|
+
/**
|
|
135
|
+
* True when `sql` contains more than one statement. Detection is delegated to
|
|
136
|
+
* SQLite itself via `prepare()` (which refuses multi-statement strings), so
|
|
137
|
+
* comments, string literals and quoted identifiers are handled correctly.
|
|
138
|
+
*/
|
|
139
|
+
hasMultipleStatements(sql: string): boolean;
|
|
140
140
|
/** Run a write statement, reporting row changes when it is a single statement. */
|
|
141
141
|
runWrite(sql: string): Promise<Result<{
|
|
142
142
|
changes?: number;
|
|
@@ -166,10 +166,33 @@ export declare class SqliteDatabase {
|
|
|
166
166
|
changes: number;
|
|
167
167
|
lastInsertRowid: number | null;
|
|
168
168
|
}>>;
|
|
169
|
+
/**
|
|
170
|
+
* Apply many row updates (each with its own SET fields + primary-key WHERE
|
|
171
|
+
* clause) inside a single transaction. Used by the bulk "apply changes"
|
|
172
|
+
* flow of inline grid editing. Rolls everything back if any row fails.
|
|
173
|
+
*/
|
|
174
|
+
updateRows(table: string, rows: {
|
|
175
|
+
fields: WhereClause[];
|
|
176
|
+
where: WhereClause[];
|
|
177
|
+
}[]): Promise<Result<{
|
|
178
|
+
updated: number;
|
|
179
|
+
}>>;
|
|
169
180
|
deleteRow(table: string, where: WhereClause[]): Promise<Result<{
|
|
170
181
|
changes: number;
|
|
171
182
|
lastInsertRowid: number | null;
|
|
172
183
|
}>>;
|
|
184
|
+
/**
|
|
185
|
+
* Delete many rows (each identified by its primary-key WHERE clause) inside a
|
|
186
|
+
* single transaction. Used by bulk delete.
|
|
187
|
+
*/
|
|
188
|
+
deleteRows(table: string, rows: WhereClause[][]): Promise<Result<{
|
|
189
|
+
deleted: number;
|
|
190
|
+
}>>;
|
|
191
|
+
/**
|
|
192
|
+
* Fetch the rows matching the given primary-key WHERE clauses in one query
|
|
193
|
+
* (`(a = ? AND b = ?) OR …`). Used by bulk export and FK-impact checks.
|
|
194
|
+
*/
|
|
195
|
+
getRowsByPks(table: string, pks: WhereClause[][]): Promise<Result<Record<string, unknown>[]>>;
|
|
173
196
|
getCreateStatement(table: string): Promise<Result<string | null>>;
|
|
174
197
|
listSavedQueries(): Promise<Result<SavedQuery[]>>;
|
|
175
198
|
saveQuery(name: string, sql: string): Promise<Result<{
|
|
@@ -185,6 +208,20 @@ export declare class SqliteDatabase {
|
|
|
185
208
|
* foreign keys, and which other tables reference it.
|
|
186
209
|
*/
|
|
187
210
|
getSchema(table: string): Promise<Result<SchemaInfo>>;
|
|
211
|
+
/**
|
|
212
|
+
* Other tables with foreign keys pointing at `table`, grouped by table, each
|
|
213
|
+
* with the FK column mapping and a count of rows that actually reference it.
|
|
214
|
+
*/
|
|
215
|
+
getReferencingTables(table: string): Promise<Result<ReferencingTableInfo[]>>;
|
|
216
|
+
/**
|
|
217
|
+
* Rows in `table` whose `column` equals `value` — used to show which rows in
|
|
218
|
+
* a referencing table point at a specific row via a foreign key — with the
|
|
219
|
+
* total matching count.
|
|
220
|
+
*/
|
|
221
|
+
getRowsByFk(table: string, column: string, value: unknown, limit?: number): Promise<Result<{
|
|
222
|
+
rows: Record<string, unknown>[];
|
|
223
|
+
total: number;
|
|
224
|
+
}>>;
|
|
188
225
|
renameTable(oldName: string, newName: string): Promise<Result<{
|
|
189
226
|
changes?: number;
|
|
190
227
|
}>>;
|
|
@@ -206,21 +243,5 @@ export declare class SqliteDatabase {
|
|
|
206
243
|
dropIndex(indexName: string): Promise<Result<{
|
|
207
244
|
changes?: number;
|
|
208
245
|
}>>;
|
|
209
|
-
listViews(): Promise<Result<ViewInfo[]>>;
|
|
210
|
-
/** Preview the first `limit` rows of a view (browse). */
|
|
211
|
-
getViewRows(viewName: string, limit?: number): Promise<Result<Record<string, unknown>[]>>;
|
|
212
|
-
createView(name: string, selectSql: string): Promise<Result<{
|
|
213
|
-
changes?: number;
|
|
214
|
-
}>>;
|
|
215
|
-
dropView(name: string): Promise<Result<{
|
|
216
|
-
changes?: number;
|
|
217
|
-
}>>;
|
|
218
|
-
listTriggers(): Promise<Result<TriggerInfo[]>>;
|
|
219
|
-
createTrigger(def: TriggerDef): Promise<Result<{
|
|
220
|
-
changes?: number;
|
|
221
|
-
}>>;
|
|
222
|
-
dropTrigger(name: string): Promise<Result<{
|
|
223
|
-
changes?: number;
|
|
224
|
-
}>>;
|
|
225
246
|
private getTableInfoSync;
|
|
226
247
|
}
|
package/dist/db/database.js
CHANGED
|
@@ -17,20 +17,87 @@ function escapeLike(s) {
|
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
19
|
* Build a parameterized WHERE clause from per-column filters.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* - `value
|
|
23
|
-
*
|
|
20
|
+
*
|
|
21
|
+
* A filter value is either:
|
|
22
|
+
* - the legacy string syntax: `=value` exact, `!=value`, `>value`, `>=value`,
|
|
23
|
+
* `<value`, `<=value`, `value*` prefix, anything else → substring match; or
|
|
24
|
+
* - a structured `FilterCondition` (or array of them) produced by the
|
|
25
|
+
* type-aware filter form (FK dropdowns, boolean toggles, date/number ranges).
|
|
24
26
|
*/
|
|
27
|
+
function pushFilterCondition(conds, params, quotedCol, c) {
|
|
28
|
+
const value = String(c.value ?? '');
|
|
29
|
+
switch (c.op) {
|
|
30
|
+
case 'eq':
|
|
31
|
+
conds.push(`${quotedCol} = ?`);
|
|
32
|
+
params.push(value);
|
|
33
|
+
break;
|
|
34
|
+
case 'neq':
|
|
35
|
+
conds.push(`${quotedCol} != ?`);
|
|
36
|
+
params.push(value);
|
|
37
|
+
break;
|
|
38
|
+
case 'gt':
|
|
39
|
+
conds.push(`${quotedCol} > ?`);
|
|
40
|
+
params.push(value);
|
|
41
|
+
break;
|
|
42
|
+
case 'gte':
|
|
43
|
+
conds.push(`${quotedCol} >= ?`);
|
|
44
|
+
params.push(value);
|
|
45
|
+
break;
|
|
46
|
+
case 'lt':
|
|
47
|
+
conds.push(`${quotedCol} < ?`);
|
|
48
|
+
params.push(value);
|
|
49
|
+
break;
|
|
50
|
+
case 'lte':
|
|
51
|
+
conds.push(`${quotedCol} <= ?`);
|
|
52
|
+
params.push(value);
|
|
53
|
+
break;
|
|
54
|
+
case 'like':
|
|
55
|
+
conds.push(`${quotedCol} LIKE ? ESCAPE '\\'`);
|
|
56
|
+
params.push('%' + escapeLike(value) + '%');
|
|
57
|
+
break;
|
|
58
|
+
case 'prefix':
|
|
59
|
+
conds.push(`${quotedCol} LIKE ? ESCAPE '\\'`);
|
|
60
|
+
params.push(escapeLike(value) + '%');
|
|
61
|
+
break;
|
|
62
|
+
case 'between':
|
|
63
|
+
conds.push(`${quotedCol} >= ?`);
|
|
64
|
+
params.push(value);
|
|
65
|
+
conds.push(`${quotedCol} <= ?`);
|
|
66
|
+
params.push(String(c.max ?? ''));
|
|
67
|
+
break;
|
|
68
|
+
case 'null':
|
|
69
|
+
conds.push(`${quotedCol} IS NULL`);
|
|
70
|
+
break;
|
|
71
|
+
case 'notnull':
|
|
72
|
+
conds.push(`${quotedCol} IS NOT NULL`);
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
25
78
|
function buildFilterClause(filters, availableCols) {
|
|
26
79
|
const conds = [];
|
|
27
80
|
const params = [];
|
|
28
81
|
const colSet = new Set(availableCols);
|
|
29
82
|
for (const [col, raw] of Object.entries(filters ?? {})) {
|
|
30
|
-
|
|
31
|
-
if (value === '' || !colSet.has(col))
|
|
83
|
+
if (!colSet.has(col))
|
|
32
84
|
continue;
|
|
33
85
|
const q = (0, generator_1.quoteIdentifier)(col);
|
|
86
|
+
if (Array.isArray(raw)) {
|
|
87
|
+
for (const c of raw) {
|
|
88
|
+
if (c && typeof c === 'object')
|
|
89
|
+
pushFilterCondition(conds, params, q, c);
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (raw && typeof raw === 'object') {
|
|
94
|
+
pushFilterCondition(conds, params, q, raw);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Legacy string syntax.
|
|
98
|
+
const value = String(raw ?? '').trim();
|
|
99
|
+
if (value === '')
|
|
100
|
+
continue;
|
|
34
101
|
if (value.startsWith('>=')) {
|
|
35
102
|
conds.push(`${q} >= ?`);
|
|
36
103
|
params.push(value.slice(2).trim());
|
|
@@ -153,6 +220,23 @@ class SqliteDatabase {
|
|
|
153
220
|
return { changes: undefined };
|
|
154
221
|
});
|
|
155
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* True when `sql` contains more than one statement. Detection is delegated to
|
|
225
|
+
* SQLite itself via `prepare()` (which refuses multi-statement strings), so
|
|
226
|
+
* comments, string literals and quoted identifiers are handled correctly.
|
|
227
|
+
*/
|
|
228
|
+
hasMultipleStatements(sql) {
|
|
229
|
+
const s = String(sql ?? '').trim();
|
|
230
|
+
if (!s)
|
|
231
|
+
return false;
|
|
232
|
+
try {
|
|
233
|
+
this.db.prepare(s);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
return /more than one statement/i.test((0, util_1.errorMessage)(err));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
156
240
|
/** Run a write statement, reporting row changes when it is a single statement. */
|
|
157
241
|
async runWrite(sql) {
|
|
158
242
|
if (this.isReadOnly)
|
|
@@ -294,11 +378,99 @@ class SqliteDatabase {
|
|
|
294
378
|
const sql = `UPDATE ${(0, generator_1.quoteIdentifier)(table)} SET ${sets.join(', ')} WHERE ${conds.join(' AND ')}`;
|
|
295
379
|
return this.run(sql, [...fields.map((f) => this.toBind(f.value)), ...where.map((w) => this.toBind(w.value))]);
|
|
296
380
|
}
|
|
381
|
+
/**
|
|
382
|
+
* Apply many row updates (each with its own SET fields + primary-key WHERE
|
|
383
|
+
* clause) inside a single transaction. Used by the bulk "apply changes"
|
|
384
|
+
* flow of inline grid editing. Rolls everything back if any row fails.
|
|
385
|
+
*/
|
|
386
|
+
async updateRows(table, rows) {
|
|
387
|
+
if (this.isReadOnly)
|
|
388
|
+
return this.readonlyBlocked();
|
|
389
|
+
return this.tryRun(() => {
|
|
390
|
+
this.db.exec('BEGIN');
|
|
391
|
+
try {
|
|
392
|
+
let updated = 0;
|
|
393
|
+
for (const { fields, where } of rows) {
|
|
394
|
+
if (!fields.length || !where.length)
|
|
395
|
+
continue;
|
|
396
|
+
const sets = fields.map((f) => `${(0, generator_1.quoteIdentifier)(f.column)} = ?`);
|
|
397
|
+
const conds = where.map((w) => `${(0, generator_1.quoteIdentifier)(w.column)} = ?`);
|
|
398
|
+
const sql = `UPDATE ${(0, generator_1.quoteIdentifier)(table)} SET ${sets.join(', ')} WHERE ${conds.join(' AND ')}`;
|
|
399
|
+
const info = this.db.prepare(sql).run(...[...fields.map((f) => this.toBind(f.value)), ...where.map((w) => this.toBind(w.value))]);
|
|
400
|
+
updated += Number(info.changes);
|
|
401
|
+
}
|
|
402
|
+
this.db.exec('COMMIT');
|
|
403
|
+
return { updated };
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
try {
|
|
407
|
+
this.db.exec('ROLLBACK');
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
/* ignore */
|
|
411
|
+
}
|
|
412
|
+
throw err;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
297
416
|
async deleteRow(table, where) {
|
|
298
417
|
const conds = where.map((w) => `${(0, generator_1.quoteIdentifier)(w.column)} = ?`);
|
|
299
418
|
const sql = `DELETE FROM ${(0, generator_1.quoteIdentifier)(table)} WHERE ${conds.join(' AND ')}`;
|
|
300
419
|
return this.run(sql, where.map((w) => this.toBind(w.value)));
|
|
301
420
|
}
|
|
421
|
+
/**
|
|
422
|
+
* Delete many rows (each identified by its primary-key WHERE clause) inside a
|
|
423
|
+
* single transaction. Used by bulk delete.
|
|
424
|
+
*/
|
|
425
|
+
async deleteRows(table, rows) {
|
|
426
|
+
if (this.isReadOnly)
|
|
427
|
+
return this.readonlyBlocked();
|
|
428
|
+
return this.tryRun(() => {
|
|
429
|
+
this.db.exec('BEGIN');
|
|
430
|
+
try {
|
|
431
|
+
let deleted = 0;
|
|
432
|
+
for (const where of rows) {
|
|
433
|
+
if (!where.length)
|
|
434
|
+
continue;
|
|
435
|
+
const conds = where.map((w) => `${(0, generator_1.quoteIdentifier)(w.column)} = ?`);
|
|
436
|
+
const sql = `DELETE FROM ${(0, generator_1.quoteIdentifier)(table)} WHERE ${conds.join(' AND ')}`;
|
|
437
|
+
const info = this.db.prepare(sql).run(...where.map((w) => this.toBind(w.value)));
|
|
438
|
+
deleted += Number(info.changes);
|
|
439
|
+
}
|
|
440
|
+
this.db.exec('COMMIT');
|
|
441
|
+
return { deleted };
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
try {
|
|
445
|
+
this.db.exec('ROLLBACK');
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
/* ignore */
|
|
449
|
+
}
|
|
450
|
+
throw err;
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Fetch the rows matching the given primary-key WHERE clauses in one query
|
|
456
|
+
* (`(a = ? AND b = ?) OR …`). Used by bulk export and FK-impact checks.
|
|
457
|
+
*/
|
|
458
|
+
async getRowsByPks(table, pks) {
|
|
459
|
+
return this.tryRun(() => {
|
|
460
|
+
const groups = [];
|
|
461
|
+
const params = [];
|
|
462
|
+
for (const where of pks) {
|
|
463
|
+
if (!where.length)
|
|
464
|
+
continue;
|
|
465
|
+
groups.push(where.map((w) => `${(0, generator_1.quoteIdentifier)(w.column)} = ?`).join(' AND '));
|
|
466
|
+
params.push(...where.map((w) => this.toBind(w.value)));
|
|
467
|
+
}
|
|
468
|
+
if (!groups.length)
|
|
469
|
+
return [];
|
|
470
|
+
const sql = `SELECT * FROM ${(0, generator_1.quoteIdentifier)(table)} WHERE ${groups.map((g) => `(${g})`).join(' OR ')}`;
|
|
471
|
+
return this.db.prepare(sql).all(...params);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
302
474
|
async getCreateStatement(table) {
|
|
303
475
|
return this.tryRun(() => {
|
|
304
476
|
const row = this.db
|
|
@@ -426,6 +598,67 @@ class SqliteDatabase {
|
|
|
426
598
|
};
|
|
427
599
|
});
|
|
428
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* Other tables with foreign keys pointing at `table`, grouped by table, each
|
|
603
|
+
* with the FK column mapping and a count of rows that actually reference it.
|
|
604
|
+
*/
|
|
605
|
+
async getReferencingTables(table) {
|
|
606
|
+
return this.tryRun(() => {
|
|
607
|
+
const allTables = this.db
|
|
608
|
+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
|
|
609
|
+
.all().map((r) => r.name);
|
|
610
|
+
const byTable = new Map();
|
|
611
|
+
for (const t of allTables) {
|
|
612
|
+
if (t === table)
|
|
613
|
+
continue;
|
|
614
|
+
try {
|
|
615
|
+
const fks = this.db.prepare(`PRAGMA foreign_key_list(${(0, generator_1.quoteIdentifier)(t)})`).all();
|
|
616
|
+
for (const fk of fks) {
|
|
617
|
+
if (fk.table === table) {
|
|
618
|
+
const arr = byTable.get(t) ?? [];
|
|
619
|
+
arr.push({ from: fk.from, to: fk.to ?? '' });
|
|
620
|
+
byTable.set(t, arr);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
/* ignore unreadable tables */
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const result = [];
|
|
629
|
+
for (const [t, refs] of byTable) {
|
|
630
|
+
let refCount = 0;
|
|
631
|
+
try {
|
|
632
|
+
const conds = refs.map((r) => `${(0, generator_1.quoteIdentifier)(r.from)} IS NOT NULL`);
|
|
633
|
+
const row = this.db.prepare(`SELECT COUNT(*) AS c FROM ${(0, generator_1.quoteIdentifier)(t)} WHERE ${conds.join(' OR ')}`).get();
|
|
634
|
+
refCount = Number(row.c);
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
/* count is best-effort */
|
|
638
|
+
}
|
|
639
|
+
result.push({ table: t, refs, refCount });
|
|
640
|
+
}
|
|
641
|
+
result.sort((a, b) => a.table.localeCompare(b.table));
|
|
642
|
+
return result;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Rows in `table` whose `column` equals `value` — used to show which rows in
|
|
647
|
+
* a referencing table point at a specific row via a foreign key — with the
|
|
648
|
+
* total matching count.
|
|
649
|
+
*/
|
|
650
|
+
async getRowsByFk(table, column, value, limit = 50) {
|
|
651
|
+
return this.tryRun(() => {
|
|
652
|
+
const n = Math.max(1, Math.min(500, Number(limit) || 50));
|
|
653
|
+
const countRow = this.db
|
|
654
|
+
.prepare(`SELECT COUNT(*) AS c FROM ${(0, generator_1.quoteIdentifier)(table)} WHERE ${(0, generator_1.quoteIdentifier)(column)} = ?`)
|
|
655
|
+
.get(value);
|
|
656
|
+
const rows = this.db
|
|
657
|
+
.prepare(`SELECT * FROM ${(0, generator_1.quoteIdentifier)(table)} WHERE ${(0, generator_1.quoteIdentifier)(column)} = ? LIMIT ${n}`)
|
|
658
|
+
.all(value);
|
|
659
|
+
return { rows, total: Number(countRow.c) };
|
|
660
|
+
});
|
|
661
|
+
}
|
|
429
662
|
async renameTable(oldName, newName) {
|
|
430
663
|
return this.execResult((0, generator_1.generateRenameTable)(oldName, newName));
|
|
431
664
|
}
|
|
@@ -447,42 +680,6 @@ class SqliteDatabase {
|
|
|
447
680
|
async dropIndex(indexName) {
|
|
448
681
|
return this.execResult((0, generator_1.generateDropIndex)(indexName));
|
|
449
682
|
}
|
|
450
|
-
// ---- Views & triggers -------------------------------------------------
|
|
451
|
-
async listViews() {
|
|
452
|
-
return this.tryRun(() => {
|
|
453
|
-
const rows = this.db
|
|
454
|
-
.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'view' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
455
|
-
.all();
|
|
456
|
-
return rows.map((r) => ({ name: r.name, sql: r.sql ?? null }));
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
/** Preview the first `limit` rows of a view (browse). */
|
|
460
|
-
async getViewRows(viewName, limit = 200) {
|
|
461
|
-
return this.tryRun(() => {
|
|
462
|
-
const n = Math.max(1, Math.min(1000, Number(limit) || 200));
|
|
463
|
-
return this.db.prepare(`SELECT * FROM ${(0, generator_1.quoteIdentifier)(viewName)} LIMIT ${n}`).all();
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
async createView(name, selectSql) {
|
|
467
|
-
return this.execResult((0, generator_1.generateCreateView)(name, selectSql));
|
|
468
|
-
}
|
|
469
|
-
async dropView(name) {
|
|
470
|
-
return this.execResult((0, generator_1.generateDropView)(name));
|
|
471
|
-
}
|
|
472
|
-
async listTriggers() {
|
|
473
|
-
return this.tryRun(() => {
|
|
474
|
-
const rows = this.db
|
|
475
|
-
.prepare('SELECT name, tbl_name AS "table", sql FROM sqlite_master WHERE type = \'trigger\' AND name NOT LIKE \'sqlite_%\' ORDER BY name')
|
|
476
|
-
.all();
|
|
477
|
-
return rows.map((r) => ({ name: r.name, table: r.table, sql: r.sql ?? null }));
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
async createTrigger(def) {
|
|
481
|
-
return this.execResult((0, generator_1.generateCreateTrigger)(def));
|
|
482
|
-
}
|
|
483
|
-
async dropTrigger(name) {
|
|
484
|
-
return this.execResult((0, generator_1.generateDropTrigger)(name));
|
|
485
|
-
}
|
|
486
683
|
getTableInfoSync(table) {
|
|
487
684
|
try {
|
|
488
685
|
const cols = this.db.prepare(`PRAGMA table_info(${(0, generator_1.quoteIdentifier)(table)})`).all();
|