From ad1b1c22f094352a7c2933874a83ca3880fac472 Mon Sep 17 00:00:00 2001 From: Michael Sweeney Date: Sun, 1 Sep 2024 13:15:13 -0700 Subject: [PATCH] fixed multi level objects --- src/index.ts | 193 ++++++++++++++---------------- tests/01_basic_schema.spec.ts | 214 +++++++++++++++++----------------- 2 files changed, 195 insertions(+), 212 deletions(-) diff --git a/src/index.ts b/src/index.ts index 945e0b5..be849b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,99 +1,81 @@ /* eslint-disable */ -import { z, ZodType } from 'zod'; -import { proxy as vproxy, useSnapshot as vsnap } from 'valtio'; -import _ from 'lodash'; +import { z, ZodType } from 'zod' +import { proxy as vproxy, useSnapshot as vsnap } from 'valtio' +import _ from 'lodash' type ValtioProxy = { - [P in keyof T]: T[P]; -}; + [P in keyof T]: T[P] +} type SchemaConfig = { - parseAsync?: boolean; - safeParse?: boolean; - errorHandler?: (error: unknown) => void; -}; + parseAsync?: boolean + safeParse?: boolean + errorHandler?: (error: unknown) => void +} const defaultConfig = { parseAsync: false, safeParse: false, - errorHandler: (error: unknown) => console.error(error), -}; + errorHandler: (error: unknown) => console.error(error) +} export const vzGlobalConfig = { safeParse: false, - errorHandler: (error: unknown) => console.error(error), -}; + errorHandler: (error: unknown) => console.error(error) +} const isObject = (x: unknown): x is object => - typeof x === 'object' && x !== null; + typeof x === 'object' && x !== null -type MergedConfig = Required; +type MergedConfig = Required type SchemaMeta = SchemaConfig & { - initialState: unknown; -}; + initialState: unknown +} -type PropType = string | number | symbol; -const schemaMeta = new WeakMap, SchemaMeta>(); -const pathList = new WeakMap<{}, PropType[]>(); +type PropType = string | number | symbol +const schemaMeta = new WeakMap, SchemaMeta>() +const pathList = new WeakMap<{}, PropType[]>() type SchemaReturn> = { proxy: { - (initialState: any, config?: SchemaConfig): ValtioProxy>; - }; -}; - -function updateObjectAtPath(obj: any, path: PropType[], newValue: any) { - let stack = [...path]; - let object = obj; - - while (stack.length > 1) { - const key = stack.shift(); - if (key === undefined) return; - if (!object[key] || typeof object[key] !== 'object') { - object[key] = {}; - } - object = object[key]; + (initialState: any, config?: SchemaConfig): ValtioProxy> } - - const lastKey = stack.shift(); - if (lastKey !== undefined) object[lastKey] = newValue; } -const valtioStoreSymbol = Symbol('valtioStore'); +const valtioStoreSymbol = Symbol('valtioStore') export const useSnapshot = (store: any) => { - return vsnap(store[valtioStoreSymbol]); -}; + return vsnap(store[valtioStoreSymbol]) +} export const schema = >( - zodSchema: T, + zodSchema: T ): SchemaReturn => { - let valtioProxy: any; const proxy = ( initialState: z.infer, - config: SchemaConfig = {}, + config: SchemaConfig = {} ): ValtioProxy> => { if (!isObject(initialState)) { - throw new Error('object required'); + throw new Error('object required') } - const mergedConfig: MergedConfig = { ...defaultConfig, ...config }; + const mergedConfig: MergedConfig = { ...defaultConfig, ...config } - const parseAsync = mergedConfig.parseAsync; - const safeParse = mergedConfig.safeParse; - const errorHandler = mergedConfig.errorHandler; + const parseAsync = mergedConfig.parseAsync + const safeParse = mergedConfig.safeParse + const errorHandler = mergedConfig.errorHandler // before proxying, validate the initial state if (parseAsync) { zodSchema.parseAsync(initialState).catch((e) => { - throw e; - }); + throw e + }) } else { - zodSchema.parse(initialState); + zodSchema.parse(initialState) } - valtioProxy = vproxy(initialState); + const valtioProxy = vproxy(initialState) const createProxy = (target: any, parentPath: PropType[] = []): any => { if (!schemaMeta.has(zodSchema)) { @@ -101,91 +83,92 @@ export const schema = >( safeParse, parseAsync, errorHandler, - initialState, - }); + initialState + }) } return new Proxy(target, { - // get(target, prop, receiver) { - // const value = Reflect.get(target, prop, receiver) - // if (isObject(value)) { - // const newPath = parentPath.concat(prop) - // pathList.set(value, newPath) - // return createProxy(value, newPath) - // } else { - // return value - // } - // }, + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + if (isObject(value)) { + const newPath = parentPath.concat(prop) + pathList.set(value, newPath) + return createProxy(value, newPath) + } else { + const pathToSet = [...(pathList.get(target) || []), prop] + return _.get(valtioProxy, pathToSet, value) + } + }, set(target, prop, value, receiver) { const originalObject = schemaMeta.get(zodSchema)! - .initialState as z.infer; + .initialState as z.infer - const objectToValidate = JSON.parse(JSON.stringify(originalObject)); - const path = (pathList.get(target) || []).concat(prop); + const objectToValidate = _.cloneDeep(originalObject) + const path = (pathList.get(target) || []).concat(prop) - updateObjectAtPath(objectToValidate, path, value); + _.set(objectToValidate, path, value) const handleAsyncParse = async () => { try { - const parsedValue = await zodSchema.parseAsync(objectToValidate); - _.set(valtioProxy, value, path); - Reflect.set(target, prop, value, receiver); - return true; + const parsedValue = await zodSchema.parseAsync(objectToValidate) + _.set(valtioProxy, value, path) + Reflect.set(target, prop, value, receiver) + return true } catch (error) { - errorHandler(error); + errorHandler(error) if (!safeParse) { - throw error; + throw error } - return false; + return false } - }; + } const handleSyncParse = () => { try { if (safeParse) { - const result = zodSchema.safeParse(objectToValidate); + const result = zodSchema.safeParse(objectToValidate) if (result.success) { - valtioProxy[prop] = value; - Reflect.set(target, prop, value, receiver); - return true; + valtioProxy[prop] = value + Reflect.set(target, prop, value, receiver) + return true } else { - errorHandler(result.error); - return false; + errorHandler(result.error) + return false } } else { - const parsedValue = zodSchema.parse(objectToValidate); - Reflect.set(target, prop, value, receiver); - valtioProxy[prop] = value; - return true; + const parsedValue = zodSchema.parse(objectToValidate) + Reflect.set(target, prop, value, receiver) + valtioProxy[prop] = value + return true } } catch (error) { - errorHandler(error); + errorHandler(error) if (!safeParse) { - throw error; + throw error } - return false; + return false } - }; + } if (parseAsync) { handleAsyncParse().catch((error) => { - errorHandler(error); + errorHandler(error) if (!safeParse) { - throw error; + throw error } - }); - return true; + }) + return true } else { - return handleSyncParse(); + return handleSyncParse() } - }, - }); - }; + } + }) + } - const store = createProxy(valtioProxy); - store[valtioStoreSymbol] = valtioProxy; + const store = createProxy(valtioProxy) + store[valtioStoreSymbol] = valtioProxy - return store; - }; - return { proxy }; -}; + return store + } + return { proxy } +} diff --git a/tests/01_basic_schema.spec.ts b/tests/01_basic_schema.spec.ts index 96e248d..9339ec0 100644 --- a/tests/01_basic_schema.spec.ts +++ b/tests/01_basic_schema.spec.ts @@ -1,144 +1,144 @@ -import { z } from 'zod'; -import { describe, it, expect, vi } from 'vitest'; -import { schema } from 'valtio-zod'; +import { z } from 'zod' +import { describe, it, expect, vi } from 'vitest' +import { schema } from 'valtio-zod' describe('valtio-zod schema', () => { it('should create a proxy and set synchronous values correctly', () => { const userSchema = z.object({ username: z.string(), - age: z.number().int(), - }); + age: z.number().int() + }) - const { proxy } = schema(userSchema); - const user = proxy({ username: 'Alice', age: 30 }); + const { proxy } = schema(userSchema) + const user = proxy({ username: 'Alice', age: 30 }) - user.username = 'Bob'; - expect(user.username).toBe('Bob'); + user.username = 'Bob' + expect(user.username).toBe('Bob') - user.age = 42; - expect(user.age).toBe(42); - }); + user.age = 42 + expect(user.age).toBe(42) + }) it('should handle parseSafe correctly', () => { const userSchema = z.object({ username: z.string(), - age: z.number().int(), - }); + age: z.number().int() + }) - const { proxy } = schema(userSchema); + const { proxy } = schema(userSchema) const user = proxy( { username: 'Alice', age: 30 }, - { safeParse: true, errorHandler: vi.fn() }, - ); + { safeParse: true, errorHandler: vi.fn() } + ) - const errorHandler = vi.fn(); + const errorHandler = vi.fn() try { // Invalid age // eslint-disable-next-line @typescript-eslint/no-explicit-any - user.age = 'invalidAge' as any; + user.age = 'invalidAge' as any } catch (e) { - errorHandler(e); + errorHandler(e) } - expect(errorHandler).toHaveBeenCalled(); - expect(user.age).toBe(30); // Ensure the value hasn't changed - }); + expect(errorHandler).toHaveBeenCalled() + expect(user.age).toBe(30) // Ensure the value hasn't changed + }) it('should use custom error handler', () => { const userSchema = z.object({ username: z.string(), - age: z.number().int(), - }); + age: z.number().int() + }) - const errorHandler = vi.fn(); + const errorHandler = vi.fn() - const { proxy } = schema(userSchema); - const user = proxy({ username: 'Alice', age: 30 }, { errorHandler }); + const { proxy } = schema(userSchema) + const user = proxy({ username: 'Alice', age: 30 }, { errorHandler }) try { // Invalid age // eslint-disable-next-line @typescript-eslint/no-explicit-any - user.age = 'invalidAge' as any; + user.age = 'invalidAge' as any } catch (_e) { // Since parseSafe is false, the error should be caught here } - expect(errorHandler).toHaveBeenCalled(); - }); - - // it('should handle multi-level objects correctly', async () => { - // const userSchema = z.object({ - // username: z.string(), - // age: z.number().int(), - // profile: z.object({ - // firstName: z.string(), - // lastName: z.string(), - // address: z.object({ - // city: z.string(), - // country: z.string(), - // }), - // }), - // }); - - // const { proxy } = schema(userSchema); - // const user = proxy({ - // username: 'Alice', - // age: 30, - // profile: { - // firstName: 'Alice', - // lastName: 'Smith', - // address: { - // city: 'Wonderland', - // country: 'Fantasy', - // }, - // }, - // }); - - // // Ensure nested fields maintain object structure and types - // user.profile.address.city = 'New City'; // Ensure the proxy update handling completes - // expect(user.profile.address.city).toBe('New City'); - // }); - - // it('should error by updating a value in a nested object', () => { - // const userSchema = z.object({ - // username: z.string(), - // age: z.number().int(), - // profile: z.object({ - // firstName: z.string(), - // lastName: z.string(), - // address: z.object({ - // city: z.string(), - // country: z.string(), - // }), - // }), - // }); - - // const errorHandler = vi.fn(); - - // const { proxy } = schema(userSchema); - // const user = proxy( - // { - // username: 'Alice', - // age: 30, - // profile: { - // firstName: 'Alice', - // lastName: 'Smith', - // address: { - // city: 'Wonderland', - // country: 'Fantasy', - // }, - // }, - // }, - // { safeParse: true, errorHandler }, - // ); - - // // Invalid country type - // const result = Reflect.set(user.profile.address, 'country', 123); - - // expect(result).toBe(false); - // expect(errorHandler).toHaveBeenCalled(); - // // Ensure the value hasn't changed from the initial valid value - // expect(user.profile.address.country).toBe('Fantasy'); - // }); -}); + expect(errorHandler).toHaveBeenCalled() + }) + + it('should handle multi-level objects correctly', async () => { + const userSchema = z.object({ + username: z.string(), + age: z.number().int(), + profile: z.object({ + firstName: z.string(), + lastName: z.string(), + address: z.object({ + city: z.string(), + country: z.string() + }) + }) + }) + + const { proxy } = schema(userSchema) + const user = proxy({ + username: 'Alice', + age: 30, + profile: { + firstName: 'Alice', + lastName: 'Smith', + address: { + city: 'Wonderland', + country: 'Fantasy' + } + } + }) + + // Ensure nested fields maintain object structure and types + user.profile.address.city = 'New City' // Ensure the proxy update handling completes + expect(user.profile.address.city).toBe('New City') + }) + + it('should error by updating a value in a nested object', () => { + const userSchema = z.object({ + username: z.string(), + age: z.number().int(), + profile: z.object({ + firstName: z.string(), + lastName: z.string(), + address: z.object({ + city: z.string(), + country: z.string() + }) + }) + }) + + const errorHandler = vi.fn() + + const { proxy } = schema(userSchema) + const user = proxy( + { + username: 'Alice', + age: 30, + profile: { + firstName: 'Alice', + lastName: 'Smith', + address: { + city: 'Wonderland', + country: 'Fantasy' + } + } + }, + { safeParse: true, errorHandler } + ) + + // Invalid country type + const result = Reflect.set(user.profile.address, 'country', 123) + + expect(result).toBe(false) + expect(errorHandler).toHaveBeenCalled() + // Ensure the value hasn't changed from the initial valid value + expect(user.profile.address.country).toBe('Fantasy') + }) +})