diff --git a/lib/jsonapi_compliable/adapters/active_record_sideloading.rb b/lib/jsonapi_compliable/adapters/active_record_sideloading.rb index 3804f31..b2ffbf5 100644 --- a/lib/jsonapi_compliable/adapters/active_record_sideloading.rb +++ b/lib/jsonapi_compliable/adapters/active_record_sideloading.rb @@ -47,7 +47,7 @@ def belongs_to(association_name, scope: nil, resource:, foreign_key:, primary_ke def has_one(association_name, scope: nil, resource:, foreign_key:, primary_key: :id, &blk) _scope = scope - allow_sideload association_name, resource: resource do + allow_sideload association_name, type: :has_one, foreign_key: foreign_key, primary_key: primary_key, resource: resource do scope do |parents| parent_ids = parents.map { |p| p.send(primary_key) } _scope.call.where(foreign_key => parent_ids.uniq.compact) @@ -71,7 +71,7 @@ def has_and_belongs_to_many(association_name, scope: nil, resource:, foreign_key fk = foreign_key.values.first _scope = scope - allow_sideload association_name, resource: resource do + allow_sideload association_name, type: :habtm, foreign_key: foreign_key, primary_key: primary_key, resource: resource do scope do |parents| parent_ids = parents.map { |p| p.send(primary_key) } parent_ids.uniq! @@ -94,14 +94,14 @@ def has_and_belongs_to_many(association_name, scope: nil, resource:, foreign_key end def polymorphic_belongs_to(association_name, group_by:, groups:, &blk) - allow_sideload association_name, polymorphic: true do - group_by(&group_by) + allow_sideload association_name, type: :polymorphic_belongs_to, polymorphic: true do + group_by(group_by) groups.each_pair do |type, config| primary_key = config[:primary_key] || :id foreign_key = config[:foreign_key] - allow_sideload type, resource: config[:resource] do + allow_sideload type, parent: self, primary_key: primary_key, foreign_key: foreign_key, type: :belongs_to, resource: config[:resource] do scope do |parents| parent_ids = parents.map { |p| p.send(foreign_key) } parent_ids.compact! diff --git a/lib/jsonapi_compliable/sideload.rb b/lib/jsonapi_compliable/sideload.rb index fb32829..a5db152 100644 --- a/lib/jsonapi_compliable/sideload.rb +++ b/lib/jsonapi_compliable/sideload.rb @@ -6,7 +6,7 @@ module JsonapiCompliable # @attr_reader [Hash] sideloads The associated sibling sideloads # @attr_reader [Proc] scope_proc The configured 'scope' block # @attr_reader [Proc] assign_proc The configured 'assign' block - # @attr_reader [Proc] grouper The configured 'group_by' proc + # @attr_reader [Symbol] grouping_field The configured 'group_by' symbol # @attr_reader [Symbol] foreign_key The attribute used to match objects - need not be a true database foreign key. # @attr_reader [Symbol] primary_key The attribute used to match objects - need not be a true database primary key. # @attr_reader [Symbol] type One of :has_many, :belongs_to, etc @@ -15,10 +15,11 @@ class Sideload :resource_class, :polymorphic, :polymorphic_groups, + :parent, :sideloads, :scope_proc, :assign_proc, - :grouper, + :grouping_field, :foreign_key, :primary_key, :type @@ -28,12 +29,13 @@ class Sideload # An anonymous Resource will be assigned when none provided. # # @see Adapters::Abstract#sideloading_module - def initialize(name, type: nil, resource: nil, polymorphic: false, primary_key: :id, foreign_key: nil) + def initialize(name, type: nil, resource: nil, polymorphic: false, primary_key: :id, foreign_key: nil, parent: nil) @name = name @resource_class = (resource || Class.new(Resource)) @sideloads = {} @polymorphic = !!polymorphic @polymorphic_groups = {} if polymorphic? + @parent = parent @primary_key = primary_key @foreign_key = foreign_key @type = type @@ -55,7 +57,7 @@ def resource # +Business+ or +Government+: # # allow_sideload :organization, :polymorphic: true do - # group_by { |record| record.organization_type } + # group_by :organization_type # # allow_sideload 'Business', resource: BusinessResource do # # ... code ... @@ -70,7 +72,7 @@ def resource # with ActiveRecord: # # polymorphic_belongs_to :organization, - # group_by: ->(office) { office.organization_type }, + # group_by: :organization_type, # groups: { # 'Business' => { # scope: -> { Business.all }, @@ -181,21 +183,25 @@ def assign(&blk) # @see #name # @see #type def associate(parent, child) - resource_class.config[:adapter].associate(parent, child, name, type) + association_name = @parent ? @parent.name : name + resource_class.config[:adapter].associate parent, + child, + association_name, + type end - # Define a proc that groups the parent records. For instance, with + # Define an attribute that groups the parent records. For instance, with # an ActiveRecord polymorphic belongs_to there will be a +parent_id+ # and +parent_type+. We would want to group on +parent_type+: # # allow_sideload :organization, polymorphic: true do # # group parent_type, parent here is 'organization' - # group_by ->(office) { office.organization_type } + # group_by :organization_type # end # # @see #polymorphic? - def group_by(&grouper) - @grouper = grouper + def group_by(grouping_field) + @grouping_field = grouping_field end # Resolve the sideload. @@ -323,6 +329,13 @@ def to_hash(processed = []) result end + # @api private + def polymorphic_child_for_type(type) + polymorphic_groups.values.find do |v| + v.resource_class.config[:type] == type + end + end + private def nested_sideload_hash(sideload, processed) @@ -333,8 +346,24 @@ def nested_sideload_hash(sideload, processed) end end + def polymorphic_grouper(grouping_field) + lambda do |record| + if record.is_a?(Hash) + if record.keys[0].is_a?(Symbol) + record[grouping_field] + else + record[grouping_field.to_s] + end + else + record.send(grouping_field) + end + end + end + def resolve_polymorphic(parents, query) - parents.group_by(&@grouper).each_pair do |group_type, group_members| + grouper = polymorphic_grouper(@grouping_field) + + parents.group_by(&grouper).each_pair do |group_type, group_members| sideload_for_group = @polymorphic_groups[group_type] if sideload_for_group sideload_for_group.resolve(group_members, query, name) diff --git a/lib/jsonapi_compliable/util/persistence.rb b/lib/jsonapi_compliable/util/persistence.rb index b731618..a81ccff 100644 --- a/lib/jsonapi_compliable/util/persistence.rb +++ b/lib/jsonapi_compliable/util/persistence.rb @@ -52,11 +52,18 @@ def run def update_foreign_key(parent_object, attrs, x) if [:destroy, :disassociate].include?(x[:meta][:method]) attrs[x[:foreign_key]] = nil + update_foreign_type(attrs, x, null: true) if x[:is_polymorphic] else attrs[x[:foreign_key]] = parent_object.send(x[:primary_key]) + update_foreign_type(attrs, x) if x[:is_polymorphic] end end + def update_foreign_type(attrs, x, null: false) + grouping_field = x[:sideload].parent.grouping_field + attrs[grouping_field] = null ? nil : x[:sideload].name + end + def update_foreign_key_for_parents(parents) parents.each do |x| update_foreign_key(x[:object], @attributes, x) @@ -88,7 +95,7 @@ def persist_object(method, attributes) def process_has_many(relationships) [].tap do |processed| - iterate(except: [:belongs_to]) do |x| + iterate(except: [:polymorphic_belongs_to, :belongs_to]) do |x| yield x x[:object] = x[:sideload].resource .persist_with_relationships(x[:meta], x[:attributes], x[:relationships]) @@ -99,7 +106,7 @@ def process_has_many(relationships) def process_belongs_to(relationships) [].tap do |processed| - iterate(only: [:belongs_to]) do |x| + iterate(only: [:polymorphic_belongs_to, :belongs_to]) do |x| x[:object] = x[:sideload].resource .persist_with_relationships(x[:meta], x[:attributes], x[:relationships]) processed << x diff --git a/lib/jsonapi_compliable/util/relationship_payload.rb b/lib/jsonapi_compliable/util/relationship_payload.rb index b581921..ed30db7 100644 --- a/lib/jsonapi_compliable/util/relationship_payload.rb +++ b/lib/jsonapi_compliable/util/relationship_payload.rb @@ -43,8 +43,14 @@ def should_yield?(type) end def payload_for(sideload, relationship_payload) + if sideload.polymorphic? + type = relationship_payload[:meta][:jsonapi_type] + sideload = sideload.polymorphic_child_for_type(type) + end + { sideload: sideload, + is_polymorphic: !sideload.parent.nil?, primary_key: sideload.primary_key, foreign_key: sideload.foreign_key, attributes: relationship_payload[:attributes], diff --git a/lib/jsonapi_compliable/util/validation_response.rb b/lib/jsonapi_compliable/util/validation_response.rb index 6670970..7085774 100644 --- a/lib/jsonapi_compliable/util/validation_response.rb +++ b/lib/jsonapi_compliable/util/validation_response.rb @@ -33,7 +33,8 @@ def to_a private def valid_object?(object) - object.respond_to?(:errors) && object.errors.blank? + !object.respond_to?(:errors) || + (object.respond_to?(:errors) && object.errors.blank?) end def all_valid?(model, deserialized_params) diff --git a/spec/fixtures/employee_directory.rb b/spec/fixtures/employee_directory.rb index b3d7edb..224dece 100644 --- a/spec/fixtures/employee_directory.rb +++ b/spec/fixtures/employee_directory.rb @@ -3,7 +3,17 @@ t.string :description end + create_table :offices do |t| + t.string :address + end + + create_table :home_offices do |t| + t.string :address + end + create_table :employees do |t| + t.string :workspace_type + t.integer :workspace_id t.integer :classification_id t.string :first_name t.string :last_name @@ -30,7 +40,16 @@ class Classification < ApplicationRecord validates :description, presence: true end +class Office < ApplicationRecord + has_many :employees, as: :workspace +end + +class HomeOffice < ApplicationRecord + has_many :employees, as: :workspace +end + class Employee < ApplicationRecord + belongs_to :workspace, polymorphic: true belongs_to :classification has_many :positions validates :first_name, presence: true @@ -69,6 +88,16 @@ class PositionResource < ApplicationResource resource: DepartmentResource end +class OfficeResource < ApplicationResource + type 'offices' + model Office +end + +class HomeOfficeResource < ApplicationResource + type 'home_offices' + model HomeOffice +end + class EmployeeResource < ApplicationResource type 'employees' model Employee @@ -81,6 +110,20 @@ class EmployeeResource < ApplicationResource scope: -> { Position.all }, foreign_key: :employee_id, resource: PositionResource + polymorphic_belongs_to :workspace, + group_by: :workspace_type, + groups: { + 'Office' => { + scope: -> { Office.all }, + resource: OfficeResource, + foreign_key: :workspace_id + }, + 'HomeOffice' => { + scope: -> { HomeOffice.all }, + resource: HomeOfficeResource, + foreign_key: :workspace_id + } + } end class SerializableAbstract < JSONAPI::Serializable::Resource diff --git a/spec/integration/rails/finders_spec.rb b/spec/integration/rails/finders_spec.rb index bc3303f..b4d5326 100644 --- a/spec/integration/rails/finders_spec.rb +++ b/spec/integration/rails/finders_spec.rb @@ -86,7 +86,7 @@ class AuthorResource < JsonapiCompliable::Resource foreign_key: { author_hobbies: :author_id } polymorphic_belongs_to :dwelling, - group_by: proc { |author| author.dwelling_type }, + group_by: :dwelling_type, groups: { 'House' => { foreign_key: :dwelling_id, diff --git a/spec/integration/rails/persistence_spec.rb b/spec/integration/rails/persistence_spec.rb index 205097c..899745d 100644 --- a/spec/integration/rails/persistence_spec.rb +++ b/spec/integration/rails/persistence_spec.rb @@ -149,6 +149,62 @@ def do_put(id) end end + describe 'nested polymorphic relationship' do + let(:workspace_type) { 'offices' } + + let(:payload) do + { + data: { + type: 'employees', + attributes: { first_name: 'Joe' }, + relationships: { + workspace: { + data: { + :'temp-id' => 'work1', type: workspace_type, method: 'create' + } + } + } + }, + included: [ + { + type: workspace_type, + :'temp-id' => 'work1', + attributes: { + address: 'Fake Workspace Address' + } + } + ] + } + end + + context 'with jsonapi type "offices"' do + it 'associates workspace as office' do + do_post + employee = Employee.first + expect(employee.workspace).to be_a(Office) + end + end + + context 'with jsonapi type "home_offices"' do + let(:workspace_type) { 'home_offices' } + + it 'associates workspace as home office' do + do_post + employee = Employee.first + expect(employee.workspace).to be_a(HomeOffice) + end + end + + it 'saves the relationship correctly' do + expect { + do_post + }.to change { Employee.count }.by(1) + employee = Employee.first + workspace = employee.workspace + expect(workspace.address).to eq('Fake Workspace Address') + end + end + describe 'nested create' do let(:payload) do { diff --git a/spec/sideload_spec.rb b/spec/sideload_spec.rb index 95c6ca3..dfe28b9 100644 --- a/spec/sideload_spec.rb +++ b/spec/sideload_spec.rb @@ -53,7 +53,7 @@ def foo end before do - instance.group_by { |parent| parent[:type] } + instance.group_by :type instance.allow_sideload 'foo', resource: foo_resource do scope { |parents| [{ parent_id: 1 }] } @@ -147,6 +147,30 @@ def foo end end + describe '#associate' do + before do + instance.instance_variable_set(:@type, :has_many) + end + + it 'delegates to adapter' do + expect(instance.resource_class.config[:adapter]) + .to receive(:associate).with('parent', 'child', :foo, :has_many) + instance.associate('parent', 'child') + end + + context 'when a polymorphic child' do + before do + instance.instance_variable_set(:@parent, double(name: :parent_name)) + end + + it 'passes parent name as association name' do + expect(instance.resource_class.config[:adapter]) + .to receive(:associate).with('parent', 'child', :parent_name, :has_many) + instance.associate('parent', 'child') + end + end + end + describe '#to_hash' do # Set this up to catch any recursive trap let(:resource) do diff --git a/spec/sideloading_spec.rb b/spec/sideloading_spec.rb index 5586a13..8f65d52 100644 --- a/spec/sideloading_spec.rb +++ b/spec/sideloading_spec.rb @@ -95,7 +95,7 @@ _dwelling_resource = dwelling_resource resource_class.allow_sideload :dwelling, polymorphic: true do - group_by { |author| binding.pry;author[:dwelling_type] } + group_by :dwelling_type allow_sideload 'House', resource: _dwelling_resource do scope do |authors| @@ -201,7 +201,7 @@ def json _dwelling_resource = dwelling_resource resource_class.allow_sideload :dwelling, polymorphic: true do - group_by { |author| author[:dwelling_type] } + group_by :dwelling_type allow_sideload 'House', resource: _dwelling_resource do scope do |authors|