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/
|
/tmp/
|
||||||
log/
|
log/
|
||||||
.vscode
|
.vscode
|
||||||
|
/bin
|
||||||
|
!/bin/.gitkeep
|
||||||
|
!/bin/console
|
||||||
|
!/bin/setup
|
||||||
|
|
|
@ -5,6 +5,7 @@ module Dry
|
||||||
# Common API for building types and composition
|
# Common API for building types and composition
|
||||||
#
|
#
|
||||||
# @api public
|
# @api public
|
||||||
|
# rubocop:disable Metrics/ModuleLength
|
||||||
module Builder
|
module Builder
|
||||||
include Dry::Core::Constants
|
include Dry::Core::Constants
|
||||||
|
|
||||||
|
@ -30,8 +31,18 @@ module Dry
|
||||||
#
|
#
|
||||||
# @api private
|
# @api private
|
||||||
def |(other)
|
def |(other)
|
||||||
klass = constrained? && other.constrained? ? Sum::Constrained : Sum
|
compose(other, Sum)
|
||||||
klass.new(self, other)
|
end
|
||||||
|
|
||||||
|
# Compose two types into an Intersection type
|
||||||
|
#
|
||||||
|
# @param [Type] other
|
||||||
|
#
|
||||||
|
# @return [Intersection, Intersection::Constrained]
|
||||||
|
#
|
||||||
|
# @api private
|
||||||
|
def &(other)
|
||||||
|
compose(other, Intersection)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Turn a type into an optional type
|
# Turn a type into an optional type
|
||||||
|
@ -179,6 +190,21 @@ module Dry
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
# rubocop:enable Metrics/ModuleLength
|
||||||
end
|
end
|
||||||
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,
|
Default::Callable => :visit_default,
|
||||||
Sum => :visit_sum,
|
Sum => :visit_sum,
|
||||||
Sum::Constrained => :visit_sum,
|
Sum::Constrained => :visit_sum,
|
||||||
|
Intersection => :visit_intersection,
|
||||||
|
Intersection::Constrained => :visit_intersection,
|
||||||
Any.class => :visit_any
|
Any.class => :visit_any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -189,6 +191,45 @@ module Dry
|
||||||
end
|
end
|
||||||
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)
|
def visit_enum(enum)
|
||||||
visit(enum.type) do |type|
|
visit(enum.type) do |type|
|
||||||
options = enum.options.dup
|
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