Skip to content

Instantly share code, notes, and snippets.

@estum
Last active May 3, 2023 02:15
Show Gist options
  • Save estum/9561636564f83f5c3f2267fca96e1ffa to your computer and use it in GitHub Desktop.
Save estum/9561636564f83f5c3f2267fca96e1ffa to your computer and use it in GitHub Desktop.
RW.rb - inverted-control accessor tool
# frozen_string_literal: true
require 'dry/core/cache'
require 'dry/core/constants'
require 'dry/container'
require 'dry/types'
require 'dry/types/tuple' # → https://github.com/estum/dry-types-tuple
# External instance-level accessor wrapper in inverted control:
# once defined with an accessor name it could be used with different objects
# to get, set and predicate values.
#
# @example Instance variable accessor
# RW[@ivar_name].get(object)
# RW[@ivar_name].set(object, value)
# RW[@ivar_name].defined_on?(object)
#
# @example Instance accessor method
# RW[:accessor_method].get(object)
# RW[:accessor_method].set(object, value)
# RW[:accessor_method].defined_on?(object)
#
# @see RW.[]
# @see RW::Base#get
# @see RW::Base#set
# @see RW::Base#defined_on?
#
class RW
include Dry::Core::Constants
# @api private
module Repo
extend Dry::Container::Mixin
register :any, Dry::Types['any']
register :str, Dry::Types['string']
register :tuple, Dry::Types['tuple']
register :hash, Dry::Types['hash']
register :writer, proc { @r.is_a?(Symbol) ? :"#{@r}=" : Undefined }, call: false
register :ivar, resolve(:str).constrained(start_with: ?@).constructor(&:to_s)
register :not_ivar, resolve(:str).constrained(format: /^[^@]/).constructor(&:to_s)
register :api, proc { |any, name| any.constrained(respond_to: name) }.curry(2).(resolve(:any))
register :fn, proc { |api| api[:call] | api[:to_proc].constructor(&:to_proc) }.(resolve(:api))
register :curry_unbound do |fn, *args|
fn = resolve(:fn)[fn]
proc { |o, *rest, &blk| fn.(o, *args, *rest, &blk) }
end
end
# Implements typecasting methods and makes an extended class act like Dry::Types::Type.
# @api private
module ClassInterface
include Dry::Types::Type
include Dry::Types::Builder
include Dry::Types::Decorator
attr_accessor :type
# @param [Any] input
# @return [Object]
def call_safe(input, &block)
return input if input.is_a?(self)
result = @type.try(input) do
input = block.call(input) if block_given?
return input
end
new(*coerce_args(result.input))
end
# @param [Any] input
# @return [Object]
def call_unsafe(input, &block)
return input if input.is_a?(self)
input = @type.call_unsafe(input)
new(*coerce_args(input))
end
# @abstract
# Expected to be defined in an extended class
# @param [Any] input
# @return [Array(Any)]
# @see IVarSetter.coerce_args
# @see MethodCall.coerce_args
def coerce_args(input)
raise NotImplementedError
end
end
# @abstract
# When inherited, set a tuple type with <tt>self.type =</tt> and
# define <tt>self.coerce_args</tt> as a static class method.
class Base
send :extend, Dry::Initializer[undefined: false]
param :name
option :reader, type: Repo[:fn]
option :writer, type: Repo[:fn]
option :predicate, type: Repo[:fn]
# @!method initialize(name, **options)
# @abstract
# @api private
# Initializes an instance of {Base}
# @param [String, Symbol] name (required)
# @param [Hash] options
# @option options [#to_proc, #call] :reader (required)
# @option options [#to_proc, #call] :writer (required)
# @option options [#to_proc, #call] :predicate (required)
# @return [self]
send :include, Dry.Equalizer(:name, immutable: true)
extend ClassInterface
# Get value by calling +@reader+ proc
# @param [Any] receiver
# @param [Array(Any)] args
# passed to #call method of <tt>@reader</tt>
def get(receiver, *args, &block)
@reader.(receiver, *args, &block)
end
# Set value by calling +@writer+ proc
# @param [Any] receiver
# @param [Array(Any)] args
# passed to #call method of <tt>@writer</tt>
def set(receiver, *args, &block)
@writer.(receiver, *args, &block)
end
# Check if target is accessible on the receiver by calling +@predicate+ proc.
# @param [Any] receiver
# @return [Boolean]
def defined_on?(receiver)
@predicate.(receiver)
end
# Get value of ivar and, if it is nil, set it with the given
# @see #set
def set_if_absent(receiver, *args, &block)
set(receiver, *args, &block) if get(receiver).nil?
end
end
# Handles an instance variable access, when used with <tt>RW[:@variable_name]</tt>.
class IVarSetter < Base
self.type = Repo[:tuple].of(Repo[:ivar], [Repo[:hash].optional])
# @!method initialize(name, **options)
# Initializes an instance of {IVarSetter}
# @param [String, Symbol] name (required)
# @param [Hash] options
# @option options [#to_proc, #call] :reader
# (Repo[:curry_unbound][:instance_variable_get, name])
# @option options [#to_proc, #call] :writer
# (Repo[:curry_unbound][:instance_variable_set, name])
# @option options [#to_proc, #call] :predicate
# (Repo[:curry_unbound][:instance_variable_defined?, name])
# @return [self]
# @api private
def self.coerce_args((name, *rest))
opts = rest.extract_options!
opts[:reader] ||= Repo[:curry_unbound][:instance_variable_get, name]
opts[:writer] ||= Repo[:curry_unbound][:instance_variable_set, name]
opts[:predicate] ||= Repo[:curry_unbound][:instance_variable_defined?, name]
[name, *rest, **opts]
end
end
# Handles an accessor method, when used with <tt>RW[:accessor_name]</tt.
class MethodCall < Base
self.type = Repo[:tuple].of(Repo[:not_ivar], [Repo[:hash].optional])
# @!method initialize(name, **options)
# Initializes an instance of {MethodCall}
# @param [String, Symbol] name (required)
# @param [Hash] options
# @option options [#to_proc, #call] :reader (proc(&name.to_sym))
# @option options [#to_proc, #call] :writer (proc(&"#{name}="))
# @option options [#to_proc, #call] :predicate (Repo[:curry_unbound][:respond_to?, name])
# @return [self]
# @api private
def self.coerce_args((name, *rest))
opts = rest.extract_options!
opts[:reader] ||= proc(&name.to_sym)
opts[:writer] ||= proc(&:"#{name}=")
opts[:predicate] ||= Repo[:curry_unbound][:respond_to?, name]
[name, *rest, **opts]
end
end
extend Dry::Core::Cache
class << self
# @see ClassInterface#call_safe
#
# @param [Hash] opts
# custom procs to use instead of generated ones
# @option opts [#call] reader
# optional custom reader proc
# @option opts [#call] writer
# optional custom writer proc
# @option opts [#call] predicate
# optional custom predicate proc
#
# @overload [](ivar_name, **opts)
# @see IVarSetter.coerce_args
# @see IVarSetter#initialize
# @param [String, Symbol] ivar_name
# An instance variable name to be accessed, i.e. '@example'
# @example
# rw = RW[:@example]
# rw.get(object) # => object.instance_variable_get(:@example)
# rw.set(object, value) # => object.instance_variable_set(:@example, value)
# rw.defined_on?(object) # => object.instance_variable_defined?(:@example)
# @return [IVarSetter]
#
# @overload [](accessor_name, **opts)
# @see MethodCall.coerce_args
# @see MethodCall#initialize
# @example
# rw = RW[:example]
# @param [String, Symbol] accessor_name
# An accessor method name, i.e. `example`
# @example
# rw = RW[:example]
# rw.get(object) # => object.example
# rw.set(object, value) # => object.example = value
# rw.defined_on?(object) # => object.respond_to?(:example)
# @return [MethodCall]
def [](name, **opts)
fetch_or_store(name, opts) do
@sum[[name, **opts]]
end
end
attr_accessor :sum, :to_proc
end
# Composition type to handle both cases.
self.sum = IVarSetter | MethodCall
self.to_proc = method(:[]).to_proc
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment