Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements polymorphic associations (Closes #51) #275

Merged
merged 1 commit into from
Jun 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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