@apisr/drizzle-model 0.0.4 → 2.0.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/CHANGELOG.md +22 -0
- package/DISCLAIMER.md +5 -0
- package/README.md +433 -0
- package/TODO.md +8 -61
- package/package.json +5 -3
- package/src/core/dialect.ts +81 -0
- package/src/core/index.ts +24 -0
- package/src/core/query/error.ts +15 -0
- package/src/core/query/joins.ts +663 -0
- package/src/core/query/projection.ts +136 -0
- package/src/core/query/where.ts +449 -0
- package/src/core/result.ts +303 -0
- package/src/core/runtime.ts +636 -0
- package/src/core/transform.ts +119 -0
- package/src/model/builder.ts +40 -6
- package/src/model/config.ts +9 -9
- package/src/model/format.ts +20 -8
- package/src/model/methods/exclude.ts +1 -7
- package/src/model/methods/return.ts +11 -11
- package/src/model/methods/select.ts +2 -8
- package/src/model/model.ts +10 -16
- package/src/model/query/error.ts +1 -0
- package/src/model/result.ts +137 -21
- package/src/types.ts +38 -0
- package/tests/base/count.test.ts +47 -0
- package/tests/base/delete.test.ts +90 -0
- package/tests/base/find.test.ts +209 -0
- package/tests/base/insert.test.ts +152 -0
- package/tests/base/relations.test.ts +593 -0
- package/tests/base/safe.test.ts +91 -0
- package/tests/base/update.test.ts +88 -0
- package/tests/base/upsert.test.ts +121 -0
- package/tests/base.ts +21 -0
- package/src/model/core/joins.ts +0 -364
- package/src/model/core/projection.ts +0 -61
- package/src/model/core/runtime.ts +0 -334
- package/src/model/core/thenable.ts +0 -94
- package/src/model/core/transform.ts +0 -65
- package/src/model/core/where.ts +0 -249
- package/src/model/core/with.ts +0 -28
- package/tests/builder-v2-mysql.type-test.ts +0 -51
- package/tests/builder-v2.type-test.ts +0 -336
- package/tests/builder.test.ts +0 -63
- package/tests/find.test.ts +0 -166
- package/tests/insert.test.ts +0 -247
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import type { DialectHelper } from "../dialect.ts";
|
|
3
|
+
import { ProjectionBuilder } from "./projection.ts";
|
|
4
|
+
import { WhereCompiler } from "./where.ts";
|
|
5
|
+
|
|
6
|
+
/** Generic record type used throughout the join executor. */
|
|
7
|
+
type AnyRecord = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Join node — describes one relation join in the tree
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A single node in the join tree.
|
|
15
|
+
*
|
|
16
|
+
* Each node represents one relation that will be LEFT-JOINed into the
|
|
17
|
+
* base query. Nodes form a tree rooted at the base table, where child
|
|
18
|
+
* nodes correspond to nested relations (e.g. `with: { posts: { comments: true } }`).
|
|
19
|
+
*/
|
|
20
|
+
export interface JoinNode {
|
|
21
|
+
/** Unique alias key used for the select map namespace. */
|
|
22
|
+
aliasKey: string;
|
|
23
|
+
/** Child nodes for nested relations. */
|
|
24
|
+
children: JoinNode[];
|
|
25
|
+
/** The relation key on the parent table. */
|
|
26
|
+
key: string;
|
|
27
|
+
/** Parent node, or `undefined` for root-level relations. */
|
|
28
|
+
parent?: JoinNode;
|
|
29
|
+
/** The full dot-separated path from root (e.g. `["posts", "comments"]`). */
|
|
30
|
+
path: string[];
|
|
31
|
+
/** The primary key field name on the target table. */
|
|
32
|
+
pkField: string;
|
|
33
|
+
/** Whether this is a `"one"` or `"many"` relation. */
|
|
34
|
+
relationType: "one" | "many";
|
|
35
|
+
/** Source columns participating in the join condition. */
|
|
36
|
+
sourceColumns: unknown[];
|
|
37
|
+
/** The Drizzle source table reference. */
|
|
38
|
+
sourceTable: AnyRecord;
|
|
39
|
+
/** Name of the source (parent) table. */
|
|
40
|
+
sourceTableName: string;
|
|
41
|
+
/** The aliased Drizzle target table used in the join. */
|
|
42
|
+
targetAliasTable: AnyRecord;
|
|
43
|
+
/** Target columns participating in the join condition. */
|
|
44
|
+
targetColumns: unknown[];
|
|
45
|
+
/** The original (non-aliased) Drizzle target table. */
|
|
46
|
+
targetTable: AnyRecord;
|
|
47
|
+
/** Name of the target (child) table. */
|
|
48
|
+
targetTableName: string;
|
|
49
|
+
/** Optional compiled WHERE filter for this relation (added to JOIN ON). */
|
|
50
|
+
whereFilter?: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Configuration for the executor
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** Input configuration for {@link JoinExecutor.execute}. */
|
|
58
|
+
export interface JoinExecutorConfig {
|
|
59
|
+
/** The Drizzle table object for the base table. */
|
|
60
|
+
baseTable: AnyRecord;
|
|
61
|
+
/** The name of the base table being queried. */
|
|
62
|
+
baseTableName: string;
|
|
63
|
+
/** The Drizzle database instance. */
|
|
64
|
+
db: unknown;
|
|
65
|
+
/** SQL SELECT blacklist for base table columns. */
|
|
66
|
+
exclude?: AnyRecord;
|
|
67
|
+
/** When `true`, only the first result is returned. */
|
|
68
|
+
limitOne?: boolean;
|
|
69
|
+
/** The relations metadata map from Drizzle. */
|
|
70
|
+
relations: Record<string, AnyRecord>;
|
|
71
|
+
/** The full schema map (`{ tableName: drizzleTable }`). */
|
|
72
|
+
schema: Record<string, AnyRecord>;
|
|
73
|
+
/** SQL SELECT whitelist for base table columns. */
|
|
74
|
+
select?: AnyRecord;
|
|
75
|
+
/** An optional compiled SQL where clause. */
|
|
76
|
+
whereSql?: unknown;
|
|
77
|
+
/** The user-supplied `.with()` value describing which relations to load. */
|
|
78
|
+
withValue: AnyRecord;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// JoinExecutor
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Executes queries that load related entities via LEFT JOINs.
|
|
87
|
+
*
|
|
88
|
+
* Builds a join tree from the `.with()` descriptor, constructs a single
|
|
89
|
+
* multi-join query, and then groups the flat rows back into a nested
|
|
90
|
+
* object structure matching the requested relations.
|
|
91
|
+
*
|
|
92
|
+
* Usage:
|
|
93
|
+
* ```ts
|
|
94
|
+
* const executor = new JoinExecutor(dialectHelper);
|
|
95
|
+
* const result = await executor.execute(config);
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export class JoinExecutor {
|
|
99
|
+
private readonly dialect: DialectHelper;
|
|
100
|
+
private readonly projection: ProjectionBuilder;
|
|
101
|
+
private readonly whereCompiler: WhereCompiler;
|
|
102
|
+
|
|
103
|
+
constructor(dialect: DialectHelper) {
|
|
104
|
+
this.dialect = dialect;
|
|
105
|
+
this.projection = new ProjectionBuilder();
|
|
106
|
+
this.whereCompiler = new WhereCompiler();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Executes a query with LEFT JOINs for the requested relations,
|
|
111
|
+
* then groups the flat result into a nested object tree.
|
|
112
|
+
*
|
|
113
|
+
* @param config - The full join execution configuration.
|
|
114
|
+
* @returns A single object (when `limitOne`) or an array of grouped results.
|
|
115
|
+
*/
|
|
116
|
+
async execute(config: JoinExecutorConfig): Promise<unknown> {
|
|
117
|
+
const root = await this.buildJoinTree(config);
|
|
118
|
+
const flatNodes = this.flattenNodes(root);
|
|
119
|
+
|
|
120
|
+
const rows = await this.executeQuery(config, root, flatNodes);
|
|
121
|
+
const grouped = this.groupRows(rows, root, flatNodes);
|
|
122
|
+
|
|
123
|
+
return config.limitOne ? grouped[0] : grouped;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Tree construction
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Builds the full join tree from the `.with()` descriptor.
|
|
132
|
+
*
|
|
133
|
+
* The root node represents the base table. Each key in the `withValue`
|
|
134
|
+
* map becomes a child node, potentially with nested children.
|
|
135
|
+
*/
|
|
136
|
+
private async buildJoinTree(config: JoinExecutorConfig): Promise<JoinNode> {
|
|
137
|
+
const usedAliasKeys = new Set<string>();
|
|
138
|
+
usedAliasKeys.add(`table:${config.baseTableName}`);
|
|
139
|
+
|
|
140
|
+
const root: JoinNode = {
|
|
141
|
+
path: [],
|
|
142
|
+
key: "$root",
|
|
143
|
+
relationType: "one",
|
|
144
|
+
sourceTableName: config.baseTableName,
|
|
145
|
+
targetTableName: config.baseTableName,
|
|
146
|
+
sourceTable: config.baseTable,
|
|
147
|
+
targetTable: config.baseTable,
|
|
148
|
+
targetAliasTable: config.baseTable,
|
|
149
|
+
aliasKey: "$base",
|
|
150
|
+
sourceColumns: [],
|
|
151
|
+
targetColumns: [],
|
|
152
|
+
pkField: this.getPrimaryKeyField(config.baseTable),
|
|
153
|
+
children: [],
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
for (const [key, value] of Object.entries(config.withValue)) {
|
|
157
|
+
if (value !== true && (typeof value !== "object" || value == null)) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const child = await this.buildNode(
|
|
162
|
+
config,
|
|
163
|
+
usedAliasKeys,
|
|
164
|
+
undefined,
|
|
165
|
+
config.baseTableName,
|
|
166
|
+
config.baseTable,
|
|
167
|
+
key,
|
|
168
|
+
value,
|
|
169
|
+
[]
|
|
170
|
+
);
|
|
171
|
+
root.children.push(child);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return root;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Recursively builds a single join node and its children.
|
|
179
|
+
*
|
|
180
|
+
* Resolves relation metadata, determines whether aliasing is needed,
|
|
181
|
+
* and descends into nested sub-relations.
|
|
182
|
+
*/
|
|
183
|
+
private async buildNode(
|
|
184
|
+
config: JoinExecutorConfig,
|
|
185
|
+
usedAliasKeys: Set<string>,
|
|
186
|
+
parent: JoinNode | undefined,
|
|
187
|
+
currentTableName: string,
|
|
188
|
+
currentTable: AnyRecord,
|
|
189
|
+
key: string,
|
|
190
|
+
value: unknown,
|
|
191
|
+
path: string[]
|
|
192
|
+
): Promise<JoinNode> {
|
|
193
|
+
const { whereValue, nestedWith } =
|
|
194
|
+
this.extractRelationDescriptor(value);
|
|
195
|
+
|
|
196
|
+
const relMeta = this.getRelationMeta(
|
|
197
|
+
config.relations,
|
|
198
|
+
currentTableName,
|
|
199
|
+
key
|
|
200
|
+
);
|
|
201
|
+
const targetTableName: string = relMeta.targetTableName as string;
|
|
202
|
+
const targetTable: AnyRecord =
|
|
203
|
+
config.schema[targetTableName] ?? ({} as AnyRecord);
|
|
204
|
+
|
|
205
|
+
const aliasKey = this.resolveUniqueAlias(usedAliasKeys, [...path, key]);
|
|
206
|
+
|
|
207
|
+
const needsAlias =
|
|
208
|
+
targetTableName === currentTableName ||
|
|
209
|
+
usedAliasKeys.has(`table:${targetTableName}`);
|
|
210
|
+
usedAliasKeys.add(`table:${targetTableName}`);
|
|
211
|
+
|
|
212
|
+
const targetAliasTable = needsAlias
|
|
213
|
+
? await this.dialect.createTableAlias(targetTable, aliasKey)
|
|
214
|
+
: targetTable;
|
|
215
|
+
|
|
216
|
+
const whereFilter = whereValue
|
|
217
|
+
? this.whereCompiler.compile(
|
|
218
|
+
targetAliasTable as AnyRecord,
|
|
219
|
+
whereValue
|
|
220
|
+
)
|
|
221
|
+
: undefined;
|
|
222
|
+
|
|
223
|
+
const node: JoinNode = {
|
|
224
|
+
path: [...path, key],
|
|
225
|
+
key,
|
|
226
|
+
relationType: relMeta.relationType as "one" | "many",
|
|
227
|
+
sourceTableName: currentTableName,
|
|
228
|
+
targetTableName,
|
|
229
|
+
sourceTable: currentTable,
|
|
230
|
+
targetTable,
|
|
231
|
+
targetAliasTable,
|
|
232
|
+
aliasKey,
|
|
233
|
+
sourceColumns: (relMeta.sourceColumns ?? []) as unknown[],
|
|
234
|
+
targetColumns: (relMeta.targetColumns ?? []) as unknown[],
|
|
235
|
+
pkField: this.getPrimaryKeyField(targetAliasTable),
|
|
236
|
+
parent,
|
|
237
|
+
children: [],
|
|
238
|
+
whereFilter,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (nestedWith && typeof nestedWith === "object") {
|
|
242
|
+
for (const [childKey, childVal] of Object.entries(
|
|
243
|
+
nestedWith as AnyRecord
|
|
244
|
+
)) {
|
|
245
|
+
if (
|
|
246
|
+
childVal !== true &&
|
|
247
|
+
(typeof childVal !== "object" || childVal == null)
|
|
248
|
+
) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const child = await this.buildNode(
|
|
253
|
+
config,
|
|
254
|
+
usedAliasKeys,
|
|
255
|
+
node,
|
|
256
|
+
targetTableName,
|
|
257
|
+
targetAliasTable,
|
|
258
|
+
childKey,
|
|
259
|
+
childVal,
|
|
260
|
+
[...path, key]
|
|
261
|
+
);
|
|
262
|
+
node.children.push(child);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return node;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Query execution
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Builds and executes the multi-join SELECT query.
|
|
275
|
+
*
|
|
276
|
+
* Constructs a select map namespaced by alias key (base + each join),
|
|
277
|
+
* applies LEFT JOINs in preorder, and optionally limits to one row.
|
|
278
|
+
*/
|
|
279
|
+
private async executeQuery(
|
|
280
|
+
config: JoinExecutorConfig,
|
|
281
|
+
root: JoinNode,
|
|
282
|
+
nodes: JoinNode[]
|
|
283
|
+
): Promise<AnyRecord[]> {
|
|
284
|
+
const baseColumns = this.projection.build(
|
|
285
|
+
root.targetAliasTable,
|
|
286
|
+
config.select,
|
|
287
|
+
config.exclude
|
|
288
|
+
).selectMap;
|
|
289
|
+
|
|
290
|
+
const selectMap: AnyRecord = {
|
|
291
|
+
base: baseColumns,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
for (const node of nodes) {
|
|
295
|
+
selectMap[node.aliasKey] = this.projection.extractColumns(
|
|
296
|
+
node.targetAliasTable
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const db = config.db as AnyRecord;
|
|
301
|
+
let query = (db.select as (map: AnyRecord) => AnyRecord)(selectMap);
|
|
302
|
+
query = (query as AnyRecord & { from: (t: AnyRecord) => AnyRecord }).from(
|
|
303
|
+
config.baseTable
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
if (config.whereSql) {
|
|
307
|
+
query = (query as AnyRecord & { where: (w: unknown) => AnyRecord }).where(
|
|
308
|
+
config.whereSql
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const node of nodes) {
|
|
313
|
+
const onCondition = this.buildJoinOn(node);
|
|
314
|
+
query = (
|
|
315
|
+
query as AnyRecord & {
|
|
316
|
+
leftJoin: (t: AnyRecord, on: unknown) => AnyRecord;
|
|
317
|
+
}
|
|
318
|
+
).leftJoin(node.targetAliasTable, onCondition);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (config.limitOne) {
|
|
322
|
+
query = (query as AnyRecord & { limit: (n: number) => AnyRecord }).limit(
|
|
323
|
+
1
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return (await (query as unknown as PromiseLike<
|
|
328
|
+
AnyRecord[]
|
|
329
|
+
>)) as AnyRecord[];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Result grouping
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Groups flat joined rows back into a nested object structure.
|
|
338
|
+
*
|
|
339
|
+
* Uses the base table's primary key to deduplicate base rows, then
|
|
340
|
+
* attaches relation data to the correct parent in the tree.
|
|
341
|
+
*/
|
|
342
|
+
private groupRows(
|
|
343
|
+
rows: AnyRecord[],
|
|
344
|
+
root: JoinNode,
|
|
345
|
+
nodes: JoinNode[]
|
|
346
|
+
): AnyRecord[] {
|
|
347
|
+
const basePk = root.pkField;
|
|
348
|
+
const baseMap = new Map<unknown, AnyRecord>();
|
|
349
|
+
const manyIndexByPath = new Map<string, Map<unknown, AnyRecord>>();
|
|
350
|
+
|
|
351
|
+
for (const row of rows) {
|
|
352
|
+
const baseRow = (row as AnyRecord).base as AnyRecord;
|
|
353
|
+
const baseId = baseRow[basePk];
|
|
354
|
+
if (baseId === undefined) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const baseObj = this.getOrCreateBase(baseMap, baseId, baseRow);
|
|
359
|
+
|
|
360
|
+
for (const node of nodes) {
|
|
361
|
+
const data = (row as AnyRecord)[node.aliasKey] as
|
|
362
|
+
| AnyRecord
|
|
363
|
+
| null
|
|
364
|
+
| undefined;
|
|
365
|
+
const relPath = node.path.join(".");
|
|
366
|
+
const parentObj = this.resolveParentObject(
|
|
367
|
+
node,
|
|
368
|
+
baseObj,
|
|
369
|
+
manyIndexByPath
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
if (this.isAllNull(data)) {
|
|
373
|
+
this.ensureContainer(parentObj, node);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const pk = (data as AnyRecord)[node.pkField];
|
|
378
|
+
|
|
379
|
+
if (node.relationType === "one") {
|
|
380
|
+
parentObj[node.key] = { ...(data as AnyRecord) };
|
|
381
|
+
} else {
|
|
382
|
+
this.ensureManyArray(parentObj, node.key);
|
|
383
|
+
const indexMap = this.getOrCreateIndexMap(manyIndexByPath, relPath);
|
|
384
|
+
|
|
385
|
+
if (!indexMap.has(pk)) {
|
|
386
|
+
const obj = { ...(data as AnyRecord) };
|
|
387
|
+
indexMap.set(pk, obj);
|
|
388
|
+
(parentObj[node.key] as unknown[]).push(obj);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return Array.from(baseMap.values());
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// Helpers: join conditions
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Builds the ON clause for a single join node.
|
|
403
|
+
*
|
|
404
|
+
* Maps source columns to their corresponding aliased target columns
|
|
405
|
+
* and produces an equality (or multi-equality with AND) condition.
|
|
406
|
+
*/
|
|
407
|
+
private buildJoinOn(node: JoinNode): unknown {
|
|
408
|
+
const parts = (node.sourceColumns as unknown[]).map((src, i) => {
|
|
409
|
+
const tgt = (node.targetColumns as unknown[])[i];
|
|
410
|
+
const tgtKey = Object.entries(node.targetTable).find(
|
|
411
|
+
([, v]) => v === tgt
|
|
412
|
+
)?.[0];
|
|
413
|
+
const tgtCol = tgtKey
|
|
414
|
+
? (node.targetAliasTable as AnyRecord)[tgtKey]
|
|
415
|
+
: tgt;
|
|
416
|
+
return eq(tgtCol as never, src as never);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
if (node.whereFilter) {
|
|
420
|
+
parts.push(node.whereFilter as never);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return parts.length === 1 ? parts[0] : and(...parts);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Helpers: tree traversal & aliasing
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Flattens the join tree into a preorder list (excluding the root).
|
|
432
|
+
*
|
|
433
|
+
* The order matches the LEFT JOIN application order in the query.
|
|
434
|
+
*/
|
|
435
|
+
private flattenNodes(root: JoinNode): JoinNode[] {
|
|
436
|
+
const nodes: JoinNode[] = [];
|
|
437
|
+
|
|
438
|
+
const walk = (node: JoinNode): void => {
|
|
439
|
+
for (const child of node.children) {
|
|
440
|
+
nodes.push(child);
|
|
441
|
+
walk(child);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
walk(root);
|
|
445
|
+
|
|
446
|
+
return nodes;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Generates a unique alias key for a join node, avoiding collisions
|
|
451
|
+
* with previously used keys.
|
|
452
|
+
*/
|
|
453
|
+
private resolveUniqueAlias(usedKeys: Set<string>, path: string[]): string {
|
|
454
|
+
const base = path.join("__");
|
|
455
|
+
let alias = base;
|
|
456
|
+
let counter = 1;
|
|
457
|
+
|
|
458
|
+
while (usedKeys.has(alias)) {
|
|
459
|
+
alias = `${base}_${counter++}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
usedKeys.add(alias);
|
|
463
|
+
return alias;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Retrieves the relation metadata for a given table and relation key.
|
|
468
|
+
*
|
|
469
|
+
* @throws {Error} When the relation is not found in the schema metadata.
|
|
470
|
+
*/
|
|
471
|
+
private getRelationMeta(
|
|
472
|
+
relations: Record<string, AnyRecord>,
|
|
473
|
+
tableName: string,
|
|
474
|
+
key: string
|
|
475
|
+
): AnyRecord {
|
|
476
|
+
const tableRelations = relations[tableName] as AnyRecord | undefined;
|
|
477
|
+
const relationsMap = tableRelations?.relations as AnyRecord | undefined;
|
|
478
|
+
const relMeta = relationsMap?.[key] as AnyRecord | undefined;
|
|
479
|
+
|
|
480
|
+
if (!relMeta) {
|
|
481
|
+
throw new Error(`Unknown relation '${key}' on table '${tableName}'.`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return relMeta;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// Helpers: primary key detection
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Detects the primary key field name of a Drizzle table.
|
|
493
|
+
*
|
|
494
|
+
* Tries (in order):
|
|
495
|
+
* 1. A column with `primary === true`.
|
|
496
|
+
* 2. A column with `config.primaryKey === true`.
|
|
497
|
+
* 3. A field named `"id"`.
|
|
498
|
+
* 4. The first Drizzle column found.
|
|
499
|
+
*/
|
|
500
|
+
private getPrimaryKeyField(table: AnyRecord): string {
|
|
501
|
+
for (const [key, value] of Object.entries(table)) {
|
|
502
|
+
if (!this.isDrizzleColumn(value)) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const col = value as AnyRecord;
|
|
506
|
+
if (col.primary === true) {
|
|
507
|
+
return key;
|
|
508
|
+
}
|
|
509
|
+
if ((col.config as AnyRecord | undefined)?.primaryKey === true) {
|
|
510
|
+
return key;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if ("id" in table) {
|
|
515
|
+
return "id";
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return (
|
|
519
|
+
Object.keys(table).find((k) => this.isDrizzleColumn(table[k])) ?? "id"
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Checks whether a value is a Drizzle column reference. */
|
|
524
|
+
private isDrizzleColumn(value: unknown): boolean {
|
|
525
|
+
return (
|
|
526
|
+
!!value &&
|
|
527
|
+
typeof value === "object" &&
|
|
528
|
+
typeof (value as AnyRecord).getSQL === "function"
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Extracts relation where clause and nested relations from a `.with()` value.
|
|
534
|
+
*
|
|
535
|
+
* Handles three cases:
|
|
536
|
+
* - `true` → no filter, no nested relations.
|
|
537
|
+
* - A ModelRuntime (has `$model === "model"`) → extract `$where`, no nested.
|
|
538
|
+
* - A model descriptor (`__modelRelation: true`) → extract `whereValue` and `with`.
|
|
539
|
+
* - A plain object → treat as nested relation map.
|
|
540
|
+
*/
|
|
541
|
+
private extractRelationDescriptor(value: unknown): {
|
|
542
|
+
whereValue: unknown;
|
|
543
|
+
nestedWith: unknown;
|
|
544
|
+
} {
|
|
545
|
+
if (value === true || value == null) {
|
|
546
|
+
return { whereValue: undefined, nestedWith: undefined };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (typeof value !== "object") {
|
|
550
|
+
return { whereValue: undefined, nestedWith: undefined };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const rec = value as AnyRecord;
|
|
554
|
+
|
|
555
|
+
if (rec.$model === "model") {
|
|
556
|
+
return { whereValue: rec.$where, nestedWith: undefined };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (rec.__modelRelation === true) {
|
|
560
|
+
return { whereValue: rec.whereValue, nestedWith: rec.with };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return { whereValue: undefined, nestedWith: value };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
// Helpers: result grouping internals
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Gets or creates the base-row object for a given primary key.
|
|
572
|
+
*/
|
|
573
|
+
private getOrCreateBase(
|
|
574
|
+
baseMap: Map<unknown, AnyRecord>,
|
|
575
|
+
baseId: unknown,
|
|
576
|
+
baseRow: AnyRecord
|
|
577
|
+
): AnyRecord {
|
|
578
|
+
const existing = baseMap.get(baseId);
|
|
579
|
+
if (existing) {
|
|
580
|
+
return existing;
|
|
581
|
+
}
|
|
582
|
+
const created = { ...baseRow };
|
|
583
|
+
baseMap.set(baseId, created);
|
|
584
|
+
return created;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Resolves the parent object that a join node's data should attach to.
|
|
589
|
+
*
|
|
590
|
+
* For root-level relations, the parent is the base row. For nested
|
|
591
|
+
* relations, it is the last inserted instance of the parent node.
|
|
592
|
+
*/
|
|
593
|
+
private resolveParentObject(
|
|
594
|
+
node: JoinNode,
|
|
595
|
+
baseObj: AnyRecord,
|
|
596
|
+
manyIndexByPath: Map<string, Map<unknown, AnyRecord>>
|
|
597
|
+
): AnyRecord {
|
|
598
|
+
if (!node.parent) {
|
|
599
|
+
return baseObj;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const parentPath = node.parent.path.join(".");
|
|
603
|
+
if (!parentPath) {
|
|
604
|
+
return baseObj;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const parentIndex = manyIndexByPath.get(parentPath);
|
|
608
|
+
if (parentIndex && parentIndex.size > 0) {
|
|
609
|
+
return Array.from(parentIndex.values()).at(-1) as AnyRecord;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const parentKey = node.parent.key;
|
|
613
|
+
return (baseObj[parentKey] as AnyRecord | undefined) ?? baseObj;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Checks whether all values in a row are `null` or `undefined`. */
|
|
617
|
+
private isAllNull(obj: AnyRecord | null | undefined): boolean {
|
|
618
|
+
if (!obj || typeof obj !== "object") {
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
for (const value of Object.values(obj)) {
|
|
622
|
+
if (value !== null && value !== undefined) {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Ensures the correct empty container exists on the parent for a node.
|
|
631
|
+
*
|
|
632
|
+
* Creates `[]` for many-relations and `null` for one-relations.
|
|
633
|
+
*/
|
|
634
|
+
private ensureContainer(parentObj: AnyRecord, node: JoinNode): void {
|
|
635
|
+
if (node.relationType === "one") {
|
|
636
|
+
if (!(node.key in parentObj)) {
|
|
637
|
+
parentObj[node.key] = null;
|
|
638
|
+
}
|
|
639
|
+
} else {
|
|
640
|
+
this.ensureManyArray(parentObj, node.key);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Ensures `parentObj[key]` is an array. */
|
|
645
|
+
private ensureManyArray(parentObj: AnyRecord, key: string): void {
|
|
646
|
+
if (!Array.isArray(parentObj[key])) {
|
|
647
|
+
parentObj[key] = [];
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Gets or creates a deduplication index map for a many-relation path. */
|
|
652
|
+
private getOrCreateIndexMap(
|
|
653
|
+
manyIndexByPath: Map<string, Map<unknown, AnyRecord>>,
|
|
654
|
+
relPath: string
|
|
655
|
+
): Map<unknown, AnyRecord> {
|
|
656
|
+
let indexMap = manyIndexByPath.get(relPath);
|
|
657
|
+
if (!indexMap) {
|
|
658
|
+
indexMap = new Map();
|
|
659
|
+
manyIndexByPath.set(relPath, indexMap);
|
|
660
|
+
}
|
|
661
|
+
return indexMap;
|
|
662
|
+
}
|
|
663
|
+
}
|