lib/rom/mapper/attribute_dsl.rb
require 'rom/header'
require 'rom/mapper/model_dsl'
module ROM
class Mapper
# Mapper attribute DSL exposed by mapper subclasses
#
# This class is private even though its methods are exposed by mappers.
# Typically it's not meant to be used directly.
#
# TODO: break this madness down into smaller pieces
#
# @api private
class AttributeDSL
include ModelDSL
attr_reader :attributes, :options, :copy_keys, :symbolize_keys, :reject_keys, :steps
# @param [Array] attributes accumulator array
# @param [Hash] options
#
# @api private
def initialize(attributes, options)
@attributes = attributes
@options = options
@copy_keys = options.fetch(:copy_keys)
@symbolize_keys = options.fetch(:symbolize_keys)
@prefix = options.fetch(:prefix)
@prefix_separator = options.fetch(:prefix_separator)
@reject_keys = options.fetch(:reject_keys)
@steps = []
end
# Redefine the prefix for the following attributes
#
# @example
#
# dsl = AttributeDSL.new([])
# dsl.attribute(:prefix, 'user')
#
# @api public
def prefix(value = Undefined)
if value.equal?(Undefined)
@prefix
else
@prefix = value
end
end
# Redefine the prefix separator for the following attributes
#
# @example
#
# dsl = AttributeDSL.new([])
# dsl.attribute(:prefix_separator, '.')
#
# @api public
def prefix_separator(value = Undefined)
if value.equal?(Undefined)
@prefix_separator
else
@prefix_separator = value
end
end
# Define a mapping attribute with its options and/or block
#
# @example
# dsl = AttributeDSL.new([])
#
# dsl.attribute(:name)
# dsl.attribute(:email, from: 'user_email')
# dsl.attribute(:name) { 'John' }
# dsl.attribute(:name) { |t| t.upcase }
#
# @api public
def attribute(name, options = EMPTY_HASH, &block)
with_attr_options(name, options) do |attr_options|
raise ArgumentError,
"can't specify type and block at the same time" if options[:type] && block
attr_options[:coercer] = block if block
add_attribute(name, attr_options)
end
end
def exclude(name)
attributes << [name, { exclude: true }]
end
# Perform transformations sequentially
#
# @example
# dsl = AttributeDSL.new()
#
# dsl.step do
# attribute :name
# end
#
# @api public
def step(options = EMPTY_HASH, &block)
steps << new(options, &block)
end
# Define an embedded attribute
#
# Block exposes the attribute dsl too
#
# @example
# dsl = AttributeDSL.new([])
#
# dsl.embedded :tags, type: :array do
# attribute :name
# end
#
# dsl.embedded :address, type: :hash do
# model Address
# attribute :name
# end
#
# @param [Symbol] name attribute
#
# @param [Hash] options
# @option options [Symbol] :type Embedded type can be :hash or :array
# @option options [Symbol] :prefix Prefix that should be used for
# its attributes
#
# @api public
def embedded(name, options, &block)
with_attr_options(name) do |attr_options|
mapper = options[:mapper]
if mapper
embedded_options = { type: :array }.update(options)
attributes_from_mapper(
mapper, name, embedded_options.update(attr_options)
)
else
dsl = new(options, &block)
attr_options.update(options)
add_attribute(
name, { header: dsl.header, type: :array }.update(attr_options)
)
end
end
end
# Define an embedded hash attribute that requires "wrapping" transformation
#
# Typically this is used in sql context when relation is a join.
#
# @example
# dsl = AttributeDSL.new([])
#
# dsl.wrap(address: [:street, :zipcode, :city])
#
# dsl.wrap(:address) do
# model Address
# attribute :street
# attribute :zipcode
# attribute :city
# end
#
# @see AttributeDSL#embedded
#
# @api public
def wrap(*args, &block)
ensure_mapper_configuration('wrap', args, block_given?)
with_name_or_options(*args) do |name, options, mapper|
wrap_options = { type: :hash, wrap: true }.update(options)
if mapper
attributes_from_mapper(mapper, name, wrap_options)
else
dsl(name, wrap_options, &block)
end
end
end
# Define an embedded hash attribute that requires "unwrapping" transformation
#
# Typically this is used in no-sql context to normalize data before
# inserting to sql gateway.
#
# @example
# dsl = AttributeDSL.new([])
#
# dsl.unwrap(address: [:street, :zipcode, :city])
#
# dsl.unwrap(:address) do
# attribute :street
# attribute :zipcode
# attribute :city
# end
#
# @see AttributeDSL#embedded
#
# @api public
def unwrap(*args, &block)
with_name_or_options(*args) do |name, options, mapper|
unwrap_options = { type: :hash, unwrap: true }.update(options)
if mapper
attributes_from_mapper(mapper, name, unwrap_options)
else
dsl(name, unwrap_options, &block)
end
end
end
# Define an embedded hash attribute that requires "grouping" transformation
#
# Typically this is used in sql context when relation is a join.
#
# @example
# dsl = AttributeDSL.new([])
#
# dsl.group(tags: [:name])
#
# dsl.group(:tags) do
# model Tag
# attribute :name
# end
#
# @see AttributeDSL#embedded
#
# @api public
def group(*args, &block)
ensure_mapper_configuration('group', args, block_given?)
with_name_or_options(*args) do |name, options, mapper|
group_options = { type: :array, group: true }.update(options)
if mapper
attributes_from_mapper(mapper, name, group_options)
else
dsl(name, group_options, &block)
end
end
end
# Define an embedded array attribute that requires "ungrouping" transformation
#
# Typically this is used in non-sql context being prepared for import to sql.
#
# @example
# dsl = AttributeDSL.new([])
# dsl.ungroup(tags: [:name])
#
# @see AttributeDSL#embedded
#
# @api public
def ungroup(*args, &block)
with_name_or_options(*args) do |name, options, *|
ungroup_options = { type: :array, ungroup: true }.update(options)
dsl(name, ungroup_options, &block)
end
end
# Define an embedded hash attribute that requires "fold" transformation
#
# Typically this is used in sql context to fold single joined field
# to the array of values.
#
# @example
# dsl = AttributeDSL.new([])
#
# dsl.fold(tags: [:name])
#
# @see AttributeDSL#embedded
#
# @api public
def fold(*args, &block)
with_name_or_options(*args) do |name, *|
fold_options = { type: :array, fold: true }
dsl(name, fold_options, &block)
end
end
# Define an embedded hash attribute that requires "unfold" transformation
#
# Typically this is used in non-sql context to convert array of
# values (like in Cassandra 'SET' or 'LIST' types) to array of tuples.
#
# Source values are assigned to the first key, the other keys being left blank.
#
# @example
# dsl = AttributeDSL.new([])
#
# dsl.unfold(tags: [:name, :type], from: :tags_list)
#
# dsl.unfold :tags, from: :tags_list do
# attribute :name, from: :tag_name
# attribute :type, from: :tag_type
# end
#
# @see AttributeDSL#embedded
#
# @api public
def unfold(name, options = EMPTY_HASH)
with_attr_options(name, options) do |attr_options|
old_name = attr_options.fetch(:from, name)
dsl(old_name, type: :array, unfold: true) do
attribute name, attr_options
yield if block_given?
end
end
end
# Define an embedded combined attribute that requires "combine" transformation
#
# Typically this can be used to process results of eager-loading
#
# @example
# dsl = AttributeDSL.new([])
#
# dsl.combine(:tags, user_id: :id) do
# model Tag
#
# attribute :name
# end
#
# @param [Symbol] name
# @param [Hash] options
# @option options [Hash] :on The "join keys"
# @option options [Symbol] :type The type, either :array (default) or :hash
#
# @api public
def combine(name, options, &block)
dsl = new(options, &block)
attr_opts = {
type: options.fetch(:type, :array),
keys: options.fetch(:on),
combine: true,
header: dsl.header
}
add_attribute(name, attr_opts)
end
# Generate a header from attribute definitions
#
# @return [Header]
#
# @api private
def header
Header.coerce(attributes, copy_keys: copy_keys, model: model, reject_keys: reject_keys)
end
private
# Remove the attribute used somewhere else (in wrap, group, model etc.)
#
# @api private
def remove(*names)
attributes.delete_if { |attr| names.include?(attr.first) }
end
# Handle attribute options common for all definitions
#
# @api private
def with_attr_options(name, options = EMPTY_HASH)
attr_options = options.dup
if @prefix
attr_options[:from] ||= "#{@prefix}#{@prefix_separator}#{name}"
attr_options[:from] = attr_options[:from].to_sym if name.is_a? Symbol
end
if symbolize_keys
attr_options.update(from: attr_options.fetch(:from) { name }.to_s)
end
yield(attr_options)
end
# Handle "name or options" syntax used by `wrap` and `group`
#
# @api private
def with_name_or_options(*args)
name, options =
if args.size > 1
args
else
[args.first, {}]
end
yield(name, options, options[:mapper])
end
# Create another instance of the dsl for nested definitions
#
# This is used by embedded, wrap and group
#
# @api private
def dsl(name_or_attrs, options, &block)
if block
attributes_from_block(name_or_attrs, options, &block)
else
attributes_from_hash(name_or_attrs, options)
end
end
# Define attributes from a nested block
#
# Used by embedded, wrap and group
#
# @api private
def attributes_from_block(name, options, &block)
dsl = new(options, &block)
header = dsl.header
add_attribute(name, options.update(header: header))
header.each { |attr| remove(attr.key) unless name == attr.key }
end
# Define attributes from the `name => attributes` hash syntax
#
# Used by wrap and group
#
# @api private
def attributes_from_hash(hash, options)
hash.each do |name, header|
with_attr_options(name, options) do |attr_options|
add_attribute(name, attr_options.update(header: header.zip))
header.each { |attr| remove(attr) unless name == attr }
end
end
end
# Infer mapper header for an embedded attribute
#
# @api private
def attributes_from_mapper(mapper, name, options)
if mapper.is_a?(Class)
add_attribute(name, { header: mapper.header }.update(options))
else
raise(
ArgumentError, ":mapper must be a class #{mapper.inspect}"
)
end
end
# Add a new attribute and make sure it overrides previous definition
#
# @api private
def add_attribute(name, options)
remove(name, name.to_s)
attributes << [name, options]
end
# Create a new dsl instance of potentially overidden options
#
# Embedded, wrap and group can override top-level options like `prefix`
#
# @api private
def new(options, &block)
dsl = self.class.new([], @options.merge(options))
dsl.instance_exec(&block) unless block.nil?
dsl
end
# Ensure the mapping configuration isn't ambiguous
#
# @api private
def ensure_mapper_configuration(method_name, args, block_present)
if args.first.is_a?(Hash) && block_present
raise MapperMisconfiguredError,
"Cannot configure `#{method_name}#` using both options and a block"
end
if args.first.is_a?(Hash) && args.first[:mapper]
raise MapperMisconfiguredError,
"Cannot configure `#{method_name}#` using both options and a mapper"
end
end
end
end
end