@dockstat/sqlite-wrapper 1.2.1 → 1.2.3

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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@dockstat/sqlite-wrapper",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "A TypeScript wrapper around bun:sqlite with type-safe query building",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
7
7
  "types": "./index.ts",
8
- "files": ["index.ts", "src/**/*", "README.md", "types.ts"],
8
+ "files": ["index.ts", "query-builder/**/*", "README.md", "types.ts"],
9
9
  "scripts": {
10
10
  "dev": "bun build index.ts",
11
11
  "lint": "biome lint .",
@@ -0,0 +1,217 @@
1
+ import type { Database, SQLQueryBindings } from 'bun:sqlite'
2
+ import { createLogger } from '@dockstat/logger'
3
+ import type {
4
+ ColumnNames,
5
+ DatabaseRowData,
6
+ JsonColumnConfig,
7
+ OrderDirection,
8
+ QueryBuilderState,
9
+ } from '../types'
10
+
11
+ /**
12
+ * Base QueryBuilder class that manages core state and shared functionality.
13
+ * This class provides the foundation for all query operations.
14
+ */
15
+ export abstract class BaseQueryBuilder<T extends Record<string, unknown>> {
16
+ private logger = createLogger('BaseQueryBuilder')
17
+ protected state: QueryBuilderState<T>
18
+
19
+ /**
20
+ * Get the logger instance
21
+ */
22
+ protected getLogger() {
23
+ return this.logger
24
+ }
25
+
26
+ constructor(
27
+ db: Database,
28
+ tableName: string,
29
+ jsonConfig?: JsonColumnConfig<T>
30
+ ) {
31
+ this.state = {
32
+ db,
33
+ tableName,
34
+ whereConditions: [],
35
+ whereParams: [],
36
+ regexConditions: [],
37
+ jsonColumns: jsonConfig?.jsonColumns,
38
+ }
39
+ this.logger.debug(`Created QueryBuilder for table: ${tableName}`)
40
+ }
41
+
42
+ /**
43
+ * Reset query builder state
44
+ */
45
+ protected reset(): void {
46
+ this.state.whereConditions = []
47
+ this.state.whereParams = []
48
+ this.state.regexConditions = []
49
+ // Reset any ordering, limit, offset, selected columns if present
50
+ if ('orderColumn' in this) (this as any).orderColumn = undefined
51
+ if ('orderDirection' in this) (this as any).orderDirection = 'ASC'
52
+ if ('limitValue' in this) (this as any).limitValue = undefined
53
+ if ('offsetValue' in this) (this as any).offsetValue = undefined
54
+ if ('selectedColumns' in this) (this as any).selectedColumns = ['*']
55
+ }
56
+
57
+ /**
58
+ * Get the database instance
59
+ */
60
+ protected getDb(): Database {
61
+ return this.state.db
62
+ }
63
+
64
+ /**
65
+ * Get the table name
66
+ */
67
+ protected getTableName(): string {
68
+ return this.state.tableName
69
+ }
70
+
71
+ /**
72
+ * Build the WHERE clause portion of a SQL query.
73
+ * @returns Tuple of [whereClause, parameters] where whereClause includes "WHERE" prefix
74
+ */
75
+ protected buildWhereClause(): [string, SQLQueryBindings[]] {
76
+ if (this.state.whereConditions.length === 0) {
77
+ return ['', []]
78
+ }
79
+ return [
80
+ ` WHERE ${this.state.whereConditions.join(' AND ')}`,
81
+ this.state.whereParams.slice(),
82
+ ]
83
+ }
84
+
85
+ /**
86
+ * Check if there are any regex conditions that require client-side filtering.
87
+ */
88
+ protected hasRegexConditions(): boolean {
89
+ return this.state.regexConditions.length > 0
90
+ }
91
+
92
+ /**
93
+ * Apply client-side regex filtering to a set of rows.
94
+ * This is used when regex conditions are present.
95
+ */
96
+ protected applyRegexFiltering(rows: T[]): T[] {
97
+ if (this.state.regexConditions.length === 0) {
98
+ return rows
99
+ }
100
+
101
+ return rows.filter((row) =>
102
+ this.state.regexConditions.every(({ column, regex }) => {
103
+ const value = row[String(column)]
104
+ if (value === null || value === undefined) return false
105
+ return regex.test(String(value))
106
+ })
107
+ )
108
+ }
109
+
110
+ /**
111
+ * Validate that WHERE conditions exist for operations that require them.
112
+ * Throws an error if no WHERE conditions are present.
113
+ */
114
+ protected requireWhereClause(operation: string): void {
115
+ if (
116
+ this.state.whereConditions.length === 0 &&
117
+ this.state.regexConditions.length === 0
118
+ ) {
119
+ const error = `${operation} operation requires at least one WHERE condition. Use where(), whereRaw(), whereIn(), whereOp(), or whereRgx() to add conditions.`
120
+ this.logger.error(error)
121
+ throw new Error(error)
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Quote SQL identifiers to prevent injection and handle special characters.
127
+ */
128
+ protected quoteIdentifier(identifier: string): string {
129
+ return `"${identifier.replace(/"/g, '""')}"`
130
+ }
131
+
132
+ /**
133
+ * Reset all WHERE conditions and parameters.
134
+ * Useful for reusing the same builder instance.
135
+ */
136
+ protected resetWhereConditions(): void {
137
+ this.state.whereConditions = []
138
+ this.state.whereParams = []
139
+ this.state.regexConditions = []
140
+ }
141
+
142
+ /**
143
+ * Transform row data after fetching from database (deserialize JSON columns).
144
+ */
145
+ protected transformRowFromDb(row: unknown): T {
146
+ if (!this.state.jsonColumns || !row) return row as T
147
+
148
+ try {
149
+ const transformed = { ...row } as DatabaseRowData
150
+ for (const column of this.state.jsonColumns) {
151
+ const columnKey = String(column)
152
+ if (
153
+ transformed[columnKey] !== null &&
154
+ transformed[columnKey] !== undefined &&
155
+ typeof transformed[columnKey] === 'string'
156
+ ) {
157
+ try {
158
+ transformed[columnKey] = JSON.parse(
159
+ transformed[columnKey] as string
160
+ )
161
+ } catch (parseError) {
162
+ // Keep original value if JSON parsing fails
163
+ this.logger.warn(
164
+ `JSON parse failed for column ${columnKey}: ${parseError}`
165
+ )
166
+ }
167
+ }
168
+ }
169
+ return transformed as T
170
+ } catch (error) {
171
+ this.logger.error(`Error in transformRowFromDb: ${error}`)
172
+ return row as T
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Transform multiple rows after fetching from database.
178
+ */
179
+ protected transformRowsFromDb(rows: unknown[]): T[] {
180
+ if (!this.state.jsonColumns || !Array.isArray(rows)) return rows as T[]
181
+
182
+ try {
183
+ return rows.map((row, index) => {
184
+ try {
185
+ return this.transformRowFromDb(row)
186
+ } catch (error) {
187
+ this.logger.error(`Error transforming row ${index}: ${error}`)
188
+ return row as T
189
+ }
190
+ })
191
+ } catch (error) {
192
+ this.logger.error(`Error in transformRowsFromDb: ${error}`)
193
+ return rows as T[]
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Transform row data before inserting/updating to database (serialize JSON columns).
199
+ */
200
+ protected transformRowToDb(row: Partial<T>): DatabaseRowData {
201
+ if (!this.state.jsonColumns || !row) return row as DatabaseRowData
202
+
203
+ const transformed: DatabaseRowData = { ...row } as DatabaseRowData
204
+ for (const column of this.state.jsonColumns) {
205
+ const columnKey = String(column)
206
+ if (
207
+ transformed[columnKey] !== undefined &&
208
+ transformed[columnKey] !== null
209
+ ) {
210
+ if (typeof transformed[columnKey] === 'object') {
211
+ transformed[columnKey] = JSON.stringify(transformed[columnKey])
212
+ }
213
+ }
214
+ }
215
+ return transformed
216
+ }
217
+ }
@@ -0,0 +1,352 @@
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
+ }