jsonapi-resources 0.8.3 → 0.9.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +2124 -8
- data/lib/jsonapi-resources.rb +2 -0
- data/lib/jsonapi/acts_as_resource_controller.rb +70 -29
- data/lib/jsonapi/cached_resource_fragment.rb +119 -0
- data/lib/jsonapi/compiled_json.rb +36 -0
- data/lib/jsonapi/configuration.rb +54 -4
- data/lib/jsonapi/error_codes.rb +2 -2
- data/lib/jsonapi/exceptions.rb +19 -13
- data/lib/jsonapi/formatter.rb +15 -1
- data/lib/jsonapi/include_directives.rb +23 -3
- data/lib/jsonapi/processor.rb +69 -27
- data/lib/jsonapi/relationship_builder.rb +23 -21
- data/lib/jsonapi/request_parser.rb +27 -72
- data/lib/jsonapi/resource.rb +234 -38
- data/lib/jsonapi/resource_serializer.rb +229 -95
- data/lib/jsonapi/resources/version.rb +1 -1
- data/lib/jsonapi/response_document.rb +9 -20
- metadata +25 -9
data/lib/jsonapi/resource.rb
CHANGED
@@ -36,6 +36,10 @@ module JSONAPI
|
|
36
36
|
_model.public_send(self.class._primary_key)
|
37
37
|
end
|
38
38
|
|
39
|
+
def cache_id
|
40
|
+
[id, _model.public_send(self.class._cache_field)]
|
41
|
+
end
|
42
|
+
|
39
43
|
def is_new?
|
40
44
|
id.nil?
|
41
45
|
end
|
@@ -165,6 +169,11 @@ module JSONAPI
|
|
165
169
|
{}
|
166
170
|
end
|
167
171
|
|
172
|
+
def preloaded_fragments
|
173
|
+
# A hash of hashes
|
174
|
+
@preloaded_fragments ||= Hash.new
|
175
|
+
end
|
176
|
+
|
168
177
|
private
|
169
178
|
|
170
179
|
def save
|
@@ -315,7 +324,7 @@ module JSONAPI
|
|
315
324
|
relationship = self.class._relationships[relationship_type.to_sym]
|
316
325
|
|
317
326
|
_model.public_send("#{relationship.foreign_key}=", key_value)
|
318
|
-
_model.public_send("#{relationship.polymorphic_type}=", key_type
|
327
|
+
_model.public_send("#{relationship.polymorphic_type}=", _model_class_name(key_type))
|
319
328
|
|
320
329
|
@save_needed = true
|
321
330
|
|
@@ -395,10 +404,17 @@ module JSONAPI
|
|
395
404
|
:completed
|
396
405
|
end
|
397
406
|
|
407
|
+
def _model_class_name(key_type)
|
408
|
+
type_class_name = key_type.to_s.classify
|
409
|
+
resource = self.class.resource_for(type_class_name)
|
410
|
+
resource ? resource._model_name.to_s : type_class_name
|
411
|
+
end
|
412
|
+
|
398
413
|
class << self
|
399
414
|
def inherited(subclass)
|
400
415
|
subclass.abstract(false)
|
401
416
|
subclass.immutable(false)
|
417
|
+
subclass.caching(false)
|
402
418
|
subclass._attributes = (_attributes || {}).dup
|
403
419
|
subclass._model_hints = (_model_hints || {}).dup
|
404
420
|
|
@@ -417,9 +433,7 @@ module JSONAPI
|
|
417
433
|
type = subclass.name.demodulize.sub(/Resource$/, '').underscore
|
418
434
|
subclass._type = type.pluralize.to_sym
|
419
435
|
|
420
|
-
|
421
|
-
subclass.attribute :id, format: :id
|
422
|
-
end
|
436
|
+
subclass.attribute :id, format: :id
|
423
437
|
|
424
438
|
check_reserved_resource_name(subclass._type, subclass.name)
|
425
439
|
end
|
@@ -453,7 +467,8 @@ module JSONAPI
|
|
453
467
|
end
|
454
468
|
end
|
455
469
|
|
456
|
-
attr_accessor :_attributes, :_relationships, :
|
470
|
+
attr_accessor :_attributes, :_relationships, :_type, :_model_hints
|
471
|
+
attr_writer :_allowed_filters, :_paginator
|
457
472
|
|
458
473
|
def create(context)
|
459
474
|
new(create_model, context)
|
@@ -559,20 +574,9 @@ module JSONAPI
|
|
559
574
|
@_primary_key = key.to_sym
|
560
575
|
end
|
561
576
|
|
562
|
-
|
563
|
-
|
564
|
-
def method_missing(method, *args)
|
565
|
-
if method.to_s.match /createable_fields/
|
566
|
-
ActiveSupport::Deprecation.warn('`createable_fields` is deprecated, please use `creatable_fields` instead')
|
567
|
-
creatable_fields(*args)
|
568
|
-
elsif method.to_s.match /updateable_fields/
|
569
|
-
ActiveSupport::Deprecation.warn('`updateable_fields` is deprecated, please use `updatable_fields` instead')
|
570
|
-
updatable_fields(*args)
|
571
|
-
else
|
572
|
-
super
|
573
|
-
end
|
577
|
+
def cache_field(field)
|
578
|
+
@_cache_field = field.to_sym
|
574
579
|
end
|
575
|
-
# :nocov:
|
576
580
|
|
577
581
|
# Override in your resource to filter the updatable keys
|
578
582
|
def updatable_fields(_context = nil)
|
@@ -581,7 +585,7 @@ module JSONAPI
|
|
581
585
|
|
582
586
|
# Override in your resource to filter the creatable keys
|
583
587
|
def creatable_fields(_context = nil)
|
584
|
-
_updatable_relationships | _attributes.keys
|
588
|
+
_updatable_relationships | _attributes.keys
|
585
589
|
end
|
586
590
|
|
587
591
|
# Override in your resource to filter the sortable keys
|
@@ -729,19 +733,8 @@ module JSONAPI
|
|
729
733
|
count_records(filter_records(filters, options))
|
730
734
|
end
|
731
735
|
|
732
|
-
# Override this method if you have more complex requirements than this basic find method provides
|
733
736
|
def find(filters, options = {})
|
734
|
-
|
735
|
-
|
736
|
-
records = filter_records(filters, options)
|
737
|
-
|
738
|
-
sort_criteria = options.fetch(:sort_criteria) { [] }
|
739
|
-
order_options = construct_order_options(sort_criteria)
|
740
|
-
records = sort_records(records, order_options, context)
|
741
|
-
|
742
|
-
records = apply_pagination(records, options[:paginator], order_options)
|
743
|
-
|
744
|
-
resources_for(records, context)
|
737
|
+
resources_for(find_records(filters, options), options[:context])
|
745
738
|
end
|
746
739
|
|
747
740
|
def resources_for(records, context)
|
@@ -761,17 +754,39 @@ module JSONAPI
|
|
761
754
|
end
|
762
755
|
end
|
763
756
|
|
757
|
+
def find_serialized_with_caching(filters_or_source, serializer, options = {})
|
758
|
+
if filters_or_source.is_a?(ActiveRecord::Relation)
|
759
|
+
records = filters_or_source
|
760
|
+
elsif _model_class.respond_to?(:all) && _model_class.respond_to?(:arel_table)
|
761
|
+
records = find_records(filters_or_source, options.except(:include_directives))
|
762
|
+
else
|
763
|
+
records = find(filters_or_source, options)
|
764
|
+
end
|
765
|
+
cached_resources_for(records, serializer, options)
|
766
|
+
end
|
767
|
+
|
764
768
|
def find_by_key(key, options = {})
|
765
769
|
context = options[:context]
|
766
|
-
records =
|
767
|
-
|
768
|
-
model = records.where({_primary_key => key}).first
|
770
|
+
records = find_records({_primary_key => key}, options.except(:paginator, :sort_criteria))
|
771
|
+
model = records.first
|
769
772
|
fail JSONAPI::Exceptions::RecordNotFound.new(key) if model.nil?
|
770
773
|
self.resource_for_model(model).new(model, context)
|
771
774
|
end
|
772
775
|
|
776
|
+
def find_by_key_serialized_with_caching(key, serializer, options = {})
|
777
|
+
if _model_class.respond_to?(:all) && _model_class.respond_to?(:arel_table)
|
778
|
+
results = find_serialized_with_caching({_primary_key => key}, serializer, options)
|
779
|
+
result = results.first
|
780
|
+
fail JSONAPI::Exceptions::RecordNotFound.new(key) if result.nil?
|
781
|
+
return result
|
782
|
+
else
|
783
|
+
resource = find_by_key(key, options)
|
784
|
+
return cached_resources_for([resource], serializer, options).first
|
785
|
+
end
|
786
|
+
end
|
787
|
+
|
773
788
|
# Override this method if you want to customize the relation for
|
774
|
-
# finder methods (find, find_by_key)
|
789
|
+
# finder methods (find, find_by_key, find_serialized_with_caching)
|
775
790
|
def records(_options = {})
|
776
791
|
_model_class.all
|
777
792
|
end
|
@@ -882,13 +897,24 @@ module JSONAPI
|
|
882
897
|
end
|
883
898
|
|
884
899
|
def _model_name
|
885
|
-
_abstract
|
900
|
+
if _abstract
|
901
|
+
return ''
|
902
|
+
else
|
903
|
+
return @_model_name if defined?(@_model_name)
|
904
|
+
class_name = self.name
|
905
|
+
return '' if class_name.nil?
|
906
|
+
return @_model_name = class_name.demodulize.sub(/Resource$/, '')
|
907
|
+
end
|
886
908
|
end
|
887
909
|
|
888
910
|
def _primary_key
|
889
911
|
@_primary_key ||= _model_class.respond_to?(:primary_key) ? _model_class.primary_key : :id
|
890
912
|
end
|
891
913
|
|
914
|
+
def _cache_field
|
915
|
+
@_cache_field ||= JSONAPI.configuration.default_resource_cache_field
|
916
|
+
end
|
917
|
+
|
892
918
|
def _table_name
|
893
919
|
@_table_name ||= _model_class.respond_to?(:table_name) ? _model_class.table_name : _model_name.tableize
|
894
920
|
end
|
@@ -898,7 +924,7 @@ module JSONAPI
|
|
898
924
|
end
|
899
925
|
|
900
926
|
def _allowed_filters
|
901
|
-
|
927
|
+
defined?(@_allowed_filters) ? @_allowed_filters : { id: {} }
|
902
928
|
end
|
903
929
|
|
904
930
|
def _paginator
|
@@ -929,10 +955,27 @@ module JSONAPI
|
|
929
955
|
!@immutable
|
930
956
|
end
|
931
957
|
|
958
|
+
def caching(val = true)
|
959
|
+
@caching = val
|
960
|
+
end
|
961
|
+
|
962
|
+
def _caching
|
963
|
+
@caching
|
964
|
+
end
|
965
|
+
|
966
|
+
def caching?
|
967
|
+
@caching && !JSONAPI.configuration.resource_cache.nil?
|
968
|
+
end
|
969
|
+
|
970
|
+
def attribute_caching_context(context)
|
971
|
+
nil
|
972
|
+
end
|
973
|
+
|
932
974
|
def _model_class
|
933
975
|
return nil if _abstract
|
934
976
|
|
935
|
-
return @model if @model
|
977
|
+
return @model if defined?(@model)
|
978
|
+
return nil if self.name.to_s.blank? && _model_name.to_s.blank?
|
936
979
|
@model = _model_name.to_s.safe_constantize
|
937
980
|
warn "[MODEL NOT FOUND] Model could not be found for #{self.name}. If this a base Resource declare it as abstract." if @model.nil?
|
938
981
|
@model
|
@@ -989,6 +1032,36 @@ module JSONAPI
|
|
989
1032
|
|
990
1033
|
private
|
991
1034
|
|
1035
|
+
def cached_resources_for(records, serializer, options)
|
1036
|
+
if records.is_a?(Array) && records.all?{|rec| rec.is_a?(JSONAPI::Resource)}
|
1037
|
+
resources = records.map{|r| [r.id, r] }.to_h
|
1038
|
+
elsif self.caching?
|
1039
|
+
t = _model_class.arel_table
|
1040
|
+
cache_ids = pluck_arel_attributes(records, t[_primary_key], t[_cache_field])
|
1041
|
+
resources = CachedResourceFragment.fetch_fragments(self, serializer, options[:context], cache_ids)
|
1042
|
+
else
|
1043
|
+
resources = resources_for(records, options).map{|r| [r.id, r] }.to_h
|
1044
|
+
end
|
1045
|
+
|
1046
|
+
preload_included_fragments(resources, records, serializer, options)
|
1047
|
+
|
1048
|
+
resources.values
|
1049
|
+
end
|
1050
|
+
|
1051
|
+
def find_records(filters, options = {})
|
1052
|
+
context = options[:context]
|
1053
|
+
|
1054
|
+
records = filter_records(filters, options)
|
1055
|
+
|
1056
|
+
sort_criteria = options.fetch(:sort_criteria) { [] }
|
1057
|
+
order_options = construct_order_options(sort_criteria)
|
1058
|
+
records = sort_records(records, order_options, context)
|
1059
|
+
|
1060
|
+
records = apply_pagination(records, options[:paginator], order_options)
|
1061
|
+
|
1062
|
+
records
|
1063
|
+
end
|
1064
|
+
|
992
1065
|
def check_reserved_resource_name(type, name)
|
993
1066
|
if [:ids, :types, :hrefs, :links].include?(type)
|
994
1067
|
warn "[NAME COLLISION] `#{name}` is a reserved resource name."
|
@@ -1021,6 +1094,129 @@ module JSONAPI
|
|
1021
1094
|
warn "[DUPLICATE ATTRIBUTE] `#{name}` has already been defined in #{_resource_name_from_type(_type)}."
|
1022
1095
|
end
|
1023
1096
|
end
|
1097
|
+
|
1098
|
+
def preload_included_fragments(resources, records, serializer, options)
|
1099
|
+
return if resources.empty?
|
1100
|
+
res_ids = resources.keys
|
1101
|
+
|
1102
|
+
include_directives = options[:include_directives]
|
1103
|
+
return unless include_directives
|
1104
|
+
|
1105
|
+
relevant_options = options.except(:include_directives, :order, :paginator)
|
1106
|
+
context = options[:context]
|
1107
|
+
|
1108
|
+
# For each association, including indirect associations, find the target record ids.
|
1109
|
+
# Even if a target class doesn't have caching enabled, we still have to look up
|
1110
|
+
# and match the target ids here, because we can't use ActiveRecord#includes.
|
1111
|
+
#
|
1112
|
+
# Note that `paths` returns partial paths before complete paths, so e.g. the partial
|
1113
|
+
# fragments for posts.comments will exist before we start working with posts.comments.author
|
1114
|
+
target_resources = {}
|
1115
|
+
include_directives.paths.each do |path|
|
1116
|
+
# If path is [:posts, :comments, :author], then...
|
1117
|
+
pluck_attrs = [] # ...will be [posts.id, comments.id, authors.id, authors.updated_at]
|
1118
|
+
pluck_attrs << self._model_class.arel_table[self._primary_key]
|
1119
|
+
|
1120
|
+
relation = records
|
1121
|
+
.except(:limit, :offset, :order)
|
1122
|
+
.where({_primary_key => res_ids})
|
1123
|
+
|
1124
|
+
# These are updated as we iterate through the association path; afterwards they will
|
1125
|
+
# refer to the final resource on the path, i.e. the actual resource to find in the cache.
|
1126
|
+
# So e.g. if path is [:posts, :comments, :author], then after iteration...
|
1127
|
+
parent_klass = nil # Comment
|
1128
|
+
klass = self # Person
|
1129
|
+
relationship = nil # JSONAPI::Relationship::ToOne for CommentResource.author
|
1130
|
+
table = nil # people
|
1131
|
+
assocs_path = [] # [ :posts, :approved_comments, :author ]
|
1132
|
+
ar_hash = nil # { :posts => { :approved_comments => :author } }
|
1133
|
+
|
1134
|
+
# For each step on the path, figure out what the actual table name/alias in the join
|
1135
|
+
# will be, and include the primary key of that table in our list of fields to select
|
1136
|
+
path.each do |elem|
|
1137
|
+
relationship = klass._relationships[elem]
|
1138
|
+
assocs_path << relationship.relation_name(options).to_sym
|
1139
|
+
# Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }}
|
1140
|
+
ar_hash = assocs_path.reverse.reduce{|memo, step| { step => memo } }
|
1141
|
+
# We can't just look up the table name from the resource class, because Arel could
|
1142
|
+
# have used a table alias if the relation includes a self-reference.
|
1143
|
+
join_source = relation.joins(ar_hash).arel.source.right.reverse.find do |arel_node|
|
1144
|
+
arel_node.is_a?(Arel::Nodes::InnerJoin)
|
1145
|
+
end
|
1146
|
+
table = join_source.left
|
1147
|
+
parent_klass = klass
|
1148
|
+
klass = relationship.resource_klass
|
1149
|
+
pluck_attrs << table[klass._primary_key]
|
1150
|
+
end
|
1151
|
+
|
1152
|
+
# Pre-fill empty hashes for each resource up to the end of the path.
|
1153
|
+
# This allows us to later distinguish between a preload that returned nothing
|
1154
|
+
# vs. a preload that never ran.
|
1155
|
+
prefilling_resources = resources.values
|
1156
|
+
path.each do |rel_name|
|
1157
|
+
rel_name = serializer.key_formatter.format(rel_name)
|
1158
|
+
prefilling_resources.map! do |res|
|
1159
|
+
res.preloaded_fragments[rel_name] ||= {}
|
1160
|
+
res.preloaded_fragments[rel_name].values
|
1161
|
+
end
|
1162
|
+
prefilling_resources.flatten!(1)
|
1163
|
+
end
|
1164
|
+
|
1165
|
+
pluck_attrs << table[klass._cache_field] if klass.caching?
|
1166
|
+
relation = relation.joins(ar_hash)
|
1167
|
+
if relationship.is_a?(JSONAPI::Relationship::ToMany)
|
1168
|
+
# Rails doesn't include order clauses in `joins`, so we have to add that manually here.
|
1169
|
+
# FIXME Should find a better way to reflect on relationship ordering. :-(
|
1170
|
+
relation = relation.order(parent_klass._model_class.new.send(assocs_path.last).arel.orders)
|
1171
|
+
end
|
1172
|
+
|
1173
|
+
# [[post id, comment id, author id, author updated_at], ...]
|
1174
|
+
id_rows = pluck_arel_attributes(relation.joins(ar_hash), *pluck_attrs)
|
1175
|
+
|
1176
|
+
target_resources[klass.name] ||= {}
|
1177
|
+
|
1178
|
+
if klass.caching?
|
1179
|
+
sub_cache_ids = id_rows
|
1180
|
+
.map{|row| row.last(2) }
|
1181
|
+
.reject{|row| target_resources[klass.name].has_key?(row.first) }
|
1182
|
+
.uniq
|
1183
|
+
target_resources[klass.name].merge! CachedResourceFragment.fetch_fragments(
|
1184
|
+
klass, serializer, context, sub_cache_ids
|
1185
|
+
)
|
1186
|
+
else
|
1187
|
+
sub_res_ids = id_rows
|
1188
|
+
.map(&:last)
|
1189
|
+
.reject{|id| target_resources[klass.name].has_key?(id) }
|
1190
|
+
.uniq
|
1191
|
+
found = klass.find({klass._primary_key => sub_res_ids}, relevant_options)
|
1192
|
+
target_resources[klass.name].merge! found.map{|r| [r.id, r] }.to_h
|
1193
|
+
end
|
1194
|
+
|
1195
|
+
id_rows.each do |row|
|
1196
|
+
res = resources[row.first]
|
1197
|
+
path.each_with_index do |rel_name, index|
|
1198
|
+
rel_name = serializer.key_formatter.format(rel_name)
|
1199
|
+
rel_id = row[index+1]
|
1200
|
+
assoc_rels = res.preloaded_fragments[rel_name]
|
1201
|
+
if index == path.length - 1
|
1202
|
+
assoc_rels[rel_id] = target_resources[klass.name].fetch(rel_id)
|
1203
|
+
else
|
1204
|
+
res = assoc_rels[rel_id]
|
1205
|
+
end
|
1206
|
+
end
|
1207
|
+
end
|
1208
|
+
end
|
1209
|
+
end
|
1210
|
+
|
1211
|
+
def pluck_arel_attributes(relation, *attrs)
|
1212
|
+
conn = relation.connection
|
1213
|
+
quoted_attrs = attrs.map do |attr|
|
1214
|
+
quoted_table = conn.quote_table_name(attr.relation.table_alias || attr.relation.name)
|
1215
|
+
quoted_column = conn.quote_column_name(attr.name)
|
1216
|
+
"#{quoted_table}.#{quoted_column}"
|
1217
|
+
end
|
1218
|
+
relation.pluck(*quoted_attrs)
|
1219
|
+
end
|
1024
1220
|
end
|
1025
1221
|
end
|
1026
1222
|
end
|
@@ -1,7 +1,9 @@
|
|
1
1
|
module JSONAPI
|
2
2
|
class ResourceSerializer
|
3
3
|
|
4
|
-
attr_reader :link_builder, :key_formatter, :serialization_options, :primary_class_name
|
4
|
+
attr_reader :link_builder, :key_formatter, :serialization_options, :primary_class_name,
|
5
|
+
:fields, :include_directives, :always_include_to_one_linkage_data,
|
6
|
+
:always_include_to_many_linkage_data
|
5
7
|
|
6
8
|
# initialize
|
7
9
|
# Options can include
|
@@ -13,7 +15,7 @@ module JSONAPI
|
|
13
15
|
# relationship ids in the links section for a resource. Fields are global for a resource type.
|
14
16
|
# Example: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]}
|
15
17
|
# key_formatter: KeyFormatter instance to override the default configuration
|
16
|
-
#
|
18
|
+
# serialization_options: additional options that will be passed to resource meta and links lambdas
|
17
19
|
|
18
20
|
def initialize(primary_resource_klass, options = {})
|
19
21
|
@primary_resource_klass = primary_resource_klass
|
@@ -21,6 +23,7 @@ module JSONAPI
|
|
21
23
|
@fields = options.fetch(:fields, {})
|
22
24
|
@include = options.fetch(:include, [])
|
23
25
|
@include_directives = options[:include_directives]
|
26
|
+
@include_directives ||= JSONAPI::IncludeDirectives.new(@primary_resource_klass, @include)
|
24
27
|
@key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter)
|
25
28
|
@id_formatter = ValueFormatter.value_formatter_for(:id)
|
26
29
|
@link_builder = generate_link_builder(primary_resource_klass, options)
|
@@ -33,16 +36,19 @@ module JSONAPI
|
|
33
36
|
# Warning: This makes ResourceSerializer non-thread-safe. That's not a problem with the
|
34
37
|
# request-specific way it's currently used, though.
|
35
38
|
@value_formatter_type_cache = NaiveCache.new{|arg| ValueFormatter.value_formatter_for(arg) }
|
39
|
+
|
40
|
+
@_config_keys = {}
|
41
|
+
@_supplying_attribute_fields = {}
|
42
|
+
@_supplying_relationship_fields = {}
|
36
43
|
end
|
37
44
|
|
38
45
|
# Converts a single resource, or an array of resources to a hash, conforming to the JSONAPI structure
|
39
46
|
def serialize_to_hash(source)
|
40
|
-
@top_level_sources = Set.new([source].flatten.compact.map {|s| top_level_source_key(s) })
|
47
|
+
@top_level_sources = Set.new([source].flatten(1).compact.map {|s| top_level_source_key(s) })
|
41
48
|
|
42
49
|
is_resource_collection = source.respond_to?(:to_ary)
|
43
50
|
|
44
51
|
@included_objects = {}
|
45
|
-
@include_directives ||= JSONAPI::IncludeDirectives.new(@primary_resource_klass, @include)
|
46
52
|
|
47
53
|
process_primary(source, @include_directives.include_directives)
|
48
54
|
|
@@ -92,67 +98,120 @@ module JSONAPI
|
|
92
98
|
@value_formatter_type_cache.get(format).format(value)
|
93
99
|
end
|
94
100
|
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
# The fields options controls both fields and included links references.
|
101
|
-
def process_primary(source, include_directives)
|
102
|
-
if source.respond_to?(:to_ary)
|
103
|
-
source.each { |resource| process_primary(resource, include_directives) }
|
104
|
-
else
|
105
|
-
return {} if source.nil?
|
106
|
-
|
107
|
-
resource = source
|
108
|
-
id = resource.id
|
109
|
-
add_included_object(id, object_hash(source, include_directives), true)
|
101
|
+
def config_key(resource_klass)
|
102
|
+
@_config_keys.fetch resource_klass do
|
103
|
+
desc = self.config_description(resource_klass).map(&:inspect).join(",")
|
104
|
+
key = JSONAPI.configuration.resource_cache_digest_function.call(desc)
|
105
|
+
@_config_keys[resource_klass] = "SRLZ-#{key}"
|
110
106
|
end
|
111
107
|
end
|
112
108
|
|
109
|
+
def config_description(resource_klass)
|
110
|
+
{
|
111
|
+
class_name: self.class.name,
|
112
|
+
seriserialization_options: serialization_options.sort.map(&:as_json),
|
113
|
+
supplying_attribute_fields: supplying_attribute_fields(resource_klass).sort,
|
114
|
+
supplying_relationship_fields: supplying_relationship_fields(resource_klass).sort,
|
115
|
+
link_builder_base_url: link_builder.base_url,
|
116
|
+
route_formatter_class: link_builder.route_formatter.uncached.class.name,
|
117
|
+
key_formatter_class: key_formatter.uncached.class.name,
|
118
|
+
always_include_to_one_linkage_data: always_include_to_one_linkage_data,
|
119
|
+
always_include_to_many_linkage_data: always_include_to_many_linkage_data
|
120
|
+
}
|
121
|
+
end
|
122
|
+
|
113
123
|
# Returns a serialized hash for the source model
|
114
|
-
def object_hash(source, include_directives)
|
124
|
+
def object_hash(source, include_directives = {})
|
115
125
|
obj_hash = {}
|
116
126
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
obj_hash['id'] = format_value(source.id, id_format)
|
127
|
+
if source.is_a?(JSONAPI::CachedResourceFragment)
|
128
|
+
obj_hash['id'] = source.id
|
129
|
+
obj_hash['type'] = source.type
|
121
130
|
|
122
|
-
|
131
|
+
obj_hash['links'] = source.links_json if source.links_json
|
132
|
+
obj_hash['attributes'] = source.attributes_json if source.attributes_json
|
123
133
|
|
124
|
-
|
125
|
-
|
134
|
+
relationships = cached_relationships_hash(source, include_directives)
|
135
|
+
obj_hash['relationships'] = relationships unless relationships.empty?
|
126
136
|
|
127
|
-
|
128
|
-
|
137
|
+
obj_hash['meta'] = source.meta_json if source.meta_json
|
138
|
+
else
|
139
|
+
fetchable_fields = Set.new(source.fetchable_fields)
|
129
140
|
|
130
|
-
|
131
|
-
|
141
|
+
# TODO Should this maybe be using @id_formatter instead, for consistency?
|
142
|
+
id_format = source.class._attribute_options(:id)[:format]
|
143
|
+
# protect against ids that were declared as an attribute, but did not have a format set.
|
144
|
+
id_format = 'id' if id_format == :default
|
145
|
+
obj_hash['id'] = format_value(source.id, id_format)
|
132
146
|
|
133
|
-
|
134
|
-
|
147
|
+
obj_hash['type'] = format_key(source.class._type.to_s)
|
148
|
+
|
149
|
+
links = links_hash(source)
|
150
|
+
obj_hash['links'] = links unless links.empty?
|
151
|
+
|
152
|
+
attributes = attributes_hash(source, fetchable_fields)
|
153
|
+
obj_hash['attributes'] = attributes unless attributes.empty?
|
154
|
+
|
155
|
+
relationships = relationships_hash(source, fetchable_fields, include_directives)
|
156
|
+
obj_hash['relationships'] = relationships unless relationships.nil? || relationships.empty?
|
157
|
+
|
158
|
+
meta = meta_hash(source)
|
159
|
+
obj_hash['meta'] = meta unless meta.empty?
|
160
|
+
end
|
135
161
|
|
136
162
|
obj_hash
|
137
163
|
end
|
138
164
|
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
165
|
+
private
|
166
|
+
|
167
|
+
# Process the primary source object(s). This will then serialize associated object recursively based on the
|
168
|
+
# requested includes. Fields are controlled fields option for each resource type, such
|
169
|
+
# as fields: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]}
|
170
|
+
# The fields options controls both fields and included links references.
|
171
|
+
def process_primary(source, include_directives)
|
172
|
+
if source.respond_to?(:to_ary)
|
173
|
+
source.each { |resource| process_primary(resource, include_directives) }
|
174
|
+
else
|
175
|
+
return {} if source.nil?
|
176
|
+
add_resource(source, include_directives, true)
|
145
177
|
end
|
146
178
|
end
|
147
179
|
|
148
|
-
def
|
149
|
-
|
150
|
-
|
151
|
-
|
180
|
+
def supplying_attribute_fields(resource_klass)
|
181
|
+
@_supplying_attribute_fields.fetch resource_klass do
|
182
|
+
attrs = Set.new(resource_klass._attributes.keys.map(&:to_sym))
|
183
|
+
cur = resource_klass
|
184
|
+
while cur != JSONAPI::Resource
|
185
|
+
if @fields.has_key?(cur._type)
|
186
|
+
attrs &= @fields[cur._type]
|
187
|
+
break
|
188
|
+
end
|
189
|
+
cur = cur.superclass
|
190
|
+
end
|
191
|
+
@_supplying_attribute_fields[resource_klass] = attrs
|
192
|
+
end
|
193
|
+
end
|
152
194
|
|
195
|
+
def supplying_relationship_fields(resource_klass)
|
196
|
+
@_supplying_relationship_fields.fetch resource_klass do
|
197
|
+
relationships = Set.new(resource_klass._relationships.keys.map(&:to_sym))
|
198
|
+
cur = resource_klass
|
199
|
+
while cur != JSONAPI::Resource
|
200
|
+
if @fields.has_key?(cur._type)
|
201
|
+
relationships &= @fields[cur._type]
|
202
|
+
break
|
203
|
+
end
|
204
|
+
cur = cur.superclass
|
205
|
+
end
|
206
|
+
@_supplying_relationship_fields[resource_klass] = relationships
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def attributes_hash(source, fetchable_fields)
|
211
|
+
fields = fetchable_fields & supplying_attribute_fields(source.class)
|
153
212
|
fields.each_with_object({}) do |name, hash|
|
154
|
-
format = source.class._attribute_options(name)[:format]
|
155
213
|
unless name == :id
|
214
|
+
format = source.class._attribute_options(name)[:format]
|
156
215
|
hash[format_key(name)] = format_value(source.public_send(name), format)
|
157
216
|
end
|
158
217
|
end
|
@@ -182,59 +241,101 @@ module JSONAPI
|
|
182
241
|
end
|
183
242
|
|
184
243
|
def top_level_source_key(source)
|
185
|
-
|
244
|
+
case source
|
245
|
+
when CachedResourceFragment then "#{source.resource_klass}_#{source.id}"
|
246
|
+
when Resource then "#{source.class}_#{@id_formatter.format(source.id)}"
|
247
|
+
else raise "Unknown source type #{source.inspect}"
|
248
|
+
end
|
186
249
|
end
|
187
250
|
|
188
251
|
def self_referential_and_already_in_source(resource)
|
189
252
|
resource && @top_level_sources.include?(top_level_source_key(resource))
|
190
253
|
end
|
191
254
|
|
192
|
-
def relationships_hash(source, include_directives)
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
255
|
+
def relationships_hash(source, fetchable_fields, include_directives = {})
|
256
|
+
if source.is_a?(CachedResourceFragment)
|
257
|
+
return cached_relationships_hash(source, include_directives)
|
258
|
+
end
|
259
|
+
|
260
|
+
include_directives[:include_related] ||= {}
|
261
|
+
|
262
|
+
relationships = source.class._relationships.select{|k,v| fetchable_fields.include?(k) }
|
263
|
+
field_set = supplying_relationship_fields(source.class) & relationships.keys
|
264
|
+
|
265
|
+
relationships.each_with_object({}) do |(name, relationship), hash|
|
266
|
+
ia = include_directives[:include_related][name]
|
267
|
+
include_linkage = ia && ia[:include]
|
268
|
+
include_linked_children = ia && !ia[:include_related].empty?
|
197
269
|
|
198
|
-
|
270
|
+
if field_set.include?(name)
|
271
|
+
hash[format_key(name)] = link_object(source, relationship, include_linkage)
|
272
|
+
end
|
199
273
|
|
200
|
-
|
274
|
+
# If the object has been serialized once it will be in the related objects list,
|
275
|
+
# but it's possible all children won't have been captured. So we must still go
|
276
|
+
# through the relationships.
|
277
|
+
if include_linkage || include_linked_children
|
278
|
+
resources = if source.preloaded_fragments.has_key?(format_key(name))
|
279
|
+
source.preloaded_fragments[format_key(name)].values
|
280
|
+
else
|
281
|
+
[source.public_send(name)].flatten(1).compact
|
282
|
+
end
|
283
|
+
resources.each do |resource|
|
284
|
+
next if self_referential_and_already_in_source(resource)
|
285
|
+
id = resource.id
|
286
|
+
relationships_only = already_serialized?(relationship.type, id)
|
287
|
+
if include_linkage && !relationships_only
|
288
|
+
add_resource(resource, ia)
|
289
|
+
elsif include_linked_children || relationships_only
|
290
|
+
relationships_hash(resource, fetchable_fields, ia)
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
201
296
|
|
202
|
-
|
297
|
+
def cached_relationships_hash(source, include_directives)
|
298
|
+
h = source.relationships || {}
|
299
|
+
return h unless include_directives.has_key?(:include_related)
|
203
300
|
|
204
|
-
relationships
|
205
|
-
if included_relationships.include? name
|
206
|
-
ia = include_directives[:include_related][name]
|
301
|
+
relationships = source.resource_klass._relationships.select{|k,v| source.fetchable_fields.include?(k) }
|
207
302
|
|
208
|
-
|
209
|
-
|
210
|
-
|
303
|
+
relationships.each do |rel_name, relationship|
|
304
|
+
key = @key_formatter.format(rel_name)
|
305
|
+
to_many = relationship.is_a? JSONAPI::Relationship::ToMany
|
211
306
|
|
212
|
-
|
213
|
-
|
307
|
+
ia = include_directives[:include_related][rel_name]
|
308
|
+
if ia
|
309
|
+
if h.has_key?(key)
|
310
|
+
h[key][:data] = to_many ? [] : nil
|
214
311
|
end
|
215
312
|
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
313
|
+
source.preloaded_fragments[key].each do |id, f|
|
314
|
+
add_resource(f, ia)
|
315
|
+
|
316
|
+
if h.has_key?(key)
|
317
|
+
# The hash already has everything we need except the :data field
|
318
|
+
data = {
|
319
|
+
type: format_key(f.is_a?(Resource) ? f.class._type : f.type),
|
320
|
+
id: @id_formatter.format(id)
|
321
|
+
}
|
322
|
+
|
323
|
+
if to_many
|
324
|
+
h[key][:data] << data
|
325
|
+
else
|
326
|
+
h[key][:data] = data
|
229
327
|
end
|
230
328
|
end
|
231
329
|
end
|
232
330
|
end
|
233
331
|
end
|
332
|
+
|
333
|
+
return h
|
234
334
|
end
|
235
335
|
|
236
336
|
def already_serialized?(type, id)
|
237
337
|
type = format_key(type)
|
338
|
+
id = @id_formatter.format(id)
|
238
339
|
@included_objects.key?(type) && @included_objects[type].key?(id)
|
239
340
|
end
|
240
341
|
|
@@ -247,8 +348,10 @@ module JSONAPI
|
|
247
348
|
end
|
248
349
|
|
249
350
|
def to_one_linkage(source, relationship)
|
250
|
-
|
251
|
-
|
351
|
+
linkage_id = foreign_key_value(source, relationship)
|
352
|
+
linkage_type = format_key(relationship.type_for_source(source))
|
353
|
+
return unless linkage_id.present? && linkage_type.present?
|
354
|
+
|
252
355
|
{
|
253
356
|
type: linkage_type,
|
254
357
|
id: linkage_id,
|
@@ -257,7 +360,27 @@ module JSONAPI
|
|
257
360
|
|
258
361
|
def to_many_linkage(source, relationship)
|
259
362
|
linkage = []
|
260
|
-
linkage_types_and_values =
|
363
|
+
linkage_types_and_values = if source.preloaded_fragments.has_key?(format_key(relationship.name))
|
364
|
+
source.preloaded_fragments[format_key(relationship.name)].map do |_, resource|
|
365
|
+
[relationship.type, resource.id]
|
366
|
+
end
|
367
|
+
elsif relationship.polymorphic?
|
368
|
+
assoc = source._model.public_send(relationship.name)
|
369
|
+
# Avoid hitting the database again for values already pre-loaded
|
370
|
+
if assoc.respond_to?(:loaded?) and assoc.loaded?
|
371
|
+
assoc.map do |obj|
|
372
|
+
[obj.type.underscore.pluralize, obj.id]
|
373
|
+
end
|
374
|
+
else
|
375
|
+
assoc.pluck(:type, :id).map do |type, id|
|
376
|
+
[type.underscore.pluralize, id]
|
377
|
+
end
|
378
|
+
end
|
379
|
+
else
|
380
|
+
source.public_send(relationship.name).map do |value|
|
381
|
+
[relationship.type, value.id]
|
382
|
+
end
|
383
|
+
end
|
261
384
|
|
262
385
|
linkage_types_and_values.each do |type, value|
|
263
386
|
if type && value
|
@@ -297,18 +420,18 @@ module JSONAPI
|
|
297
420
|
|
298
421
|
# Extracts the foreign key value for a to_one relationship.
|
299
422
|
def foreign_key_value(source, relationship)
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
423
|
+
related_resource_id = if source.preloaded_fragments.has_key?(format_key(relationship.name))
|
424
|
+
source.preloaded_fragments[format_key(relationship.name)].values.first.try(:id)
|
425
|
+
elsif source.respond_to?("#{relationship.name}_id")
|
426
|
+
# If you have direct access to the underlying id, you don't have to load the relationship
|
427
|
+
# which can save quite a lot of time when loading a lot of data.
|
428
|
+
# This does not apply to e.g. has_one :through relationships.
|
429
|
+
source.public_send("#{relationship.name}_id")
|
307
430
|
else
|
308
|
-
|
309
|
-
return nil unless related_resource
|
310
|
-
@id_formatter.format(related_resource.id)
|
431
|
+
source.public_send(relationship.name).try(:id)
|
311
432
|
end
|
433
|
+
return nil unless related_resource_id
|
434
|
+
@id_formatter.format(related_resource_id)
|
312
435
|
end
|
313
436
|
|
314
437
|
def foreign_key_types_and_values(source, relationship)
|
@@ -339,17 +462,28 @@ module JSONAPI
|
|
339
462
|
@included_objects[type][id][:primary] = true
|
340
463
|
end
|
341
464
|
|
342
|
-
|
343
|
-
|
344
|
-
|
465
|
+
def add_resource(source, include_directives, primary = false)
|
466
|
+
type = source.is_a?(JSONAPI::CachedResourceFragment) ? source.type : source.class._type
|
467
|
+
id = source.id
|
345
468
|
|
346
|
-
@included_objects[type]
|
469
|
+
@included_objects[type] ||= {}
|
470
|
+
existing = @included_objects[type][id]
|
347
471
|
|
348
|
-
if
|
349
|
-
|
350
|
-
|
472
|
+
if existing.nil?
|
473
|
+
obj_hash = object_hash(source, include_directives)
|
474
|
+
@included_objects[type][id] = {
|
475
|
+
primary: primary,
|
476
|
+
object_hash: obj_hash,
|
477
|
+
includes: Set.new(include_directives[:include_related].keys)
|
478
|
+
}
|
351
479
|
else
|
352
|
-
|
480
|
+
include_related = Set.new(include_directives[:include_related].keys)
|
481
|
+
unless existing[:includes].superset?(include_related)
|
482
|
+
obj_hash = object_hash(source, include_directives)
|
483
|
+
@included_objects[type][id][:object_hash].deep_merge!(obj_hash)
|
484
|
+
@included_objects[type][id][:includes].add(include_related)
|
485
|
+
@included_objects[type][id][:primary] = existing[:primary] | primary
|
486
|
+
end
|
353
487
|
end
|
354
488
|
end
|
355
489
|
|