Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions spec/schema/base_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 8 additions & 7 deletions src/adapter/memory.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/schema/base.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading