Skip to content

Commit 9b628e9

Browse files
committed
Add cursor-based pagination support and related query enhancements
1 parent 02e0831 commit 9b628e9

7 files changed

Lines changed: 608 additions & 55 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
require "../spec_helper"
2+
3+
# PostgreSQL UUID Primary Key Tests
4+
# These tests validate that the cql_compat.cr PostgreSQL UUID compatibility layer works correctly
5+
6+
PostgresUuidDB = CQL::Schema.define(
7+
:postgres_uuid_db,
8+
adapter: CQL::Adapter::Postgres,
9+
uri: ENV["DATABASE_URL"]? || "postgres://localhost/cql_test"
10+
) do
11+
table :postgres_uuid_sessions do
12+
primary :id, String, auto_increment: false
13+
text :token
14+
bigint :user_id
15+
timestamp :created_at
16+
timestamp :updated_at
17+
end
18+
end
19+
20+
class PostgresUuidSession
21+
include CQL::ActiveRecord::Model(UUID)
22+
23+
db_context schema: PostgresUuidDB, table: :postgres_uuid_sessions
24+
25+
property token : String = ""
26+
property user_id : Int64 = 0_i64
27+
property created_at : Time?
28+
property updated_at : Time?
29+
end
30+
31+
describe "PostgreSQL UUID Primary Keys" do
32+
before_all do
33+
PostgresUuidDB.postgres_uuid_sessions.drop!
34+
PostgresUuidDB.postgres_uuid_sessions.create!
35+
end
36+
37+
after_all do
38+
PostgresUuidDB.postgres_uuid_sessions.drop!
39+
end
40+
41+
after_each do
42+
PostgresUuidSession.delete_all
43+
end
44+
45+
describe "CREATE operations" do
46+
it "creates a record with auto-generated UUID" do
47+
session = PostgresUuidSession.create!(
48+
token: "test_token",
49+
user_id: 1_i64,
50+
created_at: Time.utc,
51+
updated_at: Time.utc
52+
)
53+
54+
session.id.should_not be_nil
55+
session.id!.should be_a(UUID)
56+
session.token.should eq("test_token")
57+
end
58+
59+
it "generates unique UUIDs for multiple records" do
60+
session1 = PostgresUuidSession.create!(token: "token1", user_id: 1_i64)
61+
session2 = PostgresUuidSession.create!(token: "token2", user_id: 2_i64)
62+
63+
session1.id!.should_not eq(session2.id!)
64+
end
65+
66+
it "generates valid UUID format" do
67+
session = PostgresUuidSession.create!(token: "format_test", user_id: 1_i64)
68+
69+
uuid_string = session.id!.to_s
70+
uuid_string.should match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
71+
end
72+
end
73+
74+
describe "READ operations" do
75+
it "finds a record by UUID" do
76+
session = PostgresUuidSession.create!(token: "findable", user_id: 1_i64)
77+
78+
found = PostgresUuidSession.find!(session.id!)
79+
found.token.should eq("findable")
80+
found.id!.should eq(session.id!)
81+
end
82+
83+
it "returns nil for non-existent UUID" do
84+
random_uuid = UUID.random
85+
result = PostgresUuidSession.find?(random_uuid)
86+
result.should be_nil
87+
end
88+
89+
it "supports find_by with UUID" do
90+
session = PostgresUuidSession.create!(token: "find_by_test", user_id: 42_i64)
91+
92+
found = PostgresUuidSession.find_by(user_id: 42_i64)
93+
found.should_not be_nil
94+
found.not_nil!.id!.should eq(session.id!)
95+
end
96+
end
97+
98+
describe "UPDATE operations" do
99+
it "updates a record by UUID" do
100+
session = PostgresUuidSession.create!(token: "original", user_id: 1_i64)
101+
102+
PostgresUuidSession.update!(session.id!, token: "updated")
103+
104+
found = PostgresUuidSession.find!(session.id!)
105+
found.token.should eq("updated")
106+
end
107+
108+
it "updates multiple fields" do
109+
session = PostgresUuidSession.create!(token: "multi", user_id: 1_i64)
110+
111+
PostgresUuidSession.update!(session.id!, token: "new_token", user_id: 99_i64)
112+
113+
found = PostgresUuidSession.find!(session.id!)
114+
found.token.should eq("new_token")
115+
found.user_id.should eq(99_i64)
116+
end
117+
end
118+
119+
describe "DELETE operations" do
120+
it "deletes a record by UUID" do
121+
session = PostgresUuidSession.create!(token: "deletable", user_id: 1_i64)
122+
uuid = session.id!
123+
124+
PostgresUuidSession.delete!(uuid)
125+
126+
PostgresUuidSession.find?(uuid).should be_nil
127+
end
128+
129+
it "returns 0 for non-existent UUID delete" do
130+
random_uuid = UUID.random
131+
result = PostgresUuidSession.delete!(random_uuid)
132+
result.rows_affected.should eq(0)
133+
end
134+
end
135+
136+
describe "batch operations with UUID" do
137+
it "supports find_each with UUID primary keys" do
138+
5.times do |i|
139+
PostgresUuidSession.create!(token: "batch_#{i}", user_id: i.to_i64)
140+
end
141+
142+
count = 0
143+
PostgresUuidSession.query.find_each(batch_size: 2) do |session|
144+
count += 1
145+
session.id.should_not be_nil
146+
end
147+
148+
count.should eq(5)
149+
end
150+
151+
it "supports find_in_batches with UUID primary keys" do
152+
5.times do |i|
153+
PostgresUuidSession.create!(token: "batch_#{i}", user_id: i.to_i64)
154+
end
155+
156+
batch_count = 0
157+
total_records = 0
158+
159+
PostgresUuidSession.query.find_in_batches(batch_size: 2) do |batch|
160+
batch_count += 1
161+
total_records += batch.size
162+
end
163+
164+
batch_count.should be >= 2
165+
total_records.should eq(5)
166+
end
167+
end
168+
169+
describe "PostgreSQL native UUID handling" do
170+
it "correctly handles PostgreSQL native UUID type conversion" do
171+
# This test validates that the cql_compat.cr patch works correctly
172+
# PostgreSQL returns native UUID objects, which must be converted to String
173+
session = PostgresUuidSession.create!(token: "native_uuid", user_id: 1_i64)
174+
175+
# Fetch the record - this exercises the PG::ResultSet#read override
176+
found = PostgresUuidSession.find!(session.id!)
177+
178+
# Verify the UUID is correctly converted and usable
179+
found.id!.should be_a(UUID)
180+
found.id!.to_s.should eq(session.id!.to_s)
181+
end
182+
183+
it "handles WHERE clause with UUID comparison" do
184+
session = PostgresUuidSession.create!(token: "where_test", user_id: 1_i64)
185+
186+
# Query using the UUID in a WHERE clause
187+
results = PostgresUuidSession.query.where(token: "where_test").all
188+
results.size.should eq(1)
189+
results.first.id!.should eq(session.id!)
190+
end
191+
end
192+
end
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
require "../../spec_helper"
2+
3+
# Cursor-based pagination tests
4+
# Tests after_cursor, before_cursor, and paginate_by methods
5+
6+
CursorPaginationDB = CQL::Schema.define(
7+
:cursor_pagination_db,
8+
adapter: CQL::Adapter::SQLite,
9+
uri: "sqlite3://./spec/support/db/cursor_pagination.db"
10+
) do
11+
table :cursor_test_records do
12+
primary :id, Int64, auto_increment: true
13+
text :name
14+
bigint :sort_order
15+
timestamp :created_at
16+
end
17+
end
18+
19+
class CursorTestRecord
20+
include CQL::ActiveRecord::Model(Int64)
21+
22+
db_context schema: CursorPaginationDB, table: :cursor_test_records
23+
24+
property name : String = ""
25+
property sort_order : Int64 = 0_i64
26+
property created_at : Time?
27+
end
28+
29+
describe "Cursor-based Pagination" do
30+
before_all do
31+
CursorPaginationDB.cursor_test_records.drop!
32+
CursorPaginationDB.cursor_test_records.create!
33+
end
34+
35+
after_all do
36+
CursorPaginationDB.cursor_test_records.drop!
37+
end
38+
39+
before_each do
40+
CursorTestRecord.delete_all
41+
# Create test records with sequential IDs
42+
10.times do |i|
43+
CursorTestRecord.create!(
44+
name: "Record #{i + 1}",
45+
sort_order: (i + 1).to_i64 * 10,
46+
created_at: Time.utc - (10 - i).hours
47+
)
48+
end
49+
end
50+
51+
describe ".after_cursor" do
52+
it "returns records after the given cursor ID" do
53+
all_records = CursorTestRecord.query.order(:id).all
54+
cursor_id = all_records[4].id! # 5th record
55+
56+
results = CursorTestRecord.after_cursor(cursor_id, limit: 3)
57+
58+
results.size.should eq(3)
59+
results.all? { |r| r.id! > cursor_id }.should be_true
60+
end
61+
62+
it "returns empty array when cursor is at the end" do
63+
all_records = CursorTestRecord.query.order(:id).all
64+
last_id = all_records.last.id!
65+
66+
results = CursorTestRecord.after_cursor(last_id, limit: 10)
67+
68+
results.should be_empty
69+
end
70+
71+
it "returns records in ascending order by ID" do
72+
all_records = CursorTestRecord.query.order(:id).all
73+
first_id = all_records.first.id!
74+
75+
results = CursorTestRecord.after_cursor(first_id, limit: 5)
76+
77+
results.size.should eq(5)
78+
ids = results.map(&.id!)
79+
ids.should eq(ids.sort)
80+
end
81+
82+
it "respects the limit parameter" do
83+
all_records = CursorTestRecord.query.order(:id).all
84+
first_id = all_records.first.id!
85+
86+
results = CursorTestRecord.after_cursor(first_id, limit: 2)
87+
88+
results.size.should eq(2)
89+
end
90+
end
91+
92+
describe ".before_cursor" do
93+
it "returns records before the given cursor ID" do
94+
all_records = CursorTestRecord.query.order(:id).all
95+
cursor_id = all_records[5].id! # 6th record
96+
97+
results = CursorTestRecord.before_cursor(cursor_id, limit: 3)
98+
99+
results.size.should eq(3)
100+
results.all? { |r| r.id! < cursor_id }.should be_true
101+
end
102+
103+
it "returns empty array when cursor is at the beginning" do
104+
all_records = CursorTestRecord.query.order(:id).all
105+
first_id = all_records.first.id!
106+
107+
results = CursorTestRecord.before_cursor(first_id, limit: 10)
108+
109+
results.should be_empty
110+
end
111+
112+
it "returns records in ascending order (reversed from query)" do
113+
all_records = CursorTestRecord.query.order(:id).all
114+
last_id = all_records.last.id!
115+
116+
results = CursorTestRecord.before_cursor(last_id, limit: 5)
117+
118+
results.size.should eq(5)
119+
ids = results.map(&.id!)
120+
ids.should eq(ids.sort)
121+
end
122+
123+
it "respects the limit parameter" do
124+
all_records = CursorTestRecord.query.order(:id).all
125+
last_id = all_records.last.id!
126+
127+
results = CursorTestRecord.before_cursor(last_id, limit: 2)
128+
129+
results.size.should eq(2)
130+
end
131+
end
132+
133+
describe ".paginate_by" do
134+
it "paginates by a custom column" do
135+
results = CursorTestRecord.paginate_by(:sort_order, 30_i64, limit: 3)
136+
137+
results.size.should eq(3)
138+
results.all? { |r| r.sort_order > 30 }.should be_true
139+
end
140+
141+
it "returns records in order of the specified column" do
142+
results = CursorTestRecord.paginate_by(:sort_order, 0_i64, limit: 5)
143+
144+
sort_orders = results.map(&.sort_order)
145+
sort_orders.should eq(sort_orders.sort)
146+
end
147+
148+
it "returns empty when no records match" do
149+
results = CursorTestRecord.paginate_by(:sort_order, 1000_i64, limit: 10)
150+
151+
results.should be_empty
152+
end
153+
end
154+
155+
describe "pagination workflow" do
156+
it "supports forward pagination through all records" do
157+
collected_ids = [] of Int64
158+
cursor_id = 0_i64
159+
160+
loop do
161+
page = CursorTestRecord.after_cursor(cursor_id, limit: 3)
162+
break if page.empty?
163+
164+
page.each { |r| collected_ids << r.id! }
165+
cursor_id = page.last.id!
166+
end
167+
168+
collected_ids.size.should eq(10)
169+
collected_ids.should eq(collected_ids.sort)
170+
end
171+
172+
it "supports backward pagination through all records" do
173+
all_records = CursorTestRecord.query.order(:id).all
174+
cursor_id = all_records.last.id! + 1 # Start after last record
175+
176+
collected_ids = [] of Int64
177+
178+
loop do
179+
page = CursorTestRecord.before_cursor(cursor_id, limit: 3)
180+
break if page.empty?
181+
182+
page.reverse_each { |r| collected_ids.unshift(r.id!) }
183+
cursor_id = page.first.id!
184+
end
185+
186+
collected_ids.size.should eq(10)
187+
collected_ids.should eq(collected_ids.sort)
188+
end
189+
end
190+
end

0 commit comments

Comments
 (0)