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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +74 -0
- data/LICENSE +21 -0
- data/NOTICE.md +28 -0
- data/README.md +459 -0
- data/docs/ARCHITECTURE.md +256 -0
- data/docs/COMPATIBILITY.md +198 -0
- data/docs/FEASIBILITY.md +207 -0
- data/docs/SUPPORT_MATRIX.md +66 -0
- data/docs/VERIFICATION.md +128 -0
- data/dry-validation-rust.gemspec +58 -0
- data/ext/dry_validation_rust/Cargo.lock +809 -0
- data/ext/dry_validation_rust/Cargo.toml +44 -0
- data/ext/dry_validation_rust/benches/coercion.rs +77 -0
- data/ext/dry_validation_rust/benches/full_schema.rs +189 -0
- data/ext/dry_validation_rust/benches/plan_compile.rs +37 -0
- data/ext/dry_validation_rust/benches/predicates.rs +105 -0
- data/ext/dry_validation_rust/extconf.rb +29 -0
- data/ext/dry_validation_rust/src/coercion.rs +515 -0
- data/ext/dry_validation_rust/src/engine.rs +416 -0
- data/ext/dry_validation_rust/src/error.rs +82 -0
- data/ext/dry_validation_rust/src/extract_primitive.rs +23 -0
- data/ext/dry_validation_rust/src/generated_predicates.rs +33 -0
- data/ext/dry_validation_rust/src/lib.rs +228 -0
- data/ext/dry_validation_rust/src/plan.rs +611 -0
- data/ext/dry_validation_rust/src/predicates.rs +449 -0
- data/ext/dry_validation_rust/src/ruby_bridge.rs +78 -0
- data/lib/dry/schema.rb +6 -0
- data/lib/dry/validation/rust/block_keyword_parameters.rb +20 -0
- data/lib/dry/validation/rust/config.rb +74 -0
- data/lib/dry/validation/rust/contract/result.rb +180 -0
- data/lib/dry/validation/rust/contract/values.rb +73 -0
- data/lib/dry/validation/rust/contract.rb +400 -0
- data/lib/dry/validation/rust/errors.rb +14 -0
- data/lib/dry/validation/rust/evaluator.rb +295 -0
- data/lib/dry/validation/rust/failures.rb +57 -0
- data/lib/dry/validation/rust/generated_predicates.rb +14 -0
- data/lib/dry/validation/rust/macros.rb +45 -0
- data/lib/dry/validation/rust/message.rb +41 -0
- data/lib/dry/validation/rust/message_backend.rb +115 -0
- data/lib/dry/validation/rust/message_set.rb +159 -0
- data/lib/dry/validation/rust/native.rb +25 -0
- data/lib/dry/validation/rust/path.rb +65 -0
- data/lib/dry/validation/rust/path_trie.rb +57 -0
- data/lib/dry/validation/rust/result.rb +3 -0
- data/lib/dry/validation/rust/rule.rb +62 -0
- data/lib/dry/validation/rust/schema/dsl.rb +76 -0
- data/lib/dry/validation/rust/schema/field_builder.rb +156 -0
- data/lib/dry/validation/rust/schema/field_definition.rb +99 -0
- data/lib/dry/validation/rust/schema/predicate_block.rb +56 -0
- data/lib/dry/validation/rust/schema/processor_hooks.rb +46 -0
- data/lib/dry/validation/rust/schema/result.rb +67 -0
- data/lib/dry/validation/rust/schema/ruby_type_processor.rb +44 -0
- data/lib/dry/validation/rust/schema.rb +323 -0
- data/lib/dry/validation/rust/values.rb +3 -0
- data/lib/dry/validation/rust/version.rb +10 -0
- data/lib/dry/validation/rust.rb +55 -0
- data/lib/dry/validation.rb +66 -0
- data/lib/dry-schema.rb +3 -0
- data/lib/dry-validation.rb +3 -0
- data/lib/dry_validation_rust.rb +3 -0
- data/predicates.yml +67 -0
- data/rust-toolchain.toml +9 -0
- metadata +260 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class Schema
|
|
7
|
+
# @api private
|
|
8
|
+
class PredicateBlock
|
|
9
|
+
ARITY_MAP = {
|
|
10
|
+
gt: 1, gteq: 1, lt: 1, lteq: 1, min_size: 1, max_size: 1, size: 1,
|
|
11
|
+
format: 1, included_in: 1, excluded_from: 1, eql: 1, not_eql: 1,
|
|
12
|
+
odd: 0, even: 0
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(definition)
|
|
16
|
+
@definition = definition
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
20
|
+
if name.to_s.end_with?('?') && block.nil?
|
|
21
|
+
validate_arity(name, args, kwargs)
|
|
22
|
+
argument = if kwargs.empty?
|
|
23
|
+
args.length <= 1 ? args.first : args
|
|
24
|
+
else
|
|
25
|
+
kwargs
|
|
26
|
+
end
|
|
27
|
+
@definition.add_predicate(name, argument: argument.nil? || argument)
|
|
28
|
+
return self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
raise UnsupportedFeatureError,
|
|
32
|
+
"unsupported predicate composition expression: #{name.inspect}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def respond_to_missing?(name, include_private = false)
|
|
36
|
+
name.to_s.end_with?('?') || super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def validate_arity(name, args, kwargs)
|
|
42
|
+
normalized_name = name.to_s.delete_suffix('?').to_sym
|
|
43
|
+
expected = ARITY_MAP[normalized_name]
|
|
44
|
+
return unless expected
|
|
45
|
+
|
|
46
|
+
argument_count = args.length + (kwargs.empty? ? 0 : 1)
|
|
47
|
+
return if argument_count == expected
|
|
48
|
+
|
|
49
|
+
article = expected == 1 ? 'exactly one argument' : 'no arguments'
|
|
50
|
+
raise ArgumentError, "#{name} expects #{article}, got #{argument_count}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class Schema
|
|
7
|
+
# @api private
|
|
8
|
+
class ProcessorHooks
|
|
9
|
+
STAGES = %i[value_coercer].freeze
|
|
10
|
+
|
|
11
|
+
def self.deep_dup(value)
|
|
12
|
+
case value
|
|
13
|
+
when Hash
|
|
14
|
+
value.each_with_object({}) do |(key, item), copy|
|
|
15
|
+
copy[deep_dup(key)] = deep_dup(item)
|
|
16
|
+
end
|
|
17
|
+
when Array
|
|
18
|
+
value.map { |item| deep_dup(item) }
|
|
19
|
+
else
|
|
20
|
+
value.dup
|
|
21
|
+
end
|
|
22
|
+
rescue TypeError
|
|
23
|
+
value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.register(hooks, name, block)
|
|
27
|
+
unless STAGES.include?(name)
|
|
28
|
+
raise ArgumentError, "Undefined step name #{name.inspect}. Available names: #{STAGES.inspect}"
|
|
29
|
+
end
|
|
30
|
+
raise ArgumentError, 'processor hooks require a block' unless block
|
|
31
|
+
|
|
32
|
+
hooks << block
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.apply(hooks, data)
|
|
36
|
+
hooks.each do |hook|
|
|
37
|
+
replacement = hook.call(data)
|
|
38
|
+
data = replacement if replacement.is_a?(Hash)
|
|
39
|
+
end
|
|
40
|
+
data
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class Schema
|
|
7
|
+
Result = Data.define(:output, :messages, :error_prefixes) do
|
|
8
|
+
class << self
|
|
9
|
+
alias_method :build, :new
|
|
10
|
+
private :build
|
|
11
|
+
|
|
12
|
+
# Creates a structural validation result and caches its schema-error path index.
|
|
13
|
+
#
|
|
14
|
+
# @param output [Hash] coerced schema output
|
|
15
|
+
# @param messages [Array<Message>] schema validation failures
|
|
16
|
+
# @return [Result] immutable schema validation result
|
|
17
|
+
def new(output = nil, messages = nil, **kwargs)
|
|
18
|
+
unknown = kwargs.keys - %i[output messages]
|
|
19
|
+
unless unknown.empty?
|
|
20
|
+
raise ArgumentError,
|
|
21
|
+
"unknown keyword#{'s' if unknown.length > 1}: #{unknown.map(&:inspect).join(', ')}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
output = kwargs.fetch(:output) if kwargs.key?(:output)
|
|
25
|
+
messages = kwargs.fetch(:messages) if kwargs.key?(:messages)
|
|
26
|
+
prefixes = PathTrie.new
|
|
27
|
+
messages.each { |message| prefixes.add(message.path) }
|
|
28
|
+
|
|
29
|
+
build(output, messages, prefixes.freeze)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the immutable schema-error path index.
|
|
34
|
+
#
|
|
35
|
+
# @return [PathTrie] cached schema-error path index
|
|
36
|
+
|
|
37
|
+
alias_method :to_h, :output
|
|
38
|
+
|
|
39
|
+
def success?
|
|
40
|
+
messages.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def failure?
|
|
44
|
+
!success?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def errors(options = {})
|
|
48
|
+
MessageSet.new(messages, options).with(options)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def error?(spec)
|
|
52
|
+
path = Path.parse(spec)
|
|
53
|
+
messages.any? { |message| Path.prefix?(message.path, path) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def [](key)
|
|
57
|
+
output[key]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def key?(key)
|
|
61
|
+
output.key?(key)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class Schema
|
|
7
|
+
# @api private
|
|
8
|
+
class RubyTypeProcessor
|
|
9
|
+
def self.apply(definitions, data, messages, message_backend)
|
|
10
|
+
error_paths = messages.to_set(&:path)
|
|
11
|
+
apply_at(definitions, data, [], messages, error_paths, message_backend)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.apply_at(definitions, data, prefix, messages, error_paths, message_backend)
|
|
15
|
+
return unless data.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
definitions.each do |field|
|
|
18
|
+
next unless data.key?(field.name)
|
|
19
|
+
|
|
20
|
+
path = [*prefix, field.name]
|
|
21
|
+
if field.ruby_type && !error_paths.include?(path)
|
|
22
|
+
result = field.ruby_type.try(data[field.name])
|
|
23
|
+
data[field.name] = result.input
|
|
24
|
+
unless result.success?
|
|
25
|
+
messages << Message.new(
|
|
26
|
+
text: message_backend.message(
|
|
27
|
+
code: :type, predicate: nil, args: [], type: field.type, fallback: 'is invalid'
|
|
28
|
+
),
|
|
29
|
+
path: path, code: :type, source: :schema
|
|
30
|
+
)
|
|
31
|
+
error_paths << path
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
apply_at(field.children, data[field.name], path, messages, error_paths, message_backend)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private_class_method :apply_at
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'date'
|
|
5
|
+
require 'time'
|
|
6
|
+
require 'bigdecimal'
|
|
7
|
+
require_relative 'generated_predicates'
|
|
8
|
+
|
|
9
|
+
module Dry
|
|
10
|
+
module Validation
|
|
11
|
+
module Rust
|
|
12
|
+
# A compiled schema that validates and coerces input hashes.
|
|
13
|
+
#
|
|
14
|
+
# @example Define and call a schema
|
|
15
|
+
# schema = Dry::Validation::Rust::Schema.Params do
|
|
16
|
+
# required(:age).value(:integer)
|
|
17
|
+
# end
|
|
18
|
+
# result = schema.call("age" => "25")
|
|
19
|
+
# result.to_h # => { age: 25 }
|
|
20
|
+
class Schema
|
|
21
|
+
# Type symbols supported by schema fields.
|
|
22
|
+
#
|
|
23
|
+
# @return [Array<Symbol>]
|
|
24
|
+
TYPES = %i[
|
|
25
|
+
any nil bool true false integer float decimal string symbol array hash
|
|
26
|
+
date date_time datetime time
|
|
27
|
+
].freeze
|
|
28
|
+
# @api private
|
|
29
|
+
Predicate = Data.define(:name, :argument) do
|
|
30
|
+
def initialize(name:, argument: true)
|
|
31
|
+
super(name: name.to_s.delete_suffix('?').to_sym, argument: argument)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
require_relative 'schema/result'
|
|
37
|
+
require_relative 'schema/processor_hooks'
|
|
38
|
+
require_relative 'schema/field_definition'
|
|
39
|
+
require_relative 'schema/predicate_block'
|
|
40
|
+
require_relative 'schema/field_builder'
|
|
41
|
+
require_relative 'schema/ruby_type_processor'
|
|
42
|
+
require_relative 'schema/dsl'
|
|
43
|
+
|
|
44
|
+
class Schema
|
|
45
|
+
# @return [Symbol] the schema input mode.
|
|
46
|
+
attr_reader :mode
|
|
47
|
+
|
|
48
|
+
# @api private
|
|
49
|
+
# @return [Array<FieldDefinition>] the compiled top-level field definitions.
|
|
50
|
+
attr_reader :fields
|
|
51
|
+
|
|
52
|
+
# @api private
|
|
53
|
+
# @return [Native::Engine] the native engine that executes this schema.
|
|
54
|
+
attr_reader :engine
|
|
55
|
+
|
|
56
|
+
# Builds a schema from a DSL block and optional schemas to import.
|
|
57
|
+
#
|
|
58
|
+
# @param mode [Symbol] the input mode, such as `:schema`, `:params`, or `:json`.
|
|
59
|
+
# @param external_schemas [Array<Schema>] compiled schemas whose fields are imported.
|
|
60
|
+
# @yield the schema DSL block.
|
|
61
|
+
# @return [Schema] the compiled schema.
|
|
62
|
+
def self.define(mode = :schema, *external_schemas, &block)
|
|
63
|
+
dsl = DSL.new(mode: mode)
|
|
64
|
+
external_schemas.each { |schema| dsl.import(schema) }
|
|
65
|
+
dsl.instance_eval(&block) if block
|
|
66
|
+
dsl.compile
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Builds a schema that coerces web request parameter input.
|
|
70
|
+
#
|
|
71
|
+
# @param external_schemas [Array<Schema>] compiled schemas whose fields are imported.
|
|
72
|
+
# @yield the schema DSL block.
|
|
73
|
+
# @return [Schema] the compiled params-mode schema.
|
|
74
|
+
def self.Params(*external_schemas, &) = define(:params, *external_schemas, &)
|
|
75
|
+
|
|
76
|
+
# Builds a schema that coerces JSON-compatible input.
|
|
77
|
+
#
|
|
78
|
+
# @param external_schemas [Array<Schema>] compiled schemas whose fields are imported.
|
|
79
|
+
# @yield the schema DSL block.
|
|
80
|
+
# @return [Schema] the compiled JSON-mode schema.
|
|
81
|
+
def self.JSON(*external_schemas, &) = define(:json, *external_schemas, &)
|
|
82
|
+
|
|
83
|
+
# Compiles field definitions into a native schema plan.
|
|
84
|
+
#
|
|
85
|
+
# @api private
|
|
86
|
+
#
|
|
87
|
+
# @param mode [Symbol] the input mode.
|
|
88
|
+
# @param fields [Array<FieldDefinition>] field definitions to compile.
|
|
89
|
+
# @param before_hooks [Array<#call>] processors run before native validation.
|
|
90
|
+
# @param after_hooks [Array<#call>] processors run after native validation.
|
|
91
|
+
# @param validate_keys [Boolean] whether unknown keys are validation errors.
|
|
92
|
+
# @param messages [MessageConfig] validation message configuration.
|
|
93
|
+
# @raise [NativeExtensionError] if the native schema plan cannot be compiled.
|
|
94
|
+
def initialize(mode:, fields:, before_hooks: [], after_hooks: [], validate_keys: false,
|
|
95
|
+
messages: MessageConfig.new)
|
|
96
|
+
@mode = mode.to_sym
|
|
97
|
+
@fields = fields.freeze
|
|
98
|
+
@fields_by_name = fields.to_h { |field| [field.name, field] }.freeze
|
|
99
|
+
@has_ruby_predicates = ruby_predicates?(fields)
|
|
100
|
+
@before_hooks, @after_hooks = [before_hooks, after_hooks].map { _1.dup.freeze }
|
|
101
|
+
@message_backend = messages.backend_class.new(messages)
|
|
102
|
+
begin
|
|
103
|
+
plan = {
|
|
104
|
+
engine_version: ENGINE_VERSION,
|
|
105
|
+
mode: mode.to_s,
|
|
106
|
+
validate_keys: validate_keys,
|
|
107
|
+
fields: fields.map(&:to_native_h)
|
|
108
|
+
}
|
|
109
|
+
@engine = Native::Engine.new(JSON.generate(plan, max_nesting: false))
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
raise NativeExtensionError, "could not compile native schema plan: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Validates and coerces a Hash.
|
|
116
|
+
#
|
|
117
|
+
# @param input [Hash] input to validate.
|
|
118
|
+
# @return [Result] the output and validation messages.
|
|
119
|
+
# @raise [ArgumentError] if +input+ is not a Hash.
|
|
120
|
+
def call(input)
|
|
121
|
+
raise ArgumentError, "Input must be a Hash. #{input.class} was given." unless input.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
# Before hooks receive an isolated copy and may safely mutate nested values.
|
|
124
|
+
prepared_input = before_hooks.empty? ? input.dup : ProcessorHooks.deep_dup(input)
|
|
125
|
+
prepared_input = ProcessorHooks.apply(before_hooks, prepared_input)
|
|
126
|
+
result = engine.call(prepared_input)
|
|
127
|
+
output = ProcessorHooks.apply(after_hooks, result.output)
|
|
128
|
+
messages = result.errors.map do |error|
|
|
129
|
+
path = error[:path]
|
|
130
|
+
code = error[:code]
|
|
131
|
+
text = error[:text]
|
|
132
|
+
predicate, args = native_predicate_details(path, code)
|
|
133
|
+
native_message(path, code, text, predicate, args)
|
|
134
|
+
end
|
|
135
|
+
RubyTypeProcessor.apply(fields, output, messages, @message_backend)
|
|
136
|
+
apply_ruby_predicates(fields, output, [], messages) if @has_ruby_predicates
|
|
137
|
+
Result.new(output, messages.freeze)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Validates and coerces a Hash.
|
|
141
|
+
#
|
|
142
|
+
# Alias for {#call}.
|
|
143
|
+
#
|
|
144
|
+
# @param input [Hash] input to validate.
|
|
145
|
+
# @return [Result] the output and validation messages.
|
|
146
|
+
# @raise [ArgumentError] if +input+ is not a Hash.
|
|
147
|
+
def [](input)
|
|
148
|
+
call(input)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns all declared field paths, including nested array paths.
|
|
152
|
+
#
|
|
153
|
+
# @api private
|
|
154
|
+
#
|
|
155
|
+
# @return [Array<Array<Symbol, Integer>>] declared field paths. Array members
|
|
156
|
+
# use +:__index__+ as an index placeholder.
|
|
157
|
+
def key_paths
|
|
158
|
+
paths_for(fields)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Returns a diagnostic representation of this compiled schema.
|
|
162
|
+
#
|
|
163
|
+
# @return [String] the schema mode, field names, and native-engine marker.
|
|
164
|
+
def inspect
|
|
165
|
+
"#<#{self.class} mode=#{mode.inspect} fields=#{fields.map(&:name).inspect} native=true>"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
attr_reader :before_hooks, :after_hooks
|
|
171
|
+
|
|
172
|
+
# @api private
|
|
173
|
+
def native_message(path, code, text, predicate, args)
|
|
174
|
+
Message.new(
|
|
175
|
+
text: native_error_message(code, text, predicate, args, path),
|
|
176
|
+
path: path, code: code, source: :schema, predicate: predicate, args: args
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @api private
|
|
181
|
+
def native_error_message(code, native_text, predicate, args, path)
|
|
182
|
+
field = field_at_path(path)
|
|
183
|
+
@message_backend.message(
|
|
184
|
+
code: code, predicate: predicate&.to_s&.delete_suffix('?'), args: args,
|
|
185
|
+
type: field&.normalized_type, fallback: native_text
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @api private
|
|
190
|
+
def paths_for(definitions, prefix = [])
|
|
191
|
+
definitions.flat_map do |field|
|
|
192
|
+
current = [*prefix, field.name]
|
|
193
|
+
nested = paths_for(field.children, current)
|
|
194
|
+
member_nested = field.member ? paths_for(field.member.children, [*current, :__index__]) : []
|
|
195
|
+
[current, *nested, *member_nested]
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @api private
|
|
200
|
+
def ruby_predicates?(definitions)
|
|
201
|
+
definitions.any? do |field|
|
|
202
|
+
field.predicates.any? { |predicate| !NATIVE_PREDICATES.include?(predicate.name) } ||
|
|
203
|
+
ruby_predicates?(field.children) ||
|
|
204
|
+
(field.member && ruby_predicates?([field.member]))
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# @api private
|
|
209
|
+
def apply_ruby_predicates(definitions, data, prefix, messages)
|
|
210
|
+
error_paths = messages.to_set(&:path)
|
|
211
|
+
apply_ruby_predicates_at(definitions, data, prefix, messages, error_paths)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# @api private
|
|
215
|
+
def apply_ruby_predicates_at(definitions, data, prefix, messages, error_paths)
|
|
216
|
+
stack = [[:definitions, definitions, data, prefix]]
|
|
217
|
+
|
|
218
|
+
until stack.empty?
|
|
219
|
+
kind, *arguments = stack.pop
|
|
220
|
+
case kind
|
|
221
|
+
when :definitions
|
|
222
|
+
current_definitions, current_data, current_prefix = arguments
|
|
223
|
+
next unless current_data.is_a?(Hash)
|
|
224
|
+
|
|
225
|
+
current_definitions.reverse_each do |field|
|
|
226
|
+
stack << [:field, field, current_data, current_prefix]
|
|
227
|
+
end
|
|
228
|
+
when :field
|
|
229
|
+
field, current_data, current_prefix = arguments
|
|
230
|
+
next unless current_data.key?(field.name)
|
|
231
|
+
|
|
232
|
+
path = [*current_prefix, field.name]
|
|
233
|
+
value = current_data[field.name]
|
|
234
|
+
apply_ruby_predicates_to(field, value, path, messages, error_paths)
|
|
235
|
+
|
|
236
|
+
if value.is_a?(Hash)
|
|
237
|
+
stack << [:definitions, field.children, value, path]
|
|
238
|
+
elsif value.is_a?(Array) && field.member
|
|
239
|
+
value.each_index.reverse_each do |index|
|
|
240
|
+
stack << [:member, field.member, value[index], [*path, index]]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
when :member
|
|
244
|
+
member, value, path = arguments
|
|
245
|
+
apply_ruby_predicates_to(member, value, path, messages, error_paths)
|
|
246
|
+
stack << [:definitions, member.children, value, path]
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @api private
|
|
252
|
+
def apply_ruby_predicates_to(field, value, path, messages, error_paths)
|
|
253
|
+
return if error_paths.include?(path)
|
|
254
|
+
|
|
255
|
+
field.predicates.each do |predicate|
|
|
256
|
+
next if NATIVE_PREDICATES.include?(predicate.name)
|
|
257
|
+
|
|
258
|
+
unless predicate_valid?(predicate, value)
|
|
259
|
+
messages << predicate_message(predicate, path)
|
|
260
|
+
error_paths << path
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# @api private
|
|
266
|
+
def predicate_valid?(predicate, value)
|
|
267
|
+
case predicate.name
|
|
268
|
+
when :format then value.respond_to?(:match?) && predicate.argument.match?(value)
|
|
269
|
+
when :included_in then predicate.argument.include?(value)
|
|
270
|
+
when :excluded_from then !predicate.argument.include?(value)
|
|
271
|
+
when :eql then value.eql?(predicate.argument)
|
|
272
|
+
when :not_eql then !value.eql?(predicate.argument)
|
|
273
|
+
else
|
|
274
|
+
raise UnsupportedFeatureError,
|
|
275
|
+
"predicate #{predicate.name.inspect} is not supported natively; move it to a contract rule"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# @api private
|
|
280
|
+
def predicate_message(predicate, path)
|
|
281
|
+
text = case predicate.name
|
|
282
|
+
when :format then 'is in invalid format'
|
|
283
|
+
when :included_in then "must be one of: #{Array(predicate.argument).join(', ')}"
|
|
284
|
+
when :excluded_from then "must not be one of: #{Array(predicate.argument).join(', ')}"
|
|
285
|
+
when :eql then "must be equal to #{predicate.argument}"
|
|
286
|
+
when :not_eql then "must not be equal to #{predicate.argument}"
|
|
287
|
+
else 'is invalid'
|
|
288
|
+
end
|
|
289
|
+
text = @message_backend.message(
|
|
290
|
+
code: predicate.name, predicate: predicate.name, args: [predicate.argument], type: nil, fallback: text
|
|
291
|
+
)
|
|
292
|
+
Message.new(
|
|
293
|
+
text: text, path: path, code: predicate.name, source: :schema,
|
|
294
|
+
predicate: "#{predicate.name}?", args: [predicate.argument]
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def native_predicate_details(path, code)
|
|
299
|
+
field = field_at_path(path)
|
|
300
|
+
predicate = field&.predicates&.find { |candidate| candidate.name == code.to_sym }
|
|
301
|
+
predicate ? [:"#{predicate.name}?", [predicate.argument]] : [nil, []]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def field_at_path(path)
|
|
305
|
+
definition = nil
|
|
306
|
+
|
|
307
|
+
path.each do |part|
|
|
308
|
+
if part.is_a?(Integer)
|
|
309
|
+
return nil unless definition&.member
|
|
310
|
+
|
|
311
|
+
definition = definition.member
|
|
312
|
+
else
|
|
313
|
+
definition = definition ? definition.child_at(part) : @fields_by_name[part.to_sym]
|
|
314
|
+
return nil unless definition
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
definition
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'rust/version'
|
|
4
|
+
require_relative 'rust/errors'
|
|
5
|
+
require_relative 'rust/native'
|
|
6
|
+
require_relative 'rust/path'
|
|
7
|
+
require_relative 'rust/path_trie'
|
|
8
|
+
require_relative 'rust/message'
|
|
9
|
+
require_relative 'rust/message_set'
|
|
10
|
+
require_relative 'rust/contract/values'
|
|
11
|
+
require_relative 'rust/macros'
|
|
12
|
+
require_relative 'rust/failures'
|
|
13
|
+
require_relative 'rust/message_backend'
|
|
14
|
+
require_relative 'rust/config'
|
|
15
|
+
require_relative 'rust/schema'
|
|
16
|
+
require_relative 'rust/rule'
|
|
17
|
+
require_relative 'rust/evaluator'
|
|
18
|
+
require_relative 'rust/contract/result'
|
|
19
|
+
require_relative 'rust/contract'
|
|
20
|
+
|
|
21
|
+
module Dry
|
|
22
|
+
module Validation
|
|
23
|
+
module Rust
|
|
24
|
+
class << self
|
|
25
|
+
# @api private
|
|
26
|
+
def global_macros
|
|
27
|
+
@global_macros ||= MacroRegistry.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def register_macro(name, *, &)
|
|
31
|
+
global_macros.register(name, *, &)
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def Contract(options = {}, &)
|
|
36
|
+
Contract.build(options, &)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def load_extensions(*names)
|
|
40
|
+
names.each do |name|
|
|
41
|
+
next if name.to_sym == :predicates_as_macros
|
|
42
|
+
|
|
43
|
+
raise UnsupportedFeatureError,
|
|
44
|
+
"extension #{name.inspect} is not implemented in the experimental Rust compatibility layer"
|
|
45
|
+
end
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
register_macro(:acceptance) do
|
|
51
|
+
key.failure(:acceptance) unless value.equal?(true)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'validation/rust'
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
if const_defined?(:Schema, false) &&
|
|
7
|
+
!const_get(:Schema, false).const_defined?(:RUST_COMPATIBILITY_LAYER, false)
|
|
8
|
+
raise LoadError, <<~MESSAGE
|
|
9
|
+
dry-schema and dry-validation-rust cannot both own Dry::Schema in exact compatibility mode.
|
|
10
|
+
Remove the upstream dry-schema/dry-validation gems, or use the side-by-side
|
|
11
|
+
dry/validation/rust namespace.
|
|
12
|
+
MESSAGE
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
unless const_defined?(:Schema, false)
|
|
16
|
+
module Schema
|
|
17
|
+
RUST_COMPATIBILITY_LAYER = true
|
|
18
|
+
VERSION = Validation::Rust::VERSION
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def Params(*external_schemas, &)
|
|
22
|
+
Validation::Rust::Schema.Params(*external_schemas, &)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def JSON(*external_schemas, &)
|
|
26
|
+
Validation::Rust::Schema.JSON(*external_schemas, &)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def define(*external_schemas, &)
|
|
30
|
+
Validation::Rust::Schema.define(:schema, *external_schemas, &)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module Validation
|
|
37
|
+
if const_defined?(:Contract, false) && const_get(:Contract, false) != Rust::Contract
|
|
38
|
+
raise LoadError, <<~MESSAGE
|
|
39
|
+
dry-validation and dry-validation-rust cannot both own Dry::Validation::Contract.
|
|
40
|
+
Remove the upstream dry-validation gem when using exact compatibility mode, or
|
|
41
|
+
require "dry/validation/rust" and inherit from Dry::Validation::Rust::Contract.
|
|
42
|
+
MESSAGE
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Contract = Rust::Contract unless const_defined?(:Contract, false)
|
|
46
|
+
Result = Rust::Contract::Result unless const_defined?(:Result, false)
|
|
47
|
+
Message = Rust::Message unless const_defined?(:Message, false)
|
|
48
|
+
MessageSet = Rust::MessageSet unless const_defined?(:MessageSet, false)
|
|
49
|
+
Evaluator = Rust::Evaluator unless const_defined?(:Evaluator, false)
|
|
50
|
+
VERSION = Rust::VERSION unless const_defined?(:VERSION, false)
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
def Contract(options = {}, &)
|
|
54
|
+
Rust.Contract(options, &)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def register_macro(name, *, &)
|
|
58
|
+
Rust.register_macro(name, *, &)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load_extensions(*names)
|
|
62
|
+
Rust.load_extensions(*names)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/dry-schema.rb
ADDED