Skip to content

Commit

Permalink
Implements polymorphic associations (Closes #51) (#275)
Browse files Browse the repository at this point in the history
  • Loading branch information
zedtux authored Jun 15, 2022
1 parent 4709db4 commit 594b86f
Show file tree
Hide file tree
Showing 11 changed files with 438 additions and 94 deletions.
11 changes: 11 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require:
- rubocop-rspec

AllCops:
NewCops: enable

Metrics/BlockLength:
IgnoredMethods: ['describe', 'context']

Style/Documentation:
Enabled: No
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Implements polymorphic associations

## [0.42.0] - 2022-06-15
### Added
Expand Down
1 change: 1 addition & 0 deletions lib/no_brainer/criteria/join.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def _compile_join_ast(value)
association = model.association_metadata[k.to_sym]
raise "`#{k}' must be an association on `#{model}'" unless association
raise "join() does not support through associations" if association.options[:through]
raise "join() does not support polymorphic associations" if association.options[:polymorphic]

criteria = association.base_criteria
criteria = case v
Expand Down
54 changes: 48 additions & 6 deletions lib/no_brainer/document/association/belongs_to.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,24 @@ class NoBrainer::Document::Association::BelongsTo
include NoBrainer::Document::Association::Core

class Metadata
VALID_OPTIONS = [:primary_key, :foreign_key, :class_name, :foreign_key_store_as,
:index, :validates, :required, :uniq, :unique]
VALID_OPTIONS = %i[
primary_key foreign_key foreign_type class_name foreign_key_store_as
index validates required uniq unique polymorphic
]

include NoBrainer::Document::Association::Core::Metadata
include NoBrainer::Document::Association::EagerLoader::Generic

def foreign_key
options[:foreign_key].try(:to_sym) || :"#{target_name}_#{primary_key}"
end

def foreign_type
return nil unless options[:polymorphic]

options[:foreign_type].try(:to_sym) || (:"#{target_name}_type")
end

def primary_key
# We default the primary_key to `:id' and not `target_model.pk_name',
# because we don't want to require the target_model to be already loaded.
Expand All @@ -30,12 +39,22 @@ def primary_key
end
end

def target_model
get_model_by_name(options[:class_name] || target_name.to_s.camelize)
def target_model(target_class = nil)
return if options[:polymorphic] && target_class.nil?

model_name = if options[:polymorphic]
target_class
else
options[:class_name] || target_name.to_s.camelize
end

get_model_by_name(model_name)
end

def base_criteria
target_model.without_ordering
def base_criteria(target_class = nil)
model = target_model(target_class)

model ? model.without_ordering : nil
end

def hook
Expand All @@ -47,6 +66,11 @@ def hook
raise "Cannot declare `#{target_name}' in #{owner_model}: the foreign_key `#{foreign_key}' is already used"
end

if options[:polymorphic] && options[:class_name]
raise 'You cannot set class_name on a polymorphic belongs_to'
end

owner_model.field(foreign_type) if options[:polymorphic]
owner_model.field(foreign_key, :store_as => options[:foreign_key_store_as], :index => options[:index])

unless options[:validates] == false
Expand Down Expand Up @@ -85,6 +109,7 @@ def cast_attr(k, v)
end

def eager_load_owner_key; foreign_key; end
def eager_load_owner_type; foreign_type; end
def eager_load_target_key; primary_key; end
end

Expand All @@ -97,6 +122,17 @@ def assign_foreign_key(value)
@target_container = nil
end

def polymorphic_read
return target if loaded?

target_class = owner.read_attribute(foreign_type)
fk = owner.read_attribute(foreign_key)

if target_class && fk
preload(base_criteria(target_class).where(primary_key => fk).first)
end
end

def read
return target if loaded?

Expand All @@ -105,6 +141,12 @@ def read
end
end

def polymorphic_write(target)
owner.write_attribute(foreign_key, target.try(primary_key))
owner.write_attribute(foreign_type, target.root_class.name)
preload(target)
end

def write(target)
assert_target_type(target)
owner.write_attribute(foreign_key, target.try(primary_key))
Expand Down
7 changes: 4 additions & 3 deletions lib/no_brainer/document/association/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ def delegate(method_src, method_dst, options={})

def hook
options.assert_valid_keys(*self.class.const_get(:VALID_OPTIONS))
delegate("#{target_name}=", :write)
delegate("#{target_name}", :read)
delegate("#{target_name}=", "#{'polymorphic_' if options[:polymorphic]}write".to_sym)
delegate("#{target_name}", "#{'polymorphic_' if options[:polymorphic]}read".to_sym)
end

def add_callback_for(what)
Expand Down Expand Up @@ -62,7 +62,8 @@ def get_model_by_name(model_name)

included { attr_accessor :metadata, :owner }

delegate :primary_key, :foreign_key, :target_name, :target_model, :base_criteria, :to => :metadata
delegate :primary_key, :foreign_key, :foreign_type, :target_name,
:target_model, :base_criteria, :to => :metadata

def initialize(metadata, owner)
@metadata, @owner = metadata, owner
Expand Down
17 changes: 15 additions & 2 deletions lib/no_brainer/document/association/eager_loader.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
# frozen_string_literal: true

module NoBrainer::Document::Association::EagerLoader
extend self

module Generic
# Used in associations to declare generic eager loading capabilities
# The association should implement loaded?, preload,
# eager_load_owner_key and eager_load_target_key.
def eager_load(docs, additional_criteria=nil)
def eager_load(docs, additional_criteria = nil)
owner_key = eager_load_owner_key
owner_type = eager_load_owner_type
target_key = eager_load_target_key

criteria = base_criteria
if is_a?(NoBrainer::Document::Association::BelongsTo::Metadata) && owner_type
target_class = docs.first.__send__(owner_type)

if docs.detect { |doc| doc.__send__(owner_type) != target_class }
raise NoBrainer::Error::PolymorphicAssociationWithDifferentTypes,
"The documents to be eager loaded doesn't have the same " \
'type, which is not supported'
end
end

criteria = target_class ? base_criteria(target_class) : base_criteria
criteria = criteria.merge(additional_criteria) if additional_criteria

unloaded_docs = docs.reject { |doc| doc.associations[self].loaded? }
Expand Down
34 changes: 26 additions & 8 deletions lib/no_brainer/document/association/has_many.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ class NoBrainer::Document::Association::HasMany
include NoBrainer::Document::Association::Core

class Metadata
VALID_OPTIONS = [:primary_key, :foreign_key, :class_name, :dependent, :scope]
VALID_OPTIONS = [:primary_key, :foreign_key, :class_name, :dependent, :scope,
:as]
include NoBrainer::Document::Association::Core::Metadata
include NoBrainer::Document::Association::EagerLoader::Generic

def foreign_key
options[:foreign_key].try(:to_sym) || :"#{owner_model.name.split('::').last.underscore}_#{primary_key}"
return options[:foreign_key].try(:to_sym) if options.key?(:foreign_key)
return :"#{options[:as]}_#{primary_key}" if options[:as]

:"#{owner_model.name.split('::').last.underscore}_#{primary_key}"
end

def foreign_type
options[:foreign_type].try(:to_sym) || (options[:as] && :"#{options[:as]}_type")
end

def primary_key
Expand All @@ -30,9 +38,9 @@ def inverses
# caching is hard (rails console reload, etc.).
target_model.association_metadata.values.select do |assoc|
assoc.is_a?(NoBrainer::Document::Association::BelongsTo::Metadata) and
assoc.foreign_key == self.foreign_key and
assoc.primary_key == self.primary_key and
assoc.target_model.root_class == owner_model.root_class
assoc.foreign_key == foreign_key and
assoc.primary_key == primary_key and
assoc.target_model(target_model).root_class == owner_model.root_class
end
end

Expand All @@ -46,20 +54,30 @@ def hook

if options[:dependent]
unless [:destroy, :delete, :nullify, :restrict, nil].include?(options[:dependent])
raise "Invalid dependent option: `#{options[:dependent].inspect}'. " +
raise "Invalid dependent option: `#{options[:dependent].inspect}'. " \
"Valid options are: :destroy, :delete, :nullify, or :restrict"
end
add_callback_for(:before_destroy)
end
end

def eager_load_owner_key; primary_key; end
def eager_load_owner_type; foreign_type; end
def eager_load_target_key; foreign_key; end
end

def target_criteria
@target_criteria ||= base_criteria.where(foreign_key => owner.__send__(primary_key))
.after_find(set_inverse_proc)
@target_criteria ||= begin
query_criteria = { foreign_key => owner.__send__(primary_key) }

if metadata.options[:as]
query_criteria = query_criteria.merge(
foreign_type => owner.root_class.name
)
end

base_criteria.where(query_criteria).after_find(set_inverse_proc)
end
end

def read
Expand Down
32 changes: 17 additions & 15 deletions lib/no_brainer/error.rb
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
# frozen_string_literal: true

module NoBrainer::Error
class Connection < RuntimeError; end
class DocumentNotFound < RuntimeError; end
class DocumentNotPersisted < RuntimeError; end
class ChildrenExist < RuntimeError; end
class CannotUseIndex < RuntimeError; end
class MissingIndex < RuntimeError; end
class AssociationNotPersisted < RuntimeError; end
class ReadonlyField < RuntimeError; end
class MissingAttribute < RuntimeError; end
class UnknownAttribute < RuntimeError; end
class AtomicBlock < RuntimeError; end
class LostLock < RuntimeError; end
class LockInvalidOp < RuntimeError; end
class LockUnavailable < RuntimeError; end
class InvalidPolymorphicType < RuntimeError; end
class AssociationNotPersisted < RuntimeError; end
class AtomicBlock < RuntimeError; end
class ChildrenExist < RuntimeError; end
class Connection < RuntimeError; end
class DocumentNotFound < RuntimeError; end
class DocumentNotPersisted < RuntimeError; end
class InvalidPolymorphicType < RuntimeError; end
class LockInvalidOp < RuntimeError; end
class LostLock < RuntimeError; end
class LockUnavailable < RuntimeError; end
class MissingAttribute < RuntimeError; end
class MissingIndex < RuntimeError; end
class PolymorphicAssociationWithDifferentTypes < RuntimeError; end
class ReadonlyField < RuntimeError; end
class UnknownAttribute < RuntimeError; end

class DocumentInvalid < RuntimeError
attr_accessor :instance
Expand Down
Loading

0 comments on commit 594b86f

Please sign in to comment.