@zmdb/orm 1.0.0-beta.1
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/LICENSE +674 -0
- package/README.md +30 -0
- package/dist/cache/index.d.ts +34 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +149 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/drivers/transactional.d.ts +6 -0
- package/dist/drivers/transactional.d.ts.map +1 -0
- package/dist/drivers/transactional.js +2 -0
- package/dist/drivers/transactional.js.map +1 -0
- package/dist/dto/index.d.ts +41 -0
- package/dist/dto/index.d.ts.map +1 -0
- package/dist/dto/index.js +334 -0
- package/dist/dto/index.js.map +1 -0
- package/dist/entity-modeling/index.d.ts +18 -0
- package/dist/entity-modeling/index.d.ts.map +1 -0
- package/dist/entity-modeling/index.js +26 -0
- package/dist/entity-modeling/index.js.map +1 -0
- package/dist/filters/index.d.ts +62 -0
- package/dist/filters/index.d.ts.map +1 -0
- package/dist/filters/index.js +163 -0
- package/dist/filters/index.js.map +1 -0
- package/dist/index.d.ts +413 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2003 -0
- package/dist/index.js.map +1 -0
- package/dist/loaders/index.d.ts +38 -0
- package/dist/loaders/index.d.ts.map +1 -0
- package/dist/loaders/index.js +164 -0
- package/dist/loaders/index.js.map +1 -0
- package/dist/outbox/index.d.ts +59 -0
- package/dist/outbox/index.d.ts.map +1 -0
- package/dist/outbox/index.js +323 -0
- package/dist/outbox/index.js.map +1 -0
- package/dist/outbox/sql.d.ts +52 -0
- package/dist/outbox/sql.d.ts.map +1 -0
- package/dist/outbox/sql.js +115 -0
- package/dist/outbox/sql.js.map +1 -0
- package/dist/relations/index.d.ts +38 -0
- package/dist/relations/index.d.ts.map +1 -0
- package/dist/relations/index.js +164 -0
- package/dist/relations/index.js.map +1 -0
- package/dist/replicas/index.d.ts +10 -0
- package/dist/replicas/index.d.ts.map +1 -0
- package/dist/replicas/index.js +42 -0
- package/dist/replicas/index.js.map +1 -0
- package/dist/seeding/index.d.ts +18 -0
- package/dist/seeding/index.d.ts.map +1 -0
- package/dist/seeding/index.js +66 -0
- package/dist/seeding/index.js.map +1 -0
- package/dist/streaming/index.d.ts +7 -0
- package/dist/streaming/index.d.ts.map +1 -0
- package/dist/streaming/index.js +67 -0
- package/dist/streaming/index.js.map +1 -0
- package/dist/testing/official-dialects.fixture.d.ts +16 -0
- package/dist/testing/official-dialects.fixture.d.ts.map +1 -0
- package/dist/testing/official-dialects.fixture.js +21 -0
- package/dist/testing/official-dialects.fixture.js.map +1 -0
- package/dist/testing/repository.fixture.d.ts +11 -0
- package/dist/testing/repository.fixture.d.ts.map +1 -0
- package/dist/testing/repository.fixture.js +33 -0
- package/dist/testing/repository.fixture.js.map +1 -0
- package/dist/transactions/index.d.ts +40 -0
- package/dist/transactions/index.d.ts.map +1 -0
- package/dist/transactions/index.js +249 -0
- package/dist/transactions/index.js.map +1 -0
- package/dist/typed-populate/nested.fixtures.d.ts +34 -0
- package/dist/typed-populate/nested.fixtures.d.ts.map +1 -0
- package/dist/typed-populate/nested.fixtures.js +4 -0
- package/dist/typed-populate/nested.fixtures.js.map +1 -0
- package/package.json +78 -0
- package/src/cache/index.ts +188 -0
- package/src/drivers/transactional.ts +6 -0
- package/src/dto/index.ts +379 -0
- package/src/entity-modeling/index.ts +38 -0
- package/src/filters/index.ts +267 -0
- package/src/index.ts +2757 -0
- package/src/loaders/index.ts +256 -0
- package/src/outbox/index.ts +409 -0
- package/src/outbox/sql.ts +179 -0
- package/src/relations/index.ts +221 -0
- package/src/replicas/index.ts +51 -0
- package/src/seeding/index.ts +72 -0
- package/src/streaming/index.ts +67 -0
- package/src/testing/repository.fixture.ts +44 -0
- package/src/transactions/index.ts +315 -0
- package/src/typed-populate/nested.fixtures.ts +59 -0
package/src/dto/index.ts
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { isRecord, type DeclaredTable } from '@zmdb/schema';
|
|
2
|
+
import type { WhereDTO, UnknownRow, OrderDir, OrderBySpec, PaginationSpec } from '@zmdb/schema/dto';
|
|
3
|
+
import { createQueryCompiler, type ComparisonPredicate, type SqlDialect } from '@zmdb/sql';
|
|
4
|
+
import { ValidationError } from '@zmdb/validator';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal structural view of the query-compiler SelectBuilder we drive.
|
|
8
|
+
*
|
|
9
|
+
* The methods return `this` (not `WhereTarget`), so folding a DTO into a builder
|
|
10
|
+
* preserves the caller's concrete builder type. That is what lets `compileWhere`
|
|
11
|
+
* return `B` without asserting: previously the chain widened to `WhereTarget` and
|
|
12
|
+
* every helper ended in `return b as B`.
|
|
13
|
+
*/
|
|
14
|
+
export interface WhereTarget {
|
|
15
|
+
where(col: string, op: string, value: unknown): this;
|
|
16
|
+
orWhere(col: string, op: string, value: unknown): this;
|
|
17
|
+
whereGroup?(predicates: readonly ComparisonPredicate[]): this;
|
|
18
|
+
orWhereGroup?(predicates: readonly ComparisonPredicate[]): this;
|
|
19
|
+
whereExists?(subquery: unknown): this;
|
|
20
|
+
orWhereExists?(subquery: unknown): this;
|
|
21
|
+
whereNotExists?(subquery: unknown): this;
|
|
22
|
+
orWhereNotExists?(subquery: unknown): this;
|
|
23
|
+
whereIn?(col: string, values: readonly unknown[]): this;
|
|
24
|
+
orWhereIn?(col: string, values: readonly unknown[]): this;
|
|
25
|
+
whereNotIn?(col: string, values: readonly unknown[]): this;
|
|
26
|
+
orWhereNotIn?(col: string, values: readonly unknown[]): this;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Record view of a value, or `undefined` if it is not a plain object.
|
|
31
|
+
*
|
|
32
|
+
* Taking `unknown` is deliberate: narrowing a *generic* DTO (`WhereDTO<T>`) in
|
|
33
|
+
* place leaves the mapped type, which has no string index signature, so keyed
|
|
34
|
+
* reads would need `as Record<string, unknown>`. Routing through `unknown` lets
|
|
35
|
+
* the guard do the widening instead of an assertion.
|
|
36
|
+
*/
|
|
37
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
38
|
+
return isRecord(value) ? value : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const OP_SQL: Record<string, string> = {
|
|
42
|
+
eq: '=',
|
|
43
|
+
ne: '!=',
|
|
44
|
+
lt: '<',
|
|
45
|
+
lte: '<=',
|
|
46
|
+
gt: '>',
|
|
47
|
+
gte: '>=',
|
|
48
|
+
in: 'in',
|
|
49
|
+
nin: 'not in',
|
|
50
|
+
like: 'like',
|
|
51
|
+
ilike: 'ilike',
|
|
52
|
+
l2: 'l2',
|
|
53
|
+
cosine: 'cosine',
|
|
54
|
+
ip: 'ip',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Every operator `applyField` accepts, for the error an unrecognised one raises.
|
|
58
|
+
// `isNull`/`notNull` are handled ahead of the map, so they are not keys of it.
|
|
59
|
+
const KNOWN_OPERATORS: readonly string[] = [...Object.keys(OP_SQL), 'isNull', 'notNull'];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A `{ table, select?, where? }` literal in a DTO, compiled into a subquery builder.
|
|
63
|
+
*
|
|
64
|
+
* boundary: the input is `unknown` because a DTO arrives from outside the process, and what
|
|
65
|
+
* establishes the shape is the `in` checks in the `if`, not the two casts. The first reads
|
|
66
|
+
* the one property those checks have just proven is a string; the second names the rest of
|
|
67
|
+
* the shape, and each optional field is tested again before it is used — `select` for a
|
|
68
|
+
* non-zero length, `where` for presence — so the only thing a wrong payload can produce is
|
|
69
|
+
* a narrower subquery, never a call with a value of the wrong kind in it.
|
|
70
|
+
*/
|
|
71
|
+
function resolveSubqueryTarget(target: unknown, dialect: SqlDialect | undefined): unknown {
|
|
72
|
+
if (
|
|
73
|
+
target !== null &&
|
|
74
|
+
typeof target === 'object' &&
|
|
75
|
+
!('compile' in target) &&
|
|
76
|
+
'table' in target &&
|
|
77
|
+
typeof (target as { table: unknown }).table === 'string'
|
|
78
|
+
) {
|
|
79
|
+
const spec = target as {
|
|
80
|
+
table: string;
|
|
81
|
+
select?: readonly string[];
|
|
82
|
+
where?: WhereDTO<UnknownRow>;
|
|
83
|
+
};
|
|
84
|
+
if (dialect === undefined) {
|
|
85
|
+
throw new ValidationError('compileWhere: a table subquery requires the builder dialect object');
|
|
86
|
+
}
|
|
87
|
+
// Both clauses, not either: `{ table, select, where }` means a projection *and* a
|
|
88
|
+
// filter, and a subquery that dropped the filter would match every row.
|
|
89
|
+
let sub = createQueryCompiler(dialect).selectFrom(spec.table);
|
|
90
|
+
if (spec.select && spec.select.length > 0) {
|
|
91
|
+
sub = sub.select(spec.select);
|
|
92
|
+
}
|
|
93
|
+
if (spec.where) {
|
|
94
|
+
sub = compileWhere(sub, spec.where);
|
|
95
|
+
}
|
|
96
|
+
return sub;
|
|
97
|
+
}
|
|
98
|
+
return target;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Fold a WhereDTO into a query-compiler builder. Bare values become `eq`.
|
|
103
|
+
* Fields/operators are applied in stable object-key order (golden SQL).
|
|
104
|
+
* `and`/`or` groups compose; `or` members are ORed.
|
|
105
|
+
*/
|
|
106
|
+
export function compileWhere<T extends DeclaredTable, B extends WhereTarget>(
|
|
107
|
+
builder: B,
|
|
108
|
+
where: WhereDTO<T> | undefined,
|
|
109
|
+
resolveColumn: (column: string) => string = column => column,
|
|
110
|
+
): B {
|
|
111
|
+
if (!where) return builder;
|
|
112
|
+
let b: B = builder;
|
|
113
|
+
// boundary: `WhereTarget` is the structural minimum this function calls — `where`, `and`,
|
|
114
|
+
// `or` — and deliberately does not require a `dialect`, so that a caller's own builder
|
|
115
|
+
// qualifies. Reading one off it is therefore a probe for an optional property rather than
|
|
116
|
+
// a claim about the type, and the `??` is what handles the builder that has none.
|
|
117
|
+
const dialect = (builder as { dialect?: SqlDialect }).dialect;
|
|
118
|
+
|
|
119
|
+
const applyField = (col: string, spec: unknown, connector: 'and' | 'or') => {
|
|
120
|
+
const resolvedColumn = resolveColumn(col);
|
|
121
|
+
const add = (op: string, rawVal: unknown) => {
|
|
122
|
+
const value = resolveSubqueryTarget(rawVal, dialect);
|
|
123
|
+
if (connector === 'or') {
|
|
124
|
+
b = b.orWhere(resolvedColumn, op, value);
|
|
125
|
+
} else {
|
|
126
|
+
b = b.where(resolvedColumn, op, value);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
if (
|
|
130
|
+
spec !== null &&
|
|
131
|
+
typeof spec === 'object' &&
|
|
132
|
+
!Array.isArray(spec) &&
|
|
133
|
+
!('compile' in spec) &&
|
|
134
|
+
!('table' in spec)
|
|
135
|
+
) {
|
|
136
|
+
const ops = asRecord(spec);
|
|
137
|
+
if (ops) {
|
|
138
|
+
if (Object.keys(ops).length === 0) {
|
|
139
|
+
// `FieldOps`' keys are all optional, so `{ age: {} }` is type-legal, and it is
|
|
140
|
+
// what building a filter conditionally produces: `{ age: min === undefined ? {}
|
|
141
|
+
// : { gte: min } }`. Folding it to nothing means the query looks filtered and is
|
|
142
|
+
// not — over-disclosure on a SELECT, the whole table on an UPDATE or DELETE. An
|
|
143
|
+
// empty operator map is not a filter, and "match everything" is the least likely
|
|
144
|
+
// thing the caller meant (#608).
|
|
145
|
+
throw new ValidationError(
|
|
146
|
+
`compileWhere: column "${col}" has an empty operator map, which would match every row`,
|
|
147
|
+
[{ path: col, message: 'empty operator map', expected: KNOWN_OPERATORS.join(' | ') }],
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
for (const [op, value] of Object.entries(ops)) {
|
|
151
|
+
if (op === 'isNull') {
|
|
152
|
+
if (value) add('is null', null);
|
|
153
|
+
else add('is not null', null);
|
|
154
|
+
} else if (op === 'notNull') {
|
|
155
|
+
add(value ? 'is not null' : 'is null', null);
|
|
156
|
+
} else if (op === 'in' && Array.isArray(value)) {
|
|
157
|
+
if (connector === 'or' && b.orWhereIn) b = b.orWhereIn(resolvedColumn, value);
|
|
158
|
+
else if (connector !== 'or' && b.whereIn) b = b.whereIn(resolvedColumn, value);
|
|
159
|
+
else add('in', value);
|
|
160
|
+
} else if (op === 'nin' && Array.isArray(value)) {
|
|
161
|
+
if (connector === 'or' && b.orWhereNotIn) b = b.orWhereNotIn(resolvedColumn, value);
|
|
162
|
+
else if (connector !== 'or' && b.whereNotIn) b = b.whereNotIn(resolvedColumn, value);
|
|
163
|
+
else add('not in', value);
|
|
164
|
+
} else {
|
|
165
|
+
// `Object.hasOwn`, not a truthy read: `OP_SQL` is an object literal, so an
|
|
166
|
+
// operator named `toString`, `constructor`, `valueOf` or `__proto__` resolves
|
|
167
|
+
// through `Object.prototype` and passes a truthiness check as a function or an
|
|
168
|
+
// object. A where-DTO is the path user JSON takes into the builder, so those
|
|
169
|
+
// keys arrive from outside the process (#364).
|
|
170
|
+
const sql = Object.hasOwn(OP_SQL, op) ? OP_SQL[op] : undefined;
|
|
171
|
+
if (sql === undefined) {
|
|
172
|
+
// Fail closed. Skipping the key emitted a statement with one predicate
|
|
173
|
+
// fewer than the caller wrote, which on an UPDATE or DELETE is the whole
|
|
174
|
+
// table.
|
|
175
|
+
throw new ValidationError(`compileWhere: unknown operator "${op}" on column "${col}"`, [
|
|
176
|
+
{
|
|
177
|
+
path: col,
|
|
178
|
+
message: `unknown operator "${op}"`,
|
|
179
|
+
expected: KNOWN_OPERATORS.join(' | '),
|
|
180
|
+
value,
|
|
181
|
+
},
|
|
182
|
+
]);
|
|
183
|
+
}
|
|
184
|
+
add(sql, value);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
// bare value or direct subquery spec ⇒ eq
|
|
190
|
+
add('=', spec);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const applyExists = (spec: unknown, isNot: boolean, connector: 'and' | 'or') => {
|
|
195
|
+
const items = Array.isArray(spec) ? spec : [spec];
|
|
196
|
+
for (const item of items) {
|
|
197
|
+
const resolved = resolveSubqueryTarget(item, dialect);
|
|
198
|
+
if (connector === 'or') {
|
|
199
|
+
if (isNot) {
|
|
200
|
+
if (!b.orWhereNotExists) {
|
|
201
|
+
throw new Error('Builder does not support orWhereNotExists');
|
|
202
|
+
}
|
|
203
|
+
b = b.orWhereNotExists(resolved);
|
|
204
|
+
} else {
|
|
205
|
+
if (!b.orWhereExists) {
|
|
206
|
+
throw new Error('Builder does not support orWhereExists');
|
|
207
|
+
}
|
|
208
|
+
b = b.orWhereExists(resolved);
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
if (isNot) {
|
|
212
|
+
if (!b.whereNotExists) {
|
|
213
|
+
throw new Error('Builder does not support whereNotExists');
|
|
214
|
+
}
|
|
215
|
+
b = b.whereNotExists(resolved);
|
|
216
|
+
} else {
|
|
217
|
+
if (!b.whereExists) {
|
|
218
|
+
throw new Error('Builder does not support whereExists');
|
|
219
|
+
}
|
|
220
|
+
b = b.whereExists(resolved);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const { and, or } = where;
|
|
227
|
+
const fields = asRecord(where);
|
|
228
|
+
if (!fields) return b;
|
|
229
|
+
for (const key of Object.keys(fields)) {
|
|
230
|
+
if (key === 'and') {
|
|
231
|
+
if (and) for (const sub of and) b = compileWhere(b, sub, resolveColumn);
|
|
232
|
+
} else if (key === 'or') {
|
|
233
|
+
for (const sub of or ?? []) {
|
|
234
|
+
const group = asRecord(sub);
|
|
235
|
+
if (group) {
|
|
236
|
+
for (const [col, spec] of Object.entries(group)) {
|
|
237
|
+
if (col === 'exists') {
|
|
238
|
+
applyExists(spec, false, 'or');
|
|
239
|
+
} else if (col === 'notExists') {
|
|
240
|
+
applyExists(spec, true, 'or');
|
|
241
|
+
} else {
|
|
242
|
+
applyField(col, spec, 'or');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} else if (key === 'exists') {
|
|
248
|
+
applyExists(fields[key], false, 'and');
|
|
249
|
+
} else if (key === 'notExists') {
|
|
250
|
+
applyExists(fields[key], true, 'and');
|
|
251
|
+
} else {
|
|
252
|
+
applyField(key, fields[key], 'and');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return b;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Like {@link WhereTarget}: `this`-returning so folding preserves the builder type. */
|
|
259
|
+
export interface OrderTarget {
|
|
260
|
+
orderBy(col: string, dir: OrderDir): this;
|
|
261
|
+
limit(n: number): this;
|
|
262
|
+
offset(n: number): this;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function applyOrderBy<B extends OrderTarget>(
|
|
266
|
+
builder: B,
|
|
267
|
+
order: OrderBySpec | undefined,
|
|
268
|
+
pkColumn?: string,
|
|
269
|
+
resolveColumn: (column: string) => string = column => column,
|
|
270
|
+
): B {
|
|
271
|
+
if (!order && !pkColumn) return builder;
|
|
272
|
+
let b = builder;
|
|
273
|
+
const cols: { column: PropertyKey; dir?: OrderDir }[] = order ? [...order] : [];
|
|
274
|
+
if (pkColumn && !cols.some(item => String(item.column) === pkColumn)) {
|
|
275
|
+
cols.push({ column: pkColumn, dir: 'asc' });
|
|
276
|
+
}
|
|
277
|
+
if (cols.length === 0) return builder;
|
|
278
|
+
for (const { column, dir } of cols) b = b.orderBy(resolveColumn(String(column)), dir ?? 'asc');
|
|
279
|
+
return b;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
class BranchTarget implements WhereTarget {
|
|
283
|
+
private b: WhereTarget;
|
|
284
|
+
private firstCallInBranch: boolean;
|
|
285
|
+
|
|
286
|
+
constructor(b: WhereTarget, isFirstBranch: boolean) {
|
|
287
|
+
this.b = b;
|
|
288
|
+
this.firstCallInBranch = !isFirstBranch;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
where(col: string, op: string, value: unknown): this {
|
|
292
|
+
if (this.firstCallInBranch) {
|
|
293
|
+
this.firstCallInBranch = false;
|
|
294
|
+
this.b = this.b.orWhere(col, op, value);
|
|
295
|
+
} else {
|
|
296
|
+
this.b = this.b.where(col, op, value);
|
|
297
|
+
}
|
|
298
|
+
return this;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// A keyset branch is a conjunction that is OR'd onto the branches before it,
|
|
302
|
+
// so the branch spends its OR on the first predicate and conjoins the rest.
|
|
303
|
+
// Repository filters use `whereGroup` below to preserve their own OR boundary;
|
|
304
|
+
// compileWhere's user-authored `or` tree is still flat and remains a separate
|
|
305
|
+
// predicate-tree problem.
|
|
306
|
+
orWhere(col: string, op: string, value: unknown): this {
|
|
307
|
+
return this.where(col, op, value);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
whereGroup(predicates: readonly ComparisonPredicate[]): this {
|
|
311
|
+
const method = this.firstCallInBranch ? this.b.orWhereGroup : this.b.whereGroup;
|
|
312
|
+
if (method === undefined) throw new Error('keyset filters require predicate-group support');
|
|
313
|
+
this.firstCallInBranch = false;
|
|
314
|
+
this.b = method.call(this.b, predicates);
|
|
315
|
+
return this;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
getBuilder(): WhereTarget {
|
|
319
|
+
return this.b;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function applyKeysetFilter<B extends WhereTarget>(
|
|
324
|
+
builder: B,
|
|
325
|
+
cursorValues: Record<string, unknown>,
|
|
326
|
+
orderBy: OrderBySpec,
|
|
327
|
+
userWhere?: WhereDTO<UnknownRow>,
|
|
328
|
+
additionalWhere?: (builder: WhereTarget) => void,
|
|
329
|
+
resolveColumn: (column: string) => string = column => column,
|
|
330
|
+
): B {
|
|
331
|
+
if (orderBy.length === 0) return builder;
|
|
332
|
+
|
|
333
|
+
for (const item of orderBy) {
|
|
334
|
+
if (!item) continue;
|
|
335
|
+
const colStr = String(item.column);
|
|
336
|
+
if (cursorValues[colStr] === undefined) {
|
|
337
|
+
throw new Error(`Invalid cursor: missing value for column "${colStr}"`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let currentBuilder: WhereTarget = builder;
|
|
342
|
+
const k = orderBy.length;
|
|
343
|
+
|
|
344
|
+
for (let i = 0; i < k; i++) {
|
|
345
|
+
const itemI = orderBy[i];
|
|
346
|
+
if (!itemI) continue;
|
|
347
|
+
|
|
348
|
+
const target = new BranchTarget(currentBuilder, i === 0);
|
|
349
|
+
|
|
350
|
+
if (userWhere) {
|
|
351
|
+
compileWhere(target, userWhere, resolveColumn);
|
|
352
|
+
}
|
|
353
|
+
additionalWhere?.(target);
|
|
354
|
+
|
|
355
|
+
for (let j = 0; j < i; j++) {
|
|
356
|
+
const itemJ = orderBy[j];
|
|
357
|
+
if (!itemJ) continue;
|
|
358
|
+
const col = String(itemJ.column);
|
|
359
|
+
target.where(resolveColumn(col), '=', cursorValues[col]);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const curCol = String(itemI.column);
|
|
363
|
+
const dir = itemI.dir ?? 'asc';
|
|
364
|
+
const op = dir === 'desc' ? '<' : '>';
|
|
365
|
+
target.where(resolveColumn(curCol), op, cursorValues[curCol]);
|
|
366
|
+
|
|
367
|
+
currentBuilder = target.getBuilder();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// boundary: BranchTarget wraps B (implementing WhereTarget); getBuilder() returns the mutated query builder B.
|
|
371
|
+
return currentBuilder as B;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function applyPagination<B extends OrderTarget>(builder: B, page: PaginationSpec | undefined): B {
|
|
375
|
+
if (!page) return builder;
|
|
376
|
+
let b = builder.limit(page.limit);
|
|
377
|
+
if (typeof page.offset === 'number') b = b.offset(page.offset);
|
|
378
|
+
return b;
|
|
379
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Entity modeling: lifecycle events — see ./SPEC.md.
|
|
2
|
+
|
|
3
|
+
// §1 lifecycle events
|
|
4
|
+
export type LifecycleEvent =
|
|
5
|
+
| 'beforeCreate'
|
|
6
|
+
| 'afterCreate'
|
|
7
|
+
| 'beforeUpdate'
|
|
8
|
+
| 'afterUpdate'
|
|
9
|
+
| 'beforeDelete'
|
|
10
|
+
| 'afterDelete';
|
|
11
|
+
|
|
12
|
+
export interface Subscriber {
|
|
13
|
+
on: LifecycleEvent;
|
|
14
|
+
run: (ctx: unknown) => void | Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Sequential entity-lifecycle subscribers for repository write hooks.
|
|
19
|
+
*
|
|
20
|
+
* A failure intentionally stops the remaining subscribers and rejects the
|
|
21
|
+
* write, so this is not the application-event emitter. Use `createEvents` from
|
|
22
|
+
* `@zmdb/app/events` when handlers must run concurrently with isolated errors.
|
|
23
|
+
*/
|
|
24
|
+
export class EventBus {
|
|
25
|
+
private subs: Subscriber[] = [];
|
|
26
|
+
subscribe(s: Subscriber): () => void {
|
|
27
|
+
this.subs.push(s);
|
|
28
|
+
return () => {
|
|
29
|
+
const i = this.subs.indexOf(s);
|
|
30
|
+
if (i >= 0) this.subs.splice(i, 1);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async emit(event: LifecycleEvent, ctx: unknown): Promise<void> {
|
|
34
|
+
for (const s of this.subs) {
|
|
35
|
+
if (s.on === event) await s.run(ctx);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { isRecord, type CoreSchema } from '@zmdb/schema';
|
|
2
|
+
import { appTypeOf, type ColumnIR } from '@zmdb/schema/ir';
|
|
3
|
+
import { type Predicate } from '@zmdb/sql';
|
|
4
|
+
import { issuesFor, ValidationError } from '@zmdb/validator';
|
|
5
|
+
|
|
6
|
+
/** One compiler predicate contributed by a named repository filter. */
|
|
7
|
+
export interface FilterPredicate {
|
|
8
|
+
readonly col: string;
|
|
9
|
+
readonly op: string;
|
|
10
|
+
readonly value: unknown;
|
|
11
|
+
readonly connector?: 'AND' | 'OR';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A named predicate applied to repository reads unless it is disabled for one call.
|
|
16
|
+
*
|
|
17
|
+
* `table` is optional for the repository's own table and explicit for a join or
|
|
18
|
+
* populate target. That keeps target filters scoped to this repository instance;
|
|
19
|
+
* there is no process-global registry.
|
|
20
|
+
*/
|
|
21
|
+
export interface FilterDef<P = void> {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly table?: string;
|
|
24
|
+
/** Required for a target table unless the read supplies that table's schema directly. */
|
|
25
|
+
readonly schema?: CoreSchema<string>;
|
|
26
|
+
readonly where: {
|
|
27
|
+
bivarianceHack(params: P): readonly FilterPredicate[];
|
|
28
|
+
}['bivarianceHack'];
|
|
29
|
+
readonly enabled?: boolean;
|
|
30
|
+
readonly appliesToWrites?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type FilterParams<F> = F extends { readonly where: (params: infer P) => readonly FilterPredicate[] } ? P : never;
|
|
34
|
+
|
|
35
|
+
export type FilterOverride<F> = [FilterParams<F>] extends [never]
|
|
36
|
+
? unknown | false
|
|
37
|
+
: [FilterParams<F>] extends [void]
|
|
38
|
+
? false
|
|
39
|
+
: FilterParams<F> | false;
|
|
40
|
+
|
|
41
|
+
export type FilterOverrides<Defs extends readonly FilterDef<unknown>[]> = {
|
|
42
|
+
readonly [Def in Defs[number] as Def['name']]?: FilterOverride<Def>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface ResolvedFilters {
|
|
46
|
+
readonly names: readonly string[];
|
|
47
|
+
readonly predicates: readonly FilterPredicate[];
|
|
48
|
+
readonly groups: readonly {
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly predicates: readonly FilterPredicate[];
|
|
51
|
+
}[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ResolveFiltersOptions {
|
|
55
|
+
readonly method: string;
|
|
56
|
+
readonly table: string;
|
|
57
|
+
readonly columnPrefix?: string;
|
|
58
|
+
readonly schema?: CoreSchema<string>;
|
|
59
|
+
readonly qualifyColumns?: boolean;
|
|
60
|
+
readonly knownNames?: readonly string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FilterTarget {
|
|
64
|
+
where(col: string, op: string, value: unknown): this;
|
|
65
|
+
orWhere?(col: string, op: string, value: unknown): this;
|
|
66
|
+
whereGroup?(predicates: readonly FilterPredicate[]): this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function missingParameterError(filter: FilterDef<unknown>, method: string, names: readonly string[]): ValidationError {
|
|
70
|
+
const required = names.length === 0 ? ['parameters'] : names;
|
|
71
|
+
const rendered = required.join(', ');
|
|
72
|
+
return new ValidationError(
|
|
73
|
+
`filter \`${filter.name}\` requires parameters (${rendered}) and none were supplied; pass them per call — ` +
|
|
74
|
+
`${method}({ filters: { ${filter.name}: { ${rendered} } } }) — or disable it by name`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function trackedObject(value: Record<string, unknown>, accessed: Set<string>): Record<string, unknown> {
|
|
79
|
+
return new Proxy(value, {
|
|
80
|
+
get(target, property, receiver) {
|
|
81
|
+
if (typeof property === 'string') accessed.add(property);
|
|
82
|
+
return Reflect.get(target, property, receiver);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parametersFor(
|
|
88
|
+
filter: FilterDef<unknown>,
|
|
89
|
+
override: unknown,
|
|
90
|
+
supplied: boolean,
|
|
91
|
+
method: string,
|
|
92
|
+
): { readonly predicates: readonly FilterPredicate[]; readonly accessed: readonly string[] } {
|
|
93
|
+
const accessed = new Set<string>();
|
|
94
|
+
const rawParameters =
|
|
95
|
+
supplied && override !== null && override !== undefined ? override : trackedObject(Object.create(null), accessed);
|
|
96
|
+
const parameters = isRecord(rawParameters) ? trackedObject(rawParameters, accessed) : rawParameters;
|
|
97
|
+
|
|
98
|
+
let predicates: readonly FilterPredicate[];
|
|
99
|
+
try {
|
|
100
|
+
predicates = filter.where(parameters);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (
|
|
103
|
+
accessed.size > 0 &&
|
|
104
|
+
(!supplied || [...accessed].some(name => !isRecord(rawParameters) || rawParameters[name] == null))
|
|
105
|
+
) {
|
|
106
|
+
throw missingParameterError(filter, method, [...accessed]);
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const missing = [...accessed].filter(name => !isRecord(rawParameters) || rawParameters[name] == null);
|
|
112
|
+
if (!supplied && accessed.size > 0) throw missingParameterError(filter, method, [...accessed]);
|
|
113
|
+
if (missing.length > 0) throw missingParameterError(filter, method, missing);
|
|
114
|
+
return { predicates, accessed: [...accessed] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validatePredicate(
|
|
118
|
+
filter: FilterDef<unknown>,
|
|
119
|
+
predicate: FilterPredicate,
|
|
120
|
+
schema: CoreSchema<string> | undefined,
|
|
121
|
+
accessed: readonly string[],
|
|
122
|
+
): ColumnIR {
|
|
123
|
+
if (!isRecord(predicate) || typeof predicate.col !== 'string' || typeof predicate.op !== 'string') {
|
|
124
|
+
throw new ValidationError(`filter \`${filter.name}\` returned an invalid predicate`);
|
|
125
|
+
}
|
|
126
|
+
if (predicate.connector !== undefined && predicate.connector !== 'AND' && predicate.connector !== 'OR') {
|
|
127
|
+
throw new ValidationError(`filter \`${filter.name}\` returned an invalid connector`);
|
|
128
|
+
}
|
|
129
|
+
const validationSchema = filter.schema ?? schema;
|
|
130
|
+
if (validationSchema === undefined) {
|
|
131
|
+
throw new ValidationError(
|
|
132
|
+
`filter \`${filter.name}\` targets \`${filter.table}\` without a schema; provide FilterDef.schema so its columns and parameters can be validated`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const columnName = predicate.col.slice(predicate.col.lastIndexOf('.') + 1);
|
|
137
|
+
const column = validationSchema.ir.columns.find(candidate => candidate.name === columnName);
|
|
138
|
+
if (column === undefined) {
|
|
139
|
+
throw new ValidationError(
|
|
140
|
+
`filter \`${filter.name}\` names column \`${predicate.col}\`, which is not declared by \`${validationSchema.ir.table}\``,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const operator = predicate.op.toLowerCase().trim();
|
|
145
|
+
if (operator === 'is null' || operator === 'is not null') return column;
|
|
146
|
+
if (
|
|
147
|
+
predicate.value !== null &&
|
|
148
|
+
typeof predicate.value === 'object' &&
|
|
149
|
+
'compile' in predicate.value &&
|
|
150
|
+
typeof predicate.value.compile === 'function'
|
|
151
|
+
) {
|
|
152
|
+
return column;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const path = `filters.${filter.name}.${accessed[0] ?? columnName}`;
|
|
156
|
+
const values =
|
|
157
|
+
(operator === 'in' || operator === 'not in' || operator === 'nin') && Array.isArray(predicate.value)
|
|
158
|
+
? predicate.value
|
|
159
|
+
: [predicate.value];
|
|
160
|
+
const issues = values.flatMap((value, index) =>
|
|
161
|
+
issuesFor(value, appTypeOf(column), values.length === 1 ? path : `${path}.${index}`),
|
|
162
|
+
);
|
|
163
|
+
if (issues.length > 0) {
|
|
164
|
+
throw new ValidationError(`validation failed: ${issues.map(issue => issue.path).join(', ')}`, issues);
|
|
165
|
+
}
|
|
166
|
+
return column;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Resolve disables and parameters before a builder is allowed to compile. */
|
|
170
|
+
export function resolveFilters(
|
|
171
|
+
definitions: readonly FilterDef<unknown>[],
|
|
172
|
+
overrides: unknown,
|
|
173
|
+
options: ResolveFiltersOptions,
|
|
174
|
+
): ResolvedFilters {
|
|
175
|
+
const values = overrides === undefined ? undefined : isRecord(overrides) ? overrides : undefined;
|
|
176
|
+
if (overrides !== undefined && values === undefined) {
|
|
177
|
+
throw new ValidationError('filters must be an object keyed by declared filter name');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const knownNames = new Set(options.knownNames ?? definitions.map(filter => filter.name));
|
|
181
|
+
for (const name of Object.keys(values ?? {})) {
|
|
182
|
+
if (!knownNames.has(name)) {
|
|
183
|
+
const declared = [...knownNames].toSorted();
|
|
184
|
+
throw new ValidationError(
|
|
185
|
+
`unknown filter \`${name}\`; declared filters: ${declared.length === 0 ? '(none)' : declared.join(', ')}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const names: string[] = [];
|
|
191
|
+
const predicates: FilterPredicate[] = [];
|
|
192
|
+
const groups: { readonly name: string; readonly predicates: readonly FilterPredicate[] }[] = [];
|
|
193
|
+
for (const filter of definitions) {
|
|
194
|
+
const supplied = values !== undefined && Object.hasOwn(values, filter.name);
|
|
195
|
+
const override = supplied ? values[filter.name] : undefined;
|
|
196
|
+
if (override === false || (!supplied && filter.enabled === false)) continue;
|
|
197
|
+
|
|
198
|
+
const resolved = parametersFor(filter, override, supplied, options.method);
|
|
199
|
+
if (!Array.isArray(resolved.predicates) || resolved.predicates.length === 0) {
|
|
200
|
+
throw new ValidationError(`filter \`${filter.name}\` returned no predicates`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
names.push(filter.name);
|
|
204
|
+
const group: FilterPredicate[] = [];
|
|
205
|
+
for (let index = 0; index < resolved.predicates.length; index++) {
|
|
206
|
+
const predicate = resolved.predicates[index];
|
|
207
|
+
if (predicate === undefined) throw new ValidationError(`filter \`${filter.name}\` returned an empty predicate`);
|
|
208
|
+
const column = validatePredicate(filter, predicate, options.schema, resolved.accessed);
|
|
209
|
+
const separator = predicate.col.lastIndexOf('.');
|
|
210
|
+
const physicalColumn =
|
|
211
|
+
separator === -1 ? column.physicalName : `${predicate.col.slice(0, separator + 1)}${column.physicalName}`;
|
|
212
|
+
const col =
|
|
213
|
+
options.qualifyColumns === true && separator === -1
|
|
214
|
+
? `${options.columnPrefix ?? options.table}.${physicalColumn}`
|
|
215
|
+
: physicalColumn;
|
|
216
|
+
const qualified = {
|
|
217
|
+
...predicate,
|
|
218
|
+
col,
|
|
219
|
+
connector: index === 0 ? 'AND' : (predicate.connector ?? 'AND'),
|
|
220
|
+
} satisfies FilterPredicate;
|
|
221
|
+
predicates.push(qualified);
|
|
222
|
+
group.push(qualified);
|
|
223
|
+
}
|
|
224
|
+
groups.push({ name: filter.name, predicates: Object.freeze(group) });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
names: Object.freeze([...new Set(names)]),
|
|
229
|
+
predicates: Object.freeze(predicates),
|
|
230
|
+
groups: Object.freeze(groups),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function needsGrouping(predicates: readonly FilterPredicate[]): boolean {
|
|
235
|
+
return predicates.some((predicate, index) => index > 0 && predicate.connector === 'OR');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Preserve each filter's boolean boundary when it is placed in WHERE or JOIN ON. */
|
|
239
|
+
export function filtersAsPredicates(resolved: ResolvedFilters): readonly Predicate[] {
|
|
240
|
+
const predicates: Predicate[] = [];
|
|
241
|
+
for (const group of resolved.groups) {
|
|
242
|
+
if (needsGrouping(group.predicates)) {
|
|
243
|
+
predicates.push({ kind: 'group', predicates: group.predicates, connector: 'AND' });
|
|
244
|
+
} else {
|
|
245
|
+
predicates.push(...group.predicates);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return Object.freeze(predicates);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Conjoin an already-resolved set with the predicates a read builder already carries. */
|
|
252
|
+
export function applyResolvedFilters<B extends FilterTarget>(builder: B, resolved: ResolvedFilters): B {
|
|
253
|
+
let filtered = builder;
|
|
254
|
+
for (const group of resolved.groups) {
|
|
255
|
+
if (needsGrouping(group.predicates)) {
|
|
256
|
+
if (filtered.whereGroup === undefined) {
|
|
257
|
+
throw new ValidationError('this statement builder cannot represent a grouped filter predicate');
|
|
258
|
+
}
|
|
259
|
+
filtered = filtered.whereGroup(group.predicates);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
for (const predicate of group.predicates) {
|
|
263
|
+
filtered = filtered.where(predicate.col, predicate.op, predicate.value);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return filtered;
|
|
267
|
+
}
|