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.
@@ -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
- /** Fetch first or throw 404. */
315
- async firstOrFail(message) {
316
- const result = await this.first();
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().clearSelect().count(`${column} as count`).first();
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().clearSelect().count('* as count').first()
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
- return this._query.update({ ...data, updated_at: new Date().toISOString() });
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
- /** Return the raw SQL string (for debugging). */
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
- // Fallback for tests / programmatic use
29
+ } catch (err) {
30
+ if (err.code !== 'MODULE_NOT_FOUND') throw err;
31
31
  dbConfig = {
32
32
  default: 'sqlite',
33
33
  connections: {