From 933a31267dbaf064befacb6e1efc0bd5b4891062 Mon Sep 17 00:00:00 2001 From: Paulo Coghi Date: Sat, 11 Jul 2026 18:14:46 +0200 Subject: [PATCH 1/2] perf: `Alumna::Schema` recursively parses `unique: true` fields and caches it on boot on a pre-computed array, speeding up database adapters --- src/adapter/memory.cr | 15 ++++++++------- src/schema/base.cr | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/adapter/memory.cr b/src/adapter/memory.cr index 57f4547..573b3c3 100644 --- a/src/adapter/memory.cr +++ b/src/adapter/memory.cr @@ -3,12 +3,16 @@ module Alumna @store : Hash(String, Hash(String, AnyData)) @next_id : Int64 @mutex : Sync::Mutex + @unique_fields : Array({String, FieldDescriptor}) # <--- Added def initialize(schema : Schema? = nil) super(schema) @store = {} of String => Hash(String, AnyData) @next_id = 1_i64 @mutex = Sync::Mutex.new + + # Eagerly evaluate and cache unique fields at boot time + @unique_fields = schema ? schema.unique_fields : [] of {String, FieldDescriptor} end @[AlwaysInline] @@ -108,19 +112,16 @@ module Alumna # 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) + @unique_fields.each do |(path, fd)| + val = extract_value(record, path) next if val.nil? conflict = @store.each_value.any? do |existing| - existing["id"] != skip_id && extract_value(existing, fd.name) == val + existing["id"] != skip_id && extract_value(existing, path) == val end if conflict - return ServiceError.unprocessable("Unique constraint violation", {fd.name => "already exists"} of String => AnyData) + return ServiceError.unprocessable("Unique constraint violation", {path => "already exists"} of String => AnyData) end end nil diff --git a/src/schema/base.cr b/src/schema/base.cr index ef86e6d..2ed7a89 100644 --- a/src/schema/base.cr +++ b/src/schema/base.cr @@ -64,12 +64,52 @@ module Alumna protected getter fields_by_name : Hash(String, FieldDescriptor) + @unique_fields_cache : Array({String, FieldDescriptor})? + @indexed_fields_cache : Array({String, FieldDescriptor})? + def initialize(@strict : Bool = true) @fields = [] of FieldDescriptor @fields_by_name = {} of String => FieldDescriptor @schema_indexes = [] of IndexDef end + # Returns a flat list of all unique fields (including nested hashes) and their dot-notation paths + def unique_fields : Array({String, FieldDescriptor}) + @unique_fields_cache ||= begin + results = [] of {String, FieldDescriptor} + collect_fields(@fields, "", results, &.unique) + + results + end + end + + # Returns a flat list of all indexed fields (including nested hashes) and their dot-notation paths + def indexed_fields : Array({String, FieldDescriptor}) + @indexed_fields_cache ||= begin + results = [] of {String, FieldDescriptor} + collect_fields(@fields, "", results) { |fd| fd.unique || fd.indexed } + results + end + end + + private def collect_fields( + fields : Array(FieldDescriptor), + prefix : String, + results : Array({String, FieldDescriptor}), + &predicate : FieldDescriptor -> Bool + ) + fields.each do |fd| + path = prefix.empty? ? fd.name : "#{prefix}.#{fd.name}" + + results << {path, fd} if predicate.call(fd) + + # Only descend into nested objects. Arrays do not yield valid dot-notation paths. + if fd.type.hash? && (sub = fd.sub_schema) + collect_fields(sub.fields, path, results, &predicate) + end + end + end + private def resolve_field_type(type : FieldType | Symbol) : FieldType return type if type.is_a?(FieldType) case type From e9477497b64d872137016f1e14db0a44eeb52967 Mon Sep 17 00:00:00 2001 From: Paulo Coghi Date: Sat, 11 Jul 2026 20:48:34 +0200 Subject: [PATCH 2/2] test: schema - recursive field collection (unique and indexed) --- spec/schema/base_spec.cr | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/spec/schema/base_spec.cr b/spec/schema/base_spec.cr index 86c8d92..a7490c7 100644 --- a/spec/schema/base_spec.cr +++ b/spec/schema/base_spec.cr @@ -215,4 +215,44 @@ describe Alumna::Schema do errors.first.message.should eq("must be a valid email address") end end + + describe "recursive field collection (unique and indexed)" do + it "collects unique and indexed fields recursively with correct dot-notation paths" do + schema = Alumna::Schema.new + .str("id", unique: true) + .str("tenant_id", indexed: true) + .str("name") # neither + .hash("profile") do |p| + p.str("handle", unique: true) + p.str("category", indexed: true) + p.hash("preferences") do |prefs| + prefs.bool("marketing", indexed: true) + end + end + .array("users") do |u| + # These are inside an array, so the recursive walker MUST stop + # and ignore them to prevent generating invalid dot-notation paths. + u.str("email", unique: true) + u.str("role", indexed: true) + end + + # Check unique_fields + uniq = schema.unique_fields + uniq.size.should eq(2) + uniq.map(&.first).should eq(["id", "profile.handle"]) + + # Check indexed_fields (should include unique ones too!) + idx = schema.indexed_fields + idx.size.should eq(5) + + expected_indexed_paths = [ + "id", + "tenant_id", + "profile.handle", + "profile.category", + "profile.preferences.marketing", + ] + idx.map(&.first).should eq(expected_indexed_paths) + end + end end