jsonapi_compliable 0.11.34 → 1.0.alpha.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. checksums.yaml +5 -5
  2. data/.ruby-version +1 -1
  3. data/.travis.yml +1 -2
  4. data/Rakefile +7 -3
  5. data/jsonapi_compliable.gemspec +7 -3
  6. data/lib/generators/jsonapi/resource_generator.rb +8 -79
  7. data/lib/generators/jsonapi/templates/application_resource.rb.erb +2 -1
  8. data/lib/generators/jsonapi/templates/controller.rb.erb +19 -64
  9. data/lib/generators/jsonapi/templates/resource.rb.erb +5 -47
  10. data/lib/generators/jsonapi/templates/resource_reads_spec.rb.erb +62 -0
  11. data/lib/generators/jsonapi/templates/resource_writes_spec.rb.erb +63 -0
  12. data/lib/jsonapi_compliable.rb +87 -18
  13. data/lib/jsonapi_compliable/adapters/abstract.rb +202 -45
  14. data/lib/jsonapi_compliable/adapters/active_record.rb +6 -130
  15. data/lib/jsonapi_compliable/adapters/active_record/base.rb +247 -0
  16. data/lib/jsonapi_compliable/adapters/active_record/belongs_to_sideload.rb +17 -0
  17. data/lib/jsonapi_compliable/adapters/active_record/has_many_sideload.rb +17 -0
  18. data/lib/jsonapi_compliable/adapters/active_record/has_one_sideload.rb +17 -0
  19. data/lib/jsonapi_compliable/adapters/active_record/inferrence.rb +12 -0
  20. data/lib/jsonapi_compliable/adapters/active_record/many_to_many_sideload.rb +30 -0
  21. data/lib/jsonapi_compliable/adapters/null.rb +177 -6
  22. data/lib/jsonapi_compliable/base.rb +33 -320
  23. data/lib/jsonapi_compliable/context.rb +16 -0
  24. data/lib/jsonapi_compliable/deserializer.rb +14 -39
  25. data/lib/jsonapi_compliable/errors.rb +227 -24
  26. data/lib/jsonapi_compliable/extensions/extra_attribute.rb +3 -1
  27. data/lib/jsonapi_compliable/filter_operators.rb +25 -0
  28. data/lib/jsonapi_compliable/hash_renderer.rb +57 -0
  29. data/lib/jsonapi_compliable/query.rb +190 -202
  30. data/lib/jsonapi_compliable/rails.rb +12 -6
  31. data/lib/jsonapi_compliable/railtie.rb +64 -0
  32. data/lib/jsonapi_compliable/renderer.rb +60 -0
  33. data/lib/jsonapi_compliable/resource.rb +35 -663
  34. data/lib/jsonapi_compliable/resource/configuration.rb +239 -0
  35. data/lib/jsonapi_compliable/resource/dsl.rb +138 -0
  36. data/lib/jsonapi_compliable/resource/interface.rb +32 -0
  37. data/lib/jsonapi_compliable/resource/polymorphism.rb +68 -0
  38. data/lib/jsonapi_compliable/resource/sideloading.rb +102 -0
  39. data/lib/jsonapi_compliable/resource_proxy.rb +127 -0
  40. data/lib/jsonapi_compliable/responders.rb +19 -0
  41. data/lib/jsonapi_compliable/runner.rb +25 -0
  42. data/lib/jsonapi_compliable/scope.rb +37 -79
  43. data/lib/jsonapi_compliable/scoping/extra_attributes.rb +29 -0
  44. data/lib/jsonapi_compliable/scoping/filter.rb +39 -58
  45. data/lib/jsonapi_compliable/scoping/filterable.rb +9 -14
  46. data/lib/jsonapi_compliable/scoping/paginate.rb +9 -3
  47. data/lib/jsonapi_compliable/scoping/sort.rb +16 -4
  48. data/lib/jsonapi_compliable/sideload.rb +221 -347
  49. data/lib/jsonapi_compliable/sideload/belongs_to.rb +34 -0
  50. data/lib/jsonapi_compliable/sideload/has_many.rb +16 -0
  51. data/lib/jsonapi_compliable/sideload/has_one.rb +9 -0
  52. data/lib/jsonapi_compliable/sideload/many_to_many.rb +24 -0
  53. data/lib/jsonapi_compliable/sideload/polymorphic_belongs_to.rb +108 -0
  54. data/lib/jsonapi_compliable/stats/payload.rb +4 -8
  55. data/lib/jsonapi_compliable/types.rb +172 -0
  56. data/lib/jsonapi_compliable/util/attribute_check.rb +88 -0
  57. data/lib/jsonapi_compliable/util/persistence.rb +29 -7
  58. data/lib/jsonapi_compliable/util/relationship_payload.rb +4 -4
  59. data/lib/jsonapi_compliable/util/render_options.rb +4 -32
  60. data/lib/jsonapi_compliable/util/serializer_attributes.rb +98 -0
  61. data/lib/jsonapi_compliable/util/validation_response.rb +15 -9
  62. data/lib/jsonapi_compliable/version.rb +1 -1
  63. metadata +105 -24
  64. data/lib/generators/jsonapi/field_generator.rb +0 -0
  65. data/lib/generators/jsonapi/templates/create_request_spec.rb.erb +0 -29
  66. data/lib/generators/jsonapi/templates/destroy_request_spec.rb.erb +0 -20
  67. data/lib/generators/jsonapi/templates/index_request_spec.rb.erb +0 -22
  68. data/lib/generators/jsonapi/templates/payload.rb.erb +0 -39
  69. data/lib/generators/jsonapi/templates/serializer.rb.erb +0 -25
  70. data/lib/generators/jsonapi/templates/show_request_spec.rb.erb +0 -19
  71. data/lib/generators/jsonapi/templates/update_request_spec.rb.erb +0 -33
  72. data/lib/jsonapi_compliable/adapters/active_record_sideloading.rb +0 -152
  73. data/lib/jsonapi_compliable/scoping/extra_fields.rb +0 -58
@@ -28,9 +28,12 @@ module JsonapiCompliable
28
28
  # aliases. If valid, call either the default or custom filtering logic.
29
29
  # @return the scope we are chaining/modifying
30
30
  def apply
31
- raise JsonapiCompliable::Errors::RequiredFilter.new(missing_required_filters) unless required_filters_provided?
32
- each_filter do |filter, value|
33
- @scope = filter_scope(filter, value)
31
+ if missing_required_filters.any?
32
+ raise Errors::RequiredFilter.new(resource, missing_required_filters)
33
+ end
34
+
35
+ each_filter do |filter, operator, value|
36
+ @scope = filter_scope(filter, operator, value)
34
37
  end
35
38
 
36
39
  @scope
@@ -38,74 +41,52 @@ module JsonapiCompliable
38
41
 
39
42
  private
40
43
 
41
- # If there's custom logic, run it, otherwise run the default logic
42
- # specified in the adapter.
43
- def filter_scope(filter, value)
44
- if custom_scope = filter.values.first[:filter]
44
+ def filter_scope(filter, operator, value)
45
+ operator = operator.to_s.gsub('!', 'not_').to_sym
46
+
47
+ if custom_scope = filter.values.first[operator]
45
48
  custom_scope.call(@scope, value, resource.context)
46
49
  else
47
- resource.adapter.filter(@scope, filter.keys.first, value)
50
+ filter_via_adapter(filter, operator, value)
48
51
  end
49
52
  end
50
53
 
51
- def each_filter
52
- filter_param.each_pair do |param_name, param_value|
53
- filter = find_filter!(param_name.to_sym)
54
- value = param_value
55
- value = parse_string_arrays(value)
56
- value = normalize_string_values(value)
57
- yield filter, value
58
- end
59
- end
54
+ def filter_via_adapter(filter, operator, value)
55
+ type_name = Types.name_for(filter.values.first[:type])
56
+ method = :"filter_#{type_name}_#{operator}"
57
+ attribute = filter.keys.first
60
58
 
61
- # foo,bar,baz becomes ["foo", "bar", "baz"]
62
- # {{foo}} becomes ["foo"]
63
- # {{foo,bar}},baz becomes ["foo,bar", "baz"]
64
- #
65
- # JSON of
66
- # {{{ "id": 1 }}} becomes { 'id' => 1 }
67
- def parse_string_arrays(value)
68
- if value.is_a?(String)# && value[0..2] != '{{{'
69
- # Escaped JSON
70
- if value[0..2] == '{{{'
71
- value = value.sub('{{', '').sub('}}', '')
72
- value = JSON.parse(value)
73
- else
74
- # Find the quoted strings
75
- quotes = value.scan(/{{.*?}}/)
76
- # remove them from the rest
77
- quotes.each { |q| value.gsub!(q, '') }
78
- # remove the quote characters from the quoted strings
79
- quotes.each { |q| q.gsub!('{{', '').gsub!('}}', '') }
80
- # merge everything back together into an array
81
- value = Array(value.split(',')) + quotes
82
- # remove any blanks that are left
83
- value.reject! { |v| v.length.zero? }
84
- value = value[0] if value.length == 1
85
- end
59
+ if resource.adapter.respond_to?(method)
60
+ resource.adapter.send(method, @scope, attribute, value)
61
+ else
62
+ raise Errors::AdapterNotImplemented.new \
63
+ resource.adapter, attribute, method
86
64
  end
87
- value
88
65
  end
89
66
 
90
- # Convert a string of "true" to true, etc
91
- #
92
- # NB - avoid Array(value) here since we might want to
93
- # return a single element instead of array
94
- def normalize_string_values(value)
95
- if value.is_a?(Array)
96
- value.map { |v| normalize_string_value(v) }
97
- else
98
- normalize_string_value(value)
67
+ def each_filter
68
+ filter_param.each_pair do |param_name, param_value|
69
+ filter = find_filter!(param_name)
70
+ param_value = { eq: param_value } unless param_value.is_a?(Hash)
71
+ value = param_value.values.first
72
+ operator = param_value.keys.first
73
+ value = param_value.values.first unless filter.values[0][:type] == :hash
74
+ value = value.split(',') if value.is_a?(String) && value.include?(',')
75
+ value = coerce_types(param_name.to_sym, value)
76
+ yield filter, operator, value
99
77
  end
100
78
  end
101
79
 
102
- def normalize_string_value(value)
103
- case value
104
- when 'true' then true
105
- when 'false' then false
106
- when 'nil', 'null' then nil
80
+ def coerce_types(name, value)
81
+ type_name = @resource.all_attributes[name][:type]
82
+ is_array = type_name.to_s.starts_with?('array_of') ||
83
+ Types[type_name][:canonical_name] == :array
84
+
85
+ if is_array
86
+ @resource.typecast(name, value, :filterable)
107
87
  else
108
- value
88
+ value = value.nil? || value.is_a?(Hash) ? [value] : Array(value)
89
+ value.map { |v| @resource.typecast(name, v, :filterable) }
109
90
  end
110
91
  end
111
92
  end
@@ -4,34 +4,29 @@ module JsonapiCompliable
4
4
  # @api private
5
5
  def find_filter(name)
6
6
  find_filter!(name)
7
- rescue JsonapiCompliable::Errors::BadFilter
7
+ rescue JsonapiCompliable::Errors::AttributeError
8
8
  nil
9
9
  end
10
10
 
11
11
  # @api private
12
12
  def find_filter!(name)
13
- filter_name, filter_value = \
14
- resource.filters.find { |_name, opts| opts[:aliases].include?(name.to_sym) }
15
- raise JsonapiCompliable::Errors::BadFilter unless filter_name
16
- if guard = filter_value[:if]
17
- raise JsonapiCompliable::Errors::BadFilter if resource.context.send(guard) == false
18
- end
19
- { filter_name => filter_value }
13
+ resource.class.get_attr!(name, :filterable, request: true)
14
+ { name => resource.filters[name] }
20
15
  end
21
16
 
22
17
  # @api private
23
18
  def filter_param
24
- query_hash[:filter]
19
+ query_hash[:filter] || {}
25
20
  end
26
21
 
27
22
  def missing_required_filters
28
- required_filters.keys - filter_param.keys
23
+ required_attributes - filter_param.keys
29
24
  end
30
25
 
31
- def required_filters
32
- resource.filters.select do |_name, opts|
33
- opts[:required].respond_to?(:call) ? opts[:required].call(resource.context) : opts[:required]
34
- end
26
+ def required_attributes
27
+ resource.attributes.map do |k, v|
28
+ k if v[:filterable] == :required
29
+ end.compact
35
30
  end
36
31
 
37
32
  def required_filters_provided?
@@ -31,6 +31,8 @@ module JsonapiCompliable
31
31
  if size > MAX_PAGE_SIZE
32
32
  raise JsonapiCompliable::Errors::UnsupportedPageSize
33
33
  .new(size, MAX_PAGE_SIZE)
34
+ elsif requested? && @opts[:sideload_parent_length].to_i > 1
35
+ raise JsonapiCompliable::Errors::UnsupportedPagination
34
36
  else
35
37
  super
36
38
  end
@@ -42,8 +44,8 @@ module JsonapiCompliable
42
44
  #
43
45
  # @return [Boolean] should we apply this logic?
44
46
  def apply?
45
- if @opts[:default] == false
46
- not [page_param[:size], page_param[:number]].all?(&:nil?)
47
+ if @opts[:default_paginate] == false
48
+ requested?
47
49
  else
48
50
  true
49
51
  end
@@ -66,12 +68,16 @@ module JsonapiCompliable
66
68
 
67
69
  private
68
70
 
71
+ def requested?
72
+ not [page_param[:size], page_param[:number]].all?(&:nil?)
73
+ end
74
+
69
75
  def page_param
70
76
  @page_param ||= (query_hash[:page] || {})
71
77
  end
72
78
 
73
79
  def number
74
- (page_param[:number] || resource.default_page_number).to_i
80
+ (page_param[:number] || 1).to_i
75
81
  end
76
82
 
77
83
  def size
@@ -15,14 +15,18 @@ module JsonapiCompliable
15
15
  class Scoping::Sort < Scoping::Base
16
16
  # @return [Proc, Nil] The custom proc specified by Resource DSL
17
17
  def custom_scope
18
- resource.sorting
18
+ resource.sort_all
19
19
  end
20
20
 
21
21
  # Apply default scope logic via Resource adapter
22
22
  # @return the scope we are chaining/modifying
23
23
  def apply_standard_scope
24
24
  each_sort do |attribute, direction|
25
- @scope = resource.adapter.order(@scope, attribute, direction)
25
+ if sort = resource.sorts[attribute]
26
+ @scope = sort.call(@scope, direction)
27
+ else
28
+ @scope = resource.adapter.order(@scope, attribute, direction)
29
+ end
26
30
  end
27
31
  @scope
28
32
  end
@@ -41,12 +45,20 @@ module JsonapiCompliable
41
45
 
42
46
  def each_sort
43
47
  sort_param.each do |sort_hash|
44
- yield sort_hash.keys.first, sort_hash.values.first
48
+ attribute = sort_hash.keys.first
49
+ direction = sort_hash.values.first
50
+ yield attribute, direction
45
51
  end
46
52
  end
47
53
 
48
54
  def sort_param
49
- @sort_param ||= query_hash[:sort].empty? ? resource.default_sort : query_hash[:sort]
55
+ @sort_param ||= begin
56
+ if query_hash[:sort].blank?
57
+ resource.default_sort
58
+ else
59
+ query_hash[:sort]
60
+ end
61
+ end
50
62
  end
51
63
  end
52
64
  end
@@ -1,220 +1,179 @@
1
1
  module JsonapiCompliable
2
- # @attr_reader [Symbol] name The name of the sideload
3
- # @attr_reader [Class] resource_class The corresponding Resource class
4
- # @attr_reader [Boolean] polymorphic Is this a polymorphic sideload?
5
- # @attr_reader [Hash] polymorphic_groups The subgroups, when polymorphic
6
- # @attr_reader [Hash] sideloads The associated sibling sideloads
7
- # @attr_reader [Proc] scope_proc The configured 'scope' block
8
- # @attr_reader [Proc] assign_proc The configured 'assign' block
9
- # @attr_reader [Symbol] grouping_field The configured 'group_by' symbol
10
- # @attr_reader [Symbol] foreign_key The attribute used to match objects - need not be a true database foreign key.
11
- # @attr_reader [Symbol] primary_key The attribute used to match objects - need not be a true database primary key.
12
- # @attr_reader [Symbol] type One of :has_many, :belongs_to, etc
13
2
  class Sideload
3
+ HOOK_ACTIONS = [:save, :create, :update, :destroy, :disassociate]
4
+ TYPES = [:has_many, :belongs_to, :has_one, :many_to_many]
5
+
14
6
  attr_reader :name,
15
7
  :resource_class,
16
- :polymorphic,
17
- :polymorphic_groups,
18
- :parent,
19
- :sideloads,
20
- :scope_proc,
21
- :assign_proc,
22
- :grouping_field,
8
+ :parent_resource_class,
23
9
  :foreign_key,
24
10
  :primary_key,
25
- :type
26
-
27
- # NB - the adapter's +#sideloading_module+ is mixed in on instantiation
28
- #
29
- # An anonymous Resource will be assigned when none provided.
30
- #
31
- # @see Adapters::Abstract#sideloading_module
32
- def initialize(name, type: nil, resource: nil, polymorphic: false, primary_key: :id, foreign_key: nil, parent: nil)
33
- @name = name
34
- @resource_class = (resource || Class.new(Resource))
35
- @sideloads = {}
36
- @polymorphic = !!polymorphic
37
- @polymorphic_groups = {} if polymorphic?
38
- @parent = parent
39
- @primary_key = primary_key
40
- @foreign_key = foreign_key
41
- @type = type
42
-
43
- extend @resource_class.config[:adapter].sideloading_module
44
- end
45
-
46
- # @see #resource_class
47
- # @return [Resource] an instance of +#resource_class+
11
+ :parent,
12
+ :group_name
13
+
14
+ class_attribute :scope_proc,
15
+ :assign_proc,
16
+ :assign_each_proc,
17
+ :params_proc,
18
+ :pre_load_proc
19
+
20
+ def initialize(name, opts)
21
+ @name = name
22
+ @parent_resource_class = opts[:parent_resource]
23
+ @resource_class = opts[:resource]
24
+ @primary_key = opts[:primary_key]
25
+ @foreign_key = opts[:foreign_key]
26
+ @type = opts[:type]
27
+ @base_scope = opts[:base_scope]
28
+ @readable = opts[:readable]
29
+ @writable = opts[:writable]
30
+ @as = opts[:as]
31
+
32
+ # polymorphic-specific
33
+ @group_name = opts[:group_name]
34
+ @polymorphic_child = opts[:polymorphic_child]
35
+ @parent = opts[:parent]
36
+ if polymorphic_child?
37
+ parent.resource.polymorphic << resource_class
38
+ end
39
+ end
40
+
41
+ def self.scope(&blk)
42
+ self.scope_proc = blk
43
+ end
44
+
45
+ def self.assign(&blk)
46
+ self.assign_proc = blk
47
+ end
48
+
49
+ def self.assign_each(&blk)
50
+ self.assign_each_proc = blk
51
+ end
52
+
53
+ def self.params(&blk)
54
+ self.params_proc = blk
55
+ end
56
+
57
+ def self.pre_load(&blk)
58
+ self.pre_load_proc = blk
59
+ end
60
+
61
+ def readable?
62
+ !!@readable
63
+ end
64
+
65
+ def writable?
66
+ !!@writable
67
+ end
68
+
69
+ def polymorphic_parent?
70
+ resource.polymorphic?
71
+ end
72
+
73
+ def polymorphic_child?
74
+ !!@polymorphic_child
75
+ end
76
+
77
+ def primary_key
78
+ @primary_key ||= :id
79
+ end
80
+
81
+ def foreign_key
82
+ @foreign_key ||= infer_foreign_key
83
+ end
84
+
85
+ def association_name
86
+ @as || name
87
+ end
88
+
89
+ def resource_class
90
+ @resource_class ||= infer_resource_class
91
+ end
92
+
93
+ def scope(parents)
94
+ raise "No #scope defined for sideload with name '#{name}'. Make sure to define this in your adapter, or pass a block that defines the scope."
95
+ end
96
+
97
+ def assign_each(parent, children)
98
+ raise 'Override #assign_each in subclass'
99
+ end
100
+
101
+ def type
102
+ @type || raise("Override #type in subclass. Should be one of #{TYPES.inspect}")
103
+ end
104
+
105
+ def load_params(parents, query)
106
+ raise 'Override #load_params in subclass'
107
+ end
108
+
109
+ def base_scope
110
+ if @base_scope
111
+ @base_scope.respond_to?(:call) ? @base_scope.call : @base_scope
112
+ else
113
+ resource.base_scope
114
+ end
115
+ end
116
+
117
+ def load(parents, query)
118
+ params = load_params(parents, query)
119
+ params_proc.call(params, parents, query) if params_proc
120
+ opts = load_options(parents, query)
121
+ proxy = resource.class._all(params, opts, base_scope)
122
+ pre_load_proc.call(proxy) if pre_load_proc
123
+ proxy.to_a
124
+ end
125
+
126
+ # Override in subclass
127
+ def infer_foreign_key
128
+ model = parent_resource_class.model
129
+ namespace = namespace_for(model)
130
+ model_name = model.name.gsub("#{namespace}::", '')
131
+ :"#{model_name.underscore}_id"
132
+ end
133
+
48
134
  def resource
49
135
  @resource ||= resource_class.new
50
136
  end
51
137
 
52
- # Is this sideload polymorphic?
53
- #
54
- # Polymorphic sideloads group the parent objects in some fashion,
55
- # so different 'types' can be resolved differently. Let's say an
56
- # +Office+ has a polymorphic +Organization+, which can be either a
57
- # +Business+ or +Government+:
58
- #
59
- # allow_sideload :organization, :polymorphic: true do
60
- # group_by :organization_type
61
- #
62
- # allow_sideload 'Business', resource: BusinessResource do
63
- # # ... code ...
64
- # end
65
- #
66
- # allow_sideload 'Governemnt', resource: GovernmentResource do
67
- # # ... code ...
68
- # end
69
- # end
70
- #
71
- # You probably want to extract this code into an Adapter. For instance,
72
- # with ActiveRecord:
73
- #
74
- # polymorphic_belongs_to :organization,
75
- # group_by: :organization_type,
76
- # groups: {
77
- # 'Business' => {
78
- # scope: -> { Business.all },
79
- # resource: BusinessResource,
80
- # foreign_key: :organization_id
81
- # },
82
- # 'Government' => {
83
- # scope: -> { Government.all },
84
- # resource: GovernmentResource,
85
- # foreign_key: :organization_id
86
- # }
87
- # }
88
- #
89
- # @see Adapters::ActiveRecordSideloading#polymorphic_belongs_to
90
- # @return [Boolean] is this sideload polymorphic?
91
- def polymorphic?
92
- @polymorphic == true
93
- end
94
-
95
- # Build a scope that will be used to fetch the related records
96
- # This scope will be further chained with filtering/sorting/etc
97
- #
98
- # You probably want to wrap this logic in an Adapter, instead of
99
- # specifying in your resource directly.
100
- #
101
- # @example Default ActiveRecord
102
- # class PostResource < ApplicationResource
103
- # # ... code ...
104
- # allow_sideload :comments, resource: CommentResource do
105
- # scope do |posts|
106
- # Comment.where(post_id: posts.map(&:id))
107
- # end
108
- # # ... code ...
109
- # end
110
- # end
111
- #
112
- # @example Custom Scope
113
- # # In this example, our base scope is a Hash
114
- # scope do |posts|
115
- # { post_ids: posts.map(&:id) }
116
- # end
117
- #
118
- # @example ActiveRecord via Adapter
119
- # class PostResource < ApplicationResource
120
- # # ... code ...
121
- # has_many :comments,
122
- # scope: -> { Comment.all },
123
- # resource: CommentResource,
124
- # foreign_key: :post_id
125
- # end
126
- #
127
- # @see Adapters::Abstract
128
- # @see Adapters::ActiveRecordSideloading#has_many
129
- # @see #allow_sideload
130
- # @yieldparam parents - The resolved parent records
131
- def scope(&blk)
132
- @scope_proc = blk
133
- end
134
-
135
- # The proc used to assign the resolved parents and children.
136
- #
137
- # You probably want to wrap this logic in an Adapter, instead of
138
- # specifying in your resource directly.
139
- #
140
- # @example Default ActiveRecord
141
- # class PostResource < ApplicationResource
142
- # # ... code ...
143
- # allow_sideload :comments, resource: CommentResource do
144
- # # ... code ...
145
- # assign do |posts, comments|
146
- # posts.each do |post|
147
- # relevant_comments = comments.select { |c| c.post_id == post.id }
148
- # post.comments = relevant_comments
149
- # end
150
- # end
151
- # end
152
- # end
153
- #
154
- # @example ActiveRecord via Adapter
155
- # class PostResource < ApplicationResource
156
- # # ... code ...
157
- # has_many :comments,
158
- # scope: -> { Comment.all },
159
- # resource: CommentResource,
160
- # foreign_key: :post_id
161
- # end
162
- #
163
- # @see Adapters::Abstract
164
- # @see Adapters::ActiveRecordSideloading#has_many
165
- # @see #allow_sideload
166
- # @yieldparam parents - The resolved parent records
167
- # @yieldparam children - The resolved child records
168
- def assign(&blk)
169
- @assign_proc = blk
170
- end
171
-
172
- # Configure how to associate parent and child records.
173
- # Delegates to #resource
174
- #
175
- # @see #name
176
- # @see #type
177
- # @api private
178
- def associate(parent, child)
179
- association_name = @parent ? @parent.name : name
180
- resource.associate(parent, child, association_name, type)
138
+ def parent_resource
139
+ @parent_resource ||= parent_resource_class.new
181
140
  end
182
141
 
183
- # Configure how to disassociate parent and child records.
184
- # Delegates to #resource
185
- #
186
- # @see #name
187
- # @see #type
188
- # @api private
189
- def disassociate(parent, child)
190
- association_name = @parent ? @parent.name : name
191
- resource.disassociate(parent, child, association_name, type)
142
+ def assign(parents, children)
143
+ associated = []
144
+ parents.each do |parent|
145
+ relevant_children = fire_assign_each(parent, children)
146
+ if relevant_children.is_a?(Array)
147
+ associated |= relevant_children
148
+ associate_all(parent, relevant_children)
149
+ else
150
+ associated << relevant_children
151
+ associate(parent, relevant_children)
152
+ end
153
+ end
154
+ (children - associated).each do |unassigned|
155
+ children.delete(unassigned)
156
+ end
192
157
  end
193
158
 
194
- HOOK_ACTIONS = [:save, :create, :update, :destroy, :disassociate]
159
+ def resolve(parents, query)
160
+ # legacy / custom / many-to-many
161
+ if self.class.scope_proc || type == :many_to_many
162
+ sideload_scope = fire_scope(parents)
163
+ sideload_scope = Scope.new sideload_scope,
164
+ resource,
165
+ query,
166
+ sideload_parent_length: parents.length,
167
+ default_paginate: false
168
+ sideload_scope.resolve do |sideload_results|
169
+ fire_assign(parents, sideload_results)
170
+ end
171
+ else
172
+ load(parents, query)
173
+ end
174
+ end
195
175
 
196
- # Configure post-processing hooks
197
- #
198
- # In particular, helpful for bulk operations. "after_save" will fire
199
- # for any persistence method - +:create+, +:update+, +:destroy+, +:disassociate+.
200
- # Use "only" and "except" keyword arguments to fire only for a
201
- # specific persistence method.
202
- #
203
- # @example Bulk Notify Users on Invite
204
- # class ProjectResource < ApplicationResource
205
- # # ... code ...
206
- # allow_sideload :users, resource: UserResource do
207
- # # scope {}
208
- # # assign {}
209
- # after_save only: [:create] do |project, users|
210
- # UserMailer.invite(project, users).deliver_later
211
- # end
212
- # end
213
- # end
214
- #
215
- # @see #hooks
216
- # @see Util::Persistence
217
- def after_save(only: [], except: [], &blk)
176
+ def self.after_save(only: [], except: [], &blk)
218
177
  actions = HOOK_ACTIONS - except
219
178
  actions = only & actions
220
179
  actions = [:save] if only.empty? && except.empty?
@@ -223,10 +182,7 @@ module JsonapiCompliable
223
182
  end
224
183
  end
225
184
 
226
- # Get the hooks the user has configured
227
- # @see #after_save
228
- # @return hash of hooks, ie +{ after_create: #<Proc>}+
229
- def hooks
185
+ def self.hooks
230
186
  @hooks ||= {}.tap do |h|
231
187
  HOOK_ACTIONS.each do |a|
232
188
  h[:"after_#{a}"] = []
@@ -235,173 +191,91 @@ module JsonapiCompliable
235
191
  end
236
192
  end
237
193
 
238
- # Define an attribute that groups the parent records. For instance, with
239
- # an ActiveRecord polymorphic belongs_to there will be a +parent_id+
240
- # and +parent_type+. We would want to group on +parent_type+:
241
- #
242
- # allow_sideload :organization, polymorphic: true do
243
- # # group parent_type, parent here is 'organization'
244
- # group_by :organization_type
245
- # end
246
- #
247
- # @see #polymorphic?
248
- def group_by(grouping_field)
249
- @grouping_field = grouping_field
250
- end
251
-
252
- # Resolve the sideload.
253
- #
254
- # * Uses the 'scope' proc to build a 'base scope'
255
- # * Chains additional criteria onto that 'base scope'
256
- # * Resolves that scope (see Scope#resolve)
257
- # * Assigns the resulting child objects to their corresponding parents
258
- #
259
- # @see Scope#resolve
260
- # @param [Object] parents The resolved parent models
261
- # @param [Query] query The Query instance
262
- # @param [Symbol] namespace The current namespace (see Resource#with_context)
263
- # @see Query
264
- # @see Resource#with_context
265
- # @return [void]
266
- # @api private
267
- def resolve(parents, query, namespace)
268
- if polymorphic?
269
- resolve_polymorphic(parents, query)
270
- else
271
- resolve_basic(parents, query, namespace)
272
- end
273
- end
194
+ def fire_hooks!(parent, objects, method)
195
+ return unless self.class.hooks
274
196
 
275
- # Configure a relationship between Resource objects
276
- #
277
- # You probably want to extract this logic into an adapter
278
- # rather than using directly
279
- #
280
- # @example Default ActiveRecord
281
- # # What happens 'under the hood'
282
- # class CommentResource < ApplicationResource
283
- # # ... code ...
284
- # allow_sideload :post, resource: PostResource do
285
- # scope do |comments|
286
- # Post.where(id: comments.map(&:post_id))
287
- # end
288
- #
289
- # assign do |comments, posts|
290
- # comments.each do |comment|
291
- # relevant_post = posts.find { |p| p.id == comment.post_id }
292
- # comment.post = relevant_post
293
- # end
294
- # end
295
- # end
296
- # end
297
- #
298
- # # Rather than writing that code directly, go through the adapter:
299
- # class CommentResource < ApplicationResource
300
- # # ... code ...
301
- # use_adapter JsonapiCompliable::Adapters::ActiveRecord
302
- #
303
- # belongs_to :post,
304
- # scope: -> { Post.all },
305
- # resource: PostResource,
306
- # foreign_key: :post_id
307
- # end
308
- #
309
- # @see Adapters::ActiveRecordSideloading#belongs_to
310
- # @see #assign
311
- # @see #scope
312
- # @return void
313
- def allow_sideload(name, opts = {}, &blk)
314
- sideload = Sideload.new(name, opts)
315
- sideload.instance_eval(&blk) if blk
316
-
317
- if polymorphic?
318
- @polymorphic_groups[name] = sideload
319
- else
320
- @sideloads[name] = sideload
197
+ all = self.class.hooks[:"after_#{method}"] + self.class.hooks[:after_save]
198
+ all.compact.each do |hook|
199
+ resource.instance_exec(parent, objects, &hook)
321
200
  end
322
201
  end
323
202
 
324
- # Fetch a Sideload object by its name
325
- # @param [Symbol] name The name of the corresponding sideload
326
- # @see +allow_sideload
327
- # @return the corresponding Sideload object
328
- def sideload(name)
329
- @sideloads[name]
203
+ def associate_all(parent, children)
204
+ parent_resource.associate_all(parent, children, association_name, type)
330
205
  end
331
206
 
332
- # @api private
333
- def all_sideloads
334
- {}.tap do |all|
335
- if polymorphic?
336
- polymorphic_groups.each_pair do |type, sl|
337
- all.merge!(sl.resource.sideloading.all_sideloads)
338
- end
339
- else
340
- all.merge!(@sideloads.merge(resource.sideloading.sideloads))
341
- end
342
- end
207
+ def associate(parent, child)
208
+ parent_resource.associate(parent, child, association_name, type)
343
209
  end
344
210
 
345
- def association_names(memo = [])
346
- all_sideloads.each_pair do |name, sl|
347
- unless memo.include?(sl.name)
348
- memo << sl.name
349
- memo |= sl.association_names(memo)
350
- end
351
- end
211
+ def disassociate(parent, child)
212
+ parent_resource.disassociate(parent, child, association_name, type)
213
+ end
352
214
 
353
- memo
215
+ def ids_for_parents(parents)
216
+ parent_ids = parents.map(&primary_key)
217
+ parent_ids.compact!
218
+ parent_ids.uniq!
219
+ parent_ids
354
220
  end
355
221
 
356
- # @api private
357
- def polymorphic_child_for_type(type)
358
- polymorphic_groups.values.find do |v|
359
- v.resource_class.config[:type] == type.to_sym
222
+ private
223
+
224
+ def load_options(parents, query)
225
+ {}.tap do |opts|
226
+ opts[:default_paginate] = false
227
+ opts[:sideload_parent_length] = parents.length
228
+ opts[:after_resolve] = ->(results) {
229
+ fire_assign(parents, results)
230
+ }
360
231
  end
361
232
  end
362
233
 
363
- def fire_hooks!(parent, objects, method)
364
- return unless self.hooks
365
-
366
- hooks = self.hooks[:"after_#{method}"] + self.hooks[:after_save]
367
- hooks.compact.each do |hook|
368
- resource.instance_exec(parent, objects, &hook)
234
+ def fire_assign_each(parent, children)
235
+ if self.class.assign_each_proc
236
+ instance_exec(parent, children, &self.class.assign_each_proc)
237
+ else
238
+ assign_each(parent, children)
369
239
  end
370
240
  end
371
241
 
372
- private
242
+ def fire_assign(parents, children)
243
+ if self.class.assign_proc
244
+ instance_exec(parents, children, &self.class.assign_proc)
245
+ else
246
+ assign(parents, children)
247
+ end
248
+ end
373
249
 
374
- def polymorphic_grouper(grouping_field)
375
- lambda do |record|
376
- if record.is_a?(Hash)
377
- if record.keys[0].is_a?(Symbol)
378
- record[grouping_field]
379
- else
380
- record[grouping_field.to_s]
381
- end
250
+ def fire_scope(parents)
251
+ parent_ids = ids_for_parents(parents)
252
+ if self.class.scope_proc
253
+ instance_exec(parent_ids, parents, &self.class.scope_proc)
254
+ else
255
+ method = method(:scope)
256
+ if [2,-2].include?(method.arity)
257
+ scope(parent_ids, parents)
382
258
  else
383
- record.send(grouping_field)
259
+ scope(parent_ids)
384
260
  end
385
261
  end
386
262
  end
387
263
 
388
- def resolve_polymorphic(parents, query)
389
- grouper = polymorphic_grouper(@grouping_field)
390
-
391
- parents.group_by(&grouper).each_pair do |group_type, group_members|
392
- sideload_for_group = @polymorphic_groups[group_type]
393
- if sideload_for_group
394
- sideload_for_group.resolve(group_members, query, name)
395
- end
396
- end
264
+ def namespace_for(klass)
265
+ namespace = klass.name
266
+ split = namespace.split('::')
267
+ split[0,split.length-1].join('::')
397
268
  end
398
269
 
399
- def resolve_basic(parents, query, namespace)
400
- sideload_scope = scope_proc.call(parents)
401
- sideload_scope = Scope.new(sideload_scope, resource_class.new, query, default_paginate: false, namespace: namespace)
402
- sideload_scope.resolve do |sideload_results|
403
- assign_proc.call(parents, sideload_results)
270
+ def infer_resource_class
271
+ namespace = namespace_for(parent_resource.class)
272
+ inferred_name = "#{name.to_s.singularize.classify}Resource"
273
+ klass = "#{namespace}::#{inferred_name}".safe_constantize
274
+ klass ||= inferred_name.safe_constantize
275
+ unless klass
276
+ raise Errors::ResourceNotFound.new(parent_resource, name)
404
277
  end
278
+ klass
405
279
  end
406
280
  end
407
281
  end