tina4-nodejs 3.13.131 → 3.13.133

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.
@@ -3,8 +3,9 @@ import {
3
3
  adapterQuery, adapterFetch, adapterExecute, adapterFetchOne,
4
4
  adapterStartTransaction, adapterCommit, adapterRollback,
5
5
  adapterTableExists, adapterCreateTable, extractLastInsertId,
6
- DEFAULT_ROW_CAP,
6
+ probeTotal, DEFAULT_ROW_CAP,
7
7
  } from "./database.js";
8
+ import { ModelCollection } from "./modelCollection.js";
8
9
  import { validate as validateFields } from "./validation.js";
9
10
  import { QueryBuilder } from "./queryBuilder.js";
10
11
  import { SQLiteAdapter } from "./adapters/sqlite.js";
@@ -451,6 +452,43 @@ export class BaseModel {
451
452
  return this.getDbColumn(this.getPkField());
452
453
  }
453
454
 
455
+ /**
456
+ * Shared read tail for the collection-returning finders (where / all / select
457
+ * / find filter-form / withTrashed). Runs the SAME two calls `db.fetch()`
458
+ * makes — the page fetch AND the COUNT probe over the SAME base SQL — hydrates
459
+ * the rows into model instances, and returns a ModelCollection carrying the
460
+ * total (ADR-0064).
461
+ *
462
+ * The total is FREE: `probeTotal` is the exact `COUNT(*)` probe `db.fetch()`
463
+ * already runs; the ORM used to discard it. ZERO extra queries beyond that one
464
+ * probe. `sql` MUST NOT carry its own LIMIT/OFFSET — `adapterFetch` applies
465
+ * limit/offset to the page, and the probe wraps the un-limited SQL so it counts
466
+ * the WHOLE filtered set, not the page.
467
+ */
468
+ protected static async _collect<T extends BaseModel>(
469
+ this: typeof BaseModel & (new (data?: Record<string, unknown>) => T),
470
+ sql: string,
471
+ params: unknown[] | undefined,
472
+ limit: number,
473
+ offset: number,
474
+ include?: string[],
475
+ ): Promise<ModelCollection<T>> {
476
+ const db = this.getDb();
477
+ const rows = await adapterFetch(db, sql, params, limit, offset);
478
+ const data: Record<string, unknown>[] = Array.isArray(rows)
479
+ ? (rows as Record<string, unknown>[])
480
+ : ((rows as { data?: Record<string, unknown>[] })?.data ?? []);
481
+ // The total comes from the fetch COUNT probe — NOT data.length (that is only
482
+ // the page). probeTotal returns undefined only for an unbounded read
483
+ // (limit <= 0), where the page IS the whole set, so data.length is right.
484
+ const total = (await probeTotal(db, sql, params, limit)) ?? data.length;
485
+ const instances = data.map((row) => new this(row) as T);
486
+ if (include) {
487
+ await (this as typeof BaseModel)._eagerLoad(instances as BaseModel[], include);
488
+ }
489
+ return new ModelCollection<T>(instances, total, limit, offset);
490
+ }
491
+
454
492
  /**
455
493
  * Find a record by primary key.
456
494
  * @param id Primary key value.
@@ -531,7 +569,7 @@ export class BaseModel {
531
569
  offset?: number,
532
570
  orderBy?: string,
533
571
  include?: string[],
534
- ): Promise<T[]>;
572
+ ): Promise<ModelCollection<T>>;
535
573
  static async find<T extends BaseModel>(
536
574
  this: new (data?: Record<string, unknown>) => T,
537
575
  filter?: Record<string, unknown> | number | string,
@@ -539,7 +577,7 @@ export class BaseModel {
539
577
  offset = 0,
540
578
  orderBy?: string,
541
579
  include?: string[],
542
- ): Promise<T[] | T | null> {
580
+ ): Promise<ModelCollection<T> | T | null> {
543
581
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
544
582
 
545
583
  // Scalar PK lookup routes to findById. A number or a string (but NOT a
@@ -555,7 +593,6 @@ export class BaseModel {
555
593
 
556
594
  // Array form — coerce `limit` back to a number for the list path.
557
595
  const lim = typeof limit === "number" ? limit : 100;
558
- const db = ModelClass.getDb();
559
596
  const conditions: string[] = [];
560
597
  const params: unknown[] = [];
561
598
 
@@ -579,18 +616,7 @@ export class BaseModel {
579
616
  sql += ` ORDER BY ${orderBy}`;
580
617
  }
581
618
 
582
- const rows = await adapterFetch(db, sql, params, lim, offset);
583
- const data = (rows as any)?.data ?? rows;
584
- const instances = (Array.isArray(data) ? data : []).map((row: Record<string, unknown>) => {
585
- const inst = new this(row) as T;
586
- return inst;
587
- });
588
-
589
- if (include) {
590
- await ModelClass._eagerLoad(instances as BaseModel[], include);
591
- }
592
-
593
- return instances;
619
+ return ModelClass._collect<T>(sql, params, lim, offset, include);
594
620
  }
595
621
 
596
622
  /**
@@ -671,9 +697,8 @@ export class BaseModel {
671
697
  offset: number = 0,
672
698
  include?: string[],
673
699
  orderBy?: string,
674
- ): Promise<T[]> {
700
+ ): Promise<ModelCollection<T>> {
675
701
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
676
- const db = ModelClass.getDb();
677
702
 
678
703
  const conditions: string[] = [];
679
704
  if (ModelClass.softDelete) {
@@ -685,18 +710,14 @@ export class BaseModel {
685
710
 
686
711
  const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
687
712
  const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
688
- const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}${orderClause}`
689
- + ` LIMIT ${limit} OFFSET ${offset}`;
713
+ // No LIMIT/OFFSET embedded: _collect's adapterFetch applies them to the page
714
+ // and the COUNT probe wraps the un-limited SQL so the total is the whole set.
715
+ const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}${orderClause}`;
690
716
 
691
717
  // No bind parameters: the only conditions left are the framework's own
692
718
  // softDelete / tableFilter literals. A caller-supplied filter belongs on
693
719
  // where(), which binds its params properly.
694
- const rows = await adapterQuery(db, sql, []);
695
- const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
696
- if (include) {
697
- await ModelClass._eagerLoad(instances, include);
698
- }
699
- return instances;
720
+ return ModelClass._collect<T>(sql, [], limit, offset, include);
700
721
  }
701
722
 
702
723
  /**
@@ -718,9 +739,8 @@ export class BaseModel {
718
739
  offset: number = 0,
719
740
  include?: string[],
720
741
  orderBy?: string,
721
- ): Promise<T[]> {
742
+ ): Promise<ModelCollection<T>> {
722
743
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
723
- const db = ModelClass.getDb();
724
744
 
725
745
  const parts: string[] = [];
726
746
  if (ModelClass.softDelete) {
@@ -732,14 +752,11 @@ export class BaseModel {
732
752
  parts.push(`(${conditions})`);
733
753
 
734
754
  const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
735
- const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${parts.join(" AND ")}${orderClause} LIMIT ${limit} OFFSET ${offset}`;
755
+ // No LIMIT/OFFSET embedded _collect applies them to the page and probes
756
+ // the total (ADR-0064). orderBy affects the page only; COUNT is order-free.
757
+ const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${parts.join(" AND ")}${orderClause}`;
736
758
 
737
- const rows = await adapterQuery(db, sql, params);
738
- const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
739
- if (include) {
740
- await ModelClass._eagerLoad(instances, include);
741
- }
742
- return instances;
759
+ return ModelClass._collect<T>(sql, params, limit, offset, include);
743
760
  }
744
761
 
745
762
  /**
@@ -1418,17 +1435,14 @@ export class BaseModel {
1418
1435
  params?: unknown[],
1419
1436
  limit: number = DEFAULT_ROW_CAP,
1420
1437
  offset: number = 0,
1421
- ): Promise<T[]> {
1438
+ ): Promise<ModelCollection<T>> {
1422
1439
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
1423
- const db = ModelClass.getDb();
1424
- // Skip appending when the caller's SQL already carries its own LIMIT --
1425
- // a second one is a syntax error on every engine. SQLTranslator.appendLimit
1426
- // scrubs literals/comments first (a substring search here used to drop the
1427
- // row cap on `WHERE label != 'LIMIT'`) and appends on a new line so the
1428
- // clause is never swallowed by a trailing `--` comment.
1429
- const paged = SQLTranslator.appendLimit(sql, limit, offset);
1430
- const rows = await adapterQuery(db, paged, params);
1431
- return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
1440
+ // _collect runs the page fetch — the adapter applies limit/offset via
1441
+ // SQLTranslator.appendLimit, which scrubs literals/comments and skips when
1442
+ // the caller's SQL already carries its own LIMIT (a second one is a syntax
1443
+ // error on every engine) AND the COUNT probe over the raw SQL, so the
1444
+ // ModelCollection carries the total for the filter (ADR-0064).
1445
+ return ModelClass._collect<T>(sql, params, limit, offset);
1432
1446
  }
1433
1447
 
1434
1448
  static async selectOne<T extends BaseModel>(
@@ -1518,9 +1532,8 @@ export class BaseModel {
1518
1532
  params?: unknown[],
1519
1533
  limit: number = DEFAULT_ROW_CAP,
1520
1534
  offset: number = 0,
1521
- ): Promise<T[]> {
1535
+ ): Promise<ModelCollection<T>> {
1522
1536
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
1523
- const db = ModelClass.getDb();
1524
1537
 
1525
1538
  const parts: string[] = [];
1526
1539
  if (ModelClass.tableFilter) {
@@ -1534,15 +1547,11 @@ export class BaseModel {
1534
1547
  if (parts.length > 0) {
1535
1548
  sql += ` WHERE ${parts.join(" AND ")}`;
1536
1549
  }
1537
- if (limit !== undefined) {
1538
- sql += ` LIMIT ${limit}`;
1539
- }
1540
- if (offset !== undefined) {
1541
- sql += ` OFFSET ${offset}`;
1542
- }
1543
1550
 
1544
- const rows = await adapterQuery(db, sql, params);
1545
- return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
1551
+ // No LIMIT/OFFSET embedded _collect applies them to the page and probes
1552
+ // the total. No is_deleted filter here, so soft-deleted rows are INCLUDED in
1553
+ // both the page and the total (ADR-0064).
1554
+ return ModelClass._collect<T>(sql, params, limit, offset);
1546
1555
  }
1547
1556
 
1548
1557
  /**
@@ -13,6 +13,8 @@ export { REQUIRED_ADAPTER_CAPABILITIES, NOT_REQUIRED_ON_ADAPTER } from "./types.
13
13
 
14
14
  export { DatabaseResult } from "./databaseResult.js";
15
15
  export type { ColumnInfoResult } from "./databaseResult.js";
16
+ export { ModelCollection } from "./modelCollection.js";
17
+ export type { PaginateEnvelope } from "./modelCollection.js";
16
18
  export { Database, initDatabase, getAdapter, setAdapter, bindDatabase, createAdapterFromUrl, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool, stripTrailingSemicolons, wrapWithCache, resetRequestCaches } from "./database.js";
17
19
  export {
18
20
  adapterFetch, adapterQuery, adapterFetchOne, adapterExecute, adapterInsert,
@@ -0,0 +1,149 @@
1
+ /**
2
+ * ModelCollection — a list of ORM models that also carries the query total.
3
+ *
4
+ * What the ORM read queries (`where` / `select` / `find` filter-form / `all` /
5
+ * `withTrashed`) return. It IS an Array — iterate it, index it, slice it, `.map`
6
+ * it, `.filter` it, read `.length`, `JSON.stringify` it — so every existing
7
+ * caller keeps working unchanged (`Array.isArray(coll) === true`). It adds one
8
+ * thing: the TOTAL number of rows matching the query's filter, independent of
9
+ * `limit` / `offset`.
10
+ *
11
+ * The total is free. Every one of those methods already runs the fetch COUNT
12
+ * probe (`db.fetch` / `probeTotal`) that computes `SELECT COUNT(*)` for the same
13
+ * filter; the ORM used to hydrate the page of models and throw that count away.
14
+ * This class carries it instead, so a caller with 20 models on the page can
15
+ * still learn there are 250 rows in the set. ZERO extra queries.
16
+ *
17
+ * Uniform across all four Tina4 frameworks (ADR-0064). Same concept, language-
18
+ * idiomatic accessor name:
19
+ *
20
+ * Python / Ruby : get_total_records() to_paginate()
21
+ * PHP / Node : getTotalRecords() toPaginate()
22
+ *
23
+ * The accessor is a METHOD, not a `.total` property, on purpose: `Array#count`
24
+ * exists in Ruby and `list.count()` in Python, so a `.count` would shadow a
25
+ * built-in. `DatabaseResult` keeps its `.count` property (it is not a list); both
26
+ * expose the identical seven-key `toPaginate()` envelope.
27
+ *
28
+ * ### Why a real Array subclass with `[Symbol.species] === Array`
29
+ *
30
+ * `class X extends Array` is the parity-faithful backing (the ADR-0064 table
31
+ * says Node is a "subclass of Array"), and it keeps `Array.isArray` true and
32
+ * `instanceof ModelCollection` true (mirroring Python's `isinstance`). BUT a bare
33
+ * Array subclass is a footgun: `map`/`filter`/`slice` build the result via
34
+ * `this.constructor[Symbol.species]`, calling the constructor with a single
35
+ * NUMBER (the length), and `new X(oneNumber)` means "length N", not "one
36
+ * element". Overriding `[Symbol.species]` to return `Array` makes every derived
37
+ * operation build a plain `Array` — so `.map`/`.filter`/`.slice`/spread never
38
+ * touch this constructor and can never explode. The constructor below is also
39
+ * defended against the numeric-length call directly, so even a path that ignores
40
+ * species is safe.
41
+ */
42
+
43
+ /** The canonical pagination envelope — seven snake_case keys (ADR-0043/0064). */
44
+ export interface PaginateEnvelope {
45
+ records: unknown[];
46
+ total: number;
47
+ page: number;
48
+ per_page: number;
49
+ total_pages: number;
50
+ limit: number;
51
+ offset: number;
52
+ }
53
+
54
+ export class ModelCollection<T = unknown> extends Array<T> {
55
+ /** Total rows matching the query's filter (ignores limit/offset). */
56
+ private _total = 0;
57
+ /** The SQL limit that produced this page. */
58
+ private _limit = 0;
59
+ /** The SQL offset that produced this page. */
60
+ private _offset = 0;
61
+
62
+ /**
63
+ * Derived array operations (`map`, `filter`, `slice`, spread, …) build a plain
64
+ * `Array`, never another `ModelCollection`. This is what defuses the Array-
65
+ * subclass constructor/species trap: the engine never calls this constructor
66
+ * with a length during those operations.
67
+ */
68
+ static get [Symbol.species](): ArrayConstructor {
69
+ return Array;
70
+ }
71
+
72
+ /**
73
+ * @param items the page of hydrated model instances (or the length, when the
74
+ * engine constructs a derived array — defended against here).
75
+ * @param total total rows matching the query's filter (ignores limit/offset).
76
+ * @param limit the SQL limit that produced this page.
77
+ * @param offset the SQL offset that produced this page.
78
+ */
79
+ constructor(items?: readonly T[] | number, total = 0, limit = 0, offset = 0) {
80
+ super();
81
+ // Defensive: a raw `new ModelCollection(5)` (or any engine path that ignores
82
+ // [Symbol.species]) must behave like `new Array(5)` — a length-5 array — not
83
+ // wedge a number into element 0. `[Symbol.species] = Array` means the built-
84
+ // in derived ops never reach here, but this keeps the constructor honest.
85
+ if (typeof items === "number") {
86
+ this.length = items;
87
+ return;
88
+ }
89
+ if (items) {
90
+ for (let i = 0; i < items.length; i++) {
91
+ this[i] = items[i];
92
+ }
93
+ }
94
+ this._total = Math.trunc(total) || 0;
95
+ this._limit = Math.trunc(limit) || 0;
96
+ this._offset = Math.trunc(offset) || 0;
97
+ }
98
+
99
+ /**
100
+ * Total rows matching the query's filter, ignoring limit/offset.
101
+ *
102
+ * This is the whole point of the collection: the page slice you are iterating
103
+ * is capped by `limit`, but this number is the full count of matching rows —
104
+ * what a pager needs to render "page 3 of 13".
105
+ */
106
+ getTotalRecords(): number {
107
+ return this._total;
108
+ }
109
+
110
+ /**
111
+ * The canonical pagination envelope — seven snake_case keys, identical to
112
+ * `DatabaseResult.toPaginate()` (ADR-0043) and to the other three frameworks'
113
+ * `toPaginate()` / `to_paginate()`.
114
+ *
115
+ * records the page's rows as plain objects (never re-sliced)
116
+ * total getTotalRecords() — the true total for the filter
117
+ * page floor(offset / per_page) + 1
118
+ * per_page the query's limit
119
+ * total_pages ceil(total / per_page)
120
+ * limit the SQL limit actually applied
121
+ * offset the SQL offset actually applied
122
+ *
123
+ * `records` are model dicts (via `toDict()`, the same serialisation the
124
+ * framework applies to a model in a JSON response), so the JSON a client sees
125
+ * matches `DatabaseResult` exactly — the result is uniform whether the route
126
+ * returned a raw `db.fetch()` or an ORM query.
127
+ */
128
+ toPaginate(): PaginateEnvelope {
129
+ const perPage = this._limit > 0 ? this._limit : this.length;
130
+ const page = perPage > 0 ? Math.floor(this._offset / perPage) + 1 : 1;
131
+ const totalPages = perPage > 0 ? Math.max(1, Math.ceil(this._total / perPage)) : 1;
132
+ // `.map` returns a plain Array here (see [Symbol.species]). Each model is
133
+ // serialised via toDict() — the same shape the framework emits for a model
134
+ // in a JSON response — so the envelope matches DatabaseResult exactly.
135
+ const records = this.map((model) => {
136
+ const m = model as unknown as { toDict?: () => unknown };
137
+ return m && typeof m.toDict === "function" ? m.toDict() : model;
138
+ });
139
+ return {
140
+ records,
141
+ total: this._total,
142
+ page,
143
+ per_page: perPage,
144
+ total_pages: totalPages,
145
+ limit: perPage,
146
+ offset: this._offset,
147
+ };
148
+ }
149
+ }