@danceroutine/tango-orm 1.4.0 → 1.5.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.
@@ -1,5 +1,6 @@
1
1
  import { __export } from "./chunk-DLY2FNSh.js";
2
- import { SqlSafetyEngine } from "@danceroutine/tango-core";
2
+ import { SqlSafetyEngine, isError } from "@danceroutine/tango-core";
3
+ import { ModelRegistry } from "@danceroutine/tango-schema";
3
4
 
4
5
  //#region src/query/domain/internal/InternalRelationKind.ts
5
6
  const InternalRelationKind = {
@@ -9,6 +10,72 @@ const InternalRelationKind = {
9
10
  MANY_TO_MANY: "manyToMany"
10
11
  };
11
12
 
13
+ //#endregion
14
+ //#region src/query/domain/TableMetaFactory.ts
15
+ var TableMetaFactory = class TableMetaFactory {
16
+ static create(model) {
17
+ const owner = model.metadata.key ? ModelRegistry.getOwner(model) : undefined;
18
+ const cache = new Map();
19
+ return TableMetaFactory.createWithCache(model, owner, cache);
20
+ }
21
+ static createWithCache(model, owner, cache) {
22
+ if (model.metadata.key) {
23
+ const cached = cache.get(model.metadata.key);
24
+ if (cached) return cached;
25
+ }
26
+ const pkField = model.metadata.fields.find((field) => field.primaryKey);
27
+ if (!pkField) throw new Error(`Model '${model.metadata.name}' cannot attach a manager without a primary key field.`);
28
+ const tableMeta = {
29
+ modelKey: model.metadata.key,
30
+ table: model.metadata.table,
31
+ pk: pkField.name,
32
+ columns: Object.fromEntries(model.metadata.fields.map((field) => [field.name, field.type]))
33
+ };
34
+ if (model.metadata.key) cache.set(model.metadata.key, tableMeta);
35
+ if (!model.metadata.key || !owner) return tableMeta;
36
+ const relations = owner.getResolvedRelationGraph().byModel.get(model.metadata.key);
37
+ if (!relations || relations.size === 0) return tableMeta;
38
+ tableMeta.relations = Object.fromEntries(Array.from(relations.entries()).filter(([, relation]) => relation.capabilities.queryable && relation.capabilities.hydratable).map(([name, relation]) => {
39
+ const targetModel = owner.getByKey(relation.targetModelKey);
40
+ const targetMeta = TableMetaFactory.createWithCache(targetModel, owner, cache);
41
+ const { queryable, hydratable } = relation.capabilities;
42
+ const isSingleRelation = relation.kind === InternalRelationKind.BELONGS_TO || relation.kind === InternalRelationKind.HAS_ONE;
43
+ const sourceKey = relation.kind === InternalRelationKind.BELONGS_TO ? relation.localFieldName : relation.targetFieldName;
44
+ const targetKey = relation.kind === InternalRelationKind.BELONGS_TO ? relation.targetFieldName : relation.localFieldName;
45
+ const targetColumns = Object.fromEntries(targetModel.metadata.fields.map((field) => [field.name, field.type]));
46
+ const capabilities = {
47
+ queryable,
48
+ hydratable,
49
+ joinable: isSingleRelation && queryable && hydratable,
50
+ prefetchable: queryable && hydratable
51
+ };
52
+ return [name, {
53
+ edgeId: relation.edgeId,
54
+ sourceModelKey: relation.sourceModelKey,
55
+ targetModelKey: relation.targetModelKey,
56
+ kind: relation.kind,
57
+ cardinality: isSingleRelation ? "single" : "many",
58
+ capabilities,
59
+ table: targetModel.metadata.table,
60
+ sourceKey,
61
+ targetKey,
62
+ targetPrimaryKey: targetMeta.pk,
63
+ targetColumns,
64
+ alias: relation.alias,
65
+ targetMeta
66
+ }];
67
+ }));
68
+ return tableMeta;
69
+ }
70
+ };
71
+
72
+ //#endregion
73
+ //#region src/query/domain/RelationMeta.ts
74
+ const InternalRelationHydrationLoadMode = {
75
+ JOIN: "join",
76
+ PREFETCH: "prefetch"
77
+ };
78
+
12
79
  //#endregion
13
80
  //#region src/query/domain/internal/InternalDialect.ts
14
81
  const InternalDialect = {
@@ -179,39 +246,129 @@ var OrmSqlSafetyAdapter = class OrmSqlSafetyAdapter {
179
246
  }
180
247
  };
181
248
 
249
+ //#endregion
250
+ //#region src/query/domain/RelationTyping.ts
251
+ const InternalRelationHydrationCardinality = {
252
+ SINGLE: "single",
253
+ MANY: "many"
254
+ };
255
+
256
+ //#endregion
257
+ //#region src/query/planning/QueryPlanner.ts
258
+ var QueryPlanner = class QueryPlanner {
259
+ static BRAND = "tango.orm.query_planner";
260
+ __tangoBrand = QueryPlanner.BRAND;
261
+ constructor(meta) {
262
+ this.meta = meta;
263
+ }
264
+ static isQueryPlanner(value) {
265
+ return typeof value === "object" && value !== null && value.__tangoBrand === QueryPlanner.BRAND;
266
+ }
267
+ plan(state) {
268
+ const requestedPaths = Array.from(new Set([...state.selectRelated ?? [], ...state.prefetchRelated ?? []]));
269
+ if (requestedPaths.length === 0) return {
270
+ joinNodes: [],
271
+ prefetchNodes: [],
272
+ requestedPaths: []
273
+ };
274
+ const rootChildren = new Map();
275
+ for (const relationPath of new Set(state.selectRelated ?? [])) this.addPath(rootChildren, relationPath, "select");
276
+ for (const relationPath of new Set(state.prefetchRelated ?? [])) this.addPath(rootChildren, relationPath, "prefetch");
277
+ const { joinNodes, prefetchNodes } = this.buildPlannedChildren(rootChildren);
278
+ return {
279
+ joinNodes,
280
+ prefetchNodes,
281
+ requestedPaths
282
+ };
283
+ }
284
+ addPath(rootChildren, relationPath, mode) {
285
+ const segments = relationPath.split("__").filter(Boolean);
286
+ if (segments.length === 0) throw new Error(`Invalid empty relation path '${relationPath}'.`);
287
+ let currentMeta = this.meta;
288
+ let currentChildren = rootChildren;
289
+ let builtPath = "";
290
+ let containsCollection = false;
291
+ for (const segment of segments) {
292
+ const relation = currentMeta.relations?.[segment];
293
+ if (!relation) throw new Error(`Unknown relation path '${relationPath}' for table '${currentMeta.table}'.`);
294
+ if (segment in currentMeta.columns && relation.sourceKey !== segment) throw new Error(`Relation path '${relationPath}' collides with an existing field on table '${currentMeta.table}'.`);
295
+ if (relation.kind === InternalRelationKind.MANY_TO_MANY) throw new Error(`Relation path '${relationPath}' uses unsupported many-to-many hydration.`);
296
+ if (!relation.capabilities.queryable || !relation.capabilities.hydratable) throw new Error(`Relation path '${relationPath}' cannot be hydrated.`);
297
+ if (mode === "select") {
298
+ if (relation.cardinality !== InternalRelationHydrationCardinality.SINGLE || !relation.capabilities.joinable) throw new Error(`Relation path '${relationPath}' cannot be loaded with selectRelated(...).`);
299
+ } else if (relation.cardinality === InternalRelationHydrationCardinality.MANY) {
300
+ if (!relation.capabilities.prefetchable) throw new Error(`Relation path '${relationPath}' cannot be loaded with prefetchRelated(...).`);
301
+ containsCollection = true;
302
+ } else if (!relation.capabilities.joinable) throw new Error(`Relation path '${relationPath}' cannot be loaded with prefetchRelated(...).`);
303
+ const targetMeta = relation.targetMeta;
304
+ if (!targetMeta) throw new Error(`Relation path '${relationPath}' is missing target metadata.`);
305
+ builtPath = builtPath.length > 0 ? `${builtPath}__${segment}` : segment;
306
+ const existing = currentChildren.get(segment);
307
+ const nextNode = existing ?? {
308
+ segment,
309
+ relationEdge: relation,
310
+ relationPath: builtPath,
311
+ targetMeta,
312
+ provenance: new Set(),
313
+ children: new Map()
314
+ };
315
+ nextNode.provenance.add(relationPath);
316
+ currentChildren.set(segment, nextNode);
317
+ currentChildren = nextNode.children;
318
+ currentMeta = targetMeta;
319
+ }
320
+ if (mode === "prefetch" && !containsCollection) throw new Error(`Relation path '${relationPath}' cannot be loaded with prefetchRelated(...).`);
321
+ }
322
+ buildPlannedChildren(children) {
323
+ const joinNodes = [];
324
+ const prefetchNodes = [];
325
+ for (const child of children.values()) {
326
+ const { joinNodes: joinChildren, prefetchNodes: prefetchChildren } = this.buildPlannedChildren(child.children);
327
+ const plannedNode = {
328
+ nodeId: child.relationPath,
329
+ relationName: child.segment,
330
+ relationPath: child.relationPath,
331
+ ownerModelKey: child.relationEdge.sourceModelKey,
332
+ relationEdge: child.relationEdge,
333
+ targetModelKey: child.relationEdge.targetModelKey,
334
+ loadMode: child.relationEdge.cardinality === InternalRelationHydrationCardinality.SINGLE ? InternalRelationHydrationLoadMode.JOIN : InternalRelationHydrationLoadMode.PREFETCH,
335
+ cardinality: child.relationEdge.cardinality,
336
+ provenance: [...child.provenance],
337
+ joinChildren,
338
+ prefetchChildren
339
+ };
340
+ if (plannedNode.loadMode === InternalRelationHydrationLoadMode.JOIN) joinNodes.push(plannedNode);
341
+ else prefetchNodes.push(plannedNode);
342
+ }
343
+ return {
344
+ joinNodes,
345
+ prefetchNodes
346
+ };
347
+ }
348
+ };
349
+
182
350
  //#endregion
183
351
  //#region src/query/compiler/QueryCompiler.ts
184
352
  const sqlSafetyAdapter = new OrmSqlSafetyAdapter();
185
353
  var QueryCompiler = class QueryCompiler {
186
354
  static BRAND = "tango.orm.query_compiler";
187
355
  __tangoBrand = QueryCompiler.BRAND;
188
- /**
189
- * Build a compiler for the given repository metadata and SQL dialect.
190
- */
191
356
  constructor(meta, dialect = InternalDialect.POSTGRES) {
192
357
  this.meta = meta;
193
358
  this.dialect = dialect;
194
359
  }
195
- /**
196
- * Narrow an unknown value to `QueryCompiler`.
197
- */
198
360
  static isQueryCompiler(value) {
199
361
  return typeof value === "object" && value !== null && value.__tangoBrand === QueryCompiler.BRAND;
200
362
  }
201
- /**
202
- * Compile a query state tree into a SQL statement and bound parameters.
203
- */
204
363
  compile(state) {
205
- const selectRelationNames = state.selectRelated ?? [];
206
- const prefetchRelationNames = state.prefetchRelated ?? [];
207
- const relationNames = [...new Set([...selectRelationNames, ...prefetchRelationNames])];
364
+ const hydrationPlan = new QueryPlanner(this.meta).plan(state);
208
365
  const validatedPlan = sqlSafetyAdapter.validate({
209
366
  kind: "select",
210
367
  meta: this.meta,
211
368
  selectFields: state.select?.map(String),
212
369
  filterKeys: this.collectStateFilterKeys(state),
213
370
  orderFields: state.order?.map((order) => String(order.by)),
214
- relationNames
371
+ relationNames: []
215
372
  });
216
373
  const table = validatedPlan.meta.table;
217
374
  const whereParts = [];
@@ -233,87 +390,188 @@ var QueryCompiler = class QueryCompiler {
233
390
  params.push(...result.params);
234
391
  }
235
392
  });
236
- const baseSelect = state.select?.length ? state.select.map((field) => validatedPlan.selectFields[String(field)]).join(", ") : `${table}.*`;
237
- const relationSelects = selectRelationNames.flatMap((relationName) => {
238
- const relation = validatedPlan.relations[relationName];
239
- return Object.keys(relation.targetColumns).map((column) => `${relation.alias}.${column} AS ${this.buildHydrationColumnAlias(relation.alias, column)}`);
240
- });
241
- const prefetchSourceSelects = state.select?.length && prefetchRelationNames.length ? prefetchRelationNames.map((relationName) => validatedPlan.relations[relationName]).filter((relation) => !state.select.map(String).includes(relation.sourceKey)).map((relation) => `${table}.${relation.sourceKey} AS ${this.buildPrefetchSourceAlias(relation.sourceKey)}`) : [];
393
+ const baseSelects = state.select?.length ? state.select.map((field) => validatedPlan.selectFields[String(field)]) : [`${table}.*`];
394
+ const joinCollection = {
395
+ selects: [],
396
+ joins: []
397
+ };
398
+ const hiddenRootAliases = [];
399
+ const compiledJoinNodes = hydrationPlan.joinNodes.map((node) => this.compileHydrationNode(node, {
400
+ rootTable: table,
401
+ ownerMeta: this.meta,
402
+ ownerAlias: table,
403
+ collectRootJoins: true,
404
+ rootSelectedFields: state.select?.map(String) ?? undefined,
405
+ hiddenRootAliases,
406
+ joinCollection
407
+ }));
408
+ const compiledPrefetchNodes = hydrationPlan.prefetchNodes.map((node) => this.compileHydrationNode(node, {
409
+ rootTable: table,
410
+ ownerMeta: this.meta,
411
+ ownerAlias: table,
412
+ collectRootJoins: false,
413
+ rootSelectedFields: state.select?.map(String) ?? undefined,
414
+ hiddenRootAliases,
415
+ joinCollection
416
+ }));
242
417
  const select = [
243
- baseSelect,
244
- ...relationSelects,
245
- ...prefetchSourceSelects
418
+ ...baseSelects,
419
+ ...joinCollection.selects,
420
+ ...this.buildRootHiddenSelects(compiledPrefetchNodes, table)
246
421
  ].join(", ");
247
- const joins = selectRelationNames.map((rel) => {
248
- const relation = validatedPlan.relations[rel];
249
- if (relation.kind !== InternalRelationKind.BELONGS_TO && relation.kind !== InternalRelationKind.HAS_ONE) throw new Error(`Relation '${rel}' cannot be loaded with selectRelated(...).`);
250
- return `LEFT JOIN ${relation.table} ${relation.alias} ON ${relation.alias}.${relation.targetKey} = ${table}.${relation.sourceKey}`;
251
- }).filter(Boolean).join(" ");
252
- for (const rel of prefetchRelationNames) {
253
- const relation = validatedPlan.relations[rel];
254
- if (relation?.kind !== InternalRelationKind.HAS_MANY) throw new Error(`Relation '${rel}' cannot be loaded with prefetchRelated(...).`);
255
- }
256
422
  const whereSQL = whereParts.length ? ` WHERE ${whereParts.join(" AND ")}` : "";
257
423
  const orderSQL = ` ORDER BY ${state.order?.length ? state.order.map((order) => `${validatedPlan.orderFields[String(order.by)]} ${order.dir.toUpperCase()}`).join(", ") : `${table}.${validatedPlan.meta.pk} ASC`}`;
258
424
  const limitSQL = state.limit ? ` LIMIT ${state.limit}` : "";
259
425
  const offsetSQL = state.offset ? ` OFFSET ${state.offset}` : "";
260
- const sql = `SELECT ${select} FROM ${table}${joins ? ` ${joins}` : ""}${whereSQL}${orderSQL}${limitSQL}${offsetSQL}`;
426
+ const sql = `SELECT ${select} FROM ${table}${joinCollection.joins.length ? ` ${joinCollection.joins.join(" ")}` : ""}${whereSQL}${orderSQL}${limitSQL}${offsetSQL}`;
427
+ const compiledHydrationPlan = compiledJoinNodes.length > 0 || compiledPrefetchNodes.length > 0 ? {
428
+ requestedPaths: hydrationPlan.requestedPaths,
429
+ hiddenRootAliases: [...new Set(hiddenRootAliases)],
430
+ joinNodes: compiledJoinNodes,
431
+ prefetchNodes: compiledPrefetchNodes
432
+ } : undefined;
261
433
  return {
262
434
  sql,
263
435
  params,
264
- hydrations: selectRelationNames.map((relationName) => {
265
- const relation = validatedPlan.relations[relationName];
266
- return {
267
- relationName,
268
- alias: relation.alias,
269
- columns: Object.fromEntries(Object.keys(relation.targetColumns).map((column) => [column, this.buildHydrationColumnAlias(relation.alias, column)]))
270
- };
271
- }),
272
- prefetches: prefetchRelationNames.map((relationName) => {
273
- const relation = validatedPlan.relations[relationName];
274
- const needsAlias = !!state.select?.length && !state.select.map(String).includes(relation.sourceKey);
275
- return {
276
- relationName,
277
- sourceKey: relation.sourceKey,
278
- sourceKeyAlias: needsAlias ? this.buildPrefetchSourceAlias(relation.sourceKey) : undefined,
279
- table: relation.table,
280
- targetKey: relation.targetKey,
281
- targetColumns: relation.targetColumns
282
- };
283
- })
436
+ hydrationPlan: compiledHydrationPlan
284
437
  };
285
438
  }
286
- /**
287
- * Compile the follow-up query used by `prefetchRelated(...)`.
288
- *
289
- * The base query cannot bind source values until after it has returned rows,
290
- * but SQL rendering and validation still belong to the compiler.
291
- */
292
- compilePrefetch(prefetch, sourceValues) {
293
- const validatedPlan = sqlSafetyAdapter.validate({
294
- kind: "select",
295
- meta: this.meta,
296
- relationNames: [prefetch.relationName]
297
- });
298
- const relation = validatedPlan.relations[prefetch.relationName];
299
- const compiledTargetColumns = Object.keys(prefetch.targetColumns).sort();
300
- const validatedTargetColumns = Object.keys(relation.targetColumns).sort();
301
- const compiledMatchesValidated = prefetch.sourceKey === relation.sourceKey && prefetch.table === relation.table && prefetch.targetKey === relation.targetKey && compiledTargetColumns.length === validatedTargetColumns.length && compiledTargetColumns.every((column, index) => column === validatedTargetColumns[index] && prefetch.targetColumns[column] === relation.targetColumns[column]);
302
- if (!compiledMatchesValidated) throw new Error(`Compiled prefetch metadata for relation '${prefetch.relationName}' failed validation.`);
303
- const columns = Object.keys(relation.targetColumns);
439
+ compilePrefetch(node, sourceValues) {
304
440
  const placeholders = this.dialect === InternalDialect.POSTGRES ? sourceValues.map((_, index) => `$${index + 1}`).join(", ") : sourceValues.map(() => "?").join(", ");
441
+ const validatedTarget = this.validatePrefetchTarget(node);
442
+ const baseAlias = this.buildPrefetchBaseAlias(node.relationPath);
443
+ const joinCollection = {
444
+ selects: [],
445
+ joins: []
446
+ };
447
+ for (const joinChild of node.joinChildren) this.collectNestedJoinSql(joinChild, baseAlias, validatedTarget.columns, joinCollection);
448
+ const baseSelects = Object.keys(validatedTarget.columns).map((column) => `${baseAlias}.${column} AS ${column}`);
305
449
  return {
306
- sql: `SELECT ${columns.join(", ")} FROM ${relation.table} WHERE ${relation.targetKey} IN (${placeholders}) ORDER BY ${relation.targetKey} ASC`,
450
+ sql: `SELECT ${[...baseSelects, ...joinCollection.selects].join(", ")} FROM ${validatedTarget.table} ${baseAlias}${joinCollection.joins.length ? ` ${joinCollection.joins.join(" ")}` : ""} WHERE ${baseAlias}.${validatedTarget.targetKey} IN (${placeholders}) ORDER BY ${baseAlias}.${validatedTarget.targetKey} ASC, ${baseAlias}.${validatedTarget.primaryKey} ASC`,
307
451
  params: sourceValues,
308
- targetKey: relation.targetKey,
309
- targetColumns: relation.targetColumns
452
+ targetKey: validatedTarget.targetKey,
453
+ targetColumns: validatedTarget.columns
310
454
  };
311
455
  }
312
- buildHydrationColumnAlias(alias, column) {
313
- return this.assertInternalAliasDoesNotCollide(`__tango_hydrate_${alias}_${column}`);
456
+ compileHydrationNode(node, context) {
457
+ const validatedRelation = this.validateHydrationRelation(context.ownerMeta, node.relationName);
458
+ const targetColumns = validatedRelation.targetColumns;
459
+ const targetMeta = node.relationEdge.targetMeta;
460
+ if (!targetMeta) throw new Error(`Relation path '${node.relationPath}' is missing target metadata.`);
461
+ const compiledJoinChildren = node.joinChildren.map((child) => this.compileHydrationNode(child, {
462
+ ...context,
463
+ ownerMeta: targetMeta,
464
+ ownerAlias: this.buildJoinAlias(node.relationPath),
465
+ collectRootJoins: context.collectRootJoins
466
+ }));
467
+ const compiledPrefetchChildren = node.prefetchChildren.map((child) => this.compileHydrationNode(child, {
468
+ ...context,
469
+ ownerMeta: targetMeta,
470
+ ownerAlias: this.buildJoinAlias(node.relationPath),
471
+ collectRootJoins: false
472
+ }));
473
+ let joinDescriptor;
474
+ if (node.loadMode === InternalRelationHydrationLoadMode.JOIN) {
475
+ joinDescriptor = {
476
+ alias: this.buildJoinAlias(node.relationPath),
477
+ columns: Object.fromEntries(Object.keys(targetColumns).map((column) => [column, this.buildHydrationColumnAlias(node.relationPath, column)]))
478
+ };
479
+ if (context.collectRootJoins) {
480
+ context.joinCollection.joins.push(`LEFT JOIN ${validatedRelation.table} ${joinDescriptor.alias} ON ${joinDescriptor.alias}.${validatedRelation.targetKey} = ${context.ownerAlias}.${validatedRelation.sourceKey}`);
481
+ context.joinCollection.selects.push(...Object.entries(joinDescriptor.columns).map(([column, alias]) => `${joinDescriptor.alias}.${column} AS ${alias}`));
482
+ }
483
+ }
484
+ const ownerSourceAccessor = node.loadMode === InternalRelationHydrationLoadMode.PREFETCH && context.collectRootJoins === false && context.ownerAlias === context.rootTable && context.rootSelectedFields?.length && !context.rootSelectedFields.includes(validatedRelation.sourceKey) ? this.buildPrefetchSourceAlias(node.relationPath, validatedRelation.sourceKey) : validatedRelation.sourceKey;
485
+ if (node.loadMode === InternalRelationHydrationLoadMode.PREFETCH && ownerSourceAccessor !== validatedRelation.sourceKey) context.hiddenRootAliases.push(ownerSourceAccessor);
486
+ return {
487
+ nodeId: node.nodeId,
488
+ relationName: node.relationName,
489
+ relationPath: node.relationPath,
490
+ ownerModelKey: node.ownerModelKey,
491
+ targetModelKey: node.targetModelKey,
492
+ loadMode: node.loadMode,
493
+ cardinality: node.cardinality,
494
+ sourceKey: validatedRelation.sourceKey,
495
+ ownerSourceAccessor,
496
+ targetKey: validatedRelation.targetKey,
497
+ targetTable: validatedRelation.table,
498
+ targetPrimaryKey: node.relationEdge.targetPrimaryKey,
499
+ targetColumns,
500
+ provenance: node.provenance,
501
+ joinChildren: compiledJoinChildren,
502
+ prefetchChildren: compiledPrefetchChildren,
503
+ join: joinDescriptor
504
+ };
314
505
  }
315
- buildPrefetchSourceAlias(sourceKey) {
316
- return this.assertInternalAliasDoesNotCollide(`__tango_prefetch_${sourceKey}`);
506
+ validateHydrationRelation(ownerMeta, relationName) {
507
+ return sqlSafetyAdapter.validate({
508
+ kind: "select",
509
+ meta: ownerMeta,
510
+ relationNames: [relationName]
511
+ }).relations[relationName];
512
+ }
513
+ buildRootHiddenSelects(nodes, table) {
514
+ return nodes.flatMap((node) => {
515
+ const select = node.ownerSourceAccessor !== node.sourceKey ? [`${table}.${node.sourceKey} AS ${node.ownerSourceAccessor}`] : [];
516
+ return [...select, ...this.buildRootHiddenSelects(node.prefetchChildren, table)];
517
+ });
518
+ }
519
+ validatePrefetchTarget(node) {
520
+ try {
521
+ const validated = sqlSafetyAdapter.validate({
522
+ kind: "select",
523
+ meta: {
524
+ table: node.targetTable,
525
+ pk: node.targetPrimaryKey,
526
+ columns: node.targetColumns
527
+ },
528
+ filterKeys: [node.targetKey]
529
+ });
530
+ return {
531
+ table: validated.meta.table,
532
+ primaryKey: validated.meta.pk,
533
+ targetKey: validated.filterKeys[node.targetKey].field,
534
+ columns: validated.meta.columns
535
+ };
536
+ } catch (error) {
537
+ const message = isError(error) ? error.message : String(error);
538
+ throw new Error(`Compiled prefetch query failed validation: ${message}`, { cause: error });
539
+ }
540
+ }
541
+ collectNestedJoinSql(node, ownerAlias, ownerColumns, collection) {
542
+ if (!node.join) return;
543
+ const validatedTarget = this.validatePrefetchJoinTarget(node, ownerColumns);
544
+ const validatedJoinAlias = this.validateInternalAlias(node.join.alias);
545
+ const validatedJoinColumns = Object.fromEntries(Object.entries(node.join.columns).map(([column, alias]) => {
546
+ if (!(column in validatedTarget.columns)) throw new Error(`Compiled prefetch query failed validation: unknown nested join column '${column}'.`);
547
+ return [column, this.validateInternalAlias(alias)];
548
+ }));
549
+ collection.joins.push(`LEFT JOIN ${validatedTarget.table} ${validatedJoinAlias} ON ${validatedJoinAlias}.${validatedTarget.targetKey} = ${ownerAlias}.${node.sourceKey}`);
550
+ collection.selects.push(...Object.entries(validatedJoinColumns).map(([column, alias]) => `${validatedJoinAlias}.${column} AS ${alias}`));
551
+ for (const child of node.joinChildren) this.collectNestedJoinSql(child, validatedJoinAlias, validatedTarget.columns, collection);
552
+ }
553
+ validatePrefetchJoinTarget(node, ownerColumns) {
554
+ if (!(node.sourceKey in ownerColumns)) throw new Error(`Compiled prefetch query failed validation: unknown owner column '${node.sourceKey}' for nested join.`);
555
+ return this.validatePrefetchTarget(node);
556
+ }
557
+ validateInternalAlias(alias) {
558
+ if (!/^__tango_[A-Za-z0-9_]+$/.test(alias)) throw new Error(`Compiled prefetch query failed validation: invalid internal alias '${alias}'.`);
559
+ return alias;
560
+ }
561
+ buildJoinAlias(relationPath) {
562
+ return this.assertInternalAliasDoesNotCollide(`__tango_join_${this.sanitizeRelationPath(relationPath)}`);
563
+ }
564
+ buildPrefetchBaseAlias(relationPath) {
565
+ return this.assertInternalAliasDoesNotCollide(`__tango_prefetch_base_${this.sanitizeRelationPath(relationPath)}`);
566
+ }
567
+ buildHydrationColumnAlias(relationPath, column) {
568
+ return this.assertInternalAliasDoesNotCollide(`__tango_hydrate_${this.sanitizeRelationPath(relationPath)}_${column}`);
569
+ }
570
+ buildPrefetchSourceAlias(relationPath, sourceKey) {
571
+ return this.assertInternalAliasDoesNotCollide(`__tango_prefetch_${this.sanitizeRelationPath(relationPath)}_${sourceKey}`);
572
+ }
573
+ sanitizeRelationPath(relationPath) {
574
+ return relationPath.replace(/[^a-zA-Z0-9]+/g, "_");
317
575
  }
318
576
  assertInternalAliasDoesNotCollide(alias) {
319
577
  if (alias in this.meta.columns) throw new Error(`Internal query alias '${alias}' collides with a field on table '${this.meta.table}'.`);
@@ -497,17 +755,13 @@ var QueryCompiler = class QueryCompiler {
497
755
  var compiler_exports = {};
498
756
  __export(compiler_exports, { QueryCompiler: () => QueryCompiler });
499
757
 
500
- //#endregion
501
- //#region src/query/domain/RelationTyping.ts
502
- const InternalRelationHydrationCardinality = {
503
- SINGLE: "single",
504
- MANY: "many"
505
- };
506
-
507
758
  //#endregion
508
759
  //#region src/query/domain/index.ts
509
760
  var domain_exports = {};
510
- __export(domain_exports, { InternalRelationHydrationCardinality: () => InternalRelationHydrationCardinality });
761
+ __export(domain_exports, {
762
+ InternalRelationHydrationCardinality: () => InternalRelationHydrationCardinality,
763
+ TableMetaFactory: () => TableMetaFactory
764
+ });
511
765
 
512
766
  //#endregion
513
767
  //#region src/query/domain/internal/InternalDirection.ts
@@ -655,12 +909,13 @@ var QuerySet = class QuerySet {
655
909
  });
656
910
  }
657
911
  /**
658
- * Hydrate single-valued relations through SQL joins.
912
+ * Hydrate single-valued relation paths through SQL joins.
659
913
  *
660
914
  * Forward `belongsTo` relations can be inferred from the source model's
661
915
  * field-authored relation metadata. Reverse `hasOne` relations can be
662
916
  * selected with a target model generic when the target model points back to
663
- * the source model.
917
+ * the source model. Generated relation typing also enables nested `__`
918
+ * path keys for applications that keep the app-local registry current.
664
919
  */
665
920
  selectRelated(...rels) {
666
921
  return new QuerySet(this.executor, {
@@ -669,10 +924,12 @@ var QuerySet = class QuerySet {
669
924
  });
670
925
  }
671
926
  /**
672
- * Hydrate collection relations with a follow-up query.
927
+ * Hydrate collection-rooted relation paths with follow-up queries.
673
928
  *
674
929
  * Reverse `hasMany` relations can be prefetched with a target model generic
675
- * when the target model points back to the source model.
930
+ * when the target model points back to the source model. Generated relation
931
+ * typing also enables nested `__` path keys for applications that keep the
932
+ * app-local registry current.
676
933
  */
677
934
  prefetchRelated(...rels) {
678
935
  return new QuerySet(this.executor, {
@@ -681,7 +938,6 @@ var QuerySet = class QuerySet {
681
938
  });
682
939
  }
683
940
  async fetch(shape) {
684
- this.validateHydrationState();
685
941
  const compiler = new QueryCompiler(this.executor.meta, this.executor.dialect);
686
942
  const compiled = compiler.compile(this.state);
687
943
  const rows = await this.executor.run(compiled);
@@ -703,9 +959,8 @@ var QuerySet = class QuerySet {
703
959
  * Execute a `COUNT(*)` query for the current filtered state.
704
960
  */
705
961
  async count() {
706
- this.validateHydrationState();
707
962
  const compiler = new QueryCompiler(this.executor.meta, this.executor.dialect);
708
- const compiled = compiler.compile(this.state);
963
+ const compiled = compiler.compile(this.withoutHydrationState());
709
964
  const countQuery = `SELECT COUNT(*) as count FROM (${compiled.sql}) AS tango_count_subquery`;
710
965
  const rows = await this.executor.client.query(countQuery, compiled.params);
711
966
  return Number(rows.rows[0]?.count ?? 0);
@@ -723,84 +978,96 @@ var QuerySet = class QuerySet {
723
978
  if (booleanColumns.length === 0) return rows;
724
979
  return rows.map((row) => this.normalizeBooleanColumns(row, booleanColumns));
725
980
  }
726
- validateHydrationState() {
727
- const seen = new Set();
728
- for (const relationName of [...this.state.selectRelated ?? [], ...this.state.prefetchRelated ?? []]) {
729
- if (seen.has(relationName)) throw new Error(`Relation '${relationName}' was requested more than once.`);
730
- seen.add(relationName);
731
- const relation = this.executor.meta.relations?.[relationName];
732
- if (!relation) throw new Error(`Unknown relation '${relationName}' for table '${this.executor.meta.table}'.`);
733
- if (relation.kind === InternalRelationKind.MANY_TO_MANY) throw new Error(`Relation '${relationName}' is many-to-many and cannot be hydrated yet.`);
734
- if (relationName in this.executor.meta.columns && !(relation.kind === InternalRelationKind.BELONGS_TO && relationName === relation.sourceKey)) throw new Error(`Relation '${relationName}' on table '${this.executor.meta.table}' collides with an existing field.`);
735
- }
736
- for (const relationName of this.state.selectRelated ?? []) {
737
- const relation = this.executor.meta.relations[relationName];
738
- if (relation.kind !== InternalRelationKind.BELONGS_TO && relation.kind !== InternalRelationKind.HAS_ONE) throw new Error(`Relation '${relationName}' cannot be loaded with selectRelated(...).`);
739
- }
740
- for (const relationName of this.state.prefetchRelated ?? []) {
741
- const relation = this.executor.meta.relations[relationName];
742
- if (relation.kind !== InternalRelationKind.HAS_MANY) throw new Error(`Relation '${relationName}' cannot be loaded with prefetchRelated(...).`);
743
- }
744
- }
745
981
  async hydrateRows(rows, compiled) {
746
- const selectedRows = this.hydrateSelectedRows(rows, compiled);
747
- return this.hydratePrefetchedRows(selectedRows, compiled);
748
- }
749
- hydrateSelectedRows(rows, compiled) {
750
- const hydrations = compiled.hydrations;
751
- if (!hydrations?.length) return rows;
752
- return rows.map((row) => {
753
- const next = { ...row };
754
- for (const hydration of hydrations) {
755
- const target = {};
756
- let hasTargetValue = false;
757
- for (const [column, alias] of Object.entries(hydration.columns)) {
758
- const value = next[alias];
759
- delete next[alias];
760
- target[column] = this.normalizeTargetValue(hydration.relationName, column, value);
761
- if (value !== null && value !== undefined) hasTargetValue = true;
762
- }
763
- next[hydration.relationName] = hasTargetValue ? target : null;
764
- }
765
- return next;
766
- });
982
+ if (!compiled.hydrationPlan) return rows;
983
+ const hydratedRows = rows.map((row) => ({ ...row }));
984
+ const canonicalEntities = new Map();
985
+ const queuedJoinPrefetchOwners = new Map();
986
+ const compiler = new QueryCompiler(this.executor.meta, this.executor.dialect);
987
+ for (const row of hydratedRows) this.hydrateJoinNodesForOwner(row, row, compiled.hydrationPlan.joinNodes, canonicalEntities, queuedJoinPrefetchOwners);
988
+ for (const node of compiled.hydrationPlan.prefetchNodes) await this.hydratePrefetchNode(node, hydratedRows, canonicalEntities, compiler);
989
+ for (const [node, owners] of queuedJoinPrefetchOwners.entries()) await this.hydratePrefetchNode(node, [...owners], canonicalEntities, compiler);
990
+ for (const row of hydratedRows) for (const alias of compiled.hydrationPlan.hiddenRootAliases) delete row[alias];
991
+ return hydratedRows;
767
992
  }
768
- async hydratePrefetchedRows(rows, compiled) {
769
- if (!compiled.prefetches?.length || rows.length === 0) return rows;
770
- const prefetchGroups = await Promise.all(compiled.prefetches.map(async (prefetch) => {
771
- const sourceValues = rows.map((row) => row[prefetch.sourceKeyAlias ?? prefetch.sourceKey]).filter((value) => typeof value === "string" || typeof value === "number");
772
- const uniqueSourceValues = [...new Set(sourceValues)];
773
- return {
774
- prefetch,
775
- grouped: await this.fetchPrefetchGroup(prefetch, uniqueSourceValues)
776
- };
777
- }));
778
- const hiddenSourceAliases = new Set(compiled.prefetches.map((prefetch) => prefetch.sourceKeyAlias).filter((alias) => typeof alias === "string"));
779
- return rows.map((row) => {
780
- const next = { ...row };
781
- for (const { prefetch, grouped } of prefetchGroups) {
782
- const sourceValue = row[prefetch.sourceKeyAlias ?? prefetch.sourceKey];
783
- next[prefetch.relationName] = typeof sourceValue === "string" || typeof sourceValue === "number" ? grouped.get(sourceValue) ?? [] : [];
993
+ hydrateJoinNodesForOwner(owner, rawRow, nodes, canonicalEntities, queuedJoinPrefetchOwners) {
994
+ for (const node of nodes) {
995
+ if (!node.join) continue;
996
+ const target = {};
997
+ let hasTargetValue = false;
998
+ for (const [column, alias] of Object.entries(node.join.columns)) {
999
+ const value = rawRow[alias];
1000
+ delete rawRow[alias];
1001
+ target[column] = this.normalizeColumnValue(node.targetColumns[column], value);
1002
+ if (value !== null && value !== undefined) hasTargetValue = true;
784
1003
  }
785
- for (const alias of hiddenSourceAliases) delete next[alias];
786
- return next;
787
- });
1004
+ if (!hasTargetValue) {
1005
+ owner[node.relationName] = null;
1006
+ continue;
1007
+ }
1008
+ const canonical = this.canonicalizeEntity(node, target, canonicalEntities);
1009
+ owner[node.relationName] = canonical;
1010
+ for (const childNode of node.prefetchChildren) {
1011
+ const queuedOwners = queuedJoinPrefetchOwners?.get(childNode);
1012
+ if (queuedOwners) {
1013
+ queuedOwners.add(canonical);
1014
+ continue;
1015
+ }
1016
+ queuedJoinPrefetchOwners?.set(childNode, new Set([canonical]));
1017
+ }
1018
+ this.hydrateJoinNodesForOwner(canonical, rawRow, node.joinChildren, canonicalEntities, queuedJoinPrefetchOwners);
1019
+ }
788
1020
  }
789
- async fetchPrefetchGroup(prefetch, sourceValues) {
790
- const compiledPrefetch = new QueryCompiler(this.executor.meta, this.executor.dialect).compilePrefetch(prefetch, sourceValues);
791
- const grouped = new Map();
792
- if (sourceValues.length === 0) return grouped;
1021
+ async hydratePrefetchNode(node, owners, canonicalEntities, compiler) {
1022
+ if (owners.length === 0) return;
1023
+ const groupedOwners = this.groupOwnersByAccessor(owners, node.ownerSourceAccessor);
1024
+ const sourceValues = [...groupedOwners.keys()];
1025
+ for (const owner of owners) owner[node.relationName] = node.cardinality === InternalRelationHydrationCardinality.MANY ? [] : null;
1026
+ if (sourceValues.length === 0) return;
1027
+ const compiledPrefetch = compiler.compilePrefetch(node, sourceValues);
793
1028
  const result = await this.executor.client.query(compiledPrefetch.sql, compiledPrefetch.params);
794
- for (const row of result.rows) {
795
- const normalized = this.normalizeTargetRow(compiledPrefetch, row);
1029
+ const groupedTargets = new Map();
1030
+ const canonicalChildren = new Map();
1031
+ for (const rawResultRow of result.rows) {
1032
+ const normalized = this.normalizeTargetRow(compiledPrefetch, rawResultRow);
1033
+ const canonical = this.canonicalizeEntity(node, normalized, canonicalEntities);
1034
+ this.hydrateJoinNodesForOwner(canonical, normalized, node.joinChildren, canonicalEntities);
796
1035
  const key = normalized[compiledPrefetch.targetKey];
797
1036
  if (typeof key !== "string" && typeof key !== "number") continue;
1037
+ const bucket = groupedTargets.get(key) ?? [];
1038
+ bucket.push(canonical);
1039
+ groupedTargets.set(key, bucket);
1040
+ const childPrimaryKey = canonical[node.targetPrimaryKey];
1041
+ if (typeof childPrimaryKey === "string" || typeof childPrimaryKey === "number") canonicalChildren.set(childPrimaryKey, canonical);
1042
+ }
1043
+ for (const [sourceValue, grouped] of groupedTargets.entries()) for (const owner of groupedOwners.get(sourceValue) ?? []) owner[node.relationName] = node.cardinality === InternalRelationHydrationCardinality.MANY ? grouped : grouped[0];
1044
+ const childOwners = [...canonicalChildren.values()];
1045
+ for (const childNode of node.prefetchChildren) await this.hydratePrefetchNode(childNode, childOwners, canonicalEntities, compiler);
1046
+ }
1047
+ groupOwnersByAccessor(owners, accessor) {
1048
+ const grouped = new Map();
1049
+ for (const owner of owners) {
1050
+ const key = owner[accessor];
1051
+ if (typeof key !== "string" && typeof key !== "number") continue;
798
1052
  const bucket = grouped.get(key) ?? [];
799
- bucket.push(normalized);
1053
+ bucket.push(owner);
800
1054
  grouped.set(key, bucket);
801
1055
  }
802
1056
  return grouped;
803
1057
  }
1058
+ canonicalizeEntity(node, row, canonicalEntities) {
1059
+ const primaryKeyValue = row[node.targetPrimaryKey];
1060
+ if (typeof primaryKeyValue !== "string" && typeof primaryKeyValue !== "number") return row;
1061
+ const byModel = canonicalEntities.get(node.targetModelKey) ?? new Map();
1062
+ const existing = byModel.get(primaryKeyValue);
1063
+ if (existing) {
1064
+ Object.assign(existing, row);
1065
+ return existing;
1066
+ }
1067
+ byModel.set(primaryKeyValue, row);
1068
+ canonicalEntities.set(node.targetModelKey, byModel);
1069
+ return row;
1070
+ }
804
1071
  normalizeTargetRow(prefetch, row) {
805
1072
  if (this.executor.dialect !== InternalDialect.SQLITE) return row;
806
1073
  let normalized = null;
@@ -813,10 +1080,8 @@ var QuerySet = class QuerySet {
813
1080
  }
814
1081
  return normalized ?? row;
815
1082
  }
816
- normalizeTargetValue(relationName, column, value) {
817
- if (this.executor.dialect !== InternalDialect.SQLITE) return value;
818
- const relation = this.executor.meta.relations[relationName];
819
- return this.isBooleanColumnType(relation.targetColumns[column]) ? this.normalizeSqliteBoolean(value) : value;
1083
+ normalizeColumnValue(columnType, value) {
1084
+ return this.executor.dialect === InternalDialect.SQLITE && this.isBooleanColumnType(columnType) ? this.normalizeSqliteBoolean(value) : value;
820
1085
  }
821
1086
  isBooleanColumnType(value) {
822
1087
  return typeof value === "string" && ["bool", "boolean"].includes(value.trim().toLowerCase());
@@ -837,6 +1102,10 @@ var QuerySet = class QuerySet {
837
1102
  }
838
1103
  return normalized ?? row;
839
1104
  }
1105
+ withoutHydrationState() {
1106
+ const { selectRelated: _selectRelated, prefetchRelated: _prefetchRelated,...rest } = this.state;
1107
+ return rest;
1108
+ }
840
1109
  };
841
1110
 
842
1111
  //#endregion
@@ -852,5 +1121,5 @@ __export(query_exports, {
852
1121
  });
853
1122
 
854
1123
  //#endregion
855
- export { InternalRelationKind, OrmSqlSafetyAdapter, QBuilder, QueryCompiler, QuerySet, compiler_exports, domain_exports, query_exports };
856
- //# sourceMappingURL=query-CWZ1cfjo.js.map
1124
+ export { OrmSqlSafetyAdapter, QBuilder, QueryCompiler, QuerySet, TableMetaFactory, compiler_exports, domain_exports, query_exports };
1125
+ //# sourceMappingURL=query-DYiJ5m_B.js.map