Optimize builder

This commit is contained in:
Linus Oleander 2021-03-21 11:45:39 +01:00
parent 6189429086
commit bc9248d9c7
15 changed files with 203 additions and 290 deletions

1
.gitignore vendored
View File

@ -6,4 +6,3 @@ tmp/
pkg/
.idea/
Gemfile.lock

32
benchmarks/builder.rb Normal file
View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
require "benchmark/ips"
require "dry/logic"
require "dry/logic/builder"
def regular
user_present = Dry::Logic::Rule::Predicate.new(Dry::Logic::Predicates[:key?]).curry(:user)
min_age = Dry::Logic::Rule::Predicate.new(Dry::Logic::Predicates[:gt?]).curry(18)
has_min_age = Dry::Logic::Operations::Key.new(min_age, name: %i[user age])
user_present & has_min_age
end
def builder
Dry::Logic::Builder.call do
key?(:user) & key(name: %i[user age]) { gt?(18) }
end
end
Benchmark.ips do |x|
x.report("builder") do
builder.(user: {age: 19})
builder.(user: {age: 18})
end
x.report("regular") do
regular.(user: {age: 18})
regular.(user: {age: 19})
end
x.compare!
end

View File

@ -1,49 +0,0 @@
# frozen_string_literal: true
require_relative "build/base"
require_relative "build/predicate"
require_relative "build/operation"
module Dry
module Logic
module Build
#
# Predicate and operation builder
#
# @block [Proc] Block containing the logic
# @return [Dry::Logic::Result]
# @throws [NameError] For undefined predicates and operations
# @example Check if value is zero, without using ==
# is_zero = Dry::Logic::Build.call do
# lt?(0) ^ gt?(0)
# end
#
# is_zero.call(1).success? # => false
# is_zero.call(0).success? # => true
# is_zero.call(-1).success? # => false
#
def call(&block)
begin
Operation.call(&block)
rescue NameError
Predicate.call(&block)
end
rescue NameError => e
raise NameError, "#{e.message} or #{Module.nesting.first}::Operation"
end
module_function :call
#
# @example Check for odd numbers using {Build#build}
# extend Dry::Logic::Build
#
# is_odd = build { odd? }
#
# is_odd.call(1).success? # => true
# is_odd.call(2).success? # => false
#
alias_method :build, :call
end
end
end

View File

@ -1,22 +0,0 @@
# frozen_string_literal: true
require "dry/core/memoizable"
module Dry
module Logic
module Build
class Base
include Dry::Core::Memoizable
# As to prevent overlap with {Predicates::Methods}
# Alternativly {Base} can inherit from {BasicObject}
# but {Dry::Core::Memoizable} doesn't (currently)
# support {BasicObject} inherited classes
undef :eql?, :respond_to?, :nil?
def self.call(&block)
new.instance_eval(&block)
end
end
end
end
end

View File

@ -1,13 +0,0 @@
require "dry/logic/predicates"
module Dry
module Logic
module Build
# This module is used to prevent {Predicates::Methods.predicate}
# from adding custom, user defined methods to the global scope
module LocalPredicates
include Dry::Logic::Predicates
end
end
end
end

View File

@ -1,36 +0,0 @@
# frozen_string_literal: true
module Dry
module Logic
module Build
class Operation < Base
def method_missing(method, *args, **kwargs, &block)
super unless respond_to_missing?(method)
super unless Kernel.block_given?
to_operation(method).new(*to_predicate(&block), *args, **kwargs)
end
def respond_to_missing?(method, *)
defined?(to_class_name(method))
end
private
def to_operation(name)
Kernel.eval(to_class_name(name))
end
def to_class_name(name)
["Operations", name.capitalize].join("::")
end
def to_predicate(&block)
Build.call(&block)
end
memoize :to_class_name
end
end
end
end

View File

@ -1,62 +0,0 @@
# frozen_string_literal: true
require "dry/logic/predicates"
require_relative "local_predicates"
module Dry
module Logic
module Build
class Predicate < Base
#
# Defines a user defined predicate
#
# @name [Symbol] A predicate name
# @block [Proc] Block containing the predicate logic
# @example Checks input is equals to 10
# predicate(:ten?) do |input|
# input == 10
# end
#
# is_ten = predicates[:ten?]
#
# is_ten.call(10).success? # => true
# is_ten.call(11).success? # => false
#
def predicate(name, &block)
# Remove the existing predicate defined by user
# Without this a warning is shown similar to:
# warning: method redefined; discarding old {name}
if respond_to_missing?(name)
predicates.singleton_class.undef_method(name)
end
predicates[:predicate].call(name, &block)
end
def method_missing(method, *args, **kwargs, &block)
super unless respond_to_missing?(method)
super if Kernel.block_given?
super unless kwargs.empty?
to_predicate(method).curry(*args)
end
def respond_to_missing?(method, *)
predicates.singleton_class.method_defined?(method)
end
private
def to_predicate(name)
Rule::Predicate.new(predicates[name])
end
def predicates
LocalPredicates
end
memoize :to_predicate
end
end
end
end

91
lib/dry/logic/builder.rb Normal file
View File

@ -0,0 +1,91 @@
# frozen_string_literal: true
require "dry/logic"
require "singleton"
require "delegate"
module Dry
module Logic
autoload :Operations, "dry/logic/operations"
autoload :Predicates, "dry/logic/predicates"
module Builder
IGNORED_OPERATIONS = %i[
Abstract
Binary
Unary
].freeze
IGNORED_PREDICATES = [
:predicate
].freeze
# Predicate and operation builder
#
# @block [Proc]
# @return [Builder::Result]
# @example Check if input is zero
# is_zero = Dry::Logic::Builder.call do
# negation { lt?(0) ^ gt?(0) }
# end
#
# p is_zero.call(1) # => false
# p is_zero.call(0) # => true
# p is_zero.call(-1) # => false
def call(&context)
Context.instance.call(&context)
end
module_function :call
alias_method :build, :call
public :call, :build
class Context
include Dry::Logic
include Singleton
module Predicates
include Logic::Predicates
end
# @see Builder#call
def call(&context)
instance_eval(&context)
end
# Defines custom predicate
#
# @name [Symbol] Name of predicate
# @Context [Proc]
def predicate(name, &context)
if singleton_class.method_defined?(name)
singleton_class.undef_method(name)
end
prerdicate = Rule::Predicate.new(context)
define_singleton_method(name) do |*args|
prerdicate.curry(*args)
end
end
# Defines methods for operations and predicates
def initialize
Operations.constants(false).each do |name|
next if IGNORED_OPERATIONS.include?(name)
operation = Operations.const_get(name)
define_singleton_method(name.downcase) do |*args, **kwargs, &block|
operation.new(*call(&block), *args, **kwargs)
end
end
Predicates::Methods.instance_methods(false).each do |name|
unless IGNORED_PREDICATES.include?(name)
predicate(name, &Predicates[name])
end
end
end
end
end
end
end

View File

@ -1,76 +0,0 @@
# frozen_string_literal: true
require "dry/logic/build"
RSpec.describe ".build" do
subject { predicate.call(described_class) }
before { extend Dry::Logic::Build }
describe "nested operations" do
let(:predicate) do
build do
check keys: [:person] do
check keys: [:age] do
gt?(50) & lt?(200)
end
end
end
end
describe({person: {age: 100}}) do
it { is_expected.to be_a_success }
end
describe({person: {age: 10}}) do
it { is_expected.not_to be_a_success }
end
end
describe "operations" do
let(:predicate) do
build do
int? | float? | number?
end
end
describe 1 do
it { is_expected.to be_a_success }
end
describe 2.0 do
it { is_expected.to be_a_success }
end
describe "3" do
it { is_expected.not_to be_a_success }
end
describe "four" do
it { is_expected.not_to be_a_success }
end
end
describe "predicates" do
let(:predicate) do
build { even? }
end
describe 10 do
it { is_expected.to be_a_success }
end
describe 5 do
it { is_expected.not_to be_a_success }
end
end
describe "undefined methods" do
let(:predicate) do
build { does_not_exist }
end
it "raises NameError" do
expect { subject }.to raise_error(NameError, /does_not_exist/)
end
end
end

View File

@ -3,6 +3,28 @@
require_relative "../../shared/operation"
RSpec.describe "operations" do
describe "nested" do
let(:predicate) do
Dry::Logic::Builder.call do
check keys: [:person] do
check keys: [:age] do
gt?(50) & lt?(200)
end
end
end
end
describe "success" do
subject { predicate.call({person: {age: 100}}) }
it { is_expected.to be_a_success }
end
describe "failure" do
subject { predicate.call({person: {age: 10}}) }
it { is_expected.not_to be_a_success }
end
end
describe :check do
describe "one path" do
let(:expression) do
@ -241,31 +263,31 @@ RSpec.describe "operations" do
end
end
describe :attr do
let(:expression) do
lambda do |*|
attr name: :age do
gt?(50)
end
end
end
let(:person) { Struct.new(:age) }
describe "success" do
it_behaves_like "operation" do
let(:input) { person.new(100) }
let(:output) { true }
end
end
describe "failure" do
it_behaves_like "operation" do
let(:input) { person.new(0) }
let(:output) { false }
end
end
end
# describe :attr do
# let(:expression) do
# lambda do |*|
# attr name: :age do
# gt?(50)
# end
# end
# end
#
# let(:person) { Struct.new(:age) }
#
# describe "success" do
# it_behaves_like "operation" do
# let(:input) { person.new(100) }
# let(:output) { true }
# end
# end
#
# describe "failure" do
# it_behaves_like "operation" do
# let(:input) { person.new(0) }
# let(:output) { false }
# end
# end
# end
describe "operators" do
describe :& do

View File

@ -1136,7 +1136,7 @@ RSpec.describe "predicates" do
end
describe :predicate do
before { extend Dry::Logic::Build }
before { extend Dry::Logic::Builder }
before(:each) do
build do

View File

@ -1,9 +1,10 @@
# frozen_string_literal: true
require "dry/logic/build"
require "dry/logic/builder"
RSpec.shared_examples "operation" do
let(:operation) { Dry::Logic::Build.call(&expression) }
before { extend Dry::Logic::Builder }
let(:operation) { build(&expression) }
let(:args) { defined?(input) ? [input] : [] }
subject { operation.call(*args).success? }
it { is_expected.to eq(output) }

View File

@ -1,10 +1,11 @@
# frozen_string_literal: true
require "dry/logic/build"
require "dry/logic/builder"
# TODO: Merge with {operation}?
RSpec.shared_examples "predicate" do
let(:predicate) { Dry::Logic::Build.call(&expression) }
before { extend Dry::Logic::Builder }
let(:predicate) { build(&expression) }
let(:args) { defined?(input) ? [input] : [] }
subject { predicate.call(*args).success? }
it { is_expected.to eq(output) }

View File

@ -6,7 +6,7 @@ if ENV["COVERAGE"] == "true"
require "simplecov"
require "simplecov-cobertura"
SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter
# SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter
SimpleCov.start do
add_filter "/spec/"

25
spec/unit/builder_spec.rb Normal file
View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
RSpec.describe Dry::Logic::Builder do
describe "undefined methods" do
it "raises NameError" do
expect do
described_class.call { does_not_exist }
end.to raise_error(NameError, /does_not_exist/)
end
end
describe "leakage" do
context "given a module extending ::Builder" do
subject do
Module.new do
extend Dry::Logic::Builder
end
end
it { is_expected.not_to respond_to(:int?) }
it { is_expected.to respond_to(:call) }
it { is_expected.to respond_to(:build) }
end
end
end