pragma-decorator 2.0.0 → 2.1.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 4cc969bd13e27d434cc079786db43b29ae6b6154
4
- data.tar.gz: cfefba30c1aab7b6d53db48031df9e991159537e
3
+ metadata.gz: 3112d0727880b4dc23ca921b073f064e2f06067f
4
+ data.tar.gz: fea5ef98f76c7a32c28e461502039f1f1c9a2d32
5
5
  SHA512:
6
- metadata.gz: 26574df0b3a1807c18fe1c156feadf6ea37947fcb044f1587d4de0319782af2164cb5a7c591a99bb954d187b0ec6c6e5c499150eba89be2ab04c8b36c00a6763
7
- data.tar.gz: d502113406896fd913100c0ededa0242ead3fc8fa1d1cdd7545d2bff0ed15fe9d8a227beac6f3177f22189354223c9bada76a62f02f188b4119b3549bab0c51d
6
+ metadata.gz: ac722386db2bfe735422685fbf81fdecbcf4c79a8c03677df38b84a681b2c435232260dc60f11843504dfca7d772439f42ec3f8c4a8dbbdd76d66c30853fb93b
7
+ data.tar.gz: 444d356b1fbc1277c77a2caa86ee2d4378636f285c574451a4b7a0843f53bd9fe46898b2d599f10d18ea77f830bb60ae8b3e0413104143c72f0b6f3123b21d59
data/.rubocop.yml CHANGED
@@ -92,3 +92,13 @@ Style/GuardClause:
92
92
 
93
93
  Capybara/FeatureMethods:
94
94
  Enabled: false
95
+
96
+ Metrics/ClassLength:
97
+ Enabled: false
98
+
99
+ Style/GuardClause:
100
+ Enabled: false
101
+
102
+ Naming/FileName:
103
+ Exclude:
104
+ - 'pragma-decorator.gemspec'
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
6
+ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [2.1.0]
11
+
12
+ ### Added
13
+
14
+ - Added support for `:as` in `timestamp` properties
15
+ - `user_options` are now forwarded to expanded associations
16
+ - Made associations ORM-independent with the Adapter API
17
+ - Implemented the Type Overrides API
18
+ - Implemented the Pagination Adapter API
19
+
20
+ ### Changed
21
+
22
+ - Changed the `#type` of collections from `collection` to `list`
23
+ - Replaced `feature` with `include` in tests and examples
24
+
25
+ ### Fixed
26
+
27
+ - Fixed `type` property not returning `list` for instances of `ActiveRecord::Relation`
28
+ - Fixed bugs with the optimization of associations with custom scopes
29
+
30
+ ## [2.0.0]
31
+
32
+ First Pragma 2 release.
33
+
34
+ [Unreleased]: https://github.com/pragmarb/pragma-decorator/compare/v2.1.0...HEAD
35
+ [2.1.0]: https://github.com/pragmarb/pragma-decorator/compare/v2.0.0...v2.1.0
36
+ [2.0.0]: https://github.com/pragmarb/pragma-decorator/compare/v1.2.0...v2.0.0
data/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # Pragma::Decorator
2
2
 
3
- [![Build Status](https://img.shields.io/travis/pragmarb/pragma-decorator.svg?maxAge=3600&style=flat-square)](https://travis-ci.org/pragmarb/pragma-decorator)
4
- [![Dependency Status](https://img.shields.io/gemnasium/pragmarb/pragma-decorator.svg?maxAge=3600&style=flat-square)](https://gemnasium.com/github.com/pragmarb/pragma-decorator)
5
- [![Code Climate](https://img.shields.io/codeclimate/github/pragmarb/pragma-decorator.svg?maxAge=3600&style=flat-square)](https://codeclimate.com/github/pragmarb/pragma-decorator)
6
- [![Coveralls](https://img.shields.io/coveralls/pragmarb/pragma-decorator.svg?maxAge=3600&style=flat-square)](https://coveralls.io/github/pragmarb/pragma-decorator)
3
+ [![Build Status](https://travis-ci.org/pragmarb/pragma-decorator.svg?branch=master)](https://travis-ci.org/pragmarb/pragma-decorator)
4
+ [![Dependency Status](https://gemnasium.com/badges/github.com/pragmarb/pragma-decorator.svg)](https://gemnasium.com/github.com/pragmarb/pragma-decorator)
5
+ [![Coverage Status](https://coveralls.io/repos/github/pragmarb/pragma-decorator/badge.svg?branch=master)](https://coveralls.io/github/pragmarb/pragma-decorator?branch=master)
6
+ [![Maintainability](https://api.codeclimate.com/v1/badges/e51e8d7489eb72ab97ba/maintainability)](https://codeclimate.com/github/pragmarb/pragma-decorator/maintainability)
7
7
 
8
8
  Decorators are a way to easily convert your API resources to JSON with minimum hassle.
9
9
 
@@ -84,7 +84,7 @@ module API
84
84
  module User
85
85
  module Decorator
86
86
  class Instance < Pragma::Decorator::Base
87
- feature Pragma::Decorator::Type
87
+ include Pragma::Decorator::Type
88
88
  end
89
89
  end
90
90
  end
@@ -134,7 +134,7 @@ module API
134
134
  module User
135
135
  module Decorator
136
136
  class Instance < Pragma::Decorator::Base
137
- feature Pragma::Decorator::Timestamp
137
+ include Pragma::Decorator::Timestamp
138
138
 
139
139
  timestamp :created_at
140
140
  end
@@ -153,7 +153,7 @@ This will render a user like this:
153
153
  }
154
154
  ```
155
155
 
156
- The `#timestamp` method supports all the options supported by `#property` (except for `:as`).
156
+ The `#timestamp` method supports all the options supported by `#property`.
157
157
 
158
158
  ### Associations
159
159
 
@@ -166,7 +166,7 @@ module API
166
166
  module Invoice
167
167
  module Decorator
168
168
  class Instance < Pragma::Decorator::Base
169
- feature Pragma::Decorator::Association
169
+ include Pragma::Decorator::Association
170
170
 
171
171
  belongs_to :customer, decorator: API::V1::Customer::Decorator
172
172
  end
@@ -212,6 +212,8 @@ decorator.to_json(user_options: {
212
212
  Needless to say, this is done automatically for you when you use all components together through
213
213
  the [pragma](https://github.com/pragmarb/pragma) gem! :)
214
214
 
215
+ Associations support all the options supported by `#property`.
216
+
215
217
  ### Collection
216
218
 
217
219
  `Pragma::Decorator::Collection` wraps collections in a `data` property so that you can include
@@ -223,7 +225,7 @@ module API
223
225
  module Invoice
224
226
  module Decorator
225
227
  class Collection < Pragma::Decorator::Base
226
- feature Pragma::Decorator::Collection
228
+ include Pragma::Decorator::Collection
227
229
  decorate_with Instance # specify the instance decorator
228
230
 
229
231
  property :total_cents, exec_context: :decorator
@@ -273,8 +275,8 @@ module API
273
275
  module Invoice
274
276
  module Decorator
275
277
  class Collection < Pragma::Decorator::Base
276
- feature Pragma::Decorator::Collection
277
- feature Pragma::Decorator::Pagination
278
+ include Pragma::Decorator::Collection
279
+ include Pragma::Decorator::Pagination
278
280
 
279
281
  decorate_with Instance
280
282
  end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pragma
4
+ module Decorator
5
+ module Association
6
+ module Adapter
7
+ # The ActiveRecord association adapter is used in AR environments and tries to minimize the
8
+ # number of SQL queries that are made to retrieve the associated object's data.
9
+ #
10
+ # @api private
11
+ class ActiveRecord < Base
12
+ class << self
13
+ # Returns whether the adapter supports the given model.
14
+ #
15
+ # @param model [Object] the model to check
16
+ #
17
+ # @return [Boolean] whether the object is an instance of +ActiveRecord::Base+
18
+ def supports?(model)
19
+ Object.const_defined?('ActiveRecord::Base') && model.is_a?(ActiveRecord::Base)
20
+ end
21
+ end
22
+
23
+ # Initializes the adapter.
24
+ #
25
+ # @param bond [Bond] the bond to use in the adapter
26
+ #
27
+ # @raise [InconsistentTypeError] when the association's real type is different from the
28
+ # one defined on the decorator ()e.g. decorator defines the association as +belongs_to+,
29
+ # but ActiveRecord reports its type is +has_one+)
30
+ def initialize(bond)
31
+ super
32
+ check_type_consistency
33
+ end
34
+
35
+ # Returns the primary key of the associated object.
36
+ #
37
+ # If the +exec_context+ of the association is +decorator+, this will simply return early
38
+ # with the value returned by +#id+ on the associated object.
39
+ #
40
+ # If the association is a +belongs_to+, there are three possible scenarios:
41
+ #
42
+ # * the association does not have a custom scope: this will compute the PK by calling
43
+ # the foreign key on the parent model;
44
+ # * the association has a custom scope and it has not been loaded: this will compute
45
+ # the PK by +pluck+ing the PK column of the associated object;
46
+ # * the association has a custom scope and it has been loaded: this will compute
47
+ # the PK by retrieving the PK attribute from the loaded object.
48
+ #
49
+ # If the association is a +has_one+, there are two possible scenarios:
50
+ #
51
+ # * the association has already been loaded: this will compute the PK by retrieving the
52
+ # PK attribute from the loaded object;
53
+ # * the association has not been loaded: this will compute the PK by +pluck+ing the PK
54
+ # column of the associated object;
55
+ #
56
+ # Custom scopes are always respected in both +belongs_to+ and +has_one+.
57
+ #
58
+ # +nil+ values are handled gracefully in all cases.
59
+ #
60
+ # @return [String|Integer|NilClass] the PK of the associated object
61
+ #
62
+ # @todo Allow to specify a different PK attribute when +exec_context+ is +decorator+
63
+ def primary_key
64
+ return associated_object&.id if reflection.options[:exec_context] == :decorator
65
+
66
+ case reflection.type
67
+ when :belongs_to
68
+ compute_belongs_to_fk
69
+ when :has_one
70
+ compute_has_one_fk
71
+ else
72
+ fail "Cannot compute primary key for #{reflection.type} association"
73
+ end
74
+ end
75
+
76
+ # Returns the expanded associated object.
77
+ #
78
+ # This will simply return the associated object itself, delegating caching to AR.
79
+ #
80
+ # @return [Object] the associated object
81
+ #
82
+ # @todo Ensure the required attributes are present on the associated object
83
+ def full_object
84
+ associated_object
85
+ end
86
+
87
+ private
88
+
89
+ def compute_belongs_to_fk
90
+ if model.association(reflection.property).loaded?
91
+ return associated_object&.public_send(association_reflection.association_primary_key)
92
+ end
93
+
94
+ if association_reflection.scope.nil?
95
+ return model.public_send(association_reflection.foreign_key)
96
+ end
97
+
98
+ pluck_association_fk do |scope|
99
+ fk = model.public_send(association_reflection.foreign_key)
100
+ scope.where(association_reflection.association_primary_key => fk)
101
+ end
102
+ end
103
+
104
+ def compute_has_one_fk
105
+ if model.association(reflection.property).loaded?
106
+ return associated_object&.public_send(associated_object.association_primary_key)
107
+ end
108
+
109
+ pluck_association_fk do |scope|
110
+ pk = model.public_send(association_reflection.active_record_primary_key)
111
+ scope.where(association_reflection.foreign_key => pk)
112
+ end
113
+ end
114
+
115
+ def pluck_association_fk
116
+ scope = association_reflection.klass.all
117
+
118
+ if association_reflection.scope
119
+ scope = association_reflection.instance_eval(&association_reflection.scope)
120
+ end
121
+
122
+ yield(scope).pluck(association_reflection.association_primary_key).first
123
+ end
124
+
125
+ def association_reflection
126
+ @association_reflection ||= model.class.reflect_on_association(reflection.property)
127
+ end
128
+
129
+ def check_type_consistency
130
+ return if association_reflection.macro.to_sym == reflection.type.to_sym
131
+
132
+ fail InconsistentTypeError.new(
133
+ decorator: decorator,
134
+ reflection: reflection,
135
+ model_type: association_reflection.macro
136
+ )
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pragma
4
+ module Decorator
5
+ module Association
6
+ module Adapter
7
+ # The base association adapter, defining the interface for the implementations.
8
+ #
9
+ # @abstract Subclass and override {.supports?}, {#primary_key} and {#full_object} to
10
+ # create a new adapter
11
+ #
12
+ # @api private
13
+ class Base
14
+ class << self
15
+ # Returns whether the adapter supports the given model.
16
+ #
17
+ # @param _model [Object] a model
18
+ #
19
+ # @return [Boolean] whether the adpater supports the model
20
+ #
21
+ # @abstract
22
+ def supports?(_model)
23
+ fail NotImplementedError
24
+ end
25
+ end
26
+
27
+ # @!attribute [r] bond
28
+ # @return [Bond] the bond this adapter has been instantiated with
29
+ attr_reader :bond
30
+
31
+ # Initializes the adapter.
32
+ #
33
+ # @param bond [Bond] the bond to use
34
+ def initialize(bond)
35
+ @bond = bond
36
+ end
37
+
38
+ # Returns the primary key of the association represented by the provided bond.
39
+ #
40
+ # @return [String|Integer] the PK
41
+ #
42
+ # @abstract
43
+ def primary_key
44
+ fail NotImplementedError
45
+ end
46
+
47
+ # Returns the full object of the association represented by the provided bond.
48
+ #
49
+ # @return [Object] the full object
50
+ #
51
+ # @abstract
52
+ def full_object
53
+ fail NotImplementedError
54
+ end
55
+
56
+ protected
57
+
58
+ # This is a convenience method returning the reflection defined on the bond.
59
+ #
60
+ # @return [Reflection] the bond's reflection
61
+ #
62
+ # @see Bond#reflection
63
+ def reflection
64
+ bond.reflection
65
+ end
66
+
67
+ # This is a convenience method returning the reflection defined on the bond.
68
+ #
69
+ # @return [Object] the bond's associated object
70
+ #
71
+ # @see Bond#associated_object
72
+ def associated_object
73
+ bond.associated_object
74
+ end
75
+
76
+ # This is a convenience method returning the model defined on the bond.
77
+ #
78
+ # @return [Reflection] the bond's model
79
+ #
80
+ # @see Bond#model
81
+ def model
82
+ bond.model
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pragma
4
+ module Decorator
5
+ module Association
6
+ module Adapter
7
+ # This is the fallback adapter that is used when no other adpater is compatible with a
8
+ # model. It simply calls +#id+ on the associated object to get the PK and returns the
9
+ # associated object itself when expanding.
10
+ #
11
+ # @api private
12
+ class Poro < Base
13
+ class << self
14
+ # Returns whether the adapter supports the model.
15
+ #
16
+ # Since this is the default adapter, this always returns +true+.
17
+ #
18
+ # @param _model [Object] the model to check
19
+ #
20
+ # @return [Boolean] always +true+
21
+ def supports?(_model)
22
+ true
23
+ end
24
+ end
25
+
26
+ # Returns the PK of the associated object.
27
+ #
28
+ # This adapter simply calls +#id+ on the associated object or returns +nil+ if there is
29
+ # no associated object.
30
+ #
31
+ # @return [Integer|String|NilClass] the PK of the associated object
32
+ def primary_key
33
+ associated_object&.id
34
+ end
35
+
36
+ # Returns the expanded associated object.
37
+ #
38
+ # This adapter simply returns the associated object itself.
39
+ #
40
+ # @return [Object] the associated object
41
+ def full_object
42
+ associated_object
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pragma
4
+ module Decorator
5
+ module Association
6
+ # Adapters make associations ORM-independent by providing support for multiple underlying
7
+ # libraries like ActiveRecord and simple POROs.
8
+ #
9
+ # @api private
10
+ module Adapter
11
+ # The list of supported adapters, in order of priority.
12
+ SUPPORTED_ADAPTERS = [ActiveRecord, Poro].freeze
13
+
14
+ # Loads the adapter for the given association bond.
15
+ #
16
+ # This will try {SUPPORTED_ADAPTERS} in order until it finds an adapter that supports the
17
+ # bond's model. When the adapter is found, it will return a new instance of it.
18
+ #
19
+ # @param bond [Bond] the bond to load the adapter for
20
+ #
21
+ # @return [Adapter::Base]
22
+ #
23
+ # @see Adapter::Base.supports?
24
+ def self.load_for(bond)
25
+ SUPPORTED_ADAPTERS.find do |adapter|
26
+ adapter.supports?(bond.model)
27
+ end.new(bond)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -5,8 +5,8 @@ module Pragma
5
5
  module Association
6
6
  # Links an association definition to a specific decorator instance, allowing to render it.
7
7
  #
8
- # @author Alessandro Desantis
9
- class Binding
8
+ # @api private
9
+ class Bond
10
10
  # @!attribute [r] reflection
11
11
  # @return [Reflection] the association reflection
12
12
  #
@@ -14,7 +14,7 @@ module Pragma
14
14
  # @return [Pragma::Decorator::Base] the decorator instance
15
15
  attr_reader :reflection, :decorator
16
16
 
17
- # Initializes the binding.
17
+ # Initializes the bond.
18
18
  #
19
19
  # @param reflection [Reflection] the association reflection
20
20
  # @param decorator [Pragma::Decorator::Base] the decorator instance
@@ -29,9 +29,9 @@ module Pragma
29
29
  def associated_object
30
30
  case reflection.options[:exec_context]
31
31
  when :decorated
32
- decorator.decorated.send(reflection.property)
32
+ model.public_send(reflection.property)
33
33
  when :decorator
34
- decorator.send(reflection.property)
34
+ decorator.public_send(reflection.property)
35
35
  end
36
36
  end
37
37
 
@@ -39,8 +39,7 @@ module Pragma
39
39
  #
40
40
  # @return [String]
41
41
  def unexpanded_value
42
- return unless associated_object
43
- associated_object.id
42
+ adapter.primary_key
44
43
  end
45
44
 
46
45
  # Returns the expanded value for the associated object.
@@ -53,48 +52,58 @@ module Pragma
53
52
  # In any case, passes all nested associations as the +expand+ user option of the method
54
53
  # called.
55
54
  #
56
- # @param expand [Array<String>] the associations to expand
55
+ # @param user_options [Array]
57
56
  #
58
57
  # @return [Hash]
59
- def expanded_value(expand)
60
- return unless associated_object
58
+ def expanded_value(user_options)
59
+ full_object = adapter.full_object
60
+ return unless full_object
61
61
 
62
62
  options = {
63
- user_options: {
64
- expand: flatten_expand(expand)
65
- }
63
+ user_options: user_options.merge(
64
+ expand: flatten_expand(user_options[:expand])
65
+ )
66
66
  }
67
67
 
68
68
  decorator_klass = compute_decorator
69
69
 
70
70
  if decorator_klass
71
- decorator_klass.new(associated_object).to_hash(options)
71
+ decorator_klass.new(full_object).to_hash(options)
72
72
  else
73
- associated_object.as_json(options)
73
+ full_object.as_json(options)
74
74
  end
75
75
  end
76
76
 
77
77
  # Renders the unexpanded or expanded associations, depending on the +expand+ user option
78
78
  # passed to the decorator.
79
79
  #
80
- # @param expand [Array<String>] the associations to expand
80
+ # @param user_options [Array]
81
81
  #
82
82
  # @return [Hash|Pragma::Decorator::Base]
83
- def render(expand)
84
- return unless associated_object
85
-
86
- expand ||= []
87
-
88
- if expand.any? { |value| value.to_s == reflection.property.to_s }
89
- expanded_value(expand)
83
+ def render(user_options)
84
+ if user_options[:expand]&.any? { |value| value.to_s == reflection.property.to_s }
85
+ expanded_value(user_options)
90
86
  else
91
87
  unexpanded_value
92
88
  end
93
89
  end
94
90
 
91
+ # Returns the model associated to this bond.
92
+ #
93
+ # @return [Object]
94
+ def model
95
+ decorator.decorated
96
+ end
97
+
95
98
  private
96
99
 
100
+ def adapter
101
+ @adapter ||= Adapter.load_for(self)
102
+ end
103
+
97
104
  def flatten_expand(expand)
105
+ expand ||= []
106
+
98
107
  expected_beginning = "#{reflection.property}."
99
108
 
100
109
  expand.reject { |value| value.to_s == reflection.property.to_s }.map do |value|
@@ -3,21 +3,38 @@
3
3
  module Pragma
4
4
  module Decorator
5
5
  module Association
6
+ # This is a generic class for all errors during association expansion.
6
7
  class ExpansionError < StandardError
7
8
  end
8
9
 
10
+ # This is raised when a non-existing association is expanded.
9
11
  class AssociationNotFound < ExpansionError
12
+ # @!attribute [r] property
13
+ # @return [String|Sybmol] the property the user tried to expand
10
14
  attr_reader :property
11
15
 
16
+ # Initializes the rror.
17
+ #
18
+ # @param property [String|Symbol] the property the user tried to expand
12
19
  def initialize(property)
13
20
  @property = property
14
21
  super "The '#{property}' association is not defined."
15
22
  end
16
23
  end
17
24
 
25
+ # This is raised when the user expanded a nested association without expanding its parent.
18
26
  class UnexpandedAssociationParent < ExpansionError
27
+ # @!attribute [r] child
28
+ # @return [String|Symbol] the name of the child association
29
+ #
30
+ # @!attribute [r] parent
31
+ # @return [String|Symbol] the name of the parent association
19
32
  attr_reader :child, :parent
20
33
 
34
+ # Initializes the error.
35
+ #
36
+ # @param child [String|Symbol] the name of the child association
37
+ # @param parent [String|Symbol] the name of the parent association
21
38
  def initialize(child, parent)
22
39
  @child = child
23
40
  @parent = parent
@@ -25,6 +42,26 @@ module Pragma
25
42
  super "The '#{child}' association is expanded, but its parent '#{parent}' is not."
26
43
  end
27
44
  end
45
+
46
+ # This error is raised when an association's type is different from its type as reported by
47
+ # the model's reflection.
48
+ #
49
+ # @author Alessandro Desantis
50
+ class InconsistentTypeError < StandardError
51
+ # Initializes the error.
52
+ #
53
+ # @param decorator [Base] the decorator where the association is defined
54
+ # @param reflection [Reflection] the reflection of the inconsistent association
55
+ # @param model_type [Symbol|String] the real type of the association
56
+ def initialize(decorator:, reflection:, model_type:)
57
+ message = <<~MSG.tr("\n", ' ')
58
+ #{decorator.class}: Association #{reflection.property} is defined as #{model_type} on
59
+ the model, but as #{reflection.type} in the decorator.
60
+ MSG
61
+
62
+ super message
63
+ end
64
+ end
28
65
  end
29
66
  end
30
67
  end
@@ -5,7 +5,7 @@ module Pragma
5
5
  module Association
6
6
  # Holds the information about an association.
7
7
  #
8
- # @author Alessandro Desantis
8
+ # @api private
9
9
  class Reflection
10
10
  # @!attribute [r] type
11
11
  # @return [Symbol] the type of the association