praxis-blueprints 2.2 → 3.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: c0621988651c28b2a7067510da6b334b3bb788fd
4
- data.tar.gz: f89f81316cb31874e7353cf86b85e5f29552112e
3
+ metadata.gz: 9fe3a8efadb5bf8bc0014eae372157bd62020363
4
+ data.tar.gz: df99c67d44c66bf7d99ab017a56c220b7ff01586
5
5
  SHA512:
6
- metadata.gz: a9d0c616bb141113114edadd7aa8446ad686da969bb0bb7f4b018b5999a7190cb8f8f570b003503728849841dfbee17be1c5bd21a14e50cd9a1e147e58724f82
7
- data.tar.gz: e5c20f1b9577216b1a480cabfbf1fcb0941391f3937b0096ce0f1e7c11212e6eba64e60aad6b3be0b4403a6f6584a4ba83d561c53bc3591d6e017d0e1deff60c
6
+ metadata.gz: ba760805fbd070350e360638d781833bd9f66abc2dfb2f1a3f30cc5456ab8f6fbfbfdd3e0a1cb5088ba18d4c1299641f8f804e4123fcc453a91c40b4e6d49091
7
+ data.tar.gz: 2588dbe95bc05c824c8dfd7bffe1ddb7973839f8cbe0dca72ff5a37744add676535c09f27f733dd40f593e57aba95d9556cee183cad0e093c20c170e152c9cbc
data/.travis.yml CHANGED
@@ -1,5 +1,6 @@
1
1
  language: ruby
2
2
  cache: bundler
3
+ sudo: false
3
4
  rvm:
4
5
  - 2.1.2
5
6
  - 2.2.2
data/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  ## next
4
4
 
5
+ ## 3.0
6
+
7
+ * Added `FieldExpander`, a new class that recursively expands a tree of
8
+ of attributes for Blueprint types as well as view objects into their lowest-level values.
9
+ * For example, for the `Person` and `Address` blueprints defined in
10
+ [spec_blueprints.rb](sped/support/spec_blueprints.rb):
11
+ * `FieldExpander.expand(Person, {name: true, full_name: true})` would return
12
+ `{name: true, full_name: {first: true, last:true}}`.
13
+ * Attempting to expand attributes with circular references (i.e. the
14
+ a person's address, where that address then references that person) will
15
+ raise a `Praxis::FieldExpander::CircularExpansionError` error.
16
+ * Added `Renderer`, a new class for rendering objects (typically Blueprint
17
+ instances) with an explicit and recursive set of fields (as from
18
+ `Fieldexpander`).
19
+ * For example:
20
+ * `render(person, name: true)` would produce `{name: person.name}`
21
+ * `render(person, name: true, address: {state: true})` would
22
+ produce `{name: person.name, address: {state: person.address.state}}`
23
+ * Accepts an `include_nil:` option when initialized, that if true will
24
+ cause the renderer to output values that are nil, rather than omit them.
25
+ Note: this will apply to the entire rendering, there is no way to set it
26
+ for only a portion of the rendering, or on a per-View basis.
27
+ * Added `Blueprint.domain_model` to specify the underlying domain model, e.g.
28
+ a `Praxis::Mapper::Resource`. Accepts a string value that is resolved when
29
+ `Blueprint.finalize!` is called. Defaults to `Object`.
30
+ * `Blueprint.load` will use the `domain_model` to wrap the incoming object
31
+ if it is not an instance of that class. Note: this does not change existing
32
+ default if `domain_model` is not set.
33
+ * Refactored `View` and `CollectionView` rendering to use a `Renderer` with
34
+ a set of expanded fields.
35
+ * The automatically generated `:master` view will now render sub-attributes
36
+ using their `:default` view, rather than `:master`.
37
+ * `Blueprint#render` now uses `Renderer` and `FieldExpander` internally. In most
38
+ cases this will continue to function identically to the old behavior, but
39
+ * Views defined with `Blueprint.view`, including subviews defined inside,
40
+ no longer accept the `:include_nil` option. This can be set using the same
41
+ option on `Renderer` described above.
42
+
43
+
5
44
  ## 2.2
6
45
 
7
46
  * Added instrumentation through `ActiveSupport::Notifications`
data/Gemfile CHANGED
@@ -1,3 +1,2 @@
1
1
  source 'https://rubygems.org'
2
-
3
- gemspec
2
+ gemspec
@@ -12,3 +12,6 @@ require 'praxis-blueprints/config_hash'
12
12
  require 'praxis-blueprints/blueprint'
13
13
  require 'praxis-blueprints/view'
14
14
  require 'praxis-blueprints/collection_view'
15
+
16
+ require 'praxis-blueprints/renderer'
17
+ require 'praxis-blueprints/field_expander'
@@ -18,13 +18,15 @@ module Praxis
18
18
 
19
19
  @@caching_enabled = false
20
20
 
21
- CIRCULAR_REFERENCE_MARKER = '...'.freeze
22
21
 
23
- attr_accessor :object, :decorators
24
- attr_reader :validating, :active_renders
22
+ attr_reader :validating
23
+ attr_accessor :object
24
+ attr_accessor :decorators
25
25
 
26
26
  class << self
27
- attr_reader :views, :attribute, :options
27
+ attr_reader :views
28
+ attr_reader :attribute
29
+ attr_reader :options
28
30
  attr_accessor :reference
29
31
  end
30
32
 
@@ -34,21 +36,20 @@ module Praxis
34
36
  klass.instance_eval do
35
37
  @views = Hash.new
36
38
  @options = Hash.new
39
+ @domain_model = Object
37
40
  end
38
41
  end
39
42
 
40
43
  # Override default new behavior to support memoized creation through an IdentityMap
41
44
  def self.new(object, decorators=nil)
42
45
  if @@caching_enabled && decorators.nil?
43
- key = object
44
-
45
46
  cache = if object.respond_to?(:identity_map) && object.identity_map
46
47
  object.identity_map.blueprint_cache[self]
47
48
  else
48
49
  self.cache
49
50
  end
50
51
 
51
- return cache[key] ||= begin
52
+ return cache[object] ||= begin
52
53
  blueprint = self.allocate
53
54
  blueprint.send(:initialize, object, decorators)
54
55
  blueprint
@@ -112,6 +113,11 @@ module Praxis
112
113
  end
113
114
 
114
115
 
116
+ def self.domain_model(klass=nil)
117
+ return @domain_model if klass.nil?
118
+ @domain_model = klass
119
+ end
120
+
115
121
  def self.check_option!(name, value)
116
122
  case name
117
123
  when :identity
@@ -134,11 +140,19 @@ module Praxis
134
140
  self.new(value)
135
141
  end
136
142
  else
137
- # Just wrap whatever value
138
- self.new(value)
143
+ if value.kind_of?(self.domain_model) || value.kind_of?(self::Struct)
144
+ # Wrap the value directly
145
+ self.new(value)
146
+ else
147
+ # Wrap the object inside the domain_model
148
+ self.new(domain_model.new(value))
149
+ end
139
150
  end
140
151
  end
141
152
 
153
+ class << self
154
+ alias_method :from, :load
155
+ end
142
156
 
143
157
  def self.caching_enabled?
144
158
  @@caching_enabled
@@ -203,10 +217,8 @@ module Praxis
203
217
 
204
218
  object.render(view: view, context: context, **opts)
205
219
  end
206
-
207
- # Allow render on the class too, for completeness and consistency
208
- def self.render(object, **opts)
209
- self.dump(object, **opts)
220
+ class << self
221
+ alias_method :render, :dump
210
222
  end
211
223
 
212
224
  # Internal finalize! logic
@@ -216,10 +228,17 @@ module Praxis
216
228
  self.define_readers!
217
229
  # Don't blindly override a master view if the MediaType wants to define it on its own
218
230
  self.generate_master_view! unless self.view(:master)
231
+ self.resolve_domain_model!
219
232
  end
220
233
  super
221
234
  end
222
235
 
236
+ def self.resolve_domain_model!
237
+ return unless self.domain_model.kind_of?(String)
238
+
239
+ @domain_model = self.domain_model.constantize
240
+ end
241
+
223
242
  def self.define_attribute!
224
243
  @attribute = Attributor::Attribute.new(Attributor::Struct, @options, &@block)
225
244
  @block = nil
@@ -256,13 +275,14 @@ module Praxis
256
275
  end
257
276
 
258
277
 
278
+
259
279
  def self.generate_master_view!
260
280
  attributes = self.attributes
261
281
  view :master do
262
282
  attributes.each do | name, attr |
263
283
  # Note: we can freely pass master view for attributes that aren't blueprint/containers because
264
284
  # their dump methods will ignore it (they always dump everything regardless)
265
- attribute name, view: :master
285
+ attribute name, view: :default
266
286
  end
267
287
  end
268
288
  end
@@ -271,69 +291,50 @@ module Praxis
271
291
  def initialize(object, decorators=nil)
272
292
  # TODO: decide what sort of type checking (if any) we want to perform here.
273
293
  @object = object
294
+
274
295
  @decorators = if decorators.kind_of?(Hash) && decorators.any?
275
296
  OpenStruct.new(decorators)
276
297
  else
277
298
  decorators
278
299
  end
279
- @rendered_views = {}
280
- @validating = false
281
300
 
282
- # OPTIMIZE: revisit the circular rendering tracking.
283
- # removing this results in a significant performance
284
- # and memory use savings.
285
- @active_renders = []
301
+ @validating = false
286
302
  end
287
303
 
288
304
 
289
305
  # Render the wrapped data with the given view
290
- def render(view_name=nil, context: Attributor::DEFAULT_ROOT_CONTEXT,**opts)
306
+ def render(view_name=nil, context: Attributor::DEFAULT_ROOT_CONTEXT,renderer: Renderer.new, **opts)
291
307
  if view_name != nil
292
308
  warn "DEPRECATED: please do not pass the view name as the first parameter in Blueprint.render, pass through the view: named param instead."
293
- else
294
- view_name = :default # Backwards compatibility with the default param value
309
+ elsif opts.key?(:view)
310
+ view_name = opts[:view]
295
311
  end
296
312
 
297
- # Allow the opts to specify the view name for consistency with dump (overriding the deprecated named param)
298
- view_name = opts[:view] if opts[:view]
299
- unless (view = self.class.views[view_name])
300
- raise "view with name '#{view_name.inspect}' is not defined in #{self.class}"
313
+ fields = opts[:fields]
314
+ if view_name.nil? && fields.nil?
315
+ view_name = :default
301
316
  end
302
317
 
303
- rendered_key = if (fields = opts[:fields])
304
- if fields.is_a? Array
305
- # Accept a simple array of fields, and transform it to a 1-level hash with nil values
306
- opts[:fields] = opts[:fields].each_with_object({}) {|field, hash| hash[field] = nil }
318
+
319
+ if view_name
320
+ unless (view = self.class.views[view_name])
321
+ raise "view with name '#{view_name.inspect}' is not defined in #{self.class}"
307
322
  end
308
- # Rendered key needs to be different if only some fields were output
309
- "%s:#%s" % [view_name, opts[:fields].hash.to_s]
310
- else
311
- view_name
323
+ return view.render(self, context: context, renderer: renderer)
312
324
  end
313
325
 
314
- return @rendered_views[rendered_key] if @rendered_views.has_key? rendered_key
315
- return CIRCULAR_REFERENCE_MARKER if @active_renders.include?(rendered_key)
316
- @active_renders << rendered_key
317
-
318
- notification_payload = {
319
- blueprint: self,
320
- view: view,
321
- fields: fields
322
- }
323
-
324
- ActiveSupport::Notifications.instrument 'praxis.blueprint.render'.freeze, notification_payload do
325
- @rendered_views[rendered_key] = view.dump(self, context: context,**opts)
326
+ # Accept a simple array of fields, and transform it to a 1-level hash with true values
327
+ if fields.is_a? Array
328
+ fields = fields.each_with_object({}) {|field, hash| hash[field] = true }
326
329
  end
327
- ensure
328
- @active_renders.delete rendered_key
329
- end
330
- alias_method :to_hash, :render
331
330
 
331
+ # expand fields
332
+ expanded_fields = FieldExpander.expand(self.class, fields)
332
333
 
333
- def dump(view: :default, context: Attributor::DEFAULT_ROOT_CONTEXT)
334
- self.render(view: view, context: context)
334
+ renderer.render(self, expanded_fields, context: context)
335
335
  end
336
-
336
+ alias_method :to_hash, :render
337
+ alias_method :dump, :render
337
338
 
338
339
  def validate(context=Attributor::DEFAULT_ROOT_CONTEXT)
339
340
  raise ArgumentError, "Invalid context received (nil) while validating value of type #{self.name}" if context == nil
@@ -349,12 +350,17 @@ module Praxis
349
350
  if value.respond_to?(:validating) # really, it's a thing with sub-attributes
350
351
  next if value.validating
351
352
  end
352
- errors.push *sub_attribute.validate(value, sub_context)
353
+ errors.push(*sub_attribute.validate(value, sub_context))
353
354
  end
354
355
  ensure
355
356
  @validating = false
356
357
  end
357
358
 
359
+ # generic semi-private getter used by Renderer
360
+ def _get_attr(name)
361
+ self.send(name)
362
+ end
363
+
358
364
  end
359
365
 
360
366
  end
@@ -1,30 +1,27 @@
1
1
  module Praxis
2
2
 
3
- class CollectionView
4
- attr_reader :name, :schema, :using
3
+ class CollectionView < View
4
+ def initialize(name, schema, member_view=nil)
5
+ super(name,schema)
5
6
 
6
- def initialize(name, schema, using)
7
- @name = name
8
- @schema = schema
9
- @using = using
10
- end
11
-
12
- def dump(collection, context: Attributor::DEFAULT_ROOT_CONTEXT,**opts)
13
- collection.collect.with_index do |object, i|
14
- subcontext = context + ["at(#{i})"]
15
- using.dump(object, context: subcontext, **opts)
7
+ if member_view
8
+ @contents = member_view.contents.clone
16
9
  end
17
10
  end
18
11
 
19
12
  def example(context=Attributor::DEFAULT_ROOT_CONTEXT)
20
- collection = self.schema.example(context)
13
+ collection = 3.times.collect do |i|
14
+ subcontext = context + ["at(#{i})"]
15
+ self.schema.example(subcontext)
16
+ end
21
17
  opts = {}
22
18
  opts[:context] = context if context
23
- self.dump(collection, opts)
19
+
20
+ self.render(collection, **opts)
24
21
  end
25
22
 
26
23
  def describe
27
- using.describe.merge(type: :collection)
24
+ super.merge(type: :collection)
28
25
  end
29
26
 
30
27
  end
@@ -0,0 +1,104 @@
1
+ module Praxis
2
+ class FieldExpander
3
+ class CircularExpansionError < StandardError
4
+ attr_reader :stack
5
+ def initialize(message, stack=[])
6
+ super(message)
7
+ @stack = stack
8
+ end
9
+ end
10
+
11
+ def self.expand(object, fields=true)
12
+ self.new.expand(object,fields)
13
+ end
14
+
15
+ attr_reader :stack
16
+ attr_reader :history
17
+
18
+ def initialize
19
+ @stack = Hash.new do |hash, key|
20
+ hash[key] = Set.new
21
+ end
22
+ @history = Hash.new do |hash,key|
23
+ hash[key] = Hash.new
24
+ end
25
+ end
26
+
27
+ def expand(object, fields=true)
28
+ if stack[object].include? fields
29
+ raise CircularExpansionError, "Circular expansion detected for object #{object.inspect} with fields #{fields.inspect}"
30
+ else
31
+ stack[object] << fields
32
+ end
33
+
34
+ if object.kind_of?(Praxis::View)
35
+ self.expand_view(object, fields)
36
+ elsif object.kind_of? Attributor::Attribute
37
+ self.expand_type(object.type, fields)
38
+ else
39
+ self.expand_type(object,fields)
40
+ end
41
+ rescue CircularExpansionError => e
42
+ e.stack.unshift [object,fields]
43
+ raise
44
+ ensure
45
+ stack[object].delete fields
46
+ end
47
+
48
+ def expand_fields(attributes, fields)
49
+ raise ArgumentError, "expand_fields must be given a block" unless block_given?
50
+
51
+ unless fields == true
52
+ attributes = attributes.select do |k,v|
53
+ fields.key?(k)
54
+ end
55
+ end
56
+
57
+ attributes.each_with_object({}) do |(name, dumpable), hash|
58
+ sub_fields = case fields
59
+ when true
60
+ true
61
+ when Hash
62
+ fields[name] || true
63
+ end
64
+ hash[name] = yield(dumpable,sub_fields)
65
+ end
66
+ end
67
+
68
+
69
+ def expand_view(object,fields=true)
70
+ result = expand_fields(object.contents, fields) do |dumpable, sub_fields|
71
+ self.expand(dumpable, sub_fields)
72
+ end
73
+
74
+ return [result] if object.kind_of?(Praxis::CollectionView)
75
+ result
76
+ end
77
+
78
+
79
+ def expand_type(object,fields=true)
80
+ unless object.respond_to?(:attributes)
81
+ if object.respond_to?(:member_attribute)
82
+ fields = fields[0] if fields.kind_of? Array
83
+ return [self.expand(object.member_attribute.type, fields)]
84
+ else
85
+ return true
86
+ end
87
+ end
88
+
89
+ # just include the full thing if it has no attributes
90
+ if object.attributes.empty?
91
+ return true
92
+ end
93
+
94
+ if history[object].include? fields
95
+ return history[object][fields]
96
+ end
97
+
98
+ history[object][fields] = expand_fields(object.attributes, fields) do |dumpable, sub_fields|
99
+ self.expand(dumpable.type, sub_fields)
100
+ end
101
+ end
102
+
103
+ end
104
+ end