Skip to content

Commit 26257d6

Browse files
committed
feat(postgres,sqlite): add typed temp table creation in transactions
Signed-off-by: paulwer <paul@wer-ner.de>
1 parent 10d8b60 commit 26257d6

6 files changed

Lines changed: 534 additions & 6 deletions

File tree

packages/3-extensions/postgres/src/runtime/postgres.ts

Lines changed: 141 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@ import type { Contract } from '@prisma-next/contract/types';
44
import postgresDriver from '@prisma-next/driver-postgres/runtime';
55
import { instantiateExecutionStack } from '@prisma-next/framework-components/execution';
66
import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime';
7-
import type { Db } from '@prisma-next/sql-builder/types';
7+
import type {
8+
Db,
9+
QueryContext,
10+
Scope,
11+
ScopeField,
12+
SelectQuery,
13+
Subquery,
14+
} from '@prisma-next/sql-builder/types';
815
import type { ExtractCodecTypes, SqlStorage } from '@prisma-next/sql-contract/types';
916
import { orm as ormBuilder } from '@prisma-next/sql-orm-client';
17+
import { RawSqlExpr, TableSource } from '@prisma-next/sql-relational-core/ast';
1018
import type { CodecTypesBase, RawSqlTag } from '@prisma-next/sql-relational-core/expression';
1119
import { createRawSql } from '@prisma-next/sql-relational-core/expression';
12-
import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan';
20+
import { planFromAst, type SqlQueryPlan } from '@prisma-next/sql-relational-core/plan';
1321
import type {
1422
BindSiteParams,
1523
Declaration,
@@ -43,11 +51,34 @@ import { PostgresRuntimeImpl } from './postgres-runtime';
4351
export type PostgresTargetId = 'postgres';
4452
type OrmClient<TContract extends Contract<SqlStorage>> = ReturnType<typeof ormBuilder<TContract>>;
4553

54+
const TEMP_TABLE_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
55+
const TEMP_TABLE_NAME_MAX_LEN = 63;
56+
57+
export interface TempTableCreateOptions {
58+
readonly name?: string;
59+
}
60+
61+
type TempTableJoinSource<Row extends Record<string, ScopeField>> = ReturnType<
62+
SelectQuery<QueryContext, Scope, Row>['as']
63+
>;
64+
65+
export type TempTableHandle<Row extends Record<string, ScopeField> = Record<string, ScopeField>> =
66+
TempTableJoinSource<Row> & {
67+
readonly name: string;
68+
readonly fields: Row;
69+
drop(): Promise<void>;
70+
};
71+
72+
export interface TempTableBuilder {
73+
as<Row extends Record<string, ScopeField>>(query: Subquery<Row>): Promise<TempTableHandle<Row>>;
74+
}
75+
4676
export interface PostgresTransactionContext<TContract extends Contract<SqlStorage>>
4777
extends TransactionContext {
4878
readonly sql: Db<TContract>;
4979
readonly orm: OrmClient<TContract>;
5080
readonly enums: NamespacedEnums<TContract>;
81+
tempTable(options?: string | TempTableCreateOptions): TempTableBuilder;
5182
}
5283

5384
export interface PostgresClient<TContract extends Contract<SqlStorage>> {
@@ -140,6 +171,106 @@ function toRuntimeBinding<TContract extends Contract<SqlStorage>>(
140171
} as const;
141172
}
142173

174+
function quoteIdentifier(name: string): string {
175+
return `"${name.replaceAll('"', '""')}"`;
176+
}
177+
178+
function resolveTempTableName(options?: string | TempTableCreateOptions): string {
179+
const requestedName = typeof options === 'string' ? options : options?.name;
180+
if (requestedName !== undefined) {
181+
const trimmed = requestedName.trim();
182+
if (!TEMP_TABLE_NAME_RE.test(trimmed)) {
183+
throw new Error(
184+
'Invalid temp table name. Use only letters, numbers, and underscore, and start with a letter/underscore.',
185+
);
186+
}
187+
if (trimmed.length > TEMP_TABLE_NAME_MAX_LEN) {
188+
throw new Error(`Invalid temp table name. Maximum length is ${TEMP_TABLE_NAME_MAX_LEN}.`);
189+
}
190+
return trimmed;
191+
}
192+
193+
const suffix = crypto.randomUUID().replaceAll('-', '').slice(0, 20);
194+
return `pn_temp_${suffix}`;
195+
}
196+
197+
async function executeRawSql(
198+
txCtx: TransactionContext,
199+
contract: Contract<SqlStorage>,
200+
sql: string,
201+
): Promise<void> {
202+
await txCtx.execute(planFromAst(RawSqlExpr.of([sql], []), contract, 'raw.temp-table')).toArray();
203+
}
204+
205+
function createTempTableBuilder(
206+
txCtx: TransactionContext,
207+
contract: Contract<SqlStorage>,
208+
stack: SqlExecutionStackWithDriver<PostgresTargetId>,
209+
options?: string | TempTableCreateOptions,
210+
): TempTableBuilder {
211+
const asJoinSource = <Row extends Record<string, ScopeField>>(
212+
tableName: string,
213+
alias: string,
214+
rowFields: Row,
215+
): TempTableJoinSource<Row> => {
216+
const source = {
217+
getJoinOuterScope: () => ({
218+
topLevel: rowFields,
219+
namespaces: { [alias]: rowFields } as Record<string, Row>,
220+
}),
221+
buildAst: () => TableSource.named(tableName, alias),
222+
};
223+
return blindCast<TempTableJoinSource<Row>, 'source implements TempTableJoinSource duck-type'>(
224+
source,
225+
);
226+
};
227+
228+
return {
229+
async as<Row extends Record<string, ScopeField>>(
230+
query: Subquery<Row>,
231+
): Promise<TempTableHandle<Row>> {
232+
const tableName = resolveTempTableName(options);
233+
const quotedTableName = quoteIdentifier(tableName);
234+
const queryPlan = planFromAst(query.buildAst(), contract, 'dsl');
235+
const adapter = instantiateExecutionStack(stack).adapter;
236+
const lowered = adapter.lower(queryPlan.ast, {
237+
contract,
238+
params: queryPlan.params,
239+
});
240+
const params = lowered.params.map((slot) => {
241+
if (slot.kind === 'literal') return slot.value;
242+
throw new Error('tempTable.as(...) does not accept bind-site parameters.');
243+
});
244+
245+
const createPlan = Object.freeze({
246+
sql: `CREATE TEMP TABLE ${quoteIdentifier(tableName)} AS ${lowered.sql}`,
247+
params,
248+
ast: queryPlan.ast,
249+
meta: queryPlan.meta,
250+
});
251+
252+
await txCtx.execute(createPlan).toArray();
253+
254+
const rowFields = blindCast<Row, 'subquery row fields align with Subquery<Row> generic'>(
255+
query.getRowFields(),
256+
);
257+
const defaultJoin = asJoinSource<Row>(tableName, tableName, rowFields);
258+
259+
return blindCast<
260+
TempTableHandle<Row>,
261+
'temp table handle created from Subquery<Row> preserves the same row field shape'
262+
>({
263+
...defaultJoin,
264+
name: tableName,
265+
fields: rowFields,
266+
async drop(): Promise<void> {
267+
await executeRawSql(txCtx, contract, `DROP TABLE IF EXISTS ${quotedTableName}`);
268+
},
269+
});
270+
},
271+
};
272+
}
273+
143274
/**
144275
* Creates a lazy Postgres client from either `contractJson` or a TypeScript-authored `contract`.
145276
* Static query surfaces are available immediately, while `runtime()` instantiates the driver/pool on first call.
@@ -337,7 +468,14 @@ export default function postgres<TContract extends Contract<SqlStorage>>(
337468
// Spreading would evaluate the getter once and freeze its value.
338469
const tx: PostgresTransactionContext<TContract> = Object.assign(
339470
Object.create(txCtx) as TransactionContext,
340-
{ sql: txSql, orm: txOrm, enums },
471+
{
472+
sql: txSql,
473+
orm: txOrm,
474+
enums,
475+
tempTable(options?: string | TempTableCreateOptions): TempTableBuilder {
476+
return createTempTableBuilder(txCtx, context.contract, stack, options);
477+
},
478+
},
341479
);
342480

343481
return fn(tx);

packages/3-extensions/postgres/test/postgres.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import type { Contract } from '@prisma-next/contract/types';
2+
import type { ScopeField, Subquery } from '@prisma-next/sql-builder/types';
23
import type { SqlStorage } from '@prisma-next/sql-contract/types';
4+
import { ProjectionItem, SelectAst, TableSource } from '@prisma-next/sql-relational-core/ast';
5+
import { planFromAst } from '@prisma-next/sql-relational-core/plan';
36
import type { Runtime } from '@prisma-next/sql-runtime';
47
import { createContract } from '@prisma-next/test-utils';
8+
import { blindCast } from '@prisma-next/utils/casts';
59
import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
610

711
// Only mock the third-party pg boundary. Real drivers, adapters, and runtimes
@@ -378,6 +382,150 @@ describe('postgres', () => {
378382
expect(receivedTx!.orm).toBeDefined();
379383
});
380384

385+
it('transaction tempTable() creates and drops a typed temp table with generated name', async () => {
386+
const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' });
387+
const fakeClient = {
388+
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
389+
release: vi.fn(),
390+
};
391+
(pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient);
392+
393+
const db = postgres({ contract, pg: pool });
394+
await db.connect();
395+
396+
const subquery = blindCast<
397+
Subquery<{ id: ScopeField }>,
398+
'test fixture for temp-table typed subquery'
399+
>({
400+
buildAst: () =>
401+
SelectAst.from(TableSource.named('source_table')).withProjection([
402+
ProjectionItem.of('id', db.raw`1`.returns('pg/int4@1').buildAst()),
403+
]),
404+
getRowFields: () => ({ id: { codecId: 'pg/int4@1', nullable: false } }),
405+
});
406+
407+
await db.transaction(async (tx) => {
408+
const temp = await tx.tempTable().as(subquery);
409+
expect(temp.name).toMatch(/^pn_temp_[a-f0-9]+$/);
410+
expect(temp.fields['id']?.codecId).toBe('pg/int4@1');
411+
expect('buildAst' in temp).toBe(true);
412+
expect('getJoinOuterScope' in temp).toBe(true);
413+
await temp.drop();
414+
});
415+
416+
await db.close();
417+
418+
const issuedSql = fakeClient.query.mock.calls.map((call) => {
419+
const arg = call[0] as string | { text?: string };
420+
return typeof arg === 'string' ? arg : (arg.text ?? '');
421+
});
422+
expect(issuedSql.some((sql) => sql.startsWith('CREATE TEMP TABLE'))).toBe(true);
423+
expect(issuedSql.some((sql) => sql.startsWith('DROP TABLE IF EXISTS'))).toBe(true);
424+
});
425+
426+
it('transaction tempTable() accepts a manual table name', async () => {
427+
const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' });
428+
const fakeClient = {
429+
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
430+
release: vi.fn(),
431+
};
432+
(pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient);
433+
434+
const db = postgres({ contract, pg: pool });
435+
await db.connect();
436+
437+
const subquery = blindCast<
438+
Subquery<{ id: ScopeField; email: ScopeField }>,
439+
'test fixture for temp-table typed subquery'
440+
>({
441+
buildAst: () =>
442+
SelectAst.from(TableSource.named('source_table')).withProjection([
443+
ProjectionItem.of('id', db.raw`1`.returns('pg/int4@1').buildAst()),
444+
ProjectionItem.of('email', db.raw`'a@example.com'`.returns('pg/text@1').buildAst()),
445+
]),
446+
getRowFields: () => ({
447+
id: { codecId: 'pg/int4@1', nullable: false },
448+
email: { codecId: 'pg/text@1', nullable: false },
449+
}),
450+
});
451+
452+
await db.transaction(async (tx) => {
453+
const temp = await tx.tempTable({ name: 'recent_users' }).as(subquery);
454+
expect(temp.name).toBe('recent_users');
455+
expect(temp.fields).toEqual({
456+
id: { codecId: 'pg/int4@1', nullable: false },
457+
email: { codecId: 'pg/text@1', nullable: false },
458+
});
459+
await temp.drop();
460+
});
461+
462+
await db.close();
463+
464+
const issuedSql = fakeClient.query.mock.calls.map((call) => {
465+
const arg = call[0] as string | { text?: string };
466+
return typeof arg === 'string' ? arg : (arg.text ?? '');
467+
});
468+
expect(issuedSql.some((sql) => sql.includes('CREATE TEMP TABLE "recent_users" AS'))).toBe(true);
469+
expect(issuedSql.some((sql) => sql.includes('DROP TABLE IF EXISTS "recent_users"'))).toBe(true);
470+
});
471+
472+
it('transaction tempTable() can be reused in tx.sql join composition within the same transaction', async () => {
473+
const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' });
474+
const fakeClient = {
475+
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
476+
release: vi.fn(),
477+
};
478+
(pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient);
479+
480+
const db = postgres({ contract, pg: pool });
481+
await db.connect();
482+
483+
const subquery = blindCast<
484+
Subquery<{ id: ScopeField; email: ScopeField }>,
485+
'test fixture for temp-table typed subquery'
486+
>({
487+
buildAst: () =>
488+
SelectAst.from(TableSource.named('source_table')).withProjection([
489+
ProjectionItem.of('id', db.raw`1`.returns('pg/int4@1').buildAst()),
490+
ProjectionItem.of('email', db.raw`'a@example.com'`.returns('pg/text@1').buildAst()),
491+
]),
492+
getRowFields: () => ({
493+
id: { codecId: 'pg/int4@1', nullable: false },
494+
email: { codecId: 'pg/text@1', nullable: false },
495+
}),
496+
});
497+
498+
await db.transaction(async (tx) => {
499+
const recentUsers = await tx.tempTable({ name: 'recent_users' }).as(subquery);
500+
501+
await tx
502+
.execute(
503+
planFromAst(
504+
SelectAst.from(recentUsers.buildAst()).withProjection([
505+
ProjectionItem.of('id', db.raw`1`.returns('pg/int4@1').buildAst()),
506+
ProjectionItem.of('email', db.raw`'a@example.com'`.returns('pg/text@1').buildAst()),
507+
]),
508+
contract,
509+
'dsl',
510+
),
511+
)
512+
.toArray();
513+
514+
await recentUsers.drop();
515+
});
516+
517+
await db.close();
518+
519+
const issuedSql = fakeClient.query.mock.calls.map((call) => {
520+
const arg = call[0] as string | { text?: string };
521+
return typeof arg === 'string' ? arg : (arg.text ?? '');
522+
});
523+
expect(issuedSql.some((sql) => sql.includes('CREATE TEMP TABLE "recent_users" AS'))).toBe(true);
524+
expect(issuedSql.some((sql) => sql.includes('FROM "recent_users" AS "recent_users"'))).toBe(
525+
true,
526+
);
527+
});
528+
381529
it('transaction() lazily creates runtime before connect()', async () => {
382530
const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' });
383531
const fakeClient = {

packages/3-extensions/postgres/test/transaction.types.test-d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,20 @@ test('tx.orm has the same type as db.orm', () => {
3333
type TxOrm = PostgresTransactionContext<TestContract>['orm'];
3434
expectTypeOf<TxOrm>().toEqualTypeOf<DbOrm>();
3535
});
36+
37+
test('transaction context exposes tempTable()', () => {
38+
type HasTempTable = 'tempTable' extends keyof PostgresTransactionContext<TestContract>
39+
? true
40+
: false;
41+
expectTypeOf<HasTempTable>().toEqualTypeOf<true>();
42+
});
43+
44+
test('tempTable().as returns a metadata-rich handle', () => {
45+
type Builder = ReturnType<PostgresTransactionContext<TestContract>['tempTable']>;
46+
type HandlePromise = ReturnType<Builder['as']>;
47+
expectTypeOf<Awaited<HandlePromise>>().toMatchTypeOf<{
48+
name: string;
49+
fields: Record<string, { codecId: string; nullable: boolean }>;
50+
drop(): Promise<void>;
51+
}>();
52+
});

0 commit comments

Comments
 (0)