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,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
# A collection of validation messages with filtering and rendering views.
|
|
7
|
+
#
|
|
8
|
+
# Message sets preserve individual {Message} objects until a caller asks
|
|
9
|
+
# for a nested error hash with {#to_h}. Use {#with} with `full: true` to
|
|
10
|
+
# render field names into message text.
|
|
11
|
+
#
|
|
12
|
+
# @example Read nested errors from a contract result
|
|
13
|
+
# result.errors.to_h # => { name: ["is missing"] }
|
|
14
|
+
class MessageSet
|
|
15
|
+
include Enumerable
|
|
16
|
+
|
|
17
|
+
# @return [Hash] rendering options applied to this message set.
|
|
18
|
+
attr_reader :options
|
|
19
|
+
|
|
20
|
+
# Creates a message set from messages and optional rendering options.
|
|
21
|
+
#
|
|
22
|
+
# @param messages [Array<Message>] messages to include
|
|
23
|
+
# @param options [Hash] rendering options, such as `full: true`
|
|
24
|
+
# @return [MessageSet]
|
|
25
|
+
def initialize(messages = [], options = {})
|
|
26
|
+
@messages = messages.dup
|
|
27
|
+
@options = options
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Iterates over messages.
|
|
31
|
+
#
|
|
32
|
+
# @yield [message] each message in the set
|
|
33
|
+
# @yieldparam message [Message]
|
|
34
|
+
# @return [Enumerator, MessageSet] an enumerator without a block, or this set with one
|
|
35
|
+
# @example Print each message text
|
|
36
|
+
# result.errors.each { |message| puts message.text }
|
|
37
|
+
def each(&)
|
|
38
|
+
@messages.each(&)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns an immutable snapshot of the messages.
|
|
42
|
+
#
|
|
43
|
+
# @return [Array<Message>] frozen message-object view
|
|
44
|
+
def messages
|
|
45
|
+
readonly_messages
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns messages at or below a key or path.
|
|
49
|
+
#
|
|
50
|
+
# @param key [Symbol, String, Array, Hash] key or supported path specification
|
|
51
|
+
# @return [MessageSet] messages whose paths start with `key`
|
|
52
|
+
# @example Select nested address messages
|
|
53
|
+
# result.errors[:address].to_h # => { city: ["is missing"] }
|
|
54
|
+
def [](key)
|
|
55
|
+
wanted = Path.parse(key)
|
|
56
|
+
self.class.new(@messages.select { |message| Path.prefix?(message.path, wanted) }, options)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Adds a message and invalidates derived views.
|
|
60
|
+
#
|
|
61
|
+
# @param message [Message] message to append
|
|
62
|
+
# @return [MessageSet] this set
|
|
63
|
+
def add(message)
|
|
64
|
+
@messages << message
|
|
65
|
+
@readonly_messages = nil
|
|
66
|
+
@to_h = nil
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns whether this set contains no messages.
|
|
71
|
+
#
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def empty?
|
|
74
|
+
@messages.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns messages satisfying every supplied message predicate.
|
|
78
|
+
#
|
|
79
|
+
# Each predicate must be a public predicate method on a message and
|
|
80
|
+
# return a truthy value. A message without a requested predicate is
|
|
81
|
+
# excluded.
|
|
82
|
+
#
|
|
83
|
+
# @param predicates [Array<Symbol, String>] message predicate names
|
|
84
|
+
# @return [MessageSet] matching messages
|
|
85
|
+
# @example Select base-level messages
|
|
86
|
+
# result.errors.filter(:base?)
|
|
87
|
+
def filter(*predicates)
|
|
88
|
+
self.class.new(
|
|
89
|
+
@messages.select do |message|
|
|
90
|
+
predicates.all? { |predicate| message.respond_to?(predicate) && message.public_send(predicate) }
|
|
91
|
+
end,
|
|
92
|
+
options
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns a copy with rendering options, including full-message text.
|
|
97
|
+
#
|
|
98
|
+
# With `full: true`, non-base message text is prefixed with its
|
|
99
|
+
# humanized path (for example, `:first_name` becomes `"first name"`).
|
|
100
|
+
#
|
|
101
|
+
# @param new_options [Hash] rendering options to merge into {#options}
|
|
102
|
+
# @return [MessageSet] a new set using the merged options
|
|
103
|
+
# @example Render full messages
|
|
104
|
+
# result.errors.with(full: true).messages.map(&:text)
|
|
105
|
+
# # => ["name is missing"]
|
|
106
|
+
def with(new_options = {})
|
|
107
|
+
merged = options.merge(new_options)
|
|
108
|
+
return self.class.new(@messages, merged) unless merged[:full]
|
|
109
|
+
|
|
110
|
+
self.class.new(@messages.map { |message| full_message(message) }, merged)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns messages grouped into a nested Hash by path.
|
|
114
|
+
#
|
|
115
|
+
# @return [Hash] nested error messages keyed by their paths
|
|
116
|
+
# @example Read errors as a hash
|
|
117
|
+
# result.errors.to_h # => { address: { city: ["is missing"] } }
|
|
118
|
+
def to_h
|
|
119
|
+
@to_h ||= build_nested_hash
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Freezes this set and its cached derived Hash.
|
|
123
|
+
#
|
|
124
|
+
# @return [MessageSet] this frozen set
|
|
125
|
+
def freeze
|
|
126
|
+
@messages.freeze
|
|
127
|
+
# Keep the derived representation so callers of a frozen set reuse it.
|
|
128
|
+
to_h.freeze
|
|
129
|
+
super
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def readonly_messages
|
|
135
|
+
@readonly_messages ||= @messages.dup.freeze
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_nested_hash
|
|
139
|
+
@messages.each_with_object({}) do |message, result|
|
|
140
|
+
insert(result, message.path.empty? ? [nil] : message.path, message.payload)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def insert(root, path, payload)
|
|
145
|
+
leaf = path.last
|
|
146
|
+
parent = path[0...-1].reduce(root) { |node, part| node[part] ||= {} }
|
|
147
|
+
(parent[leaf] ||= []) << payload
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def full_message(message)
|
|
151
|
+
return message if message.base?
|
|
152
|
+
|
|
153
|
+
label = message.path.map { |part| part.is_a?(Integer) ? part : part.to_s.tr('_', ' ') }.join(' ')
|
|
154
|
+
message.with(text: "#{label} #{message.text}")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
# @api private
|
|
7
|
+
module Native; end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
begin
|
|
13
|
+
require 'dry_validation_rust/native'
|
|
14
|
+
rescue LoadError => e
|
|
15
|
+
source_extension = File.expand_path('../../../../ext/dry_validation_rust/native', __dir__)
|
|
16
|
+
begin
|
|
17
|
+
require source_extension
|
|
18
|
+
rescue LoadError
|
|
19
|
+
raise LoadError, <<~MESSAGE
|
|
20
|
+
dry-validation-rust could not load its native extension.
|
|
21
|
+
Build it with `bundle exec rake compile` (source checkout) or reinstall the gem.
|
|
22
|
+
Original error: #{e.message}
|
|
23
|
+
MESSAGE
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
# Normalizes the path forms accepted by dry-validation's public rule API.
|
|
7
|
+
# @api private
|
|
8
|
+
module Path
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def parse(spec)
|
|
12
|
+
case spec
|
|
13
|
+
when nil then []
|
|
14
|
+
when Symbol then [spec]
|
|
15
|
+
when String then spec.split('.').map!(&:to_sym)
|
|
16
|
+
when Array then spec.dup
|
|
17
|
+
when Hash
|
|
18
|
+
key, value = spec.first
|
|
19
|
+
[key, *parse(value)]
|
|
20
|
+
else
|
|
21
|
+
raise ArgumentError, '+spec+ must be a Symbol, String, Array, or Hash'
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def expand(spec)
|
|
26
|
+
return [parse(spec)] unless spec.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
spec.flat_map do |key, value|
|
|
29
|
+
if value.is_a?(Array)
|
|
30
|
+
value.map { |child| [key, *parse(child)] }
|
|
31
|
+
else
|
|
32
|
+
[[key, *parse(value)]]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fetch(data, path, undefined = Undefined)
|
|
38
|
+
current = data
|
|
39
|
+
parse(path).each do |key|
|
|
40
|
+
if current.is_a?(Array) && key.is_a?(Integer)
|
|
41
|
+
return undefined unless key >= 0 && key < current.length
|
|
42
|
+
|
|
43
|
+
current = current[key]
|
|
44
|
+
elsif current.respond_to?(:key?) && current.key?(key)
|
|
45
|
+
current = current[key]
|
|
46
|
+
else
|
|
47
|
+
return undefined
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
current
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def key?(data, path)
|
|
54
|
+
!fetch(data, path).equal?(Undefined)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def prefix?(candidate, prefix)
|
|
58
|
+
candidate[0, prefix.length] == prefix
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Undefined = Object.new.freeze
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class PathTrie
|
|
7
|
+
TERMINAL = Object.new.freeze
|
|
8
|
+
private_constant :TERMINAL
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@root = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(path)
|
|
15
|
+
node = @root
|
|
16
|
+
path.each { |part| node = node[part] ||= {} }
|
|
17
|
+
node[TERMINAL] = true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def prefix?(path)
|
|
21
|
+
return false if path.empty?
|
|
22
|
+
|
|
23
|
+
node = @root
|
|
24
|
+
path.each do |part|
|
|
25
|
+
return true if node.key?(TERMINAL)
|
|
26
|
+
|
|
27
|
+
node = node[part]
|
|
28
|
+
return false unless node
|
|
29
|
+
end
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def freeze
|
|
34
|
+
freeze_node(@root)
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ==(other)
|
|
39
|
+
other.is_a?(self.class) && @root == other.instance_variable_get(:@root)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
alias eql? ==
|
|
43
|
+
|
|
44
|
+
def hash
|
|
45
|
+
@root.hash
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def freeze_node(node)
|
|
51
|
+
node.each_value { |child| freeze_node(child) if child.is_a?(Hash) }
|
|
52
|
+
node.freeze
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'block_keyword_parameters'
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
module Validation
|
|
7
|
+
module Rust
|
|
8
|
+
class Rule
|
|
9
|
+
attr_reader :paths, :default_path, :block, :keyword_params, :macro_calls
|
|
10
|
+
|
|
11
|
+
def initialize(paths:, default_path: paths.first || [], block: nil)
|
|
12
|
+
@paths = paths
|
|
13
|
+
@default_path = default_path
|
|
14
|
+
assign_block(block)
|
|
15
|
+
@macro_calls = []
|
|
16
|
+
@each = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate(*macros, &new_block)
|
|
20
|
+
assign_block(new_block) if new_block
|
|
21
|
+
@macro_calls = parse_macros(macros)
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each(*macros, &new_block)
|
|
26
|
+
raise ArgumentError, 'rule.each requires exactly one root key' unless paths.length == 1
|
|
27
|
+
|
|
28
|
+
@each = true
|
|
29
|
+
assign_block(new_block) if new_block
|
|
30
|
+
@macro_calls = parse_macros(macros)
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def each?
|
|
35
|
+
@each
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inspect
|
|
39
|
+
"#<#{self.class} paths=#{paths.inspect} each=#{each?}>"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def assign_block(block)
|
|
45
|
+
@block = block
|
|
46
|
+
@keyword_params = block ? BlockKeywordParameters.extract(block) : BlockKeywordParameters::EMPTY
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_macros(specs)
|
|
50
|
+
specs.flat_map do |spec|
|
|
51
|
+
case spec
|
|
52
|
+
when Hash
|
|
53
|
+
spec.map { |name, args| [name.to_sym, *Array(args)] }
|
|
54
|
+
else
|
|
55
|
+
[[spec.to_sym]]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class Schema
|
|
7
|
+
# @api private
|
|
8
|
+
class DSL
|
|
9
|
+
attr_reader :mode, :fields, :before_hooks, :after_hooks
|
|
10
|
+
|
|
11
|
+
def initialize(mode:, fields: [], before_hooks: [], after_hooks: [])
|
|
12
|
+
@mode = mode.to_sym
|
|
13
|
+
@fields = fields
|
|
14
|
+
@before_hooks = before_hooks
|
|
15
|
+
@after_hooks = after_hooks
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def required(name, &)
|
|
19
|
+
add_field(name, required: true, &)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def optional(name, &)
|
|
23
|
+
add_field(name, required: false, &)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def import(schema)
|
|
27
|
+
unless schema.is_a?(Schema)
|
|
28
|
+
raise UnsupportedFeatureError, 'only schemas built by Dry::Validation::Rust can be imported'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
schema.fields.each do |field|
|
|
32
|
+
if fields.any? { |existing| existing.name == field.name }
|
|
33
|
+
raise ArgumentError, "key #{field.name.inspect} is already defined"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
fields << field.deep_dup
|
|
37
|
+
end
|
|
38
|
+
before_hooks.concat(schema.send(:before_hooks))
|
|
39
|
+
after_hooks.concat(schema.send(:after_hooks))
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def before(name, &block)
|
|
44
|
+
ProcessorHooks.register(before_hooks, name, block)
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def after(name, &block)
|
|
49
|
+
ProcessorHooks.register(after_hooks, name, block)
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def compile(validate_keys: false, messages: MessageConfig.new)
|
|
54
|
+
Schema.new(
|
|
55
|
+
mode: mode, fields: fields, before_hooks: before_hooks, after_hooks: after_hooks,
|
|
56
|
+
validate_keys: validate_keys, messages: messages
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def add_field(name, required:, &block)
|
|
63
|
+
raise ArgumentError, "Key +#{name}+ is not a symbol" unless name.is_a?(Symbol)
|
|
64
|
+
raise ArgumentError, "key #{name.inspect} is already defined" if fields.any? { |field| field.name == name }
|
|
65
|
+
|
|
66
|
+
definition = FieldDefinition.new(name: name, required: required)
|
|
67
|
+
fields << definition
|
|
68
|
+
builder = FieldBuilder.new(definition, mode: mode)
|
|
69
|
+
builder.value(&block) if block
|
|
70
|
+
builder
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class Schema
|
|
7
|
+
# @api private
|
|
8
|
+
class FieldBuilder
|
|
9
|
+
attr_reader :definition, :mode
|
|
10
|
+
|
|
11
|
+
def initialize(definition, mode:)
|
|
12
|
+
@definition = definition
|
|
13
|
+
@mode = mode
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def value(*specs, **predicates, &block)
|
|
17
|
+
apply_specs(specs, predicates)
|
|
18
|
+
if block
|
|
19
|
+
nested_value_block? ? nested_target(block) : PredicateBlock.new(definition).instance_eval(&block)
|
|
20
|
+
end
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def filled(*specs, **predicates, &)
|
|
25
|
+
definition.filled = true
|
|
26
|
+
value(*specs, **predicates, &)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def maybe(*specs, **predicates, &)
|
|
30
|
+
definition.nullable = true
|
|
31
|
+
value(*specs, **predicates, &)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def hash(schema = nil, &block)
|
|
35
|
+
definition.type = :hash
|
|
36
|
+
nested = DSL.new(mode: mode)
|
|
37
|
+
nested.import(schema) if schema
|
|
38
|
+
nested.instance_eval(&block) if block
|
|
39
|
+
definition.children = nested.fields
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def array(member_type = nil, **predicates, &block)
|
|
44
|
+
definition.type = :array
|
|
45
|
+
predicates.each { |name, argument| definition.add_predicate(name, argument: argument) }
|
|
46
|
+
|
|
47
|
+
if member_type
|
|
48
|
+
member = FieldDefinition.new(name: nil, required: true)
|
|
49
|
+
if member_type.is_a?(Schema)
|
|
50
|
+
member.type = :hash
|
|
51
|
+
member.children = member_type.fields.map(&:deep_dup)
|
|
52
|
+
elsif custom_type?(member_type)
|
|
53
|
+
raise UnsupportedFeatureError,
|
|
54
|
+
'custom dry-types array members are not supported by the Ruby fallback yet'
|
|
55
|
+
else
|
|
56
|
+
assign_type(member, member_type)
|
|
57
|
+
end
|
|
58
|
+
definition.member = member
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if block
|
|
62
|
+
definition.member ||= FieldDefinition.new(name: nil, required: true)
|
|
63
|
+
definition.member.type = :hash
|
|
64
|
+
nested = DSL.new(mode: mode)
|
|
65
|
+
nested.instance_eval(&block)
|
|
66
|
+
definition.member.children = nested.fields
|
|
67
|
+
end
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def each(member_type = nil, **predicates, &)
|
|
72
|
+
array(member_type, **predicates, &)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
76
|
+
if name.to_s.end_with?('?') && block.nil?
|
|
77
|
+
argument = if kwargs.empty?
|
|
78
|
+
args.length <= 1 ? args.first : args
|
|
79
|
+
else
|
|
80
|
+
kwargs
|
|
81
|
+
end
|
|
82
|
+
definition.add_predicate(name, argument: argument.nil? || argument)
|
|
83
|
+
return self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
super
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def respond_to_missing?(name, include_private = false)
|
|
90
|
+
name.to_s.end_with?('?') || super
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def apply_specs(specs, predicates)
|
|
96
|
+
remaining = specs.dup
|
|
97
|
+
assign_type(definition, remaining.shift) if remaining.first && type_spec?(remaining.first)
|
|
98
|
+
|
|
99
|
+
remaining.each do |predicate|
|
|
100
|
+
case predicate
|
|
101
|
+
when Symbol then definition.add_predicate(predicate)
|
|
102
|
+
when Hash then predicate.each { |name, argument| definition.add_predicate(name, argument: argument) }
|
|
103
|
+
else
|
|
104
|
+
raise UnsupportedFeatureError,
|
|
105
|
+
"unsupported type or predicate specification: #{predicate.class.name}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
predicates.each { |name, argument| definition.add_predicate(name, argument: argument) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def type_spec?(spec)
|
|
112
|
+
builtin_type?(spec) || custom_type?(spec)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def assign_type(target, type)
|
|
116
|
+
if builtin_type?(type)
|
|
117
|
+
target.type = type == :datetime ? :date_time : type
|
|
118
|
+
elsif custom_type?(type)
|
|
119
|
+
target.type = :any
|
|
120
|
+
target.ruby_type = type
|
|
121
|
+
else
|
|
122
|
+
raise UnsupportedFeatureError,
|
|
123
|
+
"unsupported type or predicate specification: #{type.class.name}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def builtin_type?(type)
|
|
128
|
+
type.is_a?(Symbol) && TYPES.include?(type)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def custom_type?(type)
|
|
132
|
+
!type.is_a?(Symbol) && type.respond_to?(:try)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def nested_value_block?
|
|
136
|
+
definition.type == :hash || (definition.type == :array && definition.member&.type == :hash)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def nested_target(block)
|
|
140
|
+
if definition.type == :hash
|
|
141
|
+
nested = DSL.new(mode: mode)
|
|
142
|
+
nested.instance_eval(&block)
|
|
143
|
+
definition.children = nested.fields
|
|
144
|
+
else
|
|
145
|
+
definition.member ||= FieldDefinition.new(name: nil, required: true)
|
|
146
|
+
definition.member.type = :hash
|
|
147
|
+
nested = DSL.new(mode: mode)
|
|
148
|
+
nested.instance_eval(&block)
|
|
149
|
+
definition.member.children = nested.fields
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class Schema
|
|
7
|
+
# @api private
|
|
8
|
+
class FieldDefinition
|
|
9
|
+
attr_accessor :name, :required, :nullable, :filled, :type, :member, :ruby_type
|
|
10
|
+
attr_reader :children, :predicates
|
|
11
|
+
|
|
12
|
+
def initialize(name:, required:)
|
|
13
|
+
@name = name&.to_sym
|
|
14
|
+
@required = required
|
|
15
|
+
@nullable = false
|
|
16
|
+
@filled = false
|
|
17
|
+
@type = :any
|
|
18
|
+
@member = nil
|
|
19
|
+
@ruby_type = nil
|
|
20
|
+
@children = []
|
|
21
|
+
@children_by_name = {}
|
|
22
|
+
@predicates = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def children=(children)
|
|
26
|
+
@children = children
|
|
27
|
+
@children_by_name = children.to_h { |child| [child.name, child] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def child_at(name)
|
|
31
|
+
@children_by_name[name.to_sym]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def add_predicate(name, argument: true)
|
|
35
|
+
normalized_name = name.to_s.delete_suffix('?').to_sym
|
|
36
|
+
unless (NATIVE_PREDICATES | RUBY_PREDICATES).include?(normalized_name)
|
|
37
|
+
raise UnsupportedFeatureError,
|
|
38
|
+
"predicate #{normalized_name.inspect} is not supported natively; move it to a contract rule"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
predicates << Predicate.new(name: normalized_name, argument: argument)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_native_h
|
|
45
|
+
{
|
|
46
|
+
name: name&.to_s,
|
|
47
|
+
required: required,
|
|
48
|
+
nullable: nullable,
|
|
49
|
+
filled: filled,
|
|
50
|
+
type: normalized_type.to_s,
|
|
51
|
+
member: member&.to_native_h,
|
|
52
|
+
children: children.map(&:to_native_h),
|
|
53
|
+
predicates: predicates.filter_map do |predicate|
|
|
54
|
+
next unless NATIVE_PREDICATES.include?(predicate.name)
|
|
55
|
+
|
|
56
|
+
{ name: predicate.name.to_s, argument: predicate.argument }
|
|
57
|
+
end
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def normalized_type
|
|
62
|
+
type == :datetime ? :date_time : type
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def deep_dup
|
|
66
|
+
self.class.new(name: name, required: required).tap do |copy|
|
|
67
|
+
copy.nullable = nullable
|
|
68
|
+
copy.filled = filled
|
|
69
|
+
copy.type = type
|
|
70
|
+
copy.ruby_type = ruby_type
|
|
71
|
+
copy.member = member&.deep_dup
|
|
72
|
+
copy.children = children.map(&:deep_dup)
|
|
73
|
+
predicates.each do |predicate|
|
|
74
|
+
copy.predicates << predicate.with(argument: duplicate_value(predicate.argument))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def duplicate_value(value)
|
|
82
|
+
case value
|
|
83
|
+
when Array
|
|
84
|
+
value.map { |item| duplicate_value(item) }
|
|
85
|
+
when Hash
|
|
86
|
+
value.each_with_object({}) do |(key, item), copy|
|
|
87
|
+
copy[duplicate_value(key)] = duplicate_value(item)
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
value.dup
|
|
91
|
+
end
|
|
92
|
+
rescue TypeError
|
|
93
|
+
value
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|