Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Schema: Extend Support for Array filters, closes #4269 #4273

Merged
merged 3 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading