From ad1a318e135f9d1d6f7708dd9617c110a2225154 Mon Sep 17 00:00:00 2001 From: DemonHa Date: Thu, 28 Aug 2025 12:49:46 -0700 Subject: [PATCH] feat: support for recursive structures --- src/value/cast/cast.ts | 75 +++++---- src/value/check/check.ts | 100 +++++++----- src/value/clone/clone.ts | 24 ++- test/runtime/value/cast/recursive.ts | 223 ++++++++++++++++++++++++++ test/runtime/value/check/recursive.ts | 49 ++++++ test/runtime/value/clone/clone.ts | 97 +++++++++++ 6 files changed, 483 insertions(+), 85 deletions(-) diff --git a/src/value/cast/cast.ts b/src/value/cast/cast.ts index 28fe3028f..80039e2fc 100644 --- a/src/value/cast/cast.ts +++ b/src/value/cast/cast.ts @@ -96,12 +96,12 @@ function SelectUnion(union: TUnion, references: TSchema[], value: any): TSchema } return select } -function CastUnion(union: TUnion, references: TSchema[], value: any) { +function CastUnion(union: TUnion, references: TSchema[], value: any, cache: WeakMap) { if ('default' in union) { return typeof value === 'function' ? union.default : Clone(union.default) } else { const schema = SelectUnion(union, references, value) - return Cast(schema, references, value) + return Cast(schema, references, value, cache) } } @@ -117,31 +117,31 @@ function Default(schema: TSchema, references: TSchema[], value: any): any { // ------------------------------------------------------------------ // Cast // ------------------------------------------------------------------ -function FromArray(schema: TArray, references: TSchema[], value: any): any { +function FromArray(schema: TArray, references: TSchema[], value: any, cache: WeakMap): any { if (Check(schema, references, value)) return Clone(value) - const created = IsArray(value) ? Clone(value) : Create(schema, references) + const created = IsArray(value) ? value : Create(schema, references) const minimum = IsNumber(schema.minItems) && created.length < schema.minItems ? [...created, ...Array.from({ length: schema.minItems - created.length }, () => null)] : created const maximum = IsNumber(schema.maxItems) && minimum.length > schema.maxItems ? minimum.slice(0, schema.maxItems) : minimum - const casted = maximum.map((value: unknown) => Visit(schema.items, references, value)) + const casted = maximum.map((value: unknown) => Visit(schema.items, references, value, cache)) if (schema.uniqueItems !== true) return casted const unique = [...new Set(casted)] if (!Check(schema, references, unique)) throw new ValueCastError(schema, 'Array cast produced invalid data due to uniqueItems constraint') return unique } -function FromConstructor(schema: TConstructor, references: TSchema[], value: any): any { +function FromConstructor(schema: TConstructor, references: TSchema[], value: any, cache: WeakMap): any { if (Check(schema, references, value)) return Create(schema, references) const required = new Set(schema.returns.required || []) const result = function () {} for (const [key, property] of Object.entries(schema.returns.properties)) { if (!required.has(key) && value.prototype[key] === undefined) continue - result.prototype[key] = Visit(property as TSchema, references, value.prototype[key]) + result.prototype[key] = Visit(property as TSchema, references, value.prototype[key], cache) } return result } -function FromImport(schema: TImport, references: TSchema[], value: unknown): boolean { +function FromImport(schema: TImport, references: TSchema[], value: unknown, cache: WeakMap): boolean { const definitions = globalThis.Object.values(schema.$defs) as TSchema[] const target = schema.$defs[schema.$ref] as TSchema - return Visit(target, [...references, ...definitions], value) + return Visit(target, [...references, ...definitions], value, cache) } // ------------------------------------------------------------------ @@ -165,52 +165,54 @@ function FromIntersect(schema: TIntersect, references: TSchema[], value: any): a function FromNever(schema: TNever, references: TSchema[], value: any): any { throw new ValueCastError(schema, 'Never types cannot be cast') } -function FromObject(schema: TObject, references: TSchema[], value: any): any { +function FromObject(schema: TObject, references: TSchema[], value: any, cache: WeakMap): any { + if (cache.has(value)) return cache.get(value) if (Check(schema, references, value)) return value if (value === null || typeof value !== 'object') return Create(schema, references) const required = new Set(schema.required || []) const result = {} as Record + cache.set(value, result) for (const [key, property] of Object.entries(schema.properties)) { if (!required.has(key) && value[key] === undefined) continue - result[key] = Visit(property, references, value[key]) + result[key] = Visit(property, references, value[key], cache) } // additional schema properties if (typeof schema.additionalProperties === 'object') { const propertyNames = Object.getOwnPropertyNames(schema.properties) for (const propertyName of Object.getOwnPropertyNames(value)) { if (propertyNames.includes(propertyName)) continue - result[propertyName] = Visit(schema.additionalProperties, references, value[propertyName]) + result[propertyName] = Visit(schema.additionalProperties, references, value[propertyName], cache) } } return result } -function FromRecord(schema: TRecord, references: TSchema[], value: any): any { +function FromRecord(schema: TRecord, references: TSchema[], value: any, cache: WeakMap): any { if (Check(schema, references, value)) return Clone(value) if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) return Create(schema, references) const subschemaPropertyName = Object.getOwnPropertyNames(schema.patternProperties)[0] const subschema = schema.patternProperties[subschemaPropertyName] const result = {} as Record for (const [propKey, propValue] of Object.entries(value)) { - result[propKey] = Visit(subschema, references, propValue) + result[propKey] = Visit(subschema, references, propValue, cache) } return result } -function FromRef(schema: TRef, references: TSchema[], value: any): any { - return Visit(Deref(schema, references), references, value) +function FromRef(schema: TRef, references: TSchema[], value: any, cache: WeakMap): any { + return Visit(Deref(schema, references), references, value, cache) } -function FromThis(schema: TThis, references: TSchema[], value: any): any { - return Visit(Deref(schema, references), references, value) +function FromThis(schema: TThis, references: TSchema[], value: any, cache: WeakMap): any { + return Visit(Deref(schema, references), references, value, cache) } -function FromTuple(schema: TTuple, references: TSchema[], value: any): any { +function FromTuple(schema: TTuple, references: TSchema[], value: any, cache: WeakMap): any { if (Check(schema, references, value)) return Clone(value) if (!IsArray(value)) return Create(schema, references) if (schema.items === undefined) return [] - return schema.items.map((schema, index) => Visit(schema, references, value[index])) + return schema.items.map((schema, index) => Visit(schema, references, value[index], cache)) } -function FromUnion(schema: TUnion, references: TSchema[], value: any): any { - return Check(schema, references, value) ? Clone(value) : CastUnion(schema, references, value) +function FromUnion(schema: TUnion, references: TSchema[], value: any, cache: WeakMap): any { + return Check(schema, references, value) ? Clone(value) : CastUnion(schema, references, value, cache) } -function Visit(schema: TSchema, references: TSchema[], value: any): any { +function Visit(schema: TSchema, references: TSchema[], value: any, cache: WeakMap): any { const references_ = IsString(schema.$id) ? Pushref(schema, references) : references const schema_ = schema as any switch (schema[Kind]) { @@ -218,27 +220,27 @@ function Visit(schema: TSchema, references: TSchema[], value: any): any { // Structural // -------------------------------------------------------------- case 'Array': - return FromArray(schema_, references_, value) + return FromArray(schema_, references_, value, cache) case 'Constructor': - return FromConstructor(schema_, references_, value) + return FromConstructor(schema_, references_, value, cache) case 'Import': - return FromImport(schema_, references_, value) + return FromImport(schema_, references_, value, cache) case 'Intersect': return FromIntersect(schema_, references_, value) case 'Never': return FromNever(schema_, references_, value) case 'Object': - return FromObject(schema_, references_, value) + return FromObject(schema_, references_, value, cache) case 'Record': - return FromRecord(schema_, references_, value) + return FromRecord(schema_, references_, value, cache) case 'Ref': - return FromRef(schema_, references_, value) + return FromRef(schema_, references_, value, cache) case 'This': - return FromThis(schema_, references_, value) + return FromThis(schema_, references_, value, cache) case 'Tuple': - return FromTuple(schema_, references_, value) + return FromTuple(schema_, references_, value, cache) case 'Union': - return FromUnion(schema_, references_, value) + return FromUnion(schema_, references_, value, cache) // -------------------------------------------------------------- // DefaultClone // -------------------------------------------------------------- @@ -257,10 +259,13 @@ function Visit(schema: TSchema, references: TSchema[], value: any): any { // Cast // ------------------------------------------------------------------ /** Casts a value into a given type and references. The return value will retain as much information of the original value as possible. */ -export function Cast(schema: T, references: TSchema[], value: unknown): Static +export function Cast(schema: T, references: TSchema[], value: unknown, cache?: WeakMap): Static /** Casts a value into a given type. The return value will retain as much information of the original value as possible. */ -export function Cast(schema: T, value: unknown): Static +export function Cast(schema: T, value: unknown, cache?: WeakMap): Static /** Casts a value into a given type. The return value will retain as much information of the original value as possible. */ export function Cast(...args: any[]) { - return args.length === 3 ? Visit(args[0], args[1], args[2]) : Visit(args[0], [], args[1]) + if (args.length === 2 || (args.length === 3 && args[2] instanceof WeakMap)) { + return Visit(args[0], [], args[1], args[2] ?? new WeakMap()) + } + return Visit(args[0], args[1], args[2], args[3] ?? new WeakMap()) } diff --git a/src/value/check/check.ts b/src/value/check/check.ts index 7a61d9ad1..0412f4a55 100644 --- a/src/value/check/check.ts +++ b/src/value/check/check.ts @@ -109,7 +109,7 @@ function FromAny(schema: TAny, references: TSchema[], value: any): boolean { function FromArgument(schema: TArgument, references: TSchema[], value: any): boolean { return true } -function FromArray(schema: TArray, references: TSchema[], value: any): boolean { +function FromArray(schema: TArray, references: TSchema[], value: any, cache: WeakSet): boolean { if (!IsArray(value)) return false if (IsDefined(schema.minItems) && !(value.length >= schema.minItems)) { return false @@ -117,7 +117,9 @@ function FromArray(schema: TArray, references: TSchema[], value: any): boolean { if (IsDefined(schema.maxItems) && !(value.length <= schema.maxItems)) { return false } - if (!value.every((value) => Visit(schema.items, references, value))) { + if (cache.has(value)) return true + cache.add(value) + if (!value.every((value) => Visit(schema.items, references, value, cache))) { return false } // prettier-ignore @@ -129,7 +131,7 @@ function FromArray(schema: TArray, references: TSchema[], value: any): boolean { return true // exit } const containsSchema = IsDefined(schema.contains) ? schema.contains : Never() - const containsCount = value.reduce((acc: number, value) => (Visit(containsSchema, references, value) ? acc + 1 : acc), 0) + const containsCount = value.reduce((acc: number, value) => (Visit(containsSchema, references, value, cache) ? acc + 1 : acc), 0) if (containsCount === 0) { return false } @@ -166,8 +168,8 @@ function FromBigInt(schema: TBigInt, references: TSchema[], value: any): boolean function FromBoolean(schema: TBoolean, references: TSchema[], value: any): boolean { return IsBoolean(value) } -function FromConstructor(schema: TConstructor, references: TSchema[], value: any): boolean { - return Visit(schema.returns, references, value.prototype) +function FromConstructor(schema: TConstructor, references: TSchema[], value: any, cache: WeakSet): boolean { + return Visit(schema.returns, references, value.prototype, cache) } function FromDate(schema: TDate, references: TSchema[], value: any): boolean { if (!IsDate(value)) return false @@ -191,10 +193,10 @@ function FromDate(schema: TDate, references: TSchema[], value: any): boolean { function FromFunction(schema: TFunction, references: TSchema[], value: any): boolean { return IsFunction(value) } -function FromImport(schema: TImport, references: TSchema[], value: any): boolean { +function FromImport(schema: TImport, references: TSchema[], value: any, cache: WeakSet): boolean { const definitions = globalThis.Object.values(schema.$defs) as TSchema[] const target = schema.$defs[schema.$ref] as TSchema - return Visit(target, [...references, ...definitions], value) + return Visit(target, [...references, ...definitions], value, cache) } function FromInteger(schema: TInteger, references: TSchema[], value: any): boolean { if (!IsInteger(value)) { @@ -217,15 +219,15 @@ function FromInteger(schema: TInteger, references: TSchema[], value: any): boole } return true } -function FromIntersect(schema: TIntersect, references: TSchema[], value: any): boolean { - const check1 = schema.allOf.every((schema) => Visit(schema, references, value)) +function FromIntersect(schema: TIntersect, references: TSchema[], value: any, cache: WeakSet): boolean { + const check1 = schema.allOf.every((schema) => Visit(schema, references, value, cache)) if (schema.unevaluatedProperties === false) { const keyPattern = new RegExp(KeyOfPattern(schema)) const check2 = Object.getOwnPropertyNames(value).every((key) => keyPattern.test(key)) return check1 && check2 } else if (IsSchema(schema.unevaluatedProperties)) { const keyCheck = new RegExp(KeyOfPattern(schema)) - const check2 = Object.getOwnPropertyNames(value).every((key) => keyCheck.test(key) || Visit(schema.unevaluatedProperties as TSchema, references, value[key])) + const check2 = Object.getOwnPropertyNames(value).every((key) => keyCheck.test(key) || Visit(schema.unevaluatedProperties as TSchema, references, value[key], cache)) return check1 && check2 } else { return check1 @@ -240,8 +242,8 @@ function FromLiteral(schema: TLiteral, references: TSchema[], value: any): boole function FromNever(schema: TNever, references: TSchema[], value: any): boolean { return false } -function FromNot(schema: TNot, references: TSchema[], value: any): boolean { - return !Visit(schema.not, references, value) +function FromNot(schema: TNot, references: TSchema[], value: any, cache: WeakSet): boolean { + return !Visit(schema.not, references, value, cache) } function FromNull(schema: TNull, references: TSchema[], value: any): boolean { return IsNull(value) @@ -265,7 +267,7 @@ function FromNumber(schema: TNumber, references: TSchema[], value: any): boolean } return true } -function FromObject(schema: TObject, references: TSchema[], value: any): boolean { +function FromObject(schema: TObject, references: TSchema[], value: any, cache: WeakSet): boolean { if (!TypeSystemPolicy.IsObjectLike(value)) return false if (IsDefined(schema.minProperties) && !(Object.getOwnPropertyNames(value).length >= schema.minProperties)) { return false @@ -273,18 +275,23 @@ function FromObject(schema: TObject, references: TSchema[], value: any): boolean if (IsDefined(schema.maxProperties) && !(Object.getOwnPropertyNames(value).length <= schema.maxProperties)) { return false } + if (cache.has(value)) return true + cache.add(value) const knownKeys = Object.getOwnPropertyNames(schema.properties) for (const knownKey of knownKeys) { const property = schema.properties[knownKey] if (schema.required && schema.required.includes(knownKey)) { - if (!Visit(property, references, value[knownKey])) { + if (!Visit(property, references, value[knownKey], cache)) { + cache.delete(value) return false } if ((ExtendsUndefinedCheck(property) || IsAnyOrUnknown(property)) && !(knownKey in value)) { + cache.delete(value) return false } } else { - if (TypeSystemPolicy.IsExactOptionalProperty(value, knownKey) && !Visit(property, references, value[knownKey])) { + if (TypeSystemPolicy.IsExactOptionalProperty(value, knownKey) && !Visit(property, references, value[knownKey], cache)) { + cache.delete(value) return false } } @@ -293,21 +300,27 @@ function FromObject(schema: TObject, references: TSchema[], value: any): boolean const valueKeys = Object.getOwnPropertyNames(value) // optimization: value is valid if schemaKey length matches the valueKey length if (schema.required && schema.required.length === knownKeys.length && valueKeys.length === knownKeys.length) { + cache.delete(value) return true } else { + cache.delete(value) return valueKeys.every((valueKey) => knownKeys.includes(valueKey)) } } else if (typeof schema.additionalProperties === 'object') { const valueKeys = Object.getOwnPropertyNames(value) - return valueKeys.every((key) => knownKeys.includes(key) || Visit(schema.additionalProperties as TSchema, references, value[key])) + const result = valueKeys.every((key) => knownKeys.includes(key) || Visit(schema.additionalProperties as TSchema, references, value[key], cache)) + + cache.delete(value) + return result } else { + cache.delete(value) return true } } function FromPromise(schema: TPromise, references: TSchema[], value: any): boolean { return IsPromise(value) } -function FromRecord(schema: TRecord, references: TSchema[], value: any): boolean { +function FromRecord(schema: TRecord, references: TSchema[], value: any, cache: WeakSet): boolean { if (!TypeSystemPolicy.IsRecordLike(value)) { return false } @@ -321,11 +334,11 @@ function FromRecord(schema: TRecord, references: TSchema[], value: any): boolean const regex = new RegExp(patternKey) // prettier-ignore const check1 = Object.entries(value).every(([key, value]) => { - return (regex.test(key)) ? Visit(patternSchema, references, value) : true + return (regex.test(key)) ? Visit(patternSchema, references, value, cache) : true }) // prettier-ignore const check2 = typeof schema.additionalProperties === 'object' ? Object.entries(value).every(([key, value]) => { - return (!regex.test(key)) ? Visit(schema.additionalProperties as TSchema, references, value) : true + return (!regex.test(key)) ? Visit(schema.additionalProperties as TSchema, references, value, cache) : true }) : true const check3 = schema.additionalProperties === false @@ -335,8 +348,8 @@ function FromRecord(schema: TRecord, references: TSchema[], value: any): boolean : true return check1 && check2 && check3 } -function FromRef(schema: TRef, references: TSchema[], value: any): boolean { - return Visit(Deref(schema, references), references, value) +function FromRef(schema: TRef, references: TSchema[], value: any, cache: WeakSet): boolean { + return Visit(Deref(schema, references), references, value, cache) } function FromRegExp(schema: TRegExp, references: TSchema[], value: any): boolean { const regex = new RegExp(schema.source, schema.flags) @@ -375,10 +388,10 @@ function FromSymbol(schema: TSymbol, references: TSchema[], value: any): boolean function FromTemplateLiteral(schema: TTemplateLiteral, references: TSchema[], value: any): boolean { return IsString(value) && new RegExp(schema.pattern).test(value) } -function FromThis(schema: TThis, references: TSchema[], value: any): boolean { - return Visit(Deref(schema, references), references, value) +function FromThis(schema: TThis, references: TSchema[], value: any, cache: WeakSet): boolean { + return Visit(Deref(schema, references), references, value, cache) } -function FromTuple(schema: TTuple, references: TSchema[], value: any): boolean { +function FromTuple(schema: TTuple, references: TSchema[], value: any, cache: WeakSet): boolean { if (!IsArray(value)) { return false } @@ -392,15 +405,15 @@ function FromTuple(schema: TTuple, references: TSchema[], value: any): bo return true } for (let i = 0; i < schema.items.length; i++) { - if (!Visit(schema.items[i], references, value[i])) return false + if (!Visit(schema.items[i], references, value[i], cache)) return false } return true } function FromUndefined(schema: TUndefined, references: TSchema[], value: any): boolean { return IsUndefined(value) } -function FromUnion(schema: TUnion, references: TSchema[], value: any): boolean { - return schema.anyOf.some((inner) => Visit(inner, references, value)) +function FromUnion(schema: TUnion, references: TSchema[], value: any, cache: WeakSet): boolean { + return schema.anyOf.some((inner) => Visit(inner, references, value, cache)) } function FromUint8Array(schema: TUint8Array, references: TSchema[], value: any): boolean { if (!IsUint8Array(value)) { @@ -425,7 +438,7 @@ function FromKind(schema: TSchema, references: TSchema[], value: unknown): boole const func = TypeRegistry.Get(schema[Kind])! return func(schema, value) } -function Visit(schema: T, references: TSchema[], value: any): boolean { +function Visit(schema: T, references: TSchema[], value: any, cache: WeakSet): boolean { const references_ = IsDefined(schema.$id) ? Pushref(schema, references) : references const schema_ = schema as any switch (schema_[Kind]) { @@ -434,7 +447,7 @@ function Visit(schema: T, references: TSchema[], value: any): case 'Argument': return FromArgument(schema_, references_, value) case 'Array': - return FromArray(schema_, references_, value) + return FromArray(schema_, references_, value, cache) case 'AsyncIterator': return FromAsyncIterator(schema_, references_, value) case 'BigInt': @@ -442,17 +455,17 @@ function Visit(schema: T, references: TSchema[], value: any): case 'Boolean': return FromBoolean(schema_, references_, value) case 'Constructor': - return FromConstructor(schema_, references_, value) + return FromConstructor(schema_, references_, value, cache) case 'Date': return FromDate(schema_, references_, value) case 'Function': return FromFunction(schema_, references_, value) case 'Import': - return FromImport(schema_, references_, value) + return FromImport(schema_, references_, value, cache) case 'Integer': return FromInteger(schema_, references_, value) case 'Intersect': - return FromIntersect(schema_, references_, value) + return FromIntersect(schema_, references_, value, cache) case 'Iterator': return FromIterator(schema_, references_, value) case 'Literal': @@ -460,19 +473,19 @@ function Visit(schema: T, references: TSchema[], value: any): case 'Never': return FromNever(schema_, references_, value) case 'Not': - return FromNot(schema_, references_, value) + return FromNot(schema_, references_, value, cache) case 'Null': return FromNull(schema_, references_, value) case 'Number': return FromNumber(schema_, references_, value) case 'Object': - return FromObject(schema_, references_, value) + return FromObject(schema_, references_, value, cache) case 'Promise': return FromPromise(schema_, references_, value) case 'Record': - return FromRecord(schema_, references_, value) + return FromRecord(schema_, references_, value, cache) case 'Ref': - return FromRef(schema_, references_, value) + return FromRef(schema_, references_, value, cache) case 'RegExp': return FromRegExp(schema_, references_, value) case 'String': @@ -482,13 +495,13 @@ function Visit(schema: T, references: TSchema[], value: any): case 'TemplateLiteral': return FromTemplateLiteral(schema_, references_, value) case 'This': - return FromThis(schema_, references_, value) + return FromThis(schema_, references_, value, cache) case 'Tuple': - return FromTuple(schema_, references_, value) + return FromTuple(schema_, references_, value, cache) case 'Undefined': return FromUndefined(schema_, references_, value) case 'Union': - return FromUnion(schema_, references_, value) + return FromUnion(schema_, references_, value, cache) case 'Uint8Array': return FromUint8Array(schema_, references_, value) case 'Unknown': @@ -504,10 +517,13 @@ function Visit(schema: T, references: TSchema[], value: any): // Check // -------------------------------------------------------------------------- /** Returns true if the value matches the given type. */ -export function Check(schema: T, references: TSchema[], value: unknown): value is Static +export function Check(schema: T, references: TSchema[], value: unknown, cache?: WeakSet): value is Static /** Returns true if the value matches the given type. */ -export function Check(schema: T, value: unknown): value is Static +export function Check(schema: T, value: unknown, cache?: WeakSet): value is Static /** Returns true if the value matches the given type. */ export function Check(...args: any[]) { - return args.length === 3 ? Visit(args[0], args[1], args[2]) : Visit(args[0], [], args[1]) + if (args.length === 2 || (args.length === 3 && args[2] instanceof WeakSet)) { + return Visit(args[0], [], args[1], args[2] ?? new WeakSet()) + } + return Visit(args[0], args[1], args[2], args[3] ?? new WeakSet()) } diff --git a/src/value/clone/clone.ts b/src/value/clone/clone.ts index 7ea2261b4..3a7d36964 100644 --- a/src/value/clone/clone.ts +++ b/src/value/clone/clone.ts @@ -35,18 +35,26 @@ import { IsArray, IsDate, IsMap, IsSet, IsObject, IsTypedArray, IsValueType } fr // ------------------------------------------------------------------ // Clonable // ------------------------------------------------------------------ -function FromObject(value: FromObject): any { +function FromObject(value: FromObject, cache: WeakMap): any { + if (cache.has(value)) return cache.get(value) const Acc = {} as Record + cache.set(value, Acc) for (const key of Object.getOwnPropertyNames(value)) { - Acc[key] = Clone(value[key]) + Acc[key] = Clone(value[key], cache) } for (const key of Object.getOwnPropertySymbols(value)) { - Acc[key] = Clone(value[key]) + Acc[key] = Clone(value[key], cache) } return Acc } -function FromArray(value: FromArray): any { - return value.map((element: any) => Clone(element)) +function FromArray(value: FromArray, cache: WeakMap): any { + if (cache.has(value)) return cache.get(value) + const Acc: any[] = [] + cache.set(value, Acc) + for (let i = 0; i < value.length; i++) { + Acc.push(Clone(value[i], cache)) + } + return Acc } function FromTypedArray(value: TypedArrayType): any { return value.slice() @@ -67,13 +75,13 @@ function FromValue(value: ValueType): any { // Clone // ------------------------------------------------------------------ /** Returns a clone of the given value */ -export function Clone(value: T): T { - if (IsArray(value)) return FromArray(value) +export function Clone(value: T, cache = new WeakMap()): T { + if (IsArray(value)) return FromArray(value, cache) if (IsDate(value)) return FromDate(value) if (IsTypedArray(value)) return FromTypedArray(value) if (IsMap(value)) return FromMap(value) if (IsSet(value)) return FromSet(value) - if (IsObject(value)) return FromObject(value) + if (IsObject(value)) return FromObject(value, cache) if (IsValueType(value)) return FromValue(value) throw new Error('ValueClone: Unable to clone value') } diff --git a/test/runtime/value/cast/recursive.ts b/test/runtime/value/cast/recursive.ts index cb2616043..7c5d13286 100644 --- a/test/runtime/value/cast/recursive.ts +++ b/test/runtime/value/cast/recursive.ts @@ -95,4 +95,227 @@ describe('value/cast/Recursive', () => { // ], // }) }) + + it('should handle simple circular structures', () => { + const input = { + a: 'hello', + } + + // @ts-expect-error + input.b = input + + const schema = Type.Recursive((This) => + Type.Object({ + a: Type.String(), + b: This, + }), + ) + + const result = Value.Cast(schema, input) + + Assert.IsEqual(result, input) + }) + + it('should handle type coercion in circular structures #1', () => { + const input = { + id: 1, + nodes: [], + } + + // @ts-expect-error + input.nodes = [input] + + const schema = Type.Recursive((This) => + Type.Object({ + id: Type.String(), + nodes: Type.Array(This), + }), + ) + + const result = Value.Cast(schema, input) + + const output = { + id: '', + nodes: [], + } + + // @ts-expect-error + output.nodes = [output] + + Assert.IsEqual(result, output) + }) + + it('should handle type coercion in circular structures #2', () => { + const input = { + value: 42, // number should be cast to string + next: null, + } + + // @ts-expect-error + input.next = input + + const schema = Type.Recursive((This) => + Type.Object({ + value: Type.String(), + next: Type.Union([This, Type.Null()]), + }), + ) + + const result = Value.Cast(schema, input) + + Assert.IsEqual(result.value, '') + Assert.IsEqual(result.next, result) + }) + + it('should handle deeply nested circular structures', () => { + const input = { + id: 'root', + child: { + id: 'child', + parent: null, + }, + } + + // Create circular reference + // @ts-expect-error + input.child.parent = input + + const schema = Type.Recursive((This) => + Type.Object({ + id: Type.String(), + child: Type.Object({ + id: Type.String(), + parent: Type.Union([This, Type.Null()]), + }), + }), + ) + + const result = Value.Cast(schema, input) + + Assert.IsEqual(result.id, 'root') + Assert.IsEqual(result.child.id, 'child') + Assert.IsEqual(result.child.parent, result) + }) + + it('should handle circular array with multiple references', () => { + const node1 = { id: 'node1', refs: [] } + const node2 = { id: 'node2', refs: [] } + + // @ts-expect-error + node1.refs = [node2, node1] + // @ts-expect-error + node2.refs = [node1] + + const schema = Type.Recursive((This) => + Type.Object({ + id: Type.String(), + refs: Type.Array(This), + }), + ) + + const result = Value.Cast(schema, node1) + + Assert.IsEqual(result.id, 'node1') + Assert.IsEqual(result.refs.length, 2) + Assert.IsEqual(result.refs[0].id, 'node2') + Assert.IsEqual(result.refs[1], result) + Assert.IsEqual(result.refs[0].refs[0], result) + }) + + it('should handle optional properties in circular structures', () => { + const input = { + id: 'test', + parent: undefined, + } + + // @ts-expect-error + input.parent = input + + const schema = Type.Recursive((This) => + Type.Object({ + id: Type.String(), + parent: Type.Optional(This), + metadata: Type.Optional(Type.String()), + }), + ) + + const result = Value.Cast(schema, input) + + Assert.IsEqual(result.id, 'test') + Assert.IsEqual(result.parent, result) + Assert.IsEqual(result.metadata, undefined) + }) + + it('should handle mixed circular and non-circular references', () => { + const leaf = { id: 'leaf', children: [] } + const branch = { id: 'branch', children: [leaf] } + const root = { id: 'root', children: [branch] } + + // Add circular reference + // @ts-expect-error + branch.children.push(root) + + const schema = Type.Recursive((This) => + Type.Object({ + id: Type.String(), + children: Type.Array(This), + }), + ) + + const result = Value.Cast(schema, root) + + Assert.IsEqual(result.id, 'root') + Assert.IsEqual(result.children.length, 1) + Assert.IsEqual(result.children[0].id, 'branch') + Assert.IsEqual(result.children[0].children.length, 2) + Assert.IsEqual(result.children[0].children[0].id, 'leaf') + Assert.IsEqual(result.children[0].children[1], result) + }) + + it('should handle circular references with union types', () => { + const textNode = { + type: 'text', + content: 'Hello', + parent: null, + } + + const containerNode = { + type: 'container', + children: [textNode], + parent: null, + } + + // @ts-expect-error + textNode.parent = containerNode + // @ts-expect-error + containerNode.parent = containerNode // self-reference + + const schema = Type.Recursive((This) => + Type.Union([ + Type.Object({ + type: Type.Literal('text'), + content: Type.String(), + parent: Type.Union([This, Type.Null()]), + }), + Type.Object({ + type: Type.Literal('container'), + children: Type.Array(This), + parent: Type.Union([This, Type.Null()]), + }), + ]), + ) + + const result = Value.Cast(schema, containerNode) + + Assert.IsEqual(result.type, 'container') + // @ts-expect-error - TypeScript can't infer the union type here + Assert.IsEqual(result.children.length, 1) + // @ts-expect-error + Assert.IsEqual(result.children[0].type, 'text') + // @ts-expect-error + Assert.IsEqual(result.children[0].content, 'Hello') + // @ts-expect-error + Assert.IsEqual(result.children[0].parent, result) + Assert.IsEqual(result.parent, result) + }) }) diff --git a/test/runtime/value/check/recursive.ts b/test/runtime/value/check/recursive.ts index 1b6c60bb9..4387f4efd 100644 --- a/test/runtime/value/check/recursive.ts +++ b/test/runtime/value/check/recursive.ts @@ -66,4 +66,53 @@ describe('value/check/Recursive', () => { const result = Value.Check(T, value) Assert.IsEqual(result, false) }) + + // ------------------------------------------------------------------------ + // ref: https://github.com/sinclairzx81/typebox/issues/1302 + // ------------------------------------------------------------------------ + it('should not break when checking a circular structure #1', () => { + const value = { + id: '1', + nodes: [], + } + + // @ts-expect-error + value.nodes[0] = value + + const result = Value.Check(T, value) + Assert.IsEqual(result, true) + }) + + it('should not break when checking a circular structure #2', () => { + const value = { + id: 1, + nodes: [], + } + + // @ts-expect-error + value.nodes[0] = value + + const result = Value.Check(T, value) + Assert.IsEqual(result, false) + }) + + it('should not break when checking a circular structure #3', () => { + const value = { + a: '', + } + + // @ts-expect-error + value.b = value + + const T = Type.Recursive((This) => + Type.Object({ + a: Type.String(), + b: This, + }), + ) + + const result = Value.Check(T, value) + + Assert.IsEqual(result, true) + }) }) diff --git a/test/runtime/value/clone/clone.ts b/test/runtime/value/clone/clone.ts index 15ecb1f57..b12edaf96 100644 --- a/test/runtime/value/clone/clone.ts +++ b/test/runtime/value/clone/clone.ts @@ -153,4 +153,101 @@ describe('value/clone/Clone', () => { const R = Value.Clone(V) Assert.IsEqual(R, V) }) + // ------------------------------------------------------------------------ + // ref: https://github.com/sinclairzx81/typebox/issues/1300 + // ------------------------------------------------------------------------ + it('Should handle circular references #1', () => { + const V = { a: 1, b: { c: 2 } } as any + V.b.d = V.b + const R = Value.Clone(V) + Assert.IsEqual(R, V) + }) + it('Should handle circular references #2', () => { + const V = { a: {}, b: {} } as any + V.a.c = V.b + V.b.d = V.a + const R = Value.Clone(V) + console.log(R) + Assert.IsEqual(R, V) + }) + it('Should handle indirect circular references #1', () => { + // Create a chain: A -> B -> C -> A + const A = { name: 'A' } as any + const B = { name: 'B' } as any + const C = { name: 'C' } as any + + A.next = B + B.next = C + C.next = A // Circular reference through chain + + const R = Value.Clone(A) + Assert.IsEqual(R.name, 'A') + Assert.IsEqual(R.next.name, 'B') + Assert.IsEqual(R.next.next.name, 'C') + Assert.IsEqual(R.next.next.next, R) // Should reference back to root + }) + it('Should handle indirect circular references #2', () => { + // Create a more complex structure with multiple indirect references + const root = { + data: { value: 1 }, + children: [], + metadata: {}, + } as any + + const child1 = { + id: 1, + parent: root, + siblings: [], + } as any + + const child2 = { + id: 2, + parent: root, + siblings: [], + } as any + + // Set up the circular references + root.children = [child1, child2] + child1.siblings = [child2] + child2.siblings = [child1] + root.metadata.firstChild = child1 + + const R = Value.Clone(root) + + // Verify structure integrity + Assert.IsEqual(R.data.value, 1) + Assert.IsEqual(R.children.length, 2) + Assert.IsEqual(R.children[0].id, 1) + Assert.IsEqual(R.children[1].id, 2) + + // Verify circular references are maintained + Assert.IsEqual(R.children[0].parent, R) + Assert.IsEqual(R.children[1].parent, R) + Assert.IsEqual(R.children[0].siblings[0], R.children[1]) + Assert.IsEqual(R.children[1].siblings[0], R.children[0]) + Assert.IsEqual(R.metadata.firstChild, R.children[0]) + }) + it('Should handle deep indirect circular references', () => { + // Create a deeply nested structure with circular reference at the end + const V = { + level1: { + level2: { + level3: { + level4: { + level5: {}, + }, + }, + }, + }, + } as any + + // Create circular reference from deep level back to root + V.level1.level2.level3.level4.level5.backToRoot = V + V.level1.level2.level3.level4.level5.backToLevel2 = V.level1.level2 + + const R = Value.Clone(V) + + // Verify the structure and circular references + Assert.IsEqual(R, V) + }) })