From bd8045efb60b2a5bfb4f2672d9fe9384c81462b6 Mon Sep 17 00:00:00 2001 From: Youssef Gaber <1728215+Gabrola@users.noreply.github.com> Date: Mon, 25 May 2026 16:14:35 +0400 Subject: [PATCH 1/2] Fix schema arbitrary constraints for BigDecimal --- .changeset/fix-schema-arbitrary-combiners.md | 7 + packages/effect/src/Schema.ts | 81 ++++++++- .../effect/src/internal/schema/arbitrary.ts | 96 ++++++----- .../effect/test/schema/toArbitrary.test.ts | 157 +++++++++++++++++- 4 files changed, 297 insertions(+), 44 deletions(-) create mode 100644 .changeset/fix-schema-arbitrary-combiners.md diff --git a/.changeset/fix-schema-arbitrary-combiners.md b/.changeset/fix-schema-arbitrary-combiners.md new file mode 100644 index 0000000000..6cec9ade9b --- /dev/null +++ b/.changeset/fix-schema-arbitrary-combiners.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +Fix schema arbitrary constraint combiners for BigInt, Date, and BigDecimal + +Fix `Schema.isBetweenBigDecimal` arbitrary constraint derivation diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 55a24bc76a..a0ba472461 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -7191,6 +7191,10 @@ export const isBetweenBigInt = makeIsBetween({ }) }) +const nextBigDecimal = (n: BigDecimal_.BigDecimal) => BigDecimal_.make(n.value + 1n, n.scale) + +const previousBigDecimal = (n: BigDecimal_.BigDecimal) => BigDecimal_.make(n.value - 1n, n.scale) + /** * Validates that a BigDecimal is greater than the specified value (exclusive). * @@ -7199,6 +7203,13 @@ export const isBetweenBigInt = makeIsBetween({ */ export const isGreaterThanBigDecimal = makeIsGreaterThan({ order: BigDecimal_.Order, + annotate: (exclusiveMinimum) => ({ + toArbitraryConstraint: { + bigDecimal: { + min: nextBigDecimal(exclusiveMinimum) + } + } + }), formatter: (bd) => BigDecimal_.format(bd) }) @@ -7211,6 +7222,13 @@ export const isGreaterThanBigDecimal = makeIsGreaterThan({ */ export const isGreaterThanOrEqualToBigDecimal = makeIsGreaterThanOrEqualTo({ order: BigDecimal_.Order, + annotate: (minimum) => ({ + toArbitraryConstraint: { + bigDecimal: { + min: minimum + } + } + }), formatter: (bd) => BigDecimal_.format(bd) }) @@ -7222,6 +7240,13 @@ export const isGreaterThanOrEqualToBigDecimal = makeIsGreaterThanOrEqualTo({ */ export const isLessThanBigDecimal = makeIsLessThan({ order: BigDecimal_.Order, + annotate: (exclusiveMaximum) => ({ + toArbitraryConstraint: { + bigDecimal: { + max: previousBigDecimal(exclusiveMaximum) + } + } + }), formatter: (bd) => BigDecimal_.format(bd) }) @@ -7234,6 +7259,13 @@ export const isLessThanBigDecimal = makeIsLessThan({ */ export const isLessThanOrEqualToBigDecimal = makeIsLessThanOrEqualTo({ order: BigDecimal_.Order, + annotate: (maximum) => ({ + toArbitraryConstraint: { + bigDecimal: { + max: maximum + } + } + }), formatter: (bd) => BigDecimal_.format(bd) }) @@ -7250,6 +7282,14 @@ export const isLessThanOrEqualToBigDecimal = makeIsLessThanOrEqualTo({ */ export const isBetweenBigDecimal = makeIsBetween({ order: BigDecimal_.Order, + annotate: (options) => ({ + toArbitraryConstraint: { + bigDecimal: { + min: options.exclusiveMinimum ? nextBigDecimal(options.minimum) : options.minimum, + max: options.exclusiveMaximum ? previousBigDecimal(options.maximum) : options.maximum + } + } + }), formatter: (bd) => BigDecimal_.format(bd) }) @@ -10061,9 +10101,31 @@ export const BigDecimal: BigDecimal = declare( BigDecimalString, Transformation.bigDecimalFromString ), - toArbitrary: () => (fc) => - fc.tuple(fc.bigInt(), fc.integer({ min: 0, max: 20 })) - .map(([value, scale]) => BigDecimal_.make(value, scale)), + toArbitrary: () => (fc, ctx) => { + const constraints = ctx.constraints?.bigDecimal + + return fc.integer({ min: 0, max: 20 }).map( + (scale) => { + const min = Predicate.isNotUndefined(constraints?.min) ? + BigDecimal_.scale(BigDecimal_.ceil(constraints.min, scale), scale).value : + undefined + + const max = Predicate.isNotUndefined(constraints?.max) ? + BigDecimal_.scale(BigDecimal_.floor(constraints.max, scale), scale).value : + undefined + + return { min, max, scale } + } + ) + // Skip scales where the rounded bounds are too narrow and cause min > max. + .filter(({ min, max }) => min === undefined || max === undefined || min <= max) + .chain(({ min, max, scale }) => + fc.bigInt({ + ...(Predicate.isNotUndefined(min) ? { min } : undefined), + ...(Predicate.isNotUndefined(max) ? { max } : undefined) + }).map((value) => BigDecimal_.make(value, scale)) + ) + }, toFormatter: () => (bd) => BigDecimal_.format(bd), toEquivalence: () => BigDecimal_.Equivalence } @@ -13545,6 +13607,18 @@ export declare namespace Annotations { */ export interface BigIntConstraints extends FastCheck.BigIntConstraints {} + /** + * BigDecimal constraints used when deriving arbitraries for `BigDecimal` + * schemas. + * + * @category models + * @since 4.0.0 + */ + export interface BigDecimalConstraints { + readonly min?: BigDecimal_.BigDecimal + readonly max?: BigDecimal_.BigDecimal + } + /** * fast-check array constraints plus an optional comparator used when deriving * unique-array arbitraries. @@ -13575,6 +13649,7 @@ export declare namespace Annotations { export interface Constraint { readonly string?: StringConstraints | undefined readonly number?: NumberConstraints | undefined + readonly bigDecimal?: BigDecimalConstraints | undefined readonly bigint?: BigIntConstraints | undefined readonly array?: ArrayConstraints | undefined readonly date?: DateConstraints | undefined diff --git a/packages/effect/src/internal/schema/arbitrary.ts b/packages/effect/src/internal/schema/arbitrary.ts index eaf8759404..61715a2448 100644 --- a/packages/effect/src/internal/schema/arbitrary.ts +++ b/packages/effect/src/internal/schema/arbitrary.ts @@ -1,9 +1,12 @@ import * as Array from "../../Array.ts" +import * as BigDecimal from "../../BigDecimal.ts" +import * as BigInt_ from "../../BigInt.ts" import * as Boolean from "../../Boolean.ts" -import type * as Combiner from "../../Combiner.ts" +import * as Combiner from "../../Combiner.ts" import { memoize } from "../../Function.ts" import * as Number from "../../Number.ts" import * as Option from "../../Option.ts" +import * as Order from "../../Order.ts" import * as Predicate from "../../Predicate.ts" import type * as Schema from "../../Schema.ts" import * as AST from "../../SchemaAST.ts" @@ -43,58 +46,71 @@ function array(fc: typeof FastCheck, ctx: Schema.Annotations.ToArbitrary.Context return out } -const max = UndefinedOr.makeReducer(Number.ReducerMax) -const min = UndefinedOr.makeReducer(Number.ReducerMin) +const numberMax = UndefinedOr.makeReducer(Number.ReducerMax) +const numberMin = UndefinedOr.makeReducer(Number.ReducerMin) const or = UndefinedOr.makeReducer(Boolean.ReducerOr) -const concat = UndefinedOr.makeReducer(Array.makeReducerConcat()) -const combiner: Combiner.Combiner = Struct.makeCombiner({ - isInteger: or, - max: min, - maxExcluded: or, - maxLength: min, - min: max, - minExcluded: or, - minLength: max, - noDefaultInfinity: or, - noInteger: or, - noInvalidDate: or, - noNaN: or, - patterns: concat, - comparator: or -}, { - omitKeyWhen: Predicate.isUndefined -}) +type ConstraintKey = keyof Schema.Annotations.ToArbitrary.Constraint +type ConstraintFor = NonNullable +type CombinerFields = { readonly [K in keyof A]: Combiner.Combiner } + +interface AsCombiner extends Struct.Lambda { + (combiners: CombinerFields): Combiner.Combiner + readonly "~lambda.out": this["~lambda.in"] extends CombinerFields ? Combiner.Combiner : never +} -type FastCheckConstraint = - | Schema.Annotations.ToArbitrary.StringConstraints - | Schema.Annotations.ToArbitrary.NumberConstraints - | Schema.Annotations.ToArbitrary.BigIntConstraints - | Schema.Annotations.ToArbitrary.ArrayConstraints - | Schema.Annotations.ToArbitrary.DateConstraints +const constraintCombiners = Struct.map({ + string: { + maxLength: numberMin, + minLength: numberMax, + patterns: UndefinedOr.makeReducer(Array.getReadonlyReducerConcat()) as Combiner.Combiner< + Schema.Annotations.ToArbitrary.StringConstraints["patterns"] + > + }, + number: { + isInteger: or, + max: numberMin, + maxExcluded: or, + min: numberMax, + minExcluded: or, + noDefaultInfinity: or, + noInteger: or, + noNaN: or + }, + bigDecimal: { + max: UndefinedOr.makeReducer(Combiner.min(BigDecimal.Order)), + min: UndefinedOr.makeReducer(Combiner.max(BigDecimal.Order)) + }, + bigint: { + max: UndefinedOr.makeReducer(BigInt_.CombinerMin), + min: UndefinedOr.makeReducer(BigInt_.CombinerMax) + }, + array: { + comparator: UndefinedOr.makeReducer(Combiner.first()), + maxLength: numberMin, + minLength: numberMax + }, + date: { + max: UndefinedOr.makeReducer(Combiner.min(Order.Date)), + min: UndefinedOr.makeReducer(Combiner.max(Order.Date)), + noInvalidDate: or + } +}, Struct.lambda((combiners) => Struct.makeCombiner(combiners, { omitKeyWhen: Predicate.isUndefined }))) -function merge( - _tag: "string" | "number" | "bigint" | "array" | "date", +function merge( + _tag: K, constraints: Schema.Annotations.ToArbitrary.Constraint, - constraint: FastCheckConstraint + constraint: ConstraintFor ): Schema.Annotations.ToArbitrary.Constraint { const c = constraints[_tag] return { ...constraints, - [_tag]: c ? combiner.combine(c, constraint) : constraint + [_tag]: c ? constraintCombiners[_tag].combine(c, constraint) : constraint } } -const constraintsKeys = { - string: null, - number: null, - bigint: null, - array: null, - date: null -} - function isConstraintKey(key: string): key is keyof Schema.Annotations.ToArbitrary.Constraint { - return key in constraintsKeys + return key in constraintCombiners } /** @internal */ diff --git a/packages/effect/test/schema/toArbitrary.test.ts b/packages/effect/test/schema/toArbitrary.test.ts index 75051ab592..e2ccd5aef9 100644 --- a/packages/effect/test/schema/toArbitrary.test.ts +++ b/packages/effect/test/schema/toArbitrary.test.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect" +import { BigDecimal, Schema } from "effect" import * as InternalArbitrary from "effect/internal/schema/arbitrary" import { TestSchema } from "effect/testing" import { describe, it } from "vitest" @@ -608,6 +608,42 @@ describe("Arbitrary generation", () => { exclusiveMaximum: true }))) }) + + it("isGreaterThanOrEqualToBigDecimal", () => { + verifyGeneration(Schema.BigDecimal.check(Schema.isGreaterThanOrEqualToBigDecimal(BigDecimal.make(0n, 0)))) + }) + + it("isGreaterThanBigDecimal", () => { + verifyGeneration(Schema.BigDecimal.check(Schema.isGreaterThanBigDecimal(BigDecimal.make(0n, 0)))) + }) + + it("isLessThanOrEqualToBigDecimal", () => { + verifyGeneration(Schema.BigDecimal.check(Schema.isLessThanOrEqualToBigDecimal(BigDecimal.make(10n, 0)))) + }) + + it("isLessThanBigDecimal", () => { + verifyGeneration(Schema.BigDecimal.check(Schema.isLessThanBigDecimal(BigDecimal.make(10n, 0)))) + }) + + it("isBetweenBigDecimal", () => { + verifyGeneration( + Schema.BigDecimal.check( + Schema.isBetweenBigDecimal({ + minimum: BigDecimal.make(100n, 0), + maximum: BigDecimal.make(200n, 0) + }) + ) + ) + }) + + it("isGreaterThanBigDecimal + isLessThanBigDecimal", () => { + verifyGeneration( + Schema.BigDecimal.check( + Schema.isGreaterThanBigDecimal(BigDecimal.make(0n, 0)), + Schema.isLessThanBigDecimal(BigDecimal.make(10n, 0)) + ) + ) + }) }) it("Finite", () => { @@ -1011,6 +1047,23 @@ describe("Arbitrary generation", () => { }) }) + it("isGreaterThanDate & isBetweenDate", () => { + assertContext( + Schema.Date.check( + Schema.isGreaterThanDate(new Date(5)), + Schema.isBetweenDate({ minimum: new Date(5), maximum: new Date(10) }) + ), + { + constraints: { + date: { + min: new Date(6), + max: new Date(10) + } + } + } + ) + }) + it("isGreaterThanOrEqualToBigInt", () => { assertContext(Schema.BigInt.check(Schema.isGreaterThanOrEqualToBigInt(BigInt(0))), { constraints: { @@ -1081,6 +1134,108 @@ describe("Arbitrary generation", () => { ) }) + it("isGreaterThanBigInt & isBetweenBigInt", () => { + assertContext( + Schema.BigInt.check( + Schema.isGreaterThanBigInt(BigInt(0)), + Schema.isBetweenBigInt({ + minimum: BigInt(0), + maximum: BigInt(10) + }) + ), + { + constraints: { + bigint: { + min: BigInt(1), + max: BigInt(10) + } + } + } + ) + }) + + it("isGreaterThanOrEqualToBigDecimal", () => { + const min = BigDecimal.make(0n, 0) + + assertContext(Schema.BigDecimal.check(Schema.isGreaterThanOrEqualToBigDecimal(min)), { + constraints: { + bigDecimal: { + min + } + } + }) + }) + + it("isGreaterThanBigDecimal", () => { + assertContext(Schema.BigDecimal.check(Schema.isGreaterThanBigDecimal(BigDecimal.make(0n, 0))), { + constraints: { + bigDecimal: { + min: BigDecimal.make(1n, 0) + } + } + }) + }) + + it("isLessThanOrEqualToBigDecimal", () => { + const max = BigDecimal.make(10n, 0) + + assertContext(Schema.BigDecimal.check(Schema.isLessThanOrEqualToBigDecimal(max)), { + constraints: { + bigDecimal: { + max + } + } + }) + }) + + it("isLessThanBigDecimal", () => { + assertContext(Schema.BigDecimal.check(Schema.isLessThanBigDecimal(BigDecimal.make(10n, 0))), { + constraints: { + bigDecimal: { + max: BigDecimal.make(9n, 0) + } + } + }) + }) + + it("isBetweenBigDecimal", () => { + const min = BigDecimal.make(0n, 0) + const max = BigDecimal.make(10n, 0) + + assertContext( + Schema.BigDecimal.check( + Schema.isBetweenBigDecimal({ minimum: min, maximum: max }) + ), + { + constraints: { + bigDecimal: { + min, + max + } + } + } + ) + }) + + it("isBetweenBigDecimal with exclusive bounds", () => { + assertContext( + Schema.BigDecimal.check(Schema.isBetweenBigDecimal({ + minimum: BigDecimal.make(0n, 0), + maximum: BigDecimal.make(10n, 0), + exclusiveMinimum: true, + exclusiveMaximum: true + })), + { + constraints: { + bigDecimal: { + min: BigDecimal.make(1n, 0), + max: BigDecimal.make(9n, 0) + } + } + } + ) + }) + it("UniqueArray", () => { const comparator = Schema.toEquivalence(Schema.String) assertContext(Schema.UniqueArray(Schema.String), { From 1e05bf293dd59efd89c8f8884cc4c82550c21cbb Mon Sep 17 00:00:00 2001 From: Youssef Gaber <1728215+Gabrola@users.noreply.github.com> Date: Mon, 25 May 2026 16:29:20 +0400 Subject: [PATCH 2/2] more accurate handling of exclusive bounds for BigDecimal --- packages/effect/src/Schema.ts | 22 +++++++++------- .../effect/src/internal/schema/arbitrary.ts | 4 ++- .../effect/test/schema/toArbitrary.test.ts | 26 +++++++++++++------ 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index a0ba472461..ca4a700910 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -7191,10 +7191,6 @@ export const isBetweenBigInt = makeIsBetween({ }) }) -const nextBigDecimal = (n: BigDecimal_.BigDecimal) => BigDecimal_.make(n.value + 1n, n.scale) - -const previousBigDecimal = (n: BigDecimal_.BigDecimal) => BigDecimal_.make(n.value - 1n, n.scale) - /** * Validates that a BigDecimal is greater than the specified value (exclusive). * @@ -7206,7 +7202,8 @@ export const isGreaterThanBigDecimal = makeIsGreaterThan({ annotate: (exclusiveMinimum) => ({ toArbitraryConstraint: { bigDecimal: { - min: nextBigDecimal(exclusiveMinimum) + min: exclusiveMinimum, + minExcluded: true } } }), @@ -7243,7 +7240,8 @@ export const isLessThanBigDecimal = makeIsLessThan({ annotate: (exclusiveMaximum) => ({ toArbitraryConstraint: { bigDecimal: { - max: previousBigDecimal(exclusiveMaximum) + max: exclusiveMaximum, + maxExcluded: true } } }), @@ -7285,8 +7283,10 @@ export const isBetweenBigDecimal = makeIsBetween({ annotate: (options) => ({ toArbitraryConstraint: { bigDecimal: { - min: options.exclusiveMinimum ? nextBigDecimal(options.minimum) : options.minimum, - max: options.exclusiveMaximum ? previousBigDecimal(options.maximum) : options.maximum + min: options.minimum, + max: options.maximum, + ...(options.exclusiveMinimum && { minExcluded: true }), + ...(options.exclusiveMaximum && { maxExcluded: true }) } } }), @@ -10121,8 +10121,8 @@ export const BigDecimal: BigDecimal = declare( .filter(({ min, max }) => min === undefined || max === undefined || min <= max) .chain(({ min, max, scale }) => fc.bigInt({ - ...(Predicate.isNotUndefined(min) ? { min } : undefined), - ...(Predicate.isNotUndefined(max) ? { max } : undefined) + ...(Predicate.isNotUndefined(min) ? { min: min + (constraints?.minExcluded ? 1n : 0n) } : undefined), + ...(Predicate.isNotUndefined(max) ? { max: max - (constraints?.maxExcluded ? 1n : 0n) } : undefined) }).map((value) => BigDecimal_.make(value, scale)) ) }, @@ -13617,6 +13617,8 @@ export declare namespace Annotations { export interface BigDecimalConstraints { readonly min?: BigDecimal_.BigDecimal readonly max?: BigDecimal_.BigDecimal + readonly minExcluded?: boolean + readonly maxExcluded?: boolean } /** diff --git a/packages/effect/src/internal/schema/arbitrary.ts b/packages/effect/src/internal/schema/arbitrary.ts index 61715a2448..f65d4f5bb1 100644 --- a/packages/effect/src/internal/schema/arbitrary.ts +++ b/packages/effect/src/internal/schema/arbitrary.ts @@ -79,7 +79,9 @@ const constraintCombiners = Struct.map({ }, bigDecimal: { max: UndefinedOr.makeReducer(Combiner.min(BigDecimal.Order)), - min: UndefinedOr.makeReducer(Combiner.max(BigDecimal.Order)) + maxExcluded: or, + min: UndefinedOr.makeReducer(Combiner.max(BigDecimal.Order)), + minExcluded: or }, bigint: { max: UndefinedOr.makeReducer(BigInt_.CombinerMin), diff --git a/packages/effect/test/schema/toArbitrary.test.ts b/packages/effect/test/schema/toArbitrary.test.ts index e2ccd5aef9..4c7cec7d19 100644 --- a/packages/effect/test/schema/toArbitrary.test.ts +++ b/packages/effect/test/schema/toArbitrary.test.ts @@ -1167,10 +1167,13 @@ describe("Arbitrary generation", () => { }) it("isGreaterThanBigDecimal", () => { - assertContext(Schema.BigDecimal.check(Schema.isGreaterThanBigDecimal(BigDecimal.make(0n, 0))), { + const min = BigDecimal.make(0n, 0) + + assertContext(Schema.BigDecimal.check(Schema.isGreaterThanBigDecimal(min)), { constraints: { bigDecimal: { - min: BigDecimal.make(1n, 0) + min, + minExcluded: true } } }) @@ -1189,10 +1192,13 @@ describe("Arbitrary generation", () => { }) it("isLessThanBigDecimal", () => { - assertContext(Schema.BigDecimal.check(Schema.isLessThanBigDecimal(BigDecimal.make(10n, 0))), { + const max = BigDecimal.make(10n, 0) + + assertContext(Schema.BigDecimal.check(Schema.isLessThanBigDecimal(max)), { constraints: { bigDecimal: { - max: BigDecimal.make(9n, 0) + max, + maxExcluded: true } } }) @@ -1218,18 +1224,22 @@ describe("Arbitrary generation", () => { }) it("isBetweenBigDecimal with exclusive bounds", () => { + const min = BigDecimal.make(0n, 0) + const max = BigDecimal.make(10n, 0) assertContext( Schema.BigDecimal.check(Schema.isBetweenBigDecimal({ - minimum: BigDecimal.make(0n, 0), - maximum: BigDecimal.make(10n, 0), + minimum: min, + maximum: max, exclusiveMinimum: true, exclusiveMaximum: true })), { constraints: { bigDecimal: { - min: BigDecimal.make(1n, 0), - max: BigDecimal.make(9n, 0) + min, + max, + minExcluded: true, + maxExcluded: true } } }