lib/rom/repository/relation_proxy.rb
require 'dry/core/deprecations'
require 'rom/initializer'
require 'rom/relation/materializable'
require 'rom/repository/relation_proxy/combine'
require 'rom/repository/relation_proxy/wrap'
module ROM
class Repository
# RelationProxy decorates a relation and automatically generates mappers that
# will map raw tuples into rom structs
#
# Relation proxies are being registered within repositories so typically there's
# no need to instantiate them manually.
#
# @api public
class RelationProxy
extend Initializer
extend Dry::Core::Deprecations[:rom]
include Relation::Materializable
(Kernel.private_instance_methods - %i(raise)).each(&method(:undef_method))
include RelationProxy::Combine
include RelationProxy::Wrap
deprecate :combine_parents
deprecate :combine_children
deprecate :wrap_parent
RelationRegistryType = Types.Definition(RelationRegistry).constrained(type: RelationRegistry)
# @!attribute [r] relation
# @return [Relation, Relation::Composite, Relation::Graph, Relation::Curried] The decorated relation object
param :relation
option :name, type: Types::Strict::Symbol
option :mappers, default: -> { MapperBuilder.new }
option :meta, default: -> { EMPTY_HASH }
option :registry, type: RelationRegistryType, default: -> { RelationRegistry.new }
option :auto_struct, default: -> { true }
# Relation name
#
# @return [ROM::Relation::Name]
#
# @api public
def name
@name ? relation.name.with(@name) : relation.name
end
# Materializes wrapped relation and sends it through a mapper
#
# For performance reasons a combined relation will skip mapping since
# we only care about extracting key values for combining
#
# @api public
def call(*args)
((combine? || composite?) ? relation : (relation >> mapper)).call(*args)
end
# Maps the wrapped relation with other mappers available in the registry
#
# @overload map_with(model)
# Map tuples to the provided custom model class
#
# @example
# users.as(MyUserModel)
#
# @param [Class>] model Your custom model class
#
# @overload map_with(*mappers)
# Map tuples using registered mappers
#
# @example
# users.map_with(:my_mapper, :my_other_mapper)
#
# @param [Array<Symbol>] mappers A list of mapper identifiers
#
# @overload map_with(*mappers, auto_map: true)
# Map tuples using auto-mapping and custom registered mappers
#
# If `auto_map` is enabled, your mappers will be applied after performing
# default auto-mapping. This means that you can compose complex relations
# and have them auto-mapped, and use much simpler custom mappers to adjust
# resulting data according to your requirements.
#
# @example
# users.map_with(:my_mapper, :my_other_mapper, auto_map: true)
#
# @param [Array<Symbol>] mappers A list of mapper identifiers
#
# @return [RelationProxy] A new relation proxy with pipelined relation
#
# @api public
def map_with(*names, **opts)
if names.size == 1 && names[0].is_a?(Class)
map_to(names[0])
elsif names.size > 1 && names.any? { |name| name.is_a?(Class) }
raise ArgumentError, 'using custom mappers and a model is not supported'
else
if opts[:auto_map] && !meta[:combine_type]
mappers = [mapper, *names.map { |name| relation.mappers[name] }]
mappers.reduce(self) { |a, e| a >> e }
else
names.reduce(self) { |a, e| a >> relation.mappers[e] }
end
end
end
# @api public
def as(*names, **opts)
if names.size == 1 && names[0].is_a?(Class)
msg = <<-STR
Relation#as will change behavior in 4.0. Use `map_to` instead
=> Called at:
#{Kernel.caller[0..5].join("\n")}
STR
Dry::Core::Deprecations.warn(msg)
map_to(names[0])
elsif names.size > 1 && names.any? { |name| name.is_a?(Class) }
raise ArgumentError, 'using custom mappers and a model is not supported'
else
if opts[:auto_map] && !meta[:combine_type]
mappers = [mapper, *names.map { |name| relation.mappers[name] }]
mappers.reduce(self) { |a, e| a >> e }
else
names.reduce(self) { |a, e| a >> relation.mappers[e] }
end
end
end
# @api public
def map_to(model)
with(meta: meta.merge(model: model))
end
# Return a new graph with adjusted node returned from a block
#
# @example with a node identifier
# aggregate(:tasks).node(:tasks) { |tasks| tasks.prioritized }
#
# @example with a nested path
# aggregate(tasks: :tags).node(tasks: :tags) { |tags| tags.where(name: 'red') }
#
# @param [Symbol] name The node relation name
#
# @yieldparam [RelationProxy] The relation node
# @yieldreturn [RelationProxy] The new relation node
#
# @return [RelationProxy]
#
# @api public
def node(name, &block)
if name.is_a?(Symbol) && !nodes.map { |n| n.name.relation }.include?(name)
raise ArgumentError, "#{name.inspect} is not a valid aggregate node name"
end
new_nodes = nodes.map { |node|
case name
when Symbol
name == node.name.relation ? yield(node) : node
when Hash
other, *rest = name.flatten(1)
if other == node.name.relation
nodes.detect { |n| n.name.relation == other }.node(*rest, &block)
else
node
end
else
node
end
}
with_nodes(new_nodes)
end
# Return a string representation of this relation proxy
#
# @return [String]
#
# @api public
def inspect
%(#<#{relation.class} name=#{name} dataset=#{dataset.inspect}>)
end
# Infers a mapper for the wrapped relation
#
# @return [ROM::Mapper]
#
# @api private
def mapper
mappers[to_ast]
end
# Returns a new instance with new options
#
# @param new_options [Hash]
#
# @return [RelationProxy]
#
# @api private
def with(new_options)
__new__(relation, options.merge(new_options))
end
# Returns if this relation is combined aka a relation graph
#
# @return [Boolean]
#
# @api private
def combine?
meta[:combine_type]
end
# Return if this relation is a composite
#
# @return [Boolean]
#
# @api private
def composite?
relation.is_a?(Relation::Composite)
end
# @return [Symbol] The wrapped relation's adapter identifier ie :sql or :http
#
# @api private
def adapter
relation.class.adapter
end
# Returns AST for the wrapped relation
#
# @return [Array]
#
# @api private
def to_ast
@to_ast ||=
begin
attr_ast = schema.map { |attr| [:attribute, attr] }
meta = self.meta.merge(dataset: base_name.dataset)
meta.update(model: false) unless meta[:model] || auto_struct
meta.delete(:wraps)
header = attr_ast + nodes_ast + wraps_ast
[:relation, [base_name.relation, meta, [:header, header]]]
end
end
# @api private
def respond_to_missing?(meth, _include_private = false)
relation.respond_to?(meth) || super
end
private
# @api private
def schema
if meta[:wrap]
relation.schema.wrap
else
relation.schema.reject(&:wrapped?)
end
end
# @api private
def base_name
relation.base_name
end
# @api private
def nodes_ast
@nodes_ast ||= nodes.map(&:to_ast)
end
# @api private
def wraps_ast
@wraps_ast ||= wraps.map(&:to_ast)
end
# Return a new instance with another relation and options
#
# @return [RelationProxy]
#
# @api private
def __new__(relation, new_options = EMPTY_HASH)
self.class.new(
relation, new_options.size > 0 ? options.merge(new_options) : options
)
end
# Return all nodes that this relation combines
#
# @return [Array<RelationProxy>]
#
# @api private
def nodes
relation.graph? ? relation.nodes : EMPTY_ARRAY
end
# Return all nodes that this relation wraps
#
# @return [Array<RelationProxy>]
#
# @api private
def wraps
meta.fetch(:wraps, EMPTY_ARRAY)
end
# Forward to relation and wrap it with proxy if response was a relation too
#
# TODO: this will be simplified once ROM::Relation has lazy-features built-in
# and ROM::Lazy is gone
#
# @api private
def method_missing(meth, *args, &block)
if relation.respond_to?(meth)
result = relation.__send__(meth, *args, &block)
if result.kind_of?(Relation::Materializable) && !result.is_a?(Relation::Loaded)
__new__(result)
else
result
end
else
raise NoMethodError, "undefined method `#{meth}' for #{relation.class.name}"
end
end
end
end
end