praxis-blueprints 2.2 → 3.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: 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