@dockstat/sqlite-wrapper 1.2.8 → 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";
4
-
5
- /**
6
- * Mixin class that adds WHERE-related functionality to the QueryBuilder.
7
- * This includes all conditional filtering methods.
8
- */
9
- export class WhereQueryBuilder<
10
- T extends Record<string, unknown>,
11
- > extends BaseQueryBuilder<T> {
12
- /**
13
- * Remove existing condition for a column
14
- * @param column - Column name to check
15
- * @param operation - Optional operation type (e.g., '=', 'IN', 'BETWEEN')
16
- */
17
- 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
- }
31
-
32
- if (existingIndex !== -1) {
33
- this.state.whereConditions.splice(existingIndex, 1);
34
- // Only remove params if they exist (some conditions might not have params)
35
- if (existingIndex < this.state.whereParams.length) {
36
- this.state.whereParams.splice(existingIndex, 1);
37
- }
38
- }
39
-
40
- // Also remove any regex conditions for this column
41
- this.state.regexConditions = this.state.regexConditions.filter(
42
- cond => String(cond.column) !== column
43
- );
44
- }
45
-
46
- /**
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.
50
- *
51
- * @param conditions - Object with column-value pairs for equality checks
52
- * @returns this for method chaining
53
- */
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
- }
73
- }
74
- return this;
75
- }
76
-
77
- /**
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.
81
- *
82
- * @param conditions - Object with column-regex pairs
83
- * @returns this for method chaining
84
- */
85
- whereRgx(conditions: RegexCondition<T>): this {
86
- for (const [column, value] of Object.entries(conditions)) {
87
- // Remove any existing conditions for this column
88
- this.removeExistingCondition(column);
89
-
90
- if (value instanceof RegExp) {
91
- this.state.regexConditions.push({
92
- column: column as keyof T,
93
- regex: value,
94
- });
95
- } else if (typeof value === "string") {
96
- this.state.regexConditions.push({
97
- column: column as keyof T,
98
- regex: new RegExp(value),
99
- });
100
- } else if (value !== null && value !== undefined) {
101
- this.state.whereConditions.push(`${String(column)} = ?`);
102
- this.state.whereParams.push(value);
103
- }
104
- }
105
- return this;
106
- }
107
-
108
- /**
109
- * Add a raw SQL WHERE fragment with parameter binding.
110
- * Note: Raw expressions bypass duplicate checking as they may be complex conditions.
111
- *
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
115
- */
116
- whereExpr(expr: string, params: SQLQueryBindings[] = []): this {
117
- if (!expr || typeof expr !== "string") {
118
- throw new Error("whereExpr: expr must be a non-empty string");
119
- }
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);
124
- }
125
- return this;
126
- }
127
-
128
- /**
129
- * Alias for whereExpr for compatibility
130
- */
131
- whereRaw(expr: string, params: SQLQueryBindings[] = []): this {
132
- return this.whereExpr(expr, params);
133
- }
134
-
135
- /**
136
- * Add an IN clause for the given column with proper parameter binding.
137
- * Replaces any existing conditions for the same column.
138
- *
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
142
- */
143
- whereIn(column: keyof T, values: SQLQueryBindings[]): this {
144
- if (!Array.isArray(values) || values.length === 0) {
145
- throw new Error("whereIn: values must be a non-empty array");
146
- }
147
-
148
- // Remove any existing conditions for this column
149
- this.removeExistingCondition(String(column), "IN");
150
-
151
- const placeholders = values.map(() => "?").join(", ");
152
- this.state.whereConditions.push(`${String(column)} IN (${placeholders})`);
153
- this.state.whereParams.push(...values);
154
- return this;
155
- }
156
-
157
- /**
158
- * Add a NOT IN clause for the given column with proper parameter binding.
159
- * Replaces any existing conditions for the same column.
160
- *
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
- */
165
- whereNotIn(column: keyof T, values: SQLQueryBindings[]): this {
166
- if (!Array.isArray(values) || values.length === 0) {
167
- throw new Error("whereNotIn: values must be a non-empty array");
168
- }
169
-
170
- // Remove any existing conditions for this column
171
- this.removeExistingCondition(String(column), "NOT IN");
172
-
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;
177
- }
178
-
179
- /**
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
- *
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
188
- */
189
- 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
- }
208
-
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;
216
- }
217
-
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
- }
227
-
228
- // Normal param-bound condition
229
- this.state.whereConditions.push(`${String(column)} ${normalizedOp} ?`);
230
- this.state.whereParams.push(value);
231
- return this;
232
- }
233
-
234
- /**
235
- * Add a BETWEEN condition for the given column.
236
- * Replaces any existing conditions for the same column.
237
- *
238
- * @param column - Column name
239
- * @param min - Minimum value (inclusive)
240
- * @param max - Maximum value (inclusive)
241
- * @returns this for method chaining
242
- */
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;
254
- }
255
-
256
- /**
257
- * Add a NOT BETWEEN condition for the given column.
258
- * Replaces any existing conditions for the same column.
259
- *
260
- * @param column - Column name
261
- * @param min - Minimum value (exclusive)
262
- * @param max - Maximum value (exclusive)
263
- * @returns this for method chaining
264
- */
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;
276
- }
277
-
278
- /**
279
- * Add an IS NULL condition for the given column.
280
- * Replaces any existing conditions for the same column.
281
- *
282
- * @param column - Column name
283
- * @returns this for method chaining
284
- */
285
- 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;
291
- }
292
-
293
- /**
294
- * Add an IS NOT NULL condition for the given column.
295
- * Replaces any existing conditions for the same column.
296
- *
297
- * @param column - Column name
298
- * @returns this for method chaining
299
- */
300
- 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;
306
- }
307
- }
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"
5
+
6
+ /**
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)
17
+ */
18
+ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQueryBuilder<T> {
19
+ // ===== Private Helpers =====
20
+
21
+ /**
22
+ * Remove an existing condition for a column to prevent duplicates
23
+ */
24
+ private removeExistingCondition(column: string, operation?: string): void {
25
+ const columnPattern = operation ? `${column} ${operation}` : `${column} `
26
+
27
+ const existingIndex = this.state.whereConditions.findIndex(
28
+ (condition) => condition.startsWith(columnPattern) || condition.startsWith(`"${column}"`)
29
+ )
30
+
31
+ if (existingIndex !== -1) {
32
+ this.state.whereConditions.splice(existingIndex, 1)
33
+ // Remove corresponding params if they exist
34
+ if (existingIndex < this.state.whereParams.length) {
35
+ this.state.whereParams.splice(existingIndex, 1)
36
+ }
37
+ }
38
+
39
+ // Also remove any regex conditions for this column
40
+ this.state.regexConditions = this.state.regexConditions.filter(
41
+ (cond) => String(cond.column) !== column
42
+ )
43
+ }
44
+
45
+ /**
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" = ?
63
+ *
64
+ * @example
65
+ * .where({ deleted_at: null })
66
+ * // WHERE "deleted_at" IS NULL
67
+ */
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))
77
+ }
78
+ }
79
+ return this
80
+ }
81
+
82
+ /**
83
+ * Add regex conditions (applied client-side after SQL execution)
84
+ *
85
+ * @example
86
+ * .whereRgx({ email: /@gmail\.com$/ })
87
+ */
88
+ whereRgx(conditions: RegexCondition<T>): this {
89
+ for (const [column, value] of Object.entries(conditions)) {
90
+ this.removeExistingCondition(column)
91
+
92
+ if (value instanceof RegExp) {
93
+ this.state.regexConditions.push({
94
+ column: column as keyof T,
95
+ regex: value,
96
+ })
97
+ } else if (typeof value === "string") {
98
+ this.state.regexConditions.push({
99
+ column: column as keyof T,
100
+ regex: new RegExp(value),
101
+ })
102
+ } else if (value !== null && value !== undefined) {
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)
106
+ }
107
+ }
108
+ return this
109
+ }
110
+
111
+ /**
112
+ * Add a raw SQL WHERE expression with parameter binding
113
+ *
114
+ * @example
115
+ * .whereExpr("LENGTH(name) > ?", [5])
116
+ * .whereExpr("created_at > datetime('now', '-1 day')")
117
+ */
118
+ whereExpr(expr: string, params: SQLQueryBindings[] = []): this {
119
+ if (!expr || typeof expr !== "string") {
120
+ throw new Error("whereExpr: expression must be a non-empty string")
121
+ }
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)
128
+ }
129
+
130
+ return this
131
+ }
132
+
133
+ /**
134
+ * Alias for whereExpr
135
+ */
136
+ whereRaw(expr: string, params: SQLQueryBindings[] = []): this {
137
+ return this.whereExpr(expr, params)
138
+ }
139
+
140
+ /**
141
+ * Add an IN clause
142
+ *
143
+ * @example
144
+ * .whereIn("status", ["active", "pending"])
145
+ * // WHERE "status" IN (?, ?)
146
+ */
147
+ whereIn(column: keyof T, values: SQLQueryBindings[]): this {
148
+ if (!Array.isArray(values) || values.length === 0) {
149
+ throw new Error("whereIn: values must be a non-empty array")
150
+ }
151
+
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)
157
+
158
+ return this
159
+ }
160
+
161
+ /**
162
+ * Add a NOT IN clause
163
+ *
164
+ * @example
165
+ * .whereNotIn("role", ["banned", "suspended"])
166
+ * // WHERE "role" NOT IN (?, ?)
167
+ */
168
+ whereNotIn(column: keyof T, values: SQLQueryBindings[]): this {
169
+ if (!Array.isArray(values) || values.length === 0) {
170
+ throw new Error("whereNotIn: values must be a non-empty array")
171
+ }
172
+
173
+ this.removeExistingCondition(String(column), "NOT IN")
174
+
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
180
+ }
181
+
182
+ /**
183
+ * Add a comparison operator condition
184
+ *
185
+ * Supported operators: =, !=, <>, <, <=, >, >=, LIKE, GLOB, IS, IS NOT
186
+ *
187
+ * @example
188
+ * .whereOp("age", ">=", 18)
189
+ * .whereOp("name", "LIKE", "%smith%")
190
+ */
191
+ whereOp(column: keyof T, op: string, value: SQLQueryBindings): this {
192
+ const normalizedOp = normalizeOperator(op)
193
+ const columnStr = String(column)
194
+
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
+ }
205
+ }
206
+
207
+ this.state.whereConditions.push(`${quoteIdentifier(columnStr)} ${normalizedOp} ?`)
208
+ this.state.whereParams.push(value)
209
+
210
+ return this
211
+ }
212
+
213
+ /**
214
+ * Add a BETWEEN clause
215
+ *
216
+ * @example
217
+ * .whereBetween("age", 18, 65)
218
+ * // WHERE "age" BETWEEN ? AND ?
219
+ */
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
228
+ }
229
+
230
+ /**
231
+ * Add a NOT BETWEEN clause
232
+ *
233
+ * @example
234
+ * .whereNotBetween("score", 0, 50)
235
+ * // WHERE "score" NOT BETWEEN ? AND ?
236
+ */
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
245
+ }
246
+
247
+ /**
248
+ * Add an IS NULL condition
249
+ *
250
+ * @example
251
+ * .whereNull("deleted_at")
252
+ * // WHERE "deleted_at" IS NULL
253
+ */
254
+ whereNull(column: keyof T): this {
255
+ this.removeExistingCondition(String(column))
256
+ this.state.whereConditions.push(`${quoteIdentifier(String(column))} IS NULL`)
257
+ return this
258
+ }
259
+
260
+ /**
261
+ * Add an IS NOT NULL condition
262
+ *
263
+ * @example
264
+ * .whereNotNull("email")
265
+ * // WHERE "email" IS NOT NULL
266
+ */
267
+ whereNotNull(column: keyof T): this {
268
+ this.removeExistingCondition(String(column))
269
+ this.state.whereConditions.push(`${quoteIdentifier(String(column))} IS NOT NULL`)
270
+ return this
271
+ }
272
+ }