@dockstat/sqlite-wrapper 1.2.7 → 1.2.8
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/LICENSE +373 -373
- package/README.md +99 -99
- package/index.ts +858 -840
- package/package.json +54 -54
- package/query-builder/base.ts +221 -221
- package/query-builder/delete.ts +352 -352
- package/query-builder/index.ts +431 -431
- package/query-builder/insert.ts +249 -249
- package/query-builder/select.ts +358 -358
- package/query-builder/update.ts +278 -278
- package/query-builder/where.ts +307 -307
- package/types.ts +623 -623
package/query-builder/select.ts
CHANGED
|
@@ -1,358 +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
|
-
}
|
|
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
|
+
}
|