active_facets 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ module ActiveFacets
2
+ module Serializer
3
+ module Base
4
+ extend ActiveSupport::Concern
5
+
6
+ module ClassMethods
7
+
8
+ # ###
9
+ # DSL
10
+ # ###
11
+
12
+ # See Readme.md
13
+
14
+ # DSL Defines a transform to rename or reformat an attribute
15
+ # @param api_attribute [Symbol] name of the attribute
16
+ # @param options [Hash]
17
+ # @option options [Symbol] :as internal method name to call on the resource for serialization and hydration
18
+ # @option options [Symbol] :from internal method name to call on the resource for serialization
19
+ # @option options [Symbol] :to internal method name to call on the resource for hydration
20
+ # @option options [Symbol] :with name of a CustomAttributeSerializer Class for serialization and hydration
21
+ # @option options [Symbol] :within name of nested json attribute to serialize/hyrdrate within
22
+ def transform(api_attribute, options = {})
23
+ if options[:as].present?
24
+ config.transforms_from[api_attribute] = options[:as]
25
+ config.transforms_to[api_attribute] = options[:as]
26
+ end
27
+ if options[:from].present?
28
+ config.transforms_from[api_attribute] = options[:from]
29
+ config.transforms_to[api_attribute] ||= options[:from]
30
+ end
31
+ if options[:to].present?
32
+ config.transforms_from[api_attribute] ||= options[:to]
33
+ config.transforms_to[api_attribute] = options[:to]
34
+ end
35
+ config.serializers[api_attribute] = options[:with] if options[:with].present?
36
+ config.namespaces[api_attribute] = options[:within] if options[:within].present?
37
+ expose api_attribute
38
+ end
39
+
40
+ # DSL Defines an attribute extension available for decoration and serialization
41
+ # @param api_attribute [Symbol] name of the attribute
42
+ def extension(api_attribute)
43
+ config.extensions[api_attribute] = true
44
+ config.serializers[api_attribute] = api_attribute.to_sym
45
+ expose api_attribute
46
+ end
47
+
48
+ # DSL Defines an alias that can be used instead of a Field Set
49
+ # @param field_set_name [Symbol] the alias name
50
+ # @param options [Hash]
51
+ # @option as [MIXED] a nested field_set collection
52
+ def expose(field_set_name, options = {})
53
+ field_set_name = field_set_name.to_sym
54
+ raise ActiveFacets::Errors::ConfigurationError.new(ActiveFacets::Errors::ConfigurationError::ALL_ATTRIBUTES_ERROR_MSG) if field_set_name == :all_attributes
55
+ raise ActiveFacets::Errors::ConfigurationError.new(ActiveFacets::Errors::ConfigurationError::ALL_FIELDS_ERROR_MSG) if field_set_name == :all
56
+ config.alias_field_set(field_set_name, options.key?(:as) ? options[:as] : field_set_name)
57
+ end
58
+
59
+ #DSL Defines an alias for common ActiveRecord attributes
60
+ def expose_timestamps
61
+ transform :created_at, with: :time
62
+ transform :updated_at, with: :time
63
+ expose :timestamps, as: [:id, :created_at, :updated_at]
64
+ end
65
+
66
+ #DSL Registers the class type to be serialized
67
+ def resource_class(klass)
68
+ config.resource_class = klass
69
+ end
70
+
71
+ # ###
72
+ # Public Interface
73
+ # ###
74
+
75
+ # Singleton
76
+ # @return [Serializer::Base]
77
+ def new
78
+ @instance ||= super
79
+ end
80
+
81
+ # TODO --jdc change new/instance contract
82
+ # def instance
83
+ # @instance ||= new
84
+ # end
85
+
86
+ # Memoized class getter
87
+ # @return [Config]
88
+ def config
89
+ @config ||= ActiveFacets::Config.new
90
+ end
91
+
92
+ end
93
+
94
+ # INSTANCE METHODS
95
+
96
+ # This method returns a hash suitable to pass into ActiveRecord.includes to avoid N+1
97
+ # @param field_set [Field Set] collections of fields to be serialized from this resource later
98
+ # given:
99
+ # :basic is a defined collection
100
+ # :extended is a defined collection
101
+ # :orders is a defined association
102
+ # :line_items is a defined association on OrderSerializer
103
+ # examples:
104
+ # :basic
105
+ # [:basic]
106
+ # {basic: nil}
107
+ # [:basic, :extended]
108
+ # [:basic, :extended, :orders]
109
+ # [:basic, :extended, {orders: :basic}]
110
+ # [:basic, :extended, {orders: [:basic, :extended]}]
111
+ # [:basic, :extended, {orders: [:basic, :line_items]}]
112
+ # [:basic, :extended, {orders: [:basic, {line_items: :extended}]}]
113
+ # @return [Hash]
114
+ def scoped_includes(field_set = nil, options = {})
115
+ result = {}
116
+ config.field_set_itterator(field_set) do |field, nested_field_set|
117
+ case value = scoped_include(field, nested_field_set, options)
118
+ when nil
119
+ when Hash
120
+ result.deep_merge! value
121
+ else
122
+ result[value] ||= nil
123
+ end
124
+ end
125
+ result
126
+ end
127
+
128
+ def scoped_include(field, nested_field_set, options)
129
+ if is_association? field
130
+ attribute = resource_attribute_name(field)
131
+ if nested_field_set
132
+ serializer_class = get_association_serializer_class(field, options)
133
+ attribute = { attribute => serializer_class.present? ? serializer_class.scoped_includes(nested_field_set, options) : nested_field_set }
134
+ end
135
+ attribute
136
+ else
137
+ custom_includes(field, options)
138
+ end
139
+ end
140
+
141
+ # Returns field_set serialized for dependant resources in custom attribute serializers & extensions
142
+ # @param field [Field]
143
+ # @return [Field Set]
144
+ def custom_includes(field, options)
145
+ attribute = resource_attribute_name(field)
146
+ custom_serializer_name = config.serializers[attribute]
147
+
148
+ if custom_serializer_name
149
+ custom_serializer = get_custom_serializer_class(custom_serializer_name, options)
150
+ if custom_serializer.respond_to? :custom_scope
151
+ custom_serializer.custom_scope
152
+ else
153
+ nil
154
+ end
155
+ else
156
+ nil
157
+ end
158
+ end
159
+
160
+ # Gets flattened fields from a Field Set Alias
161
+ # @param field_set_alias [Symbol] to retrieve aliased field_sets for
162
+ # @param include_relations [Boolean]
163
+ # @param include_nested_field_sets [Boolean]
164
+ # @return [Array] of symbols
165
+ def exposed_aliases(field_set_alias = :all, include_relations = false, include_nested_field_sets = false)
166
+ return include_nested_field_sets ? field_set_alias : [field_set_alias] unless normalized_field_sets = config.normalized_field_sets[field_set_alias]
167
+ result = normalized_field_sets[include_relations ? :fields : :attributes]
168
+ return result if include_nested_field_sets
169
+ result.keys.map(&:to_sym).sort
170
+ end
171
+
172
+ # This method returns a ActiveRecord model updated to match a JSON of hash values
173
+ # @param resource [ActiveRecord] to hydrate
174
+ # @param attribute [Hash] subset of the values returned by {resource.as_json}
175
+ # @return [ActiveRecord] resource
176
+ def from_hash(resource, attributes, options = {})
177
+ ActiveFacets::Serializer::Facade.new(self, resource, options).from_hash(attributes)
178
+ end
179
+
180
+ # This method returns a JSON of hash values representing the resource(s)
181
+ # @param resource [ActiveRecord || Array] CollectionProxy object ::or:: a collection of resources
182
+ # @param options [Hash] collection of values required that are not available in lexical field_set
183
+ # @return [JSON] representing the resource
184
+ def as_json(resources, options = {})
185
+ resource_itterator(resources) do |resource|
186
+ facade = ActiveFacets::Serializer::Facade.new(self, resource, options)
187
+ facade.as_json
188
+ end
189
+ end
190
+
191
+ # Memoized instance getter
192
+ # @return [Config]
193
+ def config
194
+ @config ||= self.class.config
195
+ end
196
+
197
+ ### TODO --jdc ^^ START REFLECTOR
198
+ # move all this to config or resource manager
199
+
200
+ # Constantizes the appropriate resource serializer class
201
+ # @return [Class]
202
+ def resource_class
203
+ @resource_class ||= config.resource_class
204
+ end
205
+
206
+ # Constantizes an appropriate resource serializer class for relations
207
+ # @param field [Symbol] to find relation reflection for
208
+ # @return [Reflection | nil]
209
+ def get_association_reflection(field)
210
+ @association_reflections ||= {}
211
+ @association_reflections[field] ||= resource_class.reflect_on_association(resource_attribute_name(field).to_sym)
212
+ end
213
+
214
+ # Constantizes an appropriate resource serializer class
215
+ # @param field [Symbol] to test as relation and find serializer class for
216
+ # @return [Class | nil]
217
+ def get_association_serializer_class(field, options)
218
+ @association_serializers ||= {}
219
+ unless @association_serializers.key? field
220
+ @association_serializers[field] = nil
221
+ #return nil if field isn't an association
222
+ if reflection = get_association_reflection(field)
223
+ #return nil if association doesn't have a custom class
224
+ @association_serializers[field] = ActiveFacets::ResourceManager.instance.serializer_for(reflection.klass, options)
225
+ end
226
+ end
227
+ @association_serializers[field]
228
+ end
229
+
230
+ # Constantizes an appropriate attribute serializer class
231
+ # @param attribute [Symbol] base_name of attribute serializer class to find
232
+ # @param options [Hash]
233
+ # @return [Class | nil]
234
+ def get_custom_serializer_class(attribute, options)
235
+ @custom_serializers ||= {}
236
+ @custom_serializers[attribute] ||= ActiveFacets::ResourceManager.instance.attribute_serializer_class_for(resource_class, attribute, options)
237
+ end
238
+
239
+ # Determines if public attribute maps to a private relation
240
+ # @param field [Symbol] public attribute name
241
+ # @return [Boolean]
242
+ def is_association?(field)
243
+ !!get_association_reflection(field)
244
+ end
245
+
246
+ # Renames attribute between resource.attribute_name and json.attribute_name
247
+ # @param field [Symbol] attribute name
248
+ # @param direction [Symbol] to apply translation
249
+ # @return [Symbol]
250
+ def resource_attribute_name(field, direction = :from)
251
+ (config.transforms(direction)[field] || field).to_sym
252
+ end
253
+
254
+ ### TODO ^^ END REFLECTOR
255
+
256
+ protected
257
+
258
+ # @return [Serializer::Base]
259
+ def initialize
260
+ config.compile! self
261
+ rescue SystemStackError => e
262
+ raise ActiveFacets::Errors::ConfigurationError.new(ActiveFacets::Errors::ConfigurationError::STACK_ERROR_MSG)
263
+ end
264
+
265
+ # Itterates a resource collection invoking block
266
+ # @param resource [ActiveRecord || Array] to traverse
267
+ # @param block [Block] to call for each resource
268
+ def resource_itterator(resource)
269
+ if resource.is_a?(Array)
270
+ resource.map do |resource|
271
+ yield resource
272
+ end.compact
273
+ else
274
+ yield resource
275
+ end
276
+ end
277
+
278
+ # Removes self.class_name from the end of self.module_name
279
+ # @return [String]
280
+ def module_base_name
281
+ @module_base_name ||= module_name.deconstantize
282
+ end
283
+
284
+ # Removes self.class_name from the end of self.class.name
285
+ # @return [String]
286
+ def module_name
287
+ @module_name ||= self.class.name.deconstantize
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,223 @@
1
+ module ActiveFacets
2
+ module Serializer
3
+ class Facade
4
+ attr_accessor :serializer, # Serializer:Base
5
+ :resource, # Object to delegate to
6
+ :options, # Options Hash passed to as_json
7
+ :opts, # RCB specific options inside Options Hash
8
+ :fields, # Field Sets to apply
9
+ :field_overrides, # Field Overrides to apply
10
+ :overrides, # Field Overrides specific to resource
11
+ :version, # Serializer version to apply
12
+ :filters, # Filters to apply
13
+ :filters_enabled # Apply Filters, override global setting
14
+
15
+ # @return [Serializer::Facade]
16
+ def initialize(serializer, resource, options = {})
17
+ self.serializer = serializer
18
+ self.resource = resource
19
+ self.options = options
20
+ self.opts = options[ActiveFacets.opts_key] || {}
21
+
22
+ self.fields = opts[ActiveFacets.fields_key]
23
+ self.field_overrides = opts[ActiveFacets.field_overrides_key] || {}
24
+ self.overrides = ActiveFacets::ResourceManager.instance.resource_map(resource_class).inject({}) { |overrides, map_entry|
25
+ overrides.merge(field_overrides[map_entry] || {})
26
+ }
27
+
28
+ self.version = opts[ActiveFacets.version_key]
29
+ self.filters = opts[ActiveFacets.filters_key]
30
+ self.filters_enabled = opts.key?(ActiveFacets.filters_force_key) ? opts[ActiveFacets.filters_force_key] : ActiveFacets.filters_enabled
31
+ end
32
+
33
+ # This method returns a JSON of hash values representing the resource
34
+ # @param resource [ActiveRecord || Array] CollectionProxy object ::or:: a collection of resources
35
+ # @param opts [Hash] collection of values required that are not available in lexical scope
36
+ # @return [JSON] representing the values returned by {resource.serialize} method
37
+ def as_json
38
+ ActiveFacets.document_cache.fetch(self) {
39
+ serialize!
40
+ }
41
+ end
42
+
43
+ # @return [String] a cache key that can be used to identify this resource
44
+ def cache_key
45
+ version.to_s +
46
+ resource.cache_key +
47
+ fields.to_s +
48
+ field_overrides.to_s +
49
+ filters.to_s
50
+ end
51
+
52
+ # This method returns a ActiveRecord model updated to match a JSON of hash values
53
+ # @param resource [ActiveRecord] to hydrate
54
+ # @param attribute [Hash] subset of the values returned by {resource.as_json}
55
+ # @return [ActiveRecord] resource
56
+ def from_hash(attributes)
57
+ hydrate! ActiveFacets.deep_copy(attributes)
58
+ end
59
+
60
+ private
61
+
62
+ # @return [Config]
63
+ def config
64
+ @config ||= serializer.config
65
+ end
66
+
67
+ # @return [Boolean]
68
+ def allowed_field?(field)
69
+ overrides.blank? || overrides[field.to_sym]
70
+ end
71
+
72
+ # Checks field to see if it is a relation that is valid have Field Sets applied to it
73
+ # @param expression [Symbol]
74
+ # @return [Boolean]
75
+ def is_expression_scopeable?(expression)
76
+ resource.persisted? && is_active_relation?(expression) && is_relation_scopeable?(expression)
77
+ end
78
+
79
+ # Checks field to see if expression is a relation
80
+ # @return [Boolean]
81
+ def is_active_relation?(expression)
82
+ #TODO -jdc let me know if anyone finds a better way to identify Proxy objects
83
+ #NOTE:: Proxy Collections use method missing for most actions; .scoped is the only reliable test
84
+ expression.is_a?(ActiveRecord::Relation) || (expression.is_a?(Array) && expression.respond_to?(:scoped))
85
+ end
86
+
87
+ # Checks expression to determine if filters are enabled
88
+ # @return [Boolean]
89
+ def is_relation_scopeable?(expression)
90
+ filters_enabled
91
+ end
92
+
93
+ #TODO --jdc this is a hack for assets. fix by making this class the primary entry point
94
+ # rather than serializers and pass in resource class, or better yet, enforce pseudo resource classes
95
+ # @return [Class]
96
+ def resource_class
97
+ resource.is_a?(ActiveRecord::Base) ? resource.class : serializer.resource_class
98
+ end
99
+
100
+ # This method returns a JSON of hash values representing the resource
101
+ # @return [JSON]
102
+ def serialize!
103
+ json = {}.with_indifferent_access
104
+
105
+ config.field_set_itterator(fields) do |scope, nested_scopes|
106
+ begin
107
+ json[scope] = get_resource_attribute scope, nested_scopes if allowed_field?(scope)
108
+ rescue ActiveFacets::Errors::AttributeError => e
109
+ # Deliberately do nothing. Ignore scopes that do not map to resource methods (or aliases)
110
+ end
111
+ end
112
+
113
+ apply_custom_serializers! json
114
+ end
115
+
116
+ # Gets serialized field from the resource
117
+ # @param field [Symbol]
118
+ # @param nested_scope [Mixed] Field Set to pass for relations
119
+ # @return [Mixed]
120
+ def get_resource_attribute(field, nested_field_set)
121
+ if config.namespaces.key? field
122
+ if ns = get_resource_attribute!(config.namespaces[field])
123
+ ns[serializer.resource_attribute_name(field).to_s]
124
+ else
125
+ nil
126
+ end
127
+ elsif config.extensions.key?(field)
128
+ field
129
+ elsif serializer.is_association?(field)
130
+ get_association_attribute(field, nested_field_set)
131
+ else
132
+ get_resource_attribute!(serializer.resource_attribute_name(field))
133
+ end
134
+ end
135
+
136
+ # Invokes a method on the resource to retrieve the attribute value
137
+ # @param attribute [Symbol] identifies
138
+ # @return [Object]
139
+ def get_resource_attribute!(attribute)
140
+ raise ActiveFacets::Errors::AttributeError.new("#{resource.class.name}.#{attribute} missing") unless resource.respond_to?(attribute,true)
141
+ resource.send(attribute)
142
+ end
143
+
144
+ # Retrieves scoped association from cache or record
145
+ # @param field [Symbol] attribute to get
146
+ # @return [Array | ActiveRelation] of ActiveRecord
147
+ def get_association_attribute(field, nested_field_set)
148
+ association = serializer.resource_attribute_name(field)
149
+
150
+ ActiveFacets.document_cache.fetch_association(self, association, opts) do
151
+ attribute = resource.send(association)
152
+ attribute = attribute.scope_filters(filters) if is_expression_scopeable?(attribute)
153
+ ActiveFacets.restore_opts_after(options, ActiveFacets.fields_key, nested_field_set) do
154
+ attribute.as_json(options)
155
+ end
156
+ end
157
+ end
158
+
159
+ # Modifies json by reference by applying custom serializers to all attributes registered with custom serializers
160
+ # @param json [JSON] structure
161
+ # @return [JSON]
162
+ def apply_custom_serializers!(json)
163
+ config.serializers.each do |scope, type|
164
+ scope_s = scope
165
+ json[scope_s] = ActiveFacets.restore_opts_after(options, ActiveFacets.fields_key, fields) do
166
+ serializer.get_custom_serializer_class(type, options).serialize(json[scope_s], resource, options)
167
+ end if json.key? scope_s
168
+ end
169
+
170
+ json
171
+ end
172
+
173
+ # This method returns a ActiveRecord model updated to match a JSON of hash values
174
+ # @param json [JSON] attributes identical to the values returned by {serialize}
175
+ # @return [ActiveRecord] resource
176
+ def hydrate!(json)
177
+ filter_allowed_keys! json, serializer.exposed_aliases
178
+ hydrate_scopes! json
179
+ json.each do |scope, value|
180
+ set_resource_attribute scope, value
181
+ end
182
+
183
+ resource
184
+ end
185
+
186
+ # Modifies json by reference to remove all attributes from json which aren't exposed
187
+ # @param json [JSON] structure
188
+ # @param keys [Array] of attributes
189
+ # @return [JSON]
190
+ def filter_allowed_keys!(json, keys)
191
+ values = json.with_indifferent_access
192
+ json.replace ( keys.inject({}.with_indifferent_access) { |results, key|
193
+ results[key] = values[key] if values.key?(key)
194
+ results
195
+ } )
196
+ end
197
+
198
+ # Modifies json by reference by applying custom hydration to all fields registered with custom serializers
199
+ # @param json [JSON] structure
200
+ # @return [JSON]
201
+ def hydrate_scopes!(json)
202
+ config.serializers.each do |scope, type|
203
+ scope_s = scope
204
+ json[scope_s] = serializer.get_custom_serializer_class(type, options).hydrate(json[scope], resource, options) if json.key? scope_s
205
+ end
206
+ json
207
+ end
208
+
209
+ # Sets the specified attribute on the resource
210
+ # @param field [Symbol] to set
211
+ # @param value [Mixed] to set
212
+ # @return [Mixed] for chaining
213
+ def set_resource_attribute(field, value)
214
+ if config.namespaces.key? field
215
+ resource.send(config.namespaces[field].to_s+"=", {}) unless resource.send(config.namespaces[field]).present?
216
+ resource.send(config.namespaces[field])[serializer.resource_attribute_name(field,:to).to_s] = value
217
+ else
218
+ resource.send("#{serializer.resource_attribute_name(field,:to)}=", value)
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end