Skip to content

Commit 283c5ba

Browse files
SevInfclaude
andcommitted
test(scalar-arrays): prove String[]/Int[] read-back via SQL runtime and ORM client
Add a sibling to the DateTime[]/Bytes[]/Decimal[] roundtrip covering the plain scalar element types: author `tags String[]`/`scores Int[]` in PSL, migrate onto real Postgres, insert, and SELECT back, asserting element-wise decode for pg/text@1 and pg/int4@1. Add an ORM-client read-back test: over the same PSL-authored contract, read the native array columns through orm().<ns>.<model>.select(...).all(), proving the ORM projects and decodes scalar many:true columns as JS arrays (not just to-many relations). The PSL path yields a generic Contract<SqlStorage>, so accessors are index signatures; the narrow row-type array inference is covered by the emitted contract path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
1 parent e682b72 commit 283c5ba

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* Reads a native scalar-list column (`tags String[]`, `scores Int[]`) back
3+
* through the ORM client. A model authored in PSL emits array storage columns
4+
* (`pg/text@1`/`pg/int4@1`, `many:true`); after migrating onto a real Postgres
5+
* database and inserting a row, `orm().<ns>.<model>.select(...).all()` surfaces
6+
* each list column as a decoded JS array — proving the ORM read path projects
7+
* and decodes scalar `many` columns element-wise, not just to-many relations.
8+
*/
9+
import postgresAdapter from '@prisma-next/adapter-postgres/control';
10+
import postgresRuntimeAdapter from '@prisma-next/adapter-postgres/runtime';
11+
import type { Contract } from '@prisma-next/contract/types';
12+
import postgresControlDriver from '@prisma-next/driver-postgres/control';
13+
import sql, { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control';
14+
import { APP_SPACE_ID, createControlStack } from '@prisma-next/framework-components/control';
15+
import { buildSynthMigrationEdge } from '@prisma-next/migration-tools/aggregate';
16+
import type { SqlStorage } from '@prisma-next/sql-contract/types';
17+
import { orm } from '@prisma-next/sql-orm-client';
18+
import { InsertAst, ParamRef, TableSource } from '@prisma-next/sql-relational-core/ast';
19+
import { planFromAst } from '@prisma-next/sql-relational-core/plan';
20+
import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime';
21+
import postgres from '@prisma-next/target-postgres/control';
22+
import postgresRuntimeTarget from '@prisma-next/target-postgres/runtime';
23+
import { createDevDatabase, type DevDatabase, timeouts, withClient } from '@prisma-next/test-utils';
24+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
25+
import { createTestRuntimeFromClient } from '../utils';
26+
import {
27+
authorSqlContractFromPsl,
28+
findStorageColumn,
29+
listCodecRefFor,
30+
postgresFrameworkComponents,
31+
tableNameForColumn,
32+
} from './psl-list-authoring';
33+
34+
const controlStack = createControlStack({
35+
family: sql,
36+
target: postgres,
37+
adapter: postgresAdapter,
38+
driver: postgresControlDriver,
39+
extensionPacks: [],
40+
});
41+
const familyInstance = sql.create(controlStack);
42+
43+
async function migrateContract(
44+
connectionString: string,
45+
contract: Contract<SqlStorage>,
46+
): Promise<void> {
47+
const driver = await postgresControlDriver.create(connectionString);
48+
try {
49+
const schema = await familyInstance.introspect({ driver });
50+
const planner = postgres.createPlanner(postgresAdapter.create(controlStack));
51+
const planResult = planner.plan({
52+
contract,
53+
schema,
54+
policy: INIT_ADDITIVE_POLICY,
55+
fromContract: null,
56+
frameworkComponents: postgresFrameworkComponents,
57+
spaceId: APP_SPACE_ID,
58+
});
59+
if (planResult.kind !== 'success') {
60+
throw new Error(`planner failed: ${JSON.stringify(planResult)}`);
61+
}
62+
63+
const runner = postgres.createRunner(familyInstance);
64+
const runResult = await runner.execute({
65+
driver,
66+
perSpaceOptions: [
67+
{
68+
space: APP_SPACE_ID,
69+
plan: planResult.plan,
70+
migrationEdges: [
71+
buildSynthMigrationEdge({
72+
currentMarkerStorageHash: planResult.plan.origin?.storageHash,
73+
destinationStorageHash: planResult.plan.destination.storageHash,
74+
operationCount: planResult.plan.operations.length,
75+
}),
76+
],
77+
driver,
78+
destinationContract: contract,
79+
policy: INIT_ADDITIVE_POLICY,
80+
frameworkComponents: postgresFrameworkComponents,
81+
},
82+
],
83+
});
84+
if (!runResult.ok) {
85+
throw new Error(`runner failed: ${JSON.stringify(runResult.failure)}`);
86+
}
87+
} finally {
88+
await driver.close();
89+
}
90+
}
91+
92+
describe.sequential('ORM scalar-list read-back', () => {
93+
let database: DevDatabase | undefined;
94+
95+
beforeAll(async () => {
96+
database = await createDevDatabase();
97+
}, timeouts.spinUpPpgDev);
98+
99+
afterAll(async () => {
100+
if (database) await database.close();
101+
}, timeouts.spinUpPpgDev);
102+
103+
it(
104+
'orm().<model>.select(...).all() surfaces String[]/Int[] columns as arrays',
105+
async () => {
106+
if (!database) throw new Error('database not initialised');
107+
108+
const authored = await authorSqlContractFromPsl(`model Item {
109+
id Int @id
110+
tags String[]
111+
scores Int[]
112+
}`);
113+
expect(authored.ok).toBe(true);
114+
const contract = authored.contract;
115+
if (!contract) throw new Error('authoring produced no contract');
116+
117+
// Sanity: native array columns (not the jsonb fallback).
118+
expect(findStorageColumn(contract, 'tags')).toMatchObject({
119+
codecId: 'pg/text@1',
120+
many: true,
121+
});
122+
expect(findStorageColumn(contract, 'scores')).toMatchObject({
123+
codecId: 'pg/int4@1',
124+
many: true,
125+
});
126+
127+
await withClient(database.connectionString, async (client) => {
128+
await client.query('DROP SCHEMA IF EXISTS public CASCADE');
129+
await client.query('CREATE SCHEMA public');
130+
await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE');
131+
});
132+
await migrateContract(database.connectionString, contract);
133+
134+
const tableName = tableNameForColumn(contract, 'tags');
135+
const table = TableSource.named(tableName);
136+
const tagsRef = listCodecRefFor(contract, 'tags');
137+
const scoresRef = listCodecRefFor(contract, 'scores');
138+
const tags = ['a', 'b', 'c'];
139+
const scores = [1, 2, 3];
140+
141+
await withClient(database.connectionString, async (client) => {
142+
const runtime = await createTestRuntimeFromClient(contract, client, {
143+
verifyMarker: false,
144+
});
145+
146+
// Seed via the raw runtime; the ORM read path is what's under test.
147+
const insert = InsertAst.into(table).withRows([
148+
{
149+
id: ParamRef.of(1, { codec: { codecId: 'pg/int4@1' } }),
150+
tags: ParamRef.of(tags, { codec: tagsRef }),
151+
scores: ParamRef.of(scores, { codec: scoresRef }),
152+
},
153+
]);
154+
await runtime.execute(planFromAst(insert, contract)).toArray();
155+
156+
const context = createExecutionContext<Contract<SqlStorage>>({
157+
contract,
158+
stack: createSqlExecutionStack({
159+
target: postgresRuntimeTarget,
160+
adapter: postgresRuntimeAdapter,
161+
extensionPacks: [],
162+
}),
163+
});
164+
const db = orm({ runtime, context });
165+
166+
// The PSL path yields a generic `Contract<SqlStorage>`, so the ORM's
167+
// namespace/model accessors are index signatures (bracket access). The
168+
// narrow row-type array inference for `many` columns is proven by the
169+
// emitted-contract path; here we prove the runtime read: the ORM
170+
// projects and decodes the scalar `many` columns as JS arrays.
171+
const publicNamespace = db['public'];
172+
if (!publicNamespace) throw new Error('public namespace missing from ORM client');
173+
const items = publicNamespace['Item'];
174+
if (!items) throw new Error('Item collection missing from ORM client');
175+
176+
// Single seeded row — result order is trivially deterministic.
177+
const rows = await items.select('id', 'tags', 'scores').all();
178+
179+
expect(rows).toEqual([{ id: 1, tags, scores }]);
180+
});
181+
},
182+
timeouts.spinUpPpgDev,
183+
);
184+
});

test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,4 +282,83 @@ model Reading {
282282
},
283283
timeouts.spinUpPpgDev,
284284
);
285+
286+
it(
287+
'String[]/Int[] authored in PSL round-trip element values',
288+
async () => {
289+
if (!database) throw new Error('database not initialised');
290+
291+
const authored = await authorSqlContractFromPsl(`model Item {
292+
id Int @id
293+
tags String[]
294+
scores Int[]
295+
}`);
296+
expect(authored.ok).toBe(true);
297+
const contract = authored.contract;
298+
if (!contract) throw new Error('authoring produced no contract');
299+
300+
// Sanity: each list field lowered to a native array column (not jsonb).
301+
expect(findStorageColumn(contract, 'tags')).toMatchObject({
302+
codecId: 'pg/text@1',
303+
many: true,
304+
});
305+
expect(findStorageColumn(contract, 'scores')).toMatchObject({
306+
codecId: 'pg/int4@1',
307+
many: true,
308+
});
309+
310+
await withClient(database.connectionString, async (client) => {
311+
await client.query('DROP SCHEMA IF EXISTS public CASCADE');
312+
await client.query('CREATE SCHEMA public');
313+
await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE');
314+
});
315+
await migrateContract(database.connectionString, contract);
316+
317+
const tableName = tableNameForColumn(contract, 'tags');
318+
const table = TableSource.named(tableName);
319+
const tagsRef = listCodecRefFor(contract, 'tags');
320+
const scoresRef = listCodecRefFor(contract, 'scores');
321+
322+
const tags = ['a', 'b', 'c'];
323+
const scores = [1, 2, 3];
324+
325+
await withClient(database.connectionString, async (client) => {
326+
const runtime = await createTestRuntimeFromClient(contract, client, {
327+
verifyMarker: false,
328+
});
329+
330+
const insert = InsertAst.into(table).withRows([
331+
{
332+
id: ParamRef.of(1, { codec: { codecId: 'pg/int4@1' } }),
333+
tags: ParamRef.of(tags, { codec: tagsRef }),
334+
scores: ParamRef.of(scores, { codec: scoresRef }),
335+
},
336+
]);
337+
await runtime.execute(planFromAst(insert, contract)).toArray();
338+
339+
const select = SelectAst.from(table)
340+
.withProjection([
341+
ProjectionItem.of('tags', ColumnRef.of(tableName, 'tags'), tagsRef),
342+
ProjectionItem.of('scores', ColumnRef.of(tableName, 'scores'), scoresRef),
343+
])
344+
.withWhere(
345+
BinaryExpr.eq(
346+
ColumnRef.of(tableName, 'id'),
347+
ParamRef.of(1, { codec: { codecId: 'pg/int4@1' } }),
348+
),
349+
);
350+
351+
const rows = await runtime.execute(planFromAst(select, contract)).toArray();
352+
expect(rows).toHaveLength(1);
353+
const row = rows[0] as unknown as {
354+
tags: string[];
355+
scores: number[];
356+
};
357+
358+
expect(row.tags).toEqual(tags);
359+
expect(row.scores).toEqual(scores);
360+
});
361+
},
362+
timeouts.spinUpPpgDev,
363+
);
285364
});

0 commit comments

Comments
 (0)