Skip to content

Commit 4aeb245

Browse files
committed
test(sql-orm-client): integration parity for pointer-disambiguated relations
Adds two PSL fixtures + integration tests proving the S3 pointer-disambiguation forms drive the ORM end-to-end (PGlite, whole-row toEqual, explicit + implicit select): - disambiguated-1n-inverse: two relations between User and Post (Post.author / Post.editor, both N:1 FKs to User) with the User back-relations disambiguated by @relation(inverse: author/editor). include() over each back-relation returns the rows joined through the FK the inverse: pointer names (authoredPosts → author_id, editedPosts → editor_id). - self-ref-mn-through: a self-referential M:N (User.following / User.followers through the Follow junction) disambiguated by @relation(through: Follow.follower / Follow.followee). include() over each end drives the self-join of the junction + target onto the same users table and returns the correct per-user-distinct rows. Both fixtures are emitted through the real contract-emit pipeline (wired into the sql-orm-client emit script) and deserialized at module load, so each test also confirms the disambiguated contract round-trips the full sql contract validation pipeline. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent bf563d7 commit 4aeb245

11 files changed

Lines changed: 1756 additions & 1 deletion

File tree

packages/3-extensions/sql-orm-client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"description": "ORM client for Prisma Next — fluent, type-safe model collections",
88
"scripts": {
99
"build": "tsdown",
10-
"emit": "cd ../../../test/integration && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/polymorphism/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/junction-namespaces/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/execution-defaulted-tags/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl-through/prisma-next.config.ts && cp test/sql-orm-client/fixtures/generated/contract.json test/sql-orm-client/fixtures/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/generated/ && cp test/sql-orm-client/fixtures/junction-namespaces/generated/contract.json test/sql-orm-client/fixtures/junction-namespaces/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/junction-namespaces/generated/ && cd ../../packages/3-extensions/sql-orm-client && node scripts/strip-pgvector-fixture.mjs test/fixtures/generated/contract.d.ts",
10+
"emit": "cd ../../../test/integration && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/polymorphism/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/junction-namespaces/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/execution-defaulted-tags/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl-through/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/disambiguated-1n-inverse/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/self-ref-mn-through/prisma-next.config.ts && cp test/sql-orm-client/fixtures/generated/contract.json test/sql-orm-client/fixtures/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/generated/ && cp test/sql-orm-client/fixtures/junction-namespaces/generated/contract.json test/sql-orm-client/fixtures/junction-namespaces/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/junction-namespaces/generated/ && cd ../../packages/3-extensions/sql-orm-client && node scripts/strip-pgvector-fixture.mjs test/fixtures/generated/contract.d.ts",
1111
"emit:check": "pnpm emit && git diff --exit-code test/fixtures/generated/ test/fixtures/junction-namespaces/generated/",
1212
"test": "vitest run",
1313
"test:coverage": "vitest run --coverage",
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Runtime parity for a 1:N back-relation disambiguated with `@relation(inverse:)`.
2+
//
3+
// `User` and `Post` carry TWO relations between the same pair of models:
4+
// `Post.author` and `Post.editor` are both N:1 FKs to `User`. The two `User`
5+
// back-relations would be ambiguous on their own, so each pins its owning FK by
6+
// pointing at the relation field — `authoredPosts Post[] @relation(inverse: author)`
7+
// and `editedPosts Post[] @relation(inverse: editor)` — the directional
8+
// replacement for the legacy `@relation(name:)` disambiguator.
9+
//
10+
// These tests drive `include()` over EACH back-relation and assert the rows
11+
// joined through the FK the `inverse:` pointer named: `authoredPosts` resolves
12+
// posts by `author_id`, `editedPosts` by `editor_id`. A mis-paired pointer
13+
// would surface the wrong posts (or empty arrays) and fail.
14+
//
15+
// Deserializing the emitted JSON runs the full sql contract validation pipeline,
16+
// so each test also proves the disambiguated 1:N contract round-trips validation.
17+
//
18+
// Standard:
19+
// 1. Whole-row toEqual assertions on every test.
20+
// 2. Explicit .select() in most tests; one implicit-selection readback.
21+
22+
import postgresAdapter from '@prisma-next/adapter-postgres/runtime';
23+
import pgvectorRuntime from '@prisma-next/extension-pgvector/runtime';
24+
import { Collection } from '@prisma-next/sql-orm-client';
25+
import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context';
26+
import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime';
27+
import postgresTarget, { PostgresContractSerializer } from '@prisma-next/target-postgres/runtime';
28+
import { describe, expect, it } from 'vitest';
29+
import type { Contract as DisambiguatedContract } from './fixtures/disambiguated-1n-inverse/generated/contract';
30+
import disambiguatedContractJson from './fixtures/disambiguated-1n-inverse/generated/contract.json' with {
31+
type: 'json',
32+
};
33+
import { timeouts, withCollectionRuntime } from './integration-helpers';
34+
import type { PgIntegrationRuntime } from './runtime-helpers';
35+
36+
// Deserialization runs the full sql contract validation pipeline
37+
// (structure + domain + storage semantics), so a contract that failed to
38+
// round-trip validation would throw here at module load.
39+
const disambiguatedContract = new PostgresContractSerializer().deserializeContract(
40+
disambiguatedContractJson,
41+
) as DisambiguatedContract;
42+
43+
const disambiguatedContext: ExecutionContext<DisambiguatedContract> = createExecutionContext({
44+
contract: disambiguatedContract,
45+
stack: createSqlExecutionStack({
46+
target: postgresTarget,
47+
adapter: postgresAdapter,
48+
extensionPacks: [pgvectorRuntime],
49+
}),
50+
});
51+
52+
function createUsersCollection(runtime: PgIntegrationRuntime) {
53+
return new Collection({ runtime, context: disambiguatedContext }, 'User', {
54+
namespaceId: 'public',
55+
});
56+
}
57+
58+
// The base `posts` table (from setupTestSchema) carries only `user_id`. This
59+
// fixture needs the two disambiguated FK columns instead, so drop and recreate
60+
// the table to match the emitted contract's `author_id` / `editor_id` storage.
61+
async function createDisambiguatedSchema(runtime: PgIntegrationRuntime): Promise<void> {
62+
await runtime.query('drop table if exists comments');
63+
await runtime.query('drop table if exists posts');
64+
await runtime.query(`
65+
create table posts (
66+
id integer primary key,
67+
title text not null,
68+
author_id integer not null,
69+
editor_id integer not null
70+
)
71+
`);
72+
}
73+
74+
async function seedDisambiguatedPosts(
75+
runtime: PgIntegrationRuntime,
76+
posts: readonly { id: number; title: string; authorId: number; editorId: number }[],
77+
): Promise<void> {
78+
for (const post of posts) {
79+
await runtime.query(
80+
'insert into posts (id, title, author_id, editor_id) values ($1, $2, $3, $4)',
81+
[post.id, post.title, post.authorId, post.editorId],
82+
);
83+
}
84+
}
85+
86+
describe('integration/disambiguated-1n-inverse-parity', () => {
87+
it(
88+
'include("authoredPosts") returns posts joined by author_id (whole-row toEqual)',
89+
async () => {
90+
await withCollectionRuntime(async (runtime) => {
91+
await createDisambiguatedSchema(runtime);
92+
93+
const users = createUsersCollection(runtime);
94+
95+
await runtime.query(
96+
"insert into users (id, name, email) values (1, 'Alice', 'alice@example.com')",
97+
);
98+
await runtime.query(
99+
"insert into users (id, name, email) values (2, 'Bob', 'bob@example.com')",
100+
);
101+
// Alice authors posts 10 & 11 (Bob edits them); Bob authors post 12
102+
// (Alice edits it). authoredPosts must follow author_id, not editor_id.
103+
await seedDisambiguatedPosts(runtime, [
104+
{ id: 10, title: 'Rust intro', authorId: 1, editorId: 2 },
105+
{ id: 11, title: 'Async TS', authorId: 1, editorId: 2 },
106+
{ id: 12, title: 'SQL deep-dive', authorId: 2, editorId: 1 },
107+
]);
108+
109+
const rows = await users
110+
.select('id', 'name')
111+
.orderBy((u) => u.id.asc())
112+
.include('authoredPosts', (posts) =>
113+
posts.select('id', 'title').orderBy((p) => p.id.asc()),
114+
)
115+
.all();
116+
117+
expect(rows).toEqual([
118+
{
119+
id: 1,
120+
name: 'Alice',
121+
authoredPosts: [
122+
{ id: 10, title: 'Rust intro' },
123+
{ id: 11, title: 'Async TS' },
124+
],
125+
},
126+
{
127+
id: 2,
128+
name: 'Bob',
129+
authoredPosts: [{ id: 12, title: 'SQL deep-dive' }],
130+
},
131+
]);
132+
}, disambiguatedContext.contract);
133+
},
134+
timeouts.spinUpPpgDev,
135+
);
136+
137+
it(
138+
'include("editedPosts") returns posts joined by editor_id (whole-row toEqual)',
139+
async () => {
140+
await withCollectionRuntime(async (runtime) => {
141+
await createDisambiguatedSchema(runtime);
142+
143+
const users = createUsersCollection(runtime);
144+
145+
await runtime.query(
146+
"insert into users (id, name, email) values (1, 'Alice', 'alice@example.com')",
147+
);
148+
await runtime.query(
149+
"insert into users (id, name, email) values (2, 'Bob', 'bob@example.com')",
150+
);
151+
await seedDisambiguatedPosts(runtime, [
152+
{ id: 10, title: 'Rust intro', authorId: 1, editorId: 2 },
153+
{ id: 11, title: 'Async TS', authorId: 1, editorId: 2 },
154+
{ id: 12, title: 'SQL deep-dive', authorId: 2, editorId: 1 },
155+
]);
156+
157+
const rows = await users
158+
.select('id', 'name')
159+
.orderBy((u) => u.id.asc())
160+
.include('editedPosts', (posts) => posts.select('id', 'title').orderBy((p) => p.id.asc()))
161+
.all();
162+
163+
// The mirror of the authoredPosts case: editedPosts follows editor_id,
164+
// so Alice edits post 12 and Bob edits posts 10 & 11.
165+
expect(rows).toEqual([
166+
{
167+
id: 1,
168+
name: 'Alice',
169+
editedPosts: [{ id: 12, title: 'SQL deep-dive' }],
170+
},
171+
{
172+
id: 2,
173+
name: 'Bob',
174+
editedPosts: [
175+
{ id: 10, title: 'Rust intro' },
176+
{ id: 11, title: 'Async TS' },
177+
],
178+
},
179+
]);
180+
}, disambiguatedContext.contract);
181+
},
182+
timeouts.spinUpPpgDev,
183+
);
184+
185+
it(
186+
'include("authoredPosts") with no .select returns the full default row shape (implicit selection)',
187+
async () => {
188+
await withCollectionRuntime(async (runtime) => {
189+
await createDisambiguatedSchema(runtime);
190+
191+
const users = createUsersCollection(runtime);
192+
193+
await runtime.query(
194+
"insert into users (id, name, email) values (1, 'Alice', 'alice@example.com')",
195+
);
196+
await seedDisambiguatedPosts(runtime, [
197+
{ id: 10, title: 'Rust intro', authorId: 1, editorId: 1 },
198+
{ id: 11, title: 'Async TS', authorId: 1, editorId: 1 },
199+
]);
200+
201+
const rows = await users
202+
.orderBy((u) => u.id.asc())
203+
.include('authoredPosts', (posts) => posts.orderBy((p) => p.id.asc()))
204+
.all();
205+
206+
expect(rows).toEqual([
207+
{
208+
id: 1,
209+
name: 'Alice',
210+
email: 'alice@example.com',
211+
authoredPosts: [
212+
{ id: 10, title: 'Rust intro', authorId: 1, editorId: 1 },
213+
{ id: 11, title: 'Async TS', authorId: 1, editorId: 1 },
214+
],
215+
},
216+
]);
217+
}, disambiguatedContext.contract);
218+
},
219+
timeouts.spinUpPpgDev,
220+
);
221+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Real emitted fixture for the disambiguated 1:N integration parity test.
2+
//
3+
// `User` and `Post` carry TWO relations between the same pair of models:
4+
// `Post.author` and `Post.editor` are both N:1 foreign keys to `User`. The two
5+
// `User` back-relations (`authoredPosts` / `editedPosts`) would be ambiguous on
6+
// their own (which FK does each list field pair with?), so each pins its owning
7+
// FK by pointing at the relation field with `@relation(inverse: <fkField>)` —
8+
// the directional replacement for the legacy `@relation(name:)` disambiguator.
9+
//
10+
// The interpreter lowers each back-relation to `cardinality: '1:N'` with the
11+
// correct `on` block (`authoredPosts` → `author_id`, `editedPosts` → `editor_id`),
12+
// so the ORM `include` over each back-relation returns the rows joined through
13+
// the FK the `inverse:` pointer named.
14+
//
15+
// Column / table names are pinned with `@map` / `@@map` so the integration
16+
// test's `create table` DDL is a clean snake_case schema.
17+
18+
model User {
19+
id Int @id
20+
name String
21+
email String @unique
22+
23+
authoredPosts Post[] @relation(inverse: author)
24+
editedPosts Post[] @relation(inverse: editor)
25+
26+
@@map("users")
27+
}
28+
29+
model Post {
30+
id Int @id
31+
title String
32+
authorId Int @map("author_id")
33+
editorId Int @map("editor_id")
34+
35+
author User @relation(from: authorId)
36+
editor User @relation(from: editorId)
37+
38+
@@map("posts")
39+
}

0 commit comments

Comments
 (0)