dry-validation-rust 0.1.0.pre5

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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +74 -0
  3. data/LICENSE +21 -0
  4. data/NOTICE.md +28 -0
  5. data/README.md +459 -0
  6. data/docs/ARCHITECTURE.md +256 -0
  7. data/docs/COMPATIBILITY.md +198 -0
  8. data/docs/FEASIBILITY.md +207 -0
  9. data/docs/SUPPORT_MATRIX.md +66 -0
  10. data/docs/VERIFICATION.md +128 -0
  11. data/dry-validation-rust.gemspec +58 -0
  12. data/ext/dry_validation_rust/Cargo.lock +809 -0
  13. data/ext/dry_validation_rust/Cargo.toml +44 -0
  14. data/ext/dry_validation_rust/benches/coercion.rs +77 -0
  15. data/ext/dry_validation_rust/benches/full_schema.rs +189 -0
  16. data/ext/dry_validation_rust/benches/plan_compile.rs +37 -0
  17. data/ext/dry_validation_rust/benches/predicates.rs +105 -0
  18. data/ext/dry_validation_rust/extconf.rb +29 -0
  19. data/ext/dry_validation_rust/src/coercion.rs +515 -0
  20. data/ext/dry_validation_rust/src/engine.rs +416 -0
  21. data/ext/dry_validation_rust/src/error.rs +82 -0
  22. data/ext/dry_validation_rust/src/extract_primitive.rs +23 -0
  23. data/ext/dry_validation_rust/src/generated_predicates.rs +33 -0
  24. data/ext/dry_validation_rust/src/lib.rs +228 -0
  25. data/ext/dry_validation_rust/src/plan.rs +611 -0
  26. data/ext/dry_validation_rust/src/predicates.rs +449 -0
  27. data/ext/dry_validation_rust/src/ruby_bridge.rs +78 -0
  28. data/lib/dry/schema.rb +6 -0
  29. data/lib/dry/validation/rust/block_keyword_parameters.rb +20 -0
  30. data/lib/dry/validation/rust/config.rb +74 -0
  31. data/lib/dry/validation/rust/contract/result.rb +180 -0
  32. data/lib/dry/validation/rust/contract/values.rb +73 -0
  33. data/lib/dry/validation/rust/contract.rb +400 -0
  34. data/lib/dry/validation/rust/errors.rb +14 -0
  35. data/lib/dry/validation/rust/evaluator.rb +295 -0
  36. data/lib/dry/validation/rust/failures.rb +57 -0
  37. data/lib/dry/validation/rust/generated_predicates.rb +14 -0
  38. data/lib/dry/validation/rust/macros.rb +45 -0
  39. data/lib/dry/validation/rust/message.rb +41 -0
  40. data/lib/dry/validation/rust/message_backend.rb +115 -0
  41. data/lib/dry/validation/rust/message_set.rb +159 -0
  42. data/lib/dry/validation/rust/native.rb +25 -0
  43. data/lib/dry/validation/rust/path.rb +65 -0
  44. data/lib/dry/validation/rust/path_trie.rb +57 -0
  45. data/lib/dry/validation/rust/result.rb +3 -0
  46. data/lib/dry/validation/rust/rule.rb +62 -0
  47. data/lib/dry/validation/rust/schema/dsl.rb +76 -0
  48. data/lib/dry/validation/rust/schema/field_builder.rb +156 -0
  49. data/lib/dry/validation/rust/schema/field_definition.rb +99 -0
  50. data/lib/dry/validation/rust/schema/predicate_block.rb +56 -0
  51. data/lib/dry/validation/rust/schema/processor_hooks.rb +46 -0
  52. data/lib/dry/validation/rust/schema/result.rb +67 -0
  53. data/lib/dry/validation/rust/schema/ruby_type_processor.rb +44 -0
  54. data/lib/dry/validation/rust/schema.rb +323 -0
  55. data/lib/dry/validation/rust/values.rb +3 -0
  56. data/lib/dry/validation/rust/version.rb +10 -0
  57. data/lib/dry/validation/rust.rb +55 -0
  58. data/lib/dry/validation.rb +66 -0
  59. data/lib/dry-schema.rb +3 -0
  60. data/lib/dry-validation.rb +3 -0
  61. data/lib/dry_validation_rust.rb +3 -0
  62. data/predicates.yml +67 -0
  63. data/rust-toolchain.toml +9 -0
  64. metadata +260 -0
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Validation
5
+ module Rust
6
+ # The evaluation context for contract rules.
7
+ #
8
+ # Inside a {Contract.rule rule} block, `self` is an Evaluator instance.
9
+ # It exposes the validated values, the rule path, error collectors, and
10
+ # the mutable per-call context.
11
+ #
12
+ # @example Add a failure for the rule value
13
+ # rule(:age) { key.failure("must be at least 18") if value < 18 }
14
+ class Evaluator
15
+ # @api private
16
+ #
17
+ # Returns the contract executing the rule.
18
+ #
19
+ # @return [Contract]
20
+ # @example Read a contract option from a rule
21
+ # rule(:name) { key.failure("is reserved") if contract.reserved?(value) }
22
+ attr_reader :contract
23
+ # @api private
24
+ #
25
+ # Returns the result receiving rule failures.
26
+ #
27
+ # @return [Contract::Result]
28
+ # @example Inspect a schema error before adding a rule failure
29
+ # rule(:email) { key.failure("is unavailable") unless result.schema_error?(:email) }
30
+ attr_reader :result
31
+ # @api private
32
+ #
33
+ # Returns the paths declared for the rule.
34
+ #
35
+ # @return [Array<Array<Symbol, Integer>>]
36
+ # @example Use the current rule path
37
+ # rule(:email) { paths # => [[:email]] }
38
+ attr_reader :paths
39
+ # Returns all validated output available to the rule.
40
+ #
41
+ # @return [Contract::Values]
42
+ # @example Compare two validated values
43
+ # rule(:password_confirmation) { key.failure("does not match") if value != values[:password] }
44
+ attr_reader :values
45
+ # @api private
46
+ #
47
+ # Returns the mutable context for the current contract call.
48
+ #
49
+ # @return [Hash]
50
+ # @example Read context supplied to {Contract#call}
51
+ # rule(:role) { key.failure("is not allowed") unless context[:roles].include?(value) }
52
+ attr_reader :context
53
+ # Returns the collection index for a `rule.each` evaluation.
54
+ #
55
+ # @return [Integer, nil]
56
+ # @example Add an error for the second collection member
57
+ # rule(:tags).each { key.failure("is reserved") if index == 1 && value == "admin" }
58
+ attr_reader :index
59
+ # @api private
60
+ #
61
+ # Returns failures collected while executing the rule.
62
+ #
63
+ # @return [Array<Message>]
64
+ # @example Read failures after contract execution
65
+ # evaluator.failures # => [#<Dry::Validation::Rust::Message ...>]
66
+ attr_reader :failures
67
+
68
+ # Creates a rule evaluation context. This is primarily used by Contract.
69
+ #
70
+ # @api private
71
+ #
72
+ # @param contract [Contract] contract executing the rule
73
+ # @param result [Contract::Result] result receiving rule failures
74
+ # @param paths [Array<Array<Symbol, Integer>>] paths declared for the rule
75
+ # @param context [Hash] mutable context for the current contract call
76
+ # @param default_path [Array<Symbol, Integer>] path used by {#key} when omitted
77
+ # @param index [Integer, nil] current `rule.each` collection index
78
+ # @return [Evaluator]
79
+ # @example Contract creates an evaluator while executing a rule
80
+ # contract.call(age: 17)
81
+ def initialize(contract:, result:, paths:, context:, default_path: paths.first || [], index: nil)
82
+ @contract = contract
83
+ @result = result
84
+ @paths = paths
85
+ @default_path = default_path
86
+ @values = result.values
87
+ @context = context
88
+ @index = index
89
+ @failures = []
90
+ @key_failures = {}
91
+ @base_failures = Failures.new
92
+ end
93
+
94
+ # Executes a rule block and macro calls, then collects their failures.
95
+ #
96
+ # @api private
97
+ #
98
+ # @param block [Proc, nil] rule block to execute
99
+ # @param macro_calls [Array<Array>] macro calls declared for the rule
100
+ # @param keyword_params [Array<Symbol>] evaluator values injected into the block
101
+ # @return [Evaluator] this evaluator
102
+ # @example Execute a rule with its declared macro calls
103
+ # evaluator.execute(rule.block, rule.macro_calls)
104
+ def execute(block, macro_calls, keyword_params: [])
105
+ execute_block(block, keyword_params) if block
106
+ macro_calls.each { |call| execute_macro(call) }
107
+ collect_failures
108
+ self
109
+ end
110
+
111
+ # Returns the failure collector for a path, defaulting to the rule path.
112
+ #
113
+ # @param path [Symbol, String, Array, Hash] key or supported path specification
114
+ # @return [Failures] collector that adds rule failures at the path
115
+ # @example Add an error at the rule's default path
116
+ # rule(:email) { key.failure("is already taken") }
117
+ # @example Add an error at a different path
118
+ # rule(:password) { key(:password_confirmation).failure("does not match") }
119
+ def key(path = default_path)
120
+ normalized = Path.parse(path)
121
+ @key_failures[normalized] ||= Failures.new(normalized)
122
+ end
123
+
124
+ # Returns the failure collector for base-level messages.
125
+ #
126
+ # @return [Failures] collector that adds base-level rule failures
127
+ # @example Add a contract-wide error
128
+ # rule { base.failure("cannot be approved") }
129
+ def base
130
+ @base_failures
131
+ end
132
+
133
+ # @api private
134
+ #
135
+ # Returns the default rule path as a key or nested path.
136
+ #
137
+ # @return [Symbol, Array<Symbol, Integer>]
138
+ # @example Read a single-key rule name
139
+ # rule(:email) { key_name # => :email }
140
+ def key_name
141
+ default_path.length == 1 ? default_path.first : default_path
142
+ end
143
+
144
+ # Returns the value at the rule's primary path, or nil when absent.
145
+ #
146
+ # @return [Object, nil]
147
+ # @example Reject a value in a rule
148
+ # rule(:age) { key.failure("must be at least 18") if value < 18 }
149
+ def value
150
+ raw = Path.fetch(values.data, value_path)
151
+ raw.equal?(Path::Undefined) ? nil : raw
152
+ end
153
+
154
+ # Returns whether validated output contains a key or path.
155
+ #
156
+ # @param name [Symbol, String, Array, Hash] key or supported path specification
157
+ # @return [Boolean]
158
+ # @example Require a related value
159
+ # rule(:password) { key.failure("needs confirmation") unless key?(:password_confirmation) }
160
+ def key?(name = value_path)
161
+ Path.key?(values.data, name)
162
+ end
163
+
164
+ # Returns whether the schema has an error at a path.
165
+ #
166
+ # @param path [Symbol, String, Array, Hash] key or supported path specification
167
+ # @return [Boolean]
168
+ # @example Skip a dependent check after a schema failure
169
+ # rule(:age) { key.failure("is too young") unless schema_error?(:age) || value >= 18 }
170
+ def schema_error?(path)
171
+ result.schema_error?(path)
172
+ end
173
+
174
+ # @api private
175
+ #
176
+ # Alias for #schema_error?.
177
+ #
178
+ # @example Use the short schema-error predicate
179
+ # rule(:age) { key.failure("is too young") unless error?(:age) || value >= 18 }
180
+ alias error? schema_error?
181
+
182
+ # Returns whether a rule error exists at a path or the default path.
183
+ #
184
+ # @param path [Symbol, String, Array, Hash, nil] path to check, or the default rule path
185
+ # @return [Boolean]
186
+ # @example Avoid adding a duplicate rule error
187
+ # rule(:email) { key.failure("is blocked") unless rule_error? }
188
+ def rule_error?(path = nil)
189
+ if path
190
+ result.rule_error?(path)
191
+ else
192
+ !key(default_path).empty?
193
+ end
194
+ end
195
+
196
+ # Returns whether a base-level rule error exists.
197
+ #
198
+ # @return [Boolean]
199
+ # @example Check whether this rule added a base failure
200
+ # rule { base.failure("cannot continue") unless base_rule_error? }
201
+ def base_rule_error?
202
+ !base.empty? || result.base_rule_error?
203
+ end
204
+
205
+ # @api private
206
+ #
207
+ # Returns the mutable context supplied to the contract call.
208
+ #
209
+ # @return [Hash]
210
+ # @example Access call context through the compatibility helper
211
+ # rule(:role) { _context[:actor] # => current actor }
212
+ def _context
213
+ context
214
+ end
215
+
216
+ # @api private
217
+ #
218
+ # Reports contract methods delegated through this evaluator.
219
+ #
220
+ # @param name [Symbol] method name to check
221
+ # @param include_private [Boolean] whether private methods are included
222
+ # @return [Boolean]
223
+ # @example Check a contract helper exposed in a rule
224
+ # evaluator.respond_to?(:reserved?) # => true
225
+ def respond_to_missing?(name, include_private = false)
226
+ contract.respond_to?(name, true) || super
227
+ end
228
+
229
+ private
230
+
231
+ private :initialize, :contract, :result, :paths, :context, :failures, :execute,
232
+ :key_name, :error?, :_context, :respond_to_missing?
233
+
234
+ # @api private
235
+ attr_reader :default_path
236
+
237
+ # @api private
238
+ def value_path
239
+ paths.first || []
240
+ end
241
+
242
+ # @api private
243
+ def execute_block(block, keyword_params, macro: nil)
244
+ keyword_values = { context: context, index: index, macro: macro }
245
+ kwargs = keyword_values.slice(*keyword_params)
246
+ kwargs.empty? ? instance_exec(&block) : instance_exec(**kwargs, &block)
247
+ end
248
+
249
+ # @api private
250
+ def execute_macro(call)
251
+ name, *args = call
252
+ if contract.macro_registered?(name)
253
+ macro = contract.resolve_macro(name).with(args)
254
+ execute_block(macro.block, macro.keyword_params, macro: macro)
255
+ else
256
+ execute_predicate_macro(name, args)
257
+ end
258
+ end
259
+
260
+ # @api private
261
+ def execute_predicate_macro(name, args)
262
+ normalized = name.to_s.delete_suffix('?').to_sym
263
+ expected = args.length == 1 ? args.first : args
264
+ valid = case normalized
265
+ when :gt then value > expected
266
+ when :gteq then value >= expected
267
+ when :lt then value < expected
268
+ when :lteq then value <= expected
269
+ when :min_size then value.size >= expected
270
+ when :max_size then value.size <= expected
271
+ when :size then value.size == expected
272
+ when :format then expected.match?(value)
273
+ when :included_in then expected.include?(value)
274
+ else
275
+ raise UnsupportedFeatureError, "unknown rule macro #{name.inspect}"
276
+ end
277
+ key.failure("must satisfy #{name}") unless valid
278
+ end
279
+
280
+ # @api private
281
+ def collect_failures
282
+ failures.concat(base.messages)
283
+ @key_failures.each_value { |set| failures.concat(set.messages) }
284
+ end
285
+
286
+ # @api private
287
+ def method_missing(name, ...)
288
+ return contract.__send__(name, ...) if contract.respond_to?(name, true)
289
+
290
+ super
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Validation
5
+ module Rust
6
+ class Failures
7
+ IDENTIFIER_MESSAGES = {
8
+ acceptance: 'must be accepted',
9
+ invalid: 'is invalid',
10
+ taken: 'is already taken'
11
+ }.freeze
12
+
13
+ attr_reader :path, :messages
14
+
15
+ def initialize(path = [])
16
+ @path = Path.parse(path)
17
+ @messages = []
18
+ end
19
+
20
+ def failure(message, tokens = {})
21
+ text, code, meta = normalize(message, tokens)
22
+ messages << Message.new(text: text, path: path, code: code, meta: meta, source: :rule)
23
+ self
24
+ end
25
+
26
+ def empty?
27
+ messages.empty?
28
+ end
29
+
30
+ private
31
+
32
+ def normalize(message, tokens)
33
+ case message
34
+ when String
35
+ [interpolate(message, tokens), nil, {}]
36
+ when Symbol
37
+ [interpolate(IDENTIFIER_MESSAGES.fetch(message, message.to_s.tr('_', ' ')), tokens), message, {}]
38
+ when Hash
39
+ raw_text = message.fetch(:text)
40
+ text, code, = normalize(raw_text, tokens)
41
+ [text, code, message.except(:text)]
42
+ else
43
+ [message.to_s, nil, {}]
44
+ end
45
+ end
46
+
47
+ def interpolate(text, tokens)
48
+ return text if tokens.empty?
49
+
50
+ text % tokens
51
+ rescue KeyError
52
+ text
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is generated by `bundle exec rake generate:predicates`.
4
+ # Do not edit it directly; update predicates.yml instead.
5
+ module Dry
6
+ module Validation
7
+ module Rust
8
+ class Schema
9
+ NATIVE_PREDICATES = %i[gt gteq lt lteq min_size max_size size odd even].freeze
10
+ RUBY_PREDICATES = %i[format included_in excluded_from eql not_eql].freeze
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'block_keyword_parameters'
4
+
5
+ module Dry
6
+ module Validation
7
+ module Rust
8
+ # @api private
9
+ Macro = Struct.new(:name, :args, :block, :keyword_params, keyword_init: true) do
10
+ def with(call_args)
11
+ self.class.new(name: name, args: args + call_args, block: block, keyword_params: keyword_params)
12
+ end
13
+ end
14
+
15
+ # @api private
16
+ class MacroRegistry
17
+ def initialize(parent = nil)
18
+ @parent = parent
19
+ @entries = {}
20
+ end
21
+
22
+ def register(name, *args, &block)
23
+ raise ArgumentError, 'a macro block is required' unless block
24
+
25
+ keyword_params = BlockKeywordParameters.extract(block)
26
+ @entries[name.to_sym] = Macro.new(
27
+ name: name.to_sym,
28
+ args: args,
29
+ block: block,
30
+ keyword_params: keyword_params
31
+ )
32
+ self
33
+ end
34
+
35
+ def fetch(name)
36
+ @entries.fetch(name.to_sym) { @parent&.fetch(name) || raise(KeyError, "unknown macro: #{name}") }
37
+ end
38
+
39
+ def key?(name)
40
+ @entries.key?(name.to_sym) || @parent&.key?(name)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Validation
5
+ module Rust
6
+ Message = Data.define(:text, :path, :meta, :code, :source, :predicate, :args) do
7
+ def initialize(text:, path:, meta: {}, code: nil, source: :rule, predicate: nil, args: [])
8
+ super(
9
+ text: text.to_s.freeze,
10
+ path: Array(path).freeze,
11
+ meta: meta.freeze,
12
+ code: code&.to_sym,
13
+ source: source,
14
+ predicate: predicate&.to_sym,
15
+ args: args.freeze
16
+ )
17
+ end
18
+
19
+ def base?
20
+ path.compact.empty?
21
+ end
22
+
23
+ def schema?
24
+ source == :schema
25
+ end
26
+
27
+ def rule?
28
+ source == :rule
29
+ end
30
+
31
+ def payload
32
+ meta.empty? ? text : { text: text, **meta }
33
+ end
34
+
35
+ def to_s
36
+ text
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Dry
6
+ module Validation
7
+ module Rust
8
+ # Adapter interface for resolving localized schema error messages.
9
+ #
10
+ # Custom backends must inherit from this class and implement {#message}.
11
+ class MessageBackend
12
+ # Creates a backend using the schema message configuration.
13
+ #
14
+ # @param config [MessageConfig] the configured message settings.
15
+ def initialize(config); end
16
+
17
+ # Resolves text for a schema error.
18
+ #
19
+ # @param code [Symbol] the error code.
20
+ # @param predicate [Symbol, nil] the predicate name, without or with a trailing +?+.
21
+ # @param args [Array] predicate arguments.
22
+ # @param type [Symbol, nil] the normalized field type.
23
+ # @param fallback [String] message used when no translation is available.
24
+ # @return [String] the error message text.
25
+ def message(code:, predicate:, args:, type:, fallback:)
26
+ raise NotImplementedError, "#{self.class} must implement #message"
27
+ end
28
+
29
+ private
30
+
31
+ def tokens_for(args, type)
32
+ argument = args.first
33
+ return range_tokens(argument, type) if argument.is_a?(Range)
34
+
35
+ { num: argument, size: argument, left: argument, list: Array(argument).join(', '), type: type }
36
+ end
37
+
38
+ def range_tokens(argument, type)
39
+ {
40
+ num: argument.begin, size: argument, left: argument.begin, right: argument.end,
41
+ list: "#{argument.begin} to #{argument.end}", type: type
42
+ }
43
+ end
44
+ end
45
+
46
+ # Resolves messages from YAML translation files listed in {MessageConfig#load_paths}.
47
+ class YamlBackend < MessageBackend
48
+ def initialize(config)
49
+ super
50
+ @default_locale = config.default_locale.to_sym
51
+ @top_namespace = config.top_namespace.to_s
52
+ @translations = load_yaml(config.load_paths)
53
+ end
54
+
55
+ def message(code:, predicate:, args:, type:, fallback:)
56
+ key = predicate ? "#{predicate.to_s.delete_suffix('?')}?" : code.to_s
57
+ template = @translations[[@default_locale, *@top_namespace.split('.'), 'errors', key].join('.')]
58
+ template ? interpolate(template, tokens_for(args, type)) : fallback
59
+ end
60
+
61
+ private
62
+
63
+ def load_yaml(paths)
64
+ paths.each_with_object({}) do |path, translations|
65
+ flatten(YAML.safe_load_file(path, aliases: false), [], translations)
66
+ end
67
+ end
68
+
69
+ def flatten(value, path, translations)
70
+ if value.is_a?(Hash)
71
+ value.each { |key, child| flatten(child, [*path, key.to_s], translations) }
72
+ elsif value.is_a?(String)
73
+ translations[path.join('.')] = value
74
+ end
75
+ end
76
+
77
+ def interpolate(template, tokens)
78
+ template % tokens
79
+ rescue KeyError => e
80
+ raise ArgumentError, "message template is missing an interpolation token: #{e.message}"
81
+ end
82
+ end
83
+
84
+ # Resolves messages through the optional i18n gem.
85
+ class I18nBackend < MessageBackend
86
+ def initialize(config)
87
+ super
88
+ @default_locale = config.default_locale.to_sym
89
+ @top_namespace = config.top_namespace.to_s
90
+ load_i18n(config.load_paths)
91
+ end
92
+
93
+ def message(code:, predicate:, args:, type:, fallback:)
94
+ key = predicate ? "#{predicate.to_s.delete_suffix('?')}?" : code.to_s
95
+ translation_key = [*@top_namespace.split('.'), 'errors', key].join('.')
96
+ return fallback unless ::I18n.exists?(translation_key, @default_locale)
97
+
98
+ ::I18n.t(translation_key, locale: @default_locale, **tokens_for(args, type))
99
+ end
100
+
101
+ private
102
+
103
+ def load_i18n(paths)
104
+ require 'i18n'
105
+ paths.each do |path|
106
+ data = YAML.safe_load_file(path, aliases: false)
107
+ data.each { |locale, translations| ::I18n.backend.store_translations(locale, translations) }
108
+ end
109
+ rescue LoadError
110
+ raise LoadError, 'the i18n gem is required for config.messages.backend = :i18n'
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end