parametric 0.2.10 → 0.2.19

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.
@@ -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