@zmdb/schema 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/custom-types/index.d.ts +41 -0
- package/dist/custom-types/index.d.ts.map +1 -0
- package/dist/custom-types/index.js +32 -0
- package/dist/custom-types/index.js.map +1 -0
- package/dist/derive/index.d.ts +122 -0
- package/dist/derive/index.d.ts.map +1 -0
- package/dist/derive/index.js +13 -0
- package/dist/derive/index.js.map +1 -0
- package/dist/derive/query.d.ts +62 -0
- package/dist/derive/query.d.ts.map +1 -0
- package/dist/derive/query.js +18 -0
- package/dist/derive/query.js.map +1 -0
- package/dist/dto/index.d.ts +224 -0
- package/dist/dto/index.d.ts.map +1 -0
- package/dist/dto/index.js +118 -0
- package/dist/dto/index.js.map +1 -0
- package/dist/entity-modeling/index.d.ts +12 -0
- package/dist/entity-modeling/index.d.ts.map +1 -0
- package/dist/entity-modeling/index.js +28 -0
- package/dist/entity-modeling/index.js.map +1 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +84 -0
- package/dist/index.js.map +1 -0
- package/dist/ir/index.d.ts +374 -0
- package/dist/ir/index.d.ts.map +1 -0
- package/dist/ir/index.js +735 -0
- package/dist/ir/index.js.map +1 -0
- package/dist/ir/validation-shape.d.ts +46 -0
- package/dist/ir/validation-shape.d.ts.map +1 -0
- package/dist/ir/validation-shape.js +130 -0
- package/dist/ir/validation-shape.js.map +1 -0
- package/dist/ir/vocabulary.d.ts +54 -0
- package/dist/ir/vocabulary.d.ts.map +1 -0
- package/dist/ir/vocabulary.js +51 -0
- package/dist/ir/vocabulary.js.map +1 -0
- package/dist/naming/index.d.ts +26 -0
- package/dist/naming/index.d.ts.map +1 -0
- package/dist/naming/index.js +147 -0
- package/dist/naming/index.js.map +1 -0
- package/dist/openapi/index.d.ts +57 -0
- package/dist/openapi/index.d.ts.map +1 -0
- package/dist/openapi/index.js +98 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/relations/index.d.ts +23 -0
- package/dist/relations/index.d.ts.map +1 -0
- package/dist/relations/index.js +98 -0
- package/dist/relations/index.js.map +1 -0
- package/dist/tags/index.d.ts +261 -0
- package/dist/tags/index.d.ts.map +1 -0
- package/dist/tags/index.js +64 -0
- package/dist/tags/index.js.map +1 -0
- package/package.json +82 -0
- package/src/custom-types/index.ts +59 -0
- package/src/derive/index.ts +224 -0
- package/src/derive/query.ts +128 -0
- package/src/dto/index.ts +395 -0
- package/src/entity-modeling/index.ts +33 -0
- package/src/index.ts +263 -0
- package/src/ir/index.ts +1085 -0
- package/src/ir/validation-shape.ts +145 -0
- package/src/ir/vocabulary.ts +56 -0
- package/src/naming/index.ts +159 -0
- package/src/openapi/index.ts +133 -0
- package/src/relations/index.ts +134 -0
- package/src/tags/index.ts +284 -0
package/src/dto/index.ts
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { type DeclaredTable, type RelationKeys } from '../derive/index.js';
|
|
2
|
+
import { type Entity } from '../index.js';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// WhereDTO + operator set
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
/**
|
|
8
|
+
* A row of a table the caller named with a string.
|
|
9
|
+
*
|
|
10
|
+
* A subquery target is `{ table: 'orders' }` — a table *name*, not a declared type — so there
|
|
11
|
+
* is nothing for its filter to be keyed by. This says exactly that much and no more: every
|
|
12
|
+
* property is a column of some SQL type, and none of them is a relation, which is what keeps
|
|
13
|
+
* `WhereDTO` willing to derive from it. It is a named type rather than an inline
|
|
14
|
+
* `Record<string, unknown>` so that it stays the one corner of the query surface that is
|
|
15
|
+
* keyed by string; everything else is keyed by the interface the table was declared as.
|
|
16
|
+
*/
|
|
17
|
+
export interface UnknownRow {
|
|
18
|
+
readonly [column: string]: string | number | boolean | bigint | Date | null;
|
|
19
|
+
}
|
|
20
|
+
export type SubqueryTarget<V = unknown> =
|
|
21
|
+
| {
|
|
22
|
+
compile(): {
|
|
23
|
+
readonly text: string;
|
|
24
|
+
readonly parameters: readonly unknown[];
|
|
25
|
+
readonly telemetry?: {
|
|
26
|
+
readonly system: string;
|
|
27
|
+
readonly operation: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
|
|
28
|
+
readonly collection: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
readonly _type?: V;
|
|
32
|
+
}
|
|
33
|
+
| { table: string; select?: readonly string[]; where?: WhereDTO<UnknownRow>; readonly _type?: V };
|
|
34
|
+
|
|
35
|
+
type VectorOperand<V> =
|
|
36
|
+
NonNullable<V> extends {
|
|
37
|
+
readonly __zmdbExt?: readonly [extension: string, name: 'vector', args: readonly (string | number)[]];
|
|
38
|
+
}
|
|
39
|
+
? readonly number[]
|
|
40
|
+
: never;
|
|
41
|
+
|
|
42
|
+
export interface FieldOps<V> {
|
|
43
|
+
eq?: V | SubqueryTarget<V>;
|
|
44
|
+
ne?: V | SubqueryTarget<V>;
|
|
45
|
+
lt?: V | SubqueryTarget<V>;
|
|
46
|
+
lte?: V | SubqueryTarget<V>;
|
|
47
|
+
gt?: V | SubqueryTarget<V>;
|
|
48
|
+
gte?: V | SubqueryTarget<V>;
|
|
49
|
+
in?: readonly V[] | SubqueryTarget<V>;
|
|
50
|
+
nin?: readonly V[] | SubqueryTarget<V>;
|
|
51
|
+
like?: V extends string ? string | SubqueryTarget<string> : never;
|
|
52
|
+
ilike?: V extends string ? string | SubqueryTarget<string> : never;
|
|
53
|
+
l2?: VectorOperand<V>;
|
|
54
|
+
cosine?: VectorOperand<V>;
|
|
55
|
+
ip?: VectorOperand<V>;
|
|
56
|
+
isNull?: boolean;
|
|
57
|
+
notNull?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type WhereDTO<T extends DeclaredTable> = {
|
|
61
|
+
[K in keyof Entity<T>]?: Entity<T>[K] | FieldOps<Entity<T>[K]>;
|
|
62
|
+
} & {
|
|
63
|
+
and?: readonly WhereDTO<T>[];
|
|
64
|
+
or?: readonly WhereDTO<T>[];
|
|
65
|
+
exists?: SubqueryTarget<unknown> | readonly SubqueryTarget<unknown>[];
|
|
66
|
+
notExists?: SubqueryTarget<unknown> | readonly SubqueryTarget<unknown>[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// §2 OrderBy + Pagination (implemented in #183)
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
export type OrderDir = 'asc' | 'desc';
|
|
73
|
+
|
|
74
|
+
export type OrderByDTO<T extends DeclaredTable> = ReadonlyArray<{
|
|
75
|
+
column: keyof Entity<T>;
|
|
76
|
+
dir?: OrderDir;
|
|
77
|
+
}>;
|
|
78
|
+
|
|
79
|
+
export type OffsetPage = { limit: number; offset?: number | undefined };
|
|
80
|
+
|
|
81
|
+
export type PaginationDTO<T extends DeclaredTable> =
|
|
82
|
+
| OffsetPage
|
|
83
|
+
| {
|
|
84
|
+
limit: number;
|
|
85
|
+
after?: Partial<Entity<T>> | string | undefined;
|
|
86
|
+
before?: Partial<Entity<T>> | string | undefined;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Schema-agnostic views of the order/page DTOs — exactly the fields the folders
|
|
91
|
+
* read. `OrderByDTO<T>`/`PaginationDTO<T>` are structurally assignable to these
|
|
92
|
+
* for *any* `T`, so callers pass their own typed DTO with no
|
|
93
|
+
* `as OrderByDTO<CoreSchema<string>>` widening cast (which is what leaked into
|
|
94
|
+
* consumer code, cf. COOKBOOK "sorting" example).
|
|
95
|
+
*/
|
|
96
|
+
export type OrderBySpec = ReadonlyArray<{
|
|
97
|
+
column: PropertyKey;
|
|
98
|
+
dir?: OrderDir;
|
|
99
|
+
}>;
|
|
100
|
+
|
|
101
|
+
// `offset?: number | undefined` (not `offset?: number`) so callers under
|
|
102
|
+
// `exactOptionalPropertyTypes` can forward a possibly-absent offset positionally.
|
|
103
|
+
export type PaginationSpec = {
|
|
104
|
+
limit: number;
|
|
105
|
+
offset?: number | undefined;
|
|
106
|
+
after?: Record<string, unknown> | string | undefined;
|
|
107
|
+
before?: Record<string, unknown> | string | undefined;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function base64Encode(str: string): string {
|
|
111
|
+
if (globalThis.Buffer) {
|
|
112
|
+
return globalThis.Buffer.from(str, 'utf-8').toString('base64url');
|
|
113
|
+
}
|
|
114
|
+
if (globalThis.btoa) {
|
|
115
|
+
return globalThis.btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
116
|
+
}
|
|
117
|
+
throw new Error('No base64 encoder available');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function base64Decode(str: string): string {
|
|
121
|
+
if (globalThis.Buffer) {
|
|
122
|
+
return globalThis.Buffer.from(str, 'base64url').toString('utf-8');
|
|
123
|
+
}
|
|
124
|
+
if (globalThis.atob) {
|
|
125
|
+
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
126
|
+
while (base64.length % 4) base64 += '=';
|
|
127
|
+
return globalThis.atob(base64);
|
|
128
|
+
}
|
|
129
|
+
throw new Error('No base64 decoder available');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function encodeCursor(payload: Record<string, unknown>): string {
|
|
133
|
+
return base64Encode(JSON.stringify(payload));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function decodeCursor(cursor: string): Record<string, unknown> {
|
|
137
|
+
if (typeof cursor !== 'string' || !cursor.trim()) {
|
|
138
|
+
throw new Error('Invalid cursor: must be a non-empty string');
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const json = base64Decode(cursor);
|
|
142
|
+
const parsed = JSON.parse(json);
|
|
143
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
144
|
+
throw new Error('Invalid cursor payload');
|
|
145
|
+
}
|
|
146
|
+
// boundary: JSON.parse returns unknown (untrusted client payload); runtime check above proves parsed is a non-null, non-array object.
|
|
147
|
+
return parsed as Record<string, unknown>;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (err instanceof Error && err.message.startsWith('Invalid cursor')) {
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`Invalid cursor format: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// §3 Projection (types only; narrowing wired in #186)
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
export type Projection<T extends DeclaredTable, K extends keyof Entity<T>> = Pick<Entity<T>, K>;
|
|
160
|
+
|
|
161
|
+
/** Narrow a row to `cols` (new object, stable order); passthrough when undefined. */
|
|
162
|
+
export function project<Row extends Record<string, unknown>>(row: Row, cols: undefined): Row;
|
|
163
|
+
|
|
164
|
+
export function project<Row extends Record<string, unknown>, K extends keyof Row>(
|
|
165
|
+
row: Row,
|
|
166
|
+
cols: readonly K[],
|
|
167
|
+
): Pick<Row, K>;
|
|
168
|
+
|
|
169
|
+
export function project<Row extends Record<string, unknown>, K extends keyof Row>(
|
|
170
|
+
row: Row,
|
|
171
|
+
cols: readonly K[] | undefined,
|
|
172
|
+
): Row | Pick<Row, K>;
|
|
173
|
+
|
|
174
|
+
export function project<Row extends Record<string, unknown>, K extends keyof Row>(
|
|
175
|
+
row: Row,
|
|
176
|
+
cols: readonly K[] | undefined,
|
|
177
|
+
): Row | Pick<Row, K> {
|
|
178
|
+
if (!cols) return row;
|
|
179
|
+
// boundary: a `Pick` is built key-by-key, so it is only complete once the loop
|
|
180
|
+
// ends — there is no expression form that types a partially-filled mapped
|
|
181
|
+
// type. The loop below writes exactly `cols`, which is what `Pick<Row, K>`
|
|
182
|
+
// claims; `noUncheckedIndexedAccess` keeps the reads honest.
|
|
183
|
+
const out = {} as Pick<Row, K>;
|
|
184
|
+
for (const c of cols) out[c] = row[c];
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// §4 GetDTO
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
export interface GetOptions<T extends DeclaredTable> {
|
|
192
|
+
select?: readonly (keyof Entity<T>)[];
|
|
193
|
+
/**
|
|
194
|
+
* The relations to fetch alongside the row.
|
|
195
|
+
*
|
|
196
|
+
* `RelationKeys<T>` rather than `readonly string[]`: a declared type names its relations,
|
|
197
|
+
* so a misspelled one is a compile error rather than a relation that silently does not
|
|
198
|
+
* arrive. It was a bare `string[]` while this family was keyed by the schema value, which
|
|
199
|
+
* carries no relations to check a name against.
|
|
200
|
+
*/
|
|
201
|
+
populate?: readonly RelationKeys<T>[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export type GetDTO<
|
|
205
|
+
T extends DeclaredTable,
|
|
206
|
+
O extends GetOptions<T> = {},
|
|
207
|
+
> = O['select'] extends readonly (infer K extends keyof Entity<T>)[] ? Projection<T, K> : Entity<T>;
|
|
208
|
+
|
|
209
|
+
/** Apply a Get's select projection to a fetched row. */
|
|
210
|
+
export function getResult<Row extends Record<string, unknown>>(
|
|
211
|
+
row: Row,
|
|
212
|
+
opts?: { select?: readonly (keyof Row)[] },
|
|
213
|
+
): Row | Partial<Row> {
|
|
214
|
+
return project(row, opts?.select);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// §4–6 Get/List/Search DTOs (types; result assembly in #166/#169/#172)
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
export interface ListDTO<T extends DeclaredTable> {
|
|
221
|
+
where?: WhereDTO<T>;
|
|
222
|
+
orderBy?: OrderByDTO<T>;
|
|
223
|
+
page?: PaginationDTO<T>;
|
|
224
|
+
select?: readonly (keyof Entity<T>)[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface ListResult<Row> {
|
|
228
|
+
readonly items: readonly Row[];
|
|
229
|
+
readonly total?: number;
|
|
230
|
+
readonly hasMore: boolean;
|
|
231
|
+
readonly cursor?: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Everything `buildListResult` accepts except `select`, which is what its overloads differ on. */
|
|
235
|
+
interface ListOptions {
|
|
236
|
+
limit?: number;
|
|
237
|
+
total?: number;
|
|
238
|
+
cursor?: string;
|
|
239
|
+
orderBy?: OrderBySpec;
|
|
240
|
+
pkColumn?: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Assemble a ListResult: limit+1 trim ⇒ hasMore, per-item projection, opt-in total, opaque cursor.
|
|
245
|
+
*
|
|
246
|
+
* Overloaded on `select` so the no-projection call keeps `ListResult<Row>` instead
|
|
247
|
+
* of widening to `ListResult<Row | Partial<Row>>` — the widening is what forced
|
|
248
|
+
* `as ListResult<Entity<T>>` in `@zmdb/orm`'s `list()`.
|
|
249
|
+
*/
|
|
250
|
+
export function buildListResult<Row extends Record<string, unknown>>(
|
|
251
|
+
rows: readonly Row[],
|
|
252
|
+
opts?: ListOptions,
|
|
253
|
+
): ListResult<Row>;
|
|
254
|
+
|
|
255
|
+
export function buildListResult<Row extends Record<string, unknown>, K extends keyof Row>(
|
|
256
|
+
rows: readonly Row[],
|
|
257
|
+
opts: ListOptions & { select: readonly K[] },
|
|
258
|
+
): ListResult<Pick<Row, K>>;
|
|
259
|
+
|
|
260
|
+
export function buildListResult<Row extends Record<string, unknown>>(
|
|
261
|
+
rows: readonly Row[],
|
|
262
|
+
opts?: ListOptions & { select?: readonly (keyof Row)[] },
|
|
263
|
+
): ListResult<Row | Partial<Row>>;
|
|
264
|
+
|
|
265
|
+
export function buildListResult<Row extends Record<string, unknown>>(
|
|
266
|
+
rows: readonly Row[],
|
|
267
|
+
opts?: ListOptions & { select?: readonly (keyof Row)[] },
|
|
268
|
+
): ListResult<Row | Partial<Row>> {
|
|
269
|
+
const limit = opts?.limit;
|
|
270
|
+
const hasMore = typeof limit === 'number' && rows.length > limit;
|
|
271
|
+
const kept = hasMore ? rows.slice(0, limit) : rows;
|
|
272
|
+
const select = opts?.select;
|
|
273
|
+
const items = select ? kept.map(r => project(r, select)) : kept;
|
|
274
|
+
|
|
275
|
+
let computedCursor: string | undefined = opts?.cursor;
|
|
276
|
+
if (!computedCursor && hasMore && kept.length > 0) {
|
|
277
|
+
const lastRow = kept[kept.length - 1];
|
|
278
|
+
if (lastRow) {
|
|
279
|
+
const cursorObj: Record<string, unknown> = {};
|
|
280
|
+
const cols: { column: PropertyKey; dir?: OrderDir }[] = opts?.orderBy ? [...opts.orderBy] : [];
|
|
281
|
+
if (opts?.pkColumn && !cols.some(c => String(c.column) === opts.pkColumn)) {
|
|
282
|
+
cols.push({ column: opts.pkColumn, dir: 'asc' });
|
|
283
|
+
}
|
|
284
|
+
for (const item of cols) {
|
|
285
|
+
if (!item) continue;
|
|
286
|
+
const colStr = String(item.column);
|
|
287
|
+
if (colStr in lastRow) {
|
|
288
|
+
cursorObj[colStr] = lastRow[colStr];
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (Object.keys(cursorObj).length > 0) {
|
|
292
|
+
computedCursor = encodeCursor(cursorObj);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const result: ListResult<Row | Partial<Row>> = {
|
|
297
|
+
items,
|
|
298
|
+
hasMore,
|
|
299
|
+
...(computedCursor !== undefined ? { cursor: computedCursor } : {}),
|
|
300
|
+
};
|
|
301
|
+
return opts?.total !== undefined ? { ...result, total: opts.total } : result;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface SearchDTO<T extends DeclaredTable> {
|
|
305
|
+
query: string;
|
|
306
|
+
columns: readonly (keyof Entity<T>)[];
|
|
307
|
+
where?: WhereDTO<T>;
|
|
308
|
+
page?: PaginationDTO<T>;
|
|
309
|
+
rank?: boolean;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export type SearchHit<Row> = Row & { readonly _score?: number };
|
|
313
|
+
|
|
314
|
+
export type SearchResult<Row> = ListResult<SearchHit<Row>>;
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// §8 AggregateResult
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
export type AggFn = 'count' | 'sum' | 'avg' | 'min' | 'max';
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* `"relation.column"` for every relation the table declares.
|
|
323
|
+
*
|
|
324
|
+
* There was a second type parameter for this — the repository's relations map — and each of
|
|
325
|
+
* its entries named the target either as a declared type or as a schema value, so the arm
|
|
326
|
+
* that could see the target's columns was the one where an author had happened to write
|
|
327
|
+
* `entity: Order`; everything else fell back to `${Rel}.${string}`. `RelationKeys<T>` reads
|
|
328
|
+
* the target off the declaration, which every relation has, so every relation gets its
|
|
329
|
+
* columns listed.
|
|
330
|
+
*/
|
|
331
|
+
type RelationTargetOf<V> = (NonNullable<V> extends readonly (infer E)[] ? E : NonNullable<V>) & DeclaredTable;
|
|
332
|
+
|
|
333
|
+
type RelatedColumns<T extends DeclaredTable> = {
|
|
334
|
+
[Rel in RelationKeys<T> & string]: `${Rel}.${keyof Entity<RelationTargetOf<T[Rel & keyof T]>> & string}`;
|
|
335
|
+
}[RelationKeys<T> & string];
|
|
336
|
+
|
|
337
|
+
export type AggregateColumn<T extends DeclaredTable> = (keyof Entity<T> & string) | RelatedColumns<T> | (string & {});
|
|
338
|
+
|
|
339
|
+
export interface ComputedSpec<T extends DeclaredTable = DeclaredTable> {
|
|
340
|
+
fn: AggFn;
|
|
341
|
+
column?: AggregateColumn<T>;
|
|
342
|
+
raw?: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface AggregateSpec<T extends DeclaredTable> {
|
|
346
|
+
joins?:
|
|
347
|
+
| readonly (RelationKeys<T> & string)[]
|
|
348
|
+
| readonly { relation: RelationKeys<T> & string; kind?: 'inner' | 'left' | 'right' }[];
|
|
349
|
+
where?: WhereDTO<T> | Record<string, unknown>;
|
|
350
|
+
groupBy?: readonly AggregateColumn<T>[];
|
|
351
|
+
computed: Record<string, ComputedSpec<T>>;
|
|
352
|
+
having?: Readonly<{ column: AggregateColumn<T>; op: string; value: unknown }>;
|
|
353
|
+
orderBy?: ReadonlyArray<{ column: AggregateColumn<T>; dir?: OrderDir }>;
|
|
354
|
+
limit?: number;
|
|
355
|
+
offset?: number;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
type AggComputedType<T extends DeclaredTable, C> = C extends { fn: 'count' }
|
|
359
|
+
? number
|
|
360
|
+
: C extends { fn: 'sum' | 'avg' }
|
|
361
|
+
? number | null
|
|
362
|
+
: C extends { fn: 'min' | 'max'; column: infer Col extends keyof Entity<T> }
|
|
363
|
+
? Entity<T>[Col] | null
|
|
364
|
+
: number | null;
|
|
365
|
+
|
|
366
|
+
export type AggregateResult<T extends DeclaredTable, Spec extends AggregateSpec<T>> = {
|
|
367
|
+
[K in Spec['groupBy'] extends readonly (infer G extends keyof Entity<T>)[] ? G : never]: Entity<T>[K];
|
|
368
|
+
} & {
|
|
369
|
+
[K in keyof Spec['computed']]: AggComputedType<T, Spec['computed'][K]>;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
/** Ordered field list for an aggregate spec: group-key cols then computed keys. */
|
|
373
|
+
export function describeAggregate<T extends DeclaredTable>(spec: AggregateSpec<T>): readonly string[] {
|
|
374
|
+
const keys = (spec.groupBy ?? []).map(k => String(k));
|
|
375
|
+
return [...keys, ...Object.keys(spec.computed)];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Assemble a SearchResult (reuses buildListResult; preserves _score on hits). */
|
|
379
|
+
export function buildSearchResult<Row extends Record<string, unknown>>(
|
|
380
|
+
rows: readonly SearchHit<Row>[],
|
|
381
|
+
opts?: { limit?: number; select?: readonly (keyof Row)[]; total?: number },
|
|
382
|
+
): SearchResult<Row | Partial<Row>> {
|
|
383
|
+
const limit = opts?.limit;
|
|
384
|
+
const hasMore = typeof limit === 'number' && rows.length > limit;
|
|
385
|
+
const kept = hasMore ? rows.slice(0, limit) : rows;
|
|
386
|
+
const items = kept.map(hit => {
|
|
387
|
+
// `SearchHit<Row>` is `Row & {_score?}`, so it *is* a `Row` for projection
|
|
388
|
+
// purposes — no `hit as Row` needed once `project` is keyed on the argument.
|
|
389
|
+
const base = opts?.select ? project(hit, opts.select) : hit;
|
|
390
|
+
// preserve the ranking score on the projected hit
|
|
391
|
+
return hit._score !== undefined ? { ...base, _score: hit._score } : base;
|
|
392
|
+
});
|
|
393
|
+
const result: SearchResult<Row | Partial<Row>> = { items, hasMore };
|
|
394
|
+
return opts?.total !== undefined ? { ...result, total: opts.total } : result;
|
|
395
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// §2 embeddables
|
|
2
|
+
export function flattenEmbeddable(prefix: string, value: Record<string, unknown>): Record<string, unknown> {
|
|
3
|
+
const out: Record<string, unknown> = {};
|
|
4
|
+
for (const [k, v] of Object.entries(value)) out[`${prefix}_${k}`] = v;
|
|
5
|
+
return out;
|
|
6
|
+
}
|
|
7
|
+
export function liftEmbeddable(prefix: string, row: Record<string, unknown>): Record<string, unknown> {
|
|
8
|
+
const p = `${prefix}_`;
|
|
9
|
+
const out: Record<string, unknown> = {};
|
|
10
|
+
for (const [k, v] of Object.entries(row)) {
|
|
11
|
+
if (k.startsWith(p)) out[k.slice(p.length)] = v;
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// §3 inheritance
|
|
17
|
+
export interface SingleTableInheritance {
|
|
18
|
+
discriminator: string;
|
|
19
|
+
map: Record<string, readonly string[]>;
|
|
20
|
+
}
|
|
21
|
+
export function discriminatorFor(_sti: SingleTableInheritance, type: string): string {
|
|
22
|
+
return type;
|
|
23
|
+
}
|
|
24
|
+
export function rowToSubtype(
|
|
25
|
+
sti: SingleTableInheritance,
|
|
26
|
+
row: Record<string, unknown>,
|
|
27
|
+
): { type: string; data: Record<string, unknown> } {
|
|
28
|
+
const type = String(row[sti.discriminator]);
|
|
29
|
+
const cols = sti.map[type] ?? [];
|
|
30
|
+
const data: Record<string, unknown> = {};
|
|
31
|
+
for (const c of cols) data[c] = row[c];
|
|
32
|
+
return { type, data };
|
|
33
|
+
}
|