diff --git a/packages/pure-parse/src/memoization/parsers.ts b/packages/pure-parse/src/memoization/parsers.ts index ba51b29..379fa2e 100644 --- a/packages/pure-parse/src/memoization/parsers.ts +++ b/packages/pure-parse/src/memoization/parsers.ts @@ -3,6 +3,7 @@ import { array, // nonEmptyArray, object, + objectNoJit, // partialRecord, // record, // tuple, @@ -12,6 +13,7 @@ import { export const unionMemo = memoizeValidatorConstructor(oneOf) export const objectMemo = memoizeValidatorConstructor(object) +export const objectNoJitMemo = memoizeValidatorConstructor(objectNoJit) // TODO export const recordMemo = memoizeValidatorConstructor(record) // TODO export const partialRecordMemo = memoizeValidatorConstructor(partialRecord) export const tupleMemo = memoizeValidatorConstructor(tuple) diff --git a/packages/pure-parse/src/parsers/arrays.test.ts b/packages/pure-parse/src/parsers/arrays.test.ts index 7cb45e8..41b62c8 100644 --- a/packages/pure-parse/src/parsers/arrays.test.ts +++ b/packages/pure-parse/src/parsers/arrays.test.ts @@ -5,6 +5,7 @@ import { failure, success } from './types' import { literal } from './literal' import { oneOf } from './oneOf' import { always } from './always' +import { parseString } from './primitives' describe('arrays', () => { it('validates when all elements pass validation', () => { @@ -41,5 +42,77 @@ describe('arrays', () => { }), ) }) - describe.todo('self-referential arrays') + describe('errors', () => { + it('reports non-arrays', () => { + const parse = array(parseString) + expect(parse(1)).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [], + }), + ) + }) + describe('nested errors', () => { + it('reports shallow errors in elements', () => { + const parse = array(parseString) + expect(parse([1])).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [{ tag: 'array', index: 0 }], + }), + ) + }) + it('reports deep errors in nested elements', () => { + const parse = array(array(parseString)) + expect(parse([[1]])).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [ + { tag: 'array', index: 0 }, + { tag: 'array', index: 0 }, + ], + }), + ) + expect(parse([[], [], [1]])).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [ + { tag: 'array', index: 2 }, + { tag: 'array', index: 0 }, + ], + }), + ) + expect(parse([[], ['a'], ['a', 'b', 'c', 3], []])).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [ + { tag: 'array', index: 2 }, + { tag: 'array', index: 3 }, + ], + }), + ) + }) + test('that the index is accurate', () => { + const parse = array(parseString) + expect(parse([1, 2, 3])).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [{ tag: 'array', index: 0 }], + }), + ) + expect(parse(['1', 2, 3])).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [{ tag: 'array', index: 1 }], + }), + ) + expect(parse(['1', '2', 3])).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [{ tag: 'array', index: 2 }], + }), + ) + }) + }) + }) }) diff --git a/packages/pure-parse/src/parsers/arrays.ts b/packages/pure-parse/src/parsers/arrays.ts index b1e5a2f..d71326a 100644 --- a/packages/pure-parse/src/parsers/arrays.ts +++ b/packages/pure-parse/src/parsers/arrays.ts @@ -5,13 +5,9 @@ import { Parser, ParseResult, success, + propagateFailure, } from './types' -// Local helper function -const areAllSuccesses = ( - results: ParseResult[], -): results is ParseSuccess[] => results.every((result) => isSuccess(result)) - /** * Validate arrays * @return a function that parses arrays @@ -19,26 +15,18 @@ const areAllSuccesses = ( */ export const array = (parseItem: Parser): Parser => - (data: unknown) => { + (data) => { if (!Array.isArray(data)) { return failure('Not an array') } - const results: ParseResult[] = data.map(parseItem) - - // Imperative programming for performance - let allSuccess = true - for (const result of results) { - allSuccess &&= result.tag !== 'failure' - } - if (!allSuccess) { - return failure('Not all elements are valid') + const dataOutput = [] + for (let i = 0; i < data.length; i++) { + const parseResult = parseItem(data[i]) + if (parseResult.tag === 'failure') { + return propagateFailure(parseResult, { tag: 'array', index: i }) + } + dataOutput.push((parseResult as ParseSuccess).value) } - - // If any element is a fallbackValue, return a new array - return success( - (results as Array, { tag: 'failure' }>>).map( - (result) => result.value, - ), - ) + return success(dataOutput as T[]) } diff --git a/packages/pure-parse/src/parsers/object.test.ts b/packages/pure-parse/src/parsers/object.test.ts index 840f4af..3b59763 100644 --- a/packages/pure-parse/src/parsers/object.test.ts +++ b/packages/pure-parse/src/parsers/object.test.ts @@ -13,20 +13,28 @@ import { Infer } from '../common' import { literal } from './literal' import { nullable, optional } from './optional' import { always } from './always' +import { objectMemo, objectNoJitMemo } from '../memoization' -const suits = [ +const suites = [ { - name: 'objectNoEval', + name: 'object without JIT', fn: objectNoJit, }, { - name: 'objectEval', + name: 'object with JIT', fn: objectParseEval, }, - // TODO objectMemo + { + name: 'memoized object with JIT', + fn: objectMemo, + }, + { + name: 'memoized object without JIT', + fn: objectNoJitMemo, + }, ] -suits.forEach(({ name: suiteName, fn: object }) => { +suites.forEach(({ name: suiteName, fn: object }) => { describe(suiteName, () => { describe('objects', () => { describe('unknown properties', () => { @@ -371,6 +379,75 @@ suits.forEach(({ name: suiteName, fn: object }) => { })(data) }) }) + describe('errors', () => { + it('reports non-objects', () => { + const parse = object({ + a: parseString, + }) + expect(parse('nonanobj')).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [], + }), + ) + }) + it('reports missing properties', () => { + const parse = object({ + a: parseString, + }) + expect(parse({})).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [ + { + tag: 'object', + key: 'a', + }, + ], + }), + ) + }) + describe('nested errors', () => { + it('reports shallow errors in properties', () => { + const parse = object({ + a: parseString, + }) + expect(parse({ a: 1 })).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [ + { + tag: 'object', + key: 'a', + }, + ], + }), + ) + }) + it('reports deep errors in nested properties', () => { + const parse = object({ + a: object({ + b: parseString, + }), + }) + expect(parse({ a: { b: 1 } })).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [ + { + tag: 'object', + key: 'a', + }, + { + tag: 'object', + key: 'b', + }, + ], + }), + ) + }) + }) + }) }) }) }) diff --git a/packages/pure-parse/src/parsers/object.ts b/packages/pure-parse/src/parsers/object.ts index 860f50c..658d390 100644 --- a/packages/pure-parse/src/parsers/object.ts +++ b/packages/pure-parse/src/parsers/object.ts @@ -6,9 +6,13 @@ import { Parser, ParseResult, success, + propagateFailure, } from './types' import { optionalSymbol } from '../internals' +const notAnObjectMsg = 'Not an object' +const propertyMissingMsg = 'Property is missing' + /** * Same as {@link object}, but does not perform just-in-time (JIT) compilation with the `Function` constructor. This function is needed as a replacement in environments where `new Function()` is disallowed; for example, when the [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) policy is set without the `'unsafe-eval`' directive. */ @@ -19,7 +23,7 @@ export const objectNoJit = >(schema: { const entries = Object.entries(schema) return (data) => { if (!isObject(data)) { - return failure('Not an object') + return failure(notAnObjectMsg) } const dataOutput = {} as Record for (let i = 0; i < entries.length; i++) { @@ -31,17 +35,20 @@ export const objectNoJit = >(schema: { // The key is optional, so we can skip it continue } - return failure('A property is missing') + return propagateFailure(failure(propertyMissingMsg), { + tag: 'object', + key, + }) } const parseResult = parser(value) if (parseResult.tag === 'failure') { - return failure('Not all properties are valid') + return propagateFailure(parseResult, { tag: 'object', key }) } dataOutput[key] = (parseResult as ParseSuccess).value } - return success(dataOutput) as ParseResult + return success(dataOutput as T) } } @@ -65,7 +72,9 @@ export const object = >(schema: { const schemaEntries = Object.entries(schema) const parsers = schemaEntries.map(([_, parser]) => parser) const statements = [ - `if(typeof data !== 'object' || data === null) return {tag:'failure', message:'Not an object'}`, + `if(typeof data !== 'object' || data === null) return {tag:'failure', error: ${JSON.stringify( + notAnObjectMsg, + )}, path: []}`, `const dataOutput = {}`, `let parseResult`, ...schemaEntries.flatMap(([unescapedKey, parserFunction], i) => { @@ -78,11 +87,13 @@ export const object = >(schema: { return [ ...(!isOptional ? [ - `if(${value} === undefined && !data.hasOwnProperty(${key})) return {tag:'failure', message:'A property is missing'}`, + `if(${value} === undefined && !data.hasOwnProperty(${key})) return {tag:'failure', error:${JSON.stringify( + propertyMissingMsg, + )}, path: [{tag: 'object', key: ${key}}]}`, ] : []), `parseResult = ${parser}(${value})`, - `if(parseResult.tag === 'failure') return {tag:'failure', message:'Not all properties are valid'}`, + `if(parseResult.tag === 'failure') return {tag:'failure', error: parseResult.error, path:[{tag:'object', key:${key}}, ...parseResult.path]}`, `dataOutput[${key}] = parseResult.value`, ] }), diff --git a/packages/pure-parse/src/parsers/tuple.ts b/packages/pure-parse/src/parsers/tuple.ts deleted file mode 100644 index e69de29..0000000 diff --git a/packages/pure-parse/src/parsers/tuples.test.ts b/packages/pure-parse/src/parsers/tuples.test.ts index 078457f..691cc69 100644 --- a/packages/pure-parse/src/parsers/tuples.test.ts +++ b/packages/pure-parse/src/parsers/tuples.test.ts @@ -112,4 +112,96 @@ describe('tuples', () => { expect(a).toHaveBeenCalled() expect(b).not.toHaveBeenCalled() }) + + describe('errors', () => { + it('reports non-arrays', () => { + const parse = tuple([parseString]) + expect(parse(1)).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [], + }), + ) + }) + describe('nested errors', () => { + it('reports shallow errors in elements', () => { + const parse = tuple([parseString]) + expect(parse([1])).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [{ tag: 'array', index: 0 }], + }), + ) + }) + it('reports deep errors in nested elements', () => { + const parse = tuple([ + tuple([parseString, parseString]), + tuple([parseString, parseString]), + ]) + expect( + parse([ + [1, 1], + [1, 1], + ]), + ).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [ + { tag: 'array', index: 0 }, + { tag: 'array', index: 0 }, + ], + }), + ) + expect( + parse([ + ['1', 1], + [1, 1], + ]), + ).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [ + { tag: 'array', index: 0 }, + { tag: 'array', index: 1 }, + ], + }), + ) + expect( + parse([ + ['1', '1'], + ['1', 1], + ]), + ).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [ + { tag: 'array', index: 1 }, + { tag: 'array', index: 1 }, + ], + }), + ) + }) + test('that the index is accurate', () => { + const parse = tuple([parseString, parseString, parseString]) + expect(parse([1, 2, 3])).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [{ tag: 'array', index: 0 }], + }), + ) + expect(parse(['1', 2, 3])).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [{ tag: 'array', index: 1 }], + }), + ) + expect(parse(['1', '2', 3])).toEqual( + expect.objectContaining({ + tag: 'failure', + path: [{ tag: 'array', index: 2 }], + }), + ) + }) + }) + }) }) diff --git a/packages/pure-parse/src/parsers/tuples.ts b/packages/pure-parse/src/parsers/tuples.ts index 02994bf..d1a5bc0 100644 --- a/packages/pure-parse/src/parsers/tuples.ts +++ b/packages/pure-parse/src/parsers/tuples.ts @@ -1,4 +1,10 @@ -import { failure, Parser, ParseSuccess, success } from './types' +import { + failure, + Parser, + ParseSuccess, + propagateFailure, + success, +} from './types' /** * Construct parsers for tuples. @@ -36,12 +42,15 @@ export const tuple = ) } const dataOutput = [] - for (let i = 0; i < parsers.length; i++) { - const parser = parsers[i] - const value = data[i] + for (let index = 0; index < parsers.length; index++) { + const parser = parsers[index] + const value = data[index] const parseResult = parser(value) if (parseResult.tag === 'failure') { - return failure(`failed parsing index ${i}`) + return propagateFailure(parseResult, { + tag: 'array', + index, + }) } dataOutput.push(parseResult.value) } diff --git a/packages/pure-parse/src/parsers/types.test.ts b/packages/pure-parse/src/parsers/types.test.ts index 5843dab..e1c756e 100644 --- a/packages/pure-parse/src/parsers/types.test.ts +++ b/packages/pure-parse/src/parsers/types.test.ts @@ -6,6 +6,7 @@ import { parseNumber, parseString } from './primitives' import { literal } from './literal' import { optional } from './optional' import { always } from './always' +import { failure, ParseFailurePathSegment, propagateFailure } from './types' describe('parsing', () => { describe('some use cases', () => { @@ -79,4 +80,51 @@ describe('parsing', () => { ) }) }) + describe('propagateFailure', () => { + const segmentA: ParseFailurePathSegment = { tag: 'object', key: 'a' } + const segmentB: ParseFailurePathSegment = { tag: 'object', key: 'b' } + const segmentC: ParseFailurePathSegment = { tag: 'object', key: 'c' } + it('keeps the original error message', () => { + const errorMsg = '094uiroi34f' + expect( + propagateFailure(failure(errorMsg), { tag: 'object', key: 'a' }), + ).toEqual( + expect.objectContaining({ + error: errorMsg, + }), + ) + }) + it('includes the path segment in an error with no path segments', () => { + expect(propagateFailure(failure('errorMsg'), segmentA)).toEqual( + expect.objectContaining({ + path: [segmentA], + }), + ) + }) + it('prepends path segments to the path', () => { + expect( + propagateFailure( + propagateFailure(failure('errorMsg'), segmentB), + segmentA, + ), + ).toEqual( + expect.objectContaining({ + path: [segmentA, segmentB], + }), + ) + expect( + propagateFailure( + propagateFailure( + propagateFailure(failure('errorMsg'), segmentC), + segmentB, + ), + segmentA, + ), + ).toEqual( + expect.objectContaining({ + path: [segmentA, segmentB, segmentC], + }), + ) + }) + }) }) diff --git a/packages/pure-parse/src/parsers/types.ts b/packages/pure-parse/src/parsers/types.ts index 06cbaae..39c6476 100644 --- a/packages/pure-parse/src/parsers/types.ts +++ b/packages/pure-parse/src/parsers/types.ts @@ -14,8 +14,19 @@ export type ParseSuccess = { export type ParseFailure = { tag: 'failure' error: string + path: ParseFailurePathSegment[] } +export type ParseFailurePathSegment = + | { + tag: 'object' + key: string + } + | { + tag: 'array' + index: number + } + export type ParseResult = ParseSuccess | ParseFailure export const success = (value: T): ParseSuccess => ({ @@ -26,6 +37,16 @@ export const success = (value: T): ParseSuccess => ({ export const failure = (error: string): ParseFailure => ({ tag: 'failure', error, + path: [], +}) + +export const propagateFailure = ( + failureRes: ParseFailure, + pathSegment: ParseFailurePathSegment, +): ParseFailure => ({ + tag: 'failure', + error: failureRes.error, + path: [pathSegment, ...failureRes.path], }) export type Parser = (data: unknown) => ParseResult diff --git a/packages/pure-parse/tsconfig.json b/packages/pure-parse/tsconfig.json index dd640fa..a7a6026 100644 --- a/packages/pure-parse/tsconfig.json +++ b/packages/pure-parse/tsconfig.json @@ -19,7 +19,8 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "noImplicitAny": true }, "include": ["src"] }