jsonapi_compliable 0.6.4 → 0.6.5

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 (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