parametric 0.0.1 → 0.2.10

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,194 @@
1
+ require "parametric/context"
2
+ require "parametric/results"
3
+ require "parametric/field"
4
+
5
+ module Parametric
6
+ class Schema
7
+ def initialize(options = {}, &block)
8
+ @options = options
9
+ @fields = {}
10
+ @definitions = []
11
+ @definitions << block if block_given?
12
+ @default_field_policies = []
13
+ @ignored_field_keys = []
14
+ @expansions = {}
15
+ end
16
+
17
+ def schema
18
+ self
19
+ end
20
+
21
+ def fields
22
+ apply!
23
+ @fields
24
+ end
25
+
26
+ def policy(*names, &block)
27
+ @default_field_policies = names
28
+ definitions << block if block_given?
29
+
30
+ self
31
+ end
32
+
33
+ def ignore(*field_keys, &block)
34
+ @ignored_field_keys += field_keys
35
+ @ignored_field_keys.uniq!
36
+
37
+ definitions << block if block_given?
38
+
39
+ self
40
+ end
41
+
42
+ def clone
43
+ instance = self.class.new(options)
44
+ copy_into instance
45
+ end
46
+
47
+ def merge(other_schema = nil, &block)
48
+ raise ArgumentError, '#merge takes either a schema instance or a block' if other_schema.nil? && !block_given?
49
+
50
+ if other_schema
51
+ instance = self.class.new(options.merge(other_schema.options))
52
+ copy_into(instance)
53
+ other_schema.copy_into(instance)
54
+ else
55
+ merge(self.class.new(&block))
56
+ end
57
+ end
58
+
59
+ def copy_into(instance)
60
+ instance.policy(*default_field_policies) if default_field_policies.any?
61
+
62
+ definitions.each do |d|
63
+ instance.definitions << d
64
+ end
65
+
66
+ instance.ignore *ignored_field_keys
67
+ instance
68
+ end
69
+
70
+ def structure
71
+ fields.each_with_object({}) do |(_, field), obj|
72
+ meta = field.meta_data.dup
73
+ sc = meta.delete(:schema)
74
+ meta[:structure] = sc.structure if sc
75
+ obj[field.key] = meta
76
+ end
77
+ end
78
+
79
+ def field(field_or_key)
80
+ f, key = if field_or_key.kind_of?(Field)
81
+ [field_or_key, field_or_key.key]
82
+ else
83
+ [Field.new(field_or_key), field_or_key.to_sym]
84
+ end
85
+
86
+ if ignored_field_keys.include?(f.key)
87
+ f
88
+ else
89
+ @fields[key] = apply_default_field_policies_to(f)
90
+ end
91
+ end
92
+
93
+ def expand(exp, &block)
94
+ expansions[exp] = block
95
+ self
96
+ end
97
+
98
+ def resolve(payload)
99
+ context = Context.new
100
+ output = coerce(payload, nil, context)
101
+ Results.new(output, context.errors)
102
+ end
103
+
104
+ def walk(meta_key = nil, &visitor)
105
+ r = visit(meta_key, &visitor)
106
+ Results.new(r, {})
107
+ end
108
+
109
+ def eligible?(value, key, payload)
110
+ payload.key? key
111
+ end
112
+
113
+ def valid?(*_)
114
+ true
115
+ end
116
+
117
+ def meta_data
118
+ {}
119
+ end
120
+
121
+ def visit(meta_key = nil, &visitor)
122
+ fields.each_with_object({}) do |(_, field), m|
123
+ m[field.key] = field.visit(meta_key, &visitor)
124
+ end
125
+ end
126
+
127
+ def coerce(val, _, context)
128
+ if val.is_a?(Array)
129
+ val.map.with_index{|v, idx|
130
+ subcontext = context.sub(idx)
131
+ out = coerce_one(v, subcontext)
132
+ resolve_expansions(v, out, subcontext)
133
+ }
134
+ else
135
+ out = coerce_one(val, context)
136
+ resolve_expansions(val, out, context)
137
+ end
138
+ end
139
+
140
+ protected
141
+
142
+ attr_reader :definitions, :options
143
+
144
+ private
145
+
146
+ attr_reader :default_field_policies, :ignored_field_keys, :expansions
147
+
148
+ def coerce_one(val, context, flds: fields)
149
+ flds.each_with_object({}) do |(_, field), m|
150
+ r = field.resolve(val, context.sub(field.key))
151
+ if r.eligible?
152
+ m[field.key] = r.value
153
+ end
154
+ end
155
+ end
156
+
157
+ class MatchContext
158
+ def field(key)
159
+ Field.new(key.to_sym)
160
+ end
161
+ end
162
+
163
+ def resolve_expansions(payload, into, context)
164
+ expansions.each do |exp, block|
165
+ payload.each do |key, value|
166
+ if match = exp.match(key.to_s)
167
+ fld = MatchContext.new.instance_exec(match, &block)
168
+ if fld
169
+ into.update(coerce_one({fld.key => value}, context, flds: {fld.key => apply_default_field_policies_to(fld)}))
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ into
176
+ end
177
+
178
+ def apply_default_field_policies_to(field)
179
+ default_field_policies.reduce(field) {|f, policy_name| f.policy(policy_name) }
180
+ end
181
+
182
+ def apply!
183
+ return if @applied
184
+ definitions.each do |d|
185
+ if d.arity == 2 # pass schema instance and options, preserve block context
186
+ d.call(self, options)
187
+ else # run block in context of current instance
188
+ self.instance_exec(options, &d)
189
+ end
190
+ end
191
+ @applied = true
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,102 @@
1
+ require 'parametric/dsl'
2
+
3
+ module Parametric
4
+ class InvalidStructError < ArgumentError
5
+ attr_reader :errors
6
+ def initialize(struct)
7
+ @errors = struct.errors
8
+ msg = @errors.map do |k, strings|
9
+ "#{k} #{strings.join(', ')}"
10
+ end.join('. ')
11
+ super "#{struct.class} is not a valid struct: #{msg}"
12
+ end
13
+ end
14
+
15
+ module Struct
16
+ def self.included(base)
17
+ base.send(:include, Parametric::DSL)
18
+ base.extend ClassMethods
19
+ end
20
+
21
+ def initialize(attrs = {})
22
+ @_results = self.class.schema.resolve(attrs)
23
+ @_graph = self.class.build(@_results.output)
24
+ end
25
+
26
+ def valid?
27
+ !_results.errors.any?
28
+ end
29
+
30
+ def errors
31
+ _results.errors
32
+ end
33
+
34
+ # returns a shallow copy.
35
+ def to_h
36
+ _results.output.clone
37
+ end
38
+
39
+ def ==(other)
40
+ other.respond_to?(:to_h) && other.to_h.eql?(to_h)
41
+ end
42
+
43
+ def merge(attrs = {})
44
+ self.class.new(to_h.merge(attrs))
45
+ end
46
+
47
+ private
48
+ attr_reader :_graph, :_results
49
+
50
+ module ClassMethods
51
+ def new!(attrs = {})
52
+ st = new(attrs)
53
+ raise InvalidStructError.new(st) unless st.valid?
54
+ st
55
+ end
56
+
57
+ # this hook is called after schema definition in DSL module
58
+ def parametric_after_define_schema(schema)
59
+ schema.fields.keys.each do |key|
60
+ define_method key do
61
+ _graph[key]
62
+ end
63
+ end
64
+ end
65
+
66
+ def build(attrs)
67
+ attrs.each_with_object({}) do |(k, v), obj|
68
+ obj[k] = wrap(k, v)
69
+ end
70
+ end
71
+
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
+ def wrap(key, value)
81
+ field = schema.fields[key]
82
+ return value unless field
83
+
84
+ case value
85
+ when Hash
86
+ # 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
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
+ end
101
+ end
102
+ end
@@ -1,3 +1,3 @@
1
1
  module Parametric
2
- VERSION = "0.0.1"
2
+ VERSION = "0.2.10"
3
3
  end
data/lib/parametric.rb CHANGED
@@ -1,9 +1,20 @@
1
+ require "parametric/version"
2
+ require "parametric/registry"
3
+ require "parametric/field"
4
+ require "parametric/results"
5
+ require "parametric/schema"
6
+ require "parametric/context"
7
+
1
8
  module Parametric
2
9
 
10
+ def self.registry
11
+ @registry ||= Registry.new
12
+ end
13
+
14
+ def self.policy(name, plcy = nil, &block)
15
+ registry.policy name, plcy, &block
16
+ end
3
17
  end
4
18
 
5
- require "parametric/utils"
6
- require "parametric/version"
7
- require "parametric/policies"
8
- require "parametric/params"
9
- require "parametric/hash"
19
+ require 'parametric/default_types'
20
+ require 'parametric/policies'
data/parametric.gemspec CHANGED
@@ -14,11 +14,11 @@ 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
20
  spec.add_development_dependency "bundler", "~> 1.5"
22
21
  spec.add_development_dependency "rake"
23
- spec.add_development_dependency "rspec"
22
+ spec.add_development_dependency "rspec", '3.4.0'
23
+ spec.add_development_dependency "byebug"
24
24
  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