sql-love 0.9.1

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.
@@ -0,0 +1,845 @@
1
+ import Parser from '../Parser/Parser';
2
+ import substrCount from '../substrCount/substrCount';
3
+
4
+ export type FieldName =
5
+ | 'option'
6
+ | 'options'
7
+ | 'select'
8
+ | 'column'
9
+ | 'columns'
10
+ | 'table'
11
+ | 'tables'
12
+ | 'join'
13
+ | 'joins'
14
+ | 'where'
15
+ | 'wheres'
16
+ | 'having'
17
+ | 'havings'
18
+ | 'groupBy'
19
+ | 'groupBys'
20
+ | 'orderBy'
21
+ | 'orderBys'
22
+ | 'limit'
23
+ | 'offset'
24
+ | 'page';
25
+
26
+ export type EngineStyle = 'mssql' | 'mysql' | 'oracle' | 'pg';
27
+
28
+ export type WhereCriteria =
29
+ | [string, string, any]
30
+ | [string, any]
31
+ | [string]
32
+ | [Record<string, any>];
33
+
34
+ const isNullish = v => v === undefined || v === null || v === false || isNaN(v);
35
+
36
+ /**
37
+ * Build a select query
38
+ * Class Select
39
+ */
40
+ export default class SelectBuilder {
41
+ /**
42
+ * The list of values to bind to the query
43
+ * @private
44
+ */
45
+ _bindings: any[] = [];
46
+ /**
47
+ * The list of strings to come immediately after "SELECT"
48
+ * and before column names
49
+ * @private
50
+ */
51
+ _options: string[] = [];
52
+ /**
53
+ * The list of column names to select
54
+ * @private
55
+ */
56
+ _tables: string[] = [];
57
+ /**
58
+ * The list of tables in the FROM clause
59
+ * @private
60
+ */
61
+ _columns: string[] = [];
62
+ /**
63
+ * The list of JOIN strings to add
64
+ * @private
65
+ */
66
+ _joins: string[] = [];
67
+ /**
68
+ * The list of WHERE clauses to join with AND
69
+ * @private
70
+ */
71
+ _wheres: string[] = [];
72
+ /**
73
+ * The list of GROUP BY clauses to join with commas
74
+ * @private
75
+ */
76
+ _groupBys: string[] = [];
77
+ /**
78
+ * The list of HAVING clauses to join with AND
79
+ * @private
80
+ */
81
+ _havings: string[] = [];
82
+ /**
83
+ * The list of ORDER BY clauses to join with commas
84
+ * @private
85
+ */
86
+ _orderBys: string[] = [];
87
+ /**
88
+ * The LIMIT to use
89
+ * @private
90
+ */
91
+ _limit: number;
92
+ /**
93
+ * The OFFSET to use
94
+ * @private
95
+ */
96
+ _offset: number;
97
+ /**
98
+ * The page used to construct an OFFSET based on the LIMIT
99
+ * @private
100
+ */
101
+ _page: number;
102
+
103
+ /**
104
+ * Select constructor
105
+ * @param [sql] A base SQL query to parse
106
+ * @param [bindings] Placeholders to bind to the query
107
+ */
108
+ constructor(sql: string = null, bindings: Record<string, any> = null) {
109
+ if (sql) {
110
+ if (bindings) {
111
+ const keys = Object.keys(bindings);
112
+ const regexp = new RegExp(`:(${keys.join('|')})`, 'g');
113
+ sql = sql.replace(regexp, ($0, $1) => {
114
+ this._bindings.push(bindings[$1]);
115
+ return `__BOUND_${this._bindings.length - 1}__`;
116
+ });
117
+ }
118
+ new Parser(this).parse(sql);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Get the SQL as a pretty-printed string alongside an array of bindings
124
+ * @param engineStyle The database engine style (mssql, mysql, oracle, pg)
125
+ * @return The SQL query as a string
126
+ */
127
+ compile(engineStyle: EngineStyle = 'mysql') {
128
+ // build all the lines
129
+ const lines = [
130
+ 'SELECT',
131
+ this._options.length ? ` ${this._options.join('\n ')}` : null,
132
+ this._columns.length ? ` ${this._columns.join(',\n ')}` : ' *\n',
133
+ `FROM ${this._tables.join(', ')}`,
134
+ this._joins.length ? this._joins.join('\n') : null,
135
+ this._wheres.length ? `WHERE ${this._wheres.join('\n AND ')}` : null,
136
+ this._groupBys.length ? `GROUP BY ${this._groupBys.join(',\n ')}` : null,
137
+ this._havings.length ? `HAVING ${this._havings.join('\n AND ')}` : null,
138
+ this._orderBys.length ? `ORDER BY ${this._orderBys.join(',\n ')}` : null,
139
+ ];
140
+ if (this._page > 0) {
141
+ const offset = (this._page - 1) * this._limit;
142
+ lines.push(this._compileOFFSET(engineStyle, offset));
143
+ lines.push(this._compileLIMIT(engineStyle, this._limit));
144
+ } else {
145
+ if (this._offset) {
146
+ lines.push(this._compileOFFSET(engineStyle, this._offset));
147
+ }
148
+ if (this._limit) {
149
+ lines.push(this._compileLIMIT(engineStyle, this._limit));
150
+ }
151
+ }
152
+
153
+ const pretty = lines.filter(Boolean).join('\n').trim();
154
+ let replacer: (match0: string, match1: string) => string;
155
+ const bindings: any = [];
156
+ if (engineStyle === 'pg') {
157
+ let idx = 1;
158
+ replacer = ($0, $1) => {
159
+ bindings.push(this._bindings[$1]);
160
+ return '$' + idx++;
161
+ };
162
+ } else {
163
+ replacer = ($0, $1) => {
164
+ bindings.push(this._bindings[$1]);
165
+ return '?';
166
+ };
167
+ }
168
+ const sql = pretty.replace(/__BOUND_(\d+)__/g, replacer);
169
+ return { sql, bindings };
170
+ }
171
+
172
+ _compileLIMIT(engineStyle: EngineStyle, limit: number) {
173
+ if (engineStyle === 'pg' || engineStyle === 'mysql') {
174
+ return `LIMIT ${limit}`;
175
+ } else if (engineStyle === 'mssql') {
176
+ return `FETCH NEXT ${limit} ROWS ONLY`;
177
+ }
178
+ }
179
+
180
+ _compileOFFSET(engineStyle: EngineStyle, offset: number) {
181
+ if (engineStyle === 'pg' || engineStyle === 'mysql') {
182
+ return `OFFSET ${offset}`;
183
+ } else if (engineStyle === 'mssql' || engineStyle === 'oracle') {
184
+ return `OFFSET ${offset} ROWS`;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Get SQL needed to return the found rows of this query
190
+ * @param countExpr The expression to use inside the COUNT()
191
+ * @param engineStyle The database engine style (mssql, mysql, oracle, pg)
192
+ */
193
+ compileCount(countExpr: string = '*', engineStyle: EngineStyle = 'mysql') {
194
+ const query = this.getFoundRowsQuery(countExpr);
195
+ const { sql, bindings } = query.compile(engineStyle);
196
+ if (this._havings.length === 0) {
197
+ return { sql, bindings };
198
+ } else {
199
+ const subquerySql = sql.replace(/\n/g, '\n\t');
200
+ return {
201
+ sql: `SELECT COUNT(*) AS found_rows\nFROM (\n${subquerySql}\n) AS subquery_results`,
202
+ bindings,
203
+ };
204
+ }
205
+ }
206
+
207
+ /**
208
+ * @param [fieldOrFields] If given, reset the given component(s), otherwise reset all query components
209
+ * Valid components: option, column, table, where, orWhere, having, groupBy, orderBy, limit, offset, page
210
+ * @return This object
211
+ */
212
+ reset(fieldOrFields: FieldName | FieldName[] = null) {
213
+ if (Array.isArray(fieldOrFields)) {
214
+ fieldOrFields.forEach(name => this.reset(name));
215
+ return this;
216
+ }
217
+ if (fieldOrFields) {
218
+ const pluralizable = [
219
+ 'option',
220
+ 'column',
221
+ 'table',
222
+ 'where',
223
+ 'having',
224
+ 'groupBy',
225
+ 'orderBy',
226
+ ];
227
+ let prop = '_' + fieldOrFields.replace(/s$/, '');
228
+ if (pluralizable.indexOf(fieldOrFields) > -1) {
229
+ prop += 's';
230
+ }
231
+ const isScalar = ['limit', 'offset', 'page'].indexOf(fieldOrFields) > -1;
232
+ this[prop] = isScalar ? null : [];
233
+ } else {
234
+ this._options = [];
235
+ this._columns = [];
236
+ this._tables = [];
237
+ this._joins = [];
238
+ this._wheres = [];
239
+ this._groupBys = [];
240
+ this._havings = [];
241
+ this._orderBys = [];
242
+ this._limit = null;
243
+ this._offset = null;
244
+ this._page = null;
245
+ this._bindings = [];
246
+ }
247
+ return this;
248
+ }
249
+
250
+ /**
251
+ * Clone this builder object
252
+ */
253
+ getClone() {
254
+ const copy = new SelectBuilder();
255
+ copy._options = [...this._options];
256
+ copy._columns = [...this._columns];
257
+ copy._tables = [...this._tables];
258
+ copy._joins = [...this._joins];
259
+ copy._wheres = [...this._wheres];
260
+ copy._havings = [...this._havings];
261
+ copy._groupBys = [...this._groupBys];
262
+ copy._orderBys = [...this._orderBys];
263
+ copy._limit = this._limit;
264
+ copy._offset = this._offset;
265
+ copy._page = this._page;
266
+ copy._bindings = [...this._bindings];
267
+ return copy;
268
+ }
269
+
270
+ /**
271
+ * Build a version of this query that simply returns COUNT(*)
272
+ * @param [countExpr="*"] Use to specify `DISTINCT colname` if needed
273
+ */
274
+ getFoundRowsQuery(countExpr: string = '*') {
275
+ if (this._havings.length === 0) {
276
+ const clone = this.getClone();
277
+ clone._columns = [`COUNT(${countExpr}) AS found_rows`];
278
+ clone._options = [];
279
+ clone._groupBys = [];
280
+ clone._orderBys = [];
281
+ clone._limit = null;
282
+ clone._offset = null;
283
+ clone._page = null;
284
+ return clone;
285
+ } else {
286
+ const subquery = this.getClone();
287
+ subquery._limit = null;
288
+ subquery._offset = null;
289
+ subquery._page = null;
290
+ return subquery;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Add an array of column names to fetch
296
+ * @param columnNames The names of columns
297
+ */
298
+ select(columnNames: string[]) {
299
+ this._columns = this._columns.concat(columnNames);
300
+ return this;
301
+ }
302
+
303
+ /**
304
+ * Add an array of column names to fetch
305
+ * @param columnNames The names of columns
306
+ */
307
+ columns(columnNames: string[]) {
308
+ this._columns = this._columns.concat(columnNames);
309
+ return this;
310
+ }
311
+
312
+ /**
313
+ * Add a column name to fetch
314
+ * @param columnName The name of the column
315
+ */
316
+ column(columnName: string) {
317
+ this._columns.push(columnName);
318
+ return this;
319
+ }
320
+
321
+ /**
322
+ * Add an option expression such as "TOP 10" or "SQL_CALC_FOUND_ROWS"
323
+ * @param optionExpression Expression to go after "SELECT" and before column list
324
+ */
325
+ option(optionExpression: string) {
326
+ this._options.push(optionExpression);
327
+ return this;
328
+ }
329
+
330
+ /**
331
+ * Add a table to the "FROM" clause (same as .from())
332
+ * @param {String} tableName The name of the table to query
333
+ * @return {Select}
334
+ */
335
+ table(tableName: string) {
336
+ this._tables.push(tableName);
337
+ return this;
338
+ }
339
+
340
+ /**
341
+ * Add multiple table to the "FROM" clause
342
+ * @param {Array} tableNames The names of the tables to query
343
+ * @return {Select}
344
+ */
345
+ tables(tableNames: string[]) {
346
+ this._tables.push(...tableNames);
347
+ return this;
348
+ }
349
+
350
+ /**
351
+ * Add a table to the "FROM" clause (same as .table())
352
+ * @param {String} tableName The name of the table to query
353
+ * @return {Select}
354
+ */
355
+ from(tableName: string) {
356
+ this._tables.push(tableName);
357
+ return this;
358
+ }
359
+
360
+ /**
361
+ * Add the given join type expression
362
+ * @param joinType The type of join (e.g. "INNER JOIN", "LEFT JOIN", etc.)
363
+ * @param expression The expression following the INNER JOIN keyword
364
+ * @param bindings A list of values to bind to ? placeholders in the expression
365
+ * @example
366
+ * query.join('posts p ON p.id = c.post_id');
367
+ * query.join('posts p ON p.id = c.post_id AND p.deleted_at < ?', [myDate]);
368
+ */
369
+ _addJoin(joinType: string, expression: string, bindings: any[] = []) {
370
+ if (bindings.length > 0) {
371
+ expression = expression.replace(/\?/g, () => {
372
+ this._bindings.push(bindings.shift());
373
+ return `__BOUND_${this._bindings.length - 1}__`;
374
+ });
375
+ }
376
+ this._joins.push(`${joinType} ${expression}`);
377
+ }
378
+
379
+ /**
380
+ * Add a JOIN expression
381
+ * @param expression The expression following the INNER JOIN keyword
382
+ * @param bindings A list of values to bind to ? placeholders in the expression
383
+ * @example
384
+ * query.join('posts p ON p.id = c.post_id');
385
+ * query.join('posts p ON p.id = c.post_id AND p.deleted_at < ?', [myDate]);
386
+ */
387
+ join(expression: string, bindings: any[] = []) {
388
+ this._addJoin('JOIN', expression, bindings);
389
+ return this;
390
+ }
391
+
392
+ /**
393
+ * Add a LEFT JOIN expression
394
+ * @param expression The expression following the LEFT JOIN keyword
395
+ * @param bindings A list of values to bind to ? placeholders in the expression
396
+ * @example
397
+ * query.leftJoin('posts p ON p.id = c.post_id');
398
+ * query.leftJoin('posts p ON p.id = c.post_id AND p.deleted_at < ?', [myDate]);
399
+ */
400
+ leftJoin(expression: string, bindings: any[] = []) {
401
+ this._addJoin('LEFT JOIN', expression, bindings);
402
+ return this;
403
+ }
404
+
405
+ /**
406
+ * Add a FULL JOIN expression
407
+ * @param expression The expression following the FULL JOIN keyword
408
+ * @param bindings A list of values to bind to ? placeholders in the expression
409
+ * @example
410
+ * query.fullJoin('posts p ON p.id = c.post_id');
411
+ * query.fullJoin('posts p ON p.id = c.post_id AND p.deleted_at < ?', [myDate]);
412
+ */
413
+ fullJoin(expression: string, bindings: any[] = []) {
414
+ this._addJoin('FULL JOIN', expression, bindings);
415
+ return this;
416
+ }
417
+
418
+ /**
419
+ * Add a RIGHT JOIN expression
420
+ * @param expression The expression following the RIGHT JOIN keyword
421
+ * @param bindings A list of values to bind to ? placeholders in the expression
422
+ * @example
423
+ * query.rightJoin('posts p ON p.id = c.post_id');
424
+ * query.rightJoin('posts p ON p.id = c.post_id AND p.deleted_at < ?', [myDate]);
425
+ */
426
+ rightJoin(expression: string, bindings: any[] = []) {
427
+ this._addJoin('RIGHT JOIN', expression, bindings);
428
+ return this;
429
+ }
430
+
431
+ /**
432
+ * Add a CROSS JOIN expression
433
+ * @param expression The expression following the CROSS JOIN keyword
434
+ * @param bindings A list of values to bind to ? placeholders in the expression
435
+ * @example
436
+ * query.crossJoin('posts p ON p.id = c.post_id');
437
+ * query.crossJoin('posts p ON p.id = c.post_id AND p.deleted_at < ?', [myDate]);
438
+ */
439
+ crossJoin(expression: string, bindings: any[] = []) {
440
+ this._addJoin('CROSS JOIN', expression, bindings);
441
+ return this;
442
+ }
443
+
444
+ /**
445
+ * Add an INNER JOIN expression (same as ->join())
446
+ * @param expression The expression following the INNER JOIN keyword
447
+ * @param bindings A list of values to bind to ? placeholders in the expression
448
+ * @example
449
+ * query.innerJoin('posts p ON p.id = c.post_id');
450
+ * query.innerJoin('posts p ON p.id = c.post_id AND p.deleted_at < ?', [myDate]);
451
+ */
452
+ innerJoin(expression: string, bindings: any[] = []) {
453
+ this._addJoin('INNER JOIN', expression, bindings);
454
+ return this;
455
+ }
456
+
457
+ /**
458
+ * Add a LEFT OUTER JOIN expression
459
+ * @param expression The expression following the LEFT OUTER JOIN keyword
460
+ * @param bindings A list of values to bind to ? placeholders in the expression
461
+ * @example
462
+ * query.leftOuterJoin('posts p ON p.id = c.post_id');
463
+ * query.leftOuterJoin('posts p ON p.id = c.post_id AND p.deleted_at < ?', [myDate]);
464
+ */
465
+ leftOuterJoin(expression: string, bindings: any[] = []) {
466
+ this._addJoin('LEFT OUTER JOIN', expression, bindings);
467
+ return this;
468
+ }
469
+
470
+ /**
471
+ * Add a FULL OUTER JOIN expression
472
+ * @param expression The expression following the FULL OUTER JOIN keyword
473
+ * @param bindings A list of values to bind to ? placeholders in the expression
474
+ * @example
475
+ * query.fullOuterJoin('posts p ON p.id = c.post_id');
476
+ * query.fullOuterJoin('posts p ON p.id = c.post_id AND p.deleted_at < ?', [myDate]);
477
+ */
478
+ fullOuterJoin(expression: string, bindings: any[] = []) {
479
+ this._addJoin('FULL OUTER JOIN', expression, bindings);
480
+ return this;
481
+ }
482
+
483
+ /**
484
+ * Add a RIGHT OUTER JOIN expression
485
+ * @param expression The expression following the RIGHT OUTER JOIN keyword
486
+ * @param bindings A list of values to bind to ? placeholders in the expression
487
+ * @example
488
+ * query.rightOuterJoin('posts p ON p.id = c.post_id');
489
+ * query.rightOuterJoin('posts p ON p.id = c.post_id AND p.deleted_at < ?', [myDate]);
490
+ */
491
+ rightOuterJoin(expression: string, bindings: any[] = []) {
492
+ this._addJoin('RIGHT OUTER JOIN', expression, bindings);
493
+ return this;
494
+ }
495
+
496
+ /**
497
+ * Remove a join condition with the specified table
498
+ * @param table The name of the table or tables in the first part of the join statement
499
+ */
500
+ unjoin(table: string | string[]) {
501
+ if (Array.isArray(table)) {
502
+ table.forEach(t => this.unjoin(t));
503
+ return this;
504
+ }
505
+ this._joins = this._joins.filter(join => {
506
+ const regex = new RegExp(`JOIN ${table}\\b`);
507
+ return !regex.test(join);
508
+ });
509
+ return this;
510
+ }
511
+
512
+ /**
513
+ * Utility function to add conditions for a clause (WHERE, HAVING)
514
+ * @param collection The collection to add the clauses to (e.g. this._wheres or this._havings)
515
+ * @param criteria Criteria adhering to WhereCriteria type
516
+ * @example The following are equivalent
517
+ * this._conditions(this._wheres, ['deleted_at IS NULL']);
518
+ * this._conditions(this._wheres, ['deleted_at', null]);
519
+ * this._conditions(this._wheres, ['deleted_at', '=', null]);
520
+ * @example More examples
521
+ * this._conditions(this._wheres, ['fname', 'LIKE', 'joe']); // `fname` LIKE 'joe'
522
+ * this._conditions(this._wheres, ['fname', 'LIKE ?', 'joe']); // `fname` LIKE 'joe'
523
+ * this._conditions(this._wheres, ['fname LIKE %?%', 'joe']); // `fname` LIKE '%joe%'
524
+ * this._conditions(this._wheres, ['fname LIKE ?%', 'joe']); // `fname` LIKE 'joe%'
525
+ * this._conditions(this._wheres, ['fname', 'LIKE ?%', 'joe']); // `fname` LIKE 'joe%'
526
+ * this._conditions(this._wheres, ['price >', 10]); // `price` > 10
527
+ * this._conditions(this._wheres, ['price', '>', 10]); // `price` > 10
528
+ * this._conditions(this._wheres, ['price =', 10]); // `price` = 10
529
+ * this._conditions(this._wheres, ['price !=', 10]); // `price` != 10
530
+ * this._conditions(this._wheres, ['price', 10]); // `price` = 10
531
+ * this._conditions(this._wheres, ['price', '=', 10]); // `price` = 10
532
+ * this._conditions(this._wheres, ['price', '!=', 10]); // `price` != 10
533
+ * this._conditions(this._wheres, ['price', 'BETWEEN', [10,20]]); // `price` BETWEEN 10 AND 20
534
+ * this._conditions(this._wheres, ['price', 'NOT BETWEEN', [10,20]]); // `price` NOT BETWEEN 10 AND 20
535
+ * this._conditions(this._wheres, ['price', [10,20]]); // `price` IN(10,20)
536
+ * this._conditions(this._wheres, ['price', '=', [10,20]]); // `price` IN(10,20)
537
+ * this._conditions(this._wheres, ['price', 'IN', [10,20]]); // `price` IN(10,20)
538
+ * this._conditions(this._wheres, ['price', 'NOT IN', [10,20]]); // `price` NOT IN(10,20)
539
+ */
540
+ _conditions(collection: string[], criteria: WhereCriteria) {
541
+ const numArgs = criteria.length;
542
+ let [column, operator, value] = criteria;
543
+ if (typeof column === 'object') {
544
+ for (const keyValue of Object.entries(column)) {
545
+ this._conditions(collection, keyValue);
546
+ }
547
+ return this;
548
+ }
549
+ if (numArgs === 1) {
550
+ // condition is a stand-alone expression
551
+ // e.g. "SUM(price) > 10"
552
+ collection.push(column);
553
+ return this;
554
+ } else if (
555
+ numArgs === 2 &&
556
+ Array.isArray(operator) &&
557
+ operator.length > 0 &&
558
+ substrCount(column, '?') === operator.length
559
+ ) {
560
+ // column is a string with question marks and operator is an array of replacements
561
+ // e.g. query.where('SUBSTR(prefs, ?, ?) = role', [1, 4]);
562
+ const values = operator;
563
+ const sql = column.replace(/\?/g, () => {
564
+ this._bindings.push(values.shift());
565
+ return `__BOUND_${this._bindings.length - 1}__`;
566
+ });
567
+ collection.push(sql);
568
+ return this;
569
+ } else if (numArgs === 2) {
570
+ // condition has pairs of "column + operator" => "value"
571
+ // e.g. ["price >", 10]
572
+ // e.g. ["status LIKE ?%", 10]
573
+ value = operator;
574
+ const parts = column.split(' ');
575
+ column = parts.shift();
576
+ operator = parts.join(' ');
577
+ }
578
+ if (typeof operator !== 'string' || operator === '') {
579
+ operator = '=';
580
+ }
581
+ operator = operator.toLocaleUpperCase();
582
+ const likeMatch = operator.match(
583
+ /^(LIKE|NOT LIKE)(?: (\?|\?%|%\?|%\?%))?$/i
584
+ );
585
+ if (operator === 'NOT BETWEEN' || operator === 'BETWEEN') {
586
+ // expect a two-item array
587
+ const one = this._bindings.length;
588
+ const two = this._bindings.length + 1;
589
+ collection.push(
590
+ `${column} ${operator} __BOUND_${one}__ AND __BOUND_${two}__`
591
+ );
592
+ this._bindings.push(value[0], value[1]);
593
+ } else if (likeMatch) {
594
+ const like = likeMatch[1].toUpperCase(); // Either LIKE or NOT LIKE
595
+ const infix = likeMatch[2] || '?'; // ONE OF ?% or %?% or %? or ?
596
+ if (Array.isArray(value)) {
597
+ const ors = [];
598
+ for (const v of value) {
599
+ ors.push(`${column} ${like} __BOUND_${this._bindings.length}__`);
600
+ this._bindings.push(infix.replace('?', v));
601
+ }
602
+ const joined = ors.join(' OR ');
603
+ collection.push(`(${joined})`);
604
+ } else {
605
+ collection.push(`${column} ${like} __BOUND_${this._bindings.length}__`);
606
+ this._bindings.push(infix.replace('?', value || ''));
607
+ }
608
+ } else if (value === null) {
609
+ collection.push(
610
+ operator === '=' ? `${column} IS NULL` : `${column} IS NOT NULL`
611
+ );
612
+ } else if (Array.isArray(value)) {
613
+ // an array of values should be IN or NOT IN
614
+ const inVals = [];
615
+ for (let i = 0; i < value.length; i++) {
616
+ inVals.push(`__BOUND_${this._bindings.length}__`);
617
+ this._bindings.push(value[i]);
618
+ }
619
+ const joined = inVals.join(', ');
620
+ collection.push(
621
+ operator === '=' || operator === 'IN'
622
+ ? `${column} IN(${joined})`
623
+ : `${column} NOT IN(${joined})`
624
+ );
625
+ } else if (operator === 'IN' || operator === 'NOT IN') {
626
+ // in clause that is not array
627
+ collection.push(
628
+ `${column} ${operator}(__BOUND_${this._bindings.length}__)`
629
+ );
630
+ this._bindings.push(value);
631
+ } else {
632
+ collection.push(
633
+ `${column} ${operator} __BOUND_${this._bindings.length}__`
634
+ );
635
+ this._bindings.push(value);
636
+ }
637
+ return this;
638
+ }
639
+
640
+ /**
641
+ * Add a group by column or expression
642
+ * @param column The name of a column (or expression) to group by
643
+ */
644
+ groupBy(column: string) {
645
+ this._groupBys.push(column);
646
+ return this;
647
+ }
648
+
649
+ /**
650
+ * Add WHERE clauses to conditions (See _conditions for usage)
651
+ */
652
+ where(...args: WhereCriteria) {
653
+ this._conditions(this._wheres, args);
654
+ return this;
655
+ }
656
+
657
+ /**
658
+ * Add a WHERE clause with a BETWEEN condition
659
+ * @param column The column name
660
+ * @param twoValueArray The two values to be between
661
+ */
662
+ whereBetween(column: string, twoValueArray: [any, any]) {
663
+ if (!isNullish(twoValueArray[0]) && !isNullish(twoValueArray[1])) {
664
+ this.where(column, 'BETWEEN', twoValueArray);
665
+ } else if (!isNullish(twoValueArray[0]) && isNullish(twoValueArray[1])) {
666
+ this.where(column, '>=', twoValueArray[0]);
667
+ } else if (isNullish(twoValueArray[0]) && !isNullish(twoValueArray[1])) {
668
+ this.where(column, '<=', twoValueArray[1]);
669
+ } else {
670
+ // both are nullish! omit expression
671
+ }
672
+ return this;
673
+ }
674
+
675
+ /**
676
+ * Add a series of WHERE clauses joined by OR (See _conditions for usage)
677
+ * @param conditions
678
+ */
679
+ _orConditions(conditions: WhereCriteria[] | Record<string, any>[]) {
680
+ const items = [];
681
+ for (const condition of conditions) {
682
+ if (Array.isArray(condition)) {
683
+ this._conditions(items, condition as WhereCriteria);
684
+ } else {
685
+ this._conditions(items, [condition]);
686
+ }
687
+ }
688
+ const joined = items.join(' OR ');
689
+ if (joined.startsWith('(') && joined.endsWith(')')) {
690
+ // start/end paraens will be added by orWhere() or orHaving()
691
+ return joined.slice(1, -1);
692
+ } else {
693
+ return joined;
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Add WHERE conditions to place inside an OR block (See _conditions for usage)
699
+ * @param conditions A list or object where each item is an array with parameters that would be taken by where()
700
+ */
701
+ orWhere(conditions: WhereCriteria[] | Record<string, any>[]) {
702
+ const joined = this._orConditions(conditions);
703
+ this.where(`(${joined})`);
704
+ return this;
705
+ }
706
+
707
+ /**
708
+ * Add an OR with conditions under the HAVING clause
709
+ * @param conditions A list or object where each item is an array with parameters that would be taken by having()
710
+ */
711
+ orHaving(conditions: WhereCriteria[] | Record<string, any>[]) {
712
+ const joined = this._orConditions(conditions);
713
+ this.having(`(${joined})`);
714
+ return this;
715
+ }
716
+
717
+ /**
718
+ * Add a HAVING condition (See _conditions for usage)
719
+ */
720
+ having(...args: WhereCriteria) {
721
+ this._conditions(this._havings, args);
722
+ return this;
723
+ }
724
+
725
+ /**
726
+ * Add a column or expression to order by
727
+ * @param column The column name or expression to sort by. Include DESC or prefix with - to sort descending
728
+ * @param [direction] The direction to sort by (ASC or DESC)
729
+ * @example
730
+ * query.sortField('modified_at'); // ORDER BY modified_at
731
+ * query.sortField('-modified_at'); // ORDER BY modified_at DESC
732
+ * query.sortField('COUNT(*)'); // ORDER BY COUNT(*)
733
+ * query.sortField('COUNT(*) DESC'); // ORDER BY COUNT(*) DESC
734
+ */
735
+ orderBy(column: string, direction: 'ASC' | 'DESC' = null) {
736
+ if (direction) {
737
+ this._orderBys.push(`${column} ${direction}`);
738
+ } else {
739
+ const expression = column.replace(/^-(.+)/, '$1 DESC');
740
+ this._orderBys.push(expression);
741
+ }
742
+ return this;
743
+ }
744
+
745
+ /**
746
+ * Sort by the given column, with a map of columns to translate
747
+ * @param column The column name such as "created_at" or "-created_at" for descending
748
+ * @param [mapNames] Column names to translate from one name to another
749
+ * @example
750
+ * query.sortField('-modified_at'); // ORDER BY modified_at DESC
751
+ * query.sortField('created_at', {'created_at': 'created'}); // ORDER BY created
752
+ */
753
+ sortField(column: string, mapNames: Record<string, string> = {}) {
754
+ column.replace(/^-(.+)/, '$1 DESC');
755
+ column = mapNames[column] || column;
756
+ this.orderBy(column);
757
+ return this;
758
+ }
759
+
760
+ /**
761
+ * Check to see if the given string is 0 or all digits not starting with 0
762
+ * @param string The input string
763
+ * @private
764
+ */
765
+ _isEntirelyDigits(string: string) {
766
+ return /^(0|[1-9]\d*)$/.test(string);
767
+ }
768
+
769
+ /**
770
+ * Check to see if the given string is all digits
771
+ * @param string The input string
772
+ * @private
773
+ */
774
+ _isEntirelyDigitsNoZeros(string: string) {
775
+ return /^[1-9]\d*$/.test(string);
776
+ }
777
+
778
+ /**
779
+ * Limit results to the given number
780
+ * @param max The number to limit by (placeholder string or integer greater than 0)
781
+ */
782
+ limit(max: number | string) {
783
+ if (typeof max === 'string') {
784
+ max = max.trim();
785
+ if (this._isEntirelyDigitsNoZeros(max)) {
786
+ // is entirely digits (no leading zeros)
787
+ this._limit = parseInt(max, 10);
788
+ }
789
+ } else if (typeof max === 'number' && Number.isInteger(max) && max >= 1) {
790
+ this._limit = max;
791
+ } else {
792
+ throw new Error(
793
+ `SelectBuilder: "limit(n)" must receive a positive integer (got "${max}")`
794
+ );
795
+ }
796
+ return this;
797
+ }
798
+
799
+ /**
800
+ * Fetch results from the given offset
801
+ * @param number The offset (placeholder string or integer greater than or equal to 0)
802
+ */
803
+ offset(number: number | string) {
804
+ if (typeof number === 'string' && this._isEntirelyDigits(number.trim())) {
805
+ // is entirely digits (or zero)
806
+ this._offset = parseInt(number, 10);
807
+ } else if (
808
+ typeof number === 'number' &&
809
+ Number.isInteger(number) &&
810
+ number >= 0
811
+ ) {
812
+ this._offset = number;
813
+ } else {
814
+ throw new Error(
815
+ `SelectBuilder: "offset(n)" must receive a positive integer (got "${number}")`
816
+ );
817
+ }
818
+ return this;
819
+ }
820
+
821
+ /**
822
+ * Set the offset based on the limit with the given page number
823
+ * @param number The page number (integer greater than 0)
824
+ */
825
+ page(number: number | string) {
826
+ if (
827
+ typeof number === 'string' &&
828
+ this._isEntirelyDigitsNoZeros(number.trim())
829
+ ) {
830
+ // is entirely digits (or zero)
831
+ this._page = parseInt(number, 10);
832
+ } else if (
833
+ typeof number === 'number' &&
834
+ Number.isInteger(number) &&
835
+ number >= 1
836
+ ) {
837
+ this._page = number;
838
+ } else {
839
+ throw new Error(
840
+ 'SelectBuilder: "page(n)" must receive a positive integer'
841
+ );
842
+ }
843
+ return this;
844
+ }
845
+ }