pragma-decorator 2.0.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
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