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

Haixing-Hu / js-common-decorator / 847c5cf1-0fe8-4fde-b84c-0c83b3cc12ed

21 Apr 2025 05:02AM UTC coverage: 96.91% (+16.9%) from 80.014%
847c5cf1-0fe8-4fde-b84c-0c83b3cc12ed

push

circleci

Haixing-Hu
Fix: Remove incompatible Jest parameter, keep only --runInBand

594 of 617 branches covered (96.27%)

Branch coverage included in aggregate %.

786 of 807 relevant lines covered (97.4%)

326.75 hits per line

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

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

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

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

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

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

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

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

336
  /**
337
   * Clones an array whose element type is unknown.
338
   *
339
   * @param {array} sourceArray
340
   *     The source array being cloned.
341
   * @param {string} targetPath
342
   *     The path of the target array in the property tree of the original root
343
   *     object.
344
   * @param {string} sourcePath
345
   *     The path of the source object in the property tree of the original root
346
   *     object.
347
   * @param {array} defaultArray
348
   *     The default array, which may be `undefined` or `null`.
349
   * @param {object} options
350
   *     the additional options for the assignment.
351
   * @returns
352
   *     A target array deeply cloned from the source array, whose element type
353
   *     is exactly the same as the element type of the default array.
354
   * @author Haixing Hu
355
   * @private
356
   */
357
  cloneArrayNoElementType(sourceArray, { sourcePath, defaultArray, options }) {
358
    // TODO: If there is type information in its default field value, we can
359
    //  construct an array of the same type based on the type information in
360
    //  the default field value.
361
    if (Array.isArray(sourceArray)) {
×
362
      // clone the source array with naming conversion options
363
      return clone(sourceArray, {
×
364
        ...CLONE_OPTIONS,
365
        convertNaming: options.convertNaming,
366
        sourceNamingStyle: options.sourceNamingStyle,
367
        targetNamingStyle: options.targetNamingStyle,
368
      });
369
    } else {
370
      console.error(`The value of the property '${sourcePath}' of the source `
×
371
        + 'object should be an array:', sourceArray);
372
      // clone the default array without naming conversion
373
      return clone(defaultArray, CLONE_OPTIONS);
×
374
    }
375
  },
376

377
  /**
378
   * Copies all properties of a source object to a target object.
379
   *
380
   * @param {object} target
381
   *     The target object to be assigned to.
382
   * @param {object} source
383
   *     The source object to be assigned from, which can be `null` or `undefined`,
384
   *     or a plain old JavaScript object without class information.
385
   * @param {string} targetPath
386
   *     The path of the target object in the property tree of the original root
387
   *     object.
388
   * @param {string} sourcePath
389
   *     The path of the source object in the property tree of the original root
390
   *     object.
391
   * @param {function} targetType
392
   *     The type of the target object, i.e., the constructor of the class of
393
   *     the target object.
394
   * @param {object} defaultInstance
395
   *     A default instance in the class of the target object. If a certain
396
   *     attribute value of the target object does not exist in the source
397
   *     object or is null, the corresponding attribute value of this default
398
   *     instance is used to assign the corresponding attribute of the target
399
   *     object. Note that this argument may be `null` or `undefined`.
400
   * @param {object} options
401
   *     the additional options for the assignment.
402
   * @returns
403
   *     The target object after assignment.
404
   * @author Haixing Hu
405
   * @private
406
   */
407
  doAssign(target, source, { targetPath, sourcePath, targetType, defaultInstance, options }) {
408
    if (isUndefinedOrNull(source)) {
498✔
409
      // If the source object is nullish, directly deep clone the default instance
410
      // without naming style conversion
411
      this.copyPropertiesWithoutNamingConversion(target, defaultInstance);
14✔
412
    } else {
413
      // Loops over all enumerable properties of the default instance,
414
      // excluding those inherited from the parent class
415
      const theDefaultInstance = defaultInstance ?? getDefaultInstance(targetType);
484✔
416
      const targetKeys = this.getAllFields(target, source, { type: targetType, options });
484✔
417
      targetKeys.forEach((targetField) => {
484✔
418
        const defaultFieldValue = theDefaultInstance[targetField];
1,481✔
419
        const targetFieldPath = `${targetPath}.${targetField}`;
1,481✔
420
        const sourceField = getSourceField(targetField, options);
1,481✔
421
        const sourceFieldPath = `${sourcePath}.${sourceField}`;
1,481✔
422
        const sourceFieldValue = source[sourceField];
1,481✔
423
        // If field of the target object is annotated with `@Type`, or the
424
        // `options.types` has the type information of the field, get the field type
425
        const targetFieldType = getFieldType(targetType, targetField, targetFieldPath, options);
1,481✔
426
        // If field of the target object is annotated with `@ElementType`,
427
        // or the `options.elementTypes` has the type information of the
428
        // field element, get the field element type
429
        const targetFieldElementType = getFieldElementType(targetType, targetField, targetFieldPath, options);
1,481✔
430
        if (!Object.prototype.hasOwnProperty.call(source, sourceField)) {
1,481✔
431
          // If the source object does not have the field
432
          // warn if the source object has a field with different naming style
433
          const existSourceField = getExistFieldWithDifferentNamingStyle(sourceField, source);
176✔
434
          if (existSourceField) {
176✔
435
            console.warn(`Cannot find the source property '${sourceFieldPath}' `
15✔
436
              + `for the target property '${targetFieldPath}'. `
437
              + `But the source object has a property '${sourcePath}.${existSourceField}'. `
438
              + 'A correct naming conversion may be needed.');
439
            console.warn('The source object:', source);
15✔
440
            console.warn('The target object:', target);
15✔
441
          }
442
          // and then copy the default field value directly.
443
          target[targetField] = clone(defaultFieldValue, CLONE_OPTIONS);
176✔
444
        } else if (isUndefinedOrNull(sourceFieldValue)) {
1,305✔
445
          // If the source object has the field, but the field value is nullish,
446
          // copy the nullish source field value directly.
447
          target[targetField] = sourceFieldValue;
35✔
448
        } else if (targetFieldElementType) {
1,270✔
449
          // If the field of the target object is annotated with `@ElementType`
450
          target[targetField] = this.cloneArrayWithElementType(sourceFieldValue, {
53✔
451
            targetPath: targetFieldPath,
452
            sourcePath: sourceFieldPath,
453
            elementType: targetFieldElementType,
454
            defaultArray: defaultFieldValue,
455
            options,
456
          });
457
        } else if (targetFieldType) {
1,217✔
458
          // If the field of the target object is annotated with `@Type`
459
          target[targetField] = this.cloneWithType(sourceFieldValue, {
1,178✔
460
            targetPath: targetFieldPath,
461
            sourcePath: sourceFieldPath,
462
            targetType: targetFieldType,
463
            defaultInstance: defaultFieldValue,
464
            options,
465
          });
466
        } else if (isUndefinedOrNull(defaultFieldValue)) {
39!
467
          // If the field value of the default instance is nullish, but the
468
          // source object field value is not nullish, and the field is not
469
          // decorated with `@Type`, it is impossible to determine the type of
470
          // the attribute, therefore we directly clone the source object field
471
          // value.
472
          // clone the source field value with the naming conversion options
473
          target[targetField] = clone(sourceFieldValue, {
39✔
474
            ...CLONE_OPTIONS,
475
            convertNaming: options.convertNaming,
476
            sourceNamingStyle: options.sourceNamingStyle,
477
            targetNamingStyle: options.targetNamingStyle,
478
          });
479
        } else if (Array.isArray(defaultFieldValue)) {
×
480
          // If the field value of the target object is an array but has not
481
          // been annotated with `@ElementType`
482
          target[targetField] = this.cloneArrayNoElementType(sourceFieldValue, {
×
483
            targetPath: targetFieldPath,
484
            sourcePath: sourceFieldPath,
485
            defaultArray: defaultFieldValue,
486
            options,
487
          });
488
        } else if ((typeof defaultFieldValue) === 'object') {
×
489
          // If the property value of the target object is a non-null and
490
          // non-array object, we must create an object of the same prototype
491
          // and copy the property value of the source object
492
          target[targetField] = this.cloneNoType(sourceFieldValue, {
×
493
            targetPath: targetFieldPath,
494
            sourcePath: sourceFieldPath,
495
            defaultInstance: defaultFieldValue,
496
            options,
497
          });
498
        } else {
499
          // If the attribute value of the target object is not an object,
500
          // directly clone the attribute value of the source object and assign
501
          // it to the target object.
502
          target[targetField] = clone(sourceFieldValue, {
×
503
            ...CLONE_OPTIONS,
504
            convertNaming: options.convertNaming,
505
            sourceNamingStyle: options.sourceNamingStyle,
506
            targetNamingStyle: options.targetNamingStyle,
507
          });
508
        }
509
      });
510
    }
511
    if (options.normalize) {
478✔
512
      return defaultNormalizer(target);
423✔
513
    } else {
514
      return target;
55✔
515
    }
516
  },
517
};
518

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

568
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