From cb376ef3f617b5cb80891d2c7eddf9db0f978f4e Mon Sep 17 00:00:00 2001 From: Brandon Weaver Date: Fri, 26 Sep 2025 18:13:51 -0700 Subject: [PATCH 1/2] Add support for intersection types in schema definitions - Add visit_intersection methods to PredicateInferrer::Compiler and PrimitiveInferrer::Compiler - Handle intersection types in extract_type_spec with AND logic (similar to sum types with OR logic) - Add comprehensive tests for intersection types including DSL usage - Update CHANGELOG.md Fixes #494 --- CHANGELOG.md | 3 + lib/dry/schema/macros/dsl.rb | 4 ++ lib/dry/schema/predicate_inferrer.rb | 11 ++- lib/dry/schema/primitive_inferrer.rb | 8 ++- .../schema/intersection_types_spec.rb | 72 +++++++++++++++++++ 5 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 spec/integration/schema/intersection_types_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 751830dc..3201890f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve ## [Unreleased] +### Fixed + +- Support for intersection types (created with `&` operator) in schema definitions (fixes #494) (@baweaver) ## [1.14.1] - 2025-03-03 diff --git a/lib/dry/schema/macros/dsl.rb b/lib/dry/schema/macros/dsl.rb index 37ff42a4..f0f3fec8 100644 --- a/lib/dry/schema/macros/dsl.rb +++ b/lib/dry/schema/macros/dsl.rb @@ -234,6 +234,10 @@ def extract_type_spec(args, nullable: false, set_type: true) type_rule = [type_spec.left, type_spec.right].map { |ts| new(klass: Core, chain: false).value(ts) }.reduce(:|) + elsif type_spec.is_a?(Dry::Types::Intersection) && set_type + type_rule = [type_spec.left, type_spec.right].map { |ts| + new(klass: Core, chain: false).value(ts) + }.reduce(:&) else type_predicates = predicate_inferrer[resolved_type] diff --git a/lib/dry/schema/predicate_inferrer.rb b/lib/dry/schema/predicate_inferrer.rb index 8518c10c..8b456aee 100644 --- a/lib/dry/schema/predicate_inferrer.rb +++ b/lib/dry/schema/predicate_inferrer.rb @@ -4,7 +4,16 @@ module Dry module Schema # @api private class PredicateInferrer < ::Dry::Types::PredicateInferrer - Compiler = ::Class.new(superclass::Compiler) + Compiler = ::Class.new(superclass::Compiler) do + # @api private + def visit_intersection(node) + left_node, right_node, = node + left = visit(left_node) + right = visit(right_node) + + [left, right].flatten.compact + end + end def initialize(registry = PredicateRegistry.new) super diff --git a/lib/dry/schema/primitive_inferrer.rb b/lib/dry/schema/primitive_inferrer.rb index daa4b8d2..2d5d7928 100644 --- a/lib/dry/schema/primitive_inferrer.rb +++ b/lib/dry/schema/primitive_inferrer.rb @@ -4,7 +4,13 @@ module Dry module Schema # @api private class PrimitiveInferrer < ::Dry::Types::PrimitiveInferrer - Compiler = ::Class.new(superclass::Compiler) + Compiler = ::Class.new(superclass::Compiler) do + # @api private + def visit_intersection(node) + left, right = node + [visit(left), visit(right)].flatten(1) + end + end def initialize super diff --git a/spec/integration/schema/intersection_types_spec.rb b/spec/integration/schema/intersection_types_spec.rb new file mode 100644 index 00000000..d86fad7f --- /dev/null +++ b/spec/integration/schema/intersection_types_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +RSpec.describe "Intersection types" do + context "with hash schemas" do + let(:schema) do + Dry::Schema.Params do + required(:body).value( + Types::Hash.schema(a: Types::String) & + (Types::Hash.schema(b: Types::String) | Types::Hash.schema(c: Types::String)) + ) + end + end + + it "validates intersection of hash schemas successfully" do + result = schema.call(body: {a: "test", b: "value"}) + + expect(result).to be_success + expect(result.to_h).to eq(body: {a: "test", b: "value"}) + end + + it "validates intersection with alternative branch" do + result = schema.call(body: {a: "test", c: "value"}) + + expect(result).to be_success + expect(result.to_h).to eq(body: {a: "test", c: "value"}) + end + + it "fails when intersection requirements not met" do + result = schema.call(body: {b: "value"}) + + expect(result).to be_failure + expect(result.errors.to_h).to eq(body: {a: ["is missing"]}) + end + end + + context "with simple type intersection" do + let(:schema) do + Dry::Schema.Params do + required(:value).value(Types::String & Types::Params::String) + end + end + + it "validates simple intersection types" do + result = schema.call(value: "test") + + expect(result).to be_success + expect(result.to_h).to eq(value: "test") + end + end + + context "with DSL predicates and intersection" do + let(:schema) do + Dry::Schema.Params do + required(:name).value(Types::String & Types::Params::String) { filled? & min_size?(2) } + end + end + + it "combines type intersection with predicate rules" do + result = schema.call(name: "John") + + expect(result).to be_success + expect(result.to_h).to eq(name: "John") + end + + it "fails when predicate rules not met" do + result = schema.call(name: "J") + + expect(result).to be_failure + expect(result.errors.to_h).to eq(name: ["size cannot be less than 2"]) + end + end +end From 358afbe2948aec4241d58aba4b80795c61d1a722 Mon Sep 17 00:00:00 2001 From: Brandon Weaver Date: Sat, 11 Oct 2025 13:46:01 -0700 Subject: [PATCH 2/2] Fix Rubocop issues: trailing whitespace and empty lines --- dry-schema.gemspec | 1 - spec/integration/schema/intersection_types_spec.rb | 14 +++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/dry-schema.gemspec b/dry-schema.gemspec index b2c53f2c..2d7aae36 100644 --- a/dry-schema.gemspec +++ b/dry-schema.gemspec @@ -47,4 +47,3 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rspec" spec.add_development_dependency "yard" end - diff --git a/spec/integration/schema/intersection_types_spec.rb b/spec/integration/schema/intersection_types_spec.rb index d86fad7f..f1dcbb88 100644 --- a/spec/integration/schema/intersection_types_spec.rb +++ b/spec/integration/schema/intersection_types_spec.rb @@ -5,7 +5,7 @@ let(:schema) do Dry::Schema.Params do required(:body).value( - Types::Hash.schema(a: Types::String) & + Types::Hash.schema(a: Types::String) & (Types::Hash.schema(b: Types::String) | Types::Hash.schema(c: Types::String)) ) end @@ -13,21 +13,21 @@ it "validates intersection of hash schemas successfully" do result = schema.call(body: {a: "test", b: "value"}) - + expect(result).to be_success expect(result.to_h).to eq(body: {a: "test", b: "value"}) end it "validates intersection with alternative branch" do result = schema.call(body: {a: "test", c: "value"}) - + expect(result).to be_success expect(result.to_h).to eq(body: {a: "test", c: "value"}) end it "fails when intersection requirements not met" do result = schema.call(body: {b: "value"}) - + expect(result).to be_failure expect(result.errors.to_h).to eq(body: {a: ["is missing"]}) end @@ -42,7 +42,7 @@ it "validates simple intersection types" do result = schema.call(value: "test") - + expect(result).to be_success expect(result.to_h).to eq(value: "test") end @@ -57,14 +57,14 @@ it "combines type intersection with predicate rules" do result = schema.call(name: "John") - + expect(result).to be_success expect(result.to_h).to eq(name: "John") end it "fails when predicate rules not met" do result = schema.call(name: "J") - + expect(result).to be_failure expect(result.errors.to_h).to eq(name: ["size cannot be less than 2"]) end