@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,278 +1,314 @@
1
- import type { SQLQueryBindings } from "bun:sqlite";
2
- import type { UpdateResult } from "../types";
3
- import { SelectQueryBuilder } from "./select";
4
-
5
- /**
6
- * Mixin class that adds UPDATE functionality to the QueryBuilder.
7
- * Handles safe update operations with mandatory WHERE conditions.
8
- */
9
- export class UpdateQueryBuilder<
10
- T extends Record<string, unknown>,
11
- > extends SelectQueryBuilder<T> {
12
- /**
13
- * Update rows matching the WHERE conditions with the provided data.
14
- * Requires at least one WHERE condition to prevent accidental full table updates.
15
- *
16
- * @param data - Object with columns to update and their new values
17
- * @returns Update result with changes count
18
- */
19
- update(data: Partial<T>): UpdateResult {
20
- this.requireWhereClause("UPDATE");
21
-
22
- // Transform data to handle JSON serialization
23
- const transformedData = this.transformRowToDb(data);
24
- const updateColumns = Object.keys(transformedData);
25
- if (updateColumns.length === 0) {
26
- this.reset()
27
- throw new Error("update: no columns to update");
28
- }
29
-
30
- // Handle regex conditions by first fetching matching rows
31
- if (this.hasRegexConditions()) {
32
- this.reset()
33
- return this.updateWithRegexConditions(transformedData);
34
- }
35
-
36
- // Build UPDATE statement
37
- const setClause = updateColumns
38
- .map((col) => `${this.quoteIdentifier(col)} = ?`)
39
- .join(", ");
40
-
41
- const [whereClause, whereParams] = this.buildWhereClause();
42
- const query = `UPDATE ${this.quoteIdentifier(this.getTableName())} SET ${setClause}${whereClause}`;
43
-
44
- const updateValues = updateColumns.map((col) => transformedData[col]);
45
- const allParams = [...updateValues, ...whereParams];
46
-
47
- const result = this.getDb()
48
- .prepare(query)
49
- .run(...allParams);
50
-
51
- const out = {
52
- changes: result.changes,
53
- };
54
- this.reset();
55
- return out;
56
- }
57
-
58
- /**
59
- * Handle UPDATE operations when regex conditions are present.
60
- * This requires client-side filtering and individual row updates.
61
- */
62
- private updateWithRegexConditions(
63
- transformedData: Record<string, SQLQueryBindings>,
64
- ): UpdateResult {
65
- // First, get all rows matching SQL conditions (without regex)
66
- const [selectQuery, selectParams] = this.buildWhereClause();
67
- const candidateRows = this.getDb()
68
- .prepare(
69
- `SELECT rowid, * FROM ${this.quoteIdentifier(this.getTableName())}${selectQuery}`,
70
- )
71
- .all(...selectParams) as (T & { rowid: number })[];
72
-
73
- // Apply regex filtering
74
- const matchingRows = this.applyRegexFiltering(candidateRows);
75
-
76
- if (matchingRows.length === 0) {
77
- this.reset()
78
- return { changes: 0 };
79
- }
80
-
81
- // Update each matching row by rowid
82
- const updateColumns = Object.keys(transformedData);
83
- const setClause = updateColumns
84
- .map((col) => `${this.quoteIdentifier(col)} = ?`)
85
- .join(", ");
86
-
87
- const updateQuery = `UPDATE ${this.quoteIdentifier(this.getTableName())} SET ${setClause} WHERE rowid = ?`;
88
- const stmt = this.getDb().prepare(updateQuery);
89
-
90
- let totalChanges = 0;
91
- const updateValues = updateColumns.map((col) => transformedData[col]);
92
-
93
- for (const row of matchingRows) {
94
- const result = stmt.run(...updateValues, row.rowid as SQLQueryBindings);
95
- totalChanges += result.changes;
96
- }
97
- this.reset()
98
- return { changes: totalChanges };
99
- }
100
-
101
- /**
102
- * Update or insert (upsert) functionality using INSERT OR REPLACE.
103
- * This method attempts to update existing rows, and inserts new ones if they don't exist.
104
- * Requires a unique constraint or primary key to work properly.
105
- *
106
- * @param data - Object with columns to upsert
107
- * @returns Update result with changes count
108
- */
109
- upsert(data: Partial<T>): UpdateResult {
110
- // Transform data to handle JSON serialization
111
- const transformedData = this.transformRowToDb(data);
112
- const columns = Object.keys(transformedData);
113
- if (columns.length === 0) {
114
- this.reset()
115
- throw new Error("upsert: no columns to upsert");
116
- }
117
-
118
- const quotedColumns = columns
119
- .map((col) => this.quoteIdentifier(col))
120
- .join(", ");
121
- const placeholders = columns.map(() => "?").join(", ");
122
-
123
- const query = `INSERT OR REPLACE INTO ${this.quoteIdentifier(this.getTableName())} (${quotedColumns}) VALUES (${placeholders})`;
124
-
125
- const values = columns.map((col) => transformedData[col] ?? null);
126
- const result = this.getDb()
127
- .prepare(query)
128
- .run(...values);
129
-
130
- const out = {
131
- changes: result.changes,
132
- };
133
- this.reset();
134
- return out;
135
- }
136
-
137
- /**
138
- * Increment a numeric column by a specified amount.
139
- * This is more efficient than fetching, calculating, and updating.
140
- *
141
- * @param column - Column name to increment
142
- * @param amount - Amount to increment by (defaults to 1)
143
- * @returns Update result with changes count
144
- */
145
- increment(column: keyof T, amount = 1): UpdateResult {
146
- this.requireWhereClause("INCREMENT");
147
-
148
- const [whereClause, whereParams] = this.buildWhereClause();
149
- const query = `UPDATE ${this.quoteIdentifier(this.getTableName())} SET ${this.quoteIdentifier(String(column))} = ${this.quoteIdentifier(String(column))} + ?${whereClause}`;
150
-
151
- const result = this.getDb()
152
- .prepare(query)
153
- .run(amount, ...whereParams);
154
-
155
- const out = {
156
- changes: result.changes,
157
- };
158
- this.reset();
159
- return out;
160
- }
161
-
162
- /**
163
- * Decrement a numeric column by a specified amount.
164
- * This is more efficient than fetching, calculating, and updating.
165
- *
166
- * @param column - Column name to decrement
167
- * @param amount - Amount to decrement by (defaults to 1)
168
- * @returns Update result with changes count
169
- */
170
- decrement(column: keyof T, amount = 1): UpdateResult {
171
- const out = this.increment(column, -amount);
172
- this.reset();
173
- return out;
174
- }
175
-
176
- /**
177
- * Update and get the updated rows back.
178
- * This is useful when you want to see the rows after the update.
179
- *
180
- * @param data - Object with columns to update and their new values
181
- * @returns Array of updated rows
182
- */
183
- updateAndGet(data: Partial<T>): T[] {
184
- // First, get the rows that will be updated
185
- const rowsToUpdate = this.all();
186
-
187
- // Perform the update
188
- const updateResult = this.update(data);
189
-
190
- if (updateResult.changes === 0) {
191
- this.reset()
192
- return [];
193
- }
194
-
195
- // For simplicity, return the originally matched rows
196
- // In a real implementation, you might want to re-fetch the updated rows
197
- const out = rowsToUpdate;
198
- this.reset();
199
- return out;
200
- }
201
-
202
- /**
203
- * Batch update multiple rows with different values.
204
- * This is more efficient than individual updates when updating many rows.
205
- *
206
- * @param updates - Array of objects, each containing update data and conditions
207
- * @returns Update result with total changes count
208
- */
209
- updateBatch(
210
- updates: Array<{ where: Partial<T>; data: Partial<T> }>,
211
- ): UpdateResult {
212
- if (!Array.isArray(updates) || updates.length === 0) {
213
- this.reset()
214
- throw new Error("updateBatch: updates must be a non-empty array");
215
- }
216
-
217
- const db = this.getDb();
218
-
219
- // Use a transaction for batch operations
220
- const transaction = db.transaction(
221
- (updatesToProcess: Array<{ where: Partial<T>; data: Partial<T> }>) => {
222
- let totalChanges = 0;
223
-
224
- for (const { where: whereData, data } of updatesToProcess) {
225
- // Transform data to handle JSON serialization
226
- const transformedUpdateData = this.transformRowToDb(data);
227
- const updateColumns = Object.keys(transformedUpdateData);
228
- if (updateColumns.length === 0) {
229
- continue; // Skip empty updates
230
- }
231
-
232
- // Build WHERE conditions for this update
233
- const whereConditions: string[] = [];
234
- const whereParams: SQLQueryBindings[] = [];
235
-
236
- for (const [column, value] of Object.entries(whereData)) {
237
- if (value === null || value === undefined) {
238
- whereConditions.push(`${this.quoteIdentifier(column)} IS NULL`);
239
- } else {
240
- whereConditions.push(`${this.quoteIdentifier(column)} = ?`);
241
- whereParams.push(value);
242
- }
243
- }
244
-
245
- if (whereConditions.length === 0) {
246
- this.reset()
247
- throw new Error(
248
- "updateBatch: each update must have WHERE conditions",
249
- );
250
- }
251
-
252
- // Build UPDATE statement
253
- const setClause = updateColumns
254
- .map((col) => `${this.quoteIdentifier(col)} = ?`)
255
- .join(", ");
256
-
257
- const whereClause = ` WHERE ${whereConditions.join(" AND ")}`;
258
- const query = `UPDATE ${this.quoteIdentifier(this.getTableName())} SET ${setClause}${whereClause}`;
259
-
260
- const updateValues = updateColumns.map(
261
- (col) => transformedUpdateData[col] ?? null,
262
- );
263
- const allParams = [...updateValues, ...whereParams];
264
-
265
- const result = db.prepare(query).run(...allParams);
266
- totalChanges += result.changes;
267
- }
268
-
269
- this.reset()
270
- return { changes: totalChanges };
271
- },
272
- );
273
-
274
- const out = transaction(updates);
275
- this.reset();
276
- return out;
277
- }
278
- }
1
+ import type { Database, SQLQueryBindings } from "bun:sqlite"
2
+ import type { Logger } from "@dockstat/logger"
3
+ import type { Parser, UpdateResult } from "../types"
4
+ import { buildSetClause, createLogger, quoteIdentifier, type RowData } from "../utils"
5
+ import { SelectQueryBuilder } from "./select"
6
+
7
+ /**
8
+ * UpdateQueryBuilder - Handles UPDATE operations with safety checks
9
+ *
10
+ * Features:
11
+ * - Safe updates (WHERE required to prevent accidental full-table updates)
12
+ * - Upsert (INSERT OR REPLACE)
13
+ * - Increment/decrement numeric columns
14
+ * - Update and get (returns updated rows)
15
+ * - Batch updates with transaction support
16
+ * - Automatic JSON serialization
17
+ */
18
+ export class UpdateQueryBuilder<T extends Record<string, unknown>> extends SelectQueryBuilder<T> {
19
+ private updateLog: ReturnType<typeof createLogger>
20
+
21
+ constructor(db: Database, tableName: string, parser: Parser<T>, baseLogger?: Logger) {
22
+ super(db, tableName, parser, baseLogger)
23
+ this.updateLog = createLogger("Update", baseLogger)
24
+ }
25
+
26
+ // ===== Public Update Methods =====
27
+
28
+ /**
29
+ * Update rows matching the WHERE conditions
30
+ *
31
+ * Requires at least one WHERE condition to prevent accidental full-table updates.
32
+ *
33
+ * @example
34
+ * table.where({ id: 1 }).update({ name: "Updated Name" })
35
+ *
36
+ * @example
37
+ * table.where({ active: false }).update({ deleted_at: Date.now() })
38
+ */
39
+ update(data: Partial<T>): UpdateResult {
40
+ this.requireWhereClause("UPDATE")
41
+
42
+ // Transform data (serialize JSON, etc.)
43
+ const transformedData = this.transformRowToDb(data)
44
+ const columns = Object.keys(transformedData)
45
+
46
+ if (columns.length === 0) {
47
+ this.reset()
48
+ throw new Error("update: no columns to update")
49
+ }
50
+
51
+ // Handle regex conditions by fetching matching rows first
52
+ if (this.hasRegexConditions()) {
53
+ return this.updateWithRegexConditions(transformedData)
54
+ }
55
+
56
+ // Build UPDATE statement
57
+ const setClause = buildSetClause(columns)
58
+ const [whereClause, whereParams] = this.buildWhereClause()
59
+
60
+ const query = `UPDATE ${quoteIdentifier(this.getTableName())} SET ${setClause}${whereClause}`
61
+ const updateValues = columns.map((col) => transformedData[col])
62
+ const allParams = [...updateValues, ...whereParams] as SQLQueryBindings[]
63
+
64
+ this.updateLog.query("UPDATE", query, allParams)
65
+
66
+ const result = this.getDb()
67
+ .prepare(query)
68
+ .run(...allParams)
69
+
70
+ this.updateLog.result("UPDATE", result.changes)
71
+ this.reset()
72
+
73
+ return { changes: result.changes }
74
+ }
75
+
76
+ /**
77
+ * Handle UPDATE when regex conditions are present
78
+ *
79
+ * Since regex conditions are applied client-side, we need to:
80
+ * 1. Fetch all rows matching SQL conditions
81
+ * 2. Filter with regex client-side
82
+ * 3. Update each matching row by rowid
83
+ */
84
+ private updateWithRegexConditions(transformedData: RowData): UpdateResult {
85
+ const columns = Object.keys(transformedData)
86
+
87
+ // Get rows matching SQL conditions (without regex)
88
+ // Use alias for rowid to avoid collision with INTEGER PRIMARY KEY columns
89
+ const [whereClause, whereParams] = this.buildWhereClause()
90
+ const selectQuery = `SELECT rowid as _rowid_, * FROM ${quoteIdentifier(this.getTableName())}${whereClause}`
91
+
92
+ const candidateRows = this.getDb()
93
+ .prepare(selectQuery)
94
+ .all(...whereParams) as (T & { _rowid_: number })[]
95
+
96
+ // Apply regex filtering
97
+ const matchingRows = this.applyRegexFiltering(candidateRows)
98
+
99
+ if (matchingRows.length === 0) {
100
+ this.reset()
101
+ return { changes: 0 }
102
+ }
103
+
104
+ // Update each matching row by rowid
105
+ const setClause = buildSetClause(columns)
106
+ const updateQuery = `UPDATE ${quoteIdentifier(this.getTableName())} SET ${setClause} WHERE rowid = ?`
107
+ const stmt = this.getDb().prepare(updateQuery)
108
+
109
+ this.updateLog.query("UPDATE (regex)", updateQuery)
110
+
111
+ let totalChanges = 0
112
+ const updateValues = columns.map((col) => transformedData[col])
113
+
114
+ for (const row of matchingRows) {
115
+ const result = stmt.run(...updateValues, row._rowid_ as SQLQueryBindings)
116
+ totalChanges += result.changes
117
+ }
118
+
119
+ this.updateLog.result("UPDATE (regex)", totalChanges)
120
+ this.reset()
121
+
122
+ return { changes: totalChanges }
123
+ }
124
+
125
+ /**
126
+ * Upsert (INSERT OR REPLACE) a row
127
+ *
128
+ * If a row with the same unique key exists, it will be replaced.
129
+ * Otherwise, a new row will be inserted.
130
+ *
131
+ * Requires a unique constraint or primary key on the table.
132
+ *
133
+ * @example
134
+ * table.upsert({ email: "alice@example.com", name: "Alice Updated" })
135
+ */
136
+ upsert(data: Partial<T>): UpdateResult {
137
+ const transformedData = this.transformRowToDb(data)
138
+ const columns = Object.keys(transformedData)
139
+
140
+ if (columns.length === 0) {
141
+ this.reset()
142
+ throw new Error("upsert: no columns to upsert")
143
+ }
144
+
145
+ const columnList = columns.map((col) => quoteIdentifier(col)).join(", ")
146
+ const placeholders = columns.map(() => "?").join(", ")
147
+ const values = columns.map((col) => transformedData[col] ?? null)
148
+
149
+ const query = `INSERT OR REPLACE INTO ${quoteIdentifier(this.getTableName())} (${columnList}) VALUES (${placeholders})`
150
+
151
+ this.updateLog.query("UPSERT", query, values)
152
+
153
+ const result = this.getDb()
154
+ .prepare(query)
155
+ .run(...values)
156
+
157
+ this.updateLog.result("UPSERT", result.changes)
158
+ this.reset()
159
+
160
+ return { changes: result.changes }
161
+ }
162
+
163
+ /**
164
+ * Increment a numeric column by a specified amount
165
+ *
166
+ * More efficient than fetching, calculating, and updating.
167
+ *
168
+ * @example
169
+ * table.where({ id: 1 }).increment("login_count")
170
+ * table.where({ id: 1 }).increment("points", 10)
171
+ */
172
+ increment(column: keyof T, amount = 1): UpdateResult {
173
+ this.requireWhereClause("INCREMENT")
174
+
175
+ const [whereClause, whereParams] = this.buildWhereClause()
176
+ const quotedColumn = quoteIdentifier(String(column))
177
+
178
+ const query = `UPDATE ${quoteIdentifier(this.getTableName())} SET ${quotedColumn} = ${quotedColumn} + ?${whereClause}`
179
+ const params = [amount, ...whereParams] as SQLQueryBindings[]
180
+
181
+ this.updateLog.query("INCREMENT", query, params)
182
+
183
+ const result = this.getDb()
184
+ .prepare(query)
185
+ .run(...params)
186
+
187
+ this.updateLog.result("INCREMENT", result.changes)
188
+ this.reset()
189
+
190
+ return { changes: result.changes }
191
+ }
192
+
193
+ /**
194
+ * Decrement a numeric column by a specified amount
195
+ *
196
+ * Equivalent to increment with a negative amount.
197
+ *
198
+ * @example
199
+ * table.where({ id: 1 }).decrement("credits")
200
+ * table.where({ id: 1 }).decrement("stock", 5)
201
+ */
202
+ decrement(column: keyof T, amount = 1): UpdateResult {
203
+ return this.increment(column, -amount)
204
+ }
205
+
206
+ /**
207
+ * Update rows and return the updated rows
208
+ *
209
+ * Note: Returns the rows as they were BEFORE the update.
210
+ * For post-update values, query the rows again after updating.
211
+ *
212
+ * @example
213
+ * const updatedRows = table.where({ active: false }).updateAndGet({ deleted: true })
214
+ */
215
+ updateAndGet(data: Partial<T>): T[] {
216
+ // Preserve WHERE state before all() resets it
217
+ const savedWhereConditions = [...this.state.whereConditions]
218
+ const savedWhereParams = [...this.state.whereParams]
219
+ const savedRegexConditions = [...this.state.regexConditions]
220
+
221
+ // Get rows before update
222
+ const rowsToUpdate = this.all()
223
+
224
+ // Restore WHERE state for the update
225
+ this.state.whereConditions = savedWhereConditions
226
+ this.state.whereParams = savedWhereParams
227
+ this.state.regexConditions = savedRegexConditions
228
+
229
+ // Perform the update
230
+ const updateResult = this.update(data)
231
+
232
+ if (updateResult.changes === 0) {
233
+ return []
234
+ }
235
+
236
+ return rowsToUpdate
237
+ }
238
+
239
+ /**
240
+ * Batch update multiple rows with different values
241
+ *
242
+ * Each update item specifies its own WHERE conditions and data.
243
+ * All updates are wrapped in a transaction for atomicity.
244
+ *
245
+ * @example
246
+ * table.updateBatch([
247
+ * { where: { id: 1 }, data: { name: "Alice" } },
248
+ * { where: { id: 2 }, data: { name: "Bob" } },
249
+ * ])
250
+ */
251
+ updateBatch(updates: Array<{ where: Partial<T>; data: Partial<T> }>): UpdateResult {
252
+ if (!Array.isArray(updates) || updates.length === 0) {
253
+ this.reset()
254
+ throw new Error("updateBatch: updates must be a non-empty array")
255
+ }
256
+
257
+ const db = this.getDb()
258
+
259
+ const transaction = db.transaction(
260
+ (updatesToProcess: Array<{ where: Partial<T>; data: Partial<T> }>) => {
261
+ let totalChanges = 0
262
+
263
+ for (const { where: whereData, data } of updatesToProcess) {
264
+ // Transform data
265
+ const transformedData = this.transformRowToDb(data)
266
+ const updateColumns = Object.keys(transformedData)
267
+
268
+ if (updateColumns.length === 0) {
269
+ continue // Skip empty updates
270
+ }
271
+
272
+ // Build WHERE conditions
273
+ const whereConditions: string[] = []
274
+ const whereParams: SQLQueryBindings[] = []
275
+
276
+ for (const [column, value] of Object.entries(whereData)) {
277
+ if (value === null || value === undefined) {
278
+ whereConditions.push(`${quoteIdentifier(column)} IS NULL`)
279
+ } else {
280
+ whereConditions.push(`${quoteIdentifier(column)} = ?`)
281
+ whereParams.push(value as SQLQueryBindings)
282
+ }
283
+ }
284
+
285
+ if (whereConditions.length === 0) {
286
+ throw new Error("updateBatch: each update must have WHERE conditions")
287
+ }
288
+
289
+ // Build UPDATE statement
290
+ const setClause = buildSetClause(updateColumns)
291
+ const whereClause = ` WHERE ${whereConditions.join(" AND ")}`
292
+ const query = `UPDATE ${quoteIdentifier(this.getTableName())} SET ${setClause}${whereClause}`
293
+
294
+ const updateValues = updateColumns.map((col) => transformedData[col] ?? null)
295
+ const allParams = [...updateValues, ...whereParams] as SQLQueryBindings[]
296
+
297
+ const result = db.prepare(query).run(...allParams)
298
+ totalChanges += result.changes
299
+ }
300
+
301
+ return { changes: totalChanges }
302
+ }
303
+ )
304
+
305
+ this.updateLog.query("UPDATE BATCH", `${updates.length} updates`)
306
+
307
+ const result = transaction(updates)
308
+
309
+ this.updateLog.result("UPDATE BATCH", result.changes)
310
+ this.reset()
311
+
312
+ return result
313
+ }
314
+ }