Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 21 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ export interface Instruction {
optionals: string[]
optionalsInArray: string[][]
parentIsOptional: boolean
array: number
/** Which array context we're currently inside (for storing optionals) */
currentArrayIndex: number
/** Mutable counter for allocating unique array indices to prevent sibling collisions */
nextArrayIndex: { value: number }
unions: TypeCheck<any>[][]
unionKeys: Record<string, 1>
sanitize: MaybeArray<(v: string) => string> | undefined
Expand Down Expand Up @@ -108,16 +111,16 @@ const handleRecord = (

if (!child) return property

const i = instruction.array
instruction.array++
const i = instruction.nextArrayIndex.value
instruction.nextArrayIndex.value++

let v =
`(()=>{` +
`const ar${i}s=Object.keys(${property}),` +
`ar${i}v={};` +
`for(let i=0;i<ar${i}s.length;i++){` +
`const ar${i}p=${property}[ar${i}s[i]];` +
`ar${i}v[ar${i}s[i]]=${mirror(child, `ar${i}p`, instruction)}`
`ar${i}v[ar${i}s[i]]=${mirror(child, `ar${i}p`, { ...instruction, currentArrayIndex: i })}`

const optionals = instruction.optionalsInArray[i + 1]
if (optionals)
Expand All @@ -137,8 +140,8 @@ const handleTuple = (
property: string,
instruction: Instruction
) => {
const i = instruction.array
instruction.array++
const i = instruction.nextArrayIndex.value
instruction.nextArrayIndex.value++

const isRoot = property === 'v' && !instruction.unions.length

Expand All @@ -147,13 +150,13 @@ const handleTuple = (

v += `const ar${i}v=[`

for (let i = 0; i < schema.length; i++) {
if (i !== 0) v += ','
for (let idx = 0; idx < schema.length; idx++) {
if (idx !== 0) v += ','

v += mirror(
schema[i],
joinProperty(property, i, instruction.parentIsOptional),
instruction
schema[idx],
joinProperty(property, idx, instruction.parentIsOptional),
{ ...instruction, currentArrayIndex: i }
)
}

Expand Down Expand Up @@ -369,7 +372,8 @@ const mirror = (
)

if (isOptional) {
const index = instruction.array
// +1 because cleanup code uses optionalsInArray[i + 1] where i is captured BEFORE increment
const index = instruction.currentArrayIndex + 1

if (property.startsWith('ar')) {
const dotIndex = name.indexOf('.')
Expand Down Expand Up @@ -451,8 +455,8 @@ const mirror = (
}
}

const i = instruction.array
instruction.array++
const i = instruction.nextArrayIndex.value
instruction.nextArrayIndex.value++

let reference = property

Expand All @@ -467,7 +471,7 @@ const mirror = (
v +=
`for(let i=0;i<${reference}.length;i++){` +
`const ar${i}p=${reference}[i];` +
`ar${i}v[i]=${mirror(schema.items, `ar${i}p`, instruction)}`
`ar${i}v[i]=${mirror(schema.items, `ar${i}p`, { ...instruction, currentArrayIndex: i })}`

const optionals = instruction.optionalsInArray[i + 1]
if (optionals) {
Expand Down Expand Up @@ -562,7 +566,8 @@ export const createMirror = <T extends TAnySchema>(
const f = mirror(schema, 'v', {
optionals: [],
optionalsInArray: [],
array: 0,
currentArrayIndex: 0,
nextArrayIndex: { value: 0 },
parentIsOptional: false,
unions,
unionKeys: {},
Expand Down
128 changes: 128 additions & 0 deletions test/sibling-arrays.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { t } from 'elysia'

import { describe, it } from 'bun:test'
import { isEqual } from './utils'

describe('Sibling Arrays', () => {
/**
* Regression test for sibling array index collision bug.
* Previously, sibling arrays would share the same optionalsInArray index,
* causing cleanup code for one array to reference properties from another.
*/
it('handle sibling arrays with optionals in different objects', () => {
const shape = t.Object({
a: t.Array(
t.Object({
obj: t.Object({
n: t.Nullable(t.Object({ v: t.String() }))
})
})
),
b: t.Object({
arr: t.Array(t.Object({ x: t.Integer() }))
})
})

const value = {
a: [{ obj: { n: null } }],
b: { arr: [{ x: 1 }] }
}

// Should not throw "Cannot read properties of undefined (reading 'n')"
isEqual(shape, value)
})

it('handle sibling arrays at same depth with optionals', () => {
const shape = t.Object({
first: t.Array(
t.Object({
name: t.String(),
optional: t.Optional(t.String())
})
),
second: t.Array(
t.Object({
id: t.Number(),
optional: t.Optional(t.Number())
})
)
})

const value = {
first: [
{ name: 'a', optional: 'x' },
{ name: 'b' } // no optional
],
second: [
{ id: 1 },
{ id: 2, optional: 42 }
]
}

const expected = {
first: [
{ name: 'a', optional: 'x' },
{ name: 'b' }
],
second: [
{ id: 1 },
{ id: 2, optional: 42 }
]
}

isEqual(shape, value, expected)
})

it('handle nested arrays with optionals at multiple levels', () => {
const shape = t.Array(
t.Object({
name: t.String(),
games: t.Array(
t.Object({
id: t.Number(),
hoursPlay: t.Optional(t.Number())
})
),
// This optional should not be affected by games array processing
social: t.Optional(
t.Object({
twitter: t.Optional(t.String())
})
)
})
)

const value = [
{
name: 'user1',
games: [
{ id: 1, hoursPlay: 10 },
{ id: 2 }
],
social: { twitter: 'user1' }
},
{
name: 'user2',
games: [{ id: 3 }]
// no social
}
]

const expected = [
{
name: 'user1',
games: [
{ id: 1, hoursPlay: 10 },
{ id: 2 }
],
social: { twitter: 'user1' }
},
{
name: 'user2',
games: [{ id: 3 }]
}
]

isEqual(shape, value, expected)
})
})