Skip to content

Commit 0738497

Browse files
committed
Merge pull request #287 from ggordon/polymorphic
Add support for has_one :x, polymorphic: true
2 parents 42a3c64 + 821aed6 commit 0738497

15 files changed

Lines changed: 522 additions & 50 deletions

README.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -239,31 +239,34 @@ end
239239
##### Options
240240

241241
The association methods support the following options:
242+
242243
* `class_name` - a string specifying the underlying class for the related resource
243-
* `foreign_key` - the method on the resource used to fetch the related resource. Defaults to `<resource_name>_id` for
244-
has_one and `<resource_name>_ids` for has_many relationships.
244+
* `foreign_key` - the method on the resource used to fetch the related resource. Defaults to `<resource_name>_id` for has_one and `<resource_name>_ids` for has_many relationships.
245245
* `acts_as_set` - allows the entire set of related records to be replaced in one operation. Defaults to false if not set.
246-
* `relation_name` - the name of the relation to use on the model. A lambda may be provided which allows conditional
247-
selection of the relation based on the context.
248-
246+
* `relation_name` - the name of the relation to use on the model. A lambda may be provided which allows conditional selection of the relation based on the context.
247+
* `polymorphic` - set to true to identify `has_one` associations that are polymorphic.
248+
249249
Examples:
250250

251251
```ruby
252-
class CommentResource < JSONAPI::Resource
252+
class CommentResource < JSONAPI::Resource
253253
attributes :body
254254
has_one :post
255255
has_one :author, class_name: 'Person'
256256
has_many :tags, acts_as_set: true
257-
end
258-
```
257+
end
259258

260-
```ruby
261259
class ExpenseEntryResource < JSONAPI::Resource
262260
attributes :cost, :transaction_date
263261

264262
has_one :currency, class_name: 'Currency', foreign_key: 'currency_code'
265263
has_one :employee
266264
end
265+
266+
class TagResource < JSONAPI::Resource
267+
attributes :name
268+
has_one :taggable, polymorphic: true
269+
end
267270
```
268271

269272
```ruby
@@ -283,8 +286,14 @@ class BookResource < JSONAPI::Resource
283286
}
284287
...
285288
end
286-
```
287289

290+
The polymorphic association will require the resource and controller to exist, although routing to them will cause an error.
291+
292+
```ruby
293+
class TaggableResource < JSONAPI::Resource; end
294+
class TaggablesController < JSONAPI::ResourceController; end
295+
```
296+
288297
#### Filters
289298

290299
Filters for locating objects of the resource type are specified in the resource definition. Single filters can be

lib/jsonapi/association.rb

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
module JSONAPI
22
class Association
3-
attr_reader :acts_as_set, :foreign_key, :type, :options, :name, :class_name
3+
attr_reader :acts_as_set, :foreign_key, :type, :options, :name,
4+
:class_name, :polymorphic
45

56
def initialize(name, options = {})
6-
@name = name.to_s
7-
@options = options
8-
@acts_as_set = options.fetch(:acts_as_set, false) == true
9-
@foreign_key = options[:foreign_key] ? options[:foreign_key].to_sym : nil
10-
@module_path = options[:module_path] || ''
11-
@relation_name = options.fetch(:relation_name, @name)
7+
@name = name.to_s
8+
@options = options
9+
@acts_as_set = options.fetch(:acts_as_set, false) == true
10+
@foreign_key = options[:foreign_key] ? options[:foreign_key].to_sym : nil
11+
@module_path = options[:module_path] || ''
12+
@relation_name = options.fetch(:relation_name, @name)
13+
@polymorphic = options.fetch(:polymorphic, false) == true
1214
end
1315

16+
alias_method :polymorphic?, :polymorphic
17+
1418
def primary_key
1519
@primary_key ||= resource_klass._primary_key
1620
end
@@ -32,13 +36,26 @@ def relation_name(options = {})
3236
end
3337
end
3438

39+
def type_for_source(source)
40+
if polymorphic?
41+
resource = source.public_send(name)
42+
resource.class._type if resource
43+
else
44+
type
45+
end
46+
end
47+
3548
class HasOne < Association
3649
def initialize(name, options = {})
3750
super
3851
@class_name = options.fetch(:class_name, name.to_s.camelize)
3952
@type = class_name.underscore.pluralize.to_sym
4053
@foreign_key ||= "#{name}_id".to_sym
4154
end
55+
56+
def polymorphic_type
57+
"#{type.to_s.singularize}_type" if polymorphic?
58+
end
4259
end
4360

4461
class HasMany < Association

lib/jsonapi/operation.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,25 @@ def apply
236236
end
237237
end
238238

239+
class ReplacePolymorphicHasOneAssociationOperation < Operation
240+
attr_reader :resource_id, :association_type, :key_value, :key_type
241+
242+
def initialize(resource_klass, options = {})
243+
@resource_id = options.fetch(:resource_id)
244+
@key_value = options.fetch(:key_value)
245+
@key_type = options.fetch(:key_type)
246+
@association_type = options.fetch(:association_type).to_sym
247+
super(resource_klass, options)
248+
end
249+
250+
def apply
251+
resource = @resource_klass.find_by_key(@resource_id, context: @context)
252+
result = resource.replace_polymorphic_has_one_link(@association_type, @key_value, @key_type)
253+
254+
return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted)
255+
end
256+
end
257+
239258
class CreateHasManyAssociationOperation < Operation
240259
attr_reader :resource_id, :association_type, :data
241260

lib/jsonapi/operations_processor.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class OperationsProcessor
1212
:remove_resource_operation,
1313
:replace_fields_operation,
1414
:replace_has_one_association_operation,
15+
:replace_polymorphic_has_one_association_operation,
1516
:create_has_many_association_operation,
1617
:replace_has_many_association_operation,
1718
:remove_has_many_association_operation,

lib/jsonapi/request.rb

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ def check_include(resource_klass, include_parts)
185185
association = resource_klass._association(association_name)
186186
if association && format_key(association_name) == include_parts.first
187187
unless include_parts.last.empty?
188-
check_include(Resource.resource_for(@resource_klass.module_path + association.class_name.to_s), include_parts.last.partition('.'))
188+
check_include(Resource.resource_for(@resource_klass.module_path + association.class_name.to_s.underscore), include_parts.last.partition('.'))
189189
end
190190
else
191191
@errors.concat(JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type),
@@ -403,11 +403,8 @@ def parse_params(params, allowed_fields)
403403
end
404404

405405
links_object = parse_has_one_links_object(linkage)
406-
# Since we do not yet support polymorphic associations we will raise an error if the type does not match the
407-
# association's type.
408-
# TODO: Support Polymorphic associations
409-
if links_object[:type] && (links_object[:type].to_s != association.type.to_s)
410-
fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type])
406+
if !association.polymorphic? && links_object[:type] && (links_object[:type].to_s != association.type.to_s)
407+
raise JSONAPI::Exceptions::TypeMismatch.new(links_object[:type])
411408
end
412409

413410
unless links_object[:id].nil?
@@ -520,18 +517,31 @@ def parse_add_association_operation(data, association_type, parent_key)
520517

521518
def parse_update_association_operation(data, association_type, parent_key)
522519
association = resource_klass._association(association_type)
523-
524520
if association.is_a?(JSONAPI::Association::HasOne)
525-
object_params = { relationships: { format_key(association.name) => { data: data } } }
526-
verified_param_set = parse_params(object_params, updatable_fields)
527-
528-
@operations.push JSONAPI::ReplaceHasOneAssociationOperation.new(
529-
resource_klass,
530-
context: @context,
531-
resource_id: parent_key,
532-
association_type: association_type,
533-
key_value: verified_param_set[:has_one].values[0]
534-
)
521+
if association.polymorphic?
522+
object_params = {relationships: {format_key(association.name) => {data: data}}}
523+
verified_param_set = parse_params(object_params, updatable_fields)
524+
525+
@operations.push JSONAPI::ReplacePolymorphicHasOneAssociationOperation.new(
526+
resource_klass,
527+
context: @context,
528+
resource_id: parent_key,
529+
association_type: association_type,
530+
key_value: verified_param_set[:has_one].values[0],
531+
key_type: data['type']
532+
)
533+
else
534+
object_params = {relationships: {format_key(association.name) => {data: data}}}
535+
verified_param_set = parse_params(object_params, updatable_fields)
536+
537+
@operations.push JSONAPI::ReplaceHasOneAssociationOperation.new(
538+
resource_klass,
539+
context: @context,
540+
resource_id: parent_key,
541+
association_type: association_type,
542+
key_value: verified_param_set[:has_one].values[0]
543+
)
544+
end
535545
else
536546
unless association.acts_as_set
537547
fail JSONAPI::Exceptions::HasManySetReplacementForbidden.new
@@ -541,12 +551,12 @@ def parse_update_association_operation(data, association_type, parent_key)
541551
verified_param_set = parse_params(object_params, updatable_fields)
542552

543553
@operations.push JSONAPI::ReplaceHasManyAssociationOperation.new(
544-
resource_klass,
545-
context: @context,
546-
resource_id: parent_key,
547-
association_type: association_type,
548-
data: verified_param_set[:has_many].values[0]
549-
)
554+
resource_klass,
555+
context: @context,
556+
resource_id: parent_key,
557+
association_type: association_type,
558+
data: verified_param_set[:has_many].values[0]
559+
)
550560
end
551561
end
552562

lib/jsonapi/resource.rb

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Resource
1717
:replace_has_many_links,
1818
:create_has_one_link,
1919
:replace_has_one_link,
20+
:replace_polymorphic_has_one_link,
2021
:remove_has_many_link,
2122
:remove_has_one_link,
2223
:replace_fields
@@ -79,6 +80,12 @@ def replace_has_one_link(association_type, association_key_value)
7980
end
8081
end
8182

83+
def replace_polymorphic_has_one_link(association_type, association_key_value, association_key_type)
84+
change :replace_polymorphic_has_one_link do
85+
_replace_polymorphic_has_one_link(association_type, association_key_value, association_key_type)
86+
end
87+
end
88+
8289
def remove_has_many_link(association_type, key)
8390
change :remove_has_many_link do
8491
_remove_has_many_link(association_type, key)
@@ -188,6 +195,17 @@ def _replace_has_one_link(association_type, association_key_value)
188195
:completed
189196
end
190197

198+
def _replace_polymorphic_has_one_link(association_type, key_value, key_type)
199+
association = self.class._associations[association_type]
200+
201+
send("#{association.foreign_key}=", key_value)
202+
send("#{association.polymorphic_type}=", key_type.singularize.capitalize)
203+
204+
@save_needed = true
205+
206+
:completed
207+
end
208+
191209
def _remove_has_many_link(association_type, key)
192210
association = self.class._associations[association_type]
193211

@@ -632,12 +650,11 @@ def _associate(klass, *attrs)
632650

633651
attrs.each do |attr|
634652
check_reserved_association_name(attr)
635-
636-
association = @_associations[attr] = klass.new(attr, options)
653+
@_associations[attr] = association = klass.new(attr, options)
637654

638655
associated_records_method_name = case association
639-
when JSONAPI::Association::HasOne then "record_for_#{attr}"
640-
when JSONAPI::Association::HasMany then "records_for_#{attr}"
656+
when JSONAPI::Association::HasOne then "record_for_#{attr}"
657+
when JSONAPI::Association::HasMany then "records_for_#{attr}"
641658
end
642659

643660
foreign_key = association.foreign_key
@@ -657,10 +674,16 @@ def _associate(klass, *attrs)
657674
end unless method_defined?(foreign_key)
658675

659676
define_method attr do |options = {}|
660-
resource_klass = association.resource_klass
661-
if resource_klass
677+
if association.polymorphic?
662678
associated_model = public_send(associated_records_method_name)
663-
return associated_model ? resource_klass.new(associated_model, @context) : nil
679+
resource_klass = Resource.resource_for(self.class.module_path + associated_model.class.to_s.underscore) if associated_model
680+
return resource_klass.new(associated_model, @context) if resource_klass
681+
else
682+
resource_klass = association.resource_klass
683+
if resource_klass
684+
associated_model = public_send(associated_records_method_name)
685+
return associated_model ? resource_klass.new(associated_model, @context) : nil
686+
end
664687
end
665688
end unless method_defined?(attr)
666689
elsif association.is_a?(JSONAPI::Association::HasMany)

lib/jsonapi/resource_serializer.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ def relationship_data(source, include_directives)
168168
resource = source.send(name)
169169
if resource
170170
id = resource.id
171+
type = association.type_for_source(source)
171172
associations_only = already_serialized?(type, id)
172173
if include_linkage && !associations_only
173174
add_included_object(type, id, object_hash(resource, ia))
@@ -232,7 +233,7 @@ def has_one_linkage(source, association)
232233
linkage = {}
233234
linkage_id = foreign_key_value(source, association)
234235
if linkage_id
235-
linkage[:type] = format_key(association.type)
236+
linkage[:type] = format_key(association.type_for_source(source))
236237
linkage[:id] = linkage_id
237238
else
238239
linkage = nil

lib/jsonapi/routing_ext.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,13 @@ def jsonapi_related_resource(*association)
163163
association = source._associations[association_name]
164164

165165
formatted_association_name = format_route(association.name)
166-
related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(association.class_name.underscore.pluralize))
167-
options[:controller] ||= related_resource._type.to_s
166+
167+
if association.polymorphic?
168+
options[:controller] ||= association.class_name.underscore.pluralize
169+
else
170+
related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(association.class_name.underscore.pluralize))
171+
options[:controller] ||= related_resource._type.to_s
172+
end
168173

169174
match "#{formatted_association_name}", controller: options[:controller],
170175
association: association.name, source: resource_type_with_module_prefix(source._type),

0 commit comments

Comments
 (0)