@dockstat/sqlite-wrapper 1.2.8 → 1.3.1

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.
@@ -1,352 +1,447 @@
1
- import type { SQLQueryBindings } from "bun:sqlite";
2
- import type { DeleteResult } from "../types";
3
- import { SelectQueryBuilder } from "./select";
4
-
5
- /**
6
- * Mixin class that adds DELETE functionality to the QueryBuilder.
7
- * Handles safe delete operations with mandatory WHERE conditions.
8
- */
9
- export class DeleteQueryBuilder<
10
- T extends Record<string, unknown>,
11
- > extends SelectQueryBuilder<T> {
12
- /**
13
- * Delete rows matching the WHERE conditions.
14
- * Requires at least one WHERE condition to prevent accidental full table deletion.
15
- *
16
- * @returns Delete result with changes count
17
- */
18
- delete(): DeleteResult {
19
- this.requireWhereClause("DELETE");
20
-
21
- // Handle regex conditions by first fetching matching rows
22
- if (this.hasRegexConditions()) {
23
- const result = this.deleteWithRegexConditions();
24
- this.reset();
25
- return result;
26
- }
27
-
28
- // Build DELETE statement
29
- const [whereClause, whereParams] = this.buildWhereClause();
30
- const query = `DELETE FROM ${this.quoteIdentifier(this.getTableName())}${whereClause}`;
31
-
32
- const result = this.getDb()
33
- .prepare(query)
34
- .run(...whereParams);
35
-
36
- this.reset();
37
- return {
38
- changes: result.changes,
39
- };
40
- }
41
-
42
- /**
43
- * Handle DELETE operations when regex conditions are present.
44
- * This requires client-side filtering and individual row deletion.
45
- */
46
- private deleteWithRegexConditions(): DeleteResult {
47
- this.reset();
48
- // First, get all rows matching SQL conditions (without regex)
49
- const [selectQuery, selectParams] = this.buildWhereClause();
50
- const candidateRows = this.getDb()
51
- .prepare(
52
- `SELECT rowid, * FROM ${this.quoteIdentifier(this.getTableName())}${selectQuery}`,
53
- )
54
- .all(...selectParams) as (T & { rowid: number })[];
55
-
56
- // Apply regex filtering
57
- const matchingRows = this.applyRegexFiltering(candidateRows);
58
-
59
- if (matchingRows.length === 0) {
60
- return { changes: 0 };
61
- }
62
-
63
- // Delete each matching row by rowid
64
- const deleteQuery = `DELETE FROM ${this.quoteIdentifier(this.getTableName())} WHERE rowid = ?`;
65
- const stmt = this.getDb().prepare(deleteQuery);
66
-
67
- let totalChanges = 0;
68
- for (const row of matchingRows) {
69
- const result = stmt.run(row.rowid as SQLQueryBindings);
70
- totalChanges += result.changes;
71
- }
72
-
73
- return { changes: totalChanges };
74
- }
75
-
76
- /**
77
- * Delete and get the deleted rows back.
78
- * This is useful when you want to see what was deleted for logging or audit purposes.
79
- *
80
- * @returns Array of deleted rows
81
- */
82
- deleteAndGet(): T[] {
83
- // First, get the rows that will be deleted
84
- const rowsToDelete = this.all();
85
-
86
- // Perform the delete
87
- const deleteResult = this.delete();
88
-
89
- if (deleteResult.changes === 0) {
90
- return [];
91
- }
92
-
93
- // Return the rows that were deleted
94
- const out = rowsToDelete;
95
- this.reset();
96
- return out;
97
- }
98
-
99
- /**
100
- * Soft delete - mark rows as deleted instead of physically removing them.
101
- * This requires a 'deleted_at' or similar column in your table schema.
102
- *
103
- * @param deletedColumn - Column name to mark as deleted (defaults to 'deleted_at')
104
- * @param deletedValue - Value to set for the deleted marker (defaults to current timestamp)
105
- * @returns Update result with changes count
106
- */
107
- softDelete(
108
- deletedColumn: keyof T = "deleted_at" as keyof T,
109
- deletedValue: SQLQueryBindings = Math.floor(Date.now() / 1000),
110
- ): DeleteResult {
111
- this.requireWhereClause("SOFT DELETE");
112
-
113
- // Handle regex conditions
114
- if (this.hasRegexConditions()) {
115
- const result = this.softDeleteWithRegexConditions(deletedColumn, deletedValue);
116
- this.reset();
117
- return result;
118
- }
119
-
120
- // Build UPDATE statement to mark as deleted
121
- const [whereClause, whereParams] = this.buildWhereClause();
122
- const query = `UPDATE ${this.quoteIdentifier(this.getTableName())} SET ${this.quoteIdentifier(String(deletedColumn))} = ?${whereClause}`;
123
-
124
- const result = this.getDb()
125
- .prepare(query)
126
- .run(deletedValue, ...whereParams);
127
-
128
- this.reset();
129
- return {
130
- changes: result.changes,
131
- };
132
- }
133
-
134
- /**
135
- * Handle soft delete operations when regex conditions are present.
136
- */
137
- private softDeleteWithRegexConditions(
138
- deletedColumn: keyof T,
139
- deletedValue: SQLQueryBindings,
140
- ): DeleteResult {
141
- // First, get all rows matching SQL conditions (without regex)
142
- const [selectQuery, selectParams] = this.buildWhereClause();
143
- const candidateRows = this.getDb()
144
- .prepare(
145
- `SELECT rowid, * FROM ${this.quoteIdentifier(this.getTableName())}${selectQuery}`,
146
- )
147
- .all(...selectParams) as (T & { rowid: number })[];
148
-
149
- // Apply regex filtering
150
- const matchingRows = this.applyRegexFiltering(candidateRows);
151
-
152
- if (matchingRows.length === 0) {
153
- return { changes: 0 };
154
- }
155
-
156
- // Soft delete each matching row by rowid
157
- const updateQuery = `UPDATE ${this.quoteIdentifier(this.getTableName())} SET ${this.quoteIdentifier(String(deletedColumn))} = ? WHERE rowid = ?`;
158
- const stmt = this.getDb().prepare(updateQuery);
159
-
160
- let totalChanges = 0;
161
- for (const row of matchingRows) {
162
- const result = stmt.run(deletedValue, row.rowid as SQLQueryBindings);
163
- totalChanges += result.changes;
164
- }
165
-
166
- return { changes: totalChanges };
167
- }
168
-
169
- /**
170
- * Restore soft deleted rows by clearing the deleted marker.
171
- *
172
- * @param deletedColumn - Column name that marks rows as deleted
173
- * @returns Update result with changes count
174
- */
175
- restore(deletedColumn: keyof T = "deleted_at" as keyof T): DeleteResult {
176
- this.requireWhereClause("RESTORE");
177
-
178
- // Handle regex conditions
179
- if (this.hasRegexConditions()) {
180
- const result = this.restoreWithRegexConditions(deletedColumn);
181
- this.reset();
182
- return result;
183
- }
184
-
185
- // Build UPDATE statement to clear the deleted marker
186
- const [whereClause, whereParams] = this.buildWhereClause();
187
- const query = `UPDATE ${this.quoteIdentifier(this.getTableName())} SET ${this.quoteIdentifier(String(deletedColumn))} = NULL${whereClause}`;
188
-
189
- const result = this.getDb()
190
- .prepare(query)
191
- .run(...whereParams);
192
-
193
- this.reset();
194
- return {
195
- changes: result.changes,
196
- };
197
- }
198
-
199
- /**
200
- * Handle restore operations when regex conditions are present.
201
- */
202
- private restoreWithRegexConditions(deletedColumn: keyof T): DeleteResult {
203
- // First, get all rows matching SQL conditions (without regex)
204
- const [selectQuery, selectParams] = this.buildWhereClause();
205
- const candidateRows = this.getDb()
206
- .prepare(
207
- `SELECT rowid, * FROM ${this.quoteIdentifier(this.getTableName())}${selectQuery}`,
208
- )
209
- .all(...selectParams) as (T & { rowid: number })[];
210
-
211
- // Apply regex filtering
212
- const matchingRows = this.applyRegexFiltering(candidateRows);
213
-
214
- if (matchingRows.length === 0) {
215
- return { changes: 0 };
216
- }
217
-
218
- // Restore each matching row by rowid
219
- const updateQuery = `UPDATE ${this.quoteIdentifier(this.getTableName())} SET ${this.quoteIdentifier(String(deletedColumn))} = NULL WHERE rowid = ?`;
220
- const stmt = this.getDb().prepare(updateQuery);
221
-
222
- let totalChanges = 0;
223
- for (const row of matchingRows) {
224
- const result = stmt.run(row.rowid as SQLQueryBindings);
225
- totalChanges += result.changes;
226
- }
227
-
228
- return { changes: totalChanges };
229
- }
230
-
231
- /**
232
- * Batch delete multiple sets of rows based on different conditions.
233
- * This is more efficient than individual deletes when removing many different row sets.
234
- *
235
- * @param conditions - Array of WHERE condition objects
236
- * @returns Delete result with total changes count
237
- */
238
- deleteBatch(conditions: Array<Partial<T>>): DeleteResult {
239
- if (!Array.isArray(conditions) || conditions.length === 0) {
240
- throw new Error("deleteBatch: conditions must be a non-empty array");
241
- }
242
-
243
- const db = this.getDb();
244
-
245
- // Use a transaction for batch operations
246
- const transaction = db.transaction(
247
- (conditionsToProcess: Array<Partial<T>>) => {
248
- let totalChanges = 0;
249
-
250
- for (const whereData of conditionsToProcess) {
251
- // Build WHERE conditions for this delete
252
- const whereConditions: string[] = [];
253
- const whereParams: SQLQueryBindings[] = [];
254
-
255
- for (const [column, value] of Object.entries(whereData)) {
256
- if (value === null || value === undefined) {
257
- whereConditions.push(`${this.quoteIdentifier(column)} IS NULL`);
258
- } else {
259
- whereConditions.push(`${this.quoteIdentifier(column)} = ?`);
260
- whereParams.push(value);
261
- }
262
- }
263
-
264
- if (whereConditions.length === 0) {
265
- throw new Error(
266
- "deleteBatch: each delete must have WHERE conditions",
267
- );
268
- }
269
-
270
- // Build DELETE statement
271
- const whereClause = ` WHERE ${whereConditions.join(" AND ")}`;
272
- const query = `DELETE FROM ${this.quoteIdentifier(this.getTableName())}${whereClause}`;
273
-
274
- const result = db.prepare(query).run(...whereParams);
275
- totalChanges += result.changes;
276
- }
277
-
278
- return { changes: totalChanges };
279
- },
280
- );
281
-
282
- const result = transaction(conditions);
283
- this.reset();
284
- return result;
285
- }
286
-
287
- /**
288
- * Truncate the entire table (delete all rows).
289
- * This bypasses the WHERE condition requirement since it's explicitly destructive.
290
- * USE WITH EXTREME CAUTION!
291
- *
292
- * @returns Delete result with changes count
293
- */
294
- truncate(): DeleteResult {
295
- const query = `DELETE FROM ${this.quoteIdentifier(this.getTableName())}`;
296
- const result = this.getDb().prepare(query).run();
297
-
298
- this.reset();
299
- return {
300
- changes: result.changes,
301
- };
302
- }
303
-
304
- /**
305
- * Delete rows older than a specified timestamp.
306
- * Convenience method for common cleanup operations.
307
- *
308
- * @param timestampColumn - Column name containing the timestamp
309
- * @param olderThan - Timestamp threshold (rows older than this will be deleted)
310
- * @returns Delete result with changes count
311
- */
312
- deleteOlderThan(timestampColumn: keyof T, olderThan: number): DeleteResult {
313
- const changes = this.whereOp(timestampColumn, "<", olderThan).delete();
314
- this.reset();
315
- return changes;
316
- }
317
-
318
- /**
319
- * Delete duplicate rows based on specified columns, keeping only the first occurrence.
320
- * This is useful for data cleanup operations.
321
- *
322
- * @param columns - Columns to check for duplicates
323
- * @returns Delete result with changes count
324
- */
325
- deleteDuplicates(columns: Array<keyof T>): DeleteResult {
326
- if (!Array.isArray(columns) || columns.length === 0) {
327
- throw new Error("deleteDuplicates: columns must be a non-empty array");
328
- }
329
-
330
- const columnNames = columns.map((col) => String(col));
331
- const quotedColumns = columnNames
332
- .map((col) => this.quoteIdentifier(col))
333
- .join(", ");
334
-
335
- // Find duplicate rows, keeping the one with the minimum rowid
336
- const query = `
337
- DELETE FROM ${this.quoteIdentifier(this.getTableName())}
338
- WHERE rowid NOT IN (
339
- SELECT MIN(rowid)
340
- FROM ${this.quoteIdentifier(this.getTableName())}
341
- GROUP BY ${quotedColumns}
342
- )
343
- `;
344
-
345
- const result = this.getDb().prepare(query).run();
346
-
347
- this.reset();
348
- return {
349
- changes: result.changes,
350
- };
351
- }
352
- }
1
+ import type { Database, SQLQueryBindings } from "bun:sqlite"
2
+ import type { Logger } from "@dockstat/logger"
3
+ import type { DeleteResult, Parser } from "../types"
4
+ import { createLogger, quoteIdentifier } from "../utils"
5
+ import { SelectQueryBuilder } from "./select"
6
+
7
+ /**
8
+ * DeleteQueryBuilder - Handles DELETE operations with safety checks
9
+ *
10
+ * Features:
11
+ * - Safe deletes (WHERE required to prevent accidental full-table deletes)
12
+ * - Soft delete (mark as deleted instead of removing)
13
+ * - Restore soft deleted rows
14
+ * - Delete and get (returns deleted rows)
15
+ * - Batch deletes with transaction support
16
+ * - Delete older than timestamp
17
+ * - Delete duplicates
18
+ * - Truncate (explicit full-table delete)
19
+ */
20
+ export class DeleteQueryBuilder<T extends Record<string, unknown>> extends SelectQueryBuilder<T> {
21
+ private deleteLog: ReturnType<typeof createLogger>
22
+
23
+ constructor(db: Database, tableName: string, parser: Parser<T>, baseLogger?: Logger) {
24
+ super(db, tableName, parser, baseLogger)
25
+ this.deleteLog = createLogger("Delete", baseLogger)
26
+ }
27
+
28
+ // ===== Public Delete Methods =====
29
+
30
+ /**
31
+ * Delete rows matching the WHERE conditions
32
+ *
33
+ * Requires at least one WHERE condition to prevent accidental full-table deletes.
34
+ *
35
+ * @example
36
+ * table.where({ id: 1 }).delete()
37
+ *
38
+ * @example
39
+ * table.where({ active: false, deleted_at: null }).delete()
40
+ */
41
+ delete(): DeleteResult {
42
+ this.requireWhereClause("DELETE")
43
+
44
+ // Handle regex conditions by fetching matching rows first
45
+ if (this.hasRegexConditions()) {
46
+ return this.deleteWithRegexConditions()
47
+ }
48
+
49
+ // Build DELETE statement
50
+ const [whereClause, whereParams] = this.buildWhereClause()
51
+ const query = `DELETE FROM ${quoteIdentifier(this.getTableName())}${whereClause}`
52
+
53
+ this.deleteLog.query("DELETE", query, whereParams)
54
+
55
+ const result = this.getDb()
56
+ .prepare(query)
57
+ .run(...whereParams)
58
+
59
+ this.deleteLog.result("DELETE", result.changes)
60
+ this.reset()
61
+
62
+ return { changes: result.changes }
63
+ }
64
+
65
+ /**
66
+ * Handle DELETE when regex conditions are present
67
+ *
68
+ * Since regex conditions are applied client-side, we need to:
69
+ * 1. Fetch all rows matching SQL conditions
70
+ * 2. Filter with regex client-side
71
+ * 3. Delete each matching row by rowid
72
+ */
73
+ private deleteWithRegexConditions(): DeleteResult {
74
+ // Get rows matching SQL conditions (without regex)
75
+ // Use alias for rowid to avoid collision with INTEGER PRIMARY KEY columns
76
+ const [whereClause, whereParams] = this.buildWhereClause()
77
+ const selectQuery = `SELECT rowid as _rowid_, * FROM ${quoteIdentifier(this.getTableName())}${whereClause}`
78
+
79
+ const candidateRows = this.getDb()
80
+ .prepare(selectQuery)
81
+ .all(...whereParams) as (T & { _rowid_: number })[]
82
+
83
+ // Apply regex filtering
84
+ const matchingRows = this.applyRegexFiltering(candidateRows)
85
+
86
+ if (matchingRows.length === 0) {
87
+ this.reset()
88
+ return { changes: 0 }
89
+ }
90
+
91
+ // Delete each matching row by rowid
92
+ const deleteQuery = `DELETE FROM ${quoteIdentifier(this.getTableName())} WHERE rowid = ?`
93
+ const stmt = this.getDb().prepare(deleteQuery)
94
+
95
+ this.deleteLog.query("DELETE (regex)", deleteQuery)
96
+
97
+ let totalChanges = 0
98
+
99
+ for (const row of matchingRows) {
100
+ const result = stmt.run(row._rowid_ as SQLQueryBindings)
101
+ totalChanges += result.changes
102
+ }
103
+
104
+ this.deleteLog.result("DELETE (regex)", totalChanges)
105
+ this.reset()
106
+
107
+ return { changes: totalChanges }
108
+ }
109
+
110
+ /**
111
+ * Delete rows and return the deleted rows
112
+ *
113
+ * Useful for logging or audit purposes.
114
+ *
115
+ * @example
116
+ * const deletedUsers = table.where({ active: false }).deleteAndGet()
117
+ * console.log(`Deleted ${deletedUsers.length} inactive users`)
118
+ */
119
+ deleteAndGet(): T[] {
120
+ // Preserve WHERE state before all() resets it
121
+ const savedWhereConditions = [...this.state.whereConditions]
122
+ const savedWhereParams = [...this.state.whereParams]
123
+ const savedRegexConditions = [...this.state.regexConditions]
124
+
125
+ // Get rows before deletion
126
+ const rowsToDelete = this.all()
127
+
128
+ // Restore WHERE state for the delete
129
+ this.state.whereConditions = savedWhereConditions
130
+ this.state.whereParams = savedWhereParams
131
+ this.state.regexConditions = savedRegexConditions
132
+
133
+ // Perform the delete
134
+ const deleteResult = this.delete()
135
+
136
+ if (deleteResult.changes === 0) {
137
+ return []
138
+ }
139
+
140
+ return rowsToDelete
141
+ }
142
+
143
+ /**
144
+ * Soft delete - mark rows as deleted instead of removing them
145
+ *
146
+ * Sets a timestamp column to mark rows as deleted.
147
+ * Useful for data recovery and audit trails.
148
+ *
149
+ * @example
150
+ * // Using default column name 'deleted_at'
151
+ * table.where({ id: 1 }).softDelete()
152
+ *
153
+ * @example
154
+ * // Using custom column name and value
155
+ * table.where({ id: 1 }).softDelete("removed_at", Date.now())
156
+ */
157
+ softDelete(
158
+ deletedColumn: keyof T = "deleted_at" as keyof T,
159
+ deletedValue: SQLQueryBindings = Math.floor(Date.now() / 1000)
160
+ ): DeleteResult {
161
+ this.requireWhereClause("SOFT DELETE")
162
+
163
+ // Handle regex conditions
164
+ if (this.hasRegexConditions()) {
165
+ return this.softDeleteWithRegexConditions(deletedColumn, deletedValue)
166
+ }
167
+
168
+ // Build UPDATE statement to mark as deleted
169
+ const [whereClause, whereParams] = this.buildWhereClause()
170
+ const query = `UPDATE ${quoteIdentifier(this.getTableName())} SET ${quoteIdentifier(String(deletedColumn))} = ?${whereClause}`
171
+ const params = [deletedValue, ...whereParams] as SQLQueryBindings[]
172
+
173
+ this.deleteLog.query("SOFT DELETE", query, params)
174
+
175
+ const result = this.getDb()
176
+ .prepare(query)
177
+ .run(...params)
178
+
179
+ this.deleteLog.result("SOFT DELETE", result.changes)
180
+ this.reset()
181
+
182
+ return { changes: result.changes }
183
+ }
184
+
185
+ /**
186
+ * Handle soft delete when regex conditions are present
187
+ */
188
+ private softDeleteWithRegexConditions(
189
+ deletedColumn: keyof T,
190
+ deletedValue: SQLQueryBindings
191
+ ): DeleteResult {
192
+ // Get rows matching SQL conditions (without regex)
193
+ // Use alias for rowid to avoid collision with INTEGER PRIMARY KEY columns
194
+ const [whereClause, whereParams] = this.buildWhereClause()
195
+ const selectQuery = `SELECT rowid as _rowid_, * FROM ${quoteIdentifier(this.getTableName())}${whereClause}`
196
+
197
+ const candidateRows = this.getDb()
198
+ .prepare(selectQuery)
199
+ .all(...whereParams) as (T & { _rowid_: number })[]
200
+
201
+ // Apply regex filtering
202
+ const matchingRows = this.applyRegexFiltering(candidateRows)
203
+
204
+ if (matchingRows.length === 0) {
205
+ this.reset()
206
+ return { changes: 0 }
207
+ }
208
+
209
+ // Soft delete each matching row by rowid
210
+ const updateQuery = `UPDATE ${quoteIdentifier(this.getTableName())} SET ${quoteIdentifier(String(deletedColumn))} = ? WHERE rowid = ?`
211
+ const stmt = this.getDb().prepare(updateQuery)
212
+
213
+ this.deleteLog.query("SOFT DELETE (regex)", updateQuery)
214
+
215
+ let totalChanges = 0
216
+
217
+ for (const row of matchingRows) {
218
+ const result = stmt.run(deletedValue, row._rowid_ as SQLQueryBindings)
219
+ totalChanges += result.changes
220
+ }
221
+
222
+ this.deleteLog.result("SOFT DELETE (regex)", totalChanges)
223
+ this.reset()
224
+
225
+ return { changes: totalChanges }
226
+ }
227
+
228
+ /**
229
+ * Restore soft deleted rows by clearing the deleted marker
230
+ *
231
+ * @example
232
+ * // Restore using default column name 'deleted_at'
233
+ * table.where({ id: 1 }).restore()
234
+ *
235
+ * @example
236
+ * // Restore using custom column name
237
+ * table.where({ email: "user@example.com" }).restore("removed_at")
238
+ */
239
+ restore(deletedColumn: keyof T = "deleted_at" as keyof T): DeleteResult {
240
+ this.requireWhereClause("RESTORE")
241
+
242
+ // Handle regex conditions
243
+ if (this.hasRegexConditions()) {
244
+ return this.restoreWithRegexConditions(deletedColumn)
245
+ }
246
+
247
+ // Build UPDATE statement to clear the deleted marker
248
+ const [whereClause, whereParams] = this.buildWhereClause()
249
+ const query = `UPDATE ${quoteIdentifier(this.getTableName())} SET ${quoteIdentifier(String(deletedColumn))} = NULL${whereClause}`
250
+
251
+ this.deleteLog.query("RESTORE", query, whereParams)
252
+
253
+ const result = this.getDb()
254
+ .prepare(query)
255
+ .run(...whereParams)
256
+
257
+ this.deleteLog.result("RESTORE", result.changes)
258
+ this.reset()
259
+
260
+ return { changes: result.changes }
261
+ }
262
+
263
+ /**
264
+ * Handle restore when regex conditions are present
265
+ */
266
+ private restoreWithRegexConditions(deletedColumn: keyof T): DeleteResult {
267
+ // Get rows matching SQL conditions (without regex)
268
+ // Use alias for rowid to avoid collision with INTEGER PRIMARY KEY columns
269
+ const [whereClause, whereParams] = this.buildWhereClause()
270
+ const selectQuery = `SELECT rowid as _rowid_, * FROM ${quoteIdentifier(this.getTableName())}${whereClause}`
271
+
272
+ const candidateRows = this.getDb()
273
+ .prepare(selectQuery)
274
+ .all(...whereParams) as (T & { _rowid_: number })[]
275
+
276
+ // Apply regex filtering
277
+ const matchingRows = this.applyRegexFiltering(candidateRows)
278
+
279
+ if (matchingRows.length === 0) {
280
+ this.reset()
281
+ return { changes: 0 }
282
+ }
283
+
284
+ // Restore each matching row by rowid
285
+ const updateQuery = `UPDATE ${quoteIdentifier(this.getTableName())} SET ${quoteIdentifier(String(deletedColumn))} = NULL WHERE rowid = ?`
286
+ const stmt = this.getDb().prepare(updateQuery)
287
+
288
+ this.deleteLog.query("RESTORE (regex)", updateQuery)
289
+
290
+ let totalChanges = 0
291
+
292
+ for (const row of matchingRows) {
293
+ const result = stmt.run(row._rowid_ as SQLQueryBindings)
294
+ totalChanges += result.changes
295
+ }
296
+
297
+ this.deleteLog.result("RESTORE (regex)", totalChanges)
298
+ this.reset()
299
+
300
+ return { changes: totalChanges }
301
+ }
302
+
303
+ /**
304
+ * Batch delete multiple sets of rows based on different conditions
305
+ *
306
+ * Each condition set deletes rows matching those conditions.
307
+ * All deletes are wrapped in a transaction for atomicity.
308
+ *
309
+ * @example
310
+ * table.deleteBatch([
311
+ * { id: 1 },
312
+ * { id: 2 },
313
+ * { email: "deleted@example.com" },
314
+ * ])
315
+ */
316
+ deleteBatch(conditions: Array<Partial<T>>): DeleteResult {
317
+ if (!Array.isArray(conditions) || conditions.length === 0) {
318
+ throw new Error("deleteBatch: conditions must be a non-empty array")
319
+ }
320
+
321
+ const db = this.getDb()
322
+
323
+ const transaction = db.transaction((conditionsToProcess: Array<Partial<T>>) => {
324
+ let totalChanges = 0
325
+
326
+ for (const whereData of conditionsToProcess) {
327
+ // Build WHERE conditions for this delete
328
+ const whereConditions: string[] = []
329
+ const whereParams: SQLQueryBindings[] = []
330
+
331
+ for (const [column, value] of Object.entries(whereData)) {
332
+ if (value === null || value === undefined) {
333
+ whereConditions.push(`${quoteIdentifier(column)} IS NULL`)
334
+ } else {
335
+ whereConditions.push(`${quoteIdentifier(column)} = ?`)
336
+ whereParams.push(value as SQLQueryBindings)
337
+ }
338
+ }
339
+
340
+ if (whereConditions.length === 0) {
341
+ throw new Error("deleteBatch: each delete must have WHERE conditions")
342
+ }
343
+
344
+ // Build DELETE statement
345
+ const whereClause = ` WHERE ${whereConditions.join(" AND ")}`
346
+ const query = `DELETE FROM ${quoteIdentifier(this.getTableName())}${whereClause}`
347
+
348
+ const result = db.prepare(query).run(...whereParams)
349
+ totalChanges += result.changes
350
+ }
351
+
352
+ return { changes: totalChanges }
353
+ })
354
+
355
+ this.deleteLog.query("DELETE BATCH", `${conditions.length} deletes`)
356
+
357
+ const result = transaction(conditions)
358
+
359
+ this.deleteLog.result("DELETE BATCH", result.changes)
360
+ this.reset()
361
+
362
+ return result
363
+ }
364
+
365
+ /**
366
+ * Truncate the entire table (delete all rows)
367
+ *
368
+ * This bypasses the WHERE condition requirement since it's explicitly destructive.
369
+ * USE WITH EXTREME CAUTION!
370
+ *
371
+ * @example
372
+ * // Delete all rows from the table
373
+ * table.truncate()
374
+ */
375
+ truncate(): DeleteResult {
376
+ const query = `DELETE FROM ${quoteIdentifier(this.getTableName())}`
377
+
378
+ this.deleteLog.query("TRUNCATE", query)
379
+
380
+ const result = this.getDb().prepare(query).run()
381
+
382
+ this.deleteLog.result("TRUNCATE", result.changes)
383
+ this.reset()
384
+
385
+ return { changes: result.changes }
386
+ }
387
+
388
+ /**
389
+ * Delete rows older than a specified timestamp
390
+ *
391
+ * Convenience method for common cleanup operations.
392
+ *
393
+ * @example
394
+ * // Delete rows older than 24 hours
395
+ * table.deleteOlderThan("created_at", Date.now() - 86400000)
396
+ *
397
+ * @example
398
+ * // Delete rows older than 30 days (Unix timestamp)
399
+ * const thirtyDaysAgo = Math.floor(Date.now() / 1000) - (30 * 24 * 60 * 60)
400
+ * table.deleteOlderThan("timestamp", thirtyDaysAgo)
401
+ */
402
+ deleteOlderThan(timestampColumn: keyof T, olderThan: number): DeleteResult {
403
+ return this.whereOp(timestampColumn, "<", olderThan).delete()
404
+ }
405
+
406
+ /**
407
+ * Delete duplicate rows based on specified columns
408
+ *
409
+ * Keeps the row with the minimum rowid for each unique combination.
410
+ * Useful for data cleanup operations.
411
+ *
412
+ * @example
413
+ * // Delete duplicate emails, keeping the first occurrence
414
+ * table.deleteDuplicates(["email"])
415
+ *
416
+ * @example
417
+ * // Delete rows with duplicate name+email combinations
418
+ * table.deleteDuplicates(["name", "email"])
419
+ */
420
+ deleteDuplicates(columns: Array<keyof T>): DeleteResult {
421
+ if (!Array.isArray(columns) || columns.length === 0) {
422
+ throw new Error("deleteDuplicates: columns must be a non-empty array")
423
+ }
424
+
425
+ const quotedColumns = columns.map((col) => quoteIdentifier(String(col))).join(", ")
426
+ const tableName = quoteIdentifier(this.getTableName())
427
+
428
+ // Delete all rows except the one with minimum rowid for each unique combination
429
+ const query = `
430
+ DELETE FROM ${tableName}
431
+ WHERE rowid NOT IN (
432
+ SELECT MIN(rowid)
433
+ FROM ${tableName}
434
+ GROUP BY ${quotedColumns}
435
+ )
436
+ `.trim()
437
+
438
+ this.deleteLog.query("DELETE DUPLICATES", query)
439
+
440
+ const result = this.getDb().prepare(query).run()
441
+
442
+ this.deleteLog.result("DELETE DUPLICATES", result.changes)
443
+ this.reset()
444
+
445
+ return { changes: result.changes }
446
+ }
447
+ }