Skip to content

Commit 89b0bd6

Browse files
committed
test(sql-orm-client): M:N correlated include integration tests
Adds `mn-include.test.ts` covering the end-to-end M:N include path for `User.tags` via the `user_tags` junction, using the PGlite harness. Tests satisfy the operator integration-test standard: - Whole-row toEqual assertions on every test - Explicit .select() on 6/7 tests - One implicit/default-selection test (full User + tags: Tag[] shape) - Single-execution assertion + no LATERAL in emitted SQL - Depth-2: M:N tags nested under invitedUsers (1:N self-relation) - Sibling depth-2: include("tags") alongside include("posts") in one execution - Edge: user with no tags returns tags: [] - Edge: tag shared by multiple users resolves independently for each Also extends `setupTestSchema` and adds `seedTags`/`seedUserTags` helpers to `runtime-helpers.ts` to support the junction table in integration tests. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent d2244a8 commit 89b0bd6

2 files changed

Lines changed: 352 additions & 0 deletions

File tree

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
// Integration coverage for M:N include (User -> tags via user_tags junction).
2+
//
3+
// `User.tags` is a many-to-many relation to `Tag` through the `user_tags`
4+
// junction table. The read path compiles a correlated junction subquery that
5+
// resolves each user's tags in a single SQL execution. These tests prove the
6+
// end-to-end behaviour against a real database.
7+
//
8+
// Test data shape:
9+
//
10+
// User(id, name, email, invitedById?)
11+
// tags: N:M Tag through user_tags (via user_id / tag_id)
12+
//
13+
// Tag(id: text, name: text)
14+
//
15+
// UserTag(userId, tagId) — junction
16+
//
17+
// Standard (from project integration-test standard):
18+
// 1. Whole-row assertions via toEqual on every test.
19+
// 2. Explicit .select() used in most tests.
20+
// 3. At least one implicit/default-selection test (no .select()).
21+
22+
import { describe, expect, it } from 'vitest';
23+
import { createUsersCollection, timeouts, withCollectionRuntime } from './integration-helpers';
24+
import { seedTags, seedUsers, seedUserTags } from './runtime-helpers';
25+
26+
// Tag IDs are text at the DB level (sql/char@1 at contract level).
27+
const TAG_RUST = 'tag-rust';
28+
const TAG_TS = 'tag-typescript';
29+
const TAG_DB = 'tag-database';
30+
31+
describe('integration/mn-include', () => {
32+
// ===========================================================================
33+
// Core M:N include via junction: whole-row correctness.
34+
// ===========================================================================
35+
36+
it(
37+
'include("tags") with explicit select returns selected fields on user and tags (whole-row toEqual)',
38+
async () => {
39+
await withCollectionRuntime(async (runtime) => {
40+
const users = createUsersCollection(runtime);
41+
42+
await seedUsers(runtime, [
43+
{ id: 1, name: 'Alice', email: 'alice@example.com' },
44+
{ id: 2, name: 'Bob', email: 'bob@example.com' },
45+
]);
46+
await seedTags(runtime, [
47+
{ id: TAG_RUST, name: 'Rust' },
48+
{ id: TAG_TS, name: 'TypeScript' },
49+
]);
50+
await seedUserTags(runtime, [
51+
{ userId: 1, tagId: TAG_RUST },
52+
{ userId: 1, tagId: TAG_TS },
53+
{ userId: 2, tagId: TAG_TS },
54+
]);
55+
56+
const rows = await users
57+
.select('id', 'name')
58+
.orderBy((u) => u.id.asc())
59+
.include('tags', (tags) => tags.select('id', 'name').orderBy((t) => t.name.asc()))
60+
.all();
61+
62+
expect(rows).toEqual([
63+
{
64+
id: 1,
65+
name: 'Alice',
66+
tags: [
67+
{ id: TAG_RUST, name: 'Rust' },
68+
{ id: TAG_TS, name: 'TypeScript' },
69+
],
70+
},
71+
{
72+
id: 2,
73+
name: 'Bob',
74+
tags: [{ id: TAG_TS, name: 'TypeScript' }],
75+
},
76+
]);
77+
});
78+
},
79+
timeouts.spinUpPpgDev,
80+
);
81+
82+
it(
83+
'include("tags") resolves in a single SQL execution with no LATERAL keyword',
84+
async () => {
85+
// The M:N correlated subquery through the junction must lower to a
86+
// single SQL execution. The junction join inside the subquery is an
87+
// inner join — never a LATERAL join — so LATERAL must be absent from
88+
// the emitted SQL.
89+
await withCollectionRuntime(async (runtime) => {
90+
const users = createUsersCollection(runtime);
91+
92+
await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]);
93+
await seedTags(runtime, [{ id: TAG_TS, name: 'TypeScript' }]);
94+
await seedUserTags(runtime, [{ userId: 1, tagId: TAG_TS }]);
95+
96+
runtime.resetExecutions();
97+
const rows = await users
98+
.select('id', 'name')
99+
.include('tags', (tags) => tags.select('id', 'name'))
100+
.all();
101+
102+
expect(rows).toEqual([
103+
{ id: 1, name: 'Alice', tags: [{ id: TAG_TS, name: 'TypeScript' }] },
104+
]);
105+
expect(runtime.executions).toHaveLength(1);
106+
expect(runtime.executions[0]?.sql).not.toContain('LATERAL');
107+
});
108+
},
109+
timeouts.spinUpPpgDev,
110+
);
111+
112+
it(
113+
'user with no tags returns tags: []',
114+
async () => {
115+
// Edge case: a user with no junction rows must yield an empty array,
116+
// not null or undefined.
117+
await withCollectionRuntime(async (runtime) => {
118+
const users = createUsersCollection(runtime);
119+
120+
await seedUsers(runtime, [
121+
{ id: 1, name: 'Alice', email: 'alice@example.com' },
122+
{ id: 2, name: 'Bob', email: 'bob@example.com' },
123+
]);
124+
await seedTags(runtime, [{ id: TAG_RUST, name: 'Rust' }]);
125+
// Only Alice has a tag; Bob has none.
126+
await seedUserTags(runtime, [{ userId: 1, tagId: TAG_RUST }]);
127+
128+
const rows = await users
129+
.select('id', 'name')
130+
.orderBy((u) => u.id.asc())
131+
.include('tags', (tags) => tags.select('id', 'name'))
132+
.all();
133+
134+
expect(rows).toEqual([
135+
{ id: 1, name: 'Alice', tags: [{ id: TAG_RUST, name: 'Rust' }] },
136+
{ id: 2, name: 'Bob', tags: [] },
137+
]);
138+
});
139+
},
140+
timeouts.spinUpPpgDev,
141+
);
142+
143+
it(
144+
'a tag connected to multiple users resolves correctly for each user',
145+
async () => {
146+
// A shared tag must appear in every user's tags array independently.
147+
// A bug that deduplicated tags globally (e.g. keyed by tag ID across
148+
// all users) would drop the tag from some users' result.
149+
await withCollectionRuntime(async (runtime) => {
150+
const users = createUsersCollection(runtime);
151+
152+
await seedUsers(runtime, [
153+
{ id: 1, name: 'Alice', email: 'alice@example.com' },
154+
{ id: 2, name: 'Bob', email: 'bob@example.com' },
155+
{ id: 3, name: 'Cara', email: 'cara@example.com' },
156+
]);
157+
await seedTags(runtime, [
158+
{ id: TAG_TS, name: 'TypeScript' },
159+
{ id: TAG_DB, name: 'Database' },
160+
]);
161+
// TypeScript is shared by Alice and Cara; Bob has only Database.
162+
await seedUserTags(runtime, [
163+
{ userId: 1, tagId: TAG_TS },
164+
{ userId: 2, tagId: TAG_DB },
165+
{ userId: 3, tagId: TAG_TS },
166+
]);
167+
168+
const rows = await users
169+
.select('id', 'name')
170+
.orderBy((u) => u.id.asc())
171+
.include('tags', (tags) => tags.select('id', 'name').orderBy((t) => t.name.asc()))
172+
.all();
173+
174+
expect(rows).toEqual([
175+
{ id: 1, name: 'Alice', tags: [{ id: TAG_TS, name: 'TypeScript' }] },
176+
{ id: 2, name: 'Bob', tags: [{ id: TAG_DB, name: 'Database' }] },
177+
{ id: 3, name: 'Cara', tags: [{ id: TAG_TS, name: 'TypeScript' }] },
178+
]);
179+
});
180+
},
181+
timeouts.spinUpPpgDev,
182+
);
183+
184+
it(
185+
'include("tags") with no .select returns the full default row shape (implicit selection)',
186+
async () => {
187+
// Standard requirement: at least one test with no .select so the
188+
// full default shape for User + tags: Tag[] is asserted.
189+
await withCollectionRuntime(async (runtime) => {
190+
const users = createUsersCollection(runtime);
191+
192+
await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]);
193+
await seedTags(runtime, [
194+
{ id: TAG_RUST, name: 'Rust' },
195+
{ id: TAG_TS, name: 'TypeScript' },
196+
]);
197+
await seedUserTags(runtime, [
198+
{ userId: 1, tagId: TAG_RUST },
199+
{ userId: 1, tagId: TAG_TS },
200+
]);
201+
202+
const rows = await users
203+
.orderBy((u) => u.id.asc())
204+
.include('tags', (tags) => tags.orderBy((t) => t.name.asc()))
205+
.all();
206+
207+
// Full User shape + tags: Tag[] (all Tag fields).
208+
expect(rows).toEqual([
209+
{
210+
id: 1,
211+
name: 'Alice',
212+
email: 'alice@example.com',
213+
invitedById: null,
214+
address: null,
215+
tags: [
216+
{ id: TAG_RUST, name: 'Rust' },
217+
{ id: TAG_TS, name: 'TypeScript' },
218+
],
219+
},
220+
]);
221+
});
222+
},
223+
timeouts.spinUpPpgDev,
224+
);
225+
226+
// ===========================================================================
227+
// Depth-2: M:N include nested under a 1:N (invitedUsers -> tags).
228+
// Proves the junction walk composes when the parent row comes from a
229+
// depth-1 include rather than the root collection.
230+
// ===========================================================================
231+
232+
it(
233+
'depth-2: M:N tags nested under 1:N invitedUsers resolves in a single execution',
234+
async () => {
235+
// users -> invitedUsers (1:N self-relation) -> tags (N:M via junction).
236+
// The M:N subquery at depth 2 must still correlate correctly to the
237+
// depth-1 invitedUsers alias and resolve in a single SQL execution.
238+
await withCollectionRuntime(async (runtime) => {
239+
const users = createUsersCollection(runtime);
240+
241+
await seedUsers(runtime, [
242+
{ id: 1, name: 'Alice', email: 'alice@example.com' },
243+
{ id: 2, name: 'Bob', email: 'bob@example.com', invitedById: 1 },
244+
{ id: 3, name: 'Cara', email: 'cara@example.com', invitedById: 1 },
245+
]);
246+
await seedTags(runtime, [
247+
{ id: TAG_RUST, name: 'Rust' },
248+
{ id: TAG_TS, name: 'TypeScript' },
249+
]);
250+
// Bob has Rust; Cara has TypeScript; Alice has no tags.
251+
await seedUserTags(runtime, [
252+
{ userId: 2, tagId: TAG_RUST },
253+
{ userId: 3, tagId: TAG_TS },
254+
]);
255+
256+
runtime.resetExecutions();
257+
const rows = await users
258+
.select('id', 'name')
259+
.where((u) => u.id.eq(1))
260+
.include('invitedUsers', (inv) =>
261+
inv
262+
.select('id', 'name')
263+
.orderBy((u) => u.id.asc())
264+
.include('tags', (tags) => tags.select('id', 'name').orderBy((t) => t.name.asc())),
265+
)
266+
.all();
267+
268+
expect(rows).toEqual([
269+
{
270+
id: 1,
271+
name: 'Alice',
272+
invitedUsers: [
273+
{ id: 2, name: 'Bob', tags: [{ id: TAG_RUST, name: 'Rust' }] },
274+
{ id: 3, name: 'Cara', tags: [{ id: TAG_TS, name: 'TypeScript' }] },
275+
],
276+
},
277+
]);
278+
expect(runtime.executions).toHaveLength(1);
279+
expect(runtime.executions[0]?.sql).not.toContain('LATERAL');
280+
});
281+
},
282+
timeouts.spinUpPpgDev,
283+
);
284+
285+
it(
286+
'depth-2: sibling include("tags") and include("posts") on the same user resolves in one execution',
287+
async () => {
288+
// Two sibling top-level includes: tags (N:M) and posts (1:N). Both must
289+
// pack into a single SQL execution and resolve to independent correct shapes.
290+
await withCollectionRuntime(async (runtime) => {
291+
const users = createUsersCollection(runtime);
292+
293+
await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]);
294+
await seedTags(runtime, [{ id: TAG_RUST, name: 'Rust' }]);
295+
await seedUserTags(runtime, [{ userId: 1, tagId: TAG_RUST }]);
296+
297+
runtime.resetExecutions();
298+
const rows = await users
299+
.select('id', 'name')
300+
.include('tags', (tags) => tags.select('id', 'name'))
301+
.include('posts', (posts) => posts.select('id', 'title').orderBy((p) => p.id.asc()))
302+
.all();
303+
304+
expect(rows).toEqual([
305+
{ id: 1, name: 'Alice', tags: [{ id: TAG_RUST, name: 'Rust' }], posts: [] },
306+
]);
307+
expect(runtime.executions).toHaveLength(1);
308+
});
309+
},
310+
timeouts.spinUpPpgDev,
311+
);
312+
});

test/integration/test/sql-orm-client/runtime-helpers.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ interface SeedComment {
4141
postId: number;
4242
}
4343

44+
interface SeedTag {
45+
id: string;
46+
name: string;
47+
}
48+
49+
interface SeedUserTag {
50+
userId: number;
51+
tagId: string;
52+
}
53+
4454
export interface PgIntegrationRuntime extends RuntimeQueryable {
4555
readonly executions: readonly SqlExecutionPlan[];
4656
query<Row extends Record<string, unknown> = Record<string, unknown>>(
@@ -166,6 +176,7 @@ export async function setupTestSchema(runtime: PgIntegrationRuntime): Promise<vo
166176
)`);
167177
await runtime.query('create extension if not exists vector');
168178

179+
await runtime.query('drop table if exists user_tags');
169180
await runtime.query('drop table if exists tags');
170181
await runtime.query('drop table if exists comments');
171182
await runtime.query('drop table if exists profiles');
@@ -214,6 +225,14 @@ export async function setupTestSchema(runtime: PgIntegrationRuntime): Promise<vo
214225
name text not null unique
215226
)
216227
`);
228+
229+
await runtime.query(`
230+
create table user_tags (
231+
user_id integer not null,
232+
tag_id text not null,
233+
primary key (user_id, tag_id)
234+
)
235+
`);
217236
}
218237

219238
export async function seedUsers(
@@ -271,3 +290,24 @@ export async function seedComments(
271290
]);
272291
}
273292
}
293+
294+
export async function seedTags(
295+
runtime: PgIntegrationRuntime,
296+
tags: readonly SeedTag[],
297+
): Promise<void> {
298+
for (const tag of tags) {
299+
await runtime.query('insert into tags (id, name) values ($1, $2)', [tag.id, tag.name]);
300+
}
301+
}
302+
303+
export async function seedUserTags(
304+
runtime: PgIntegrationRuntime,
305+
userTags: readonly SeedUserTag[],
306+
): Promise<void> {
307+
for (const ut of userTags) {
308+
await runtime.query('insert into user_tags (user_id, tag_id) values ($1, $2)', [
309+
ut.userId,
310+
ut.tagId,
311+
]);
312+
}
313+
}

0 commit comments

Comments
 (0)