From 1868a284008136712427b17d0ab87518b8e7a45c Mon Sep 17 00:00:00 2001 From: Alvaro Frias Date: Wed, 7 Jan 2026 16:48:25 -0300 Subject: [PATCH 1/5] Add struct validation Signed-off-by: Alvaro Frias --- src/model.cr | 228 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/src/model.cr b/src/model.cr index 6402e10..de71d4b 100644 --- a/src/model.cr +++ b/src/model.cr @@ -271,4 +271,232 @@ module Schematics "must be between #{min} and #{max}" ) end + + # Float64 validators + def self.gte(value : Float64) + CustomValidator(Float64).new( + ->(n : Float64) { n >= value }, + "must be >= #{value}" + ) + end + + def self.lte(value : Float64) + CustomValidator(Float64).new( + ->(n : Float64) { n <= value }, + "must be <= #{value}" + ) + end + + def self.gt(value : Float64) + CustomValidator(Float64).new( + ->(n : Float64) { n > value }, + "must be > #{value}" + ) + end + + def self.lt(value : Float64) + CustomValidator(Float64).new( + ->(n : Float64) { n < value }, + "must be < #{value}" + ) + end + + def self.range(min : Float64, max : Float64) + CustomValidator(Float64).new( + ->(n : Float64) { n >= min && n <= max }, + "must be between #{min} and #{max}" + ) + end + + # Struct module for immutable value types + # + # Example: + # ``` + # struct Point + # include Schematics::Struct + # + # field x, Float64, validators: [Schematics.gte(0.0)] + # field y, Float64, validators: [Schematics.gte(0.0)] + # end + # ``` + module Struct + macro included + VALIDATIONS = [] of Tuple(Symbol, TypeNode, Bool, ASTNode | NilLiteral) + + # Field definition macro for structs (uses getter instead of property) + macro field(name, type, required = false, default = nil, validators = nil) + # Generate getter only (structs are immutable) + \{% if default != nil %} + getter \{{name.id}} : \{{type}} = \{{default}} + \{% elsif type.resolve.nilable? %} + getter \{{name.id}} : \{{type}} = nil + \{% else %} + getter \{{name.id}} : \{{type}} + \{% end %} + + # Add validation logic + \{% VALIDATIONS << {name.id.symbolize, type, required, validators} %} + end + + macro finished + # Generate initializer + def initialize( + \{% for field_data in VALIDATIONS %} + \{% name = field_data[0].id %} + \{% type = field_data[1] %} + @\{{name}}, + \{% end %} + ) + end + + # Non-mutating validation - returns hash of errors + def errors : Hash(Symbol, Array(String)) + errs = {} of Symbol => Array(String) + \{% for field_data in VALIDATIONS %} + \{% name = field_data[0] %} + \{% required = field_data[2] %} + \{% validators = field_data[3] %} + + # Check required + \{% if required %} + if @\{{name.id}}.nil? + errs[\{{name}}] ||= [] of String + errs[\{{name}}] << "is required" + end + \{% end %} + + # Run validators + \{% if validators && !validators.is_a?(NilLiteral) %} + if val = @\{{name.id}} + \{{validators}}.each do |validator| + result = validator.validate(val, \{{name.stringify}}) + unless result.valid? + result.errors.each do |error| + errs[\{{name}}] ||= [] of String + errs[\{{name}}] << error.error_message + end + end + end + end + \{% end %} + \{% end %} + _collect_custom_errors(errs) + errs + end + + # Check if struct is valid + def valid? : Bool + errors.empty? + end + + # Validate and raise if invalid + def validate! : Bool + e = errors + unless e.empty? + msgs = e.map { |f, ms| "#{f}: #{ms.join(", ")}" }.join("; ") + raise Schematics::ValidationError.new("root", msgs) + end + true + end + + # Override for custom validation - adds errors to the passed hash + # Only define if not already defined by user + \{% unless @type.methods.map(&.name.stringify).includes?("_collect_custom_errors") %} + protected def _collect_custom_errors(errs : Hash(Symbol, Array(String))) + # Override in struct for custom validation + end + \{% end %} + + # Generate to_h + def to_h : Hash(String, JSON::Any) + hash = {} of String => JSON::Any + \{% for field_data in VALIDATIONS %} + \{% name = field_data[0] %} + \{% type = field_data[1] %} + val = @\{{name.id}} + \{% if type.resolve.nilable? %} + if val.nil? + hash[\{{name.id.stringify}}] = JSON::Any.new(nil) + else + \{% inner_type = type.resolve.union_types.find { |t| t != Nil } %} + \{% if inner_type == Int32 %} + hash[\{{name.id.stringify}}] = JSON::Any.new(val.to_i64) + \{% else %} + hash[\{{name.id.stringify}}] = JSON::Any.new(val) + \{% end %} + end + \{% elsif type.resolve == Int32 %} + hash[\{{name.id.stringify}}] = JSON::Any.new(val.to_i64) + \{% else %} + hash[\{{name.id.stringify}}] = JSON::Any.new(val) + \{% end %} + \{% end %} + hash + end + + # Convert to JSON + def to_json(io : IO) : Nil + to_h.to_json(io) + end + + def to_json : String + to_h.to_json + end + + # Generate from_json + def self.from_json(json_str : String) : self + data = JSON.parse(json_str) + from_hash(data.as_h) + end + + def self.from_hash(hash : Hash) : self + new( + \{% for field_data in VALIDATIONS %} + \{% name = field_data[0] %} + \{% type = field_data[1] %} + \{{name.id}}: begin + val = hash[\{{name.id.stringify}}]? + if val + \{% if type.resolve.nilable? %} + \{% inner_type = type.resolve.union_types.find { |t| t != Nil } %} + \{% if inner_type == String %} + val.as_s? + \{% elsif inner_type == Int32 %} + val.as_i?.try(&.to_i32) + \{% elsif inner_type == Int64 %} + val.as_i64? + \{% elsif inner_type == Float64 %} + val.as_f? + \{% elsif inner_type == Bool %} + val.as_bool? + \{% else %} + val.as?(\{{inner_type}}) + \{% end %} + \{% elsif type.resolve == String %} + val.as_s + \{% elsif type.resolve == Int32 %} + val.as_i.to_i32 + \{% elsif type.resolve == Int64 %} + val.as_i64 + \{% elsif type.resolve == Float64 %} + val.as_f + \{% elsif type.resolve == Bool %} + val.as_bool + \{% else %} + val.as(\{{type}}) + \{% end %} + else + \{% if type.resolve.nilable? %} + nil + \{% else %} + raise "Missing required field: " + \{{name.id.stringify}} + \{% end %} + end + end.as(\{{type}}), + \{% end %} + ) + end + end + end + end end From 61a3c129efbd22b2842d1ff3f0be31690beee6f1 Mon Sep 17 00:00:00 2001 From: Alvaro Frias Date: Wed, 7 Jan 2026 16:48:51 -0300 Subject: [PATCH 2/5] add tests & update docs Signed-off-by: Alvaro Frias --- README.md | 74 +++++++++++- spec/struct_spec.cr | 267 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 spec/struct_spec.cr diff --git a/README.md b/README.md index db39590..37df8a2 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,77 @@ The Model DSL uses compile-time macros for zero runtime overhead: end ``` +## Struct Support + +For immutable value types, use `Schematics::Struct` instead of inheriting from `Model`: + +```crystal +struct Point + include Schematics::Struct + + field x, Float64, validators: [Schematics.gte(0.0)] + field y, Float64, validators: [Schematics.gte(0.0)] +end + +struct ServerConfig + include Schematics::Struct + + field host, String, required: true + field port, Int32, default: 8080, validators: [Schematics.range(1, 65535)] + field debug, Bool, default: false +end + +# Usage is the same as Model +point = Point.new(x: 10.0, y: 20.0) +point.valid? # => true +point.x # => 10.0 (read-only) + +config = ServerConfig.new(host: "localhost", port: 3000, debug: true) +config.to_json # => {"host":"localhost","port":3000,"debug":true} +``` + +### Struct vs Model + +| Feature | Model (class) | Struct | +|---------|---------------|--------| +| Definition | `class Foo < Schematics::Model` | `struct Foo` + `include Schematics::Struct` | +| Mutability | Mutable (property) | Immutable (getter only) | +| Memory | Heap allocated | Stack allocated | +| Assignment | Pass by reference | Pass by copy | +| Performance | ~2μs/validation | ~0.4μs/validation | + +### Custom Validation for Structs + +Override `_collect_custom_errors` for custom validation logic: + +```crystal +struct Rectangle + include Schematics::Struct + + field width, Float64, validators: [Schematics.gt(0.0)] + field height, Float64, validators: [Schematics.gt(0.0)] + + protected def _collect_custom_errors(errs : Hash(Symbol, Array(String))) + if width > height * 10 + errs[:width] ||= [] of String + errs[:width] << "aspect ratio too extreme" + end + end +end +``` + +### Float64 Validators + +Structs commonly use Float64 for numeric fields. All numeric validators support Float64: + +```crystal +Schematics.gte(0.0) # >= 0.0 +Schematics.lte(100.0) # <= 100.0 +Schematics.gt(0.0) # > 0.0 +Schematics.lt(1.0) # < 1.0 +Schematics.range(-273.15, 1000.0) # Between min and max +``` + ## Schema-Based Validation For simpler use cases without models, use the schema API directly: @@ -538,7 +609,8 @@ Schemas::POSITIVE_INT.validate(42) - [x] JSON serialization/deserialization - [x] Built-in validators (length, format, ranges, one_of) - [x] Custom validation methods -- [ ] Struct support in Model DSL +- [x] Struct support (immutable value types) +- [x] Float64 validators - [ ] Type coercion - [ ] JSON Schema export - [ ] Async validation diff --git a/spec/struct_spec.cr b/spec/struct_spec.cr new file mode 100644 index 0000000..a06de8a --- /dev/null +++ b/spec/struct_spec.cr @@ -0,0 +1,267 @@ +require "./spec_helper" + +# Test struct definitions +struct TestPoint + include Schematics::Struct + + field x, Float64, validators: [Schematics.gte(0.0)] + field y, Float64, validators: [Schematics.gte(0.0)] +end + +struct TestVector + include Schematics::Struct + + field x, Float64 + field y, Float64 + field z, Float64 +end + +struct TestRectangle + include Schematics::Struct + + field width, Float64, validators: [Schematics.gt(0.0)] + field height, Float64, validators: [Schematics.gt(0.0)] + + protected def _collect_custom_errors(errs : Hash(Symbol, Array(String))) + if width > height * 10 + errs[:width] ||= [] of String + errs[:width] << "width cannot be more than 10x height" + end + end +end + +struct TestConfig + include Schematics::Struct + + field name, String, required: true, validators: [Schematics.min_length(1)] + field port, Int32, default: 8080, validators: [Schematics.range(1, 65535)] + field debug, Bool, default: false +end + +struct TestOptionalStruct + include Schematics::Struct + + field name, String + field value, Int32? + field description, String?, default: nil +end + +struct TestRangeStruct + include Schematics::Struct + + field temperature, Float64, validators: [Schematics.range(-273.15, 1000.0)] + field percentage, Float64, validators: [Schematics.gte(0.0), Schematics.lte(100.0)] +end + +describe Schematics::Struct do + describe "field definitions" do + it "creates struct with fields" do + point = TestPoint.new(x: 10.0, y: 20.0) + point.x.should eq(10.0) + point.y.should eq(20.0) + end + + it "creates struct with default values" do + config = TestConfig.new(name: "app", port: 8080, debug: false) + config.name.should eq("app") + config.port.should eq(8080) + config.debug.should be_false + end + + it "supports nilable fields" do + opt = TestOptionalStruct.new(name: "test", value: nil, description: nil) + opt.name.should eq("test") + opt.value.should be_nil + opt.description.should be_nil + end + end + + describe "validation" do + it "validates valid struct" do + point = TestPoint.new(x: 10.0, y: 20.0) + point.valid?.should be_true + point.errors.should be_empty + end + + it "validates invalid struct" do + point = TestPoint.new(x: -5.0, y: 20.0) + point.valid?.should be_false + point.errors[:x].should_not be_empty + end + + it "validates multiple fields" do + point = TestPoint.new(x: -5.0, y: -10.0) + point.valid?.should be_false + point.errors[:x].should_not be_empty + point.errors[:y].should_not be_empty + end + + it "validates Float64 ranges" do + range1 = TestRangeStruct.new(temperature: 25.0, percentage: 50.0) + range1.valid?.should be_true + + range2 = TestRangeStruct.new(temperature: -300.0, percentage: 50.0) + range2.valid?.should be_false + range2.errors[:temperature].should_not be_empty + + range3 = TestRangeStruct.new(temperature: 25.0, percentage: 150.0) + range3.valid?.should be_false + range3.errors[:percentage].should_not be_empty + end + + it "validates with gt/lt" do + rect1 = TestRectangle.new(width: 10.0, height: 5.0) + rect1.valid?.should be_true + + rect2 = TestRectangle.new(width: 0.0, height: 5.0) + rect2.valid?.should be_false + rect2.errors[:width].should_not be_empty + end + + it "supports custom validation" do + rect1 = TestRectangle.new(width: 50.0, height: 10.0) + rect1.valid?.should be_true + + rect2 = TestRectangle.new(width: 150.0, height: 10.0) + rect2.valid?.should be_false + rect2.errors[:width].should contain("width cannot be more than 10x height") + end + + it "validates required fields" do + config = TestConfig.new(name: "", port: 8080, debug: false) + config.valid?.should be_false + config.errors[:name].should_not be_empty + end + + it "validates Int32 range" do + config1 = TestConfig.new(name: "app", port: 8080, debug: false) + config1.valid?.should be_true + + config2 = TestConfig.new(name: "app", port: 0, debug: false) + config2.valid?.should be_false + config2.errors[:port].should_not be_empty + + config3 = TestConfig.new(name: "app", port: 70000, debug: false) + config3.valid?.should be_false + config3.errors[:port].should_not be_empty + end + end + + describe "validate!" do + it "returns true for valid struct" do + point = TestPoint.new(x: 10.0, y: 20.0) + point.validate!.should be_true + end + + it "raises for invalid struct" do + point = TestPoint.new(x: -5.0, y: 20.0) + expect_raises(Schematics::ValidationError) do + point.validate! + end + end + end + + describe "immutability" do + it "has getter-only fields" do + point = TestPoint.new(x: 10.0, y: 20.0) + # This should compile - read-only access + x_val : Float64 = point.x + y_val : Float64 = point.y + x_val.should eq(10.0) + y_val.should eq(20.0) + end + + it "returns fresh errors hash each time" do + point = TestPoint.new(x: -5.0, y: 20.0) + errors1 = point.errors + errors2 = point.errors + # Each call returns a new hash (non-mutating) + errors1.should_not be(errors2) + errors1.should eq(errors2) + end + end + + describe "JSON serialization" do + it "serializes to JSON" do + point = TestPoint.new(x: 10.5, y: 20.5) + json = point.to_json + json.should contain("10.5") + json.should contain("20.5") + end + + it "deserializes from JSON" do + json = %({"x": 15.0, "y": 25.0}) + point = TestPoint.from_json(json) + point.x.should eq(15.0) + point.y.should eq(25.0) + end + + it "round-trips through JSON" do + original = TestVector.new(x: 1.5, y: 2.5, z: 3.5) + json = original.to_json + restored = TestVector.from_json(json) + + restored.x.should eq(original.x) + restored.y.should eq(original.y) + restored.z.should eq(original.z) + end + + it "handles nilable fields in JSON" do + opt = TestOptionalStruct.new(name: "test", value: nil, description: nil) + json = opt.to_json + json.should contain("test") + + restored = TestOptionalStruct.from_json(json) + restored.name.should eq("test") + restored.value.should be_nil + end + + it "serializes struct with mixed types" do + config = TestConfig.new(name: "myapp", port: 3000, debug: true) + json = config.to_json + json.should contain("myapp") + json.should contain("3000") + json.should contain("true") + + restored = TestConfig.from_json(json) + restored.name.should eq("myapp") + restored.port.should eq(3000) + restored.debug.should be_true + end + end + + describe "type safety" do + it "enforces field types" do + point = TestPoint.new(x: 10.0, y: 20.0) + + x_var : Float64 = point.x + y_var : Float64 = point.y + + x_var.should be_a(Float64) + y_var.should be_a(Float64) + end + + it "handles nilable types correctly" do + opt = TestOptionalStruct.new(name: "test", value: 42, description: "desc") + + name_var : String = opt.name + value_var : Int32? = opt.value + desc_var : String? = opt.description + + name_var.should be_a(String) + value_var.should eq(42) + desc_var.should eq("desc") + end + end + + describe "value semantics" do + it "copies on assignment" do + point1 = TestPoint.new(x: 10.0, y: 20.0) + point2 = point1 + + # Both have same values + point1.x.should eq(point2.x) + point1.y.should eq(point2.y) + end + end +end From 148d98189d61b11d0ec2367830885bc432c58fbc Mon Sep 17 00:00:00 2001 From: Alvaro Frias Date: Wed, 7 Jan 2026 16:50:15 -0300 Subject: [PATCH 3/5] update docs Signed-off-by: Alvaro Frias --- README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/README.md b/README.md index 37df8a2..1061ea4 100644 --- a/README.md +++ b/README.md @@ -379,16 +379,6 @@ config = ServerConfig.new(host: "localhost", port: 3000, debug: true) config.to_json # => {"host":"localhost","port":3000,"debug":true} ``` -### Struct vs Model - -| Feature | Model (class) | Struct | -|---------|---------------|--------| -| Definition | `class Foo < Schematics::Model` | `struct Foo` + `include Schematics::Struct` | -| Mutability | Mutable (property) | Immutable (getter only) | -| Memory | Heap allocated | Stack allocated | -| Assignment | Pass by reference | Pass by copy | -| Performance | ~2μs/validation | ~0.4μs/validation | - ### Custom Validation for Structs Override `_collect_custom_errors` for custom validation logic: @@ -610,7 +600,6 @@ Schemas::POSITIVE_INT.validate(42) - [x] Built-in validators (length, format, ranges, one_of) - [x] Custom validation methods - [x] Struct support (immutable value types) -- [x] Float64 validators - [ ] Type coercion - [ ] JSON Schema export - [ ] Async validation From a4e91c0388adf80932fb968876f49583d915c7df Mon Sep 17 00:00:00 2001 From: Alvaro Frias Date: Wed, 7 Jan 2026 16:52:58 -0300 Subject: [PATCH 4/5] update version Signed-off-by: Alvaro Frias --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index 136d54a..c834029 100644 --- a/shard.yml +++ b/shard.yml @@ -1,6 +1,6 @@ name: schematics description: a library to validate data using schemas -version: 0.3.0 +version: 0.4.0 authors: - Alvaro Frias Garay From db33d11201738b17df1f8d550f3afefa869bcd4b Mon Sep 17 00:00:00 2001 From: Alvaro Frias Date: Wed, 7 Jan 2026 16:58:49 -0300 Subject: [PATCH 5/5] update docs Signed-off-by: Alvaro Frias --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index 1061ea4..d9fdba7 100644 --- a/README.md +++ b/README.md @@ -399,18 +399,6 @@ struct Rectangle end ``` -### Float64 Validators - -Structs commonly use Float64 for numeric fields. All numeric validators support Float64: - -```crystal -Schematics.gte(0.0) # >= 0.0 -Schematics.lte(100.0) # <= 100.0 -Schematics.gt(0.0) # > 0.0 -Schematics.lt(1.0) # < 1.0 -Schematics.range(-273.15, 1000.0) # Between min and max -``` - ## Schema-Based Validation For simpler use cases without models, use the schema API directly: