parametric 0.0.1 → 0.2.12

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parametric/context"
4
+ require "parametric/results"
5
+ require "parametric/field"
6
+
7
+ module Parametric
8
+ class Schema
9
+ def initialize(options = {}, &block)
10
+ @options = options
11
+ @fields = {}
12
+ @definitions = []
13
+ @definitions << block if block_given?
14
+ @default_field_policies = []
15
+ @ignored_field_keys = []
16
+ @expansions = {}
17
+ @before_hooks = []
18
+ @after_hooks = []
19
+ end
20
+
21
+ def schema
22
+ self
23
+ end
24
+
25
+ def fields
26
+ apply!
27
+ @fields
28
+ end
29
+
30
+ def policy(*names, &block)
31
+ @default_field_policies = names
32
+ definitions << block if block_given?
33
+
34
+ self
35
+ end
36
+
37
+ def ignore(*field_keys, &block)
38
+ @ignored_field_keys += field_keys
39
+ @ignored_field_keys.uniq!
40
+
41
+ definitions << block if block_given?
42
+
43
+ self
44
+ end
45
+
46
+ def clone
47
+ instance = self.class.new(options)
48
+ copy_into instance
49
+ end
50
+
51
+ def merge(other_schema = nil, &block)
52
+ raise ArgumentError, '#merge takes either a schema instance or a block' if other_schema.nil? && !block_given?
53
+
54
+ if other_schema
55
+ instance = self.class.new(options.merge(other_schema.options))
56
+ copy_into(instance)
57
+ other_schema.copy_into(instance)
58
+ else
59
+ merge(self.class.new(&block))
60
+ end
61
+ end
62
+
63
+ def copy_into(instance)
64
+ instance.policy(*default_field_policies) if default_field_policies.any?
65
+
66
+ definitions.each do |d|
67
+ instance.definitions << d
68
+ end
69
+
70
+ instance.ignore *ignored_field_keys
71
+ instance
72
+ end
73
+
74
+ def structure
75
+ fields.each_with_object({}) do |(_, field), obj|
76
+ meta = field.meta_data.dup
77
+ sc = meta.delete(:schema)
78
+ meta[:structure] = sc.structure if sc
79
+ obj[field.key] = meta
80
+ end
81
+ end
82
+
83
+ def field(field_or_key)
84
+ f, key = if field_or_key.kind_of?(Field)
85
+ [field_or_key, field_or_key.key]
86
+ else
87
+ [Field.new(field_or_key), field_or_key.to_sym]
88
+ end
89
+
90
+ if ignored_field_keys.include?(f.key)
91
+ f
92
+ else
93
+ @fields[key] = apply_default_field_policies_to(f)
94
+ end
95
+ end
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
+
111
+ def expand(exp, &block)
112
+ expansions[exp] = block
113
+ self
114
+ end
115
+
116
+ def resolve(payload)
117
+ context = Context.new
118
+ output = coerce(payload, nil, context)
119
+ Results.new(output, context.errors)
120
+ end
121
+
122
+ def walk(meta_key = nil, &visitor)
123
+ r = visit(meta_key, &visitor)
124
+ Results.new(r, {})
125
+ end
126
+
127
+ def eligible?(value, key, payload)
128
+ payload.key? key
129
+ end
130
+
131
+ def valid?(*_)
132
+ true
133
+ end
134
+
135
+ def meta_data
136
+ {}
137
+ end
138
+
139
+ def visit(meta_key = nil, &visitor)
140
+ fields.each_with_object({}) do |(_, field), m|
141
+ m[field.key] = field.visit(meta_key, &visitor)
142
+ end
143
+ end
144
+
145
+ def coerce(val, _, context)
146
+ if val.is_a?(Array)
147
+ val.map.with_index{|v, idx|
148
+ subcontext = context.sub(idx)
149
+ out = coerce_one(v, subcontext)
150
+ resolve_expansions(v, out, subcontext)
151
+ }
152
+ else
153
+ out = coerce_one(val, context)
154
+ resolve_expansions(val, out, context)
155
+ end
156
+ end
157
+
158
+ protected
159
+
160
+ attr_reader :definitions, :options, :before_hooks, :after_hooks
161
+
162
+ private
163
+
164
+ attr_reader :default_field_policies, :ignored_field_keys, :expansions
165
+
166
+ def coerce_one(val, context, flds: fields)
167
+ val = run_before_hooks(val, context)
168
+
169
+ out = flds.each_with_object({}) do |(_, field), m|
170
+ r = field.resolve(val, context.sub(field.key))
171
+ if r.eligible?
172
+ m[field.key] = r.value
173
+ end
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
189
+ end
190
+
191
+ class MatchContext
192
+ def field(key)
193
+ Field.new(key.to_sym)
194
+ end
195
+ end
196
+
197
+ def resolve_expansions(payload, into, context)
198
+ expansions.each do |exp, block|
199
+ payload.each do |key, value|
200
+ if match = exp.match(key.to_s)
201
+ fld = MatchContext.new.instance_exec(match, &block)
202
+ if fld
203
+ into.update(coerce_one({fld.key => value}, context, flds: {fld.key => apply_default_field_policies_to(fld)}))
204
+ end
205
+ end
206
+ end
207
+ end
208
+
209
+ into
210
+ end
211
+
212
+ def apply_default_field_policies_to(field)
213
+ default_field_policies.reduce(field) {|f, policy_name| f.policy(policy_name) }
214
+ end
215
+
216
+ def apply!
217
+ return if @applied
218
+ definitions.each do |d|
219
+ if d.arity == 2 # pass schema instance and options, preserve block context
220
+ d.call(self, options)
221
+ else # run block in context of current instance
222
+ self.instance_exec(options, &d)
223
+ end
224
+ end
225
+ @applied = true
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parametric/dsl'
4
+
5
+ module Parametric
6
+ class InvalidStructError < ArgumentError
7
+ attr_reader :errors
8
+ def initialize(struct)
9
+ @errors = struct.errors
10
+ msg = @errors.map do |k, strings|
11
+ "#{k} #{strings.join(', ')}"
12
+ end.join('. ')
13
+ super "#{struct.class} is not a valid struct: #{msg}"
14
+ end
15
+ end
16
+
17
+ module Struct
18
+ def self.included(base)
19
+ base.send(:include, Parametric::DSL)
20
+ base.extend ClassMethods
21
+ end
22
+
23
+ def initialize(attrs = {})
24
+ @_results = self.class.schema.resolve(attrs)
25
+ @_graph = self.class.build(@_results.output)
26
+ end
27
+
28
+ def valid?
29
+ !_results.errors.any?
30
+ end
31
+
32
+ def errors
33
+ _results.errors
34
+ end
35
+
36
+ # returns a shallow copy.
37
+ def to_h
38
+ _results.output.clone
39
+ end
40
+
41
+ def ==(other)
42
+ other.respond_to?(:to_h) && other.to_h.eql?(to_h)
43
+ end
44
+
45
+ def merge(attrs = {})
46
+ self.class.new(to_h.merge(attrs))
47
+ end
48
+
49
+ private
50
+ attr_reader :_graph, :_results
51
+
52
+ module ClassMethods
53
+ def new!(attrs = {})
54
+ st = new(attrs)
55
+ raise InvalidStructError.new(st) unless st.valid?
56
+ st
57
+ end
58
+
59
+ # this hook is called after schema definition in DSL module
60
+ def parametric_after_define_schema(schema)
61
+ schema.fields.values.each do |field|
62
+ if field.meta_data[:schema]
63
+ if field.meta_data[:schema].is_a?(Parametric::Schema)
64
+ klass = Class.new do
65
+ include Struct
66
+ end
67
+ klass.schema = field.meta_data[:schema]
68
+ self.const_set(__class_name(field.key), klass)
69
+ klass.parametric_after_define_schema(field.meta_data[:schema])
70
+ else
71
+ self.const_set(__class_name(field.key), field.meta_data[:schema])
72
+ end
73
+ end
74
+ self.class_eval <<-RUBY, __FILE__, __LINE__ + 1
75
+ def #{field.key}
76
+ _graph[:#{field.key}]
77
+ end
78
+ RUBY
79
+ end
80
+ end
81
+
82
+ def build(attrs)
83
+ attrs.each_with_object({}) do |(k, v), obj|
84
+ obj[k] = wrap(k, v)
85
+ end
86
+ end
87
+
88
+ def wrap(key, value)
89
+ case value
90
+ when Hash
91
+ # find constructor for field
92
+ cons = self.const_get(__class_name(key))
93
+ cons ? cons.new(value) : value.freeze
94
+ when Array
95
+ value.map{|v| wrap(key, v) }.freeze
96
+ else
97
+ value.freeze
98
+ end
99
+ end
100
+
101
+ PLURAL_END = /s$/.freeze
102
+
103
+ def __class_name(key)
104
+ key.to_s.split('_').map(&:capitalize).join.sub(PLURAL_END, '')
105
+ end
106
+ end
107
+ end
108
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Parametric
2
- VERSION = "0.0.1"
4
+ VERSION = "0.2.12"
3
5
  end
data/lib/parametric.rb CHANGED
@@ -1,9 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parametric/version"
4
+ require "parametric/registry"
5
+ require "parametric/field"
6
+ require "parametric/results"
7
+ require "parametric/schema"
8
+ require "parametric/context"
9
+
1
10
  module Parametric
2
11
 
12
+ def self.registry
13
+ @registry ||= Registry.new
14
+ end
15
+
16
+ def self.policy(name, plcy = nil, &block)
17
+ registry.policy name, plcy, &block
18
+ end
3
19
  end
4
20
 
5
- require "parametric/utils"
6
- require "parametric/version"
7
- require "parametric/policies"
8
- require "parametric/params"
9
- require "parametric/hash"
21
+ require 'parametric/default_types'
22
+ require 'parametric/policies'
data/parametric.gemspec CHANGED
@@ -14,11 +14,10 @@ Gem::Specification.new do |spec|
14
14
  spec.license = "MIT"
15
15
 
16
16
  spec.files = `git ls-files -z`.split("\x0")
17
- spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
17
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
18
  spec.require_paths = ["lib"]
20
19
 
21
- spec.add_development_dependency "bundler", "~> 1.5"
22
20
  spec.add_development_dependency "rake"
23
- spec.add_development_dependency "rspec"
21
+ spec.add_development_dependency "rspec", '3.4.0'
22
+ spec.add_development_dependency "byebug"
24
23
  end
@@ -0,0 +1,21 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'custom block validator' do
4
+ Parametric.policy :validate_if do
5
+ eligible do |options, value, key, payload|
6
+ options.all? do |key, value|
7
+ payload[key] == value
8
+ end
9
+ end
10
+ end
11
+
12
+ it 'works if I just define an :eligible block' do
13
+ schema = Parametric::Schema.new do
14
+ field(:name).policy(:validate_if, age: 40).present
15
+ field(:age).type(:integer)
16
+ end
17
+
18
+ expect(schema.resolve(age: 30).errors.any?).to be false
19
+ expect(schema.resolve(age: 40).errors.any?).to be true # name is missing
20
+ end
21
+ end
data/spec/dsl_spec.rb ADDED
@@ -0,0 +1,176 @@
1
+ require 'spec_helper'
2
+ require "parametric/dsl"
3
+
4
+ describe "classes including DSL module" do
5
+ class Parent
6
+ include Parametric::DSL
7
+
8
+ schema :extras, search_type: :string do |opts|
9
+ field(:search).policy(opts[:search_type])
10
+ end
11
+
12
+ schema(age_type: :integer) do |opts|
13
+ field(:title).policy(:string)
14
+ field(:age).policy(opts[:age_type])
15
+ end
16
+ end
17
+
18
+ class Child < Parent
19
+ schema :extras do
20
+ field(:query).type(:string)
21
+ end
22
+
23
+ schema(age_type: :string) do
24
+ field(:description).policy(:string)
25
+ end
26
+ end
27
+
28
+ class GrandChild < Child
29
+ schema :extras, search_type: :integer
30
+
31
+ schema(age_type: :integer)
32
+ end
33
+
34
+ describe "#schema" do
35
+ let(:input) {
36
+ {
37
+ title: "A title",
38
+ age: 38,
39
+ description: "A description"
40
+ }
41
+ }
42
+
43
+ it "merges parent's schema into child's" do
44
+ parent_output = Parent.schema.resolve(input).output
45
+ child_output = Child.schema.resolve(input).output
46
+
47
+ expect(parent_output.keys).to match_array([:title, :age])
48
+ expect(parent_output[:title]).to eq "A title"
49
+ expect(parent_output[:age]).to eq 38
50
+
51
+ expect(child_output.keys).to match_array([:title, :age, :description])
52
+ expect(child_output[:title]).to eq "A title"
53
+ expect(child_output[:age]).to eq "38"
54
+ expect(child_output[:description]).to eq "A description"
55
+
56
+ # named schema
57
+ parent_output = Parent.schema(:extras).resolve(search: 10, query: 'foo').output
58
+ child_output = Child.schema(:extras).resolve(search: 10, query: 'foo').output
59
+
60
+ expect(parent_output.keys).to match_array([:search])
61
+ expect(parent_output[:search]).to eq "10"
62
+ expect(child_output.keys).to match_array([:search, :query])
63
+ expect(child_output[:search]).to eq "10"
64
+ expect(child_output[:query]).to eq "foo"
65
+ end
66
+
67
+ it "inherits options" do
68
+ grand_child_output = GrandChild.schema.resolve(input).output
69
+
70
+ expect(grand_child_output.keys).to match_array([:title, :age, :description])
71
+ expect(grand_child_output[:title]).to eq "A title"
72
+ expect(grand_child_output[:age]).to eq 38
73
+ expect(grand_child_output[:description]).to eq "A description"
74
+
75
+ # named schema
76
+ grand_child_output = GrandChild.schema(:extras).resolve(search: "100", query: "bar").output
77
+ expect(grand_child_output.keys).to match_array([:search, :query])
78
+ expect(grand_child_output[:search]).to eq 100
79
+ end
80
+ end
81
+
82
+ describe "inheriting schema policy" do
83
+ let!(:a) {
84
+ Class.new do
85
+ include Parametric::DSL
86
+
87
+ schema.policy(:present) do
88
+ field(:title).policy(:string)
89
+ end
90
+ end
91
+ }
92
+
93
+ let!(:b) {
94
+ Class.new(a)
95
+ }
96
+
97
+ it "inherits policy" do
98
+ results = a.schema.resolve({})
99
+ expect(results.errors["$.title"]).not_to be_empty
100
+
101
+ results = b.schema.resolve({})
102
+ expect(results.errors["$.title"]).not_to be_empty
103
+ end
104
+ end
105
+
106
+ describe "overriding schema policy" do
107
+ let!(:a) {
108
+ Class.new do
109
+ include Parametric::DSL
110
+
111
+ schema.policy(:present) do
112
+ field(:title).policy(:string)
113
+ end
114
+ end
115
+ }
116
+
117
+ let!(:b) {
118
+ Class.new(a) do
119
+ schema.policy(:declared)
120
+ end
121
+ }
122
+
123
+ it "does not mutate parent schema" do
124
+ results = a.schema.resolve({})
125
+ expect(results.errors).not_to be_empty
126
+
127
+ results = b.schema.resolve({})
128
+ expect(results.errors).to be_empty
129
+ end
130
+ end
131
+
132
+ describe "removes fields defined in the parent class" do
133
+ let!(:a) {
134
+ Class.new do
135
+ include Parametric::DSL
136
+
137
+ schema do
138
+ field(:title).policy(:string)
139
+ end
140
+ end
141
+ }
142
+
143
+ let!(:b) {
144
+ Class.new(a) do
145
+ schema.ignore(:title) do
146
+ field(:age)
147
+ end
148
+ end
149
+ }
150
+
151
+ it "removes inherited field from child class" do
152
+ results = a.schema.resolve({title: "Mr.", age: 20})
153
+ expect(results.output).to eq({title: "Mr."})
154
+
155
+ results = b.schema.resolve({title: "Mr.", age: 20})
156
+ expect(results.output).to eq({age: 20})
157
+ end
158
+ end
159
+
160
+ describe "passing other schema or form in definition" do
161
+ it 'applies schema' do
162
+ a = Parametric::Schema.new do
163
+ field(:name).policy(:string)
164
+ field(:age).policy(:integer).default(40)
165
+ end
166
+ b = Class.new do
167
+ include Parametric::DSL
168
+ schema a
169
+ end
170
+
171
+ results = b.schema.resolve(name: 'Neil')
172
+ expect(results.output).to eq({name: 'Neil', age: 40})
173
+ end
174
+ end
175
+ end
176
+
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+
3
+ describe Parametric::Schema do
4
+ it "expands fields dynamically" do
5
+ schema = described_class.new do
6
+ field(:title).type(:string).present
7
+ expand(/^attr_(.+)/) do |match|
8
+ field(match[1]).type(:string)
9
+ end
10
+ expand(/^validate_(.+)/) do |match|
11
+ field(match[1]).type(:string).present
12
+ end
13
+ end
14
+
15
+ out = schema.resolve({
16
+ title: "foo",
17
+ :"attr_Attribute 1" => "attr 1",
18
+ :"attr_Attribute 2" => "attr 2",
19
+ :"validate_valid_attr" => "valid",
20
+ :"validate_invalid_attr" => "",
21
+ })
22
+
23
+ expect(out.output[:title]).to eq 'foo'
24
+ expect(out.output[:"Attribute 1"]).to eq 'attr 1'
25
+ expect(out.output[:"Attribute 2"]).to eq 'attr 2'
26
+
27
+ expect(out.errors['$.invalid_attr']).to eq ['is required and value must be present']
28
+ end
29
+ end