@ix-xs/node-comfort 1.1.2 → 1.1.31
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 +255 -52
- package/package.json +6 -9
- package/src/SQLite.js +719 -174
- package/tsconfig.json +18 -0
- package/types/index.d.ts +120 -0
- package/types/index.d.ts.map +1 -0
- package/types/src/Checker.d.ts +145 -0
- package/types/src/Checker.d.ts.map +1 -0
- package/types/src/FS.d.ts +243 -0
- package/types/src/FS.d.ts.map +1 -0
- package/types/src/Logger.d.ts +35 -0
- package/types/src/Logger.d.ts.map +1 -0
- package/types/src/SQLite.d.ts +391 -0
- package/types/src/SQLite.d.ts.map +1 -0
- package/types/src/Utils.d.ts +124 -0
- package/types/src/Utils.d.ts.map +1 -0
package/src/SQLite.js
CHANGED
|
@@ -12,57 +12,78 @@ process.emitWarning = (warning, ...args) => {
|
|
|
12
12
|
) {
|
|
13
13
|
return;
|
|
14
14
|
}
|
|
15
|
+
|
|
15
16
|
return originalEmitWarning.call(process, warning, ...args);
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
const { DatabaseSync } = require("node:sqlite");
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
*
|
|
22
|
+
* A lightweight SQLite wrapper around Node.js built-in `node:sqlite` module.
|
|
23
|
+
*
|
|
24
|
+
* Goals:
|
|
25
|
+
* - Keep the API small and easy to learn.
|
|
26
|
+
* - Support common CRUD workflows.
|
|
27
|
+
* - Expose raw SQL helpers when advanced control is needed.
|
|
28
|
+
* - Support indexes, foreign keys, composite unique constraints, and transactions.
|
|
22
29
|
*
|
|
23
|
-
*
|
|
24
|
-
* -
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
27
|
-
* -
|
|
30
|
+
* Notes:
|
|
31
|
+
* - Values are safely bound with `?` placeholders.
|
|
32
|
+
* - Table names, column names, and index names cannot be parameterized in SQLite,
|
|
33
|
+
* so this class validates identifiers before interpolating them into SQL.
|
|
34
|
+
* - WAL mode and foreign keys are enabled automatically.
|
|
28
35
|
*
|
|
29
36
|
* @example
|
|
30
37
|
* const { SQLite } = require("@ix-xs/node-comfort");
|
|
31
|
-
* const db = new SQLite(
|
|
38
|
+
* const db = new SQLite("db/app.sqlite");
|
|
32
39
|
*
|
|
33
40
|
* await db.createTable({
|
|
34
|
-
* name: "
|
|
35
|
-
*
|
|
36
|
-
* id:
|
|
37
|
-
*
|
|
38
|
-
*
|
|
41
|
+
* name: "User",
|
|
42
|
+
* columns: {
|
|
43
|
+
* id: { type: "INTEGER", primaryKey: true, autoincrement: true },
|
|
44
|
+
* email: { type: "TEXT", unique: true },
|
|
45
|
+
* createdAt: { type: "INTEGER", notNull: true }
|
|
39
46
|
* },
|
|
47
|
+
* indexes: [
|
|
48
|
+
* { columns: ["email"], unique: true }
|
|
49
|
+
* ]
|
|
40
50
|
* });
|
|
41
51
|
*
|
|
42
|
-
* await db.
|
|
43
|
-
*
|
|
52
|
+
* await db.insert("User", {
|
|
53
|
+
* email: "john@example.com",
|
|
54
|
+
* createdAt: Date.now()
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* const user = await db.get("User", { email: "john@example.com" });
|
|
44
58
|
*/
|
|
45
59
|
module.exports = class SQLite {
|
|
46
60
|
/** @type {DatabaseSync} */
|
|
47
61
|
#$;
|
|
48
|
-
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* In-memory schema registry.
|
|
65
|
+
* @type {Map<string, {
|
|
66
|
+
* name: string,
|
|
67
|
+
* columns: Record<string, any>,
|
|
68
|
+
* constraints?: any[],
|
|
69
|
+
* indexes?: any[]
|
|
70
|
+
* }>}
|
|
71
|
+
*/
|
|
49
72
|
#models = new Map();
|
|
50
73
|
|
|
51
74
|
/**
|
|
52
|
-
* Creates a new SQLite
|
|
75
|
+
* Creates a new SQLite database connection.
|
|
53
76
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
77
|
+
* The database file and its parent folders are created if necessary.
|
|
78
|
+
* WAL journal mode and foreign key support are enabled automatically.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} [path] - Path to the SQLite database file.
|
|
56
81
|
*
|
|
57
82
|
* @example
|
|
58
|
-
* // Default path: "./db.sqlite"
|
|
59
83
|
* const db = new SQLite();
|
|
60
84
|
*
|
|
61
85
|
* @example
|
|
62
|
-
*
|
|
63
|
-
* const db = new SQLite("data/myapp.sqlite");
|
|
64
|
-
*
|
|
65
|
-
* @param {string} [path] - Path to the SQLite database file.
|
|
86
|
+
* const db = new SQLite("data/my-app.sqlite");
|
|
66
87
|
*/
|
|
67
88
|
constructor(path = fs.createPath("db.sqlite").replaceAll("\\", "/")) {
|
|
68
89
|
if (path.includes("/")) {
|
|
@@ -73,93 +94,89 @@ module.exports = class SQLite {
|
|
|
73
94
|
}
|
|
74
95
|
|
|
75
96
|
this.#$ = new DatabaseSync(path);
|
|
76
|
-
this.#
|
|
77
|
-
this.#
|
|
97
|
+
this.#run("PRAGMA journal_mode = WAL");
|
|
98
|
+
this.#run("PRAGMA foreign_keys = ON");
|
|
78
99
|
}
|
|
79
100
|
|
|
80
101
|
/**
|
|
81
|
-
*
|
|
102
|
+
* Ensures a SQL identifier is safe to interpolate.
|
|
103
|
+
*
|
|
104
|
+
* SQLite does not allow binding table or column names as parameters,
|
|
105
|
+
* so identifiers must be validated before being used in SQL strings.
|
|
106
|
+
*
|
|
107
|
+
* Allowed characters:
|
|
108
|
+
* - letters
|
|
109
|
+
* - digits
|
|
110
|
+
* - underscore
|
|
111
|
+
*
|
|
112
|
+
* The first character must be a letter or underscore.
|
|
113
|
+
*
|
|
82
114
|
* @private
|
|
83
|
-
* @param {string}
|
|
84
|
-
* @param {
|
|
85
|
-
* @returns {
|
|
115
|
+
* @param {string} name - Identifier to validate.
|
|
116
|
+
* @param {string} [kind="identifier"] - Human-readable identifier type for error messages.
|
|
117
|
+
* @returns {string}
|
|
118
|
+
* @throws {Error} If the identifier is invalid.
|
|
86
119
|
*/
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
: stmt.run(params);
|
|
120
|
+
#assertIdentifier(name, kind = "identifier") {
|
|
121
|
+
if (typeof name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
122
|
+
throw new Error(`Invalid ${kind}: "${name}"`);
|
|
123
|
+
}
|
|
92
124
|
|
|
93
|
-
return
|
|
94
|
-
changes: result.changes,
|
|
95
|
-
lastInsertRowid: result.lastInsertRowid,
|
|
96
|
-
};
|
|
125
|
+
return name;
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
/**
|
|
100
|
-
*
|
|
129
|
+
* Converts a JavaScript value into a SQL literal.
|
|
101
130
|
*
|
|
102
|
-
*
|
|
131
|
+
* This is only used for SQL fragments that cannot be parameter-bound,
|
|
132
|
+
* such as `DEFAULT`, `CHECK`, or migration-time SQL generation.
|
|
103
133
|
*
|
|
104
134
|
* @private
|
|
105
|
-
* @param {
|
|
106
|
-
* @param {Record<string, {
|
|
107
|
-
* type: "INTEGER"|"TEXT"|"REAL"|"BLOB"|"NUMERIC",
|
|
108
|
-
* primaryKey?: boolean,
|
|
109
|
-
* autoincrement?: boolean,
|
|
110
|
-
* notNull?: boolean,
|
|
111
|
-
* unique?: boolean,
|
|
112
|
-
* defaultValue?: any,
|
|
113
|
-
* values?: any[]
|
|
114
|
-
* }>} options
|
|
135
|
+
* @param {any} value - Value to convert.
|
|
115
136
|
* @returns {string}
|
|
116
137
|
*/
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
if (def.notNull) colDef += " NOT NULL";
|
|
127
|
-
if (def.unique) colDef += " UNIQUE";
|
|
128
|
-
if (def.defaultValue !== undefined) {
|
|
129
|
-
colDef += ` DEFAULT ${this.#toSQLValue(def.defaultValue)}`;
|
|
130
|
-
}
|
|
131
|
-
if (def.values?.length) {
|
|
132
|
-
colDef += ` CHECK(${col} IN (${def.values
|
|
133
|
-
.map((v) => this.#toSQLValue(v))
|
|
134
|
-
.join(", ")}))`;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return colDef;
|
|
138
|
-
})
|
|
139
|
-
.join(", ");
|
|
140
|
-
|
|
141
|
-
return `CREATE TABLE IF NOT EXISTS ${tableName} (${columns})`;
|
|
138
|
+
#toSQLValue(value) {
|
|
139
|
+
if (value === null) return "NULL";
|
|
140
|
+
if (typeof value === "number") return String(value);
|
|
141
|
+
if (typeof value === "bigint") return String(value);
|
|
142
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
143
|
+
if (typeof value === "string") return `'${value.replace(/'/g, "''")}'`;
|
|
144
|
+
|
|
145
|
+
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
/**
|
|
145
|
-
*
|
|
149
|
+
* Executes a SQL statement that modifies the database.
|
|
150
|
+
*
|
|
151
|
+
* This is the internal low-level helper used by mutation methods.
|
|
152
|
+
*
|
|
146
153
|
* @private
|
|
147
|
-
* @param {string}
|
|
148
|
-
* @param {
|
|
154
|
+
* @param {string} sql - SQL statement.
|
|
155
|
+
* @param {any[]|any} [params=[]] - Parameters to bind.
|
|
156
|
+
* @returns {{ changes: number, lastInsertRowid: bigint|number }}
|
|
149
157
|
*/
|
|
150
|
-
#
|
|
151
|
-
|
|
158
|
+
#run(sql, params = []) {
|
|
159
|
+
const stmt = this.#$.prepare(sql);
|
|
160
|
+
const result = checker.isArray(params) ? stmt.run(...params) : stmt.run(params);
|
|
152
161
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
162
|
+
return {
|
|
163
|
+
changes: result.changes,
|
|
164
|
+
lastInsertRowid: result.lastInsertRowid,
|
|
165
|
+
};
|
|
156
166
|
}
|
|
157
167
|
|
|
158
168
|
/**
|
|
159
|
-
* Builds a WHERE clause
|
|
160
|
-
*
|
|
169
|
+
* Builds a `WHERE` clause from a plain object.
|
|
170
|
+
*
|
|
171
|
+
* Example:
|
|
172
|
+
* `{ id: 1, role: "admin" }`
|
|
173
|
+
* becomes:
|
|
174
|
+
* `WHERE id = ? AND role = ?`
|
|
175
|
+
*
|
|
176
|
+
* If the object is empty, an empty WHERE clause is returned.
|
|
177
|
+
*
|
|
161
178
|
* @private
|
|
162
|
-
* @param {Record<string, any>} [where]
|
|
179
|
+
* @param {Record<string, any>} [where={}] - Conditions.
|
|
163
180
|
* @returns {{ where: string, params: any[] }}
|
|
164
181
|
*/
|
|
165
182
|
#buildWhere(where = {}) {
|
|
@@ -171,6 +188,13 @@ module.exports = class SQLite {
|
|
|
171
188
|
const params = [];
|
|
172
189
|
|
|
173
190
|
for (const [key, value] of Object.entries(where)) {
|
|
191
|
+
this.#assertIdentifier(key, "column name");
|
|
192
|
+
|
|
193
|
+
if (value === null) {
|
|
194
|
+
conditions.push(`${key} IS NULL`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
174
198
|
conditions.push(`${key} = ?`);
|
|
175
199
|
params.push(value);
|
|
176
200
|
}
|
|
@@ -182,61 +206,388 @@ module.exports = class SQLite {
|
|
|
182
206
|
}
|
|
183
207
|
|
|
184
208
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
209
|
+
* Builds the SQL fragment for a column definition.
|
|
210
|
+
*
|
|
211
|
+
* Supported column properties:
|
|
212
|
+
* - type
|
|
213
|
+
* - primaryKey
|
|
214
|
+
* - autoincrement
|
|
215
|
+
* - notNull
|
|
216
|
+
* - unique
|
|
217
|
+
* - defaultValue
|
|
218
|
+
* - values
|
|
219
|
+
* - references
|
|
220
|
+
* - onDelete
|
|
221
|
+
* - onUpdate
|
|
222
|
+
*
|
|
223
|
+
* @private
|
|
224
|
+
* @param {string} columnName - Column name.
|
|
225
|
+
* @param {Record<string, any>} def - Column definition.
|
|
226
|
+
* @returns {string}
|
|
227
|
+
*/
|
|
228
|
+
#buildColumnSQL(columnName, def) {
|
|
229
|
+
this.#assertIdentifier(columnName, "column name");
|
|
230
|
+
|
|
231
|
+
if (!def?.type) {
|
|
232
|
+
throw new Error(`Missing "type" for column "${columnName}"`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let colDef = `${columnName} ${def.type}`;
|
|
236
|
+
|
|
237
|
+
if (def.primaryKey) {
|
|
238
|
+
colDef += " PRIMARY KEY";
|
|
239
|
+
if (def.autoincrement) colDef += " AUTOINCREMENT";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (def.notNull) colDef += " NOT NULL";
|
|
243
|
+
if (def.unique) colDef += " UNIQUE";
|
|
244
|
+
|
|
245
|
+
if (def.defaultValue !== undefined) {
|
|
246
|
+
colDef += ` DEFAULT ${this.#toSQLValue(def.defaultValue)}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (def.values?.length) {
|
|
250
|
+
colDef += ` CHECK(${columnName} IN (${def.values
|
|
251
|
+
.map((value) => this.#toSQLValue(value))
|
|
252
|
+
.join(", ")}))`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (def.references) {
|
|
256
|
+
this.#assertIdentifier(def.references.table, "referenced table");
|
|
257
|
+
this.#assertIdentifier(def.references.column, "referenced column");
|
|
258
|
+
|
|
259
|
+
colDef += ` REFERENCES ${def.references.table}(${def.references.column})`;
|
|
260
|
+
|
|
261
|
+
if (def.onDelete) colDef += ` ON DELETE ${def.onDelete}`;
|
|
262
|
+
if (def.onUpdate) colDef += ` ON UPDATE ${def.onUpdate}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return colDef;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Builds a table-level constraint SQL fragment.
|
|
270
|
+
*
|
|
271
|
+
* Supported constraint formats:
|
|
272
|
+
* - `{ type: "unique", columns: ["email", "provider"] }`
|
|
273
|
+
* - `{ type: "primaryKey", columns: ["a", "b"] }`
|
|
274
|
+
* - `{ type: "check", expression: "age >= 0" }`
|
|
275
|
+
* - `{ type: "foreignKey", columns: ["userId"], references: { table: "User", columns: ["id"] }, onDelete: "CASCADE" }`
|
|
276
|
+
*
|
|
277
|
+
* @private
|
|
278
|
+
* @param {Record<string, any>} constraint - Constraint definition.
|
|
279
|
+
* @returns {string}
|
|
280
|
+
*/
|
|
281
|
+
#buildConstraintSQL(constraint) {
|
|
282
|
+
if (!constraint?.type) {
|
|
283
|
+
throw new Error("Table constraint requires a \"type\" property");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (constraint.type === "unique") {
|
|
287
|
+
const columns = (constraint.columns || []).map((col) =>
|
|
288
|
+
this.#assertIdentifier(col, "column name"),
|
|
289
|
+
);
|
|
290
|
+
if (!columns.length) throw new Error("UNIQUE constraint requires columns");
|
|
291
|
+
return `UNIQUE (${columns.join(", ")})`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (constraint.type === "primaryKey") {
|
|
295
|
+
const columns = (constraint.columns || []).map((col) =>
|
|
296
|
+
this.#assertIdentifier(col, "column name"),
|
|
297
|
+
);
|
|
298
|
+
if (!columns.length) throw new Error("PRIMARY KEY constraint requires columns");
|
|
299
|
+
return `PRIMARY KEY (${columns.join(", ")})`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (constraint.type === "check") {
|
|
303
|
+
if (!constraint.expression || typeof constraint.expression !== "string") {
|
|
304
|
+
throw new Error("CHECK constraint requires an expression");
|
|
305
|
+
}
|
|
306
|
+
return `CHECK (${constraint.expression})`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (constraint.type === "foreignKey") {
|
|
310
|
+
const columns = (constraint.columns || []).map((col) =>
|
|
311
|
+
this.#assertIdentifier(col, "column name"),
|
|
312
|
+
);
|
|
313
|
+
const refColumns = (constraint.references?.columns || []).map((col) =>
|
|
314
|
+
this.#assertIdentifier(col, "referenced column"),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
if (!columns.length) throw new Error("FOREIGN KEY constraint requires columns");
|
|
318
|
+
if (!constraint.references?.table) {
|
|
319
|
+
throw new Error("FOREIGN KEY constraint requires references.table");
|
|
320
|
+
}
|
|
321
|
+
if (!refColumns.length) {
|
|
322
|
+
throw new Error("FOREIGN KEY constraint requires references.columns");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
this.#assertIdentifier(constraint.references.table, "referenced table");
|
|
326
|
+
|
|
327
|
+
let sql = `FOREIGN KEY (${columns.join(", ")}) REFERENCES ${constraint.references.table} (${refColumns.join(", ")})`;
|
|
328
|
+
|
|
329
|
+
if (constraint.onDelete) sql += ` ON DELETE ${constraint.onDelete}`;
|
|
330
|
+
if (constraint.onUpdate) sql += ` ON UPDATE ${constraint.onUpdate}`;
|
|
331
|
+
|
|
332
|
+
return sql;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
throw new Error(`Unsupported constraint type "${constraint.type}"`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Builds a complete `CREATE TABLE` statement.
|
|
340
|
+
*
|
|
341
|
+
* @private
|
|
342
|
+
* @param {string} tableName - Table name.
|
|
343
|
+
* @param {Record<string, any>} columns - Column definitions.
|
|
344
|
+
* @param {Record<string, any>[]} [constraints=[]] - Table-level constraints.
|
|
345
|
+
* @returns {string}
|
|
346
|
+
*/
|
|
347
|
+
#buildCreateSQL(tableName, columns, constraints = []) {
|
|
348
|
+
this.#assertIdentifier(tableName, "table name");
|
|
349
|
+
|
|
350
|
+
const columnDefs = Object.entries(columns).map(([columnName, def]) =>
|
|
351
|
+
this.#buildColumnSQL(columnName, def),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const tableConstraints = constraints.map((constraint) =>
|
|
355
|
+
this.#buildConstraintSQL(constraint),
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
return `CREATE TABLE IF NOT EXISTS ${tableName} (${[
|
|
359
|
+
...columnDefs,
|
|
360
|
+
...tableConstraints,
|
|
361
|
+
].join(", ")})`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Returns the default `ORDER BY` clause for a table.
|
|
366
|
+
*
|
|
367
|
+
* The first declared column is used as default. If the model is unknown,
|
|
368
|
+
* `"id DESC"` is used as a fallback.
|
|
369
|
+
*
|
|
187
370
|
* @private
|
|
188
|
-
* @param {string} tableName
|
|
371
|
+
* @param {string} tableName - Table name.
|
|
189
372
|
* @returns {string}
|
|
190
373
|
*/
|
|
191
374
|
#getOrderBy(tableName) {
|
|
192
375
|
const model = this.#models.get(tableName);
|
|
193
376
|
if (!model) return "id DESC";
|
|
194
|
-
|
|
377
|
+
|
|
378
|
+
const firstCol = Object.keys(model.columns)[0];
|
|
195
379
|
return `${firstCol} DESC`;
|
|
196
380
|
}
|
|
197
381
|
|
|
198
382
|
/**
|
|
199
|
-
*
|
|
383
|
+
* Registers a table model and creates the table if necessary.
|
|
384
|
+
*
|
|
385
|
+
* If the model already exists in memory, nothing is done.
|
|
386
|
+
*
|
|
200
387
|
* @private
|
|
201
|
-
* @param {
|
|
202
|
-
*
|
|
388
|
+
* @param {{
|
|
389
|
+
* name: string,
|
|
390
|
+
* columns: Record<string, any>,
|
|
391
|
+
* constraints?: Record<string, any>[],
|
|
392
|
+
* indexes?: Record<string, any>[]
|
|
393
|
+
* }} table - Table definition.
|
|
203
394
|
*/
|
|
204
|
-
#
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
395
|
+
#defineModel(table) {
|
|
396
|
+
if (this.#models.has(table.name)) return;
|
|
397
|
+
|
|
398
|
+
const createSQL = this.#buildCreateSQL(
|
|
399
|
+
table.name,
|
|
400
|
+
table.columns,
|
|
401
|
+
table.constraints || [],
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
this.#run(createSQL);
|
|
405
|
+
this.#models.set(table.name, {
|
|
406
|
+
name: table.name,
|
|
407
|
+
columns: table.columns,
|
|
408
|
+
constraints: table.constraints || [],
|
|
409
|
+
indexes: table.indexes || [],
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
for (const index of table.indexes || []) {
|
|
413
|
+
this.createIndex(table.name, index);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
210
416
|
|
|
211
|
-
|
|
417
|
+
/**
|
|
418
|
+
* Executes a raw SQL statement using `run()`.
|
|
419
|
+
*
|
|
420
|
+
* Use this for custom SQL that is not covered by the helper methods.
|
|
421
|
+
* This method is intended for statements such as:
|
|
422
|
+
* - `INSERT`
|
|
423
|
+
* - `UPDATE`
|
|
424
|
+
* - `DELETE`
|
|
425
|
+
* - `CREATE INDEX`
|
|
426
|
+
* - PRAGMA writes
|
|
427
|
+
*
|
|
428
|
+
* @param {string} sql - SQL statement.
|
|
429
|
+
* @param {any[]|any} [params=[]] - Parameters to bind.
|
|
430
|
+
* @returns {Promise<{ changes: number, lastInsertRowid: bigint|number } | { error: string }>}
|
|
431
|
+
*/
|
|
432
|
+
async exec(sql, params = []) {
|
|
433
|
+
try {
|
|
434
|
+
return this.#run(sql, params);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return { error: error.message };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Executes a raw SQL query and returns the first row.
|
|
442
|
+
*
|
|
443
|
+
* @param {string} sql - SQL query.
|
|
444
|
+
* @param {any[]|any} [params=[]] - Parameters to bind.
|
|
445
|
+
* @returns {Promise<object|undefined|{ error: string }>}
|
|
446
|
+
*/
|
|
447
|
+
async queryOne(sql, params = []) {
|
|
448
|
+
try {
|
|
449
|
+
const stmt = this.#$.prepare(sql);
|
|
450
|
+
const row = checker.isArray(params) ? stmt.get(...params) : stmt.get(params);
|
|
451
|
+
return row || undefined;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
return { error: error.message };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Executes a raw SQL query and returns all rows.
|
|
459
|
+
*
|
|
460
|
+
* @param {string} sql - SQL query.
|
|
461
|
+
* @param {any[]|any} [params=[]] - Parameters to bind.
|
|
462
|
+
* @returns {Promise<object[]|{ error: string }>}
|
|
463
|
+
*/
|
|
464
|
+
async queryAll(sql, params = []) {
|
|
465
|
+
try {
|
|
466
|
+
const stmt = this.#$.prepare(sql);
|
|
467
|
+
return checker.isArray(params) ? stmt.all(...params) : stmt.all(params);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
return { error: error.message };
|
|
470
|
+
}
|
|
212
471
|
}
|
|
213
472
|
|
|
214
473
|
/**
|
|
215
|
-
* Creates
|
|
474
|
+
* Creates a table and stores its schema in memory.
|
|
475
|
+
*
|
|
476
|
+
* This method supports both the new `columns` property and the legacy
|
|
477
|
+
* `options` property for backward compatibility.
|
|
478
|
+
*
|
|
479
|
+
* @param {{
|
|
480
|
+
* name: string,
|
|
481
|
+
* columns?: Record<string, {
|
|
482
|
+
* type: "INTEGER"|"TEXT"|"REAL"|"BLOB"|"NUMERIC",
|
|
483
|
+
* primaryKey?: boolean,
|
|
484
|
+
* autoincrement?: boolean,
|
|
485
|
+
* notNull?: boolean,
|
|
486
|
+
* unique?: boolean,
|
|
487
|
+
* defaultValue?: any,
|
|
488
|
+
* values?: any[],
|
|
489
|
+
* references?: { table: string, column: string },
|
|
490
|
+
* onDelete?: string,
|
|
491
|
+
* onUpdate?: string
|
|
492
|
+
* }>,
|
|
493
|
+
* options?: Record<string, any>,
|
|
494
|
+
* constraints?: Array<
|
|
495
|
+
* | { type: "unique", columns: string[] }
|
|
496
|
+
* | { type: "primaryKey", columns: string[] }
|
|
497
|
+
* | { type: "check", expression: string }
|
|
498
|
+
* | {
|
|
499
|
+
* type: "foreignKey",
|
|
500
|
+
* columns: string[],
|
|
501
|
+
* references: { table: string, columns: string[] },
|
|
502
|
+
* onDelete?: string,
|
|
503
|
+
* onUpdate?: string
|
|
504
|
+
* }
|
|
505
|
+
* >,
|
|
506
|
+
* indexes?: Array<{
|
|
507
|
+
* name?: string,
|
|
508
|
+
* columns: string[],
|
|
509
|
+
* unique?: boolean
|
|
510
|
+
* }>
|
|
511
|
+
* }} table - Table definition.
|
|
512
|
+
* @returns {Promise<{ ok: true } | { error: string }>}
|
|
216
513
|
*
|
|
217
514
|
* @example
|
|
218
515
|
* await db.createTable({
|
|
219
|
-
* name: "
|
|
220
|
-
*
|
|
221
|
-
* id:
|
|
222
|
-
*
|
|
516
|
+
* name: "Post",
|
|
517
|
+
* columns: {
|
|
518
|
+
* id: { type: "INTEGER", primaryKey: true, autoincrement: true },
|
|
519
|
+
* userId: {
|
|
520
|
+
* type: "INTEGER",
|
|
521
|
+
* notNull: true,
|
|
522
|
+
* references: { table: "User", column: "id" },
|
|
523
|
+
* onDelete: "CASCADE"
|
|
524
|
+
* },
|
|
525
|
+
* title: { type: "TEXT", notNull: true }
|
|
223
526
|
* },
|
|
527
|
+
* indexes: [{ columns: ["userId"] }]
|
|
224
528
|
* });
|
|
529
|
+
*/
|
|
530
|
+
async createTable(table) {
|
|
531
|
+
try {
|
|
532
|
+
const columns = table.columns || table.options;
|
|
533
|
+
|
|
534
|
+
if (!columns) {
|
|
535
|
+
return { error: "Missing \"columns\" (or legacy \"options\") in table definition" };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
this.#defineModel({
|
|
539
|
+
name: table.name,
|
|
540
|
+
columns,
|
|
541
|
+
constraints: table.constraints || [],
|
|
542
|
+
indexes: table.indexes || [],
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
return { ok: true };
|
|
546
|
+
} catch (error) {
|
|
547
|
+
return { error: error.message };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Creates an index on an existing table.
|
|
225
553
|
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
* }> }} table
|
|
554
|
+
* Composite indexes are supported by passing multiple columns.
|
|
555
|
+
*
|
|
556
|
+
* @param {string} tableName - Table name.
|
|
557
|
+
* @param {{
|
|
558
|
+
* name?: string,
|
|
559
|
+
* columns: string[],
|
|
560
|
+
* unique?: boolean
|
|
561
|
+
* }} index - Index definition.
|
|
235
562
|
* @returns {Promise<{ ok: true } | { error: string }>}
|
|
563
|
+
*
|
|
564
|
+
* @example
|
|
565
|
+
* await db.createIndex("UserAuthMethod", {
|
|
566
|
+
* columns: ["provider", "providerUserId"],
|
|
567
|
+
* unique: true
|
|
568
|
+
* });
|
|
236
569
|
*/
|
|
237
|
-
async
|
|
570
|
+
async createIndex(tableName, index) {
|
|
238
571
|
try {
|
|
239
|
-
this.#
|
|
572
|
+
this.#assertIdentifier(tableName, "table name");
|
|
573
|
+
|
|
574
|
+
const columns = (index.columns || []).map((column) =>
|
|
575
|
+
this.#assertIdentifier(column, "column name"),
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
if (!columns.length) {
|
|
579
|
+
return { error: "Index requires a non-empty \"columns\" array" };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const indexName =
|
|
583
|
+
index.name ||
|
|
584
|
+
`idx_${tableName}_${columns.join("_")}${index.unique ? "_unique" : ""}`;
|
|
585
|
+
|
|
586
|
+
this.#assertIdentifier(indexName, "index name");
|
|
587
|
+
|
|
588
|
+
const sql = `CREATE ${index.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${columns.join(", ")})`;
|
|
589
|
+
this.#run(sql);
|
|
590
|
+
|
|
240
591
|
return { ok: true };
|
|
241
592
|
} catch (error) {
|
|
242
593
|
return { error: error.message };
|
|
@@ -246,7 +597,7 @@ module.exports = class SQLite {
|
|
|
246
597
|
/**
|
|
247
598
|
* Drops a table and removes its model definition.
|
|
248
599
|
*
|
|
249
|
-
* @param {string} tableName
|
|
600
|
+
* @param {string} tableName - Table name.
|
|
250
601
|
* @returns {Promise<{ ok: true } | { error: string }>}
|
|
251
602
|
*/
|
|
252
603
|
async deleteTable(tableName) {
|
|
@@ -255,8 +606,10 @@ module.exports = class SQLite {
|
|
|
255
606
|
return { error: `Table ${tableName} doesn't exist` };
|
|
256
607
|
}
|
|
257
608
|
|
|
258
|
-
this.#
|
|
609
|
+
this.#assertIdentifier(tableName, "table name");
|
|
610
|
+
this.#run(`DROP TABLE IF EXISTS ${tableName}`);
|
|
259
611
|
this.#models.delete(tableName);
|
|
612
|
+
|
|
260
613
|
return { ok: true };
|
|
261
614
|
} catch (error) {
|
|
262
615
|
return { error: error.message };
|
|
@@ -266,7 +619,7 @@ module.exports = class SQLite {
|
|
|
266
619
|
/**
|
|
267
620
|
* Deletes all rows from a table.
|
|
268
621
|
*
|
|
269
|
-
* @param {string} tableName
|
|
622
|
+
* @param {string} tableName - Table name.
|
|
270
623
|
* @returns {Promise<{ ok: true } | { error: string }>}
|
|
271
624
|
*/
|
|
272
625
|
async clearTable(tableName) {
|
|
@@ -275,7 +628,9 @@ module.exports = class SQLite {
|
|
|
275
628
|
return { error: `Table ${tableName} doesn't exist` };
|
|
276
629
|
}
|
|
277
630
|
|
|
278
|
-
this.#
|
|
631
|
+
this.#assertIdentifier(tableName, "table name");
|
|
632
|
+
this.#run(`DELETE FROM ${tableName}`);
|
|
633
|
+
|
|
279
634
|
return { ok: true };
|
|
280
635
|
} catch (error) {
|
|
281
636
|
return { error: error.message };
|
|
@@ -283,21 +638,18 @@ module.exports = class SQLite {
|
|
|
283
638
|
}
|
|
284
639
|
|
|
285
640
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* Returns:
|
|
289
|
-
* - `undefined` if no row matches,
|
|
290
|
-
* - the row object if found,
|
|
291
|
-
* - `{ error }` if a database error occurred.
|
|
641
|
+
* Returns a single row matching a plain-object WHERE clause.
|
|
292
642
|
*
|
|
293
|
-
* @param {string} tableName
|
|
294
|
-
* @param {Record<string, any>} where
|
|
643
|
+
* @param {string} tableName - Table name.
|
|
644
|
+
* @param {Record<string, any>} [where={}] - Conditions.
|
|
295
645
|
* @returns {Promise<object|undefined|{ error: string }>}
|
|
296
646
|
*/
|
|
297
|
-
async get(tableName, where) {
|
|
647
|
+
async get(tableName, where = {}) {
|
|
298
648
|
try {
|
|
299
649
|
if (!this.#models.has(tableName)) return undefined;
|
|
300
650
|
|
|
651
|
+
this.#assertIdentifier(tableName, "table name");
|
|
652
|
+
|
|
301
653
|
const whereClause = this.#buildWhere(where);
|
|
302
654
|
const sql = `SELECT * FROM ${tableName} ${whereClause.where} LIMIT 1`;
|
|
303
655
|
const stmt = this.#$.prepare(sql);
|
|
@@ -310,29 +662,50 @@ module.exports = class SQLite {
|
|
|
310
662
|
}
|
|
311
663
|
|
|
312
664
|
/**
|
|
313
|
-
*
|
|
665
|
+
* Returns multiple rows from a table.
|
|
314
666
|
*
|
|
315
|
-
*
|
|
316
|
-
* -
|
|
317
|
-
* -
|
|
318
|
-
* -
|
|
667
|
+
* Supports:
|
|
668
|
+
* - exact-match filters via `where`
|
|
669
|
+
* - custom ordering
|
|
670
|
+
* - pagination via `limit` and `offset`
|
|
319
671
|
*
|
|
320
|
-
* @param {string} tableName
|
|
321
|
-
* @param {Record<string, any>} [where={}]
|
|
322
|
-
* @param {
|
|
323
|
-
*
|
|
672
|
+
* @param {string} tableName - Table name.
|
|
673
|
+
* @param {Record<string, any>} [where={}] - Exact-match conditions.
|
|
674
|
+
* @param {{
|
|
675
|
+
* limit?: number,
|
|
676
|
+
* offset?: number,
|
|
677
|
+
* orderBy?: string,
|
|
678
|
+
* direction?: "ASC"|"DESC"
|
|
679
|
+
* }} [options={}] - Query options.
|
|
324
680
|
* @returns {Promise<object[] | { error: string }>}
|
|
681
|
+
*
|
|
682
|
+
* @example
|
|
683
|
+
* const posts = await db.getAll("Post", { userId: 1 }, {
|
|
684
|
+
* limit: 20,
|
|
685
|
+
* offset: 0,
|
|
686
|
+
* orderBy: "createdAt",
|
|
687
|
+
* direction: "DESC"
|
|
688
|
+
* });
|
|
325
689
|
*/
|
|
326
|
-
async getAll(tableName, where = {},
|
|
690
|
+
async getAll(tableName, where = {}, options = {}) {
|
|
327
691
|
try {
|
|
328
692
|
if (!this.#models.has(tableName)) return [];
|
|
329
693
|
|
|
694
|
+
this.#assertIdentifier(tableName, "table name");
|
|
695
|
+
|
|
330
696
|
const whereClause = this.#buildWhere(where);
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
697
|
+
const limit = Number.isInteger(options.limit) ? options.limit : 50;
|
|
698
|
+
const offset = Number.isInteger(options.offset) ? options.offset : 0;
|
|
699
|
+
const direction = options.direction === "ASC" ? "ASC" : "DESC";
|
|
700
|
+
|
|
701
|
+
const orderBy = options.orderBy
|
|
702
|
+
? this.#assertIdentifier(options.orderBy, "column name")
|
|
703
|
+
: this.#getOrderBy(tableName).split(" ")[0];
|
|
704
|
+
|
|
705
|
+
const sql = `SELECT * FROM ${tableName} ${whereClause.where} ORDER BY ${orderBy} ${direction} LIMIT ? OFFSET ?`;
|
|
334
706
|
const stmt = this.#$.prepare(sql);
|
|
335
707
|
const records = stmt.all(...whereClause.params, limit, offset);
|
|
708
|
+
|
|
336
709
|
return records;
|
|
337
710
|
} catch (error) {
|
|
338
711
|
return { error: error.message };
|
|
@@ -340,17 +713,147 @@ module.exports = class SQLite {
|
|
|
340
713
|
}
|
|
341
714
|
|
|
342
715
|
/**
|
|
343
|
-
* Inserts
|
|
716
|
+
* Inserts a new row into a table.
|
|
717
|
+
*
|
|
718
|
+
* This method performs a plain `INSERT`.
|
|
719
|
+
* If a unique constraint is violated, SQLite will throw an error.
|
|
720
|
+
*
|
|
721
|
+
* @param {string} tableName - Table name.
|
|
722
|
+
* @param {Record<string, any>} data - Row to insert.
|
|
723
|
+
* @returns {Promise<{ ok: true, lastInsertRowid: bigint|number, changes: number } | { error: string }>}
|
|
724
|
+
*/
|
|
725
|
+
async insert(tableName, data) {
|
|
726
|
+
try {
|
|
727
|
+
if (!this.#models.has(tableName)) {
|
|
728
|
+
return { error: `Table ${tableName} doesn't exist` };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
this.#assertIdentifier(tableName, "table name");
|
|
732
|
+
|
|
733
|
+
const columns = Object.keys(data);
|
|
734
|
+
if (!columns.length) return { error: "Insert data cannot be empty" };
|
|
735
|
+
|
|
736
|
+
columns.forEach((column) => this.#assertIdentifier(column, "column name"));
|
|
737
|
+
|
|
738
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
739
|
+
const sql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
|
|
740
|
+
|
|
741
|
+
const result = this.#run(sql, Object.values(data));
|
|
742
|
+
|
|
743
|
+
return { ok: true, ...result };
|
|
744
|
+
} catch (error) {
|
|
745
|
+
return { error: error.message };
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Updates one or more rows matching a WHERE clause.
|
|
751
|
+
*
|
|
752
|
+
* This method performs a plain `UPDATE`.
|
|
753
|
+
*
|
|
754
|
+
* @param {string} tableName - Table name.
|
|
755
|
+
* @param {Record<string, any>} data - Fields to update.
|
|
756
|
+
* @param {Record<string, any>} where - Conditions used to select rows.
|
|
757
|
+
* @returns {Promise<{ ok: true, changes: number } | { error: string }>}
|
|
758
|
+
*/
|
|
759
|
+
async update(tableName, data, where) {
|
|
760
|
+
try {
|
|
761
|
+
if (!this.#models.has(tableName)) {
|
|
762
|
+
return { error: `Table ${tableName} doesn't exist` };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (!where || Object.keys(where).length === 0) {
|
|
766
|
+
return { error: "Update requires a non-empty where clause" };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
this.#assertIdentifier(tableName, "table name");
|
|
770
|
+
|
|
771
|
+
const columns = Object.keys(data);
|
|
772
|
+
if (!columns.length) return { error: "Update data cannot be empty" };
|
|
773
|
+
|
|
774
|
+
columns.forEach((column) => this.#assertIdentifier(column, "column name"));
|
|
775
|
+
|
|
776
|
+
const setClause = columns.map((column) => `${column} = ?`).join(", ");
|
|
777
|
+
const whereClause = this.#buildWhere(where);
|
|
778
|
+
|
|
779
|
+
const sql = `UPDATE ${tableName} SET ${setClause} ${whereClause.where}`;
|
|
780
|
+
const result = this.#run(sql, [...Object.values(data), ...whereClause.params]);
|
|
781
|
+
|
|
782
|
+
return { ok: true, changes: result.changes };
|
|
783
|
+
} catch (error) {
|
|
784
|
+
return { error: error.message };
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Inserts or updates a row using SQLite `ON CONFLICT DO UPDATE`.
|
|
790
|
+
*
|
|
791
|
+
* This is safer than `INSERT OR REPLACE` because it does not delete
|
|
792
|
+
* and recreate the row.
|
|
344
793
|
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
794
|
+
* @param {string} tableName - Table name.
|
|
795
|
+
* @param {Record<string, any>} data - Row data.
|
|
796
|
+
* @param {string[]} conflict - Columns that define the conflict target.
|
|
797
|
+
* @returns {Promise<{ ok: true, changes: number, lastInsertRowid: bigint|number } | { error: string }>}
|
|
347
798
|
*
|
|
348
799
|
* @example
|
|
349
|
-
* await db.
|
|
800
|
+
* await db.upsert("UserAuthMethod", {
|
|
801
|
+
* userId: 1,
|
|
802
|
+
* provider: "google",
|
|
803
|
+
* providerUserId: "123"
|
|
804
|
+
* }, ["provider", "providerUserId"]);
|
|
805
|
+
*/
|
|
806
|
+
async upsert(tableName, data, conflict = []) {
|
|
807
|
+
try {
|
|
808
|
+
if (!this.#models.has(tableName)) {
|
|
809
|
+
return { error: `Table ${tableName} doesn't exist` };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
this.#assertIdentifier(tableName, "table name");
|
|
813
|
+
|
|
814
|
+
const columns = Object.keys(data);
|
|
815
|
+
if (!columns.length) return { error: "Upsert data cannot be empty" };
|
|
816
|
+
if (!conflict.length) return { error: "Upsert requires at least one conflict column" };
|
|
817
|
+
|
|
818
|
+
columns.forEach((column) => this.#assertIdentifier(column, "column name"));
|
|
819
|
+
conflict.forEach((column) => this.#assertIdentifier(column, "column name"));
|
|
820
|
+
|
|
821
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
822
|
+
const updateColumns = columns.filter((column) => !conflict.includes(column));
|
|
823
|
+
|
|
824
|
+
const updateClause = updateColumns.length
|
|
825
|
+
? updateColumns.map((column) => `${column} = excluded.${column}`).join(", ")
|
|
826
|
+
: `${conflict[0]} = ${conflict[0]}`;
|
|
827
|
+
|
|
828
|
+
const sql = `
|
|
829
|
+
INSERT INTO ${tableName} (${columns.join(", ")})
|
|
830
|
+
VALUES (${placeholders})
|
|
831
|
+
ON CONFLICT (${conflict.join(", ")})
|
|
832
|
+
DO UPDATE SET ${updateClause}
|
|
833
|
+
`.trim();
|
|
834
|
+
|
|
835
|
+
const result = this.#run(sql, Object.values(data));
|
|
836
|
+
return { ok: true, ...result };
|
|
837
|
+
} catch (error) {
|
|
838
|
+
return { error: error.message };
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Backward-compatible helper that inserts or updates a row.
|
|
844
|
+
*
|
|
845
|
+
* Behavior:
|
|
846
|
+
* - If `where` is provided, the existing row is loaded and merged with `data`,
|
|
847
|
+
* then updated if found or inserted if not found.
|
|
848
|
+
* - If `where` is not provided but `data.id` exists, lookup is performed on `id`.
|
|
849
|
+
* - If no existing row is found, a new row is inserted.
|
|
850
|
+
*
|
|
851
|
+
* This method is intentionally simple, but for production-grade control,
|
|
852
|
+
* prefer using `insert()`, `update()`, or `upsert()`.
|
|
350
853
|
*
|
|
351
|
-
* @param {string} tableName
|
|
352
|
-
* @param {Record<string, any>} data
|
|
353
|
-
* @param {Record<string, any>|null} [where=null]
|
|
854
|
+
* @param {string} tableName - Table name.
|
|
855
|
+
* @param {Record<string, any>} data - Row data.
|
|
856
|
+
* @param {Record<string, any>|null} [where=null] - Lookup condition.
|
|
354
857
|
* @returns {Promise<{ ok: true } | { error: string }>}
|
|
355
858
|
*/
|
|
356
859
|
async set(tableName, data, where = null) {
|
|
@@ -360,24 +863,25 @@ module.exports = class SQLite {
|
|
|
360
863
|
}
|
|
361
864
|
|
|
362
865
|
const lookup = where ?? (data.id ? { id: data.id } : null);
|
|
363
|
-
let finalData = data;
|
|
364
866
|
|
|
365
|
-
if (lookup) {
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
finalData = { ...existing, ...data };
|
|
370
|
-
}
|
|
867
|
+
if (!lookup) {
|
|
868
|
+
const inserted = await this.insert(tableName, data);
|
|
869
|
+
if (inserted.error) return inserted;
|
|
870
|
+
return { ok: true };
|
|
371
871
|
}
|
|
372
872
|
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
const sql = `INSERT OR REPLACE INTO ${tableName} (${columns.join(
|
|
376
|
-
", ",
|
|
377
|
-
)}) VALUES (${placeholders})`;
|
|
873
|
+
const existing = await this.get(tableName, lookup);
|
|
874
|
+
if (existing?.error) return existing;
|
|
378
875
|
|
|
379
|
-
|
|
380
|
-
|
|
876
|
+
if (existing) {
|
|
877
|
+
const merged = { ...existing, ...data };
|
|
878
|
+
const updated = await this.update(tableName, merged, lookup);
|
|
879
|
+
if (updated.error) return updated;
|
|
880
|
+
return { ok: true };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const inserted = await this.insert(tableName, data);
|
|
884
|
+
if (inserted.error) return inserted;
|
|
381
885
|
|
|
382
886
|
return { ok: true };
|
|
383
887
|
} catch (error) {
|
|
@@ -388,8 +892,8 @@ module.exports = class SQLite {
|
|
|
388
892
|
/**
|
|
389
893
|
* Deletes rows matching a WHERE clause.
|
|
390
894
|
*
|
|
391
|
-
* @param {string} tableName
|
|
392
|
-
* @param {Record<string, any>} where
|
|
895
|
+
* @param {string} tableName - Table name.
|
|
896
|
+
* @param {Record<string, any>} where - Conditions.
|
|
393
897
|
* @returns {Promise<{ ok: true, changes: number } | { error: string }>}
|
|
394
898
|
*/
|
|
395
899
|
async delete(tableName, where) {
|
|
@@ -398,10 +902,16 @@ module.exports = class SQLite {
|
|
|
398
902
|
return { error: `Table ${tableName} doesn't exist` };
|
|
399
903
|
}
|
|
400
904
|
|
|
905
|
+
if (!where || Object.keys(where).length === 0) {
|
|
906
|
+
return { error: "Delete requires a non-empty where clause" };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
this.#assertIdentifier(tableName, "table name");
|
|
910
|
+
|
|
401
911
|
const whereClause = this.#buildWhere(where);
|
|
402
912
|
const sql = `DELETE FROM ${tableName} ${whereClause.where}`;
|
|
403
|
-
const
|
|
404
|
-
|
|
913
|
+
const result = this.#run(sql, whereClause.params);
|
|
914
|
+
|
|
405
915
|
return { ok: true, changes: result.changes };
|
|
406
916
|
} catch (error) {
|
|
407
917
|
return { error: error.message };
|
|
@@ -411,12 +921,8 @@ module.exports = class SQLite {
|
|
|
411
921
|
/**
|
|
412
922
|
* Counts rows matching a WHERE clause.
|
|
413
923
|
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
* - or `{ error }` if a database error occurred.
|
|
417
|
-
*
|
|
418
|
-
* @param {string} tableName
|
|
419
|
-
* @param {Record<string, any>} [where={}]
|
|
924
|
+
* @param {string} tableName - Table name.
|
|
925
|
+
* @param {Record<string, any>} [where={}] - Conditions.
|
|
420
926
|
* @returns {Promise<number | { error: string }>}
|
|
421
927
|
*/
|
|
422
928
|
async count(tableName, where = {}) {
|
|
@@ -425,10 +931,13 @@ module.exports = class SQLite {
|
|
|
425
931
|
return { error: `Table ${tableName} doesn't exist` };
|
|
426
932
|
}
|
|
427
933
|
|
|
934
|
+
this.#assertIdentifier(tableName, "table name");
|
|
935
|
+
|
|
428
936
|
const whereClause = this.#buildWhere(where);
|
|
429
937
|
const sql = `SELECT COUNT(*) as total FROM ${tableName} ${whereClause.where}`;
|
|
430
938
|
const stmt = this.#$.prepare(sql);
|
|
431
939
|
const result = stmt.get(...whereClause.params);
|
|
940
|
+
|
|
432
941
|
return result.total;
|
|
433
942
|
} catch (error) {
|
|
434
943
|
return { error: error.message };
|
|
@@ -436,7 +945,43 @@ module.exports = class SQLite {
|
|
|
436
945
|
}
|
|
437
946
|
|
|
438
947
|
/**
|
|
439
|
-
*
|
|
948
|
+
* Runs multiple operations inside a transaction.
|
|
949
|
+
*
|
|
950
|
+
* If the callback throws, the transaction is rolled back.
|
|
951
|
+
* If the callback completes successfully, the transaction is committed.
|
|
952
|
+
*
|
|
953
|
+
* The callback may be synchronous or asynchronous.
|
|
954
|
+
*
|
|
955
|
+
* @template T
|
|
956
|
+
* @param {() => T|Promise<T>} callback - Function to execute inside the transaction.
|
|
957
|
+
* @returns {Promise<T | { error: string }>}
|
|
958
|
+
*
|
|
959
|
+
* @example
|
|
960
|
+
* const result = await db.transaction(async () => {
|
|
961
|
+
* await db.insert("User", { email: "a@b.com", createdAt: Date.now() });
|
|
962
|
+
* await db.insert("Profile", { userId: 1, nickname: "John" });
|
|
963
|
+
* return { ok: true };
|
|
964
|
+
* });
|
|
965
|
+
*/
|
|
966
|
+
async transaction(callback) {
|
|
967
|
+
try {
|
|
968
|
+
this.#run("BEGIN");
|
|
969
|
+
|
|
970
|
+
const result = await callback();
|
|
971
|
+
|
|
972
|
+
this.#run("COMMIT");
|
|
973
|
+
return result;
|
|
974
|
+
} catch (error) {
|
|
975
|
+
try {
|
|
976
|
+
this.#run("ROLLBACK");
|
|
977
|
+
} catch {}
|
|
978
|
+
|
|
979
|
+
return { error: error.message };
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Closes the underlying SQLite connection.
|
|
440
985
|
*
|
|
441
986
|
* @example
|
|
442
987
|
* db.close();
|