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