dry-schema 0.1.0

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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE +20 -0
  4. data/README.md +21 -0
  5. data/config/errors.yml +91 -0
  6. data/lib/dry-schema.rb +1 -0
  7. data/lib/dry/schema.rb +51 -0
  8. data/lib/dry/schema/compiler.rb +31 -0
  9. data/lib/dry/schema/config.rb +52 -0
  10. data/lib/dry/schema/constants.rb +13 -0
  11. data/lib/dry/schema/dsl.rb +382 -0
  12. data/lib/dry/schema/extensions.rb +3 -0
  13. data/lib/dry/schema/extensions/monads.rb +18 -0
  14. data/lib/dry/schema/json.rb +16 -0
  15. data/lib/dry/schema/key.rb +166 -0
  16. data/lib/dry/schema/key_coercer.rb +37 -0
  17. data/lib/dry/schema/key_map.rb +133 -0
  18. data/lib/dry/schema/macros.rb +6 -0
  19. data/lib/dry/schema/macros/core.rb +51 -0
  20. data/lib/dry/schema/macros/dsl.rb +74 -0
  21. data/lib/dry/schema/macros/each.rb +18 -0
  22. data/lib/dry/schema/macros/filled.rb +24 -0
  23. data/lib/dry/schema/macros/hash.rb +46 -0
  24. data/lib/dry/schema/macros/key.rb +137 -0
  25. data/lib/dry/schema/macros/maybe.rb +37 -0
  26. data/lib/dry/schema/macros/optional.rb +17 -0
  27. data/lib/dry/schema/macros/required.rb +17 -0
  28. data/lib/dry/schema/macros/value.rb +41 -0
  29. data/lib/dry/schema/message.rb +103 -0
  30. data/lib/dry/schema/message_compiler.rb +193 -0
  31. data/lib/dry/schema/message_compiler/visitor_opts.rb +30 -0
  32. data/lib/dry/schema/message_set.rb +123 -0
  33. data/lib/dry/schema/messages.rb +42 -0
  34. data/lib/dry/schema/messages/abstract.rb +143 -0
  35. data/lib/dry/schema/messages/i18n.rb +60 -0
  36. data/lib/dry/schema/messages/namespaced.rb +53 -0
  37. data/lib/dry/schema/messages/yaml.rb +82 -0
  38. data/lib/dry/schema/params.rb +16 -0
  39. data/lib/dry/schema/predicate.rb +80 -0
  40. data/lib/dry/schema/predicate_inferrer.rb +49 -0
  41. data/lib/dry/schema/predicate_registry.rb +38 -0
  42. data/lib/dry/schema/processor.rb +151 -0
  43. data/lib/dry/schema/result.rb +164 -0
  44. data/lib/dry/schema/rule_applier.rb +45 -0
  45. data/lib/dry/schema/trace.rb +103 -0
  46. data/lib/dry/schema/type_registry.rb +42 -0
  47. data/lib/dry/schema/types.rb +12 -0
  48. data/lib/dry/schema/value_coercer.rb +27 -0
  49. data/lib/dry/schema/version.rb +5 -0
  50. metadata +255 -0
@@ -0,0 +1,60 @@
1
+ require 'i18n'
2
+ require 'dry/schema/messages/abstract'
3
+
4
+ module Dry
5
+ module Schema
6
+ # I18n message backend
7
+ #
8
+ # @api public
9
+ class Messages::I18n < Messages::Abstract
10
+ attr_reader :t
11
+
12
+ ::I18n.load_path.concat(config.paths)
13
+
14
+ # @api private
15
+ def initialize
16
+ super
17
+ @t = I18n.method(:t)
18
+ end
19
+
20
+ # Get a message for the given key and its options
21
+ #
22
+ # @param [Symbol] key
23
+ # @param [Hash] options
24
+ #
25
+ # @return [String]
26
+ #
27
+ # @api public
28
+ def get(key, options = {})
29
+ t.(key, options) if key
30
+ end
31
+
32
+ # Check if given key is defined
33
+ #
34
+ # @return [Boolean]
35
+ #
36
+ # @api public
37
+ def key?(key, options)
38
+ ::I18n.exists?(key, options.fetch(:locale, default_locale)) ||
39
+ ::I18n.exists?(key, I18n.default_locale)
40
+ end
41
+
42
+ # Merge messages from an additional path
43
+ #
44
+ # @param [String] path
45
+ #
46
+ # @return [Messages::I18n]
47
+ #
48
+ # @api public
49
+ def merge(path)
50
+ ::I18n.load_path << path
51
+ self
52
+ end
53
+
54
+ # @api private
55
+ def default_locale
56
+ I18n.locale || I18n.default_locale || super
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,53 @@
1
+ module Dry
2
+ module Schema
3
+ module Messages
4
+ # Namespaced messages backend
5
+ #
6
+ # @api public
7
+ class Namespaced <Dry::Schema::Messages::Abstract
8
+ # @api private
9
+ attr_reader :namespace
10
+
11
+ # @api private
12
+ attr_reader :messages
13
+
14
+ # @api private
15
+ attr_reader :root
16
+
17
+ # @api private
18
+ def initialize(namespace, messages)
19
+ super()
20
+ @namespace = namespace
21
+ @messages = messages
22
+ @root = messages.root
23
+ end
24
+
25
+ # Get a message for the given key and its options
26
+ #
27
+ # @param [Symbol] key
28
+ # @param [Hash] options
29
+ #
30
+ # @return [String]
31
+ #
32
+ # @api public
33
+ def get(key, options = {})
34
+ messages.get(key, options)
35
+ end
36
+
37
+ # Check if given key is defined
38
+ #
39
+ # @return [Boolean]
40
+ #
41
+ # @api public
42
+ def key?(key, *args)
43
+ messages.key?(key, *args)
44
+ end
45
+
46
+ # @api private
47
+ def lookup_paths(tokens)
48
+ super(tokens.merge(root: "#{root}.rules.#{namespace}")) + super
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,82 @@
1
+ require 'yaml'
2
+ require 'pathname'
3
+
4
+ require 'dry/equalizer'
5
+ require 'dry/schema/messages/abstract'
6
+
7
+ module Dry
8
+ module Schema
9
+ # Plain YAML message backend
10
+ #
11
+ # @api public
12
+ class Messages::YAML < Messages::Abstract
13
+ include Dry::Equalizer(:data)
14
+
15
+ attr_reader :data
16
+
17
+ # @api private
18
+ configure do |config|
19
+ config.root = '%{locale}.errors'.freeze
20
+ end
21
+
22
+ # @api private
23
+ def self.load(paths = config.paths)
24
+ new(paths.map { |path| load_file(path) }.reduce(:merge))
25
+ end
26
+
27
+ # @api private
28
+ def self.load_file(path)
29
+ flat_hash(YAML.load_file(path))
30
+ end
31
+
32
+ # @api private
33
+ def self.flat_hash(h, f = [], g = {})
34
+ return g.update(f.join('.'.freeze) => h) unless h.is_a? Hash
35
+ h.each { |k, r| flat_hash(r, f + [k], g) }
36
+ g
37
+ end
38
+
39
+ # @api private
40
+ def initialize(data)
41
+ super()
42
+ @data = data
43
+ end
44
+
45
+ # Get a message for the given key and its options
46
+ #
47
+ # @param [Symbol] key
48
+ # @param [Hash] options
49
+ #
50
+ # @return [String]
51
+ #
52
+ # @api public
53
+ def get(key, options = {})
54
+ data[key % { locale: options.fetch(:locale, default_locale) }]
55
+ end
56
+
57
+ # Check if given key is defined
58
+ #
59
+ # @return [Boolean]
60
+ #
61
+ # @api public
62
+ def key?(key, options = {})
63
+ data.key?(key % { locale: options.fetch(:locale, default_locale) })
64
+ end
65
+
66
+ # Merge messages from an additional path
67
+ #
68
+ # @param [String] path
69
+ #
70
+ # @return [Messages::I18n]
71
+ #
72
+ # @api public
73
+ def merge(overrides)
74
+ if overrides.is_a?(Hash)
75
+ self.class.new(data.merge(self.class.flat_hash(overrides)))
76
+ else
77
+ self.class.new(data.merge(Messages::YAML.load_file(overrides)))
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,16 @@
1
+ require 'dry/schema/processor'
2
+
3
+ module Dry
4
+ module Schema
5
+ # Params schema type
6
+ #
7
+ # @see Processor
8
+ # @see Schema.Params
9
+ #
10
+ # @api public
11
+ class Params < Processor
12
+ config.key_map_type = :stringified
13
+ config.type_registry = config.type_registry.namespaced(:params)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,80 @@
1
+ require 'dry/equalizer'
2
+ require 'dry/logic/operators'
3
+
4
+ module Dry
5
+ module Schema
6
+ # Predicate objects used within the DSL
7
+ #
8
+ # @api public
9
+ class Predicate
10
+ # A negated predicate
11
+ #
12
+ # @api private
13
+ class Negation
14
+ # @api private
15
+ attr_reader :predicate
16
+
17
+ # @api private
18
+ def initialize(predicate)
19
+ @predicate = predicate
20
+ end
21
+
22
+ # @api private
23
+ def to_ast(*args)
24
+ [:not, predicate.to_ast(*args)]
25
+ end
26
+ alias_method :ast, :to_ast
27
+ end
28
+
29
+ include Dry::Logic::Operators
30
+ include Dry::Equalizer(:name, :args, :block)
31
+
32
+ # @api private
33
+ attr_reader :compiler
34
+
35
+ # @api private
36
+ attr_reader :name
37
+
38
+ # @api private
39
+ attr_reader :args
40
+
41
+ # @api private
42
+ attr_reader :block
43
+
44
+ # @api private
45
+ def initialize(compiler, name, args, block)
46
+ @compiler = compiler
47
+ @name = name
48
+ @args = args
49
+ @block = block
50
+ end
51
+
52
+ # Negate a predicate
53
+ #
54
+ # @return [Negation]
55
+ #
56
+ # @api public
57
+ def !
58
+ Negation.new(self)
59
+ end
60
+
61
+ # @api private
62
+ def ensure_valid
63
+ if compiler.predicates[name].arity - 1 != args.size
64
+ raise ArgumentError, "#{name} predicate arity is invalid"
65
+ end
66
+ end
67
+
68
+ # @api private
69
+ def to_rule
70
+ compiler.visit(to_ast)
71
+ end
72
+
73
+ # @api private
74
+ def to_ast(*)
75
+ [:predicate, [name, compiler.predicates.arg_list(name, *args)]]
76
+ end
77
+ alias_method :ast, :to_ast
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,49 @@
1
+ require 'dry/core/cache'
2
+
3
+ module Dry
4
+ module Schema
5
+ # PredicateInferrer is used internally by `Macros::Value`
6
+ # for inferring type-check predicates from type specs.
7
+ #
8
+ # @api private
9
+ class PredicateInferrer
10
+ extend Dry::Core::Cache
11
+
12
+ TYPE_TO_PREDICATE = Hash.new do |hash, type|
13
+ primitive = type.meta[:maybe] ? type.right.primitive : type.primitive
14
+
15
+ if hash.key?(primitive)
16
+ hash[primitive]
17
+ else
18
+ :"#{primitive.name.split('::').last.downcase}?"
19
+ end
20
+ end
21
+
22
+ TYPE_TO_PREDICATE.update(
23
+ FalseClass => :false?,
24
+ Integer => :int?,
25
+ NilClass => :nil?,
26
+ String => :str?,
27
+ TrueClass => :true?
28
+ ).freeze
29
+
30
+ # Infer predicate identifier from the provided type
31
+ #
32
+ # @return [Symbol]
33
+ #
34
+ # @api private
35
+ def self.[](type)
36
+ fetch_or_store(type.hash) {
37
+ predicates =
38
+ if type.is_a?(Dry::Types::Sum) && !type.meta[:maybe]
39
+ [self[type.left], self[type.right]]
40
+ else
41
+ TYPE_TO_PREDICATE[type]
42
+ end
43
+
44
+ Array(predicates).flatten
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,38 @@
1
+ require 'dry/logic/predicates'
2
+
3
+ module Dry
4
+ module Schema
5
+ # A registry with predicate objects from `Dry::Logic::Predicates`
6
+ #
7
+ # @api private
8
+ class PredicateRegistry
9
+ # @api private
10
+ attr_reader :predicates
11
+
12
+ # @api private
13
+ def initialize(predicates = Dry::Logic::Predicates)
14
+ @predicates = predicates
15
+ end
16
+
17
+ # @api private
18
+ def [](name)
19
+ predicates[name]
20
+ end
21
+
22
+ # @api private
23
+ def key?(name)
24
+ predicates.respond_to?(name)
25
+ end
26
+
27
+ # @api private
28
+ def arg_list(name, *values)
29
+ predicate = self[name]
30
+
31
+ predicate
32
+ .parameters
33
+ .map(&:last)
34
+ .zip(values + Array.new(predicate.arity - values.size, Undefined))
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,151 @@
1
+ require 'dry/configurable'
2
+ require 'dry/initializer'
3
+
4
+ require 'dry/schema/type_registry'
5
+ require 'dry/schema/rule_applier'
6
+ require 'dry/schema/key_coercer'
7
+ require 'dry/schema/value_coercer'
8
+
9
+ module Dry
10
+ module Schema
11
+ # Processes input data using objects configured within the DSL
12
+ #
13
+ # Processing is split into 4 main steps:
14
+ #
15
+ # 1. Prepare input hash using a key map
16
+ # 2. Apply pre-coercion filtering rules (optional step, used only when `filter` was used)
17
+ # 3. Apply value coercions based on type specifications
18
+ # 4. Apply rules
19
+ #
20
+ # @see Params
21
+ # @see JSON
22
+ #
23
+ # @api public
24
+ class Processor
25
+ extend Dry::Initializer
26
+ extend Dry::Configurable
27
+
28
+ setting :key_map_type
29
+ setting :type_registry, TypeRegistry.new
30
+
31
+ param :steps, default: -> { EMPTY_ARRAY.dup }
32
+
33
+ # Define a schema for your processor class
34
+ #
35
+ # @see Params
36
+ # @see JSON
37
+ #
38
+ # @return [Class]
39
+ #
40
+ # @api public
41
+ def self.define(&block)
42
+ @__definition__ ||= DSL.new(
43
+ processor_type: self, parent: superclass.definition, **config, &block
44
+ )
45
+ self
46
+ end
47
+
48
+ # Return DSL configured via #define
49
+ #
50
+ # @return [DSL]
51
+ #
52
+ # @api private
53
+ def self.definition
54
+ @__definition__
55
+ end
56
+
57
+ # Build a new processor object
58
+ #
59
+ # @return [Processor]
60
+ #
61
+ # @api public
62
+ def self.new(&block)
63
+ if block
64
+ super.tap(&block)
65
+ elsif definition
66
+ definition.call
67
+ else
68
+ raise ArgumentError, 'Cannot create a schema without a definition'
69
+ end
70
+ end
71
+
72
+ # Append a step
73
+ #
74
+ # @return [Processor]
75
+ #
76
+ # @api private
77
+ def <<(step)
78
+ steps << step
79
+ self
80
+ end
81
+
82
+ # Apply processing steps to the provided input
83
+ #
84
+ # @param [Hash] input
85
+ #
86
+ # @return [Result]
87
+ #
88
+ # @api public
89
+ def call(input)
90
+ Result.new(input, message_compiler: message_compiler) do |result|
91
+ steps.each do |step|
92
+ output = step.(result)
93
+ result.replace(output) if output.is_a?(::Hash)
94
+ end
95
+ end
96
+ end
97
+
98
+ # Return the key map
99
+ #
100
+ # @return [KeyMap]
101
+ #
102
+ # @api public
103
+ def key_map
104
+ @__key_map__ ||= steps.detect { |s| s.is_a?(KeyCoercer) }.key_map
105
+ end
106
+
107
+ # Return the type schema
108
+ #
109
+ # @return [Dry::Types::Safe]
110
+ #
111
+ # @api private
112
+ def type_schema
113
+ @__type_schema__ ||= steps.detect { |s| s.is_a?(ValueCoercer) }.type_schema
114
+ end
115
+
116
+ # Return AST representation of the rules
117
+ #
118
+ # @api private
119
+ def to_ast
120
+ rule_applier.to_ast
121
+ end
122
+
123
+ # Return the message compiler
124
+ #
125
+ # @return [MessageCompiler]
126
+ #
127
+ # @api private
128
+ def message_compiler
129
+ rule_applier.message_compiler
130
+ end
131
+
132
+ # Return the rules from rule applier
133
+ #
134
+ # @return [MessageCompiler]
135
+ #
136
+ # @api private
137
+ def rules
138
+ rule_applier.rules
139
+ end
140
+
141
+ # Return the rule applier
142
+ #
143
+ # @api private
144
+ def rule_applier
145
+ # TODO: make this more explicit through class types
146
+ @__rule_applier__ ||= steps.last
147
+ end
148
+ alias_method :to_rule, :rule_applier
149
+ end
150
+ end
151
+ end