parametric 0.0.1 → 0.2.12

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,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