@rtpaulino/entity 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */ import { ENTITY_METADATA_KEY, ENTITY_VALIDATOR_METADATA_KEY, PROPERTY_METADATA_KEY, PROPERTY_OPTIONS_METADATA_KEY } from './types.js';
1
+ /* eslint-disable @typescript-eslint/no-unsafe-function-type */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ENTITY_METADATA_KEY, ENTITY_OPTIONS_METADATA_KEY, ENTITY_VALIDATOR_METADATA_KEY, PROPERTY_METADATA_KEY, PROPERTY_OPTIONS_METADATA_KEY } from './types.js';
2
2
  import { getInjectedPropertyNames, getInjectedPropertyOptions } from './injected-property.js';
3
3
  import { EntityDI } from './entity-di.js';
4
4
  import { isEqualWith } from 'lodash-es';
@@ -48,6 +48,42 @@ export class EntityUtils {
48
48
  const constructor = Object.getPrototypeOf(obj).constructor;
49
49
  return Reflect.hasMetadata(ENTITY_METADATA_KEY, constructor);
50
50
  }
51
+ /**
52
+ * Gets the entity options for a given constructor
53
+ *
54
+ * @param entityOrClass - The entity class constructor or instance
55
+ * @returns EntityOptions object (empty object if no options are defined)
56
+ * @private
57
+ */ static getEntityOptions(entityOrClass) {
58
+ const constructor = typeof entityOrClass === 'function' ? entityOrClass : Object.getPrototypeOf(entityOrClass).constructor;
59
+ const options = Reflect.getMetadata(ENTITY_OPTIONS_METADATA_KEY, constructor);
60
+ return options ?? {};
61
+ }
62
+ /**
63
+ * Checks if a given entity is marked as a collection entity
64
+ *
65
+ * @param entityOrClass - The entity instance or class to check
66
+ * @returns true if the entity is a collection entity, false otherwise
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * @CollectionEntity()
71
+ * class Tags {
72
+ * @ArrayProperty(() => String)
73
+ * collection: string[];
74
+ * }
75
+ *
76
+ * const tags = new Tags({ collection: ['a', 'b'] });
77
+ * console.log(EntityUtils.isCollectionEntity(tags)); // true
78
+ * console.log(EntityUtils.isCollectionEntity(Tags)); // true
79
+ * ```
80
+ */ static isCollectionEntity(entityOrClass) {
81
+ if (!this.isEntity(entityOrClass)) {
82
+ return false;
83
+ }
84
+ const options = this.getEntityOptions(entityOrClass);
85
+ return options.collection === true;
86
+ }
51
87
  static sameEntity(a, b) {
52
88
  if (!this.isEntity(a) || !this.isEntity(b)) {
53
89
  return false;
@@ -161,7 +197,7 @@ export class EntityUtils {
161
197
  * Serializes an entity to a plain object, converting only properties decorated with @Property()
162
198
  *
163
199
  * @param entity - The entity instance to serialize
164
- * @returns A plain object containing only the serialized decorated properties
200
+ * @returns A plain object containing only the serialized decorated properties, or an array for collection entities
165
201
  *
166
202
  * @remarks
167
203
  * Serialization rules:
@@ -174,6 +210,7 @@ export class EntityUtils {
174
210
  * - undefined values are excluded from the output
175
211
  * - null values are included in the output
176
212
  * - Circular references are not supported (will cause stack overflow)
213
+ * - Collection entities (@CollectionEntity) are unwrapped to just their array
177
214
  *
178
215
  * @example
179
216
  * ```typescript
@@ -205,8 +242,28 @@ export class EntityUtils {
205
242
  * // address: { street: '123 Main St', city: 'Boston' },
206
243
  * // createdAt: '2024-01-01T00:00:00.000Z'
207
244
  * // }
245
+ *
246
+ * @CollectionEntity()
247
+ * class Tags {
248
+ * @ArrayProperty(() => String)
249
+ * collection: string[];
250
+ * }
251
+ *
252
+ * const tags = new Tags({ collection: ['a', 'b'] });
253
+ * const json = EntityUtils.toJSON(tags);
254
+ * // ['a', 'b'] - unwrapped to array
208
255
  * ```
209
256
  */ static toJSON(entity) {
257
+ if (this.isCollectionEntity(entity)) {
258
+ const collectionPropertyOptions = this.getPropertyOptions(entity, 'collection');
259
+ if (!collectionPropertyOptions) {
260
+ throw new Error(`Collection entity 'collection' property is missing metadata`);
261
+ }
262
+ if (!collectionPropertyOptions.array) {
263
+ throw new Error(`Collection entity 'collection' property must be an array`);
264
+ }
265
+ return this.serializeValue(entity.collection, collectionPropertyOptions);
266
+ }
210
267
  const result = {};
211
268
  const keys = this.getPropertyKeys(entity);
212
269
  for (const key of keys){
@@ -234,16 +291,6 @@ export class EntityUtils {
234
291
  if (passthrough) {
235
292
  return value;
236
293
  }
237
- if (options?.collection === true) {
238
- if (!this.isEntity(value)) {
239
- throw new Error(`Expected collection entity but received non-entity value`);
240
- }
241
- const collectionArray = value.collection;
242
- if (!Array.isArray(collectionArray)) {
243
- throw new Error(`Collection entity must have a 'collection' property that is an array`);
244
- }
245
- return collectionArray.map((item)=>this.serializeValue(item));
246
- }
247
294
  if (Array.isArray(value)) {
248
295
  if (options?.serialize) {
249
296
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -269,49 +316,14 @@ export class EntityUtils {
269
316
  throw new Error(`Cannot serialize value of type '${typeof value}'. Use passthrough: true in @Property() to explicitly allow serialization of unknown types.`);
270
317
  }
271
318
  /**
272
- * Deserializes a plain object to an entity instance
273
- *
274
- * @param entityClass - The entity class constructor. Must accept a data object parameter.
275
- * @param plainObject - The plain object to deserialize
276
- * @param parseOptions - Parse options (strict mode)
277
- * @returns Promise resolving to a new instance of the entity with deserialized values
278
- *
279
- * @remarks
280
- * Deserialization rules:
281
- * - All @Property() decorators must include type metadata for parse() to work
282
- * - Properties without type metadata will throw an error
283
- * - Required properties (optional !== true) must be present and not null/undefined
284
- * - Optional properties (optional === true) can be undefined or null
285
- * - Arrays are supported with the array: true option
286
- * - Nested entities are recursively deserialized
287
- * - Type conversion is strict (no coercion)
288
- * - Entity constructors must accept a required data parameter
289
- *
290
- * Validation behavior:
291
- * - If strict: true - both HARD and SOFT problems throw ValidationError
292
- * - If strict: false (default) - HARD problems throw ValidationError, SOFT problems stored
293
- * - Property validators run first, then entity validators
294
- * - Validators can be synchronous or asynchronous
295
- * - Problems are accessible via EntityUtils.getProblems()
296
- * - Raw input data is accessible via EntityUtils.getRawInput()
297
- *
298
- * @example
299
- * ```typescript
300
- * @Entity()
301
- * class User {
302
- * @Property({ type: () => String }) name!: string;
303
- * @Property({ type: () => Number }) age!: number;
304
- *
305
- * constructor(data: Partial<User>) {
306
- * Object.assign(this, data);
307
- * }
308
- * }
309
- *
310
- * const json = { name: 'John', age: 30 };
311
- * const user = await EntityUtils.parse(User, json);
312
- * const userStrict = await EntityUtils.parse(User, json, { strict: true });
313
- * ```
314
- */ static async parse(entityClass, plainObject, parseOptions = {}, knownProblems) {
319
+ * Internal parse implementation with extended options
320
+ * @private
321
+ */ static async _parseInternal(entityClass, plainObject, options = {}) {
322
+ if (this.isCollectionEntity(entityClass)) {
323
+ plainObject = {
324
+ collection: plainObject
325
+ };
326
+ }
315
327
  if (plainObject == null) {
316
328
  throw createValidationError(`Expects an object but received ${typeof plainObject}`);
317
329
  }
@@ -321,7 +333,9 @@ export class EntityUtils {
321
333
  if (typeof plainObject !== 'object') {
322
334
  throw createValidationError(`Expects an object but received ${typeof plainObject}`);
323
335
  }
324
- const strict = parseOptions?.strict ?? false;
336
+ const strict = options.strict ?? false;
337
+ const skipDefaults = options.skipDefaults ?? false;
338
+ const skipMissing = options.skipMissing ?? false;
325
339
  const keys = this.getPropertyKeys(entityClass.prototype);
326
340
  const data = {};
327
341
  const hardProblems = [];
@@ -341,8 +355,11 @@ export class EntityUtils {
341
355
  }
342
356
  const isOptional = propertyOptions.optional === true;
343
357
  if (!(key in plainObject) || value == null) {
358
+ if (skipMissing) {
359
+ continue;
360
+ }
344
361
  let valueToSet = value;
345
- if (propertyOptions.default !== undefined) {
362
+ if (!skipDefaults && propertyOptions.default !== undefined) {
346
363
  valueToSet = typeof propertyOptions.default === 'function' ? await propertyOptions.default() : propertyOptions.default;
347
364
  }
348
365
  if (!isOptional && valueToSet == null) {
@@ -355,7 +372,10 @@ export class EntityUtils {
355
372
  continue;
356
373
  }
357
374
  try {
358
- data[key] = await this.deserializeValue(value, propertyOptions, parseOptions);
375
+ // Only pass strict to nested deserialization, not skipDefaults/skipMissing
376
+ data[key] = await this.deserializeValue(value, propertyOptions, {
377
+ strict
378
+ });
359
379
  } catch (error) {
360
380
  if (error instanceof ValidationError) {
361
381
  const problems = prependPropertyPath(key, error);
@@ -370,13 +390,66 @@ export class EntityUtils {
370
390
  }
371
391
  }
372
392
  }
393
+ return {
394
+ data,
395
+ hardProblems
396
+ };
397
+ }
398
+ /**
399
+ * Deserializes a plain object to an entity instance
400
+ *
401
+ * @param entityClass - The entity class constructor. Must accept a data object parameter.
402
+ * @param plainObject - The plain object to deserialize
403
+ * @param parseOptions - Parse options (strict mode)
404
+ * @returns Promise resolving to a new instance of the entity with deserialized values
405
+ *
406
+ * @remarks
407
+ * Deserialization rules:
408
+ * - All @Property() decorators must include type metadata for parse() to work
409
+ * - Properties without type metadata will throw an error
410
+ * - Required properties (optional !== true) must be present and not null/undefined
411
+ * - Optional properties (optional === true) can be undefined or null
412
+ * - Arrays are supported with the array: true option
413
+ * - Nested entities are recursively deserialized
414
+ * - Type conversion is strict (no coercion)
415
+ * - Entity constructors must accept a required data parameter
416
+ *
417
+ * Validation behavior:
418
+ * - If strict: true - both HARD and SOFT problems throw ValidationError
419
+ * - If strict: false (default) - HARD problems throw ValidationError, SOFT problems stored
420
+ * - Property validators run first, then entity validators
421
+ * - Validators can be synchronous or asynchronous
422
+ * - Problems are accessible via EntityUtils.getProblems()
423
+ * - Raw input data is accessible via EntityUtils.getRawInput()
424
+ *
425
+ * @example
426
+ * ```typescript
427
+ * @Entity()
428
+ * class User {
429
+ * @Property({ type: () => String }) name!: string;
430
+ * @Property({ type: () => Number }) age!: number;
431
+ *
432
+ * constructor(data: Partial<User>) {
433
+ * Object.assign(this, data);
434
+ * }
435
+ * }
436
+ *
437
+ * const json = { name: 'John', age: 30 };
438
+ * const user = await EntityUtils.parse(User, json);
439
+ * const userStrict = await EntityUtils.parse(User, json, { strict: true });
440
+ * ```
441
+ */ static async parse(entityClass, plainObject, parseOptions = {}) {
442
+ const strict = parseOptions?.strict ?? false;
443
+ const { data, hardProblems } = await this._parseInternal(entityClass, plainObject, {
444
+ strict
445
+ });
373
446
  if (hardProblems.length > 0) {
374
447
  throw new ValidationError(hardProblems);
375
448
  }
376
449
  await this.addInjectedDependencies(data, entityClass.prototype);
377
450
  const instance = new entityClass(data);
378
451
  rawInputStorage.set(instance, plainObject);
379
- const problems = await this.validate(instance, knownProblems);
452
+ const problems = await this.validate(instance);
380
453
  if (problems.length > 0 && strict) {
381
454
  throw new ValidationError(problems);
382
455
  }
@@ -440,6 +513,119 @@ export class EntityUtils {
440
513
  }
441
514
  }
442
515
  /**
516
+ * Partially deserializes a plain object, returning a plain object with only present properties
517
+ *
518
+ * @param entityClass - The entity class constructor
519
+ * @param plainObject - The plain object to deserialize
520
+ * @param options - Options with strict mode
521
+ * @returns Promise resolving to a plain object with deserialized properties (Partial<T>)
522
+ *
523
+ * @remarks
524
+ * Differences from parse():
525
+ * - Returns a plain object, not an entity instance
526
+ * - Ignores missing properties (does not include them in result)
527
+ * - Does NOT apply default values to missing properties
528
+ * - When strict: false (default), properties with HARD problems are excluded from result but problems are tracked
529
+ * - When strict: true, any HARD problem throws ValidationError
530
+ * - Nested entities/arrays are still fully deserialized and validated as normal
531
+ *
532
+ * @example
533
+ * ```typescript
534
+ * @Entity()
535
+ * class User {
536
+ * @Property({ type: () => String }) name!: string;
537
+ * @Property({ type: () => Number, default: 0 }) age!: number;
538
+ *
539
+ * constructor(data: Partial<User>) {
540
+ * Object.assign(this, data);
541
+ * }
542
+ * }
543
+ *
544
+ * const partial = await EntityUtils.partialParse(User, { name: 'John' });
545
+ * // partial = { name: 'John' } (age is not included, default not applied)
546
+ *
547
+ * const partialWithError = await EntityUtils.partialParse(User, { name: 'John', age: 'invalid' });
548
+ * // partialWithError = { name: 'John' } (age excluded due to HARD problem)
549
+ * // Access problems via second return value
550
+ * ```
551
+ */ static async partialParse(entityClass, plainObject, options = {}) {
552
+ const result = await this.safePartialParse(entityClass, plainObject, options);
553
+ if (!result.success) {
554
+ throw new ValidationError(result.problems);
555
+ }
556
+ return result.data;
557
+ }
558
+ /**
559
+ * Safely performs partial deserialization without throwing errors
560
+ *
561
+ * @param entityClass - The entity class constructor
562
+ * @param plainObject - The plain object to deserialize
563
+ * @param options - Options with strict mode
564
+ * @returns Promise resolving to a result object with success flag, partial data, and problems
565
+ *
566
+ * @remarks
567
+ * Similar to partialParse() but returns a result object instead of throwing errors:
568
+ * - On success with strict: true - returns { success: true, data: Partial<T>, problems: [] }
569
+ * - On success with strict: false - returns { success: true, data: Partial<T>, problems: [...] } (includes hard problems for excluded properties)
570
+ * - On failure (strict mode only) - returns { success: false, data: undefined, problems: [...] }
571
+ *
572
+ * All partial deserialization rules from partialParse() apply.
573
+ * See partialParse() documentation for detailed behavior.
574
+ *
575
+ * @example
576
+ * ```typescript
577
+ * @Entity()
578
+ * class User {
579
+ * @Property({ type: () => String }) name!: string;
580
+ * @Property({ type: () => Number }) age!: number;
581
+ *
582
+ * constructor(data: Partial<User>) {
583
+ * Object.assign(this, data);
584
+ * }
585
+ * }
586
+ *
587
+ * const result = await EntityUtils.safePartialParse(User, { name: 'John', age: 'invalid' });
588
+ * if (result.success) {
589
+ * console.log(result.data); // { name: 'John' }
590
+ * console.log(result.problems); // [Problem for age property]
591
+ * } else {
592
+ * console.log(result.problems); // Hard problems (only in strict mode)
593
+ * }
594
+ * ```
595
+ */ static async safePartialParse(entityClass, plainObject, options) {
596
+ const strict = options?.strict ?? false;
597
+ const { data, hardProblems } = await this._parseInternal(entityClass, plainObject, {
598
+ strict,
599
+ skipDefaults: true,
600
+ skipMissing: true
601
+ });
602
+ if (strict && hardProblems.length > 0) {
603
+ return {
604
+ success: false,
605
+ data: undefined,
606
+ problems: hardProblems
607
+ };
608
+ }
609
+ const propertyProblems = await this.validateProperties(data, entityClass.prototype);
610
+ const validationProblems = [
611
+ ...hardProblems,
612
+ ...propertyProblems
613
+ ];
614
+ if (strict && propertyProblems.length > 0) {
615
+ return {
616
+ success: false,
617
+ data: undefined,
618
+ problems: validationProblems
619
+ };
620
+ }
621
+ this.setProblems(data, validationProblems);
622
+ return {
623
+ success: true,
624
+ data: data,
625
+ problems: validationProblems
626
+ };
627
+ }
628
+ /**
443
629
  * Updates an entity instance with new values, respecting preventUpdates flags on properties
444
630
  *
445
631
  * @param instance - The entity instance to update. Must be an Entity.
@@ -567,25 +753,7 @@ export class EntityUtils {
567
753
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
568
754
  const typeConstructor = options.type();
569
755
  const isArray = options.array === true;
570
- const isCollection = options.collection === true;
571
756
  const isSparse = options.sparse === true;
572
- const problems = [];
573
- if (isCollection) {
574
- if (!this.isEntity(typeConstructor)) {
575
- throw createValidationError(`Collection property type must be an @Entity() class`);
576
- }
577
- if (!Array.isArray(value)) {
578
- problems.push(new Problem({
579
- message: `Collection property expects an array but received ${typeof value}`
580
- }));
581
- }
582
- const collectionInstance = await this.parse(typeConstructor, {
583
- collection: Array.isArray(value) ? value : []
584
- }, parseOptions, problems);
585
- // override raw input to be the array only
586
- rawInputStorage.set(collectionInstance, value);
587
- return collectionInstance;
588
- }
589
757
  if (isArray) {
590
758
  if (!Array.isArray(value)) {
591
759
  throw createValidationError(`Expects an array but received ${typeof value}`);
@@ -638,9 +806,6 @@ export class EntityUtils {
638
806
  return deserializePrimitive(value, typeConstructor);
639
807
  }
640
808
  if (this.isEntity(typeConstructor)) {
641
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
642
- throw createValidationError(`Expects an object but received ${typeof value}`);
643
- }
644
809
  return await this.parse(typeConstructor, value, parseOptions);
645
810
  }
646
811
  throw createValidationError(`Has unknown type constructor. Supported types are: String, Number, Boolean, Date, BigInt, and @Entity() classes. Use passthrough: true to explicitly allow unknown types.`);
@@ -711,6 +876,24 @@ export class EntityUtils {
711
876
  }
712
877
  return problems;
713
878
  }
879
+ /**
880
+ * Validates all properties on an object (entity instance or plain object)
881
+ * @private
882
+ */ static async validateProperties(dataOrInstance, prototype) {
883
+ const problems = [];
884
+ const keys = Object.keys(dataOrInstance);
885
+ for (const key of keys){
886
+ const options = this.getPropertyOptions(prototype, key);
887
+ if (options) {
888
+ const value = dataOrInstance[key];
889
+ if (value != null) {
890
+ const validationProblems = await this.runPropertyValidators(key, value, options);
891
+ problems.push(...validationProblems);
892
+ }
893
+ }
894
+ }
895
+ return problems;
896
+ }
714
897
  static async addInjectedDependencies(data, prototype) {
715
898
  const injectedPropertyNames = getInjectedPropertyNames(prototype);
716
899
  if (injectedPropertyNames.length === 0) {
@@ -742,22 +925,13 @@ export class EntityUtils {
742
925
  * const problems = await EntityUtils.validate(user);
743
926
  * console.log(problems); // [Problem, Problem, ...]
744
927
  * ```
745
- */ static async validate(instance, knownProblems = []) {
928
+ */ static async validate(instance) {
746
929
  if (!this.isEntity(instance)) {
747
930
  throw new Error('Cannot validate non-entity instance');
748
931
  }
749
932
  const problems = [];
750
- const keys = this.getPropertyKeys(instance);
751
- for (const key of keys){
752
- const options = this.getPropertyOptions(instance, key);
753
- if (options) {
754
- const value = instance[key];
755
- if (value != null) {
756
- const validationProblems = await this.runPropertyValidators(key, value, options);
757
- problems.push(...validationProblems);
758
- }
759
- }
760
- }
933
+ const propertyProblems = await this.validateProperties(instance, instance);
934
+ problems.push(...propertyProblems);
761
935
  const entityValidators = this.getEntityValidators(instance);
762
936
  for (const validatorMethod of entityValidators){
763
937
  const validatorProblems = await instance[validatorMethod]();
@@ -765,12 +939,8 @@ export class EntityUtils {
765
939
  problems.push(...validatorProblems);
766
940
  }
767
941
  }
768
- const allProblems = [
769
- ...knownProblems,
770
- ...problems
771
- ];
772
- EntityUtils.setProblems(instance, allProblems);
773
- return allProblems;
942
+ EntityUtils.setProblems(instance, problems);
943
+ return problems;
774
944
  }
775
945
  /**
776
946
  * Gets the validation problems for an entity instance