@dockstat/sqlite-wrapper 1.2.7 → 1.3.0

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