@ultimat3/query 22.15.0 → 24.0.0

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/shape.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * The read vocabulary shared by the matcher, the SQL sources, pagination and the
3
- * live descriptor. Types only plus two pure predicates — no I/O lives here.
3
+ * live descriptor: the types, and the two predicates that read a row. How two VALUES compare is
4
+ * `@ultimat3/entity`'s answer (`compareByKind`, `sameValueOfKind`) — no comparator lives here.
4
5
  */
6
+ import type { ColumnKind } from '@ultimat3/entity';
7
+ import { compareByKind, sameValueOfKind } from '@ultimat3/entity';
8
+ import type { KindOf } from './column-kinds';
5
9
  import { QueryNotPageableError } from './errors';
6
10
  import { columnOf } from './stable';
7
11
 
@@ -87,24 +91,33 @@ export function isNull(value: unknown): boolean {
87
91
  return value === null || value === undefined;
88
92
  }
89
93
 
90
- export function matchesFilters(row: object, filters: readonly Filter[]): boolean {
91
- return filters.every((filter) => matchesFilter(row, filter));
94
+ export function matchesFilters(row: object, filters: readonly Filter[], kindOf: KindOf): boolean {
95
+ return filters.every((filter) => matchesFilter(row, filter, kindOf));
92
96
  }
93
97
 
94
- export function matchesFilter(row: object, filter: Filter): boolean {
98
+ /**
99
+ * One filter against one row, as Postgres answers it for that column. `kindOf` is required: a
100
+ * comparison with no declared kind to decide by is how `'10'` in a `bigint()` column sorted before
101
+ * `'9'`, and an optional argument is one a caller forgets.
102
+ */
103
+ export function matchesFilter(row: object, filter: Filter, kindOf: KindOf): boolean {
95
104
  const actual = columnOf(row, filter.column);
105
+ const kind = kindOf(filter.column);
96
106
  switch (filter.op) {
97
107
  case '=':
98
- return same(actual, filter.value);
108
+ return equalOrBothNull(kind, actual, filter.value);
99
109
  case '!=':
100
- return !same(actual, filter.value);
110
+ return !equalOrBothNull(kind, actual, filter.value);
101
111
  case 'in':
102
- return Array.isArray(filter.value) && filter.value.some((item) => same(actual, item));
112
+ return (
113
+ Array.isArray(filter.value) &&
114
+ filter.value.some((item) => equalOrBothNull(kind, actual, item))
115
+ );
103
116
  case '>':
104
117
  case '>=':
105
118
  case '<':
106
119
  case '<=':
107
- return ordered(filter.op, actual, filter.value);
120
+ return ordered(filter.op, kind, actual, filter.value);
108
121
  default:
109
122
  return false;
110
123
  }
@@ -115,9 +128,14 @@ export function matchesFilter(row: object, filter: Filter): boolean {
115
128
  * ordering operator matches nothing here either. Only `=`, `!=` and `in` read NULL as a value —
116
129
  * and those are exactly the three `Builder.toSQL()` compiles to `is null` / `is distinct from`.
117
130
  */
118
- function ordered(op: '>' | '>=' | '<' | '<=', actual: unknown, value: unknown): boolean {
131
+ function ordered(
132
+ op: '>' | '>=' | '<' | '<=',
133
+ kind: ColumnKind | undefined,
134
+ actual: unknown,
135
+ value: unknown,
136
+ ): boolean {
119
137
  if (isNull(actual) || isNull(value)) return false;
120
- const result = compareValues(actual, value);
138
+ const result = compareByKind(kind, actual, value);
121
139
  switch (op) {
122
140
  case '>':
123
141
  return result > 0;
@@ -131,88 +149,37 @@ function ordered(op: '>' | '>=' | '<' | '<=', actual: unknown, value: unknown):
131
149
  }
132
150
 
133
151
  /**
134
- * Dates compare by instant, everything else by value. No coercion across types.
135
- *
136
- * NULL is greater than every value and equal to itself — Postgres' own sort rule, which is what
137
- * lets `Builder.toSQL()` write it down as `asc nulls last` / `desc nulls first` and mean this
138
- * function. Sorting only: a comparison *filter* against NULL matches nothing (`ordered`). Before
139
- * this, `null` sorted as the string `"null"`, so it landed between `"m"` and `"o"` in memory and
140
- * at the end in the database — the same page read two ways.
152
+ * This package's NULL rule around `@ultimat3/entity`'s equality: `=`, `!=` and `in` read NULL as a
153
+ * value, so two absences are equal and an absence equals nothing else. What two PRESENT values
154
+ * are to each other is not decided here — `sameValueOfKind` answers by the column's kind.
141
155
  */
142
- export function compareValues(a: unknown, b: unknown): number {
143
- if (isNull(a) || isNull(b)) return isNull(a) ? (isNull(b) ? 0 : 1) : -1;
144
- const left = normalize(a);
145
- const right = normalize(b);
146
- if (isNumeric(left) && isNumeric(right)) return compareNumeric(left, right);
147
- const l = String(left);
148
- const r = String(right);
149
- return l < r ? -1 : l > r ? 1 : 0;
150
- }
151
-
152
- function isNumeric(value: unknown): value is number | bigint {
153
- return typeof value === 'number' || typeof value === 'bigint';
156
+ function equalOrBothNull(kind: ColumnKind | undefined, a: unknown, b: unknown): boolean {
157
+ if (isNull(a) || isNull(b)) return isNull(a) && isNull(b);
158
+ return sameValueOfKind(kind, a, b);
154
159
  }
155
160
 
156
161
  /**
157
- * Numbers and bigints, in one order, because **Postgres orders them in one order**.
162
+ * Row ordering under an `orderBy` list. Stable, and total when an id key is last.
158
163
  *
159
- * `bigint` is a first-class `ColumnKind` — the physical type of every `<p>_minor` column — and
160
- * `@ultimat3/entity`'s `count-by.ts` lists it as groupable, so these values do reach the
161
- * comparator. The old numeric fast path was `typeof left === 'number' && typeof right ===
162
- * 'number'` alone, so a bigint fell through to `String(left) < String(right)`:
163
- * `compareValues(9n, 10n)` answered `1` and a sort came out `["10", "100", "9"]`, which means the
164
- * in-memory source, the live matcher and the seek fallback all disagreed with the database on any
165
- * bigint-ordered read — including page two of one.
166
- *
167
- * A bigint pair never subtracts: the difference is exact but the return type is a `number`. A
168
- * mixed pair goes through `BigInt` when the number is whole, so a value past 2^53 keeps its exact
169
- * place; a fractional number cannot equal a bigint, so comparing it as a float is enough to place
170
- * it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is what keeps them out of the
171
- * `BigInt()` call that would throw on them.
164
+ * Each key is ordered by `@ultimat3/entity`'s `compareByKind` under the column's declared kind —
165
+ * the one JS statement of how Postgres orders a column. NULL is the largest value there and equal
166
+ * to itself, which is what lets `Builder.toSQL()` write `asc nulls last` / `desc nulls first` and
167
+ * mean this function. This package used to keep a second comparator that decided by `typeof`: a
168
+ * `bigint()` or `decimal()` row is decimal TEXT, so `'10'` sorted before `'9'` in the in-memory
169
+ * source, the live matcher and the seek fallback while the database answered the other way.
172
170
  */
173
- function compareNumeric(left: number | bigint, right: number | bigint): number {
174
- if (typeof left === 'number' && typeof right === 'number') return left - right;
175
- if (typeof left === 'bigint' && typeof right === 'bigint') return sign(left, right);
176
- // Widened rather than negated: `-sign(a, b)` answers `-0` for a tie, and `-0` is a different
177
- // value from `0` to `Object.is` and to a caller writing `=== 0`.
178
- return typeof left === 'bigint'
179
- ? mixed(left, right as number)
180
- : -mixed(right as bigint, left) || 0;
181
- }
182
-
183
- /** A bigint against a number, in that order. Whole numbers go through `BigInt` so a value past
184
- * 2^53 keeps its exact place; a fractional number can never equal a bigint, so comparing it as a
185
- * float is enough to place it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is
186
- * what keeps them out of the `BigInt()` call that would throw on them. */
187
- function mixed(big: bigint, other: number): number {
188
- return Number.isInteger(other) ? sign(big, BigInt(other)) : sign(Number(big), other);
189
- }
190
-
191
- function sign<T extends number | bigint>(left: T, right: T): number {
192
- return left < right ? -1 : left > right ? 1 : 0;
193
- }
194
-
195
- /** A `Date` compares by instant, so two of them order by time and never by their ISO text. */
196
- function normalize(value: unknown): unknown {
197
- return value instanceof Date ? value.getTime() : value;
198
- }
199
-
200
- /**
201
- * Equality as Postgres answers it. A number and a bigint are ONE family — an `int8` arrives as a
202
- * `bigint` from one driver while a filter value is usually a `number` literal, and `5n = 5` is
203
- * true there — so the type check that keeps `'5'` from equalling `5` treats them as one.
204
- */
205
- function same(a: unknown, b: unknown): boolean {
206
- if (isNull(a) || isNull(b)) return isNull(a) && isNull(b);
207
- return compareValues(a, b) === 0 && family(normalize(a)) === family(normalize(b));
208
- }
209
-
210
- const family = (value: unknown): string => (isNumeric(value) ? 'numeric' : typeof value);
211
-
212
- /** Row ordering under an `orderBy` list. Stable, and total when an id key is last. */
213
- export function compareRows(a: object, b: object, orderBy: readonly OrderKey[]): number {
171
+ export function compareRows(
172
+ a: object,
173
+ b: object,
174
+ orderBy: readonly OrderKey[],
175
+ kindOf: KindOf,
176
+ ): number {
214
177
  for (const key of orderBy) {
215
- const result = compareValues(columnOf(a, key.column), columnOf(b, key.column));
178
+ const result = compareByKind(
179
+ kindOf(key.column),
180
+ columnOf(a, key.column),
181
+ columnOf(b, key.column),
182
+ );
216
183
  if (result !== 0) return key.direction === 'asc' ? result : -result;
217
184
  }
218
185
  return 0;
@@ -0,0 +1,22 @@
1
+ // Single responsibility: what a `single: true` read ANSWERS — its first row, or `X_NOT_FOUND`.
2
+ // One rule for the route, the read's own tool and `@ultimat3/mcp`'s served tool: three copies of
3
+ // "take the first row, refuse when there is none" are three places for one surface to drift.
4
+
5
+ import { QueryRowNotFoundError } from './errors';
6
+ import type { AnyQuery } from './query';
7
+ import { queryName } from './read';
8
+
9
+ /** The first row — what every in-process `[0]` of the same read takes — or `X_NOT_FOUND`. */
10
+ export function oneRowOf<TRow extends object>(name: string, rows: readonly TRow[]): TRow {
11
+ const [row] = rows;
12
+ if (row === undefined) throw new QueryRowNotFoundError(name);
13
+ return row;
14
+ }
15
+
16
+ /**
17
+ * A read's answer on a surface that hands back one value: the rows of a list read, the one row of
18
+ * a `single: true` read. `rows` is what the read's own source executed to.
19
+ */
20
+ export function readAnswer(target: AnyQuery, rows: readonly object[]): readonly object[] | object {
21
+ return target.single === true ? oneRowOf(queryName(target), rows) : rows;
22
+ }
package/src/source.ts CHANGED
@@ -4,9 +4,13 @@
4
4
  * app's `sql:` returns `from()` over an `@ultimat3/entity` repo, and a source only has to
5
5
  * answer these four questions.
6
6
  */
7
+
8
+ import { compareByKind } from '@ultimat3/entity';
9
+ import type { KindOf } from './column-kinds';
10
+ import { kindsOf } from './column-kinds';
7
11
  import { QueryColumnUnselectedError } from './errors';
8
12
  import type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
9
- import { compareRows, compareValues, isNull, matchesFilters, totalOrder } from './shape';
13
+ import { compareRows, isNull, matchesFilters, totalOrder } from './shape';
10
14
  import { columnOf } from './stable';
11
15
 
12
16
  /** Nothing matches. `in ()` is a syntax error in Postgres, so an empty set needs a constant. */
@@ -97,8 +101,13 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
97
101
  return this.derive({ unsupported: [...this.unsupported, feature] });
98
102
  }
99
103
 
104
+ /**
105
+ * The window is the SMALLER of the two limits. It used to replace a declared `.limit()`, so a
106
+ * page wider than a "top N" served rows the read itself never would.
107
+ */
100
108
  seek(after: SeekKey | null, limit: number): Builder<TRow> {
101
- return this.derive({ after, rowLimit: limit, totalized: true });
109
+ const rowLimit = this.rowLimit === null ? limit : Math.min(this.rowLimit, limit);
110
+ return this.derive({ after, rowLimit, totalized: true });
102
111
  }
103
112
 
104
113
  /**
@@ -134,14 +143,15 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
134
143
  async execute(): Promise<readonly TRow[]> {
135
144
  const source = typeof this.rows === 'function' ? await this.rows() : this.rows;
136
145
  this.assertCarried(source);
137
- let result = source.filter((row) => matchesFilters(row, this.filters));
146
+ const kindOf = kindsOf(this.entity);
147
+ let result = source.filter((row) => matchesFilters(row, this.filters, kindOf));
138
148
  const keys = this.servedOrder();
139
149
  if (keys.length > 0) {
140
- result = [...result].sort((a, b) => compareRows(a, b, keys));
150
+ result = [...result].sort((a, b) => compareRows(a, b, keys, kindOf));
141
151
  }
142
152
  if (this.after !== null) {
143
153
  const cut = this.after;
144
- result = result.filter((row) => isAfterKey(row, cut, this.order));
154
+ result = result.filter((row) => isAfterKey(row, cut, this.order, kindOf));
145
155
  }
146
156
  return this.rowLimit === null ? result : result.slice(0, this.rowLimit);
147
157
  }
@@ -264,7 +274,7 @@ function filterClause(filter: Filter, slot: Slot): string {
264
274
 
265
275
  /**
266
276
  * NULL's place in the ordering, written down rather than inherited. Postgres already defaults
267
- * to `nulls last` under `asc` and `nulls first` under `desc` — that is the rule `compareValues`
277
+ * to `nulls last` under `asc` and `nulls first` under `desc` — that is the rule `compareRows`
268
278
  * implements, so the in-memory sort, the live matcher and `seekClause` below can only agree
269
279
  * with it. Saying it out loud is what keeps a driver whose default differs from re-opening the
270
280
  * divergence, and it is what an agent reads when a nullable sort key surprises it.
@@ -312,11 +322,16 @@ interface BuilderState {
312
322
  * pagination needs the same answer when a source cannot push the seek down — a second definition
313
323
  * of "after" is how one path skips a row the other returns.
314
324
  */
315
- export function isAfterKey(row: object, cursor: SeekKey, order: readonly OrderKey[]): boolean {
325
+ export function isAfterKey(
326
+ row: object,
327
+ cursor: SeekKey,
328
+ order: readonly OrderKey[],
329
+ kindOf: KindOf,
330
+ ): boolean {
316
331
  for (const [index, key] of order.entries()) {
317
- const result = compareValues(columnOf(row, key.column), cursor.key[index]);
332
+ const result = compareByKind(kindOf(key.column), columnOf(row, key.column), cursor.key[index]);
318
333
  const signed = key.direction === 'asc' ? result : -result;
319
334
  if (signed !== 0) return signed > 0;
320
335
  }
321
- return compareValues(columnOf(row, 'id'), cursor.id) > 0;
336
+ return compareByKind(kindOf('id'), columnOf(row, 'id'), cursor.id) > 0;
322
337
  }