Skip to content

Commit 0bf0582

Browse files
committed
Add tests for v1.0 features: transactions, column validation, NotLoaded
- TransactionAdvancedTests: raw SQL via Repo protocol in transactions, executeRawQuery in transactions, column validation rejects unknown columns, preloading HasMany inside transactions, full query builder chain in transactions, aggregates in transactions - NotLoadedAssertionTests: isLoaded state on all 4 relationship wrappers, wrappedValue setter transitions to loaded, projectedValue injection
1 parent 05be4d2 commit 0bf0582

2 files changed

Lines changed: 295 additions & 0 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import Foundation
2+
import Testing
3+
@testable import Spectro
4+
5+
extension DatabaseIntegrationTests {
6+
@Suite("Transaction Advanced")
7+
struct TransactionAdvancedTests {
8+
9+
// MARK: - Helpers
10+
11+
private func withRelationshipTables(
12+
_ body: (GenericDatabaseRepo) async throws -> Void
13+
) async throws {
14+
let spectro = try TestDatabase.makeSpectro()
15+
let repo = spectro.repository()
16+
try await repo.executeRawSQL("""
17+
CREATE TABLE IF NOT EXISTS "test_users" (
18+
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
19+
"name" TEXT NOT NULL DEFAULT '',
20+
"email" TEXT NOT NULL DEFAULT '',
21+
"age" INT NOT NULL DEFAULT 0,
22+
"is_active" BOOLEAN NOT NULL DEFAULT true,
23+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
24+
)
25+
""")
26+
try await repo.executeRawSQL("""
27+
CREATE TABLE IF NOT EXISTS "test_posts" (
28+
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
29+
"title" TEXT NOT NULL DEFAULT '',
30+
"body" TEXT NOT NULL DEFAULT '',
31+
"user_id" UUID NOT NULL,
32+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
33+
)
34+
""")
35+
try await repo.executeRawSQL(#"TRUNCATE "test_posts""#)
36+
try await repo.executeRawSQL(#"TRUNCATE "test_users" CASCADE"#)
37+
do {
38+
try await body(repo)
39+
} catch {
40+
await spectro.shutdown()
41+
throw error
42+
}
43+
await spectro.shutdown()
44+
}
45+
46+
private func withCleanTable(
47+
_ body: (GenericDatabaseRepo) async throws -> Void
48+
) async throws {
49+
let spectro = try TestDatabase.makeSpectro()
50+
let repo = spectro.repository()
51+
try await repo.executeRawSQL("""
52+
CREATE TABLE IF NOT EXISTS "test_users" (
53+
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
54+
"name" TEXT NOT NULL DEFAULT '',
55+
"email" TEXT NOT NULL DEFAULT '',
56+
"age" INT NOT NULL DEFAULT 0,
57+
"is_active" BOOLEAN NOT NULL DEFAULT true,
58+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
59+
)
60+
""")
61+
try await repo.executeRawSQL(#"TRUNCATE "test_users""#)
62+
do {
63+
try await body(repo)
64+
} catch {
65+
await spectro.shutdown()
66+
throw error
67+
}
68+
await spectro.shutdown()
69+
}
70+
71+
// MARK: - Raw SQL via Repo protocol in transactions
72+
73+
@Test("executeRawSQL works through Repo protocol in transaction")
74+
func rawSQLInTransaction() async throws {
75+
try await withCleanTable { repo in
76+
try await repo.transaction { tx in
77+
try await tx.executeRawSQL("""
78+
INSERT INTO "test_users" ("name", "email", "age")
79+
VALUES ('RawAlice', 'raw@test.com', 30)
80+
""")
81+
return ()
82+
}
83+
let all = try await repo.all(TestUser.self)
84+
#expect(all.count == 1)
85+
#expect(all.first?.name == "RawAlice")
86+
}
87+
}
88+
89+
@Test("executeRawQuery works through Repo protocol in transaction")
90+
func rawQueryInTransaction() async throws {
91+
try await withCleanTable { repo in
92+
let _ = try await repo.insert(TestUser(name: "Alice", email: "a@test.com", age: 30))
93+
let _ = try await repo.insert(TestUser(name: "Bob", email: "b@test.com", age: 25))
94+
95+
let count: Int = try await repo.transaction { tx in
96+
let rows = try await tx.executeRawQuery(
97+
sql: "SELECT COUNT(*) as cnt FROM \"test_users\" WHERE \"age\" > $1",
98+
parameters: [.init(int: 26)]
99+
)
100+
let ra = rows.first!.makeRandomAccess()
101+
return ra[data: "cnt"].int ?? 0
102+
}
103+
#expect(count == 1)
104+
}
105+
}
106+
107+
// MARK: - Column validation in update()
108+
109+
@Test("update with unknown column throws invalidSchema")
110+
func updateUnknownColumnThrows() async throws {
111+
try await withCleanTable { repo in
112+
let user = try await repo.insert(TestUser(name: "Alice", email: "a@test.com", age: 30))
113+
do {
114+
let _ = try await repo.update(
115+
TestUser.self,
116+
id: user.id,
117+
changes: ["nonExistentColumn": "value"]
118+
)
119+
Issue.record("Expected invalidSchema error for unknown column")
120+
} catch let error as SpectroError {
121+
guard case .invalidSchema(let reason) = error else {
122+
Issue.record("Wrong error: \(error)")
123+
return
124+
}
125+
#expect(reason.contains("Unknown column"))
126+
}
127+
}
128+
}
129+
130+
@Test("update with valid column succeeds")
131+
func updateValidColumnSucceeds() async throws {
132+
try await withCleanTable { repo in
133+
let user = try await repo.insert(TestUser(name: "Alice", email: "a@test.com", age: 30))
134+
let updated = try await repo.update(
135+
TestUser.self,
136+
id: user.id,
137+
changes: ["name": "Bob"]
138+
)
139+
#expect(updated.name == "Bob")
140+
}
141+
}
142+
143+
@Test("update with unknown column throws inside transaction")
144+
func updateUnknownColumnInTransactionThrows() async throws {
145+
try await withCleanTable { repo in
146+
let user = try await repo.insert(TestUser(name: "Alice", email: "a@test.com", age: 30))
147+
do {
148+
try await repo.transaction { tx in
149+
let _ = try await tx.update(
150+
TestUser.self,
151+
id: user.id,
152+
changes: ["hackerColumn": "DROP TABLE"]
153+
)
154+
}
155+
Issue.record("Expected invalidSchema error")
156+
} catch let error as SpectroError {
157+
guard case .invalidSchema = error else {
158+
Issue.record("Wrong error: \(error)")
159+
return
160+
}
161+
}
162+
}
163+
}
164+
165+
// MARK: - Preloading inside transactions
166+
167+
@Test("Preloading HasMany works inside transaction")
168+
func preloadHasManyInTransaction() async throws {
169+
try await withRelationshipTables { repo in
170+
let user = try await repo.insert(TestUser(name: "Alice", email: "a@test.com", age: 30))
171+
var post1 = TestPost()
172+
post1.title = "Post 1"
173+
post1.userId = user.id
174+
let _ = try await repo.insert(post1)
175+
var post2 = TestPost()
176+
post2.title = "Post 2"
177+
post2.userId = user.id
178+
let _ = try await repo.insert(post2)
179+
180+
let result: [TestUser] = try await repo.transaction { tx in
181+
try await tx.query(TestUser.self)
182+
.preload(\.$posts)
183+
.all()
184+
}
185+
#expect(result.count == 1)
186+
#expect(result.first?.$posts.isLoaded == true)
187+
#expect(result.first?.posts.count == 2)
188+
}
189+
}
190+
191+
@Test("Query builder with where + orderBy + limit inside transaction")
192+
func queryBuilderFullChainInTransaction() async throws {
193+
try await withCleanTable { repo in
194+
let _ = try await repo.insert(TestUser(name: "Alice", email: "a@test.com", age: 30))
195+
let _ = try await repo.insert(TestUser(name: "Bob", email: "b@test.com", age: 25))
196+
let _ = try await repo.insert(TestUser(name: "Charlie", email: "c@test.com", age: 35))
197+
198+
let result: [TestUser] = try await repo.transaction { tx in
199+
try await tx.query(TestUser.self)
200+
.where { $0.age >= 28 }
201+
.orderBy(\.name, .desc)
202+
.limit(2)
203+
.all()
204+
}
205+
#expect(result.count == 2)
206+
#expect(result[0].name == "Charlie")
207+
#expect(result[1].name == "Alice")
208+
}
209+
}
210+
211+
@Test("Aggregates work inside transaction")
212+
func aggregatesInTransaction() async throws {
213+
try await withCleanTable { repo in
214+
let _ = try await repo.insert(TestUser(name: "Alice", email: "a@test.com", age: 30))
215+
let _ = try await repo.insert(TestUser(name: "Bob", email: "b@test.com", age: 20))
216+
217+
let sum: Int? = try await repo.transaction { tx in
218+
try await tx.query(TestUser.self).sum { $0.age }
219+
}
220+
#expect(sum == 50)
221+
}
222+
}
223+
}
224+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import Foundation
2+
import Testing
3+
@testable import Spectro
4+
5+
/// Tests for the Ecto-style NotLoaded assertion behavior.
6+
/// These are unit tests — no database needed.
7+
@Suite("NotLoaded Assertions")
8+
struct NotLoadedAssertionTests {
9+
10+
@Test("isLoaded is false on fresh HasMany")
11+
func hasManyNotLoaded() {
12+
let wrapper = HasMany<TestPost>()
13+
#expect(wrapper.projectedValue.isLoaded == false)
14+
}
15+
16+
@Test("isLoaded is true after setting wrappedValue")
17+
func hasManyLoadedAfterSet() {
18+
var wrapper = HasMany<TestPost>()
19+
wrapper.wrappedValue = []
20+
#expect(wrapper.projectedValue.isLoaded == true)
21+
}
22+
23+
@Test("isLoaded is true after preload injection via projectedValue")
24+
func hasManyLoadedAfterInjection() {
25+
var wrapper = HasMany<TestPost>()
26+
wrapper.projectedValue = SpectroLazyRelation(
27+
loaded: [TestPost](),
28+
relationshipInfo: wrapper.projectedValue.relationshipInfo
29+
)
30+
#expect(wrapper.projectedValue.isLoaded == true)
31+
}
32+
33+
@Test("isLoaded is false on fresh BelongsTo")
34+
func belongsToNotLoaded() {
35+
let wrapper = BelongsTo<TestUser>()
36+
#expect(wrapper.projectedValue.isLoaded == false)
37+
}
38+
39+
@Test("isLoaded is true after setting BelongsTo wrappedValue")
40+
func belongsToLoadedAfterSet() {
41+
var wrapper = BelongsTo<TestUser>()
42+
wrapper.wrappedValue = nil
43+
#expect(wrapper.projectedValue.isLoaded == true)
44+
}
45+
46+
@Test("isLoaded is false on fresh HasOne")
47+
func hasOneNotLoaded() {
48+
let wrapper = HasOne<TestUser>()
49+
#expect(wrapper.projectedValue.isLoaded == false)
50+
}
51+
52+
@Test("isLoaded is true after setting HasOne wrappedValue")
53+
func hasOneLoadedAfterSet() {
54+
var wrapper = HasOne<TestUser>()
55+
wrapper.wrappedValue = nil
56+
#expect(wrapper.projectedValue.isLoaded == true)
57+
}
58+
59+
@Test("isLoaded is false on fresh ManyToMany")
60+
func manyToManyNotLoaded() {
61+
let wrapper = ManyToMany<TestUser>()
62+
#expect(wrapper.projectedValue.isLoaded == false)
63+
}
64+
65+
@Test("isLoaded is true after setting ManyToMany wrappedValue")
66+
func manyToManyLoadedAfterSet() {
67+
var wrapper = ManyToMany<TestUser>()
68+
wrapper.wrappedValue = []
69+
#expect(wrapper.projectedValue.isLoaded == true)
70+
}
71+
}

0 commit comments

Comments
 (0)