@igojs/db 6.0.0-beta.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/src/Query.js ADDED
@@ -0,0 +1,584 @@
1
+
2
+ const _ = require('lodash');
3
+
4
+ const Sql = require('./Sql');
5
+ const dbs = require('./dbs');
6
+ const context = require('./context');
7
+
8
+ const DataTypes = require('./DataTypes');
9
+
10
+ //
11
+ const merge = (includes, includeParam) => {
12
+ // console.dir({MERGE: { includes, includeParam}}, { depth: 99 });
13
+ if (_.isString(includeParam)) {
14
+ if (!includes[includeParam]) {
15
+ includes[includeParam] = {};
16
+ }
17
+ return;
18
+ }
19
+
20
+ _.each(includeParam, (value, key) => {
21
+ if (includes[key]) {
22
+ if (_.isString(includes[key])) {
23
+ includes[key] = {};
24
+ }
25
+ merge(includes[key], value);
26
+ } else {
27
+ includes[key] = value;
28
+ }
29
+ });
30
+ // console.dir({RESULT: { includes }}, { depth: 99 });
31
+ };
32
+
33
+
34
+ //
35
+ module.exports = class Query {
36
+
37
+ constructor(modelClass, verb = 'select') {
38
+ this.modelClass = modelClass;
39
+ this.schema = modelClass.schema;
40
+
41
+ this.query = {
42
+ table: modelClass.schema.table,
43
+ select: null,
44
+ verb: verb,
45
+ where: [],
46
+ whereNot: [],
47
+ joins: [],
48
+ order: [],
49
+ distinct: null,
50
+ group: null,
51
+ includes: {},
52
+ options: {},
53
+ scopes: [ 'default' ]
54
+ };
55
+
56
+ // filter on subclass
57
+ const key = _.findKey(this.schema.subclasses, { name: this.modelClass.name });
58
+ if (key) {
59
+ this.query.where.push({
60
+ [this.schema.subclass_column]: key
61
+ });
62
+ }
63
+ }
64
+
65
+ // UPDATE
66
+ async update(values) {
67
+ this.query.verb = 'update';
68
+ this.values(values);
69
+ return await this.execute();
70
+ }
71
+
72
+ // DELETE
73
+ async delete() {
74
+ this.query.verb = 'delete';
75
+ return await this.execute();
76
+ }
77
+
78
+ // FROM
79
+ from(table) {
80
+ this.query.table = table;
81
+ return this;
82
+ }
83
+
84
+ // WHERE
85
+ where(where, params) {
86
+ where = params !== undefined ? [where, params] : where;
87
+ this.query.where.push(where);
88
+ return this;
89
+ }
90
+
91
+ // WHERE NOT
92
+ whereNot(whereNot) {
93
+ this.query.whereNot.push(whereNot);
94
+ return this;
95
+ }
96
+
97
+ // VALUES
98
+ values(values) {
99
+ const { logger } = context;
100
+ this.query.values = _.transform(values, (result, value, key) => {
101
+ const column = this.schema.colsByAttr[key];
102
+ if (column) {
103
+ if (DataTypes[column.type]) {
104
+ result[column.name] = DataTypes[column.type].set(value);
105
+ } else {
106
+ // unknown type
107
+ logger.warn(`Unknown type '${column.type}' for column '${column.name}'`);
108
+ }
109
+ } else {
110
+ // unknown column (ignore)
111
+ }
112
+ }, {});
113
+ return this;
114
+ }
115
+
116
+ // FIRST
117
+ async first() {
118
+ this.query.limit = 1;
119
+ this.query.take = 'first';
120
+ return await this.execute();
121
+ }
122
+
123
+ // LAST
124
+ async last() {
125
+ this.query.limit = 1;
126
+ this.query.take = 'last';
127
+ return await this.execute();
128
+ }
129
+
130
+ // LIMIT
131
+ limit(limit) {
132
+ this.query.limit = limit;
133
+ return this;
134
+ }
135
+
136
+ // OFFSET
137
+ offset(offset) {
138
+ this.query.offset = offset;
139
+ return this;
140
+ }
141
+
142
+ // PAGE
143
+ page(page, nb) {
144
+ this.query.page = parseInt(page, 10) || 1;
145
+ this.query.page = Math.max(1, this.query.page);
146
+ this.query.nb = parseInt(nb, 10) || 25;
147
+ return this;
148
+ }
149
+
150
+ // SCOPE
151
+ scope(scope) {
152
+ this.query.scopes.push(scope);
153
+ return this;
154
+ }
155
+
156
+ // UNSCOPED
157
+ unscoped() {
158
+ this.query.scopes.length = 0;
159
+ return this;
160
+ }
161
+
162
+ // LIST
163
+ async list() {
164
+ return await this.execute();
165
+ }
166
+
167
+ // SELECT
168
+ select(select) {
169
+ this.query.select = select;
170
+ return this;
171
+ }
172
+
173
+ // COUNT
174
+ async count() {
175
+ const countQuery = new Query(this.modelClass);
176
+ countQuery.query = _.cloneDeep(this.query);
177
+
178
+ countQuery.query.verb = 'count';
179
+ countQuery.query.limit = 1;
180
+ delete countQuery.query.page;
181
+ delete countQuery.query.nb;
182
+
183
+ const rows = await countQuery.execute();
184
+ const count = rows && rows[0] && Number(rows[0].count) || 0;
185
+ return count;
186
+ }
187
+
188
+ // JOIN
189
+ join(join, columns, type) {
190
+ if (_.isString(join)) {
191
+ return this.joinOne(join, columns, type);
192
+ } else if (_.isArray(join)) {
193
+ return this.joinMany(join, columns, type);
194
+ } else if (_.isObject(join)) {
195
+ return this.joinNested(join);
196
+ }
197
+ throw new Error('Invalid join argument. Must be a string, array, or object.');
198
+ }
199
+
200
+ // JOIN ONE
201
+ joinOne(associationName, columns, type = 'LEFT') {
202
+ const { query } = this;
203
+ const association = this._findAssociation(associationName, this.schema);
204
+ query.joins.push({ src_schema: this.schema, association, columns, type, src_alias: this.schema.table });
205
+ return this;
206
+ }
207
+
208
+ // JOIN MANY
209
+ joinMany(associationNames, columns, type = 'LEFT') {
210
+ _.forEach(associationNames, name => this.joinOne(name, columns, type));
211
+ return this;
212
+ }
213
+
214
+ // JOIN NESTED
215
+ joinNested(nestedAssociations) {
216
+ const processJoin = (join, current_schema, current_alias) => {
217
+ if (_.isString(join)) {
218
+ this._addJoin(join, null, 'LEFT', current_schema, current_alias);
219
+ return;
220
+ }
221
+
222
+ if (_.isArray(join)) {
223
+ join.forEach(j => processJoin(j, current_schema, current_alias));
224
+ return;
225
+ }
226
+
227
+ if (_.isObject(join)) {
228
+ _.each(join, (value, key) => {
229
+ const new_join_alias = key;
230
+ const association = this._addJoin(key, null, 'LEFT', current_schema, current_alias);
231
+ const next_schema = association[2].schema;
232
+ processJoin(value, next_schema, new_join_alias);
233
+ });
234
+ }
235
+ };
236
+ processJoin(nestedAssociations, this.schema, this.schema.table);
237
+ return this;
238
+ }
239
+
240
+ // Helper to find association
241
+ _findAssociation(associationName, src_schema) {
242
+ const association = _.find(src_schema.associations, assoc => assoc[1] === associationName);
243
+ if (!association) {
244
+ throw new Error(`Missing association '${associationName}' on '${src_schema.table}' schema.`);
245
+ }
246
+ if (association[0] !== 'belongs_to') {
247
+ throw new Error(`Association '${associationName}' on '${src_schema.table}' schema is not a 'belongs_to' association.`);
248
+ }
249
+ return association;
250
+ }
251
+
252
+ // Helper to add join to query.joins
253
+ _addJoin(associationName, columns, type, src_schema, src_alias_for_join) {
254
+ const association = this._findAssociation(associationName, src_schema);
255
+ this.query.joins.push({ src_schema, association, columns, type, src_alias: src_alias_for_join });
256
+ return association;
257
+ }
258
+
259
+ // SCOPES
260
+ applyScopes() {
261
+ const { query, schema } = this;
262
+ _.forOwn(query.scopes, (scope) => {
263
+ if (!schema.scopes[scope]) {
264
+ return;
265
+ }
266
+ schema.scopes[scope](this);
267
+ });
268
+ }
269
+
270
+ // INCLUDES
271
+ includes(includeParams) {
272
+ const { query } = this;
273
+ const pushInclude = includeParam => {
274
+ merge(query.includes, includeParam);
275
+ };
276
+ _.forEach(_.concat([], includeParams), pushInclude);
277
+ return this;
278
+ }
279
+
280
+ // FIND
281
+ async find(id) {
282
+ if (id === null || id === undefined) {
283
+ return null;
284
+ }
285
+
286
+ if (_.isString(id) || _.isNumber(id)) {
287
+ return await this.where({ id }).first();
288
+ }
289
+
290
+ if (_.isArray(id)) {
291
+ id = _.compact(id);
292
+ if (id.length === 0) {
293
+ return null;
294
+ }
295
+ return await this.where({ id }).first();
296
+ }
297
+ return await this.where(id).first();
298
+ }
299
+
300
+ // ORDER BY
301
+ order(order) {
302
+ this.query.order.push(order);
303
+ return this;
304
+ }
305
+
306
+ // DISTINCT
307
+ distinct(columns) {
308
+ this.query.distinct = _.isArray(columns) ? columns : [ columns ];
309
+ return this;
310
+ }
311
+
312
+ // GROUP
313
+ group(columns) {
314
+ this.query.group = _.castArray(columns);
315
+ return this;
316
+ }
317
+
318
+ // QUERY OPTIONS
319
+ options(options) {
320
+ _.merge(this.query.options, options);
321
+ return this;
322
+ }
323
+
324
+ getDb() {
325
+ return dbs[this.schema.database];
326
+ }
327
+
328
+ // generate SQL
329
+ toSQL() {
330
+ const { query } = this;
331
+ const db = this.getDb();
332
+ const sql = new Sql(this.query, db.driver.dialect)[this.query.verb + 'SQL']();
333
+ query.generated = sql;
334
+ return sql;
335
+ }
336
+
337
+ //
338
+ async paginate() {
339
+ const { query } = this;
340
+ if (!query.page) {
341
+ return;
342
+ }
343
+
344
+ const count = await this.count();
345
+ const nb_pages = Math.ceil(count / query.nb);
346
+ query.page = Math.min(query.page, nb_pages);
347
+ query.page = Math.max(query.page, 1);
348
+ query.offset = (query.page - 1) * query.nb;
349
+ query.limit = query.nb;
350
+
351
+ const links = [];
352
+ const page = this.query.page;
353
+ const start = Math.max(1, page - 5);
354
+ for (let i = 0; i < 10; i++) {
355
+ const p = start + i;
356
+ if (p <= nb_pages) {
357
+ links.push({ page: p, current: page === p });
358
+ }
359
+ }
360
+ return {
361
+ page: this.query.page,
362
+ nb: this.query.nb,
363
+ previous: page > 1 ? page - 1 : null,
364
+ next: page < nb_pages ? page + 1 : null,
365
+ start: query.offset + 1,
366
+ end: query.offset + Math.min(query.nb, count - query.offset),
367
+ nb_pages,
368
+ count,
369
+ links,
370
+ };
371
+ }
372
+
373
+ //
374
+ async loadAssociation(include, rows) {
375
+
376
+ let schema = this.schema;
377
+ let association = null;
378
+ let parts, path = null;
379
+
380
+ if (include.indexOf('.') !== -1) {
381
+ // nested include
382
+ parts = include.split('.');
383
+ path = parts.slice(0, parts.length - 1).join('.') + '.';
384
+ for (const part of parts) {
385
+ association = _.find(schema.associations, (assoc) => {
386
+ return assoc[1] === part;
387
+ });
388
+ schema = association ? association[2].schema : null;
389
+ }
390
+ } else {
391
+ association = _.find(schema.associations, (association) => {
392
+ return association[1] === include;
393
+ });
394
+ }
395
+
396
+ if (!association) {
397
+ throw new Error(`Missing association '${include}' on '${schema.table}' schema.`);
398
+ }
399
+
400
+ const [type, attr, Obj, column = attr + '_id', ref_column = 'id', extraWhere] = association;
401
+
402
+ let column_path = column;
403
+ if (path) {
404
+ if (type === 'has_many') {
405
+ column_path = parts.slice(0, parts.length - 2).join('.');
406
+ if (column_path) {
407
+ column_path += '.';
408
+ }
409
+ column_path += ref_column;
410
+ } else {
411
+ column_path = path + column;
412
+ }
413
+
414
+ }
415
+
416
+ const ids = _.chain(rows).flatMap(column_path).uniq().compact().value();
417
+ const defaultValue = () => (type === 'has_many' ? [] : null);
418
+
419
+ if (ids.length === 0) {
420
+ _.forEach(rows, (row) => row[attr] = defaultValue());
421
+ return;
422
+ }
423
+
424
+ const where = {
425
+ [ref_column]: ids
426
+ };
427
+ const subincludes = this.query.includes[include];
428
+ let query = Obj.includes(subincludes).where(where);
429
+ if (extraWhere) {
430
+ query.where(extraWhere);
431
+ }
432
+
433
+ const objs = await query.list();
434
+
435
+ const objsByKey = {};
436
+ _.forEach(objs, (obj) => {
437
+ const key = obj[ref_column];
438
+ if (type === 'has_many') {
439
+ objsByKey[key] = objsByKey[key] || [];
440
+ objsByKey[key].push(obj);
441
+ } else {
442
+ objsByKey[key] = obj;
443
+ }
444
+ });
445
+
446
+ const attr_path = path ? path + attr : attr;
447
+ _.forEach(rows, (row) => {
448
+ const value = _.get(row, column_path);
449
+ if (!Array.isArray(value)) {
450
+ _.set(row, attr_path, objsByKey[value] || defaultValue());
451
+ return;
452
+ }
453
+ row[attr] = _.chain(value).flatMap(id => objsByKey[id]).compact().value();
454
+ });
455
+ }
456
+ //
457
+ async execute() {
458
+ const { query, schema } = this;
459
+ const db = this.getDb();
460
+ const { dialect } = db.driver;
461
+ const { esc } = dialect;
462
+
463
+ if (schema.scopes) {
464
+ this.applyScopes();
465
+ }
466
+
467
+ if (query.order.length === 0 &&
468
+ (query.take === 'first' || query.take === 'last')) {
469
+ const order = query.take === 'first' ? 'ASC' : 'DESC';
470
+ // Default sort by primary key
471
+ _.forEach(schema.primary, (key) => {
472
+ query.order.push(`${esc}${schema.table}${esc}.${esc}${key}${esc} ${order}`);
473
+ });
474
+ }
475
+
476
+ // force limit to 1 for first/last
477
+ if (query.take === 'first' || query.take === 'last') {
478
+ query.limit = 1;
479
+ }
480
+
481
+ const pagination = await this.paginate();
482
+ let rows = await this.runQuery();
483
+
484
+ if (query.verb === 'insert') {
485
+ const insertId = dialect.insertId(rows);
486
+ return { insertId };
487
+ } else if (query.verb !== 'select') {
488
+ return rows;
489
+ }
490
+
491
+ if (query.distinct || query.group) {
492
+ return rows;
493
+ } else if (query.limit === 1 && (!rows || rows.length === 0)) {
494
+ return null;
495
+ } else if (query.verb === 'select') {
496
+ rows = _.each(rows, row => {
497
+ schema.parseTypes(row);
498
+
499
+ // parse joins values
500
+ _.forEach(this.query.joins, (join) => {
501
+ const { src_schema, association } = join;
502
+ const [assoc_type, name, Obj, src_column, column] = association;
503
+ Obj.schema.parseTypes(row, `${name}__`);
504
+ });
505
+ });
506
+ }
507
+
508
+ if (query.verb === 'select') {
509
+ rows = _.map(rows, row => {
510
+ const instance = this.newInstance(row);
511
+
512
+ if (this.query.joins.length === 0) {
513
+ return instance;
514
+ }
515
+
516
+ const createdInstances = new Map();
517
+ createdInstances.set(this.schema, instance);
518
+
519
+ _.forEach(this.query.joins, (join) => {
520
+ const { src_schema, association } = join;
521
+ const [assoc_type, name, Obj, src_column, column] = association;
522
+ const table_alias = name;
523
+
524
+ const params = {};
525
+ Obj.schema.columns.forEach(col => {
526
+ const alias = `${table_alias}__${col.attr}`;
527
+ params[col.attr] = row[alias];
528
+ delete instance[alias];
529
+ });
530
+
531
+ const joinInstance = this.newInstance(params, Obj);
532
+
533
+ const parentInstance = createdInstances.get(src_schema);
534
+
535
+ if (parentInstance) {
536
+ parentInstance[name] = joinInstance || null;
537
+ if (joinInstance) {
538
+ createdInstances.set(Obj.schema, joinInstance);
539
+ }
540
+ }
541
+ });
542
+
543
+ return instance;
544
+ });
545
+ }
546
+
547
+ // Load associations
548
+ for (let include of _.keys(query.includes)) {
549
+ await this.loadAssociation(include, rows);
550
+ }
551
+
552
+ if (pagination) {
553
+ return { pagination, rows };
554
+ }
555
+
556
+ if (query.limit === 1) {
557
+ return rows[0];
558
+ }
559
+
560
+ return rows;
561
+
562
+ }
563
+
564
+ // run the query
565
+ async runQuery() {
566
+ const { query } = this;
567
+ const sqlQuery = this.toSQL();
568
+ const db = this.getDb();
569
+ return await db.query(sqlQuery.sql, sqlQuery.params, query.options);
570
+ }
571
+
572
+ // new instance of model object
573
+ newInstance(row, instanceClass=this.modelClass) {
574
+ // let instanceClass = this.modelClass;
575
+ if (_.every(this.schema.primary, key => row[key] === null)) {
576
+ return null; // no primary key, no instance
577
+ }
578
+ const type = row[this.schema.subclass_column];
579
+ if (this.schema.subclasses && type) {
580
+ instanceClass = this.schema.subclasses[type];
581
+ }
582
+ return new instanceClass(row);
583
+ }
584
+ };
package/src/Schema.js ADDED
@@ -0,0 +1,52 @@
1
+
2
+ const _ = require('lodash');
3
+
4
+ const DataTypes = require('./DataTypes');
5
+
6
+
7
+ module.exports = class Schema {
8
+
9
+ constructor(values) {
10
+ _.assign(this, values);
11
+
12
+ this.primary = values.primary || ['id'];
13
+ this.subclass_column = values.subclass_column || 'type';
14
+ this.database = values.database || 'main';
15
+
16
+ // Map columns
17
+ this.columns = _.map(values.columns, column => {
18
+ if (typeof column === 'object') {
19
+ column.attr = column.attr || column.name;
20
+ return column;
21
+ }
22
+ return { name: column, attr: column, type: 'default' };
23
+ });
24
+ this.colsByName = _.keyBy(this.columns, 'name');
25
+ this.colsByAttr = _.keyBy(this.columns, 'attr');
26
+
27
+ // asynchronous loading of associations for circular dependencies
28
+ process.nextTick(() => {
29
+ if (_.isFunction(values.associations)) {
30
+ this.associations = values.associations();
31
+ }
32
+ if (_.isFunction(values.subclasses)) {
33
+ this.subclasses = values.subclasses();
34
+ }
35
+ });
36
+ }
37
+
38
+ parseTypes(row, prefix='') {
39
+ _.forOwn(row, (value, key) => {
40
+ if (prefix && !key.startsWith(prefix)) {
41
+ return; // skip if not prefixed
42
+ }
43
+ key = key.slice(prefix.length);
44
+ const column = this.colsByName[key];
45
+ const type = column && DataTypes[column.type];
46
+ if (type) {
47
+ row[prefix + column.attr] = type.get(value);
48
+ }
49
+ });
50
+ }
51
+
52
+ };