parametric 0.2.10 → 0.2.19

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parametric
4
+ # Adapt legacy policies to the new policy interface
5
+ class PolicyAdapter
6
+ class PolicyRunner
7
+ def initialize(policy, key, value, payload, context)
8
+ @policy, @key, @raw_value, @payload, @context = policy, key, value, payload, context
9
+ end
10
+
11
+ # The PolicyRunner interface
12
+ # @return [Boolean]
13
+ def eligible?
14
+ @policy.eligible?(@raw_value, @key, @payload)
15
+ end
16
+
17
+ # @return [Boolean]
18
+ def valid?
19
+ @policy.valid?(value, @key, @payload)
20
+ end
21
+
22
+ # @return [Any]
23
+ def value
24
+ @value ||= @policy.coerce(@raw_value, @key, @context)
25
+ end
26
+
27
+ # @return [String]
28
+ def message
29
+ @policy.message
30
+ end
31
+ end
32
+
33
+ def initialize(policy)
34
+ @policy = policy
35
+ end
36
+
37
+ # The PolicyFactory interface
38
+ # Buld a Policy Runner, which is instantiated
39
+ # for each field when resolving a schema
40
+ # @param key [Symbol]
41
+ # @param value [Any]
42
+ # @option payload [Hash]
43
+ # @option context [Parametric::Context]
44
+ # @return [PolicyRunner]
45
+ def build(key, value, payload:, context:)
46
+ PolicyRunner.new(@policy, key, value, payload, context)
47
+ end
48
+
49
+ def meta_data
50
+ @policy.meta_data
51
+ end
52
+
53
+ def key
54
+ @policy.key
55
+ end
56
+ end
57
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'parametric/block_validator'
2
4
 
3
5
  module Parametric
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Parametric
2
4
  class Results
3
5
  attr_reader :output, :errors
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "parametric/context"
2
4
  require "parametric/results"
3
5
  require "parametric/field"
@@ -12,6 +14,8 @@ module Parametric
12
14
  @default_field_policies = []
13
15
  @ignored_field_keys = []
14
16
  @expansions = {}
17
+ @before_hooks = []
18
+ @after_hooks = []
15
19
  end
16
20
 
17
21
  def schema
@@ -71,7 +75,9 @@ module Parametric
71
75
  fields.each_with_object({}) do |(_, field), obj|
72
76
  meta = field.meta_data.dup
73
77
  sc = meta.delete(:schema)
74
- meta[:structure] = sc.structure if sc
78
+ meta[:structure] = sc.schema.structure if sc
79
+ one_of = meta.delete(:one_of)
80
+ meta[:one_of] = one_of.values.map(&:structure) if one_of
75
81
  obj[field.key] = meta
76
82
  end
77
83
  end
@@ -90,6 +96,20 @@ module Parametric
90
96
  end
91
97
  end
92
98
 
99
+ def before_resolve(klass = nil, &block)
100
+ raise ArgumentError, '#before_resolve expects a callable object, or a block' if !klass && !block_given?
101
+ callable = klass || block
102
+ before_hooks << callable
103
+ self
104
+ end
105
+
106
+ def after_resolve(klass = nil, &block)
107
+ raise ArgumentError, '#after_resolve expects a callable object, or a block' if !klass && !block_given?
108
+ callable = klass || block
109
+ after_hooks << callable
110
+ self
111
+ end
112
+
93
113
  def expand(exp, &block)
94
114
  expansions[exp] = block
95
115
  self
@@ -139,19 +159,35 @@ module Parametric
139
159
 
140
160
  protected
141
161
 
142
- attr_reader :definitions, :options
162
+ attr_reader :definitions, :options, :before_hooks, :after_hooks
143
163
 
144
164
  private
145
165
 
146
166
  attr_reader :default_field_policies, :ignored_field_keys, :expansions
147
167
 
148
168
  def coerce_one(val, context, flds: fields)
149
- flds.each_with_object({}) do |(_, field), m|
169
+ val = run_before_hooks(val, context)
170
+
171
+ out = flds.each_with_object({}) do |(_, field), m|
150
172
  r = field.resolve(val, context.sub(field.key))
151
173
  if r.eligible?
152
174
  m[field.key] = r.value
153
175
  end
154
176
  end
177
+
178
+ run_after_hooks(out, context)
179
+ end
180
+
181
+ def run_before_hooks(val, context)
182
+ before_hooks.reduce(val) do |value, callable|
183
+ callable.call(value, context)
184
+ end
185
+ end
186
+
187
+ def run_after_hooks(val, context)
188
+ after_hooks.reduce(val) do |value, callable|
189
+ callable.call(value, context)
190
+ end
155
191
  end
156
192
 
157
193
  class MatchContext
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'parametric/dsl'
2
4
 
3
5
  module Parametric
@@ -36,6 +38,10 @@ module Parametric
36
38
  _results.output.clone
37
39
  end
38
40
 
41
+ def [](key)
42
+ _results.output[key.to_sym]
43
+ end
44
+
39
45
  def ==(other)
40
46
  other.respond_to?(:to_h) && other.to_h.eql?(to_h)
41
47
  end
@@ -56,10 +62,24 @@ module Parametric
56
62
 
57
63
  # this hook is called after schema definition in DSL module
58
64
  def parametric_after_define_schema(schema)
59
- schema.fields.keys.each do |key|
60
- define_method key do
61
- _graph[key]
65
+ schema.fields.values.each do |field|
66
+ if field.meta_data[:schema]
67
+ if field.meta_data[:schema].is_a?(Parametric::Schema)
68
+ klass = Class.new do
69
+ include Struct
70
+ end
71
+ klass.schema = field.meta_data[:schema]
72
+ self.const_set(__class_name(field.key), klass)
73
+ klass.parametric_after_define_schema(field.meta_data[:schema])
74
+ else
75
+ self.const_set(__class_name(field.key), field.meta_data[:schema])
76
+ end
62
77
  end
78
+ self.class_eval <<-RUBY, __FILE__, __LINE__ + 1
79
+ def #{field.key}
80
+ _graph[:#{field.key}]
81
+ end
82
+ RUBY
63
83
  end
64
84
  end
65
85
 
@@ -69,27 +89,11 @@ module Parametric
69
89
  end
70
90
  end
71
91
 
72
- def parametric_build_class_for_child(key, child_schema)
73
- klass = Class.new do
74
- include Struct
75
- end
76
- klass.schema = child_schema
77
- klass
78
- end
79
-
80
92
  def wrap(key, value)
81
- field = schema.fields[key]
82
- return value unless field
83
-
84
93
  case value
85
94
  when Hash
86
95
  # find constructor for field
87
- cons = field.meta_data[:schema]
88
- if cons.kind_of?(Parametric::Schema)
89
- klass = parametric_build_class_for_child(key, cons)
90
- klass.parametric_after_define_schema(cons)
91
- cons = klass
92
- end
96
+ cons = self.const_get(__class_name(key))
93
97
  cons ? cons.new(value) : value.freeze
94
98
  when Array
95
99
  value.map{|v| wrap(key, v) }.freeze
@@ -97,6 +101,12 @@ module Parametric
97
101
  value.freeze
98
102
  end
99
103
  end
104
+
105
+ PLURAL_END = /s$/.freeze
106
+
107
+ def __class_name(key)
108
+ key.to_s.split('_').map(&:capitalize).join.sub(PLURAL_END, '')
109
+ end
100
110
  end
101
111
  end
102
112
  end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parametric
4
+ # A policy that allows you to select a sub-schema based on a value in the payload.
5
+ # @example
6
+ #
7
+ # user_schema = Parametric::Schema.new do |sc, _|
8
+ # field(:name).type(:string).present
9
+ # field(:age).type(:integer).present
10
+ # end
11
+ #
12
+ # company_schema = Parametric::Schema.new do
13
+ # field(:name).type(:string).present
14
+ # field(:company_code).type(:string).present
15
+ # end
16
+ #
17
+ # schema = Parametric::Schema.new do |sc, _|
18
+ # # Use :type field to locate the sub-schema to use for :sub
19
+ # sc.field(:type).type(:string)
20
+ #
21
+ # # Use the :one_of policy to select the sub-schema based on the :type field above
22
+ # sc.field(:sub).type(:object).tagged_one_of do |sub|
23
+ # sub.index_by(:type)
24
+ # sub.on('user', user_schema)
25
+ # sub.on('company', company_schema)
26
+ # end
27
+ # end
28
+ #
29
+ # # The schema will now select the correct sub-schema based on the value of :type
30
+ # result = schema.resolve(type: 'user', sub: { name: 'Joe', age: 30 })
31
+ #
32
+ # Instances can also be created separately and used as a policy:
33
+ # @example
34
+ #
35
+ # UserOrCompany = Parametric::TaggedOneOf.new do |sc, _|
36
+ # sc.on('user', user_schema)
37
+ # sc.on('company', company_schema)
38
+ # end
39
+ #
40
+ # schema = Parametric::Schema.new do |sc, _|
41
+ # sc.field(:type).type(:string)
42
+ # sc.field(:sub).type(:object).policy(UserOrCompany.index_by(:type))
43
+ # end
44
+ class TaggedOneOf
45
+ NOOP_INDEX = ->(payload) { payload }.freeze
46
+ def initialize(index: NOOP_INDEX, matchers: {}, &block)
47
+ @index = index
48
+ @matchers = matchers
49
+ @configuring = false
50
+ if block_given?
51
+ @configuring = true
52
+ block.call(self)
53
+ @configuring = false
54
+ end
55
+ freeze
56
+ end
57
+
58
+ def index_by(callable = nil, &block)
59
+ if callable.is_a?(Symbol)
60
+ key = callable
61
+ callable = ->(payload) { payload[key] }
62
+ end
63
+ index = callable || block
64
+ if configuring?
65
+ @index = index
66
+ else
67
+ self.class.new(index:, matchers: @matchers)
68
+ end
69
+ end
70
+
71
+ def on(key, schema)
72
+ @matchers[key] = schema
73
+ end
74
+
75
+ # The [PolicyFactory] interface
76
+ def build(key, value, payload:, context:)
77
+ Runner.new(@index, @matchers, key, value, payload, context)
78
+ end
79
+
80
+ def meta_data
81
+ { type: :object, one_of: @matchers }
82
+ end
83
+
84
+ private def configuring?
85
+ @configuring
86
+ end
87
+
88
+ class Runner
89
+ def initialize(index, matchers, key, value, payload, context)
90
+ @matchers = matchers
91
+ @key = key
92
+ @raw_value = value
93
+ @payload = payload
94
+ @context = context
95
+ @index_value = index.call(payload)
96
+ end
97
+
98
+ # Should this policy run at all?
99
+ # returning [false] halts the field policy chain.
100
+ # @return [Boolean]
101
+ def eligible?
102
+ true
103
+ end
104
+
105
+ # If [false], add [#message] to result errors and halt processing field.
106
+ # @return [Boolean]
107
+ def valid?
108
+ has_sub_schema?
109
+ end
110
+
111
+ # Coerce the value, or return as-is.
112
+ # @return [Any]
113
+ def value
114
+ @value ||= has_sub_schema? ? sub_schema.coerce(@raw_value, @key, @context) : @raw_value
115
+ end
116
+
117
+ # Error message for this policy
118
+ # @return [String]
119
+ def message
120
+ "#{@value} is invalid. No sub-schema found for '#{@index_value}'"
121
+ end
122
+
123
+ private
124
+
125
+ def has_sub_schema?
126
+ @matchers.key?(@index_value)
127
+ end
128
+
129
+ def sub_schema
130
+ @sub_schema ||= @matchers[@index_value]
131
+ end
132
+ end
133
+ end
134
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Parametric
2
- VERSION = "0.2.10"
4
+ VERSION = '0.2.19'
3
5
  end
data/lib/parametric.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "parametric/version"
2
4
  require "parametric/registry"
3
5
  require "parametric/field"
data/parametric.gemspec CHANGED
@@ -17,8 +17,7 @@ Gem::Specification.new do |spec|
17
17
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
18
  spec.require_paths = ["lib"]
19
19
 
20
- spec.add_development_dependency "bundler", "~> 1.5"
21
20
  spec.add_development_dependency "rake"
22
- spec.add_development_dependency "rspec", '3.4.0'
21
+ spec.add_development_dependency "rspec", '3.12.0'
23
22
  spec.add_development_dependency "byebug"
24
23
  end
data/spec/field_spec.rb CHANGED
@@ -88,6 +88,14 @@ describe Parametric::Field do
88
88
  end
89
89
  end
90
90
 
91
+ describe '#has_policy?' do
92
+ it 'is a boolean' do
93
+ subject.policy(:integer)
94
+ expect(subject.has_policy?(:integer)).to be(true)
95
+ expect(subject.has_policy?(:string)).to be(false)
96
+ end
97
+ end
98
+
91
99
  describe "#default" do
92
100
  it "is default if missing key" do
93
101
  resolve(subject.default("AA"), foobar: 1).tap do |r|
@@ -112,6 +120,20 @@ describe Parametric::Field do
112
120
  end
113
121
  end
114
122
 
123
+ describe '#from' do
124
+ it 'copies policies and metadata from an existing field' do
125
+ subject.policy(:string).present.options(['a', 'b', 'c'])
126
+
127
+ field = described_class.new(:another_key, registry).from(subject)
128
+ resolve(field, another_key: "abc").tap do |r|
129
+ has_errors
130
+ end
131
+ expect(field.meta_data[:type]).to eq(:string)
132
+ expect(field.meta_data[:present]).to be(true)
133
+ expect(field.meta_data[:options]).to eq(['a', 'b', 'c'])
134
+ end
135
+ end
136
+
115
137
  describe "#present" do
116
138
  it "is valid if value is present" do
117
139
  resolve(subject.present, a_key: "abc").tap do |r|
@@ -242,6 +264,16 @@ describe Parametric::Field do
242
264
  end
243
265
  end
244
266
 
267
+ describe ":value policy" do
268
+ it 'always resolves to static value' do
269
+ resolve(subject.policy(:value, 'hello'), a_key: "tag1,tag2").tap do |r|
270
+ expect(r.eligible?).to be true
271
+ no_errors
272
+ expect(r.value).to eq 'hello'
273
+ end
274
+ end
275
+ end
276
+
245
277
  describe ":declared policy" do
246
278
  it "is eligible if key exists" do
247
279
  resolve(subject.policy(:declared).present, a_key: "").tap do |r|
@@ -2,7 +2,7 @@ require 'spec_helper'
2
2
 
3
3
  describe 'default coercions' do
4
4
  def test_coercion(key, value, expected)
5
- coercion = Parametric.registry.coercions[key]
5
+ coercion = Parametric.registry.policies[key]
6
6
  expect(coercion.new.coerce(value, nil, nil)).to eq expected
7
7
  end
8
8
 
@@ -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
data/spec/schema_spec.rb CHANGED
@@ -251,6 +251,87 @@ describe Parametric::Schema do
251
251
  end
252
252
  end
253
253
 
254
+ describe '#tagged_one_of for multiple sub-schemas' do
255
+ let(:user_schema) do
256
+ described_class.new do
257
+ field(:name).type(:string).present
258
+ field(:age).type(:integer).present
259
+ end
260
+ end
261
+
262
+ let(:company_schema) do
263
+ described_class.new do
264
+ field(:name).type(:string).present
265
+ field(:company_code).type(:string).present
266
+ end
267
+ end
268
+
269
+ it 'picks the right sub-schema' do
270
+ schema = described_class.new do |sc, _|
271
+ sc.field(:type).type(:string)
272
+ sc.field(:sub).type(:object).tagged_one_of do |sub|
273
+ sub.index_by(:type)
274
+ sub.on('user', user_schema)
275
+ sub.on('company', company_schema)
276
+ end
277
+ end
278
+
279
+ result = schema.resolve(type: 'user', sub: { name: 'Joe', age: 30 })
280
+ expect(result.valid?).to be true
281
+ expect(result.output).to eq({ type: 'user', sub: { name: 'Joe', age: 30 } })
282
+
283
+ result = schema.resolve(type: 'company', sub: { name: 'ACME', company_code: 123 })
284
+ expect(result.valid?).to be true
285
+ expect(result.output).to eq({ type: 'company', sub: { name: 'ACME', company_code: '123' } })
286
+
287
+ result = schema.resolve(type: 'company', sub: { name: nil, company_code: 123 })
288
+ expect(result.valid?).to be false
289
+ expect(result.errors['$.sub.name']).not_to be_empty
290
+
291
+ result = schema.resolve(type: 'foo', sub: { name: 'ACME', company_code: 123 })
292
+ expect(result.valid?).to be false
293
+ end
294
+
295
+ it 'can be assigned to instance and reused' do
296
+ user_or_company = Parametric::TaggedOneOf.new do |sub|
297
+ sub.on('user', user_schema)
298
+ sub.on('company', company_schema)
299
+ end
300
+
301
+ schema = described_class.new do |sc, _|
302
+ sc.field(:type).type(:string)
303
+ sc.field(:sub).type(:object).tagged_one_of(user_or_company.index_by(:type))
304
+ end
305
+
306
+ result = schema.resolve(type: 'user', sub: { name: 'Joe', age: 30 })
307
+ expect(result.valid?).to be true
308
+ expect(result.output).to eq({ type: 'user', sub: { name: 'Joe', age: 30 } })
309
+ end
310
+
311
+ specify '#structure' do
312
+ user_or_company = Parametric::TaggedOneOf.new do |sub|
313
+ sub.on('user', user_schema)
314
+ sub.on('company', company_schema)
315
+ end
316
+
317
+ schema = described_class.new do |sc, _|
318
+ sc.field(:type).type(:string)
319
+ sc.field(:sub).type(:object).tagged_one_of(user_or_company.index_by(:type))
320
+ end
321
+
322
+ structure = schema.structure
323
+ structure.dig(:sub).tap do |sub|
324
+ expect(sub[:type]).to eq :object
325
+ expect(sub[:one_of][0][:name][:type]).to eq :string
326
+ expect(sub[:one_of][0][:name][:required]).to be true
327
+ expect(sub[:one_of][0][:name][:present]).to be true
328
+ expect(sub[:one_of][0][:age][:type]).to eq :integer
329
+
330
+ expect(sub[:one_of][1][:company_code][:type]).to eq :string
331
+ end
332
+ end
333
+ end
334
+
254
335
  describe "#ignore" do
255
336
  it "ignores fields" do
256
337
  s1 = described_class.new.ignore(:title, :status) do