@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,35 +1,39 @@
1
- import type { Database, SQLQueryBindings } from 'bun:sqlite'
2
- import type { ColumnNames, JsonColumnConfig, OrderDirection } from '../types'
3
- import { WhereQueryBuilder } from './where'
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
- * Mixin class that adds SELECT-specific functionality to the QueryBuilder.
7
- * Handles column selection, ordering, limiting, and result execution methods.
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
- T extends Record<string, unknown>,
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
- 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
- }
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
- * @param columns - Array of column names or ["*"] for all columns
32
- * @returns this for method chaining
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
- * @param column - Column name to order by
43
- * @returns this for method chaining
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 = 'DESC'
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 = 'ASC'
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
- * @param amount - Maximum number of rows to return
74
- * @returns this for method chaining
73
+ * @example
74
+ * .limit(10)
75
75
  */
76
76
  limit(amount: number): this {
77
77
  if (amount < 0) {
78
- throw new Error('limit: amount must be non-negative')
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
- * @param start - Number of rows to skip
88
- * @returns this for method chaining
87
+ * @example
88
+ * .offset(20)
89
89
  */
90
90
  offset(start: number): this {
91
91
  if (start < 0) {
92
- throw new Error('offset: start must be non-negative')
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 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]
101
+ * Build the SELECT query SQL
105
102
  */
106
- private buildSelectQuery(
107
- includeOrderAndLimit = true
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
- let query = `SELECT ${cols} FROM ${this.quoteIdentifier(this.getTableName())}`
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 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
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()) return rows
141
+ if (!this.hasRegexConditions()) {
142
+ return rows
143
+ }
145
144
 
146
- // Apply regex filters
147
- let filtered = this.applyRegexFiltering(rows)
145
+ // Apply regex filters first
146
+ let result = this.applyRegexFiltering(rows)
148
147
 
149
- // Apply ordering in JavaScript
148
+ // Apply ordering
150
149
  if (this.orderColumn) {
151
150
  const col = String(this.orderColumn)
152
- filtered.sort((a: T, b: T) => {
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 -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
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 & limit in JavaScript
165
+ // Apply offset and limit
164
166
  const start = this.offsetValue ?? 0
165
167
  if (this.limitValue !== undefined) {
166
- filtered = filtered.slice(start, start + this.limitValue)
168
+ result = result.slice(start, start + this.limitValue)
167
169
  } else if (start > 0) {
168
- filtered = filtered.slice(start)
170
+ result = result.slice(start)
169
171
  }
170
172
 
171
- return filtered
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
- * @returns Array of rows matching the query
181
+ * @example
182
+ * const users = table.select(["*"]).where({ active: true }).all()
178
183
  */
179
184
  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
- }
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
- 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)
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 if none found.
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
- * @returns First matching row or null
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 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
- )
215
+ const optimizedQuery = `${query} LIMIT 1`
216
+
217
+ this.selectLog.query("SELECT (get)", optimizedQuery, params)
218
+
225
219
  const row = this.getDb()
226
- .prepare(q)
220
+ .prepare(optimizedQuery)
227
221
  .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
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
- if (!this.hasRegexConditions() && this.limitValue !== undefined) {
238
- // Limit is present; just use the query as-is
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
- this.getLogger().debug(
241
- `get() - path 2: ${JSON.stringify({
242
- query,
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
- 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
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, need to process client-side
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 if none found.
268
- * Always respects the semantics of returning the first row regardless of LIMIT.
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
- // 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
- )
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(countQuery)
298
- .get(...params) as {
299
- __count: number
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, count client-side
307
- return this.all().length
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 [baseQuery, params] = this.buildSelectQuery(true)
319
- const existsQuery = `SELECT EXISTS(${baseQuery}) AS __exists`
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(existsQuery)
322
- .get(...params) as {
323
- __exists: number
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, check client-side
308
+ // Has regex conditions - check client-side
331
309
  return this.count() > 0
332
310
  }
333
311
 
334
312
  /**
335
- * Execute the query and return a single column value from the first row.
336
- * Useful for getting a specific field value.
313
+ * Get a single column value from the first matching row
337
314
  *
338
- * @param column - Column name to extract the value from
339
- * @returns The value of the specified column from the first row, or null
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
- * Execute the query and return an array of values from a single column.
324
+ * Get an array of values from a single column
349
325
  *
350
- * @param column - Column name to extract values from
351
- * @returns Array of values from the specified column
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
  }