@dockstat/sqlite-wrapper 1.2.2 → 1.2.4
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/index.ts +3 -1
- package/package.json +7 -2
- package/query-builder/base.ts +216 -0
- package/query-builder/delete.ts +352 -0
- package/query-builder/index.ts +431 -0
- package/query-builder/insert.ts +243 -0
- package/query-builder/select.ts +358 -0
- package/query-builder/update.ts +278 -0
- package/query-builder/where.ts +307 -0
- package/types.ts +1 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { Database, SQLQueryBindings } from 'bun:sqlite'
|
|
2
|
+
import type { ColumnNames, JsonColumnConfig, OrderDirection } from '../types'
|
|
3
|
+
import { WhereQueryBuilder } from './where'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mixin class that adds SELECT-specific functionality to the QueryBuilder.
|
|
7
|
+
* Handles column selection, ordering, limiting, and result execution methods.
|
|
8
|
+
*/
|
|
9
|
+
export class SelectQueryBuilder<
|
|
10
|
+
T extends Record<string, unknown>,
|
|
11
|
+
> extends WhereQueryBuilder<T> {
|
|
12
|
+
private selectedColumns: ColumnNames<T>
|
|
13
|
+
private orderColumn?: keyof T
|
|
14
|
+
private orderDirection: OrderDirection
|
|
15
|
+
private limitValue?: number
|
|
16
|
+
private offsetValue?: number
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
db: Database,
|
|
20
|
+
tableName: string,
|
|
21
|
+
jsonConfig?: JsonColumnConfig<T>
|
|
22
|
+
) {
|
|
23
|
+
super(db, tableName, jsonConfig)
|
|
24
|
+
this.selectedColumns = ['*']
|
|
25
|
+
this.orderDirection = 'ASC'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Specify which columns to select.
|
|
30
|
+
*
|
|
31
|
+
* @param columns - Array of column names or ["*"] for all columns
|
|
32
|
+
* @returns this for method chaining
|
|
33
|
+
*/
|
|
34
|
+
select(columns: ColumnNames<T>): this {
|
|
35
|
+
this.selectedColumns = columns
|
|
36
|
+
return this
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Add ORDER BY clause.
|
|
41
|
+
*
|
|
42
|
+
* @param column - Column name to order by
|
|
43
|
+
* @returns this for method chaining
|
|
44
|
+
*/
|
|
45
|
+
orderBy(column: keyof T): this {
|
|
46
|
+
this.orderColumn = column
|
|
47
|
+
return this
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Set order direction to descending.
|
|
52
|
+
*
|
|
53
|
+
* @returns this for method chaining
|
|
54
|
+
*/
|
|
55
|
+
desc(): this {
|
|
56
|
+
this.orderDirection = 'DESC'
|
|
57
|
+
return this
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Set order direction to ascending (default).
|
|
62
|
+
*
|
|
63
|
+
* @returns this for method chaining
|
|
64
|
+
*/
|
|
65
|
+
asc(): this {
|
|
66
|
+
this.orderDirection = 'ASC'
|
|
67
|
+
return this
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Add LIMIT clause.
|
|
72
|
+
*
|
|
73
|
+
* @param amount - Maximum number of rows to return
|
|
74
|
+
* @returns this for method chaining
|
|
75
|
+
*/
|
|
76
|
+
limit(amount: number): this {
|
|
77
|
+
if (amount < 0) {
|
|
78
|
+
throw new Error('limit: amount must be non-negative')
|
|
79
|
+
}
|
|
80
|
+
this.limitValue = amount
|
|
81
|
+
return this
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Add OFFSET clause.
|
|
86
|
+
*
|
|
87
|
+
* @param start - Number of rows to skip
|
|
88
|
+
* @returns this for method chaining
|
|
89
|
+
*/
|
|
90
|
+
offset(start: number): this {
|
|
91
|
+
if (start < 0) {
|
|
92
|
+
throw new Error('offset: start must be non-negative')
|
|
93
|
+
}
|
|
94
|
+
this.offsetValue = start
|
|
95
|
+
return this
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the complete SELECT query.
|
|
100
|
+
* If regex conditions exist, ORDER/LIMIT/OFFSET are not included in SQL
|
|
101
|
+
* as they will be applied client-side after regex filtering.
|
|
102
|
+
*
|
|
103
|
+
* @param includeOrderAndLimit - Whether to include ORDER/LIMIT/OFFSET in SQL
|
|
104
|
+
* @returns Tuple of [query, parameters]
|
|
105
|
+
*/
|
|
106
|
+
private buildSelectQuery(
|
|
107
|
+
includeOrderAndLimit = true
|
|
108
|
+
): [string, SQLQueryBindings[]] {
|
|
109
|
+
const cols =
|
|
110
|
+
this.selectedColumns[0] === '*'
|
|
111
|
+
? '*'
|
|
112
|
+
: (this.selectedColumns as string[]).join(', ')
|
|
113
|
+
|
|
114
|
+
let query = `SELECT ${cols} FROM ${this.quoteIdentifier(this.getTableName())}`
|
|
115
|
+
|
|
116
|
+
const [whereClause, whereParams] = this.buildWhereClause()
|
|
117
|
+
query += whereClause
|
|
118
|
+
|
|
119
|
+
if (includeOrderAndLimit && !this.hasRegexConditions()) {
|
|
120
|
+
if (this.orderColumn) {
|
|
121
|
+
query += ` ORDER BY ${String(this.orderColumn)} ${this.orderDirection}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (this.limitValue !== undefined) {
|
|
125
|
+
query += ` LIMIT ${this.limitValue}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (this.offsetValue !== undefined) {
|
|
129
|
+
query += ` OFFSET ${this.offsetValue}`
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return [query, whereParams]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Apply JavaScript-based filtering, ordering, and pagination.
|
|
138
|
+
* Used when regex conditions require client-side processing.
|
|
139
|
+
*
|
|
140
|
+
* @param rows - Rows to process
|
|
141
|
+
* @returns Processed rows
|
|
142
|
+
*/
|
|
143
|
+
private applyClientSideOperations(rows: T[]): T[] {
|
|
144
|
+
if (!this.hasRegexConditions()) return rows
|
|
145
|
+
|
|
146
|
+
// Apply regex filters
|
|
147
|
+
let filtered = this.applyRegexFiltering(rows)
|
|
148
|
+
|
|
149
|
+
// Apply ordering in JavaScript
|
|
150
|
+
if (this.orderColumn) {
|
|
151
|
+
const col = String(this.orderColumn)
|
|
152
|
+
filtered.sort((a: T, b: T) => {
|
|
153
|
+
const va = a[col]
|
|
154
|
+
const vb = b[col]
|
|
155
|
+
if (va === vb) return 0
|
|
156
|
+
if (va === null || va === undefined) return -1
|
|
157
|
+
if (vb === null || vb === undefined) return 1
|
|
158
|
+
if (va < vb) return this.orderDirection === 'ASC' ? -1 : 1
|
|
159
|
+
return this.orderDirection === 'ASC' ? 1 : -1
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Apply offset & limit in JavaScript
|
|
164
|
+
const start = this.offsetValue ?? 0
|
|
165
|
+
if (this.limitValue !== undefined) {
|
|
166
|
+
filtered = filtered.slice(start, start + this.limitValue)
|
|
167
|
+
} else if (start > 0) {
|
|
168
|
+
filtered = filtered.slice(start)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return filtered
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Execute the query and return all matching rows.
|
|
176
|
+
*
|
|
177
|
+
* @returns Array of rows matching the query
|
|
178
|
+
*/
|
|
179
|
+
all(): T[] {
|
|
180
|
+
if (!this.hasRegexConditions()) {
|
|
181
|
+
const [query, params] = this.buildSelectQuery(true)
|
|
182
|
+
this.getLogger().debug(
|
|
183
|
+
`Executing SELECT query - query: ${query}, params: ${JSON.stringify(params)}, hasJsonColumns: ${!!this.state.jsonColumns}`
|
|
184
|
+
)
|
|
185
|
+
const rows = this.getDb()
|
|
186
|
+
.prepare(query, params)
|
|
187
|
+
.all() as T[]
|
|
188
|
+
this.getLogger().debug(`Retrieved ${rows.length} rows from database`)
|
|
189
|
+
const transformed = this.transformRowsFromDb(rows)
|
|
190
|
+
this.getLogger().debug(`Transformed ${transformed.length} rows`)
|
|
191
|
+
this.reset()
|
|
192
|
+
return transformed
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const [query, params] = this.buildSelectQuery(false)
|
|
196
|
+
this.getLogger().debug(
|
|
197
|
+
`Executing SELECT query with regex conditions - query: ${query}, params: ${JSON.stringify(params)}, hasJsonColumns: ${!!this.state.jsonColumns}`
|
|
198
|
+
)
|
|
199
|
+
const rows = this.getDb()
|
|
200
|
+
.prepare(query)
|
|
201
|
+
.all(...params) as T[]
|
|
202
|
+
this.getLogger().debug(`Retrieved ${rows.length} rows for regex filtering`)
|
|
203
|
+
const transformedRows = this.transformRowsFromDb(rows)
|
|
204
|
+
this.getLogger().debug(
|
|
205
|
+
`Transformed ${transformedRows.length} rows for regex filtering`
|
|
206
|
+
)
|
|
207
|
+
this.reset()
|
|
208
|
+
return this.applyClientSideOperations(transformedRows)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Execute the query and return the first matching row, or null if none found.
|
|
213
|
+
* If no explicit LIMIT is set, adds LIMIT 1 for efficiency.
|
|
214
|
+
*
|
|
215
|
+
* @returns First matching row or null
|
|
216
|
+
*/
|
|
217
|
+
get(): T | null {
|
|
218
|
+
if (!this.hasRegexConditions() && this.limitValue === undefined) {
|
|
219
|
+
// No regex and no explicit limit, we can safely add LIMIT 1
|
|
220
|
+
const [query, params] = this.buildSelectQuery(true)
|
|
221
|
+
const q = query.includes('LIMIT') ? query : `${query} LIMIT 1`
|
|
222
|
+
this.getLogger().debug(
|
|
223
|
+
`Executing single-row SELECT query - query: ${q}, params: ${JSON.stringify(params)}, hasJsonColumns: ${!!this.state.jsonColumns}`
|
|
224
|
+
)
|
|
225
|
+
const row = this.getDb()
|
|
226
|
+
.prepare(q)
|
|
227
|
+
.get(...params) as T | null
|
|
228
|
+
this.getLogger().debug(`Row found: ${row ? 'yes' : 'no'}`)
|
|
229
|
+
const transformed = row ? this.transformRowFromDb(row) : null
|
|
230
|
+
this.getLogger().debug(
|
|
231
|
+
`Transformed row available: ${transformed ? 'yes' : 'no'}`
|
|
232
|
+
)
|
|
233
|
+
this.reset()
|
|
234
|
+
return transformed
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!this.hasRegexConditions() && this.limitValue !== undefined) {
|
|
238
|
+
// Limit is present; just use the query as-is
|
|
239
|
+
const [query, params] = this.buildSelectQuery(true)
|
|
240
|
+
this.getLogger().debug(
|
|
241
|
+
`get() - path 2: ${JSON.stringify({
|
|
242
|
+
query,
|
|
243
|
+
params,
|
|
244
|
+
hasJsonColumns: !!this.state.jsonColumns,
|
|
245
|
+
})}`
|
|
246
|
+
)
|
|
247
|
+
const row = this.getDb()
|
|
248
|
+
.prepare(query)
|
|
249
|
+
.get(...params) as T | null
|
|
250
|
+
this.getLogger().debug(`raw row (path 2): ${row ? 'found' : 'null'}`)
|
|
251
|
+
const transformed = row ? this.transformRowFromDb(row) : null
|
|
252
|
+
this.getLogger().debug(
|
|
253
|
+
`transformed row (path 2): ${transformed ? 'found' : 'null'}`
|
|
254
|
+
)
|
|
255
|
+
this.reset()
|
|
256
|
+
return transformed
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Has regex conditions, need to process client-side
|
|
260
|
+
this.getLogger().debug('path 3 (regex fallback)')
|
|
261
|
+
const results = this.all()
|
|
262
|
+
this.reset()
|
|
263
|
+
return results[0] ?? null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Execute the query and return the first matching row, or null if none found.
|
|
268
|
+
* Always respects the semantics of returning the first row regardless of LIMIT.
|
|
269
|
+
*
|
|
270
|
+
* @returns First matching row or null
|
|
271
|
+
*/
|
|
272
|
+
first(): T | null {
|
|
273
|
+
// Temporarily set limit to 1 but preserve previous value
|
|
274
|
+
const prevLimit = this.limitValue
|
|
275
|
+
this.limitValue = 1
|
|
276
|
+
const result = this.get()
|
|
277
|
+
this.limitValue = prevLimit
|
|
278
|
+
this.reset()
|
|
279
|
+
return result
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Execute a COUNT query and return the number of matching rows.
|
|
284
|
+
* For regex conditions, this fetches all rows and counts client-side.
|
|
285
|
+
*
|
|
286
|
+
* @returns Number of matching rows
|
|
287
|
+
*/
|
|
288
|
+
count(): number {
|
|
289
|
+
if (!this.hasRegexConditions()) {
|
|
290
|
+
// Safe to do COUNT(*) in SQL
|
|
291
|
+
const [baseQuery, params] = this.buildSelectQuery(true)
|
|
292
|
+
const countQuery = baseQuery.replace(
|
|
293
|
+
/SELECT (.+?) FROM/i,
|
|
294
|
+
'SELECT COUNT(*) AS __count FROM'
|
|
295
|
+
)
|
|
296
|
+
const result = this.getDb()
|
|
297
|
+
.prepare(countQuery)
|
|
298
|
+
.get(...params) as {
|
|
299
|
+
__count: number
|
|
300
|
+
}
|
|
301
|
+
this.reset()
|
|
302
|
+
return result?.__count ?? 0
|
|
303
|
+
}
|
|
304
|
+
this.reset()
|
|
305
|
+
|
|
306
|
+
// Has regex conditions, count client-side
|
|
307
|
+
return this.all().length
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Check if any rows match the current conditions.
|
|
312
|
+
*
|
|
313
|
+
* @returns true if at least one row matches, false otherwise
|
|
314
|
+
*/
|
|
315
|
+
exists(): boolean {
|
|
316
|
+
if (!this.hasRegexConditions()) {
|
|
317
|
+
// Use EXISTS for efficiency
|
|
318
|
+
const [baseQuery, params] = this.buildSelectQuery(true)
|
|
319
|
+
const existsQuery = `SELECT EXISTS(${baseQuery}) AS __exists`
|
|
320
|
+
const result = this.getDb()
|
|
321
|
+
.prepare(existsQuery)
|
|
322
|
+
.get(...params) as {
|
|
323
|
+
__exists: number
|
|
324
|
+
}
|
|
325
|
+
this.reset()
|
|
326
|
+
return Boolean(result?.__exists)
|
|
327
|
+
}
|
|
328
|
+
this.reset()
|
|
329
|
+
|
|
330
|
+
// Has regex conditions, check client-side
|
|
331
|
+
return this.count() > 0
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Execute the query and return a single column value from the first row.
|
|
336
|
+
* Useful for getting a specific field value.
|
|
337
|
+
*
|
|
338
|
+
* @param column - Column name to extract the value from
|
|
339
|
+
* @returns The value of the specified column from the first row, or null
|
|
340
|
+
*/
|
|
341
|
+
value<K extends keyof T>(column: K): T[K] | null {
|
|
342
|
+
const row = this.first()
|
|
343
|
+
this.reset()
|
|
344
|
+
return row ? row[column] : null
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Execute the query and return an array of values from a single column.
|
|
349
|
+
*
|
|
350
|
+
* @param column - Column name to extract values from
|
|
351
|
+
* @returns Array of values from the specified column
|
|
352
|
+
*/
|
|
353
|
+
pluck<K extends keyof T>(column: K): T[K][] {
|
|
354
|
+
const rows = this.all()
|
|
355
|
+
this.reset()
|
|
356
|
+
return rows.map((row) => row[column])
|
|
357
|
+
}
|
|
358
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
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
|
+
}
|