@declaro/data 2.0.0-beta.115 → 2.0.0-beta.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { AnyModelSchema } from '@declaro/core'
1
+ import type { ActionDescriptor, AnyModelSchema, IActionDescriptor } from '@declaro/core'
2
2
  import type { InferDetail, InferInput, InferLookup, InferSummary } from '../../shared/utils/schema-inference'
3
3
  import { ModelMutationAction, ModelQueryEvent } from '../events/event-types'
4
4
  import { MutationEvent } from '../events/mutation-event'
@@ -9,6 +9,11 @@ import type { IActionOptions } from './base-model-service'
9
9
  export interface ICreateOptions extends IActionOptions {}
10
10
  export interface IUpdateOptions extends IActionOptions {}
11
11
 
12
+ export interface INormalizeInputArgs<TSchema extends AnyModelSchema> {
13
+ existing?: InferDetail<TSchema>
14
+ descriptor: ActionDescriptor
15
+ }
16
+
12
17
  export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelService<TSchema> {
13
18
  constructor(args: IModelServiceArgs<TSchema>) {
14
19
  super(args)
@@ -21,7 +26,10 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
21
26
  * @param input The input data to normalize.
22
27
  * @returns The normalized input data.
23
28
  */
24
- protected async normalizeInput(input: InferInput<TSchema>): Promise<InferInput<TSchema>> {
29
+ protected async normalizeInput(
30
+ input: InferInput<TSchema>,
31
+ args: INormalizeInputArgs<TSchema>,
32
+ ): Promise<InferInput<TSchema>> {
25
33
  return input
26
34
  }
27
35
 
@@ -82,7 +90,9 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
82
90
 
83
91
  async create(input: InferInput<TSchema>, options?: ICreateOptions): Promise<InferDetail<TSchema>> {
84
92
  // Normalize the input data
85
- const normalizedInput = await this.normalizeInput(input)
93
+ const normalizedInput = await this.normalizeInput(input, {
94
+ descriptor: this.getDescriptor(ModelMutationAction.Create),
95
+ })
86
96
 
87
97
  // Emit the before create event
88
98
  const beforeCreateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
@@ -110,8 +120,12 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
110
120
  input: InferInput<TSchema>,
111
121
  options?: IUpdateOptions,
112
122
  ): Promise<InferDetail<TSchema>> {
123
+ const existing = await this.repository.load(lookup, options)
113
124
  // Normalize the input data
114
- const normalizedInput = await this.normalizeInput(input)
125
+ const normalizedInput = await this.normalizeInput(input, {
126
+ existing,
127
+ descriptor: this.getDescriptor(ModelMutationAction.Update),
128
+ })
115
129
 
116
130
  // Emit the before update event
117
131
  const beforeUpdateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
@@ -141,19 +155,19 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
141
155
  * @returns The upserted record.
142
156
  */
143
157
  async upsert(input: InferInput<TSchema>, options?: ICreateOptions | IUpdateOptions): Promise<InferDetail<TSchema>> {
144
- // Normalize the input data
145
- const normalizedInput = await this.normalizeInput(input)
146
-
147
- const primaryKeyValue = this.getPrimaryKeyValue(normalizedInput)
158
+ const primaryKeyValue = this.getPrimaryKeyValue(input)
148
159
 
160
+ let operation: ModelMutationAction
149
161
  let beforeOperation: ModelMutationAction
150
162
  let afterOperation: ModelMutationAction
163
+ let existingItem: InferDetail<TSchema> | undefined = undefined
151
164
 
152
165
  if (primaryKeyValue === undefined) {
166
+ operation = ModelMutationAction.Create
153
167
  beforeOperation = ModelMutationAction.BeforeCreate
154
168
  afterOperation = ModelMutationAction.AfterCreate
155
169
  } else {
156
- const existingItem = await this.load(
170
+ existingItem = await this.load(
157
171
  {
158
172
  [this.entityMetadata.primaryKey]: primaryKeyValue,
159
173
  } as InferLookup<TSchema>,
@@ -161,14 +175,22 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
161
175
  )
162
176
 
163
177
  if (existingItem) {
178
+ operation = ModelMutationAction.Update
164
179
  beforeOperation = ModelMutationAction.BeforeUpdate
165
180
  afterOperation = ModelMutationAction.AfterUpdate
166
181
  } else {
182
+ operation = ModelMutationAction.Create
167
183
  beforeOperation = ModelMutationAction.BeforeCreate
168
184
  afterOperation = ModelMutationAction.AfterCreate
169
185
  }
170
186
  }
171
187
 
188
+ // Normalize the input data
189
+ const normalizedInput = await this.normalizeInput(input, {
190
+ descriptor: this.getDescriptor(operation),
191
+ existing: existingItem,
192
+ })
193
+
172
194
  // Emit the before upsert event
173
195
  const beforeUpsertEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
174
196
  this.getDescriptor(beforeOperation),
@@ -204,82 +226,86 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
204
226
  return []
205
227
  }
206
228
 
207
- // Normalize all input data in parallel using Promise.all
208
- const normalizedInputs = await Promise.all(inputs.map((input) => this.normalizeInput(input)))
209
-
210
- // Build a map of primary key to input and lookup info
211
- type EntityInfo = {
229
+ // Keep track of input metadata for each position (preserves order and duplicates)
230
+ type InputInfo = {
212
231
  input: InferInput<TSchema>
213
- lookup: InferLookup<TSchema>
214
- primaryKeyValue: string | number
232
+ index: number
233
+ primaryKeyValue?: string | number
215
234
  existingEntity?: InferDetail<TSchema>
216
235
  operation?: ModelMutationAction
217
236
  }
218
237
 
219
- const entityInfoMap = new Map<string | number, EntityInfo>()
220
- const inputsWithoutPrimaryKey: InferInput<TSchema>[] = []
238
+ const inputInfos: InputInfo[] = []
239
+ const uniqueLookups = new Map<string | number, InferLookup<TSchema>>()
221
240
 
222
- // Process each normalized input and organize by primary key
223
- for (const input of normalizedInputs) {
241
+ // Process each input and collect unique lookups
242
+ for (let i = 0; i < inputs.length; i++) {
243
+ const input = inputs[i]
224
244
  const primaryKeyValue = this.getPrimaryKeyValue(input)
225
245
 
246
+ const inputInfo: InputInfo = {
247
+ input,
248
+ index: i,
249
+ primaryKeyValue,
250
+ }
251
+ inputInfos.push(inputInfo)
252
+
253
+ // Collect unique lookups for entities that have primary keys
226
254
  if (primaryKeyValue !== undefined) {
227
- const entityInfo: EntityInfo = {
228
- input,
229
- primaryKeyValue,
230
- lookup: {
231
- [this.entityMetadata.primaryKey]: primaryKeyValue,
232
- } as InferLookup<TSchema>,
233
- }
234
- entityInfoMap.set(primaryKeyValue, entityInfo)
235
- } else {
236
- // Inputs without primary keys are always creates
237
- inputsWithoutPrimaryKey.push(input)
255
+ uniqueLookups.set(primaryKeyValue, {
256
+ [this.entityMetadata.primaryKey]: primaryKeyValue,
257
+ } as InferLookup<TSchema>)
238
258
  }
239
259
  }
240
260
 
241
- // Extract lookups for existing entities
242
- const lookups = Array.from(entityInfoMap.values()).map((info) => info.lookup)
243
-
244
- // Load existing entities and update the map
245
- if (lookups.length > 0) {
261
+ // Load existing entities for unique primary keys
262
+ const existingEntitiesMap = new Map<string | number, InferDetail<TSchema>>()
263
+ if (uniqueLookups.size > 0) {
264
+ const lookups = Array.from(uniqueLookups.values())
246
265
  const existingEntities = await this.loadMany(lookups, options)
247
266
  existingEntities.forEach((entity) => {
248
267
  if (entity) {
249
268
  const pkValue = this.getPrimaryKeyValue(entity)
250
- if (pkValue !== undefined && entityInfoMap.has(pkValue)) {
251
- const entityInfo = entityInfoMap.get(pkValue)!
252
- entityInfo.existingEntity = entity
269
+ if (pkValue !== undefined) {
270
+ existingEntitiesMap.set(pkValue, entity)
253
271
  }
254
272
  }
255
273
  })
256
274
  }
257
275
 
258
- // Determine operation types and prepare before events
259
- const beforeEvents: MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>[] = []
276
+ // Normalize all inputs and determine operations in parallel
277
+ const normalizationPromises = inputInfos.map(async (inputInfo) => {
278
+ // Set existing entity if found
279
+ if (inputInfo.primaryKeyValue !== undefined) {
280
+ inputInfo.existingEntity = existingEntitiesMap.get(inputInfo.primaryKeyValue)
281
+ }
260
282
 
261
- // Handle entities with primary keys
262
- for (const entityInfo of entityInfoMap.values()) {
263
- const operation = entityInfo.existingEntity
283
+ // Determine operation type
284
+ inputInfo.operation = inputInfo.existingEntity
264
285
  ? ModelMutationAction.BeforeUpdate
265
286
  : ModelMutationAction.BeforeCreate
266
287
 
267
- entityInfo.operation = operation
268
-
269
- beforeEvents.push(
270
- new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
271
- this.getDescriptor(operation),
272
- entityInfo.input,
288
+ // Normalize the input
289
+ const normalizedInput = await this.normalizeInput(inputInfo.input, {
290
+ existing: inputInfo.existingEntity,
291
+ descriptor: this.getDescriptor(
292
+ inputInfo.existingEntity ? ModelMutationAction.Update : ModelMutationAction.Create,
273
293
  ),
274
- )
275
- }
294
+ })
295
+
296
+ inputInfo.input = normalizedInput
297
+ return normalizedInput
298
+ })
276
299
 
277
- // Handle inputs without primary keys (always creates)
278
- for (const input of inputsWithoutPrimaryKey) {
300
+ const normalizedInputs = await Promise.all(normalizationPromises)
301
+
302
+ // Create before events
303
+ const beforeEvents: MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>[] = []
304
+ for (const inputInfo of inputInfos) {
279
305
  beforeEvents.push(
280
306
  new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
281
- this.getDescriptor(ModelMutationAction.BeforeCreate),
282
- input,
307
+ this.getDescriptor(inputInfo.operation!),
308
+ inputInfo.input,
283
309
  ),
284
310
  )
285
311
  }
@@ -287,59 +313,33 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
287
313
  // Emit all before events
288
314
  await Promise.all(beforeEvents.map((event) => this.emitter.emitAsync(event)))
289
315
 
290
- // Perform the bulk upsert operation with normalized inputs
316
+ // Perform the bulk upsert operation with all normalized inputs
291
317
  const results = await this.repository.bulkUpsert(normalizedInputs, options)
292
318
 
293
- // Create a map of result primary keys to results for matching
294
- const resultsByPrimaryKey = new Map<string | number, InferDetail<TSchema>>()
295
- const resultsWithoutPrimaryKey: InferDetail<TSchema>[] = []
296
-
297
- for (const result of results) {
298
- const pkValue = this.getPrimaryKeyValue(result)
299
- if (pkValue !== undefined) {
300
- resultsByPrimaryKey.set(pkValue, result)
301
- } else {
302
- resultsWithoutPrimaryKey.push(result)
303
- }
304
- }
305
-
306
- // Prepare after events by matching results back to original inputs
319
+ // Create after events and return results
307
320
  const afterEvents: MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>[] = []
308
- let resultsWithoutPkIndex = 0
309
321
 
310
- // Handle entities with primary keys
311
- for (const entityInfo of entityInfoMap.values()) {
312
- const matchedResult = resultsByPrimaryKey.get(entityInfo.primaryKeyValue)!
322
+ for (let i = 0; i < inputInfos.length; i++) {
323
+ const inputInfo = inputInfos[i]
324
+ const result = results[i]
313
325
 
314
326
  const afterOperation =
315
- entityInfo.operation === ModelMutationAction.BeforeCreate
327
+ inputInfo.operation === ModelMutationAction.BeforeCreate
316
328
  ? ModelMutationAction.AfterCreate
317
329
  : ModelMutationAction.AfterUpdate
318
330
 
319
331
  afterEvents.push(
320
332
  new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
321
333
  this.getDescriptor(afterOperation),
322
- entityInfo.input,
323
- ).setResult(matchedResult),
324
- )
325
- }
326
-
327
- // Handle inputs without primary keys (always creates)
328
- for (const input of inputsWithoutPrimaryKey) {
329
- const matchedResult = resultsWithoutPrimaryKey[resultsWithoutPkIndex++]
330
-
331
- afterEvents.push(
332
- new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
333
- this.getDescriptor(ModelMutationAction.AfterCreate),
334
- input,
335
- ).setResult(matchedResult),
334
+ inputInfo.input,
335
+ ).setResult(result),
336
336
  )
337
337
  }
338
338
 
339
339
  // Emit all after events
340
340
  await Promise.all(afterEvents.map((event) => this.emitter.emitAsync(event)))
341
341
 
342
- // Return the results
342
+ // Return normalized results
343
343
  return await Promise.all(results.map((result) => this.normalizeDetail(result)))
344
344
  }
345
345
  }
@@ -335,18 +335,41 @@ describe('ReadOnlyModelService', () => {
335
335
  return results
336
336
  }
337
337
  }
338
- class TestService extends ReadOnlyModelService<typeof mockSchema> {}
339
338
 
340
- let testService: TestService
339
+ class TestServiceWithNormalization extends ReadOnlyModelService<typeof mockSchema> {
340
+ async normalizeDetail(detail: InferDetail<typeof mockSchema>): Promise<InferDetail<typeof mockSchema>> {
341
+ // Handle null case (e.g., when load returns null)
342
+ if (!detail) return detail
343
+
344
+ // Convert string dates back to Date objects
345
+ if (typeof detail.publishedDate === 'string') {
346
+ detail.publishedDate = new Date(detail.publishedDate) as any
347
+ }
348
+ return detail
349
+ }
350
+
351
+ async normalizeSummary(summary: InferDetail<typeof mockSchema>): Promise<InferDetail<typeof mockSchema>> {
352
+ // Handle null case (e.g., when load returns null)
353
+ if (!summary) return summary
354
+
355
+ // Convert string dates back to Date objects
356
+ if (typeof summary.publishedDate === 'string') {
357
+ summary.publishedDate = new Date(summary.publishedDate) as any
358
+ }
359
+ return summary
360
+ }
361
+ }
362
+
363
+ let testService: TestServiceWithNormalization
341
364
 
342
365
  beforeEach(() => {
343
366
  repository = new TestRepository()
344
367
  emitter = new EventManager()
345
368
 
346
- testService = new TestService({ repository, emitter, schema: mockSchema, namespace })
369
+ testService = new TestServiceWithNormalization({ repository, emitter, schema: mockSchema, namespace })
347
370
  })
348
371
 
349
- it('should normalize details in the load response', async () => {
372
+ it('should allow custom normalization of details in the load response when overridden', async () => {
350
373
  const input = { id: 100, title: 'Normalization Test', author: 'Normalizer', publishedDate: new Date() }
351
374
  await repository.create(input)
352
375
 
@@ -359,7 +382,7 @@ describe('ReadOnlyModelService', () => {
359
382
  expect(actualDate).toBeInstanceOf(Date)
360
383
  })
361
384
 
362
- it('should normalize details in the loadMany response', async () => {
385
+ it('should allow custom normalization of details in the loadMany response when overridden', async () => {
363
386
  const input1 = { id: 101, title: 'Normalization Test 1', author: 'Normalizer 1', publishedDate: new Date() }
364
387
  const input2 = { id: 102, title: 'Normalization Test 2', author: 'Normalizer 2', publishedDate: new Date() }
365
388
  await repository.create(input1)
@@ -376,7 +399,7 @@ describe('ReadOnlyModelService', () => {
376
399
  }
377
400
  })
378
401
 
379
- it('should normalize details in the search response', async () => {
402
+ it('should allow custom normalization of summaries in the search response when overridden', async () => {
380
403
  const input1 = { id: 103, title: 'Normalization Test 3', author: 'Normalizer 3', publishedDate: new Date() }
381
404
  const input2 = { id: 104, title: 'Normalization Test 4', author: 'Normalizer 4', publishedDate: new Date() }
382
405
  await repository.create(input1)
@@ -392,5 +415,18 @@ describe('ReadOnlyModelService', () => {
392
415
  expect(actualDate).toBeInstanceOf(Date)
393
416
  }
394
417
  })
418
+
419
+ it('should not normalize data by default when normalization methods are not overridden', async () => {
420
+ const defaultService = new ReadOnlyModelService({ repository, emitter, schema: mockSchema, namespace })
421
+
422
+ const input = { id: 105, title: 'Default Test', author: 'Default Author', publishedDate: new Date() }
423
+ await repository.create(input)
424
+
425
+ const record = await defaultService.load({ id: 105 })
426
+
427
+ // Should return the raw string from repository since no normalization is applied
428
+ expect(record.publishedDate as any).toBe('2024-01-01')
429
+ expect(typeof record.publishedDate).toBe('string')
430
+ })
395
431
  })
396
432
  })
@@ -10,7 +10,6 @@ import { ModelQueryEvent } from '../events/event-types'
10
10
  import { QueryEvent } from '../events/query-event'
11
11
  import { BaseModelService, type IActionOptions } from './base-model-service'
12
12
  import type { IPaginationInput } from '../models/pagination'
13
- import type { IUpdateOptions } from './model-service'
14
13
 
15
14
  export interface ILoadOptions extends IActionOptions {}
16
15
  export interface ISearchOptions<TSchema extends AnyModelSchema> extends IActionOptions {
@@ -27,15 +26,6 @@ export class ReadOnlyModelService<TSchema extends AnyModelSchema> extends BaseMo
27
26
  * @returns The normalized detail data.
28
27
  */
29
28
  async normalizeDetail(detail: InferDetail<TSchema>): Promise<InferDetail<TSchema>> {
30
- const detailModel = this.schema.definition.detail as Model<any, any>
31
- if (detailModel) {
32
- const validation = await detailModel.validate(detail, { strict: false })
33
- if (validation.issues) {
34
- console.warn(`${detailModel.labels.singularLabel} shape did not match the expected schema`, validation)
35
- } else {
36
- return validation.value
37
- }
38
- }
39
29
  return detail
40
30
  }
41
31
 
@@ -48,15 +38,6 @@ export class ReadOnlyModelService<TSchema extends AnyModelSchema> extends BaseMo
48
38
  * @returns The normalized summary data.
49
39
  */
50
40
  async normalizeSummary(summary: InferDetail<TSchema>): Promise<InferDetail<TSchema>> {
51
- const summaryModel = this.schema.definition.summary as Model<any, any>
52
- if (summaryModel) {
53
- const validation = await summaryModel.validate(summary, { strict: false })
54
- if (validation.issues) {
55
- console.warn(`${summaryModel.labels.singularLabel} shape did not match the expected schema`)
56
- } else {
57
- return validation.value
58
- }
59
- }
60
41
  return summary
61
42
  }
62
43