Skip to content

Commit 0e60932

Browse files
Fully replace enums and option-like arrays
1 parent 184070b commit 0e60932

2 files changed

Lines changed: 196 additions & 4 deletions

File tree

src/utils.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Field } from './field/type'
2+
import type { JsfObjectSchema, JsfSchema } from './types'
23

34
type DiskSizeUnit = 'Bytes' | 'KB' | 'MB'
45

@@ -60,12 +61,24 @@ function isObject(value: any): boolean {
6061
return value && typeof value === 'object' && !Array.isArray(value)
6162
}
6263

64+
/**
65+
* Checks if the array is options-like.
66+
* Options-like arrays are arrays of objects, all items of which have a const property.
67+
*/
68+
function isOptionsLikeSchema(schemaKey: string, schema: JsfSchema[]): boolean {
69+
if (schemaKey === 'oneOf' || schemaKey === 'anyOf') {
70+
return schema.length > 0 && schema.every(item => !!item && typeof item === 'object' && item.const !== undefined)
71+
}
72+
73+
return false
74+
}
75+
6376
/**
6477
* Merges schema 2 into schema 1 recursively
6578
* @param schema1 - The first schema to merge
6679
* @param schema2 - The second schema to merge
6780
*/
68-
export function deepMergeSchemas<T extends Record<string, any>>(schema1: T, schema2: T): void {
81+
export function deepMergeSchemas<T extends Record<string, any>>(schema1?: T, schema2?: T): void {
6982
// Handle null/undefined values
7083
if (!schema1 || !schema2) {
7184
return
@@ -100,9 +113,9 @@ export function deepMergeSchemas<T extends Record<string, any>>(schema1: T, sche
100113
else if (schema1Value && Array.isArray(schema2Value)) {
101114
const originalArray = schema1Value
102115

103-
// For 'options' arrays, just replace the whole array (they're immutable and cached) rather
116+
// For 'options'/'enum' arrays or option-like arrays, just replace the whole array (they're immutable and cached) rather
104117
// than recursively deep merging them
105-
if (key === 'options') {
118+
if (['options', 'enum'].includes(key) || isOptionsLikeSchema(key, schema2Value) || isOptionsLikeSchema(key, schema1Value)) {
106119
schema1[key as keyof T] = schema2Value as T[keyof T]
107120
continue
108121
}

test/utils.test.ts

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Field } from '../src/field/type'
22
import { describe, expect, it } from '@jest/globals'
3-
import { getField } from '../src/utils'
3+
import { convertKBToMB, deepMergeSchemas, getField } from '../src/utils'
44

55
describe('getField', () => {
66
const mockFields: Field[] = [
@@ -111,3 +111,182 @@ describe('getField', () => {
111111
expect(field?.name).toBe('level3')
112112
})
113113
})
114+
115+
describe('convertKBToMB', () => {
116+
it('should return 0 when given 0', () => {
117+
expect(convertKBToMB(0)).toBe(0)
118+
})
119+
120+
it('should convert KB to MB', () => {
121+
expect(convertKBToMB(1024)).toBe(1)
122+
expect(convertKBToMB(2048)).toBe(2)
123+
})
124+
125+
it('should round the result to 2 decimal places', () => {
126+
// 1505 / 1024 = 1.469726562 -> 1.46
127+
expect(convertKBToMB(1505)).toBe(1.47)
128+
// 1590 / 1024 = 1.552734375 -> 1.55
129+
expect(convertKBToMB(1590)).toBe(1.55)
130+
})
131+
132+
it('should handle values smaller than 1 MB', () => {
133+
// 512 / 1024 = 0.5
134+
expect(convertKBToMB(512)).toBe(0.5)
135+
})
136+
})
137+
138+
describe('deepMergeSchemas', () => {
139+
it('should do nothing when either schema is missing', () => {
140+
const schema1 = { type: 'string' }
141+
expect(() => deepMergeSchemas(undefined, schema1)).not.toThrow()
142+
expect(() => deepMergeSchemas(schema1, undefined)).not.toThrow()
143+
expect(schema1).toEqual({ type: 'string' })
144+
})
145+
146+
it('should return early without mutating when a schema is a truthy non-object', () => {
147+
const schema1: Record<string, any> = { type: 'string' }
148+
expect(() => deepMergeSchemas(42 as any, schema1)).not.toThrow()
149+
expect(() => deepMergeSchemas(schema1, 'not-an-object' as any)).not.toThrow()
150+
expect(schema1).toEqual({ type: 'string' })
151+
})
152+
153+
it('should copy over properties that only exist in schema2', () => {
154+
const schema1: Record<string, any> = { type: 'string' }
155+
deepMergeSchemas(schema1, { title: 'Name' })
156+
expect(schema1).toEqual({ type: 'string', title: 'Name' })
157+
})
158+
159+
it('should overwrite primitive values that differ', () => {
160+
const schema1: Record<string, any> = { title: 'Old' }
161+
deepMergeSchemas(schema1, { title: 'New' })
162+
expect(schema1.title).toBe('New')
163+
})
164+
165+
it('should merge nested objects recursively', () => {
166+
const schema1: Record<string, any> = {
167+
properties: { name: { type: 'string' } },
168+
}
169+
deepMergeSchemas(schema1, {
170+
properties: { age: { type: 'number' } },
171+
})
172+
expect(schema1.properties).toEqual({
173+
name: { type: 'string' },
174+
age: { type: 'number' },
175+
})
176+
})
177+
178+
it('should assign an object when the target value is not an object', () => {
179+
const schema1: Record<string, any> = { meta: 'string' }
180+
deepMergeSchemas(schema1, { meta: { nested: true } })
181+
expect(schema1.meta).toEqual({ nested: true })
182+
})
183+
184+
it('should skip if/then/else properties', () => {
185+
const schema1: Record<string, any> = { type: 'object' }
186+
deepMergeSchemas(schema1, {
187+
if: { properties: { a: { const: 1 } } },
188+
then: { required: ['b'] },
189+
else: { required: ['c'] },
190+
})
191+
expect(schema1).toEqual({ type: 'object' })
192+
})
193+
194+
it('should replace options arrays instead of merging them', () => {
195+
const schema1: Record<string, any> = {
196+
options: [{ value: 'a' }, { value: 'b' }],
197+
}
198+
deepMergeSchemas(schema1, {
199+
options: [{ value: 'c' }],
200+
})
201+
expect(schema1.options).toEqual([{ value: 'c' }])
202+
})
203+
204+
it('should replace enum arrays instead of merging them', () => {
205+
const schema1: Record<string, any> = { enum: ['a', 'b'] }
206+
deepMergeSchemas(schema1, { enum: ['c', 'd'] })
207+
expect(schema1.enum).toEqual(['c', 'd'])
208+
})
209+
210+
it('should only add new elements to the required array', () => {
211+
const schema1: Record<string, any> = { required: ['a', 'b'] }
212+
deepMergeSchemas(schema1, { required: ['b', 'c'] })
213+
expect(schema1.required).toEqual(['a', 'b', 'c'])
214+
})
215+
216+
it('should replace primitive arrays, not merge by index', () => {
217+
const schema1: Record<string, any> = { enum: ['a', 'b'] }
218+
deepMergeSchemas(schema1, { enum: ['c', 'd'] })
219+
expect(schema1.enum).toEqual(['c', 'd'])
220+
})
221+
222+
it('should replace the whole options array rather than merging it by index', () => {
223+
const schema1: Record<string, any> = {
224+
options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }],
225+
}
226+
const incomingOptions = [{ value: 'c', label: 'C' }]
227+
deepMergeSchemas(schema1, {
228+
options: incomingOptions,
229+
})
230+
expect(schema1.options).toStrictEqual([{ value: 'c', label: 'C' }])
231+
// preserves the reference identity of the incoming options array
232+
expect(schema1.options).toEqual(incomingOptions)
233+
expect(schema1.options).toHaveLength(1)
234+
})
235+
236+
it('should replace the whole option-like array rather than merging it by index', () => {
237+
const schema1: Record<string, any> = {
238+
anyOf: [{ const: 'A', label: 'A' }, { const: 'B', label: 'B' }],
239+
}
240+
const incomingAnyOf = [{ const: 'C', label: 'C' }]
241+
deepMergeSchemas(schema1, {
242+
anyOf: incomingAnyOf,
243+
})
244+
// preserves the reference identity of the incoming anyOf array
245+
expect(schema1.anyOf).toEqual(incomingAnyOf)
246+
expect(schema1.anyOf).toHaveLength(1)
247+
})
248+
249+
it('should replace the whole option-like array, preserving null const values', () => {
250+
const schema1: Record<string, any> = {
251+
oneOf: [{ const: 'A', label: 'A' }],
252+
}
253+
const incomingOneOf = [{ const: 'C', label: 'C' }, { const: null, label: 'N/A' }]
254+
deepMergeSchemas(schema1, {
255+
oneOf: incomingOneOf,
256+
})
257+
expect(schema1.oneOf).toStrictEqual([{ const: 'C', label: 'C' }, { const: null, label: 'N/A' }])
258+
259+
expect(schema1.oneOf).toHaveLength(2)
260+
})
261+
262+
it('should index-merge arrays of objects for non-option-like values ', () => {
263+
// Unlike `options`, other object arrays are merged recursively by index.
264+
const schema1: Record<string, any> = {
265+
anyOf: [{ type: 'string', title: 'Old' }, { type: 'number' }],
266+
}
267+
deepMergeSchemas(schema1, {
268+
anyOf: [{ title: 'New' }],
269+
})
270+
expect(schema1.anyOf).toStrictEqual([
271+
{ type: 'string', title: 'New' },
272+
{ type: 'number' },
273+
])
274+
})
275+
276+
it('should handle incoming null values for option-like arrays', () => {
277+
const schema1: Record<string, any> = {
278+
anyOf: [{ const: 'A', label: 'A' }, { const: 'B', label: 'B' }],
279+
}
280+
expect(() => deepMergeSchemas(schema1, { anyOf: [{ const: 'C', label: 'C' }, null] })).not.toThrow()
281+
expect(schema1.anyOf).toStrictEqual([{ const: 'C', label: 'C' }, null])
282+
})
283+
284+
it('should replace the original array if it is option-like', () => {
285+
const schema1: Record<string, any> = {
286+
anyOf: [{ const: 'A', label: 'A' }, { const: 'B', label: 'B' }],
287+
}
288+
289+
deepMergeSchemas(schema1, { anyOf: [] })
290+
expect(schema1.anyOf).toStrictEqual([])
291+
})
292+
})

0 commit comments

Comments
 (0)