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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +245 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +852 -0
- data/README_CN.md +852 -0
- data/Rakefile +12 -0
- data/lib/rbdantic/base/access.rb +105 -0
- data/lib/rbdantic/base/dsl.rb +79 -0
- data/lib/rbdantic/base/validation.rb +152 -0
- data/lib/rbdantic/base.rb +30 -0
- data/lib/rbdantic/config.rb +60 -0
- data/lib/rbdantic/error_detail.rb +54 -0
- data/lib/rbdantic/field.rb +188 -0
- data/lib/rbdantic/json_schema/defs_registry.rb +79 -0
- data/lib/rbdantic/json_schema/generator.rb +148 -0
- data/lib/rbdantic/json_schema/types.rb +98 -0
- data/lib/rbdantic/serialization/dumper.rb +133 -0
- data/lib/rbdantic/serialization/json_serializer.rb +60 -0
- data/lib/rbdantic/validators/field_validator.rb +83 -0
- data/lib/rbdantic/validators/model_validator.rb +59 -0
- data/lib/rbdantic/validators/types/array.rb +77 -0
- data/lib/rbdantic/validators/types/base.rb +78 -0
- data/lib/rbdantic/validators/types/boolean.rb +37 -0
- data/lib/rbdantic/validators/types/float.rb +32 -0
- data/lib/rbdantic/validators/types/hash.rb +54 -0
- data/lib/rbdantic/validators/types/integer.rb +28 -0
- data/lib/rbdantic/validators/types/model.rb +75 -0
- data/lib/rbdantic/validators/types/number.rb +63 -0
- data/lib/rbdantic/validators/types/string.rb +70 -0
- data/lib/rbdantic/validators/types/symbol.rb +30 -0
- data/lib/rbdantic/validators/types/time.rb +33 -0
- data/lib/rbdantic/validators/types.rb +63 -0
- data/lib/rbdantic/validators/validator_context.rb +43 -0
- data/lib/rbdantic/version.rb +5 -0
- data/lib/rbdantic.rb +8 -0
- data/sig/rbdantic.rbs +4 -0
- metadata +84 -0
data/Rakefile
ADDED
|
@@ -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
|