parametric 0.2.11 → 0.2.12

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: 763b926782a534a5421ff8252b8da1e372b12ef7de36c57f8439e58daea178ac
4
- data.tar.gz: 7e07efa2345016bd28f947d9d527b0ab698796f11b6fa466059e4dcfd7ceaea3
3
+ metadata.gz: c773dd859f56f8d526384567daf66c1c259a7e5d859c9fe40ecc620c0ee73253
4
+ data.tar.gz: 9b056fd197e9fe84a2b14b16134d860ae3a7c49313f370fd9de82fb918c7bc3b
5
5
  SHA512:
6
- metadata.gz: 8b7d4569723c047a599af8da6e6f83ed05ffb976b7557396881d5ee1188ae0db30715a002cafa1d116bdfc3368fde1a1006baa83a5288ecab3ec2e39c08ea040
7
- data.tar.gz: 50f419b619fa561a0769b812864bd43c7233a637f81b033d51052eb7e85eebb92872bb9a7fc41cf28092be04a50be813644423f9b9a6f3784255a5c5e84b8a1d
6
+ metadata.gz: 9d068ab0c811e30db11d3b6eea2e38937557d2abb36df07623f419b669e7186a86af8de645575cacdb5efe7a51c0dbbe40048aa3fe5417296ee3c5810fd477bf
7
+ data.tar.gz: da9c1a990c6a0c2b85cf27d3f8ea7dd78e9b2e031548e240a79a43b85de9538e496ed491f3a5ef023ae6939d0f706b74274dd0519a7ae48d6452db65eed0d3ce
data/README.md CHANGED
@@ -849,6 +849,130 @@ results.errors["$.Weight"] # => ["is required and value must be present"]
849
849
 
850
850
  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
851
 
852
+ ## Before and after resolve hooks
853
+
854
+ `Schema#before_resolve` can be used to register blocks to modify the entire input payload _before_ individual fields are validated and coerced.
855
+ This can be useful when you need to pre-populate fields relative to other fields' values, or fetch extra data from other sources.
856
+
857
+ ```ruby
858
+ # This example computes the value of the :slug field based on :name
859
+ schema = Parametric::Schema.new do
860
+ # Note1: These blocks run before field validations, so :name might be blank or invalid at this point.
861
+ # Note2: Before hooks _must_ return a payload hash.
862
+ before_resolve do |payload, context|
863
+ payload.merge(
864
+ slug: payload[:name].to_s.downcase.gsub(/\s+/, '-')
865
+ )
866
+ end
867
+
868
+ # You still need to define the fields you want
869
+ field(:name).type(:string).present
870
+ field(:slug).type(:string).present
871
+ end
872
+
873
+ result = schema.resolve( name: 'Joe Bloggs' )
874
+ result.output # => { name: 'Joe Bloggs', slug: 'joe-bloggs' }
875
+ ```
876
+
877
+ Before hooks can be added to nested schemas, too:
878
+
879
+ ```ruby
880
+ schema = Parametric::Schema.new do
881
+ field(:friends).type(:array).schema do
882
+ before_resolve do |friend_payload, context|
883
+ friend_payload.merge(title: "Mr/Ms #{friend_payload[:name]}")
884
+ end
885
+
886
+ field(:name).type(:string)
887
+ field(:title).type(:string)
888
+ end
889
+ end
890
+ ```
891
+
892
+ You can use inline blocks, but anything that responds to `#call(payload, context)` will work, too:
893
+
894
+ ```ruby
895
+ class SlugMaker
896
+ def initialize(slug_field, from:)
897
+ @slug_field, @from = slug_field, from
898
+ end
899
+
900
+ def call(payload, context)
901
+ payload.merge(
902
+ @slug_field => payload[@from].to_s.downcase.gsub(/\s+/, '-')
903
+ )
904
+ end
905
+ end
906
+
907
+ schema = Parametric::Schema.new do
908
+ before_resolve SlugMaker.new(:slug, from: :name)
909
+
910
+ field(:name).type(:string)
911
+ field(:slug).type(:slug)
912
+ end
913
+ ```
914
+
915
+ The `context` argument can be used to add custom validation errors in a before hook block.
916
+
917
+ ```ruby
918
+ schema = Parametric::Schema.new do
919
+ before_resolve do |payload, context|
920
+ # validate that there's no duplicate friend names
921
+ friends = payload[:friends] || []
922
+ if friends.any? && friends.map{ |fr| fr[:name] }.uniq.size < friends.size
923
+ context.add_error 'friend names must be unique'
924
+ end
925
+
926
+ # don't forget to return the payload
927
+ payload
928
+ end
929
+
930
+ field(:friends).type(:array).schema do
931
+ field(:name).type(:string)
932
+ end
933
+ end
934
+
935
+ result = schema.resolve(
936
+ friends: [
937
+ {name: 'Joe Bloggs'},
938
+ {name: 'Joan Bloggs'},
939
+ {name: 'Joe Bloggs'}
940
+ ]
941
+ )
942
+
943
+ result.valid? # => false
944
+ result.errors # => {'$' => ['friend names must be unique']}
945
+ ```
946
+
947
+ In most cases you should be validating individual fields using field policies. Only validate in before hooks in cases you have dependencies between fields.
948
+
949
+ `Schema#after_resolve` takes the sanitized input hash, and can be used to further validate fields that depend on eachother.
950
+
951
+ ```ruby
952
+ schema = Parametric::Schema.new do
953
+ after_resolve do |payload, ctx|
954
+ # Add a top level error using an arbitrary key name
955
+ ctx.add_base_error('deposit', 'cannot be greater than house price') if payload[:deposit] > payload[:house_price]
956
+ # Or add an error keyed after the current position in the schema
957
+ # ctx.add_error('some error') if some_condition
958
+ # after_resolve hooks must also return the payload, or a modified copy of it
959
+ # note that any changes added here won't be validated.
960
+ payload.merge(desc: 'hello')
961
+ end
962
+
963
+ field(:deposit).policy(:integer).present
964
+ field(:house_price).policy(:integer).present
965
+ field(:desc).policy(:string)
966
+ end
967
+
968
+ result = schema.resolve({ deposit: 1100, house_price: 1000 })
969
+ result.valid? # false
970
+ result.errors[:deposit] # ['cannot be greater than house price']
971
+ result.output[:deposit] # 1100
972
+ result.output[:house_price] # 1000
973
+ result.output[:desc] # 'hello'
974
+ ```
975
+
852
976
  ## Structs
853
977
 
854
978
  Structs turn schema definitions into objects graphs with attribute readers.
@@ -28,6 +28,10 @@ module Parametric
28
28
  top.add_error(string_path, msg)
29
29
  end
30
30
 
31
+ def add_base_error(key, msg)
32
+ top.add_error(key, msg)
33
+ end
34
+
31
35
  def sub(key)
32
36
  self.class.new(path + [key], top)
33
37
  end
@@ -42,5 +46,4 @@ module Parametric
42
46
  end.join
43
47
  end
44
48
  end
45
-
46
49
  end
@@ -14,6 +14,8 @@ module Parametric
14
14
  @default_field_policies = []
15
15
  @ignored_field_keys = []
16
16
  @expansions = {}
17
+ @before_hooks = []
18
+ @after_hooks = []
17
19
  end
18
20
 
19
21
  def schema
@@ -92,6 +94,20 @@ module Parametric
92
94
  end
93
95
  end
94
96
 
97
+ def before_resolve(klass = nil, &block)
98
+ raise ArgumentError, '#before_resolve expects a callable object, or a block' if !klass && !block_given?
99
+ callable = klass || block
100
+ before_hooks << callable
101
+ self
102
+ end
103
+
104
+ def after_resolve(klass = nil, &block)
105
+ raise ArgumentError, '#after_resolve expects a callable object, or a block' if !klass && !block_given?
106
+ callable = klass || block
107
+ after_hooks << callable
108
+ self
109
+ end
110
+
95
111
  def expand(exp, &block)
96
112
  expansions[exp] = block
97
113
  self
@@ -141,19 +157,35 @@ module Parametric
141
157
 
142
158
  protected
143
159
 
144
- attr_reader :definitions, :options
160
+ attr_reader :definitions, :options, :before_hooks, :after_hooks
145
161
 
146
162
  private
147
163
 
148
164
  attr_reader :default_field_policies, :ignored_field_keys, :expansions
149
165
 
150
166
  def coerce_one(val, context, flds: fields)
151
- flds.each_with_object({}) do |(_, field), m|
167
+ val = run_before_hooks(val, context)
168
+
169
+ out = flds.each_with_object({}) do |(_, field), m|
152
170
  r = field.resolve(val, context.sub(field.key))
153
171
  if r.eligible?
154
172
  m[field.key] = r.value
155
173
  end
156
174
  end
175
+
176
+ run_after_hooks(out, context)
177
+ end
178
+
179
+ def run_before_hooks(val, context)
180
+ before_hooks.reduce(val) do |value, callable|
181
+ callable.call(value, context)
182
+ end
183
+ end
184
+
185
+ def run_after_hooks(val, context)
186
+ after_hooks.reduce(val) do |value, callable|
187
+ callable.call(value, context)
188
+ end
157
189
  end
158
190
 
159
191
  class MatchContext
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parametric
4
- VERSION = "0.2.11"
4
+ VERSION = "0.2.12"
5
5
  end
@@ -0,0 +1,133 @@
1
+ require 'spec_helper'
2
+
3
+ describe Parametric::Schema do
4
+ describe '#before_resolve' do
5
+ it 'passes payload through before_resolve block, if defined' do
6
+ schema = described_class.new do
7
+ before_resolve do |payload, _context|
8
+ payload[:slug] = payload[:name].to_s.downcase.gsub(/\s+/, '-') unless payload[:slug]
9
+ payload
10
+ end
11
+
12
+ field(:name).policy(:string).present
13
+ field(:slug).policy(:string).present
14
+ field(:variants).policy(:array).schema do
15
+ before_resolve do |payload, _context|
16
+ payload[:slug] = "v: #{payload[:name].to_s.downcase}"
17
+ payload
18
+ end
19
+ field(:name).policy(:string).present
20
+ field(:slug).type(:string).present
21
+ end
22
+ end
23
+
24
+ result = schema.resolve({ name: 'A name', variants: [{ name: 'A variant' }] })
25
+ expect(result.valid?).to be true
26
+ expect(result.output[:slug]).to eq 'a-name'
27
+ expect(result.output[:variants].first[:slug]).to eq 'v: a variant'
28
+ end
29
+
30
+ it 'collects errors added in before_resolve blocks' do
31
+ schema = described_class.new do
32
+ field(:variants).type(:array).schema do
33
+ before_resolve do |payload, context|
34
+ context.add_error 'nope!' if payload[:name] == 'with errors'
35
+ payload
36
+ end
37
+ field(:name).type(:string)
38
+ end
39
+ end
40
+
41
+ results = schema.resolve({ variants: [ {name: 'no errors'}, {name: 'with errors'}]})
42
+ expect(results.valid?).to be false
43
+ expect(results.errors['$.variants[1]']).to eq ['nope!']
44
+ end
45
+
46
+ it 'copies before_resolve hooks to merged schemas' do
47
+ schema1 = described_class.new do
48
+ before_resolve do |payload, _context|
49
+ payload[:slug] = payload[:name].to_s.downcase.gsub(/\s+/, '-') unless payload[:slug]
50
+ payload
51
+ end
52
+ field(:name).present.type(:string)
53
+ field(:slug).present.type(:string)
54
+ end
55
+
56
+ schema2 = described_class.new do
57
+ before_resolve do |payload, _context|
58
+ payload[:slug] = "slug-#{payload[:slug]}" if payload[:slug]
59
+ payload
60
+ end
61
+
62
+ field(:age).type(:integer)
63
+ end
64
+
65
+ schema3 = schema1.merge(schema2)
66
+
67
+ results = schema3.resolve({ name: 'Ismael Celis', age: 41 })
68
+ expect(results.output[:slug]).to eq 'slug-ismael-celis'
69
+ end
70
+
71
+ it 'works with any callable' do
72
+ slug_maker = Class.new do
73
+ def initialize(slug_field, from:)
74
+ @slug_field, @from = slug_field, from
75
+ end
76
+
77
+ def call(payload, _context)
78
+ payload.merge(
79
+ @slug_field => payload[@from].to_s.downcase.gsub(/\s+/, '-')
80
+ )
81
+ end
82
+ end
83
+
84
+ schema = described_class.new do |sc, _opts|
85
+ sc.before_resolve slug_maker.new(:slug, from: :name)
86
+
87
+ sc.field(:name).type(:string)
88
+ sc.field(:slug).type(:string)
89
+ end
90
+
91
+ results = schema.resolve(name: 'Ismael Celis')
92
+ expect(results.output[:slug]).to eq 'ismael-celis'
93
+ end
94
+ end
95
+
96
+ describe '#after_resolve' do
97
+ let!(:schema) do
98
+ described_class.new do
99
+ after_resolve do |payload, ctx|
100
+ ctx.add_base_error('deposit', 'cannot be greater than house price') if payload[:deposit] > payload[:house_price]
101
+ payload.merge(desc: 'hello')
102
+ end
103
+
104
+ field(:deposit).policy(:integer).present
105
+ field(:house_price).policy(:integer).present
106
+ field(:desc).policy(:string)
107
+ end
108
+ end
109
+
110
+ it 'passes payload through after_resolve block, if defined' do
111
+ result = schema.resolve({ deposit: 1100, house_price: 1000 })
112
+ expect(result.valid?).to be false
113
+ expect(result.output[:deposit]).to eq 1100
114
+ expect(result.output[:house_price]).to eq 1000
115
+ expect(result.output[:desc]).to eq 'hello'
116
+ end
117
+
118
+ it 'copies after hooks when merging schemas' do
119
+ child_schema = described_class.new do
120
+ field(:name).type(:string)
121
+ end
122
+
123
+ union = schema.merge(child_schema)
124
+
125
+ result = union.resolve({ name: 'Joe', deposit: 1100, house_price: 1000 })
126
+ expect(result.valid?).to be false
127
+ expect(result.output[:deposit]).to eq 1100
128
+ expect(result.output[:house_price]).to eq 1000
129
+ expect(result.output[:desc]).to eq 'hello'
130
+ expect(result.output[:name]).to eq 'Joe'
131
+ end
132
+ end
133
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parametric
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.11
4
+ version: 0.2.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-04-13 00:00:00.000000000 Z
11
+ date: 2020-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -88,6 +88,7 @@ files:
88
88
  - spec/expand_spec.rb
89
89
  - spec/field_spec.rb
90
90
  - spec/policies_spec.rb
91
+ - spec/schema_lifecycle_hooks_spec.rb
91
92
  - spec/schema_spec.rb
92
93
  - spec/schema_walk_spec.rb
93
94
  - spec/spec_helper.rb
@@ -97,7 +98,7 @@ homepage: ''
97
98
  licenses:
98
99
  - MIT
99
100
  metadata: {}
100
- post_install_message:
101
+ post_install_message:
101
102
  rdoc_options: []
102
103
  require_paths:
103
104
  - lib
@@ -112,8 +113,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
112
113
  - !ruby/object:Gem::Version
113
114
  version: '0'
114
115
  requirements: []
115
- rubygems_version: 3.1.2
116
- signing_key:
116
+ rubygems_version: 3.0.3
117
+ signing_key:
117
118
  specification_version: 4
118
119
  summary: DSL for declaring allowed parameters with options, regexp patern and default
119
120
  values.
@@ -123,6 +124,7 @@ test_files:
123
124
  - spec/expand_spec.rb
124
125
  - spec/field_spec.rb
125
126
  - spec/policies_spec.rb
127
+ - spec/schema_lifecycle_hooks_spec.rb
126
128
  - spec/schema_spec.rb
127
129
  - spec/schema_walk_spec.rb
128
130
  - spec/spec_helper.rb