Intersection (&) support

dry-schema and dry-logic both support &. Adding support for & in
dry-types allows for a more expressive type system...

```
module Types
  Callable = Interface(:call)
  Procable = Interface(:to_proc)
  Function = Callable & Procable
end
```

...and greatly simplifies value coercion in dry-schema, where a custom
types merge attempts to simulate an intersection type.
This commit is contained in:
Rob Hanlon 2022-07-07 10:01:40 -07:00
parent a0a4e923f6
commit 3421ddf3c0
No known key found for this signature in database
GPG Key ID: ABB8430EF3F61ABF
5 changed files with 505 additions and 2 deletions

4
.gitignore vendored
View File

@ -9,3 +9,7 @@
/tmp/
log/
.vscode
/bin
!/bin/.gitkeep
!/bin/console
!/bin/setup

View File

@ -5,6 +5,7 @@ module Dry
# Common API for building types and composition
#
# @api public
# rubocop:disable Metrics/ModuleLength
module Builder
include Dry::Core::Constants
@ -30,8 +31,18 @@ module Dry
#
# @api private
def |(other)
klass = constrained? && other.constrained? ? Sum::Constrained : Sum
klass.new(self, other)
compose(other, Sum)
end
# Compose two types into an Intersection type
#
# @param [Type] other
#
# @return [Intersection, Intersection::Constrained]
#
# @api private
def &(other)
compose(other, Intersection)
end
# Turn a type into an optional type
@ -179,6 +190,21 @@ module Dry
end
end
end
private
# @api private
def compose(other, composition_class)
klass =
if constrained? && other.constrained?
composition_class::Constrained
else
composition_class
end
klass.new(self, other)
end
end
# rubocop:enable Metrics/ModuleLength
end
end

View File

@ -0,0 +1,199 @@
# frozen_string_literal: true
require "dry/core/equalizer"
require "dry/types/options"
require "dry/types/meta"
module Dry
module Types
# Intersection type
#
# @api public
class Intersection
include Type
include Builder
include Options
include Meta
include Printable
include Dry::Equalizer(:left, :right, :options, :meta, inspect: false, immutable: true)
# @return [Type]
attr_reader :left
# @return [Type]
attr_reader :right
# @api private
class Constrained < Intersection
# @return [Dry::Logic::Operations::And]
def rule
left.rule & right.rule
end
# @return [true]
def constrained?
true
end
end
# @param [Type] left
# @param [Type] right
# @param [Hash] options
#
# @api private
def initialize(left, right, **options)
super
@left, @right = left, right
freeze
end
# @return [String]
#
# @api public
def name
[left, right].map(&:name).join(" & ")
end
# @return [false]
#
# @api public
def default?
false
end
# @return [false]
#
# @api public
def constrained?
false
end
# @return [Boolean]
#
# @api public
def optional?
false
end
# @param [Object] input
#
# @return [Object]
#
# @api private
def call_unsafe(input)
merge_results(left.call_unsafe(input), right.call_unsafe(input))
end
# @param [Object] input
#
# @return [Object]
#
# @api private
def call_safe(input, &block)
try_sides(input, &block).input
end
# @param [Object] input
#
# @api public
def try(input)
try_sides(input) do |failure|
if block_given?
yield(failure)
else
failure
end
end
end
# @api private
def success(input)
result = try(input)
if result.success?
result
else
raise ArgumentError, "Invalid success value '#{input}' for #{inspect}"
end
end
# @api private
def failure(input, _error = nil)
Result::Failure.new(input, try(input).error)
end
# @param [Object] value
#
# @return [Boolean]
#
# @api private
def primitive?(value)
left.primitive?(value) && right.primitive?(value)
end
# @see Nominal#to_ast
#
# @api public
def to_ast(meta: true)
[:intersection,
[left.to_ast(meta: meta), right.to_ast(meta: meta), meta ? self.meta : EMPTY_HASH]]
end
# Wrap the type with a proc
#
# @return [Proc]
#
# @api public
def to_proc
proc { |value| self.(value) }
end
private
# @api private
def try_sides(input, &block)
results = []
[left, right].each do |side|
result = try_side(side, input, &block)
return result if result.failure?
results << result
end
Result::Success.new(merge_results(*results.map(&:input)))
end
# @api private
def try_side(side, input)
failure = nil
result = side.try(input) do |f|
failure = f
yield(f)
end
if result.is_a?(Result)
result
elsif failure
Result::Failure.new(result, failure)
else
Result::Success.new(result)
end
end
# @api private
def merge_results(left_result, right_result)
case left_result
when ::Array
left_result
.zip(right_result)
.map { |lhs, rhs| merge_results(lhs, rhs) }
when ::Hash
left_result.merge(right_result)
else
left_result
end
end
end
end
end

View File

@ -23,6 +23,8 @@ module Dry
Default::Callable => :visit_default,
Sum => :visit_sum,
Sum::Constrained => :visit_sum,
Intersection => :visit_intersection,
Intersection::Constrained => :visit_intersection,
Any.class => :visit_any
}
@ -189,6 +191,45 @@ module Dry
end
end
def visit_intersection(intersection)
visit_intersection_constructors(intersection) do |constructors|
visit_options(intersection.options, intersection.meta) do |opts|
yield "Intersection<#{constructors}#{opts}>"
end
end
end
def visit_intersection_constructors(intersection)
case intersection.left
when Intersection
visit_intersection_constructors(intersection.left) do |left|
case intersection.right
when Intersection
visit_intersection_constructors(intersection.right) do |right|
yield "#{left} & #{right}"
end
else
visit(intersection.right) do |right|
yield "#{left} & #{right}"
end
end
end
else
visit(intersection.left) do |left|
case intersection.right
when Intersection
visit_intersection_constructors(intersection.right) do |right|
yield "#{left} & #{right}"
end
else
visit(intersection.right) do |right|
yield "#{left} & #{right}"
end
end
end
end
end
def visit_enum(enum)
visit(enum.type) do |type|
options = enum.options.dup

View File

@ -0,0 +1,233 @@
# frozen_string_literal: true
RSpec.describe Dry::Types::Intersection do
let(:t) { Dry.Types }
let(:callable_type) { t.Interface(:call) }
let(:procable_type) { t.Interface(:to_proc) }
let(:function_type) { callable_type & procable_type }
describe "common nominal behavior" do
subject(:type) { function_type }
it_behaves_like "Dry::Types::Nominal#meta"
it_behaves_like "Dry::Types::Nominal without primitive"
it_behaves_like "a composable constructor"
it "is frozen" do
expect(type).to be_frozen
end
end
it_behaves_like "a constrained type" do
let(:type) { function_type }
it_behaves_like "a composable constructor"
end
describe "#[]" do
it "works with two pass-through types" do
type = t::Nominal::Hash & t.Hash(foo: t::Nominal::Integer)
expect(type[{foo: ""}]).to eq({foo: ""})
expect(type[{foo: 312}]).to eq({foo: 312})
end
it "works with two strict types" do
type = t::Strict::Hash & t.Hash(foo: t::Strict::Integer)
expect(type[{foo: 312}]).to eq({foo: 312})
expect { type[312] }.to raise_error(Dry::Types::CoercionError)
end
it "is aliased as #call" do
type = t::Nominal::Hash & t.Hash(foo: t::Nominal::Integer)
expect(type.call({foo: ""})).to eq({foo: ""})
expect(type.call({foo: 312})).to eq({foo: 312})
end
it "works with two constructor & constrained types" do
left = t.Array(t::Strict::Hash)
right = t.Array(t.Hash(foo: t::Nominal::Integer))
type = left & right
expect(type[[{foo: 312}]]).to eql([{foo: 312}])
end
it "works with two complex types with constraints" do
type =
t
.Array(t.Array(t::Coercible::String.constrained(min_size: 5)).constrained(size: 2))
.constrained(min_size: 1) &
t
.Array(t.Array(t::Coercible::String.constrained(format: /foo/)).constrained(size: 2))
.constrained(min_size: 2)
expect(type.([%w[foofoo barfoo], %w[bazfoo fooqux]])).to eql(
[%w[foofoo barfoo], %w[bazfoo fooqux]]
)
expect { type[:oops] }.to raise_error(Dry::Types::ConstraintError, /:oops/)
expect { type[[]] }.to raise_error(Dry::Types::ConstraintError, /\[\]/)
expect { type.([%i[foo]]) }.to raise_error(Dry::Types::ConstraintError, /\[:foo\]/)
expect { type.([[1], [2]]) }.to raise_error(Dry::Types::ConstraintError, /2, \[1\]/)
expect { type.([%w[foofoo barfoo], %w[bazfoo foo]]) }.to raise_error(
Dry::Types::ConstraintError,
/min_size\?\(5, "foo"\)/
)
end
end
describe "#try" do
subject(:type) { function_type }
it "returns success when value passed" do
expect(type.try(-> {})).to be_success
end
it "returns failure when value did not pass" do
expect(type.try(:foo)).to be_failure
end
end
describe "#success" do
subject(:type) { function_type }
it "returns success when value passed" do
expect(type.success(-> {})).to be_success
end
it "raises ArgumentError when non of the types have a valid input" do
expect { type.success("foo") }.to raise_error(ArgumentError, /Invalid success value 'foo' /)
end
end
describe "#failure" do
subject(:type) { Dry::Types["integer"] & Dry::Types["string"] }
it "returns failure when invalid value is passed" do
expect(type.failure(true)).to be_failure
end
end
describe "#===" do
subject(:type) { function_type }
it "returns boolean" do
expect(type.===(-> {})).to eql(true)
expect(type.===(nil)).to eql(false) # rubocop:disable Style/NilComparison
end
context "in case statement" do
let(:value) do
case -> {}
when type
"accepted"
else
"invalid"
end
end
it "returns correct value" do
expect(value).to eql("accepted")
end
end
end
describe "#default" do
it "returns a default value intersection type" do
type = (t::Nominal::Nil & t::Nominal::Nil).default("foo")
expect(type.call).to eql("foo")
end
end
describe "#constructor" do
let(:type) do
(t::Nominal::String & t::Nominal::Nil).constructor do |input|
input ? "#{input} world" : input
end
end
it "returns the correct value" do
expect(type.call("hello")).to eql("hello world")
expect(type.call(nil)).to eql(nil)
expect(type.call(10)).to eql("10 world")
end
it "returns if value is valid" do
expect(type.valid?("hello")).to eql(true)
expect(type.valid?(nil)).to eql(true)
expect(type.valid?(10)).to eql(true)
end
end
describe "#rule" do
let(:type) { function_type }
it "returns a rule" do
rule = type.rule
expect(rule.(-> {})).to be_success
expect(rule.(nil)).to be_failure
end
end
describe "#to_s" do
context "shallow intersection" do
let(:type) { t::Nominal::String & t::Nominal::Integer }
it "returns string representation of the type" do
expect(type.to_s).to eql("#<Dry::Types[Intersection<Nominal<String> & Nominal<Integer>>]>")
end
end
context "intersection tree" do
let(:type) { t::Nominal::String & t::Nominal::Integer & t::Nominal::Date & t::Nominal::Time }
it "returns string representation of the type" do
expect(type.to_s).to eql(
"#<Dry::Types[Intersection<" \
"Nominal<String> & " \
"Nominal<Integer> & " \
"Nominal<Date> & " \
"Nominal<Time>" \
">]>"
)
end
end
end
context "with map type" do
let(:map_type) { t::Nominal::Hash.map(t::Nominal::Symbol, t::Nominal::String) }
let(:schema_type) { t.Hash(foo: t::Strict::String) }
subject(:type) { map_type & schema_type }
it "rejects invalid input" do
expect(type.valid?({foo: 1, bar: 1})).to be false
expect { type[{foo: 1, bar: 1}] }.to raise_error(Dry::Types::SchemaError)
end
end
describe "#meta" do
context "optional types" do
let(:meta) { {foo: :bar} }
subject(:type) { Dry::Types["string"].optional }
it "uses meta from the right branch" do
expect(type.meta(meta).meta).to eql(meta)
expect(type.meta(meta).right.meta).to eql(meta)
end
end
end
end