@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,307 +1,272 @@
1
- import type { SQLQueryBindings } from "bun:sqlite";
2
- import type { WhereCondition, RegexCondition } from "../types";
3
- import { BaseQueryBuilder } from "./base";
1
+ import type { SQLQueryBindings } from "bun:sqlite"
2
+ import type { RegexCondition, WhereCondition } from "../types"
3
+ import { buildBetweenClause, buildInClause, normalizeOperator, quoteIdentifier } from "../utils"
4
+ import { BaseQueryBuilder } from "./base"
4
5
 
5
6
  /**
6
- * Mixin class that adds WHERE-related functionality to the QueryBuilder.
7
- * This includes all conditional filtering methods.
7
+ * WhereQueryBuilder - Adds WHERE clause functionality to the QueryBuilder
8
+ *
9
+ * Provides methods for building SQL WHERE conditions:
10
+ * - Simple equality conditions
11
+ * - Comparison operators
12
+ * - IN/NOT IN clauses
13
+ * - BETWEEN clauses
14
+ * - NULL checks
15
+ * - Raw SQL expressions
16
+ * - Regex conditions (client-side filtering)
8
17
  */
9
- export class WhereQueryBuilder<
10
- T extends Record<string, unknown>,
11
- > extends BaseQueryBuilder<T> {
18
+ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQueryBuilder<T> {
19
+ // ===== Private Helpers =====
20
+
12
21
  /**
13
- * Remove existing condition for a column
14
- * @param column - Column name to check
15
- * @param operation - Optional operation type (e.g., '=', 'IN', 'BETWEEN')
22
+ * Remove an existing condition for a column to prevent duplicates
16
23
  */
17
24
  private removeExistingCondition(column: string, operation?: string): void {
18
- let existingIndex = -1;
19
-
20
- if (operation) {
21
- // Look for specific operation
22
- existingIndex = this.state.whereConditions.findIndex(condition =>
23
- condition.startsWith(`${String(column)} ${operation}`)
24
- );
25
- } else {
26
- // Look for any condition on this column
27
- existingIndex = this.state.whereConditions.findIndex(condition =>
28
- condition.startsWith(`${String(column)} `)
29
- );
30
- }
25
+ const columnPattern = operation ? `${column} ${operation}` : `${column} `
26
+
27
+ const existingIndex = this.state.whereConditions.findIndex(
28
+ (condition) => condition.startsWith(columnPattern) || condition.startsWith(`"${column}"`)
29
+ )
31
30
 
32
31
  if (existingIndex !== -1) {
33
- this.state.whereConditions.splice(existingIndex, 1);
34
- // Only remove params if they exist (some conditions might not have params)
32
+ this.state.whereConditions.splice(existingIndex, 1)
33
+ // Remove corresponding params if they exist
35
34
  if (existingIndex < this.state.whereParams.length) {
36
- this.state.whereParams.splice(existingIndex, 1);
35
+ this.state.whereParams.splice(existingIndex, 1)
37
36
  }
38
37
  }
39
38
 
40
39
  // Also remove any regex conditions for this column
41
40
  this.state.regexConditions = this.state.regexConditions.filter(
42
- cond => String(cond.column) !== column
43
- );
41
+ (cond) => String(cond.column) !== column
42
+ )
44
43
  }
45
44
 
46
45
  /**
47
- * Add simple equality conditions to the WHERE clause.
48
- * Handles null values appropriately with IS NULL / IS NOT NULL.
49
- * Prevents duplicate conditions for the same column.
46
+ * Convert a JavaScript value to SQLite-compatible value
47
+ */
48
+ private toSqliteValue(value: unknown): SQLQueryBindings {
49
+ if (typeof value === "boolean") {
50
+ return value ? 1 : 0
51
+ }
52
+ return value as SQLQueryBindings
53
+ }
54
+
55
+ // ===== Public WHERE Methods =====
56
+
57
+ /**
58
+ * Add simple equality conditions to the WHERE clause
59
+ *
60
+ * @example
61
+ * .where({ name: "Alice", active: true })
62
+ * // WHERE "name" = ? AND "active" = ?
50
63
  *
51
- * @param conditions - Object with column-value pairs for equality checks
52
- * @returns this for method chaining
64
+ * @example
65
+ * .where({ deleted_at: null })
66
+ * // WHERE "deleted_at" IS NULL
53
67
  */
54
- where(conditions: WhereCondition<T>): this {
55
- for (const [column, value] of Object.entries(conditions)) {
56
- // Remove any existing conditions for this column
57
- this.removeExistingCondition(column);
58
-
59
- if (value === null || value === undefined) {
60
- this.state.whereConditions.push(`${String(column)} IS NULL`);
61
- } else {
62
- this.state.whereConditions.push(`${String(column)} = ?`);
63
-
64
- // Convert JavaScript boolean to SQLite integer (0/1)
65
- let sqliteValue: SQLQueryBindings = value;
66
- if (typeof value === 'boolean') {
67
- sqliteValue = value ? 1 : 0;
68
- this.getLogger().debug(`Converting boolean value ${value} to ${sqliteValue} for column ${column}`);
69
- }
70
-
71
- this.state.whereParams.push(sqliteValue);
72
- }
68
+ where(conditions: WhereCondition<T>): this {
69
+ for (const [column, value] of Object.entries(conditions)) {
70
+ this.removeExistingCondition(column)
71
+
72
+ if (value === null || value === undefined) {
73
+ this.state.whereConditions.push(`${quoteIdentifier(column)} IS NULL`)
74
+ } else {
75
+ this.state.whereConditions.push(`${quoteIdentifier(column)} = ?`)
76
+ this.state.whereParams.push(this.toSqliteValue(value))
73
77
  }
74
- return this;
75
78
  }
79
+ return this
80
+ }
76
81
 
77
82
  /**
78
- * Add regex conditions. Note: regex conditions are applied client-side
79
- * after SQL execution due to Bun's SQLite limitations.
80
- * Prevents duplicate regex conditions for the same column.
83
+ * Add regex conditions (applied client-side after SQL execution)
81
84
  *
82
- * @param conditions - Object with column-regex pairs
83
- * @returns this for method chaining
85
+ * @example
86
+ * .whereRgx({ email: /@gmail\.com$/ })
84
87
  */
85
88
  whereRgx(conditions: RegexCondition<T>): this {
86
89
  for (const [column, value] of Object.entries(conditions)) {
87
- // Remove any existing conditions for this column
88
- this.removeExistingCondition(column);
90
+ this.removeExistingCondition(column)
89
91
 
90
92
  if (value instanceof RegExp) {
91
93
  this.state.regexConditions.push({
92
94
  column: column as keyof T,
93
95
  regex: value,
94
- });
96
+ })
95
97
  } else if (typeof value === "string") {
96
98
  this.state.regexConditions.push({
97
99
  column: column as keyof T,
98
100
  regex: new RegExp(value),
99
- });
101
+ })
100
102
  } else if (value !== null && value !== undefined) {
101
- this.state.whereConditions.push(`${String(column)} = ?`);
102
- this.state.whereParams.push(value);
103
+ // Fall back to equality check for non-regex values
104
+ this.state.whereConditions.push(`${quoteIdentifier(column)} = ?`)
105
+ this.state.whereParams.push(value as SQLQueryBindings)
103
106
  }
104
107
  }
105
- return this;
108
+ return this
106
109
  }
107
110
 
108
111
  /**
109
- * Add a raw SQL WHERE fragment with parameter binding.
110
- * Note: Raw expressions bypass duplicate checking as they may be complex conditions.
112
+ * Add a raw SQL WHERE expression with parameter binding
111
113
  *
112
- * @param expr - SQL fragment (without leading WHERE/AND), can use ? placeholders
113
- * @param params - Values for the placeholders in order
114
- * @returns this for method chaining
114
+ * @example
115
+ * .whereExpr("LENGTH(name) > ?", [5])
116
+ * .whereExpr("created_at > datetime('now', '-1 day')")
115
117
  */
116
118
  whereExpr(expr: string, params: SQLQueryBindings[] = []): this {
117
119
  if (!expr || typeof expr !== "string") {
118
- throw new Error("whereExpr: expr must be a non-empty string");
120
+ throw new Error("whereExpr: expression must be a non-empty string")
119
121
  }
120
- // Wrap in parentheses to preserve grouping when combined with other clauses
121
- this.state.whereConditions.push(`(${expr})`);
122
- if (params.length) {
123
- this.state.whereParams.push(...params);
122
+
123
+ // Wrap in parentheses to preserve grouping
124
+ this.state.whereConditions.push(`(${expr})`)
125
+
126
+ if (params.length > 0) {
127
+ this.state.whereParams.push(...params)
124
128
  }
125
- return this;
129
+
130
+ return this
126
131
  }
127
132
 
128
133
  /**
129
- * Alias for whereExpr for compatibility
134
+ * Alias for whereExpr
130
135
  */
131
136
  whereRaw(expr: string, params: SQLQueryBindings[] = []): this {
132
- return this.whereExpr(expr, params);
137
+ return this.whereExpr(expr, params)
133
138
  }
134
139
 
135
140
  /**
136
- * Add an IN clause for the given column with proper parameter binding.
137
- * Replaces any existing conditions for the same column.
141
+ * Add an IN clause
138
142
  *
139
- * @param column - Column name to check
140
- * @param values - Non-empty array of values for the IN clause
141
- * @returns this for method chaining
143
+ * @example
144
+ * .whereIn("status", ["active", "pending"])
145
+ * // WHERE "status" IN (?, ?)
142
146
  */
143
147
  whereIn(column: keyof T, values: SQLQueryBindings[]): this {
144
148
  if (!Array.isArray(values) || values.length === 0) {
145
- throw new Error("whereIn: values must be a non-empty array");
149
+ throw new Error("whereIn: values must be a non-empty array")
146
150
  }
147
151
 
148
- // Remove any existing conditions for this column
149
- this.removeExistingCondition(String(column), "IN");
152
+ this.removeExistingCondition(String(column), "IN")
153
+
154
+ const { sql, params } = buildInClause(String(column), values, false)
155
+ this.state.whereConditions.push(sql)
156
+ this.state.whereParams.push(...params)
150
157
 
151
- const placeholders = values.map(() => "?").join(", ");
152
- this.state.whereConditions.push(`${String(column)} IN (${placeholders})`);
153
- this.state.whereParams.push(...values);
154
- return this;
158
+ return this
155
159
  }
156
160
 
157
161
  /**
158
- * Add a NOT IN clause for the given column with proper parameter binding.
159
- * Replaces any existing conditions for the same column.
162
+ * Add a NOT IN clause
160
163
  *
161
- * @param column - Column name to check
162
- * @param values - Non-empty array of values for the NOT IN clause
163
- * @returns this for method chaining
164
+ * @example
165
+ * .whereNotIn("role", ["banned", "suspended"])
166
+ * // WHERE "role" NOT IN (?, ?)
164
167
  */
165
168
  whereNotIn(column: keyof T, values: SQLQueryBindings[]): this {
166
169
  if (!Array.isArray(values) || values.length === 0) {
167
- throw new Error("whereNotIn: values must be a non-empty array");
170
+ throw new Error("whereNotIn: values must be a non-empty array")
168
171
  }
169
172
 
170
- // Remove any existing conditions for this column
171
- this.removeExistingCondition(String(column), "NOT IN");
173
+ this.removeExistingCondition(String(column), "NOT IN")
172
174
 
173
- const placeholders = values.map(() => "?").join(", ");
174
- this.state.whereConditions.push(`${String(column)} NOT IN (${placeholders})`);
175
- this.state.whereParams.push(...values);
176
- return this;
175
+ const { sql, params } = buildInClause(String(column), values, true)
176
+ this.state.whereConditions.push(sql)
177
+ this.state.whereParams.push(...params)
178
+
179
+ return this
177
180
  }
178
181
 
179
182
  /**
180
- * Add a comparison operator condition with proper null handling.
181
- * Replaces any existing conditions for the same column.
182
- * Supports: =, !=, <>, <, <=, >, >=, LIKE, GLOB, IS
183
+ * Add a comparison operator condition
184
+ *
185
+ * Supported operators: =, !=, <>, <, <=, >, >=, LIKE, GLOB, IS, IS NOT
183
186
  *
184
- * @param column - Column name
185
- * @param op - Comparison operator
186
- * @param value - Value to compare (handles null appropriately)
187
- * @returns this for method chaining
187
+ * @example
188
+ * .whereOp("age", ">=", 18)
189
+ * .whereOp("name", "LIKE", "%smith%")
188
190
  */
189
191
  whereOp(column: keyof T, op: string, value: SQLQueryBindings): this {
190
- const normalizedOp = (op ?? "").toUpperCase().trim();
191
- const allowed = [
192
- "=",
193
- "!=",
194
- "<>",
195
- "<",
196
- "<=",
197
- ">",
198
- ">=",
199
- "LIKE",
200
- "GLOB",
201
- "IS",
202
- "IS NOT",
203
- ];
204
-
205
- if (!allowed.includes(normalizedOp)) {
206
- throw new Error(`whereOp: operator "${op}" not supported`);
207
- }
192
+ const normalizedOp = normalizeOperator(op)
193
+ const columnStr = String(column)
208
194
 
209
- // Handle null special-casing for IS / IS NOT and equality operators
210
- if (
211
- (value === null || value === undefined) &&
212
- (normalizedOp === "=" || normalizedOp === "IS")
213
- ) {
214
- this.state.whereConditions.push(`${String(column)} IS NULL`);
215
- return this;
195
+ // Handle NULL special cases
196
+ if (value === null || value === undefined) {
197
+ if (normalizedOp === "=" || normalizedOp === "IS") {
198
+ this.state.whereConditions.push(`${quoteIdentifier(columnStr)} IS NULL`)
199
+ return this
200
+ }
201
+ if (normalizedOp === "!=" || normalizedOp === "<>" || normalizedOp === "IS NOT") {
202
+ this.state.whereConditions.push(`${quoteIdentifier(columnStr)} IS NOT NULL`)
203
+ return this
204
+ }
216
205
  }
217
206
 
218
- if (
219
- (value === null || value === undefined) &&
220
- (normalizedOp === "!=" ||
221
- normalizedOp === "<>" ||
222
- normalizedOp === "IS NOT")
223
- ) {
224
- this.state.whereConditions.push(`${String(column)} IS NOT NULL`);
225
- return this;
226
- }
207
+ this.state.whereConditions.push(`${quoteIdentifier(columnStr)} ${normalizedOp} ?`)
208
+ this.state.whereParams.push(value)
227
209
 
228
- // Normal param-bound condition
229
- this.state.whereConditions.push(`${String(column)} ${normalizedOp} ?`);
230
- this.state.whereParams.push(value);
231
- return this;
210
+ return this
232
211
  }
233
212
 
234
213
  /**
235
- * Add a BETWEEN condition for the given column.
236
- * Replaces any existing conditions for the same column.
214
+ * Add a BETWEEN clause
237
215
  *
238
- * @param column - Column name
239
- * @param min - Minimum value (inclusive)
240
- * @param max - Maximum value (inclusive)
241
- * @returns this for method chaining
216
+ * @example
217
+ * .whereBetween("age", 18, 65)
218
+ * // WHERE "age" BETWEEN ? AND ?
242
219
  */
243
- whereBetween(
244
- column: keyof T,
245
- min: SQLQueryBindings,
246
- max: SQLQueryBindings,
247
- ): this {
248
- // Remove any existing conditions for this column
249
- this.removeExistingCondition(String(column), "BETWEEN");
250
-
251
- this.state.whereConditions.push(`${String(column)} BETWEEN ? AND ?`);
252
- this.state.whereParams.push(min, max);
253
- return this;
220
+ whereBetween(column: keyof T, min: SQLQueryBindings, max: SQLQueryBindings): this {
221
+ this.removeExistingCondition(String(column), "BETWEEN")
222
+
223
+ const { sql, params } = buildBetweenClause(String(column), min, max, false)
224
+ this.state.whereConditions.push(sql)
225
+ this.state.whereParams.push(...params)
226
+
227
+ return this
254
228
  }
255
229
 
256
230
  /**
257
- * Add a NOT BETWEEN condition for the given column.
258
- * Replaces any existing conditions for the same column.
231
+ * Add a NOT BETWEEN clause
259
232
  *
260
- * @param column - Column name
261
- * @param min - Minimum value (exclusive)
262
- * @param max - Maximum value (exclusive)
263
- * @returns this for method chaining
233
+ * @example
234
+ * .whereNotBetween("score", 0, 50)
235
+ * // WHERE "score" NOT BETWEEN ? AND ?
264
236
  */
265
- whereNotBetween(
266
- column: keyof T,
267
- min: SQLQueryBindings,
268
- max: SQLQueryBindings,
269
- ): this {
270
- // Remove any existing conditions for this column
271
- this.removeExistingCondition(String(column), "NOT BETWEEN");
272
-
273
- this.state.whereConditions.push(`${String(column)} NOT BETWEEN ? AND ?`);
274
- this.state.whereParams.push(min, max);
275
- return this;
237
+ whereNotBetween(column: keyof T, min: SQLQueryBindings, max: SQLQueryBindings): this {
238
+ this.removeExistingCondition(String(column), "NOT BETWEEN")
239
+
240
+ const { sql, params } = buildBetweenClause(String(column), min, max, true)
241
+ this.state.whereConditions.push(sql)
242
+ this.state.whereParams.push(...params)
243
+
244
+ return this
276
245
  }
277
246
 
278
247
  /**
279
- * Add an IS NULL condition for the given column.
280
- * Replaces any existing conditions for the same column.
248
+ * Add an IS NULL condition
281
249
  *
282
- * @param column - Column name
283
- * @returns this for method chaining
250
+ * @example
251
+ * .whereNull("deleted_at")
252
+ * // WHERE "deleted_at" IS NULL
284
253
  */
285
254
  whereNull(column: keyof T): this {
286
- // Remove any existing conditions for this column
287
- this.removeExistingCondition(String(column));
288
-
289
- this.state.whereConditions.push(`${String(column)} IS NULL`);
290
- return this;
255
+ this.removeExistingCondition(String(column))
256
+ this.state.whereConditions.push(`${quoteIdentifier(String(column))} IS NULL`)
257
+ return this
291
258
  }
292
259
 
293
260
  /**
294
- * Add an IS NOT NULL condition for the given column.
295
- * Replaces any existing conditions for the same column.
261
+ * Add an IS NOT NULL condition
296
262
  *
297
- * @param column - Column name
298
- * @returns this for method chaining
263
+ * @example
264
+ * .whereNotNull("email")
265
+ * // WHERE "email" IS NOT NULL
299
266
  */
300
267
  whereNotNull(column: keyof T): this {
301
- // Remove any existing conditions for this column
302
- this.removeExistingCondition(String(column));
303
-
304
- this.state.whereConditions.push(`${String(column)} IS NOT NULL`);
305
- return this;
268
+ this.removeExistingCondition(String(column))
269
+ this.state.whereConditions.push(`${quoteIdentifier(String(column))} IS NOT NULL`)
270
+ return this
306
271
  }
307
272
  }