rbdantic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +245 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +5 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +852 -0
  8. data/README_CN.md +852 -0
  9. data/Rakefile +12 -0
  10. data/lib/rbdantic/base/access.rb +105 -0
  11. data/lib/rbdantic/base/dsl.rb +79 -0
  12. data/lib/rbdantic/base/validation.rb +152 -0
  13. data/lib/rbdantic/base.rb +30 -0
  14. data/lib/rbdantic/config.rb +60 -0
  15. data/lib/rbdantic/error_detail.rb +54 -0
  16. data/lib/rbdantic/field.rb +188 -0
  17. data/lib/rbdantic/json_schema/defs_registry.rb +79 -0
  18. data/lib/rbdantic/json_schema/generator.rb +148 -0
  19. data/lib/rbdantic/json_schema/types.rb +98 -0
  20. data/lib/rbdantic/serialization/dumper.rb +133 -0
  21. data/lib/rbdantic/serialization/json_serializer.rb +60 -0
  22. data/lib/rbdantic/validators/field_validator.rb +83 -0
  23. data/lib/rbdantic/validators/model_validator.rb +59 -0
  24. data/lib/rbdantic/validators/types/array.rb +77 -0
  25. data/lib/rbdantic/validators/types/base.rb +78 -0
  26. data/lib/rbdantic/validators/types/boolean.rb +37 -0
  27. data/lib/rbdantic/validators/types/float.rb +32 -0
  28. data/lib/rbdantic/validators/types/hash.rb +54 -0
  29. data/lib/rbdantic/validators/types/integer.rb +28 -0
  30. data/lib/rbdantic/validators/types/model.rb +75 -0
  31. data/lib/rbdantic/validators/types/number.rb +63 -0
  32. data/lib/rbdantic/validators/types/string.rb +70 -0
  33. data/lib/rbdantic/validators/types/symbol.rb +30 -0
  34. data/lib/rbdantic/validators/types/time.rb +33 -0
  35. data/lib/rbdantic/validators/types.rb +63 -0
  36. data/lib/rbdantic/validators/validator_context.rb +43 -0
  37. data/lib/rbdantic/version.rb +5 -0
  38. data/lib/rbdantic.rb +8 -0
  39. data/sig/rbdantic.rbs +4 -0
  40. metadata +84 -0
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Rbdantic
6
+ class BaseModel
7
+ # Accessor and serialization methods
8
+ module Access
9
+ def model_dump(**options)
10
+ Serialization::Dumper.dump(self, **options)
11
+ end
12
+
13
+ def model_dump_json(indent: nil, **options)
14
+ Serialization::JsonSerializer.dump(self, indent: indent, **options)
15
+ end
16
+
17
+ def model_fields_set
18
+ Set.new(Array(@__fields_set__))
19
+ end
20
+
21
+ def model_extra
22
+ Array(@__extra_fields__).each_with_object({}) { |name, h| h[name] = instance_variable_get("@#{name}") }
23
+ end
24
+
25
+ def [](name)
26
+ instance_variable_get("@#{name}")
27
+ end
28
+
29
+ def []=(name, value)
30
+ raise FrozenError, "cannot modify frozen #{self.class.name}" if self.class.model_config.frozen
31
+ self.class.fields.key?(name) ? assign_field(name, value) : assign_extra_field(name, value)
32
+ end
33
+
34
+ # Create a copy of the model
35
+ # @param deep [Boolean] if true, perform deep copy of nested models and collections
36
+ # @return [BaseModel] a new instance with copied values
37
+ def copy(deep: false)
38
+ attributes = deep ? deep_copy_value(model_dump) : model_dump
39
+ rebuild_instance(attributes, fields_set: Array(@__fields_set__), extra_fields: Array(@__extra_fields__))
40
+ end
41
+
42
+ # Create a new model with updated fields
43
+ # @param data [Hash] fields to update
44
+ # @return [BaseModel] a new instance with updated values
45
+ def update(**data)
46
+ candidate = self.class.new(model_dump.merge(data))
47
+ extra_fields = candidate.model_extra.keys
48
+ declared_fields = (model_fields_set - Set.new(model_extra.keys)) | Set.new(normalized_update_field_names(data))
49
+ fields_set = declared_fields | Set.new(extra_fields)
50
+
51
+ rebuild_instance(candidate.model_dump, fields_set: fields_set.to_a, extra_fields: extra_fields)
52
+ end
53
+
54
+ def ==(other)
55
+ other.is_a?(self.class) && model_dump == other.model_dump
56
+ end
57
+
58
+ alias eql? ==
59
+
60
+ def hash
61
+ model_dump.hash
62
+ end
63
+
64
+ def method_missing(name, *args, &block)
65
+ return model_extra[name] if args.empty? && block.nil? && model_extra.key?(name)
66
+ super
67
+ end
68
+
69
+ def respond_to_missing?(name, include_private = false)
70
+ model_extra.key?(name) || super
71
+ end
72
+
73
+ private
74
+
75
+ def deep_copy_value(value)
76
+ case value
77
+ when BaseModel
78
+ value.copy(deep: true)
79
+ when Hash
80
+ value.transform_values { |v| deep_copy_value(v) }
81
+ when Array
82
+ value.map { |v| deep_copy_value(v) }
83
+ else
84
+ # Primitive types are immutable, no need to copy
85
+ value
86
+ end
87
+ end
88
+
89
+ def rebuild_instance(attributes, fields_set:, extra_fields:)
90
+ self.class.__build_instance__(attributes, fields_set: fields_set, extra_fields: extra_fields)
91
+ end
92
+
93
+ def normalized_update_field_names(data)
94
+ alias_map = self.class.fields.each_with_object({}) do |(field_name, field_info), mapping|
95
+ mapping[field_info.alias_name.to_sym] = field_name if field_info.alias_name
96
+ end
97
+
98
+ data.keys.filter_map do |key|
99
+ key = key.to_sym
100
+ self.class.fields.key?(key) ? key : alias_map[key]
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rbdantic
4
+ class BaseModel
5
+ module DSL
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def model_fields; @__rbdantic_fields__ ||= {}; end
12
+ alias fields model_fields
13
+
14
+ def field_validators; @__rbdantic_field_validators__ ||= {}; end
15
+ def model_validators; @__rbdantic_model_validators__ ||= []; end
16
+
17
+ def field(name, type, metadata = nil, **options)
18
+ field_info = FieldInfo.from_dsl(name, type, metadata, **options)
19
+ (@__rbdantic_fields__ ||= {})[name] = field_info
20
+
21
+ define_method(name) { instance_variable_get("@#{name}") }
22
+ define_method("#{name}=") do |value|
23
+ raise FrozenError, "cannot modify frozen #{self.class.name}" if self.class.model_config.frozen
24
+ assign_field(name, value)
25
+ end
26
+ end
27
+
28
+ def model_config(**options)
29
+ @__rbdantic_model_config__ ||= ModelConfig.new
30
+ options.empty? ? @__rbdantic_model_config__ : @__rbdantic_model_config__ = @__rbdantic_model_config__.with(**options)
31
+ end
32
+
33
+ def inherited(subclass)
34
+ subclass.instance_variable_set(:@__rbdantic_fields__, fields.dup)
35
+ subclass.instance_variable_set(:@__rbdantic_model_config__, model_config.dup)
36
+ subclass.instance_variable_set(:@__rbdantic_field_validators__, field_validators.each_with_object({}) do |(k, v), h|
37
+ h[k] = v.dup
38
+ end)
39
+ subclass.instance_variable_set(:@__rbdantic_model_validators__, model_validators.dup)
40
+ super
41
+ end
42
+
43
+ def field_validator(field_name, mode: :after, &block)
44
+ ((@__rbdantic_field_validators__ ||= {})[field_name] ||= []) << Validators::FieldValidator.new(field_name,
45
+ mode: mode, &block)
46
+ end
47
+
48
+ def model_validator(mode: :after, &block)
49
+ (@__rbdantic_model_validators__ ||= []) << Validators::ModelValidator.new(mode: mode, &block)
50
+ end
51
+
52
+ # Rebuild model fields (useful after inheritance or dynamic changes)
53
+ # Re-creates field accessors and validators
54
+ def model_rebuild
55
+ fields.each do |name, _|
56
+ define_method(name) { instance_variable_get("@#{name}") }
57
+ define_method("#{name}=") do |value|
58
+ raise FrozenError, "cannot modify frozen #{self.class.name}" if self.class.model_config.frozen
59
+ assign_field(name, value)
60
+ end
61
+ end
62
+ true
63
+ end
64
+
65
+ def model_json_schema(**options); JsonSchema::Generator.generate(self, **options); end
66
+ def model_validate(data); new(data); end
67
+
68
+ def __build_instance__(attributes, fields_set:, extra_fields:)
69
+ instance = allocate
70
+ attributes.each { |name, value| instance.instance_variable_set("@#{name}", value) }
71
+ instance.instance_variable_set(:@__fields_set__, fields_set.map(&:to_sym).uniq)
72
+ instance.instance_variable_set(:@__extra_fields__, extra_fields.map(&:to_sym).uniq)
73
+ instance.freeze if model_config.frozen
74
+ instance
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rbdantic
4
+ class BaseModel
5
+ module Validation
6
+ def initialize(data = {})
7
+ data = normalize_attributes(data)
8
+ errors, processed, provided, extra, consumed = [], {}, [], [], []
9
+
10
+ # 1. Before model validators (Status Safe)
11
+ data = run_before_model_validators(data, errors)
12
+
13
+ # 2. Process Fields
14
+ validate_fields(data, processed, errors, provided, consumed)
15
+
16
+ # 3. Extra Fields
17
+ handle_extra_fields(data, processed, extra, errors, consumed)
18
+
19
+ raise ValidationError.new(errors) if errors.any?
20
+
21
+ # 4. Success - Set state
22
+ finalize_state(processed, provided, extra)
23
+
24
+ # 5. After model validators
25
+ run_after_model_validators(errors)
26
+ raise ValidationError.new(errors) if errors.any?
27
+ freeze if self.class.model_config.frozen
28
+ end
29
+
30
+ private
31
+
32
+ def run_before_model_validators(data, errors)
33
+ current_data = data.dup
34
+ self.class.model_validators.select { |v| v.mode == :before }.each do |v|
35
+ begin
36
+ result = v.call(current_data)
37
+ raise TypeError, "Before model validators must return a Hash" unless result.is_a?(Hash)
38
+ current_data = normalize_attributes(result)
39
+ rescue StandardError => e
40
+ errors << ErrorDetail.new(type: :model_validation_failed, loc: [], msg: e.message, input: current_data)
41
+ end
42
+ end
43
+ current_data
44
+ end
45
+
46
+ def validate_fields(data, processed, errors, provided, consumed)
47
+ self.class.fields.each do |name, field|
48
+ present, input_key, input_value = resolve_input(data, name, field)
49
+
50
+ if present
51
+ provided << name
52
+ consumed << input_key
53
+ field_errors, value = field.validate(input_value, self, build_validator_context(name, field, processed))
54
+ errors.concat(field_errors)
55
+ processed[name] = value
56
+ elsif field.has_default?
57
+ processed[name] = field.get_default
58
+ elsif field.required?
59
+ errors << ErrorDetail.new(type: :value_missing, loc: [name], msg: "Field '#{name}' is required", input: nil)
60
+ end
61
+ end
62
+ end
63
+
64
+ def handle_extra_fields(data, processed, extra_fields, errors, consumed)
65
+ (data.keys - consumed).each do |name|
66
+ case self.class.model_config.extra
67
+ when :forbid
68
+ errors << ErrorDetail.new(type: :extra_field_forbidden, loc: [name],
69
+ msg: "Extra field '#{name}' is not allowed", input: data[name])
70
+ when :allow
71
+ extra_fields << name
72
+ processed[name] = data[name]
73
+ end
74
+ end
75
+ end
76
+
77
+ def finalize_state(processed, provided, extra)
78
+ processed.each { |name, value| instance_variable_set("@#{name}", value) }
79
+ @__fields_set__ = (provided + extra).uniq
80
+ @__extra_fields__ = extra.uniq
81
+ end
82
+
83
+ def run_after_model_validators(errors)
84
+ self.class.model_validators.select { |v| v.mode == :after }.each do |v|
85
+ errors.concat(v.validate(self))
86
+ end
87
+ end
88
+
89
+ def normalize_attributes(data)
90
+ raise ArgumentError, "BaseModel expects a Hash" unless data.is_a?(Hash)
91
+ data.transform_keys { |k| (k.is_a?(String) || k.is_a?(Symbol)) ? k.to_sym : k }
92
+ end
93
+
94
+ def resolve_input(data, name, field)
95
+ return [true, name, data[name]] if data.key?(name)
96
+
97
+ alias_name = field.alias_name&.to_sym
98
+ return [false, nil, nil] unless alias_name && data.key?(alias_name)
99
+
100
+ [true, alias_name, data[alias_name]]
101
+ end
102
+
103
+ def build_validator_context(name, field_info, data = {})
104
+ Validators::ValidatorContext.new(field_name: name, field_info: field_info, model_class: self.class,
105
+ model_instance: self, data: data)
106
+ end
107
+
108
+ def assign_field(name, value)
109
+ field = self.class.fields[name]
110
+ unless self.class.model_config.validate_assignment
111
+ return (instance_variable_set("@#{name}", value);
112
+ track_set_field(name); value)
113
+ end
114
+
115
+ errors, processed = field.validate(value, self, build_validator_context(name, field, current_field_data))
116
+ raise ValidationError.new(errors) if errors.any?
117
+
118
+ previous = instance_variable_get("@#{name}")
119
+ instance_variable_set("@#{name}", processed)
120
+
121
+ begin
122
+ model_errors = []
123
+ run_after_model_validators(model_errors)
124
+ raise ValidationError.new(model_errors) if model_errors.any?
125
+ rescue StandardError
126
+ instance_variable_set("@#{name}", previous)
127
+ raise
128
+ end
129
+ track_set_field(name)
130
+ processed
131
+ end
132
+
133
+ def assign_extra_field(name, value)
134
+ case self.class.model_config.extra
135
+ when :forbid then raise ValidationError.new([ErrorDetail.new(type: :extra_field_forbidden, loc: [name],
136
+ msg: "Extra field '#{name}' is not allowed", input: value)])
137
+ when :allow then (instance_variable_set("@#{name}", value);
138
+ track_set_field(name); track_extra_field(name); value)
139
+ end
140
+ end
141
+
142
+ def current_field_data
143
+ self.class.fields.keys.each_with_object({}) do |name, acc|
144
+ acc[name] = instance_variable_get("@#{name}") if instance_variable_defined?("@#{name}")
145
+ end
146
+ end
147
+
148
+ def track_set_field(name); (@__fields_set__ ||= []) << name unless @__fields_set__&.include?(name); end
149
+ def track_extra_field(name); (@__extra_fields__ ||= []) << name unless @__extra_fields__&.include?(name); end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+
6
+ require_relative "error_detail"
7
+ require_relative "field"
8
+ require_relative "config"
9
+ require_relative "serialization/dumper"
10
+ require_relative "serialization/json_serializer"
11
+ require_relative "json_schema/generator"
12
+ require_relative "json_schema/types"
13
+ require_relative "json_schema/defs_registry"
14
+ require_relative "validators/field_validator"
15
+ require_relative "validators/model_validator"
16
+ require_relative "validators/validator_context"
17
+ require_relative "validators/types"
18
+ require_relative "base/dsl"
19
+ require_relative "base/validation"
20
+ require_relative "base/access"
21
+
22
+ module Rbdantic
23
+ # Base class for data models with field DSL and validation
24
+ # Similar to Pydantic BaseModel in Python
25
+ class BaseModel
26
+ include DSL
27
+ include Validation
28
+ include Access
29
+ end
30
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rbdantic
4
+ # Model configuration options
5
+ class ModelConfig
6
+ VALID_EXTRA_VALUES = %i[forbid ignore allow].freeze
7
+ VALID_COERCE_MODES = %i[strict coerce].freeze
8
+
9
+ attr_reader :strict, :extra, :frozen, :validate_assignment, :coerce_mode
10
+
11
+ def initialize(strict: false, extra: :ignore, frozen: false, validate_assignment: true, coerce_mode: nil)
12
+ @coerce_mode = validate_coerce_mode!(coerce_mode || (strict ? :strict : :coerce))
13
+ strict = (@coerce_mode == :strict)
14
+ @strict = strict
15
+ @extra = validate_extra!(extra)
16
+ @frozen = frozen
17
+ @validate_assignment = validate_assignment
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ strict: @strict,
23
+ extra: @extra,
24
+ frozen: @frozen,
25
+ validate_assignment: @validate_assignment,
26
+ coerce_mode: @coerce_mode
27
+ }
28
+ end
29
+
30
+ def with(**overrides)
31
+ merged = to_h.merge(overrides)
32
+
33
+ if overrides.key?(:strict) && !overrides.key?(:coerce_mode)
34
+ merged[:coerce_mode] = overrides[:strict] ? :strict : :coerce
35
+ elsif overrides.key?(:coerce_mode) && !overrides.key?(:strict)
36
+ merged[:strict] = overrides[:coerce_mode] == :strict
37
+ end
38
+
39
+ self.class.new(**merged)
40
+ end
41
+
42
+ alias merge with
43
+
44
+ private
45
+
46
+ def validate_extra!(value)
47
+ unless VALID_EXTRA_VALUES.include?(value)
48
+ raise ArgumentError, "extra must be one of #{VALID_EXTRA_VALUES.join(", ")}"
49
+ end
50
+ value
51
+ end
52
+
53
+ def validate_coerce_mode!(value)
54
+ unless VALID_COERCE_MODES.include?(value)
55
+ raise ArgumentError, "coerce_mode must be one of #{VALID_COERCE_MODES.join(", ")}"
56
+ end
57
+ value
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rbdantic
4
+ # Pydantic-compatible error detail structure
5
+ class ErrorDetail
6
+ attr_reader :type, :loc, :msg, :input
7
+
8
+ def initialize(type:, loc:, msg:, input: nil)
9
+ @type = type
10
+ @loc = loc
11
+ @msg = msg
12
+ @input = input
13
+ end
14
+
15
+ def to_h
16
+ { type: @type, loc: @loc, msg: @msg, input: @input }.compact
17
+ end
18
+
19
+ def as_json(*)
20
+ to_h
21
+ end
22
+
23
+ def to_json(*args)
24
+ to_h.to_json(*args)
25
+ end
26
+ end
27
+
28
+ # Raised when model validation fails
29
+ class ValidationError < StandardError
30
+ attr_reader :errors
31
+
32
+ def initialize(errors)
33
+ @errors = errors
34
+ super(build_message)
35
+ end
36
+
37
+ def error_count
38
+ @errors.length
39
+ end
40
+
41
+ def as_json(*)
42
+ { errors: @errors.map(&:as_json), error_count: error_count }
43
+ end
44
+
45
+ alias to_h as_json
46
+
47
+ private
48
+
49
+ def build_message
50
+ "#{error_count} validation error(s):\n" +
51
+ @errors.map { |e| " - #{e.loc.join(".")}: #{e.msg}" }.join("\n")
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "validators/types"
4
+
5
+ module Rbdantic
6
+ # FieldInfo: Stores field metadata and handles validation pipeline
7
+ class FieldInfo
8
+ UNSET = Object.new.freeze
9
+
10
+ attr_reader :name, :type, :constraints, :default, :default_factory,
11
+ :optional, :validators, :default_provided, :type_validator,
12
+ :alias_name
13
+
14
+ CONSTRAINT_KEYS = %i[
15
+ min_length max_length gt ge lt le pattern format
16
+ min_items max_items unique_items element_type multiple_of
17
+ min_properties max_properties
18
+ ].freeze
19
+
20
+ def self.from_dsl(name, type, metadata = nil, **options)
21
+ meta = case metadata
22
+ when nil then {}
23
+ when FieldInfo then { default: metadata.default, default_factory: metadata.default_factory,
24
+ optional: metadata.optional, validators: metadata.validators,
25
+ alias_name: metadata.alias_name, **metadata.constraints }
26
+ when Hash then metadata
27
+ else raise ArgumentError, "field metadata must be a Rbdantic::FieldInfo or Hash"
28
+ end
29
+
30
+ opts = meta.merge(options)
31
+ type, opts = normalize_type(type, opts)
32
+ new(name: name, type: type, **opts)
33
+ end
34
+
35
+ def self.normalize_type(type, options)
36
+ return [Array, options.merge(element_type: type.first)] if type.is_a?(Array) && type.length == 1
37
+ [type, options]
38
+ end
39
+
40
+ def initialize(name: nil, type: nil, default: UNSET, default_factory: nil,
41
+ optional: nil, required: nil, validators: nil, alias_name: nil, **constraints)
42
+ @name = name
43
+ @type = type
44
+ @alias_name = alias_name
45
+ @default = default.equal?(UNSET) ? nil : default
46
+ @default_provided = !default.equal?(UNSET)
47
+ @default_factory = default_factory
48
+ @optional = required == false ? true : (optional || false)
49
+ @validators = validators || []
50
+ validate_constraint_keys!(constraints)
51
+ @constraints = constraints.slice(*CONSTRAINT_KEYS).freeze
52
+
53
+ validate_mutable_default! if @default_provided && !@default_factory
54
+ validate_constraint_compatibility! if @type
55
+ @type_validator = Validators::Types.create_validator(@type, **@constraints)
56
+ end
57
+
58
+ def has_default?; @default_provided || !@default_factory.nil?; end
59
+ def get_default; @default_factory ? @default_factory.call : @default; end
60
+ def required?; !@optional && !has_default?; end
61
+
62
+ # Main validation entry point
63
+ # @param value the value to validate
64
+ # @param model_instance the model instance being validated
65
+ # @param context [ValidatorContext] validation context
66
+ # @return [Array<ErrorDetail>, value] tuple of errors and validated value
67
+ def validate(value, model_instance, context)
68
+ return handle_nil_value if value.nil?
69
+
70
+ strict = model_instance.class.model_config.strict
71
+ custom_validators = model_instance.class.field_validators[@name] || []
72
+
73
+ errors, value = run_before_validators(value, custom_validators, context)
74
+ return [errors, value] if errors.any?
75
+
76
+ run_main_validation(value, custom_validators, context, strict)
77
+ end
78
+
79
+ private
80
+
81
+ # Handle nil values - either allow (optional) or error (required)
82
+ def handle_nil_value
83
+ return [[], nil] if @optional
84
+ [[required_error, nil]]
85
+ end
86
+
87
+ def required_error
88
+ ErrorDetail.new(type: :type_error, loc: [@name], msg: "Field is required", input: nil)
89
+ end
90
+
91
+ # Run before validators, return errors and potentially transformed value
92
+ def run_before_validators(value, custom_validators, context)
93
+ apply_validators_by_mode(value, custom_validators, :before, context)
94
+ end
95
+
96
+ # Main validation: wrap -> plain -> core (in priority order)
97
+ def run_main_validation(value, custom_validators, context, strict)
98
+ base_handler = if custom_validators.any? { |validator| validator.mode == :plain }
99
+ ->(current_value) { apply_validators_by_mode(current_value, custom_validators, :plain, context) }
100
+ else
101
+ ->(current_value) { run_core_validation(current_value, custom_validators, context, strict) }
102
+ end
103
+
104
+ wrap_validators = custom_validators.select { |validator| validator.mode == :wrap }
105
+ return base_handler.call(value) if wrap_validators.empty?
106
+
107
+ handler = wrap_validators.reduce(base_handler) do |next_handler, validator|
108
+ ->(current_value) { validator.apply(current_value, context, next_handler) }
109
+ end
110
+
111
+ handler.call(value)
112
+ end
113
+
114
+ # Core validation: type -> proc -> after
115
+ def run_core_validation(value, custom_validators, context, strict)
116
+ errors, value = run_type_validation(value, strict)
117
+ return [errors, value] if errors.any?
118
+
119
+ errors = run_proc_validators(value)
120
+ return [errors, value] if errors.any?
121
+
122
+ apply_validators_by_mode(value, custom_validators, :after, context)
123
+ end
124
+
125
+ def run_type_validation(value, strict)
126
+ return [[], value] unless @type_validator
127
+ @type_validator.validate(value, [@name], strict: strict)
128
+ end
129
+
130
+ def run_proc_validators(value)
131
+ @validators.each_with_object([]) do |v, errs|
132
+ next unless v.is_a?(Proc) || v.is_a?(Method)
133
+ result = v.call(value)
134
+ errs << proc_error(value, result) if proc_failed?(result)
135
+ end
136
+ end
137
+
138
+ def proc_failed?(result)
139
+ result == false || result.is_a?(String)
140
+ end
141
+
142
+ def proc_error(value, result)
143
+ msg = result.is_a?(String) ? result : "Custom validation failed"
144
+ ErrorDetail.new(type: :validation_failed, loc: [@name], msg: msg, input: value)
145
+ end
146
+
147
+ def apply_validators_by_mode(value, validators, mode, context)
148
+ errors, current = [], value
149
+ validators.select { |v| v.mode == mode }.each do |v|
150
+ errs, transformed = v.apply(current, context)
151
+ errors.concat(errs)
152
+ current = transformed if errs.empty?
153
+ end
154
+ [errors, current]
155
+ end
156
+
157
+ def validate_mutable_default!
158
+ return unless @default.is_a?(::Array) || @default.is_a?(::Hash)
159
+ raise ArgumentError, "Mutable default '#{@default.class.name}' detected for field '#{@name}'. " \
160
+ "Use `default_factory` to avoid shared state."
161
+ end
162
+
163
+ def validate_constraint_keys!(constraints)
164
+ unknown = constraints.keys - CONSTRAINT_KEYS
165
+ return if unknown.empty?
166
+
167
+ raise ArgumentError, "Unknown constraint(s) for field '#{@name}': #{unknown.join(", ")}"
168
+ end
169
+
170
+ def validate_constraint_compatibility!
171
+ if @constraints.key?(:multiple_of) && @constraints[:multiple_of].to_f.zero?
172
+ raise ArgumentError, "multiple_of must not be 0"
173
+ end
174
+
175
+ return unless @constraints[:format]
176
+ return if @type == ::String && Validators::Types::String::FORMAT_PATTERNS.key?(@constraints[:format])
177
+
178
+ if @type == ::String
179
+ raise ArgumentError, "Unsupported format '#{@constraints[:format]}' for field '#{@name}'"
180
+ end
181
+
182
+ raise ArgumentError, "format is only supported for String fields"
183
+ end
184
+ end
185
+
186
+ Field = FieldInfo
187
+ def self.Field(**options); FieldInfo.new(**options); end
188
+ end