millas 0.2.20 → 0.2.23
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/package.json +1 -1
- package/src/admin/QueryEngine.js +17 -13
- package/src/cli.js +3 -0
- package/src/core/db.js +9 -8
- package/src/events/EventEmitter.js +12 -1
- package/src/facades/Database.js +55 -34
- package/src/orm/drivers/DatabaseManager.js +12 -0
- package/src/orm/fields/index.js +18 -0
- package/src/orm/migration/MigrationWriter.js +6 -0
- package/src/orm/migration/ModelInspector.js +4 -0
- package/src/orm/migration/operations/column.js +59 -95
- package/src/orm/migration/operations/fields.js +6 -6
- package/src/orm/migration/operations/models.js +3 -3
- package/src/orm/model/Model.js +293 -61
- package/src/orm/query/F.js +98 -0
- package/src/orm/query/LookupParser.js +316 -157
- package/src/orm/query/QueryBuilder.js +230 -7
- package/src/providers/DatabaseServiceProvider.js +2 -2
|
@@ -253,6 +253,27 @@ class QueryBuilder {
|
|
|
253
253
|
return this;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
join(table, col1, col2, col3) {
|
|
257
|
+
this._query = col3
|
|
258
|
+
? this._query.join(table, col1, col2, col3)
|
|
259
|
+
: this._query.join(table, col1, col2);
|
|
260
|
+
return this;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
leftJoin(table, col1, col2, col3) {
|
|
264
|
+
this._query = col3
|
|
265
|
+
? this._query.leftJoin(table, col1, col2, col3)
|
|
266
|
+
: this._query.leftJoin(table, col1, col2);
|
|
267
|
+
return this;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
rightJoin(table, col1, col2, col3) {
|
|
271
|
+
this._query = col3
|
|
272
|
+
? this._query.rightJoin(table, col1, col2, col3)
|
|
273
|
+
: this._query.rightJoin(table, col1, col2);
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
256
277
|
having(column, operatorOrValue, value) {
|
|
257
278
|
if (value !== undefined) {
|
|
258
279
|
this._query = this._query.having(column, operatorOrValue, value);
|
|
@@ -311,9 +332,20 @@ class QueryBuilder {
|
|
|
311
332
|
return instance;
|
|
312
333
|
}
|
|
313
334
|
|
|
314
|
-
/**
|
|
315
|
-
|
|
316
|
-
|
|
335
|
+
/**
|
|
336
|
+
* Find by primary key — allows chaining with .with().
|
|
337
|
+
* Post.with('author').find(1)
|
|
338
|
+
*/
|
|
339
|
+
async find(id) {
|
|
340
|
+
return this.where(this._model.primaryKey, id).first();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Find by primary key or throw 404.
|
|
345
|
+
* Post.with('author').findOrFail(1)
|
|
346
|
+
*/
|
|
347
|
+
async findOrFail(id, message) {
|
|
348
|
+
const result = await this.find(id);
|
|
317
349
|
if (!result) {
|
|
318
350
|
const HttpError = require('../../errors/HttpError');
|
|
319
351
|
throw new HttpError(404, message || `${this._model.name} not found`);
|
|
@@ -323,7 +355,11 @@ class QueryBuilder {
|
|
|
323
355
|
|
|
324
356
|
/** Count matching rows. */
|
|
325
357
|
async count(column = '*') {
|
|
326
|
-
const result = await this._query.clone()
|
|
358
|
+
const result = await this._query.clone()
|
|
359
|
+
.clearSelect()
|
|
360
|
+
.clearOrder()
|
|
361
|
+
.count(`${column} as count`)
|
|
362
|
+
.first();
|
|
327
363
|
return Number(result?.count ?? 0);
|
|
328
364
|
}
|
|
329
365
|
|
|
@@ -344,7 +380,11 @@ class QueryBuilder {
|
|
|
344
380
|
*/
|
|
345
381
|
async paginate(page = 1, perPage = 15) {
|
|
346
382
|
const offset = (page - 1) * perPage;
|
|
347
|
-
const total = await this._query.clone()
|
|
383
|
+
const total = await this._query.clone()
|
|
384
|
+
.clearSelect()
|
|
385
|
+
.clearOrder()
|
|
386
|
+
.count('* as count')
|
|
387
|
+
.first()
|
|
348
388
|
.then(r => Number(r?.count ?? 0));
|
|
349
389
|
|
|
350
390
|
const rows = await this._query.clone().limit(perPage).offset(offset);
|
|
@@ -363,7 +403,17 @@ class QueryBuilder {
|
|
|
363
403
|
|
|
364
404
|
/** Update matching rows. */
|
|
365
405
|
async update(data) {
|
|
366
|
-
|
|
406
|
+
const F = require('./F');
|
|
407
|
+
const extra = this._model._updatedAtPayload?.() ?? {};
|
|
408
|
+
const payload = {};
|
|
409
|
+
for (const [key, val] of Object.entries({ ...data, ...extra })) {
|
|
410
|
+
if (val instanceof F) {
|
|
411
|
+
payload[key] = val.toKnex(this._query.client);
|
|
412
|
+
} else {
|
|
413
|
+
payload[key] = val;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return this._query.update(payload);
|
|
367
417
|
}
|
|
368
418
|
|
|
369
419
|
/** Delete matching rows. */
|
|
@@ -371,7 +421,148 @@ class QueryBuilder {
|
|
|
371
421
|
return this._query.delete();
|
|
372
422
|
}
|
|
373
423
|
|
|
374
|
-
/**
|
|
424
|
+
/**
|
|
425
|
+
* Get exactly one result — raises if 0 or more than 1 found.
|
|
426
|
+
* Matches Django's QuerySet.get()
|
|
427
|
+
*/
|
|
428
|
+
async get_one(...args) {
|
|
429
|
+
if (args.length) this.where(...args);
|
|
430
|
+
this._query = this._query.limit(2);
|
|
431
|
+
const rows = await this._query;
|
|
432
|
+
if (rows.length === 0) {
|
|
433
|
+
const HttpError = require('../../errors/HttpError');
|
|
434
|
+
throw new HttpError(404, `${this._model.name} matching query does not exist.`);
|
|
435
|
+
}
|
|
436
|
+
if (rows.length > 1) {
|
|
437
|
+
throw new Error(`get() returned more than one ${this._model.name} — it returned ${rows.length}!`);
|
|
438
|
+
}
|
|
439
|
+
const instance = this._model._hydrate(rows[0]);
|
|
440
|
+
if (this._withs.length) await this._eagerLoad([instance]);
|
|
441
|
+
return instance;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Return last matching row (reverse of first()).
|
|
446
|
+
* Post.orderBy('created_at').last()
|
|
447
|
+
*/
|
|
448
|
+
async last() {
|
|
449
|
+
const rows = await this._query;
|
|
450
|
+
if (!rows.length) return null;
|
|
451
|
+
const instance = this._model._hydrate(rows[rows.length - 1]);
|
|
452
|
+
if (this._withs.length) await this._eagerLoad([instance]);
|
|
453
|
+
return instance;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Return empty result set — useful for conditional queries.
|
|
458
|
+
* Post.none().get() // []
|
|
459
|
+
*/
|
|
460
|
+
none() {
|
|
461
|
+
this._query = this._query.whereRaw('1 = 0');
|
|
462
|
+
return this;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Lock selected rows for update (SELECT FOR UPDATE).
|
|
467
|
+
* Postgres and MySQL only.
|
|
468
|
+
* Post.where('status', 'pending').selectForUpdate().first()
|
|
469
|
+
*/
|
|
470
|
+
selectForUpdate(options = {}) {
|
|
471
|
+
const client = this._query.client?.config?.client || '';
|
|
472
|
+
if (client.includes('sqlite')) {
|
|
473
|
+
// SQLite doesn't support SELECT FOR UPDATE — silently skip
|
|
474
|
+
return this;
|
|
475
|
+
}
|
|
476
|
+
if (options.skipLocked) {
|
|
477
|
+
this._query = this._query.forUpdate().skipLocked();
|
|
478
|
+
} else if (options.noWait) {
|
|
479
|
+
this._query = this._query.forUpdate().noWait();
|
|
480
|
+
} else {
|
|
481
|
+
this._query = this._query.forUpdate();
|
|
482
|
+
}
|
|
483
|
+
return this;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Return a dict mapping pk → instance.
|
|
488
|
+
* Matches Django's QuerySet.in_bulk()
|
|
489
|
+
* Post.inBulk([1, 2, 3]) // { 1: Post, 2: Post, 3: Post }
|
|
490
|
+
* Post.inBulk() // all rows as dict
|
|
491
|
+
*/
|
|
492
|
+
async inBulk(ids = null, fieldName = null) {
|
|
493
|
+
const pk = fieldName || this._model.primaryKey;
|
|
494
|
+
if (ids !== null) {
|
|
495
|
+
if (ids.length === 0) return {};
|
|
496
|
+
this._query = this._query.whereIn(pk, ids);
|
|
497
|
+
}
|
|
498
|
+
const rows = await this._query;
|
|
499
|
+
const result = {};
|
|
500
|
+
for (const row of rows) {
|
|
501
|
+
const instance = this._model._hydrate(row);
|
|
502
|
+
result[instance[pk]] = instance;
|
|
503
|
+
}
|
|
504
|
+
return result;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Reverse the current ordering.
|
|
509
|
+
* Matches Django's QuerySet.reverse()
|
|
510
|
+
*/
|
|
511
|
+
reverse() {
|
|
512
|
+
// Wrap current query as subquery and reverse order
|
|
513
|
+
// Simple implementation: toggle asc/desc on existing order
|
|
514
|
+
this._reversed = true;
|
|
515
|
+
return this;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Return query plan (EXPLAIN).
|
|
520
|
+
* Matches Django's QuerySet.explain()
|
|
521
|
+
*/
|
|
522
|
+
async explain() {
|
|
523
|
+
const client = this._query.client?.config?.client || '';
|
|
524
|
+
const sql = this._query.toSQL();
|
|
525
|
+
const prefix = client.includes('pg') ? 'EXPLAIN ANALYZE ' : 'EXPLAIN ';
|
|
526
|
+
const db = require('../drivers/DatabaseManager').connection(this._model.connection || null);
|
|
527
|
+
const result = await db.raw(prefix + sql.sql, sql.bindings);
|
|
528
|
+
return result.rows ?? result[0] ?? result;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Use a specific DB connection for this query.
|
|
533
|
+
* Matches Django's QuerySet.using()
|
|
534
|
+
*/
|
|
535
|
+
using(connectionName) {
|
|
536
|
+
const DatabaseManager = require('../drivers/DatabaseManager');
|
|
537
|
+
const db = DatabaseManager.connection(connectionName);
|
|
538
|
+
this._query = db(this._model.table);
|
|
539
|
+
return this;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Return the raw SQL string with bindings substituted — for debugging.
|
|
544
|
+
* Matches Django's str(queryset.query) behaviour.
|
|
545
|
+
*
|
|
546
|
+
* console.log(Post.where('published', true).orderBy('created_at').sql())
|
|
547
|
+
* // → select * from `posts` where `published` = true order by `created_at` asc
|
|
548
|
+
*
|
|
549
|
+
* // Also available as toSQL() for the raw { sql, bindings } object
|
|
550
|
+
* Post.where('published', true).toSQL()
|
|
551
|
+
* // → { sql: 'select * from `posts` where `published` = ?', bindings: [true] }
|
|
552
|
+
*/
|
|
553
|
+
sql() {
|
|
554
|
+
const { sql, bindings } = this._query.toSQL();
|
|
555
|
+
// Substitute bindings into the SQL string for readability
|
|
556
|
+
let result = sql;
|
|
557
|
+
for (const binding of bindings) {
|
|
558
|
+
const val = binding === null ? 'NULL'
|
|
559
|
+
: typeof binding === 'string' ? `'${binding}'`
|
|
560
|
+
: String(binding);
|
|
561
|
+
result = result.replace('?', val);
|
|
562
|
+
}
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
565
|
+
|
|
375
566
|
toSQL() {
|
|
376
567
|
return this._query.toSQL();
|
|
377
568
|
}
|
|
@@ -391,6 +582,38 @@ class QueryBuilder {
|
|
|
391
582
|
continue;
|
|
392
583
|
}
|
|
393
584
|
|
|
585
|
+
// -- Nested dot notation: 'documents.document_images'
|
|
586
|
+
// Matches Django's prefetch_related('documents__document_images')
|
|
587
|
+
if (name.includes('.')) {
|
|
588
|
+
const [parentRel, ...rest] = name.split('.');
|
|
589
|
+
const nestedName = rest.join('.');
|
|
590
|
+
const parentRelDef = relations[parentRel];
|
|
591
|
+
if (!parentRelDef) {
|
|
592
|
+
MillasLog.w('ORM', `Relation "${parentRel}" not defined on ${this._model.name} -- skipping nested eager load`);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const alreadyLoaded = instances[0]?.[parentRel] !== undefined &&
|
|
596
|
+
typeof instances[0]?.[parentRel] !== 'function';
|
|
597
|
+
if (!alreadyLoaded) {
|
|
598
|
+
await parentRelDef.eagerLoad(instances, parentRel, null);
|
|
599
|
+
}
|
|
600
|
+
const parentInstances = instances.flatMap(i => {
|
|
601
|
+
const val = i[parentRel];
|
|
602
|
+
if (!val) return [];
|
|
603
|
+
return Array.isArray(val) ? val : [val];
|
|
604
|
+
});
|
|
605
|
+
if (parentInstances.length) {
|
|
606
|
+
const RelatedModel = parentRelDef._related;
|
|
607
|
+
if (RelatedModel) {
|
|
608
|
+
const QB = require('./QueryBuilder');
|
|
609
|
+
const subQb = new QB(RelatedModel._db(), RelatedModel);
|
|
610
|
+
subQb._withs = [{ name: nestedName, constraint: null }];
|
|
611
|
+
await subQb._eagerLoad(parentInstances);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
|
|
394
617
|
// ── Normal eager load ─────────────────────────────────────────────────
|
|
395
618
|
const rel = relations[name];
|
|
396
619
|
if (!rel) {
|
|
@@ -26,8 +26,8 @@ class DatabaseServiceProvider extends ServiceProvider {
|
|
|
26
26
|
let dbConfig;
|
|
27
27
|
try {
|
|
28
28
|
dbConfig = require(basePath + '/config/database');
|
|
29
|
-
} catch {
|
|
30
|
-
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code !== 'MODULE_NOT_FOUND') throw err;
|
|
31
31
|
dbConfig = {
|
|
32
32
|
default: 'sqlite',
|
|
33
33
|
connections: {
|