Skip to content

Commit 3e0e104

Browse files
authored
Merge pull request #739 from cerebris/reflect_relationship
Add relationship reflection option
2 parents 672f946 + 7a285ef commit 3e0e104

13 files changed

Lines changed: 493 additions & 50 deletions

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,9 @@ The relationship methods (`relationship`, `has_one`, and `has_many`) support the
545545
`to_one` relationships support the additional option:
546546
* `foreign_key_on` - defaults to `:self`. To indicate that the foreign key is on the related resource specify `:related`.
547547

548+
`to_many` relationships support the additional option:
549+
* `reflect` - defaults to `true`. To indicate that updates to the relationship are performed on the related resource, if relationship reflection is turned on. See [Configuration] (#configuration)
550+
548551
Examples:
549552

550553
```ruby
@@ -1133,6 +1136,13 @@ Callbacks can be defined for the following `JSONAPI::Resource` events:
11331136
- `:remove_to_one_link`
11341137
- `:replace_fields`
11351138
1139+
###### Relationship Reflection
1140+
1141+
By default updates to relationships only invoke callbacks on the primary
1142+
Resource. By setting the `use_relationship_reflection` [Configuration] (#configuration) option
1143+
updates to `has_many` relationships will occur on the related resource, triggering
1144+
callbacks on both resources.
1145+
11361146
##### `JSONAPI::Processor` Callbacks
11371147
11381148
Callbacks can also be defined for `JSONAPI::Processor` events:
@@ -1944,13 +1954,18 @@ JSONAPI.configure do |config|
19441954
# NOTE: always_include_to_many_linkage_data is not currently implemented
19451955
config.always_include_to_one_linkage_data = false
19461956

1957+
# Relationship reflection invokes the related resource when updates
1958+
# are made to a has_many relationship. By default relationship_reflection
1959+
# is turned off because it imposes a small performance penalty.
1960+
config.use_relationship_reflection = false
1961+
19471962
# Allows transactions for creating and updating records
19481963
# Set this to false if your backend does not support transactions (e.g. Mongodb)
1949-
self.allow_transactions = true
1964+
config.allow_transactions = true
19501965

19511966
# Formatter Caching
19521967
# Set to false to disable caching of string operations on keys and links.
1953-
self.cache_formatters = true
1968+
config.cache_formatters = true
19541969
end
19551970
```
19561971

lib/jsonapi/configuration.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ class Configuration
2525
:exception_class_whitelist,
2626
:always_include_to_one_linkage_data,
2727
:always_include_to_many_linkage_data,
28-
:cache_formatters
28+
:cache_formatters,
29+
:use_relationship_reflection
2930

3031
def initialize
3132
#:underscored_key, :camelized_key, :dasherized_key, or custom
@@ -88,6 +89,11 @@ def initialize
8889
# Formatter Caching
8990
# Set to false to disable caching of string operations on keys and links.
9091
self.cache_formatters = true
92+
93+
# Relationship reflection invokes the related resource when updates
94+
# are made to a has_many relationship. By default relationship_reflection
95+
# is turned off because it imposes a small performance penalty.
96+
self.use_relationship_reflection = false
9197
end
9298

9399
def cache_formatters=(bool)
@@ -186,6 +192,8 @@ def default_processor_klass=(default_processor_klass)
186192
attr_writer :always_include_to_many_linkage_data
187193

188194
attr_writer :raise_if_parameters_not_allowed
195+
196+
attr_writer :use_relationship_reflection
189197
end
190198

191199
class << self

lib/jsonapi/relationship.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,14 @@ def polymorphic_type
7979
end
8080

8181
class ToMany < Relationship
82+
attr_reader :reflect, :inverse_relationship
83+
8284
def initialize(name, options = {})
8385
super
8486
@class_name = options.fetch(:class_name, name.to_s.camelize.singularize)
8587
@foreign_key ||= "#{name.to_s.singularize}_ids".to_sym
88+
@reflect = options.fetch(:reflect, true) == true
89+
@inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type.to_s.singularize.to_sym) if parent_resource
8690
end
8791
end
8892
end

lib/jsonapi/resource.rb

Lines changed: 119 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ class Resource
2222
def initialize(model, context)
2323
@model = model
2424
@context = context
25+
@reload_needed = false
26+
@changing = false
27+
@save_needed = false
2528
end
2629

2730
def _model
@@ -63,39 +66,39 @@ def remove
6366
end
6467
end
6568

66-
def create_to_many_links(relationship_type, relationship_key_values)
69+
def create_to_many_links(relationship_type, relationship_key_values, options = {})
6770
change :create_to_many_link do
68-
_create_to_many_links(relationship_type, relationship_key_values)
71+
_create_to_many_links(relationship_type, relationship_key_values, options)
6972
end
7073
end
7174

72-
def replace_to_many_links(relationship_type, relationship_key_values)
75+
def replace_to_many_links(relationship_type, relationship_key_values, options = {})
7376
change :replace_to_many_links do
74-
_replace_to_many_links(relationship_type, relationship_key_values)
77+
_replace_to_many_links(relationship_type, relationship_key_values, options)
7578
end
7679
end
7780

78-
def replace_to_one_link(relationship_type, relationship_key_value)
81+
def replace_to_one_link(relationship_type, relationship_key_value, options = {})
7982
change :replace_to_one_link do
80-
_replace_to_one_link(relationship_type, relationship_key_value)
83+
_replace_to_one_link(relationship_type, relationship_key_value, options)
8184
end
8285
end
8386

84-
def replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type)
87+
def replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type, options = {})
8588
change :replace_polymorphic_to_one_link do
86-
_replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type)
89+
_replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type, options)
8790
end
8891
end
8992

90-
def remove_to_many_link(relationship_type, key)
93+
def remove_to_many_link(relationship_type, key, options = {})
9194
change :remove_to_many_link do
92-
_remove_to_many_link(relationship_type, key)
95+
_remove_to_many_link(relationship_type, key, options)
9396
end
9497
end
9598

96-
def remove_to_one_link(relationship_type)
99+
def remove_to_one_link(relationship_type, options = {})
97100
change :remove_to_one_link do
98-
_remove_to_one_link(relationship_type)
101+
_remove_to_one_link(relationship_type, options)
99102
end
100103
end
101104

@@ -189,6 +192,7 @@ def _save(validation_context = nil)
189192

190193
if defined? @model.save
191194
saved = @model.save(validate: false)
195+
192196
unless saved
193197
if @model.errors.present?
194198
fail JSONAPI::Exceptions::ValidationErrors.new(self)
@@ -199,6 +203,8 @@ def _save(validation_context = nil)
199203
else
200204
saved = true
201205
end
206+
@model.reload if @reload_needed
207+
@reload_needed = false
202208

203209
@save_needed = !saved
204210

@@ -215,34 +221,87 @@ def _remove
215221
fail JSONAPI::Exceptions::RecordLocked.new(e.message)
216222
end
217223

218-
def _create_to_many_links(relationship_type, relationship_key_values)
224+
def reflect_relationship?(relationship, options)
225+
return false if !relationship.reflect ||
226+
(!JSONAPI.configuration.use_relationship_reflection || options[:reflected_source])
227+
228+
inverse_relationship = relationship.resource_klass._relationships[relationship.inverse_relationship]
229+
if inverse_relationship.nil?
230+
warn "Inverse relationship could not be found for #{self.class.name}.#{relationship.name}. Relationship reflection disabled."
231+
return false
232+
end
233+
true
234+
end
235+
236+
def _create_to_many_links(relationship_type, relationship_key_values, options)
219237
relationship = self.class._relationships[relationship_type]
220238

221-
relationship_key_values.each do |relationship_key_value|
222-
related_resource = relationship.resource_klass.find_by_key(relationship_key_value, context: @context)
239+
# check if relationship_key_values are already members of this relationship
240+
relation_name = relationship.relation_name(context: @context)
241+
existing_relations = @model.public_send(relation_name).where(relationship.primary_key => relationship_key_values)
242+
if existing_relations.count > 0
243+
# todo: obscure id so not to leak info
244+
fail JSONAPI::Exceptions::HasManyRelationExists.new(existing_relations.first.id)
245+
end
223246

224-
relation_name = relationship.relation_name(context: @context)
225-
# TODO: Add option to skip relations that already exist instead of returning an error?
226-
relation = @model.public_send(relation_name).where(relationship.primary_key => relationship_key_value).first
227-
if relation.nil?
228-
@model.public_send(relation_name) << related_resource._model
247+
if options[:reflected_source]
248+
@model.public_send(relation_name) << options[:reflected_source]._model
249+
return :completed
250+
end
251+
252+
# load requested related resources
253+
# make sure they all exist (also based on context) and add them to relationship
254+
255+
related_resources = relationship.resource_klass.find_by_keys(relationship_key_values, context: @context)
256+
257+
if related_resources.count != relationship_key_values.count
258+
# todo: obscure id so not to leak info
259+
fail JSONAPI::Exceptions::RecordNotFound.new('unspecified')
260+
end
261+
262+
reflect = reflect_relationship?(relationship, options)
263+
264+
related_resources.each do |related_resource|
265+
if reflect
266+
if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany)
267+
related_resource.create_to_many_links(relationship.inverse_relationship, [id], reflected_source: self)
268+
else
269+
related_resource.replace_to_one_link(relationship.inverse_relationship, id, reflected_source: self)
270+
end
271+
@reload_needed = true
229272
else
230-
fail JSONAPI::Exceptions::HasManyRelationExists.new(relationship_key_value)
273+
@model.public_send(relation_name) << related_resource._model
231274
end
232275
end
233276

234277
:completed
235278
end
236279

237-
def _replace_to_many_links(relationship_type, relationship_key_values)
280+
def _replace_to_many_links(relationship_type, relationship_key_values, options)
238281
relationship = self.class._relationships[relationship_type]
239-
send("#{relationship.foreign_key}=", relationship_key_values)
240-
@save_needed = true
282+
283+
reflect = reflect_relationship?(relationship, options)
284+
285+
if reflect
286+
existing = send("#{relationship.foreign_key}")
287+
to_delete = existing - (relationship_key_values & existing)
288+
to_delete.each do |key|
289+
_remove_to_many_link(relationship_type, key, reflected_source: self)
290+
end
291+
292+
to_add = relationship_key_values - (relationship_key_values & existing)
293+
_create_to_many_links(relationship_type, to_add, {})
294+
295+
@reload_needed = true
296+
else
297+
send("#{relationship.foreign_key}=", relationship_key_values)
298+
@save_needed = true
299+
end
241300

242301
:completed
243302
end
244303

245-
def _replace_to_one_link(relationship_type, relationship_key_value)
304+
def _replace_to_one_link(relationship_type, relationship_key_value, options)
246305
relationship = self.class._relationships[relationship_type]
247306

248307
send("#{relationship.foreign_key}=", relationship_key_value)
@@ -251,7 +310,7 @@ def _replace_to_one_link(relationship_type, relationship_key_value)
251310
:completed
252311
end
253312

254-
def _replace_polymorphic_to_one_link(relationship_type, key_value, key_type)
313+
def _replace_polymorphic_to_one_link(relationship_type, key_value, key_type, options)
255314
relationship = self.class._relationships[relationship_type.to_sym]
256315

257316
_model.public_send("#{relationship.foreign_key}=", key_value)
@@ -262,10 +321,29 @@ def _replace_polymorphic_to_one_link(relationship_type, key_value, key_type)
262321
:completed
263322
end
264323

265-
def _remove_to_many_link(relationship_type, key)
266-
relation_name = self.class._relationships[relationship_type].relation_name(context: @context)
324+
def _remove_to_many_link(relationship_type, key, options)
325+
relationship = self.class._relationships[relationship_type]
326+
327+
reflect = reflect_relationship?(relationship, options)
328+
329+
if reflect
330+
331+
related_resource = relationship.resource_klass.find_by_key(key, context: @context)
332+
333+
if related_resource.nil?
334+
fail JSONAPI::Exceptions::RecordNotFound.new(key)
335+
else
336+
if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany)
337+
related_resource.remove_to_many_link(relationship.inverse_relationship, id, reflected_source: self)
338+
else
339+
related_resource.remove_to_one_link(relationship.inverse_relationship, reflected_source: self)
340+
end
341+
end
267342

268-
@model.public_send(relation_name).delete(key)
343+
@reload_needed = true
344+
else
345+
@model.public_send(relationship.relation_name(context: @context)).delete(key)
346+
end
269347

270348
:completed
271349

@@ -275,7 +353,7 @@ def _remove_to_many_link(relationship_type, key)
275353
fail JSONAPI::Exceptions::RecordNotFound.new(key)
276354
end
277355

278-
def _remove_to_one_link(relationship_type)
356+
def _remove_to_one_link(relationship_type, options)
279357
relationship = self.class._relationships[relationship_type]
280358

281359
send("#{relationship.foreign_key}=", nil)
@@ -664,14 +742,21 @@ def find(filters, options = {})
664742
end
665743

666744
def resources_for(records, context)
667-
resources = []
668745
resource_classes = {}
669-
records.each do |model|
746+
records.collect do |model|
670747
resource_class = resource_classes[model.class] ||= self.resource_for_model(model)
671-
resources.push resource_class.new(model, context)
748+
resource_class.new(model, context)
672749
end
750+
end
673751

674-
resources
752+
def find_by_keys(keys, options = {})
753+
context = options[:context]
754+
records = records(options)
755+
records = apply_includes(records, options)
756+
models = records.where({_primary_key => keys})
757+
models.collect do |model|
758+
self.resource_for_model(model).new(model, context)
759+
end
675760
end
676761

677762
def find_by_key(key, options = {})

0 commit comments

Comments
 (0)