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:
parent
a0a4e923f6
commit
3421ddf3c0
|
@ -9,3 +9,7 @@
|
|||
/tmp/
|
||||
log/
|
||||
.vscode
|
||||
/bin
|
||||
!/bin/.gitkeep
|
||||
!/bin/console
|
||||
!/bin/setup
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue