Skip to content

Commit 2eea0cd

Browse files
committed
Add error hierarchy tests and implement eager loading in QueryBuilder
1 parent 64c667d commit 2eea0cd

File tree

10 files changed

+320
-6
lines changed

10 files changed

+320
-6
lines changed

spec/core/error_hierarchy_spec.cr

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
require "../spec_helper"
2+
3+
describe "Error Hierarchy" do
4+
describe "CQL::Error base class" do
5+
it "is the base class for all CQL errors" do
6+
# CQL::Error should inherit from Exception
7+
error = CQL::Error.new("test")
8+
error.is_a?(Exception).should be_true
9+
end
10+
end
11+
12+
describe "Schema errors" do
13+
it "Schema::Error inherits from CQL::Error" do
14+
error = CQL::Schema::Error.new("test")
15+
error.is_a?(CQL::Error).should be_true
16+
end
17+
18+
it "Schema::InvalidURIError inherits from Schema::Error and CQL::Error" do
19+
error = CQL::Schema::InvalidURIError.new("test")
20+
error.is_a?(CQL::Schema::Error).should be_true
21+
error.is_a?(CQL::Error).should be_true
22+
end
23+
24+
it "Schema::VersionConflictError inherits from Schema::Error and CQL::Error" do
25+
error = CQL::Schema::VersionConflictError.new("test")
26+
error.is_a?(CQL::Schema::Error).should be_true
27+
error.is_a?(CQL::Error).should be_true
28+
end
29+
30+
it "Schema::ConnectionError inherits from Schema::Error and CQL::Error" do
31+
error = CQL::Schema::ConnectionError.new("test")
32+
error.is_a?(CQL::Schema::Error).should be_true
33+
error.is_a?(CQL::Error).should be_true
34+
end
35+
end
36+
37+
describe "SchemaDump errors" do
38+
it "SchemaDump::Error inherits from CQL::Error" do
39+
error = CQL::SchemaDump::Error.new("test")
40+
error.is_a?(CQL::Error).should be_true
41+
end
42+
end
43+
44+
describe "Migrator errors" do
45+
it "Migrator::Error inherits from CQL::Error" do
46+
error = CQL::Migrator::Error.new("test")
47+
error.is_a?(CQL::Error).should be_true
48+
end
49+
end
50+
51+
describe "Relation errors" do
52+
it "RelationError inherits from CQL::Error" do
53+
error = CQL::ActiveRecord::Relations::BaseRelation::RelationError.new("test")
54+
error.is_a?(CQL::Error).should be_true
55+
end
56+
57+
it "AssociationNotFound inherits from RelationError and CQL::Error" do
58+
error = CQL::ActiveRecord::Relations::BaseRelation::AssociationNotFound.new("test")
59+
error.is_a?(CQL::ActiveRecord::Relations::BaseRelation::RelationError).should be_true
60+
error.is_a?(CQL::Error).should be_true
61+
end
62+
63+
it "InvalidAssociation inherits from RelationError and CQL::Error" do
64+
error = CQL::ActiveRecord::Relations::BaseRelation::InvalidAssociation.new("test")
65+
error.is_a?(CQL::ActiveRecord::Relations::BaseRelation::RelationError).should be_true
66+
error.is_a?(CQL::Error).should be_true
67+
end
68+
69+
it "UnsavedRecord inherits from RelationError and CQL::Error" do
70+
error = CQL::ActiveRecord::Relations::BaseRelation::UnsavedRecord.new("test")
71+
error.is_a?(CQL::ActiveRecord::Relations::BaseRelation::RelationError).should be_true
72+
error.is_a?(CQL::Error).should be_true
73+
end
74+
end
75+
76+
describe "unified error handling" do
77+
it "rescue CQL::Error catches Schema::InvalidURIError" do
78+
rescued = false
79+
begin
80+
raise CQL::Schema::InvalidURIError.new("test")
81+
rescue CQL::Error
82+
rescued = true
83+
end
84+
rescued.should be_true
85+
end
86+
87+
it "rescue CQL::Error catches SchemaDump::Error" do
88+
rescued = false
89+
begin
90+
raise CQL::SchemaDump::Error.new("test")
91+
rescue CQL::Error
92+
rescued = true
93+
end
94+
rescued.should be_true
95+
end
96+
97+
it "rescue CQL::Error catches Migrator::Error" do
98+
rescued = false
99+
begin
100+
raise CQL::Migrator::Error.new("test")
101+
rescue CQL::Error
102+
rescued = true
103+
end
104+
rescued.should be_true
105+
end
106+
107+
it "rescue CQL::Error catches RelationError" do
108+
rescued = false
109+
begin
110+
raise CQL::ActiveRecord::Relations::BaseRelation::RelationError.new("test")
111+
rescue CQL::Error
112+
rescued = true
113+
end
114+
rescued.should be_true
115+
end
116+
117+
it "rescue CQL::Error catches AssociationNotFound" do
118+
rescued = false
119+
begin
120+
raise CQL::ActiveRecord::Relations::BaseRelation::AssociationNotFound.new("test")
121+
rescue CQL::Error
122+
rescued = true
123+
end
124+
rescued.should be_true
125+
end
126+
end
127+
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
require "./spec_helper"
2+
3+
describe "Eager Loading / Preload" do
4+
describe "QueryBuilder#preload" do
5+
it "returns a new QueryBuilder with preload specs" do
6+
builder = TestUser.query.preload(:posts)
7+
builder.should be_a(CQL::ActiveRecord::Queryable::QueryBuilder(TestUser))
8+
end
9+
10+
it "chains with where conditions" do
11+
builder = TestUser.where(age: 25).preload(:posts)
12+
builder.should be_a(CQL::ActiveRecord::Queryable::QueryBuilder(TestUser))
13+
end
14+
15+
it "allows multiple associations to be preloaded" do
16+
builder = TestUser.preload(:posts, :profile)
17+
builder.should be_a(CQL::ActiveRecord::Queryable::QueryBuilder(TestUser))
18+
end
19+
end
20+
21+
describe "Queryable.preload" do
22+
it "provides class-level preload method" do
23+
builder = TestUser.preload(:posts)
24+
builder.should be_a(CQL::ActiveRecord::Queryable::QueryBuilder(TestUser))
25+
end
26+
end
27+
28+
describe "Collection#_inject_preloaded" do
29+
it "exposes _inject_preloaded method" do
30+
# Create the collection manually with dummy values
31+
collection = CQL::ActiveRecord::Relations::Collection(Post, Int32).new(
32+
key: :user_id,
33+
id: 1,
34+
auto_load: false
35+
)
36+
37+
# The collection should not be loaded initially
38+
collection.loaded?.should be_false
39+
40+
# Inject empty array (no DB access needed)
41+
preloaded_posts = [] of Post
42+
collection._inject_preloaded(preloaded_posts)
43+
44+
# Now the collection should be loaded
45+
collection.loaded?.should be_true
46+
collection.size.should eq(0)
47+
end
48+
end
49+
50+
describe "has_many preload methods" do
51+
it "generates _preload_<name> class method" do
52+
# Test that the class method exists
53+
TestUser.responds_to?(:_preload_posts).should be_true
54+
end
55+
end
56+
end

src/active_record/query_builder.cr

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@ module CQL
88
class QueryBuilder(T)
99
@query : CQL::Query
1010
@model_class : T.class
11+
@preload_specs : Array(Symbol) = [] of Symbol
1112

1213
# Initialize a new QueryBuilder with the given query
1314
# - **@param** query [CQL::Query] The underlying query object
1415
# - **@param** model_class [T.class] The model class
1516
def initialize(@query : CQL::Query, @model_class : T.class = T)
1617
end
1718

19+
# Initialize with preload specs (used internally for cloning)
20+
protected def initialize(@query : CQL::Query, @model_class : T.class, @preload_specs : Array(Symbol))
21+
end
22+
1823
# Get the model class
1924
def model_class : T.class
2025
@model_class
@@ -48,7 +53,7 @@ module CQL
4853
# Clone the current QueryBuilder and apply modifications
4954
# - **@return** [QueryBuilder(T)] A new query builder instance
5055
private def clone_builder : QueryBuilder(T)
51-
QueryBuilder(T).new(@query.clone, @model_class)
56+
QueryBuilder(T).new(@query.clone, @model_class, @preload_specs.dup)
5257
end
5358

5459
# Add columns to select
@@ -266,12 +271,64 @@ module CQL
266271
QueryBuilder(T).new(merged_query, @model_class)
267272
end
268273

274+
# Add associations to preload (eager load) to avoid N+1 queries.
275+
# Preloaded associations are fetched in separate batch queries after
276+
# the main query executes, then injected into the parent records.
277+
# - **@param** associations [Symbol*] The association names to preload
278+
# - **@return** [QueryBuilder(T)] A new query builder with preload configured
279+
#
280+
# **Example**
281+
# ```
282+
# User.preload(:posts).all # Preload single association
283+
# User.preload(:posts, :comments).all # Preload multiple associations
284+
# User.where(active: true).preload(:posts).all # Chain with where
285+
# ```
286+
def preload(*associations : Symbol) : QueryBuilder(T)
287+
builder = clone_builder
288+
associations.each { |assoc| builder.@preload_specs << assoc }
289+
builder
290+
end
291+
269292
# Execute the query and return all results
293+
# When preloads are configured, automatically applies them to the results.
270294
# - **@return** [Array(T)] Array of model instances
271-
def all(as as_kind = T)
295+
def all : Array(T)
296+
records = @query.all(T)
297+
apply_preloads(records) if @preload_specs.any?
298+
records
299+
end
300+
301+
# Execute the query and return all results as a custom type
302+
# Note: Preloading is not applied when using a custom type.
303+
# - **@param** as_kind [Class] The type to deserialize results as
304+
# - **@return** [Array] Array of instances of the specified type
305+
def all(as as_kind)
272306
@query.all(as_kind)
273307
end
274308

309+
# Apply preloads to the fetched records
310+
private def apply_preloads(records : Array(T)) : Nil
311+
return if records.empty?
312+
313+
# Collect parent IDs for batch loading
314+
parent_ids = records.compact_map(&.id)
315+
return if parent_ids.empty?
316+
317+
# For each association to preload, call the class method generated by has_many
318+
@preload_specs.each do |assoc_name|
319+
{% begin %}
320+
case assoc_name
321+
{% for method in @type.type_vars[0].class.methods %}
322+
{% if method.name.starts_with?("_preload_") %}
323+
when {{method.name.stringify.gsub(/^_preload_/, "").id.symbolize}}
324+
T.{{method.name}}(records, parent_ids)
325+
{% end %}
326+
{% end %}
327+
end
328+
{% end %}
329+
end
330+
end
331+
275332
# Execute the query and return the first result
276333
# - **@return** [T?] First model instance or nil
277334
def first(as as_kind = T)

src/active_record/queryable.cr

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,21 @@ module CQL
152152
query.distinct
153153
end
154154

155+
# Create a QueryBuilder with associations to preload (eager load).
156+
# Preloading fetches associated records in batch queries to avoid N+1 queries.
157+
# - **@param** associations [Symbol*] The association names to preload
158+
# - **@return** [QueryBuilder(T)] The query builder instance with preload configured
159+
#
160+
# **Example**
161+
# ```
162+
# User.preload(:posts).all # Preload single association
163+
# User.preload(:posts, :comments).all # Preload multiple associations
164+
# User.where(active: true).preload(:posts).all # Chain with where
165+
# ```
166+
def self.preload(*associations : Symbol)
167+
query.preload(*associations)
168+
end
169+
155170
# Execute automatic inner join
156171
# - **@param** table_or_alias [Symbol | Hash] The table or alias mapping
157172
# - **@return** [QueryBuilder(T)] The query builder instance

src/active_record/relations/base_relation.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module CQL::ActiveRecord::Relations
44
# type safety, and shared behaviors across different association types.
55
module BaseRelation
66
# Common exception types for relation operations
7-
class RelationError < Exception; end
7+
class RelationError < CQL::Error; end
88

99
class AssociationNotFound < RelationError; end
1010

src/active_record/relations/collection.cr

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,16 @@ module CQL::ActiveRecord::Relations
143143
reload unless @loaded
144144
end
145145

146+
# Inject preloaded records without database query
147+
# Used by eager loading/preload to set association data that was
148+
# loaded in a batch query to avoid N+1 queries.
149+
# - **param** : records (Array(Target)) - The preloaded records
150+
# - **return** : Nil
151+
def _inject_preloaded(records : Array(Target)) : Nil
152+
@records = records
153+
@loaded = true
154+
end
155+
146156
# Returns a list of primary keys for the associated records
147157
# - **return** : Array(Pk)
148158
#

src/active_record/relations/has_many.cr

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,53 @@ module CQL::ActiveRecord::Relations
8181
@{{name.id}} = nil
8282
end
8383

84+
# Inject preloaded records into the association without database query.
85+
# Used by eager loading/preload to avoid N+1 queries.
86+
# - **param** : records (Array({{type.id}})) - The preloaded records for this association
87+
# - **return** : Nil
88+
def _set_preloaded_{{name.id}}(records : Array({{type.id}})) : Nil
89+
parent_id = safe_id(self, Pk)
90+
91+
@{{name.id}} = CQL::ActiveRecord::Relations::Collection({{type.id}}, Pk).new(
92+
key: {{fk}},
93+
id: parent_id,
94+
cascade: ({{dependent}} == :destroy || {{dependent}} == :delete_all),
95+
dependent: {{dependent}},
96+
auto_load: false
97+
)
98+
@{{name.id}}.not_nil!._inject_preloaded(records)
99+
end
100+
101+
# Class method to preload this association for a collection of parent records.
102+
# Executes a single query to fetch all related records and distributes them.
103+
# - **param** : records (Array(self)) - The parent records
104+
# - **param** : parent_ids (Array) - The parent IDs
105+
# - **return** : Nil
106+
def self._preload_{{name.id}}(records : Array(self), parent_ids : Array) : Nil
107+
return if records.empty? || parent_ids.empty?
108+
109+
# Fetch all related records in one query
110+
related_records = {{type.id}}.where({ {{fk}} => parent_ids }).all
111+
112+
# Group related records by foreign key
113+
grouped = {} of Pk => Array({{type.id}})
114+
related_records.each do |record|
115+
fk_value = record.{{fk.id}}
116+
next if fk_value.nil?
117+
key = fk_value.as(Pk)
118+
grouped[key] ||= [] of {{type.id}}
119+
grouped[key] << record
120+
end
121+
122+
# Inject preloaded records into each parent
123+
records.each do |parent|
124+
parent_id = parent.id
125+
next if parent_id.nil?
126+
related = grouped[parent_id.as(Pk)]? || [] of {{type.id}}
127+
parent._set_preloaded_{{name.id}}(related)
128+
end
129+
end
130+
84131
# Handle dependent associations when parent is destroyed
85132
def handle_{{name.id}}_dependency
86133
return unless @{{name.id}} # Only process if association was accessed

src/migrations.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,6 @@ module CQL
489489
repo.delete_by(name: migration.name, version: migration.version)
490490
end
491491

492-
class Error < Exception; end
492+
class Error < CQL::Error; end
493493
end
494494
end

0 commit comments

Comments
 (0)