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

Haixing-Hu / js-common-decorator / 19554512-8fec-4ca3-b10e-185456332dd1

17 Oct 2024 03:09AM UTC coverage: 82.486% (-1.9%) from 84.405%
19554512-8fec-4ca3-b10e-185456332dd1

push

circleci

Haixing-Hu
build: specifies files of deployment

441 of 549 branches covered (80.33%)

Branch coverage included in aggregate %.

614 of 730 relevant lines covered (84.11%)

156.93 hits per line

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

92.12
/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 {
13
  getClassMetadata,
14
  getFieldMetadata,
15
  getDefaultInstance,
16
  hasPrototypeFunction,
17
} from '../utils';
18
import {
19
  KEY_CLASS_CATEGORY,
20
  KEY_FIELD_ELEMENT_TYPE,
21
  KEY_FIELD_TYPE,
22
} from '../metadata-keys';
23
import classMetadataCache from '../class-metadata-cache';
24
import DefaultOptions from '../../default-options';
25
import defaultNormalizer from '../../default-normalizer';
26
import ofValueImpl from '../enum/of-value-impl';
27
import getSourceKey from './get-source-key';
28
import getExistKeyWithDifferentNamingStyle from './get-exist-key-with-different-naming-style';
29
import CLONE_OPTIONS from './clone-options';
30

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

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

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

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

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

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

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

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

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

550
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