From 3e696272af9147e14b1d180ded11867337f2b77c Mon Sep 17 00:00:00 2001 From: Jim Burbridge Date: Sat, 19 Jul 2025 00:55:33 -0700 Subject: [PATCH] feat(Arktype/Utils): Adds new functionality to ArkType utils --- .changeset/arktype-utils.md | 26 +++ packages/arktype-utils/README.md | 37 ++++ packages/arktype-utils/src/formData.test.ts | 35 +++ packages/arktype-utils/src/formData.ts | 223 +++++++++++++++----- 4 files changed, 268 insertions(+), 53 deletions(-) create mode 100644 .changeset/arktype-utils.md diff --git a/.changeset/arktype-utils.md b/.changeset/arktype-utils.md new file mode 100644 index 0000000..09c86a5 --- /dev/null +++ b/.changeset/arktype-utils.md @@ -0,0 +1,26 @@ +--- +"@jhecht/arktype-utils": minor +--- + +## Summary + +Updated utilities to allow for building nested files + +### Detailed Changes + +```ts +// Test nested objects with dot notation +const fd = new FormData(); +fd.append('payments.id1.location', 'England'); +fd.append('payments.id1.age', '37'); +fd.append('payments.id2.location', 'New York'); +const obj = formDataToObject(fd); + +// Test nested arrays with bracket notation +const fd2 = new FormData(); +fd2.append('locations[].name', 'England'); +fd2.append('locations[].members[]', 'John'); +fd2.append('locations[].members[]', 'Joe'); +fd2.append('locations[].name', 'France'); +const obj2 = formDataToObject(fd2); +``` diff --git a/packages/arktype-utils/README.md b/packages/arktype-utils/README.md index b36cd09..e2b8607 100644 --- a/packages/arktype-utils/README.md +++ b/packages/arktype-utils/README.md @@ -109,6 +109,43 @@ const obj = formDataToObject(fd); console.info(obj); ``` +### Deeply Nested Objects and Arrays + +`formDataToObject` supports advanced dot and bracket notation for deeply nested structures, including arrays of objects and nested arrays: + +```ts +const fd = new FormData(); +fd.append('payments.id1.location', 'England'); +fd.append('payments.id1.age', '37'); +fd.append('payments.id2.location', 'New York'); +fd.append('payments.id2.age', '81'); +fd.append('payments.id2.name', 'Steve'); + +const obj = formDataToObject(fd); +// { +// payments: { +// id1: { location: 'England', age: 37 }, +// id2: { location: 'New York', age: 81, name: 'Steve' }, +// } +// } + +const fd2 = new FormData(); +fd2.append('locations[].name', 'England'); +fd2.append('locations[].members[]', 'John'); +fd2.append('locations[].members[]', 'Joe'); +fd2.append('locations[].name', 'France'); +fd2.append('locations[].members[]', 'Francis'); +fd2.append('locations[].members[]', 'Joe2'); + +const obj2 = formDataToObject(fd2); +// { +// locations: [ +// { name: 'England', members: ['John', 'Joe'] }, +// { name: 'France', members: ['Francis', 'Joe2'] } +// ] +// } +``` + ## Validating Data with ArkType I created this wrapper, which `throws` if there are any `error` values. It takes two arguments, one being a `FormData` object, and the other a `type` or `scope` call from ArkType, which serves as the validator for the object-ified `FormData`. diff --git a/packages/arktype-utils/src/formData.test.ts b/packages/arktype-utils/src/formData.test.ts index b630c45..a8df270 100644 --- a/packages/arktype-utils/src/formData.test.ts +++ b/packages/arktype-utils/src/formData.test.ts @@ -120,6 +120,41 @@ describe('formDataToObject', () => { }); }); +describe('formDataToObject - nested keys', () => { + it('parses nested objects with dot notation', () => { + const formData = new FormData(); + formData.append('payments.id1.location', 'England'); + formData.append('payments.id1.age', '37'); + formData.append('payments.id2.location', 'New York'); + formData.append('payments.id2.age', '81'); + formData.append('payments.id2.name', 'Steve'); + const obj = formDataToObject(formData); + expect(obj).toMatchObject({ + payments: { + id1: { location: 'England', age: 37 }, + id2: { location: 'New York', age: 81, name: 'Steve' }, + }, + }); + }); + + it('parses nested arrays and objects with bracket/dot notation', () => { + const formData = new FormData(); + formData.append('locations[].name', 'England'); + formData.append('locations[].members[]', 'John'); + formData.append('locations[].members[]', 'Joe'); + formData.append('locations[].name', 'France'); + formData.append('locations[].members[]', 'Francis'); + formData.append('locations[].members[]', 'Joe2'); + const obj = formDataToObject(formData); + expect(obj).toMatchObject({ + locations: [ + { name: 'England', members: ['John', 'Joe'] }, + { name: 'France', members: ['Francis', 'Joe2'] }, + ], + }); + }); +}); + describe('validateFormData', () => { const fileType = type(['instanceof', File]); it('Validates simple object', () => { diff --git a/packages/arktype-utils/src/formData.ts b/packages/arktype-utils/src/formData.ts index 4197dfa..c7992e9 100644 --- a/packages/arktype-utils/src/formData.ts +++ b/packages/arktype-utils/src/formData.ts @@ -7,7 +7,6 @@ type FilterFn = (a: EntriesTouple) => boolean; type FormDataObjectEntry = FormDataEntryValue | number | boolean | bigint; -const nameKeyExtractor = /(?.*)\[(?.*)\]/; const digitCheck = /^\d+$/; type MagicObject = { @@ -52,68 +51,186 @@ function makeMagicObject(init: EntriesTouple[] = []): MagicObject { * @param filterFn an optional filtering function to remove some values from the end object * @returns an object mapped from the entries. */ +function parseKeyPath(key: string): (string | number)[] { + // Split on dots not inside brackets, then handle bracketed indices/keys + const path: (string | number)[] = []; + let buffer = ''; + let inBracket = false; + for (let i = 0; i < key.length; i++) { + const [char] = key; + if (char === '[') { + if (buffer) { + path.push(buffer); + buffer = ''; + } + inBracket = true; + } else if (char === ']') { + if (buffer) { + // If it's a number, treat as array index + if (/^\d+$/.test(buffer)) path.push(Number(buffer)); + else path.push(buffer); + buffer = ''; + } else { + // Support for [] as array push + path.push(''); + } + inBracket = false; + } else if (char === '.' && !inBracket) { + if (buffer) { + path.push(buffer); + buffer = ''; + } + } else buffer += char; + } + if (buffer) path.push(buffer); + return path; +} + +/** + * Recursively sets a value in a nested object/array structure based on a path. + * Handles creation of arrays/objects as needed for deep assignment. + * Supports array push (''), numeric indices, and string keys. + * + * @param obj The root object or array to mutate + * @param path The path array (from parseKeyPath) + * @param value The value to set + */ +function setDeep( + obj: Record | unknown[], + path: (string | number)[], + value: unknown, +) { + // Full disclosure: this was generated by AI. Should probably deep dive into it + // in the future but for right now it works. + let curr: Record | unknown[] = obj; + for (let i = 0; i < path.length - 1; i++) { + const { [i]: key } = path; + const { [i + 1]: nextKey } = path; + // Handle array push (''), numeric index, or string key + if (key === '') { + if (!Array.isArray(curr)) curr = []; + const arr = curr as unknown[]; + if (!arr[arr.length - 1] || typeof nextKey === 'string') arr.push({}); + const [last] = arr.slice(-1); + curr = + Array.isArray(last) || typeof last === 'object' + ? (last as Record | unknown[]) + : {}; + } else if (typeof key === 'number') { + if (!Array.isArray(curr)) curr = []; + const arr = curr as unknown[]; + if (!arr[key]) arr[key] = typeof nextKey === 'number' ? [] : {}; + const { [key]: next } = arr; + curr = + Array.isArray(next) || typeof next === 'object' + ? (next as Record | unknown[]) + : {}; + } else if (typeof key === 'string') { + if (!(typeof curr === 'object' && curr !== null && key in curr)) { + (curr as Record)[key] = + typeof nextKey === 'number' ? [] : {}; + } + const objRec = curr as Record; + const { [key]: next } = objRec; + curr = + Array.isArray(next) || typeof next === 'object' + ? (next as Record | unknown[]) + : {}; + } + } + const [lastKey] = path.slice(-1); + if (lastKey === '') { + if (!Array.isArray(curr)) curr = []; + (curr as unknown[]).push(value); + } else if (typeof lastKey === 'number') { + if (!Array.isArray(curr)) curr = []; + (curr as unknown[])[lastKey] = value; + } else if (typeof lastKey === 'string') + (curr as Record)[lastKey] = value; +} + +/** + * Converts a FormData object into a deeply nested JavaScript object. + * Supports dot and bracket notation for nested objects/arrays, e.g.: + * - 'foo.bar' → { foo: { bar: value } } + * - 'arr[]' → { arr: [value, ...] } + * - 'obj[key]' → { obj: { key: value } } + * - 'arr[].prop' → { arr: [{ prop: value }, ...] } + * Handles type conversion for numbers, booleans, and bigints. + * + * @param fd The FormData instance + * @param filterFn Optional filter function for entries + * @returns The resulting JS object + */ export function formDataToObject( fd: FormData, filterFn: FilterFn = () => true, ) { const ret: Record = {}; - // Map of key name into magic type which converts its entries to either an object - // or an array. - const info = new Map>(); - // eslint-disable-next-line prefer-const - for (let [iterator, value] of fd.entries()) { - // If the key does not match this filter, continue the loop - if (!filterFn([iterator, value])) continue; - // run the iterator against the name key extractor - const matches = iterator.match(nameKeyExtractor); - // If we do not have matches, or the index match is empty, we go here. - if (matches === null || matches[2] === '') { - // Grab all of the values for the current iterator - const all = fd.getAll(iterator); - // If the length of all entries for this iterator is 1 AND the iterator name does not end - // with [] (indicating the user wants this to be an array) we drop in here - if (all.length === 1 && !iterator.endsWith('[]')) { - // set the value on the return object - ret[iterator] = - typeof all[0] === 'string' ? stringToJSValue(all[0]) : all[0]; - // don't need the rest of the loop values here, so we forcibly continue - continue; + // For grouping array objects by their base path + const arrayGroups = new Map< + string, + { current: Record; arr: Record[] } + >(); + // For collecting repeated simple keys and top-level arrays + const simpleArrays = new Map(); + for (const [rawKey, value] of fd.entries()) { + if (!filterFn([rawKey, value])) continue; + const path = parseKeyPath(rawKey); + let v: unknown = value; + if (typeof value === 'string') v = stringToJSValue(value); + // Handle top-level arrays (e.g., names[]) + if (path.length === 2 && path[1] === '') { + const key = String(path[0]); + if (!simpleArrays.has(key)) simpleArrays.set(key, []); + simpleArrays.get(key)!.push(v); + continue; + } + // Handle nested arrays (e.g., locations[].members[]) + const arrayIdx = path.findIndex((p) => p === ''); + if (arrayIdx !== -1) { + const basePath = path.slice(0, arrayIdx).join('.'); + if (!arrayGroups.has(basePath)) + arrayGroups.set(basePath, { current: {}, arr: [] }); + const group = arrayGroups.get(basePath)!; + // If this is a new object in the array, push current and start new + if ( + Object.keys(group.current).length > 0 && + path[arrayIdx + 1] === 'name' + ) { + group.arr.push(group.current); + group.current = {}; } - // If the iterator includes an opening [], let's assume we don't want the `[]` to be included - // so we trim it out here - if (iterator.includes('[')) - iterator = iterator.slice(0, iterator.indexOf('[')); - - // Set the iterator value on returned object - ret[iterator] = all.map((v) => - typeof v === 'string' ? stringToJSValue(v) : v, - ); + // If the property is a nested array (e.g., members[]), collect as array + if (path.length === arrayIdx + 3 && path[arrayIdx + 2] === '') { + const prop = String(path[arrayIdx + 1]); + if (!Array.isArray(group.current[prop])) group.current[prop] = []; + (group.current[prop] as unknown[]).push(v); + } else setDeep(group.current, path.slice(arrayIdx + 1), v); } else { - // If we have matches, there's a bit more processing required - const { groups } = matches; - // Check to ensure our groups are there. It shouldn't be possible to have matches - // without groups in modern JS, but c'est la vie - if (groups === undefined) continue; - // pull out the name and index. we add defaults so TS doesn't yell at us about possibly - // being undefined - const { name = '', index = '' } = groups; - // Grab the magic item from the info map. - let magic = info.get(name); - // If we don't have a magic item, make one and set it - if (!magic) { - magic = makeMagicObject(); - info.set(name, magic); - } - - magic.add(index, value); + // For repeated simple keys (not arrays) + const keyStr = path.join('.'); + if (fd.getAll(rawKey).length > 1) { + if (!simpleArrays.has(keyStr)) { + simpleArrays.set( + keyStr, + fd + .getAll(rawKey) + .map((val) => + typeof val === 'string' ? stringToJSValue(val) : val, + ), + ); + } + } else setDeep(ret, path, v); } } - - // Consolidation of items in the info values. - for (const [key, magic] of info.entries()) { - if (!ret[key]) ret[key] = magic.toJS(); - else console.error(`Key ${key} already exists in object.`); + // Assign grouped arrays + for (const [base, { current, arr }] of arrayGroups.entries()) { + if (Object.keys(current).length > 0) arr.push(current); + setDeep(ret, base.split('.'), arr); } + // Assign simple arrays + for (const [key, arr] of simpleArrays.entries()) setDeep(ret, [key], arr); return ret; }