From 3421ddf3c0ca34fec88075a0bb5b681137f888c6 Mon Sep 17 00:00:00 2001 From: Rob Hanlon Date: Thu, 7 Jul 2022 10:01:40 -0700 Subject: [PATCH] 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. --- .gitignore | 4 + lib/dry/types/builder.rb | 30 +++- lib/dry/types/intersection.rb | 199 ++++++++++++++++++++++++ lib/dry/types/printer.rb | 41 +++++ spec/dry/types/intersection_spec.rb | 233 ++++++++++++++++++++++++++++ 5 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 lib/dry/types/intersection.rb create mode 100644 spec/dry/types/intersection_spec.rb diff --git a/.gitignore b/.gitignore index 6b4af38..ef2bda4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ /tmp/ log/ .vscode +/bin +!/bin/.gitkeep +!/bin/console +!/bin/setup diff --git a/lib/dry/types/builder.rb b/lib/dry/types/builder.rb index f03088f..90b1cf9 100644 --- a/lib/dry/types/builder.rb +++ b/lib/dry/types/builder.rb @@ -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 diff --git a/lib/dry/types/intersection.rb b/lib/dry/types/intersection.rb new file mode 100644 index 0000000..77d529f --- /dev/null +++ b/lib/dry/types/intersection.rb @@ -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 diff --git a/lib/dry/types/printer.rb b/lib/dry/types/printer.rb index 3b85ed6..84c237c 100644 --- a/lib/dry/types/printer.rb +++ b/lib/dry/types/printer.rb @@ -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 diff --git a/spec/dry/types/intersection_spec.rb b/spec/dry/types/intersection_spec.rb new file mode 100644 index 0000000..cc453f6 --- /dev/null +++ b/spec/dry/types/intersection_spec.rb @@ -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("# & Nominal>]>") + 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( + "# & " \ + "Nominal & " \ + "Nominal & " \ + "Nominal