diff --git a/lib/dry/types/builder.rb b/lib/dry/types/builder.rb index e647d713..a170b6a7 100644 --- a/lib/dry/types/builder.rb +++ b/lib/dry/types/builder.rb @@ -56,6 +56,28 @@ def >(other) compose(other, Implication) end + # Compose two types into an Transition type + # + # @param [Type] right resulting type + # + # @return [Transition, Transition::Constrained] + # + # @api public + def >=(right) + compose(right, Transition) + end + + # Compose two types into an Transition type + # + # @param [Type] left transitive type + # + # @return [Transition, Transition::Constrained] + # + # @api public + def <=(left) + left >= self + end + # Turn a type into an optional type # # @return [Sum] diff --git a/lib/dry/types/compiler.rb b/lib/dry/types/compiler.rb index 39a254ef..2ae01d4d 100644 --- a/lib/dry/types/compiler.rb +++ b/lib/dry/types/compiler.rb @@ -113,6 +113,21 @@ def visit_any(meta) registry["any"].meta(meta) end + def visit_intersection(node) + *types, meta = node + types.map { |type| visit(type) }.reduce(:&).meta(meta) + end + + def visit_implication(node) + *types, meta = node + types.map { |type| visit(type) }.reduce(:>).meta(meta) + end + + def visit_transition(node) + *types, meta = node + types.map { |type| visit(type) }.reduce(:>=).meta(meta) + end + def compile_fn(fn) type, *node = fn diff --git a/lib/dry/types/printer.rb b/lib/dry/types/printer.rb index f9706de2..c4005e9e 100644 --- a/lib/dry/types/printer.rb +++ b/lib/dry/types/printer.rb @@ -30,7 +30,9 @@ class Printer Intersection, Intersection::Constrained, Implication, - Implication::Constrained + Implication::Constrained, + Transition, + Transition::Constrained ] => :visit_composition, Any.class => :visit_any }.flat_map { |k, v| Array(k).map { |kk| [kk, v] } }.to_h diff --git a/lib/dry/types/transition.rb b/lib/dry/types/transition.rb new file mode 100644 index 00000000..c98bf092 --- /dev/null +++ b/lib/dry/types/transition.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Dry + module Types + # # Transition type + # + # It is a composition type, where the left type is an intermediary one and + # the right type is a resulting. + # + # It differs from an {Implication} by an input for the right type: instead of + # bypassing the original input, it will pass a coerced result of the left type. + # The same effect is possible with a {Constructor} but {Transition} allows to + # keep a transitive object as type instead of function. + # + # When the left type if failed it bypasses input value to the right type. + # + # @example Usage + # coercible_proc = Dry::Types::Nominal.new(Proc).constructor(&:to_proc) + # ceorcible_sym = Dry::Types['coercible.symbol'] + # + # # the left-hand type is resulting when is built with {Builder#<=} + # (coercible_proc <= coercible_sym)['example'] # => # + # + # # the right-hand type is resulting when is built with {Builder#>=} + # (coercible_sym >= coercible_proc)['example'] # => # + # + # @api public + class Transition + include Composition + + def self.operator + :>= + end + + class Constrained < Transition + def rule + right.rule | left.rule + end + + def pristine + self.class.new(left, right.pristine, **@options) + end + end + + # @param [Object] input + # + # @return [Object] + # + # @api private + def call_unsafe(input) + left_result = left.try(input) + if left_result.success? + right.call_unsafe(left_result.input) + else + right.call_unsafe(input) + end + end + + # @param [Object] input + # + # @return [Object] + # + # @api private + def call_safe(input, &block) + left_result = left.try(input) + if left_result.success? + right.call_safe(left_result.input, &block) + else + right.call_safe(input, &block) + end + end + + # @param [Object] input + # + # @api public + def try(input) + left_result = left.try(input) + + if left_result.success? + right.try(left_result.input) + else + right.try(input) + end + end + + # @param [Object] value + # + # @return [Boolean] + # + # @api private + def primitive?(value) + left_result = left.try(input) + + if left.primitive?(value) + right.primitive?(left_result.input) + else + right.primitive?(value) + end + end + + # #meta always delegates to the right branch of Transition type + # + # @see [Meta#meta] + # + # @api public + def meta(data = Undefined) + if Undefined.equal?(data) + right.meta + else + self.class.new(left, right.meta(data), **@options) + end + end + + # @param [Hash] options + # + # @return [Constrained,Sum] + # + # @see Builder#constrained + # + # @api public + def constrained(options) + self.class.new(left, right.constrained(options), **@options) + end + + # Resets meta in the right type + # + # @return [Dry::Types::Type] + # + # @api public + def pristine + self.class.new(left, right.pristine, **@options) + end + end + end +end \ No newline at end of file diff --git a/spec/dry/types/compiler_spec.rb b/spec/dry/types/compiler_spec.rb index 48cb126d..5c54c2b2 100644 --- a/spec/dry/types/compiler_spec.rb +++ b/spec/dry/types/compiler_spec.rb @@ -377,4 +377,46 @@ expect(type).to eql(source) end end + + context "composites" do + let(:strict_nil_ast) do + [:constrained, + [[:nominal, [NilClass, {}]], + [:predicate, [:type?, [[:type, NilClass], [:input, Undefined]]]]]] + end + + let(:strict_integer_ast) do + [:constrained, + [[:nominal, [Integer, {}]], + [:predicate, [:type?, [[:type, Integer], [:input, Undefined]]]]]] + end + + let(:any_numeric_ast) do + [:constrained, [[:any, {}], [:predicate, [:type?, [[:type, Numeric], [:input, Undefined]]]]]] + end + + it 'builds a sum' do + ast = [:sum, [strict_nil_ast, strict_integer_ast, {}]] + type = compiler.(ast) + expect(type).to eql(Dry::Types['integer'].optional) + end + + it 'builds an implication' do + ast = [:implication, [any_numeric_ast, strict_integer_ast, {}]] + type = compiler.(ast) + expect(type).to eql(Dry::Types['any'].constrained(type: Numeric) > Dry::Types['integer']) + end + + it 'builds an intersection' do + ast = [:intersection, [any_numeric_ast, strict_integer_ast, {}]] + type = compiler.(ast) + expect(type).to eql(Dry::Types['any'].constrained(type: Numeric) & Dry::Types['integer']) + end + + it 'builds a transition' do + ast = [:transition, [any_numeric_ast, strict_integer_ast, {}]] + type = compiler.(ast) + expect(type).to eql(Dry::Types['any'].constrained(type: Numeric) >= Dry::Types['integer']) + end + end end diff --git a/spec/dry/types/implication_spec.rb b/spec/dry/types/implication_spec.rb index 5170949e..0a80b443 100644 --- a/spec/dry/types/implication_spec.rb +++ b/spec/dry/types/implication_spec.rb @@ -216,15 +216,14 @@ end describe "#meta" do - context "optional types" do - let(:meta) { {foo: :bar} } + let(:meta) { {foo: :bar} } - subject(:type) { t::Nominal::String.optional } + subject(:type) { t::Nominal::Hash > t.Hash(foo: t::Nominal::Integer) } - 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 + it "has no special meta handling" do + expect(type.meta(meta).meta).to eql(meta) + expect(type.meta(meta).left.meta).to eql({}) + expect(type.meta(meta).right.meta).to eql({}) end end end diff --git a/spec/dry/types/intersection_spec.rb b/spec/dry/types/intersection_spec.rb index 10ceba2b..9c44faf2 100644 --- a/spec/dry/types/intersection_spec.rb +++ b/spec/dry/types/intersection_spec.rb @@ -231,15 +231,14 @@ end describe "#meta" do - context "optional types" do - let(:meta) { {foo: :bar} } + let(:meta) { {foo: :bar} } - subject(:type) { Dry::Types["string"].optional } + subject(:type) { t::Nominal::Hash & t.Hash(foo: t::Nominal::Integer) } - 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 + it "has no special meta handling" do + expect(type.meta(meta).meta).to eql(meta) + expect(type.meta(meta).left.meta).to eql({}) + expect(type.meta(meta).right.meta).to eql({}) end end end diff --git a/spec/dry/types/transition_spec.rb b/spec/dry/types/transition_spec.rb new file mode 100644 index 00000000..f64c31f4 --- /dev/null +++ b/spec/dry/types/transition_spec.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +RSpec.describe Dry::Types::Transition do + let(:t) { Dry.Types } + + let(:role_id_schema) { t.Hash(id: t::Strict::String) } + let(:role_title_schema) { t.Hash(title: t::Strict::String) } + let(:role_schema) { role_id_schema >= role_title_schema } + + let(:nonzero_transition) { t::Coercible::String.constrained(format: /\d+/) >= t::Coercible::Integer.constrained(type: Integer, gt: 0) } + + describe "common nominal behavior" do + subject(:type) { t.Constructor(Proc, &:to_proc) >= t.Interface(:call) } + + 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 + + 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[{foo: "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[[["hello there", "my friend"]]] }.to raise_error(Dry::Types::ConstraintError, /min_size\?\(2/) + + expect { type[[%w[hello there], ["my good", "friend"]]] }.to raise_error(Dry::Types::ConstraintError, %r{/foo/}) + end + end + + describe "#try" do + subject(:type) { nonzero_transition } + + it "returns success when value passed" do + expect(type.try('1')).to be_success + end + + it "returns failure when value did not pass" do + expect(type.try('false')).to be_failure + end + end + + describe "#success" do + subject(:type) { nonzero_transition } + + it "returns success when value passed" do + expect(type.success('1')).to be_success + end + + it "raises ArgumentError when non of the types have a valid input" do + expect { type.success('false') }.to raise_error(ArgumentError, /Invalid success value 'false' /) + end + end + + describe "#failure" do + subject(:type) { nonzero_transition } + + it "returns failure when invalid value is passed" do + expect(type.failure('false')).to be_failure + end + end + + describe "#===" do + subject(:type) { nonzero_transition } + + it "returns boolean" do + expect(type.===('1')).to eql(true) + expect(type.===('false')).to eql(false) + end + + context "in case statement" do + let(:value) do + case :'1' + 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 implication 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) { nonzero_transition } + + it "returns a rule" do + rule = type.rule + + expect(rule.(:"1")).to be_success + expect(rule.("1")).to be_success + expect(rule.(1)).to be_success + expect(rule.('false')).to be_failure + end + end + + describe "#to_s" do + context "shallow transition" do + let(:type) { t::Nominal::String >= t::Nominal::Integer } + + it "returns string representation of the type" do + expect(type.to_s).to eql("# >= Nominal>]>") + end + end + + context "constrained" do + let(:type) { t::Nominal::String.constrained(format: /foo/) >= t::Nominal::String.constrained(min_size: 4) } + + it "returns string representation of the type" do + expect(type.to_s).to eql( + "# rule=[format?(/foo/)]> >= "\ + "Constrained rule=[min_size?(4)]>>]>" + ) + end + end + + context "transition 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( + "# >= " \ + "Nominal >= " \ + "Nominal >= " \ + "Nominal