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,53 @@
1
+ require 'benchmark/ips'
2
+ require 'parametric/struct'
3
+
4
+ StructAccount = Struct.new(:id, :email, keyword_init: true)
5
+ StructFriend = Struct.new(:name, keyword_init: true)
6
+ StructUser = Struct.new(:name, :age, :friends, :account, keyword_init: true)
7
+
8
+ class ParametricAccount
9
+ include Parametric::Struct
10
+ schema do
11
+ field(:id).type(:integer).present
12
+ field(:email).type(:string)
13
+ end
14
+ end
15
+
16
+ class ParametricUser
17
+ include Parametric::Struct
18
+ schema do
19
+ field(:name).type(:string).present
20
+ field(:age).type(:integer).default(42)
21
+ field(:friends).type(:array).schema do
22
+ field(:name).type(:string).present
23
+ end
24
+ field(:account).type(:object).schema ParametricAccount
25
+ end
26
+ end
27
+
28
+ Benchmark.ips do |x|
29
+ x.report("Struct") {
30
+ StructUser.new(
31
+ name: 'Ismael',
32
+ age: 42,
33
+ friends: [
34
+ StructFriend.new(name: 'Joe'),
35
+ StructFriend.new(name: 'Joan'),
36
+ ],
37
+ account: StructAccount.new(id: 123, email: 'my@account.com')
38
+ )
39
+ }
40
+ x.report("Parametric::Struct") {
41
+ ParametricUser.new!(
42
+ name: 'Ismael',
43
+ age: 42,
44
+ friends: [
45
+ { name: 'Joe' },
46
+ { name: 'Joan' }
47
+ ],
48
+ account: { id: 123, email: 'my@account.com' }
49
+ )
50
+ }
51
+ x.compare!
52
+ end
53
+
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "parametric"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parametric
4
+ class BlockValidator
5
+ def self.build(meth, &block)
6
+ klass = Class.new(self)
7
+ klass.public_send(meth, &block)
8
+ klass
9
+ end
10
+
11
+ def self.message(&block)
12
+ @message_block = block if block_given?
13
+ @message_block if instance_variable_defined?('@message_block')
14
+ end
15
+
16
+ def self.validate(&validate_block)
17
+ @validate_block = validate_block if block_given?
18
+ @validate_block if instance_variable_defined?('@validate_block')
19
+ end
20
+
21
+ def self.coerce(&coerce_block)
22
+ @coerce_block = coerce_block if block_given?
23
+ @coerce_block
24
+ end
25
+
26
+ def self.eligible(&block)
27
+ @eligible_block = block if block_given?
28
+ @eligible_block if instance_variable_defined?('@eligible_block')
29
+ end
30
+
31
+ def self.meta_data(&block)
32
+ @meta_data_block = block if block_given?
33
+ @meta_data_block if instance_variable_defined?('@meta_data_block')
34
+ end
35
+
36
+ attr_reader :message
37
+
38
+ def initialize(*args)
39
+ @args = args
40
+ @message = 'is invalid'
41
+ @validate_block = self.class.validate || ->(*args) { true }
42
+ @coerce_block = self.class.coerce || ->(v, *_) { v }
43
+ @eligible_block = self.class.eligible || ->(*args) { true }
44
+ @meta_data_block = self.class.meta_data || ->(*args) { {} }
45
+ end
46
+
47
+ def eligible?(value, key, payload)
48
+ args = (@args + [value, key, payload])
49
+ @eligible_block.call(*args)
50
+ end
51
+
52
+ def coerce(value, key, context)
53
+ @coerce_block.call(value, key, context)
54
+ end
55
+
56
+ def valid?(value, key, payload)
57
+ args = (@args + [value, key, payload])
58
+ @message = self.class.message.call(*args) if self.class.message
59
+ @validate_block.call(*args)
60
+ end
61
+
62
+ def meta_data
63
+ @meta_data_block.call *@args
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parametric
4
+ class Top
5
+ attr_reader :errors
6
+
7
+ def initialize
8
+ @errors = {}
9
+ end
10
+
11
+ def add_error(key, msg)
12
+ errors[key] ||= []
13
+ errors[key] << msg
14
+ end
15
+ end
16
+
17
+ class Context
18
+ def initialize(path = nil, top = Top.new)
19
+ @top = top
20
+ @path = Array(path).compact
21
+ end
22
+
23
+ def errors
24
+ top.errors
25
+ end
26
+
27
+ def add_error(msg)
28
+ top.add_error(string_path, msg)
29
+ end
30
+
31
+ def add_base_error(key, msg)
32
+ top.add_error(key, msg)
33
+ end
34
+
35
+ def sub(key)
36
+ self.class.new(path + [key], top)
37
+ end
38
+
39
+ protected
40
+ attr_reader :path, :top
41
+
42
+ def string_path
43
+ path.reduce(['$']) do |m, segment|
44
+ m << (segment.is_a?(Integer) ? "[#{segment}]" : ".#{segment}")
45
+ m
46
+ end.join
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Parametric
6
+ # type coercions
7
+ Parametric.policy :integer do
8
+ coerce do |v, k, c|
9
+ v.to_i
10
+ end
11
+
12
+ meta_data do
13
+ {type: :integer}
14
+ end
15
+ end
16
+
17
+ Parametric.policy :number do
18
+ coerce do |v, k, c|
19
+ v.to_f
20
+ end
21
+
22
+ meta_data do
23
+ {type: :number}
24
+ end
25
+ end
26
+
27
+ Parametric.policy :string do
28
+ coerce do |v, k, c|
29
+ v.to_s
30
+ end
31
+
32
+ meta_data do
33
+ {type: :string}
34
+ end
35
+ end
36
+
37
+ Parametric.policy :boolean do
38
+ coerce do |v, k, c|
39
+ !!v
40
+ end
41
+
42
+ meta_data do
43
+ {type: :boolean}
44
+ end
45
+ end
46
+
47
+ # type validations
48
+ Parametric.policy :array do
49
+ message do |actual|
50
+ "expects an array, but got #{actual.inspect}"
51
+ end
52
+
53
+ validate do |value, key, payload|
54
+ !payload.key?(key) || value.is_a?(Array)
55
+ end
56
+
57
+ meta_data do
58
+ {type: :array}
59
+ end
60
+ end
61
+
62
+ Parametric.policy :object do
63
+ message do |actual|
64
+ "expects a hash, but got #{actual.inspect}"
65
+ end
66
+
67
+ validate do |value, key, payload|
68
+ !payload.key?(key) ||
69
+ value.respond_to?(:[]) &&
70
+ value.respond_to?(:key?)
71
+ end
72
+
73
+ meta_data do
74
+ {type: :object}
75
+ end
76
+ end
77
+
78
+ Parametric.policy :split do
79
+ coerce do |v, k, c|
80
+ v.kind_of?(Array) ? v : v.to_s.split(/\s*,\s*/)
81
+ end
82
+
83
+ meta_data do
84
+ {type: :array}
85
+ end
86
+ end
87
+
88
+ Parametric.policy :datetime do
89
+ coerce do |v, k, c|
90
+ DateTime.parse(v.to_s)
91
+ end
92
+
93
+ meta_data do
94
+ {type: :datetime}
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parametric"
4
+
5
+ module Parametric
6
+ module DSL
7
+ # Example
8
+ # class Foo
9
+ # include Parametric::DSL
10
+ #
11
+ # schema do
12
+ # field(:title).type(:string).present
13
+ # field(:age).type(:integer).default(20)
14
+ # end
15
+ #
16
+ # attr_reader :params
17
+ #
18
+ # def initialize(input)
19
+ # @params = self.class.schema.resolve(input)
20
+ # end
21
+ # end
22
+ #
23
+ # foo = Foo.new(title: "A title", nope: "hello")
24
+ #
25
+ # foo.params # => {title: "A title", age: 20}
26
+ #
27
+ DEFAULT_SCHEMA_NAME = :schema
28
+
29
+ def self.included(base)
30
+ base.extend(ClassMethods)
31
+ base.schemas = {DEFAULT_SCHEMA_NAME => Parametric::Schema.new}
32
+ end
33
+
34
+ module ClassMethods
35
+ def schema=(sc)
36
+ @schemas[DEFAULT_SCHEMA_NAME] = sc
37
+ end
38
+
39
+ def schemas=(sc)
40
+ @schemas = sc
41
+ end
42
+
43
+ def inherited(subclass)
44
+ subclass.schemas = @schemas.each_with_object({}) do |(key, sc), hash|
45
+ hash[key] = sc.merge(Parametric::Schema.new)
46
+ end
47
+ end
48
+
49
+ def schema(*args, &block)
50
+ options = args.last.is_a?(Hash) ? args.last : {}
51
+ key = args.first.is_a?(Symbol) ? args.first : DEFAULT_SCHEMA_NAME
52
+ current_schema = @schemas.fetch(key) { Parametric::Schema.new }
53
+ new_schema = if block_given? || options.any?
54
+ Parametric::Schema.new(options, &block)
55
+ elsif args.first.respond_to?(:schema)
56
+ args.first
57
+ end
58
+
59
+ return current_schema unless new_schema
60
+
61
+ @schemas[key] = current_schema ? current_schema.merge(new_schema) : new_schema
62
+ parametric_after_define_schema(@schemas[key])
63
+ end
64
+
65
+ def parametric_after_define_schema(sc)
66
+ # noop hook
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parametric/field_dsl"
4
+
5
+ module Parametric
6
+ class ConfigurationError < StandardError; end
7
+
8
+ class Field
9
+ include FieldDSL
10
+
11
+ attr_reader :key, :meta_data
12
+ Result = Struct.new(:eligible?, :value)
13
+
14
+ def initialize(key, registry = Parametric.registry)
15
+ @key = key
16
+ @policies = []
17
+ @registry = registry
18
+ @default_block = nil
19
+ @meta_data = {}
20
+ @policies = []
21
+ end
22
+
23
+ def meta(hash = nil)
24
+ @meta_data = @meta_data.merge(hash) if hash.is_a?(Hash)
25
+ self
26
+ end
27
+
28
+ def default(value)
29
+ meta default: value
30
+ @default_block = (value.respond_to?(:call) ? value : ->(key, payload, context) { value })
31
+ self
32
+ end
33
+
34
+ def policy(key, *args)
35
+ pol = lookup(key, args)
36
+ meta pol.meta_data
37
+ policies << pol
38
+ self
39
+ end
40
+ alias_method :type, :policy
41
+
42
+ def schema(sc = nil, &block)
43
+ sc = (sc ? sc : Schema.new(&block))
44
+ meta schema: sc
45
+ policy sc.schema
46
+ end
47
+
48
+ def visit(meta_key = nil, &visitor)
49
+ if sc = meta_data[:schema]
50
+ r = sc.visit(meta_key, &visitor)
51
+ (meta_data[:type] == :array) ? [r] : r
52
+ else
53
+ meta_key ? meta_data[meta_key] : yield(self)
54
+ end
55
+ end
56
+
57
+ def resolve(payload, context)
58
+ eligible = payload.key?(key)
59
+ value = payload[key] # might be nil
60
+
61
+ if !eligible && has_default?
62
+ eligible = true
63
+ value = default_block.call(key, payload, context)
64
+ return Result.new(eligible, value)
65
+ end
66
+
67
+ policies.each do |policy|
68
+ if !policy.eligible?(value, key, payload)
69
+ eligible = false
70
+ if has_default?
71
+ eligible = true
72
+ value = default_block.call(key, payload, context)
73
+ end
74
+ break
75
+ else
76
+ value = resolve_one(policy, value, context)
77
+ if !policy.valid?(value, key, payload)
78
+ eligible = true # eligible, but has errors
79
+ context.add_error policy.message
80
+ break # only one error at a time
81
+ end
82
+ end
83
+ end
84
+
85
+ Result.new(eligible, value)
86
+ end
87
+
88
+ private
89
+ attr_reader :policies, :registry, :default_block
90
+
91
+ def resolve_one(policy, value, context)
92
+ begin
93
+ policy.coerce(value, key, context)
94
+ rescue StandardError => e
95
+ context.add_error e.message
96
+ value
97
+ end
98
+ end
99
+
100
+ def has_default?
101
+ !!default_block && !meta_data[:skip_default]
102
+ end
103
+
104
+ def lookup(key, args)
105
+ obj = key.is_a?(Symbol) ? registry.policies[key] : key
106
+
107
+ raise ConfigurationError, "No policies defined for #{key.inspect}" unless obj
108
+
109
+ obj.respond_to?(:new) ? obj.new(*args) : obj
110
+ end
111
+ end
112
+ end
113
+
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parametric
4
+ # Field DSL
5
+ # host instance must implement:
6
+ # #meta(options Hash)
7
+ # #policy(key Symbol) self
8
+ #
9
+ module FieldDSL
10
+ def required
11
+ policy :required
12
+ end
13
+
14
+ def present
15
+ required.policy :present
16
+ end
17
+
18
+ def declared
19
+ policy :declared
20
+ end
21
+
22
+ def options(opts)
23
+ policy :options, opts
24
+ end
25
+ end
26
+ end
@@ -1,62 +1,135 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Parametric
2
4
  module Policies
5
+ class Format
6
+ attr_reader :message
3
7
 
4
- class Policy
5
- def initialize(value, options, decorated = nil)
6
- @value, @options = value, options
7
- @decorated = decorated
8
+ def initialize(fmt, msg = "invalid format")
9
+ @message = msg
10
+ @fmt = fmt
8
11
  end
9
12
 
10
- def wrap(decoratedClass)
11
- decoratedClass.new(@value, @options, self)
13
+ def eligible?(value, key, payload)
14
+ payload.key?(key)
12
15
  end
13
16
 
14
- def value
15
- Array(@value)
17
+ def coerce(value, key, context)
18
+ value
16
19
  end
17
20
 
18
- protected
19
- attr_reader :decorated, :options
20
- end
21
+ def valid?(value, key, payload)
22
+ !payload.key?(key) || !!(value.to_s =~ @fmt)
23
+ end
21
24
 
22
- class DefaultPolicy < Policy
23
- def value
24
- v = decorated.value
25
- v.any? ? v : Array(options[:default])
25
+ def meta_data
26
+ {}
26
27
  end
27
28
  end
29
+ end
28
30
 
29
- class MultiplePolicy < Policy
30
- OPTION_SEPARATOR = /\s*,\s*/.freeze
31
+ # Default validators
32
+ EMAIL_REGEXP = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i.freeze
31
33
 
32
- def value
33
- v = decorated.value.first
34
- v = v.split(options.fetch(:separator, OPTION_SEPARATOR)) if v.is_a?(String)
35
- Array(v)
36
- end
34
+ Parametric.policy :format, Policies::Format
35
+ Parametric.policy :email, Policies::Format.new(EMAIL_REGEXP, 'invalid email')
36
+
37
+ Parametric.policy :noop do
38
+ eligible do |value, key, payload|
39
+ true
37
40
  end
41
+ end
38
42
 
39
- class SinglePolicy < Policy
40
- def value
41
- decorated.value.first
42
- end
43
+ Parametric.policy :declared do
44
+ eligible do |value, key, payload|
45
+ payload.key? key
43
46
  end
47
+ end
44
48
 
45
- class OptionsPolicy < Policy
46
- def value
47
- decorated.value.each_with_object([]){|a,arr|
48
- arr << a if options[:options].include?(a)
49
- }
50
- end
49
+ Parametric.policy :declared_no_default do
50
+ eligible do |value, key, payload|
51
+ payload.key? key
52
+ end
53
+
54
+ meta_data do
55
+ {skip_default: true}
56
+ end
57
+ end
58
+
59
+ Parametric.policy :required do
60
+ message do |*|
61
+ "is required"
62
+ end
63
+
64
+ validate do |value, key, payload|
65
+ payload.key? key
66
+ end
67
+
68
+ meta_data do
69
+ {required: true}
70
+ end
71
+ end
72
+
73
+ Parametric.policy :present do
74
+ message do |*|
75
+ "is required and value must be present"
51
76
  end
52
77
 
53
- class MatchPolicy < Policy
54
- def value
55
- decorated.value.each_with_object([]){|a,arr|
56
- arr << a if a.to_s =~ options[:match]
57
- }
78
+ validate do |value, key, payload|
79
+ case value
80
+ when String
81
+ value.strip != ''
82
+ when Array, Hash
83
+ value.any?
84
+ else
85
+ !value.nil?
58
86
  end
59
87
  end
60
88
 
89
+ meta_data do
90
+ {present: true}
91
+ end
92
+ end
93
+
94
+ Parametric.policy :gt do
95
+ message do |num, actual|
96
+ "must be greater than #{num}, but got #{actual}"
97
+ end
98
+
99
+ validate do |num, actual, key, payload|
100
+ !payload[key] || actual.to_i > num.to_i
101
+ end
102
+ end
103
+
104
+ Parametric.policy :lt do
105
+ message do |num, actual|
106
+ "must be less than #{num}, but got #{actual}"
107
+ end
108
+
109
+ validate do |num, actual, key, payload|
110
+ !payload[key] || actual.to_i < num.to_i
111
+ end
112
+ end
113
+
114
+ Parametric.policy :options do
115
+ message do |options, actual|
116
+ "must be one of #{options.join(', ')}, but got #{actual}"
117
+ end
118
+
119
+ eligible do |options, actual, key, payload|
120
+ payload.key?(key)
121
+ end
122
+
123
+ validate do |options, actual, key, payload|
124
+ !payload.key?(key) || ok?(options, actual)
125
+ end
126
+
127
+ meta_data do |opts|
128
+ {options: opts}
129
+ end
130
+
131
+ def ok?(options, actual)
132
+ [actual].flatten.all?{|v| options.include?(v)}
133
+ end
61
134
  end
62
- end
135
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parametric/block_validator'
4
+
5
+ module Parametric
6
+ class Registry
7
+ attr_reader :policies
8
+
9
+ def initialize
10
+ @policies = {}
11
+ end
12
+
13
+ def coercions
14
+ policies
15
+ end
16
+
17
+ def policy(name, plcy = nil, &block)
18
+ policies[name] = (plcy || BlockValidator.build(:instance_eval, &block))
19
+ self
20
+ end
21
+ end
22
+ end
23
+
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parametric
4
+ class Results
5
+ attr_reader :output, :errors
6
+
7
+ def initialize(output, errors)
8
+ @output, @errors = output, errors
9
+ end
10
+
11
+ def valid?
12
+ !errors.keys.any?
13
+ end
14
+ end
15
+ end