diff --git a/.changeset/large-owls-kneel.md b/.changeset/large-owls-kneel.md new file mode 100644 index 0000000000..cd460b858f --- /dev/null +++ b/.changeset/large-owls-kneel.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +Arbitrary: Fix bug adjusting array constraints for schemas with fixed and rest elements + +This fix ensures that when a schema includes both fixed elements and a rest element, the constraints for the array are correctly adjusted. The adjustment now subtracts the number of values generated by the fixed elements from the overall constraints. diff --git a/.changeset/lucky-hornets-guess.md b/.changeset/lucky-hornets-guess.md new file mode 100644 index 0000000000..9cb45214c3 --- /dev/null +++ b/.changeset/lucky-hornets-guess.md @@ -0,0 +1,16 @@ +--- +"effect": patch +--- + +Schema: Extend Support for Array filters, closes #4269. + +Added support for `minItems`, `maxItems`, and `itemsCount` to all schemas where `A` extends `ReadonlyArray`, including `NonEmptyArray`. + +**Example** + +```ts +import { Schema } from "effect" + +// Previously, this would have caused an error +const schema = Schema.NonEmptyArray(Schema.String).pipe(Schema.maxItems(2)) +``` diff --git a/packages/effect/dtslint/Schema.ts b/packages/effect/dtslint/Schema.ts index ad1f0daf72..a66868fe96 100644 --- a/packages/effect/dtslint/Schema.ts +++ b/packages/effect/dtslint/Schema.ts @@ -1549,6 +1549,9 @@ S.mutable(S.Union(S.Struct({ a: S.Number }), S.Array(S.String))) // $ExpectType mutable>> S.mutable(S.Array(S.String).pipe(S.maxItems(2))) +// $ExpectType mutable>> +S.mutable(S.NonEmptyArray(S.String).pipe(S.maxItems(2))) + // $ExpectType Schema S.asSchema(S.mutable(S.suspend(() => S.Array(S.String)))) @@ -2801,11 +2804,82 @@ S.Array(S.String).pipe(S.minItems(2)) S.Array(S.String).pipe(S.minItems(2)).from // $ExpectType Schema -S.asSchema(S.Array(S.String).pipe(S.minItems(2), S.maxItems(3))) +S.asSchema(S.Array(S.String).pipe(S.minItems(1), S.maxItems(2))) // $ExpectType filter> S.Array(S.String).pipe(S.minItems(1), S.maxItems(2)) +// --------------------------------------------- +// minItems (NonEmptyArray) +// --------------------------------------------- + +// $ExpectType Schema +S.asSchema(S.NonEmptyArray(S.String).pipe(S.minItems(2))) + +// $ExpectType filter> +S.NonEmptyArray(S.String).pipe(S.minItems(2)) + +// $ExpectType Schema +S.NonEmptyArray(S.String).pipe(S.minItems(2)).from + +// --------------------------------------------- +// maxItems (Array) +// --------------------------------------------- + +// $ExpectType Schema +S.asSchema(S.Array(S.String).pipe(S.maxItems(2))) + +// $ExpectType filter> +S.Array(S.String).pipe(S.maxItems(2)) + +// $ExpectType Schema +S.Array(S.String).pipe(S.maxItems(2)).from + +// $ExpectType Schema +S.asSchema(S.Array(S.String).pipe(S.maxItems(2), S.minItems(1))) + +// $ExpectType filter> +S.Array(S.String).pipe(S.maxItems(2), S.minItems(1)) + +// --------------------------------------------- +// maxItems (NonEmptyArray) +// --------------------------------------------- + +// $ExpectType Schema +S.asSchema(S.NonEmptyArray(S.String).pipe(S.maxItems(2))) + +// $ExpectType filter> +S.NonEmptyArray(S.String).pipe(S.maxItems(2)) + +// $ExpectType Schema +S.NonEmptyArray(S.String).pipe(S.maxItems(2)).from + +// --------------------------------------------- +// itemsCount (Array) +// --------------------------------------------- + +// $ExpectType Schema +S.asSchema(S.Array(S.String).pipe(S.itemsCount(2))) + +// $ExpectType filter> +S.Array(S.String).pipe(S.itemsCount(2)) + +// $ExpectType Schema +S.Array(S.String).pipe(S.itemsCount(2)).from + +// --------------------------------------------- +// itemsCount (NonEmptyArray) +// --------------------------------------------- + +// $ExpectType Schema +S.asSchema(S.NonEmptyArray(S.String).pipe(S.itemsCount(2))) + +// $ExpectType filter> +S.NonEmptyArray(S.String).pipe(S.itemsCount(2)) + +// $ExpectType Schema +S.NonEmptyArray(S.String).pipe(S.itemsCount(2)).from + // --------------------------------------------- // TemplateLiteralParser // --------------------------------------------- diff --git a/packages/effect/src/Arbitrary.ts b/packages/effect/src/Arbitrary.ts index 8a2417f931..4b76b62b3f 100644 --- a/packages/effect/src/Arbitrary.ts +++ b/packages/effect/src/Arbitrary.ts @@ -4,6 +4,7 @@ import * as Arr from "./Array.js" import * as FastCheck from "./FastCheck.js" +import { globalValue } from "./GlobalValue.js" import * as errors_ from "./internal/schema/errors.js" import * as schemaId_ from "./internal/schema/schemaId.js" import * as util_ from "./internal/schema/util.js" @@ -276,6 +277,11 @@ const makeArrayConfig = (options: { type Config = StringConstraints | NumberConstraints | BigIntConstraints | DateConstraints | ArrayConfig +const arbitraryMemoMap = globalValue( + Symbol.for("effect/Arbitrary/arbitraryMemoMap"), + () => new WeakMap>() +) + const go = ( ast: AST.AST, ctx: ArbitraryGenerationContext, @@ -311,6 +317,7 @@ const go = ( const constStringConstraints = makeStringConstraints({}) const constNumberConstraints = makeNumberConstraints({}) const constBigIntConstraints = makeBigIntConstraints({}) +const defaultSuspendedArrayConstraints: FastCheck.ArrayConstraints = { maxLength: 2 } /** @internal */ export const toOp = ( @@ -439,8 +446,22 @@ export const toOp = ( const value = indexSignatures[i][1](fc) output = output.chain((o) => { const item = fc.tuple(key, value) + /* + + `getSuspendedArray` is used to generate less key/value pairs in + the context of a recursive schema. Without it, the following schema + would generate an big amount of values possibly leading to a stack + overflow: + + ```ts + type A = { [_: string]: A } + + const schema = S.Record({ key: S.String, value: S.suspend((): S.Schema => schema) }) + ``` + + */ const arr = ctx.depthIdentifier !== undefined ? - getSuspendedArray(fc, ctx.depthIdentifier, ctx.maxDepth, item) : + getSuspendedArray(fc, ctx.depthIdentifier, ctx.maxDepth, item, defaultSuspendedArrayConstraints) : fc.array(item) return arr.map((tuples) => ({ ...Object.fromEntries(tuples), ...o })) }) @@ -454,16 +475,39 @@ export const toOp = ( return new Succeed((fc) => fc.oneof(...types.map((arb) => arb(fc)))) } case "Suspend": { + const memo = arbitraryMemoMap.get(ast) + if (memo) { + return new Succeed(memo) + } const get = util_.memoizeThunk(() => { return go(ast.f(), getSuspendedContext(ctx, ast), path) }) - return new Succeed((fc) => fc.constant(null).chain(() => get()(fc))) + const out: LazyArbitrary = (fc) => fc.constant(null).chain(() => get()(fc)) + arbitraryMemoMap.set(ast, out) + return new Succeed(out) } case "Transformation": return toOp(ast.to, ctx, path) } } +function subtractElementsLength( + constraints: FastCheck.ArrayConstraints, + elementsLength: number +): FastCheck.ArrayConstraints { + if (elementsLength === 0 || (constraints.minLength === undefined && constraints.maxLength === undefined)) { + return constraints + } + const out = { ...constraints } + if (out.minLength !== undefined) { + out.minLength = Math.max(out.minLength - elementsLength, 0) + } + if (out.maxLength !== undefined) { + out.maxLength = Math.max(out.maxLength - elementsLength, 0) + } + return out +} + const goTupleType = ( ast: AST.TupleType, ctx: ArbitraryGenerationContext, @@ -508,9 +552,36 @@ const goTupleType = ( const [head, ...tail] = rest const item = head(fc) output = output.chain((as) => { - return (ctx.depthIdentifier !== undefined - ? getSuspendedArray(fc, ctx.depthIdentifier, ctx.maxDepth, item, constraints) - : fc.array(item, constraints)).map((rest) => [...as, ...rest]) + const len = as.length + // We must adjust the constraints for the rest element + // because the elements might have generated some values + const restArrayConstraints = subtractElementsLength(constraints, len) + if (restArrayConstraints.maxLength === 0) { + return fc.constant(as) + } + /* + + `getSuspendedArray` is used to generate less values in + the context of a recursive schema. Without it, the following schema + would generate an big amount of values possibly leading to a stack + overflow: + + ```ts + type A = ReadonlyArray + + const schema = S.Array( + S.NullOr(S.suspend((): S.Schema => schema)) + ) + ``` + + */ + const arr = ctx.depthIdentifier !== undefined + ? getSuspendedArray(fc, ctx.depthIdentifier, ctx.maxDepth, item, restArrayConstraints) + : fc.array(item, restArrayConstraints) + if (len === 0) { + return arr + } + return arr.map((rest) => [...as, ...rest]) }) // --------------------------------------------- // handle post rest elements @@ -660,20 +731,19 @@ const getSuspendedArray = ( depthIdentifier: string, maxDepth: number, item: FastCheck.Arbitrary, - constraints?: FastCheck.ArrayConstraints + constraints: FastCheck.ArrayConstraints ) => { - let minLength = 1 - let maxLength = 2 - if (constraints && constraints.minLength !== undefined && constraints.minLength > minLength) { - minLength = constraints.minLength - if (minLength > maxLength) { - maxLength = minLength - } + // In the context of a recursive schema, we don't want a `maxLength` greater than 2. + // The only exception is when `minLength` is also set, in which case we set + // `maxLength` to the minimum value, which is `minLength`. + const maxLengthLimit = Math.max(2, constraints.minLength ?? 0) + if (constraints.maxLength !== undefined && constraints.maxLength > maxLengthLimit) { + constraints = { ...constraints, maxLength: maxLengthLimit } } return fc.oneof( { maxDepth, depthIdentifier }, fc.constant([]), - fc.array(item, { minLength, maxLength }) + fc.array(item, constraints) ) } diff --git a/packages/effect/src/ParseResult.ts b/packages/effect/src/ParseResult.ts index 662dad665d..f143647166 100644 --- a/packages/effect/src/ParseResult.ts +++ b/packages/effect/src/ParseResult.ts @@ -761,11 +761,11 @@ interface Parser { } const decodeMemoMap = globalValue( - Symbol.for("effect/Schema/Parser/decodeMemoMap"), + Symbol.for("effect/ParseResult/decodeMemoMap"), () => new WeakMap() ) const encodeMemoMap = globalValue( - Symbol.for("effect/Schema/Parser/encodeMemoMap"), + Symbol.for("effect/ParseResult/encodeMemoMap"), () => new WeakMap() ) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 4e39847462..7939730b2c 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -6095,11 +6095,11 @@ export type MinItemsSchemaId = typeof MinItemsSchemaId * @category ReadonlyArray filters * @since 3.10.0 */ -export const minItems = ( +export const minItems = >( n: number, - annotations?: Annotations.Filter> + annotations?: Annotations.Filter ) => -(self: Schema, I, R>): filter, I, R>> => { +(self: Schema): filter> => { const minItems = Math.floor(n) if (minItems < 1) { throw new Error( @@ -6137,21 +6137,28 @@ export type MaxItemsSchemaId = typeof MaxItemsSchemaId * @category ReadonlyArray filters * @since 3.10.0 */ -export const maxItems = ( +export const maxItems = >( n: number, - annotations?: Annotations.Filter> + annotations?: Annotations.Filter ) => -(self: Schema, I, R>): filter, I, R>> => - self.pipe( - filter((a) => a.length <= n, { +(self: Schema): filter> => { + const maxItems = Math.floor(n) + if (maxItems < 1) { + throw new Error( + errors_.getInvalidArgumentErrorMessage(`Expected an integer greater than or equal to 1, actual ${n}`) + ) + } + return self.pipe( + filter((a) => a.length <= maxItems, { schemaId: MaxItemsSchemaId, - title: `maxItems(${n})`, - description: `an array of at most ${n} item(s)`, - jsonSchema: { maxItems: n }, + title: `maxItems(${maxItems})`, + description: `an array of at most ${maxItems} item(s)`, + jsonSchema: { maxItems }, [AST.StableFilterAnnotationId]: true, ...annotations }) ) +} /** * @category schema id @@ -6169,21 +6176,28 @@ export type ItemsCountSchemaId = typeof ItemsCountSchemaId * @category ReadonlyArray filters * @since 3.10.0 */ -export const itemsCount = ( +export const itemsCount = >( n: number, - annotations?: Annotations.Filter> + annotations?: Annotations.Filter ) => -(self: Schema, I, R>): filter, I, R>> => - self.pipe( - filter((a) => a.length === n, { +(self: Schema): filter> => { + const itemsCount = Math.floor(n) + if (itemsCount < 1) { + throw new Error( + errors_.getInvalidArgumentErrorMessage(`Expected an integer greater than or equal to 1, actual ${n}`) + ) + } + return self.pipe( + filter((a) => a.length === itemsCount, { schemaId: ItemsCountSchemaId, - title: `itemsCount(${n})`, - description: `an array of exactly ${n} item(s)`, - jsonSchema: { minItems: n, maxItems: n }, + title: `itemsCount(${itemsCount})`, + description: `an array of exactly ${itemsCount} item(s)`, + jsonSchema: { minItems: itemsCount, maxItems: itemsCount }, [AST.StableFilterAnnotationId]: true, ...annotations }) ) +} /** * @category ReadonlyArray transformations diff --git a/packages/effect/test/Schema/Arbitrary/Arbitrary.test.ts b/packages/effect/test/Schema/Arbitrary/Arbitrary.test.ts index 1c1e2d75bb..aeed34ad2c 100644 --- a/packages/effect/test/Schema/Arbitrary/Arbitrary.test.ts +++ b/packages/effect/test/Schema/Arbitrary/Arbitrary.test.ts @@ -553,21 +553,39 @@ details: Generating an Arbitrary for this schema requires at least one enum`) }) describe("array filters", () => { - it("minItems", () => { + it("minItems (Array)", () => { const schema = S.Array(S.String).pipe(S.minItems(2)) expectConstraints(schema, Arbitrary.makeArrayConstraints({ minLength: 2 })) expectValidArbitrary(schema) }) - it("maxItems", () => { + it("minItems (NonEmptyArray)", () => { + const schema = S.NonEmptyArray(S.String).pipe(S.minItems(2)) + expectConstraints(schema, Arbitrary.makeArrayConstraints({ minLength: 2 })) + expectValidArbitrary(schema) + }) + + it("maxItems (Array)", () => { const schema = S.Array(S.String).pipe(S.maxItems(5)) expectConstraints(schema, Arbitrary.makeArrayConstraints({ maxLength: 5 })) expectValidArbitrary(schema) }) - it("itemsCount", () => { - const schema = S.Array(S.String).pipe(S.itemsCount(10)) - expectConstraints(schema, Arbitrary.makeArrayConstraints({ minLength: 10, maxLength: 10 })) + it("maxItems (NonEmptyArray)", () => { + const schema = S.NonEmptyArray(S.String).pipe(S.maxItems(5)) + expectConstraints(schema, Arbitrary.makeArrayConstraints({ maxLength: 5 })) + expectValidArbitrary(schema) + }) + + it("itemsCount (Array)", () => { + const schema = S.Array(S.String).pipe(S.itemsCount(3)) + expectConstraints(schema, Arbitrary.makeArrayConstraints({ minLength: 3, maxLength: 3 })) + expectValidArbitrary(schema) + }) + + it("itemsCount (NonEmptyArray)", () => { + const schema = S.NonEmptyArray(S.String).pipe(S.itemsCount(3)) + expectConstraints(schema, Arbitrary.makeArrayConstraints({ minLength: 3, maxLength: 3 })) expectValidArbitrary(schema) }) }) diff --git a/packages/effect/test/Schema/JSONSchema.test.ts b/packages/effect/test/Schema/JSONSchema.test.ts index aa73451e3d..c588b76e50 100644 --- a/packages/effect/test/Schema/JSONSchema.test.ts +++ b/packages/effect/test/Schema/JSONSchema.test.ts @@ -1068,6 +1068,81 @@ details: Cannot encode Symbol(effect/Schema/test/a) key to JSON Schema` }) describe("Refinement", () => { + it("itemsCount (Array)", () => { + expectJSONSchemaAnnotations(Schema.Array(Schema.String).pipe(Schema.itemsCount(2)), { + "type": "array", + "items": { + "type": "string" + }, + "description": "an array of exactly 2 item(s)", + "title": "itemsCount(2)", + "minItems": 2, + "maxItems": 2 + }) + }) + + it("itemsCount (NonEmptyArray)", () => { + expectJSONSchemaAnnotations(Schema.NonEmptyArray(Schema.String).pipe(Schema.itemsCount(2)), { + "type": "array", + "items": { + "type": "string" + }, + "description": "an array of exactly 2 item(s)", + "title": "itemsCount(2)", + "minItems": 2, + "maxItems": 2 + }) + }) + + it("minItems (Array)", () => { + expectJSONSchemaAnnotations(Schema.Array(Schema.String).pipe(Schema.minItems(2)), { + "type": "array", + "items": { + "type": "string" + }, + "description": "an array of at least 2 item(s)", + "title": "minItems(2)", + "minItems": 2 + }) + }) + + it("minItems (NonEmptyArray)", () => { + expectJSONSchemaAnnotations(Schema.NonEmptyArray(Schema.String).pipe(Schema.minItems(2)), { + "type": "array", + "items": { + "type": "string" + }, + "description": "an array of at least 2 item(s)", + "title": "minItems(2)", + "minItems": 2 + }) + }) + + it("maxItems (Array)", () => { + expectJSONSchemaAnnotations(Schema.Array(Schema.String).pipe(Schema.maxItems(2)), { + "type": "array", + "items": { + "type": "string" + }, + "description": "an array of at most 2 item(s)", + "title": "maxItems(2)", + "maxItems": 2 + }) + }) + + it("maxItems (NonEmptyArray)", () => { + expectJSONSchemaAnnotations(Schema.NonEmptyArray(Schema.String).pipe(Schema.maxItems(2)), { + "type": "array", + "items": { + "type": "string" + }, + "description": "an array of at most 2 item(s)", + "title": "maxItems(2)", + "minItems": 1, + "maxItems": 2 + }) + }) + it("minLength", () => { expectJSONSchemaAnnotations(Schema.String.pipe(Schema.minLength(1)), { "type": "string", diff --git a/packages/effect/test/Schema/Schema/Array/itemsCount.test.ts b/packages/effect/test/Schema/Schema/Array/itemsCount.test.ts index 14a046a412..133fd5a9ef 100644 --- a/packages/effect/test/Schema/Schema/Array/itemsCount.test.ts +++ b/packages/effect/test/Schema/Schema/Array/itemsCount.test.ts @@ -1,31 +1,70 @@ import * as S from "effect/Schema" import * as Util from "effect/test/Schema/TestUtils" -import { describe, it } from "vitest" +import { describe, expect, it } from "vitest" describe("itemsCount", () => { - const schema = S.Array(S.Number).pipe(S.itemsCount(2)) - it("decoding", async () => { - await Util.expectDecodeUnknownFailure( - schema, - [], - `itemsCount(2) + it("should throw for invalid argument", () => { + expect(() => S.Array(S.Number).pipe(S.itemsCount(-1))).toThrowError( + new Error(`Invalid Argument +details: Expected an integer greater than or equal to 1, actual -1`) + ) + }) + + describe("decoding", () => { + it("Array", async () => { + const schema = S.Array(S.Number).pipe(S.itemsCount(2)) + + await Util.expectDecodeUnknownSuccess(schema, [1, 2]) + await Util.expectDecodeUnknownFailure( + schema, + [], + `itemsCount(2) └─ Predicate refinement failure └─ Expected an array of exactly 2 item(s), actual []` - ) - await Util.expectDecodeUnknownFailure( - schema, - [1], - `itemsCount(2) + ) + await Util.expectDecodeUnknownFailure( + schema, + [1], + `itemsCount(2) └─ Predicate refinement failure └─ Expected an array of exactly 2 item(s), actual [1]` - ) - await Util.expectDecodeUnknownSuccess(schema, [1, 2]) - await Util.expectDecodeUnknownFailure( - schema, - [1, 2, 3], - `itemsCount(2) + ) + await Util.expectDecodeUnknownFailure( + schema, + [1, 2, 3], + `itemsCount(2) └─ Predicate refinement failure └─ Expected an array of exactly 2 item(s), actual [1,2,3]` - ) + ) + }) + + it("NonEmptyArray", async () => { + const schema = S.NonEmptyArray(S.Number).pipe(S.itemsCount(2)) + + await Util.expectDecodeUnknownSuccess(schema, [1, 2]) + await Util.expectDecodeUnknownFailure( + schema, + [], + `itemsCount(2) +└─ From side refinement failure + └─ readonly [number, ...number[]] + └─ [0] + └─ is missing` + ) + await Util.expectDecodeUnknownFailure( + schema, + [1], + `itemsCount(2) +└─ Predicate refinement failure + └─ Expected an array of exactly 2 item(s), actual [1]` + ) + await Util.expectDecodeUnknownFailure( + schema, + [1, 2, 3], + `itemsCount(2) +└─ Predicate refinement failure + └─ Expected an array of exactly 2 item(s), actual [1,2,3]` + ) + }) }) }) diff --git a/packages/effect/test/Schema/Schema/Array/maxItems.test.ts b/packages/effect/test/Schema/Schema/Array/maxItems.test.ts index f283e379f9..53e9dfebbb 100644 --- a/packages/effect/test/Schema/Schema/Array/maxItems.test.ts +++ b/packages/effect/test/Schema/Schema/Array/maxItems.test.ts @@ -1,19 +1,52 @@ import * as S from "effect/Schema" import * as Util from "effect/test/Schema/TestUtils" -import { describe, it } from "vitest" +import { describe, expect, it } from "vitest" describe("maxItems", () => { - const schema = S.Array(S.Number).pipe(S.maxItems(2)) - it("decoding", async () => { - await Util.expectDecodeUnknownFailure( - schema, - [1, 2, 3], - `maxItems(2) + it("should throw for invalid argument", () => { + expect(() => S.Array(S.Number).pipe(S.maxItems(-1))).toThrowError( + new Error(`Invalid Argument +details: Expected an integer greater than or equal to 1, actual -1`) + ) + }) + + describe("decoding", () => { + it("Array", async () => { + const schema = S.Array(S.Number).pipe(S.maxItems(2)) + + await Util.expectDecodeUnknownSuccess(schema, []) + await Util.expectDecodeUnknownSuccess(schema, [1]) + await Util.expectDecodeUnknownSuccess(schema, [1, 2]) + await Util.expectDecodeUnknownFailure( + schema, + [1, 2, 3], + `maxItems(2) └─ Predicate refinement failure └─ Expected an array of at most 2 item(s), actual [1,2,3]` - ) + ) + }) + + it("NonEmptyArray", async () => { + const schema = S.NonEmptyArray(S.Number).pipe(S.maxItems(2)) - await Util.expectDecodeUnknownSuccess(schema, [1]) - await Util.expectDecodeUnknownSuccess(schema, [1, 2]) + await Util.expectDecodeUnknownSuccess(schema, [1]) + await Util.expectDecodeUnknownSuccess(schema, [1, 2]) + await Util.expectDecodeUnknownFailure( + schema, + [], + `maxItems(2) +└─ From side refinement failure + └─ readonly [number, ...number[]] + └─ [0] + └─ is missing` + ) + await Util.expectDecodeUnknownFailure( + schema, + [1, 2, 3], + `maxItems(2) +└─ Predicate refinement failure + └─ Expected an array of at most 2 item(s), actual [1,2,3]` + ) + }) }) }) diff --git a/packages/effect/test/Schema/Schema/Array/minItems.test.ts b/packages/effect/test/Schema/Schema/Array/minItems.test.ts index 2bfb53c82c..f34a4d6f96 100644 --- a/packages/effect/test/Schema/Schema/Array/minItems.test.ts +++ b/packages/effect/test/Schema/Schema/Array/minItems.test.ts @@ -10,17 +10,49 @@ details: Expected an integer greater than or equal to 1, actual -1`) ) }) - it("decoding", async () => { - const schema = S.Array(S.Number).pipe(S.minItems(2)) - await Util.expectDecodeUnknownFailure( - schema, - [1], - `minItems(2) + describe("decoding", () => { + it("Array", async () => { + const schema = S.Array(S.Number).pipe(S.minItems(2)) + + await Util.expectDecodeUnknownSuccess(schema, [1, 2]) + await Util.expectDecodeUnknownSuccess(schema, [1, 2, 3]) + await Util.expectDecodeUnknownFailure( + schema, + [], + `minItems(2) +└─ Predicate refinement failure + └─ Expected an array of at least 2 item(s), actual []` + ) + await Util.expectDecodeUnknownFailure( + schema, + [1], + `minItems(2) └─ Predicate refinement failure └─ Expected an array of at least 2 item(s), actual [1]` - ) + ) + }) - await Util.expectDecodeUnknownSuccess(schema, [1, 2]) - await Util.expectDecodeUnknownSuccess(schema, [1, 2, 3]) + it("NonEmptyArray", async () => { + const schema = S.NonEmptyArray(S.Number).pipe(S.minItems(2)) + + await Util.expectDecodeUnknownSuccess(schema, [1, 2]) + await Util.expectDecodeUnknownSuccess(schema, [1, 2, 3]) + await Util.expectDecodeUnknownFailure( + schema, + [], + `minItems(2) +└─ From side refinement failure + └─ readonly [number, ...number[]] + └─ [0] + └─ is missing` + ) + await Util.expectDecodeUnknownFailure( + schema, + [1], + `minItems(2) +└─ Predicate refinement failure + └─ Expected an array of at least 2 item(s), actual [1]` + ) + }) }) }) diff --git a/packages/effect/test/Schema/Schema/encodedBoundSchema.test.ts b/packages/effect/test/Schema/Schema/encodedBoundSchema.test.ts index dd665a1163..2543628c1c 100644 --- a/packages/effect/test/Schema/Schema/encodedBoundSchema.test.ts +++ b/packages/effect/test/Schema/Schema/encodedBoundSchema.test.ts @@ -57,87 +57,173 @@ describe("encodedBoundSchema", () => { ) }) - describe("array stable filters", () => { - it("minItems", async () => { - const schema = S.Array(StringTransformation).pipe(S.minItems(2)) - const bound = S.encodedBoundSchema(schema) - - await Util.expectDecodeUnknownSuccess(bound, ["ab", "cd"]) - await Util.expectDecodeUnknownFailure( - bound, - ["a"], - `minItems(2) + describe("Stable filters", () => { + describe("Array", () => { + it("minItems", async () => { + const schema = S.Array(StringTransformation).pipe(S.minItems(2)) + const bound = S.encodedBoundSchema(schema) + + await Util.expectDecodeUnknownSuccess(bound, ["ab", "cd"]) + await Util.expectDecodeUnknownFailure( + bound, + ["a"], + `minItems(2) └─ From side refinement failure └─ ReadonlyArray └─ [0] └─ String2 └─ Predicate refinement failure └─ Expected a string at least 2 character(s) long, actual "a"` - ) - await Util.expectDecodeUnknownFailure( - bound, - ["ab"], - `minItems(2) + ) + await Util.expectDecodeUnknownFailure( + bound, + ["ab"], + `minItems(2) └─ Predicate refinement failure └─ Expected an array of at least 2 item(s), actual ["ab"]` - ) - }) + ) + }) - it("maxItems", async () => { - const schema = S.Array(StringTransformation).pipe(S.maxItems(2)) - const bound = S.encodedBoundSchema(schema) + it("maxItems", async () => { + const schema = S.Array(StringTransformation).pipe(S.maxItems(2)) + const bound = S.encodedBoundSchema(schema) - await Util.expectDecodeUnknownSuccess(bound, ["ab", "cd"]) - await Util.expectDecodeUnknownFailure( - bound, - ["a"], - `maxItems(2) + await Util.expectDecodeUnknownSuccess(bound, ["ab", "cd"]) + await Util.expectDecodeUnknownFailure( + bound, + ["a"], + `maxItems(2) └─ From side refinement failure └─ ReadonlyArray └─ [0] └─ String2 └─ Predicate refinement failure └─ Expected a string at least 2 character(s) long, actual "a"` - ) - await Util.expectDecodeUnknownFailure( - bound, - ["ab", "cd", "ef"], - `maxItems(2) + ) + await Util.expectDecodeUnknownFailure( + bound, + ["ab", "cd", "ef"], + `maxItems(2) └─ Predicate refinement failure └─ Expected an array of at most 2 item(s), actual ["ab","cd","ef"]` - ) - }) + ) + }) - it("itemsCount", async () => { - const schema = S.Array(StringTransformation).pipe(S.itemsCount(2)) - const bound = S.encodedBoundSchema(schema) + it("itemsCount", async () => { + const schema = S.Array(StringTransformation).pipe(S.itemsCount(2)) + const bound = S.encodedBoundSchema(schema) - await Util.expectDecodeUnknownSuccess(bound, ["ab", "cd"]) - await Util.expectDecodeUnknownFailure( - bound, - ["a"], - `itemsCount(2) + await Util.expectDecodeUnknownSuccess(bound, ["ab", "cd"]) + await Util.expectDecodeUnknownFailure( + bound, + ["a"], + `itemsCount(2) └─ From side refinement failure └─ ReadonlyArray └─ [0] └─ String2 └─ Predicate refinement failure └─ Expected a string at least 2 character(s) long, actual "a"` - ) - await Util.expectDecodeUnknownFailure( - bound, - ["ab"], - `itemsCount(2) + ) + await Util.expectDecodeUnknownFailure( + bound, + ["ab"], + `itemsCount(2) +└─ Predicate refinement failure + └─ Expected an array of exactly 2 item(s), actual ["ab"]` + ) + await Util.expectDecodeUnknownFailure( + bound, + ["ab", "cd", "ef"], + `itemsCount(2) +└─ Predicate refinement failure + └─ Expected an array of exactly 2 item(s), actual ["ab","cd","ef"]` + ) + }) + }) + + describe("NonEmptyArray", () => { + it("minItems", async () => { + const schema = S.NonEmptyArray(StringTransformation).pipe(S.minItems(2)) + const bound = S.encodedBoundSchema(schema) + + await Util.expectDecodeUnknownSuccess(bound, ["ab", "cd"]) + await Util.expectDecodeUnknownFailure( + bound, + ["a"], + `minItems(2) +└─ From side refinement failure + └─ readonly [String2, ...String2[]] + └─ [0] + └─ String2 + └─ Predicate refinement failure + └─ Expected a string at least 2 character(s) long, actual "a"` + ) + await Util.expectDecodeUnknownFailure( + bound, + ["ab"], + `minItems(2) +└─ Predicate refinement failure + └─ Expected an array of at least 2 item(s), actual ["ab"]` + ) + }) + + it("maxItems", async () => { + const schema = S.NonEmptyArray(StringTransformation).pipe(S.maxItems(2)) + const bound = S.encodedBoundSchema(schema) + + await Util.expectDecodeUnknownSuccess(bound, ["ab", "cd"]) + await Util.expectDecodeUnknownFailure( + bound, + ["a"], + `maxItems(2) +└─ From side refinement failure + └─ readonly [String2, ...String2[]] + └─ [0] + └─ String2 + └─ Predicate refinement failure + └─ Expected a string at least 2 character(s) long, actual "a"` + ) + await Util.expectDecodeUnknownFailure( + bound, + ["ab", "cd", "ef"], + `maxItems(2) +└─ Predicate refinement failure + └─ Expected an array of at most 2 item(s), actual ["ab","cd","ef"]` + ) + }) + + it("itemsCount", async () => { + const schema = S.NonEmptyArray(StringTransformation).pipe(S.itemsCount(2)) + const bound = S.encodedBoundSchema(schema) + + await Util.expectDecodeUnknownSuccess(bound, ["ab", "cd"]) + await Util.expectDecodeUnknownFailure( + bound, + ["a"], + `itemsCount(2) +└─ From side refinement failure + └─ readonly [String2, ...String2[]] + └─ [0] + └─ String2 + └─ Predicate refinement failure + └─ Expected a string at least 2 character(s) long, actual "a"` + ) + await Util.expectDecodeUnknownFailure( + bound, + ["ab"], + `itemsCount(2) └─ Predicate refinement failure └─ Expected an array of exactly 2 item(s), actual ["ab"]` - ) - await Util.expectDecodeUnknownFailure( - bound, - ["ab", "cd", "ef"], - `itemsCount(2) + ) + await Util.expectDecodeUnknownFailure( + bound, + ["ab", "cd", "ef"], + `itemsCount(2) └─ Predicate refinement failure └─ Expected an array of exactly 2 item(s), actual ["ab","cd","ef"]` - ) + ) + }) }) }) }) diff --git a/packages/effect/test/Schema/Schema/filter.test.ts b/packages/effect/test/Schema/Schema/filter.test.ts index a5b802d20f..58b0bf86a8 100644 --- a/packages/effect/test/Schema/Schema/filter.test.ts +++ b/packages/effect/test/Schema/Schema/filter.test.ts @@ -290,15 +290,16 @@ describe("filter", () => { }) }) - describe("Stable Filters (such as `minItems`, `maxItems`, and `itemsCount`)", () => { - it("when the 'errors' option is set to 'all', stable filters should generate multiple errors", 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: minItems(3) } + describe("Stable Filters", () => { + describe("Array", () => { + it("when the 'errors' option is set to 'all', stable filters should generate multiple errors", 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: minItems(3) } └─ ["tags"] └─ minItems(3) ├─ minItems(3) @@ -311,12 +312,12 @@ describe("filter", () => { └─ minItems(3) └─ Predicate refinement failure └─ Expected an array of at least 3 item(s), actual ["AB","B"]`, - Util.allErrors - ) - await Util.expectDecodeUnknownFailure( - schema, - { tags: ["AB", "B"] }, - `{ readonly tags: minItems(3) } + Util.allErrors + ) + await Util.expectDecodeUnknownFailure( + schema, + { tags: ["AB", "B"] }, + `{ readonly tags: minItems(3) } └─ ["tags"] └─ minItems(3) └─ From side refinement failure @@ -325,30 +326,93 @@ describe("filter", () => { └─ minLength(2) └─ Predicate refinement failure └─ Expected a string at least 2 character(s) long, actual "B"` - ) + ) + }) + + it("when the 'errors' option is set to 'all', stable filters should be applied only if the from part fails with a `Composite` issue", async () => { + await Util.expectDecodeUnknownFailure( + S.Struct({ + tags: S.Array(S.String).pipe(S.minItems(1)) + }), + {}, + `{ readonly tags: minItems(1) } +└─ ["tags"] + └─ is missing`, + Util.allErrors + ) + await Util.expectDecodeUnknownFailure( + S.Struct({ + tags: S.Array(S.String).pipe(S.minItems(1), S.maxItems(3)) + }), + {}, + `{ readonly tags: minItems(1) & maxItems(3) } +└─ ["tags"] + └─ is missing`, + Util.allErrors + ) + }) }) - it("when the 'errors' option is set to 'all', stable filters should be applied only if the from part fails with a `Composite` issue", async () => { - await Util.expectDecodeUnknownFailure( - S.Struct({ - tags: S.Array(S.String).pipe(S.minItems(1)) - }), - {}, - `{ readonly tags: minItems(1) } + describe("NonEmptyArray", () => { + it("when the 'errors' option is set to 'all', stable filters should generate multiple errors", async () => { + const schema = S.Struct({ + tags: S.NonEmptyArray(S.String.pipe(S.minLength(2))).pipe(S.minItems(3)) + }) + await Util.expectDecodeUnknownFailure( + schema, + { tags: ["AB", "B"] }, + `{ readonly tags: minItems(3) } +└─ ["tags"] + └─ minItems(3) + ├─ minItems(3) + │ └─ From side refinement failure + │ └─ readonly [minLength(2), ...minLength(2)[]] + │ └─ [1] + │ └─ minLength(2) + │ └─ Predicate refinement failure + │ └─ Expected a string at least 2 character(s) long, actual "B" + └─ minItems(3) + └─ Predicate refinement failure + └─ Expected an array of at least 3 item(s), actual ["AB","B"]`, + Util.allErrors + ) + await Util.expectDecodeUnknownFailure( + schema, + { tags: ["AB", "B"] }, + `{ readonly tags: minItems(3) } +└─ ["tags"] + └─ minItems(3) + └─ From side refinement failure + └─ readonly [minLength(2), ...minLength(2)[]] + └─ [1] + └─ minLength(2) + └─ Predicate refinement failure + └─ Expected a string at least 2 character(s) long, actual "B"` + ) + }) + + it("when the 'errors' option is set to 'all', stable filters should be applied only if the from part fails with a `Composite` issue", async () => { + await Util.expectDecodeUnknownFailure( + S.Struct({ + tags: S.NonEmptyArray(S.String).pipe(S.minItems(1)) + }), + {}, + `{ readonly tags: minItems(1) } └─ ["tags"] └─ is missing`, - Util.allErrors - ) - await Util.expectDecodeUnknownFailure( - S.Struct({ - tags: S.Array(S.String).pipe(S.minItems(1), S.maxItems(3)) - }), - {}, - `{ readonly tags: minItems(1) & maxItems(3) } + Util.allErrors + ) + await Util.expectDecodeUnknownFailure( + S.Struct({ + tags: S.NonEmptyArray(S.String).pipe(S.minItems(1), S.maxItems(3)) + }), + {}, + `{ readonly tags: minItems(1) & maxItems(3) } └─ ["tags"] └─ is missing`, - Util.allErrors - ) + Util.allErrors + ) + }) }) }) })