Skip to content

Commit

Permalink
Schema: Extend Support for Array filters, closes #4269 (#4273)
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Jan 16, 2025
1 parent fcf8edc commit a8b0ddb
Show file tree
Hide file tree
Showing 13 changed files with 691 additions and 163 deletions.
7 changes: 7 additions & 0 deletions .changeset/large-owls-kneel.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions .changeset/lucky-hornets-guess.md
Original file line number Diff line number Diff line change
@@ -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))
```
76 changes: 75 additions & 1 deletion packages/effect/dtslint/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1549,6 +1549,9 @@ S.mutable(S.Union(S.Struct({ a: S.Number }), S.Array(S.String)))
// $ExpectType mutable<filter<Schema<readonly string[], readonly string[], never>>>
S.mutable(S.Array(S.String).pipe(S.maxItems(2)))

// $ExpectType mutable<filter<Schema<readonly [string, ...string[]], readonly [string, ...string[]], never>>>
S.mutable(S.NonEmptyArray(S.String).pipe(S.maxItems(2)))

// $ExpectType Schema<string[], string[], never>
S.asSchema(S.mutable(S.suspend(() => S.Array(S.String))))

Expand Down Expand Up @@ -2801,11 +2804,82 @@ S.Array(S.String).pipe(S.minItems(2))
S.Array(S.String).pipe(S.minItems(2)).from

// $ExpectType Schema<readonly string[], readonly string[], never>
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<Schema<readonly string[], readonly string[], never>>
S.Array(S.String).pipe(S.minItems(1), S.maxItems(2))

// ---------------------------------------------
// minItems (NonEmptyArray)
// ---------------------------------------------

// $ExpectType Schema<readonly [string, ...string[]], readonly [string, ...string[]], never>
S.asSchema(S.NonEmptyArray(S.String).pipe(S.minItems(2)))

// $ExpectType filter<Schema<readonly [string, ...string[]], readonly [string, ...string[]], never>>
S.NonEmptyArray(S.String).pipe(S.minItems(2))

// $ExpectType Schema<readonly [string, ...string[]], readonly [string, ...string[]], never>
S.NonEmptyArray(S.String).pipe(S.minItems(2)).from

// ---------------------------------------------
// maxItems (Array)
// ---------------------------------------------

// $ExpectType Schema<readonly string[], readonly string[], never>
S.asSchema(S.Array(S.String).pipe(S.maxItems(2)))

// $ExpectType filter<Schema<readonly string[], readonly string[], never>>
S.Array(S.String).pipe(S.maxItems(2))

// $ExpectType Schema<readonly string[], readonly string[], never>
S.Array(S.String).pipe(S.maxItems(2)).from

// $ExpectType Schema<readonly string[], readonly string[], never>
S.asSchema(S.Array(S.String).pipe(S.maxItems(2), S.minItems(1)))

// $ExpectType filter<Schema<readonly string[], readonly string[], never>>
S.Array(S.String).pipe(S.maxItems(2), S.minItems(1))

// ---------------------------------------------
// maxItems (NonEmptyArray)
// ---------------------------------------------

// $ExpectType Schema<readonly [string, ...string[]], readonly [string, ...string[]], never>
S.asSchema(S.NonEmptyArray(S.String).pipe(S.maxItems(2)))

// $ExpectType filter<Schema<readonly [string, ...string[]], readonly [string, ...string[]], never>>
S.NonEmptyArray(S.String).pipe(S.maxItems(2))

// $ExpectType Schema<readonly [string, ...string[]], readonly [string, ...string[]], never>
S.NonEmptyArray(S.String).pipe(S.maxItems(2)).from

// ---------------------------------------------
// itemsCount (Array)
// ---------------------------------------------

// $ExpectType Schema<readonly string[], readonly string[], never>
S.asSchema(S.Array(S.String).pipe(S.itemsCount(2)))

// $ExpectType filter<Schema<readonly string[], readonly string[], never>>
S.Array(S.String).pipe(S.itemsCount(2))

// $ExpectType Schema<readonly string[], readonly string[], never>
S.Array(S.String).pipe(S.itemsCount(2)).from

// ---------------------------------------------
// itemsCount (NonEmptyArray)
// ---------------------------------------------

// $ExpectType Schema<readonly [string, ...string[]], readonly [string, ...string[]], never>
S.asSchema(S.NonEmptyArray(S.String).pipe(S.itemsCount(2)))

// $ExpectType filter<Schema<readonly [string, ...string[]], readonly [string, ...string[]], never>>
S.NonEmptyArray(S.String).pipe(S.itemsCount(2))

// $ExpectType Schema<readonly [string, ...string[]], readonly [string, ...string[]], never>
S.NonEmptyArray(S.String).pipe(S.itemsCount(2)).from

// ---------------------------------------------
// TemplateLiteralParser
// ---------------------------------------------
Expand Down
98 changes: 84 additions & 14 deletions packages/effect/src/Arbitrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -276,6 +277,11 @@ const makeArrayConfig = (options: {

type Config = StringConstraints | NumberConstraints | BigIntConstraints | DateConstraints | ArrayConfig

const arbitraryMemoMap = globalValue(
Symbol.for("effect/Arbitrary/arbitraryMemoMap"),
() => new WeakMap<AST.AST, LazyArbitrary<any>>()
)

const go = (
ast: AST.AST,
ctx: ArbitraryGenerationContext,
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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<A> => 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 }))
})
Expand All @@ -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<any> = (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,
Expand Down Expand Up @@ -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<A | null>
const schema = S.Array(
S.NullOr(S.suspend((): S.Schema<A> => 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
Expand Down Expand Up @@ -660,20 +731,19 @@ const getSuspendedArray = (
depthIdentifier: string,
maxDepth: number,
item: FastCheck.Arbitrary<any>,
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)
)
}

Expand Down
4 changes: 2 additions & 2 deletions packages/effect/src/ParseResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,11 +761,11 @@ interface Parser {
}

const decodeMemoMap = globalValue(
Symbol.for("effect/Schema/Parser/decodeMemoMap"),
Symbol.for("effect/ParseResult/decodeMemoMap"),
() => new WeakMap<AST.AST, Parser>()
)
const encodeMemoMap = globalValue(
Symbol.for("effect/Schema/Parser/encodeMemoMap"),
Symbol.for("effect/ParseResult/encodeMemoMap"),
() => new WeakMap<AST.AST, Parser>()
)

Expand Down
52 changes: 33 additions & 19 deletions packages/effect/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6095,11 +6095,11 @@ export type MinItemsSchemaId = typeof MinItemsSchemaId
* @category ReadonlyArray filters
* @since 3.10.0
*/
export const minItems = <A>(
export const minItems = <A extends ReadonlyArray<any>>(
n: number,
annotations?: Annotations.Filter<ReadonlyArray<A>>
annotations?: Annotations.Filter<A>
) =>
<I, R>(self: Schema<ReadonlyArray<A>, I, R>): filter<Schema<ReadonlyArray<A>, I, R>> => {
<I, R>(self: Schema<A, I, R>): filter<Schema<A, I, R>> => {
const minItems = Math.floor(n)
if (minItems < 1) {
throw new Error(
Expand Down Expand Up @@ -6137,21 +6137,28 @@ export type MaxItemsSchemaId = typeof MaxItemsSchemaId
* @category ReadonlyArray filters
* @since 3.10.0
*/
export const maxItems = <A>(
export const maxItems = <A extends ReadonlyArray<any>>(
n: number,
annotations?: Annotations.Filter<ReadonlyArray<A>>
annotations?: Annotations.Filter<A>
) =>
<I, R>(self: Schema<ReadonlyArray<A>, I, R>): filter<Schema<ReadonlyArray<A>, I, R>> =>
self.pipe(
filter((a) => a.length <= n, {
<I, R>(self: Schema<A, I, R>): filter<Schema<A, I, R>> => {
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
Expand All @@ -6169,21 +6176,28 @@ export type ItemsCountSchemaId = typeof ItemsCountSchemaId
* @category ReadonlyArray filters
* @since 3.10.0
*/
export const itemsCount = <A>(
export const itemsCount = <A extends ReadonlyArray<any>>(
n: number,
annotations?: Annotations.Filter<ReadonlyArray<A>>
annotations?: Annotations.Filter<A>
) =>
<I, R>(self: Schema<ReadonlyArray<A>, I, R>): filter<Schema<ReadonlyArray<A>, I, R>> =>
self.pipe(
filter((a) => a.length === n, {
<I, R>(self: Schema<A, I, R>): filter<Schema<A, I, R>> => {
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
Expand Down
Loading

0 comments on commit a8b0ddb

Please sign in to comment.