Skip to content

Commit

Permalink
Stable filters now generate multiple errors when 'errors = all', clos… (
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Sep 19, 2024
1 parent 1742945 commit e6440a7
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 19 deletions.
43 changes: 43 additions & 0 deletions .changeset/chilly-ducks-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@effect/schema": patch
---

Stable filters such as `minItems`, `maxItems`, and `itemsCount` now generate multiple errors when the 'errors' option is set to 'all', closes #3633

**Example:**

```ts
import { ArrayFormatter, Schema } from "@effect/schema"

const schema = Schema.Struct({
tags: Schema.Array(Schema.String.pipe(Schema.minLength(2))).pipe(
Schema.minItems(3)
)
})

const invalidData = { tags: ["AB", "B"] }

const either = Schema.decodeUnknownEither(schema, { errors: "all" })(
invalidData
)
if (either._tag === "Left") {
console.log(ArrayFormatter.formatErrorSync(either.left))
/*
Output:
[
{
_tag: 'Type',
path: [ 'tags', 1 ],
message: 'Expected a string at least 2 character(s) long, actual "B"'
},
{
_tag: 'Type',
path: [ 'tags' ],
message: 'Expected an array of at least 3 items, actual ["AB","B"]'
}
]
*/
}
```

Previously, only the issue related to the `[ 'tags', 1 ]` path was reported.
19 changes: 13 additions & 6 deletions packages/schema/src/AST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,13 @@ export type SurrogateAnnotation = AST
/** @internal */
export const StableFilterAnnotationId = Symbol.for("@effect/schema/annotation/StableFilter")

/** @internal */
/**
* A stable filter consistently applies fixed validation rules, such as
* 'minItems', 'maxItems', and 'itemsCount', to ensure array length complies
* with set criteria regardless of the input data's content.
*
* @internal
*/
export type StableFilterAnnotation = boolean

/**
Expand Down Expand Up @@ -390,6 +396,10 @@ export const getSurrogateAnnotation = getAnnotation<SurrogateAnnotation>(Surroga

const getStableFilterAnnotation = getAnnotation<StableFilterAnnotation>(StableFilterAnnotationId)

/** @internal */
export const hasStableFilter = (annotated: Annotated) =>
Option.exists(getStableFilterAnnotation(annotated), (b) => b === true)

const JSONIdentifierAnnotationId = Symbol.for("@effect/schema/annotation/JSONIdentifier")

/** @internal */
Expand Down Expand Up @@ -2552,11 +2562,8 @@ const encodedAST_ = (ast: AST, isBound: boolean): AST => {
if (from === ast.from) {
return ast
}
if (!isTransformation(ast.from)) {
const annotations = getStableFilterAnnotation(ast)
if (Option.isSome(annotations) && annotations.value === true) {
return new Refinement(from, ast.filter)
}
if (!isTransformation(ast.from) && hasStableFilter(ast)) {
return new Refinement(from, ast.filter)
}
}
return from
Expand Down
37 changes: 24 additions & 13 deletions packages/schema/src/ParseResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,23 +808,34 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => {
case "Refinement": {
if (isDecoding) {
const from = goMemo(ast.from, true)
return (i, options) =>
handleForbidden(
flatMap(
mapError(from(i, options), (e) => new Refinement(ast, i, "From", e)),
(a) =>
Option.match(
ast.filter(a, options ?? AST.defaultParseOption, ast),
return (i, options) => {
options = options ?? AST.defaultParseOption
const allErrors = options?.errors === "all"
const result = flatMap(
orElse(from(i, options), (ef) => {
const issue = new Refinement(ast, i, "From", ef)
if (allErrors && AST.hasStableFilter(ast)) {
return Option.match(
ast.filter(i, options, ast),
{
onNone: () => Either.right(a),
onSome: (e) => Either.left(new Refinement(ast, i, "Predicate", e))
onNone: () => Either.left<ParseIssue>(issue),
onSome: (ep) => Either.left(new Composite(ast, i, [issue, new Refinement(ast, i, "Predicate", ep)]))
}
)
),
ast,
i,
options
}
return Either.left(issue)
}),
(a) =>
Option.match(
ast.filter(a, options, ast),
{
onNone: () => Either.right(a),
onSome: (ep) => Either.left(new Refinement(ast, i, "Predicate", ep))
}
)
)
return handleForbidden(result, ast, i, options)
}
} else {
const from = goMemo(AST.typeAST(ast), true)
const to = goMemo(dropRightRefinement(ast.from), false)
Expand Down
37 changes: 37 additions & 0 deletions packages/schema/test/Schema/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,41 @@ describe("filter", () => {
)
})
})

it("stable filters (such as `minItems`, `maxItems`, and `itemsCount`) should generate multiple errors when the 'errors' option is set to 'all'", async () => {
const schema = S.Struct({
tags: S.Array(S.String.pipe(S.minLength(2))).pipe(S.minItems(3))
})
await Util.expectDecodeUnknownFailure(
schema,
{ tags: ["AB", "B"] },
`{ readonly tags: an array of at least 3 items }
└─ ["tags"]
└─ an array of at least 3 items
├─ an array of at least 3 items
│ └─ From side refinement failure
│ └─ ReadonlyArray<a string at least 2 character(s) long>
│ └─ [1]
│ └─ a string at least 2 character(s) long
│ └─ Predicate refinement failure
│ └─ Expected a string at least 2 character(s) long, actual "B"
└─ an array of at least 3 items
└─ Predicate refinement failure
└─ Expected an array of at least 3 items, actual ["AB","B"]`,
Util.allErrors
)
await Util.expectDecodeUnknownFailure(
schema,
{ tags: ["AB", "B"] },
`{ readonly tags: an array of at least 3 items }
└─ ["tags"]
└─ an array of at least 3 items
└─ From side refinement failure
└─ ReadonlyArray<a string at least 2 character(s) long>
└─ [1]
└─ a string at least 2 character(s) long
└─ Predicate refinement failure
└─ Expected a string at least 2 character(s) long, actual "B"`
)
})
})

0 comments on commit e6440a7

Please sign in to comment.