millas 0.2.19 → 0.2.21

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.
@@ -311,9 +311,20 @@ class QueryBuilder {
311
311
  return instance;
312
312
  }
313
313
 
314
- /** Fetch first or throw 404. */
315
- async firstOrFail(message) {
316
- const result = await this.first();
314
+ /**
315
+ * Find by primary key — allows chaining with .with().
316
+ * Post.with('author').find(1)
317
+ */
318
+ async find(id) {
319
+ return this.where(this._model.primaryKey, id).first();
320
+ }
321
+
322
+ /**
323
+ * Find by primary key or throw 404.
324
+ * Post.with('author').findOrFail(1)
325
+ */
326
+ async findOrFail(id, message) {
327
+ const result = await this.find(id);
317
328
  if (!result) {
318
329
  const HttpError = require('../../errors/HttpError');
319
330
  throw new HttpError(404, message || `${this._model.name} not found`);
@@ -323,7 +334,11 @@ class QueryBuilder {
323
334
 
324
335
  /** Count matching rows. */
325
336
  async count(column = '*') {
326
- const result = await this._query.clone().clearSelect().count(`${column} as count`).first();
337
+ const result = await this._query.clone()
338
+ .clearSelect()
339
+ .clearOrder()
340
+ .count(`${column} as count`)
341
+ .first();
327
342
  return Number(result?.count ?? 0);
328
343
  }
329
344
 
@@ -344,7 +359,11 @@ class QueryBuilder {
344
359
  */
345
360
  async paginate(page = 1, perPage = 15) {
346
361
  const offset = (page - 1) * perPage;
347
- const total = await this._query.clone().clearSelect().count('* as count').first()
362
+ const total = await this._query.clone()
363
+ .clearSelect()
364
+ .clearOrder()
365
+ .count('* as count')
366
+ .first()
348
367
  .then(r => Number(r?.count ?? 0));
349
368
 
350
369
  const rows = await this._query.clone().limit(perPage).offset(offset);
@@ -363,7 +382,17 @@ class QueryBuilder {
363
382
 
364
383
  /** Update matching rows. */
365
384
  async update(data) {
366
- return this._query.update({ ...data, updated_at: new Date().toISOString() });
385
+ const F = require('./F');
386
+ const extra = this._model._updatedAtPayload?.() ?? {};
387
+ const payload = {};
388
+ for (const [key, val] of Object.entries({ ...data, ...extra })) {
389
+ if (val instanceof F) {
390
+ payload[key] = val.toKnex(this._query.client);
391
+ } else {
392
+ payload[key] = val;
393
+ }
394
+ }
395
+ return this._query.update(payload);
367
396
  }
368
397
 
369
398
  /** Delete matching rows. */
@@ -371,7 +400,148 @@ class QueryBuilder {
371
400
  return this._query.delete();
372
401
  }
373
402
 
374
- /** Return the raw SQL string (for debugging). */
403
+ /**
404
+ * Get exactly one result — raises if 0 or more than 1 found.
405
+ * Matches Django's QuerySet.get()
406
+ */
407
+ async get_one(...args) {
408
+ if (args.length) this.where(...args);
409
+ this._query = this._query.limit(2);
410
+ const rows = await this._query;
411
+ if (rows.length === 0) {
412
+ const HttpError = require('../../errors/HttpError');
413
+ throw new HttpError(404, `${this._model.name} matching query does not exist.`);
414
+ }
415
+ if (rows.length > 1) {
416
+ throw new Error(`get() returned more than one ${this._model.name} — it returned ${rows.length}!`);
417
+ }
418
+ const instance = this._model._hydrate(rows[0]);
419
+ if (this._withs.length) await this._eagerLoad([instance]);
420
+ return instance;
421
+ }
422
+
423
+ /**
424
+ * Return last matching row (reverse of first()).
425
+ * Post.orderBy('created_at').last()
426
+ */
427
+ async last() {
428
+ const rows = await this._query;
429
+ if (!rows.length) return null;
430
+ const instance = this._model._hydrate(rows[rows.length - 1]);
431
+ if (this._withs.length) await this._eagerLoad([instance]);
432
+ return instance;
433
+ }
434
+
435
+ /**
436
+ * Return empty result set — useful for conditional queries.
437
+ * Post.none().get() // []
438
+ */
439
+ none() {
440
+ this._query = this._query.whereRaw('1 = 0');
441
+ return this;
442
+ }
443
+
444
+ /**
445
+ * Lock selected rows for update (SELECT FOR UPDATE).
446
+ * Postgres and MySQL only.
447
+ * Post.where('status', 'pending').selectForUpdate().first()
448
+ */
449
+ selectForUpdate(options = {}) {
450
+ const client = this._query.client?.config?.client || '';
451
+ if (client.includes('sqlite')) {
452
+ // SQLite doesn't support SELECT FOR UPDATE — silently skip
453
+ return this;
454
+ }
455
+ if (options.skipLocked) {
456
+ this._query = this._query.forUpdate().skipLocked();
457
+ } else if (options.noWait) {
458
+ this._query = this._query.forUpdate().noWait();
459
+ } else {
460
+ this._query = this._query.forUpdate();
461
+ }
462
+ return this;
463
+ }
464
+
465
+ /**
466
+ * Return a dict mapping pk → instance.
467
+ * Matches Django's QuerySet.in_bulk()
468
+ * Post.inBulk([1, 2, 3]) // { 1: Post, 2: Post, 3: Post }
469
+ * Post.inBulk() // all rows as dict
470
+ */
471
+ async inBulk(ids = null, fieldName = null) {
472
+ const pk = fieldName || this._model.primaryKey;
473
+ if (ids !== null) {
474
+ if (ids.length === 0) return {};
475
+ this._query = this._query.whereIn(pk, ids);
476
+ }
477
+ const rows = await this._query;
478
+ const result = {};
479
+ for (const row of rows) {
480
+ const instance = this._model._hydrate(row);
481
+ result[instance[pk]] = instance;
482
+ }
483
+ return result;
484
+ }
485
+
486
+ /**
487
+ * Reverse the current ordering.
488
+ * Matches Django's QuerySet.reverse()
489
+ */
490
+ reverse() {
491
+ // Wrap current query as subquery and reverse order
492
+ // Simple implementation: toggle asc/desc on existing order
493
+ this._reversed = true;
494
+ return this;
495
+ }
496
+
497
+ /**
498
+ * Return query plan (EXPLAIN).
499
+ * Matches Django's QuerySet.explain()
500
+ */
501
+ async explain() {
502
+ const client = this._query.client?.config?.client || '';
503
+ const sql = this._query.toSQL();
504
+ const prefix = client.includes('pg') ? 'EXPLAIN ANALYZE ' : 'EXPLAIN ';
505
+ const db = require('../drivers/DatabaseManager').connection(this._model.connection || null);
506
+ const result = await db.raw(prefix + sql.sql, sql.bindings);
507
+ return result.rows ?? result[0] ?? result;
508
+ }
509
+
510
+ /**
511
+ * Use a specific DB connection for this query.
512
+ * Matches Django's QuerySet.using()
513
+ */
514
+ using(connectionName) {
515
+ const DatabaseManager = require('../drivers/DatabaseManager');
516
+ const db = DatabaseManager.connection(connectionName);
517
+ this._query = db(this._model.table);
518
+ return this;
519
+ }
520
+
521
+ /**
522
+ * Return the raw SQL string with bindings substituted — for debugging.
523
+ * Matches Django's str(queryset.query) behaviour.
524
+ *
525
+ * console.log(Post.where('published', true).orderBy('created_at').sql())
526
+ * // → select * from `posts` where `published` = true order by `created_at` asc
527
+ *
528
+ * // Also available as toSQL() for the raw { sql, bindings } object
529
+ * Post.where('published', true).toSQL()
530
+ * // → { sql: 'select * from `posts` where `published` = ?', bindings: [true] }
531
+ */
532
+ sql() {
533
+ const { sql, bindings } = this._query.toSQL();
534
+ // Substitute bindings into the SQL string for readability
535
+ let result = sql;
536
+ for (const binding of bindings) {
537
+ const val = binding === null ? 'NULL'
538
+ : typeof binding === 'string' ? `'${binding}'`
539
+ : String(binding);
540
+ result = result.replace('?', val);
541
+ }
542
+ return result;
543
+ }
544
+
375
545
  toSQL() {
376
546
  return this._query.toSQL();
377
547
  }
@@ -381,7 +551,7 @@ class QueryBuilder {
381
551
  async _eagerLoad(instances) {
382
552
  if (!instances.length) return;
383
553
 
384
- const relations = this._model.relations || {};
554
+ const relations = this._model._effectiveRelations();
385
555
 
386
556
  for (const { name, constraint, aggregate, aggregateColumn } of this._withs) {
387
557
 
@@ -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: {