jsonapi_compliable 0.11.34 → 1.0.alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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