@tim-code/my-util 0.5.2 → 0.5.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tim-code/my-util",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "author": "Tim Sprowl",
package/src/object.js CHANGED
@@ -1,3 +1,27 @@
1
+ /**
2
+ * Returns if the argument is an object.
3
+ * @param {any} thing
4
+ * @returns {boolean}
5
+ */
6
+ export function isObject(thing) {
7
+ return typeof thing === "object" && thing !== null
8
+ }
9
+
10
+ /**
11
+ * Creates a new object with values created by calling callback on each of argument's values.
12
+ * @param {Object} object
13
+ * @param {Function} callback (value, key, object) => newValue // note if not changing value, should return value
14
+ * @returns {Object}
15
+ */
16
+ export function mapValues(object, callback) {
17
+ const result = {}
18
+ const keys = Object.keys(object)
19
+ for (const key of keys) {
20
+ result[key] = callback(object[key], key, object)
21
+ }
22
+ return result
23
+ }
24
+
1
25
  /**
2
26
  * Mutates the passed in object by calling callback on each of its values.
3
27
  * @param {Object} object
@@ -56,10 +80,31 @@ export function like(template) {
56
80
  }
57
81
  }
58
82
 
83
+ /**
84
+ * Copies the source recursively.
85
+ * Does not preserve constructors of source or constructors of its keys' values.
86
+ * @template T
87
+ * @param {T} source
88
+ * @returns {T}
89
+ */
90
+ export function deepCopy(source) {
91
+ if (Array.isArray(source)) {
92
+ return source.map(deepCopy)
93
+ }
94
+ if (isObject(source)) {
95
+ return mapValues(source, deepCopy)
96
+ }
97
+ // primitive or function
98
+ return source
99
+ }
100
+
59
101
  /**
60
102
  * Deeply merges one or more source objects into a target object.
61
- * If the property values are objects, they are merged recursively.
62
- * Non-object properties (including arrays) are directly assigned to the target.
103
+ * Specifically:
104
+ * If the target object has the key and the target's key's value is an non-array object AND
105
+ * the source object's value is a non-array object, recursively merges the source into the target.
106
+ * Otherwise, assigns source's key's value into the target's key.
107
+ * This means that arrays are never merged into arrays or other objects.
63
108
  * @param {Object} target The target object that will receive the merged properties.
64
109
  * @param {...Object} sources The source objects whose properties will be merged into the target.
65
110
  * @returns {Object} The target object with the merged properties from all source objects.
@@ -76,10 +121,9 @@ export function deepMerge(target, ...sources) {
76
121
  if (Array.isArray(sourceValue)) {
77
122
  target[key] = sourceValue
78
123
  } else if (
79
- targetValue &&
80
- typeof targetValue === "object" &&
81
- sourceValue &&
82
- typeof sourceValue === "object"
124
+ isObject(targetValue) &&
125
+ isObject(sourceValue) &&
126
+ !Array.isArray(targetValue)
83
127
  ) {
84
128
  deepMerge(targetValue, sourceValue)
85
129
  } else {
@@ -90,6 +134,18 @@ export function deepMerge(target, ...sources) {
90
134
  return target
91
135
  }
92
136
 
137
+ /**
138
+ * Merges a deep copy of each source object into target. See deepCopy() and deepMerge() documentation for caveats.
139
+ * @param {Object} target The target object that will receive the merged properties.
140
+ * @param {...Object} sources The source objects whose properties will be merged into the returned object
141
+ * @returns {Object}
142
+ */
143
+ export function deepMergeCopy(target, ...sources) {
144
+ const copies = sources.map(deepCopy)
145
+ const result = deepMerge(target, ...copies)
146
+ return result
147
+ }
148
+
93
149
  /**
94
150
  * Deeply compares two values to determine if they are equal.
95
151
  * Objects and arrays are compared recursively by their properties and elements.
@@ -105,7 +161,7 @@ export function deepEqual(a, b) {
105
161
  if (a === b) {
106
162
  return true
107
163
  }
108
- if (typeof a !== "object" || typeof b !== "object" || !a || !b) {
164
+ if (!isObject(a) || !isObject(b)) {
109
165
  return false
110
166
  }
111
167
  const keysA = Object.keys(a)
@@ -1,14 +1,89 @@
1
1
  /* eslint-disable no-restricted-syntax */
2
2
  import { jest } from "@jest/globals"
3
3
  import {
4
+ deepCopy,
4
5
  deepEqual,
5
6
  deepMerge,
7
+ deepMergeCopy,
6
8
  deleteUndefinedValues,
9
+ isObject,
7
10
  like,
11
+ mapValues,
8
12
  mutateValues,
9
13
  via,
10
14
  } from "./object.js"
11
15
 
16
+ // --- isObject ---
17
+ describe("isObject", () => {
18
+ it("returns true for plain objects", () => {
19
+ expect(isObject({})).toBe(true)
20
+ expect(isObject({ a: 1 })).toBe(true)
21
+ })
22
+
23
+ it("returns true for arrays", () => {
24
+ expect(isObject([])).toBe(true)
25
+ expect(isObject([1, 2])).toBe(true)
26
+ })
27
+
28
+ it("returns false for null", () => {
29
+ expect(isObject(null)).toBe(false)
30
+ })
31
+
32
+ it("returns false for primitives", () => {
33
+ expect(isObject(1)).toBe(false)
34
+ expect(isObject("str")).toBe(false)
35
+ expect(isObject(true)).toBe(false)
36
+ expect(isObject(undefined)).toBe(false)
37
+ expect(isObject(Symbol("blah"))).toBe(false)
38
+ })
39
+
40
+ it("returns false for functions", () => {
41
+ expect(isObject(() => {})).toBe(false)
42
+ // eslint-disable-next-line func-names, prefer-arrow-callback, no-empty-function
43
+ expect(isObject(function () {})).toBe(false)
44
+ })
45
+ })
46
+
47
+ // --- mapValues ---
48
+ describe("mapValues", () => {
49
+ it("maps values in the object using the callback", () => {
50
+ const obj = { a: 1, b: 2 }
51
+ const result = mapValues(obj, (v) => v * 2)
52
+ expect(result).toEqual({ a: 2, b: 4 })
53
+ // original object is not mutated
54
+ expect(obj).toEqual({ a: 1, b: 2 })
55
+ })
56
+
57
+ it("callback receives value, key, and object", () => {
58
+ const obj = { x: 1 }
59
+ const cb = jest.fn((v) => v + 1)
60
+ mapValues(obj, cb)
61
+ expect(cb).toHaveBeenCalledWith(1, "x", obj)
62
+ })
63
+
64
+ it("returns a new object", () => {
65
+ const obj = { foo: "bar" }
66
+ const returned = mapValues(obj, (v) => v)
67
+ expect(returned).not.toBe(obj)
68
+ expect(returned).toEqual(obj)
69
+ })
70
+
71
+ it("handles empty object", () => {
72
+ const obj = {}
73
+ expect(mapValues(obj, (v) => v)).toEqual({})
74
+ })
75
+
76
+ it("does not map inherited enumerable properties", () => {
77
+ const proto = { inherited: 1 }
78
+ const obj = Object.create(proto)
79
+ obj.own = 2
80
+ const result = mapValues(obj, (v) => v + 1)
81
+ expect(result).toEqual({ own: 3 })
82
+ expect(result.inherited).toBeUndefined()
83
+ })
84
+ })
85
+
86
+ // --- mutateValues ---
12
87
  describe("mutateValues", () => {
13
88
  it("mutates values in the object using the callback", () => {
14
89
  const obj = { a: 1, b: 2 }
@@ -45,6 +120,7 @@ describe("mutateValues", () => {
45
120
  })
46
121
  })
47
122
 
123
+ // --- deleteUndefinedValues ---
48
124
  describe("deleteUndefinedValues", () => {
49
125
  it("removes keys with undefined values", () => {
50
126
  const obj = { a: 1, b: undefined, c: 3 }
@@ -84,6 +160,7 @@ describe("deleteUndefinedValues", () => {
84
160
  })
85
161
  })
86
162
 
163
+ // --- via ---
87
164
  describe("via", () => {
88
165
  it("returns a function that accesses the given key", () => {
89
166
  const getFoo = via("foo")
@@ -110,6 +187,7 @@ describe("via", () => {
110
187
  })
111
188
  })
112
189
 
190
+ // --- like ---
113
191
  describe("contains", () => {
114
192
  it("returns true when object contains all template keys with same values", () => {
115
193
  const template = { a: 1, b: 2 }
@@ -167,6 +245,66 @@ describe("contains", () => {
167
245
  })
168
246
  })
169
247
 
248
+ // --- deepCopy ---
249
+ describe("deepCopy", () => {
250
+ it("copies primitives as is", () => {
251
+ expect(deepCopy(1)).toBe(1)
252
+ expect(deepCopy("str")).toBe("str")
253
+ expect(deepCopy(null)).toBe(null)
254
+ expect(deepCopy(undefined)).toBe(undefined)
255
+ const sym = Symbol("x")
256
+ expect(deepCopy(sym)).toBe(sym)
257
+ })
258
+
259
+ it("copies arrays recursively", () => {
260
+ const arr = [1, { a: 2 }, [3, 4]]
261
+ const copy = deepCopy(arr)
262
+ expect(copy).toEqual(arr)
263
+ expect(copy).not.toBe(arr)
264
+ expect(copy[1]).not.toBe(arr[1])
265
+ expect(copy[2]).not.toBe(arr[2])
266
+ })
267
+
268
+ it("copies objects recursively", () => {
269
+ const obj = { a: { b: 2 }, c: [1, 2] }
270
+ const copy = deepCopy(obj)
271
+ expect(copy).toEqual(obj)
272
+ expect(copy).not.toBe(obj)
273
+ expect(copy.a).not.toBe(obj.a)
274
+ expect(copy.c).not.toBe(obj.c)
275
+ })
276
+
277
+ it("does not preserve constructors", () => {
278
+ function Foo() {
279
+ this.x = 1
280
+ }
281
+ const foo = new Foo()
282
+ const copy = deepCopy(foo)
283
+ expect(copy).toEqual({ x: 1 })
284
+ // The constructor is not preserved
285
+ expect(copy instanceof Foo).toBe(false)
286
+ })
287
+
288
+ it("copies empty object/array", () => {
289
+ expect(deepCopy({})).toEqual({})
290
+ expect(deepCopy([])).toEqual([])
291
+ })
292
+
293
+ it("copies nested structures", () => {
294
+ const obj = { a: [{ b: 2 }, { c: [3] }] }
295
+ const copy = deepCopy(obj)
296
+ expect(copy).toEqual(obj)
297
+ expect(copy.a[0]).not.toBe(obj.a[0])
298
+ expect(copy.a[1].c).not.toBe(obj.a[1].c)
299
+ })
300
+
301
+ it("copies functions as is (does not clone)", () => {
302
+ const fn = () => 42
303
+ expect(deepCopy(fn)).toBe(fn)
304
+ })
305
+ })
306
+
307
+ // --- deepMerge ---
170
308
  describe("deepMerge", () => {
171
309
  it("merges flat objects", () => {
172
310
  const target = { a: 1, b: 2 }
@@ -263,8 +401,96 @@ describe("deepMerge", () => {
263
401
  const result = deepMerge(target, { b: 2 })
264
402
  expect(result).toBe(target)
265
403
  })
404
+
405
+ it("assigns object over array if source value is object and target value is array", () => {
406
+ const target = { a: [1, 2] }
407
+ const source = { a: { x: 3 } }
408
+ expect(deepMerge(target, source)).toEqual({ a: { x: 3 } })
409
+ })
410
+
411
+ it("assigns array over object if source value is array and target value is object", () => {
412
+ const target = { a: { x: 1 } }
413
+ const source = { a: [2, 3] }
414
+ expect(deepMerge(target, source)).toEqual({ a: [2, 3] })
415
+ })
416
+
417
+ it("recursively merges only non-array objects", () => {
418
+ const target = { a: { b: 1 }, arr: { x: 1 } }
419
+ const source = { a: { c: 2 }, arr: [1, 2] }
420
+ expect(deepMerge(target, source)).toEqual({ a: { b: 1, c: 2 }, arr: [1, 2] })
421
+ })
422
+ })
423
+
424
+ // --- deepMergeCopy ---
425
+ describe("deepMergeCopy", () => {
426
+ it("deeply merges deep copies of sources into the target", () => {
427
+ const target = { a: { b: 1 } }
428
+ const s1 = { a: { c: 2 } }
429
+ const s2 = { a: { d: 3 } }
430
+ const origTarget = JSON.stringify(target)
431
+ const merged = deepMergeCopy(target, s1, s2)
432
+ expect(merged).toEqual({ a: { b: 1, c: 2, d: 3 } })
433
+ expect(merged).toBe(target)
434
+ expect(merged.a).not.toBe(s1.a)
435
+ expect(merged.a).not.toBe(s2.a)
436
+ expect(JSON.stringify(target)).not.toBe(origTarget)
437
+ expect(s1).toEqual({ a: { c: 2 } })
438
+ expect(s2).toEqual({ a: { d: 3 } })
439
+ })
440
+
441
+ it("does not mutate source objects", () => {
442
+ const target = { a: 1 }
443
+ const s1 = { b: 2 }
444
+ const s2 = { c: 3 }
445
+ const orig1 = JSON.stringify(s1)
446
+ const orig2 = JSON.stringify(s2)
447
+ deepMergeCopy(target, s1, s2)
448
+ expect(JSON.stringify(s1)).toBe(orig1)
449
+ expect(JSON.stringify(s2)).toBe(orig2)
450
+ })
451
+
452
+ it("handles arrays and primitives in sources", () => {
453
+ const target = { a: [1, 2] }
454
+ const s1 = { a: [3, 4], b: 5 }
455
+ expect(deepMergeCopy(target, s1)).toEqual({ a: [3, 4], b: 5 })
456
+ })
457
+
458
+ it("returns the target object", () => {
459
+ const target = { x: 1 }
460
+ const s1 = { y: 2 }
461
+ expect(deepMergeCopy(target, s1)).toBe(target)
462
+ })
463
+
464
+ it("merges nothing if no sources provided (returns target as is)", () => {
465
+ const target = { foo: 1 }
466
+ expect(deepMergeCopy(target)).toBe(target)
467
+ expect(target).toEqual({ foo: 1 })
468
+ })
469
+
470
+ it("returns target if called with no arguments", () => {
471
+ expect(deepMergeCopy({})).toEqual({})
472
+ expect(deepMergeCopy()).toEqual(undefined)
473
+ })
474
+
475
+ it("does not mutate the target if no sources are provided", () => {
476
+ const target = { z: 9 }
477
+ const result = deepMergeCopy(target)
478
+ expect(result).toBe(target)
479
+ expect(result).toEqual({ z: 9 })
480
+ })
481
+
482
+ it("does not merge inherited properties from sources", () => {
483
+ const target = {}
484
+ const proto = { x: 1 }
485
+ const s1 = Object.create(proto)
486
+ s1.a = 2
487
+ deepMergeCopy(target, s1)
488
+ expect(target).toEqual({ a: 2 })
489
+ expect("x" in target).toBe(false)
490
+ })
266
491
  })
267
492
 
493
+ // --- deepEqual ---
268
494
  describe("deepEqual", () => {
269
495
  it("returns true for strictly equal primitives", () => {
270
496
  expect(deepEqual(1, 1)).toBe(true)