• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Haixing-Hu / js-common-decorator / 214d62ca-32aa-44ba-af6c-f0078b9541b4

29 Oct 2024 06:30PM UTC coverage: 82.353% (-0.7%) from 83.06%
214d62ca-32aa-44ba-af6c-f0078b9541b4

push

circleci

Haixing-Hu
build: upgrade dependencies and rebuild the library

473 of 581 branches covered (81.41%)

Branch coverage included in aggregate %.

633 of 762 relevant lines covered (83.07%)

174.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.31
/src/impl/model/assign-impl.js
1
////////////////////////////////////////////////////////////////////////////////
2
//
3
//    Copyright (c) 2022 - 2023.
4
//    Haixing Hu, Qubit Co. Ltd.
5
//
6
//    All rights reserved.
7
//
8
////////////////////////////////////////////////////////////////////////////////
9
import clone from '@haixing_hu/clone';
10
import { isBuiltInClass } from '@haixing_hu/type-detect';
11
import { isUndefinedOrNull } from '@haixing_hu/common-util';
12
import CLONE_OPTIONS from './clone-options';
13
import DefaultOptions from '../../default-options';
14
import classMetadataCache from '../class-metadata-cache';
15
import defaultNormalizer from '../../default-normalizer';
16
import getClassMetadata from '../utils/get-class-metadata';
17
import getDefaultInstance from '../utils/get-default-instance';
18
import getExistFieldWithDifferentNamingStyle from './get-exist-field-with-different-naming-style';
19
import getSourceField from './get-source-field';
20
import getTargetFieldElementType from './get-target-field-element-type';
21
import getTargetFieldType from './get-target-field-type';
22
import hasPrototypeFunction from '../utils/has-prototype-function';
23
import ofValueImpl from '../enum/of-value-impl';
24
import { KEY_CLASS_CATEGORY } from '../metadata-keys';
25

26
const Impl = {
35✔
27
  /**
28
   * Gets the names of all fields of the default instance of target class.
29
   *
30
   * If the parent class of the target class has an `assign()` method, the
31
   * `assign()` method of the parent class is called firstly, and then the
32
   * fields owned by the parent class are excluded.
33
   *
34
   * @param {object} target
35
   *     The target object to be assigned to.
36
   * @param {object} source
37
   *     The source object to assigned from, which can be `null` or `undefined`,
38
   *     or a plain old JavaScript object without class information.
39
   * @param {function} type
40
   *     The type of the target object, i.e., the constructor of the class of
41
   *     the target object.
42
   * @param {object} options
43
   *     the additional options for the assignment.
44
   * @returns {string[]}
45
   *     The names of all fields of the default instance of the target class,
46
   *     excluding the fields owned by the parent class of the target class if
47
   *     the parent class has an `assign()` method.
48
   * @author Haixing Hu
49
   * @private
50
   */
51
  getAllFields(target, source, { type, options }) {
52
    // Get all names of fields of the default object of the `type`
53
    const fields = Object.keys(target);
316✔
54
    // call the `assign()` method in the parent classes of the target class
55
    const parentType = Object.getPrototypeOf(type);
316✔
56
    if (hasPrototypeFunction(parentType, 'assign')) {
316✔
57
      // If the parent class or its ancestor classes has an `assign()` method,
58
      // call the `assign()` method of the parent class.
59
      parentType.prototype.assign.call(target, source, options);
5✔
60
      // then exclude fields owned by the parent class
61
      const parentInstance = getDefaultInstance(parentType);
5✔
62
      const parentFields = Object.keys(parentInstance);
5✔
63
      return fields.filter((f) => !parentFields.includes(f));
20✔
64
    } else {
65
      return fields;
311✔
66
    }
67
  },
68

69
  /**
70
   * Copies all configurable, enumerable, and non-read-only properties of the
71
   * source object to the target object without naming conversion.
72
   *
73
   * @param {object} target
74
   *     The target object.
75
   * @param {object} source
76
   *     The source object, which may be `null` or `undefined`.
77
   * @author Haixing Hu
78
   * @private
79
   */
80
  copyAllPropertiesWithoutNamingConversion(target, source) {
81
    if (isUndefinedOrNull(source)) {
4!
82
      return;
×
83
    }
84
    Object.keys(target).forEach((key) => {
4✔
85
      if (Object.prototype.hasOwnProperty.call(source, key)) {
24!
86
        target[key] = clone(source[key], CLONE_OPTIONS);
24✔
87
      }
88
    });
89
  },
90

91
  /**
92
   * Clones an enumeration value.
93
   *
94
   * @param {string|any} source
95
   *    The source value being cloned.
96
   * @param {string} targetPath
97
   *    The path of the target value in the property tree of the original root.
98
   * @param {string} sourcePath
99
   *     The path of the source object in the property tree of the original root
100
   *     object.
101
   * @param {Function} type
102
   *    The constructor of the enumeration class of the target value. Note that
103
   *    this enumeration class **must** be decorated by `@Enum`.
104
   * @return {type|*|null}
105
   *    The target value deeply cloned from the source value, whose type is
106
   *    exactly the same as the specified type.
107
   * @author Haixing Hu
108
   * @private
109
   */
110
  cloneEnumValue(source, { sourcePath, type }) {
111
    if (source === null || source === undefined) {
155✔
112
      return null;
1✔
113
    } else if (source instanceof type) {
154✔
114
      return source;
26✔
115
    } else if (typeof source === 'string') {
128!
116
      // the blank string is treated as null enumerator
117
      if (source.trim().length === 0) {
128✔
118
        return null;
7✔
119
      }
120
      // convert the string representation to the enumerator
121
      const e = ofValueImpl(type, source);
121✔
122
      if (e === undefined) {
121✔
123
        throw new RangeError(`The value of property '${sourcePath}' is not an enumerator of ${type.name}: ${source}`);
3✔
124
      }
125
      return e;
118✔
126
    } else {
127
      throw new RangeError(`The value of property '${sourcePath}' is not an enumerator of ${type.name}: ${source}`);
×
128
    }
129
  },
130

131
  /**
132
   * Clones an object of a known type.
133
   *
134
   * @param {object} source
135
   *     The source object being cloned.
136
   * @param {string} targetPath
137
   *     The path of the target object in the property tree of the original root
138
   *     object.
139
   * @param {string} sourcePath
140
   *     The path of the source object in the property tree of the original root
141
   *     object.
142
   * @param {function} type
143
   *     The constructor of the class of the cloned target object.
144
   * @param {object} defaultInstance
145
   *     A default instance in the class of the target object. If a certain
146
   *     attribute value of the target object does not exist in the source
147
   *     object or is null, the corresponding attribute value of this default
148
   *     instance is used to assign the corresponding attribute of the target
149
   *     object. Note that this argument may be `null` or `undefined`.
150
   * @param {object} options
151
   *     the additional options for the assignment.
152
   * @returns
153
   *     A target object deeply cloned from the source object, whose type is
154
   *     exactly the same as the specified type.
155
   * @author Haixing Hu
156
   * @private
157
   */
158
  cloneWithTypeInfo(source, { targetPath, sourcePath, type, defaultInstance, options }) {
159
    if (isUndefinedOrNull(source)) {
284!
160
      // If the source object is nullish, directly deep clone the default object
161
      // without naming conversion
162
      return clone(defaultInstance, CLONE_OPTIONS);
×
163
    }
164
    if (isBuiltInClass(type)) {
284✔
165
      // For JS built-in standard types, directly deep clone the source object
166
      // without naming conversion
167
      return clone(source, CLONE_OPTIONS);
42✔
168
    }
169
    const category = getClassMetadata(type, KEY_CLASS_CATEGORY);
242✔
170
    switch (category) {
242✔
171
      case 'enum':
172
        return this.cloneEnumValue(source, {
152✔
173
          targetPath,
174
          sourcePath,
175
          type,
176
          defaultInstance,
177
        });
178
      case 'model':
179
      default: {
180
        // Construct a target value based on the source type
181
        /* eslint-disable-next-line new-cap */
182
        const target = new type();
90✔
183
        // Recursively assign each attribute value of `source` to `target`
184
        return this.doAssign(target, source, {
90✔
185
          targetPath,
186
          sourcePath,
187
          type,
188
          defaultInstance,
189
          options,
190
        });
191
      }
192
    }
193
  },
194

195
  /**
196
   * Clones an array whose element types are known.
197
   *
198
   * @param {object} sourceArray
199
   *     The source array being cloned.
200
   * @param {string} targetPath
201
   *     The path of the target array in the property tree of the original root
202
   *     object.
203
   * @param {string} sourcePath
204
   *     The path of the source object in the property tree of the original root
205
   *     object.
206
   * @param {function} elementType
207
   *     The constructor of the class of the target array elements to be cloned.
208
   * @param {object} defaultArray
209
   *     The default array, which may be `undefined` or `null`.
210
   * @param {object} options
211
   *     the additional options for the assignment.
212
   * @returns
213
   *     A target array deeply cloned from the source array, whose element type
214
   *     is exactly the same as the specified element type.
215
   * @author Haixing Hu
216
   * @private
217
   */
218
  cloneArrayWithElementTypeInfo(sourceArray, { targetPath, sourcePath, elementType, defaultArray, options }) {
219
    if (!Array.isArray(sourceArray)) {
18!
220
      console.error(`The value of the property '${sourcePath}' should be an array:`, sourceArray);
×
221
      // clone the default array without naming conversion
222
      return clone(defaultArray, CLONE_OPTIONS);
×
223
    }
224
    if (isBuiltInClass(elementType)) {
18✔
225
      // For JS built-in standard element types, directly deep clone the array
226
      // without naming conversion
227
      return clone(sourceArray, CLONE_OPTIONS);
4✔
228
    }
229
    const elementTypeCategory = getClassMetadata(elementType, KEY_CLASS_CATEGORY);
14✔
230
    switch (elementTypeCategory) {
14✔
231
      case 'enum':
232
        // For enumeration classes, because enumeration types are always
233
        // expressed in string form, we only need to copy the source string array.
234
        return sourceArray.map((sourceElement, index) => this.cloneEnumValue(sourceElement, {
3✔
235
          targetPath: `${targetPath}[${index}]`,
236
          sourcePath: `${sourcePath}[${index}]`,
237
          type: elementType,
238
          defaultInstance: (defaultArray ? defaultArray[index] : undefined),
3!
239
        }));
240
      case 'model':
241
      default: {
242
        // For non-enumerated element classes, create a target array of the same
243
        // element type, and assign the values in the source array to the target
244
        // array one by one.
245
        const defaultElement = getDefaultInstance(elementType);
13✔
246
        return sourceArray.map((sourceElement, index) => {
13✔
247
          if (isUndefinedOrNull(sourceElement)) {
34✔
248
            // If the source element is nullish, directly returns it
249
            return sourceElement;
7✔
250
          }
251
          // Create an element from the target array
252
          /* eslint-disable-next-line new-cap */
253
          const targetElement = new elementType();
27✔
254
          // Recursively assign each attribute value of `sourceElement` to `targetElement`.
255
          // Use `defaultElement` as default instance
256
          const defaultInstance = ((defaultArray && defaultArray[index]) ? defaultArray[index] : defaultElement);
27✔
257
          // Recursively assign each element of `sourceArray` to `targetArray`
258
          this.doAssign(targetElement, sourceElement, {
27✔
259
            targetPath: `${targetPath}[${index}]`,
260
            sourcePath: `${sourcePath}[${index}]`,
261
            type: elementType,
262
            defaultInstance,
263
            options,
264
          });
265
          return targetElement;
27✔
266
        });
267
      }
268
    }
269
  },
270

271
  /**
272
   * Clones an object of an unknown type.
273
   *
274
   * @param {object} source
275
   *     The source object being cloned.
276
   * @param {string} targetPath
277
   *     The path of the target object in the property tree of the original root
278
   *     object.
279
   * @param {string} sourcePath
280
   *     The path of the source object in the property tree of the original root
281
   *     object.
282
   * @param {object} defaultInstance
283
   *     A default instance in the class of the target object. If a certain
284
   *     attribute value of the target object does not exist in the source
285
   *     object or is null, the corresponding attribute value of this default
286
   *     instance is used to assign the corresponding attribute of the target
287
   *     object. Note that this argument may be `null` or `undefined`.
288
   * @param {object} options
289
   *     the additional options for the assignment.
290
   * @returns
291
   *     A target object deeply cloned from the source object, whose type is
292
   *     exactly the same as the default object.
293
   * @author Haixing Hu
294
   * @private
295
   */
296
  cloneWithoutTypeInfo(source, { targetPath, sourcePath, defaultInstance, options }) {
297
    // If the default instance is nullish, directly deep clone the source object
298
    if (isUndefinedOrNull(defaultInstance)) {
73!
299
      // clone the source with naming conversion options
300
      return clone(source, {
×
301
        ...CLONE_OPTIONS,
302
        convertNaming: options.convertNaming,
303
        sourceNamingStyle: options.sourceNamingStyle,
304
        targetNamingStyle: options.targetNamingStyle,
305
      });
306
    }
307
    // If the property value of the target object is an object, we must create
308
    // an object of the same prototype and copy the property values of the
309
    // source object
310
    const Class = Object.getPrototypeOf(defaultInstance).constructor;
73✔
311
    const target = new Class();
73✔
312
    // Recursively assign each attribute value of `source` to `target`
313
    return this.doAssign(target, source, {
73✔
314
      targetPath,
315
      sourcePath,
316
      type: Class,
317
      defaultInstance,
318
      options,
319
    });
320
  },
321

322
  /**
323
   * Clones an array whose element type is unknown.
324
   *
325
   * @param {array} sourceArray
326
   *     The source array being cloned.
327
   * @param {string} targetPath
328
   *     The path of the target array in the property tree of the original root
329
   *     object.
330
   * @param {string} sourcePath
331
   *     The path of the source object in the property tree of the original root
332
   *     object.
333
   * @param {array} defaultArray
334
   *     The default array, which may be `undefined` or `null`.
335
   * @param {object} options
336
   *     the additional options for the assignment.
337
   * @returns
338
   *     A target array deeply cloned from the source array, whose element type
339
   *     is exactly the same as the element type of the default array.
340
   * @author Haixing Hu
341
   * @private
342
   */
343
  cloneArrayWithoutElementTypeInfo(sourceArray, { sourcePath, defaultArray, options }) {
344
    // TODO: If there is type information in its default field value, we can
345
    //  construct an array of the same type based on the type information in
346
    //  the default field value.
347
    if (Array.isArray(sourceArray)) {
2✔
348
      // clone the source array with naming conversion options
349
      return clone(sourceArray, {
1✔
350
        ...CLONE_OPTIONS,
351
        convertNaming: options.convertNaming,
352
        sourceNamingStyle: options.sourceNamingStyle,
353
        targetNamingStyle: options.targetNamingStyle,
354
      });
355
    } else {
356
      console.error(`The value of the property '${sourcePath}' should be an array:`, sourceArray);
1✔
357
      // clone the default array without naming conversion
358
      return clone(defaultArray, CLONE_OPTIONS);
1✔
359
    }
360
  },
361

362
  /**
363
   * Copies all properties of a source object to a target object.
364
   *
365
   * @param {object} target
366
   *     The target object to be assigned to.
367
   * @param {object} source
368
   *     The source object to be assigned from, which can be `null` or `undefined`,
369
   *     or a plain old JavaScript object without class information.
370
   * @param {string} targetPath
371
   *     The path of the target object in the property tree of the original root
372
   *     object.
373
   * @param {string} sourcePath
374
   *     The path of the source object in the property tree of the original root
375
   *     object.
376
   * @param {function} type
377
   *     The type of the target object, i.e., the constructor of the class of
378
   *     the target object.
379
   * @param {object} defaultInstance
380
   *     A default instance in the class of the target object. If a certain
381
   *     attribute value of the target object does not exist in the source
382
   *     object or is null, the corresponding attribute value of this default
383
   *     instance is used to assign the corresponding attribute of the target
384
   *     object. Note that this argument may be `null` or `undefined`.
385
   * @param {object} options
386
   *     the additional options for the assignment.
387
   * @returns
388
   *     The target object after assignment.
389
   * @author Haixing Hu
390
   * @private
391
   */
392
  doAssign(target, source, { targetPath, sourcePath, type, defaultInstance, options }) {
393
    if (isUndefinedOrNull(source)) {
320✔
394
      // if source is nullish, assign the default object to the target object
395
      this.copyAllPropertiesWithoutNamingConversion(target, defaultInstance);
4✔
396
    } else {
397
      // Loops over all enumerable properties of the default instance,
398
      // excluding those inherited from the parent class
399
      const metadata = classMetadataCache.get(type);
316✔
400
      const theDefaultInstance = defaultInstance ?? getDefaultInstance(type);
316✔
401
      const targetKeys = this.getAllFields(target, source, { type, options });
316✔
402
      targetKeys.forEach((targetField) => {
316✔
403
        const defaultFieldValue = theDefaultInstance[targetField];
1,119✔
404
        const targetFieldPath = `${targetPath}.${targetField}`;
1,119✔
405
        const sourceField = getSourceField(targetField, options);
1,119✔
406
        const sourceFieldPath = `${sourcePath}.${sourceField}`;
1,119✔
407
        const sourceFieldValue = source[sourceField];
1,119✔
408
        // If field of the target object is annotated with `@Type`, or the
409
        // `options.targetTypes` has the type information of the field, get the field type
410
        const targetFieldType = getTargetFieldType(metadata, targetField, targetFieldPath, options);
1,119✔
411
        // If field of the target object is annotated with `@ElementType`,
412
        // or the `options.targetElementTypes` has the type information of the
413
        // field element, get the field element type
414
        const targetFieldElementType = getTargetFieldElementType(metadata, targetField, targetFieldPath, options);
1,119✔
415
        if (!Object.prototype.hasOwnProperty.call(source, sourceField)) {
1,119✔
416
          // If the source object does not have the field
417
          // warn if the source object has a field with different naming style
418
          const existSourceField = getExistFieldWithDifferentNamingStyle(sourceField, source);
98✔
419
          if (existSourceField) {
98✔
420
            console.warn(`Cannot find the source property '${sourceFieldPath}' `
3✔
421
              + `for the target property '${targetFieldPath}'. `
422
              + `But the source object has a property '${sourcePath}.${existSourceField}'. `
423
              + 'A correct naming conversion may be needed.');
424
            console.warn('source object:', source);
3✔
425
            console.warn('target object:', target);
3✔
426
          }
427
          // and then copy the default field value directly.
428
          target[targetField] = clone(defaultFieldValue, CLONE_OPTIONS);
98✔
429
        } else if (isUndefinedOrNull(sourceFieldValue)) {
1,021✔
430
          // If the source object has the field, but the field value is nullish,
431
          // copy the nullish source field value directly.
432
          target[targetField] = sourceFieldValue;
31✔
433
        } else if (targetFieldType) {
990✔
434
          // If the field of the target object is annotated with `@Type`
435
          target[targetField] = this.cloneWithTypeInfo(sourceFieldValue, {
284✔
436
            targetPath: targetFieldPath,
437
            sourcePath: sourceFieldPath,
438
            type: targetFieldType,
439
            defaultInstance: defaultFieldValue,
440
            options,
441
          });
442
        } else if (targetFieldElementType) {
706✔
443
          // If the field of the target object is annotated with `@ElementType`
444
          target[targetField] = this.cloneArrayWithElementTypeInfo(sourceFieldValue, {
18✔
445
            targetPath: targetFieldPath,
446
            sourcePath: sourceFieldPath,
447
            elementType: targetFieldElementType,
448
            defaultArray: defaultFieldValue,
449
            options,
450
          });
451
        } else if (isUndefinedOrNull(defaultFieldValue)) {
688✔
452
          // If the field value of the default instance is nullish, but the
453
          // source object field value is not nullish, and the field is not
454
          // decorated with `@Type`, it is impossible to determine the type of
455
          // the attribute, therefore we directly clone the source object field
456
          // value.
457
          // clone the source field value with the naming conversion options
458
          target[targetField] = clone(sourceFieldValue, {
13✔
459
            ...CLONE_OPTIONS,
460
            convertNaming: options.convertNaming,
461
            sourceNamingStyle: options.sourceNamingStyle,
462
            targetNamingStyle: options.targetNamingStyle,
463
          });
464
        } else if (Array.isArray(defaultFieldValue)) {
675✔
465
          // If the field value of the target object is an array but has not
466
          // been annotated with `@ElementType`
467
          target[targetField] = this.cloneArrayWithoutElementTypeInfo(sourceFieldValue, {
2✔
468
            targetPath: targetFieldPath,
469
            sourcePath: sourceFieldPath,
470
            defaultArray: defaultFieldValue,
471
            options,
472
          });
473
        } else if ((typeof defaultFieldValue) === 'object') {
673✔
474
          // If the property value of the target object is a non-null and
475
          // non-array object, we must create an object of the same prototype
476
          // and copy the property value of the source object
477
          target[targetField] = this.cloneWithoutTypeInfo(sourceFieldValue, {
73✔
478
            targetPath: targetFieldPath,
479
            sourcePath: sourceFieldPath,
480
            defaultInstance: defaultFieldValue,
481
            options,
482
          });
483
        } else {
484
          // If the attribute value of the target object is not an object,
485
          // directly clone the attribute value of the source object and assign
486
          // it to the target object.
487
          target[targetField] = clone(sourceFieldValue, {
600✔
488
            ...CLONE_OPTIONS,
489
            convertNaming: options.convertNaming,
490
            sourceNamingStyle: options.sourceNamingStyle,
491
            targetNamingStyle: options.targetNamingStyle,
492
          });
493
        }
494
      });
495
    }
496
    if (options.normalize) {
314✔
497
      return defaultNormalizer(target);
271✔
498
    } else {
499
      return target;
43✔
500
    }
501
  },
502
};
503

504
/**
505
 * Assigns the specified source object to the specified target object.
506
 *
507
 * @param {function|ObjectConstructor} Class
508
 *     The constructor of the class of the target object.
509
 * @param {object} target
510
 *     The target object which will be assigned to. This object must be an
511
 *     instance of the specified `Class`. Each fields of this object will be
512
 *     assigned from the corresponding fields of the source object, recursively.
513
 * @param {object} source
514
 *     The source object which will be assigned from. This object may be any
515
 *     plain old JavaScript object without class information.
516
 * @param {null|undefined|object} options
517
 *     the additional options for the assignment. If this argument is
518
 *     `undefined` or `null`, the default options will be used. The default
519
 *     options can be retrieved by calling `DefaultOptions.get('assign')`.
520
 *     Available options are:
521
 *     - `normalize: boolean`, indicates whether to normalize this object
522
 *       after the assignment. The default value is `true`.
523
 *     - `convertNaming: boolean`, indicates whether to convert the naming
524
 *       style of the target object. The default value is `false`.
525
 *     - `sourceNamingStyle: string | NamingStyle`, the naming style of the
526
 *       source object, i.e., the first argument of the `assign()` method.
527
 *       The default value of this argument is {@link LOWER_UNDERSCORE}.
528
 *     - `targetNamingStyle: string | NamingStyle`, the naming style of the
529
 *       target object, i.e., the object calling the `assign()` method. The
530
 *       default value of this argument is {@link LOWER_CAMEL}.
531
 * @return {Class}
532
 *     The target object after assignment.
533
 * @see DefaultOptions.get('assign')
534
 * @author Haixing Hu
535
 * @private
536
 */
537
function assignImpl(Class, target, source, options) {
538
  const defaultInstance = getDefaultInstance(Class);
130✔
539
  const opt = DefaultOptions.merge('assign', options);
130✔
540
  // If the source object is of the same class with the target object,
541
  // the naming conversion must be disabled
542
  if (source instanceof Class) {
130✔
543
    opt.convertNaming = false;
13✔
544
  }
545
  return Impl.doAssign(target, source, {
130✔
546
    targetPath: Class.name,
547
    sourcePath: 'source',
548
    type: Class,
549
    defaultInstance,
550
    options: opt,
551
  });
552
}
553

554
export default assignImpl;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc