From bd18d2bb7c1172bf57693440bcb1f1ad1d45b7dc Mon Sep 17 00:00:00 2001 From: Alvaro Frias Date: Mon, 8 Dec 2025 17:15:54 -0300 Subject: [PATCH 1/5] add model dsl class Signed-off-by: Alvaro Frias --- src/model.cr | 274 ++++++++++++++++++++++++++++++++++++++++++++++ src/schematics.cr | 1 + 2 files changed, 275 insertions(+) create mode 100644 src/model.cr diff --git a/src/model.cr b/src/model.cr new file mode 100644 index 0000000..6402e10 --- /dev/null +++ b/src/model.cr @@ -0,0 +1,274 @@ +require "./validation_result" +require "./validators" +require "json" + +module Schematics + # Model base class + # + # Simpler implementation using explicit field registration + # Example: + # ``` + # class User < Schematics::Model + # field email, String, required: true, validators: [min_length(5)] + # field age, Int32, default: 0, validators: [gte(0)] + # end + # ``` + abstract class Model + @errors = {} of Symbol => Array(String) + + getter errors + + # Check if model is valid + def valid? : Bool + @errors.clear + _run_validations + validate_model if responds_to?(:validate_model) + @errors.empty? + end + + # Validate and raise if invalid + def validate! : Bool + unless valid? + msgs = @errors.map { |f, ms| "#{f}: #{ms.join(", ")}" }.join("; ") + raise ValidationError.new("root", msgs) + end + true + end + + # Add validation error + protected def add_error(field : Symbol, message : String) + @errors[field] ||= [] of String + @errors[field] << message + end + + # Override this for custom validations + def validate_model + end + + # Will be implemented by subclass + private abstract def _run_validations + + # Convert to hash + abstract def to_h : Hash(String, JSON::Any) + + # Convert to JSON + def to_json(io : IO) : Nil + to_h.to_json(io) + end + + def to_json : String + to_h.to_json + end + + # Field definition macro - simpler approach + macro field(name, type, required = false, default = nil, validators = nil) + # Generate getter/setter + {% if default != nil %} + property {{name.id}} : {{type}} = {{default}} + {% elsif type.resolve.nilable? %} + property {{name.id}} : {{type}} = nil + {% else %} + property {{name.id}} : {{type}} + {% end %} + + # Add validation logic + {% VALIDATIONS << {name.id.symbolize, type, required, validators} %} + end + + # Generate all methods when class is finished + macro inherited + VALIDATIONS = [] of Tuple(Symbol, TypeNode, Bool, ASTNode | NilLiteral) + + macro finished + # Generate initializer + def initialize( + \{% for field_data in VALIDATIONS %} + \{% name = field_data[0].id %} + \{% type = field_data[1] %} + @\{{name}}, + \{% end %} + ) + end + + # Generate validation method + private def _run_validations + \{% 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? + add_error(\{{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| + add_error(\{{name}}, error.error_message) + end + end + end + end + \{% end %} + \{% 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 + + # 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 + + # Validator helper methods (Pydantic-style) + def self.min_length(length : Int32) + CustomValidator(String).new( + ->(s : String) { s.size >= length }, + "must be at least #{length} characters" + ) + end + + def self.max_length(length : Int32) + CustomValidator(String).new( + ->(s : String) { s.size <= length }, + "must be at most #{length} characters" + ) + end + + def self.format(regex : Regex) + CustomValidator(String).new( + ->(s : String) { s.matches?(regex) }, + "does not match required format" + ) + end + + def self.matches(regex : Regex) + format(regex) + end + + def self.gte(value : Int32) + CustomValidator(Int32).new( + ->(n : Int32) { n >= value }, + "must be >= #{value}" + ) + end + + def self.lte(value : Int32) + CustomValidator(Int32).new( + ->(n : Int32) { n <= value }, + "must be <= #{value}" + ) + end + + def self.gt(value : Int32) + CustomValidator(Int32).new( + ->(n : Int32) { n > value }, + "must be > #{value}" + ) + end + + def self.lt(value : Int32) + CustomValidator(Int32).new( + ->(n : Int32) { n < value }, + "must be < #{value}" + ) + end + + def self.one_of(values : Array(String)) + CustomValidator(String).new( + ->(s : String) { values.includes?(s) }, + "must be one of: #{values.join(", ")}" + ) + end + + def self.range(min : Int32, max : Int32) + CustomValidator(Int32).new( + ->(n : Int32) { n >= min && n <= max }, + "must be between #{min} and #{max}" + ) + end +end diff --git a/src/schematics.cr b/src/schematics.cr index 2abe707..4ddd513 100644 --- a/src/schematics.cr +++ b/src/schematics.cr @@ -5,6 +5,7 @@ require "./validation_result" require "./validators" require "./field" require "./schema" +require "./model" module Schematics VERSION = "0.2.0" From fab6127ecf7398133f3a4f98664be704245c2751 Mon Sep 17 00:00:00 2001 From: Alvaro Frias Date: Mon, 8 Dec 2025 17:17:06 -0300 Subject: [PATCH 2/5] add spec Signed-off-by: Alvaro Frias --- spec/model_spec.cr | 406 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 spec/model_spec.cr diff --git a/spec/model_spec.cr b/spec/model_spec.cr new file mode 100644 index 0000000..d980562 --- /dev/null +++ b/spec/model_spec.cr @@ -0,0 +1,406 @@ +require "./spec_helper" + +# Test model classes +class TestUser < Schematics::Model + field email, String, + required: true, + validators: [ + Schematics.min_length(5), + Schematics.format(/@/), + ] + + field username, String, + required: true, + validators: [Schematics.min_length(3)] +end + +class TestRequiredUser < Schematics::Model + field email, String, required: true + field name, String +end + +class TestOptionalUser < Schematics::Model + field email, String, required: true + field age, Int32? +end + +class TestDefaultUser < Schematics::Model + field email, String, required: true + field role, String, default: "user" +end + +class TestLengthUser < Schematics::Model + field username, String, + validators: [ + Schematics.min_length(3), + Schematics.max_length(20), + ] +end + +class TestFormatUser < Schematics::Model + field email, String, + validators: [Schematics.format(/@/)] + field username, String, + validators: [Schematics.matches(/^[a-zA-Z0-9_]+$/)] +end + +class TestNumericUser < Schematics::Model + field age, Int32, + validators: [ + Schematics.gte(0), + Schematics.lte(120), + ] + field rating, Int32, + validators: [Schematics.range(1, 5)] +end + +class TestRoleUser < Schematics::Model + field role, String, + validators: [Schematics.one_of(["admin", "user", "guest"])] +end + +class TestCustomUser < Schematics::Model + field email, String + field age, Int32? + + def validate_model + if age_val = age + if age_val < 18 && email.ends_with?(".gov") + add_error(:email, "Government emails require age 18+") + end + end + end +end + +class TestMultiErrorUser < Schematics::Model + field email, String, + validators: [ + Schematics.min_length(5), + Schematics.format(/@/), + ] + field username, String, + validators: [Schematics.min_length(3)] +end + +class TestRaisingUser < Schematics::Model + field email, String, + validators: [Schematics.format(/@/)] +end + +class TestSerializableUser < Schematics::Model + field email, String + field username, String + field age, Int32 +end + +class TestDeserializableUser < Schematics::Model + field email, String + field username, String + field age, Int32 +end + +class TestNilableJsonUser < Schematics::Model + field email, String + field age, Int32? +end + +class TestRoundtripUser < Schematics::Model + field email, String + field username, String + field age, Int32 + field active, Bool, default: true +end + +class TestAccessUser < Schematics::Model + field email, String + field age, Int32 +end + +class TestTypedUser < Schematics::Model + field email, String + field age, Int32 + field active, Bool +end + +class TestNilableTypedUser < Schematics::Model + field email, String + field age, Int32? +end + +class TestComparisonUser < Schematics::Model + field score, Int32, + validators: [ + Schematics.gt(0), + Schematics.lt(100), + ] +end + +class TestClearErrorsUser < Schematics::Model + field email, String, + validators: [Schematics.format(/@/)] +end + +class TestErrorMessagesUser < Schematics::Model + field email, String, + validators: [Schematics.format(/@/)] + field username, String, + validators: [Schematics.min_length(3)] +end + +describe Schematics::Model do + describe "field definitions" do + it "defines fields with required validators" do + user = TestUser.new(email: "test@example.com", username: "testuser") + user.valid?.should be_true + user.errors.should be_empty + end + + it "validates required fields" do + user = TestRequiredUser.new(email: "test@example.com", name: "John") + user.valid?.should be_true + end + + it "supports optional/nilable fields" do + user = TestOptionalUser.new(email: "test@example.com", age: nil) + user.valid?.should be_true + user.age.should be_nil + end + + it "supports default values" do + user = TestDefaultUser.new(email: "test@example.com", role: "user") + user.role.should eq("user") + user.valid?.should be_true + end + end + + describe "validation" do + it "validates string length" do + # Valid + user1 = TestLengthUser.new(username: "john") + user1.valid?.should be_true + + # Too short + user2 = TestLengthUser.new(username: "ab") + user2.valid?.should be_false + user2.errors[:username].should_not be_empty + + # Too long + user3 = TestLengthUser.new(username: "a" * 25) + user3.valid?.should be_false + user3.errors[:username].should_not be_empty + end + + it "validates string format/regex" do + # Valid + user1 = TestFormatUser.new(email: "test@example.com", username: "john_doe") + user1.valid?.should be_true + + # Invalid email + user2 = TestFormatUser.new(email: "invalid", username: "john_doe") + user2.valid?.should be_false + user2.errors[:email].should_not be_empty + + # Invalid username + user3 = TestFormatUser.new(email: "test@example.com", username: "john-doe") + user3.valid?.should be_false + user3.errors[:username].should_not be_empty + end + + it "validates numeric ranges" do + # Valid + user1 = TestNumericUser.new(age: 25, rating: 3) + user1.valid?.should be_true + + # Invalid age (negative) + user2 = TestNumericUser.new(age: -5, rating: 3) + user2.valid?.should be_false + user2.errors[:age].should_not be_empty + + # Invalid age (too high) + user3 = TestNumericUser.new(age: 150, rating: 3) + user3.valid?.should be_false + user3.errors[:age].should_not be_empty + + # Invalid rating + user4 = TestNumericUser.new(age: 25, rating: 10) + user4.valid?.should be_false + user4.errors[:rating].should_not be_empty + end + + it "validates one_of constraint" do + # Valid + user1 = TestRoleUser.new(role: "admin") + user1.valid?.should be_true + + user2 = TestRoleUser.new(role: "user") + user2.valid?.should be_true + + # Invalid + user3 = TestRoleUser.new(role: "superadmin") + user3.valid?.should be_false + user3.errors[:role].should_not be_empty + end + + it "supports custom validation methods" do + # Valid + user1 = TestCustomUser.new(email: "test@agency.gov", age: 25) + user1.valid?.should be_true + + # Invalid (underage gov email) + user2 = TestCustomUser.new(email: "test@agency.gov", age: 16) + user2.valid?.should be_false + user2.errors[:email].should contain("Government emails require age 18+") + end + + it "collects multiple validation errors" do + user = TestMultiErrorUser.new(email: "bad", username: "ab") + user.valid?.should be_false + user.errors[:email].size.should eq(2) + user.errors[:username].size.should eq(1) + end + + it "supports validate! method that raises on error" do + # Valid - should not raise + user1 = TestRaisingUser.new(email: "test@example.com") + user1.validate!.should be_true + + # Invalid - should raise + user2 = TestRaisingUser.new(email: "invalid") + expect_raises(Schematics::ValidationError) do + user2.validate! + end + end + end + + describe "JSON serialization" do + it "serializes to JSON" do + user = TestSerializableUser.new(email: "test@example.com", username: "testuser", age: 25) + json = user.to_json + + json.should contain("test@example.com") + json.should contain("testuser") + json.should contain("25") + end + + it "deserializes from JSON" do + json_data = %({ + "email": "bob@example.com", + "username": "bob_builder", + "age": 35 + }) + + user = TestDeserializableUser.from_json(json_data) + user.email.should eq("bob@example.com") + user.username.should eq("bob_builder") + user.age.should eq(35) + end + + it "handles nilable fields in JSON" do + # With nil age + user1 = TestNilableJsonUser.new(email: "test@example.com", age: nil) + json1 = user1.to_json + json1.should contain("test@example.com") + + # Deserialize with missing age + json_data = %({"email": "test2@example.com"}) + user2 = TestNilableJsonUser.from_json(json_data) + user2.email.should eq("test2@example.com") + user2.age.should be_nil + end + + it "round-trips through JSON" do + original = TestRoundtripUser.new( + email: "test@example.com", + username: "testuser", + age: 30, + active: true + ) + + json = original.to_json + restored = TestRoundtripUser.from_json(json) + + restored.email.should eq(original.email) + restored.username.should eq(original.username) + restored.age.should eq(original.age) + restored.active.should eq(original.active) + end + end + + describe "property access" do + it "provides getter/setter methods" do + user = TestAccessUser.new(email: "test@example.com", age: 25) + + # Getters + user.email.should eq("test@example.com") + user.age.should eq(25) + + # Setters + user.email = "new@example.com" + user.age = 30 + + user.email.should eq("new@example.com") + user.age.should eq(30) + end + end + + describe "type safety" do + it "enforces field types" do + user = TestTypedUser.new(email: "test@example.com", age: 25, active: true) + + # These should compile with correct types + email_var : String = user.email + age_var : Int32 = user.age + active_var : Bool = user.active + + email_var.should be_a(String) + age_var.should be_a(Int32) + active_var.should be_a(Bool) + end + + it "handles nilable types correctly" do + user = TestNilableTypedUser.new(email: "test@example.com", age: nil) + + email_var : String = user.email + age_var : Int32? = user.age + + email_var.should be_a(String) + age_var.should be_nil + end + end + + describe "validator helpers" do + it "supports gt/lt validators" do + user1 = TestComparisonUser.new(score: 50) + user1.valid?.should be_true + + user2 = TestComparisonUser.new(score: 0) + user2.valid?.should be_false + + user3 = TestComparisonUser.new(score: 100) + user3.valid?.should be_false + end + end + + describe "error handling" do + it "clears errors on re-validation" do + user = TestClearErrorsUser.new(email: "invalid") + user.valid?.should be_false + user.errors.should_not be_empty + + # Fix the email and re-validate + user.email = "valid@example.com" + user.valid?.should be_true + user.errors.should be_empty + end + + it "provides field-level error messages" do + user = TestErrorMessagesUser.new(email: "invalid", username: "ab") + user.valid?.should be_false + + user.errors[:email].should_not be_empty + user.errors[:username].should_not be_empty + user.errors[:email].first.should contain("format") + user.errors[:username].first.should contain("3 characters") + end + end +end From b22054061c3316592d811f8eef986a0ee90b306b Mon Sep 17 00:00:00 2001 From: Alvaro Frias Date: Mon, 8 Dec 2025 17:28:56 -0300 Subject: [PATCH 3/5] update readme Signed-off-by: Alvaro Frias --- README.md | 339 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 328 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ef77886..db39590 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,12 @@ A modern data validation library for Crystal with rich error reporting and type ## Features +- **Pydantic-Style Models**: Declarative model definitions with automatic validation - **Type Safe**: Leverages Crystal's compile-time type system +- **Rich Validators**: Built-in validators for common use cases (length, format, ranges, etc.) - **Custom Validators**: Fluent API for adding constraints and rules +- **JSON Serialization**: Automatic `to_json` and `from_json` methods +- **High Performance**: ~2μs per validation with zero runtime overhead - **Composable**: Build complex schemas from simple validators ## Installation @@ -22,26 +26,335 @@ A modern data validation library for Crystal with rich error reporting and type ## Quick Start +### Model DSL (Recommended) + +Define Pydantic-style models with declarative field definitions: + ```crystal require "schematics" -# Basic type validation -schema = Schematics::Schema(String).new -result = schema.validate("hello") +class User < Schematics::Model + field email, String, + required: true, + validators: [ + Schematics.min_length(5), + Schematics.format(/@/), + ] + + field username, String, + required: true, + validators: [Schematics.min_length(3)] + + field age, Int32?, + validators: [ + Schematics.gte(0), + Schematics.lte(120), + ] + + field role, String, + default: "user", + validators: [Schematics.one_of(["admin", "user", "guest"])] +end + +# Create and validate +user = User.new( + email: "john@example.com", + username: "john_doe", + age: 25, + role: "user" +) + +user.valid? # => true +user.errors # => {} + +# JSON serialization +json = user.to_json +# => {"email":"john@example.com","username":"john_doe","age":25,"role":"user"} + +# JSON deserialization +user = User.from_json(json) +``` + +For simpler use cases without models, see the [Schema-Based Validation](#schema-based-validation) section below. + +## Model DSL + +The Model DSL provides a Pydantic-style declarative approach to defining data models with automatic validation, JSON serialization, and type safety. + +### Defining Models + +```crystal +class Product < Schematics::Model + field name, String, + required: true, + validators: [ + Schematics.min_length(1), + Schematics.max_length(100), + ] + + field price, Float64, + required: true, + validators: [Schematics.gt(0)] + + field quantity, Int32, + default: 0, + validators: [Schematics.gte(0)] + + field category, String, + validators: [Schematics.one_of(["electronics", "books", "clothing"])] + + field tags, Array(String)?, + default: nil +end +``` + +### Built-in Validators + +#### String Validators + +```crystal +# Length constraints +Schematics.min_length(5) # Minimum 5 characters +Schematics.max_length(100) # Maximum 100 characters + +# Pattern matching +Schematics.format(/@/) # Must contain '@' +Schematics.matches(/^[a-z]+$/) # Only lowercase letters + +# Value constraints +Schematics.one_of(["admin", "user", "guest"]) # Must be one of these values +``` + +#### Numeric Validators + +```crystal +# Comparison operators +Schematics.gte(0) # Greater than or equal to 0 +Schematics.lte(120) # Less than or equal to 120 +Schematics.gt(0) # Greater than 0 +Schematics.lt(100) # Less than 100 + +# Range constraint +Schematics.range(1, 5) # Between 1 and 5 (inclusive) +``` + +### Field Options + +```crystal +class User < Schematics::Model + # Required field (must be provided at initialization) + field email, String, required: true + + # Optional/nilable field + field phone, String? + + # Field with default value + field role, String, default: "user" + + # Field with validators + field age, Int32, + validators: [Schematics.gte(0), Schematics.lte(120)] + + # Combining options + field username, String, + required: true, + validators: [ + Schematics.min_length(3), + Schematics.max_length(20), + Schematics.matches(/^[a-zA-Z0-9_]+$/), + ] +end +``` + +### Custom Validation + +Override the `validate_model` method for custom validation logic: + +```crystal +class Account < Schematics::Model + field email, String, required: true + field age, Int32? + field account_type, String + + def validate_model + # Custom cross-field validation + if age_val = age + if age_val < 18 && account_type == "premium" + add_error(:account_type, "Premium accounts require age 18+") + end + end + + # Custom email domain validation + if email.ends_with?(".gov") && account_type != "government" + add_error(:email, "Government emails require government account type") + end + end +end +``` + +### Validation Methods + +```crystal +user = User.new(email: "test@example.com", username: "john") + +# Check if valid (returns Bool) +user.valid? # => true/false -if result.valid? - puts "Valid!" +# Get validation errors +user.errors # => Hash(Symbol, Array(String)) +# Example: {:email => ["must contain @"], :age => ["must be >= 0"]} + +# Validate and raise on error +user.validate! # Raises Schematics::ValidationError if invalid +``` + +### JSON Serialization + +Models automatically get `to_json` and `from_json` methods: + +```crystal +class Article < Schematics::Model + field title, String + field published, Bool + field views, Int32 +end + +# To JSON +article = Article.new(title: "Hello World", published: true, views: 100) +json = article.to_json +# => {"title":"Hello World","published":true,"views":100} + +# From JSON +article = Article.from_json(json) +article.title # => "Hello World" +article.published # => true +article.views # => 100 +``` + +### Type Safety + +Models provide compile-time type checking: + +```crystal +class Post < Schematics::Model + field title, String + field likes, Int32 + field active, Bool +end + +post = Post.new(title: "Hi", likes: 10, active: true) + +# These are type-safe at compile time +title : String = post.title # ✓ OK +likes : Int32 = post.likes # ✓ OK +active : Bool = post.active # ✓ OK + +# Property modification +post.title = "New Title" +post.likes = 20 +``` + +### Working with Nilable Fields + +```crystal +class Profile < Schematics::Model + field name, String, required: true + field bio, String? # Optional, defaults to nil + field age, Int32? # Optional, defaults to nil + field avatar, String? # Optional, defaults to nil +end + +profile = Profile.new(name: "Alice", bio: nil, age: 25, avatar: nil) + +# Safe access to nilable fields +if bio = profile.bio + puts "Bio: #{bio}" else - result.errors.each { |error| puts error } + puts "No bio" end -# Or use the boolean shortcut -schema.valid?("hello") # => true +# Or use try +profile.bio.try { |b| puts "Bio: #{b}" } ``` -## Usage Examples +### Complete Example + +```crystal +class BlogPost < Schematics::Model + field title, String, + required: true, + validators: [ + Schematics.min_length(5), + Schematics.max_length(200), + ] + + field content, String, + required: true, + validators: [Schematics.min_length(10)] + + field author, String, + required: true + + field tags, Array(String)?, + default: nil + + field status, String, + default: "draft", + validators: [Schematics.one_of(["draft", "published", "archived"])] + + field views, Int32, + default: 0, + validators: [Schematics.gte(0)] + + field published_at, String? + + def validate_model + # Custom validation: published posts must have published_at + if status == "published" && published_at.nil? + add_error(:published_at, "Published posts must have a published date") + end + end +end + +# Create a blog post +post = BlogPost.new( + title: "Getting Started with Crystal", + content: "Crystal is a statically typed language...", + author: "Alice", + tags: nil, + status: "draft", + views: 0, + published_at: nil +) + +if post.valid? + puts "Post is valid!" + puts post.to_json +else + puts "Validation errors:" + post.errors.each do |field, messages| + puts " #{field}: #{messages.join(", ")}" + end +end +``` + +### Performance + +The Model DSL uses compile-time macros for zero runtime overhead: + +```crystal +# Validation performance +10_000.times do + user = User.new(email: "test@example.com", username: "test", age: 25, role: "user") + user.valid? +end +``` + +## Schema-Based Validation + +For simpler use cases without models, use the schema API directly: -### Basic Types +### Schema Types ```crystal # String validation @@ -221,7 +534,11 @@ Schemas::POSITIVE_INT.validate(42) - [x] Custom validators - [x] Rich error reporting - [x] Min/max constraints -- [ ] Model DSL (Pydantic-style classes) +- [x] Model DSL (Pydantic-style classes) +- [x] JSON serialization/deserialization +- [x] Built-in validators (length, format, ranges, one_of) +- [x] Custom validation methods +- [ ] Struct support in Model DSL - [ ] Type coercion - [ ] JSON Schema export - [ ] Async validation From 17e0f1e3a4f86473c5ee9e1ca0469403584f6f3b Mon Sep 17 00:00:00 2001 From: Alvaro Frias Date: Mon, 8 Dec 2025 17:31:16 -0300 Subject: [PATCH 4/5] 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 c864a7b..136d54a 100644 --- a/shard.yml +++ b/shard.yml @@ -1,6 +1,6 @@ name: schematics description: a library to validate data using schemas -version: 0.2.0 +version: 0.3.0 authors: - Alvaro Frias Garay From a0f118bd994e971d59b8384fba7beded136c86a7 Mon Sep 17 00:00:00 2001 From: Alvaro Frias Date: Mon, 8 Dec 2025 17:33:12 -0300 Subject: [PATCH 5/5] version Signed-off-by: Alvaro Frias --- src/schematics.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schematics.cr b/src/schematics.cr index 4ddd513..a9a601b 100644 --- a/src/schematics.cr +++ b/src/schematics.cr @@ -8,5 +8,5 @@ require "./schema" require "./model" module Schematics - VERSION = "0.2.0" + VERSION = "0.3.0" end