From c007dc0e1ad0a91d99510c0aed2b4793094103ed Mon Sep 17 00:00:00 2001 From: Christian Tweed Date: Tue, 11 Mar 2025 22:44:59 -0400 Subject: [PATCH 1/3] ISS-186: Added a new unflattenFormData method with tests --- src/lib/unflattenFormData.ts | 67 +++++++++++++++++++++++++++++ src/tests/unflattenFormData.test.ts | 59 +++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/lib/unflattenFormData.ts create mode 100644 src/tests/unflattenFormData.test.ts diff --git a/src/lib/unflattenFormData.ts b/src/lib/unflattenFormData.ts new file mode 100644 index 00000000..82d2777e --- /dev/null +++ b/src/lib/unflattenFormData.ts @@ -0,0 +1,67 @@ +import { splitPath } from './stringPath.js'; + +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; + } +} + +/** + * Take a FormData object that is a flattened representation of + * a nested form and reconstruct the nested data structure. + * Example keys: + * + * ``` + * {'a.b.c': 1} -> {a: b: {c: 1}}, + * {'a.b.d[0]': 2} -> {a: {b: {d: [2]}}} + * ``` + */ +export function unflattenFormData(data: FormData): Record { + const result: Record = {}; + + for (const flatKey of data.keys()) { + const paths = splitPath(flatKey); + const formValue = data.getAll(flatKey); + + let parent: Record | unknown[] = result; + 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) { + const actualFormValue = formValue.length === 1 ? formValue[0] : formValue; + setValueOfArrayOrObject(parent, key, actualFormValue); + return; + } + + const value = Array.isArray(parent) + ? (parent[parseInt(key, 10)] as Record | unknown[] | undefined) + : (parent[key] as Record | unknown[] | undefined); + + if (!value) { + const initializedValue = !Number.isNaN(numericAdjacentKey) ? [] : {}; + setValueOfArrayOrObject(parent, key, initializedValue); + parent = initializedValue; + return; + } + + parent = value; + }); + } + + return result; +} diff --git a/src/tests/unflattenFormData.test.ts b/src/tests/unflattenFormData.test.ts new file mode 100644 index 00000000..a5647079 --- /dev/null +++ b/src/tests/unflattenFormData.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'vitest'; +import { unflattenFormData } from '$lib/unflattenFormData.js'; + +describe('unflattenFormData', () => { + test('should assemble a simple data structure', () => { + const data = new FormData(); + data.append('a.b.c', '1'); + + const result = unflattenFormData(data); + expect(result).toEqual({ a: { b: { c: '1' } } }); + }); + + test('should aggregate multiple keys of the same name', () => { + const data = new FormData(); + data.append('a.b', '1'); + data.append('a.b', '2'); + const result = unflattenFormData(data); + expect(result).toEqual({ a: { b: ['1', '2'] } }); + }); + + test('should parse array notation', () => { + const data = new FormData(); + data.append('a.b[0]', '0'); + data.append('a.b[1]', '1'); + const result = unflattenFormData(data); + expect(result).toEqual({ a: { b: ['0', '1'] } }); + }); + + test('should parse array and object mixing', () => { + const data = new FormData(); + data.append('a.b[0].c.d[1][0]', '1'); + const result = unflattenFormData(data); + expect(result).toEqual({ a: { b: [{ c: { d: [undefined, ['1']] } }] } }); + }); + + test('should parse a large unwieldy nested form', () => { + const data = new FormData(); + data.append('a.b[0].c.d[1][0]', '1'); + data.append('a.b[0].c.d[1][0]', '2'); + data.append('a.e', '3'); + data.append('a.b[1][0]', '4'); + data.append('a.b[0].f', '5'); + const result = unflattenFormData(data); + expect(result).toEqual({ + a: { + b: [ + { + c: { + d: [undefined, [['1', '2']]] + }, + f: '5' + }, + ['4'] + ], + e: '3' + } + }); + }); +}); From 620364cc118fd65ae645469cab2ba301b9a38781 Mon Sep 17 00:00:00 2001 From: Christian Tweed Date: Sat, 22 Mar 2025 17:12:30 -0400 Subject: [PATCH 2/3] ISS-186: Added unflatten to the superValidate function for server side parsing --- src/lib/formData.ts | 178 ++++++++++++++++++++++++---- src/lib/superValidate.ts | 92 +++++++++++++- src/lib/unflattenFormData.ts | 67 ----------- src/tests/unflattenFormData.test.ts | 59 --------- 4 files changed, 246 insertions(+), 150 deletions(-) delete mode 100644 src/lib/unflattenFormData.ts delete mode 100644 src/tests/unflattenFormData.test.ts diff --git a/src/lib/formData.ts b/src/lib/formData.ts index 0cd668ab..b9050148 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 }; } @@ -185,24 +187,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 @@ -248,12 +232,12 @@ function _parseFormData>( // 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(key, e, arrayInfo)); + const arrayData = entries.map((e) => parseSingleEntry(key, e, arrayInfo, options)); if (isFileArray && arrayData.every((file) => !file)) arrayData.length = 0; output[key] = info.types.includes('set') ? new Set(arrayData) : arrayData; } else { - output[key] = parseSingleEntry(key, entries[entries.length - 1], info); + output[key] = parseSingleEntry(key, entries[entries.length - 1], info, options); } } @@ -332,3 +316,153 @@ function parseFormDataEntry( throw new SuperFormError('Unsupported schema type for FormData: ' + type); } } + +function parseSingleEntry>( + key: string, + entry: FormDataEntryValue, + info: SchemaInfo, + options?: SuperValidateOptions +) { + 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); +} + +function parseFlattenedData>( + formData: FormData, + schema: JSONSchema, + options?: SuperValidateOptions +) { + const rootInfo = schemaInfo(schema, false, []); + const output: Record = {}; + + 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[], initialValue: FormDataEntryValue | null) { + 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 property = ((schemaLeaf?.parent as SchemaInfo)?.properties ?? {})[schemaLeaf.key]; + + if (!property) { + return undefined; + } + + if (initialValue === null) { + return initialValue; + } + + const propetyIsRequired = (schemaLeaf?.parent as SchemaInfo)?.required?.includes( + schemaLeaf.key + ); + + const isOptional = propetyIsRequired === undefined ? true : !propetyIsRequired; + + return parseSingleEntry( + schemaLeaf.key, + initialValue, + schemaInfo(property, isOptional, []), + options + ); + } + + 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.get(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..c3b322d5 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,98 @@ 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) { + return []; + } + + if (items === true) { + 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 = {}; + + 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) { + ref[key] = dataArray.map((dataItem) => scrubDataToSchema(itemsSchema, 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/lib/unflattenFormData.ts b/src/lib/unflattenFormData.ts deleted file mode 100644 index 82d2777e..00000000 --- a/src/lib/unflattenFormData.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { splitPath } from './stringPath.js'; - -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; - } -} - -/** - * Take a FormData object that is a flattened representation of - * a nested form and reconstruct the nested data structure. - * Example keys: - * - * ``` - * {'a.b.c': 1} -> {a: b: {c: 1}}, - * {'a.b.d[0]': 2} -> {a: {b: {d: [2]}}} - * ``` - */ -export function unflattenFormData(data: FormData): Record { - const result: Record = {}; - - for (const flatKey of data.keys()) { - const paths = splitPath(flatKey); - const formValue = data.getAll(flatKey); - - let parent: Record | unknown[] = result; - 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) { - const actualFormValue = formValue.length === 1 ? formValue[0] : formValue; - setValueOfArrayOrObject(parent, key, actualFormValue); - return; - } - - const value = Array.isArray(parent) - ? (parent[parseInt(key, 10)] as Record | unknown[] | undefined) - : (parent[key] as Record | unknown[] | undefined); - - if (!value) { - const initializedValue = !Number.isNaN(numericAdjacentKey) ? [] : {}; - setValueOfArrayOrObject(parent, key, initializedValue); - parent = initializedValue; - return; - } - - parent = value; - }); - } - - return result; -} diff --git a/src/tests/unflattenFormData.test.ts b/src/tests/unflattenFormData.test.ts deleted file mode 100644 index a5647079..00000000 --- a/src/tests/unflattenFormData.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { unflattenFormData } from '$lib/unflattenFormData.js'; - -describe('unflattenFormData', () => { - test('should assemble a simple data structure', () => { - const data = new FormData(); - data.append('a.b.c', '1'); - - const result = unflattenFormData(data); - expect(result).toEqual({ a: { b: { c: '1' } } }); - }); - - test('should aggregate multiple keys of the same name', () => { - const data = new FormData(); - data.append('a.b', '1'); - data.append('a.b', '2'); - const result = unflattenFormData(data); - expect(result).toEqual({ a: { b: ['1', '2'] } }); - }); - - test('should parse array notation', () => { - const data = new FormData(); - data.append('a.b[0]', '0'); - data.append('a.b[1]', '1'); - const result = unflattenFormData(data); - expect(result).toEqual({ a: { b: ['0', '1'] } }); - }); - - test('should parse array and object mixing', () => { - const data = new FormData(); - data.append('a.b[0].c.d[1][0]', '1'); - const result = unflattenFormData(data); - expect(result).toEqual({ a: { b: [{ c: { d: [undefined, ['1']] } }] } }); - }); - - test('should parse a large unwieldy nested form', () => { - const data = new FormData(); - data.append('a.b[0].c.d[1][0]', '1'); - data.append('a.b[0].c.d[1][0]', '2'); - data.append('a.e', '3'); - data.append('a.b[1][0]', '4'); - data.append('a.b[0].f', '5'); - const result = unflattenFormData(data); - expect(result).toEqual({ - a: { - b: [ - { - c: { - d: [undefined, [['1', '2']]] - }, - f: '5' - }, - ['4'] - ], - e: '3' - } - }); - }); -}); From 425b0e3f37f4d515117ca678e5f532149cf5b648 Mon Sep 17 00:00:00 2001 From: Christian Tweed Date: Mon, 31 Mar 2025 22:16:36 -0400 Subject: [PATCH 3/3] ISS-186: Added the initial version of the parseFlattenedData method with an example --- src/lib/formData.ts | 167 +++++++++++++----- src/lib/superValidate.ts | 22 ++- .../(v2)/v2/flattened-json/+page.server.ts | 104 +++++++++++ .../(v2)/v2/flattened-json/+page.svelte | 118 +++++++++++++ src/routes/(v2)/v2/flattened-json/schema.ts | 46 +++++ 5 files changed, 410 insertions(+), 47 deletions(-) create mode 100644 src/routes/(v2)/v2/flattened-json/+page.server.ts create mode 100644 src/routes/(v2)/v2/flattened-json/+page.svelte create mode 100644 src/routes/(v2)/v2/flattened-json/schema.ts diff --git a/src/lib/formData.ts b/src/lib/formData.ts index b9050148..086f9de9 100644 --- a/src/lib/formData.ts +++ b/src/lib/formData.ts @@ -159,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; @@ -232,12 +250,12 @@ function _parseFormData>( // 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(key, e, arrayInfo, options)); + const arrayData = entries.map((e) => parseSingleEntry(key, e, arrayInfo)); if (isFileArray && arrayData.every((file) => !file)) arrayData.length = 0; output[key] = info.types.includes('set') ? new Set(arrayData) : arrayData; } else { - output[key] = parseSingleEntry(key, entries[entries.length - 1], info, options); + output[key] = parseSingleEntry(key, entries[entries.length - 1], info); } } @@ -317,29 +335,6 @@ function parseFormDataEntry( } } -function parseSingleEntry>( - key: string, - entry: FormDataEntryValue, - info: SchemaInfo, - options?: SuperValidateOptions -) { - 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); -} - function parseFlattenedData>( formData: FormData, schema: JSONSchema, @@ -348,6 +343,76 @@ function parseFlattenedData>( 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, @@ -367,7 +432,7 @@ function parseFlattenedData>( } } - function getParsedValue(paths: string[], initialValue: FormDataEntryValue | null) { + function getParsedValue(paths: string[], entries: FormDataEntryValue[]) { const schemaLeaf = traversePath(rootInfo, paths, ({ key, parent }) => { const newParent = parent as SchemaInfo | undefined; if (!newParent) { @@ -405,28 +470,47 @@ function parseFlattenedData>( return undefined; } - const property = ((schemaLeaf?.parent as SchemaInfo)?.properties ?? {})[schemaLeaf.key]; + const parent = schemaLeaf.parent as SchemaInfo; + + const property = parent.array ? parent.array[0] : (parent?.properties ?? {})[schemaLeaf.key]; if (!property) { return undefined; } - if (initialValue === null) { - return initialValue; - } - - const propetyIsRequired = (schemaLeaf?.parent as SchemaInfo)?.required?.includes( - schemaLeaf.key - ); + const propetyIsRequired = parent?.required?.includes(schemaLeaf.key); const isOptional = propetyIsRequired === undefined ? true : !propetyIsRequired; - return parseSingleEntry( - schemaLeaf.key, - initialValue, - schemaInfo(property, isOptional, []), - options - ); + 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) { @@ -459,8 +543,9 @@ function parseFlattenedData>( for (const formDataKey of formData.keys().filter((key) => !key.startsWith('__superform_'))) { const paths = splitPath(formDataKey); - const value = formData.get(formDataKey); + const value = formData.getAll(formDataKey); const parsedValue = getParsedValue(paths, value); + initializePath(paths, parsedValue); } diff --git a/src/lib/superValidate.ts b/src/lib/superValidate.ts index c3b322d5..f02e92a8 100644 --- a/src/lib/superValidate.ts +++ b/src/lib/superValidate.ts @@ -164,11 +164,7 @@ export async function superValidate< const { items } = schema; - if (!items) { - return []; - } - - if (items === true) { + if (!items || typeof items === 'boolean') { return []; } @@ -179,6 +175,7 @@ export async function superValidate< if (typeof item === 'boolean') { return []; } + return data.map((datum) => scrubDataToSchema(item, datum as Record | unknown[]) ); @@ -194,6 +191,10 @@ export async function superValidate< 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; @@ -211,8 +212,17 @@ export async function superValidate< const itemsSchema = Array.isArray(subSchema.items) ? subSchema.items[0] : subSchema.items; + if (Array.isArray(dataArray) && itemsSchema && itemsSchema !== true) { - ref[key] = dataArray.map((dataItem) => scrubDataToSchema(itemsSchema, dataItem)); + 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; 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();