lib/aixm/concerns/association.rb
using AIXM::Refinements
module AIXM
module Concerns
# Associate features and components with a minimalistic implementation of
# +has_many+, +has_one+ and +belongs_to+ associations.
#
# When adding or assigning an object on the associator (where the +has_many+
# or +has_one+ declaration is made), the object is verified and must be an
# instance of the declared class or a superclass thereof.
#
# When assigning an object on the associated (where the +belongs_to+
# declaration is made), the object is not verified. However, since the actual
# assignment is always delegated to the associator, unacceptable objects will
# raise errors.
#
# @example Simple +has_many+ association
# class Blog
# has_many :posts # :post has to be a key in AIXM::CLASSES
# end
# class Post
# belongs_to :blog
# end
# blog, post = Blog.new, Post.new
# # --either--
# blog.add_post(post) # => Blog
# blog.posts.count # => 1
# blog.posts.first == post # => true
# post.blog == blog # => true
# blog.remove_post(post) # => Blog
# blog.posts.count # => 0
# # --or--
# post.blog = blog # => Blog
# blog.posts.count # => 1
# blog.posts.first == post # => true
# post.blog == blog # => true
# post.blog = nil # => nil
# blog.posts.count # => 0
# # --or--
# post_2 = Post.new
# blog.add_posts([post, post_2])
# blog.posts.count # => 2
# blog.posts == [post, post_2] # => true
# blog.remove_posts([post_2, post])
# blog.posts.count # => 0
#
# @example Simple +has_one+ association
# class Blog
# has_one :posts # :post has to be a key in AIXM::CLASSES
# end
# class Post
# belongs_to :blog
# end
# blog, post = Blog.new, Post.new
# # --either--
# blog.post = post # => Post (standard assignment)
# blog.add_post(post) # => Blog (alternative for chaining)
# blog.post == post # => true
# post.blog == blog # => true
# blog.post = nil # => nil
# blog.post # => nil
# post.blog # => nil
# # --or--
# post.blog = blog # => Blog (standard assignment)
# post.add_blog(blog) # => Post (alternative for chaining)
# post.blog == blog # => true
# blog.post == post # => true
# post.blog = nil # => nil
# post.blog # => nil
# blog.post # => nil
#
# @example Association with readonly +belongs_to+ (idem for +has_one+)
# class Blog
# has_many :posts # :post has to be a key in AIXM::CLASSES
# end
# class Post
# belongs_to :blog, readonly: true
# end
# blog, post = Blog.new, Post.new
# post.blog = blog # => NoMethodError
#
# @example Association with explicit class (idem for +has_one+)
# class Blog
# include AIXM::Concerns::Association
# has_many :posts, accept: 'Picture'
# end
# class Picture
# include AIXM::Concerns::Association
# belongs_to :blog
# end
# blog, picture = Blog.new, Picture.new
# blog.add_post(picture)
# blog.posts.first == picture # => true
#
# @example Polymorphic associator (idem for +has_one+)
# class Blog
# has_many :posts, as: :postable
# end
# class Feed
# has_many :posts, as: :postable
# end
# class Post
# belongs_to :postable
# end
# blog, feed, post_1, post_2, post_3 = Blog.new, Feed.new, Post.new, Post.new, Post.new
# blog.add_post(post_1)
# post_1.postable == blog # => true
# feed.add_post(post_2)
# post_2.postable == feed # => true
# post_3.postable = blog # => NoMethodError
#
# @example Polymorphic associated (idem for +has_one+)
# class Blog
# include AIXM::Concerns::Association
# has_many :items, accept: ['Post', :picture]
# end
# class Post
# include AIXM::Concerns::Association
# belongs_to :blog, as: :item
# end
# class Picture
# include AIXM::Concerns::Association
# belongs_to :blog, as: :item
# end
# blog, post, picture = Blog.new, Post.new, Picture.new
# blog.add_item(post)
# blog.add_item(picture)
# blog.items.count # => 2
# blog.items.first == post # => true
# blog.items.last == picture # => true
# post.blog == blog # => true
# picture.blog == blog # => true
#
# @example Add method which enriches passed associated object (+has_many+ only)
# class Blog
# has_many :posts do |post, related_to: nil| # this defines the signature of add_post
# post.related_to = related_to || @posts.last # executes in the context of the current blog
# end
# end
# class Post
# belongs_to :blog
# attr_accessor :related_to
# end
# blog, post_1, post_2, post_3 = Blog.new, Post.new, Post.new, Post.new
# blog.add_post(post_1)
# post_1.related_to # => nil
# blog.add_post(post_2)
# post_2.related_to == post_1 # => true
# blog.add_post(post_3, related_to: post_1)
# post_3.related_to == post_1 # => true
#
# @example Add method which builds and yields new associated object (+has_many+ only)
# class Blog
# include AIXM::Concerns::Association
# has_many :posts do |post, title:| end
# end
# class Post
# include AIXM::Concerns::Association
# belongs_to :blog
# attr_accessor :title, :text
# def initialize(title:) # same signature as "has_many" block above
# @title = title
# end
# end
# blog = Blog.new
# blog.add_post(title: "title") do |post| # note that no post instance is passed
# post.text = "text"
# end
# blog.posts.first.title # => "title"
# blog.posts.first.text # => "text"
module Association
module ClassMethods
attr_reader :has_many_attributes, :has_one_attributes, :belongs_to_attributes
def has_many(attribute, as: nil, accept: nil, &association_block)
association = attribute.to_s.inflect(:singularize)
inversion = as || self.to_s.inflect(:demodulize, :tableize, :singularize)
class_names = [accept || association].flatten.map { AIXM::CLASSES[_1.to_sym] || _1 }
(@has_many_attributes ||= []) << attribute
# features
define_method(attribute) do
instance_variable_get(:"@#{attribute}") || AIXM::Concerns::Association::Array.new
end
# add_feature
define_method(:"add_#{association}") do |object=nil, **options, &add_block|
unless object
fail(ArgumentError, "must pass object to add") if class_names.count > 1
object = class_names.first.to_class.new(**options)
add_block.call(object) if add_block
end
instance_exec(object, **options, &association_block) if association_block
fail(ArgumentError, "#{object.__class__} not allowed") unless class_names.any? { |c| object.is_a?(c.to_class) }
instance_eval("@#{attribute} ||= AIXM::Concerns::Association::Array.new")
send(attribute).send(:push, object)
object.instance_variable_set(:"@#{inversion}", self)
self
end
# add_features
define_method(:"add_#{attribute}") do |objects=[], **options, &add_block|
objects.each { send(:"add_#{association}", _1, **options, &add_block) }
self
end
# remove_feature
define_method(:"remove_#{association}") do |object|
send(attribute).send(:delete, object)
object.instance_variable_set(:"@#{inversion}", nil)
self
end
# remove_features
define_method(:"remove_#{attribute}") do |objects=[]|
objects.each { send(:"remove_#{association}", _1) }
self
end
end
def has_one(attribute, as: nil, accept: nil, allow_nil: false)
association = attribute.to_s
inversion = (as || self.to_s.inflect(:demodulize, :tableize, :singularize)).to_s
class_names = [accept || association].flatten.map { AIXM::CLASSES[_1.to_sym] || _1 }
class_names << 'NilClass' if allow_nil
(@has_one_attributes ||= []) << attribute
# feature
attr_reader attribute
# feature=
define_method(:"#{association}=") do |object|
fail(ArgumentError, "#{object.__class__} not allowed") unless class_names.any? { |c| object.is_a?(c.to_class) }
instance_variable_get(:"@#{attribute}")&.instance_variable_set(:"@#{inversion}", nil)
instance_variable_set(:"@#{attribute}", object)
object&.instance_variable_set(:"@#{inversion}", self)
object
end
# add_feature
define_method(:"add_#{association}") do |object|
send("#{association}=", object)
self
end
# remove_feature
define_method(:"remove_#{association}") do |_|
send(:"#{association}=", nil)
self
end
end
def belongs_to(attribute, as: nil, readonly: false)
association = self.to_s.inflect(:demodulize, :tableize, :singularize)
inversion = (as || association).to_s
(@belongs_to_attributes ||= []) << attribute
# feature
attr_reader attribute
unless readonly
# feature=
define_method(:"#{attribute}=") do |object|
instance_variable_get(:"@#{attribute}")&.send(:"remove_#{inversion}", self)
object&.send(:"add_#{inversion}", self)
object
end
# add_feature
define_method(:"add_#{attribute}") do |object|
send("#{attribute}=", object)
self
end
end
end
end
def self.included(base)
base.extend(ClassMethods)
end
class Array < ::Array
private :<<, :push, :append, :unshift, :prepend
private :delete, :pop, :shift
# Find objects of the given class and optionally with the given
# attribute values on a has_many association.
#
# The class can either be declared by passing the class itself or by
# passing a shortcut symbol as listed in +AIXM::CLASSES+.
#
# @example
# class Blog
# include AIXM::Concerns::Association
# has_many :items, accept: %i(post picture)
# end
# class Post
# include AIXM::Concerns::Association
# belongs_to :blog, as: :item
# attr_accessor :title
# end
# class Picture
# include AIXM::Concerns::Association
# belongs_to :blog, as: :item
# end
# blog, post, picture = Blog.new, Post.new, Picture.new
# post.title = "title"
# blog.add_item(post)
# blog.add_item(picture)
# blog.items.find_by(:post) == [post] # => true
# blog.items.find_by(Post) == [post] # => true
# blog.items.find_by(:post, title: "title") == [post] # => true
# blog.items.find_by(Object) == [post, picture] # => true
#
# @param klass [Class, Symbol] class (e.g. AIXM::Feature::Airport,
# AIXM::Feature::NavigationalAid::VOR) or shortcut symbol (e.g.
# :airport or :vor) as listed in AIXM::CLASSES
# @param attributes [Hash] search attributes by their values
# @return [AIXM::Concerns::Association::Array]
def find_by(klass, attributes={})
if klass.is_a? Symbol
klass = AIXM::CLASSES[klass]&.to_class || fail(ArgumentError, "unknown class shortcut `#{klass}'")
end
self.class.new(
select do |element|
if element.kind_of? klass
attributes.all? { |a, v| element.send(a) == v }
end
end
)
end
# Find equal objects on a has_many association.
#
# This may seem redundant at first, but keep in mind that two instances
# of +AIXM::CLASSES+ which implement `#to_uid` are considered equal if
# they are instances of the same class and both their UIDs as calculated
# by `#to_uid` are equal. Attributes which are not part of the `#to_uid`
# calculation are irrelevant!
#
# @example
# class Blog
# include AIXM::Concerns::Association
# has_many :items, accept: %i(post picture)
# end
# class Post
# include AIXM::Concerns::Association
# belongs_to :blog, as: :item
# attr_accessor :title
# end
# blog, post = Blog.new, Post.new
# blog.add_item(post)
# blog.items.find(post) == [post] # => true
#
# @param object [Object] instance of class listed in AIXM::CLASSES
# @return [AIXM::Concerns::Association::Array]
def find(object)
klass = object.__class__
self.class.new(
select do |element|
element.kind_of?(klass) && element == object
end
)
end
# Find equal or identical duplicates on a has_many association.
#
# @example
# class Blog
# include AIXM::Concerns::Association
# has_many :posts
# end
# class Post
# include AIXM::Concerns::Association
# belongs_to :blog
# end
# blog, post = Blog.new, Post.new
# duplicate_post = post.dup
# blog.add_posts([post, duplicate_post])
# blog.posts.duplicates # => [[post, duplicate_post]]
#
# @return [Array<Array<AIXM::Feature>>]
def duplicates
AIXM::Concerns::Memoize.method :to_uid do
group_by { _1.to_uid.to_s }.select { |_, a| a.count > 1 }.map(&:last)
end
end
end
end
end
end