@ultimat3/entity 11.3.0 → 12.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 +190 -20
- package/README.md +120 -1
- package/package.json +5 -5
- package/src/aggregate-decode.ts +35 -0
- package/src/aggregate-fold.ts +91 -0
- package/src/aggregate.ts +232 -0
- package/src/batch.ts +2 -1
- package/src/column.ts +15 -1
- package/src/containment.ts +94 -0
- package/src/cursor.ts +100 -14
- package/src/database.ts +1 -1
- package/src/describe.ts +3 -0
- package/src/entity.ts +153 -7
- package/src/errors.ts +6 -0
- package/src/index.ts +3 -1
- package/src/instant.ts +93 -0
- package/src/memory-match.ts +67 -0
- package/src/memory-repo.ts +357 -0
- package/src/pg-driver.ts +94 -7
- package/src/pg-row.ts +38 -1
- package/src/pg-sql.ts +279 -133
- package/src/pg-write-sql.ts +129 -0
- package/src/plan.ts +71 -10
- package/src/query.ts +59 -3
- package/src/registry.ts +7 -0
- package/src/repo.ts +16 -328
- package/src/tenancy.ts +18 -2
- package/src/types.ts +10 -0
package/src/entity.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// this one call.
|
|
5
5
|
|
|
6
6
|
import { renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import type { IndexMethod } from '@ultimat3/db';
|
|
7
8
|
import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
9
|
import { entityNow } from './clock';
|
|
9
10
|
import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
|
|
@@ -30,6 +31,21 @@ export interface IndexInit<C extends ColumnMap> {
|
|
|
30
31
|
readonly unique?: boolean;
|
|
31
32
|
/** Partial index predicate, written in the same language as an invariant. */
|
|
32
33
|
readonly where?: (columns: InvariantColumns<C>) => Expr;
|
|
34
|
+
/**
|
|
35
|
+
* The access method. Omitted is `btree`, which is Postgres' own default and what every index
|
|
36
|
+
* declared before this existed is — so an entity that names none emits the statement it always
|
|
37
|
+
* emitted and nothing regenerates.
|
|
38
|
+
*
|
|
39
|
+
* `'gin'` is the one with a caller, and it is the whole point of the containment operators:
|
|
40
|
+
* measured on Postgres 16 over 20,000 rows, `tags @> …`, `tags <@ …`, `tags && …` and
|
|
41
|
+
* `data @> …` are each a Bitmap Index Scan with one and a Seq Scan without. The set is
|
|
42
|
+
* `@ultimat3/db`'s `INDEX_METHODS`, imported rather than restated — one declaration of one fact.
|
|
43
|
+
*
|
|
44
|
+
* Two Postgres rules ride with it and both are refused HERE, where the author is, rather than at
|
|
45
|
+
* `x db gen` or inside `ROLE=migrate` as the server's own syntax error: a GIN index cannot be
|
|
46
|
+
* unique and cannot order its keys.
|
|
47
|
+
*/
|
|
48
|
+
readonly using?: IndexMethod;
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
export interface EntityInit<C extends ColumnMap> {
|
|
@@ -109,8 +125,87 @@ export type Entity<Row, C extends ColumnMap = ColumnMap> = EntityCore<Row, C> &
|
|
|
109
125
|
|
|
110
126
|
const MONEY_PARTS = new Set(['minor', 'currency']);
|
|
111
127
|
|
|
112
|
-
|
|
113
|
-
|
|
128
|
+
/**
|
|
129
|
+
* What separates two indexes on the SAME columns: the predicate and the direction. Eight hex
|
|
130
|
+
* characters of sha256 over both — deterministic across processes, so a name is a property of the
|
|
131
|
+
* declaration and never of the run that generated it.
|
|
132
|
+
*/
|
|
133
|
+
/**
|
|
134
|
+
* What separates two indexes on the SAME columns: the predicate, the direction and the ACCESS
|
|
135
|
+
* METHOD. Eight hex characters of sha256 over all three — deterministic across processes, so a
|
|
136
|
+
* name is a property of the declaration and never of the run that generated it.
|
|
137
|
+
*
|
|
138
|
+
* The method belongs here for exactly the reason `where` does. A btree on an `arrayOf()` column
|
|
139
|
+
* answers `=` and an ordering; a GIN on the same column answers `@>` / `<@` / `&&`. They are two
|
|
140
|
+
* distinct indexes, and without the method in the name both are `<table>_<cols>_idx` — where the
|
|
141
|
+
* dedup below drops one in silence (the defect this discriminator was added for) or, since that
|
|
142
|
+
* dedup is now on the whole definition, two `create index` statements share one name and the
|
|
143
|
+
* migration is `42P07`.
|
|
144
|
+
*/
|
|
145
|
+
const indexDiscriminator = (
|
|
146
|
+
order: string | undefined,
|
|
147
|
+
where: string | null,
|
|
148
|
+
using: string | undefined,
|
|
149
|
+
): string =>
|
|
150
|
+
new Bun.CryptoHasher('sha256')
|
|
151
|
+
// The method is APPENDED only when one was declared, never as an empty field: every name this
|
|
152
|
+
// function has ever minted for a partial or ordered index is therefore unchanged by the method
|
|
153
|
+
// existing, and an index that declares no method is byte-identical to the one it was.
|
|
154
|
+
.update(`${order ?? ''}|${where ?? ''}${using === undefined ? '' : `|${using}`}`)
|
|
155
|
+
.digest('hex')
|
|
156
|
+
.slice(0, 8);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `<table>_<columns>_idx`, plus a discriminator when — and only when — the index carries a
|
|
160
|
+
* predicate, a direction or a non-default access method.
|
|
161
|
+
*
|
|
162
|
+
* Only then, because the plain name is load-bearing in two places: `unique()` on a column is an
|
|
163
|
+
* inline column clause and Postgres names the index it creates exactly `<table>_<column>_key`, so
|
|
164
|
+
* a discriminator there would make the generator emit a second `create unique index` for an index
|
|
165
|
+
* that already exists (`42P07`); and a foreign key's own index is deduped against a hand-declared
|
|
166
|
+
* one by this name.
|
|
167
|
+
*
|
|
168
|
+
* Without it, two DIFFERENT partial indexes on one column were one name — `posts_author_id_idx`
|
|
169
|
+
* for both `where status = 'published'` and `where status = 'draft'` — and the dedup below dropped
|
|
170
|
+
* the second with no error, no warning and no drift finding, since a declared index is matched by
|
|
171
|
+
* name.
|
|
172
|
+
*/
|
|
173
|
+
/**
|
|
174
|
+
* `NAMEDATALEN - 1`. Postgres truncates a longer identifier and says NOTHING, so two index names
|
|
175
|
+
* sharing their first 63 bytes become one index on the server — the same silent collapse the
|
|
176
|
+
* discriminator above exists to prevent, one layer down, and invisible to a drift check comparing
|
|
177
|
+
* DECLARED names because those still differ. Bytes and not characters: 63 is what the server
|
|
178
|
+
* counts, and `.length` would stop seeing the truncation the moment a name is not ASCII.
|
|
179
|
+
*/
|
|
180
|
+
const MAX_IDENTIFIER_BYTES = 63;
|
|
181
|
+
|
|
182
|
+
const byteLength = (value: string): number => new TextEncoder().encode(value).length;
|
|
183
|
+
|
|
184
|
+
const indexName = (
|
|
185
|
+
entityName: string,
|
|
186
|
+
table: string,
|
|
187
|
+
columns: readonly string[],
|
|
188
|
+
unique: boolean,
|
|
189
|
+
order?: string | undefined,
|
|
190
|
+
where: string | null = null,
|
|
191
|
+
using?: IndexMethod | undefined,
|
|
192
|
+
): string => {
|
|
193
|
+
const suffix = unique ? 'key' : 'idx';
|
|
194
|
+
const base = `${table}_${columns.join('_')}`;
|
|
195
|
+
const plain = order === undefined && where === null && using === undefined;
|
|
196
|
+
const name = plain
|
|
197
|
+
? `${base}_${suffix}`
|
|
198
|
+
: `${base}_${indexDiscriminator(order, where, using)}_${suffix}`;
|
|
199
|
+
const bytes = byteLength(name);
|
|
200
|
+
if (bytes <= MAX_IDENTIFIER_BYTES) return name;
|
|
201
|
+
throw invariantViolated(
|
|
202
|
+
entityName,
|
|
203
|
+
'index',
|
|
204
|
+
`the index on (${columns.join(', ')}) is named "${name}", which is ${bytes} bytes — ` +
|
|
205
|
+
`Postgres truncates an identifier at ${MAX_IDENTIFIER_BYTES} and does not say so, ` +
|
|
206
|
+
'so two indexes can silently become one',
|
|
207
|
+
);
|
|
208
|
+
};
|
|
114
209
|
|
|
115
210
|
const defaultValue = (meta: ColumnMeta): unknown => {
|
|
116
211
|
const declared = meta.default;
|
|
@@ -127,7 +222,10 @@ export const entity = <const C extends ColumnMap>(
|
|
|
127
222
|
const entries: readonly (readonly [string, AnyColumn])[] = Object.entries(init.columns);
|
|
128
223
|
for (const [property, column] of entries) bindColumn(column, name, property);
|
|
129
224
|
|
|
130
|
-
|
|
225
|
+
// Both branches. The declared table was checked and the fallback — which is every entity that
|
|
226
|
+
// does not rename its table — was not, so an entity NAME closed the identifier in the same way a
|
|
227
|
+
// column name could: `entity('t" (x int); drop table u; --')` emitted that `drop table` verbatim.
|
|
228
|
+
const table = assertColumnName(init.table ?? name);
|
|
131
229
|
const cacheTag = `entity:${name}`;
|
|
132
230
|
const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
|
|
133
231
|
const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
|
|
@@ -187,7 +285,11 @@ export const entity = <const C extends ColumnMap>(
|
|
|
187
285
|
meta.kind === 'money' ? moneyColumns(property, meta).minor : columnName(property, meta),
|
|
188
286
|
];
|
|
189
287
|
return [
|
|
190
|
-
{
|
|
288
|
+
{
|
|
289
|
+
name: indexName(name, table, physical, meta.unique),
|
|
290
|
+
columns: physical,
|
|
291
|
+
unique: meta.unique,
|
|
292
|
+
},
|
|
191
293
|
];
|
|
192
294
|
}),
|
|
193
295
|
...(init.indexes ?? []).map((index) => {
|
|
@@ -206,18 +308,62 @@ export const entity = <const C extends ColumnMap>(
|
|
|
206
308
|
'a partial index predicate must be expressible in SQL; a JS predicate cannot be one',
|
|
207
309
|
);
|
|
208
310
|
}
|
|
311
|
+
// Two rules Postgres has that a declaration can break, refused where the author wrote it.
|
|
312
|
+
// `@ultimat3/db` refuses both again at `createIndex` — that is not a duplicate, it is the
|
|
313
|
+
// guard for a description nobody built here — but its refusal lands at `x db gen` or, if a
|
|
314
|
+
// migration was already written, inside `ROLE=migrate` as the server's own syntax error with
|
|
315
|
+
// none of the entity's words in it.
|
|
316
|
+
if (index.using !== undefined && index.using !== 'btree') {
|
|
317
|
+
if (unique) {
|
|
318
|
+
throw invariantViolated(
|
|
319
|
+
name,
|
|
320
|
+
'index',
|
|
321
|
+
`the index on (${columns.join(', ')}) is unique and ${index.using}; ` +
|
|
322
|
+
`Postgres has no unique ${index.using} index — drop unique, or drop using`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (index.order !== undefined) {
|
|
326
|
+
throw invariantViolated(
|
|
327
|
+
name,
|
|
328
|
+
'index',
|
|
329
|
+
`the index on (${columns.join(', ')}) is ${index.using} and ${index.order}; ` +
|
|
330
|
+
'only a btree orders its keys — drop order, or drop using',
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
209
334
|
return {
|
|
210
|
-
name: indexName(table, columns, unique),
|
|
335
|
+
name: indexName(name, table, columns, unique, index.order, where, index.using),
|
|
211
336
|
columns,
|
|
212
337
|
unique,
|
|
213
338
|
...(index.order === undefined ? {} : { order: index.order }),
|
|
214
339
|
...(where === null ? {} : { where }),
|
|
340
|
+
// Absent stays absent: `btree` written out would be a field every existing snapshot lacks,
|
|
341
|
+
// and `indexMethodOf` reads the two the same way precisely so nothing regenerates.
|
|
342
|
+
...(index.using === undefined || index.using === 'btree' ? {} : { using: index.using }),
|
|
215
343
|
};
|
|
216
344
|
}),
|
|
217
345
|
];
|
|
218
|
-
|
|
346
|
+
/**
|
|
347
|
+
* A foreign key already indexes its column; naming it again in `indexes` is not two indexes.
|
|
348
|
+
*
|
|
349
|
+
* On the WHOLE definition and not on the name. With the discriminator above the two rules agree
|
|
350
|
+
* exactly, so this is not a behaviour change on its own — it is which one FAILS LOUDLY if the
|
|
351
|
+
* naming is ever weakened again. Matching on the name drops the second index in silence, which
|
|
352
|
+
* is how two different partial indexes became one for three majors; matching on the definition
|
|
353
|
+
* keeps both, and two `create index` statements sharing a name is `42P07` on the next migration.
|
|
354
|
+
*/
|
|
355
|
+
const identity = (index: IndexDef): string =>
|
|
356
|
+
[
|
|
357
|
+
index.name,
|
|
358
|
+
index.columns.join(','),
|
|
359
|
+
index.unique,
|
|
360
|
+
index.order ?? '',
|
|
361
|
+
index.where ?? '',
|
|
362
|
+
index.using ?? '',
|
|
363
|
+
].join('|');
|
|
219
364
|
const indexes: readonly IndexDef[] = declared.filter(
|
|
220
|
-
(index, position) =>
|
|
365
|
+
(index, position) =>
|
|
366
|
+
declared.findIndex((other) => identity(other) === identity(index)) === position,
|
|
221
367
|
);
|
|
222
368
|
|
|
223
369
|
const tags = [cacheTag, ...(init.tags ?? [])];
|
package/src/errors.ts
CHANGED
|
@@ -18,6 +18,9 @@ export const ENTITY_OWNED_ERROR_CODES = [
|
|
|
18
18
|
'X_N_PLUS_ONE_QUERY',
|
|
19
19
|
'X_N_PLUS_ONE_WRITE',
|
|
20
20
|
'X_REPO_CLIENT_PINNED',
|
|
21
|
+
'X_AGGREGATE_UNSUPPORTED',
|
|
22
|
+
'X_AGGREGATE_MIXED_CURRENCY',
|
|
23
|
+
'X_APPROXIMATE_COUNT_FILTERED',
|
|
21
24
|
] as const;
|
|
22
25
|
|
|
23
26
|
/**
|
|
@@ -52,6 +55,9 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
|
|
|
52
55
|
X_N_PLUS_ONE_QUERY: 'a read repeated once per row',
|
|
53
56
|
X_N_PLUS_ONE_WRITE: 'a write repeated once per row',
|
|
54
57
|
X_REPO_CLIENT_PINNED: 'a repository pinned to its own client cannot join the open transaction',
|
|
58
|
+
X_AGGREGATE_UNSUPPORTED: 'that column has no aggregate both drivers can answer alike',
|
|
59
|
+
X_AGGREGATE_MIXED_CURRENCY: 'an amount was aggregated across currencies',
|
|
60
|
+
X_APPROXIMATE_COUNT_FILTERED: 'an estimate was asked of a filtered chain',
|
|
55
61
|
};
|
|
56
62
|
|
|
57
63
|
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
/** Re-exported so an `entity` file needs one import, not two. Same object as schema's. */
|
|
4
4
|
export type { Infer } from '@ultimat3/schema';
|
|
5
5
|
export { t } from '@ultimat3/schema';
|
|
6
|
+
export type { AggregateFn } from './aggregate';
|
|
7
|
+
export { AVG_SCALE } from './aggregate';
|
|
6
8
|
export type { BatchIterator } from './batch';
|
|
7
9
|
export type { MoneyColumns } from './column';
|
|
8
10
|
export { columnName, moneyColumns, snake } from './column';
|
|
@@ -72,6 +74,7 @@ export {
|
|
|
72
74
|
MAX_ASSERTED_ROWS,
|
|
73
75
|
toSql,
|
|
74
76
|
} from './invariants';
|
|
77
|
+
export { memoryRepo, memoryTransactor } from './memory-repo';
|
|
75
78
|
export type { StatementLoop } from './n-plus-one';
|
|
76
79
|
export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
|
|
77
80
|
export type { PostgresDriverOptions } from './pg-driver';
|
|
@@ -111,7 +114,6 @@ export type {
|
|
|
111
114
|
Tx,
|
|
112
115
|
UpsertArgs,
|
|
113
116
|
} from './repo';
|
|
114
|
-
export { memoryRepo, memoryTransactor } from './repo';
|
|
115
117
|
export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
|
|
116
118
|
export { observedRepo, rowObserver, setRowObserver } from './row-observer';
|
|
117
119
|
export type {
|
package/src/instant.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Single responsibility: a `timestamptz` value at the precision the COLUMN stores it, rather than
|
|
2
|
+
// the precision a JS `Date` holds. The column keeps microseconds and a `Date` keeps milliseconds,
|
|
3
|
+
// so a page position minted from a decoded row ranks rows differently from the `order by` that
|
|
4
|
+
// produced them. Microseconds since the epoch is the one form both sides can be exact in, and this
|
|
5
|
+
// file is the only place the two representations meet.
|
|
6
|
+
|
|
7
|
+
/** A `Date` is exactly this much coarser than the column it came out of. */
|
|
8
|
+
const MICROS_PER_MILLI = 1000n;
|
|
9
|
+
|
|
10
|
+
const MICROS_PER_SECOND = 1_000_000n;
|
|
11
|
+
|
|
12
|
+
const FRACTION_DIGITS = 6;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What `(col at time zone 'UTC')::text` prints: `2026-01-01 00:00:00.123456`, with the fraction
|
|
16
|
+
* omitted entirely when every digit of it is zero and TRUNCATED when the trailing ones are —
|
|
17
|
+
* `.1` is a tenth of a second, not one microsecond, which is why the fraction is padded on the
|
|
18
|
+
* right and never on the left.
|
|
19
|
+
*
|
|
20
|
+
* The year is `\d{4,}` because Postgres prints one wider than four digits unpadded; a `BC` suffix
|
|
21
|
+
* matches nothing here on purpose, and an unmatched text falls back to the decoded `Date`.
|
|
22
|
+
*/
|
|
23
|
+
const PG_INSTANT_TEXT = /^(\d{4,})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `Date.UTC` maps years 0–99 into the 1900s, so the epoch is built by assignment instead. Whole
|
|
27
|
+
* seconds only: the fraction is added in the microsecond domain, where it is exact.
|
|
28
|
+
*/
|
|
29
|
+
const utcSecondMillis = (parts: readonly number[]): number => {
|
|
30
|
+
const [year = 0, month = 1, day = 1, hour = 0, minute = 0, second = 0] = parts;
|
|
31
|
+
const at = new Date(0);
|
|
32
|
+
at.setUTCFullYear(year, month - 1, day);
|
|
33
|
+
at.setUTCHours(hour, minute, second, 0);
|
|
34
|
+
return at.getTime();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Floor division: `-1n / 2n` truncates toward zero, which would place a pre-1970 instant late. */
|
|
38
|
+
const floorDiv = (value: bigint, by: bigint): bigint => {
|
|
39
|
+
const remainder = ((value % by) + by) % by;
|
|
40
|
+
return (value - remainder) / by;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The exact microsecond epoch of a `timestamptz` Postgres rendered as text, or `undefined` when
|
|
45
|
+
* the text is not one — a caller with nothing to read falls back to the decoded `Date`, which is
|
|
46
|
+
* the position it always had.
|
|
47
|
+
*/
|
|
48
|
+
export const pgInstantMicros = (text: unknown): bigint | undefined => {
|
|
49
|
+
if (typeof text !== 'string') return undefined;
|
|
50
|
+
const match = PG_INSTANT_TEXT.exec(text);
|
|
51
|
+
if (match === null) return undefined;
|
|
52
|
+
const [, year = '', month = '', day = '', hour = '', minute = '', second = '', fraction] = match;
|
|
53
|
+
const millis = utcSecondMillis([year, month, day, hour, minute, second].map(Number));
|
|
54
|
+
if (!Number.isFinite(millis)) return undefined;
|
|
55
|
+
// The fraction is always forward in time, so it ADDS even when the second boundary is negative.
|
|
56
|
+
return BigInt(millis) * MICROS_PER_MILLI + BigInt((fraction ?? '').padEnd(FRACTION_DIGITS, '0'));
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The microsecond epoch of whatever a sort key is holding: a decoded row's `Date` (milliseconds,
|
|
61
|
+
* so the last three digits are zero), a value already counted in microseconds, or the decimal a
|
|
62
|
+
* cursor carries. `undefined` for anything else, so a caller decides rather than guessing at `0`.
|
|
63
|
+
*/
|
|
64
|
+
export const instantMicros = (value: unknown): bigint | undefined => {
|
|
65
|
+
if (typeof value === 'bigint') return value;
|
|
66
|
+
if (value instanceof Date) {
|
|
67
|
+
const millis = value.getTime();
|
|
68
|
+
return Number.isNaN(millis) ? undefined : BigInt(millis) * MICROS_PER_MILLI;
|
|
69
|
+
}
|
|
70
|
+
if (typeof value === 'string' && /^-?\d+$/.test(value)) return BigInt(value);
|
|
71
|
+
return undefined;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The instant a seek binds, spelled so Postgres parses it back to the same microsecond. ISO 8601
|
|
76
|
+
* in UTC with all six fraction digits — `toISOString()` alone is milliseconds, which is the whole
|
|
77
|
+
* defect this file exists to close, so the fraction is written here rather than read off the
|
|
78
|
+
* `Date`.
|
|
79
|
+
*/
|
|
80
|
+
export const microsToIso = (micros: bigint): string => {
|
|
81
|
+
const second = floorDiv(micros, MICROS_PER_SECOND);
|
|
82
|
+
const fraction = micros - second * MICROS_PER_SECOND;
|
|
83
|
+
const whole = new Date(Number(second) * 1000).toISOString();
|
|
84
|
+
return `${whole.slice(0, whole.indexOf('.'))}.${String(fraction).padStart(FRACTION_DIGITS, '0')}Z`;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The output name the microsecond half of a sort key comes back under. Every physical column name
|
|
89
|
+
* in this framework is lower case — `columnName` is either `snake(property)`, which lower-cases,
|
|
90
|
+
* or a `.column()` name `assertColumnName` refuses unless it matches `[a-z_][a-z0-9_$]*` — so an
|
|
91
|
+
* UPPER-CASE suffix is a name no entity can declare and this alias can never shadow a column.
|
|
92
|
+
*/
|
|
93
|
+
export const seekAlias = (physicalColumn: string): string => `${physicalColumn}$US`;
|
package/src/memory-match.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { compareDecimalText } from '@ultimat3/core';
|
|
8
8
|
import { keyOf } from './batch-read';
|
|
9
|
+
import { arrayContains, arrayOverlaps, jsonContains, jsonHasKey } from './containment';
|
|
9
10
|
import { kindOf, valueAt } from './cursor';
|
|
10
11
|
import type { EntityCore } from './entity';
|
|
11
12
|
import { EntityError } from './errors';
|
|
13
|
+
import { instantMicros } from './instant';
|
|
12
14
|
import type { Predicate } from './tenancy';
|
|
13
15
|
import type { ColumnKind } from './types';
|
|
14
16
|
|
|
@@ -46,6 +48,25 @@ export const compareByKind = (
|
|
|
46
48
|
left: unknown,
|
|
47
49
|
right: unknown,
|
|
48
50
|
): number => {
|
|
51
|
+
// NULL is the LARGEST value, which is what `order by` means in Postgres and what `nulls last`
|
|
52
|
+
// ascending / `nulls first` descending spell out (`pg-sql.ts`'s `orderSql`, `@ultimat3/query`'s
|
|
53
|
+
// `compareValues`). Two absences are equal, so the next sort key decides — without this rule a
|
|
54
|
+
// NULL fell through to `String(left) < String(right)` and sorted as the four characters `null`,
|
|
55
|
+
// somewhere in the middle of the alphabet, which is a different listing from the one the
|
|
56
|
+
// database returns and a page boundary cut where the server cuts none.
|
|
57
|
+
if (isNull(left) || isNull(right)) {
|
|
58
|
+
return isNull(left) && isNull(right) ? 0 : isNull(left) ? 1 : -1;
|
|
59
|
+
}
|
|
60
|
+
// A `timestamptz` is compared in MICROSECONDS, which is what the column holds and what a cursor
|
|
61
|
+
// now carries (`cursor.ts`). The two sides are not the same shape and that is the point: a
|
|
62
|
+
// stored row here is a `Date` and a keyset position is a microsecond count, so a `Date`/`Date`
|
|
63
|
+
// test alone would fall through to `String(left) < String(right)` and order a page by the text
|
|
64
|
+
// of an ISO string against a decimal.
|
|
65
|
+
if (kind === 'timestamptz') {
|
|
66
|
+
const before = instantMicros(left);
|
|
67
|
+
const after = instantMicros(right);
|
|
68
|
+
if (before !== undefined && after !== undefined) return sign(before, after);
|
|
69
|
+
}
|
|
49
70
|
if (left instanceof Date && right instanceof Date) return sign(left.getTime(), right.getTime());
|
|
50
71
|
if (kind !== undefined && DECIMAL_TEXT.has(kind)) {
|
|
51
72
|
// `undefined` when either side is not a plain decimal — that pair is not a numeric comparison,
|
|
@@ -199,5 +220,51 @@ export const matchesPredicate = <Row>(
|
|
|
199
220
|
return isNull(actual);
|
|
200
221
|
case 'is-not-null':
|
|
201
222
|
return !isNull(actual);
|
|
223
|
+
// The containment half, decided by the column's DECLARED kind exactly as everything above it
|
|
224
|
+
// is: `@>` on a `jsonb` is recursive structural containment and `@>` on an array is plain
|
|
225
|
+
// element containment, and those are two different operators that happen to share a symbol.
|
|
226
|
+
// A NULL column value matches nothing, which is what the SQL answers too.
|
|
227
|
+
case 'contains':
|
|
228
|
+
return !isNull(actual) && containsBy(kind, actual, predicate.value);
|
|
229
|
+
case 'contained-by':
|
|
230
|
+
return !isNull(actual) && containsBy(kind, predicate.value, actual);
|
|
231
|
+
// `&&` is arrays only. A `jsonb` column reaching it is refused rather than guessed at: there
|
|
232
|
+
// is no `jsonb && jsonb` in Postgres, so any answer here would be one no statement can make.
|
|
233
|
+
case 'overlaps':
|
|
234
|
+
return (
|
|
235
|
+
!isNull(actual) &&
|
|
236
|
+
arrayOverlaps(
|
|
237
|
+
asArray(entity, predicate, actual),
|
|
238
|
+
asArray(entity, predicate, predicate.value),
|
|
239
|
+
)
|
|
240
|
+
);
|
|
241
|
+
case 'has-key':
|
|
242
|
+
return jsonHasKey(actual, predicate.value);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** `left @> right`, under the rule the LEFT column's kind decides. */
|
|
247
|
+
const containsBy = (kind: ColumnKind | undefined, left: unknown, right: unknown): boolean =>
|
|
248
|
+
kind === 'jsonb'
|
|
249
|
+
? jsonContains(left, right)
|
|
250
|
+
: arrayContains(Array.isArray(left) ? left : [left], Array.isArray(right) ? right : [right]);
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The operand of an array-only operator. A `jsonb` column here is the caller asking for an
|
|
254
|
+
* operator Postgres does not have on that type, so it is refused where they wrote it rather than
|
|
255
|
+
* answered with something the database never would.
|
|
256
|
+
*/
|
|
257
|
+
const asArray = <Row>(
|
|
258
|
+
entity: EntityCore<Row>,
|
|
259
|
+
predicate: Predicate,
|
|
260
|
+
value: unknown,
|
|
261
|
+
): readonly unknown[] => {
|
|
262
|
+
if (kindOf(entity, predicate.column) === 'jsonb') {
|
|
263
|
+
throw new EntityError({
|
|
264
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
265
|
+
cause: `${entity.$name}.${predicate.column} is jsonb, and Postgres has no && (overlaps) operator for jsonb`,
|
|
266
|
+
fix: `${entity.$name}.andWhere('${predicate.column}', 'contains', <value>) # @> matches nested structure; && is for arrayOf() columns`,
|
|
267
|
+
});
|
|
202
268
|
}
|
|
269
|
+
return Array.isArray(value) ? value : [value];
|
|
203
270
|
};
|