leoric 2.3.2 → 2.4.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.
@@ -6,84 +6,6 @@ const { copyExpr, findExpr, walkExpr } = require('../../expr');
6
6
  const { formatExpr, formatConditions, collectLiteral } = require('../../expr_formatter');
7
7
  const Raw = require('../../raw');
8
8
 
9
- /**
10
- * Format orders into ORDER BY clause in SQL
11
- * @param {Spell} spell
12
- * @param {Object[]} orders
13
- */
14
- function formatOrders(spell, orders) {
15
- return orders.map(([token, order]) => {
16
- const column = formatExpr(spell, token);
17
- return order == 'desc' ? `${column} DESC` : column;
18
- });
19
- }
20
-
21
- /**
22
- * Format a spell without joins into a full SELECT query. This function is also used to format the subquery which is then used as a drived table in a SELECT with joins.
23
- * @param {Spell} spell
24
- */
25
- function formatSelectWithoutJoin(spell) {
26
- const { columns, whereConditions, groups, havingConditions, orders, rowCount, skip } = spell;
27
- const chunks = ['SELECT'];
28
- const values = [];
29
-
30
- // see https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
31
- const hintStr = this.formatOptimizerHints(spell);
32
-
33
- if (hintStr) {
34
- chunks.push(hintStr);
35
- }
36
-
37
- if (columns.length > 0) {
38
- for (const column of columns) collectLiteral(spell, column, values);
39
- const selects = [];
40
- for (const token of columns) {
41
- const column = formatExpr(spell, token);
42
- if (!selects.includes(column)) selects.push(column);
43
- }
44
- chunks.push(`${selects.join(', ')}`);
45
- } else {
46
- chunks.push('*');
47
- }
48
-
49
- const table = formatExpr(spell, spell.table);
50
- chunks.push(`FROM ${table}`);
51
- if (spell.table.value instanceof spell.constructor) {
52
- chunks.push(`AS t${spell.subqueryIndex++}`);
53
- }
54
-
55
- // see https://dev.mysql.com/doc/refman/8.0/en/index-hints.html
56
- const indexHintStr = this.formatIndexHints(spell);
57
- if (indexHintStr) {
58
- chunks.push(indexHintStr);
59
- }
60
-
61
- if (whereConditions.length > 0) {
62
- for (const condition of whereConditions) collectLiteral(spell, condition, values);
63
- chunks.push(`WHERE ${formatConditions(spell, whereConditions)}`);
64
- }
65
-
66
- if (groups.length > 0) {
67
- const groupColumns = groups.map(group => formatExpr(spell, group));
68
- chunks.push(`GROUP BY ${groupColumns.join(', ')}`);
69
- }
70
-
71
- if (havingConditions.length > 0) {
72
- for (const condition of havingConditions) collectLiteral(spell, condition, values);
73
- chunks.push(`HAVING ${formatConditions(spell, havingConditions)}`);
74
- }
75
-
76
- if (orders.length > 0) {
77
- // ORDER BY FIND_IN_SET(`id`, '1,2,3')
78
- for (const [ expr ] of orders) collectLiteral(spell, expr, values);
79
- chunks.push(`ORDER BY ${formatOrders(spell, orders).join(', ')}`);
80
- }
81
- if (rowCount > 0) chunks.push(`LIMIT ${rowCount}`);
82
- if (skip > 0) chunks.push(`OFFSET ${skip}`);
83
-
84
- return { sql: chunks.join(' '), values };
85
- }
86
-
87
9
  /**
88
10
  * Create a subquery to make sure OFFSET and LIMIT on left table takes effect.
89
11
  * @param {Spell} spell
@@ -194,416 +116,485 @@ function formatSelectExpr(spell, values) {
194
116
  return Array.from(selects);
195
117
  }
196
118
 
197
- /**
198
- * Format a spell with joins into a full SELECT query.
199
- * @param {Spell} spell
200
- */
201
- function formatSelectWithJoin(spell) {
202
- // Since it is a JOIN query, make sure columns are always qualified.
203
- qualify(spell);
204
-
205
- const { Model, whereConditions, groups, havingConditions, orders, rowCount, skip, joins } = spell;
206
- const { escapeId } = Model.driver;
207
- const baseName = Model.tableAlias;
208
-
209
- const chunks = ['SELECT'];
210
- const values = [];
211
- const selects = formatSelectExpr(spell, values);
212
-
213
- // see https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
214
- const hintStr = this.formatOptimizerHints(spell);
215
-
216
- if (hintStr) {
217
- chunks.push(hintStr);
218
- }
219
- chunks.push(selects.join(', '));
220
-
221
- let hoistable = skip > 0 || rowCount > 0;
222
- if (hoistable) {
223
- function checkQualifier({ type, qualifiers = [] }) {
224
- if (type === 'id' && qualifiers.length> 0 && !qualifiers.includes(baseName)) {
225
- hoistable = false;
226
- }
119
+ class SpellBook {
120
+ format(spell) {
121
+ for (const scope of spell.scopes) scope(spell);
122
+ switch (spell.command) {
123
+ case 'insert':
124
+ case 'bulkInsert':
125
+ return this.formatInsert(spell);
126
+ case 'select':
127
+ return this.formatSelect(spell);
128
+ case 'update':
129
+ return this.formatUpdate(spell);
130
+ case 'delete':
131
+ return this.formatDelete(spell);
132
+ case 'upsert':
133
+ return this.formatUpsert(spell);
134
+ default:
135
+ throw new Error(`Unsupported SQL command ${spell.command}`);
227
136
  }
228
- for (const condition of whereConditions) walkExpr(condition, checkQualifier);
229
- for (const orderExpr of orders) walkExpr(orderExpr[0], checkQualifier);
230
- }
231
-
232
- if (hoistable) {
233
- const subspell = createSubspell(spell);
234
- const subquery = this.formatSelectWithoutJoin(subspell);
235
- values.push(...subquery.values);
236
- chunks.push(`FROM (${subquery.sql}) AS ${escapeId(baseName)}`);
237
- } else {
238
- chunks.push(`FROM ${escapeId(Model.table)} AS ${escapeId(baseName)}`);
239
137
  }
240
138
 
241
- for (const qualifier in joins) {
242
- const { Model: RefModel, on } = joins[qualifier];
243
- collectLiteral(spell, on, values);
244
- chunks.push(`LEFT JOIN ${escapeId(RefModel.table)} AS ${escapeId(qualifier)} ON ${formatExpr(spell, on)}`);
139
+ /**
140
+ * @abstract
141
+ * @returns {string} optimizer hints
142
+ */
143
+ formatOptimizerHints() {
144
+ return '';
245
145
  }
246
146
 
247
- // see https://dev.mysql.com/doc/refman/8.0/en/index-hints.html
248
- const indexHintStr = this.formatIndexHints(spell);
249
- if (indexHintStr) {
250
- chunks.push(indexHintStr);
147
+ /**
148
+ * @abstract
149
+ * @returns {string} index hints
150
+ */
151
+ formatIndexHints() {
152
+ return '';
251
153
  }
252
154
 
253
- if (whereConditions.length > 0) {
254
- for (const condition of whereConditions) collectLiteral(spell, condition, values);
255
- chunks.push(`WHERE ${formatConditions(spell, whereConditions)}`);
155
+ /**
156
+ * Format a spell into INSERT query.
157
+ * @param {Spell} spell
158
+ */
159
+ formatInsert(spell) {
160
+ const { Model, sets, columnAttributes: optAttrs, updateOnDuplicate } = spell;
161
+ const { shardingKey } = Model;
162
+ const { createdAt } = Model.timestamps;
163
+ const { escapeId } = Model.driver;
164
+ let columns = [];
165
+ let updateOnDuplicateColumns = [];
166
+
167
+ let values = [];
168
+ let placeholders = [];
169
+ if (Array.isArray(sets)) {
170
+ // merge records to get the big picture of involved columnAttributes
171
+ const involved = sets.reduce((result, entry) => {
172
+ return Object.assign(result, entry);
173
+ }, {});
174
+ const columnAttributes = [];
175
+ if (optAttrs) {
176
+ for (const name in optAttrs) {
177
+ if (involved.hasOwnProperty(name)) columnAttributes.push(columnAttributes[name]);
178
+ }
179
+ } else {
180
+ for (const name in involved) {
181
+ columnAttributes.push(Model.columnAttributes[name]);
182
+ }
183
+ }
184
+
185
+ for (const entry of columnAttributes) {
186
+ columns.push(entry.columnName);
187
+ if (updateOnDuplicate && createdAt && entry.name === createdAt
188
+ && !(Array.isArray(updateOnDuplicate) && updateOnDuplicate.includes(createdAt))) continue;
189
+ updateOnDuplicateColumns.push(entry.columnName);
190
+ }
191
+
192
+ for (const entry of sets) {
193
+ if (shardingKey && entry[shardingKey] == null) {
194
+ throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL.`);
195
+ }
196
+ for (const attribute of columnAttributes) {
197
+ const { name } = attribute;
198
+ values.push(entry[name]);
199
+ }
200
+ placeholders.push(`(${new Array(columnAttributes.length).fill('?').join(',')})`);
201
+ }
202
+
203
+ } else {
204
+ if (shardingKey && sets[shardingKey] == null) {
205
+ throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL.`);
206
+ }
207
+ for (const name in sets) {
208
+ const value = sets[name];
209
+ columns.push(Model.unalias(name));
210
+ if (value instanceof Raw) {
211
+ values.push(SqlString.raw(value.value));
212
+ } else {
213
+ values.push(value);
214
+ }
215
+ if (updateOnDuplicate && createdAt && name === createdAt) continue;
216
+ updateOnDuplicateColumns.push(Model.unalias(name));
217
+ }
218
+ }
219
+
220
+
221
+ const chunks = ['INSERT'];
222
+
223
+ // see https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
224
+ const hintStr = this.formatOptimizerHints(spell);
225
+ if (hintStr) {
226
+ chunks.push(hintStr);
227
+ }
228
+ chunks.push(`INTO ${escapeId(Model.table)} (${columns.map(column => escapeId(column)).join(', ')})`);
229
+ if (placeholders.length) {
230
+ chunks.push(`VALUES ${placeholders.join(', ')}`);
231
+ } else {
232
+ chunks.push(`VALUES (${columns.map(_ => '?').join(', ')})`);
233
+ }
234
+ chunks.push(this.formatUpdateOnDuplicate(spell, updateOnDuplicateColumns));
235
+ chunks.push(this.formatReturning(spell));
236
+ return {
237
+ sql: chunks.join(' ').trim(),
238
+ values,
239
+ };
256
240
  }
257
241
 
258
- if (groups.length > 0) {
259
- chunks.push(`GROUP BY ${groups.map(group => formatExpr(spell, group)).join(', ')}`);
260
- }
242
+ /**
243
+ * Format a spell without joins into a full SELECT query. This function is also used to format the subquery which is then used as a drived table in a SELECT with joins.
244
+ * @param {Spell} spell
245
+ */
246
+ formatSelectWithoutJoin(spell) {
247
+ const { columns, whereConditions, groups, havingConditions, orders, rowCount, skip } = spell;
248
+ const chunks = ['SELECT'];
249
+ const values = [];
261
250
 
262
- if (havingConditions.length > 0) {
263
- for (const condition of havingConditions) collectLiteral(spell, condition, values);
264
- chunks.push(`HAVING ${formatConditions(spell, havingConditions)}`);
265
- }
251
+ // see https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
252
+ const hintStr = this.formatOptimizerHints(spell);
266
253
 
267
- if (orders.length > 0) chunks.push(`ORDER BY ${formatOrders(spell, orders).join(', ')}`);
268
- if (!hoistable) {
269
- if (rowCount > 0) chunks.push(`LIMIT ${rowCount}`);
270
- if (skip > 0) chunks.push(`OFFSET ${skip}`);
271
- }
272
- return { sql: chunks.join(' '), values };
273
- }
254
+ if (hintStr) {
255
+ chunks.push(hintStr);
256
+ }
274
257
 
275
- /**
276
- * To help choosing the right function when formatting a spell into SELECT query.
277
- * @param {Spell} spell
278
- */
279
- function formatSelect(spell) {
280
- const { whereConditions } = spell;
281
- const { shardingKey, table } = spell.Model;
258
+ if (columns.length > 0) {
259
+ for (const column of columns) collectLiteral(spell, column, values);
260
+ const selects = [];
261
+ for (const token of columns) {
262
+ const column = formatExpr(spell, token);
263
+ if (!selects.includes(column)) selects.push(column);
264
+ }
265
+ chunks.push(`${selects.join(', ')}`);
266
+ } else {
267
+ chunks.push('*');
268
+ }
282
269
 
283
- if (shardingKey && !whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingKey }))) {
284
- throw new Error(`Sharding key ${table}.${shardingKey} is required.`);
285
- }
270
+ const table = formatExpr(spell, spell.table);
271
+ chunks.push(`FROM ${table}`);
272
+ if (spell.table.value instanceof spell.constructor) {
273
+ chunks.push(`AS t${spell.subqueryIndex++}`);
274
+ }
286
275
 
287
- if (spell.skip > 0 && spell.rowCount == null) {
288
- throw new Error('Unable to query with OFFSET yet without LIMIT');
289
- }
276
+ // see https://dev.mysql.com/doc/refman/8.0/en/index-hints.html
277
+ const indexHintStr = this.formatIndexHints(spell);
278
+ if (indexHintStr) {
279
+ chunks.push(indexHintStr);
280
+ }
290
281
 
291
- return Object.keys(spell.joins).length > 0
292
- ? this.formatSelectWithJoin(spell)
293
- : this.formatSelectWithoutJoin(spell);
294
- }
282
+ if (whereConditions.length > 0) {
283
+ for (const condition of whereConditions) collectLiteral(spell, condition, values);
284
+ chunks.push(`WHERE ${formatConditions(spell, whereConditions)}`);
285
+ }
295
286
 
296
- /**
297
- * Format the spell into a DELETE query.
298
- * @param {Spell} spell
299
- */
300
- function formatDelete(spell) {
301
- const { Model, whereConditions } = spell;
302
- const { shardingKey } = Model;
303
- const { escapeId } = Model.driver;
304
- const table = escapeId(Model.table);
287
+ if (groups.length > 0) {
288
+ const groupColumns = groups.map(group => formatExpr(spell, group));
289
+ chunks.push(`GROUP BY ${groupColumns.join(', ')}`);
290
+ }
305
291
 
306
- if (shardingKey && !whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingKey }))) {
307
- throw new Error(`Sharding key ${Model.table}.${shardingKey} is required.`);
308
- }
292
+ if (havingConditions.length > 0) {
293
+ for (const condition of havingConditions) collectLiteral(spell, condition, values);
294
+ chunks.push(`HAVING ${formatConditions(spell, havingConditions)}`);
295
+ }
309
296
 
310
- const chunks = ['DELETE'];
297
+ if (orders.length > 0) {
298
+ // ORDER BY FIND_IN_SET(`id`, '1,2,3')
299
+ for (const [ expr ] of orders) collectLiteral(spell, expr, values);
300
+ chunks.push(`ORDER BY ${this.formatOrders(spell, orders).join(', ')}`);
301
+ }
302
+ if (rowCount > 0) chunks.push(`LIMIT ${rowCount}`);
303
+ if (skip > 0) chunks.push(`OFFSET ${skip}`);
311
304
 
312
- // see https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
313
- const hintStr = this.formatOptimizerHints(spell);
314
- if (hintStr) {
315
- chunks.push(hintStr);
305
+ return { sql: chunks.join(' '), values };
316
306
  }
317
307
 
318
- chunks.push(`FROM ${table}`);
308
+ /**
309
+ * INSERT ... ON CONFLICT ... UPDATE SET
310
+ * - https://www.postgresql.org/docs/9.5/sql-insert.html
311
+ * - https://www.sqlite.org/lang_UPSERT.html
312
+ * @param {Spell} spell
313
+ */
314
+ formatUpsert(spell) {
315
+ if (!spell.updateOnDuplicate) {
316
+ spell.updateOnDuplicate = true;
317
+ }
319
318
 
320
- if (whereConditions.length > 0) {
321
- const values = [];
322
- for (const condition of whereConditions) collectLiteral(spell, condition, values);
323
- chunks.push(`WHERE ${formatConditions(spell, whereConditions)}`);
319
+ let { sql, values } = this.formatInsert(spell);
324
320
  return {
325
- sql: chunks.join(' '),
326
- values
321
+ sql,
322
+ values,
327
323
  };
328
- } else {
329
- return { sql: chunks.join(' ') };
330
324
  }
331
- }
332
325
 
333
- /**
334
- * Format a spell into INSERT query.
335
- * @param {Spell} spell
336
- */
337
- function formatInsert(spell) {
338
- const { Model, sets, columnAttributes: optAttrs, updateOnDuplicate } = spell;
339
- const { shardingKey } = Model;
340
- const { createdAt } = Model.timestamps;
341
- const { escapeId } = Model.driver;
342
- let columns = [];
343
- let updateOnDuplicateColumns = [];
344
-
345
- let values = [];
346
- let placeholders = [];
347
- if (Array.isArray(sets)) {
348
- // merge records to get the big picture of involved columnAttributes
349
- const involved = sets.reduce((result, entry) => {
350
- return Object.assign(result, entry);
351
- }, {});
352
- const columnAttributes = [];
353
- if (optAttrs) {
354
- for (const name in optAttrs) {
355
- if (involved.hasOwnProperty(name)) columnAttributes.push(columnAttributes[name]);
326
+ /**
327
+ * @param {Spell} spell
328
+ * @returns returning sql string
329
+ */
330
+ formatReturning(spell) {
331
+ const { Model, returning } = spell;
332
+ const { primaryColumn } = Model;
333
+ const { escapeId } = Model.driver;
334
+
335
+ let returnings;
336
+ if (returning === true) returnings = [ escapeId(primaryColumn) ];
337
+ if (Array.isArray(returning)) {
338
+ returnings = returning.map(escapeId);
339
+ }
340
+ return returnings && returnings.length? `RETURNING ${returnings.join(', ')}` : '';
341
+ }
342
+
343
+ /**
344
+ * @param {Spell} spell
345
+ * @param {Array} columns columns for value set
346
+ */
347
+ formatUpdateOnDuplicate(spell, columns) {
348
+ const { updateOnDuplicate, uniqueKeys, Model } = spell;
349
+ if (!updateOnDuplicate) return '';
350
+ const { columnAttributes, primaryColumn } = Model;
351
+ const { escapeId } = Model.driver;
352
+ const actualUniqueKeys = [];
353
+
354
+ if (uniqueKeys) {
355
+ for (const field of [].concat(uniqueKeys)) {
356
+ actualUniqueKeys.push(escapeId(field));
356
357
  }
357
358
  } else {
358
- for (const name in involved) {
359
- columnAttributes.push(Model.columnAttributes[name]);
359
+ // conflict_target must be unique
360
+ // get all unique keys
361
+ if (columnAttributes) {
362
+ for (const key in columnAttributes) {
363
+ const att = columnAttributes[key];
364
+ // use the first unique key
365
+ if (att.unique) {
366
+ actualUniqueKeys.push(escapeId(att.columnName));
367
+ break;
368
+ }
369
+ }
360
370
  }
371
+ if (!actualUniqueKeys.length) actualUniqueKeys.push(escapeId(primaryColumn));
372
+ // default use id as primary key
373
+ if (!actualUniqueKeys.length) actualUniqueKeys.push(escapeId('id'));
361
374
  }
362
375
 
363
- for (const entry of columnAttributes) {
364
- columns.push(entry.columnName);
365
- if (updateOnDuplicate && createdAt && entry.name === createdAt
366
- && !(Array.isArray(updateOnDuplicate) && updateOnDuplicate.includes(createdAt))) continue;
367
- updateOnDuplicateColumns.push(entry.columnName);
376
+ if (Array.isArray(updateOnDuplicate) && updateOnDuplicate.length) {
377
+ columns = updateOnDuplicate.map(column => (columnAttributes[column] && columnAttributes[column].columnName )|| column);
378
+ } else if (!columns.length) {
379
+ columns = Object.values(columnAttributes).map(({ columnName }) => columnName);
368
380
  }
381
+ const updateKeys = columns.map((column) => `${escapeId(column)}=EXCLUDED.${escapeId(column)}`);
369
382
 
370
- for (const entry of sets) {
371
- if (shardingKey && entry[shardingKey] == null) {
372
- throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL.`);
383
+ return `ON CONFLICT (${actualUniqueKeys.join(', ')}) DO UPDATE SET ${updateKeys.join(', ')}`;
384
+ }
385
+
386
+ /**
387
+ * Format a spell into UPDATE query
388
+ * @param {Spell} spell
389
+ */
390
+ formatUpdate(spell) {
391
+ const { Model, sets, whereConditions } = spell;
392
+ const { shardingKey } = Model;
393
+
394
+ if (shardingKey) {
395
+ if (sets.hasOwnProperty(shardingKey) && sets[shardingKey] == null) {
396
+ throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL`);
373
397
  }
374
- for (const attribute of columnAttributes) {
375
- const { name } = attribute;
376
- values.push(entry[name]);
398
+ if (!whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingKey }))) {
399
+ throw new Error(`Sharding key ${Model.table}.${shardingKey} is required.`);
377
400
  }
378
- placeholders.push(`(${new Array(columnAttributes.length).fill('?').join(',')})`);
379
401
  }
380
402
 
381
- } else {
382
- if (shardingKey && sets[shardingKey] == null) {
383
- throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL.`);
403
+ if (Object.keys(sets).length === 0) {
404
+ throw new Error('Unable to update with empty set');
384
405
  }
406
+
407
+ const chunks = ['UPDATE'];
408
+
409
+ const values = [];
410
+ const assigns = [];
411
+ const { escapeId } = Model.driver;
385
412
  for (const name in sets) {
386
413
  const value = sets[name];
387
- columns.push(Model.unalias(name));
388
- if (value instanceof Raw) {
389
- values.push(SqlString.raw(value.value));
414
+ if (value && value.__expr) {
415
+ assigns.push(`${escapeId(Model.unalias(name))} = ${formatExpr(spell, value)}`);
416
+ collectLiteral(spell, value, values);
417
+ } else if (value instanceof Raw) {
418
+ assigns.push(`${escapeId(Model.unalias(name))} = ${value.value}`);
390
419
  } else {
391
- values.push(value);
420
+ assigns.push(`${escapeId(Model.unalias(name))} = ?`);
421
+ values.push(sets[name]);
392
422
  }
393
- if (updateOnDuplicate && createdAt && name === createdAt) continue;
394
- updateOnDuplicateColumns.push(Model.unalias(name));
395
423
  }
396
- }
397
424
 
425
+ // see https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
426
+ const hintStr = this.formatOptimizerHints(spell);
427
+ // see https://dev.mysql.com/doc/refman/8.0/en/index-hints.html
428
+ const indexHintStr = this.formatIndexHints(spell);
398
429
 
399
- const chunks = ['INSERT'];
430
+ if (hintStr) {
431
+ chunks.push(hintStr);
432
+ }
433
+ chunks.push(escapeId(Model.table));
434
+ if (indexHintStr) {
435
+ chunks.push(indexHintStr);
436
+ }
400
437
 
401
- // see https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
402
- const hintStr = this.formatOptimizerHints(spell);
403
- if (hintStr) {
404
- chunks.push(hintStr);
405
- }
406
- chunks.push(`INTO ${escapeId(Model.table)} (${columns.map(column => escapeId(column)).join(', ')})`);
407
- if (placeholders.length) {
408
- chunks.push(`VALUES ${placeholders.join(', ')}`);
409
- } else {
410
- chunks.push(`VALUES (${columns.map(_ => '?').join(', ')})`);
438
+ chunks.push(`SET ${assigns.join(', ')}`);
439
+ if (whereConditions.length > 0) {
440
+ for (const condition of whereConditions) collectLiteral(spell, condition, values);
441
+ chunks.push(`WHERE ${formatConditions(spell, whereConditions)}`);
442
+ }
443
+
444
+ return {
445
+ sql: chunks.join(' '),
446
+ values,
447
+ };
411
448
  }
412
- chunks.push(this.formatUpdateOnDuplicate(spell, updateOnDuplicateColumns));
413
- chunks.push(this.formatReturning(spell));
414
- return {
415
- sql: chunks.join(' ').trim(),
416
- values,
417
- };
418
- }
419
449
 
420
- /**
421
- * Format a spell into UPDATE query
422
- * @param {Spell} spell
423
- */
424
- function formatUpdate(spell) {
425
- const { Model, sets, whereConditions } = spell;
426
- const { shardingKey } = Model;
450
+ /**
451
+ * Format the spell into a DELETE query.
452
+ * @param {Spell} spell
453
+ */
454
+ formatDelete(spell) {
455
+ const { Model, whereConditions } = spell;
456
+ const { shardingKey } = Model;
457
+ const { escapeId } = Model.driver;
458
+ const table = escapeId(Model.table);
427
459
 
428
- if (shardingKey) {
429
- if (sets.hasOwnProperty(shardingKey) && sets[shardingKey] == null) {
430
- throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL`);
431
- }
432
- if (!whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingKey }))) {
460
+ if (shardingKey && !whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingKey }))) {
433
461
  throw new Error(`Sharding key ${Model.table}.${shardingKey} is required.`);
434
462
  }
435
- }
436
463
 
437
- if (Object.keys(sets).length === 0) {
438
- throw new Error('Unable to update with empty set');
439
- }
464
+ const chunks = ['DELETE'];
440
465
 
441
- const chunks = ['UPDATE'];
466
+ // see https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
467
+ const hintStr = this.formatOptimizerHints(spell);
468
+ if (hintStr) {
469
+ chunks.push(hintStr);
470
+ }
442
471
 
443
- const values = [];
444
- const assigns = [];
445
- const { escapeId } = Model.driver;
446
- for (const name in sets) {
447
- const value = sets[name];
448
- if (value && value.__expr) {
449
- assigns.push(`${escapeId(Model.unalias(name))} = ${formatExpr(spell, value)}`);
450
- collectLiteral(spell, value, values);
451
- } else if (value instanceof Raw) {
452
- assigns.push(`${escapeId(Model.unalias(name))} = ${value.value}`);
472
+ chunks.push(`FROM ${table}`);
473
+
474
+ if (whereConditions.length > 0) {
475
+ const values = [];
476
+ for (const condition of whereConditions) collectLiteral(spell, condition, values);
477
+ chunks.push(`WHERE ${formatConditions(spell, whereConditions)}`);
478
+ return {
479
+ sql: chunks.join(' '),
480
+ values
481
+ };
453
482
  } else {
454
- assigns.push(`${escapeId(Model.unalias(name))} = ?`);
455
- values.push(sets[name]);
483
+ return { sql: chunks.join(' ') };
456
484
  }
457
485
  }
458
486
 
459
- // see https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
460
- const hintStr = this.formatOptimizerHints(spell);
461
- // see https://dev.mysql.com/doc/refman/8.0/en/index-hints.html
462
- const indexHintStr = this.formatIndexHints(spell);
487
+ /**
488
+ * To help choosing the right function when formatting a spell into SELECT query.
489
+ * @param {Spell} spell
490
+ */
491
+ formatSelect(spell) {
492
+ const { whereConditions } = spell;
493
+ const { shardingKey, table } = spell.Model;
463
494
 
464
- if (hintStr) {
465
- chunks.push(hintStr);
466
- }
467
- chunks.push(escapeId(Model.table));
468
- if (indexHintStr) {
469
- chunks.push(indexHintStr);
470
- }
495
+ if (shardingKey && !whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingKey }))) {
496
+ throw new Error(`Sharding key ${table}.${shardingKey} is required.`);
497
+ }
498
+
499
+ if (spell.skip > 0 && spell.rowCount == null) {
500
+ throw new Error('Unable to query with OFFSET yet without LIMIT');
501
+ }
471
502
 
472
- chunks.push(`SET ${assigns.join(', ')}`);
473
- if (whereConditions.length > 0) {
474
- for (const condition of whereConditions) collectLiteral(spell, condition, values);
475
- chunks.push(`WHERE ${formatConditions(spell, whereConditions)}`);
503
+ return Object.keys(spell.joins).length > 0
504
+ ? this.formatSelectWithJoin(spell)
505
+ : this.formatSelectWithoutJoin(spell);
476
506
  }
477
507
 
478
- return {
479
- sql: chunks.join(' '),
480
- values,
481
- };
482
- }
508
+ /**
509
+ * Format a spell with joins into a full SELECT query.
510
+ * @param {Spell} spell
511
+ */
512
+ formatSelectWithJoin(spell) {
513
+ // Since it is a JOIN query, make sure columns are always qualified.
514
+ qualify(spell);
483
515
 
484
- /**
485
- * @param {Spell} spell
486
- * @param {Array} columns columns for value set
487
- */
488
- function formatUpdateOnDuplicate(spell, columns) {
489
- const { updateOnDuplicate, uniqueKeys, Model } = spell;
490
- if (!updateOnDuplicate) return '';
491
- const { columnAttributes, primaryColumn } = Model;
492
- const { escapeId } = Model.driver;
493
- const actualUniqueKeys = [];
494
-
495
- if (uniqueKeys) {
496
- for (const field of [].concat(uniqueKeys)) {
497
- actualUniqueKeys.push(escapeId(field));
498
- }
499
- } else {
500
- // conflict_target must be unique
501
- // get all unique keys
502
- if (columnAttributes) {
503
- for (const key in columnAttributes) {
504
- const att = columnAttributes[key];
505
- // use the first unique key
506
- if (att.unique) {
507
- actualUniqueKeys.push(escapeId(att.columnName));
508
- break;
516
+ const { Model, whereConditions, groups, havingConditions, orders, rowCount, skip, joins } = spell;
517
+ const { escapeId } = Model.driver;
518
+ const baseName = Model.tableAlias;
519
+
520
+ const chunks = ['SELECT'];
521
+ const values = [];
522
+ const selects = formatSelectExpr(spell, values);
523
+
524
+ // see https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
525
+ const hintStr = this.formatOptimizerHints(spell);
526
+
527
+ if (hintStr) {
528
+ chunks.push(hintStr);
529
+ }
530
+ chunks.push(selects.join(', '));
531
+
532
+ let hoistable = skip > 0 || rowCount > 0;
533
+ if (hoistable) {
534
+ function checkQualifier({ type, qualifiers = [] }) {
535
+ if (type === 'id' && qualifiers.length> 0 && !qualifiers.includes(baseName)) {
536
+ hoistable = false;
509
537
  }
510
538
  }
539
+ for (const condition of whereConditions) walkExpr(condition, checkQualifier);
540
+ for (const orderExpr of orders) walkExpr(orderExpr[0], checkQualifier);
511
541
  }
512
- if (!actualUniqueKeys.length) actualUniqueKeys.push(escapeId(primaryColumn));
513
- // default use id as primary key
514
- if (!actualUniqueKeys.length) actualUniqueKeys.push(escapeId('id'));
515
- }
516
542
 
517
- if (Array.isArray(updateOnDuplicate) && updateOnDuplicate.length) {
518
- columns = updateOnDuplicate.map(column => (columnAttributes[column] && columnAttributes[column].columnName )|| column);
519
- } else if (!columns.length) {
520
- columns = Object.values(columnAttributes).map(({ columnName }) => columnName);
521
- }
522
- const updateKeys = columns.map((column) => `${escapeId(column)}=EXCLUDED.${escapeId(column)}`);
523
-
524
- return `ON CONFLICT (${actualUniqueKeys.join(', ')}) DO UPDATE SET ${updateKeys.join(', ')}`;
525
- }
543
+ if (hoistable) {
544
+ const subspell = createSubspell(spell);
545
+ const subquery = this.formatSelectWithoutJoin(subspell);
546
+ values.push(...subquery.values);
547
+ chunks.push(`FROM (${subquery.sql}) AS ${escapeId(baseName)}`);
548
+ } else {
549
+ chunks.push(`FROM ${escapeId(Model.table)} AS ${escapeId(baseName)}`);
550
+ }
526
551
 
527
- /**
528
- * @param {Spell} spell
529
- * @returns returning sql string
530
- */
531
- function formatReturning(spell) {
532
- const { Model, returning } = spell;
533
- const { primaryColumn } = Model;
534
- const { escapeId } = Model.driver;
552
+ for (const qualifier in joins) {
553
+ const { Model: RefModel, on } = joins[qualifier];
554
+ collectLiteral(spell, on, values);
555
+ chunks.push(`LEFT JOIN ${escapeId(RefModel.table)} AS ${escapeId(qualifier)} ON ${formatExpr(spell, on)}`);
556
+ }
535
557
 
536
- let returnings;
537
- if (returning === true) returnings = [ escapeId(primaryColumn) ];
538
- if (Array.isArray(returning)) {
539
- returnings = returning.map(escapeId);
540
- }
541
- return returnings && returnings.length? `RETURNING ${returnings.join(', ')}` : '';
542
- }
558
+ // see https://dev.mysql.com/doc/refman/8.0/en/index-hints.html
559
+ const indexHintStr = this.formatIndexHints(spell);
560
+ if (indexHintStr) {
561
+ chunks.push(indexHintStr);
562
+ }
543
563
 
544
- /**
545
- * INSERT ... ON CONFLICT ... UPDATE SET
546
- * - https://www.postgresql.org/docs/9.5/static/sql-insert.html
547
- * - https://www.sqlite.org/lang_UPSERT.html
548
- * @param {Spell} spell
549
- */
550
- function formatUpsert(spell) {
551
- if (!spell.updateOnDuplicate) {
552
- spell.updateOnDuplicate = true;
553
- }
564
+ if (whereConditions.length > 0) {
565
+ for (const condition of whereConditions) collectLiteral(spell, condition, values);
566
+ chunks.push(`WHERE ${formatConditions(spell, whereConditions)}`);
567
+ }
554
568
 
555
- let { sql, values } = this.formatInsert(spell);
556
- return {
557
- sql,
558
- values,
559
- };
560
- }
569
+ if (groups.length > 0) {
570
+ chunks.push(`GROUP BY ${groups.map(group => formatExpr(spell, group)).join(', ')}`);
571
+ }
561
572
 
562
- module.exports = {
563
- format(spell) {
564
- for (const scope of spell.scopes) scope(spell);
573
+ if (havingConditions.length > 0) {
574
+ for (const condition of havingConditions) collectLiteral(spell, condition, values);
575
+ chunks.push(`HAVING ${formatConditions(spell, havingConditions)}`);
576
+ }
565
577
 
566
- switch (spell.command) {
567
- case 'insert':
568
- case 'bulkInsert':
569
- return this.formatInsert(spell);
570
- case 'select':
571
- return this.formatSelect(spell);
572
- case 'update':
573
- return this.formatUpdate(spell);
574
- case 'delete':
575
- return this.formatDelete(spell);
576
- case 'upsert':
577
- return this.formatUpsert(spell);
578
- default:
579
- throw new Error(`Unsupported SQL command ${spell.command}`);
578
+ if (orders.length > 0) chunks.push(`ORDER BY ${this.formatOrders(spell, orders).join(', ')}`);
579
+ if (!hoistable) {
580
+ if (rowCount > 0) chunks.push(`LIMIT ${rowCount}`);
581
+ if (skip > 0) chunks.push(`OFFSET ${skip}`);
580
582
  }
581
- },
583
+ return { sql: chunks.join(' '), values };
584
+ }
582
585
 
583
586
  /**
584
- * @abstract
585
- * @returns {string} optimizer hints
587
+ * Format orders into ORDER BY clause in SQL
588
+ * @param {Spell} spell
589
+ * @param {Object[]} orders
586
590
  */
587
- formatOptimizerHints() {
588
- return '';
589
- },
591
+ formatOrders(spell, orders) {
592
+ return orders.map(([token, order]) => {
593
+ const column = formatExpr(spell, token);
594
+ return order == 'desc' ? `${column} DESC` : column;
595
+ });
596
+ }
590
597
 
591
- /**
592
- * @abstract
593
- * @returns {string} index hints
594
- */
595
- formatIndexHints() {
596
- return '';
597
- },
598
-
599
- formatInsert,
600
- formatSelect,
601
- formatUpdate,
602
- formatDelete,
603
- formatUpsert,
604
- formatSelectWithJoin,
605
- formatSelectWithoutJoin,
606
- formatUpdateOnDuplicate,
607
- formatReturning,
608
- formatOrders
609
598
  };
599
+
600
+ module.exports = SpellBook;