parametric 0.2.10 → 0.2.19

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
  SHA256:
3
- metadata.gz: 8f629915e6b2fee5ae5fcb9c63e340cd19880a72a3b53e06e33d3daff39ffa6a
4
- data.tar.gz: c3fd0ec0a51e954fccd021c62098192b52913d44e1f0c35299cc77c56e428f57
3
+ metadata.gz: 98676c8edebba19bd1adff020f5eadf9dce07e44f89125be4f0451d6bdee50cf
4
+ data.tar.gz: 5a5d0ecded864e5185d2b1d1cafe5eeb2307d92cf52c4111e7dd85ebe65c0321
5
5
  SHA512:
6
- metadata.gz: 9b46933db3b2bddd50ea66ec5e03faeb637248382e9a6fab81b2c539e201f148dc88ddc49e77f467974813a0c30a9c5196d48c3eef69f324ac47b15f39cd3d9e
7
- data.tar.gz: 14ce2da62e976fa4c234d25087cfa9abeff2b4d4a077dbeab2a85654cbcc641f33fadcd9f42c76f02cf21321468d971e754e9ed4ff76ee0c314c1382d0201770
6
+ metadata.gz: 1a3b81c816a02516450bdeb70ddb5b804741481ec0084167f31cdddcef4c3734f25b0d69da022048d9889c7b47a462cd821bf3a4cef07d719aff9b57293e10ef
7
+ data.tar.gz: 5a2cd8dd4d68cd896901d744f656fca3385f6d3763bc05d62dbd1b6c53362a32092781e7bd57e439b27c6e0071d1179a0355372778d33b9c96f893e6fd4e07d3
data/Gemfile CHANGED
@@ -2,3 +2,7 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in parametric.gemspec
4
4
  gemspec
5
+
6
+ group :development do
7
+ gem 'benchmark-ips'
8
+ end
data/README.md CHANGED
@@ -127,6 +127,60 @@ person_schema = Parametric::Schema.new do |sc, options|
127
127
  sc.field(:friends).type(:array).schema(friends_schema)
128
128
  end
129
129
  ```
130
+
131
+ ## Tagged One Of (multiple nested schemas, discriminated by payload key).
132
+
133
+ You can use `Field#tagged_one_of` to resolve a nested schema based on the value of a top-level field.
134
+
135
+ ```ruby
136
+ user_schema = Parametric::Schema.new do |sc, _|
137
+ field(:name).type(:string).present
138
+ field(:age).type(:integer).present
139
+ end
140
+
141
+ company_schema = Parametric::Schema.new do
142
+ field(:name).type(:string).present
143
+ field(:company_code).type(:string).present
144
+ end
145
+
146
+ schema = Parametric::Schema.new do |sc, _|
147
+ # Use :type field to locate the sub-schema to use for :sub
148
+ sc.field(:type).type(:string)
149
+
150
+ # Use the :one_of policy to select the sub-schema based on the :type field above
151
+ sc.field(:sub).type(:object).tagged_one_of do |sub|
152
+ sub.index_by(:type)
153
+ sub.on('user', user_schema)
154
+ sub.on('company', company_schema)
155
+ end
156
+ end
157
+
158
+ # The schema will now select the correct sub-schema based on the value of :type
159
+ result = schema.resolve(type: 'user', sub: { name: 'Joe', age: 30 })
160
+
161
+ # Instances can also be created separately and used as a policy:
162
+
163
+ UserOrCompany = Parametric::TaggedOneOf.new do |sc, _|
164
+ sc.on('user', user_schema)
165
+ sc.on('company', company_schema)
166
+ end
167
+
168
+ schema = Parametric::Schema.new do |sc, _|
169
+ sc.field(:type).type(:string)
170
+ sc.field(:sub).type(:object).policy(UserOrCompany.index_by(:type))
171
+ end
172
+ ```
173
+
174
+ `#index_by` can take a block to decide what value to resolve schemas by:
175
+
176
+ ```ruby
177
+ sc.field(:sub).type(:object).tagged_one_of do |sub|
178
+ sub.index_by { |payload| payload[:entity_type] }
179
+ sub.on('user', user_schema)
180
+ sub.on('company', company_schema)
181
+ end
182
+ ```
183
+
130
184
  ## Built-in policies
131
185
 
132
186
  Type coercions (the `type` method) and validations (the `validate` method) are all _policies_.
@@ -275,62 +329,106 @@ Useful for parsing comma-separated query-string parameters.
275
329
  field(:status).policy(:split) # turns "pending,confirmed" into ["pending", "confirmed"]
276
330
  ```
277
331
 
332
+ ### :value
333
+
334
+ A policy to return a static value
335
+
336
+ ```ruby
337
+ field(:currency).policy(:value, 'gbp') # this field always resolves to 'gbp'
338
+ ```
339
+
278
340
  ## Custom policies
279
341
 
280
- You can also register your own custom policy objects. A policy must implement the following methods:
342
+ You can also register your own custom policy objects.
343
+ A policy consist of the following:
344
+
345
+ * A `PolicyFactory` interface:
281
346
 
282
347
  ```ruby
283
348
  class MyPolicy
284
- # Validation error message, if invalid
285
- def message
286
- 'is invalid'
349
+ # Initializer signature is up to you.
350
+ # These are the arguments passed to the policy when using in a Field,
351
+ # ex. field(:name).policy(:my_policy, 'arg1', 'arg2')
352
+ def initialize(arg1, arg2)
353
+ @arg1, @arg2 = arg1, arg2
287
354
  end
288
355
 
289
- # Whether or not to validate and coerce this value
290
- # if false, no other policies will be run on the field
291
- def eligible?(value, key, payload)
292
- true
356
+ # @return [Hash]
357
+ def meta_data
358
+ { type: :string }
359
+ end
360
+
361
+ # Buld a Policy Runner, which is instantiated
362
+ # for each field when resolving a schema
363
+ # @param key [Symbol]
364
+ # @param value [Any]
365
+ # @option payload [Hash]
366
+ # @option context [Parametric::Context]
367
+ # @return [PolicyRunner]
368
+ def build(key, value, payload:, context:)
369
+ MyPolicyRunner.new(key, value, payload, context)
370
+ end
371
+ end
372
+ ```
373
+
374
+ * A `PolicyRunner` interface.
375
+
376
+ ```ruby
377
+ class MyPolicyRunner
378
+ # Initializer is up to you. See `MyPolicy#build`
379
+ def initialize(key, value, payload, context)
380
+
293
381
  end
294
382
 
295
- # Transform the value
296
- def coerce(value, key, context)
297
- value
383
+ # Should this policy run at all?
384
+ # returning [false] halts the field policy chain.
385
+ # @return [Boolean]
386
+ def eligible?
387
+ true
298
388
  end
299
389
 
300
- # Is the value valid?
301
- def valid?(value, key, payload)
390
+ # If [false], add [#message] to result errors and halt processing field.
391
+ # @return [Boolean]
392
+ def valid?
302
393
  true
303
394
  end
304
395
 
305
- # merge this object into the field's meta data
306
- def meta_data
307
- {type: :string}
396
+ # Coerce the value, or return as-is.
397
+ # @return [Any]
398
+ def value
399
+ @value
400
+ end
401
+
402
+ # Error message for this policy
403
+ # @return [String]
404
+ def message
405
+ "#{@value} is invalid"
308
406
  end
309
407
  end
310
408
  ```
311
409
 
312
- You can register your policy with:
410
+ Then register your custom policy factory:
313
411
 
314
412
  ```ruby
315
- Parametric.policy :my_policy, MyPolicy
413
+ Parametric.policy :my_polict, MyPolicy
316
414
  ```
317
415
 
318
416
  And then refer to it by name when declaring your schema fields
319
417
 
320
418
  ```ruby
321
- field(:title).policy(:my_policy)
419
+ field(:title).policy(:my_policy, 'arg1', 'arg2')
322
420
  ```
323
421
 
324
422
  You can chain custom policies with other policies.
325
423
 
326
424
  ```ruby
327
- field(:title).required.policy(:my_policy)
425
+ field(:title).required.policy(:my_policy, 'arg1', 'arg2')
328
426
  ```
329
427
 
330
428
  Note that you can also register instances.
331
429
 
332
430
  ```ruby
333
- Parametric.policy :my_policy, MyPolicy.new
431
+ Parametric.policy :my_policy, MyPolicy.new('arg1', 'arg2')
334
432
  ```
335
433
 
336
434
  For example, a policy that can be configured on a field-by-field basis:
@@ -341,27 +439,34 @@ class AddJobTitle
341
439
  @job_title = job_title
342
440
  end
343
441
 
344
- def message
345
- 'is invalid'
442
+ def build(key, value, payload:, context:)
443
+ Runner.new(@job_title, key, value, payload, context)
346
444
  end
347
445
 
348
- # Noop
349
- def eligible?(value, key, payload)
350
- true
446
+ def meta_data
447
+ {}
351
448
  end
352
449
 
353
- # Add job title to value
354
- def coerce(value, key, context)
355
- "#{value}, #{@job_title}"
356
- end
450
+ class Runner
451
+ attr_reader :message
357
452
 
358
- # Noop
359
- def valid?(value, key, payload)
360
- true
361
- end
453
+ def initialize(job_title, key, value, payload, _context)
454
+ @job_title = job_title
455
+ @key, @value, @payload = key, value, payload
456
+ @message = 'is invalid'
457
+ end
362
458
 
363
- def meta_data
364
- {}
459
+ def eligible?
460
+ true
461
+ end
462
+
463
+ def valid?
464
+ true
465
+ end
466
+
467
+ def value
468
+ "#{@value}, #{@job_title}"
469
+ end
365
470
  end
366
471
  end
367
472
 
@@ -849,6 +954,130 @@ results.errors["$.Weight"] # => ["is required and value must be present"]
849
954
 
850
955
  NOTES: dynamically expanded field names are not included in `Schema#structure` metadata, and they are only processes if fields with the given expressions are present in the payload. This means that validations applied to those fields only run if keys are present in the first place.
851
956
 
957
+ ## Before and after resolve hooks
958
+
959
+ `Schema#before_resolve` can be used to register blocks to modify the entire input payload _before_ individual fields are validated and coerced.
960
+ This can be useful when you need to pre-populate fields relative to other fields' values, or fetch extra data from other sources.
961
+
962
+ ```ruby
963
+ # This example computes the value of the :slug field based on :name
964
+ schema = Parametric::Schema.new do
965
+ # Note1: These blocks run before field validations, so :name might be blank or invalid at this point.
966
+ # Note2: Before hooks _must_ return a payload hash.
967
+ before_resolve do |payload, context|
968
+ payload.merge(
969
+ slug: payload[:name].to_s.downcase.gsub(/\s+/, '-')
970
+ )
971
+ end
972
+
973
+ # You still need to define the fields you want
974
+ field(:name).type(:string).present
975
+ field(:slug).type(:string).present
976
+ end
977
+
978
+ result = schema.resolve( name: 'Joe Bloggs' )
979
+ result.output # => { name: 'Joe Bloggs', slug: 'joe-bloggs' }
980
+ ```
981
+
982
+ Before hooks can be added to nested schemas, too:
983
+
984
+ ```ruby
985
+ schema = Parametric::Schema.new do
986
+ field(:friends).type(:array).schema do
987
+ before_resolve do |friend_payload, context|
988
+ friend_payload.merge(title: "Mr/Ms #{friend_payload[:name]}")
989
+ end
990
+
991
+ field(:name).type(:string)
992
+ field(:title).type(:string)
993
+ end
994
+ end
995
+ ```
996
+
997
+ You can use inline blocks, but anything that responds to `#call(payload, context)` will work, too:
998
+
999
+ ```ruby
1000
+ class SlugMaker
1001
+ def initialize(slug_field, from:)
1002
+ @slug_field, @from = slug_field, from
1003
+ end
1004
+
1005
+ def call(payload, context)
1006
+ payload.merge(
1007
+ @slug_field => payload[@from].to_s.downcase.gsub(/\s+/, '-')
1008
+ )
1009
+ end
1010
+ end
1011
+
1012
+ schema = Parametric::Schema.new do
1013
+ before_resolve SlugMaker.new(:slug, from: :name)
1014
+
1015
+ field(:name).type(:string)
1016
+ field(:slug).type(:slug)
1017
+ end
1018
+ ```
1019
+
1020
+ The `context` argument can be used to add custom validation errors in a before hook block.
1021
+
1022
+ ```ruby
1023
+ schema = Parametric::Schema.new do
1024
+ before_resolve do |payload, context|
1025
+ # validate that there's no duplicate friend names
1026
+ friends = payload[:friends] || []
1027
+ if friends.any? && friends.map{ |fr| fr[:name] }.uniq.size < friends.size
1028
+ context.add_error 'friend names must be unique'
1029
+ end
1030
+
1031
+ # don't forget to return the payload
1032
+ payload
1033
+ end
1034
+
1035
+ field(:friends).type(:array).schema do
1036
+ field(:name).type(:string)
1037
+ end
1038
+ end
1039
+
1040
+ result = schema.resolve(
1041
+ friends: [
1042
+ {name: 'Joe Bloggs'},
1043
+ {name: 'Joan Bloggs'},
1044
+ {name: 'Joe Bloggs'}
1045
+ ]
1046
+ )
1047
+
1048
+ result.valid? # => false
1049
+ result.errors # => {'$' => ['friend names must be unique']}
1050
+ ```
1051
+
1052
+ In most cases you should be validating individual fields using field policies. Only validate in before hooks in cases you have dependencies between fields.
1053
+
1054
+ `Schema#after_resolve` takes the sanitized input hash, and can be used to further validate fields that depend on eachother.
1055
+
1056
+ ```ruby
1057
+ schema = Parametric::Schema.new do
1058
+ after_resolve do |payload, ctx|
1059
+ # Add a top level error using an arbitrary key name
1060
+ ctx.add_base_error('deposit', 'cannot be greater than house price') if payload[:deposit] > payload[:house_price]
1061
+ # Or add an error keyed after the current position in the schema
1062
+ # ctx.add_error('some error') if some_condition
1063
+ # after_resolve hooks must also return the payload, or a modified copy of it
1064
+ # note that any changes added here won't be validated.
1065
+ payload.merge(desc: 'hello')
1066
+ end
1067
+
1068
+ field(:deposit).policy(:integer).present
1069
+ field(:house_price).policy(:integer).present
1070
+ field(:desc).policy(:string)
1071
+ end
1072
+
1073
+ result = schema.resolve({ deposit: 1100, house_price: 1000 })
1074
+ result.valid? # false
1075
+ result.errors[:deposit] # ['cannot be greater than house price']
1076
+ result.output[:deposit] # 1100
1077
+ result.output[:house_price] # 1000
1078
+ result.output[:desc] # 'hello'
1079
+ ```
1080
+
852
1081
  ## Structs
853
1082
 
854
1083
  Structs turn schema definitions into objects graphs with attribute readers.
@@ -0,0 +1,53 @@
1
+ require 'benchmark/ips'
2
+ require 'parametric/struct'
3
+
4
+ StructAccount = Struct.new(:id, :email, keyword_init: true)
5
+ StructFriend = Struct.new(:name, keyword_init: true)
6
+ StructUser = Struct.new(:name, :age, :friends, :account, keyword_init: true)
7
+
8
+ class ParametricAccount
9
+ include Parametric::Struct
10
+ schema do
11
+ field(:id).type(:integer).present
12
+ field(:email).type(:string)
13
+ end
14
+ end
15
+
16
+ class ParametricUser
17
+ include Parametric::Struct
18
+ schema do
19
+ field(:name).type(:string).present
20
+ field(:age).type(:integer).default(42)
21
+ field(:friends).type(:array).schema do
22
+ field(:name).type(:string).present
23
+ end
24
+ field(:account).type(:object).schema ParametricAccount
25
+ end
26
+ end
27
+
28
+ Benchmark.ips do |x|
29
+ x.report("Struct") {
30
+ StructUser.new(
31
+ name: 'Ismael',
32
+ age: 42,
33
+ friends: [
34
+ StructFriend.new(name: 'Joe'),
35
+ StructFriend.new(name: 'Joan'),
36
+ ],
37
+ account: StructAccount.new(id: 123, email: 'my@account.com')
38
+ )
39
+ }
40
+ x.report("Parametric::Struct") {
41
+ ParametricUser.new!(
42
+ name: 'Ismael',
43
+ age: 42,
44
+ friends: [
45
+ { name: 'Joe' },
46
+ { name: 'Joan' }
47
+ ],
48
+ account: { id: 123, email: 'my@account.com' }
49
+ )
50
+ }
51
+ x.compare!
52
+ end
53
+
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Parametric
2
4
  class BlockValidator
3
5
  def self.build(meth, &block)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Parametric
2
4
  class Top
3
5
  attr_reader :errors
@@ -26,6 +28,10 @@ module Parametric
26
28
  top.add_error(string_path, msg)
27
29
  end
28
30
 
31
+ def add_base_error(key, msg)
32
+ top.add_error(key, msg)
33
+ end
34
+
29
35
  def sub(key)
30
36
  self.class.new(path + [key], top)
31
37
  end
@@ -40,5 +46,4 @@ module Parametric
40
46
  end.join
41
47
  end
42
48
  end
43
-
44
49
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "date"
2
4
 
3
5
  module Parametric
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "parametric"
2
4
 
3
5
  module Parametric
@@ -1,4 +1,9 @@
1
- require "parametric/field_dsl"
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+ require 'parametric/field_dsl'
5
+ require 'parametric/policy_adapter'
6
+ require 'parametric/tagged_one_of'
2
7
 
3
8
  module Parametric
4
9
  class ConfigurationError < StandardError; end
@@ -37,15 +42,32 @@ module Parametric
37
42
  end
38
43
  alias_method :type, :policy
39
44
 
45
+ def tagged_one_of(instance = nil, &block)
46
+ policy(instance || Parametric::TaggedOneOf.new(&block))
47
+ end
48
+
40
49
  def schema(sc = nil, &block)
41
50
  sc = (sc ? sc : Schema.new(&block))
42
51
  meta schema: sc
43
52
  policy sc.schema
44
53
  end
45
54
 
55
+ def from(another_field)
56
+ meta another_field.meta_data
57
+ another_field.policies.each do |plc|
58
+ policies << plc
59
+ end
60
+
61
+ self
62
+ end
63
+
64
+ def has_policy?(key)
65
+ policies.any? { |pol| pol.key == key }
66
+ end
67
+
46
68
  def visit(meta_key = nil, &visitor)
47
69
  if sc = meta_data[:schema]
48
- r = sc.visit(meta_key, &visitor)
70
+ r = sc.schema.visit(meta_key, &visitor)
49
71
  (meta_data[:type] == :array) ? [r] : r
50
72
  else
51
73
  meta_key ? meta_data[meta_key] : yield(self)
@@ -63,37 +85,39 @@ module Parametric
63
85
  end
64
86
 
65
87
  policies.each do |policy|
66
- if !policy.eligible?(value, key, payload)
67
- eligible = false
68
- if has_default?
69
- eligible = true
70
- value = default_block.call(key, payload, context)
88
+ begin
89
+ pol = policy.build(key, value, payload:, context:)
90
+ if !pol.eligible?
91
+ eligible = false
92
+ if has_default?
93
+ eligible = true
94
+ value = default_block.call(key, payload, context)
95
+ end
96
+ break
97
+ else
98
+ value = pol.value
99
+ if !pol.valid?
100
+ eligible = true # eligible, but has errors
101
+ context.add_error pol.message
102
+ break # only one error at a time
103
+ end
71
104
  end
105
+ rescue StandardError => e
106
+ context.add_error e.message
72
107
  break
73
- else
74
- value = resolve_one(policy, value, context)
75
- if !policy.valid?(value, key, payload)
76
- eligible = true # eligible, but has errors
77
- context.add_error policy.message
78
- break # only one error at a time
79
- end
80
108
  end
81
109
  end
82
110
 
83
111
  Result.new(eligible, value)
84
112
  end
85
113
 
114
+ protected
115
+
116
+ attr_reader :policies
117
+
86
118
  private
87
- attr_reader :policies, :registry, :default_block
88
-
89
- def resolve_one(policy, value, context)
90
- begin
91
- policy.coerce(value, key, context)
92
- rescue StandardError => e
93
- context.add_error e.message
94
- value
95
- end
96
- end
119
+
120
+ attr_reader :registry, :default_block
97
121
 
98
122
  def has_default?
99
123
  !!default_block && !meta_data[:skip_default]
@@ -104,7 +128,20 @@ module Parametric
104
128
 
105
129
  raise ConfigurationError, "No policies defined for #{key.inspect}" unless obj
106
130
 
107
- obj.respond_to?(:new) ? obj.new(*args) : obj
131
+ obj = obj.new(*args) if obj.respond_to?(:new)
132
+ obj = PolicyWithKey.new(obj, key)
133
+ obj = PolicyAdapter.new(obj) unless obj.respond_to?(:build)
134
+
135
+ obj
136
+ end
137
+
138
+ class PolicyWithKey < SimpleDelegator
139
+ attr_reader :key
140
+
141
+ def initialize(policy, key)
142
+ super policy
143
+ @key = key
144
+ end
108
145
  end
109
146
  end
110
147
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Parametric
2
4
  # Field DSL
3
5
  # host instance must implement:
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Parametric
2
4
  module Policies
3
5
  class Format
@@ -24,6 +26,31 @@ module Parametric
24
26
  {}
25
27
  end
26
28
  end
29
+
30
+ class Value
31
+ attr_reader :message
32
+
33
+ def initialize(val, msg = 'invalid value')
34
+ @message = msg
35
+ @val = val
36
+ end
37
+
38
+ def eligible?(value, key, payload)
39
+ payload.key?(key)
40
+ end
41
+
42
+ def coerce(_value, _key, _context)
43
+ @val
44
+ end
45
+
46
+ def valid?(value, key, payload)
47
+ !payload.key?(key) || !!(value == @val)
48
+ end
49
+
50
+ def meta_data
51
+ { value: @val }
52
+ end
53
+ end
27
54
  end
28
55
 
29
56
  # Default validators
@@ -31,6 +58,7 @@ module Parametric
31
58
 
32
59
  Parametric.policy :format, Policies::Format
33
60
  Parametric.policy :email, Policies::Format.new(EMAIL_REGEXP, 'invalid email')
61
+ Parametric.policy :value, Policies::Value
34
62
 
35
63
  Parametric.policy :noop do
36
64
  eligible do |value, key, payload|