@rvoh/dream 2.22.0 → 2.24.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.
Files changed (211) hide show
  1. package/dist/cjs/src/Dream.js +68 -23
  2. package/dist/cjs/src/dream/Query.js +39 -8
  3. package/dist/cjs/src/dream/QueryDriver/Kysely.js +30 -2
  4. package/dist/cjs/src/dream/internal/buildSerializerPreloadPaths.js +93 -20
  5. package/dist/cjs/src/dream/internal/convertDreamClassAndAssociationNameTupleArrayToPreloadArgs.js +18 -10
  6. package/dist/cjs/src/dream/internal/mergeSerializerAssociationEdges.js +75 -0
  7. package/dist/cjs/src/dream/internal/printSerializerHierarchyLevel.js +50 -8
  8. package/dist/cjs/src/dream/internal/resolveSerializerAssociationEdges.js +79 -14
  9. package/dist/cjs/src/errors/serializers/NoSerializersResolvedForKey.js +30 -0
  10. package/dist/cjs/src/serializer/helpers/inferSerializerFromDreamOrViewModel.js +22 -0
  11. package/dist/cjs/src/serializer/helpers/serializerForAssociatedClass.js +13 -0
  12. package/dist/esm/src/Dream.js +68 -23
  13. package/dist/esm/src/dream/Query.js +39 -8
  14. package/dist/esm/src/dream/QueryDriver/Kysely.js +30 -2
  15. package/dist/esm/src/dream/internal/buildSerializerPreloadPaths.js +93 -20
  16. package/dist/esm/src/dream/internal/convertDreamClassAndAssociationNameTupleArrayToPreloadArgs.js +18 -10
  17. package/dist/esm/src/dream/internal/mergeSerializerAssociationEdges.js +75 -0
  18. package/dist/esm/src/dream/internal/printSerializerHierarchyLevel.js +50 -8
  19. package/dist/esm/src/dream/internal/resolveSerializerAssociationEdges.js +79 -14
  20. package/dist/esm/src/errors/serializers/NoSerializersResolvedForKey.js +30 -0
  21. package/dist/esm/src/serializer/helpers/inferSerializerFromDreamOrViewModel.js +22 -0
  22. package/dist/esm/src/serializer/helpers/serializerForAssociatedClass.js +13 -0
  23. package/dist/types/src/Dream.d.ts +9 -0
  24. package/dist/types/src/dream/Query.d.ts +10 -0
  25. package/dist/types/src/dream/internal/mergeSerializerAssociationEdges.d.ts +69 -0
  26. package/dist/types/src/dream/internal/printSerializerHierarchyLevel.d.ts +23 -5
  27. package/dist/types/src/dream/internal/resolveSerializerAssociationEdges.d.ts +27 -1
  28. package/dist/types/src/errors/serializers/NoSerializersResolvedForKey.d.ts +18 -0
  29. package/dist/types/src/serializer/helpers/inferSerializerFromDreamOrViewModel.d.ts +16 -0
  30. package/docs/classes/db.DreamMigrationHelpers.html +11 -11
  31. package/docs/classes/db.KyselyQueryDriver.html +40 -40
  32. package/docs/classes/db.PostgresQueryDriver.html +41 -41
  33. package/docs/classes/db.QueryDriverBase.html +39 -39
  34. package/docs/classes/errors.CheckConstraintViolation.html +3 -3
  35. package/docs/classes/errors.ColumnOverflow.html +3 -3
  36. package/docs/classes/errors.CreateOrFindByFailedToCreateAndFind.html +3 -3
  37. package/docs/classes/errors.DataIncompatibleWithDatabaseField.html +3 -3
  38. package/docs/classes/errors.DataTypeColumnTypeMismatch.html +3 -3
  39. package/docs/classes/errors.DecryptionError.html +2 -2
  40. package/docs/classes/errors.DecryptionParseError.html +2 -2
  41. package/docs/classes/errors.DecryptionRotationError.html +3 -3
  42. package/docs/classes/errors.GlobalNameNotSet.html +3 -3
  43. package/docs/classes/errors.InvalidCalendarDate.html +2 -2
  44. package/docs/classes/errors.InvalidClockTime.html +2 -2
  45. package/docs/classes/errors.InvalidClockTimeTz.html +2 -2
  46. package/docs/classes/errors.InvalidDateTime.html +2 -2
  47. package/docs/classes/errors.MissingSerializersDefinition.html +3 -3
  48. package/docs/classes/errors.NonLoadedAssociation.html +3 -3
  49. package/docs/classes/errors.NotNullViolation.html +3 -3
  50. package/docs/classes/errors.RecordNotFound.html +3 -3
  51. package/docs/classes/errors.ValidationError.html +3 -3
  52. package/docs/classes/index.CalendarDate.html +33 -33
  53. package/docs/classes/index.ClockTime.html +32 -32
  54. package/docs/classes/index.ClockTimeTz.html +35 -35
  55. package/docs/classes/index.DateTime.html +86 -86
  56. package/docs/classes/index.Decorators.html +19 -19
  57. package/docs/classes/index.Dream.html +124 -124
  58. package/docs/classes/index.DreamApp.html +10 -10
  59. package/docs/classes/index.DreamTransaction.html +2 -2
  60. package/docs/classes/index.Env.html +2 -2
  61. package/docs/classes/index.Query.html +62 -62
  62. package/docs/classes/system.CliFileWriter.html +4 -4
  63. package/docs/classes/system.DreamBin.html +2 -2
  64. package/docs/classes/system.DreamCLI.html +7 -7
  65. package/docs/classes/system.DreamImporter.html +2 -2
  66. package/docs/classes/system.DreamLogos.html +2 -2
  67. package/docs/classes/system.DreamSerializerBuilder.html +11 -11
  68. package/docs/classes/system.ObjectSerializerBuilder.html +8 -8
  69. package/docs/classes/system.PathHelpers.html +3 -3
  70. package/docs/classes/utils.Encrypt.html +3 -3
  71. package/docs/classes/utils.Range.html +2 -2
  72. package/docs/functions/db.closeAllDbConnections.html +1 -1
  73. package/docs/functions/db.dreamDbConnections.html +1 -1
  74. package/docs/functions/db.untypedDb.html +1 -1
  75. package/docs/functions/db.validateColumn.html +1 -1
  76. package/docs/functions/db.validateTable.html +1 -1
  77. package/docs/functions/errors.pgErrorType.html +1 -1
  78. package/docs/functions/index.DreamSerializer.html +1 -1
  79. package/docs/functions/index.ObjectSerializer.html +1 -1
  80. package/docs/functions/index.ReplicaSafe.html +1 -1
  81. package/docs/functions/index.STI.html +1 -1
  82. package/docs/functions/index.SoftDelete.html +1 -1
  83. package/docs/functions/utils.camelize.html +1 -1
  84. package/docs/functions/utils.capitalize.html +1 -1
  85. package/docs/functions/utils.cloneDeepSafe.html +1 -1
  86. package/docs/functions/utils.compact.html +1 -1
  87. package/docs/functions/utils.groupBy.html +1 -1
  88. package/docs/functions/utils.hyphenize.html +1 -1
  89. package/docs/functions/utils.intersection.html +1 -1
  90. package/docs/functions/utils.isEmpty.html +1 -1
  91. package/docs/functions/utils.normalizeUnicode.html +1 -1
  92. package/docs/functions/utils.pascalize.html +1 -1
  93. package/docs/functions/utils.percent.html +1 -1
  94. package/docs/functions/utils.range.html +1 -1
  95. package/docs/functions/utils.round.html +1 -1
  96. package/docs/functions/utils.sanitizeString.html +1 -1
  97. package/docs/functions/utils.snakeify.html +1 -1
  98. package/docs/functions/utils.sort.html +1 -1
  99. package/docs/functions/utils.sortBy.html +1 -1
  100. package/docs/functions/utils.sortObjectByKey.html +1 -1
  101. package/docs/functions/utils.sortObjectByValue.html +1 -1
  102. package/docs/functions/utils.uncapitalize.html +1 -1
  103. package/docs/functions/utils.uniq.html +1 -1
  104. package/docs/interfaces/openapi.OpenapiDescription.html +2 -2
  105. package/docs/interfaces/openapi.OpenapiSchemaProperties.html +1 -1
  106. package/docs/interfaces/openapi.OpenapiSchemaPropertiesShorthand.html +1 -1
  107. package/docs/interfaces/openapi.OpenapiTypeFieldObject.html +1 -1
  108. package/docs/interfaces/types.BelongsToStatement.html +2 -2
  109. package/docs/interfaces/types.DecoratorContext.html +2 -2
  110. package/docs/interfaces/types.DreamAppInitOptions.html +2 -2
  111. package/docs/interfaces/types.DreamAppOpts.html +2 -2
  112. package/docs/interfaces/types.DreamDbConfig.html +5 -5
  113. package/docs/interfaces/types.DurationObject.html +2 -2
  114. package/docs/interfaces/types.EncryptOptions.html +2 -2
  115. package/docs/interfaces/types.InternalAnyTypedSerializerRendersMany.html +2 -2
  116. package/docs/interfaces/types.InternalAnyTypedSerializerRendersOne.html +2 -2
  117. package/docs/interfaces/types.SerializerRendererOpts.html +2 -2
  118. package/docs/types/openapi.CommonOpenapiSchemaObjectFields.html +1 -1
  119. package/docs/types/openapi.OpenapiAllTypes.html +1 -1
  120. package/docs/types/openapi.OpenapiFormats.html +1 -1
  121. package/docs/types/openapi.OpenapiNumberFormats.html +1 -1
  122. package/docs/types/openapi.OpenapiPrimitiveBaseTypes.html +1 -1
  123. package/docs/types/openapi.OpenapiPrimitiveTypes.html +1 -1
  124. package/docs/types/openapi.OpenapiSchemaArray.html +1 -1
  125. package/docs/types/openapi.OpenapiSchemaArrayShorthand.html +1 -1
  126. package/docs/types/openapi.OpenapiSchemaBase.html +1 -1
  127. package/docs/types/openapi.OpenapiSchemaBody.html +1 -1
  128. package/docs/types/openapi.OpenapiSchemaBodyShorthand.html +1 -1
  129. package/docs/types/openapi.OpenapiSchemaCommonFields.html +1 -1
  130. package/docs/types/openapi.OpenapiSchemaExpressionAllOf.html +2 -2
  131. package/docs/types/openapi.OpenapiSchemaExpressionAnyOf.html +2 -2
  132. package/docs/types/openapi.OpenapiSchemaExpressionOneOf.html +2 -2
  133. package/docs/types/openapi.OpenapiSchemaExpressionRef.html +2 -2
  134. package/docs/types/openapi.OpenapiSchemaExpressionRefSchemaShorthand.html +2 -2
  135. package/docs/types/openapi.OpenapiSchemaInteger.html +1 -1
  136. package/docs/types/openapi.OpenapiSchemaNull.html +2 -2
  137. package/docs/types/openapi.OpenapiSchemaNumber.html +1 -1
  138. package/docs/types/openapi.OpenapiSchemaObject.html +1 -1
  139. package/docs/types/openapi.OpenapiSchemaObjectAllOf.html +1 -1
  140. package/docs/types/openapi.OpenapiSchemaObjectAllOfShorthand.html +1 -1
  141. package/docs/types/openapi.OpenapiSchemaObjectAnyOf.html +1 -1
  142. package/docs/types/openapi.OpenapiSchemaObjectAnyOfShorthand.html +1 -1
  143. package/docs/types/openapi.OpenapiSchemaObjectBase.html +1 -1
  144. package/docs/types/openapi.OpenapiSchemaObjectBaseShorthand.html +1 -1
  145. package/docs/types/openapi.OpenapiSchemaObjectOneOf.html +1 -1
  146. package/docs/types/openapi.OpenapiSchemaObjectOneOfShorthand.html +1 -1
  147. package/docs/types/openapi.OpenapiSchemaObjectShorthand.html +1 -1
  148. package/docs/types/openapi.OpenapiSchemaPrimitiveGeneric.html +1 -1
  149. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAllOf.html +2 -2
  150. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAnyOf.html +2 -2
  151. package/docs/types/openapi.OpenapiSchemaShorthandExpressionOneOf.html +2 -2
  152. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializableRef.html +2 -2
  153. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializerRef.html +2 -2
  154. package/docs/types/openapi.OpenapiSchemaShorthandPrimitiveGeneric.html +1 -1
  155. package/docs/types/openapi.OpenapiSchemaString.html +1 -1
  156. package/docs/types/openapi.OpenapiShorthandAllTypes.html +1 -1
  157. package/docs/types/openapi.OpenapiShorthandPrimitiveBaseTypes.html +1 -1
  158. package/docs/types/openapi.OpenapiShorthandPrimitiveTypes.html +1 -1
  159. package/docs/types/openapi.OpenapiTypeField.html +1 -1
  160. package/docs/types/system.DreamAppAllowedPackageManagersEnum.html +1 -1
  161. package/docs/types/types.CalendarDateDurationUnit.html +1 -1
  162. package/docs/types/types.CalendarDateObject.html +1 -1
  163. package/docs/types/types.Camelized.html +1 -1
  164. package/docs/types/types.ClockTimeObject.html +1 -1
  165. package/docs/types/types.DbConnectionType.html +1 -1
  166. package/docs/types/types.DbTypes.html +1 -1
  167. package/docs/types/types.DreamAssociationMetadata.html +1 -1
  168. package/docs/types/types.DreamAttributes.html +1 -1
  169. package/docs/types/types.DreamClassAssociationAndStatement.html +1 -1
  170. package/docs/types/types.DreamClassColumn.html +1 -1
  171. package/docs/types/types.DreamColumn.html +1 -1
  172. package/docs/types/types.DreamColumnNames.html +1 -1
  173. package/docs/types/types.DreamLogLevel.html +1 -1
  174. package/docs/types/types.DreamLogger.html +2 -2
  175. package/docs/types/types.DreamModelSerializerType.html +1 -1
  176. package/docs/types/types.DreamOrViewModelClassSerializerKey.html +1 -1
  177. package/docs/types/types.DreamOrViewModelSerializerKey.html +1 -1
  178. package/docs/types/types.DreamParamSafeAttributes.html +1 -1
  179. package/docs/types/types.DreamParamSafeColumnNames.html +1 -1
  180. package/docs/types/types.DreamSerializable.html +1 -1
  181. package/docs/types/types.DreamSerializableArray.html +1 -1
  182. package/docs/types/types.DreamSerializerKey.html +1 -1
  183. package/docs/types/types.DreamSerializers.html +1 -1
  184. package/docs/types/types.DreamVirtualColumns.html +1 -1
  185. package/docs/types/types.DurationUnit.html +1 -1
  186. package/docs/types/types.EncryptAlgorithm.html +1 -1
  187. package/docs/types/types.HasManyStatement.html +1 -1
  188. package/docs/types/types.HasOneStatement.html +1 -1
  189. package/docs/types/types.Hyphenized.html +1 -1
  190. package/docs/types/types.Pascalized.html +1 -1
  191. package/docs/types/types.PrimaryKeyType.html +1 -1
  192. package/docs/types/types.RoundingPrecision.html +1 -1
  193. package/docs/types/types.SerializerCasing.html +1 -1
  194. package/docs/types/types.SimpleObjectSerializerType.html +1 -1
  195. package/docs/types/types.Snakeified.html +1 -1
  196. package/docs/types/types.StrictInterface.html +1 -1
  197. package/docs/types/types.UpdateableAssociationProperties.html +1 -1
  198. package/docs/types/types.UpdateableProperties.html +1 -1
  199. package/docs/types/types.ValidationType.html +1 -1
  200. package/docs/types/types.ViewModel.html +2 -2
  201. package/docs/types/types.ViewModelClass.html +1 -1
  202. package/docs/types/types.WeekdayName.html +1 -1
  203. package/docs/types/types.WhereStatementForDream.html +1 -1
  204. package/docs/types/types.WhereStatementForDreamClass.html +1 -1
  205. package/docs/variables/index.DreamConst.html +1 -1
  206. package/docs/variables/index.ops.html +1 -1
  207. package/docs/variables/openapi.openapiPrimitiveTypes.html +1 -1
  208. package/docs/variables/openapi.openapiShorthandPrimitiveTypes.html +1 -1
  209. package/docs/variables/system.DreamAppAllowedPackageManagersEnumValues.html +1 -1
  210. package/docs/variables/system.primaryKeyTypes.html +1 -1
  211. package/package.json +1 -1
@@ -1,19 +1,78 @@
1
- import MissingSerializersDefinition from '../../errors/serializers/MissingSerializersDefinition.js';
2
1
  import compact from '../../helpers/compact.js';
3
2
  import { inferSerializersFromDreamClassOrViewModelClass } from '../../serializer/helpers/inferSerializerFromDreamOrViewModel.js';
3
+ /**
4
+ * Keyed on the two arguments. Weak on both, so an entry is collectable once either the Dream class
5
+ * or the serializer is. The cached arrays are handed out by reference and must be treated as
6
+ * read-only by callers.
7
+ */
8
+ let resolvedEdgesCache = new WeakMap();
9
+ /**
10
+ * @internal
11
+ *
12
+ * Drops every memoized edge set. Only needed when a class object that stays in the process has its
13
+ * `serializers` getter replaced, which the memo cannot detect — specs that install or remove one
14
+ * around an example. Replacing the model classes themselves needs no call, since the memo keys on
15
+ * the class and serializer objects and entries resolved against the previous ones become
16
+ * unreachable.
17
+ */
18
+ export function clearResolvedSerializerAssociationEdgesCache() {
19
+ resolvedEdgesCache = new WeakMap();
20
+ }
21
+ /**
22
+ * @internal
23
+ *
24
+ * Resolves the rendersOne/rendersMany/delegatedAttribute attributes of `serializer` to the
25
+ * associations of `dreamClass` they preload, along with the classes and serializers each one
26
+ * targets.
27
+ *
28
+ * Memoized on its two arguments, because every call rebuilds a throwaway serializer builder and
29
+ * re-infers the serializers of every association target, and the traversal in
30
+ * buildSerializerPreloadPaths revisits the same (class, serializer) pair many times.
31
+ *
32
+ * The two arguments are not the function's only inputs, though: resolving a target's serializers
33
+ * also reads that target's `serializers` getter, the STI children registered on it, and the
34
+ * DreamApp serializer registry. Loading a fresh set of classes replaces the keys along with that
35
+ * state, so the memo follows it. Mutating a `serializers` getter in place does not, and needs
36
+ * `clearResolvedSerializerAssociationEdgesCache` — see there.
37
+ */
4
38
  export default function resolveSerializerAssociationEdges(dreamClass, serializer) {
39
+ let cachedForDreamClass = resolvedEdgesCache.get(dreamClass);
40
+ if (cachedForDreamClass === undefined) {
41
+ cachedForDreamClass = new WeakMap();
42
+ resolvedEdgesCache.set(dreamClass, cachedForDreamClass);
43
+ }
44
+ const cached = cachedForDreamClass.get(serializer);
45
+ if (cached !== undefined)
46
+ return cached;
47
+ const edges = buildSerializerAssociationEdges(dreamClass, serializer);
48
+ cachedForDreamClass.set(serializer, edges);
49
+ return edges;
50
+ }
51
+ function buildSerializerAssociationEdges(dreamClass, serializer) {
5
52
  const serializerBuilder = serializer(undefined, undefined);
6
53
  const serializerAssociations = serializerBuilder['attributes'].filter(attribute => ['rendersOne', 'rendersMany', 'delegatedAttribute'].includes(attribute.type));
7
54
  return compact(serializerAssociations.map(serializerAssociation => {
8
55
  const serializerAssociationName = serializerAssociation.targetName ??
9
56
  serializerAssociation.name;
57
+ // `dreamClass` is the class the walk is rooted at, which for an STI base is the base itself
58
+ // even while `serializer` is one child's serializer. That pairing is safe because an STI child
59
+ // cannot declare associations of its own — the `STI` class decorator throws
60
+ // StiChildCannotDefineNewAssociations — so every association a child's serializer can render
61
+ // is declared on the base and resolves here.
62
+ //
63
+ // The one hole: that guard reads decorator metadata and
64
+ // `associationDeclarationNamesFromMetadata` returns [] when the metadata is absent, which is
65
+ // also what happens when the `STI` decorator runs without a `context`. Under a TS config that
66
+ // does not emit decorator metadata the guard silently passes, a child-declared association
67
+ // survives, and it is dropped *here*, silently, by the return below — the symptom is then a
68
+ // missing preload and a NonLoadedAssociation at render, not a decorator error naming the real
69
+ // problem.
10
70
  const association = dreamClass['getAssociationMetadata'](serializerAssociationName);
11
71
  if (!association)
12
72
  return null;
13
73
  if (serializerAssociation.type === 'delegatedAttribute') {
14
74
  return {
15
75
  associationAs: association.as,
16
- sourceDreamClass: dreamClass,
17
76
  type: serializerAssociation.type,
18
77
  serializerAssociationName,
19
78
  targets: [],
@@ -26,17 +85,24 @@ export default function resolveSerializerAssociationEdges(dreamClass, serializer
26
85
  ? maybeAssociatedClasses
27
86
  : [maybeAssociatedClasses];
28
87
  const targets = associatedClasses.flatMap(associatedClass => {
29
- let serializers = [];
30
- try {
31
- serializers = serializerAssociation.options.serializer
32
- ? compact([serializerAssociation.options.serializer])
33
- : compact(inferSerializersFromDreamClassOrViewModelClass(associatedClass, serializerAssociation.options.serializerKey));
34
- }
35
- catch (error) {
36
- if (!(error instanceof MissingSerializersDefinition))
37
- throw error;
38
- serializers = [];
39
- }
88
+ // Deliberately unguarded: every serializer that cannot be resolved while building preload
89
+ // paths throws, and there is no tolerance here by design.
90
+ //
91
+ // Why: an unresolvable serializer is a configuration error, and the first query that walks
92
+ // the serializer graph is where it should be found — no rows, no rendering required.
93
+ // Swallowing it means the only way to discover a broken `serializerKey` (or a missing
94
+ // `serializers` getter) is a spec that creates a row of the affected class *and* renders it.
95
+ //
96
+ // Why the obvious tolerance is also wrong, not merely undesirable:
97
+ // `inferSerializersFromDreamClassOrViewModelClass` below expands STI and throws on the
98
+ // *first* child that fails, so catching here would drop the serializers of *every* child of
99
+ // this target. An edge with no targets is treated as terminal by buildSerializerPreloadPaths,
100
+ // so the association is preloaded but never descended into, and a row of a *well-formed*
101
+ // sibling fails at render with NonLoadedAssociation — an error naming a class that has
102
+ // nothing wrong with it.
103
+ const serializers = serializerAssociation.options.serializer
104
+ ? compact([serializerAssociation.options.serializer])
105
+ : compact(inferSerializersFromDreamClassOrViewModelClass(associatedClass, serializerAssociation.options.serializerKey));
40
106
  return serializers.map(associatedSerializer => ({
41
107
  dreamClass: associatedClass,
42
108
  serializer: associatedSerializer,
@@ -44,7 +110,6 @@ export default function resolveSerializerAssociationEdges(dreamClass, serializer
44
110
  });
45
111
  return {
46
112
  associationAs: association.as,
47
- sourceDreamClass: dreamClass,
48
113
  type: serializerAssociation.type,
49
114
  serializerAssociationName,
50
115
  targets,
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Raised when resolving a serializer key against a class yields an empty set of serializers.
3
+ *
4
+ * This is an internal-invariant failure, not a misconfiguration: an unresolvable serializer key
5
+ * raises `MissingSerializersDefinitionForKey` (and a class with no `serializers` getter at all
6
+ * raises `MissingSerializersDefinition`) while it is being resolved, and STI expansion always
7
+ * yields at least one class, so there is no supported application shape that reaches this. It is
8
+ * typed rather than a bare `Error` so that it can be asserted on, and so that the two callers that
9
+ * guard the invariant raise the same thing.
10
+ */
11
+ export default class NoSerializersResolvedForKey extends Error {
12
+ classDef;
13
+ serializerKey;
14
+ constructor(classDef, serializerKey) {
15
+ super();
16
+ this.classDef = classDef;
17
+ this.serializerKey = serializerKey;
18
+ }
19
+ get message() {
20
+ const className = this.classDef === null || this.classDef === undefined
21
+ ? String(this.classDef)
22
+ : (this.classDef.globalName ?? this.classDef.name);
23
+ return `
24
+ No serializers resolved for \`${className}\` under serializer key \`${this.serializerKey}\`
25
+
26
+ This should be impossible — a serializer key that cannot be resolved throws while it is being
27
+ resolved, before reaching here — and indicates a bug in Dream rather than a problem with your
28
+ application. Please report it.`;
29
+ }
30
+ }
@@ -3,6 +3,7 @@ import MissingSerializersDefinition from '../../errors/serializers/MissingSerial
3
3
  import MissingSerializersDefinitionForKey from '../../errors/serializers/MissingSerializersDefinitionForKey.js';
4
4
  import NoGlobalSerializerForSpecifiedKey from '../../errors/serializers/NoGlobalSerializerForSpecifiedKey.js';
5
5
  import NonDreamSerializerDerivedFromGlobalSerializerForSpecifiedKey from '../../errors/serializers/NonDreamSerializerDerivedFromGlobalSerializerForSpecifiedKey.js';
6
+ import NoSerializersResolvedForKey from '../../errors/serializers/NoSerializersResolvedForKey.js';
6
7
  import compact from '../../helpers/compact.js';
7
8
  import expandStiClasses from '../../helpers/sti/expandStiClasses.js';
8
9
  import uniq from '../../helpers/uniq.js';
@@ -35,3 +36,24 @@ export function inferSerializersFromDreamClassOrViewModelClass(classDef, seriali
35
36
  const serializers = classes.map(classDef => inferSerializerFromDreamOrViewModel(classDef.prototype, serializerKey));
36
37
  return uniq(compact(serializers));
37
38
  }
39
+ /**
40
+ * As `inferSerializersFromDreamClassOrViewModelClass`, but guarantees a non-empty result.
41
+ *
42
+ * The guard is unreachable today, and deliberately not phrased as "no serializer found for key": a
43
+ * serializer key that a class does not register throws `MissingSerializersDefinitionForKey` (and a
44
+ * class with no `serializers` getter at all throws `MissingSerializersDefinition`) from inside the
45
+ * inference above, and `expandStiClasses` always yields at least one class. It exists only so that
46
+ * a future change to any of those cannot turn an unresolvable serializer key into a silently empty
47
+ * serializer set — which, for the callers below, means a silently empty preload set and a
48
+ * `NonLoadedAssociation` at render rather than an error naming the real problem.
49
+ *
50
+ * Used by everything that walks a serializer graph rooted on a class rather than an instance —
51
+ * preload-path building and serialization display — so that they raise one typed error between
52
+ * them.
53
+ */
54
+ export function inferSerializersFromDreamClassOrViewModelClassOrFail(classDef, serializerKey) {
55
+ const serializers = inferSerializersFromDreamClassOrViewModelClass(classDef, serializerKey);
56
+ if (serializers.length === 0)
57
+ throw new NoSerializersResolvedForKey(classDef, serializerKey);
58
+ return serializers;
59
+ }
@@ -15,5 +15,18 @@ export function serializerForAssociatedClass(object, associationName, options) {
15
15
  const associatedClass = association.modelCB();
16
16
  if (Array.isArray(associatedClass))
17
17
  throw new Error('rendersOne flatten is incompatible with a polymorphic belongs-to association');
18
+ // Taking the first is only a narrowing when `associatedClass` is an STI base, where
19
+ // inferSerializersFromDreamClassOrViewModelClass returns one serializer per child sorted by
20
+ // sanitizedName. It is correct here because there is no associated row: this function is reached
21
+ // only from SerializerRenderer's `flatten: true` branch for a *null* associated object, so no
22
+ // child of the base is the right one — the row that would have decided is absent. (rendersMany
23
+ // never reaches here and does not accept `flatten`.)
24
+ //
25
+ // It is observable, though, so it is not "nothing happens": the picked serializer is invoked and
26
+ // its output spread into the parent's payload, so the parent JSON carries the
27
+ // alphabetically-first child's flattened keys as nulls, and omits keys only a later-sorting child
28
+ // would flatten. Unioning every child's serializer here — which is what buildSerializerPreloadPaths
29
+ // does for preload paths — is meaningless: exactly one set of keys can be flattened into the
30
+ // payload.
18
31
  return inferSerializersFromDreamClassOrViewModelClass(associatedClass, options.serializerKey)[0] ?? null;
19
32
  }
@@ -19,9 +19,9 @@ import destroyDream from './dream/internal/destroyDream.js';
19
19
  import { destroyOptions, reallyDestroyOptions, undestroyOptions, } from './dream/internal/destroyOptions.js';
20
20
  import ensureSTITypeFieldIsSet from './dream/internal/ensureSTITypeFieldIsSet.js';
21
21
  import findOrCreateBy from './dream/internal/findOrCreateBy.js';
22
+ import mergeSerializerAssociationEdges from './dream/internal/mergeSerializerAssociationEdges.js';
22
23
  import printSerializerHierarchyLevel from './dream/internal/printSerializerHierarchyLevel.js';
23
24
  import reload from './dream/internal/reload.js';
24
- import resolveSerializerAssociationEdges from './dream/internal/resolveSerializerAssociationEdges.js';
25
25
  import runValidations from './dream/internal/runValidations.js';
26
26
  import saveDream from './dream/internal/saveDream.js';
27
27
  import { DEFAULT_BYPASS_ALL_DEFAULT_SCOPES, DEFAULT_DEFAULT_SCOPES_TO_BYPASS, DEFAULT_SKIP_HOOKS, } from './dream/internal/scopeHelpers.js';
@@ -48,7 +48,7 @@ import areEqual from './helpers/areEqual.js';
48
48
  import cloneDeepSafe from './helpers/cloneDeepSafe.js';
49
49
  import isJsonColumn from './helpers/db/types/isJsonColumn.js';
50
50
  import notEqual from './helpers/notEqual.js';
51
- import { inferSerializersFromDreamClassOrViewModelClass } from './serializer/helpers/inferSerializerFromDreamOrViewModel.js';
51
+ import { inferSerializersFromDreamClassOrViewModelClassOrFail } from './serializer/helpers/inferSerializerFromDreamOrViewModel.js';
52
52
  export default class Dream {
53
53
  DB;
54
54
  /**
@@ -542,44 +542,89 @@ export default class Dream {
542
542
  }
543
543
  static serializationMap(serializerKey) {
544
544
  const key = serializerKey || 'default';
545
- const serializer = inferSerializersFromDreamClassOrViewModelClass(this, key)[0] ?? null;
546
- if (!serializer)
547
- throw new Error(`unable to find serializer with key: ${key}`);
548
- return this.recursiveSerializationMap(serializer);
545
+ // Deliberately one serializer: this maps a single serializer's view of the class, and taking
546
+ // the first is only a narrowing when `this` is an STI base (where
547
+ // inferSerializersFromDreamClassOrViewModelClass returns one serializer per child, sorted by
548
+ // sanitizedName). That narrowing is deliberate and differs from `buildSerializerPreloadPaths`
549
+ // and `displaySerialization` below, both of which union every child's serializer because they
550
+ // answer "what does a query load / display for this class", not "what does one serializer
551
+ // render". Nothing in src/ calls
552
+ // this method; it exists as the single-serializer entry point onto recursiveSerializationMap and
553
+ // is exercised by spec/unit/dream/serializationMap.spec.ts, which pins the narrowing on an STI
554
+ // base so that the divergence from displaySerialization is executable rather than only
555
+ // described here. A caller that wants the union for an STI base wants displaySerialization's
556
+ // shape, not this one.
557
+ //
558
+ // Note that the narrowing is to one serializer of *this* class only: every association this
559
+ // serializer renders is still walked with the full set of serializers its target resolves to,
560
+ // so an STI base reached as a target below is unioned.
561
+ const serializer = inferSerializersFromDreamClassOrViewModelClassOrFail(this, key)[0];
562
+ return this.recursiveSerializationMap([serializer]);
549
563
  }
550
564
  static displaySerialization(serializerKey) {
551
565
  const key = serializerKey || 'default';
552
- const serializer = inferSerializersFromDreamClassOrViewModelClass(this, key)[0] ?? null;
553
- if (!serializer)
554
- throw new Error(`unable to find serializer with key: ${key}`);
566
+ /**
567
+ * When `this` is an STI base, this is one serializer per STI child (deduped), since each child
568
+ * may register a different serializer under `key`. Every one is walked, because this method
569
+ * backs `psy i:serialization`, whose purpose is verifying what gets preloaded — and
570
+ * `preloadFor` unions the preload paths of every child's serializer. Walking only one child's
571
+ * serializer would print a strict subset of what the query actually loads.
572
+ */
573
+ // ...OrFail, rather than the plain inference, so that an empty serializer set raises
574
+ // NoSerializersResolvedForKey instead of silently printing a class name with nothing under it.
575
+ // That guard is unreachable today and is documented where it lives; it is shared with
576
+ // buildSerializerPreloadPaths so that the display and the preload walk raise the same thing.
577
+ const serializers = inferSerializersFromDreamClassOrViewModelClassOrFail(this, key);
555
578
  // eslint-disable-next-line no-console
556
579
  console.log(yoctocolors.cyan(this.sanitizedName));
557
- // eslint-disable-next-line no-console
558
- console.log(yoctocolors.gray(serializer.globalName));
559
- return this.recursiveSerializationMap(serializer, {
580
+ // One line per serializer: for an STI base the displayed tree is the union across its children,
581
+ // so naming a single child's serializer under the base's own name would misattribute the tree.
582
+ serializers.forEach(serializer => {
583
+ // eslint-disable-next-line no-console
584
+ console.log(yoctocolors.gray(serializer.globalName));
585
+ });
586
+ return this.recursiveSerializationMap(serializers, {
560
587
  forDisplay: true,
561
588
  });
562
589
  }
563
- static recursiveSerializationMap(serializer, { forDisplay = false, forDisplayDepth = 0, repeatedAssociationTracker = {}, } = {}) {
590
+ /**
591
+ * @internal
592
+ *
593
+ * Maps the association tree reachable from `serializers` — every serializer that can render this
594
+ * class at this point in the walk, which is more than one when this class is an STI base. The
595
+ * serializers are merged into one edge set (see mergeSerializerAssociationEdges) rather than
596
+ * walked one at a time, so an association several of them render is visited once and the nested
597
+ * trees of a shared target merge instead of clobbering each other.
598
+ */
599
+ static recursiveSerializationMap(serializers, { forDisplay = false, forDisplayDepth = 0, repeatedAssociationTracker = {}, } = {}) {
564
600
  const depthTrackerId = this.globalName;
565
601
  repeatedAssociationTracker[depthTrackerId] ??= 0;
566
602
  if (repeatedAssociationTracker[depthTrackerId] + 1 > RECURSIVE_SERIALIZATION_MAX_REPEATS)
567
603
  return {};
568
604
  repeatedAssociationTracker[depthTrackerId]++;
569
- const edges = resolveSerializerAssociationEdges(this, serializer);
605
+ const edges = mergeSerializerAssociationEdges(this, serializers);
570
606
  const mappedAssociations = edges.reduce((accumulator, edge) => {
607
+ // An edge with no targets terminates: either a delegatedAttribute (never printed, and
608
+ // carrying `targets: []` by construction) or an association resolving to no serializer. Its
609
+ // nestedSerializerInfo is `{}` because this reduce never runs.
571
610
  const innerAssociationSerializerInfo = edge.targets.reduce((innerAccumulator, target) => {
572
- if (forDisplay && edge.type !== 'delegatedAttribute') {
573
- printSerializerHierarchyLevel({
574
- serializerAssociationType: edge.type,
575
- serializerAssociationName: edge.serializerAssociationName,
576
- associationSerializer: target.serializer,
577
- forDisplayDepth,
578
- });
611
+ if (forDisplay) {
612
+ // One heading per (association, target class), naming every serializer that renders that
613
+ // target underneath it, and then — below all of them — the union of their association
614
+ // trees, which is what `preloadFor` loads through this association.
615
+ //
616
+ // The recursion below is a single merged walk of every serializer of the target, so
617
+ // there is one tree, not one per child. Grouping the serializer names under a single
618
+ // heading is what keeps that honest — repeating the heading per serializer would leave
619
+ // the merged tree indented directly beneath the last one's name and attribute every
620
+ // sibling's associations to it.
621
+ printSerializerHierarchyLevel({ targetSerializers: target.serializers, forDisplayDepth });
579
622
  }
580
623
  return {
581
624
  ...innerAccumulator,
582
- ...target.dreamClass['recursiveSerializationMap'](target.serializer, {
625
+ // recursed once per target class with *all* of that class's serializers, so their nested
626
+ // trees merge rather than the last one winning a shallow spread
627
+ ...target.dreamClass['recursiveSerializationMap'](target.serializers.map(({ serializer }) => serializer), {
583
628
  forDisplay,
584
629
  forDisplayDepth: forDisplayDepth + 1,
585
630
  repeatedAssociationTracker,
@@ -588,7 +633,7 @@ export default class Dream {
588
633
  }, {});
589
634
  accumulator[edge.associationAs] = {
590
635
  parentDreamClass: this,
591
- nestedSerializerInfo: edge.type === 'delegatedAttribute' ? {} : innerAssociationSerializerInfo,
636
+ nestedSerializerInfo: innerAssociationSerializerInfo,
592
637
  };
593
638
  return accumulator;
594
639
  }, {});
@@ -22,8 +22,26 @@ import ops from '../ops/index.js';
22
22
  import buildSerializerPreloadPaths from './internal/buildSerializerPreloadPaths.js';
23
23
  import computedPaginatePage from './internal/computedPaginatePage.js';
24
24
  import convertDreamClassAndAssociationNameTupleArrayToPreloadArgs from './internal/convertDreamClassAndAssociationNameTupleArrayToPreloadArgs.js';
25
- // Cache for serializer preload-path results, keyed by "${globalName}:${serializerKey}"
26
- const extractedNestedPathsCache = new Map();
25
+ /**
26
+ * Cache for serializer preload-path results, keyed on the root class itself and then the serializer
27
+ * key. Keying on the class rather than its global name is what makes the cache self-invalidating:
28
+ * loading a fresh set of model classes into the process yields new class objects, so entries
29
+ * resolved against the previous ones become unreachable and collectable rather than being served
30
+ * under a name that now means something else.
31
+ */
32
+ let extractedNestedPathsCache = new WeakMap();
33
+ /**
34
+ * @internal
35
+ *
36
+ * Drops every memoized preload path. Only needed when a class object that stays in the process has
37
+ * its `serializers` getter replaced, which the cache cannot detect — specs that install or remove
38
+ * one around an example. Replacing the model classes themselves needs no call, since the cache
39
+ * keys on the class object. See `clearResolvedSerializerAssociationEdgesCache`, which has the same
40
+ * contract and is cleared alongside it.
41
+ */
42
+ export function clearSerializerPreloadPathsCache() {
43
+ extractedNestedPathsCache = new WeakMap();
44
+ }
27
45
  export default class Query {
28
46
  /**
29
47
  * @internal
@@ -560,17 +578,30 @@ export default class Query {
560
578
  * @returns A Query with all serialization associations preloaded
561
579
  */
562
580
  preloadFor(serializerKey, modifierFn) {
563
- const cacheKey = `${this.dreamClass.globalName}:${serializerKey ?? 'default'}`;
564
- let preloadArgs = extractedNestedPathsCache.get(cacheKey);
581
+ const cacheKey = serializerKey ?? 'default';
582
+ let pathsBySerializerKey = extractedNestedPathsCache.get(this.dreamClass);
583
+ if (!pathsBySerializerKey) {
584
+ pathsBySerializerKey = new Map();
585
+ extractedNestedPathsCache.set(this.dreamClass, pathsBySerializerKey);
586
+ }
587
+ let preloadArgs = pathsBySerializerKey.get(cacheKey);
565
588
  if (!preloadArgs) {
566
589
  preloadArgs = buildSerializerPreloadPaths(this.dreamClass, serializerKey);
567
- extractedNestedPathsCache.set(cacheKey, preloadArgs);
590
+ pathsBySerializerKey.set(cacheKey, preloadArgs);
568
591
  }
569
- let query = this;
592
+ // Rather than calling `preload` once per path (which deep clones the accumulating preload tree
593
+ // on every call, making this quadratic in the number of paths), clone once, flesh out every
594
+ // path against the same accumulators, and clone the Query once at the end. Iteration order is
595
+ // preserved, so a later path's `and` still wins over an earlier path's for a shared prefix.
596
+ const preloadStatements = cloneDeepSafe(this.preloadStatements);
597
+ const preloadOnStatements = cloneDeepSafe(this.preloadOnStatements);
570
598
  preloadArgs.forEach(dreamClassAndAssociationNameTupleArray => {
571
- query = query.preload(...convertDreamClassAndAssociationNameTupleArrayToPreloadArgs(dreamClassAndAssociationNameTupleArray, modifierFn));
599
+ const args = convertDreamClassAndAssociationNameTupleArrayToPreloadArgs(dreamClassAndAssociationNameTupleArray, modifierFn);
600
+ // `modifierFn` returning 'omit' prunes the path from the omitted association down, which can
601
+ // leave nothing to preload; `fleshOutJoinStatements` treats an empty arg list as a no-op.
602
+ this.fleshOutJoinStatements([], preloadStatements, preloadOnStatements, null, args);
572
603
  });
573
- return query;
604
+ return this.clone({ preloadStatements, preloadOnStatements });
574
605
  }
575
606
  /**
576
607
  * Returns a new Query instance, with the provided
@@ -2421,8 +2421,36 @@ export default class KyselyQueryDriver extends QueryDriverBase {
2421
2421
  dreams = [dreams];
2422
2422
  const { name: associationName } = associationStringToNameAndAlias(associationNameAndMaybeAlias);
2423
2423
  dreams = dreams.filter(dream => dream.hasAssociation(associationName));
2424
- const groupedDreams = groupBy(dreams, dream => dream.sanitizedConstructorName);
2425
- return (await Promise.all(Object.keys(groupedDreams).map(key => this._applyOnePreload(associationNameAndMaybeAlias, groupedDreams[key], onStatement)))).flat();
2424
+ // A single preload level can hold Dreams of unrelated classes: a polymorphic
2425
+ // BelongsTo resolves to several target classes, and the Dreams it hands to the
2426
+ // next level down declare their same-named associations independently. Each
2427
+ // distinct declaration needs its own query, so the Dreams are batched by the
2428
+ // pair that determines every query `_applyOnePreload` derives — the association
2429
+ // metadata the Dream resolves for this name, and the class that query roots at.
2430
+ // STI siblings share both (`STI` forbids a child from declaring its own
2431
+ // associations, and all siblings root at the STI base), so they resolve in one
2432
+ // query rather than one per concrete class.
2433
+ const batchesByAssociation = new Map();
2434
+ for (const dream of dreams) {
2435
+ const association = dream['getAssociationMetadata'](associationName);
2436
+ if (association === undefined)
2437
+ throw new UnexpectedUndefined();
2438
+ let batchesByRootClass = batchesByAssociation.get(association);
2439
+ if (batchesByRootClass === undefined) {
2440
+ batchesByRootClass = new Map();
2441
+ batchesByAssociation.set(association, batchesByRootClass);
2442
+ }
2443
+ const rootClass = dream.constructor['stiBaseClassOrOwnClass'];
2444
+ const batch = batchesByRootClass.get(rootClass);
2445
+ if (batch === undefined)
2446
+ batchesByRootClass.set(rootClass, [dream]);
2447
+ else
2448
+ batch.push(dream);
2449
+ }
2450
+ const batches = [...batchesByAssociation.values()].flatMap(batchesByRootClass => [
2451
+ ...batchesByRootClass.values(),
2452
+ ]);
2453
+ return (await Promise.all(batches.map(batch => this._applyOnePreload(associationNameAndMaybeAlias, batch, onStatement)))).flat();
2426
2454
  }
2427
2455
  async _applyOnePreload(associationName, dreams, onStatement = {}) {
2428
2456
  if (!dreams.length)
@@ -1,18 +1,39 @@
1
- import { inferSerializersFromDreamClassOrViewModelClass } from '../../serializer/helpers/inferSerializerFromDreamOrViewModel.js';
1
+ import { inferSerializersFromDreamClassOrViewModelClassOrFail } from '../../serializer/helpers/inferSerializerFromDreamOrViewModel.js';
2
2
  import { RECURSIVE_SERIALIZATION_MAX_REPEATS } from '../constants.js';
3
3
  import buildAssociationPaths from './buildAssociationPaths.js';
4
- import resolveSerializerAssociationEdges from './resolveSerializerAssociationEdges.js';
4
+ import mergeSerializerAssociationEdges from './mergeSerializerAssociationEdges.js';
5
5
  export default function buildSerializerPreloadPaths(dreamClass, serializerKey) {
6
6
  const key = serializerKey || 'default';
7
- const serializer = inferSerializersFromDreamClassOrViewModelClass(dreamClass, key)[0] ?? null;
8
- if (!serializer)
9
- throw new Error(`unable to find serializer with key: ${key}`);
10
- const paths = buildAssociationPaths({
11
- dreamClass,
12
- serializer,
13
- }, {
7
+ /**
8
+ * When `dreamClass` is an STI base, this returns one serializer per STI child (deduped), since
9
+ * each child may register a different serializer under `key`. Every child's serializer has to be
10
+ * walked: an association rendered only by, say, the alphabetically-last child would otherwise be
11
+ * missing from the preload set and throw NonLoadedAssociation at render time.
12
+ */
13
+ const serializers = inferSerializersFromDreamClassOrViewModelClassOrFail(dreamClass, key);
14
+ /**
15
+ * All of the serializers are walked in a *single* traversal, as one node per Dream class carrying
16
+ * the set of serializers that reach it, rather than one traversal per serializer. A path is a
17
+ * sequence of [DreamClass, associationName] tuples — it records no serializer — so walking each
18
+ * serializer separately recomputes the same paths once per route through the graph, which is
19
+ * exponential in the number of sibling serializers when a serializer cycles back to the STI base.
20
+ * Merging is exactly equivalent: an edge is emitted from the merged node when *some* serializer in
21
+ * the set declares it, and every serializer in the set is reachable by a real route with this
22
+ * path's prefix, so the merged walk emits precisely the paths the per-serializer walks emit after
23
+ * deduping — with one exception: where one serializer of a class renders no associations and
24
+ * another renders some, the per-serializer walk also emitted the bare prefix path reached by the
25
+ * first, which is a strict prefix of a path the second contributes and so preloads nothing extra.
26
+ * The recursion budget is keyed on the Dream class and counted along the current path, so it is
27
+ * unaffected by the merge.
28
+ *
29
+ * Every tuple emitted at the root of the traversal carries `dreamClass` (the STI base) rather than
30
+ * the contributing child, both so that identical paths dedupe below and so that a caller's
31
+ * `modifierFn` keeps receiving the class it was queried on. Tuples emitted deeper in a path carry
32
+ * the association's target class, which is what `modifierFn` receives for a nested association.
33
+ */
34
+ const paths = buildAssociationPaths({ dreamClass, serializers }, {
14
35
  getKey: node => node.dreamClass.globalName,
15
- getEdges: serializerNodeToEdges,
36
+ getEdges: memoizedSerializerNodeToEdges(),
16
37
  maxRepeats: RECURSIVE_SERIALIZATION_MAX_REPEATS,
17
38
  });
18
39
  const dedupedPaths = new Map();
@@ -23,16 +44,68 @@ export default function buildSerializerPreloadPaths(dreamClass, serializerKey) {
23
44
  }
24
45
  return [...dedupedPaths.values()];
25
46
  }
26
- function serializerNodeToEdges({ dreamClass, serializer, }) {
27
- const edges = resolveSerializerAssociationEdges(dreamClass, serializer);
28
- return edges.flatMap(edge => {
29
- const tuple = [dreamClass, edge.associationAs];
30
- if (edge.type === 'delegatedAttribute' || edge.targets.length === 0) {
31
- return [{ nextNode: null, tuple }];
47
+ /**
48
+ * `serializerNodeToEdges` is a pure function of the node's (Dream class, ordered serializer set), so
49
+ * two nodes carrying the same pair have the same edges no matter which route reached them. The
50
+ * traversal hands `getEdges` a *fresh* node object per edge it descends — the `nextNode`s built
51
+ * below are new objects every time — so memoizing on the object itself would never hit; the key has
52
+ * to be the node's contents. Without this the whole merge (resolve every serializer's edges, then
53
+ * one Map per association and one node object per target class) is rebuilt at every visit, and the
54
+ * visit count is the number of *routes* through the serializer graph rather than the number of
55
+ * distinct nodes in it: an STI base whose children render `n` differently-named self-referential
56
+ * associations visits 1 + n + n² + n³ nodes to reach n⁴ paths, all of them the same one node.
57
+ *
58
+ * The serializer identifiers are assigned per call rather than globally so that the key stays short
59
+ * and so that nothing here holds a serializer alive; identity is all that matters, since the key is
60
+ * only ever compared against others built in the same call. Order-sensitive on purpose: the merge
61
+ * is insertion-ordered, so two nodes carrying the same serializers in a different order do not have
62
+ * the same edges.
63
+ *
64
+ * Scoped to a single `buildSerializerPreloadPaths` call for the same reason: it cannot then serve
65
+ * edges resolved against a stale serializer registry or a not-yet-complete set of STI children.
66
+ */
67
+ function memoizedSerializerNodeToEdges() {
68
+ const cache = new Map();
69
+ const serializerIds = new Map();
70
+ const serializerId = (serializer) => {
71
+ let id = serializerIds.get(serializer);
72
+ if (id === undefined) {
73
+ id = serializerIds.size;
74
+ serializerIds.set(serializer, id);
32
75
  }
33
- return edge.targets.map(target => ({
34
- nextNode: target,
35
- tuple,
36
- }));
76
+ return id;
77
+ };
78
+ return node => {
79
+ const cacheKey = `${node.dreamClass.globalName}|${node.serializers.map(serializerId).join(',')}`;
80
+ const cached = cache.get(cacheKey);
81
+ if (cached !== undefined)
82
+ return cached;
83
+ const edges = serializerNodeToEdges(node);
84
+ cache.set(cacheKey, edges);
85
+ return edges;
86
+ };
87
+ }
88
+ /**
89
+ * `mergeSerializerAssociationEdges` — the single union both serializer walks run on, the other being
90
+ * `Dream.recursiveSerializationMap`'s display walk — has already resolved every serializer in the
91
+ * node's set and merged them into one insertion-ordered edge set keyed on (association, target
92
+ * class). All that is left here is to shed what only the display walk needs (the per-serializer edge
93
+ * type and serializer-side association name) and turn each merged edge into path edges: a leaf where
94
+ * some serializer terminates the association, plus one traversal node per target class carrying that
95
+ * class's serializers.
96
+ */
97
+ function serializerNodeToEdges({ dreamClass, serializers, }) {
98
+ return mergeSerializerAssociationEdges(dreamClass, serializers).flatMap(edge => {
99
+ const tuple = [dreamClass, edge.associationAs];
100
+ return [
101
+ ...(edge.terminates ? [{ nextNode: null, tuple }] : []),
102
+ ...edge.targets.map(target => ({
103
+ nextNode: {
104
+ dreamClass: target.dreamClass,
105
+ serializers: target.serializers.map(({ serializer }) => serializer),
106
+ },
107
+ tuple,
108
+ })),
109
+ ];
37
110
  });
38
111
  }
@@ -1,15 +1,23 @@
1
- import compact from '../../helpers/compact.js';
2
1
  export default function convertDreamClassAndAssociationNameTupleArrayToPreloadArgs(dreamClassAndAssociationNameTupleArray, modifierFn, counter) {
3
- return compact(dreamClassAndAssociationNameTupleArray.flatMap(dreamClassAndAssociationNameTuple => {
2
+ const preloadArgs = [];
3
+ for (const dreamClassAndAssociationNameTuple of dreamClassAndAssociationNameTupleArray) {
4
4
  const associationName = dreamClassAndAssociationNameTuple[1];
5
- const aliasedAssociationName = counter
6
- ? `${dreamClassAndAssociationNameTuple[1]} as drsz${counter.count++}`
7
- : dreamClassAndAssociationNameTuple[1];
8
- if (!modifierFn)
9
- return aliasedAssociationName;
5
+ const aliasedAssociationName = counter ? `${associationName} as drsz${counter.count++}` : associationName;
6
+ if (!modifierFn) {
7
+ preloadArgs.push(aliasedAssociationName);
8
+ continue;
9
+ }
10
10
  const modifier = modifierFn(associationName, dreamClassAndAssociationNameTuple[0]);
11
+ // omitting an association prunes the entire subtree beneath it: the path is truncated at the
12
+ // omitted association, keeping the ancestors already accumulated (so the omitted association's
13
+ // parent is still preloaded) and discarding the remainder, since the surviving tail would be
14
+ // re-rooted on the class the query was rooted on and preload a different association than the
15
+ // caller asked for. When the omitted association is the first tuple, this yields an empty list.
11
16
  if (modifier === 'omit')
12
- return undefined;
13
- return [aliasedAssociationName, modifier];
14
- }));
17
+ return preloadArgs;
18
+ preloadArgs.push(aliasedAssociationName);
19
+ if (modifier !== undefined && modifier !== null)
20
+ preloadArgs.push(modifier);
21
+ }
22
+ return preloadArgs;
15
23
  }