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,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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'contract/result'
@@ -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