diff --git a/src/lib/schemaEntity.ts b/src/lib/schemaEntity.ts index 84e87a11..918a5beb 100644 --- a/src/lib/schemaEntity.ts +++ b/src/lib/schemaEntity.ts @@ -223,12 +223,7 @@ function schemaInfo(schema: T) { return _mapSchema(schema, (obj) => unwrapZodType(obj)); } -export function valueOrDefault( - value: unknown, - strict: boolean, - implicitDefaults: true, - schemaInfo: ZodTypeInfo -) { +export function valueOrDefault(value: unknown, schemaInfo: ZodTypeInfo) { if (value) return value; const { zodType, isNullable, isOptional, hasDefault, defaultValue } = @@ -242,26 +237,23 @@ export function valueOrDefault( // so this should be ok. // Also make a check for strict, so empty strings from FormData can also be set here. - if (strict && value !== undefined) return value; if (hasDefault) return defaultValue; if (isNullable) return null; if (isOptional) return undefined; - if (implicitDefaults) { - if (zodType._def.typeName == 'ZodString') return ''; - if (zodType._def.typeName == 'ZodNumber') return 0; - if (zodType._def.typeName == 'ZodBoolean') return false; - // Cannot add default for ZodDate due to https://github.com/Rich-Harris/devalue/issues/51 - //if (zodType._def.typeName == "ZodDate") return new Date(NaN); - if (zodType._def.typeName == 'ZodArray') return []; - if (zodType._def.typeName == 'ZodObject') { - return defaultValues(zodType as AnyZodObject); - } - if (zodType._def.typeName == 'ZodSet') return new Set(); - if (zodType._def.typeName == 'ZodRecord') return {}; - if (zodType._def.typeName == 'ZodBigInt') return BigInt(0); - if (zodType._def.typeName == 'ZodSymbol') return Symbol(); + if (zodType._def.typeName == 'ZodString') return ''; + if (zodType._def.typeName == 'ZodNumber') return 0; + if (zodType._def.typeName == 'ZodBoolean') return false; + // Cannot add default for ZodDate due to https://github.com/Rich-Harris/devalue/issues/51 + //if (zodType._def.typeName == "ZodDate") return new Date(NaN); + if (zodType._def.typeName == 'ZodArray') return []; + if (zodType._def.typeName == 'ZodObject') { + return defaultValues(zodType as AnyZodObject); } + if (zodType._def.typeName == 'ZodSet') return new Set(); + if (zodType._def.typeName == 'ZodRecord') return {}; + if (zodType._def.typeName == 'ZodBigInt') return BigInt(0); + if (zodType._def.typeName == 'ZodSymbol') return Symbol(); return undefined; } @@ -291,7 +283,7 @@ export function defaultValues>( return Object.fromEntries( fields.map((field) => { const typeInfo = schemaTypeInfo[field]; - const newValue = valueOrDefault(undefined, true, true, typeInfo); + const newValue = valueOrDefault(undefined, typeInfo); return [field, newValue]; }) diff --git a/src/lib/superValidate.ts b/src/lib/superValidate.ts index 01cc286b..0642f8ad 100644 --- a/src/lib/superValidate.ts +++ b/src/lib/superValidate.ts @@ -157,10 +157,37 @@ function formDataToValidation( data: FormData, schemaData: SchemaData, preprocessed?: (keyof z.infer)[] -) { - const output: Record = {}; +): { partial: Partial>; parsed: z.infer } { + const strictData: Record = {}; + const parsedData: Record = {}; const { schemaKeys, entityInfo } = schemaData; + for (const key of schemaKeys) { + const typeInfo = entityInfo.typeInfo[key]; + const entries = data.getAll(key); + + if (!(typeInfo.zodType._def.typeName == 'ZodArray')) { + parsedData[key] = parseSingleEntry(key, entries[0], typeInfo); + } else { + const arrayType = unwrapZodType(typeInfo.zodType._def.type); + parsedData[key] = entries.map((e) => + parseSingleEntry(key, e, arrayType) + ); + } + + if (!entries.length && !typeInfo.isOptional) { + strictData[key] = undefined; + } else { + strictData[key] = parsedData[key]; + } + } + + for (const key of Object.keys(strictData)) { + if (strictData[key] === undefined) delete strictData[key]; + } + + return { parsed: parsedData, partial: strictData }; + function parseSingleEntry( key: string, entry: FormDataEntryValue, @@ -178,24 +205,12 @@ function formDataToValidation( return parseFormDataEntry(key, entry, typeInfo); } - for (const key of schemaKeys) { - const typeInfo = entityInfo.typeInfo[key]; - const entries = data.getAll(key); - - if (!(typeInfo.zodType._def.typeName == 'ZodArray')) { - output[key] = parseSingleEntry(key, entries[0], typeInfo); - } else { - const arrayType = unwrapZodType(typeInfo.zodType._def.type); - output[key] = entries.map((e) => parseSingleEntry(key, e, arrayType)); - } - } - function parseFormDataEntry( field: string, value: string | null, typeInfo: ZodTypeInfo ): unknown { - const newValue = valueOrDefault(value, false, true, typeInfo); + const newValue = valueOrDefault(value, typeInfo); const zodType = typeInfo.zodType; const typeName = zodType._def.typeName; @@ -274,8 +289,6 @@ function formDataToValidation( 'Unsupported Zod default type: ' + zodType.constructor.name ); } - - return output as z.infer; } ///// superValidate helpers ///////////////////////////////////////// @@ -289,10 +302,12 @@ type SchemaData = { opts: SuperValidateOptions; }; -type ParsedData = { +type ParsedData> = { id: string | undefined; posted: boolean; - data: Record | null | undefined; + data: T | null | undefined; + // Used in strict mode + partialData: Partial | null | undefined; }; /** @@ -301,23 +316,24 @@ type ParsedData = { * should be displayed. */ function dataToValidate( - parsed: ParsedData, + parsed: ParsedData>, schemaData: SchemaData ): Record | undefined { + const strict = schemaData.opts?.strict ?? false; if (!parsed.data) { return schemaData.hasEffects || schemaData.opts.errors === true ? schemaData.entityInfo.defaultEntity : undefined; - } else { - return parsed.data; - } + } else if (strict && parsed.partialData) { + return parsed.partialData; + } else return parsed.data; } function parseFormData( formData: FormData, schemaData: SchemaData, - options?: SuperValidateOptions -): ParsedData { + preprocessed?: SuperValidateOptions['preprocessed'] +): ParsedData> { function tryParseSuperJson() { if (formData.has('__superform_json')) { try { @@ -325,7 +341,7 @@ function parseFormData( formData.getAll('__superform_json').join('') ?? '' ); if (typeof output === 'object') { - return output as z.infer>; + return output as Record; } } catch { // @@ -337,24 +353,25 @@ function parseFormData( const data = tryParseSuperJson(); const id = formData.get('__superform_id')?.toString() ?? undefined; - return data - ? { id, data, posted: true } - : { - id, - data: formDataToValidation( - formData, - schemaData, - options?.preprocessed - ), - posted: true - }; + if (data) { + return { id, data, posted: true, partialData: null }; + } + + const parsed = formDataToValidation(formData, schemaData, preprocessed); + + return { + id, + data: parsed.parsed, + partialData: parsed.partial, + posted: true + }; } function parseSearchParams( data: URL | URLSearchParams, schemaData: SchemaData, - options?: SuperValidateOptions -): ParsedData { + preprocessed?: SuperValidateOptions['preprocessed'] +): ParsedData> { if (data instanceof URL) data = data.searchParams; const convert = new FormData(); @@ -363,13 +380,13 @@ function parseSearchParams( } // Only FormData can be posted. - const output = parseFormData(convert, schemaData, options); + const output = parseFormData(convert, schemaData, preprocessed); output.posted = false; return output; } function validateResult( - parsed: ParsedData, + parsed: ParsedData>, schemaData: SchemaData, result: SafeParseReturnType> | undefined ): SuperValidated { @@ -389,12 +406,12 @@ function validateResult( let errors: ReturnType = {}; const valid = result?.success ?? false; - const { opts: options, entityInfo } = schemaData; + const addErrors = options.errors ?? options.strict; if (result) { if (result.success) { data = result.data; - } else if (options.errors === true) { + } else if (addErrors) { errors = mapErrors(result.error.format(), entityInfo.errorShape); } } @@ -432,17 +449,22 @@ function validateResult( // passthrough, strip, strict const zodKeyStatus = unwrappedSchema._def.unknownKeys; - const data = - zodKeyStatus == 'passthrough' - ? { ...clone(entityInfo.defaultEntity), ...partialData } - : Object.fromEntries( - schemaKeys.map((key) => [ - key, - key in partialData - ? partialData[key] - : clone(entityInfo.defaultEntity[key]) - ]) - ); + let data; + + if (options.strict) { + data = parsed.data; + } else if (zodKeyStatus == 'passthrough') { + data = { ...clone(entityInfo.defaultEntity), ...partialData }; + } else { + data = Object.fromEntries( + schemaKeys.map((key) => [ + key, + key in partialData + ? partialData[key] + : clone(entityInfo.defaultEntity[key]) + ]) + ); + } return { id, @@ -505,6 +527,7 @@ function getSchemaData( export type SuperValidateOptions = Partial<{ + strict: boolean; errors: boolean; id: string; warnings: { @@ -563,7 +586,9 @@ export async function superValidate< const schemaData = getSchemaData(schema as UnwrapEffects, options); - async function tryParseFormData(request: Request) { + async function tryParseFormData( + request: Request + ): Promise>>> { let formData: FormData | undefined = undefined; try { formData = await request.formData(); @@ -577,18 +602,23 @@ export async function superValidate< throw e; } // No data found, return an empty form - return { id: undefined, data: undefined, posted: false }; + return { + id: undefined, + data: undefined, + posted: false, + partialData: undefined + }; } - return parseFormData(formData, schemaData, options); + return parseFormData(formData, schemaData, options?.preprocessed); } async function parseRequest() { - let parsed: ParsedData; + let parsed: ParsedData>>; if (data instanceof FormData) { - parsed = parseFormData(data, schemaData, options); + parsed = parseFormData(data, schemaData, options?.preprocessed); } else if (data instanceof URL || data instanceof URLSearchParams) { - parsed = parseSearchParams(data, schemaData, options); + parsed = parseSearchParams(data, schemaData, options?.preprocessed); } else if (data instanceof Request) { parsed = await tryParseFormData(data); } else if ( @@ -598,11 +628,16 @@ export async function superValidate< data.request instanceof Request ) { parsed = await tryParseFormData(data.request); + } else if (options?.strict) { + // Ensure that defaults are set on data if strict mode is enabled (Should this maybe always happen?) + const params = new URLSearchParams(data as Record); + parsed = parseSearchParams(params, schemaData, options?.preprocessed); } else { parsed = { id: undefined, + posted: false, data: data as Record, - posted: false + partialData: data as Record }; } @@ -618,7 +653,13 @@ export async function superValidate< } const { parsed, result } = await parseRequest(); - return validateResult, M>(parsed, schemaData, result); + + const superValidated = validateResult, M>( + parsed, + schemaData, + result + ); + return superValidated; } //////////////////////////////////////////////////////////////////// @@ -672,12 +713,13 @@ export function superValidateSync< const parsed = data instanceof FormData - ? parseFormData(data, schemaData, options) + ? parseFormData(data, schemaData, options?.preprocessed) : data instanceof URL || data instanceof URLSearchParams ? parseSearchParams(data, schemaData) : { id: undefined, - data: data as Record, + data: data as z.infer>, + partialData: data as z.infer>, posted: false }; // Only schema, null or undefined left diff --git a/src/strict.test.ts b/src/strict.test.ts new file mode 100644 index 00000000..88d68e24 --- /dev/null +++ b/src/strict.test.ts @@ -0,0 +1,318 @@ +import { superValidate } from '$lib/server'; +import { expect, test, describe } from 'vitest'; +import { z, type AnyZodObject } from 'zod'; + +type ModeTest = { + name: string; + schema: AnyZodObject; + input: Record | null | undefined; + expected: Record; + valid: boolean; + errors: Record; + // If true, expect POJO test to be invalid with the following errors: + strictPOJOErrors?: Record; +}; + +describe('Strict mode', () => { + test('Should remove keys not part of the schema', async () => { + const input = { fooo: 'wrong-key', foo: 'correct-key' }; + const schema = z.object({ + foo: z.string() + }); + + const form = await superValidate( + input as Record, + schema, + { + strict: true + } + ); + expect(input).toMatchObject({ fooo: 'wrong-key', foo: 'correct-key' }); + expect(form.data).toEqual({ foo: 'correct-key' }); + expect(form.errors).toEqual({}); + expect(form.valid).toEqual(true); + }); + + const strictTests = [ + { + name: 'Should be invalid if foo is not present in object', + schema: z.object({ + foo: z.string() + }), + input: {}, + expected: { + foo: '' + }, + valid: false, + errors: { + foo: ['Required'] + } + }, + { + name: 'Should be valid if foo is not present but optional in object', + schema: z.object({ + foo: z.string().optional() + }), + input: {}, + expected: {}, + valid: true, + errors: {} + }, + { + name: 'Should be invalid if key is mispelled', + schema: z.object({ + foo: z.string() + }), + input: { + fo: 'bar' + }, + expected: { + foo: '' + }, + valid: false, + errors: { + foo: ['Required'] + } + }, + { + name: 'Should work with number', + schema: z.object({ + cost: z.number() + }), + input: { + cost: 20 + }, + expected: { + cost: 20 + }, + valid: true, + errors: {} + }, + { + name: 'Should work with a string with min length requirements', + schema: z.object({ + foo: z.string().min(2) + }), + input: { + foo: '' + }, + expected: { + foo: '' + }, + valid: false, + errors: { + foo: ['String must contain at least 2 character(s)'] + } + }, + { + name: 'Should be invalid and display errors if null is passed', + schema: z.object({ + foo: z.string() + }), + input: null, + expected: { + foo: '' + }, + valid: false, + errors: { + foo: ['Required'] + } + }, + { + name: 'Should be invalid and display errors if undefined is passed', + schema: z.object({ + foo: z.string() + }), + input: undefined, + expected: { + foo: '' + }, + valid: false, + errors: { + foo: ['Required'] + } + } + ]; + + testMode(strictTests, true); +}); + +describe('Non-strict mode', () => { + test('Should remove keys not part of the schema', async () => { + const input = { fooo: 'wrong-key', foo: 'correct-key' }; + const schema = z.object({ + foo: z.string() + }); + + const form = await superValidate( + input as Record, + schema + ); + expect(input).toMatchObject({ fooo: 'wrong-key', foo: 'correct-key' }); + expect(form.data).toEqual({ foo: 'correct-key' }); + expect(form.errors).toEqual({}); + expect(form.valid).toEqual(true); + }); + + const nonStrictTests = [ + { + name: 'Should be valid if foo is not present in object, unless POJO', + schema: z.object({ + foo: z.string() + }), + input: {}, + expected: { + foo: '' + }, + valid: true, + errors: {}, + strictPOJOErrors: { + foo: ['Required'] + } + }, + { + name: 'Should be valid if foo is not present but optional in object', + schema: z.object({ + foo: z.string().optional() + }), + input: {}, + expected: {}, + valid: true, + errors: {} + }, + { + name: 'Should be valid if key is mispelled, default value will be used, unless POJO', + schema: z.object({ + foo: z.string() + }), + input: { + fo: 'bar' + }, + expected: { + foo: '' + }, + valid: true, + errors: {}, + strictPOJOErrors: { + foo: ['Required'] + } + }, + { + name: 'Should work with number', + schema: z.object({ + cost: z.number() + }), + input: { + cost: 20 + }, + expected: { + cost: 20 + }, + valid: true, + errors: {} + }, + { + name: 'Should work a string with min length requirements', + schema: z.object({ + foo: z.string().min(2) + }), + input: { + foo: '' + }, + expected: { + foo: '' + }, + valid: false, + errors: { + foo: ['String must contain at least 2 character(s)'] + } + }, + { + name: 'Should be invalid and not display errors if null is passed', + schema: z.object({ + foo: z.string() + }), + input: null, + expected: { + foo: '' + }, + valid: false, + errors: {} + }, + { + name: 'Should be invalid and not display errors if undefined is passed', + schema: z.object({ + foo: z.string() + }), + input: undefined, + expected: { + foo: '' + }, + valid: false, + errors: {} + } + ]; + + testMode(nonStrictTests, false); +}); + +function testMode(tests: ModeTest[], strict: boolean) { + for (const { + name, + input, + schema, + valid, + expected, + errors, + strictPOJOErrors + } of tests) { + const inputClone = input ? structuredClone(input) : input; + + test(name + ' (POJO)', async () => { + const form = await superValidate(input, schema, { + strict: strictPOJOErrors ? true : strict + }); + + expect(input).toStrictEqual(inputClone); + expect(form.data).toEqual(expected); + expect(form.errors).toEqual( + strictPOJOErrors ? strictPOJOErrors : errors + ); + expect(form.valid).toEqual(strictPOJOErrors ? false : valid); + }); + + if (!input) continue; + + test(name + ' (FormData)', async () => { + const formData = new FormData(); + for (const [key, value] of Object.entries(input)) { + formData.set(key, value ? `${value}` : ''); + } + + const form = await superValidate(formData, schema, { + strict + }); + + expect(input).toStrictEqual(inputClone); + expect(form.data).toEqual(expected); + expect(form.errors).toEqual(errors); + expect(form.valid).toEqual(valid); + }); + + test(name + ' (UrlSearchParams)', async () => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(input)) { + params.set(key, `${value}`); + } + + const form = await superValidate(params, schema, { + strict + }); + + expect(input).toStrictEqual(inputClone); + expect(form.data).toEqual(expected); + expect(form.errors).toEqual(errors); + expect(form.valid).toEqual(valid); + }); + } +}