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.
- package/.github/workflows/node.js.yml +37 -0
- package/.prettierrc.json +13 -0
- package/CHANGELOG.md +12 -0
- package/CONTRIBUTING.md +85 -0
- package/LICENSE.md +6 -0
- package/README.md +261 -0
- package/codecov.yml +7 -0
- package/dist/.gitkeep +0 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/src/Parser/Parser.d.ts +157 -0
- package/dist/src/Parser/Parser.js +304 -0
- package/dist/src/SelectBuilder/SelectBuilder.d.ts +355 -0
- package/dist/src/SelectBuilder/SelectBuilder.js +772 -0
- package/dist/src/substrCount/substrCount.d.ts +1 -0
- package/dist/src/substrCount/substrCount.js +9 -0
- package/index.ts +2 -0
- package/package.json +41 -0
- package/src/Parser/Parser.spec.ts +226 -0
- package/src/Parser/Parser.ts +325 -0
- package/src/SelectBuilder/SelectBuilder.spec.ts +741 -0
- package/src/SelectBuilder/SelectBuilder.ts +845 -0
- package/src/substrCount/substrCount.spec.ts +29 -0
- package/src/substrCount/substrCount.ts +9 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.json +22 -0
- package/vitest.config.js +8 -0
|
@@ -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
|
+
}
|