jsonapi_compliable 0.6.4 → 0.6.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -1
  3. data/.travis.yml +11 -3
  4. data/.yardopts +1 -0
  5. data/README.md +10 -1
  6. data/Rakefile +1 -0
  7. data/docs/JsonapiCompliable.html +202 -0
  8. data/docs/JsonapiCompliable/Adapters.html +119 -0
  9. data/docs/JsonapiCompliable/Adapters/Abstract.html +2285 -0
  10. data/docs/JsonapiCompliable/Adapters/ActiveRecord.html +2151 -0
  11. data/docs/JsonapiCompliable/Adapters/ActiveRecordSideloading.html +582 -0
  12. data/docs/JsonapiCompliable/Adapters/Null.html +1682 -0
  13. data/docs/JsonapiCompliable/Base.html +1395 -0
  14. data/docs/JsonapiCompliable/Deserializer.html +835 -0
  15. data/docs/JsonapiCompliable/Errors.html +115 -0
  16. data/docs/JsonapiCompliable/Errors/BadFilter.html +124 -0
  17. data/docs/JsonapiCompliable/Errors/StatNotFound.html +266 -0
  18. data/docs/JsonapiCompliable/Errors/UnsupportedPageSize.html +264 -0
  19. data/docs/JsonapiCompliable/Errors/ValidationError.html +124 -0
  20. data/docs/JsonapiCompliable/Extensions.html +117 -0
  21. data/docs/JsonapiCompliable/Extensions/BooleanAttribute.html +212 -0
  22. data/docs/JsonapiCompliable/Extensions/BooleanAttribute/ClassMethods.html +229 -0
  23. data/docs/JsonapiCompliable/Extensions/ExtraAttribute.html +242 -0
  24. data/docs/JsonapiCompliable/Extensions/ExtraAttribute/ClassMethods.html +237 -0
  25. data/docs/JsonapiCompliable/Query.html +1099 -0
  26. data/docs/JsonapiCompliable/Rails.html +211 -0
  27. data/docs/JsonapiCompliable/Resource.html +5241 -0
  28. data/docs/JsonapiCompliable/Scope.html +703 -0
  29. data/docs/JsonapiCompliable/Scoping.html +117 -0
  30. data/docs/JsonapiCompliable/Scoping/Base.html +843 -0
  31. data/docs/JsonapiCompliable/Scoping/DefaultFilter.html +318 -0
  32. data/docs/JsonapiCompliable/Scoping/ExtraFields.html +301 -0
  33. data/docs/JsonapiCompliable/Scoping/Filter.html +313 -0
  34. data/docs/JsonapiCompliable/Scoping/Filterable.html +364 -0
  35. data/docs/JsonapiCompliable/Scoping/Paginate.html +613 -0
  36. data/docs/JsonapiCompliable/Scoping/Sort.html +454 -0
  37. data/docs/JsonapiCompliable/SerializableTempId.html +216 -0
  38. data/docs/JsonapiCompliable/Sideload.html +2484 -0
  39. data/docs/JsonapiCompliable/Stats.html +117 -0
  40. data/docs/JsonapiCompliable/Stats/DSL.html +999 -0
  41. data/docs/JsonapiCompliable/Stats/Payload.html +391 -0
  42. data/docs/JsonapiCompliable/Util.html +117 -0
  43. data/docs/JsonapiCompliable/Util/FieldParams.html +228 -0
  44. data/docs/JsonapiCompliable/Util/Hash.html +471 -0
  45. data/docs/JsonapiCompliable/Util/IncludeParams.html +299 -0
  46. data/docs/JsonapiCompliable/Util/Persistence.html +435 -0
  47. data/docs/JsonapiCompliable/Util/RelationshipPayload.html +563 -0
  48. data/docs/JsonapiCompliable/Util/RenderOptions.html +250 -0
  49. data/docs/JsonapiCompliable/Util/ValidationResponse.html +532 -0
  50. data/docs/_index.html +527 -0
  51. data/docs/class_list.html +51 -0
  52. data/docs/css/common.css +1 -0
  53. data/docs/css/full_list.css +58 -0
  54. data/docs/css/style.css +492 -0
  55. data/docs/file.README.html +99 -0
  56. data/docs/file_list.html +56 -0
  57. data/docs/frames.html +17 -0
  58. data/docs/index.html +99 -0
  59. data/docs/js/app.js +248 -0
  60. data/docs/js/full_list.js +216 -0
  61. data/docs/js/jquery.js +4 -0
  62. data/docs/method_list.html +1731 -0
  63. data/docs/top-level-namespace.html +110 -0
  64. data/gemfiles/rails_5.gemfile +1 -1
  65. data/lib/jsonapi_compliable/adapters/abstract.rb +267 -4
  66. data/lib/jsonapi_compliable/adapters/active_record.rb +23 -1
  67. data/lib/jsonapi_compliable/adapters/null.rb +43 -3
  68. data/lib/jsonapi_compliable/base.rb +182 -33
  69. data/lib/jsonapi_compliable/deserializer.rb +90 -21
  70. data/lib/jsonapi_compliable/extensions/boolean_attribute.rb +12 -0
  71. data/lib/jsonapi_compliable/extensions/extra_attribute.rb +32 -0
  72. data/lib/jsonapi_compliable/extensions/temp_id.rb +11 -0
  73. data/lib/jsonapi_compliable/query.rb +94 -2
  74. data/lib/jsonapi_compliable/rails.rb +8 -0
  75. data/lib/jsonapi_compliable/resource.rb +548 -11
  76. data/lib/jsonapi_compliable/scope.rb +43 -1
  77. data/lib/jsonapi_compliable/scoping/base.rb +59 -8
  78. data/lib/jsonapi_compliable/scoping/default_filter.rb +33 -0
  79. data/lib/jsonapi_compliable/scoping/extra_fields.rb +33 -0
  80. data/lib/jsonapi_compliable/scoping/filter.rb +29 -2
  81. data/lib/jsonapi_compliable/scoping/filterable.rb +4 -0
  82. data/lib/jsonapi_compliable/scoping/paginate.rb +33 -0
  83. data/lib/jsonapi_compliable/scoping/sort.rb +18 -0
  84. data/lib/jsonapi_compliable/sideload.rb +229 -1
  85. data/lib/jsonapi_compliable/stats/dsl.rb +44 -0
  86. data/lib/jsonapi_compliable/stats/payload.rb +20 -0
  87. data/lib/jsonapi_compliable/util/field_params.rb +1 -0
  88. data/lib/jsonapi_compliable/util/hash.rb +18 -0
  89. data/lib/jsonapi_compliable/util/include_params.rb +22 -0
  90. data/lib/jsonapi_compliable/util/persistence.rb +13 -3
  91. data/lib/jsonapi_compliable/util/relationship_payload.rb +2 -0
  92. data/lib/jsonapi_compliable/util/render_options.rb +2 -0
  93. data/lib/jsonapi_compliable/util/validation_response.rb +16 -0
  94. data/lib/jsonapi_compliable/version.rb +1 -1
  95. metadata +60 -5
  96. data/gemfiles/rails_4.gemfile.lock +0 -208
  97. data/gemfiles/rails_5.gemfile.lock +0 -212
  98. data/lib/jsonapi_compliable/write.rb +0 -93
@@ -1,5 +1,29 @@
1
1
  module JsonapiCompliable
2
+ # A Scope wraps an underlying object. It modifies that object
3
+ # using the corresponding Resource and Query, and how to resolve
4
+ # that underlying object scope.
5
+ #
6
+ # @example Basic Controller usage
7
+ # def index
8
+ # base = Post.all
9
+ # scope = jsonapi_scope(base)
10
+ # scope.object == base # => true
11
+ # scope.object = scope.object.where(active: true)
12
+ #
13
+ # # actually fires sql
14
+ # scope.resolve #=> [#<Post ...>, #<Post ...>, etc]
15
+ # end
2
16
  class Scope
17
+ # @param object - The underlying, chainable base scope object
18
+ # @param resource - The Resource that will process the object
19
+ # @param query - The Query object for the current request
20
+ # @param [Hash] opts Options to configure the Scope
21
+ # @option opts [String] :namespace The nested relationship name
22
+ # @option opts [Boolean] :filter Opt-out of filter scoping
23
+ # @option opts [Boolean] :extra_fields Opt-out of extra_fields scoping
24
+ # @option opts [Boolean] :sort Opt-out of sort scoping
25
+ # @option opts [Boolean] :pagination Opt-out of pagination scoping
26
+ # @option opts [Boolean] :default_paginate Opt-out of default pagination when not specified in request
3
27
  def initialize(object, resource, query, opts = {})
4
28
  @object = object
5
29
  @resource = resource
@@ -7,17 +31,32 @@ module JsonapiCompliable
7
31
 
8
32
  # Namespace for the 'outer' or 'main' resource is its type
9
33
  # For its relationships, its the relationship name
10
- # IOW when hitting /states, it's resource type 'states
34
+ # IOW when hitting /states, it's resource type 'states'
11
35
  # when hitting /authors?include=state its 'state'
12
36
  @namespace = opts.delete(:namespace) || resource.type
13
37
 
14
38
  apply_scoping(opts)
15
39
  end
16
40
 
41
+ # Resolve the requested stats. Returns hash like:
42
+ #
43
+ # { rating: { average: 5.5, maximum: 9 } }
44
+ #
45
+ # @return [Hash] the resolved stat info
46
+ # @api private
17
47
  def resolve_stats
18
48
  Stats::Payload.new(@resource, query_hash, @unpaginated_object).generate
19
49
  end
20
50
 
51
+ # Resolve the scope. This is where SQL is actually fired, an HTTP
52
+ # request is actually made, etc.
53
+ #
54
+ # Does nothing if the user requested zero results, ie /posts?page[size]=0
55
+ #
56
+ # First resolves the top-level resource, then processes each relevant sideload
57
+ #
58
+ # @see Resource#resolve
59
+ # @return [Array] an array of resolved model instances
21
60
  def resolve
22
61
  if @query.zero_results?
23
62
  []
@@ -28,6 +67,9 @@ module JsonapiCompliable
28
67
  end
29
68
  end
30
69
 
70
+ # The slice of Query#to_hash for the current namespace
71
+ # @see Query#to_hash
72
+ # @api private
31
73
  def query_hash
32
74
  @query_hash ||= @query.to_hash[@namespace]
33
75
  end
@@ -1,8 +1,28 @@
1
1
  module JsonapiCompliable
2
2
  module Scoping
3
+ # The interface for scoping logic (filter, paginate, etc).
4
+ #
5
+ # This class defines some common behavior, such as falling back on
6
+ # a default if not part of the user request.
7
+ #
8
+ # @attr_reader [Resource] resource The corresponding Resource instance
9
+ # @attr_reader [Hash] query_hash the Query#to_hash node relevant to the current resource
10
+ #
11
+ # @see Scoping::DefaultFilter
12
+ # @see Scoping::ExtraFields
13
+ # @see Scoping::Filter
14
+ # @see Scoping::Paginate
15
+ # @see Scoping::Sort
16
+ # @see Scope#initialize
17
+ # @see Scope#query_hash
18
+ # @see Query#to_hash
3
19
  class Base
4
20
  attr_reader :resource, :query_hash
5
21
 
22
+ # @param [Resource] resource the Resource instance
23
+ # @param [Hash] query_hash the Query#to_hash node relevant to the current resource
24
+ # @param scope the base scope object to chain/modify
25
+ # @param [Hash] opts configuration options used by subclasses
6
26
  def initialize(resource, query_hash, scope, opts = {})
7
27
  @query_hash = query_hash
8
28
  @resource = resource
@@ -10,6 +30,23 @@ module JsonapiCompliable
10
30
  @opts = opts
11
31
  end
12
32
 
33
+ # Apply this scoping criteria.
34
+ # This is where we would chain on pagination, sorting, etc.
35
+ #
36
+ # If #apply? returns false, does nothing. Otherwise will apply
37
+ # the default logic:
38
+ #
39
+ # # no block, run default logic via adapter
40
+ # allow_filter :name
41
+ #
42
+ # Or the customized proc:
43
+ #
44
+ # allow_filter :name do |scope, value|
45
+ # scope.where("upper(name) = ?", value.upcase)
46
+ # end
47
+ #
48
+ # @see #apply?
49
+ # @return the scope object
13
50
  def apply
14
51
  if apply?
15
52
  apply_standard_or_override
@@ -18,10 +55,31 @@ module JsonapiCompliable
18
55
  end
19
56
  end
20
57
 
58
+ # Should we process this scope logic?
59
+ #
60
+ # Useful for when we want to explicitly opt-out on
61
+ # certain requests, or avoid a default in certain contexts.
62
+ #
63
+ # @return [Boolean] if we should apply this scope logic
21
64
  def apply?
22
65
  true
23
66
  end
24
67
 
68
+ # Defines how to call/apply the default scoping logic
69
+ def apply_standard_scope
70
+ raise 'override in subclass'
71
+ end
72
+
73
+ # Defines how to call/apply the custom scoping logic provided by the
74
+ # user.
75
+ def apply_custom_scope
76
+ raise 'override in subclass'
77
+ end
78
+
79
+ private
80
+
81
+ # If the user customized (by providing a block in the Resource DSL)
82
+ # then call the custom proc. Else, call the default proc.
25
83
  def apply_standard_or_override
26
84
  if apply_standard_scope?
27
85
  @scope = apply_standard_scope
@@ -32,17 +90,10 @@ module JsonapiCompliable
32
90
  @scope
33
91
  end
34
92
 
93
+ # Should we apply the default proc, or a custom one?
35
94
  def apply_standard_scope?
36
95
  custom_scope.nil?
37
96
  end
38
-
39
- def apply_standard_scope
40
- raise 'override in subclass'
41
- end
42
-
43
- def apply_custom_scope
44
- raise 'override in subclass'
45
- end
46
97
  end
47
98
  end
48
99
  end
@@ -1,7 +1,40 @@
1
1
  module JsonapiCompliable
2
+ # Default filters apply to every request, unless specifically overridden in
3
+ # the request.
4
+ #
5
+ # Maybe we only want to show active posts:
6
+ #
7
+ # class PostResource < ApplicationResource
8
+ # # ... code ...
9
+ # default_filter :active do |scope|
10
+ # scope.where(active: true)
11
+ # end
12
+ # end
13
+ #
14
+ # But if the user is an admin and specifically requests inactive posts:
15
+ #
16
+ # class PostResource < ApplicationResource
17
+ # # ... code ...
18
+ # allow_filter :active, if: admin?
19
+ #
20
+ # default_filter :active do |scope|
21
+ # scope.where(active: true)
22
+ # end
23
+ # end
24
+ #
25
+ # # Now a GET /posts?filter[active]=false will return inactive posts
26
+ # # if the user is an admin.
27
+ #
28
+ # @see Resource.default_filter
29
+ # @see Resource.allow_filter
2
30
  class Scoping::DefaultFilter < Scoping::Base
3
31
  include Scoping::Filterable
4
32
 
33
+ # Apply the default filtering logic.
34
+ # Loop through each defined default filter, and apply the default
35
+ # proc unless an explicit override is requested
36
+ #
37
+ # @return scope the scope object we are chaining/modifying
5
38
  def apply
6
39
  resource.default_filters.each_pair do |name, opts|
7
40
  next if overridden?(name)
@@ -1,5 +1,38 @@
1
1
  module JsonapiCompliable
2
+ # Apply logic when an extra field is requested. Useful for eager loading
3
+ # associations used to compute the extra field.
4
+ #
5
+ # Given a Resource
6
+ #
7
+ # class PersonResource < ApplicationResource
8
+ # extra_field :net_worth do |scope|
9
+ # scope.includes(:assets)
10
+ # end
11
+ # end
12
+ #
13
+ # And a corresponding serializer:
14
+ #
15
+ # class SerializablePerson < JSONAPI::Serializable::Resource
16
+ # extra_attribute :net_worth do
17
+ # @object.assets.sum(&:value)
18
+ # end
19
+ # end
20
+ #
21
+ # When the user requests the extra field 'net_worth':
22
+ #
23
+ # GET /people?extra_fields[people]=net_worth
24
+ #
25
+ # The +assets+ will be eager loaded and the 'net_worth' attribute
26
+ # will be present in the response. If this field is not explicitly
27
+ # requested, none of this logic fires.
28
+ #
29
+ # @see Resource.extra_field
30
+ # @see Extensions::ExtraAttribute
2
31
  class Scoping::ExtraFields < Scoping::Base
32
+ # Loop through all requested extra fields. If custom scoping
33
+ # logic is define for that field, run it. Otherwise, do nothing.
34
+ #
35
+ # @return the scope object we are chaining/modofying
3
36
  def apply
4
37
  each_extra_field do |callable|
5
38
  @scope = callable.call(@scope)
@@ -1,7 +1,32 @@
1
1
  module JsonapiCompliable
2
+ # Apply filtering logic to the scope
3
+ #
4
+ # If the user requests to filter a field that has not been whitelisted,
5
+ # a +JsonapiCompliable::Errors::BadFilter+ error will be raised.
6
+ #
7
+ # allow_filter :title # :title now whitelisted
8
+ #
9
+ # If the user requests a filter field that has been whitelisted, but
10
+ # does not pass the associated `+:if+ clause, +BadFilter+ will be raised.
11
+ #
12
+ # allow_filter :title, if: :admin?
13
+ #
14
+ # This will also honor filter aliases.
15
+ #
16
+ # # GET /posts?filter[headline]=foo will filter on title
17
+ # allow_filter :title, aliases: [:headline]
18
+ #
19
+ # @see Adapters::Abstract#filter
20
+ # @see Adapters::ActiveRecord#filter
21
+ # @see Resource.allow_filter
2
22
  class Scoping::Filter < Scoping::Base
3
23
  include Scoping::Filterable
4
24
 
25
+ # Apply the filtering logic.
26
+ #
27
+ # Loop and parse all requested filters, taking into account guards and
28
+ # aliases. If valid, call either the default or custom filtering logic.
29
+ # @return the scope we are chaining/modifying
5
30
  def apply
6
31
  each_filter do |filter, value|
7
32
  @scope = filter_scope(filter, value)
@@ -10,6 +35,10 @@ module JsonapiCompliable
10
35
  @scope
11
36
  end
12
37
 
38
+ private
39
+
40
+ # If there's custom logic, run it, otherwise run the default logic
41
+ # specified in the adapter.
13
42
  def filter_scope(filter, value)
14
43
  if custom_scope = filter.values.first[:filter]
15
44
  custom_scope.call(@scope, value)
@@ -18,8 +47,6 @@ module JsonapiCompliable
18
47
  end
19
48
  end
20
49
 
21
- private
22
-
23
50
  def each_filter
24
51
  filter_param.each_pair do |param_name, param_value|
25
52
  filter = find_filter!(param_name.to_sym)
@@ -1,11 +1,14 @@
1
1
  module JsonapiCompliable
2
+ # @api private
2
3
  module Scoping::Filterable
4
+ # @api private
3
5
  def find_filter(name)
4
6
  find_filter!(name)
5
7
  rescue JsonapiCompliable::Errors::BadFilter
6
8
  nil
7
9
  end
8
10
 
11
+ # @api private
9
12
  def find_filter!(name)
10
13
  filter_name, filter_value = \
11
14
  resource.filters.find { |_name, opts| opts[:aliases].include?(name.to_sym) }
@@ -16,6 +19,7 @@ module JsonapiCompliable
16
19
  { filter_name => filter_value }
17
20
  end
18
21
 
22
+ # @api private
19
23
  def filter_param
20
24
  query_hash[:filter]
21
25
  end
@@ -1,7 +1,32 @@
1
1
  module JsonapiCompliable
2
+ # Apply pagination logic to the scope
3
+ #
4
+ # If the user requests a page size greater than +MAX_PAGE_SIZE+,
5
+ # a +JsonapiCompliable::Errors::UnsupportedPageSize+ error will be raised.
6
+ #
7
+ # Notably, this will not fire when the `default: false` option is passed.
8
+ # This is the case for sideloads - if the user requests "give me the post
9
+ # and its comments", we shouldn't implicitly limit those comments to 20.
10
+ # *BUT* if the user requests, "give me the post and 3 of its comments", we
11
+ # *should* honor that pagination.
12
+ #
13
+ # This can be confusing because there are also 'default' and 'customized'
14
+ # pagination procs. The default comes 'for free'. Customized pagination
15
+ # looks like
16
+ #
17
+ # class PostResource < ApplicationResource
18
+ # paginate do |scope, current_page, per_page|
19
+ # # ... the custom logic ...
20
+ # end
21
+ # end
22
+ #
23
+ # We should use the default unless the user has customized.
24
+ # @see Resource.paginate
2
25
  class Scoping::Paginate < Scoping::Base
3
26
  MAX_PAGE_SIZE = 1_000
4
27
 
28
+ # Apply the pagination logic. Raise error if over the max page size.
29
+ # @return the scope object we are chaining/modifying
5
30
  def apply
6
31
  if size > MAX_PAGE_SIZE
7
32
  raise JsonapiCompliable::Errors::UnsupportedPageSize
@@ -11,6 +36,11 @@ module JsonapiCompliable
11
36
  end
12
37
  end
13
38
 
39
+ # We want to apply this logic unless we've explicitly received the
40
+ # +default: false+ option. In that case, only apply if pagination
41
+ # was explicitly specified in the request.
42
+ #
43
+ # @return [Boolean] should we apply this logic?
14
44
  def apply?
15
45
  if @opts[:default] == false
16
46
  not [page_param[:size], page_param[:number]].all?(&:nil?)
@@ -19,14 +49,17 @@ module JsonapiCompliable
19
49
  end
20
50
  end
21
51
 
52
+ # @return [Proc, Nil] the custom pagination proc
22
53
  def custom_scope
23
54
  resource.pagination
24
55
  end
25
56
 
57
+ # Apply default pagination proc via the Resource adapter
26
58
  def apply_standard_scope
27
59
  resource.adapter.paginate(@scope, number, size)
28
60
  end
29
61
 
62
+ # Apply the custom pagination proc
30
63
  def apply_custom_scope
31
64
  custom_scope.call(@scope, number, size)
32
65
  end
@@ -1,9 +1,25 @@
1
1
  module JsonapiCompliable
2
+ # Apply sorting logic to the scope.
3
+ #
4
+ # By default, sorting comes 'for free'. To specify a custom sorting proc:
5
+ #
6
+ # class PostResource < ApplicationResource
7
+ # sort do |scope, att, dir|
8
+ # int = dir == :desc ? -1 : 1
9
+ # scope.sort_by { |x| x[att] * int }
10
+ # end
11
+ # end
12
+ #
13
+ # The sorting proc will be called once for each sort att/dir requested.
14
+ # @see Resource.sort
2
15
  class Scoping::Sort < Scoping::Base
16
+ # @return [Proc, Nil] The custom proc specified by Resource DSL
3
17
  def custom_scope
4
18
  resource.sorting
5
19
  end
6
20
 
21
+ # Apply default scope logic via Resource adapter
22
+ # @return the scope we are chaining/modifying
7
23
  def apply_standard_scope
8
24
  each_sort do |attribute, direction|
9
25
  @scope = resource.adapter.order(@scope, attribute, direction)
@@ -11,6 +27,8 @@ module JsonapiCompliable
11
27
  @scope
12
28
  end
13
29
 
30
+ # Apply custom scoping proc configured via Resource DSL
31
+ # @return the scope we are chaining/modifying
14
32
  def apply_custom_scope
15
33
  each_sort do |attribute, direction|
16
34
  @scope = custom_scope.call(@scope, attribute, direction)
@@ -1,4 +1,15 @@
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 [Proc] grouper The configured 'group_by' proc
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
2
13
  class Sideload
3
14
  attr_reader :name,
4
15
  :resource_class,
@@ -12,6 +23,11 @@ module JsonapiCompliable
12
23
  :primary_key,
13
24
  :type
14
25
 
26
+ # NB - the adapter's +#sideloading_module+ is mixed in on instantiation
27
+ #
28
+ # An anonymous Resource will be assigned when none provided.
29
+ #
30
+ # @see Adapters::Abstract#sideloading_module
15
31
  def initialize(name, type: nil, resource: nil, polymorphic: false, primary_key: :id, foreign_key: nil)
16
32
  @name = name
17
33
  @resource_class = (resource || Class.new(Resource))
@@ -25,30 +41,178 @@ module JsonapiCompliable
25
41
  extend @resource_class.config[:adapter].sideloading_module
26
42
  end
27
43
 
44
+ # @see #resource_class
45
+ # @return [Resource] an instance of +#resource_class+
28
46
  def resource
29
47
  @resource ||= resource_class.new
30
48
  end
31
49
 
50
+ # Is this sideload polymorphic?
51
+ #
52
+ # Polymorphic sideloads group the parent objects in some fashion,
53
+ # so different 'types' can be resolved differently. Let's say an
54
+ # +Office+ has a polymorphic +Organization+, which can be either a
55
+ # +Business+ or +Government+:
56
+ #
57
+ # allow_sideload :organization, :polymorphic: true do
58
+ # group_by { |record| record.organization_type }
59
+ #
60
+ # allow_sideload 'Business', resource: BusinessResource do
61
+ # # ... code ...
62
+ # end
63
+ #
64
+ # allow_sideload 'Governemnt', resource: GovernmentResource do
65
+ # # ... code ...
66
+ # end
67
+ # end
68
+ #
69
+ # You probably want to extract this code into an Adapter. For instance,
70
+ # with ActiveRecord:
71
+ #
72
+ # polymorphic_belongs_to :organization,
73
+ # group_by: ->(office) { office.organization_type },
74
+ # groups: {
75
+ # 'Business' => {
76
+ # scope: -> { Business.all },
77
+ # resource: BusinessResource,
78
+ # foreign_key: :organization_id
79
+ # },
80
+ # 'Government' => {
81
+ # scope: -> { Government.all },
82
+ # resource: GovernmentResource,
83
+ # foreign_key: :organization_id
84
+ # }
85
+ # }
86
+ #
87
+ # @see Adapters::ActiveRecordSideloading#polymorphic_belongs_to
88
+ # @return [Boolean] is this sideload polymorphic?
32
89
  def polymorphic?
33
90
  @polymorphic == true
34
91
  end
35
92
 
93
+ # Build a scope that will be used to fetch the related records
94
+ # This scope will be further chained with filtering/sorting/etc
95
+ #
96
+ # You probably want to wrap this logic in an Adapter, instead of
97
+ # specifying in your resource directly.
98
+ #
99
+ # @example Default ActiveRecord
100
+ # class PostResource < ApplicationResource
101
+ # # ... code ...
102
+ # allow_sideload :comments, resource: CommentResource do
103
+ # scope do |posts|
104
+ # Comment.where(post_id: posts.map(&:id))
105
+ # end
106
+ # # ... code ...
107
+ # end
108
+ # end
109
+ #
110
+ # @example Custom Scope
111
+ # # In this example, our base scope is a Hash
112
+ # scope do |posts|
113
+ # { post_ids: posts.map(&:id) }
114
+ # end
115
+ #
116
+ # @example ActiveRecord via Adapter
117
+ # class PostResource < ApplicationResource
118
+ # # ... code ...
119
+ # has_many :comments,
120
+ # scope: -> { Comment.all },
121
+ # resource: CommentResource,
122
+ # foreign_key: :post_id
123
+ # end
124
+ #
125
+ # @see Adapters::Abstract
126
+ # @see Adapters::ActiveRecordSideloading#has_many
127
+ # @see #allow_sideload
128
+ # @yieldparam parents - The resolved parent records
36
129
  def scope(&blk)
37
130
  @scope_proc = blk
38
131
  end
39
132
 
133
+ # The proc used to assign the resolved parents and children.
134
+ #
135
+ # You probably want to wrap this logic in an Adapter, instead of
136
+ # specifying in your resource directly.
137
+ #
138
+ # @example Default ActiveRecord
139
+ # class PostResource < ApplicationResource
140
+ # # ... code ...
141
+ # allow_sideload :comments, resource: CommentResource do
142
+ # # ... code ...
143
+ # assign do |posts, comments|
144
+ # posts.each do |post|
145
+ # relevant_comments = comments.select { |c| c.post_id == post.id }
146
+ # post.comments = relevant_comments
147
+ # end
148
+ # end
149
+ # end
150
+ # end
151
+ #
152
+ # @example ActiveRecord via Adapter
153
+ # class PostResource < ApplicationResource
154
+ # # ... code ...
155
+ # has_many :comments,
156
+ # scope: -> { Comment.all },
157
+ # resource: CommentResource,
158
+ # foreign_key: :post_id
159
+ # end
160
+ #
161
+ # @see Adapters::Abstract
162
+ # @see Adapters::ActiveRecordSideloading#has_many
163
+ # @see #allow_sideload
164
+ # @yieldparam parents - The resolved parent records
165
+ # @yieldparam children - The resolved child records
40
166
  def assign(&blk)
41
167
  @assign_proc = blk
42
168
  end
43
169
 
170
+ # Configure how to associate parent and child records.
171
+ #
172
+ # @example Basic attr_accessor
173
+ # def associate(parent, child)
174
+ # if type == :has_many
175
+ # parent.send(:"#{name}").push(child)
176
+ # else
177
+ # child.send(:"#{name}=", parent)
178
+ # end
179
+ # end
180
+ #
181
+ # @see #name
182
+ # @see #type
44
183
  def associate(parent, child)
45
184
  resource_class.config[:adapter].associate(parent, child, name, type)
46
185
  end
47
186
 
187
+ # Define a proc that groups the parent records. For instance, with
188
+ # an ActiveRecord polymorphic belongs_to there will be a +parent_id+
189
+ # and +parent_type+. We would want to group on +parent_type+:
190
+ #
191
+ # allow_sideload :organization, polymorphic: true do
192
+ # # group parent_type, parent here is 'organization'
193
+ # group_by ->(office) { office.organization_type }
194
+ # end
195
+ #
196
+ # @see #polymorphic?
48
197
  def group_by(&grouper)
49
198
  @grouper = grouper
50
199
  end
51
200
 
201
+ # Resolve the sideload.
202
+ #
203
+ # * Uses the 'scope' proc to build a 'base scope'
204
+ # * Chains additional criteria onto that 'base scope'
205
+ # * Resolves that scope (see Scope#resolve)
206
+ # * Assigns the resulting child objects to their corresponding parents
207
+ #
208
+ # @see Scope#resolve
209
+ # @param [Object] parents The resolved parent models
210
+ # @param [Query] query The Query instance
211
+ # @param [Symbol] namespace The current namespace (see Resource#with_context)
212
+ # @see Query
213
+ # @see Resource#with_context
214
+ # @return [void]
215
+ # @api private
52
216
  def resolve(parents, query, namespace = nil)
53
217
  namespace ||= name
54
218
 
@@ -59,6 +223,44 @@ module JsonapiCompliable
59
223
  end
60
224
  end
61
225
 
226
+ # Configure a relationship between Resource objects
227
+ #
228
+ # You probably want to extract this logic into an adapter
229
+ # rather than using directly
230
+ #
231
+ # @example Default ActiveRecord
232
+ # # What happens 'under the hood'
233
+ # class CommentResource < ApplicationResource
234
+ # # ... code ...
235
+ # allow_sideload :post, resource: PostResource do
236
+ # scope do |comments|
237
+ # Post.where(id: comments.map(&:post_id))
238
+ # end
239
+ #
240
+ # assign do |comments, posts|
241
+ # comments.each do |comment|
242
+ # relevant_post = posts.find { |p| p.id == comment.post_id }
243
+ # comment.post = relevant_post
244
+ # end
245
+ # end
246
+ # end
247
+ # end
248
+ #
249
+ # # Rather than writing that code directly, go through the adapter:
250
+ # class CommentResource < ApplicationResource
251
+ # # ... code ...
252
+ # use_adapter JsonapiCompliable::Adapters::ActiveRecord
253
+ #
254
+ # belongs_to :post,
255
+ # scope: -> { Post.all },
256
+ # resource: PostResource,
257
+ # foreign_key: :post_id
258
+ # end
259
+ #
260
+ # @see Adapters::ActiveRecordSideloading#belongs_to
261
+ # @see #assign
262
+ # @see #scope
263
+ # @return void
62
264
  def allow_sideload(name, opts = {}, &blk)
63
265
  sideload = Sideload.new(name, opts)
64
266
  sideload.instance_eval(&blk) if blk
@@ -70,11 +272,37 @@ module JsonapiCompliable
70
272
  end
71
273
  end
72
274
 
275
+ # Fetch a Sideload object by its name
276
+ # @param [Symbol] name The name of the corresponding sideload
277
+ # @see +allow_sideload
278
+ # @return the corresponding Sideload object
73
279
  def sideload(name)
74
280
  @sideloads[name]
75
281
  end
76
282
 
77
- # Grab from nested sideloads, AND resource, recursively
283
+ # Looks at all nested sideload, and all nested sideloads for the
284
+ # corresponding Resources, and returns an Include Directive hash
285
+ #
286
+ # For instance, this configuration:
287
+ #
288
+ # class BarResource < ApplicationResource
289
+ # allow_sideload :baz do
290
+ # end
291
+ # end
292
+ #
293
+ # class PostResource < ApplicationResource
294
+ # allow_sideload :foo do
295
+ # allow_sideload :bar, resource: BarResource do
296
+ # end
297
+ # end
298
+ # end
299
+ #
300
+ # +post_resource.sideloading.to_hash+ would return
301
+ #
302
+ # { base: { foo: { bar: { baz: {} } } } }
303
+ #
304
+ # @return [Hash] The nested include hash
305
+ # @api private
78
306
  def to_hash(processed = [])
79
307
  return { name => {} } if processed.include?(self)
80
308
  processed << self