From 2ac51e39408b9770b677ba267b02a508e8ffaa77 Mon Sep 17 00:00:00 2001 From: Paulo Coghi Date: Thu, 9 Jul 2026 12:14:57 +0200 Subject: [PATCH 1/8] WIP --- spec/adapter/memory_spec.cr | 52 ++++++++++++- spec/schema/base_spec.cr | 136 +++++++++++++--------------------- spec/schema/validator_spec.cr | 129 +++++++++++++++++++++----------- src/adapter/memory.cr | 34 +++++++++ src/schema/base.cr | 60 +++++++++++---- src/schema/validator.cr | 22 ++++-- src/service/query.cr | 4 +- 7 files changed, 282 insertions(+), 155 deletions(-) diff --git a/spec/adapter/memory_spec.cr b/spec/adapter/memory_spec.cr index 082bada..1647001 100644 --- a/spec/adapter/memory_spec.cr +++ b/spec/adapter/memory_spec.cr @@ -11,6 +11,56 @@ Alumna::Testing::AdapterSuite.run("Alumna::MemoryAdapter") do .int("score").float("price").int("order_index").str("category").bool("is_published") .str("title", required: false).str("sequence", required: false) .str("first_name", required: false).str("last_name", required: false) - .int("view_count", required: false).nullable("metadata", required: false) + .int("view_count", required: false).any("metadata", nullable: true, required: false) ) end + +describe "MemoryAdapter Unique Constraints" do + schema = Alumna::Schema.new + .str("email", unique: true) + .str("username", unique: true) + .str("bio", required: false) + + it "enforces uniqueness on create" do + adapter = Alumna::MemoryAdapter.new(schema) + + # 1. First insert should succeed + ctx1 = Alumna::Testing.build_ctx(service: adapter, method: Alumna::ServiceMethod::Create, data: {"email" => "alice@a.com", "username" => "alice"} of String => Alumna::AnyData) + res1 = adapter.create(ctx1) + res1.should be_a(Hash(String, Alumna::AnyData)) + + # 2. Duplicate email should fail + ctx2 = Alumna::Testing.build_ctx(service: adapter, method: Alumna::ServiceMethod::Create, data: {"email" => "alice@a.com", "username" => "bob"} of String => Alumna::AnyData) + res2 = adapter.create(ctx2) + res2.should be_a(Alumna::ServiceError) + res2.as(Alumna::ServiceError).status.should eq(422) + res2.as(Alumna::ServiceError).details["email"].should eq("already exists") + + # 3. Duplicate username should fail + ctx3 = Alumna::Testing.build_ctx(service: adapter, method: Alumna::ServiceMethod::Create, data: {"email" => "bob@a.com", "username" => "alice"} of String => Alumna::AnyData) + res3 = adapter.create(ctx3) + res3.as(Alumna::ServiceError).details["username"].should eq("already exists") + end + + it "enforces uniqueness on update and patch, safely skipping its own ID" do + adapter = Alumna::MemoryAdapter.new(schema) + + # Setup + c_ctx1 = Alumna::Testing.build_ctx(service: adapter, method: Alumna::ServiceMethod::Create, data: {"email" => "alice@a.com", "username" => "alice"} of String => Alumna::AnyData) + c_ctx2 = Alumna::Testing.build_ctx(service: adapter, method: Alumna::ServiceMethod::Create, data: {"email" => "bob@a.com", "username" => "bob"} of String => Alumna::AnyData) + id1 = adapter.create(c_ctx1).as(Hash(String, Alumna::AnyData))["id"].as(String) + id2 = adapter.create(c_ctx2).as(Hash(String, Alumna::AnyData))["id"].as(String) + + # 1. Patching to an existing email (owned by someone else) should fail + p_ctx1 = Alumna::Testing.build_ctx(service: adapter, method: Alumna::ServiceMethod::Patch, id: id2, data: {"email" => "alice@a.com"} of String => Alumna::AnyData) + res1 = adapter.patch(p_ctx1) + res1.should be_a(Alumna::ServiceError) + res1.as(Alumna::ServiceError).status.should eq(422) + + # 2. Patching a record WITHOUT changing its unique fields (or updating to the same value) MUST succeed + p_ctx2 = Alumna::Testing.build_ctx(service: adapter, method: Alumna::ServiceMethod::Patch, id: id1, data: {"email" => "alice@a.com", "bio" => "new bio"} of String => Alumna::AnyData) + res2 = adapter.patch(p_ctx2) + res2.should be_a(Hash(String, Alumna::AnyData)) + res2.as(Hash(String, Alumna::AnyData))["bio"].should eq("new bio") + end +end diff --git a/spec/schema/base_spec.cr b/spec/schema/base_spec.cr index d93fadd..2ae53d9 100644 --- a/spec/schema/base_spec.cr +++ b/spec/schema/base_spec.cr @@ -6,6 +6,7 @@ describe Alumna::Schema do s = Alumna::Schema.new s.fields.should be_empty s.strict.should be_true + s.schema_indexes.should be_empty end it "accepts strict: false" do @@ -36,9 +37,54 @@ describe Alumna::Schema do fd.max_length.should eq(10) end - it "stores read_only flag" do - fd = Alumna::Schema.new.field("x", :str, read_only: true).fields.first + it "stores traits (read_only, nullable, unique, indexed)" do + fd = Alumna::Schema.new.field("x", :str, read_only: true, nullable: true, unique: true, indexed: true).fields.first fd.read_only.should be_true + fd.nullable.should be_true + fd.unique.should be_true + fd.indexed.should be_true + end + end + + describe "default values" do + it "identifies when a default is not provided" do + fd = Alumna::Schema.new.field("x", :str).fields.first + fd.has_default.should be_false + fd.default_value.should be_nil + end + + it "identifies when a default is explicitly provided as nil" do + fd = Alumna::Schema.new.field("x", :str, default: nil).fields.first + fd.has_default.should be_true + fd.default_value.should be_nil + end + + it "stores and returns static default values" do + fd = Alumna::Schema.new.field("x", :int, default: 42_i64).fields.first + fd.has_default.should be_true + fd.default_value.should eq(42_i64) + end + + it "stores and evaluates dynamic default Procs at runtime" do + fd = Alumna::Schema.new.field("x", :str, default: -> { "dynamic".as(Alumna::AnyData) }).fields.first + fd.has_default.should be_true + fd.default_value.should eq("dynamic") + end + end + + describe "schema-level indexes" do + it "stores single field indexes" do + s = Alumna::Schema.new.index("email", unique: true) + idx = s.schema_indexes.first + idx.fields.should eq(["email"]) + idx.unique.should be_true + end + + it "stores compound field indexes" do + s = Alumna::Schema.new.index(["user_id", "status"]) + idx = s.schema_indexes.first + idx.fields.should eq(["user_id", "status"]) + idx.unique.should be_false end end @@ -51,21 +97,6 @@ describe Alumna::Schema do fd.format_message.should eq("must be a valid email address") end - it "normalizes strings and capitalized symbols" do - s1 = Alumna::Schema.new.field("x", :str, format: :url) - s1.fields.first.format_name.should eq("url") - - s2 = Alumna::Schema.new.str("y", format: "Uuid") - s2.fields.first.format_name.should eq("uuid") - end - - it "allows nil" do - fd = Alumna::Schema.new.str("n").fields.first - fd.format_name.should be_nil - fd.format_validator.should be_nil - fd.format_message.should be_nil - end - it "raises for unknown format" do expect_raises(ArgumentError, /Unknown format/) do Alumna::Schema.new.str("x", format: :not_a_format) @@ -78,33 +109,6 @@ describe Alumna::Schema do fd = Alumna::Schema.new.str("t", required_on: [:create, :update]).fields.first fd.required_on.should eq([Alumna::ServiceMethod::Create, Alumna::ServiceMethod::Update]) end - - it "normalizes single symbol" do - fd = Alumna::Schema.new.str("t", required_on: :patch).fields.first - fd.required_on.should eq([Alumna::ServiceMethod::Patch]) - end - - it "normalizes single enum" do - fd = Alumna::Schema.new.str("t", required_on: Alumna::ServiceMethod::Create).fields.first - fd.required_on.should eq([Alumna::ServiceMethod::Create]) - end - - it "normalizes mixed symbols and enums" do - fd = Alumna::Schema.new.str("t", - required_on: [Alumna::ServiceMethod::Patch, :remove] - ).fields.first - fd.required_on.should eq([Alumna::ServiceMethod::Patch, Alumna::ServiceMethod::Remove]) - end - - it "accepts nil" do - Alumna::Schema.new.str("t").fields.first.required_on.should be_nil - end - - it "raises for unknown method" do - expect_raises(ArgumentError, /Unknown enum Alumna::ServiceMethod/) do - Alumna::Schema.new.str("x", required_on: [:bogus]) - end - end end describe "helpers" do @@ -114,7 +118,7 @@ describe Alumna::Schema do .int("b", required: false) .float("c") .bool("d") - .nullable("e", required_on: [:patch]) + .any("e", nullable: true) .time("f") .bytes("g") @@ -123,50 +127,12 @@ describe Alumna::Schema do Alumna::FieldType::Int, Alumna::FieldType::Float, Alumna::FieldType::Bool, - Alumna::FieldType::Nullable, + Alumna::FieldType::Any, Alumna::FieldType::Time, Alumna::FieldType::Bytes, ]) s.fields[1].required.should be_false - s.fields[4].required_on.should eq([Alumna::ServiceMethod::Patch]) - end - end - - describe "chaining and builder" do - it "returns self" do - s = Alumna::Schema.new - s.str("a").int("b").should be(s) - end - - it "builds via block and preserves order" do - s = Alumna::Schema.build do |sc| - sc.str("first") - sc.int("second") - end - s.fields.map(&.name).should eq(["first", "second"]) - end - - it "builds via block with strict flag" do - s = Alumna::Schema.build(strict: false) do |sc| - sc.str("first") - end - s.strict.should be_false - end - end - - describe "error paths for type" do - it "raises for unknown type" do - expect_raises(ArgumentError, /Unknown enum Alumna::FieldType/) do - Alumna::Schema.new.field("x", :nope) - end - end - end - - describe "validator integration (kept from your original)" do - it "validates format when given as symbol" do - schema = Alumna::Schema.new.str("email", format: :email) - errors = schema.validate({"email" => "not-an-email"} of String => Alumna::AnyData) - errors.first.message.should eq("must be a valid email address") + s.fields[4].nullable.should be_true end end end diff --git a/spec/schema/validator_spec.cr b/spec/schema/validator_spec.cr index 73c8da6..3de50b9 100644 --- a/spec/schema/validator_spec.cr +++ b/spec/schema/validator_spec.cr @@ -129,66 +129,109 @@ describe Alumna::Schema do end end - # ── Type checking ───────────────────────────────────────────────────────────── + describe "Nullability" do + schema = Alumna::Schema.new + .str("non_null", nullable: false) + .str("is_null", nullable: true) + .any("untyped", nullable: true) - describe "Str type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Str) + it "rejects explicit null for non-nullable fields" do + error_on(schema, {"non_null" => any_nil, "is_null" => any("ok")}, "non_null").should eq("cannot be null") + end - it "accepts a string" { errors_for(schema, {"v" => any("hello")}).should be_empty } - it "rejects an integer" { error_on(schema, {"v" => any(1_i64)}, "v").should eq("must be a string") } - it "rejects a bool" { error_on(schema, {"v" => any(true)}, "v").should eq("must be a string") } + it "accepts explicit null for nullable fields" do + errors_for(schema, {"non_null" => any("ok"), "is_null" => any_nil, "untyped" => any_nil}).should be_empty + end + + it "allows Any field to accept various types" do + errors_for(schema, {"non_null" => any("ok"), "untyped" => any(123_i64)}).should be_empty + errors_for(schema, {"non_null" => any("ok"), "untyped" => any(true)}).should be_empty + end end - describe "Int type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Int) + describe "Default Value Injection" do + schema = Alumna::Schema.new + .int("score", default: 100_i64) + .str("status", default: "active") + .time("created_at", default: -> { Time.utc.as(Alumna::AnyData) }) + .str("note", required: false) + + it "injects defaults during create if field is omitted, waiving required check" do + data = empty_data + errs = errors_for(schema, data, Alumna::ServiceMethod::Create) - it "accepts an integer" { errors_for(schema, {"v" => any(42_i64)}).should be_empty } - it "rejects a string" { error_on(schema, {"v" => any("42")}, "v").should eq("must be an integer") } - it "rejects a bool" { error_on(schema, {"v" => any(false)}, "v").should eq("must be an integer") } - end + errs.should be_empty + data["score"].should eq(100_i64) + data["status"].should eq("active") + data["created_at"].as(Time).should be_close(Time.utc, 1.second) + end - describe "Float type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Float) + it "does NOT inject defaults if client explicitly provides a value" do + data = {"score" => any(50_i64), "status" => any("pending")} + errors_for(schema, data, Alumna::ServiceMethod::Create).should be_empty - it "accepts a float" { errors_for(schema, {"v" => any(3.14)}).should be_empty } - it "accepts an integer (coercible)" { errors_for(schema, {"v" => any(3_i64)}).should be_empty } - it "rejects a string" { error_on(schema, {"v" => any("3.14")}, "v").should eq("must be a number") } - end + data["score"].should eq(50_i64) + data["status"].should eq("pending") + end - describe "Bool type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Bool) + it "does NOT inject defaults during patch operations" do + data = {"note" => any("updating note only")} + errs = errors_for(schema, data, Alumna::ServiceMethod::Patch) - it "accepts true" { errors_for(schema, {"v" => any(true)}).should be_empty } - it "accepts false" { errors_for(schema, {"v" => any(false)}).should be_empty } - it "rejects a string" { error_on(schema, {"v" => any("true")}, "v").should eq("must be true or false") } - it "rejects an int" { error_on(schema, {"v" => any(1_i64)}, "v").should eq("must be true or false") } + errs.should be_empty + data.has_key?("score").should be_false + data.has_key?("status").should be_false + end end - describe "Time type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Time) + # ── Type checking ───────────────────────────────────────────────────────────── + + describe "Type checking" do + it "Str type" do + schema = Alumna::Schema.new.field("v", Alumna::FieldType::Str) - it "accepts a time" { errors_for(schema, {"v" => any(Time.utc)}).should be_empty } - it "rejects a string" { error_on(schema, {"v" => any("2024-01-01")}, "v").should eq("must be a time") } - end + it "accepts a string" { errors_for(schema, {"v" => any("hello")}).should be_empty } + it "rejects an integer" { error_on(schema, {"v" => any(1_i64)}, "v").should eq("must be a string") } + it "rejects a bool" { error_on(schema, {"v" => any(true)}, "v").should eq("must be a string") } + end - describe "Bytes type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Bytes) + it "Int type" do + schema = Alumna::Schema.new.field("v", Alumna::FieldType::Int) - it "accepts bytes" { errors_for(schema, {"v" => any(Bytes[1, 2])}).should be_empty } - it "rejects an array" { error_on(schema, {"v" => any([any(1_i64)])}, "v").should eq("must be bytes") } - end + it "accepts an integer" { errors_for(schema, {"v" => any(42_i64)}).should be_empty } + it "rejects a string" { error_on(schema, {"v" => any("42")}, "v").should eq("must be an integer") } + it "rejects a bool" { error_on(schema, {"v" => any(false)}, "v").should eq("must be an integer") } + end + + it "Float type" do + schema = Alumna::Schema.new.field("v", Alumna::FieldType::Float) + + it "accepts a float" { errors_for(schema, {"v" => any(3.14)}).should be_empty } + it "accepts an integer (coercible)" { errors_for(schema, {"v" => any(3_i64)}).should be_empty } + it "rejects a string" { error_on(schema, {"v" => any("3.14")}, "v").should eq("must be a number") } + end - describe "Nullable type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Nullable, required: false) + it "Bool type" do + schema = Alumna::Schema.new.field("v", Alumna::FieldType::Bool) - it "accepts any value when present" do - errors_for(schema, {"v" => any("anything")}).should be_empty - errors_for(schema, {"v" => any(1_i64)}).should be_empty - errors_for(schema, {"v" => any(true)}).should be_empty + it "accepts true" { errors_for(schema, {"v" => any(true)}).should be_empty } + it "accepts false" { errors_for(schema, {"v" => any(false)}).should be_empty } + it "rejects a string" { error_on(schema, {"v" => any("true")}, "v").should eq("must be true or false") } + it "rejects an int" { error_on(schema, {"v" => any(1_i64)}, "v").should eq("must be true or false") } end - it "does not require the field" do - errors_for(schema, empty_data).should be_empty + it "Time type" do + schema = Alumna::Schema.new.field("v", Alumna::FieldType::Time) + + it "accepts a time" { errors_for(schema, {"v" => any(Time.utc)}).should be_empty } + it "rejects a string" { error_on(schema, {"v" => any("2024-01-01")}, "v").should eq("must be a time") } + end + + it "Bytes type" do + schema = Alumna::Schema.new.field("v", Alumna::FieldType::Bytes) + + it "accepts bytes" { errors_for(schema, {"v" => any(Bytes[1, 2])}).should be_empty } + it "rejects an array" { error_on(schema, {"v" => any([any(1_i64)])}, "v").should eq("must be bytes") } end end @@ -259,7 +302,7 @@ describe Alumna::Schema do describe "edge cases" do it "requires a Nullable field when missing, but accepts null" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Nullable, required: true) + schema = Alumna::Schema.new.field("v", Alumna::FieldType::Any, nullable: true, required: true) error_fields(schema, empty_data).should contain("v") errors_for(schema, {"v" => any_nil}).should be_empty end diff --git a/src/adapter/memory.cr b/src/adapter/memory.cr index d5bd540..a5fc2fa 100644 --- a/src/adapter/memory.cr +++ b/src/adapter/memory.cr @@ -106,6 +106,26 @@ module Alumna end end + # Check for unique constraint violations (called inside the mutex lock) + private def check_unique(record : Hash(String, AnyData), skip_id : String? = nil) : ServiceError? + return nil unless sch = @schema + + sch.fields.each do |fd| + next unless fd.unique + val = extract_value(record, fd.name) + next if val.nil? + + conflict = @store.values.any? do |existing| + existing["id"] != skip_id && extract_value(existing, fd.name) == val + end + + if conflict + return ServiceError.unprocessable("Unique constraint violation", {fd.name => "already exists"} of String => AnyData) + end + end + nil + end + def find(ctx : RuleContext) : Array(Hash(String, AnyData)) | ServiceError q = ctx.query filters = q.typed_filters(schema) @@ -170,6 +190,10 @@ module Alumna def create(ctx : RuleContext) : Hash(String, AnyData) | ServiceError record = ctx.data.dup @mutex.synchronize do + if err = check_unique(record) + return err + end + id = @next_id.to_s @next_id += 1 record["id"] = id @@ -187,6 +211,11 @@ module Alumna @mutex.synchronize do return ServiceError.not_found unless @store.has_key?(id) + + if err = check_unique(record, skip_id: id) + return err + end + @store[id] = record end record @@ -202,6 +231,11 @@ module Alumna record = existing.merge(ctx.data) record["id"] = id + + if err = check_unique(record, skip_id: id) + return err + end + @store[id] = record record end diff --git a/src/schema/base.cr b/src/schema/base.cr index 19f1fad..8f297b1 100644 --- a/src/schema/base.cr +++ b/src/schema/base.cr @@ -1,10 +1,16 @@ require "set" module Alumna + # Sentinel struct to distinguish omitted arguments from explicit nils + struct Unprovided; end + struct FieldDescriptor getter name : String getter type : FieldType getter required : Bool + getter nullable : Bool + getter unique : Bool + getter indexed : Bool getter read_only : Bool getter required_on : Array(ServiceMethod)? getter min_length : Int32? @@ -15,10 +21,16 @@ module Alumna getter sub_schema : Schema? getter element_type : FieldType? + getter has_default : Bool + @default_value : AnyData | Proc(AnyData) | Nil + def initialize( @name : String, @type : FieldType, @required : Bool = true, + @nullable : Bool = false, + @unique : Bool = false, + @indexed : Bool = false, @read_only : Bool = false, @min_length : Int32? = nil, @max_length : Int32? = nil, @@ -28,18 +40,30 @@ module Alumna @required_on : Array(ServiceMethod)? = nil, @sub_schema : Schema? = nil, @element_type : FieldType? = nil, + default : AnyData | Proc(AnyData) | Unprovided | Nil = Unprovided.new, ) + @has_default = !default.is_a?(Unprovided) + @default_value = default.is_a?(Unprovided) ? nil : default.as(AnyData | Proc(AnyData) | Nil) + end + + def default_value : AnyData + val = @default_value + val.is_a?(Proc(AnyData)) ? val.call : val end end + # Nullable is replaced by Any. Nullability is now a trait on all fields. enum FieldType - Str; Int; Float; Bool; Time; Bytes; Nullable; Hash; Array + Str; Int; Float; Bool; Time; Bytes; Any; Hash; Array end + record IndexDef, fields : Array(String), unique : Bool + class Schema getter fields : Array(FieldDescriptor) getter strict : Bool getter field_names : Set(String) + getter schema_indexes : Array(IndexDef) protected getter fields_by_name : Hash(String, FieldDescriptor) @@ -47,6 +71,7 @@ module Alumna @fields = [] of FieldDescriptor @field_names = Set(String).new @fields_by_name = {} of String => FieldDescriptor + @schema_indexes = [] of IndexDef end private def resolve_format(format : Symbol | String | Nil) : {String?, Proc(String, Bool)?, String?} @@ -67,10 +92,19 @@ module Alumna end end + def index(fields : Array(String) | String, unique : Bool = false) : self + arr = fields.is_a?(String) ? [fields] : fields + @schema_indexes << IndexDef.new(arr, unique) + self + end + def field( name : String, type : FieldType | Symbol, required : Bool = true, + nullable : Bool = false, + unique : Bool = false, + indexed : Bool = false, read_only : Bool = false, min_length : Int32? = nil, max_length : Int32? = nil, @@ -78,18 +112,16 @@ module Alumna required_on : ServiceMethod | Symbol | Array(ServiceMethod | Symbol) | Nil = nil, sub_schema : Schema? = nil, element_type : FieldType? = nil, + default : AnyData | Proc(AnyData) | Unprovided | Nil = Unprovided.new, ) : self field_type = type.is_a?(Symbol) ? FieldType.parse(type.to_s.capitalize) : type - format_name, format_validator, format_message = resolve_format(format) norm_required_on = case required_on in Nil nil in Array - required_on.map do |m| - m.is_a?(ServiceMethod) ? m : ServiceMethod.parse(m.to_s.capitalize) - end + required_on.map { |m| m.is_a?(ServiceMethod) ? m : ServiceMethod.parse(m.to_s.capitalize) } in ServiceMethod [required_on] in Symbol @@ -100,6 +132,9 @@ module Alumna name: name, type: field_type, required: required, + nullable: nullable, + unique: unique, + indexed: indexed, read_only: read_only, min_length: min_length, max_length: max_length, @@ -108,13 +143,13 @@ module Alumna format_message: format_message, required_on: norm_required_on, sub_schema: sub_schema, - element_type: element_type + element_type: element_type, + default: default ) @fields << fd @field_names << name @fields_by_name[name] = fd - self end @@ -143,26 +178,22 @@ module Alumna field(name, :bytes, **opts) end - def nullable(name, **opts) - field(name, :nullable, **opts) + def any(name, **opts) + field(name, :any, **opts) end # --- Nested helpers --- - - # For objects/hashes def hash(name : String, **opts, &block : Schema ->) sub = Schema.new(strict: @strict) yield sub field(name, :hash, **opts, sub_schema: sub) end - # For arrays of primitives def array(name : String, of : FieldType | Symbol, **opts) el_type = of.is_a?(Symbol) ? FieldType.parse(of.to_s.capitalize) : of field(name, :array, **opts, element_type: el_type.as(FieldType)) end - # For arrays of objects def array(name : String, **opts, &block : Schema ->) sub = Schema.new(strict: @strict) yield sub @@ -175,9 +206,7 @@ module Alumna field = nil parts.each_with_index do |part, i| - # Ignore array indices in query params (e.g. users[0].age -> users.age) clean_part = part.sub(/\[\d+\]/, "") - field = current_schema.fields_by_name[clean_part]? return nil unless field @@ -189,7 +218,6 @@ module Alumna end end end - field end diff --git a/src/schema/validator.cr b/src/schema/validator.cr index 1de6a7b..d3bd1ba 100644 --- a/src/schema/validator.cr +++ b/src/schema/validator.cr @@ -12,14 +12,12 @@ module Alumna def validate(data : Hash(String, AnyData), method : ServiceMethod? = nil) : Array(FieldError) errors = nil - # A single tracker instantiated once per validation to trace paths without allocations path = [] of String | Int32 errors = _validate(data, method, path, errors) errors || EMPTY_ERRORS end protected def _validate(data : Hash(String, AnyData), method : ServiceMethod?, path : Array(String | Int32), errors : Array(FieldError)?) : Array(FieldError)? - # --- Strict Check --- if @strict data.each_key do |key| unless @field_names.includes?(key) @@ -33,6 +31,15 @@ module Alumna @fields.each do |field| path.push(field.name) has_key = data.has_key?(field.name) + + # --- Inject Defaults --- + # Only inject on writes where the client omitted the field + if !has_key && field.has_default && method.try(&.create?) + injected_val = field.default_value + data[field.name] = injected_val + has_key = true + end + value = data[field.name]? # --- Read-Only Check --- @@ -49,10 +56,10 @@ module Alumna next end - # --- Explicit null --- + # --- Explicit null check --- if value.nil? - unless field.type.nullable? - errors = push_error(errors, path, "is required") if required?(field, method) + unless field.nullable + errors = push_error(errors, path, "cannot be null") end path.pop next @@ -65,7 +72,7 @@ module Alumna next end - # --- Type-specific checks (type already verified above) --- + # --- Type-specific checks --- case value when String if field.type.str? @@ -118,7 +125,6 @@ module Alumna end private def required?(field : FieldDescriptor, method : ServiceMethod?) : Bool - # A read-only field is never expected from the client during write operations return false if field.read_only && method.try(&.write?) if req_on = field.required_on @@ -132,7 +138,6 @@ module Alumna private def push_error(errors : Array(FieldError)?, path : Array(String | Int32), message : String) : Array(FieldError) arr = errors || [] of FieldError - # Generates a string like "user.address.coordinates[0]" natively field_path = String.build do |io| path.each_with_index do |part, i| if part.is_a?(Int32) @@ -158,6 +163,7 @@ module Alumna when .bytes? then value.is_a?(Bytes) ? nil : "must be bytes" when .hash? then value.is_a?(Hash(String, AnyData)) ? nil : "must be an object" when .array? then value.is_a?(Array(AnyData)) ? nil : "must be an array" + when .any? then nil else nil end end diff --git a/src/service/query.cr b/src/service/query.cr index 3bcdf4d..0ee91e0 100644 --- a/src/service/query.cr +++ b/src/service/query.cr @@ -98,7 +98,7 @@ module Alumna raw_vals.each do |v| cast_val = cast_value(v, type, fd) - return ServiceError.bad_request("Invalid type for query parameter #{key}") if cast_val.nil? && !type.nullable? + return ServiceError.bad_request("Invalid type for query parameter #{key}") if cast_val.nil? arr << cast_val end res[key] << TypedCondition.new(c.op, arr.as(AnyData)) @@ -106,7 +106,7 @@ module Alumna raw_val = cv.is_a?(Array(String)) ? cv.first : cv cast_val = cast_value(raw_val, type, fd) - return ServiceError.bad_request("Invalid type for query parameter #{key}") if cast_val.nil? && !type.nullable? + return ServiceError.bad_request("Invalid type for query parameter #{key}") if cast_val.nil? res[key] << TypedCondition.new(c.op, cast_val) end end From 6e51afaf38f7ebabb5cc0c50b457dca56a599fa6 Mon Sep 17 00:00:00 2001 From: Paulo Coghi Date: Thu, 9 Jul 2026 20:00:04 +0200 Subject: [PATCH 2/8] feat: schema indexes, primary keys, default values, nullable trait --- spec/schema/validator_spec.cr | 195 +++++++++++++++++----------------- src/schema/validator.cr | 1 - 2 files changed, 95 insertions(+), 101 deletions(-) diff --git a/spec/schema/validator_spec.cr b/spec/schema/validator_spec.cr index 3de50b9..46c4b7e 100644 --- a/spec/schema/validator_spec.cr +++ b/spec/schema/validator_spec.cr @@ -25,22 +25,14 @@ private def any(v : Bytes) : Alumna::AnyData v end -# Accepts ANY array (e.g. Array(Int64), Array(String)) and safely -# builds an Array(AnyData) to bypass compiler type-narrowing quirks. private def any(v : Array) : Alumna::AnyData - arr = [] of Alumna::AnyData - v.each { |x| arr << any(x) } - arr.as(Alumna::AnyData) + arr = [] of Alumna::AnyData; v.each { |x| arr << any(x) }; arr.as(Alumna::AnyData) end -# Accepts ANY hash and safely builds a Hash(String, AnyData) private def any(v : Hash) : Alumna::AnyData - h = {} of String => Alumna::AnyData - v.each { |k, val| h[k.to_s] = any(val) } - h.as(Alumna::AnyData) + h = {} of String => Alumna::AnyData; v.each { |k, val| h[k.to_s] = any(val) }; h.as(Alumna::AnyData) end -# Fallback for when the value is ALREADY an AnyData (e.g., from inside nested loops) private def any(v : Alumna::AnyData) : Alumna::AnyData v end @@ -76,23 +68,23 @@ describe Alumna::Schema do .field("note", Alumna::FieldType::Str, required: false) it "passes when the required field is present" do - errors_for(schema, {"name" => any("Alice")}).should be_empty + errors_for(schema, {"name" => any("Alice")} of String => Alumna::AnyData).should be_empty end it "fails when a required field is absent" do - error_fields(schema, {"note" => any("hi")}).should contain("name") + error_fields(schema, {"note" => any("hi")} of String => Alumna::AnyData).should contain("name") end it "fails when a required field is explicitly null" do - error_fields(schema, {"name" => any_nil}).should contain("name") + error_fields(schema, {"name" => any_nil} of String => Alumna::AnyData).should contain("name") end it "does not report an error for a missing optional field" do - error_fields(schema, {"name" => any("Alice")}).should_not contain("note") + error_fields(schema, {"name" => any("Alice")} of String => Alumna::AnyData).should_not contain("note") end it "reports the canonical 'is required' message" do - error_on(schema, {"note" => any("hi")}, "name").should eq("is required") + error_on(schema, ({"note" => any("hi")} of String => Alumna::AnyData), "name").should eq("is required") end end @@ -124,8 +116,7 @@ describe Alumna::Schema do end it "validates constraints only when field is present on patch" do - # title missing on patch = ok, but if provided empty, min_length fails - errors_for(schema, {"title" => any("")}, Alumna::ServiceMethod::Patch).first.message.should eq("must be at least 1 character") + errors_for(schema, ({"title" => any("")} of String => Alumna::AnyData), Alumna::ServiceMethod::Patch).first.message.should eq("must be at least 1 character") end end @@ -136,24 +127,24 @@ describe Alumna::Schema do .any("untyped", nullable: true) it "rejects explicit null for non-nullable fields" do - error_on(schema, {"non_null" => any_nil, "is_null" => any("ok")}, "non_null").should eq("cannot be null") + error_on(schema, ({"non_null" => any_nil, "is_null" => any("ok")} of String => Alumna::AnyData), "non_null").should eq("cannot be null") end it "accepts explicit null for nullable fields" do - errors_for(schema, {"non_null" => any("ok"), "is_null" => any_nil, "untyped" => any_nil}).should be_empty + errors_for(schema, {"non_null" => any("ok"), "is_null" => any_nil, "untyped" => any_nil} of String => Alumna::AnyData).should be_empty end it "allows Any field to accept various types" do - errors_for(schema, {"non_null" => any("ok"), "untyped" => any(123_i64)}).should be_empty - errors_for(schema, {"non_null" => any("ok"), "untyped" => any(true)}).should be_empty + errors_for(schema, {"non_null" => any("ok"), "is_null" => any_nil, "untyped" => any(123_i64)} of String => Alumna::AnyData).should be_empty + errors_for(schema, {"non_null" => any("ok"), "is_null" => any_nil, "untyped" => any(true)} of String => Alumna::AnyData).should be_empty end end describe "Default Value Injection" do schema = Alumna::Schema.new - .int("score", default: 100_i64) - .str("status", default: "active") - .time("created_at", default: -> { Time.utc.as(Alumna::AnyData) }) + .int("score", default: 100_i64, required_on: [:create, :update]) + .str("status", default: "active", required_on: [:create, :update]) + .time("created_at", default: -> { Time.utc.as(Alumna::AnyData) }, required_on: [:create, :update]) .str("note", required: false) it "injects defaults during create if field is omitted, waiving required check" do @@ -167,7 +158,7 @@ describe Alumna::Schema do end it "does NOT inject defaults if client explicitly provides a value" do - data = {"score" => any(50_i64), "status" => any("pending")} + data = {"score" => any(50_i64), "status" => any("pending")} of String => Alumna::AnyData errors_for(schema, data, Alumna::ServiceMethod::Create).should be_empty data["score"].should eq(50_i64) @@ -175,7 +166,7 @@ describe Alumna::Schema do end it "does NOT inject defaults during patch operations" do - data = {"note" => any("updating note only")} + data = {"note" => any("updating note only")} of String => Alumna::AnyData errs = errors_for(schema, data, Alumna::ServiceMethod::Patch) errs.should be_empty @@ -188,50 +179,44 @@ describe Alumna::Schema do describe "Type checking" do it "Str type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Str) - - it "accepts a string" { errors_for(schema, {"v" => any("hello")}).should be_empty } - it "rejects an integer" { error_on(schema, {"v" => any(1_i64)}, "v").should eq("must be a string") } - it "rejects a bool" { error_on(schema, {"v" => any(true)}, "v").should eq("must be a string") } + s = Alumna::Schema.new.field("v", Alumna::FieldType::Str) + errors_for(s, {"v" => any("hello")} of String => Alumna::AnyData).should be_empty + error_on(s, ({"v" => any(1_i64)} of String => Alumna::AnyData), "v").should eq("must be a string") + error_on(s, ({"v" => any(true)} of String => Alumna::AnyData), "v").should eq("must be a string") end it "Int type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Int) - - it "accepts an integer" { errors_for(schema, {"v" => any(42_i64)}).should be_empty } - it "rejects a string" { error_on(schema, {"v" => any("42")}, "v").should eq("must be an integer") } - it "rejects a bool" { error_on(schema, {"v" => any(false)}, "v").should eq("must be an integer") } + s = Alumna::Schema.new.field("v", Alumna::FieldType::Int) + errors_for(s, {"v" => any(42_i64)} of String => Alumna::AnyData).should be_empty + error_on(s, ({"v" => any("42")} of String => Alumna::AnyData), "v").should eq("must be an integer") + error_on(s, ({"v" => any(false)} of String => Alumna::AnyData), "v").should eq("must be an integer") end it "Float type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Float) - - it "accepts a float" { errors_for(schema, {"v" => any(3.14)}).should be_empty } - it "accepts an integer (coercible)" { errors_for(schema, {"v" => any(3_i64)}).should be_empty } - it "rejects a string" { error_on(schema, {"v" => any("3.14")}, "v").should eq("must be a number") } + s = Alumna::Schema.new.field("v", Alumna::FieldType::Float) + errors_for(s, {"v" => any(3.14)} of String => Alumna::AnyData).should be_empty + errors_for(s, {"v" => any(3_i64)} of String => Alumna::AnyData).should be_empty + error_on(s, ({"v" => any("3.14")} of String => Alumna::AnyData), "v").should eq("must be a number") end it "Bool type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Bool) - - it "accepts true" { errors_for(schema, {"v" => any(true)}).should be_empty } - it "accepts false" { errors_for(schema, {"v" => any(false)}).should be_empty } - it "rejects a string" { error_on(schema, {"v" => any("true")}, "v").should eq("must be true or false") } - it "rejects an int" { error_on(schema, {"v" => any(1_i64)}, "v").should eq("must be true or false") } + s = Alumna::Schema.new.field("v", Alumna::FieldType::Bool) + errors_for(s, {"v" => any(true)} of String => Alumna::AnyData).should be_empty + errors_for(s, {"v" => any(false)} of String => Alumna::AnyData).should be_empty + error_on(s, ({"v" => any("true")} of String => Alumna::AnyData), "v").should eq("must be true or false") + error_on(s, ({"v" => any(1_i64)} of String => Alumna::AnyData), "v").should eq("must be true or false") end it "Time type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Time) - - it "accepts a time" { errors_for(schema, {"v" => any(Time.utc)}).should be_empty } - it "rejects a string" { error_on(schema, {"v" => any("2024-01-01")}, "v").should eq("must be a time") } + s = Alumna::Schema.new.field("v", Alumna::FieldType::Time) + errors_for(s, {"v" => any(Time.utc)} of String => Alumna::AnyData).should be_empty + error_on(s, ({"v" => any("2024-01-01")} of String => Alumna::AnyData), "v").should eq("must be a time") end it "Bytes type" do - schema = Alumna::Schema.new.field("v", Alumna::FieldType::Bytes) - - it "accepts bytes" { errors_for(schema, {"v" => any(Bytes[1, 2])}).should be_empty } - it "rejects an array" { error_on(schema, {"v" => any([any(1_i64)])}, "v").should eq("must be bytes") } + s = Alumna::Schema.new.field("v", Alumna::FieldType::Bytes) + errors_for(s, {"v" => any(Bytes[1, 2])} of String => Alumna::AnyData).should be_empty + error_on(s, ({"v" => any([any(1_i64)])} of String => Alumna::AnyData), "v").should eq("must be bytes") end end @@ -240,24 +225,24 @@ describe Alumna::Schema do describe "min_length" do schema = Alumna::Schema.new.field("v", Alumna::FieldType::Str, min_length: 3) - it "passes when length == min" { errors_for(schema, {"v" => any("abc")}).should be_empty } - it "passes when length > min" { errors_for(schema, {"v" => any("abcd")}).should be_empty } - it "fails when length < min" { error_on(schema, {"v" => any("ab")}, "v").should eq("must be at least 3 characters") } + it "passes when length == min" { errors_for(schema, {"v" => any("abc")} of String => Alumna::AnyData).should be_empty } + it "passes when length > min" { errors_for(schema, {"v" => any("abcd")} of String => Alumna::AnyData).should be_empty } + it "fails when length < min" { error_on(schema, ({"v" => any("ab")} of String => Alumna::AnyData), "v").should eq("must be at least 3 characters") } it "uses singular 'character' for 1" do s = Alumna::Schema.new.field("v", Alumna::FieldType::Str, min_length: 1) - error_on(s, {"v" => any("")}, "v").should eq("must be at least 1 character") + error_on(s, ({"v" => any("")} of String => Alumna::AnyData), "v").should eq("must be at least 1 character") end end describe "max_length" do schema = Alumna::Schema.new.field("v", Alumna::FieldType::Str, max_length: 5) - it "passes when length == max" { errors_for(schema, {"v" => any("abcde")}).should be_empty } - it "passes when length < max" { errors_for(schema, {"v" => any("ab")}).should be_empty } - it "fails when length > max" { error_on(schema, {"v" => any("abcdef")}, "v").should eq("must be at most 5 characters") } + it "passes when length == max" { errors_for(schema, {"v" => any("abcde")} of String => Alumna::AnyData).should be_empty } + it "passes when length < max" { errors_for(schema, {"v" => any("ab")} of String => Alumna::AnyData).should be_empty } + it "fails when length > max" { error_on(schema, ({"v" => any("abcdef")} of String => Alumna::AnyData), "v").should eq("must be at most 5 characters") } it "uses singular 'character' for 1" do s = Alumna::Schema.new.field("v", Alumna::FieldType::Str, max_length: 1) - error_on(s, {"v" => any("ab")}, "v").should eq("must be at most 1 character") + error_on(s, ({"v" => any("ab")} of String => Alumna::AnyData), "v").should eq("must be at most 1 character") end end @@ -266,23 +251,23 @@ describe Alumna::Schema do describe "Email format" do schema = Alumna::Schema.new.field("email", Alumna::FieldType::Str, format: :email) - it "accepts a valid email" { errors_for(schema, {"email" => any("alice@example.com")}).should be_empty } - it "rejects missing @" { error_on(schema, {"email" => any("notanemail")}, "email").should eq("must be a valid email address") } + it "accepts a valid email" { errors_for(schema, {"email" => any("alice@example.com")} of String => Alumna::AnyData).should be_empty } + it "rejects missing @" { error_on(schema, ({"email" => any("notanemail")} of String => Alumna::AnyData), "email").should eq("must be a valid email address") } end describe "Url format" do schema = Alumna::Schema.new.field("url", Alumna::FieldType::Str, format: :url) - it "accepts https URL" { errors_for(schema, {"url" => any("https://example.com/path?q=1")}).should be_empty } - it "rejects plain domain" { error_on(schema, {"url" => any("example.com")}, "url").should eq("must be a valid URL (http or https)") } + it "accepts https URL" { errors_for(schema, {"url" => any("https://example.com/path?q=1")} of String => Alumna::AnyData).should be_empty } + it "rejects plain domain" { error_on(schema, ({"url" => any("example.com")} of String => Alumna::AnyData), "url").should eq("must be a valid URL (http or https)") } end describe "Uuid format" do schema = Alumna::Schema.new.field("id", Alumna::FieldType::Str, format: :uuid) - it "accepts a lowercase UUID" { errors_for(schema, {"id" => any("550e8400-e29b-41d4-a716-446655440000")}).should be_empty } - it "accepts UUID without hyphens" { errors_for(schema, {"id" => any("550e8400e29b41d4a716446655440000")}).should be_empty } - it "rejects invalid UUID" { error_on(schema, {"id" => any("550e8400e29b41d4a71644665544")}, "id").should eq("must be a valid UUID") } + it "accepts a lowercase UUID" { errors_for(schema, {"id" => any("550e8400-e29b-41d4-a716-446655440000")} of String => Alumna::AnyData).should be_empty } + it "accepts UUID without hyphens" { errors_for(schema, {"id" => any("550e8400e29b41d4a716446655440000")} of String => Alumna::AnyData).should be_empty } + it "rejects invalid UUID" { error_on(schema, ({"id" => any("550e8400e29b41d4a71644665544")} of String => Alumna::AnyData), "id").should eq("must be a valid UUID") } end # ── Constraint skipping on type error ──────────────────────────────────────── @@ -294,27 +279,27 @@ describe Alumna::Schema do ) it "reports only the type error" do - errs = errors_for(schema, {"email" => any(123_i64)}) + errs = errors_for(schema, {"email" => any(123_i64)} of String => Alumna::AnyData) errs.size.should eq(1) errs.first.message.should eq("must be a string") end end describe "edge cases" do - it "requires a Nullable field when missing, but accepts null" do + it "requires an Any field when missing, but accepts null if nullable" do schema = Alumna::Schema.new.field("v", Alumna::FieldType::Any, nullable: true, required: true) error_fields(schema, empty_data).should contain("v") - errors_for(schema, {"v" => any_nil}).should be_empty + errors_for(schema, {"v" => any_nil} of String => Alumna::AnyData).should be_empty end it "Int rejects float values" do schema = Alumna::Schema.new.field("v", Alumna::FieldType::Int) - error_on(schema, {"v" => any(2.5)}, "v").should eq("must be an integer") + error_on(schema, ({"v" => any(2.5)} of String => Alumna::AnyData), "v").should eq("must be an integer") end it "Float rejects bool" do schema = Alumna::Schema.new.field("v", Alumna::FieldType::Float) - error_on(schema, {"v" => any(true)}, "v").should eq("must be a number") + error_on(schema, ({"v" => any(true)} of String => Alumna::AnyData), "v").should eq("must be a number") end it "returns multiple errors for one field" do @@ -323,7 +308,7 @@ describe Alumna::Schema do format: :email ) # "a@b" is too short AND fails the email regex (no TLD) - errs = errors_for(schema, {"email" => any("a@b")}) + errs = errors_for(schema, {"email" => any("a@b")} of String => Alumna::AnyData) errs.map(&.message).should contain("must be at least 10 characters") errs.map(&.message).should contain("must be a valid email address") errs.size.should eq(2) @@ -331,22 +316,22 @@ describe Alumna::Schema do it "ignores fields not defined in schema when strict is false" do schema = Alumna::Schema.new(strict: false).field("name", Alumna::FieldType::Str) - errors_for(schema, {"name" => any("ok"), "extra" => any("ignored")}).should be_empty + errors_for(schema, {"name" => any("ok"), "extra" => any("ignored")} of String => Alumna::AnyData).should be_empty end it "accepts uppercase UUID" do schema = Alumna::Schema.new.field("id", Alumna::FieldType::Str, format: :uuid) - errors_for(schema, {"id" => any("550E8400-E29B-41D4-A716-446655440000")}).should be_empty + errors_for(schema, {"id" => any("550E8400-E29B-41D4-A716-446655440000")} of String => Alumna::AnyData).should be_empty end it "accepts URL with surrounding whitespace" do schema = Alumna::Schema.new.field("u", Alumna::FieldType::Str, format: :url) - errors_for(schema, {"u" => any(" https://example.com ")}).should be_empty + errors_for(schema, {"u" => any(" https://example.com ")} of String => Alumna::AnyData).should be_empty end it "rejects URL with internal space" do schema = Alumna::Schema.new.field("u", Alumna::FieldType::Str, format: :url) - error_on(schema, {"u" => any("https://exa mple.com")}, "u").should eq("must be a valid URL (http or https)") + error_on(schema, ({"u" => any("https://exa mple.com")} of String => Alumna::AnyData), "u").should eq("must be a valid URL (http or https)") end it "required_on implies presence even when required: false" do @@ -364,11 +349,15 @@ describe Alumna::Schema do end # Valid - valid_data = {"profile" => {"username" => any("Alice"), "age" => any(30)} of String => Alumna::AnyData} + valid_data = { + "profile" => ({"username" => any("Alice"), "age" => any(30)} of String => Alumna::AnyData), + } of String => Alumna::AnyData errors_for(schema, valid_data).should be_empty # Invalid nested fields - invalid_data = {"profile" => {"username" => any("Al"), "age" => any("old")} of String => Alumna::AnyData} + invalid_data = { + "profile" => ({"username" => any("Al"), "age" => any("old")} of String => Alumna::AnyData), + } of String => Alumna::AnyData errs = errors_for(schema, invalid_data) errs.find { |e| e.field == "profile.username" }.try(&.message).should eq("must be at least 3 characters") @@ -379,15 +368,15 @@ describe Alumna::Schema do schema = Alumna::Schema.new.array("tags", of: :str, min_length: 1, max_length: 3) # Valid array size & type - errors_for(schema, {"tags" => [any("crystal"), any("alumna")] of Alumna::AnyData}).should be_empty + errors_for(schema, {"tags" => [any("crystal"), any("alumna")] of Alumna::AnyData} of String => Alumna::AnyData).should be_empty # Invalid element type - errs = errors_for(schema, {"tags" => [any("crystal"), any(123)] of Alumna::AnyData}) + errs = errors_for(schema, {"tags" => [any("crystal"), any(123)] of Alumna::AnyData} of String => Alumna::AnyData) errs.first.field.should eq("tags[1]") errs.first.message.should eq("must be a string") # Invalid array constraints (min_length applied to array size!) - errs_len = errors_for(schema, {"tags" => [] of Alumna::AnyData}) + errs_len = errors_for(schema, {"tags" => [] of Alumna::AnyData} of String => Alumna::AnyData) errs_len.first.field.should eq("tags") errs_len.first.message.should eq("must contain at least 1 item") end @@ -398,17 +387,21 @@ describe Alumna::Schema do s.bool("admin") end - valid_data = {"users" => [ - {"id" => any("u1"), "admin" => any(true)} of String => Alumna::AnyData, - {"id" => any("u2"), "admin" => any(false)} of String => Alumna::AnyData, - ] of Alumna::AnyData} + valid_data = { + "users" => [ + ({"id" => any("u1"), "admin" => any(true)} of String => Alumna::AnyData), + ({"id" => any("u2"), "admin" => any(false)} of String => Alumna::AnyData), + ] of Alumna::AnyData, + } of String => Alumna::AnyData errors_for(schema, valid_data).should be_empty - invalid_data = {"users" => [ - {"id" => any("u1"), "admin" => any("yes")} of String => Alumna::AnyData, - any("not-an-object"), - ] of Alumna::AnyData} + invalid_data = { + "users" => [ + ({"id" => any("u1"), "admin" => any("yes")} of String => Alumna::AnyData), + any("not-an-object"), + ] of Alumna::AnyData, + } of String => Alumna::AnyData errs = errors_for(schema, invalid_data) errs.find { |e| e.field == "users[0].admin" }.try(&.message).should eq("must be true or false") @@ -419,7 +412,7 @@ describe Alumna::Schema do describe "Strict Validation" do it "rejects unknown fields by default" do schema = Alumna::Schema.new.str("name") - errs = errors_for(schema, {"name" => any("Alice"), "age" => any(30)}) + errs = errors_for(schema, {"name" => any("Alice"), "age" => any(30)} of String => Alumna::AnyData) errs.size.should eq(1) errs.first.field.should eq("age") errs.first.message.should eq("is not allowed") @@ -429,7 +422,9 @@ describe Alumna::Schema do schema = Alumna::Schema.new.hash("profile") do |s| s.str("username") end - errs = errors_for(schema, {"profile" => {"username" => any("bob"), "extra" => any(1)} of String => Alumna::AnyData}) + errs = errors_for(schema, { + "profile" => ({"username" => any("bob"), "extra" => any(1)} of String => Alumna::AnyData), + } of String => Alumna::AnyData) errs.size.should eq(1) errs.first.field.should eq("profile.extra") errs.first.message.should eq("is not allowed") @@ -442,28 +437,28 @@ describe Alumna::Schema do .str("name") it "allows read-only fields on read operations" do - errors_for(schema, {"id" => any("123"), "name" => any("Alice")}, Alumna::ServiceMethod::Find).should be_empty + errors_for(schema, ({"id" => any("123"), "name" => any("Alice")} of String => Alumna::AnyData), Alumna::ServiceMethod::Find).should be_empty end it "rejects read-only fields on create" do - errs = errors_for(schema, {"id" => any("123"), "name" => any("Alice")}, Alumna::ServiceMethod::Create) + errs = errors_for(schema, ({"id" => any("123"), "name" => any("Alice")} of String => Alumna::AnyData), Alumna::ServiceMethod::Create) errs.size.should eq(1) errs.first.field.should eq("id") errs.first.message.should eq("is read-only") end it "rejects read-only fields on update" do - errs = errors_for(schema, {"id" => any("123"), "name" => any("Alice")}, Alumna::ServiceMethod::Update) + errs = errors_for(schema, ({"id" => any("123"), "name" => any("Alice")} of String => Alumna::AnyData), Alumna::ServiceMethod::Update) errs.first.message.should eq("is read-only") end it "rejects read-only fields on patch" do - errs = errors_for(schema, {"id" => any("123"), "name" => any("Alice")}, Alumna::ServiceMethod::Patch) + errs = errors_for(schema, ({"id" => any("123"), "name" => any("Alice")} of String => Alumna::AnyData), Alumna::ServiceMethod::Patch) errs.first.message.should eq("is read-only") end it "allows missing read-only fields on write" do - errors_for(schema, {"name" => any("Alice")}, Alumna::ServiceMethod::Create).should be_empty + errors_for(schema, ({"name" => any("Alice")} of String => Alumna::AnyData), Alumna::ServiceMethod::Create).should be_empty end end end diff --git a/src/schema/validator.cr b/src/schema/validator.cr index d3bd1ba..6b6ebb3 100644 --- a/src/schema/validator.cr +++ b/src/schema/validator.cr @@ -33,7 +33,6 @@ module Alumna has_key = data.has_key?(field.name) # --- Inject Defaults --- - # Only inject on writes where the client omitted the field if !has_key && field.has_default && method.try(&.create?) injected_val = field.default_value data[field.name] = injected_val From 1fa9e930aad3cc842a44de5c025a644cda61ca97 Mon Sep 17 00:00:00 2001 From: Paulo Coghi Date: Thu, 9 Jul 2026 20:36:00 +0200 Subject: [PATCH 3/8] tests: return removed tests --- spec/schema/base_spec.cr | 80 ++++++++++++++++++++++++++++++++++++++++ src/schema/base.cr | 6 +++ 2 files changed, 86 insertions(+) diff --git a/spec/schema/base_spec.cr b/spec/schema/base_spec.cr index 2ae53d9..86c8d92 100644 --- a/spec/schema/base_spec.cr +++ b/spec/schema/base_spec.cr @@ -97,6 +97,21 @@ describe Alumna::Schema do fd.format_message.should eq("must be a valid email address") end + it "normalizes strings and capitalized symbols" do + s1 = Alumna::Schema.new.field("x", :str, format: :url) + s1.fields.first.format_name.should eq("url") + + s2 = Alumna::Schema.new.str("y", format: "Uuid") + s2.fields.first.format_name.should eq("uuid") + end + + it "allows nil" do + fd = Alumna::Schema.new.str("n").fields.first + fd.format_name.should be_nil + fd.format_validator.should be_nil + fd.format_message.should be_nil + end + it "raises for unknown format" do expect_raises(ArgumentError, /Unknown format/) do Alumna::Schema.new.str("x", format: :not_a_format) @@ -109,6 +124,33 @@ describe Alumna::Schema do fd = Alumna::Schema.new.str("t", required_on: [:create, :update]).fields.first fd.required_on.should eq([Alumna::ServiceMethod::Create, Alumna::ServiceMethod::Update]) end + + it "normalizes single symbol" do + fd = Alumna::Schema.new.str("t", required_on: :patch).fields.first + fd.required_on.should eq([Alumna::ServiceMethod::Patch]) + end + + it "normalizes single enum" do + fd = Alumna::Schema.new.str("t", required_on: Alumna::ServiceMethod::Create).fields.first + fd.required_on.should eq([Alumna::ServiceMethod::Create]) + end + + it "normalizes mixed symbols and enums" do + fd = Alumna::Schema.new.str("t", + required_on: [Alumna::ServiceMethod::Patch, :remove] + ).fields.first + fd.required_on.should eq([Alumna::ServiceMethod::Patch, Alumna::ServiceMethod::Remove]) + end + + it "accepts nil" do + Alumna::Schema.new.str("t").fields.first.required_on.should be_nil + end + + it "raises for unknown method" do + expect_raises(ArgumentError, /Unknown enum Alumna::ServiceMethod/) do + Alumna::Schema.new.str("x", required_on: [:bogus]) + end + end end describe "helpers" do @@ -135,4 +177,42 @@ describe Alumna::Schema do s.fields[4].nullable.should be_true end end + + describe "chaining and builder" do + it "returns self" do + s = Alumna::Schema.new + s.str("a").int("b").should be(s) + end + + it "builds via block and preserves order" do + s = Alumna::Schema.build do |sc| + sc.str("first") + sc.int("second") + end + s.fields.map(&.name).should eq(["first", "second"]) + end + + it "builds via block with strict flag" do + s = Alumna::Schema.build(strict: false) do |sc| + sc.str("first") + end + s.strict.should be_false + end + end + + describe "error paths for type" do + it "raises for unknown type" do + expect_raises(ArgumentError, /Unknown enum Alumna::FieldType/) do + Alumna::Schema.new.field("x", :nope) + end + end + end + + describe "validator integration" do + it "validates format when given as symbol" do + schema = Alumna::Schema.new.str("email", format: :email) + errors = schema.validate({"email" => "not-an-email"} of String => Alumna::AnyData) + errors.first.message.should eq("must be a valid email address") + end + end end diff --git a/src/schema/base.cr b/src/schema/base.cr index 8f297b1..b8c0846 100644 --- a/src/schema/base.cr +++ b/src/schema/base.cr @@ -183,17 +183,21 @@ module Alumna end # --- Nested helpers --- + + # For objects/hashes def hash(name : String, **opts, &block : Schema ->) sub = Schema.new(strict: @strict) yield sub field(name, :hash, **opts, sub_schema: sub) end + # For arrays of primitives def array(name : String, of : FieldType | Symbol, **opts) el_type = of.is_a?(Symbol) ? FieldType.parse(of.to_s.capitalize) : of field(name, :array, **opts, element_type: el_type.as(FieldType)) end + # For arrays of objects def array(name : String, **opts, &block : Schema ->) sub = Schema.new(strict: @strict) yield sub @@ -206,7 +210,9 @@ module Alumna field = nil parts.each_with_index do |part, i| + # Ignore array indices in query params (e.g. users[0].age -> users.age) clean_part = part.sub(/\[\d+\]/, "") + field = current_schema.fields_by_name[clean_part]? return nil unless field From d03ace2c52b108c7549e249fa78f123682380cef Mon Sep 17 00:00:00 2001 From: Paulo Coghi Date: Thu, 9 Jul 2026 22:17:16 +0200 Subject: [PATCH 4/8] - Remove @field_names : Set(String) - Change FieldDescriptor from struct to class - Avoid array copy in norm_required_on - Named tuple for resolve_format - UUID format: simplify double negation - Direct case/when for FieldType instead of .to_s.capitalize --- src/schema/base.cr | 47 ++++++++++++++++++++++++-------------- src/schema/formats/uuid.cr | 2 +- src/schema/validator.cr | 2 +- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/schema/base.cr b/src/schema/base.cr index b8c0846..587d597 100644 --- a/src/schema/base.cr +++ b/src/schema/base.cr @@ -1,10 +1,8 @@ -require "set" - module Alumna # Sentinel struct to distinguish omitted arguments from explicit nils struct Unprovided; end - struct FieldDescriptor + class FieldDescriptor getter name : String getter type : FieldType getter required : Bool @@ -62,20 +60,34 @@ module Alumna class Schema getter fields : Array(FieldDescriptor) getter strict : Bool - getter field_names : Set(String) getter schema_indexes : Array(IndexDef) protected getter fields_by_name : Hash(String, FieldDescriptor) def initialize(@strict : Bool = true) @fields = [] of FieldDescriptor - @field_names = Set(String).new @fields_by_name = {} of String => FieldDescriptor @schema_indexes = [] of IndexDef end - private def resolve_format(format : Symbol | String | Nil) : {String?, Proc(String, Bool)?, String?} - return {nil, nil, nil} unless format + private def resolve_field_type(type : FieldType | Symbol) : FieldType + return type if type.is_a?(FieldType) + case type + when :str then FieldType::Str + when :int then FieldType::Int + when :float then FieldType::Float + when :bool then FieldType::Bool + when :time then FieldType::Time + when :bytes then FieldType::Bytes + when :any then FieldType::Any + when :hash then FieldType::Hash + when :array then FieldType::Array + else raise ArgumentError.new("Unknown enum Alumna::FieldType: #{type}") + end + end + + private def resolve_format(format : Symbol | String | Nil) : {name: String?, validator: Proc(String, Bool)?, message: String?} + return {name: nil, validator: nil, message: nil} unless format format_name = case format when Symbol then format.to_s.downcase @@ -83,10 +95,10 @@ module Alumna else nil end - return {nil, nil, nil} unless format_name + return {name: nil, validator: nil, message: nil} unless format_name if entry = Formats.fetch(format_name) - {format_name, entry.validator, entry.message} + {name: format_name, validator: entry.validator, message: entry.message} else raise ArgumentError.new("Unknown format: #{format_name}") end @@ -114,12 +126,14 @@ module Alumna element_type : FieldType? = nil, default : AnyData | Proc(AnyData) | Unprovided | Nil = Unprovided.new, ) : self - field_type = type.is_a?(Symbol) ? FieldType.parse(type.to_s.capitalize) : type - format_name, format_validator, format_message = resolve_format(format) + field_type = resolve_field_type(type) + fmt = resolve_format(format) norm_required_on = case required_on in Nil nil + in Array(ServiceMethod) + required_on in Array required_on.map { |m| m.is_a?(ServiceMethod) ? m : ServiceMethod.parse(m.to_s.capitalize) } in ServiceMethod @@ -138,9 +152,9 @@ module Alumna read_only: read_only, min_length: min_length, max_length: max_length, - format_name: format_name, - format_validator: format_validator, - format_message: format_message, + format_name: fmt[:name], + format_validator: fmt[:validator], + format_message: fmt[:message], required_on: norm_required_on, sub_schema: sub_schema, element_type: element_type, @@ -148,7 +162,6 @@ module Alumna ) @fields << fd - @field_names << name @fields_by_name[name] = fd self end @@ -193,8 +206,8 @@ module Alumna # For arrays of primitives def array(name : String, of : FieldType | Symbol, **opts) - el_type = of.is_a?(Symbol) ? FieldType.parse(of.to_s.capitalize) : of - field(name, :array, **opts, element_type: el_type.as(FieldType)) + el_type = resolve_field_type(of) + field(name, :array, **opts, element_type: el_type) end # For arrays of objects diff --git a/src/schema/formats/uuid.cr b/src/schema/formats/uuid.cr index 813e7df..908fd90 100644 --- a/src/schema/formats/uuid.cr +++ b/src/schema/formats/uuid.cr @@ -1,5 +1,5 @@ require "uuid" Alumna::Formats.register("uuid", "must be a valid UUID") do |v| - !UUID.parse?(v).nil? + !!UUID.parse?(v) end diff --git a/src/schema/validator.cr b/src/schema/validator.cr index 6b6ebb3..a024104 100644 --- a/src/schema/validator.cr +++ b/src/schema/validator.cr @@ -20,7 +20,7 @@ module Alumna protected def _validate(data : Hash(String, AnyData), method : ServiceMethod?, path : Array(String | Int32), errors : Array(FieldError)?) : Array(FieldError)? if @strict data.each_key do |key| - unless @field_names.includes?(key) + unless @fields_by_name.has_key?(key) path.push(key) errors = push_error(errors, path, "is not allowed") path.pop From 776c326c96e19f8ae4fc4fccff6af5c0635e9d1b Mon Sep 17 00:00:00 2001 From: Paulo Coghi Date: Thu, 9 Jul 2026 22:24:43 +0200 Subject: [PATCH 5/8] Validation Hot-Path - Use ensure in _validate for path.pop - Hoist method.try(&.create?) / &.write? - Avoid extra hash lookup after default injection - @[AlwaysInline] for check_type and required? --- src/schema/validator.cr | 162 +++++++++++++++++++++------------------- 1 file changed, 85 insertions(+), 77 deletions(-) diff --git a/src/schema/validator.cr b/src/schema/validator.cr index a024104..d30507d 100644 --- a/src/schema/validator.cr +++ b/src/schema/validator.cr @@ -18,6 +18,9 @@ module Alumna end protected def _validate(data : Hash(String, AnyData), method : ServiceMethod?, path : Array(String | Int32), errors : Array(FieldError)?) : Array(FieldError)? + is_create = method.try(&.create?) || false + is_write = method.try(&.write?) || false + if @strict data.each_key do |key| unless @fields_by_name.has_key?(key) @@ -30,101 +33,105 @@ module Alumna @fields.each do |field| path.push(field.name) - has_key = data.has_key?(field.name) - - # --- Inject Defaults --- - if !has_key && field.has_default && method.try(&.create?) - injected_val = field.default_value - data[field.name] = injected_val - has_key = true - end - - value = data[field.name]? - - # --- Read-Only Check --- - if field.read_only && method.try(&.write?) && has_key - errors = push_error(errors, path, "is read-only") - path.pop - next - end - - # --- Presence check --- - unless has_key - errors = push_error(errors, path, "is required") if required?(field, method) - path.pop - next - end - # --- Explicit null check --- - if value.nil? - unless field.nullable - errors = push_error(errors, path, "cannot be null") + begin + has_key = data.has_key?(field.name) + + # --- Inject Defaults & Avoid Double Lookup --- + value = if !has_key && field.has_default && is_create + v = field.default_value + data[field.name] = v + has_key = true + v + else + data[field.name]? + end + + # --- Read-Only Check --- + if field.read_only && is_write && has_key + errors = push_error(errors, path, "is read-only") + next end - path.pop - next - end - # --- Type check --- - if type_error = check_type(field.type, value) - errors = push_error(errors, path, type_error) - path.pop - next - end + # --- Presence check --- + unless has_key + errors = push_error(errors, path, "is required") if required?(field, method, is_write) + next + end - # --- Type-specific checks --- - case value - when String - if field.type.str? - if min = field.min_length - errors = push_error(errors, path, "must be at least #{min} character#{min == 1 ? "" : "s"}") if value.size < min - end - if max = field.max_length - errors = push_error(errors, path, "must be at most #{max} character#{max == 1 ? "" : "s"}") if value.size > max - end - if validator = field.format_validator - errors = push_error(errors, path, field.format_message || "has an invalid format") unless validator.call(value) + # --- Explicit null check --- + if value.nil? + unless field.nullable + errors = push_error(errors, path, "cannot be null") end + next end - when Array(AnyData) - if field.type.array? - if min = field.min_length - errors = push_error(errors, path, "must contain at least #{min} item#{min == 1 ? "" : "s"}") if value.size < min - end - if max = field.max_length - errors = push_error(errors, path, "must contain at most #{max} item#{max == 1 ? "" : "s"}") if value.size > max + + # --- Type check --- + if type_error = check_type(field.type, value) + errors = push_error(errors, path, type_error) + next + end + + # --- Type-specific checks --- + case value + when String + if field.type.str? + if min = field.min_length + errors = push_error(errors, path, "must be at least #{min} character#{min == 1 ? "" : "s"}") if value.size < min + end + if max = field.max_length + errors = push_error(errors, path, "must be at most #{max} character#{max == 1 ? "" : "s"}") if value.size > max + end + if validator = field.format_validator + errors = push_error(errors, path, field.format_message || "has an invalid format") unless validator.call(value) + end end - value.each_with_index do |item, idx| - path.push(idx) - if sub = field.sub_schema - if item.is_a?(Hash(String, AnyData)) - errors = sub._validate(item, method, path, errors) - else - errors = push_error(errors, path, "must be an object") - end - elsif el_type = field.element_type - if type_err = check_type(el_type, item) - errors = push_error(errors, path, type_err) + when Array(AnyData) + if field.type.array? + if min = field.min_length + errors = push_error(errors, path, "must contain at least #{min} item#{min == 1 ? "" : "s"}") if value.size < min + end + if max = field.max_length + errors = push_error(errors, path, "must contain at most #{max} item#{max == 1 ? "" : "s"}") if value.size > max + end + value.each_with_index do |item, idx| + path.push(idx) + begin + if sub = field.sub_schema + if item.is_a?(Hash(String, AnyData)) + errors = sub._validate(item, method, path, errors) + else + errors = push_error(errors, path, "must be an object") + end + elsif el_type = field.element_type + if type_err = check_type(el_type, item) + errors = push_error(errors, path, type_err) + end + end + ensure + path.pop end end - path.pop end - end - when Hash(String, AnyData) - if field.type.hash? - if sub = field.sub_schema - errors = sub._validate(value, method, path, errors) + when Hash(String, AnyData) + if field.type.hash? + if sub = field.sub_schema + errors = sub._validate(value, method, path, errors) + end end end + ensure + path.pop end - - path.pop end errors end - private def required?(field : FieldDescriptor, method : ServiceMethod?) : Bool - return false if field.read_only && method.try(&.write?) + @[AlwaysInline] + private def required?(field : FieldDescriptor, method : ServiceMethod?, is_write : Bool) : Bool + return false if field.read_only && is_write if req_on = field.required_on method.nil? || req_on.includes?(method) @@ -152,6 +159,7 @@ module Alumna arr end + @[AlwaysInline] private def check_type(type : FieldType, value : AnyData) : String? case type when .str? then value.is_a?(String) ? nil : "must be a string" From e69de9f509b3257ac8b57400bbbcf0d563a21511 Mon Sep 17 00:00:00 2001 From: Paulo Coghi Date: Thu, 9 Jul 2026 22:40:33 +0200 Subject: [PATCH 6/8] test: fix kcov wrong report on covered lines --- src/schema/validator.cr | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/schema/validator.cr b/src/schema/validator.cr index d30507d..71f94b6 100644 --- a/src/schema/validator.cr +++ b/src/schema/validator.cr @@ -34,7 +34,9 @@ module Alumna @fields.each do |field| path.push(field.name) + # LCOV_EXCL_START - kcov wrongly reports on this "begin", while reporting coverage inside it begin + # LCOV_EXCL_STOP has_key = data.has_key?(field.name) # --- Inject Defaults & Avoid Double Lookup --- @@ -97,7 +99,9 @@ module Alumna end value.each_with_index do |item, idx| path.push(idx) + # LCOV_EXCL_START - kcov wrongly reports on this "begin", while reporting coverage inside it begin + # LCOV_EXCL_STOP if sub = field.sub_schema if item.is_a?(Hash(String, AnyData)) errors = sub._validate(item, method, path, errors) From f40178d5734ae2f9b10787f98ad36876ee9520eb Mon Sep 17 00:00:00 2001 From: Paulo Coghi Date: Thu, 9 Jul 2026 22:56:06 +0200 Subject: [PATCH 7/8] Pathing & Queries - find_field zero-allocation rewrite - Simplify parse_positive_int --- src/schema/base.cr | 27 ++++++++++++++++----------- src/service/query.cr | 5 ++--- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/schema/base.cr b/src/schema/base.cr index 587d597..ef86e6d 100644 --- a/src/schema/base.cr +++ b/src/schema/base.cr @@ -218,23 +218,28 @@ module Alumna end def find_field(path : String) : FieldDescriptor? - parts = path.split('.') current_schema = self - field = nil + field : FieldDescriptor? = nil + start = 0 - parts.each_with_index do |part, i| - # Ignore array indices in query params (e.g. users[0].age -> users.age) - clean_part = part.sub(/\[\d+\]/, "") + loop do + dot = path.index('.', start) + raw_part = dot ? path[start...dot] : path[start..] + + # Strip array index suffix like [0] without regex + bracket = raw_part.index('[') + clean_part = bracket ? raw_part[0...bracket] : raw_part field = current_schema.fields_by_name[clean_part]? return nil unless field - if i < parts.size - 1 - if sub = field.sub_schema - current_schema = sub - else - return nil - end + break unless dot + start = dot + 1 + + if sub = field.sub_schema + current_schema = sub + else + return nil end end field diff --git a/src/service/query.cr b/src/service/query.cr index 0ee91e0..778c574 100644 --- a/src/service/query.cr +++ b/src/service/query.cr @@ -158,9 +158,8 @@ module Alumna @[AlwaysInline] private def parse_positive_int(str : String) : Int32? - return nil if str.empty? - str.each_byte { |b| return nil unless 48 <= b <= 57 } - str.to_i? + n = str.to_i?(whitespace: false) + n && n >= 0 ? n : nil end end end From fb6e77394527443076777441cfa9240f98de58aa Mon Sep 17 00:00:00 2001 From: Paulo Coghi Date: Thu, 9 Jul 2026 23:12:47 +0200 Subject: [PATCH 8/8] perf: avoid extra allocation on memory adapter check_unique --- src/adapter/memory.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapter/memory.cr b/src/adapter/memory.cr index a5fc2fa..57f4547 100644 --- a/src/adapter/memory.cr +++ b/src/adapter/memory.cr @@ -115,7 +115,7 @@ module Alumna val = extract_value(record, fd.name) next if val.nil? - conflict = @store.values.any? do |existing| + conflict = @store.each_value.any? do |existing| existing["id"] != skip_id && extract_value(existing, fd.name) == val end