diff --git a/src/lib/formData.ts b/src/lib/formData.ts index 0cd668ab..086f9de9 100644 --- a/src/lib/formData.ts +++ b/src/lib/formData.ts @@ -5,7 +5,7 @@ import type { JSONSchema7Definition } from 'json-schema'; import { schemaInfo, type SchemaInfo, type SchemaType } from './jsonSchema/schemaInfo.js'; import { defaultValues } from './jsonSchema/schemaDefaults.js'; import type { JSONSchema } from './index.js'; -import { setPaths } from './traversal.js'; +import { setPaths, traversePath } from './traversal.js'; import { splitPath } from './stringPath.js'; import { assertSchema } from './utils.js'; @@ -147,7 +147,9 @@ export function parseFormData>( ? { id, data, posted: true } : { id, - data: _parseFormData(formData, schemaData, options), + data: options?.unflatten + ? parseFlattenedData(formData, schemaData, options) + : _parseFormData(formData, schemaData, options), posted: true }; } @@ -157,6 +159,24 @@ function _parseFormData>( schema: JSONSchema, options?: SuperValidateOptions ) { + function parseSingleEntry(key: string, entry: FormDataEntryValue, info: SchemaInfo) { + if (options?.preprocessed && options.preprocessed.includes(key as keyof T)) { + return entry; + } + + if (entry && typeof entry !== 'string') { + const allowFiles = legacyMode ? options?.allowFiles === true : options?.allowFiles !== false; + return !allowFiles ? undefined : entry.size ? entry : info.isNullable ? null : undefined; + } + + if (info.types.length > 1) { + throw new SchemaError(unionError, key); + } + + const [type] = info.types; + return parseFormDataEntry(key, entry, type ?? 'any', info); + } + const output: Record = {}; let schemaKeys: Set; @@ -185,24 +205,6 @@ function _parseFormData>( ); } - function parseSingleEntry(key: string, entry: FormDataEntryValue, info: SchemaInfo) { - if (options?.preprocessed && options.preprocessed.includes(key as keyof T)) { - return entry; - } - - if (entry && typeof entry !== 'string') { - const allowFiles = legacyMode ? options?.allowFiles === true : options?.allowFiles !== false; - return !allowFiles ? undefined : entry.size ? entry : info.isNullable ? null : undefined; - } - - if (info.types.length > 1) { - throw new SchemaError(unionError, key); - } - - const [type] = info.types; - return parseFormDataEntry(key, entry, type ?? 'any', info); - } - const defaultPropertyType = typeof schema.additionalProperties == 'object' ? schema.additionalProperties @@ -332,3 +334,220 @@ function parseFormDataEntry( throw new SuperFormError('Unsupported schema type for FormData: ' + type); } } + +function parseFlattenedData>( + formData: FormData, + schema: JSONSchema, + options?: SuperValidateOptions +) { + const rootInfo = schemaInfo(schema, false, []); + const output: Record = {}; + + function parseSingleEntry(key: string, entry: FormDataEntryValue, info: SchemaInfo): unknown { + if (options?.preprocessed && options.preprocessed.includes(key as keyof T)) { + return entry; + } + + if (entry && typeof entry !== 'string') { + const allowFiles = legacyMode ? options?.allowFiles === true : options?.allowFiles !== false; + return !allowFiles ? undefined : entry.size ? entry : info.isNullable ? null : undefined; + } + + if (info.types.length > 1) { + throw new SchemaError(unionError, key); + } + + const [type] = info.types; + + if (!entry) { + //Returning empty strings safely when passed in + if (type === 'string' && !info.schema.format && typeof entry === 'string') { + return ''; + } + + if (type === 'boolean' && info.isOptional && info.schema.default === true) { + return false; + } + + const defaultValue = defaultValues(info.schema, info.isOptional, [key]); + + // Special case for empty posted enums, then the empty value should be returned, + // otherwise even a required field will get a default value, resulting in that + // posting missing enum values must use strict mode. + if (info.schema.enum && defaultValue !== null && defaultValue !== undefined) { + return entry; + } + + if (defaultValue !== undefined) return defaultValue; + + if (info.isNullable) { + return null; + } + if (info.isOptional) { + return undefined; + } + } + + switch (type) { + case 'string': + case 'any': + return entry; + case 'integer': + return parseInt(entry ?? '', 10); + case 'number': + return parseFloat(entry ?? ''); + case 'boolean': + return Boolean(entry == 'false' ? '' : entry).valueOf(); + case 'unix-time': { + // Must return undefined for invalid dates due to https://github.com/Rich-Harris/devalue/issues/51 + const date = new Date(entry ?? ''); + return !isNaN(date as unknown as number) ? date : undefined; + } + case 'int64': + case 'bigint': + return BigInt(entry ?? '.'); + case 'symbol': + return Symbol(String(entry)); + default: + throw new SuperFormError('Unsupported schema type for FormData: ' + type); + } + } + + function setValueOfArrayOrObject( + record: Record | unknown[], + key: string, + value: unknown + ) { + const isParentArray = Array.isArray(record); + const numericKey = parseInt(key, 10); + + if (isParentArray) { + if (Number.isNaN(numericKey)) { + return; + } + + (record as unknown[])[numericKey] = value; + } else { + (record as Record)[key] = value; + } + } + + function getParsedValue(paths: string[], entries: FormDataEntryValue[]) { + const schemaLeaf = traversePath(rootInfo, paths, ({ key, parent }) => { + const newParent = parent as SchemaInfo | undefined; + if (!newParent) { + return undefined; + } + + if ( + !Number.isNaN(parseInt(key, 10)) && + (newParent.types.includes('array') || newParent.types.includes('set')) + ) { + const items = + newParent.schema.items ?? (newParent.union?.length == 1 ? newParent.union[0] : undefined); + if (!items || typeof items == 'boolean' || (Array.isArray(items) && items.length != 1)) { + throw new SchemaError( + 'Arrays must have a single "items" property that defines its type.', + key + ); + } + + const arrayType = Array.isArray(items) ? items[0] : items; + + return schemaInfo(arrayType, !newParent.required?.includes(key), [key]); + } + + const property: JSONSchema | undefined = ((parent as SchemaInfo).properties ?? {})[key]; + + if (!property) { + return undefined; + } + + return schemaInfo(property, !newParent.required?.includes(key), []); + }); + + if (!schemaLeaf) { + return undefined; + } + + const parent = schemaLeaf.parent as SchemaInfo; + + const property = parent.array ? parent.array[0] : (parent?.properties ?? {})[schemaLeaf.key]; + + if (!property) { + return undefined; + } + + const propetyIsRequired = parent?.required?.includes(schemaLeaf.key); + + const isOptional = propetyIsRequired === undefined ? true : !propetyIsRequired; + + const info = schemaInfo(property, isOptional, []); + + if (info.types.includes('array') || info.types.includes('set')) { + // If no items, it could be a union containing the info + const items = property.items ?? (info.union?.length == 1 ? info.union[0] : undefined); + if (!items || typeof items == 'boolean' || (Array.isArray(items) && items.length != 1)) { + throw new SchemaError( + 'Arrays must have a single "items" property that defines its type.', + schemaLeaf.key + ); + } + + const arrayType = Array.isArray(items) ? items[0] : items; + assertSchema(arrayType, schemaLeaf.key); + + const arrayInfo = schemaInfo(arrayType, info.isOptional, [schemaLeaf.key]); + if (!arrayInfo) { + return undefined; + } + + // Check for empty files being posted (and filtered) + const isFileArray = entries.length && entries.some((e) => e && typeof e !== 'string'); + const arrayData = entries.map((e) => parseSingleEntry(schemaLeaf.key, e, arrayInfo)); + if (isFileArray && arrayData.every((file) => !file)) arrayData.length = 0; + + return info.types.includes('set') ? new Set(arrayData) : arrayData; + } + + return parseSingleEntry(schemaLeaf.key, entries[entries.length - 1], info); + } + + function initializePath(paths: string[], value: unknown) { + let parent: Record | unknown[] = output; + paths.forEach((key, i) => { + const adjacentKey = paths[i + 1]; + const numericAdjacentKey = parseInt(adjacentKey, 10); + + //End of the paths, so we set the actual FormData value + if (!adjacentKey) { + setValueOfArrayOrObject(parent, key, value); + return; + } + + const referenceValue = Array.isArray(parent) + ? (parent[parseInt(key, 10)] as Record | unknown[] | undefined) + : (parent[key] as Record | unknown[] | undefined); + + if (!referenceValue) { + const initializedValue = !Number.isNaN(numericAdjacentKey) ? [] : {}; + setValueOfArrayOrObject(parent, key, initializedValue); + parent = initializedValue; + return; + } + + parent = referenceValue; + }); + } + + for (const formDataKey of formData.keys().filter((key) => !key.startsWith('__superform_'))) { + const paths = splitPath(formDataKey); + + const value = formData.getAll(formDataKey); + const parsedValue = getParsedValue(paths, value); + + initializePath(paths, parsedValue); + } + + return output; +} diff --git a/src/lib/superValidate.ts b/src/lib/superValidate.ts index 07d2bc40..f02e92a8 100644 --- a/src/lib/superValidate.ts +++ b/src/lib/superValidate.ts @@ -68,6 +68,7 @@ export type SuperValidateOptions> = Partial< strict: boolean; allowFiles: boolean; transport: IsAny extends true ? never : Transport; + unflatten: boolean; }>; export type TaintedFields> = SuperStructArray; @@ -151,11 +152,108 @@ export async function superValidate< ); let outputData: Record; + + function scrubDataToSchema( + schema: JSONSchema, + data: Record | unknown[] + ): unknown { + if (schema.type === 'array') { + if (!Array.isArray(data)) { + return []; + } + + const { items } = schema; + + if (!items || typeof items === 'boolean') { + return []; + } + + if (Array.isArray(items)) { + if (items.length === 1) { + const item = items[0]; + + if (typeof item === 'boolean') { + return []; + } + + return data.map((datum) => + scrubDataToSchema(item, datum as Record | unknown[]) + ); + } + + return []; + } + + return data.map((datum) => + scrubDataToSchema(items, datum as Record | unknown[]) + ); + } + + const ref: Record = {}; + + if (!schema.properties && schema.type !== 'object') { + return data; + } + + for (const [key, subSchema] of Object.entries(schema.properties ?? {})) { + if (typeof subSchema === 'boolean') { + continue; + } + + switch (subSchema.type) { + case 'object': + if (!Array.isArray(data)) { + ref[key] = scrubDataToSchema(subSchema, (data ?? {})[key] as Record); + } + break; + case 'array': { + if (!Array.isArray(data)) { + const dataArray = (data ?? {})[key]; + const itemsSchema = Array.isArray(subSchema.items) + ? subSchema.items[0] + : subSchema.items; + + if (Array.isArray(dataArray) && itemsSchema && itemsSchema !== true) { + const arrayItems = itemsSchema.anyOf + ? itemsSchema.anyOf.filter((item) => typeof item !== 'boolean' && item.type)[0] + : itemsSchema; + + if (typeof arrayItems === 'boolean') { + break; + } + + ref[key] = dataArray.map((dataItem) => scrubDataToSchema(arrayItems, dataItem)); + } + } + break; + } + case 'boolean': + case 'integer': + case 'null': + case 'number': + case 'string': { + if (!Array.isArray(data)) { + ref[key] = (data ?? {})[key]; + } + break; + } + default: + continue; + } + } + + return ref; + } + if (jsonSchema.additionalProperties === false) { // Strip keys not belonging to schema outputData = {}; - for (const key of Object.keys(jsonSchema.properties ?? {})) { - if (key in dataWithDefaults) outputData[key] = dataWithDefaults[key]; + if (options?.unflatten) { + outputData = scrubDataToSchema(jsonSchema, dataWithDefaults) as Record; + } else { + for (const key of Object.keys(jsonSchema.properties ?? {})) { + if (key in dataWithDefaults) outputData[key] = dataWithDefaults[key]; + } } } else { outputData = dataWithDefaults; diff --git a/src/routes/(v2)/v2/flattened-json/+page.server.ts b/src/routes/(v2)/v2/flattened-json/+page.server.ts new file mode 100644 index 00000000..6b36a423 --- /dev/null +++ b/src/routes/(v2)/v2/flattened-json/+page.server.ts @@ -0,0 +1,104 @@ +import { zod } from '$lib/adapters/zod.js'; +import { partialSchema, schema } from './schema.js'; +import { message, superValidate } from '$lib/index.js'; +import type { Actions } from '@sveltejs/kit'; + +export const load = async () => { + const initialData = { + firstName: 'Stephen', + lastName: 'King', + books: [ + { + chapters: [{}] + } + ] + }; + const form = await superValidate(initialData, zod(partialSchema), { unflatten: true }); + return { form }; +}; + +export const actions: Actions = { + submit: async ({ request }) => { + const form = await superValidate(request, zod(schema), { unflatten: true }); + + if (!form.valid) { + return message(form, 'Not valid', { status: 400 }); + } + + return message(form, 'OK'); + }, + 'partial-update': async ({ request }) => { + const form = await superValidate(request, zod(partialSchema), { + unflatten: true + }); + + if (!form.valid) { + return message(form, 'Not valid', { status: 400 }); + } + + return { form }; + }, + 'add-book': async ({ request }) => { + const form = await superValidate(request, zod(partialSchema), { + unflatten: true + }); + + if (!form.valid) { + return message(form, 'Not valid', { status: 400 }); + } + + if (!form.data.books) { + form.data.books = []; + } + + form.data.books.push({}); + + return { form }; + }, + 'add-chapter': async ({ request }) => { + const form = await superValidate(request, zod(partialSchema), { + unflatten: true + }); + + if (!form.valid) { + return message(form, 'Not valid', { status: 400 }); + } + + if (form.data.bookIndex === undefined) { + return message(form, 'No book index provided', { status: 400 }); + } + + const book = (form.data.books ?? [])[form.data.bookIndex]; + + if (!book) { + return message(form, 'No matching book', { status: 400 }); + } + + if (!book.chapters) { + book.chapters = []; + } + + book.chapters.push({}); + + return { form }; + }, + 'add-event': async ({ request }) => { + const form = await superValidate(request, zod(partialSchema), { + unflatten: true + }); + + if (!form.valid) { + return message(form, 'Not valid', { status: 400 }); + } + + form.data.books?.forEach((book) => + book.chapters?.forEach((chapter) => { + if (chapter && chapter?.addEvent) { + chapter.events = chapter.events !== undefined ? [...chapter.events, ''] : ['']; + } + }) + ); + + return { form }; + } +}; diff --git a/src/routes/(v2)/v2/flattened-json/+page.svelte b/src/routes/(v2)/v2/flattened-json/+page.svelte new file mode 100644 index 00000000..b5a86e01 --- /dev/null +++ b/src/routes/(v2)/v2/flattened-json/+page.svelte @@ -0,0 +1,118 @@ + + +
+ {$message} + {JSON.stringify($errors)} +
+
+ Author + + + + {#if $form.books} + {#each $form.books as _, i} +
+ {$form.books[i].title} + + + {#each $form.books[i]?.chapters ?? [] as chapter, j} +
+ Chapter {j + 1} + {#if $form.books[i]?.chapters?.[j]} + + +
+ Events + {#each $form.books[i]?.chapters?.[j].events ?? [] as _, k} + {#if $form.books[i]?.chapters?.[j]?.events?.[k] !== undefined} + + {/if} + {/each} +
+ + + {/if} +
+ {/each} + +
+ {/each} + {/if} + +
+
+
+ +
+
+ + diff --git a/src/routes/(v2)/v2/flattened-json/schema.ts b/src/routes/(v2)/v2/flattened-json/schema.ts new file mode 100644 index 00000000..7b7aac5e --- /dev/null +++ b/src/routes/(v2)/v2/flattened-json/schema.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +const chapter = z.object({ + pages: z.number(), + events: z.array(z.string()) +}); + +const book = z.object({ + title: z.string(), + publishingDate: z.string().date(), + chapters: z.array(chapter) +}); + +export const schema = z.object({ + firstName: z.string(), + lastName: z.string(), + books: z.array(book), + birthday: z.string().date() +}); + +//Deep partial is deprecated? +const partialChapter = z + .object({ + pages: z.number(), + events: z.array(z.string().optional()) + }) + .partial() + .extend({ addEvent: z.boolean().optional() }); + +const partialBook = z + .object({ + title: z.string(), + publishingDate: z.string().date(), + chapters: z.array(partialChapter) + }) + .partial(); + +export const partialSchema = z + .object({ + firstName: z.string(), + lastName: z.string(), + books: z.array(partialBook), + birthday: z.string().date(), + bookIndex: z.number() + }) + .partial();