@rvoh/dream 2.21.1 → 2.23.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 (248) hide show
  1. package/dist/cjs/src/Dream.js +71 -23
  2. package/dist/cjs/src/dream/DreamClassTransactionBuilder.js +3 -0
  3. package/dist/cjs/src/dream/Query.js +242 -12
  4. package/dist/cjs/src/dream/QueryDriver/Base.js +26 -0
  5. package/dist/cjs/src/dream/QueryDriver/Kysely.js +46 -4
  6. package/dist/cjs/src/dream/QueryDriver/Postgres.js +21 -0
  7. package/dist/cjs/src/dream/internal/buildSerializerPreloadPaths.js +93 -20
  8. package/dist/cjs/src/dream/internal/convertDreamClassAndAssociationNameTupleArrayToPreloadArgs.js +18 -10
  9. package/dist/cjs/src/dream/internal/destroyAssociatedRecords.js +76 -4
  10. package/dist/cjs/src/dream/internal/destroyDream.js +6 -7
  11. package/dist/cjs/src/dream/internal/mergeSerializerAssociationEdges.js +75 -0
  12. package/dist/cjs/src/dream/internal/printSerializerHierarchyLevel.js +50 -8
  13. package/dist/cjs/src/dream/internal/resolveSerializerAssociationEdges.js +79 -14
  14. package/dist/cjs/src/errors/InvalidBatchSize.js +19 -0
  15. package/dist/cjs/src/errors/RowLockIncompatibleWithDistinct.js +14 -0
  16. package/dist/cjs/src/errors/RowLockIncompatibleWithJoinLoad.js +14 -0
  17. package/dist/cjs/src/errors/ops/ArrayEqualityRequiresArrayColumn.js +23 -0
  18. package/dist/cjs/src/errors/serializers/NoSerializersResolvedForKey.js +30 -0
  19. package/dist/cjs/src/ops/index.js +19 -0
  20. package/dist/cjs/src/serializer/helpers/inferSerializerFromDreamOrViewModel.js +22 -0
  21. package/dist/cjs/src/serializer/helpers/serializerForAssociatedClass.js +13 -0
  22. package/dist/esm/src/Dream.js +71 -23
  23. package/dist/esm/src/dream/DreamClassTransactionBuilder.js +3 -0
  24. package/dist/esm/src/dream/Query.js +242 -12
  25. package/dist/esm/src/dream/QueryDriver/Base.js +26 -0
  26. package/dist/esm/src/dream/QueryDriver/Kysely.js +46 -4
  27. package/dist/esm/src/dream/QueryDriver/Postgres.js +21 -0
  28. package/dist/esm/src/dream/internal/buildSerializerPreloadPaths.js +93 -20
  29. package/dist/esm/src/dream/internal/convertDreamClassAndAssociationNameTupleArrayToPreloadArgs.js +18 -10
  30. package/dist/esm/src/dream/internal/destroyAssociatedRecords.js +76 -4
  31. package/dist/esm/src/dream/internal/destroyDream.js +6 -7
  32. package/dist/esm/src/dream/internal/mergeSerializerAssociationEdges.js +75 -0
  33. package/dist/esm/src/dream/internal/printSerializerHierarchyLevel.js +50 -8
  34. package/dist/esm/src/dream/internal/resolveSerializerAssociationEdges.js +79 -14
  35. package/dist/esm/src/errors/InvalidBatchSize.js +19 -0
  36. package/dist/esm/src/errors/RowLockIncompatibleWithDistinct.js +14 -0
  37. package/dist/esm/src/errors/RowLockIncompatibleWithJoinLoad.js +14 -0
  38. package/dist/esm/src/errors/ops/ArrayEqualityRequiresArrayColumn.js +23 -0
  39. package/dist/esm/src/errors/serializers/NoSerializersResolvedForKey.js +30 -0
  40. package/dist/esm/src/ops/index.js +19 -0
  41. package/dist/esm/src/serializer/helpers/inferSerializerFromDreamOrViewModel.js +22 -0
  42. package/dist/esm/src/serializer/helpers/serializerForAssociatedClass.js +13 -0
  43. package/dist/types/src/Dream.d.ts +12 -0
  44. package/dist/types/src/dream/DreamClassTransactionBuilder.d.ts +3 -0
  45. package/dist/types/src/dream/Query.d.ts +150 -3
  46. package/dist/types/src/dream/QueryDriver/Base.d.ts +21 -0
  47. package/dist/types/src/dream/QueryDriver/Kysely.d.ts +1 -0
  48. package/dist/types/src/dream/QueryDriver/Postgres.d.ts +20 -0
  49. package/dist/types/src/dream/internal/destroyAssociatedRecords.d.ts +22 -3
  50. package/dist/types/src/dream/internal/mergeSerializerAssociationEdges.d.ts +69 -0
  51. package/dist/types/src/dream/internal/printSerializerHierarchyLevel.d.ts +23 -5
  52. package/dist/types/src/dream/internal/resolveSerializerAssociationEdges.d.ts +27 -1
  53. package/dist/types/src/errors/InvalidBatchSize.d.ts +5 -0
  54. package/dist/types/src/errors/RowLockIncompatibleWithDistinct.d.ts +3 -0
  55. package/dist/types/src/errors/RowLockIncompatibleWithJoinLoad.d.ts +3 -0
  56. package/dist/types/src/errors/ops/ArrayEqualityRequiresArrayColumn.d.ts +8 -0
  57. package/dist/types/src/errors/serializers/NoSerializersResolvedForKey.d.ts +18 -0
  58. package/dist/types/src/ops/index.d.ts +19 -0
  59. package/dist/types/src/serializer/helpers/inferSerializerFromDreamOrViewModel.d.ts +16 -0
  60. package/dist/types/src/types/associations/shared.d.ts +2 -2
  61. package/dist/types/src/types/dream.d.ts +8 -0
  62. package/dist/types/src/types/types/associations/shared.ts +84 -55
  63. package/dist/types/src/types/types/dream.ts +30 -0
  64. package/docs/assets/hierarchy.js +1 -1
  65. package/docs/assets/search.js +1 -1
  66. package/docs/classes/db.DreamMigrationHelpers.html +11 -11
  67. package/docs/classes/db.KyselyQueryDriver.html +55 -41
  68. package/docs/classes/db.PostgresQueryDriver.html +55 -42
  69. package/docs/classes/db.QueryDriverBase.html +54 -40
  70. package/docs/classes/errors.CheckConstraintViolation.html +3 -3
  71. package/docs/classes/errors.ColumnOverflow.html +3 -3
  72. package/docs/classes/errors.CreateOrFindByFailedToCreateAndFind.html +3 -3
  73. package/docs/classes/errors.DataIncompatibleWithDatabaseField.html +3 -3
  74. package/docs/classes/errors.DataTypeColumnTypeMismatch.html +3 -3
  75. package/docs/classes/errors.DecryptionError.html +2 -2
  76. package/docs/classes/errors.DecryptionParseError.html +2 -2
  77. package/docs/classes/errors.DecryptionRotationError.html +3 -3
  78. package/docs/classes/errors.GlobalNameNotSet.html +3 -3
  79. package/docs/classes/errors.InvalidCalendarDate.html +2 -2
  80. package/docs/classes/errors.InvalidClockTime.html +2 -2
  81. package/docs/classes/errors.InvalidClockTimeTz.html +2 -2
  82. package/docs/classes/errors.InvalidDateTime.html +2 -2
  83. package/docs/classes/errors.MissingSerializersDefinition.html +3 -3
  84. package/docs/classes/errors.NonLoadedAssociation.html +3 -3
  85. package/docs/classes/errors.NotNullViolation.html +3 -3
  86. package/docs/classes/errors.RecordNotFound.html +3 -3
  87. package/docs/classes/errors.ValidationError.html +3 -3
  88. package/docs/classes/index.CalendarDate.html +33 -33
  89. package/docs/classes/index.ClockTime.html +32 -32
  90. package/docs/classes/index.ClockTimeTz.html +35 -35
  91. package/docs/classes/index.DateTime.html +86 -86
  92. package/docs/classes/index.Decorators.html +19 -19
  93. package/docs/classes/index.Dream.html +127 -125
  94. package/docs/classes/index.DreamApp.html +10 -10
  95. package/docs/classes/index.DreamTransaction.html +2 -2
  96. package/docs/classes/index.Env.html +2 -2
  97. package/docs/classes/index.Query.html +161 -71
  98. package/docs/classes/system.CliFileWriter.html +4 -4
  99. package/docs/classes/system.DreamBin.html +2 -2
  100. package/docs/classes/system.DreamCLI.html +7 -7
  101. package/docs/classes/system.DreamImporter.html +2 -2
  102. package/docs/classes/system.DreamLogos.html +2 -2
  103. package/docs/classes/system.DreamSerializerBuilder.html +11 -11
  104. package/docs/classes/system.ObjectSerializerBuilder.html +8 -8
  105. package/docs/classes/system.PathHelpers.html +3 -3
  106. package/docs/classes/utils.Encrypt.html +3 -3
  107. package/docs/classes/utils.Range.html +2 -2
  108. package/docs/functions/db.closeAllDbConnections.html +1 -1
  109. package/docs/functions/db.dreamDbConnections.html +1 -1
  110. package/docs/functions/db.untypedDb.html +1 -1
  111. package/docs/functions/db.validateColumn.html +1 -1
  112. package/docs/functions/db.validateTable.html +1 -1
  113. package/docs/functions/errors.pgErrorType.html +1 -1
  114. package/docs/functions/index.DreamSerializer.html +1 -1
  115. package/docs/functions/index.ObjectSerializer.html +1 -1
  116. package/docs/functions/index.ReplicaSafe.html +1 -1
  117. package/docs/functions/index.STI.html +1 -1
  118. package/docs/functions/index.SoftDelete.html +1 -1
  119. package/docs/functions/utils.camelize.html +1 -1
  120. package/docs/functions/utils.capitalize.html +1 -1
  121. package/docs/functions/utils.cloneDeepSafe.html +1 -1
  122. package/docs/functions/utils.compact.html +1 -1
  123. package/docs/functions/utils.groupBy.html +1 -1
  124. package/docs/functions/utils.hyphenize.html +1 -1
  125. package/docs/functions/utils.intersection.html +1 -1
  126. package/docs/functions/utils.isEmpty.html +1 -1
  127. package/docs/functions/utils.normalizeUnicode.html +1 -1
  128. package/docs/functions/utils.pascalize.html +1 -1
  129. package/docs/functions/utils.percent.html +1 -1
  130. package/docs/functions/utils.range.html +1 -1
  131. package/docs/functions/utils.round.html +1 -1
  132. package/docs/functions/utils.sanitizeString.html +1 -1
  133. package/docs/functions/utils.snakeify.html +1 -1
  134. package/docs/functions/utils.sort.html +1 -1
  135. package/docs/functions/utils.sortBy.html +1 -1
  136. package/docs/functions/utils.sortObjectByKey.html +1 -1
  137. package/docs/functions/utils.sortObjectByValue.html +1 -1
  138. package/docs/functions/utils.uncapitalize.html +1 -1
  139. package/docs/functions/utils.uniq.html +1 -1
  140. package/docs/hierarchy.html +1 -1
  141. package/docs/interfaces/openapi.OpenapiDescription.html +2 -2
  142. package/docs/interfaces/openapi.OpenapiSchemaProperties.html +1 -1
  143. package/docs/interfaces/openapi.OpenapiSchemaPropertiesShorthand.html +1 -1
  144. package/docs/interfaces/openapi.OpenapiTypeFieldObject.html +1 -1
  145. package/docs/interfaces/types.BelongsToStatement.html +2 -2
  146. package/docs/interfaces/types.DecoratorContext.html +2 -2
  147. package/docs/interfaces/types.DreamAppInitOptions.html +2 -2
  148. package/docs/interfaces/types.DreamAppOpts.html +2 -2
  149. package/docs/interfaces/types.DreamDbConfig.html +5 -5
  150. package/docs/interfaces/types.DurationObject.html +2 -2
  151. package/docs/interfaces/types.EncryptOptions.html +2 -2
  152. package/docs/interfaces/types.InternalAnyTypedSerializerRendersMany.html +2 -2
  153. package/docs/interfaces/types.InternalAnyTypedSerializerRendersOne.html +2 -2
  154. package/docs/interfaces/types.SerializerRendererOpts.html +2 -2
  155. package/docs/types/openapi.CommonOpenapiSchemaObjectFields.html +1 -1
  156. package/docs/types/openapi.OpenapiAllTypes.html +1 -1
  157. package/docs/types/openapi.OpenapiFormats.html +1 -1
  158. package/docs/types/openapi.OpenapiNumberFormats.html +1 -1
  159. package/docs/types/openapi.OpenapiPrimitiveBaseTypes.html +1 -1
  160. package/docs/types/openapi.OpenapiPrimitiveTypes.html +1 -1
  161. package/docs/types/openapi.OpenapiSchemaArray.html +1 -1
  162. package/docs/types/openapi.OpenapiSchemaArrayShorthand.html +1 -1
  163. package/docs/types/openapi.OpenapiSchemaBase.html +1 -1
  164. package/docs/types/openapi.OpenapiSchemaBody.html +1 -1
  165. package/docs/types/openapi.OpenapiSchemaBodyShorthand.html +1 -1
  166. package/docs/types/openapi.OpenapiSchemaCommonFields.html +1 -1
  167. package/docs/types/openapi.OpenapiSchemaExpressionAllOf.html +2 -2
  168. package/docs/types/openapi.OpenapiSchemaExpressionAnyOf.html +2 -2
  169. package/docs/types/openapi.OpenapiSchemaExpressionOneOf.html +2 -2
  170. package/docs/types/openapi.OpenapiSchemaExpressionRef.html +2 -2
  171. package/docs/types/openapi.OpenapiSchemaExpressionRefSchemaShorthand.html +2 -2
  172. package/docs/types/openapi.OpenapiSchemaInteger.html +1 -1
  173. package/docs/types/openapi.OpenapiSchemaNull.html +2 -2
  174. package/docs/types/openapi.OpenapiSchemaNumber.html +1 -1
  175. package/docs/types/openapi.OpenapiSchemaObject.html +1 -1
  176. package/docs/types/openapi.OpenapiSchemaObjectAllOf.html +1 -1
  177. package/docs/types/openapi.OpenapiSchemaObjectAllOfShorthand.html +1 -1
  178. package/docs/types/openapi.OpenapiSchemaObjectAnyOf.html +1 -1
  179. package/docs/types/openapi.OpenapiSchemaObjectAnyOfShorthand.html +1 -1
  180. package/docs/types/openapi.OpenapiSchemaObjectBase.html +1 -1
  181. package/docs/types/openapi.OpenapiSchemaObjectBaseShorthand.html +1 -1
  182. package/docs/types/openapi.OpenapiSchemaObjectOneOf.html +1 -1
  183. package/docs/types/openapi.OpenapiSchemaObjectOneOfShorthand.html +1 -1
  184. package/docs/types/openapi.OpenapiSchemaObjectShorthand.html +1 -1
  185. package/docs/types/openapi.OpenapiSchemaPrimitiveGeneric.html +1 -1
  186. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAllOf.html +2 -2
  187. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAnyOf.html +2 -2
  188. package/docs/types/openapi.OpenapiSchemaShorthandExpressionOneOf.html +2 -2
  189. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializableRef.html +2 -2
  190. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializerRef.html +2 -2
  191. package/docs/types/openapi.OpenapiSchemaShorthandPrimitiveGeneric.html +1 -1
  192. package/docs/types/openapi.OpenapiSchemaString.html +1 -1
  193. package/docs/types/openapi.OpenapiShorthandAllTypes.html +1 -1
  194. package/docs/types/openapi.OpenapiShorthandPrimitiveBaseTypes.html +1 -1
  195. package/docs/types/openapi.OpenapiShorthandPrimitiveTypes.html +1 -1
  196. package/docs/types/openapi.OpenapiTypeField.html +1 -1
  197. package/docs/types/system.DreamAppAllowedPackageManagersEnum.html +1 -1
  198. package/docs/types/types.CalendarDateDurationUnit.html +1 -1
  199. package/docs/types/types.CalendarDateObject.html +1 -1
  200. package/docs/types/types.Camelized.html +1 -1
  201. package/docs/types/types.ClockTimeObject.html +1 -1
  202. package/docs/types/types.DbConnectionType.html +1 -1
  203. package/docs/types/types.DbTypes.html +1 -1
  204. package/docs/types/types.DreamAssociationMetadata.html +1 -1
  205. package/docs/types/types.DreamAttributes.html +1 -1
  206. package/docs/types/types.DreamClassAssociationAndStatement.html +1 -1
  207. package/docs/types/types.DreamClassColumn.html +1 -1
  208. package/docs/types/types.DreamColumn.html +1 -1
  209. package/docs/types/types.DreamColumnNames.html +1 -1
  210. package/docs/types/types.DreamLogLevel.html +1 -1
  211. package/docs/types/types.DreamLogger.html +2 -2
  212. package/docs/types/types.DreamModelSerializerType.html +1 -1
  213. package/docs/types/types.DreamOrViewModelClassSerializerKey.html +1 -1
  214. package/docs/types/types.DreamOrViewModelSerializerKey.html +1 -1
  215. package/docs/types/types.DreamParamSafeAttributes.html +1 -1
  216. package/docs/types/types.DreamParamSafeColumnNames.html +1 -1
  217. package/docs/types/types.DreamSerializable.html +1 -1
  218. package/docs/types/types.DreamSerializableArray.html +1 -1
  219. package/docs/types/types.DreamSerializerKey.html +1 -1
  220. package/docs/types/types.DreamSerializers.html +1 -1
  221. package/docs/types/types.DreamVirtualColumns.html +1 -1
  222. package/docs/types/types.DurationUnit.html +1 -1
  223. package/docs/types/types.EncryptAlgorithm.html +1 -1
  224. package/docs/types/types.HasManyStatement.html +1 -1
  225. package/docs/types/types.HasOneStatement.html +1 -1
  226. package/docs/types/types.Hyphenized.html +1 -1
  227. package/docs/types/types.Pascalized.html +1 -1
  228. package/docs/types/types.PrimaryKeyType.html +1 -1
  229. package/docs/types/types.RoundingPrecision.html +1 -1
  230. package/docs/types/types.SerializerCasing.html +1 -1
  231. package/docs/types/types.SimpleObjectSerializerType.html +1 -1
  232. package/docs/types/types.Snakeified.html +1 -1
  233. package/docs/types/types.StrictInterface.html +1 -1
  234. package/docs/types/types.UpdateableAssociationProperties.html +1 -1
  235. package/docs/types/types.UpdateableProperties.html +1 -1
  236. package/docs/types/types.ValidationType.html +1 -1
  237. package/docs/types/types.ViewModel.html +2 -2
  238. package/docs/types/types.ViewModelClass.html +1 -1
  239. package/docs/types/types.WeekdayName.html +1 -1
  240. package/docs/types/types.WhereStatementForDream.html +1 -1
  241. package/docs/types/types.WhereStatementForDreamClass.html +1 -1
  242. package/docs/variables/index.DreamConst.html +1 -1
  243. package/docs/variables/index.ops.html +38 -23
  244. package/docs/variables/openapi.openapiPrimitiveTypes.html +1 -1
  245. package/docs/variables/openapi.openapiShorthandPrimitiveTypes.html +1 -1
  246. package/docs/variables/system.DreamAppAllowedPackageManagersEnumValues.html +1 -1
  247. package/docs/variables/system.primaryKeyTypes.html +1 -1
  248. package/package.json +1 -1
@@ -256,6 +256,32 @@ export default class QueryDriverBase {
256
256
  options = {}) {
257
257
  throw new Error('implement takeAll in child class');
258
258
  }
259
+ /**
260
+ * @internal
261
+ *
262
+ * Adapter seam for exclusive row locking. Given the select statement about to
263
+ * be executed, return a statement that additionally acquires an exclusive
264
+ * lock on each row it returns, held until the enclosing transaction commits
265
+ * or rolls back (Postgres: `SELECT ... FOR UPDATE OF <table>`).
266
+ *
267
+ * This is only ever reached when a caller opts into a locked read (today,
268
+ * `Query#destroy({ lock: true })`), and only from inside a transaction. The
269
+ * base implementation throws so that an adapter which has not implemented row
270
+ * locking fails loud rather than silently performing an unlocked read and
271
+ * quietly dropping the compare-and-set guarantee the caller asked for.
272
+ *
273
+ * @param kyselyQuery - the select statement to lock
274
+ * @param tableAlias - the alias of the Query's base table; only rows of that
275
+ * table are locked, so a joined query does not lock unrelated rows and does
276
+ * not fail on the nullable side of an outer join
277
+ */
278
+ applyRowLock(
279
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
280
+ kyselyQuery,
281
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
282
+ tableAlias) {
283
+ throw new Error('implement applyRowLock in child class');
284
+ }
259
285
  /**
260
286
  * Retrieves the max value of the specified column
261
287
  * for this Query
@@ -29,6 +29,9 @@ import MissingThroughAssociationSource from '../../errors/associations/MissingTh
29
29
  import CannotNegateSimilarityClause from '../../errors/CannotNegateSimilarityClause.js';
30
30
  import CannotNamespaceAssociationFilterToAnotherTable from '../../errors/CannotNamespaceAssociationFilterToAnotherTable.js';
31
31
  import CannotPassUndefinedAsAValueToAWhereClause from '../../errors/CannotPassUndefinedAsAValueToAWhereClause.js';
32
+ import ArrayEqualityRequiresArrayColumn from '../../errors/ops/ArrayEqualityRequiresArrayColumn.js';
33
+ import RowLockIncompatibleWithDistinct from '../../errors/RowLockIncompatibleWithDistinct.js';
34
+ import RowLockIncompatibleWithJoinLoad from '../../errors/RowLockIncompatibleWithJoinLoad.js';
32
35
  import CheckConstraintViolation from '../../errors/db/CheckConstraintViolation.js';
33
36
  import ColumnOverflow from '../../errors/db/ColumnOverflow.js';
34
37
  import DataTypeColumnTypeMismatch from '../../errors/db/DataTypeColumnTypeMismatch.js';
@@ -39,6 +42,7 @@ import ASTSchemaBuilder from '../../helpers/cli/ASTSchemaBuilder.js';
39
42
  import generateMigration from '../../helpers/cli/generateMigration.js';
40
43
  import compact from '../../helpers/compact.js';
41
44
  import { normalizeDataForDb } from '../../helpers/db/normalizeDataForDb.js';
45
+ import isDatabaseArrayColumn from '../../helpers/db/types/isDatabaseArrayColumn.js';
42
46
  import EnvInternal from '../../helpers/EnvInternal.js';
43
47
  import groupBy from '../../helpers/groupBy.js';
44
48
  import isEmpty from '../../helpers/isEmpty.js';
@@ -412,9 +416,22 @@ export default class KyselyQueryDriver extends QueryDriverBase {
412
416
  * @returns an array of dreams
413
417
  */
414
418
  async takeAll(options = {}) {
415
- if (this.query['joinLoadActivated'])
419
+ if (this.query['joinLoadActivated']) {
420
+ // executeJoinLoad builds its own select and has nowhere to put a row
421
+ // lock, so a locked read here has to fail rather than quietly return
422
+ // unlocked rows and drop the guarantee the caller asked for
423
+ if (options.lock)
424
+ throw new RowLockIncompatibleWithJoinLoad();
416
425
  return await this.executeJoinLoad(options);
417
- const kyselyQuery = this.buildSelect(options);
426
+ }
427
+ // `SELECT DISTINCT ON (...) ... FOR UPDATE` is rejected by the database, so
428
+ // fail loudly here instead of emitting SQL that cannot run
429
+ if (options.lock && this.query['distinctColumn'])
430
+ throw new RowLockIncompatibleWithDistinct();
431
+ let kyselyQuery = this.buildSelect(options);
432
+ // row locking is adapter-specific, so it goes through the query driver seam
433
+ if (options.lock)
434
+ kyselyQuery = this.applyRowLock(kyselyQuery, this.query['baseSqlAlias']);
418
435
  const results = await executeDatabaseQuery(kyselyQuery, 'execute');
419
436
  const theAll = results.map(r => this.dbResultToDreamInstance(r, this.dreamClass));
420
437
  await this.applyPreload(this.query['preloadStatements'], this.query['preloadOnStatements'], theAll);
@@ -1484,8 +1501,8 @@ export default class KyselyQueryDriver extends QueryDriverBase {
1484
1501
  b = '=';
1485
1502
  c = val;
1486
1503
  }
1487
- c = this.normalizedWhereValue(dreamClass, attr, c);
1488
- c2 = this.normalizedWhereValue(dreamClass, attr, c2);
1504
+ c = maybeBindArrayAsSingleValue(dreamClass, a, b, this.normalizedWhereValue(dreamClass, attr, c));
1505
+ c2 = maybeBindArrayAsSingleValue(dreamClass, a2, b2, this.normalizedWhereValue(dreamClass, attr, c2));
1489
1506
  if (a && c === undefined)
1490
1507
  throw new CannotPassUndefinedAsAValueToAWhereClause(this.dreamClass, a);
1491
1508
  if (a2 && c2 === undefined)
@@ -2525,6 +2542,31 @@ export function resolvePostgresSsl(connectionConf) {
2525
2542
  return { rejectUnauthorized: false };
2526
2543
  return false;
2527
2544
  }
2545
+ const EQUALITY_OPERATORS_TAKING_A_WHOLE_ARRAY_VALUE = ['=', '!=', '<>'];
2546
+ /**
2547
+ * Kysely renders a raw Javascript array passed as the right-hand side of a
2548
+ * comparison as a value list (`($1, $2)`), which is what `in` / `not in` need,
2549
+ * but which Postgres rejects for `=` against an array column
2550
+ * (`operator does not exist: text[] = record`). For the equality operators,
2551
+ * bind the array as a single parameter so that `ops.equal([...])` against an
2552
+ * array column means literal array equality (`"col" = $1`).
2553
+ *
2554
+ * The rewrite is gated on the column actually being a database array column.
2555
+ * An array reaching an equality operator against a *scalar* column is a
2556
+ * mistake, and binding it as a single value would turn that mistake into a
2557
+ * clause that matches nothing (`"name" = '{}'`) instead of one that raises, so
2558
+ * it throws instead.
2559
+ */
2560
+ function maybeBindArrayAsSingleValue(dreamClass, column, operator, val) {
2561
+ if (!Array.isArray(val))
2562
+ return val;
2563
+ if (!EQUALITY_OPERATORS_TAKING_A_WHOLE_ARRAY_VALUE.includes(operator))
2564
+ return val;
2565
+ const isolatedColumn = maybeNamespacedColumnNameToColumnName(column ?? '');
2566
+ if (!isDatabaseArrayColumn(dreamClass, isolatedColumn))
2567
+ throw new ArrayEqualityRequiresArrayColumn(dreamClass, isolatedColumn, operator);
2568
+ return sql.val(val);
2569
+ }
2528
2570
  function shouldSoftDelete(dream, reallyDestroy) {
2529
2571
  const dreamClass = dream.constructor;
2530
2572
  return dreamClass['softDelete'] && !reallyDestroy;
@@ -198,6 +198,27 @@ export default class PostgresQueryDriver extends KyselyQueryDriver {
198
198
  await client.end();
199
199
  DreamCLI.logger.logEndProgress();
200
200
  }
201
+ /**
202
+ * @internal
203
+ *
204
+ * Postgres implementation of the row-locking seam: appends
205
+ * `FOR UPDATE OF "<tableAlias>"` to the select, so every row the statement
206
+ * returns is exclusively locked until the enclosing transaction ends.
207
+ *
208
+ * Scoping the lock to the Query's own base table (rather than a bare
209
+ * `FOR UPDATE`) means a query that joins other tables neither locks rows the
210
+ * caller never asked about nor raises Postgres's "FOR UPDATE cannot be
211
+ * applied to the nullable side of an outer join".
212
+ *
213
+ * This is a waiting lock: neither `NOWAIT` nor `SKIP LOCKED` is applied, so a
214
+ * statement that collides with another transaction's lock blocks until that
215
+ * transaction ends. Callers that cannot afford an unbounded wait should set a
216
+ * `lock_timeout` on the connection; see the guarded-destroy notes on
217
+ * `Query#destroy`.
218
+ */
219
+ applyRowLock(kyselyQuery, tableAlias) {
220
+ return kyselyQuery.forUpdate(tableAlias);
221
+ }
201
222
  static supportsParallelTestDatabases = true;
202
223
  /**
203
224
  * @internal
@@ -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
  }
@@ -1,20 +1,69 @@
1
+ import loadDependentDestroyTree from './loadDependentDestroyTree.js';
2
+ /**
3
+ * @internal
4
+ *
5
+ * Marks a Dream instance as one whose `dependent: 'destroy'` associations were
6
+ * hydrated by this cascade's own call to `loadDependentDestroyTree`, using the
7
+ * same destroy options the cascade is running under.
8
+ *
9
+ * Associations a caller happened to preload themselves (e.g.
10
+ * `Post.preload('comments')`) are deliberately NOT trusted: they may have been
11
+ * loaded under different default scopes than the destroy requires — most
12
+ * importantly, `reallyDestroy` strips the soft-delete scope, so a
13
+ * caller-preloaded association could be missing soft-deleted records that the
14
+ * cascade must destroy.
15
+ */
16
+ const CASCADE_LOADED = Symbol.for('dream:dependentDestroyTreeLoaded');
1
17
  /**
2
18
  * @internal
3
19
  *
4
20
  * Destroys all HasOne/HasMany associations on this
5
21
  * dream that are marked as `dependent: 'destroy'`.
6
22
  *
7
- * Expects associations to be preloaded onto the dream instance
8
- * via `loadDependentDestroyTree`. Iterates loaded associations
9
- * directly instead of querying the database.
23
+ * The whole dependent-destroy tree is loaded ONCE, at the root of the destroy
24
+ * operation, by `loadDependentDestroyTree`. Descendants reached by recursing
25
+ * through this function consume those already-hydrated associations rather
26
+ * than re-querying their own subtrees.
27
+ *
28
+ * A load still happens here when an association is not hydrated. That covers
29
+ * two cases:
30
+ *
31
+ * 1. the root of the operation, whose associations have not been loaded yet
32
+ * 2. a node past `RECURSIVE_DESTROY_PRELOAD_MAX_REPEATS` levels of
33
+ * self-reference, which the root's preload could not reach
34
+ *
35
+ * The second case is why the check is per-association-hydration rather than a
36
+ * "descendants are already loaded" flag: such a flag would truncate cascades
37
+ * deeper than the preload horizon.
38
+ *
39
+ * NOTE: reusing a hydrated subtree is only sound because the same `options`
40
+ * are forwarded unchanged all the way down the cascade (see the recursive
41
+ * `destroy`/`reallyDestroy` calls below), so every node's associations were
42
+ * loaded under the scopes this node would have used. Nothing in the types
43
+ * enforces that; if `options` ever start varying by depth, this reuse has to
44
+ * be revisited.
10
45
  */
11
46
  export default async function destroyAssociatedRecords(dream, txn, options) {
12
47
  const dreamClass = dream.constructor;
13
48
  const { reallyDestroy } = options;
49
+ // `loadDependentDestroyTree` hydrates and returns a CLONE rather than
50
+ // mutating the receiver, so the result must be assigned back — otherwise
51
+ // each association in the loop below would trigger its own reload.
52
+ let dreamWithAssociations = dream;
53
+ let alreadyLoadedTree = false;
14
54
  for (const associationName of dreamClass['dependentDestroyAssociationNames']()) {
15
- const loaded = dream[associationName];
55
+ if (!alreadyLoadedTree && !cascadeLoaded(dreamWithAssociations, associationName)) {
56
+ dreamWithAssociations = await loadDependentDestroyTree(dreamWithAssociations, txn, {
57
+ reallyDestroy,
58
+ bypassAllDefaultScopes: options.bypassAllDefaultScopes ?? false,
59
+ defaultScopesToBypass: options.defaultScopesToBypass ?? [],
60
+ });
61
+ alreadyLoadedTree = true;
62
+ }
63
+ const loaded = dreamWithAssociations[associationName];
16
64
  const records = Array.isArray(loaded) ? loaded : loaded ? [loaded] : [];
17
65
  for (const record of records) {
66
+ markCascadeLoaded(record);
18
67
  if (reallyDestroy) {
19
68
  await record.txn(txn).reallyDestroy(options);
20
69
  }
@@ -24,3 +73,26 @@ export default async function destroyAssociatedRecords(dream, txn, options) {
24
73
  }
25
74
  }
26
75
  }
76
+ /**
77
+ * @internal
78
+ *
79
+ * True when this dream was hydrated by this cascade's own preload AND the
80
+ * given association is hydrated on it.
81
+ */
82
+ function cascadeLoaded(dream, associationName) {
83
+ if (!dream[CASCADE_LOADED])
84
+ return false;
85
+ return dream.loaded(associationName);
86
+ }
87
+ /**
88
+ * @internal
89
+ *
90
+ * A plain assignment rather than `Object.defineProperty`: the key is a symbol,
91
+ * so it is already excluded from `Object.keys`, `for...in` and
92
+ * `JSON.stringify` without an explicit descriptor, and this runs once per node
93
+ * of a potentially wide cascade tree.
94
+ */
95
+ function markCascadeLoaded(dream) {
96
+ ;
97
+ dream[CASCADE_LOADED] = true;
98
+ }
@@ -1,6 +1,5 @@
1
1
  import DreamApp from '../../dream-app/index.js';
2
2
  import destroyAssociatedRecords from './destroyAssociatedRecords.js';
3
- import loadDependentDestroyTree from './loadDependentDestroyTree.js';
4
3
  import runHooksFor from './runHooksFor.js';
5
4
  /**
6
5
  * @internal
@@ -31,12 +30,12 @@ export default async function destroyDream(dream, txn = null, options) {
31
30
  async function destroyDreamWithTransaction(dream, txn, options) {
32
31
  const { cascade, reallyDestroy, skipHooks } = options;
33
32
  if (cascade) {
34
- const dreamWithAssociations = await loadDependentDestroyTree(dream, txn, {
35
- reallyDestroy,
36
- bypassAllDefaultScopes: options.bypassAllDefaultScopes ?? false,
37
- defaultScopesToBypass: options.defaultScopesToBypass ?? [],
38
- });
39
- await destroyAssociatedRecords(dreamWithAssociations, txn, options);
33
+ // NOTE: the dependent-destroy tree is loaded lazily, inside
34
+ // destroyAssociatedRecords, so that a cascaded descendant whose
35
+ // associations the root already preloaded does not re-query its own
36
+ // subtree. `dream` itself is intentionally never replaced by the loaded
37
+ // clone: the hooks and the delete below operate on the original.
38
+ await destroyAssociatedRecords(dream, txn, options);
40
39
  }
41
40
  if (!skipHooks) {
42
41
  await runHooksFor('beforeDestroy', dream, true, null, txn);
@@ -0,0 +1,75 @@
1
+ import resolveSerializerAssociationEdges from './resolveSerializerAssociationEdges.js';
2
+ /**
3
+ * @internal
4
+ *
5
+ * Resolves the association edges of *every* serializer that can render `dreamClass` at this point
6
+ * in a serialization walk and merges them into one set, so that a walk rooted at an STI base sees
7
+ * the union across its children's serializers rather than one child's view of the class.
8
+ *
9
+ * The merge is keyed on (association, target class), so:
10
+ *
11
+ * - an association several children render identically (all three `stiUnion` serializers render
12
+ * `sandbags`) collapses to a single edge with a single target, rather than repeating per child;
13
+ * - a polymorphic association keeps one target per target class
14
+ * (`Polymorphic/Task#taskable` → `Chore` and `Workout`), which is a real distinction, not a
15
+ * duplicate;
16
+ * - an association only some children render (`heartRatings`, rendered only by Latex's `stiUnion`
17
+ * serializer) is present exactly once.
18
+ *
19
+ * Grouping the serializers *under* the target rather than emitting one target per serializer is
20
+ * what lets a caller recurse once per target class with the full set, so nested trees from
21
+ * different children of the same target merge instead of clobbering one another.
22
+ *
23
+ * Insertion-ordered throughout, so edges come back in the order the serializers declare them.
24
+ *
25
+ * This is the single union both serializer walks run on: `buildSerializerPreloadPaths` turns these
26
+ * edges into preload paths (taking `terminates` and the target classes, ignoring the per-serializer
27
+ * edge type and serializer-side name), and `Dream.recursiveSerializationMap` prints them (taking the
28
+ * per-serializer edge type and name, ignoring `terminates`). Keeping the two walks on one merge is the point: the bug
29
+ * this exists to prevent is `psy i:serialization` and `preloadFor` disagreeing about which
30
+ * associations a serializer key reaches, which a second hand-maintained merge invites back.
31
+ */
32
+ export default function mergeSerializerAssociationEdges(dreamClass, serializers) {
33
+ /**
34
+ * Each target carries a Set mirroring its `serializers`, so the identity dedupe below is a lookup
35
+ * rather than a scan of the array. Dropped when the merged edges are built at the end.
36
+ */
37
+ const mergedEdges = new Map();
38
+ for (const serializer of serializers) {
39
+ for (const edge of resolveSerializerAssociationEdges(dreamClass, serializer)) {
40
+ let mergedEdge = mergedEdges.get(edge.associationAs);
41
+ if (mergedEdge === undefined) {
42
+ mergedEdge = { terminates: false, targets: new Map() };
43
+ mergedEdges.set(edge.associationAs, mergedEdge);
44
+ }
45
+ if (edge.targets.length === 0) {
46
+ mergedEdge.terminates = true;
47
+ continue;
48
+ }
49
+ for (const target of edge.targets) {
50
+ const targetKey = target.dreamClass.globalName;
51
+ const mergedTarget = mergedEdge.targets.get(targetKey);
52
+ const targetSerializer = {
53
+ serializer: target.serializer,
54
+ type: edge.type,
55
+ serializerAssociationName: edge.serializerAssociationName,
56
+ };
57
+ if (mergedTarget === undefined) {
58
+ mergedEdge.targets.set(targetKey, {
59
+ target: { dreamClass: target.dreamClass, serializers: [targetSerializer] },
60
+ serializerSet: new Set([target.serializer]),
61
+ });
62
+ }
63
+ else if (!mergedTarget.serializerSet.has(target.serializer)) {
64
+ mergedTarget.serializerSet.add(target.serializer);
65
+ mergedTarget.target.serializers.push(targetSerializer);
66
+ }
67
+ }
68
+ }
69
+ }
70
+ return [...mergedEdges.entries()].map(([associationAs, { terminates, targets }]) => ({
71
+ associationAs,
72
+ terminates,
73
+ targets: [...targets.values()].map(({ target }) => target),
74
+ }));
75
+ }
@@ -1,16 +1,58 @@
1
1
  import yoctocolors from 'yoctocolors';
2
2
  import { indent } from '../../helpers/indent.js';
3
- export default function printSerializerHierarchyLevel({ serializerAssociationType, serializerAssociationName, associationSerializer, forDisplayDepth, }) {
3
+ /**
4
+ * @internal
5
+ *
6
+ * Prints one level of `psy i:serialization`'s tree: the heading for an association, then the name
7
+ * of every serializer that renders the target of that association at this point in the walk. That
8
+ * is more than one serializer when the target is an STI base, since each child may register a
9
+ * different serializer under the same key.
10
+ *
11
+ * Those serializers are printed under a *single* heading rather than one heading each, because the
12
+ * caller prints the union of their association trees below, indented one level in from the
13
+ * heading. Repeating the heading per serializer would leave that union sitting directly beneath
14
+ * whichever child's serializer happened to print last, reading as though every sibling's
15
+ * associations belonged to that one child. Grouped, the shape matches what `displaySerialization`
16
+ * prints at the root of the tree — one name, then every serializer that can render it, then one
17
+ * merged tree.
18
+ *
19
+ * A target reached under more than one edge type or serializer-side name (two children of an STI
20
+ * base rendering the same class through `rendersOne` and `rendersMany`, say) gets one heading per
21
+ * distinct (type, name), since those are genuinely different lines rather than repetitions of one.
22
+ */
23
+ export default function printSerializerHierarchyLevel({ targetSerializers, forDisplayDepth, }) {
4
24
  const hierarchyLine = '└───';
5
25
  const indentation = indent((hierarchyLine.length + 1) * forDisplayDepth, {
6
26
  tabWidth: 1,
7
27
  });
8
28
  const prefix = `${hierarchyLine} `;
9
- const nestedAssociationDisplay = indentation + `${prefix}${serializerAssociationType} ${yoctocolors.cyan(serializerAssociationName)}`;
10
- // eslint-disable-next-line no-console
11
- console.log(nestedAssociationDisplay);
12
- // eslint-disable-next-line no-console
13
- console.log(yoctocolors.gray(indentation +
14
- indent(prefix.length, { tabWidth: 1 }) +
15
- associationSerializer.globalName));
29
+ groupByHeading(targetSerializers).forEach(({ type, serializerAssociationName, serializers }) => {
30
+ // eslint-disable-next-line no-console
31
+ console.log(indentation + `${prefix}${type} ${yoctocolors.cyan(serializerAssociationName)}`);
32
+ serializers.forEach(serializer => {
33
+ // eslint-disable-next-line no-console
34
+ console.log(yoctocolors.gray(indentation +
35
+ indent(prefix.length, { tabWidth: 1 }) +
36
+ serializer.globalName));
37
+ });
38
+ });
39
+ }
40
+ /**
41
+ * Collects the serializers rendering a single merged target into one entry per heading they print
42
+ * under — (edge type, serializer-side association name) — preserving the order the serializers
43
+ * were merged in.
44
+ */
45
+ function groupByHeading(targetSerializers) {
46
+ const headings = new Map();
47
+ for (const { serializer, type, serializerAssociationName } of targetSerializers) {
48
+ const headingKey = `${type}:${serializerAssociationName}`;
49
+ const heading = headings.get(headingKey);
50
+ if (heading === undefined) {
51
+ headings.set(headingKey, { type, serializerAssociationName, serializers: [serializer] });
52
+ }
53
+ else {
54
+ heading.serializers.push(serializer);
55
+ }
56
+ }
57
+ return [...headings.values()];
16
58
  }