@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/CLAUDE.md +38 -6
- package/README.md +67 -7
- package/package.json +8 -7
- package/src/client-scale-pins.ts +78 -0
- package/src/client.ts +15 -1
- package/src/column-kinds.ts +28 -0
- package/src/cursor-value.ts +3 -3
- package/src/facade.ts +1 -1
- package/src/http.ts +5 -9
- package/src/index.ts +11 -7
- package/src/input-shape.ts +20 -0
- package/src/live.ts +3 -0
- package/src/matcher.ts +14 -7
- package/src/mcp-tool.ts +29 -8
- package/src/pagination.ts +58 -15
- package/src/query.ts +2 -0
- package/src/sealed-shape.ts +27 -0
- package/src/search.ts +46 -28
- package/src/shape.ts +52 -85
- package/src/single-answer.ts +22 -0
- package/src/source.ts +24 -9
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
|
|
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
|
-
|
|
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
|
|
108
|
+
return equalOrBothNull(kind, actual, filter.value);
|
|
99
109
|
case '!=':
|
|
100
|
-
return !
|
|
110
|
+
return !equalOrBothNull(kind, actual, filter.value);
|
|
101
111
|
case 'in':
|
|
102
|
-
return
|
|
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(
|
|
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 =
|
|
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
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
-
|
|
143
|
-
if (isNull(a) || isNull(b)) return isNull(a)
|
|
144
|
-
|
|
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
|
-
*
|
|
162
|
+
* Row ordering under an `orderBy` list. Stable, and total when an id key is last.
|
|
158
163
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* `
|
|
164
|
-
*
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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(
|
|
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 =
|
|
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
|
|
336
|
+
return compareByKind(kindOf('id'), columnOf(row, 'id'), cursor.id) > 0;
|
|
322
337
|
}
|