@ultimat3/query 1.2.0 → 3.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 +404 -0
- package/README.md +179 -10
- package/package.json +7 -5
- package/src/cache.ts +180 -71
- package/src/client.ts +83 -2
- package/src/cursor-value.ts +65 -0
- package/src/deprecation.ts +81 -0
- package/src/errors.ts +97 -1
- package/src/facade.ts +2 -0
- package/src/http.ts +109 -0
- package/src/index.ts +40 -9
- package/src/input-shape.ts +74 -0
- package/src/live.ts +27 -3
- package/src/matcher.ts +56 -13
- package/src/mcp-tool.ts +11 -5
- package/src/naming.ts +5 -9
- package/src/pagination.ts +25 -3
- package/src/policy-gate.ts +13 -2
- package/src/query.ts +108 -8
- package/src/read.ts +116 -16
- package/src/shape.ts +103 -6
- package/src/source.ts +137 -49
- package/src/sql.ts +2 -2
- package/src/stable.ts +23 -12
- package/src/tags.ts +0 -17
package/src/read.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import type { Ctx } from '@ultimat3/core';
|
|
9
9
|
import {
|
|
10
10
|
anonymousActor,
|
|
11
|
+
assert,
|
|
11
12
|
createContext,
|
|
13
|
+
logger,
|
|
12
14
|
runWithContext,
|
|
13
15
|
tryUseContext,
|
|
14
16
|
useContext,
|
|
@@ -17,7 +19,14 @@ import {
|
|
|
17
19
|
} from '@ultimat3/core';
|
|
18
20
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
19
21
|
import { formatPath, validateAsync } from '@ultimat3/schema';
|
|
20
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
cacheKeyFor,
|
|
24
|
+
DEFAULT_READ_CACHE_TTL_MS,
|
|
25
|
+
readAuthority,
|
|
26
|
+
readFresh,
|
|
27
|
+
readOnce,
|
|
28
|
+
readThrough,
|
|
29
|
+
} from './cache';
|
|
21
30
|
import { QueryForeignError, QueryInputInvalidError, QueryUnregisteredError } from './errors';
|
|
22
31
|
import { actorOf, guard } from './policy-gate';
|
|
23
32
|
import type { AnyQuery, AnyQueryDef, Query, QueryOptions, SourceOptions } from './query';
|
|
@@ -57,8 +66,23 @@ export function queryName(target: AnyQuery): string {
|
|
|
57
66
|
export function runQuery<TInput extends StandardSchemaV1, TRow extends object>(
|
|
58
67
|
target: Query<TInput, TRow>,
|
|
59
68
|
raw: unknown,
|
|
69
|
+
options?: QueryOptions,
|
|
70
|
+
): Promise<readonly TRow[]>;
|
|
71
|
+
/**
|
|
72
|
+
* The same read from a schema-erased handle. The route projection maps `listQueries()`,
|
|
73
|
+
* which knows only `AnyQuery` — an overload rather than a second function, so what is
|
|
74
|
+
* gone is the row TYPE and never the parse, the policy or the memo.
|
|
75
|
+
*/
|
|
76
|
+
export function runQuery(
|
|
77
|
+
target: AnyQuery,
|
|
78
|
+
raw: unknown,
|
|
79
|
+
options?: QueryOptions,
|
|
80
|
+
): Promise<readonly object[]>;
|
|
81
|
+
export function runQuery(
|
|
82
|
+
target: AnyQuery,
|
|
83
|
+
raw: unknown,
|
|
60
84
|
options: QueryOptions = {},
|
|
61
|
-
): Promise<readonly
|
|
85
|
+
): Promise<readonly object[]> {
|
|
62
86
|
return asActor(options, (ctx) => readRows(target, raw, ctx, options));
|
|
63
87
|
}
|
|
64
88
|
|
|
@@ -81,7 +105,14 @@ export function sourceFor(
|
|
|
81
105
|
* core models it as the anonymous actor. Omitting `actor` touches no context at all.
|
|
82
106
|
*/
|
|
83
107
|
function asActor<T>(options: QueryOptions, run: (ctx: Ctx) => Promise<T>): Promise<T> {
|
|
84
|
-
if (options.actor === undefined)
|
|
108
|
+
if (options.actor === undefined) {
|
|
109
|
+
// INSTALLED, never only handed over — the same fix `@ultimat3/action`'s `invoke` carries, for
|
|
110
|
+
// the same reason: `@ultimat3/entity`'s tenant guard derives from `tryUseContext()`, so a read
|
|
111
|
+
// built under an explicit `ctx` evaluated its policy against that actor and its row tenancy
|
|
112
|
+
// against nobody. Absent a `ctx` this reinstalls the ambient one, which changes nothing.
|
|
113
|
+
const ctx = options.ctx ?? useContext();
|
|
114
|
+
return runWithContext(ctx, () => run(ctx));
|
|
115
|
+
}
|
|
85
116
|
const patch = { actor: options.actor ?? anonymousActor() };
|
|
86
117
|
const inChild = (): Promise<T> => run(useContext());
|
|
87
118
|
const base = options.ctx ?? tryUseContext();
|
|
@@ -90,23 +121,72 @@ function asActor<T>(options: QueryOptions, run: (ctx: Ctx) => Promise<T>): Promi
|
|
|
90
121
|
: runWithContext(base, () => withChildContext(patch, inChild));
|
|
91
122
|
}
|
|
92
123
|
|
|
93
|
-
|
|
94
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The span covers the WHOLE read, not just `execute()`. Wrapping the execution alone left the
|
|
126
|
+
* input parse, the policy evaluation and `sql()`'s own construction outside every span, so a read
|
|
127
|
+
* whose cost was in building the source reported milliseconds while its parent HTTP span reported
|
|
128
|
+
* seconds — a gap with no name, which reads as framework overhead and gets hand-instrumented.
|
|
129
|
+
*
|
|
130
|
+
* Attributes are bounded: surface, actor KIND, booleans, and a row count. Never the input, never
|
|
131
|
+
* an actor id — a read is keyed per tenant and per cursor, and either would be unbounded.
|
|
132
|
+
*/
|
|
133
|
+
function readRows(
|
|
134
|
+
target: AnyQuery,
|
|
135
|
+
raw: unknown,
|
|
136
|
+
ctx: Ctx,
|
|
137
|
+
options: QueryOptions,
|
|
138
|
+
): Promise<readonly object[]> {
|
|
139
|
+
const name = queryName(target);
|
|
140
|
+
return withSpan(`query.${name}`, async (span) => {
|
|
141
|
+
span.setAttributes({
|
|
142
|
+
'ultimate.primitive': 'query',
|
|
143
|
+
'ultimate.query': name,
|
|
144
|
+
'ultimate.surface': options.surface ?? 'server',
|
|
145
|
+
'ultimate.actor.kind': ctx.actor.kind,
|
|
146
|
+
'ultimate.live': target.isLive,
|
|
147
|
+
'ultimate.fresh': options.fresh === true,
|
|
148
|
+
// Whether this read goes through the tier at all. Every read is memoized; only a `cache:`
|
|
149
|
+
// read is filled — and which of the two a slow read took is the first thing to ask.
|
|
150
|
+
'ultimate.cached': defOf(target).cache !== undefined,
|
|
151
|
+
});
|
|
152
|
+
const rows = await readRowsIn(target, raw, ctx, options);
|
|
153
|
+
span.setAttribute('ultimate.rows', rows.length);
|
|
154
|
+
return rows;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readRowsIn(
|
|
159
|
+
target: AnyQuery,
|
|
95
160
|
raw: unknown,
|
|
96
161
|
ctx: Ctx,
|
|
97
162
|
options: QueryOptions,
|
|
98
|
-
): Promise<readonly
|
|
163
|
+
): Promise<readonly object[]> {
|
|
99
164
|
const def = defOf(target);
|
|
100
165
|
const name = queryName(target);
|
|
101
166
|
const source = await buildSource(target, raw, ctx, options);
|
|
102
|
-
const read = (): Promise<readonly object[]> =>
|
|
103
|
-
// The source came from this query's own `sql()`, so its rows are TRow
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
167
|
+
const read = (): Promise<readonly object[]> => source.execute();
|
|
168
|
+
// The source came from this query's own `sql()`, so its rows are TRow throughout —
|
|
169
|
+
// which is what the typed overload above states, and this body never has to assert.
|
|
170
|
+
const tags = def.cache?.tags ?? [];
|
|
171
|
+
// The authority is part of the key on EVERY read, cached or not, because there is one key
|
|
172
|
+
// function and a second one beside it is what this package's own rule forbids. It costs the memo
|
|
173
|
+
// nothing — a memo is already per-ctx — and it is the whole of what the tier was missing: keyed
|
|
174
|
+
// on the name, the input and the tags alone, the process-wide tier handed one org's rows to the
|
|
175
|
+
// next org that asked for them. `actor` when the declaration named no scope, always.
|
|
176
|
+
const key = cacheKeyFor(name, raw, tags, readAuthority(ctx.actor, def.cache?.scope ?? 'actor'));
|
|
177
|
+
// `fresh` is the caller saying no cache may answer this one — the memo included, a memo being
|
|
178
|
+
// a cache whose lifetime is the request. It still *publishes* into the memo: this read is the
|
|
179
|
+
// newest answer the request has, so the next plain read of the key joins it rather than the
|
|
180
|
+
// entry a write earlier in the request already moved past.
|
|
181
|
+
if (options.fresh === true) return await readFresh(ctx, key, read);
|
|
182
|
+
// `cache:` buys the tier, never the memo: a read asked twice in one request is one execution
|
|
183
|
+
// whether or not its author opted into caching.
|
|
184
|
+
// A declared `cache:` with no `ttlMs` gets one anyway. Tags are the primary eviction, but a
|
|
185
|
+
// read whose tags never fire would otherwise hold one entry per distinct input for the life of
|
|
186
|
+
// the process — a paginated feed over 10k tenants is 10k immortal entries.
|
|
187
|
+
return def.cache === undefined
|
|
188
|
+
? await readOnce(ctx, key, read)
|
|
189
|
+
: await readThrough(ctx, key, def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS, read, tags);
|
|
110
190
|
}
|
|
111
191
|
|
|
112
192
|
async function buildSource(
|
|
@@ -118,14 +198,34 @@ async function buildSource(
|
|
|
118
198
|
const def = defOf(target);
|
|
119
199
|
const name = queryName(target);
|
|
120
200
|
const input = await validate(def.input, raw, name);
|
|
121
|
-
|
|
201
|
+
const unenforced = options.unenforced;
|
|
202
|
+
if (unenforced === undefined) {
|
|
122
203
|
guard(
|
|
123
204
|
def.policy,
|
|
124
205
|
{ actor: actorOf(ctx), input, ctx, query: name },
|
|
125
206
|
options.surface ?? 'server',
|
|
126
207
|
);
|
|
208
|
+
} else {
|
|
209
|
+
// The reason IS the mechanism, exactly as it is for `crossTenant`: a blank one leaves the
|
|
210
|
+
// escape with no argument, and the next reader cannot tell a considered skip from a forgotten
|
|
211
|
+
// policy. Refused before the source is built, so nothing is read on a blank justification.
|
|
212
|
+
assert(
|
|
213
|
+
unenforced.trim() !== '',
|
|
214
|
+
`query "${name}" was built with a blank unenforced reason, so the policy it skips carries no argument`,
|
|
215
|
+
`pass why this read needs no subject: sourceFor(target, input, { unenforced: 'explain returns no rows' })`,
|
|
216
|
+
);
|
|
217
|
+
// The audit half. `debug`, because the two shipped callers are dev tooling and a sync node's
|
|
218
|
+
// once-per-query-id window — never a per-request path — and core's logger costs one level
|
|
219
|
+
// comparison when nothing is listening.
|
|
220
|
+
logger.debug('query.policy.unenforced', { query: name, reason: unenforced });
|
|
127
221
|
}
|
|
128
|
-
|
|
222
|
+
const source = def.sql(input, ctx);
|
|
223
|
+
// A live window is served in the order its patches are placed in. The matcher breaks a tie on
|
|
224
|
+
// the declared keys with `id` (`totalOrder`) and so does the keyset re-read a reconnect resumes
|
|
225
|
+
// with, so an initial window served in the declared keys alone puts tied rows where neither of
|
|
226
|
+
// them would: the client renders one order and the next read answers another. A source that
|
|
227
|
+
// cannot say (`total` absent) already serves one order it can be resumed in.
|
|
228
|
+
return options.surface === 'live' && source.total !== undefined ? source.total() : source;
|
|
129
229
|
}
|
|
130
230
|
|
|
131
231
|
async function validate(schema: StandardSchemaV1, raw: unknown, name: string): Promise<unknown> {
|
package/src/shape.ts
CHANGED
|
@@ -57,6 +57,32 @@ export function seekKeyOf(
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Appended by `totalOrder`. Ascending whatever the declared keys do — so is the seek predicate. */
|
|
61
|
+
const ID_TIEBREAK: OrderKey = { column: 'id', direction: 'asc' };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The ordering a read is actually served in: the declared keys, then `id` to make it total.
|
|
65
|
+
*
|
|
66
|
+
* Two rows sharing every declared sort value have no order at all without it — the database
|
|
67
|
+
* returns them either way round while `isAfterKey` decides the next page as though `id` had
|
|
68
|
+
* settled it. `Builder.seek()` compiles this list, `paginate()` sorts by it, and the matcher
|
|
69
|
+
* places a row by it: a row inserted at the end of a tie group is a row the next re-read finds
|
|
70
|
+
* somewhere else. An ordering that already names `id` is total, and adding a second `id` term
|
|
71
|
+
* would compare the key to itself.
|
|
72
|
+
*/
|
|
73
|
+
export function totalOrder(orderBy: readonly OrderKey[]): readonly OrderKey[] {
|
|
74
|
+
return orderBy.some((key) => key.column === 'id') ? orderBy : [...orderBy, ID_TIEBREAK];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* SQL NULL, as a row spells it. A column the row simply omits reads `undefined` here and NULL
|
|
79
|
+
* in Postgres, so both are the same absence — otherwise a fixture row without `deletedAt` and
|
|
80
|
+
* the same row round-tripped through a driver answer `where({ deletedAt: null })` differently.
|
|
81
|
+
*/
|
|
82
|
+
export function isNull(value: unknown): boolean {
|
|
83
|
+
return value === null || value === undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
60
86
|
export function matchesFilters(row: object, filters: readonly Filter[]): boolean {
|
|
61
87
|
return filters.every((filter) => matchesFilter(row, filter));
|
|
62
88
|
}
|
|
@@ -71,33 +97,104 @@ export function matchesFilter(row: object, filter: Filter): boolean {
|
|
|
71
97
|
case 'in':
|
|
72
98
|
return Array.isArray(filter.value) && filter.value.some((item) => same(actual, item));
|
|
73
99
|
case '>':
|
|
74
|
-
return compareValues(actual, filter.value) > 0;
|
|
75
100
|
case '>=':
|
|
76
|
-
return compareValues(actual, filter.value) >= 0;
|
|
77
101
|
case '<':
|
|
78
|
-
return compareValues(actual, filter.value) < 0;
|
|
79
102
|
case '<=':
|
|
80
|
-
return
|
|
103
|
+
return ordered(filter.op, actual, filter.value);
|
|
81
104
|
default:
|
|
82
105
|
return false;
|
|
83
106
|
}
|
|
84
107
|
}
|
|
85
108
|
|
|
86
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* `col > NULL` is unknown in SQL and unknown is not a match, so a NULL on either side of an
|
|
111
|
+
* ordering operator matches nothing here either. Only `=`, `!=` and `in` read NULL as a value —
|
|
112
|
+
* and those are exactly the three `Builder.toSQL()` compiles to `is null` / `is distinct from`.
|
|
113
|
+
*/
|
|
114
|
+
function ordered(op: '>' | '>=' | '<' | '<=', actual: unknown, value: unknown): boolean {
|
|
115
|
+
if (isNull(actual) || isNull(value)) return false;
|
|
116
|
+
const result = compareValues(actual, value);
|
|
117
|
+
switch (op) {
|
|
118
|
+
case '>':
|
|
119
|
+
return result > 0;
|
|
120
|
+
case '>=':
|
|
121
|
+
return result >= 0;
|
|
122
|
+
case '<':
|
|
123
|
+
return result < 0;
|
|
124
|
+
case '<=':
|
|
125
|
+
return result <= 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Dates compare by instant, everything else by value. No coercion across types.
|
|
131
|
+
*
|
|
132
|
+
* NULL is greater than every value and equal to itself — Postgres' own sort rule, which is what
|
|
133
|
+
* lets `Builder.toSQL()` write it down as `asc nulls last` / `desc nulls first` and mean this
|
|
134
|
+
* function. Sorting only: a comparison *filter* against NULL matches nothing (`ordered`). Before
|
|
135
|
+
* this, `null` sorted as the string `"null"`, so it landed between `"m"` and `"o"` in memory and
|
|
136
|
+
* at the end in the database — the same page read two ways.
|
|
137
|
+
*/
|
|
87
138
|
export function compareValues(a: unknown, b: unknown): number {
|
|
139
|
+
if (isNull(a) || isNull(b)) return isNull(a) ? (isNull(b) ? 0 : 1) : -1;
|
|
88
140
|
const left = normalize(a);
|
|
89
141
|
const right = normalize(b);
|
|
90
|
-
if (
|
|
142
|
+
if (isNumeric(left) && isNumeric(right)) return compareNumeric(left, right);
|
|
91
143
|
const l = String(left);
|
|
92
144
|
const r = String(right);
|
|
93
145
|
return l < r ? -1 : l > r ? 1 : 0;
|
|
94
146
|
}
|
|
95
147
|
|
|
148
|
+
function isNumeric(value: unknown): value is number | bigint {
|
|
149
|
+
return typeof value === 'number' || typeof value === 'bigint';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Numbers and bigints, in one order, because **Postgres orders them in one order**.
|
|
154
|
+
*
|
|
155
|
+
* `bigint` is a first-class `ColumnKind` — the physical type of every `<p>_minor` column — and
|
|
156
|
+
* `@ultimat3/entity`'s `count-by.ts` lists it as groupable, so these values do reach the
|
|
157
|
+
* comparator. The old numeric fast path was `typeof left === 'number' && typeof right ===
|
|
158
|
+
* 'number'` alone, so a bigint fell through to `String(left) < String(right)`:
|
|
159
|
+
* `compareValues(9n, 10n)` answered `1` and a sort came out `["10", "100", "9"]`, which means the
|
|
160
|
+
* in-memory source, the live matcher and the seek fallback all disagreed with the database on any
|
|
161
|
+
* bigint-ordered read — including page two of one.
|
|
162
|
+
*
|
|
163
|
+
* A bigint pair never subtracts: the difference is exact but the return type is a `number`. A
|
|
164
|
+
* mixed pair goes through `BigInt` when the number is whole, so a value past 2^53 keeps its exact
|
|
165
|
+
* place; a fractional number cannot equal a bigint, so comparing it as a float is enough to place
|
|
166
|
+
* it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is what keeps them out of the
|
|
167
|
+
* `BigInt()` call that would throw on them.
|
|
168
|
+
*/
|
|
169
|
+
function compareNumeric(left: number | bigint, right: number | bigint): number {
|
|
170
|
+
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
|
171
|
+
if (typeof left === 'bigint' && typeof right === 'bigint') return sign(left, right);
|
|
172
|
+
// Widened rather than negated: `-sign(a, b)` answers `-0` for a tie, and `-0` is a different
|
|
173
|
+
// value from `0` to `Object.is` and to a caller writing `=== 0`.
|
|
174
|
+
return typeof left === 'bigint'
|
|
175
|
+
? mixed(left, right as number)
|
|
176
|
+
: -mixed(right as bigint, left) || 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A bigint against a number, in that order. Whole numbers go through `BigInt` so a value past
|
|
180
|
+
* 2^53 keeps its exact place; a fractional number can never equal a bigint, so comparing it as a
|
|
181
|
+
* float is enough to place it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is
|
|
182
|
+
* what keeps them out of the `BigInt()` call that would throw on them. */
|
|
183
|
+
function mixed(big: bigint, other: number): number {
|
|
184
|
+
return Number.isInteger(other) ? sign(big, BigInt(other)) : sign(Number(big), other);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sign<T extends number | bigint>(left: T, right: T): number {
|
|
188
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A `Date` compares by instant, so two of them order by time and never by their ISO text. */
|
|
96
192
|
function normalize(value: unknown): unknown {
|
|
97
193
|
return value instanceof Date ? value.getTime() : value;
|
|
98
194
|
}
|
|
99
195
|
|
|
100
196
|
function same(a: unknown, b: unknown): boolean {
|
|
197
|
+
if (isNull(a) || isNull(b)) return isNull(a) && isNull(b);
|
|
101
198
|
return compareValues(a, b) === 0 && typeof normalize(a) === typeof normalize(b);
|
|
102
199
|
}
|
|
103
200
|
|
package/src/source.ts
CHANGED
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
* answer these four questions.
|
|
6
6
|
*/
|
|
7
7
|
import type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
|
|
8
|
-
import { compareRows, compareValues, matchesFilters } from './shape';
|
|
8
|
+
import { compareRows, compareValues, isNull, matchesFilters, totalOrder } from './shape';
|
|
9
9
|
import { columnOf } from './stable';
|
|
10
10
|
|
|
11
|
+
/** Nothing matches. `in ()` is a syntax error in Postgres, so an empty set needs a constant. */
|
|
12
|
+
const NEVER = '1 = 0';
|
|
13
|
+
|
|
14
|
+
/** Binds a value and answers the `$n` that reads it. Nothing here interpolates a value. */
|
|
15
|
+
type Slot = (value: unknown) => string;
|
|
16
|
+
|
|
11
17
|
export interface SqlText {
|
|
12
18
|
readonly sql: string;
|
|
13
19
|
readonly params: readonly unknown[];
|
|
@@ -19,6 +25,12 @@ export interface SqlSource<TRow> {
|
|
|
19
25
|
execute(): Promise<readonly TRow[]>;
|
|
20
26
|
/** Required for `live: true`: the matcher patches from the shape, not from SQL. */
|
|
21
27
|
shape(): QueryShape;
|
|
28
|
+
/**
|
|
29
|
+
* The same read served in `totalOrder` — the declared keys, then `id`. A live read is built
|
|
30
|
+
* through this, because the matcher places a patched row by that order and a reconnect resumes
|
|
31
|
+
* by it. Absent means the source already serves one order it can be resumed in.
|
|
32
|
+
*/
|
|
33
|
+
total?(): SqlSource<TRow>;
|
|
22
34
|
/** Cursor push-down. Absent means pagination slices after execution. */
|
|
23
35
|
seek?(after: SeekKey | null, limit: number): SqlSource<TRow>;
|
|
24
36
|
}
|
|
@@ -42,10 +54,16 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
42
54
|
private readonly rowLimit: number | null,
|
|
43
55
|
private readonly after: SeekKey | null,
|
|
44
56
|
private readonly unsupported: readonly string[],
|
|
45
|
-
/** Set by `seek()
|
|
46
|
-
private readonly
|
|
57
|
+
/** Set by `seek()` and `total()` — the only reads that pay for the id tiebreak. */
|
|
58
|
+
private readonly totalized: boolean = false,
|
|
47
59
|
) {}
|
|
48
60
|
|
|
61
|
+
/**
|
|
62
|
+
* One equality filter per key, and the clauses come out in **lexical key order** — not the order
|
|
63
|
+
* the object literal was typed in. The generated text is something callers compare across runs:
|
|
64
|
+
* `explain()` prints it, `LiveQuery.sqlText` caches it, and tests pin it. Reordering two keys in
|
|
65
|
+
* a call site must not rewrite the statement.
|
|
66
|
+
*/
|
|
49
67
|
where(equals: Readonly<Record<string, unknown>>): Builder<TRow> {
|
|
50
68
|
const added = Object.keys(equals)
|
|
51
69
|
.sort()
|
|
@@ -71,7 +89,15 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
71
89
|
}
|
|
72
90
|
|
|
73
91
|
seek(after: SeekKey | null, limit: number): Builder<TRow> {
|
|
74
|
-
return this.derive({ after, rowLimit: limit,
|
|
92
|
+
return this.derive({ after, rowLimit: limit, totalized: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The declared keys plus the `id` tiebreak, with no cursor and no window — page one of the
|
|
97
|
+
* ordering a paged read already serves. `seek()` implies it; a live read asks for it directly.
|
|
98
|
+
*/
|
|
99
|
+
total(): Builder<TRow> {
|
|
100
|
+
return this.derive({ totalized: true });
|
|
75
101
|
}
|
|
76
102
|
|
|
77
103
|
shape(): QueryShape {
|
|
@@ -86,24 +112,12 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
86
112
|
|
|
87
113
|
toSQL(): SqlText {
|
|
88
114
|
const params: unknown[] = [];
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
params.push(item);
|
|
93
|
-
return `$${params.length}`;
|
|
94
|
-
});
|
|
95
|
-
return `"${filter.column}" in (${slots.join(', ')})`;
|
|
96
|
-
}
|
|
97
|
-
params.push(filter.value);
|
|
98
|
-
return `"${filter.column}" ${filter.op} $${params.length}`;
|
|
99
|
-
});
|
|
100
|
-
if (this.after !== null) clauses.push(this.seekClause(this.after, params));
|
|
115
|
+
const slot = slotter(params);
|
|
116
|
+
const clauses = this.filters.map((filter) => filterClause(filter, slot));
|
|
117
|
+
if (this.after !== null) clauses.push(this.seekClause(this.after, slot));
|
|
101
118
|
const where = clauses.length > 0 ? ` where ${clauses.join(' and ')}` : '';
|
|
102
|
-
const keys = this.
|
|
103
|
-
const order =
|
|
104
|
-
keys.length > 0
|
|
105
|
-
? ` order by ${keys.map((key) => `"${key.column}" ${key.direction}`).join(', ')}`
|
|
106
|
-
: '';
|
|
119
|
+
const keys = this.servedOrder();
|
|
120
|
+
const order = keys.length > 0 ? ` order by ${keys.map(orderTerm).join(', ')}` : '';
|
|
107
121
|
const limit = this.rowLimit === null ? '' : ` limit ${this.rowLimit}`;
|
|
108
122
|
return { sql: `select * from "${this.entity}"${where}${order}${limit}`, params };
|
|
109
123
|
}
|
|
@@ -111,7 +125,7 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
111
125
|
async execute(): Promise<readonly TRow[]> {
|
|
112
126
|
const source = typeof this.rows === 'function' ? await this.rows() : this.rows;
|
|
113
127
|
let result = source.filter((row) => matchesFilters(row, this.filters));
|
|
114
|
-
const keys = this.
|
|
128
|
+
const keys = this.servedOrder();
|
|
115
129
|
if (keys.length > 0) {
|
|
116
130
|
result = [...result].sort((a, b) => compareRows(a, b, keys));
|
|
117
131
|
}
|
|
@@ -128,20 +142,14 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
128
142
|
}
|
|
129
143
|
|
|
130
144
|
/**
|
|
131
|
-
* The ordering
|
|
145
|
+
* The ordering this read is actually served in — `totalOrder`, so the SQL, the in-memory sort,
|
|
146
|
+
* `seekClause()` and the matcher all read one list.
|
|
132
147
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* so one of the pair comes back twice and the other never does. `execute()` had the same split,
|
|
136
|
-
* sorting by the declared keys and then filtering with the id-aware predicate. One order, read
|
|
137
|
-
* by the SQL, the in-memory sort and the predicate alike, is what closes it.
|
|
138
|
-
*
|
|
139
|
-
* Only a paged read pays for it: an unpaginated `from()` over rows that have no `id` must keep
|
|
140
|
-
* generating exactly the SQL it was asked for.
|
|
148
|
+
* Only a read that asked for it pays: `seek()` for a page, `total()` for a live window. A plain
|
|
149
|
+
* `from()` over rows that have no `id` must keep generating exactly the SQL it was asked for.
|
|
141
150
|
*/
|
|
142
|
-
private
|
|
143
|
-
|
|
144
|
-
return [...this.order, { column: 'id', direction: 'asc' }];
|
|
151
|
+
private servedOrder(): readonly OrderKey[] {
|
|
152
|
+
return this.totalized ? totalOrder(this.order) : this.order;
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
/**
|
|
@@ -152,25 +160,28 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
152
160
|
* a mixed listing repeated and skipped rows while `execute()` did the right
|
|
153
161
|
* thing. Same shape as `@ultimat3/entity`'s `seekSql`: one meaning, two drivers.
|
|
154
162
|
*/
|
|
155
|
-
private seekClause(after: SeekKey,
|
|
156
|
-
|
|
157
|
-
params.push(value);
|
|
158
|
-
return `$${params.length}`;
|
|
159
|
-
};
|
|
160
|
-
// The same `pageOrder()` the ORDER BY is built from, so the predicate can only ever describe
|
|
163
|
+
private seekClause(after: SeekKey, slot: Slot): string {
|
|
164
|
+
// The same `servedOrder()` the ORDER BY is built from, so the predicate can only ever describe
|
|
161
165
|
// the order the rows actually arrive in. The tiebreak is absent when the ordering already
|
|
162
166
|
// named `id`: a second `id` term compares the key to itself, can never be true, and is dead
|
|
163
167
|
// SQL an agent then has to reason about.
|
|
164
|
-
const keys = this.
|
|
168
|
+
const keys = this.servedOrder();
|
|
165
169
|
const values = this.ordersById ? [...after.key] : [...after.key, after.id];
|
|
166
|
-
const terms =
|
|
170
|
+
const terms: string[] = [];
|
|
171
|
+
for (const [index, key] of keys.entries()) {
|
|
172
|
+
const value = values[index];
|
|
173
|
+
// Under `nulls last` nothing sorts after a NULL, so this key's term is dead SQL — dropped
|
|
174
|
+
// rather than emitted, exactly as the doubled id tiebreak is. The remaining keys still
|
|
175
|
+
// carry the page: the equality prefix below reaches them as `"col" is null`.
|
|
176
|
+
if (isNull(value) && key.direction === 'asc') continue;
|
|
167
177
|
const equal = keys
|
|
168
178
|
.slice(0, index)
|
|
169
|
-
.map((earlier, position) =>
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
.map((earlier, position) => equalTerm(earlier.column, values[position], slot));
|
|
180
|
+
terms.push(`(${[...equal, afterTerm(key, value, slot)].join(' and ')})`);
|
|
181
|
+
}
|
|
182
|
+
// Every key null under an ascending order is the very end of the listing. Only reachable
|
|
183
|
+
// from a hand-built cursor — `seekKeyOf` refuses a row with no id — but `()` is a syntax error.
|
|
184
|
+
return terms.length === 0 ? NEVER : `(${terms.join(' or ')})`;
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
private derive(patch: Partial<BuilderState>): Builder<TRow> {
|
|
@@ -182,18 +193,95 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
182
193
|
patch.rowLimit === undefined ? this.rowLimit : patch.rowLimit,
|
|
183
194
|
patch.after === undefined ? this.after : patch.after,
|
|
184
195
|
patch.unsupported ?? this.unsupported,
|
|
185
|
-
patch.
|
|
196
|
+
patch.totalized ?? this.totalized,
|
|
186
197
|
);
|
|
187
198
|
}
|
|
188
199
|
}
|
|
189
200
|
|
|
201
|
+
const slotter =
|
|
202
|
+
(params: unknown[]): Slot =>
|
|
203
|
+
(value) => {
|
|
204
|
+
params.push(value);
|
|
205
|
+
return `$${params.length}`;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* One filter, in SQL that means what `matchesFilter` means.
|
|
210
|
+
*
|
|
211
|
+
* `= $n` with a NULL parameter is unknown in Postgres and unknown is never true, so
|
|
212
|
+
* `where({ deletedAt: null })` matched every live row in memory and no row at all in the
|
|
213
|
+
* database. `is null` / `is distinct from` is the pair `@ultimat3/entity`'s `predicateSql`
|
|
214
|
+
* already emits — one meaning, two sources. An ordering operator needs no case: `"col" > $n`
|
|
215
|
+
* against a NULL matches nothing, which is what `ordered()` now answers too.
|
|
216
|
+
*/
|
|
217
|
+
function filterClause(filter: Filter, slot: Slot): string {
|
|
218
|
+
const column = `"${filter.column}"`;
|
|
219
|
+
if (filter.op === 'in') {
|
|
220
|
+
// `in` reads a list or nothing. A non-array operand matches no row in memory, so the SQL says
|
|
221
|
+
// the same constant — the fallback below would emit `"col" in $n`, which is a syntax error a
|
|
222
|
+
// driver reports instead of the empty result the two sources agree on.
|
|
223
|
+
if (!Array.isArray(filter.value)) return NEVER;
|
|
224
|
+
const present = filter.value.filter((item) => !isNull(item));
|
|
225
|
+
const list =
|
|
226
|
+
present.length === 0
|
|
227
|
+
? null
|
|
228
|
+
: `${column} in (${present.map((item) => slot(item)).join(', ')})`;
|
|
229
|
+
const nulls = present.length === filter.value.length ? null : `${column} is null`;
|
|
230
|
+
if (list === null) return nulls ?? NEVER;
|
|
231
|
+
return nulls === null ? list : `(${list} or ${nulls})`;
|
|
232
|
+
}
|
|
233
|
+
if (filter.op === '=') return equalTerm(filter.column, filter.value, slot);
|
|
234
|
+
if (filter.op === '!=') {
|
|
235
|
+
return isNull(filter.value)
|
|
236
|
+
? `${column} is not null`
|
|
237
|
+
: `${column} is distinct from ${slot(filter.value)}`;
|
|
238
|
+
}
|
|
239
|
+
return `${column} ${filter.op} ${slot(filter.value)}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* NULL's place in the ordering, written down rather than inherited. Postgres already defaults
|
|
244
|
+
* to `nulls last` under `asc` and `nulls first` under `desc` — that is the rule `compareValues`
|
|
245
|
+
* implements, so the in-memory sort, the live matcher and `seekClause` below can only agree
|
|
246
|
+
* with it. Saying it out loud is what keeps a driver whose default differs from re-opening the
|
|
247
|
+
* divergence, and it is what an agent reads when a nullable sort key surprises it.
|
|
248
|
+
*/
|
|
249
|
+
function orderTerm(key: OrderKey): string {
|
|
250
|
+
const nulls = key.direction === 'desc' ? 'nulls first' : 'nulls last';
|
|
251
|
+
return `"${key.column}" ${key.direction} ${nulls}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** `= $n` never matches a NULL — the same defect as a filter, one page later. */
|
|
255
|
+
function equalTerm(column: string, value: unknown, slot: Slot): string {
|
|
256
|
+
return isNull(value) ? `"${column}" is null` : `"${column}" = ${slot(value)}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Strictly past this key's value, under the ordering `orderTerm` writes.
|
|
261
|
+
*
|
|
262
|
+
* `desc` is `nulls first`: every non-null row follows a NULL cursor, and no NULL row follows a
|
|
263
|
+
* value — which `"col" < $n` already excludes. `asc` is `nulls last`: the NULLs follow every
|
|
264
|
+
* value, so a value cursor has to reach them explicitly or page two ends at the first NULL.
|
|
265
|
+
*/
|
|
266
|
+
function afterTerm(key: OrderKey, value: unknown, slot: Slot): string {
|
|
267
|
+
const column = `"${key.column}"`;
|
|
268
|
+
if (key.direction === 'desc') {
|
|
269
|
+
return isNull(value) ? `${column} is not null` : `${column} < ${slot(value)}`;
|
|
270
|
+
}
|
|
271
|
+
const after = `${column} > ${slot(value)}`;
|
|
272
|
+
// `id` is the tiebreak that makes the order total and `seekKeyOf` refuses a row without one,
|
|
273
|
+
// so `"id" is null` is unsatisfiable: reaching for it would be dead SQL on every paged read,
|
|
274
|
+
// and an `or` the planner has to defeat before it can seek the index.
|
|
275
|
+
return key.column === 'id' ? after : `(${after} or ${column} is null)`;
|
|
276
|
+
}
|
|
277
|
+
|
|
190
278
|
interface BuilderState {
|
|
191
279
|
readonly filters: readonly Filter[];
|
|
192
280
|
readonly order: readonly OrderKey[];
|
|
193
281
|
readonly rowLimit: number | null;
|
|
194
282
|
readonly after: SeekKey | null;
|
|
195
283
|
readonly unsupported: readonly string[];
|
|
196
|
-
readonly
|
|
284
|
+
readonly totalized: boolean;
|
|
197
285
|
}
|
|
198
286
|
|
|
199
287
|
/**
|
package/src/sql.ts
CHANGED
|
@@ -27,7 +27,7 @@ export async function explain<TInput extends StandardSchemaV1, TRow extends obje
|
|
|
27
27
|
ctx?: Ctx,
|
|
28
28
|
): Promise<ExplainResult> {
|
|
29
29
|
const source = await sourceFor(target, input, {
|
|
30
|
-
|
|
30
|
+
unenforced: 'explain returns the statement and never a row, and its surfaces are admin-gated',
|
|
31
31
|
...(ctx === undefined ? {} : { ctx }),
|
|
32
32
|
});
|
|
33
33
|
const text = source.toSQL();
|
|
@@ -65,7 +65,7 @@ export async function describeSql(
|
|
|
65
65
|
continue;
|
|
66
66
|
}
|
|
67
67
|
const source = await sourceFor(target, sample, {
|
|
68
|
-
|
|
68
|
+
unenforced: 'the dashboard listing renders SQL text for a sample input and reads no rows',
|
|
69
69
|
...(ctx === undefined ? {} : { ctx }),
|
|
70
70
|
});
|
|
71
71
|
entries.push({ query: name, live: target.isLive, sql: source.toSQL().sql });
|