From e93149ae1dfbd67cd7fc2ecd693962cfae67c5e6 Mon Sep 17 00:00:00 2001 From: paulwer Date: Sun, 12 Jul 2026 14:06:33 +0200 Subject: [PATCH] feat(postgres,sqlite): add typed temp table creation in transactions Signed-off-by: paulwer --- packages/2-sql/5-runtime/src/exports/index.ts | 3 +- packages/2-sql/5-runtime/src/sql-runtime.ts | 697 +++++-------- .../2-sql/5-runtime/test/sql-runtime.test.ts | 107 ++ .../postgres/src/runtime/postgres.ts | 471 +++++++-- .../postgres/test/postgres.test.ts | 513 ++++++--- .../postgres/test/transaction.types.test-d.ts | 132 ++- .../sql-orm-client/src/collection.ts | 985 ++++++++---------- .../sql-orm-client/src/exports/index.ts | 1 + .../src/internal-temp-table-source.ts | 11 + .../test/collection.as-subquery.test.ts | 29 + .../3-extensions/sqlite/src/runtime/sqlite.ts | 498 +++++++-- .../sqlite/test/transaction.test.ts | 358 ++++++- .../sqlite/test/transaction.types.test-d.ts | 132 ++- .../supabase/src/runtime/supabase-runtime.ts | 230 +--- projects/temp-tables-in-transactions/spec.md | 280 +++++ .../framework/test/sqlite/transaction.test.ts | 103 +- .../framework/test/transaction-orm.test.ts | 94 +- 17 files changed, 3120 insertions(+), 1524 deletions(-) create mode 100644 packages/3-extensions/sql-orm-client/src/internal-temp-table-source.ts create mode 100644 packages/3-extensions/sql-orm-client/test/collection.as-subquery.test.ts create mode 100644 projects/temp-tables-in-transactions/spec.md diff --git a/packages/2-sql/5-runtime/src/exports/index.ts b/packages/2-sql/5-runtime/src/exports/index.ts index 0708dabe17bb..0042d28123f0 100644 --- a/packages/2-sql/5-runtime/src/exports/index.ts +++ b/packages/2-sql/5-runtime/src/exports/index.ts @@ -64,6 +64,7 @@ export { createSqlExecutionStack, } from '../sql-context'; export type { + ConnectionContext, ConnectionProvider, Runtime, RuntimeConnection, @@ -71,4 +72,4 @@ export type { RuntimeTransaction, TransactionContext, } from '../sql-runtime'; -export { SqlRuntimeBase, withTransaction } from '../sql-runtime'; +export { SqlRuntimeBase, withConnection, withTransaction } from '../sql-runtime'; diff --git a/packages/2-sql/5-runtime/src/sql-runtime.ts b/packages/2-sql/5-runtime/src/sql-runtime.ts index 874b9c8e4f8b..fc03af42d79d 100644 --- a/packages/2-sql/5-runtime/src/sql-runtime.ts +++ b/packages/2-sql/5-runtime/src/sql-runtime.ts @@ -1,4 +1,4 @@ -import type { Contract } from '@internal/contract/types'; +import type { Contract } from '@prisma-next/contract/types'; import { AsyncIterableResult, checkAborted, @@ -8,12 +8,10 @@ import { type RuntimeLog, type RuntimeMiddlewareContext, runBeforeExecuteChain, - runBeforeQueryChain, - runExecuteWithMiddleware, - runQueryWithMiddleware, runtimeError, -} from '@internal/framework-components/runtime'; -import type { SqlStorage } from '@internal/sql-contract/types'; + runWithMiddleware, +} from '@prisma-next/framework-components/runtime'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { Adapter, AnyQueryAst, @@ -24,21 +22,19 @@ import type { SqlConnection, SqlDriver, SqlQueryable, - SqlStatementStats, SqlTransaction, -} from '@internal/sql-relational-core/ast'; -import { collectOrderedParamRefs } from '@internal/sql-relational-core/ast'; -import type { CodecTypesBase } from '@internal/sql-relational-core/expression'; +} from '@prisma-next/sql-relational-core/ast'; +import { collectOrderedParamRefs } from '@prisma-next/sql-relational-core/ast'; +import type { CodecTypesBase } from '@prisma-next/sql-relational-core/expression'; import { createSqlParamRefMutator, type SqlParamRefMutator, type SqlParamRefMutatorInternal, -} from '@internal/sql-relational-core/middleware'; -import type { SqlExecutionPlan, SqlQueryPlan } from '@internal/sql-relational-core/plan'; -import type { CodecDescriptorRegistry } from '@internal/sql-relational-core/query-lane-context'; -import type { RuntimeScope } from '@internal/sql-relational-core/types'; -import { blindCast } from '@internal/utils/casts'; -import { ifDefined } from '@internal/utils/defined'; +} from '@prisma-next/sql-relational-core/middleware'; +import type { SqlExecutionPlan, SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; +import type { CodecDescriptorRegistry } from '@prisma-next/sql-relational-core/query-lane-context'; +import type { RuntimeScope } from '@prisma-next/sql-relational-core/types'; +import { ifDefined } from '@prisma-next/utils/defined'; import { buildDecodeContext, type DecodeContext, decodeRow } from './codecs/decoding'; import { deriveParamMetadata, encodeParams, encodeParamsWithMetadata } from './codecs/encoding'; import { validateCodecRegistryCompleteness } from './codecs/validation'; @@ -50,17 +46,6 @@ import type { SqlMiddleware, SqlMiddlewareContext } from './middleware/sql-middl import { buildBindSiteParams } from './prepared/bind-site-params'; import { resolvePreparedSlotValues } from './prepared/encode-prepared'; import { - type PreparedStatementExecuteTarget, - preparedStatementExecute, - runPreparedExecute, -} from './prepared/prepared-execute'; -import { - type PreparedStatementQueryTarget, - preparedStatementQuery, - runPreparedQuery, -} from './prepared/prepared-query'; -import { - PreparedExecutionImpl, PreparedStatementImpl, type PreparedStatementInternals, } from './prepared/prepared-statement'; @@ -68,8 +53,6 @@ import type { Declaration, ParamsFromDeclaration, PrepareCallback, - PreparedExecution, - PreparedFor, PreparedStatement, } from './prepared/types'; import type { @@ -110,34 +93,84 @@ export interface Runtime extends RuntimeQueryable { prepare, Row, CT extends CodecTypesBase = CodecTypesBase>( declaration: D, callback: PrepareCallback, - ): Promise, Row>>; + ): Promise, Row>>; } export interface RuntimeConnection extends RuntimeQueryable { transaction(): Promise; /** - * Returns the connection to the pool for reuse. Only call this when the connection is known to be in a clean state. If a transaction commit/rollback failed or the connection is otherwise suspect, call `destroy(reason)` instead. + * Register a hook to run immediately before the connection is released back to the pool. + * Hooks are invoked in registration order and awaited sequentially. If any hook throws, + * the connection is destroyed rather than returned to the pool, and the error propagates. + * Not invoked when `destroy()` is called directly. + */ + registerReleaseHook(hook: () => Promise): void; + /** + * Returns the connection to the pool for reuse. Runs all registered release hooks first; + * if any hook throws the connection is destroyed and the error propagates. Only call this + * when the connection is known to be in a clean state. If a transaction commit/rollback + * failed or the connection is otherwise suspect, call `destroy(reason)` instead. */ release(): Promise; /** - * Evicts the connection so it is never reused. Call this when the connection may be in an indeterminate state (e.g. a failed rollback leaving an open transaction, or a broken socket). + * Evicts the connection so it is never reused. Call this when the connection may be in an + * indeterminate state (e.g. a failed rollback leaving an open transaction, or a broken socket). * - * If teardown fails the error is propagated and the connection remains retryable, so the caller can decide whether to swallow the failure or retry cleanup. Calling destroy() or release() more than once after a successful teardown is caller error. + * If teardown fails the error is propagated and the connection remains retryable, so the caller + * can decide whether to swallow the failure or retry cleanup. Calling destroy() or release() more + * than once after a successful teardown is caller error. * - * `reason` is advisory context only. It may be surfaced to driver-level observability hooks (e.g. pg-pool's `'release'` event) but does not influence eviction behavior and is not rethrown. + * `reason` is advisory context only. It may be surfaced to driver-level observability hooks + * (e.g. pg-pool's `'release'` event) but does not influence eviction behavior and is not rethrown. */ destroy(reason?: unknown): Promise; } +/** + * Restricted view of a {@link RuntimeConnection} passed to a {@link withConnection} callback. + * Exposes query execution and release-hook registration, but not the raw `release()` / `destroy()` + * lifecycle methods — those are managed by `withConnection` itself. + */ +export interface ConnectionContext extends RuntimeQueryable { + registerReleaseHook(hook: () => Promise): void; +} + export interface RuntimeTransaction extends RuntimeQueryable { commit(): Promise; rollback(): Promise; + /** + * Register a hook to run immediately before the transaction is committed. + * Hooks are invoked in registration order and awaited sequentially. If any + * hook throws, the commit is aborted and the error propagates to the caller. + * Not invoked on rollback. + */ + registerPreCommitHook(hook: () => Promise): void; + runPreCommitHooks(): Promise; } -export interface RuntimeQueryable extends RuntimeScope {} +export interface RuntimeQueryable extends RuntimeScope { + /** + * Run a prepared statement against this scope. Required for the explicit + * `PreparedStatement.execute(target, params)` API — every scope (top-level + * runtime, connection, transaction) routes prepared executions through the + * `SqlQueryable` it is backed by. + */ + executePrepared( + ps: PreparedStatement, + params: Params, + options?: RuntimeExecuteOptions, + ): AsyncIterableResult; +} export interface TransactionContext extends RuntimeQueryable { readonly invalidated: boolean; + /** + * Register a hook to run immediately before the transaction is committed. + * Hooks are invoked in registration order and awaited sequentially. If any + * hook throws, the commit is aborted and the error propagates to the caller. + * Not invoked on rollback. + */ + registerPreCommitHook(hook: () => Promise): void; } export type { RuntimeTelemetryEvent, TelemetryOutcome, VerifyMarkerOption }; @@ -187,18 +220,13 @@ export abstract class SqlRuntimeBase = Co mode: mode ?? 'strict', now: () => Date.now(), log: log ?? noopLog, - // ctx is only invoked by operation-specific middleware runner with execs this runtime lowered; the framework parameter type is the cross-family base. - contentHash: (exec) => - computeSqlContentHash( - blindCast< - SqlExecutionPlan, - 'SQL operation middleware receives lowered SQL execution plans' - >(exec), - ), + // ctx is only invoked by runWithMiddleware with execs this runtime lowered; the framework parameter type is the cross-family base. + contentHash: (exec) => computeSqlContentHash(exec as SqlExecutionPlan), scope: 'runtime', // Placeholder satisfying the required field on the cross-family base. The - // stored ctx is a runtime-level template; `createQueryContexts` spreads it - // and overrides `planExecutionId` with a fresh UUID. ADR 220. + // stored ctx is a runtime-level template; the per-execute ctxs constructed + // in `executeAgainstQueryable` / `executePreparedAgainstQueryable` spread + // this template and override `planExecutionId` with a fresh UUID. ADR 220. planExecutionId: '', }; @@ -222,14 +250,15 @@ export abstract class SqlRuntimeBase = Co * with encoded parameters ready for the driver. * * Implementation note: SQL splits lower-then-encode across - * {@link lowerToDraft} + {@link encodeDraftParams} so the selected - * operation's middleware chain can run between them: - * {@link prepareQueryExecution} uses `runBeforeQueryChain` for `query()`, - * while {@link prepareExecuteExecution} uses `runBeforeExecuteChain` for - * `execute()` (cipherstash bulk-encrypt, for example, mutates pre-encode - * `ParamRef.value` slots). This protected hook composes the two back into - * the cross-family `lower()` shape `RuntimeCore` expects. The production - * operation methods use the matching split form before driver execution. + * {@link lowerToDraft} + {@link encodeDraftParams} so the runtime + * can fire the `beforeExecute` middleware chain between them + * (cipherstash bulk-encrypt, for example, mutates pre-encode + * `ParamRef.value` slots). This protected hook composes the two + * back into the cross-family `lower()` shape `RuntimeCore.execute` + * expects, and is called from the no-middleware fast paths / + * fixtures that hit `RuntimeCore`'s default template directly. + * `execute()` overrides the template and uses the split form so + * `beforeExecute` lands between the two halves. * * `ctx: SqlCodecCallContext` is forwarded to `encodeParams` so * per-query cancellation reaches every codec body during parameter @@ -245,21 +274,21 @@ export abstract class SqlRuntimeBase = Co } /** - * AST → pre-encode draft for the selected `query()` or `execute()` operation. - * The returned plan has `sql` rendered and `params` populated with the - * user-domain values the lowering site collected from `ParamRef` nodes. No - * codec encode has happened yet; consumers can mutate `params` via the - * `SqlParamRefMutator` before {@link encodeDraftParams} runs. + * AST → pre-encode draft. The returned plan has `sql` rendered and + * `params` populated with the user-domain values the lowering site + * collected from `ParamRef` nodes. No codec encode has happened + * yet; consumers can mutate `params` via the `SqlParamRefMutator` + * before {@link encodeDraftParams} runs. */ private lowerToDraft(plan: SqlQueryPlan): SqlExecutionPlan { return lowerSqlPlan(this.adapter, this.contract, plan); } /** - * Encode a draft plan's params for the selected `query()` or `execute()` - * operation through the per-column codecs and freeze the result into the - * final `SqlExecutionPlan` the driver sees. Errors surface as - * `RUNTIME.ENCODE_FAILED` envelopes from {@link encodeParams}. + * Encode a draft plan's params through the per-column codecs and + * freeze the result into the final `SqlExecutionPlan` the driver + * sees. Errors surface as `RUNTIME.ENCODE_FAILED` envelopes from + * {@link encodeParams}. */ private async encodeDraftParams( draft: SqlExecutionPlan, @@ -271,82 +300,47 @@ export abstract class SqlRuntimeBase = Co }); } - /** Default query invocation required by the abstract `RuntimeCore` contract. */ + /** + * Default driver invocation required by the abstract `RuntimeCore` contract. Every production path overrides `execute()` and routes through `executeAgainstQueryable`, so this hook is defensive only — subclasses that delegate back to `super.execute()` would land here. + */ // v8 ignore next 6 protected override runDriver(exec: SqlExecutionPlan): AsyncIterable> { - return this.driver.query>({ + return this.driver.execute>({ sql: exec.sql, params: exec.params, }); } - protected override runExecute(exec: SqlExecutionPlan): Promise { - return this.driver.execute({ sql: exec.sql, params: exec.params }); - } - /** * SQL pre-compile hook. Runs the registered middleware `beforeCompile` chain over the plan's draft (AST + meta). Returns the original plan unchanged when no middleware rewrote the AST; otherwise returns a new plan carrying the rewritten AST and meta. The AST is the authoritative source of execution metadata, so a rewrite needs no sidecar reconciliation here — the lowering adapter and the encoder both walk the rewritten * AST directly. */ - protected override runBeforeCompile(plan: SqlQueryPlan): Promise { - return this.compilePlan(plan, this.sqlCtx); - } - - private async compilePlan( - plan: SqlQueryPlan, - middlewareCtx: SqlMiddlewareContext, - ): Promise { + protected override async runBeforeCompile(plan: SqlQueryPlan): Promise { const rewrittenDraft = await runBeforeCompileChain( this.middleware, { ast: plan.ast, meta: plan.meta }, - middlewareCtx, + this.sqlCtx, ); return rewrittenDraft.ast === plan.ast ? plan : { ...plan, ast: rewrittenDraft.ast, meta: rewrittenDraft.meta }; } - override query( + override execute( plan: (SqlExecutionPlan | SqlQueryPlan) & { readonly _row?: Row }, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return this.queryAgainstQueryable(plan, this.driver, options); + return this.executeAgainstQueryable(plan, this.driver, options); } - override execute( - plan: SqlExecutionPlan | SqlQueryPlan, - options?: RuntimeExecuteOptions, - ): Promise { - return this.executeStatisticsAgainstQueryable(plan, this.driver, options); - } - - [preparedStatementQuery]( + executePrepared( ps: PreparedStatement, params: Params, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return this.runPreparedQueryAgainstQueryable( - blindCast< - PreparedStatementImpl, - 'prepared statements are created by this runtime implementation' - >(ps), - params, - this.driver, - options, - ); - } - - [preparedStatementExecute]( - ps: PreparedExecution, - params: Params, - options?: RuntimeExecuteOptions, - ): Promise { - return this.runPreparedExecuteAgainstQueryable( - blindCast< - PreparedExecutionImpl, - 'prepared statements are created by this runtime implementation' - >(ps), - params, + return this.executePreparedAgainstQueryable( + ps as PreparedStatementImpl, + params as Record, this.driver, options, ); @@ -362,15 +356,6 @@ export abstract class SqlRuntimeBase = Co return this.driver.acquireConnection(); } - private async setupDriverExecution(exec: SqlExecutionPlan): Promise { - this.familyAdapter.validatePlan(exec, this.contract); - this._telemetry = null; - if (this.verifyMarkerPromise === null) { - this.verifyMarkerPromise = this.verifyMarker(); - } - await this.verifyMarkerPromise; - } - private async *streamRows( exec: SqlExecutionPlan, decodeContext: DecodeContext, @@ -378,13 +363,19 @@ export abstract class SqlRuntimeBase = Co codecCtx: SqlCodecCallContext, execMiddlewareCtx: RuntimeMiddlewareContext, ): AsyncGenerator { - await this.setupDriverExecution(exec); + this.familyAdapter.validatePlan(exec, this.contract); + this._telemetry = null; + + if (this.verifyMarkerPromise === null) { + this.verifyMarkerPromise = this.verifyMarker(); + } + await this.verifyMarkerPromise; const startedAt = Date.now(); let outcome: TelemetryOutcome | null = null; try { - const stream = runQueryWithMiddleware>( + const stream = runWithMiddleware>( exec, this.middleware, execMiddlewareCtx, @@ -425,138 +416,116 @@ export abstract class SqlRuntimeBase = Co } } - private createQueryContexts(options: RuntimeExecuteOptions | undefined): { - readonly codecCtx: SqlCodecCallContext; - readonly middlewareCtx: SqlMiddlewareContext; - } { + /** + * Execute a plan against a caller-supplied queryable, running the full + * middleware/codec/telemetry pipeline. Use `acquireRawConnection` to obtain a + * queryable that subclasses can bind typed plans to. + */ + protected executeAgainstQueryable( + plan: SqlExecutionPlan | SqlQueryPlan, + queryable: SqlQueryable, + options?: RuntimeExecuteOptions, + ): AsyncIterableResult { + this.ensureCodecRegistryValidated(); + + const self = this; const signal = options?.signal; const scope = options?.scope ?? 'runtime'; + // One ctx per execute() call — the same reference is shared by encodeParams (lower), decodeRow (per-row), and the stream loop's between-row checks. Per-cell ctx allocations inside decodeField add `column` for resolvable cells without re-wrapping the signal. The ctx object is always allocated; the `signal` field is only included when a signal was supplied (exactOptionalPropertyTypes). const codecCtx: SqlCodecCallContext = signal === undefined ? {} : { signal }; - const middlewareCtx: SqlMiddlewareContext = { - ...this.sqlCtx, + + // Per-execute view of the middleware ctx that carries the per-query + // signal. `self.ctx` is allocated once at construction (no signal); we + // shallow-clone it here so middleware sees the same `AbortSignal` + // reference threaded into `codecCtx.signal` (ADR 207 identity). + // + // The middleware context for this execution is also scope-narrowed: the + // top-level runtime path uses the constructor-time `'runtime'` ctx as-is; + // `connection.execute` and `transaction.execute` produce a derived ctx + // with the appropriate scope. Middleware that observe `ctx.scope` + // (e.g. the cache middleware, which only intercepts at `'runtime'`) + // see the right value without any out-of-band signaling. + // + // `planExecutionId` is minted here too: every execute() call — top-level, + // connection-scoped, or transaction-scoped — flows through this helper and + // gets its own fresh UUID. Hooks for one call see the same value; two + // calls (even with the same plan) see distinct values. ADR 220. + const execMiddlewareCtx: RuntimeMiddlewareContext = { + ...self.ctx, ...ifDefined('signal', signal), ...(scope !== 'runtime' ? { scope } : {}), planExecutionId: crypto.randomUUID(), }; - return { codecCtx, middlewareCtx }; - } - private prepareQueryExecution( - plan: SqlExecutionPlan | SqlQueryPlan, - codecCtx: SqlCodecCallContext, - middlewareCtx: SqlMiddlewareContext, - ): Promise { - return this.prepareOperation(plan, codecCtx, middlewareCtx, runBeforeQueryChain); - } - - private prepareExecuteExecution( - plan: SqlExecutionPlan | SqlQueryPlan, - codecCtx: SqlCodecCallContext, - middlewareCtx: SqlMiddlewareContext, - ): Promise { - return this.prepareOperation(plan, codecCtx, middlewareCtx, runBeforeExecuteChain); - } - - private async prepareOperation( - plan: SqlExecutionPlan | SqlQueryPlan, - codecCtx: SqlCodecCallContext, - middlewareCtx: SqlMiddlewareContext, - runBefore: ( - plan: SqlExecutionPlan, - middleware: ReadonlyArray, - ctx: RuntimeMiddlewareContext, - mutator: SqlParamRefMutator, - ) => Promise, - ): Promise { - checkAborted(codecCtx, 'stream'); - - if (isExecutionPlan(plan)) { - const mutator: SqlParamRefMutatorInternal = createSqlParamRefMutator(plan); - await runBefore(plan, this.middleware, middlewareCtx, mutator); - return Object.freeze({ - ...plan, - params: await encodeParams( - { ...plan, params: mutator.currentParams() }, - codecCtx, - this.contractCodecs, - ), - }); - } - - const compiled = await this.compilePlan(plan, middlewareCtx); - const draft = this.lowerToDraft(compiled); - const mutator: SqlParamRefMutatorInternal = createSqlParamRefMutator(draft); - await runBefore(draft, this.middleware, middlewareCtx, mutator); - const draftWithMutations: SqlExecutionPlan = Object.freeze({ - ...draft, - params: mutator.currentParams(), - }); - return this.encodeDraftParams(draftWithMutations, codecCtx); - } + const generator = async function* (): AsyncGenerator { + checkAborted(codecCtx, 'stream'); - /** Query rows against a caller-supplied queryable through the shared preparation pipeline. */ - protected queryAgainstQueryable( - plan: SqlExecutionPlan | SqlQueryPlan, - queryable: SqlQueryable, - options?: RuntimeExecuteOptions, - ): AsyncIterableResult { - this.ensureCodecRegistryValidated(); + let exec: SqlExecutionPlan; + if (isExecutionPlan(plan)) { + // Pre-lowered fixture path. The plan's params are typically + // already encoded; we still fire `beforeExecute` so middleware + // that mutates ParamRef values (e.g. cipherstash bulk-encrypt) + // gets a chance to run, then re-encode so any mutations land. + const preEncodeMutator: SqlParamRefMutatorInternal = createSqlParamRefMutator(plan); + await runBeforeExecuteChain( + plan, + self.middleware, + execMiddlewareCtx, + preEncodeMutator, + ); + exec = Object.freeze({ + ...plan, + params: await encodeParams( + { ...plan, params: preEncodeMutator.currentParams() }, + codecCtx, + self.contractCodecs, + ), + }); + } else { + // Standard AST → exec path. Split lower from encode so the + // `beforeExecute` chain fires between them with a mutator built + // over the pre-encode draft params; encode then renders the + // (possibly mutated) values through the column codecs. + const compiled = await self.runBeforeCompile(plan); + const draft = self.lowerToDraft(compiled); + const preEncodeMutator: SqlParamRefMutatorInternal = createSqlParamRefMutator(draft); + await runBeforeExecuteChain( + draft, + self.middleware, + execMiddlewareCtx, + preEncodeMutator, + ); + const draftWithMutations: SqlExecutionPlan = Object.freeze({ + ...draft, + params: preEncodeMutator.currentParams(), + }); + exec = await self.encodeDraftParams(draftWithMutations, codecCtx); + } - const self = this; - const { codecCtx, middlewareCtx } = this.createQueryContexts(options); - const generator = async function* (): AsyncGenerator { - const exec = await self.prepareQueryExecution(plan, codecCtx, middlewareCtx); const decodeContext = buildDecodeContext(exec.ast, self.contractCodecs); + yield* self.streamRows( exec, decodeContext, - () => queryable.query>({ sql: exec.sql, params: exec.params }), + () => queryable.execute>({ sql: exec.sql, params: exec.params }), codecCtx, - middlewareCtx, + execMiddlewareCtx, ); }; return new AsyncIterableResult(generator()); } - /** Execute statistics against a caller-supplied queryable through the shared preparation pipeline. */ - protected async executeStatisticsAgainstQueryable( - plan: SqlExecutionPlan | SqlQueryPlan, - queryable: SqlQueryable, - options?: RuntimeExecuteOptions, - ): Promise { - this.ensureCodecRegistryValidated(); - - const { codecCtx, middlewareCtx } = this.createQueryContexts(options); - const exec = await this.prepareExecuteExecution(plan, codecCtx, middlewareCtx); - await this.setupDriverExecution(exec); - checkAborted(codecCtx, 'stream'); - - const startedAt = Date.now(); - let outcome: TelemetryOutcome = 'success'; - try { - return await runExecuteWithMiddleware(exec, this.middleware, middlewareCtx, () => - queryable.execute({ sql: exec.sql, params: exec.params }), - ); - } catch (error) { - outcome = 'runtime-error'; - throw error; - } finally { - this.recordTelemetry(exec, outcome, Date.now() - startedAt); - } - } - async prepare, Row, CT extends CodecTypesBase = CodecTypesBase>( declaration: D, callback: PrepareCallback, - ): Promise, Row>> { + ): Promise, Row>> { this.ensureCodecRegistryValidated(); const bindSiteParams = buildBindSiteParams(declaration); const userPlan = callback(bindSiteParams); const finalPlan = await this.runBeforeCompile(userPlan); - const orderedRefs = collectOrderedParamRefs(finalPlan.ast); // Type-level detection isn't achievable across chained-builder generics. @@ -590,32 +559,33 @@ export abstract class SqlRuntimeBase = Co paramMetadata, }); - // The plan's declared result picks the handle: a statement reporting an - // affected-row count prepares into one that executes, everything else into - // one that streams rows. The cast carries that runtime choice into the - // conditional type `PreparedFor` states for the caller. - const prepared = - finalPlan.ast.kind === 'raw-query' && finalPlan.ast.result.kind === 'affected-count' - ? new PreparedExecutionImpl>(internals) - : new PreparedStatementImpl, Row>(internals); - - return blindCast< - PreparedFor, Row>, - "the plan's declared result decides the handle, and PreparedFor states that same choice in the type" - >(prepared); + return new PreparedStatementImpl, Row>(internals); } - /** Query prepared rows against a caller-supplied queryable through the full pipeline. */ - protected runPreparedQueryAgainstQueryable( + /** + * Execute a prepared statement against a caller-supplied queryable, running + * the full middleware/codec/telemetry pipeline. + */ + protected executePreparedAgainstQueryable( ps: PreparedStatementImpl, - userParams: unknown, + userParams: Record, queryable: SqlQueryable, options?: RuntimeExecuteOptions, ): AsyncIterableResult { this.ensureCodecRegistryValidated(); const self = this; - const { codecCtx, middlewareCtx: execMiddlewareCtx } = this.createQueryContexts(options); + const signal = options?.signal; + const scope = options?.scope ?? 'runtime'; + const codecCtx: SqlCodecCallContext = signal === undefined ? {} : { signal }; + // `executePrepared` is a parallel entry point to `executeAgainstQueryable` + // and mints its own fresh `planExecutionId` per call. ADR 220. + const execMiddlewareCtx: RuntimeMiddlewareContext = { + ...self.ctx, + ...ifDefined('signal', signal), + ...(scope !== 'runtime' ? { scope } : {}), + planExecutionId: crypto.randomUUID(), + }; const generator = async function* (): AsyncGenerator { checkAborted(codecCtx, 'stream'); @@ -632,7 +602,7 @@ export abstract class SqlRuntimeBase = Co }; const mutator: SqlParamRefMutatorInternal = createSqlParamRefMutator(preEncodeExec); - await runBeforeQueryChain( + await runBeforeExecuteChain( preEncodeExec, self.middleware, execMiddlewareCtx, @@ -656,7 +626,7 @@ export abstract class SqlRuntimeBase = Co const request: PreparedExecuteRequest = { sql: exec.sql, params: exec.params, - preparedStatementHandle: { + handle: { get: () => handles.get(ps), set: (value) => { handles.set(ps, value); @@ -667,7 +637,7 @@ export abstract class SqlRuntimeBase = Co yield* self.streamRows( exec, ps.decodeContext, - () => queryable.query>(request), + () => queryable.executePrepared>(request), codecCtx, execMiddlewareCtx, ); @@ -676,138 +646,52 @@ export abstract class SqlRuntimeBase = Co return new AsyncIterableResult(generator()); } - /** Execute a prepared statement's statistics against a caller-supplied queryable through the full pipeline. */ - protected async runPreparedExecuteAgainstQueryable

( - ps: PreparedExecutionImpl

, - userParams: unknown, - queryable: SqlQueryable, - options?: RuntimeExecuteOptions, - ): Promise { - this.ensureCodecRegistryValidated(); - - const { codecCtx, middlewareCtx } = this.createQueryContexts(options); - checkAborted(codecCtx, 'stream'); - - // Slot order resolves to unencoded values first so `beforeExecute`'s - // mutator sees pre-encode user values and can override them before encode - // runs — the same split the ad-hoc execute path takes. - const preEncodeValues = resolvePreparedSlotValues(ps, userParams); - const preEncodeExec: SqlExecutionPlan = { - sql: ps.sql, - params: preEncodeValues, - ast: ps.ast, - meta: ps.meta, - }; - - const mutator: SqlParamRefMutatorInternal = createSqlParamRefMutator(preEncodeExec); - await runBeforeExecuteChain( - preEncodeExec, - this.middleware, - middlewareCtx, - mutator, - ); - - const exec: SqlExecutionPlan = { - sql: ps.sql, - params: await encodeParamsWithMetadata( - mutator.currentParams(), - ps.paramMetadata, - codecCtx, - this.contractCodecs, - ), - ast: ps.ast, - meta: ps.meta, - }; - await this.setupDriverExecution(exec); - checkAborted(codecCtx, 'stream'); - - const handles = this.#preparedStatementHandles; - const request: PreparedExecuteRequest = { - sql: exec.sql, - params: exec.params, - preparedStatementHandle: { - get: () => handles.get(ps), - set: (value) => { - handles.set(ps, value); - }, - }, - }; - - const startedAt = Date.now(); - let outcome: TelemetryOutcome = 'success'; - try { - return await runExecuteWithMiddleware(exec, this.middleware, middlewareCtx, () => - queryable.execute(request), - ); - } catch (error) { - outcome = 'runtime-error'; - throw error; - } finally { - this.recordTelemetry(exec, outcome, Date.now() - startedAt); - } - } - async connection(): Promise { const driverConn = await this.driver.acquireConnection(); const self = this; + const releaseHooks: Array<() => Promise> = []; - const wrappedConnection: RuntimeConnection & - PreparedStatementQueryTarget & - PreparedStatementExecuteTarget = { + const wrappedConnection: RuntimeConnection = { async transaction(): Promise { const driverTx = await driverConn.beginTransaction(); return self.wrapTransaction(driverTx); }, + registerReleaseHook(hook: () => Promise): void { + releaseHooks.push(hook); + }, async release(): Promise { - await driverConn.release(); + try { + let hook = releaseHooks.shift(); + while (hook !== undefined) { + await hook(); + hook = releaseHooks.shift(); + } + await driverConn.release(); + } catch (err) { + await driverConn.destroy(err); + throw err; + } }, async destroy(reason?: unknown): Promise { await driverConn.destroy(reason); }, - query( + execute( plan: (SqlExecutionPlan | SqlQueryPlan) & { readonly _row?: Row }, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return self.queryAgainstQueryable(plan, driverConn, { + return self.executeAgainstQueryable(plan, driverConn, { ...options, scope: 'connection', }); }, - execute( - plan: SqlExecutionPlan | SqlQueryPlan, - options?: RuntimeExecuteOptions, - ): Promise { - return self.executeStatisticsAgainstQueryable(plan, driverConn, { - ...options, - scope: 'connection', - }); - }, - [preparedStatementQuery]( + executePrepared( ps: PreparedStatement, params: Params, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return self.runPreparedQueryAgainstQueryable( - blindCast< - PreparedStatementImpl, - 'prepared statements are created by this runtime implementation' - >(ps), - params, - driverConn, - { ...options, scope: 'connection' }, - ); - }, - [preparedStatementExecute]( - ps: PreparedExecution, - params: Params, - options?: RuntimeExecuteOptions, - ): Promise { - return self.runPreparedExecuteAgainstQueryable( - blindCast< - PreparedExecutionImpl, - 'prepared statements are created by this runtime implementation' - >(ps), - params, + return self.executePreparedAgainstQueryable( + ps as PreparedStatementImpl, + params as Record, driverConn, { ...options, scope: 'connection' }, ); @@ -817,67 +701,53 @@ export abstract class SqlRuntimeBase = Co return wrappedConnection; } - private wrapTransaction(driverTx: SqlTransaction): RuntimeTransaction { + protected wrapTransaction(driverTx: SqlTransaction): RuntimeTransaction { const self = this; - const wrappedTransaction: RuntimeTransaction & - PreparedStatementQueryTarget & - PreparedStatementExecuteTarget = { + const preCommitHooks: Array<() => Promise> = []; + return { + registerPreCommitHook(hook: () => Promise): void { + preCommitHooks.push(hook); + }, + async runPreCommitHooks(): Promise { + let hook = preCommitHooks.shift(); + while (hook !== undefined) { + await hook(); + hook = preCommitHooks.shift(); + } + }, async commit(): Promise { + let hook = preCommitHooks.shift(); + while (hook !== undefined) { + await hook(); + hook = preCommitHooks.shift(); + } await driverTx.commit(); }, async rollback(): Promise { await driverTx.rollback(); }, - query( + execute( plan: (SqlExecutionPlan | SqlQueryPlan) & { readonly _row?: Row }, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return self.queryAgainstQueryable(plan, driverTx, { + return self.executeAgainstQueryable(plan, driverTx, { ...options, scope: 'transaction', }); }, - execute( - plan: SqlExecutionPlan | SqlQueryPlan, - options?: RuntimeExecuteOptions, - ): Promise { - return self.executeStatisticsAgainstQueryable(plan, driverTx, { - ...options, - scope: 'transaction', - }); - }, - [preparedStatementQuery]( + executePrepared( ps: PreparedStatement, params: Params, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return self.runPreparedQueryAgainstQueryable( - blindCast< - PreparedStatementImpl, - 'prepared statements are created by this runtime implementation' - >(ps), - params, - driverTx, - { ...options, scope: 'transaction' }, - ); - }, - [preparedStatementExecute]( - ps: PreparedExecution, - params: Params, - options?: RuntimeExecuteOptions, - ): Promise { - return self.runPreparedExecuteAgainstQueryable( - blindCast< - PreparedExecutionImpl, - 'prepared statements are created by this runtime implementation' - >(ps), - params, + return self.executePreparedAgainstQueryable( + ps as PreparedStatementImpl, + params as Record, driverTx, { ...options, scope: 'transaction' }, ); }, }; - return wrappedTransaction; } telemetry(): RuntimeTelemetryEvent | null { @@ -948,7 +818,7 @@ export abstract class SqlRuntimeBase = Co function transactionClosedError(): Error { return runtimeError( 'RUNTIME.TRANSACTION_CLOSED', - 'Cannot use a transaction operation after the transaction has ended. Consume query results and await execute calls inside the transaction callback.', + 'Cannot read from a query result after the transaction has ended. Await the result or call .toArray() inside the transaction callback.', {}, ); } @@ -981,31 +851,20 @@ export async function withTransaction( } } - const txContext: TransactionContext & - PreparedStatementQueryTarget & - PreparedStatementExecuteTarget = { + const txContext: TransactionContext = { get invalidated() { return invalidated; }, - query( + execute( plan: (SqlExecutionPlan | SqlQueryPlan) & { readonly _row?: Row }, options?: RuntimeExecuteOptions, ): AsyncIterableResult { if (invalidated) { throw transactionClosedError(); } - return new AsyncIterableResult(guardedStream(transaction.query(plan, options))); - }, - async execute( - plan: SqlExecutionPlan | SqlQueryPlan, - options?: RuntimeExecuteOptions, - ): Promise { - if (invalidated) { - throw transactionClosedError(); - } - return transaction.execute(plan, options); + return new AsyncIterableResult(guardedStream(transaction.execute(plan, options))); }, - [preparedStatementQuery]( + executePrepared( ps: PreparedStatement, params: Params, options?: RuntimeExecuteOptions, @@ -1014,18 +873,14 @@ export async function withTransaction( throw transactionClosedError(); } return new AsyncIterableResult( - guardedStream(runPreparedQuery(transaction, ps, params, options)), + guardedStream(transaction.executePrepared(ps, params, options)), ); }, - [preparedStatementExecute]( - ps: PreparedExecution, - params: Params, - options?: RuntimeExecuteOptions, - ): Promise { + registerPreCommitHook(hook: () => Promise): void { if (invalidated) { throw transactionClosedError(); } - return runPreparedExecute(transaction, ps, params, options); + transaction.registerPreCommitHook(hook); }, }; @@ -1041,6 +896,7 @@ export async function withTransaction( let result: R; try { result = await fn(txContext); + await transaction.runPreCommitHooks(); } catch (error) { try { await transaction.rollback(); @@ -1085,3 +941,22 @@ export async function withTransaction( } } } + +export async function withConnection( + runtime: ConnectionProvider, + fn: (conn: ConnectionContext) => PromiseLike, +): Promise { + const connection = await runtime.connection(); + let released = false; + try { + const result = await fn(connection); + released = true; + await connection.release(); + return result; + } catch (err) { + if (!released) { + await connection.destroy(err).catch(() => undefined); + } + throw err; + } +} diff --git a/packages/2-sql/5-runtime/test/sql-runtime.test.ts b/packages/2-sql/5-runtime/test/sql-runtime.test.ts index 8f9e860e5888..7e64911fe0f6 100644 --- a/packages/2-sql/5-runtime/test/sql-runtime.test.ts +++ b/packages/2-sql/5-runtime/test/sql-runtime.test.ts @@ -1138,3 +1138,110 @@ describe('withTransaction', () => { expect(driver.__spies.transactionRollback).toHaveBeenCalledTimes(1); }); }); + +describe('RuntimeTransaction.registerPreCommitHook', () => { + function createRuntimeForHooks() { + const { stackInstance, context, driver } = createTestSetup(); + const runtime = createRuntime({ stackInstance, context, driver, verifyMarker: false }); + return { runtime, driver }; + } + + it('runs the hook before driverTx.commit()', async () => { + const { runtime, driver } = createRuntimeForHooks(); + const order: string[] = []; + driver.__spies.transactionCommit.mockImplementation(async () => { + order.push('commit'); + }); + + const conn = await runtime.connection(); + const tx = await conn.transaction(); + tx.registerPreCommitHook(async () => { + order.push('hook'); + }); + await tx.commit(); + await conn.release(); + + expect(order).toEqual(['hook', 'commit']); + }); + + it('runs multiple hooks in registration order before commit', async () => { + const { runtime, driver } = createRuntimeForHooks(); + const order: string[] = []; + driver.__spies.transactionCommit.mockImplementation(async () => { + order.push('commit'); + }); + + const conn = await runtime.connection(); + const tx = await conn.transaction(); + tx.registerPreCommitHook(async () => { + order.push('hook-1'); + }); + tx.registerPreCommitHook(async () => { + order.push('hook-2'); + }); + tx.registerPreCommitHook(async () => { + order.push('hook-3'); + }); + await tx.commit(); + await conn.release(); + + expect(order).toEqual(['hook-1', 'hook-2', 'hook-3', 'commit']); + }); + + it('aborts commit and propagates the error when a hook throws', async () => { + const { runtime, driver } = createRuntimeForHooks(); + const hookError = new Error('hook failed'); + + const conn = await runtime.connection(); + const tx = await conn.transaction(); + tx.registerPreCommitHook(async () => { + throw hookError; + }); + + await expect(tx.commit()).rejects.toBe(hookError); + expect(driver.__spies.transactionCommit).not.toHaveBeenCalled(); + await conn.release(); + }); + + it('does not invoke subsequent hooks after an earlier hook throws', async () => { + const { runtime, driver } = createRuntimeForHooks(); + const hook2 = vi.fn(); + + const conn = await runtime.connection(); + const tx = await conn.transaction(); + tx.registerPreCommitHook(async () => { + throw new Error('hook-1 failed'); + }); + tx.registerPreCommitHook(hook2); + + await tx.commit().catch(() => {}); + + expect(hook2).not.toHaveBeenCalled(); + expect(driver.__spies.transactionCommit).not.toHaveBeenCalled(); + await conn.release(); + }); + + it('does not invoke hooks on rollback', async () => { + const { runtime } = createRuntimeForHooks(); + const hook = vi.fn(); + + const conn = await runtime.connection(); + const tx = await conn.transaction(); + tx.registerPreCommitHook(hook); + await tx.rollback(); + await conn.release(); + + expect(hook).not.toHaveBeenCalled(); + }); + + it('no hooks registered — commit proceeds normally', async () => { + const { runtime, driver } = createRuntimeForHooks(); + + const conn = await runtime.connection(); + const tx = await conn.transaction(); + await tx.commit(); + await conn.release(); + + expect(driver.__spies.transactionCommit).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/3-extensions/postgres/src/runtime/postgres.ts b/packages/3-extensions/postgres/src/runtime/postgres.ts index d50c58b01abf..8f445fcfff75 100644 --- a/packages/3-extensions/postgres/src/runtime/postgres.ts +++ b/packages/3-extensions/postgres/src/runtime/postgres.ts @@ -1,74 +1,138 @@ -import postgresAdapter from '@internal/adapter-postgres/runtime'; -import type { NamespacedEnums } from '@internal/contract/enum-accessor'; -import type { Contract } from '@internal/contract/types'; -import postgresDriver, { suppressIdleConnectionErrors } from '@internal/driver-postgres/runtime'; -import { instantiateExecutionStack } from '@internal/framework-components/execution'; -import { sql as sqlBuilder } from '@internal/sql-builder/runtime'; -import type { Db, RawLane } from '@internal/sql-builder/types'; -import type { ExtractCodecTypes, SqlStorage } from '@internal/sql-contract/types'; -import { orm as ormBuilder } from '@internal/sql-orm-client'; -import type { CodecTypesBase } from '@internal/sql-relational-core/expression'; -import type { SqlQueryPlan } from '@internal/sql-relational-core/plan'; +import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; +import { buildNamespacedEnums, type NamespacedEnums } from '@prisma-next/contract/enum-accessor'; +import type { Contract } from '@prisma-next/contract/types'; +import postgresDriver from '@prisma-next/driver-postgres/runtime'; +import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; +import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime'; +import type { + Db, + QueryContext, + Scope, + ScopeField, + SelectQuery, +} from '@prisma-next/sql-builder/types'; +import type { ExtractCodecTypes, SqlStorage } from '@prisma-next/sql-contract/types'; +import { + INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE, + orm as ormBuilder, +} from '@prisma-next/sql-orm-client'; +import { RawSqlExpr, type SelectAst, TableSource } from '@prisma-next/sql-relational-core/ast'; +import type { CodecTypesBase, RawSqlTag } from '@prisma-next/sql-relational-core/expression'; +import { createRawSql } from '@prisma-next/sql-relational-core/expression'; +import { planFromAst, type SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; import type { BindSiteParams, + ConnectionContext, Declaration, ExecutionContext, ParamsFromDeclaration, - PreparedFor, + PreparedStatement, Runtime, SqlExecutionStackWithDriver, SqlMiddleware, + SqlRuntimeAdapterInstance, SqlRuntimeExtensionDescriptor, TransactionContext, VerifyMarkerOption, -} from '@internal/sql-runtime'; +} from '@prisma-next/sql-runtime'; import { createExecutionContext, createSqlExecutionStack, + withConnection, withTransaction, -} from '@internal/sql-runtime'; -import postgresTarget, { PostgresContractSerializer } from '@internal/target-postgres/runtime'; -import { ifDefined } from '@internal/utils/defined'; -import { InternalError } from '@internal/utils/internal-error'; +} from '@prisma-next/sql-runtime'; +import postgresTarget, { PostgresContractSerializer } from '@prisma-next/target-postgres/runtime'; +import { blindCast } from '@prisma-next/utils/casts'; +import { ifDefined } from '@prisma-next/utils/defined'; import { type Client, Pool } from 'pg'; -import { postgresError } from '../errors'; -import { buildPostgresStaticContext } from '../static/postgres-static'; import { type PostgresBinding, type PostgresBindingInput, resolveOptionalPostgresBinding, resolvePostgresBinding, } from './binding'; -import type { NamespacedNativeEnums } from './native-enums'; import { PostgresRuntimeImpl } from './postgres-runtime'; export type PostgresTargetId = 'postgres'; type OrmClient> = ReturnType>; +export interface TempTableColumnDef { + readonly name: string; + readonly type: string; +} + +type TempTableJoinSource> = ReturnType< + SelectQuery['as'] +>; + +type TempTableQuerySource> = { + buildAst(): SelectAst; + getRowFields(): Row; +}; + +type TempTableSubqueryConvertible> = { + [INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE](): TempTableQuerySource; +}; + +type TempTableAsInput> = + | TempTableQuerySource + | TempTableSubqueryConvertible; + +export type TempTableHandle = Record> = + TempTableJoinSource & { + readonly name: string; + readonly fields: Row; + append(input: TempTableAppendInput): Promise; + drop(): Promise; + [Symbol.asyncDispose](): Promise; + }; + +export type TempTableAppendInput< + Row extends Record = Record, +> = TempTableAsInput | readonly (readonly (string | number | boolean | null)[])[]; + +export interface TempTableBuilder { + as>( + query: TempTableAsInput, + ): Promise>; + from(columns: readonly TempTableColumnDef[]): Promise; +} + export interface PostgresTransactionContext> extends TransactionContext { readonly sql: Db; readonly orm: OrmClient; readonly enums: NamespacedEnums; - readonly nativeEnums: NamespacedNativeEnums; + tempTable(): TempTableBuilder; +} + +export interface PostgresConnectionContext> + extends ConnectionContext { + readonly sql: Db; + readonly orm: OrmClient; + readonly enums: NamespacedEnums; + tempTable(): TempTableBuilder; } export interface PostgresClient> { readonly sql: Db; readonly orm: OrmClient; readonly enums: NamespacedEnums; - readonly nativeEnums: NamespacedNativeEnums; - readonly raw: RawLane; + readonly raw: RawSqlTag; readonly context: ExecutionContext; - readonly contract: TContract; readonly stack: SqlExecutionStackWithDriver; connect(bindingInput?: PostgresBindingInput): Promise; runtime(): Runtime; transaction(fn: (tx: PostgresTransactionContext) => PromiseLike): Promise; - prepare, Row, CT extends CodecTypesBase = ExtractCodecTypes>( + connection(fn: (conn: PostgresConnectionContext) => PromiseLike): Promise; + prepare< + D extends Declaration, + Row, + CT extends CodecTypesBase = ExtractCodecTypes & CodecTypesBase, + >( declaration: D, callback: (sql: Db, params: BindSiteParams) => SqlQueryPlan, - ): Promise, Row>>; + ): Promise, Row>>; close(): Promise; [Symbol.asyncDispose](): Promise; } @@ -119,10 +183,8 @@ const contractSerializer = new PostgresContractSerializer(); function resolveContract>( options: PostgresOptions, ): TContract { - const contractJson = hasContractJson(options) - ? options.contractJson - : contractSerializer.serializeContract(options.contract); - return contractSerializer.deserializeContract(contractJson) as TContract; + const contractInput = hasContractJson(options) ? options.contractJson : options.contract; + return contractSerializer.deserializeContract(contractInput) as TContract; } function toRuntimeBinding>( @@ -135,16 +197,234 @@ function toRuntimeBinding>( return { kind: 'pgPool', - pool: suppressIdleConnectionErrors( - new Pool({ - connectionString: binding.url, - connectionTimeoutMillis: options.poolOptions?.connectionTimeoutMillis ?? 20_000, - idleTimeoutMillis: options.poolOptions?.idleTimeoutMillis ?? 30_000, - }), - ), + pool: new Pool({ + connectionString: binding.url, + connectionTimeoutMillis: options.poolOptions?.connectionTimeoutMillis ?? 20_000, + idleTimeoutMillis: options.poolOptions?.idleTimeoutMillis ?? 30_000, + }), } as const; } +function quoteIdentifier(name: string): string { + return `"${name.replaceAll('"', '""')}"`; +} + +function toSqlLiteral(value: string | number | boolean | null): string { + if (value === null) return 'NULL'; + if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'; + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(`Cannot use non-finite number as SQL literal: ${value}`); + } + return String(value); + } + return `'${value.replaceAll("'", "''")}'`; +} + +function resolveTempTableName(): string { + const suffix = crypto.randomUUID().replaceAll('-', '').slice(0, 20); + return `pn_temp_${suffix}`; +} + +function createTempTableBuilder( + execCtx: Pick, + registerCleanupHook: (hook: () => Promise) => void, + contract: Contract, + adapter: SqlRuntimeAdapterInstance, + onCommitDrop = true, +): TempTableBuilder { + const normalizeQuerySource = >( + query: TempTableAsInput, + ): TempTableQuerySource => { + if ('buildAst' in query && 'getRowFields' in query) { + return query; + } + return query[INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE](); + }; + + const asJoinSource = >( + tableName: string, + alias: string, + rowFields: Row, + ): TempTableJoinSource => { + const source = { + getJoinOuterScope: () => ({ + topLevel: rowFields, + namespaces: { [alias]: rowFields } as Record, + }), + buildAst: () => TableSource.named(tableName, alias), + }; + return blindCast, 'source implements TempTableJoinSource duck-type'>( + source, + ); + }; + + const createAppend = + (quotedName: string) => + async (input: TempTableAppendInput>): Promise => { + if (Array.isArray(input)) { + const rows = blindCast< + readonly (readonly (string | number | boolean | null)[])[], + 'Array.isArray true — input is a raw rows array' + >(input); + if (rows.length === 0) return; + const valueRows = rows.map((row) => `(${row.map(toSqlLiteral).join(', ')})`).join(', '); + const insertSql = `INSERT INTO ${quotedName} VALUES ${valueRows}`; + const insertAst = RawSqlExpr.of([insertSql], []); + const insertQueryPlan = planFromAst(insertAst, contract, 'raw.temp-table'); + await execCtx + .execute( + Object.freeze({ + sql: insertAst.fragments[0] ?? '', + params: [] as unknown[], + ast: insertAst, + meta: insertQueryPlan.meta, + }), + ) + .toArray(); + } else { + const source = normalizeQuerySource( + blindCast< + TempTableAsInput>, + 'Array.isArray false — input is a query source' + >(input), + ); + const queryPlan = planFromAst(source.buildAst(), contract, 'dsl'); + const lowered = adapter.lower(queryPlan.ast, { contract, params: queryPlan.params }); + const params = lowered.params.map((slot) => { + if (slot.kind === 'literal') return slot.value; + throw new Error('tempTable.append(...) does not accept bind-site parameters.'); + }); + const insertSql = `INSERT INTO ${quotedName} ${lowered.sql}`; + const insertAst = RawSqlExpr.of([insertSql], []); + const insertQueryPlan = planFromAst(insertAst, contract, 'raw.temp-table'); + await execCtx + .execute( + Object.freeze({ + sql: insertAst.fragments[0] ?? '', + params, + ast: insertAst, + meta: insertQueryPlan.meta, + }), + ) + .toArray(); + } + }; + + return { + async as>( + query: TempTableAsInput, + ): Promise> { + const source = normalizeQuerySource(query); + const tableName = resolveTempTableName(); + const quotedTableName = quoteIdentifier(tableName); + const queryPlan = planFromAst(source.buildAst(), contract, 'dsl'); + const lowered = adapter.lower(queryPlan.ast, { + contract, + params: queryPlan.params, + }); + const params = lowered.params.map((slot) => { + if (slot.kind === 'literal') return slot.value; + throw new Error('tempTable.as(...) does not accept bind-site parameters.'); + }); + + const createAst = RawSqlExpr.of( + [ + `CREATE TEMP TABLE ${quotedTableName}${onCommitDrop ? ' ON COMMIT DROP' : ''} AS ${lowered.sql}`, + ], + [], + ); + const createQueryPlan = planFromAst(createAst, contract, 'raw.temp-table'); + const createPlan = Object.freeze({ + sql: createAst.fragments[0] ?? '', + params, + ast: createAst, + meta: createQueryPlan.meta, + }); + await execCtx.execute(createPlan).toArray(); + + const dropPlan = Object.freeze({ + sql: `DROP TABLE IF EXISTS ${quotedTableName}`, + params: [], + ast: queryPlan.ast, + meta: queryPlan.meta, + }); + let dropped = false; + const drop = async (): Promise => { + if (dropped) return; + dropped = true; + await execCtx.execute(dropPlan).toArray(); + }; + if (!onCommitDrop) { + registerCleanupHook(drop); + } + + const rowFields = blindCast generic'>( + source.getRowFields(), + ); + const defaultJoin = asJoinSource(tableName, tableName, rowFields); + + return blindCast< + TempTableHandle, + 'temp table handle created from Subquery preserves the same row field shape' + >({ + ...defaultJoin, + name: tableName, + fields: rowFields, + append: createAppend(quotedTableName), + drop, + [Symbol.asyncDispose]: drop, + }); + }, + + async from(columns: readonly TempTableColumnDef[]): Promise { + const tableName = resolveTempTableName(); + const quotedTableName = quoteIdentifier(tableName); + + const colDefs = columns.map((c) => `${quoteIdentifier(c.name)} ${c.type}`).join(', '); + const createSql = `CREATE TEMP TABLE ${quotedTableName} (${colDefs})${onCommitDrop ? ' ON COMMIT DROP' : ''}`; + const createAst = RawSqlExpr.of([createSql], []); + const createQueryPlan = planFromAst(createAst, contract, 'raw.temp-table'); + const createPlan = Object.freeze({ + sql: createAst.fragments[0] ?? '', + params: [] as unknown[], + ast: createAst, + meta: createQueryPlan.meta, + }); + await execCtx.execute(createPlan).toArray(); + + const dropAst = RawSqlExpr.of([`DROP TABLE IF EXISTS ${quotedTableName}`], []); + const dropQueryPlan = planFromAst(dropAst, contract, 'raw.temp-table'); + const dropPlan = Object.freeze({ + sql: dropAst.fragments[0] ?? '', + params: [] as unknown[], + ast: dropAst, + meta: dropQueryPlan.meta, + }); + let dropped = false; + const drop = async (): Promise => { + if (dropped) return; + dropped = true; + await execCtx.execute(dropPlan).toArray(); + }; + if (!onCommitDrop) { + registerCleanupHook(drop); + } + + const emptyFields = {} as Record; + const defaultJoin = asJoinSource(tableName, tableName, emptyFields); + return blindCast({ + ...defaultJoin, + name: tableName, + fields: emptyFields, + append: createAppend(quotedTableName), + drop, + [Symbol.asyncDispose]: drop, + }); + }, + }; +} + /** * Creates a lazy Postgres client from either `contractJson` or a TypeScript-authored `contract`. * Static query surfaces are available immediately, while `runtime()` instantiates the driver/pool on first call. @@ -163,25 +443,21 @@ export default function postgres>( ): PostgresClient { const contract = resolveContract(options); let binding = resolveOptionalPostgresBinding(options); - const stack = createSqlExecutionStack({ target: postgresTarget, adapter: postgresAdapter, driver: postgresDriver, - extensions: options.extensions ?? [], + extensionPacks: options.extensions ?? [], }); + const stackInstance = instantiateExecutionStack(stack); - const context = createExecutionContext({ + const context = createExecutionContext({ contract, stack, - driver: postgresDriver, }); - const { - sql, - raw: rawSqlTag, - enums, - nativeEnums, - } = buildPostgresStaticContext(context, stack.adapter.rawCodecInferer); + + const rawCodecInferer = stack.adapter.rawCodecInferer; + const rawSqlTag: RawSqlTag = createRawSql(rawCodecInferer); let runtimeInstance: Runtime | undefined; let runtimeDriver: { connect(binding: unknown): Promise } | undefined; @@ -193,7 +469,7 @@ export default function postgres>( const connectDriver = async (resolvedBinding: PostgresBinding): Promise => { if (driverConnected) return; - if (!runtimeDriver) throw new InternalError('Postgres runtime driver missing'); + if (!runtimeDriver) throw new Error('Postgres runtime driver missing'); if (connectPromise) return connectPromise; const runtimeBinding = toRuntimeBinding(resolvedBinding, options); if (resolvedBinding.kind === 'url' && runtimeBinding.kind === 'pgPool') { @@ -218,14 +494,9 @@ export default function postgres>( }); return connectPromise; }; - const getRuntime = (): Runtime => { if (closed) { - throw postgresError('DRIVER.NOT_CONNECTED', 'Postgres client is closed', { - why: 'close() was called on this client.', - fix: 'Create a new postgres(...) client.', - meta: { extension: 'postgres' }, - }); + throw new Error('Postgres client is closed'); } if (backgroundConnectError !== undefined) { @@ -236,10 +507,9 @@ export default function postgres>( return runtimeInstance; } - const stackInstance = instantiateExecutionStack(stack); const driverDescriptor = stack.driver; if (!driverDescriptor) { - throw new InternalError('Driver descriptor missing from execution stack'); + throw new Error('Driver descriptor missing from execution stack'); } const driver = driverDescriptor.create({ @@ -260,12 +530,8 @@ export default function postgres>( return runtimeInstance; }; - const orm: OrmClient = ormBuilder({ runtime: { - query(plan) { - return getRuntime().query(plan); - }, execute(plan) { return getRuntime().execute(plan); }, @@ -276,30 +542,28 @@ export default function postgres>( context, }); + const sql: Db = sqlBuilder({ context, rawCodecInferer }); + + const enums = blindCast< + NamespacedEnums, + 'buildNamespacedEnums returns the namespace-keyed accessor map this contract types' + >(Object.freeze(buildNamespacedEnums(contract.domain))); + return { sql, orm, enums, - nativeEnums, raw: rawSqlTag, context, - contract, stack, async connect(bindingInput) { if (closed) { - throw postgresError('DRIVER.NOT_CONNECTED', 'Postgres client is closed', { - why: 'close() was called on this client.', - fix: 'Create a new postgres(...) client.', - meta: { extension: 'postgres' }, - }); + throw new Error('Postgres client is closed'); } if (driverConnected || connectPromise) { - throw postgresError('DRIVER.ALREADY_CONNECTED', 'Postgres client already connected', { - fix: 'Call connect() at most once per client.', - meta: { extension: 'postgres' }, - }); + throw new Error('Postgres client already connected'); } if (bindingInput !== undefined) { @@ -307,10 +571,8 @@ export default function postgres>( } if (binding === undefined) { - throw postgresError( - 'RUNTIME.BINDING_MISSING', + throw new Error( 'Postgres binding not configured. Pass url/pg/binding to postgres(...) or call db.connect({ ... }).', - { meta: { extension: 'postgres' } }, ); } @@ -330,17 +592,16 @@ export default function postgres>( prepare< D extends Declaration, Row, - CT extends CodecTypesBase = ExtractCodecTypes, + CT extends CodecTypesBase = ExtractCodecTypes & CodecTypesBase, >( declaration: D, callback: (sql: Db, params: BindSiteParams) => SqlQueryPlan, - ): Promise, Row>> { + ): Promise, Row>> { return getRuntime().prepare(declaration, (params) => callback(sql, params)); }, transaction(fn: (tx: PostgresTransactionContext) => PromiseLike): Promise { return withTransaction(getRuntime(), (txCtx) => { - const rawCodecInferer = stack.adapter.rawCodecInferer; const txSql: Db = sqlBuilder({ context, rawCodecInferer, @@ -348,9 +609,6 @@ export default function postgres>( const txOrm: OrmClient = ormBuilder({ runtime: { - query(plan) { - return txCtx.query(plan); - }, execute(plan) { return txCtx.execute(plan); }, @@ -364,13 +622,64 @@ export default function postgres>( // Spreading would evaluate the getter once and freeze its value. const tx: PostgresTransactionContext = Object.assign( Object.create(txCtx) as TransactionContext, - { sql: txSql, orm: txOrm, enums, nativeEnums }, + { + sql: txSql, + orm: txOrm, + enums, + tempTable(): TempTableBuilder { + return createTempTableBuilder( + txCtx, + (hook) => txCtx.registerPreCommitHook(hook), + context.contract, + stackInstance.adapter, + true, + ); + }, + }, ); return fn(tx); }); }, + connection(fn: (conn: PostgresConnectionContext) => PromiseLike): Promise { + return withConnection(getRuntime(), (connCtx) => { + const connSql: Db = sqlBuilder({ + context, + rawCodecInferer, + }); + + const connOrm: OrmClient = ormBuilder({ + runtime: { + execute(plan) { + return connCtx.execute(plan); + }, + }, + context, + }); + + const conn: PostgresConnectionContext = Object.assign( + Object.create(connCtx) as ConnectionContext, + { + sql: connSql, + orm: connOrm, + enums, + tempTable(): TempTableBuilder { + return createTempTableBuilder( + connCtx, + (hook) => connCtx.registerReleaseHook(hook), + context.contract, + stackInstance.adapter, + false, + ); + }, + }, + ); + + return fn(conn); + }); + }, + async close(): Promise { if (closed) return; closed = true; diff --git a/packages/3-extensions/postgres/test/postgres.test.ts b/packages/3-extensions/postgres/test/postgres.test.ts index 96247767751c..6583016b3b7d 100644 --- a/packages/3-extensions/postgres/test/postgres.test.ts +++ b/packages/3-extensions/postgres/test/postgres.test.ts @@ -1,9 +1,11 @@ -import type { Contract } from '@internal/contract/types'; -import { coreHash } from '@internal/contract/types'; -import type { SqlStorage } from '@internal/sql-contract/types'; -import type { Runtime } from '@internal/sql-runtime'; -import { PostgresSchema } from '@internal/target-postgres/types'; -import { createContract } from '@repo/test-utils'; +import type { Contract } from '@prisma-next/contract/types'; +import type { ScopeField, Subquery } from '@prisma-next/sql-builder/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { ProjectionItem, SelectAst, TableSource } from '@prisma-next/sql-relational-core/ast'; +import { planFromAst } from '@prisma-next/sql-relational-core/plan'; +import type { Runtime } from '@prisma-next/sql-runtime'; +import { createContract } from '@prisma-next/test-utils'; +import { blindCast } from '@prisma-next/utils/casts'; import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; // Only mock the third-party pg boundary. Real drivers, adapters, and runtimes @@ -21,7 +23,6 @@ vi.mock('pg', () => { const connectSpy = vi.fn().mockResolvedValue(new FakePoolClient()); class Pool { - on = vi.fn().mockReturnThis(); static readonly _endSpy = poolEndSpy; static readonly _connectSpy = connectSpy; static readonly _querySpy = querySpy; @@ -34,25 +35,18 @@ vi.mock('pg', () => { connect = connectSpy; end = poolEndSpy; - totalCount = 0; - idleCount = 0; - waitingCount = 0; } class Client { - on = vi.fn().mockReturnThis(); connect = vi.fn().mockResolvedValue(undefined); query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }); end = vi.fn().mockResolvedValue(undefined); - escapeIdentifier = vi.fn(); - escapeLiteral = vi.fn(); } return { Pool, Client }; }); import { Client, Pool } from 'pg'; -import { buildNativeEnumsMapForNamespace } from '../src/runtime/native-enums'; import postgres, { type PostgresClient } from '../src/runtime/postgres'; const contract = createContract(); @@ -71,7 +65,6 @@ beforeEach(() => { Object.assign(new (Pool as unknown as new () => object)(), { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), release: vi.fn(), - on: vi.fn(), }), ); }); @@ -247,25 +240,6 @@ describe('postgres', () => { expect(db.context).toBeDefined(); }); - it('exposes contract on the facade', () => { - const db = postgres({ - contract, - url: 'postgres://localhost:5432/db', - }); - - expect(db.contract).toBeDefined(); - expect(db.contract.target).toBe(contract.target); - }); - - it('db.contract is typed as TContract', () => { - const db = postgres({ - contract, - url: 'postgres://localhost:5432/db', - }); - - expectTypeOf(db.contract).toEqualTypeOf>(); - }); - it('creates pool from url with explicit timeout defaults (pool options passed)', () => { const db = postgres({ contract, @@ -355,7 +329,6 @@ describe('postgres', () => { const fakeClient = { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), release: vi.fn(), - on: vi.fn(), }; (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); @@ -374,7 +347,6 @@ describe('postgres', () => { const fakeClient = { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), release: vi.fn(), - on: vi.fn(), }; (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); @@ -395,7 +367,6 @@ describe('postgres', () => { const fakeClient = { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), release: vi.fn(), - on: vi.fn(), }; (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); @@ -411,12 +382,378 @@ describe('postgres', () => { expect(receivedTx!.orm).toBeDefined(); }); + it('transaction tempTable() creates and drops a typed temp table with generated name', async () => { + const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); + const fakeClient = { + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn(), + }; + (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); + + const db = postgres({ contract, pg: pool }); + await db.connect(); + + const subquery = blindCast< + Subquery<{ id: ScopeField }>, + 'test fixture for temp-table typed subquery' + >({ + buildAst: () => + SelectAst.from(TableSource.named('source_table')).withProjection([ + ProjectionItem.of('id', db.raw`1`.returns('pg/int4@1').buildAst()), + ]), + getRowFields: () => ({ id: { codecId: 'pg/int4@1', nullable: false } }), + }); + + await db.transaction(async (tx) => { + const temp = await tx.tempTable().as(subquery); + expect(temp.name).toMatch(/^pn_temp_[a-f0-9]+$/); + expect(temp.fields['id']?.codecId).toBe('pg/int4@1'); + expect('buildAst' in temp).toBe(true); + expect('getJoinOuterScope' in temp).toBe(true); + await temp.drop(); + }); + + await db.close(); + + const issuedSql = fakeClient.query.mock.calls.map((call) => { + const arg = call[0] as string | { text?: string }; + return typeof arg === 'string' ? arg : (arg.text ?? ''); + }); + expect(issuedSql.some((sql) => sql.startsWith('CREATE TEMP TABLE'))).toBe(true); + expect(issuedSql.some((sql) => sql.startsWith('DROP TABLE IF EXISTS'))).toBe(true); + }); + + it('transaction tempTable() uses an internal table name', async () => { + const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); + const fakeClient = { + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn(), + }; + (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); + + const db = postgres({ contract, pg: pool }); + await db.connect(); + + const subquery = blindCast< + Subquery<{ id: ScopeField; email: ScopeField }>, + 'test fixture for temp-table typed subquery' + >({ + buildAst: () => + SelectAst.from(TableSource.named('source_table')).withProjection([ + ProjectionItem.of('id', db.raw`1`.returns('pg/int4@1').buildAst()), + ProjectionItem.of('email', db.raw`'a@example.com'`.returns('pg/text@1').buildAst()), + ]), + getRowFields: () => ({ + id: { codecId: 'pg/int4@1', nullable: false }, + email: { codecId: 'pg/text@1', nullable: false }, + }), + }); + + let tableName: string | undefined; + await db.transaction(async (tx) => { + const temp = await tx.tempTable().as(subquery); + tableName = temp.name; + expect(temp.name).toMatch(/^pn_temp_[a-f0-9]+$/); + expect(temp.fields).toEqual({ + id: { codecId: 'pg/int4@1', nullable: false }, + email: { codecId: 'pg/text@1', nullable: false }, + }); + await temp.drop(); + }); + + await db.close(); + + const issuedSql = fakeClient.query.mock.calls.map((call) => { + const arg = call[0] as string | { text?: string }; + return typeof arg === 'string' ? arg : (arg.text ?? ''); + }); + expect(tableName).toBeDefined(); + expect( + issuedSql.some((sql) => sql.includes(`CREATE TEMP TABLE "${tableName}" ON COMMIT DROP AS`)), + ).toBe(true); + expect(issuedSql.some((sql) => sql.includes(`DROP TABLE IF EXISTS "${tableName}"`))).toBe(true); + }); + + it('transaction tempTable() can be reused in tx.sql join composition within the same transaction', async () => { + const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); + const fakeClient = { + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn(), + }; + (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); + + const db = postgres({ contract, pg: pool }); + await db.connect(); + + const subquery = blindCast< + Subquery<{ id: ScopeField; email: ScopeField }>, + 'test fixture for temp-table typed subquery' + >({ + buildAst: () => + SelectAst.from(TableSource.named('source_table')).withProjection([ + ProjectionItem.of('id', db.raw`1`.returns('pg/int4@1').buildAst()), + ProjectionItem.of('email', db.raw`'a@example.com'`.returns('pg/text@1').buildAst()), + ]), + getRowFields: () => ({ + id: { codecId: 'pg/int4@1', nullable: false }, + email: { codecId: 'pg/text@1', nullable: false }, + }), + }); + + let recentUsersName: string | undefined; + await db.transaction(async (tx) => { + const recentUsers = await tx.tempTable().as(subquery); + recentUsersName = recentUsers.name; + + await tx + .execute( + planFromAst( + SelectAst.from(recentUsers.buildAst()).withProjection([ + ProjectionItem.of('id', db.raw`1`.returns('pg/int4@1').buildAst()), + ProjectionItem.of('email', db.raw`'a@example.com'`.returns('pg/text@1').buildAst()), + ]), + contract, + 'dsl', + ), + ) + .toArray(); + + await recentUsers.drop(); + }); + + await db.close(); + + const issuedSql = fakeClient.query.mock.calls.map((call) => { + const arg = call[0] as string | { text?: string }; + return typeof arg === 'string' ? arg : (arg.text ?? ''); + }); + expect(recentUsersName).toBeDefined(); + expect( + issuedSql.some((sql) => + sql.includes(`CREATE TEMP TABLE "${recentUsersName}" ON COMMIT DROP AS`), + ), + ).toBe(true); + expect( + issuedSql.some((sql) => sql.includes(`FROM "${recentUsersName}" AS "${recentUsersName}"`)), + ).toBe(true); + }); + + it('transaction tempTable().from() issues CREATE TABLE with ON COMMIT DROP and INSERT', async () => { + const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); + const fakeClient = { + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn(), + }; + (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); + + const db = postgres({ contract, pg: pool }); + await db.connect(); + + let tableName: string | undefined; + await db.transaction(async (tx) => { + const handle = await tx.tempTable().from([ + { name: 'id', type: 'int4' }, + { name: 'label', type: 'text' }, + ]); + tableName = handle.name; + await handle.append([ + ['1', 'Alice'], + ['2', 'Bob'], + ]); + expect(handle.name).toMatch(/^pn_temp_[a-f0-9]+$/); + }); + + await db.close(); + + const issuedSql = fakeClient.query.mock.calls.map((call) => { + const arg = call[0] as string | { text?: string }; + return typeof arg === 'string' ? arg : (arg.text ?? ''); + }); + + expect(tableName).toBeDefined(); + expect( + issuedSql.some( + (sql) => + sql.includes(`CREATE TEMP TABLE "${tableName}"`) && + sql.includes('"id" int4') && + sql.includes('"label" text') && + sql.includes('ON COMMIT DROP'), + ), + ).toBe(true); + expect( + issuedSql.some( + (sql) => + sql.includes(`INSERT INTO "${tableName}"`) && + sql.includes("'1'") && + sql.includes("'Alice'"), + ), + ).toBe(true); + }); + + it('transaction tempTable().from() with empty rows skips INSERT', async () => { + const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); + const fakeClient = { + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn(), + }; + (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); + + const db = postgres({ contract, pg: pool }); + await db.connect(); + + await db.transaction(async (tx) => { + await tx.tempTable().from([{ name: 'id', type: 'int4' }]); + }); + + await db.close(); + + const issuedSql = fakeClient.query.mock.calls.map((call) => { + const arg = call[0] as string | { text?: string }; + return typeof arg === 'string' ? arg : (arg.text ?? ''); + }); + + expect(issuedSql.some((sql) => sql.includes('CREATE TEMP TABLE'))).toBe(true); + expect(issuedSql.some((sql) => sql.startsWith('INSERT INTO'))).toBe(false); + }); + + it('transaction tempTable().from() inlines null, number and boolean as SQL literals', async () => { + const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); + const fakeClient = { + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn(), + }; + (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); + + const db = postgres({ contract, pg: pool }); + await db.connect(); + + let tableName: string | undefined; + await db.transaction(async (tx) => { + const handle = await tx.tempTable().from([ + { name: 'n', type: 'int4' }, + { name: 'flag', type: 'bool' }, + { name: 'nullable', type: 'text' }, + ]); + tableName = handle.name; + await handle.append([[42, true, null]]); + }); + + await db.close(); + + const issuedSql = fakeClient.query.mock.calls.map((call) => { + const arg = call[0] as string | { text?: string }; + return typeof arg === 'string' ? arg : (arg.text ?? ''); + }); + + const insertSql = issuedSql.find((sql) => sql.startsWith(`INSERT INTO "${tableName}"`)) ?? ''; + expect(insertSql).toContain('42'); + expect(insertSql).toContain('TRUE'); + expect(insertSql).toContain('NULL'); + }); + + it('transaction tempTable().from() handle supports append() with raw rows', async () => { + const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); + const fakeClient = { + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn(), + }; + (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); + + const db = postgres({ contract, pg: pool }); + await db.connect(); + + let tableName: string | undefined; + await db.transaction(async (tx) => { + const handle = await tx.tempTable().from([{ name: 'id', type: 'int4' }]); + tableName = handle.name; + await handle.append([['1']]); + await handle.append([['2'], ['3']]); + }); + + await db.close(); + + const issuedSql = fakeClient.query.mock.calls.map((call) => { + const arg = call[0] as string | { text?: string }; + return typeof arg === 'string' ? arg : (arg.text ?? ''); + }); + + expect(issuedSql.filter((sql) => sql.startsWith(`INSERT INTO "${tableName}"`))).toHaveLength(2); + const appendSql = issuedSql.find( + (sql) => sql.startsWith(`INSERT INTO "${tableName}"`) && sql.includes("'2'"), + ); + expect(appendSql).toMatch(/INSERT INTO "[A-Za-z0-9_]+" VALUES \('2'\), \('3'\)/); + }); + + it('transaction tempTable().as() handle supports append() with a subquery (INSERT INTO ... SELECT)', async () => { + const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); + const fakeClient = { + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn(), + }; + (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); + + const db = postgres({ contract, pg: pool }); + await db.connect(); + + const subquery = blindCast, 'test fixture'>({ + buildAst: () => + SelectAst.from(TableSource.named('source_table')).withProjection([ + ProjectionItem.of('id', db.raw`1`.returns('pg/int4@1').buildAst()), + ]), + getRowFields: () => ({ id: { codecId: 'pg/int4@1', nullable: false } }), + }); + + let tableName: string | undefined; + await db.transaction(async (tx) => { + const handle = await tx.tempTable().as(subquery); + tableName = handle.name; + await handle.append(subquery); + }); + + await db.close(); + + const issuedSql = fakeClient.query.mock.calls.map((call) => { + const arg = call[0] as string | { text?: string }; + return typeof arg === 'string' ? arg : (arg.text ?? ''); + }); + + expect( + issuedSql.some( + (sql) => sql.startsWith(`INSERT INTO "${tableName}"`) && sql.includes('SELECT'), + ), + ).toBe(true); + }); + + it('transaction tempTable().append() with empty rows is a no-op', async () => { + const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); + const fakeClient = { + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn(), + }; + (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); + + const db = postgres({ contract, pg: pool }); + await db.connect(); + + await db.transaction(async (tx) => { + const handle = await tx.tempTable().from([{ name: 'x', type: 'int4' }]); + await handle.append([]); + }); + + await db.close(); + + const issuedSql = fakeClient.query.mock.calls.map((call) => { + const arg = call[0] as string | { text?: string }; + return typeof arg === 'string' ? arg : (arg.text ?? ''); + }); + expect(issuedSql.some((sql) => sql.startsWith('INSERT INTO'))).toBe(false); + }); + it('transaction() lazily creates runtime before connect()', async () => { const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); const fakeClient = { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), release: vi.fn(), - on: vi.fn(), }; (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); @@ -494,104 +831,4 @@ describe('postgres', () => { expect(poolConnectSpy()).not.toHaveBeenCalled(); }); }); - - describe('db.nativeEnums (facade)', () => { - // Built from real `PostgresSchema` IR instances (not plain literals): - // `PostgresContractSerializer.serializeContract` carries `entries.valueSet` - // for any namespace it recognizes via `isPostgresSchema`, matching how a - // real Postgres contract always rehydrates through the serializer at the - // `postgres()` call site. `db.nativeEnums` reads that `valueSet` entry — - // the same generic entry a `native_enum`'s `deriveValueSet` hook produces - // — not the (never re-serialized) `native_enum` entity itself. - const publicNs = new PostgresSchema({ - id: 'public', - entries: { - table: {}, - valueSet: { - AalLevel: { kind: 'valueSet', values: ['aal1', 'aal2'] }, - }, - }, - }); - const auditNs = new PostgresSchema({ - id: 'audit', - entries: { - table: {}, - valueSet: { - AalLevel: { kind: 'valueSet', values: ['low', 'high'] }, - }, - }, - }); - - const twoNamespaceStorage = { - ...contract, - storage: { - ...contract.storage, - namespaces: { public: publicNs, audit: auditNs }, - }, - }; - - // A literal-keyed contract so `db.nativeEnums.public` and `.audit` resolve - // to distinct namespace maps, proving per-namespace resolution rather than - // falling back to a single shared `Record`. Each namespace's - // enum accessors are still looked up by bracket access (`['AalLevel']`): - // `NamespacedNativeEnums` intentionally keeps entity names as an open - // index signature (see native-enums.ts), not a per-name literal facade. - type TwoNsStorageContract = Contract & { - readonly storage: (typeof twoNamespaceStorage)['storage']; - }; - - it('exposes native enum members per namespace and resolves same-named native enums independently', () => { - const db = postgres({ - contract: twoNamespaceStorage, - url: 'postgres://localhost:5432/db', - }); - - const publicAalLevel = db.nativeEnums.public['AalLevel']; - const auditAalLevel = db.nativeEnums.audit['AalLevel']; - - expect(publicAalLevel?.values).toEqual(['aal1', 'aal2']); - expect(auditAalLevel?.values).toEqual(['low', 'high']); - expect(publicAalLevel?.names).toEqual(['aal1', 'aal2']); - expect(publicAalLevel?.has('aal1')).toBe(true); - expect(publicAalLevel?.nameOf('aal2')).toBe('aal2'); - expect(auditAalLevel?.nameOf('high')).toBe('high'); - }); - - it('builds the nativeEnums surface eagerly, without a runtime', () => { - const db = postgres({ - contract: twoNamespaceStorage, - url: 'postgres://localhost:5432/db', - }); - - expect(db.nativeEnums.public['AalLevel']?.values).toEqual(['aal1', 'aal2']); - expect(poolConnectSpy()).not.toHaveBeenCalled(); - }); - - // F02: the accessor must read the same plain namespace shape a - // `validateContract`'d JSON contract carries, not a hydrated - // `PostgresSchema` class instance — symmetric with `db.enums` - // (`buildNamespacedEnums`), which already works on plain data. - it('resolves members from a plain (non-hydrated) contract, not just a hydrated PostgresSchema', () => { - const plainStorage: SqlStorage = { - storageHash: coreHash('test-storage-hash'), - namespaces: { - public: { - id: 'public', - kind: 'postgres-schema', - entries: { - table: {}, - valueSet: { - AalLevel: { kind: 'valueSet', values: ['aal1', 'aal2', 'aal3'] }, - }, - }, - }, - }, - }; - - const result = buildNativeEnumsMapForNamespace(plainStorage, 'public'); - - expect(result['AalLevel']?.values).toEqual(['aal1', 'aal2', 'aal3']); - expect(result['AalLevel']?.has('aal2')).toBe(true); - }); - }); }); diff --git a/packages/3-extensions/postgres/test/transaction.types.test-d.ts b/packages/3-extensions/postgres/test/transaction.types.test-d.ts index 10ed0d5c250c..de7493489bc9 100644 --- a/packages/3-extensions/postgres/test/transaction.types.test-d.ts +++ b/packages/3-extensions/postgres/test/transaction.types.test-d.ts @@ -1,7 +1,14 @@ -import type { Contract } from '@internal/contract/types'; -import type { SqlStorage } from '@internal/sql-contract/types'; +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE } from '@prisma-next/sql-orm-client'; import { expectTypeOf, test } from 'vitest'; -import type { PostgresClient, PostgresTransactionContext } from '../src/runtime/postgres'; +import type { + PostgresClient, + PostgresConnectionContext, + PostgresTransactionContext, + TempTableAppendInput, + TempTableColumnDef, +} from '../src/runtime/postgres'; type TestContract = Contract; @@ -33,3 +40,122 @@ test('tx.orm has the same type as db.orm', () => { type TxOrm = PostgresTransactionContext['orm']; expectTypeOf().toEqualTypeOf(); }); + +test('transaction context exposes tempTable()', () => { + type HasTempTable = 'tempTable' extends keyof PostgresTransactionContext + ? true + : false; + expectTypeOf().toEqualTypeOf(); +}); + +test('transaction tempTable() accepts no arguments', () => { + type Params = Parameters['tempTable']>; + expectTypeOf().toEqualTypeOf<[]>(); +}); + +test('tempTable().as returns a metadata-rich handle', () => { + type Builder = ReturnType['tempTable']>; + type HandlePromise = ReturnType; + expectTypeOf>().toMatchTypeOf<{ + name: string; + fields: Record; + drop(): Promise; + [Symbol.asyncDispose](): Promise; + }>(); +}); + +test('tempTable().as accepts internally-convertible ORM-like inputs', () => { + type Builder = ReturnType['tempTable']>; + type AsInput = Parameters[0]; + type Convertible = { + [INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE](): { + buildAst(): never; + getRowFields(): Record; + }; + }; + + type AcceptsConvertible = Convertible extends AsInput ? true : false; + expectTypeOf().toEqualTypeOf(); +}); + +test('TempTableColumnDef has name and type fields', () => { + expectTypeOf().toMatchTypeOf<{ name: string; type: string }>(); +}); + +test('tempTable().from() accepts only column defs', () => { + type Builder = ReturnType['tempTable']>; + type FromParams = Parameters; + + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toEqualTypeOf<[columns: readonly TempTableColumnDef[]]>(); +}); + +test('tempTable().from() returns a TempTableHandle promise', () => { + type Builder = ReturnType['tempTable']>; + type HandlePromise = ReturnType; + expectTypeOf>().toMatchTypeOf<{ + name: string; + drop(): Promise; + [Symbol.asyncDispose](): Promise; + }>(); +}); + +test('TempTableHandle.append() accepts a typed subquery that matches Row', () => { + type Builder = ReturnType['tempTable']>; + type Handle = Awaited>; + type AppendParam = Parameters[0]; + + expectTypeOf().toMatchTypeOf(); +}); + +test('TempTableAppendInput accepts raw rows', () => { + type RawRows = readonly (readonly (string | number | boolean | null)[])[]; + type IsAccepted = RawRows extends TempTableAppendInput ? true : false; + expectTypeOf().toEqualTypeOf(); +}); + +test('TempTableHandle.append() returns Promise', () => { + type Builder = ReturnType['tempTable']>; + type Handle = Awaited>; + type AppendReturn = ReturnType; + expectTypeOf().toEqualTypeOf>(); +}); + +test('db.connection infers the callback return type correctly', () => { + const db = {} as PostgresClient; + + const numResult = db.connection(async (_conn) => 42); + expectTypeOf(numResult).toEqualTypeOf>(); +}); + +test('connection context exposes tempTable()', () => { + type HasTempTable = 'tempTable' extends keyof PostgresConnectionContext + ? true + : false; + expectTypeOf().toEqualTypeOf(); +}); + +test('connection tempTable() accepts no arguments', () => { + type Params = Parameters['tempTable']>; + expectTypeOf().toEqualTypeOf<[]>(); +}); + +test('connection context exposes sql with same type as db.sql', () => { + type DbSql = PostgresClient['sql']; + type ConnSql = PostgresConnectionContext['sql']; + expectTypeOf().toEqualTypeOf(); +}); + +test('connection context does not expose release or destroy', () => { + type HasRelease = 'release' extends keyof PostgresConnectionContext ? true : false; + type HasDestroy = 'destroy' extends keyof PostgresConnectionContext ? true : false; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +}); + +test('connection context exposes registerReleaseHook', () => { + type HasHook = 'registerReleaseHook' extends keyof PostgresConnectionContext + ? true + : false; + expectTypeOf().toEqualTypeOf(); +}); diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index 293e360f4263..6041a91ed4bf 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -1,11 +1,11 @@ -import type { Contract } from '@internal/contract/types'; +import type { Contract } from '@prisma-next/contract/types'; import type { AnnotationValue, MetaBuilder, OperationKind, -} from '@internal/framework-components/runtime'; -import { AsyncIterableResult, createMetaBuilder } from '@internal/framework-components/runtime'; -import type { SqlStorage } from '@internal/sql-contract/types'; +} from '@prisma-next/framework-components/runtime'; +import { AsyncIterableResult, createMetaBuilder } from '@prisma-next/framework-components/runtime'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { type AnyExpression, BinaryExpr, @@ -15,26 +15,20 @@ import { type OrderByItem, type ToWhereExpr, type WhereArg, -} from '@internal/sql-relational-core/ast'; -import { blindCast } from '@internal/utils/casts'; -import { ifDefined } from '@internal/utils/defined'; -import { InternalError } from '@internal/utils/internal-error'; -import type { SimplifyDeep } from '@internal/utils/simplify-deep'; -import type { Simplify } from '@internal/utils/types'; +} from '@prisma-next/sql-relational-core/ast'; +import type { ScopeField } from '@prisma-next/sql-relational-core/expression'; +import { ifDefined } from '@prisma-next/utils/defined'; +import type { SimplifyDeep } from '@prisma-next/utils/simplify-deep'; import { createAggregateBuilder, isAggregateSelector } from './aggregate-builder'; -import { resolveAggregate } from './aggregate-codecs'; -import { emptyAggregateResult } from './aggregate-empty-result'; -import { aggregateOperationNames } from './aggregate-operations'; +import { normalizeAggregateResult } from './collection-aggregate-result'; import { mapCursorValuesToColumns, mapFieldsToColumns } from './collection-column-mapping'; import { - assertDistinctOnCapability, assertReturningCapability, getColumnToFieldMap, getFieldToColumnMap, isToOneCardinality, modelOf, type PolymorphismInfo, - type PolymorphismVariantInfo, resolveFieldToColumn, resolveIncludeRelation, resolveModelTableName, @@ -65,6 +59,7 @@ import { executeMutationReturningSingleRow, } from './collection-mutation-dispatch'; import { mapModelDataToStorageRow, mapPolymorphicRow } from './collection-runtime'; +import { executeQueryPlan } from './execute-query-plan'; import { shorthandToWhereExpr } from './filters'; import { GroupedCollection } from './grouped-collection'; import { @@ -74,6 +69,10 @@ import { isIncludeCombine, isIncludeScalar, } from './include-descriptors'; +import { + INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE, + type InternalTempTableQuerySource, +} from './internal-temp-table-source'; import { createModelAccessor } from './model-accessor'; import { buildPrimaryKeyFilterFromRow, @@ -82,7 +81,6 @@ import { hasNestedMutationCallbacks, withMutationScope, } from './mutation-executor'; -import { ormError } from './orm-errors'; import { compileAggregate, compileDeleteCount, @@ -91,49 +89,45 @@ import { compileInsertCountSplit, compileInsertReturning, compileInsertReturningSplit, + compileSelect, compileUpdateCount, compileUpdateReturning, compileUpsertReturning, mergeAnnotations, } from './query-plan'; -import { queryPlanRows } from './query-plan-rows'; +import { storageTableForContract } from './storage-resolution'; import { type AggregateBuilder, - type AggregateIncludeReducers, type AggregateResult, - type AggregateSelector, type AggregateSpec, type CollectionContext, type CollectionState, type CollectionTypeState, type DefaultCollectionTypeState, type DefaultModelRow, - emptyGroupPagingState, emptyState, type IncludeCombine, type IncludeCombineBranch, type IncludeExpr, - type IncludeRelationOwner, type IncludeScalar, type InferRootRow, type MutationCreateInput, type MutationCreateInputWithRelations, type MutationUpdateInput, + type NumericFieldNames, type RelatedModelName, + type RelationNames, type RelationTargetNamespace, type ResolvedCreateInput, type RuntimeQueryable, type ShorthandWhereFilter, type UniqueConstraintCriterion, - type VariantAwareIncludeRelationNames, type VariantAwareModelAccessor, type VariantModelRow, type VariantNames, } from './types'; import { normalizeWhereArg } from './where-interop'; -type EmptyAggregateValue = ReturnType; - function applyCreateDefaults( ctx: CollectionContext>, namespaceId: string, @@ -183,36 +177,26 @@ function isToWhereExprInput(value: unknown): value is ToWhereExpr { typeof value === 'object' && value !== null && 'toWhereExpr' in value && - typeof value.toWhereExpr === 'function' + typeof (value as { toWhereExpr?: unknown }).toWhereExpr === 'function' ); } function isWhereDirectInput(value: unknown): value is WhereDirectInput { return ( - (isWhereExpr(value) && - typeof value === 'object' && - value !== null && - 'accept' in value && - typeof value.accept === 'function') || + (isWhereExpr(value) && typeof (value as { accept?: unknown }).accept === 'function') || isToWhereExprInput(value) ); } -type MtiVariantInfo = Simplify; - -function isMtiVariantInfo(variant: PolymorphismVariantInfo | undefined): variant is MtiVariantInfo { - return variant?.strategy === 'mti'; -} - interface MtiCreateContext { polyInfo: PolymorphismInfo; - variant: MtiVariantInfo; + variant: { modelName: string; value: string; table: string; strategy: 'mti' }; baseFieldToColumn: Record; variantFieldToColumn: Record; pkColumn: string; } -class CollectionImpl< +export class Collection< TContract extends Contract, ModelName extends string, Row = SimplifyDeep>, @@ -251,56 +235,6 @@ class CollectionImpl< this.state = options.state ?? emptyState(); this.registry = options.registry ?? new Map>(); this.includeRefinementMode = options.includeRefinementMode ?? false; - this.#installAggregateReducers(); - } - - /** - * Install one include-scalar reducer per operation the composed registry - * contributes — the runtime mirror of the contract's emitted aggregate map, - * which is what types the reducers as {@link AggregateIncludeReducers} on - * the public {@link Collection} surface. The reducers live on the instance - * because their names are the registry's, not the class declaration's. - * - * A name the collection already carries is skipped, and which member holds - * it decides what the skip means. A `CollectionImpl` member is rejected at - * ORM composition with `ORM.AGGREGATE_OPERATION_RESERVED`, since - * {@link reservedCollectionMemberNames} scans this class. A member declared - * by a custom collection class registered through `orm({ collections })` - * falls outside that set, so it keeps the name and the operation gets no - * reducer. The type level is what guards that case: {@link Collection} - * intersects the class with {@link AggregateIncludeReducers}, so for any - * contract whose emitted map carries the operation, a subclass member that - * does not match the reducer's signature is a type error. - */ - #installAggregateReducers(): void { - for (const operation of aggregateOperationNames(this.ctx.context.aggregateDescriptors)) { - if (operation in this) { - continue; - } - Object.defineProperty(this, operation, { - value: (field?: string) => this.#includeScalarReducer(operation, field), - writable: true, - enumerable: false, - configurable: true, - }); - } - } - - /** - * Scalar reducer — reduces a to-many relation to the operation's value over - * the related rows. Use inside an `include(...)` refinement callback as - * `include(..., (rel) => rel.count())`; throws if called elsewhere. The - * parent row's relation field becomes that value instead of an array. A - * call without a field aggregates over rows; a call with one aggregates the - * field's storage column. - */ - #includeScalarReducer(operation: string, field: string | undefined): IncludeScalar { - this.#assertIncludeRefinementMode(`${operation}()`); - const column = - field === undefined - ? undefined - : resolveFieldToColumn(this.contract, this.namespaceId, this.modelName, field); - return createIncludeScalar(operation, this.state, column); } /** @@ -376,10 +310,7 @@ class CollectionImpl< }); if (!filter) { - return blindCast< - Collection>, - 'where() records its static state even when normalization produces no filter' - >(this); + return this as Collection>; } return this.#clone>({ @@ -415,23 +346,29 @@ class CollectionImpl< WithVariantState, V> > { type ReturnState = WithVariantState, V>; - const model = modelOf(this.contract, this.namespaceId, this.modelName); - const discriminator = model?.discriminator; - const variants = model?.variants; + const model = modelOf(this.contract, this.namespaceId, this.modelName) as + | Record + | undefined; + const discriminator = model?.['discriminator'] as { field: string } | undefined; + const variants = model?.['variants'] as Record | undefined; if (!discriminator || !variants) { - return blindCast< - Collection, ReturnState>, - 'variant() preserves its declared static narrowing when runtime polymorphism metadata is absent' - >(this); + return this as unknown as Collection< + TContract, + ModelName, + VariantModelRow, + ReturnState + >; } const variantEntry = variants[variantName]; if (!variantEntry) { - return blindCast< - Collection, ReturnState>, - 'variant() preserves its declared static narrowing when runtime metadata lacks the selected variant' - >(this); + return this as unknown as Collection< + TContract, + ModelName, + VariantModelRow, + ReturnState + >; } const columnName = resolveFieldToColumn( @@ -459,7 +396,7 @@ class CollectionImpl< return this.#cloneWithRow, ReturnState>({ filters: [...filtersWithoutPreviousVariant, filter], - variantName, + variantName: variantName as string, }); } @@ -479,7 +416,7 @@ class CollectionImpl< * * // Refine the related collection: * const withRecent = await db.orm.User.include('posts', (posts) => - * posts.where({ published: true }).orderBy((p) => p.createdAt.desc()).limit(5), + * posts.where({ published: true }).orderBy((p) => p.createdAt.desc()).take(5), * ).all(); * * // Reduce a to-many relation to a scalar value: @@ -487,34 +424,16 @@ class CollectionImpl< * * // Multiple sub-views via combine(): * const overview = await db.orm.User.include('posts', (posts) => - * posts.combine({ recent: posts.limit(3), total: posts.count() }), + * posts.combine({ recent: posts.take(3), total: posts.count() }), * ).all(); * ``` */ include< - RelName extends VariantAwareIncludeRelationNames< - TContract, - ModelName, - State['variantName'], - State['nsId'] - >, - RelationOwner extends string = IncludeRelationOwner< - TContract, - ModelName, - State['variantName'], - RelName, - State['nsId'] - > & - string, - RelatedName extends RelatedModelName & - string = RelatedModelName & string, - TargetNs extends string = RelationTargetNamespace< - TContract, - RelationOwner, - RelName, - State['nsId'] - >, - IsToMany extends boolean = IsToManyRelation, + RelName extends RelationNames, + RelatedName extends RelatedModelName & + string = RelatedModelName & string, + TargetNs extends string = RelationTargetNamespace, + IsToMany extends boolean = IsToManyRelation, RefinedResult extends IncludeRefinementResult< TContract, RelatedName, @@ -544,7 +463,7 @@ class CollectionImpl< Row & { [K in RelName]: IncludeRefinementValue< TContract, - RelationOwner, + ModelName, K, SimplifyDeep>, RefinedResult, @@ -558,8 +477,7 @@ class CollectionImpl< this.contract, this.namespaceId, this.modelName, - relationName, - this.state.variantName, + relationName as string, ); let nestedState = emptyState(); @@ -571,55 +489,51 @@ class CollectionImpl< RelatedName, SimplifyDeep>, DefaultCollectionTypeState - >( - blindCast( - relation.relatedModelName, - ), - { - tableName: relation.relatedTableName, - namespaceId: relation.relatedNamespaceId, - state: emptyState(), - includeRefinementMode: true, - }, + >(relation.relatedModelName as RelatedName, { + tableName: relation.relatedTableName, + namespaceId: relation.relatedNamespaceId, + state: emptyState(), + includeRefinementMode: true, + }); + const refined = refineFn( + nestedCollection as unknown as IncludeRefinementCollection< + TContract, + RelatedName, + SimplifyDeep>, + DefaultCollectionTypeState, + IsToMany + >, ); - const refined = refineFn(nestedCollection); if (isIncludeScalar(refined)) { if (isToOneCardinality(relation.cardinality)) { - throw ormError( - 'ORM.INCLUDE_UNSUPPORTED', - `include('${relationName}') scalar aggregations are only supported for to-many relations`, - { meta: { relation: relationName, kind: 'scalar' } }, + throw new Error( + `include('${relationName as string}') scalar aggregations are only supported for to-many relations`, ); } scalarSelector = refined; nestedState = refined.state; } else if (isIncludeCombine(refined)) { if (isToOneCardinality(relation.cardinality)) { - throw ormError( - 'ORM.INCLUDE_UNSUPPORTED', - `include('${relationName}') combine() is only supported for to-many relations`, - { meta: { relation: relationName, kind: 'combine' } }, + throw new Error( + `include('${relationName as string}') combine() is only supported for to-many relations`, ); } combineBranches = refined.branches; } else if (isCollectionStateCarrier(refined)) { nestedState = refined.state; } else { - throw ormError( - 'ORM.INCLUDE_INVALID', - `include('${relationName}') refinement must return a collection, include scalar selector, or combine() descriptor`, - { meta: { relation: relationName } }, + throw new Error( + `include('${relationName as string}') refinement must return a collection, include scalar selector, or combine() descriptor`, ); } } const includeExpr: IncludeExpr = { - relationName, + relationName: relationName as string, relatedModelName: relation.relatedModelName, relatedNamespaceId: relation.relatedNamespaceId, relatedTableName: relation.relatedTableName, - localTableName: relation.localTableName, targetColumn: relation.targetColumn, localColumn: relation.localColumn, cardinality: relation.cardinality, @@ -634,7 +548,7 @@ class CollectionImpl< Row & { [K in RelName]: IncludeRefinementValue< TContract, - RelationOwner, + ModelName, K, SimplifyDeep>, RefinedResult, @@ -696,6 +610,49 @@ class CollectionImpl< }); } + [INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE](): InternalTempTableQuerySource< + Record + > { + if (this.state.includes.length > 0) { + throw new Error('tempTable().as(...) does not support include(...) projections.'); + } + + const compiled = compileSelect( + this.contract, + this.namespaceId, + this.tableName, + this.state, + this.modelName, + ); + + if (compiled.ast.kind !== 'select') { + throw new Error('tempTable().as(...) expected a SELECT AST.'); + } + const ast = compiled.ast; + + const table = storageTableForContract(this.contract, this.namespaceId, this.tableName); + const selectedColumns = this.state.selectedFields ?? Object.keys(table.columns); + const rowFields: Record = {}; + + for (const column of selectedColumns) { + const columnSpec = table.columns[column]; + if (!columnSpec) { + throw new Error( + `tempTable().as(...): unknown selected column "${column}" for table "${this.tableName}".`, + ); + } + rowFields[column] = { + codecId: columnSpec.codecId, + nullable: columnSpec.nullable, + }; + } + + return { + buildAst: () => ast, + getRowFields: () => rowFields, + }; + } + /** * Append an `ORDER BY` clause. Pass a single selector callback or an * array of callbacks; each receives a typed model accessor whose @@ -753,10 +710,10 @@ class CollectionImpl< */ groupBy< Fields extends readonly [ - keyof DefaultModelRow & string, - ...(keyof DefaultModelRow & string)[], + keyof DefaultModelRow & string, + ...(keyof DefaultModelRow & string)[], ], - >(...fields: Fields): GroupedCollection { + >(...fields: Fields): GroupedCollection { const groupByColumns = mapFieldsToColumns( this.contract, this.namespaceId, @@ -767,14 +724,124 @@ class CollectionImpl< return new GroupedCollection(this.ctx, this.modelName, { tableName: this.tableName, namespaceId: this.namespaceId, - preGroupState: this.state, + baseFilters: this.state.filters, groupByFields: [...fields], groupByColumns, havingFilters: [], - postGroup: emptyGroupPagingState(), }); } + /** + * Scalar reducer — reduces a to-many relation to the number of + * related rows. Use inside an `include(...)` refinement callback as + * `include(..., (rel) => rel.count())`; throws if called elsewhere. + * The parent row's relation field becomes that count instead of an + * array. + * + * ```typescript + * const users = await db.orm.User.include('posts', (posts) => posts.count()).all(); + * // each user row: { ...user, posts: number } + * ``` + */ + count(): IncludeScalar { + this.#assertIncludeRefinementMode('count()'); + return createIncludeScalar('count', this.state); + } + + /** + * Scalar reducer — reduces a to-many relation to the sum of `field` + * across related rows. Returns `null` when there are no related + * rows. Use inside an `include(...)` refinement callback; throws if + * called elsewhere. + * + * ```typescript + * const users = await db.orm.User.include('posts', (posts) => posts.sum('views')).all(); + * // each user row: { ...user, posts: number | null } + * ``` + */ + sum>( + field: FieldName, + ): IncludeScalar { + this.#assertIncludeRefinementMode('sum()'); + const columnName = resolveFieldToColumn( + this.contract, + this.namespaceId, + this.modelName, + field as string, + ); + return createIncludeScalar('sum', this.state, columnName); + } + + /** + * Scalar reducer — reduces a to-many relation to the average of + * `field` across related rows. Returns `null` when there are no + * related rows. Use inside an `include(...)` refinement callback; + * throws if called elsewhere. + * + * ```typescript + * const users = await db.orm.User.include('posts', (posts) => posts.avg('views')).all(); + * // each user row: { ...user, posts: number | null } + * ``` + */ + avg>( + field: FieldName, + ): IncludeScalar { + this.#assertIncludeRefinementMode('avg()'); + const columnName = resolveFieldToColumn( + this.contract, + this.namespaceId, + this.modelName, + field as string, + ); + return createIncludeScalar('avg', this.state, columnName); + } + + /** + * Scalar reducer — reduces a to-many relation to the minimum value + * of `field` across related rows. Returns `null` when there are no + * related rows. Use inside an `include(...)` refinement callback; + * throws if called elsewhere. + * + * ```typescript + * const users = await db.orm.User.include('posts', (posts) => posts.min('views')).all(); + * ``` + */ + min>( + field: FieldName, + ): IncludeScalar { + this.#assertIncludeRefinementMode('min()'); + const columnName = resolveFieldToColumn( + this.contract, + this.namespaceId, + this.modelName, + field as string, + ); + return createIncludeScalar('min', this.state, columnName); + } + + /** + * Scalar reducer — reduces a to-many relation to the maximum value + * of `field` across related rows. Returns `null` when there are no + * related rows. Use inside an `include(...)` refinement callback; + * throws if called elsewhere. + * + * ```typescript + * const users = await db.orm.User.include('posts', (posts) => posts.max('views')).all(); + * ``` + */ + max>( + field: FieldName, + ): IncludeScalar { + this.#assertIncludeRefinementMode('max()'); + const columnName = resolveFieldToColumn( + this.contract, + this.namespaceId, + this.modelName, + field as string, + ); + return createIncludeScalar('max', this.state, columnName); + } + /** * Produce multiple named sub-views of a to-many relation in a * single `include(...)`. Each branch is either another refined @@ -785,7 +852,7 @@ class CollectionImpl< * ```typescript * const users = await db.orm.User.include('posts', (posts) => * posts.combine({ - * recent: posts.where({ published: true }).limit(3), + * recent: posts.where({ published: true }).take(3), * total: posts.count(), * averageViews: posts.avg('views'), * }), @@ -799,14 +866,14 @@ class CollectionImpl< combine< Spec extends Record< string, - CollectionImpl | IncludeScalar + Collection | IncludeScalar >, >( spec: Spec, ): IncludeCombine<{ [K in keyof Spec]: Spec[K] extends IncludeScalar ? ScalarResult - : Spec[K] extends CollectionImpl + : Spec[K] extends Collection ? BranchRow[] : never; }> { @@ -830,18 +897,16 @@ class CollectionImpl< continue; } - throw ormError('ORM.INCLUDE_INVALID', `include().combine() branch "${name}" is invalid`, { - meta: { branch: name }, - }); + throw new Error(`include().combine() branch "${name}" is invalid`); } - return createIncludeCombine<{ + return createIncludeCombine(branches) as IncludeCombine<{ [K in keyof Spec]: Spec[K] extends IncludeScalar ? ScalarResult - : Spec[K] extends CollectionImpl + : Spec[K] extends Collection ? BranchRow[] : never; - }>(branches); + }>; } /** @@ -853,14 +918,14 @@ class CollectionImpl< * ```typescript * const page1 = await db.orm.Post * .orderBy((p) => p.createdAt.desc()) - * .limit(20) + * .take(20) * .all(); * * const last = page1[page1.length - 1]!; * const page2 = await db.orm.Post * .orderBy((p) => p.createdAt.desc()) * .cursor({ createdAt: last.createdAt }) - * .limit(20) + * .take(20) * .all(); * ``` */ @@ -873,14 +938,11 @@ class CollectionImpl< this.contract, this.namespaceId, this.modelName, - cursorValues, + cursorValues as Readonly>, ); if (Object.keys(mappedCursor).length === 0) { - return blindCast< - Collection, - 'the constructor installed the reducer members the surface type declares' - >(this); + return this; } return this.#clone({ @@ -921,8 +983,6 @@ class CollectionImpl< * prior `orderBy(...)`; replaces any previous `distinct(...)` / * `distinctOn(...)` selection. * - * Requires the `postgres.distinctOn` capability. - * * ```typescript * // Latest post per user: * const latestPerUser = await db.orm.Post @@ -937,18 +997,13 @@ class CollectionImpl< ...(keyof DefaultModelRow & string)[], ], >( - ...fields: TContract['capabilities'] extends { postgres: { distinctOn: true } } - ? State['hasOrderBy'] extends true - ? Fields - : never - : never + ...fields: State['hasOrderBy'] extends true ? Fields : never ): Collection { - assertDistinctOnCapability(this.contract, 'distinctOn'); const distinctOnFields = mapFieldsToColumns( this.contract, this.namespaceId, this.modelName, - fields, + fields as readonly string[], ); return this.#clone({ @@ -961,10 +1016,10 @@ class CollectionImpl< * Apply `LIMIT n`. Replaces any previous limit set on this collection. * * ```typescript - * const firstTen = await db.orm.User.orderBy((u) => u.id.asc()).limit(10).all(); + * const firstTen = await db.orm.User.orderBy((u) => u.id.asc()).take(10).all(); * ``` */ - limit(n: number): Collection { + take(n: number): Collection { return this.#clone({ limit: n }); } @@ -974,12 +1029,12 @@ class CollectionImpl< * ```typescript * const page2 = await db.orm.User * .orderBy((u) => u.id.asc()) - * .offset(10) - * .limit(10) + * .skip(10) + * .take(10) * .all(); * ``` */ - offset(n: number): Collection { + skip(n: number): Collection { return this.#clone({ offset: n }); } @@ -1086,7 +1141,7 @@ class CollectionImpl< : typeof filter === 'function' ? this.where(filter) : this.where(filter); - const limited = scoped.limit(1).#withAnnotationsFromMeta(configure, 'first'); + const limited = scoped.take(1).#withAnnotationsFromMeta(configure, 'first'); const rows = await limited.#dispatch().toArray(); return rows[0] ?? null; } @@ -1113,35 +1168,20 @@ class CollectionImpl< * Annotations are merged into the compiled plan's `meta.annotations`. */ async aggregate( - fn: (aggregate: AggregateBuilder) => Spec, + fn: (aggregate: AggregateBuilder) => Spec, configure?: (meta: MetaBuilder<'read'>) => void, ): Promise> { const aggregateSpec = fn( - createAggregateBuilder( - this.contract, - this.ctx.context.aggregateDescriptors, - this.namespaceId, - this.modelName, - ), + createAggregateBuilder(this.contract, this.namespaceId, this.modelName), ); const entries = Object.entries(aggregateSpec); if (entries.length === 0) { - throw ormError( - 'ORM.AGGREGATE_SELECTOR_MISSING', - 'aggregate() requires at least one aggregation selector', - { meta: { method: 'aggregate', model: this.modelName } }, - ); + throw new Error('aggregate() requires at least one aggregation selector'); } for (const [alias, selector] of entries) { if (!isAggregateSelector(selector)) { - throw ormError( - 'ORM.AGGREGATE_SELECTOR_INVALID', - `aggregate() selector "${alias}" is invalid`, - { - meta: { method: 'aggregate', model: this.modelName, alias }, - }, - ); + throw new Error(`aggregate() selector "${alias}" is invalid`); } } @@ -1150,29 +1190,18 @@ class CollectionImpl< const compiled = mergeAnnotations( compileAggregate( this.contract, - this.ctx.context.aggregateDescriptors, this.namespaceId, this.tableName, - this.state, + this.state.filters, aggregateSpec, - this.modelName, ), annotationsMap, ); - const rows = await queryPlanRows>(this.ctx.runtime, compiled).toArray(); - // Values arrive decoded: the projection carries each aggregate's resolved - // output codec, so the runtime's decode pass has already turned the wire - // value into the application one. An absent alias means an empty input - // set, whose answer reads off the operation's declared row. - const row = rows[0] ?? {}; - const result: Record = {}; - for (const [alias, selector] of entries) { - result[alias] = row[alias] ?? this.#emptyAggregateValue(selector); - } - return blindCast< - AggregateResult, - "aliases are the aggregateSpec's own keys; values decoded by the projection codecs the same spec resolved" - >(result); + const rows = await executeQueryPlan>( + this.ctx.runtime, + compiled, + ).toArray(); + return normalizeAggregateResult(aggregateSpec, rows[0] ?? {}); } /** @@ -1243,10 +1272,7 @@ class CollectionImpl< this.contract, this.namespaceId, this.modelName, - blindCast< - Record, - 'create overload inputs are model-field records inspected for relation callbacks' - >(data), + data as Record, ) ) { const createdRow = await executeNestedCreateMutation({ @@ -1254,10 +1280,7 @@ class CollectionImpl< runtime: this.ctx.runtime, namespaceId: this.namespaceId, modelName: this.modelName, - data: blindCast< - MutationCreateInput, string>, - 'nested callback detection selects the relation-mutation create input' - >(data), + data: data as MutationCreateInput, string>, }); const pkCriterion = buildPrimaryKeyFilterFromRow( @@ -1268,22 +1291,13 @@ class CollectionImpl< ); const reloaded = await this.#reloadMutationRowByPrimaryKey(pkCriterion); if (!reloaded) { - throw ormError( - 'ORM.MUTATION_ROW_MISSING', - `create() for model "${this.modelName}" did not return a row`, - { meta: { operation: 'create', model: this.modelName } }, - ); + throw new Error(`create() for model "${this.modelName}" did not return a row`); } return reloaded; } const rows = await this.#createAllWithAnnotations( - [ - blindCast< - ResolvedCreateInput, - 'absence of nested callbacks selects the scalar create overload input' - >(data), - ], + [data as ResolvedCreateInput], annotationsMap, ); const created = rows[0]; @@ -1291,11 +1305,7 @@ class CollectionImpl< return created; } - throw ormError( - 'ORM.MUTATION_ROW_MISSING', - `create() for model "${this.modelName}" did not return a row`, - { meta: { operation: 'create', model: this.modelName } }, - ); + throw new Error(`create() for model "${this.modelName}" did not return a row`); } /** @@ -1346,10 +1356,7 @@ class CollectionImpl< assertReturningCapability(this.contract, 'createAll()'); - const rows = blindCast< - readonly Record[], - 'resolved create inputs are model-field records for storage mapping' - >(data); + const rows = data as readonly Record[]; const mtiContext = this.#resolveMtiCreateContext(); if (mtiContext) { return this.#executeMtiCreate(rows, mtiContext); @@ -1367,18 +1374,16 @@ class CollectionImpl< selectedForInsert, ).map((plan) => mergeAnnotations(plan, annotationsMap)); return dispatchSplitMutationRows({ - context: this.ctx.context, + contract: this.contract, runtime: this.ctx.runtime, plans, tableName: this.tableName, modelName: this.modelName, namespaceId: this.namespaceId, - variantName: this.state.variantName, includes: this.state.includes, selectedFields: this.state.selectedFields, hiddenColumns, - mapRow: (mapped) => - blindCast(mapped), + mapRow: (mapped) => mapped as Row, }); } @@ -1393,35 +1398,24 @@ class CollectionImpl< annotationsMap, ); return dispatchMutationRows({ - context: this.ctx.context, + contract: this.contract, runtime: this.ctx.runtime, compiled, tableName: this.tableName, modelName: this.modelName, namespaceId: this.namespaceId, - variantName: this.state.variantName, includes: this.state.includes, selectedFields: this.state.selectedFields, hiddenColumns, - mapRow: (mapped) => - blindCast(mapped), + mapRow: (mapped) => mapped as Row, }); } #assertNotMtiVariant(method: string): void { const mtiCtx = this.#resolveMtiCreateContext(); if (mtiCtx) { - throw ormError( - 'ORM.OPERATION_UNSUPPORTED', + throw new Error( `${method} is not supported for MTI variant "${this.state.variantName}" on model "${this.modelName}". Use createAll() instead.`, - { - meta: { - method, - model: this.modelName, - variant: this.state.variantName, - reason: 'mti-variant', - }, - }, ); } } @@ -1434,7 +1428,7 @@ class CollectionImpl< if (!polyInfo) return null; const variant = polyInfo.variants.get(variantName); - if (!isMtiVariantInfo(variant)) return null; + if (!variant || variant.strategy !== 'mti') return null; const baseFieldToColumn = getFieldToColumnMap(this.contract, this.namespaceId, this.modelName); const variantFieldToColumn = getFieldToColumnMap( @@ -1446,7 +1440,7 @@ class CollectionImpl< return { polyInfo, - variant, + variant: variant as typeof variant & { strategy: 'mti' }, baseFieldToColumn, variantFieldToColumn, pkColumn, @@ -1472,7 +1466,7 @@ class CollectionImpl< const generator = async function* (): AsyncGenerator { for (const row of data) { const allMapped: Record = {}; - for (const [fieldName, value] of Object.entries(row)) { + for (const [fieldName, value] of Object.entries(row as Record)) { if (value === undefined) continue; const columnName = mergedFieldToColumn[fieldName] ?? fieldName; allMapped[columnName] = value; @@ -1499,24 +1493,13 @@ class CollectionImpl< [baseRow], undefined, ); - const baseResult = await queryPlanRows>( + const baseResult = await executeQueryPlan>( scope, baseCompiled, ).toArray(); const baseCreated = baseResult[0]; if (!baseCreated) { - throw ormError( - 'ORM.MUTATION_ROW_MISSING', - `MTI base INSERT for model "${modelName}" did not return a row`, - { - meta: { - operation: 'create', - model: modelName, - table: tableName, - phase: 'mti-base', - }, - }, - ); + throw new Error(`MTI base INSERT for model "${modelName}" did not return a row`); } const pkValue = baseCreated[pkColumn]; @@ -1529,23 +1512,14 @@ class CollectionImpl< [variantRow], undefined, ); - const variantResult = await queryPlanRows>( + const variantResult = await executeQueryPlan>( scope, variantCompiled, ).toArray(); const variantCreated = variantResult[0]; if (!variantCreated) { - throw ormError( - 'ORM.MUTATION_ROW_MISSING', + throw new Error( `MTI variant INSERT for model "${modelName}" into "${variant.table}" did not return a row`, - { - meta: { - operation: 'create', - model: modelName, - table: variant.table, - phase: 'mti-variant', - }, - }, ); } @@ -1565,7 +1539,7 @@ class CollectionImpl< ); }); - yield blindCast(merged); + yield merged as Row; } }; @@ -1604,7 +1578,7 @@ class CollectionImpl< return data.map((row) => { const mapped: Record = {}; - for (const [fieldName, value] of Object.entries(row)) { + for (const [fieldName, value] of Object.entries(row as Record)) { if (value === undefined) continue; const columnName = mergedFieldToColumn[fieldName] ?? fieldName; mapped[columnName] = value; @@ -1623,7 +1597,7 @@ class CollectionImpl< * compiled plan skips `RETURNING`). * * ```typescript - * const inserted = await db.orm.User.createAndCount([ + * const inserted = await db.orm.User.createCount([ * { email: 'a@example.com' }, * { email: 'b@example.com' }, * ]); @@ -1632,7 +1606,7 @@ class CollectionImpl< * * Not supported on MTI variants — use `createAll(...)` instead. */ - async createAndCount( + async createCount( data: readonly ResolvedCreateInput[], configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { @@ -1640,13 +1614,10 @@ class CollectionImpl< return 0; } - this.#assertNotMtiVariant('createAndCount()'); - const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'createAndCount'); + this.#assertNotMtiVariant('createCount()'); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'createCount'); - const rows = blindCast< - readonly Record[], - 'resolved create-and-count inputs are model-field records for storage mapping' - >(data); + const rows = data as readonly Record[]; const mappedRows = this.#mapCreateRows(rows); applyCreateDefaults(this.ctx, this.namespaceId, this.tableName, mappedRows); @@ -1658,7 +1629,7 @@ class CollectionImpl< mappedRows, ).map((plan) => mergeAnnotations(plan, annotationsMap)); for (const plan of plans) { - await this.ctx.runtime.execute(plan); + await executeQueryPlan>(this.ctx.runtime, plan).toArray(); } return data.length; } @@ -1667,7 +1638,7 @@ class CollectionImpl< compileInsertCount(this.contract, this.namespaceId, this.tableName, mappedRows), annotationsMap, ); - await this.ctx.runtime.execute(compiled); + await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); return data.length; } @@ -1715,12 +1686,7 @@ class CollectionImpl< this.#assertNotMtiVariant('upsert()'); const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'upsert'); - const mappedCreateRows = this.#mapCreateRows([ - blindCast< - Record, - 'resolved upsert create input is a model-field record for storage mapping' - >(input.create), - ]); + const mappedCreateRows = this.#mapCreateRows([input.create as Record]); const createValues = mappedCreateRows[0] ?? {}; applyCreateDefaults(this.ctx, this.namespaceId, this.tableName, [createValues]); const updateValues = mapModelDataToStorageRow( @@ -1737,17 +1703,10 @@ class CollectionImpl< this.contract, this.namespaceId, this.modelName, - blindCast< - Record | undefined, - 'typed unique criterion is read as a field-value record by conflict resolution' - >(input.conflictOn), + input.conflictOn as Record | undefined, ); if (conflictColumns.length === 0) { - throw ormError( - 'ORM.ARGUMENT_INVALID', - `upsert() for model "${this.modelName}" requires conflict columns`, - { meta: { method: 'upsert', model: this.modelName } }, - ); + throw new Error(`upsert() for model "${this.modelName}" requires conflict columns`); } const { selectedForQuery: selectedForUpsert, hiddenColumns } = this.#augmentMutationSelection(); @@ -1764,19 +1723,16 @@ class CollectionImpl< annotationsMap, ); const row = await executeMutationReturningSingleRow({ - context: this.ctx.context, + contract: this.contract, runtime: this.ctx.runtime, compiled, tableName: this.tableName, modelName: this.modelName, namespaceId: this.namespaceId, - variantName: this.state.variantName, includes: this.state.includes, selectedFields: this.state.selectedFields, hiddenColumns, - mapRow: (mapped) => - blindCast(mapped), - operation: 'upsert', + mapRow: (mapped) => mapped as Row, onMissingRowMessage: `upsert() for model "${this.modelName}" did not return a row`, }); if (row) { @@ -1794,18 +1750,13 @@ class CollectionImpl< } } - throw ormError( - 'ORM.MUTATION_ROW_MISSING', - `upsert() for model "${this.modelName}" did not return a row`, - { meta: { operation: 'upsert', model: this.modelName } }, - ); + throw new Error(`upsert() for model "${this.modelName}" did not return a row`); } /** - * Write terminal: update a single matching row — the first one the - * filter matches — and return it (or `null` when no row matched). - * Requires a prior `.where(...)` — calling `update(...)` on an - * unfiltered collection is a type error. + * Write terminal: update matching rows and return the first one (or + * `null` when no row matched). Requires a prior `.where(...)` — + * calling `update(...)` on an unfiltered collection is a type error. * * Related rows can be created or relinked through relation * callbacks on parent/child-owned relations (one-to-one or @@ -1852,10 +1803,7 @@ class CollectionImpl< this.contract, this.namespaceId, this.modelName, - blindCast< - Record, - 'update input is a model-field record inspected for relation callbacks' - >(data), + data as Record, ) ) { const updatedRow = await executeNestedUpdateMutation({ @@ -1864,10 +1812,7 @@ class CollectionImpl< namespaceId: this.namespaceId, modelName: this.modelName, filters: this.state.filters, - data: blindCast< - MutationUpdateInput, string>, - 'nested callback detection selects the relation-mutation update input' - >(data), + data: data as MutationUpdateInput, string>, }); if (!updatedRow) { return null; @@ -1890,12 +1835,9 @@ class CollectionImpl< } const narrowed = scoped.#clone({ filters: [identityWhere] }); const rows = await narrowed.#updateAllWithAnnotations( - blindCast< - State['hasWhere'] extends true - ? Partial> - : never, - 'absence of nested callbacks selects the scalar update input' - >(data), + data as State['hasWhere'] extends true + ? Partial> + : never, annotationsMap, ); return rows[0] ?? null; @@ -1973,18 +1915,16 @@ class CollectionImpl< annotationsMap, ); return dispatchMutationRows({ - context: this.ctx.context, + contract: this.contract, runtime: this.ctx.runtime, compiled, tableName: this.tableName, modelName: this.modelName, namespaceId: this.namespaceId, - variantName: this.state.variantName, includes: this.state.includes, selectedFields: this.state.selectedFields, hiddenColumns, - mapRow: (mapped) => - blindCast(mapped), + mapRow: (mapped) => mapped as Row, }); } @@ -1999,10 +1939,10 @@ class CollectionImpl< * ```typescript * const count = await db.orm.Post * .where({ published: false }) - * .updateAndCount({ published: true }); + * .updateCount({ published: true }); * ``` */ - async updateAndCount( + async updateCount( data: State['hasWhere'] extends true ? Partial> : never, @@ -2020,7 +1960,30 @@ class CollectionImpl< applyUpdateDefaults(this.ctx, this.namespaceId, this.tableName, mappedData); - const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'updateAndCount'); + // Annotations attach to the write, not the matching read. + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'updateCount'); + + const primaryKeyColumn = resolvePrimaryKeyColumn( + this.contract, + this.namespaceId, + this.tableName, + ); + const countState: CollectionState = { + ...emptyState(), + filters: this.state.filters, + selectedFields: [primaryKeyColumn], + }; + const countCompiled = compileSelect( + this.contract, + this.namespaceId, + this.tableName, + countState, + undefined, + ); + const matchingRows = await executeQueryPlan>( + this.ctx.runtime, + countCompiled, + ).toArray(); const compiled = mergeAnnotations( compileUpdateCount( @@ -2029,20 +1992,18 @@ class CollectionImpl< this.tableName, mappedData, this.state.filters, - this.state.variantName, - this.modelName, ), annotationsMap, ); - const stats = await this.ctx.runtime.execute(compiled); - return stats.affectedRows; + await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); + + return matchingRows.length; } /** - * Write terminal: delete a single matching row — the first one the - * filter matches — and return it (or `null` when no row matched). - * Requires a prior `.where(...)` — calling `delete()` on an - * unfiltered collection is a type error. + * Write terminal: delete matching rows and return the first deleted + * row (or `null` when no row matched). Requires a prior `.where(...)` + * — calling `delete()` on an unfiltered collection is a type error. * * ```typescript * const deleted = await db.orm.User.where({ id: 1 }).delete(); @@ -2097,10 +2058,7 @@ class CollectionImpl< this: State['hasWhere'] extends true ? Collection : never, configure?: (meta: MetaBuilder<'write'>) => void, ): AsyncIterableResult { - return blindCast< - Collection, - 'deleteAll() conditional this parameter is a filtered collection at runtime' - >(this).#deleteAllWithAnnotations( + return (this as Collection).#deleteAllWithAnnotations( this.#collectAnnotationsFromMeta(configure, 'write', 'deleteAll'), ); } @@ -2131,18 +2089,16 @@ class CollectionImpl< annotationsMap, ); return dispatchMutationRows({ - context: this.ctx.context, + contract: this.contract, runtime: this.ctx.runtime, compiled, tableName: this.tableName, modelName: this.modelName, namespaceId: this.namespaceId, - variantName: this.state.variantName, includes: this.state.includes, selectedFields: this.state.selectedFields, hiddenColumns, - mapRow: (mapped) => - blindCast(mapped), + mapRow: (mapped) => mapped as Row, }); } @@ -2166,7 +2122,7 @@ class CollectionImpl< const generator = async function* (): AsyncGenerator { const snapshot = await withMutationScope(collection.ctx.runtime, async (scope) => { const rows = await dispatchCollectionRows({ - context: collection.ctx.context, + contract: collection.contract, runtime: scope, state: collection.state, tableName: collection.tableName, @@ -2179,12 +2135,10 @@ class CollectionImpl< collection.namespaceId, collection.tableName, collection.state.filters, - collection.state.variantName, - collection.modelName, ), annotationsMap, ); - await scope.execute(deletePlan); + await executeQueryPlan>(scope, deletePlan).toArray(); return rows; }); for (const row of snapshot) { @@ -2203,28 +2157,45 @@ class CollectionImpl< * this when you only need the affected-row count. * * ```typescript - * const removed = await db.orm.Post.where({ archived: true }).deleteAndCount(); + * const removed = await db.orm.Post.where({ archived: true }).deleteCount(); * ``` */ - async deleteAndCount( + async deleteCount( this: State['hasWhere'] extends true ? Collection : never, configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { - const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'deleteAndCount'); + // Annotations attach to the write, not the matching read. + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'deleteCount'); + + const primaryKeyColumn = resolvePrimaryKeyColumn( + this.contract, + this.namespaceId, + this.tableName, + ); + const countState: CollectionState = { + ...emptyState(), + filters: this.state.filters, + selectedFields: [primaryKeyColumn], + }; + const countCompiled = compileSelect( + this.contract, + this.namespaceId, + this.tableName, + countState, + undefined, + ); + const matchingRows = await executeQueryPlan>( + this.ctx.runtime, + countCompiled, + ).toArray(); const compiled = mergeAnnotations( - compileDeleteCount( - this.contract, - this.namespaceId, - this.tableName, - this.state.filters, - this.state.variantName, - this.modelName, - ), + compileDeleteCount(this.contract, this.namespaceId, this.tableName, this.state.filters), annotationsMap, ); - const stats = await this.ctx.runtime.execute(compiled); - return stats.affectedRows; + await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); + + return matchingRows.length; } #buildUpsertConflictCriterion( @@ -2236,10 +2207,8 @@ class CollectionImpl< for (const columnName of conflictColumns) { if (!(columnName in createValues)) { - throw ormError( - 'ORM.ARGUMENT_INVALID', + throw new Error( `upsert() for model "${this.modelName}" requires create value for conflict column "${columnName}"`, - { meta: { method: 'upsert', model: this.modelName, column: columnName } }, ); } @@ -2271,10 +2240,8 @@ class CollectionImpl< this.tableName, ); if (identityColumns.length === 0) { - throw ormError( - 'ORM.ROW_IDENTITY_MISSING', + throw new Error( `Cannot load includes for the mutation result on model "${this.modelName}": table "${this.tableName}" has no primary key or unique constraint to key the include read-back on.`, - { meta: { model: this.modelName, table: this.tableName } }, ); } return { selectedForQuery: identityColumns, hiddenColumns: [] }; @@ -2289,10 +2256,8 @@ class CollectionImpl< this.tableName, ); if (identityColumns.length === 0) { - throw ormError( - 'ORM.ROW_IDENTITY_MISSING', + throw new Error( `update()/delete() on model "${this.modelName}" requires the table to have a primary key or unique constraint`, - { meta: { model: this.modelName, table: this.tableName } }, ); } const firstRow = await this.#clone({ @@ -2306,12 +2271,9 @@ class CollectionImpl< const criterion: Record = {}; for (const column of identityColumns) { const fieldName = columnToField[column] ?? column; - const value = blindCast< - Record, - 'selected collection rows are model-field records used for identity lookup' - >(firstRow)[fieldName]; + const value = (firstRow as Record)[fieldName]; if (value === undefined) { - throw new InternalError( + throw new Error( `Missing identity field "${fieldName}" while resolving single-row scope for model "${this.modelName}"`, ); } @@ -2322,10 +2284,7 @@ class CollectionImpl< this.ctx.context, this.namespaceId, this.modelName, - blindCast< - ShorthandWhereFilter, - 'identity columns were resolved from this model before building the shorthand filter' - >(criterion), + criterion as ShorthandWhereFilter, ) ?? null ); } @@ -2342,13 +2301,10 @@ class CollectionImpl< this.ctx.context, this.namespaceId, this.modelName, - blindCast< - ShorthandWhereFilter, - 'mutation reload criterion contains resolved fields for this model' - >(criterion), + criterion as ShorthandWhereFilter, ); if (!whereExpr) { - throw new InternalError( + throw new Error( `Failed to build ${criterionLabel} filter for mutation result on model "${this.modelName}"`, ); } @@ -2362,7 +2318,7 @@ class CollectionImpl< }; const rows = await dispatchCollectionRows({ - context: this.ctx.context, + contract: this.contract, runtime: this.ctx.runtime, state: resultState, tableName: this.tableName, @@ -2372,37 +2328,12 @@ class CollectionImpl< return rows[0] ?? null; } - /** - * The value an aggregate alias reads as when the result set has no row to - * read at all. Resolution mirrors planning — the same registry, operation, - * and column — so the answer derives from the operation's declared row - * rather than its name. - */ - #emptyAggregateValue(selector: AggregateSelector): EmptyAggregateValue { - const resolved = resolveAggregate({ - aggregates: this.ctx.context.aggregateDescriptors, - contract: this.contract, - namespaceId: this.namespaceId, - tableName: this.tableName, - fn: selector.fn, - column: selector.column, - }); - return emptyAggregateResult( - resolved, - this.ctx.context.contractCodecs.forCodecRef(resolved.codec), - ); - } - #assertIncludeRefinementMode(action: string): void { if (this.includeRefinementMode) { return; } - throw ormError( - 'ORM.INCLUDE_INVALID', - `${action} is only available inside include() refinement callbacks`, - { meta: { action } }, - ); + throw new Error(`${action} is only available inside include() refinement callbacks`); } #clone( @@ -2414,23 +2345,15 @@ class CollectionImpl< }); } - #withRuntime(runtime: RuntimeQueryable): CollectionImpl { - const Ctor = blindCast< - CollectionConstructor, - 'runtime collection subclasses preserve the Collection constructor contract' - >(this.constructor); - return blindCast< - CollectionImpl, - 'runtime collection construction erases model row and state generics' - >( - new Ctor({ ...this.ctx, runtime }, this.modelName, { - tableName: this.tableName, - namespaceId: this.namespaceId, - state: this.state, - registry: this.registry, - includeRefinementMode: this.includeRefinementMode, - }), - ); + #withRuntime(runtime: RuntimeQueryable): Collection { + const Ctor = this.constructor as CollectionConstructor; + return new Ctor({ ...this.ctx, runtime }, this.modelName, { + tableName: this.tableName, + namespaceId: this.namespaceId, + state: this.state, + registry: this.registry, + includeRefinementMode: this.includeRefinementMode, + }) as unknown as Collection; } #cloneWithRow( @@ -2445,22 +2368,14 @@ class CollectionImpl< #createSelf( state: CollectionState, ): Collection { - const Ctor = blindCast< - CollectionConstructor, - 'runtime collection subclasses preserve the Collection constructor contract' - >(this.constructor); - return blindCast< - Collection, - 'runtime collection cloning erases projected row and state generics' - >( - new Ctor(this.ctx, this.modelName, { - tableName: this.tableName, - namespaceId: this.namespaceId, - state, - registry: this.registry, - includeRefinementMode: this.includeRefinementMode, - }), - ); + const Ctor = this.constructor as CollectionConstructor; + return new Ctor(this.ctx, this.modelName, { + tableName: this.tableName, + namespaceId: this.namespaceId, + state, + registry: this.registry, + includeRefinementMode: this.includeRefinementMode, + }) as unknown as Collection; } #createCollection< @@ -2472,28 +2387,22 @@ class CollectionImpl< options: CollectionInit, ): Collection { const Ctor = - this.registry.get(modelName) ?? - blindCast< - CollectionConstructor, - 'base Collection constructor is generic over the runtime contract' - >(CollectionImpl); - return blindCast< - Collection, - 'runtime related collection construction erases model row and state generics' - >( - new Ctor(this.ctx, modelName, { - tableName: options.tableName, - namespaceId: options.namespaceId, - state: options.state, - registry: options.registry ?? this.registry, - includeRefinementMode: options.includeRefinementMode ?? this.includeRefinementMode, - }), - ); + (this.registry.get(modelName) as CollectionConstructor | undefined) ?? + (Collection as unknown as CollectionConstructor); + return new Ctor(this.ctx, modelName, { + tableName: options.tableName, + namespaceId: options.namespaceId, + state: options.state, + registry: + options.registry ?? + (this.registry as ReadonlyMap>), + includeRefinementMode: options.includeRefinementMode ?? this.includeRefinementMode, + }) as unknown as Collection; } #dispatch(): AsyncIterableResult { return dispatchCollectionRows({ - context: this.ctx.context, + contract: this.contract, runtime: this.ctx.runtime, state: this.state, tableName: this.tableName, @@ -2530,10 +2439,7 @@ class CollectionImpl< for (const [namespace, value] of meta.annotations) { next.set(namespace, value); } - return blindCast< - this, - 'annotation cloning preserves the concrete collection subclass runtime type' - >(this.#clone({ annotations: next })); + return this.#clone({ annotations: next }) as this; } /** @@ -2561,76 +2467,3 @@ class CollectionImpl< return meta.annotations.size === 0 ? undefined : meta.annotations; } } - -const collectionInstanceMemberNames = [ - 'ctx', - 'contract', - 'modelName', - 'tableName', - 'namespaceId', - 'state', - 'registry', - 'includeRefinementMode', -] as const; - -/** - * Every member name the collection surface owns: the prototype's methods plus - * the declared instance fields. A contributed aggregate operation may not - * take one of these names — reducers install into the same flat namespace — - * so ORM composition rejects any operation this set contains. - */ -export function reservedCollectionMemberNames(): ReadonlySet { - return new Set([ - ...Object.getOwnPropertyNames(CollectionImpl.prototype), - ...collectionInstanceMemberNames, - ]); -} - -/** - * The public collection surface: the chainable builder and terminal methods - * the class declares, plus one include-scalar reducer per operation the - * contract's emitted aggregate map declares - * ({@link AggregateIncludeReducers}). The reducer set derives from the map — - * chaining preserves it, and a contributed operation surfaces without any - * client change. - */ -export type Collection< - TContract extends Contract, - ModelName extends string, - Row = SimplifyDeep>, - State extends CollectionTypeState = DefaultCollectionTypeState, -> = CollectionImpl & - AggregateIncludeReducers; - -/** - * The constructor face of {@link Collection}: constructing — or subclassing, - * as custom collections registered via `orm({ collections })` do — yields the - * intersection surface, whose reducer members the constructor installs from - * the registry the execution context carries. - */ -interface CollectionSurfaceConstructor { - new < - TContract extends Contract, - ModelName extends string, - Row = SimplifyDeep>, - State extends CollectionTypeState = DefaultCollectionTypeState, - >( - ctx: CollectionContext, - modelName: ModelName, - options: CollectionInit, - ): Collection; -} - -export const Collection = blindCast< - CollectionSurfaceConstructor, - 'the constructor installs one reducer per aggregate operation the registry contributes' ->(CollectionImpl); - -/** - * The class behind {@link Collection}, for package-internal prototype-chain - * checks (`instanceof`) and default construction. The public constructor - * surface carries a single construct signature returning the intersection, - * which heritage clauses require; the raw class keeps the `Function` shape - * those checks need. - */ -export const CollectionBase = CollectionImpl; diff --git a/packages/3-extensions/sql-orm-client/src/exports/index.ts b/packages/3-extensions/sql-orm-client/src/exports/index.ts index e8261172eeee..08ee2eb155f0 100644 --- a/packages/3-extensions/sql-orm-client/src/exports/index.ts +++ b/packages/3-extensions/sql-orm-client/src/exports/index.ts @@ -1,6 +1,7 @@ export { Collection } from '../collection'; export { all, and, not, or } from '../filters'; export { GroupedCollection } from '../grouped-collection'; +export { INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE } from '../internal-temp-table-source'; export { createModelAccessor } from '../model-accessor'; export type { OrmOptions } from '../orm'; export { orm } from '../orm'; diff --git a/packages/3-extensions/sql-orm-client/src/internal-temp-table-source.ts b/packages/3-extensions/sql-orm-client/src/internal-temp-table-source.ts new file mode 100644 index 000000000000..4da42ad49ef6 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/src/internal-temp-table-source.ts @@ -0,0 +1,11 @@ +import type { SelectAst } from '@prisma-next/sql-relational-core/ast'; +import type { ScopeField } from '@prisma-next/sql-relational-core/expression'; + +export const INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE = Symbol.for( + '@prisma-next/sql-orm-client/internal-temp-table-query-source', +); + +export type InternalTempTableQuerySource> = { + buildAst(): SelectAst; + getRowFields(): Row; +}; diff --git a/packages/3-extensions/sql-orm-client/test/collection.as-subquery.test.ts b/packages/3-extensions/sql-orm-client/test/collection.as-subquery.test.ts new file mode 100644 index 000000000000..979fe3c8b123 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/collection.as-subquery.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE } from '../src/internal-temp-table-source'; +import { createCollection } from './collection-fixtures'; + +describe('Collection internal temp-table query source bridge', () => { + it('returns a select AST and row fields for the selected ORM columns', () => { + const { collection } = createCollection(); + + const subquery = collection.select('id', 'email')[INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE](); + const ast = subquery.buildAst(); + + expect(ast.kind).toBe('select'); + expect(ast.projection.map((item) => item.alias)).toEqual(['id', 'email']); + expect(subquery.getRowFields()).toEqual({ + id: { codecId: 'pg/int4@1', nullable: false }, + email: { codecId: 'pg/text@1', nullable: false }, + }); + }); + + it('uses the collection projection defaults when no select(...) was applied', () => { + const { collection } = createCollection(); + + const subquery = collection[INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE](); + const fields = subquery.getRowFields(); + + expect(fields['id']).toEqual({ codecId: 'pg/int4@1', nullable: false }); + expect(fields['email']).toEqual({ codecId: 'pg/text@1', nullable: false }); + }); +}); diff --git a/packages/3-extensions/sqlite/src/runtime/sqlite.ts b/packages/3-extensions/sqlite/src/runtime/sqlite.ts index 1f7377aa7d01..873d45770fdc 100644 --- a/packages/3-extensions/sqlite/src/runtime/sqlite.ts +++ b/packages/3-extensions/sqlite/src/runtime/sqlite.ts @@ -1,86 +1,149 @@ -import sqliteAdapter from '@internal/adapter-sqlite/runtime'; -import type { Contract } from '@internal/contract/types'; -import type { SqliteBinding } from '@internal/driver-sqlite/runtime'; -import sqliteDriver from '@internal/driver-sqlite/runtime'; -import { instantiateExecutionStack } from '@internal/framework-components/execution'; -import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import { sql as sqlBuilder } from '@internal/sql-builder/runtime'; -import type { Db, RawLane } from '@internal/sql-builder/types'; -import type { ExtractCodecTypes, SqlStorage } from '@internal/sql-contract/types'; -import { orm as ormBuilder } from '@internal/sql-orm-client'; -import type { CodecTypesBase } from '@internal/sql-relational-core/expression'; -import type { SqlQueryPlan } from '@internal/sql-relational-core/plan'; +import sqliteAdapter from '@prisma-next/adapter-sqlite/runtime'; +import { buildNamespacedEnums, type NamespacedEnums } from '@prisma-next/contract/enum-accessor'; +import type { Contract } from '@prisma-next/contract/types'; +import type { SqliteBinding } from '@prisma-next/driver-sqlite/runtime'; +import sqliteDriver from '@prisma-next/driver-sqlite/runtime'; +import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; +import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime'; +import type { + Db, + QueryContext, + Scope, + ScopeField, + SelectQuery, +} from '@prisma-next/sql-builder/types'; +import type { ExtractCodecTypes, SqlStorage } from '@prisma-next/sql-contract/types'; +import { + INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE, + orm as ormBuilder, +} from '@prisma-next/sql-orm-client'; +import { RawSqlExpr, type SelectAst, TableSource } from '@prisma-next/sql-relational-core/ast'; +import type { CodecTypesBase, RawSqlTag } from '@prisma-next/sql-relational-core/expression'; +import { createRawSql } from '@prisma-next/sql-relational-core/expression'; +import { planFromAst, type SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; import type { BindSiteParams, + ConnectionContext, Declaration, ExecutionContext, ParamsFromDeclaration, - PreparedFor, + PreparedStatement, Runtime, SqlExecutionStackWithDriver, SqlMiddleware, + SqlRuntimeAdapterInstance, SqlRuntimeExtensionDescriptor, TransactionContext, VerifyMarkerOption, -} from '@internal/sql-runtime'; +} from '@prisma-next/sql-runtime'; import { createExecutionContext, createSqlExecutionStack, + withConnection, withTransaction, -} from '@internal/sql-runtime'; -import sqliteTarget, { - SqliteContractSerializer as SqlContractSerializer, -} from '@internal/target-sqlite/runtime'; -import { assertDefined } from '@internal/utils/assertions'; -import { blindCast, castAs } from '@internal/utils/casts'; -import { ifDefined } from '@internal/utils/defined'; -import { InternalError } from '@internal/utils/internal-error'; -import { sqliteError } from '../errors'; -import { buildSqliteStaticContext, type SqliteStaticContext } from '../static/sqlite-static'; +} from '@prisma-next/sql-runtime'; +import sqliteTarget from '@prisma-next/target-sqlite/runtime'; +import { blindCast, castAs } from '@prisma-next/utils/casts'; +import { ifDefined } from '@prisma-next/utils/defined'; import { resolveOptionalSqliteBinding, resolveSqliteBinding } from './binding'; import { SqliteRuntimeImpl } from './sqlite-runtime'; export type SqliteTargetId = 'sqlite'; type OrmClient> = ReturnType>; +export interface TempTableColumnDef { + readonly name: string; + readonly type: string; +} + +type TempTableJoinSource> = ReturnType< + SelectQuery['as'] +>; + +type TempTableQuerySource> = { + buildAst(): SelectAst; + getRowFields(): Row; +}; + +type TempTableSubqueryConvertible> = { + [INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE](): TempTableQuerySource; +}; + +type TempTableAsInput> = + | TempTableQuerySource + | TempTableSubqueryConvertible; + +export interface TempTableHandle< + Row extends Record = Record, +> extends TempTableJoinSource { + readonly name: string; + readonly fields: Row; + append(input: TempTableAppendInput): Promise; + drop(): Promise; + [Symbol.asyncDispose](): Promise; +} + +export type TempTableAppendInput< + Row extends Record = Record, +> = TempTableAsInput | readonly (readonly (string | number | boolean | null)[])[]; + +export interface TempTableBuilder { + as>( + query: TempTableAsInput, + ): Promise>; + from(columns: readonly TempTableColumnDef[]): Promise; +} + type UnboundSql> = Db[typeof UNBOUND_NAMESPACE_ID]; type UnboundOrm> = OrmClient[typeof UNBOUND_NAMESPACE_ID]; +type UnboundEnums> = + NamespacedEnums[typeof UNBOUND_NAMESPACE_ID]; -function unboundOrm>( - orm: OrmClient, -): UnboundOrm { - const value = orm[UNBOUND_NAMESPACE_ID]; - assertDefined(value, 'the unbound namespace always exists on a sqlite builder output'); - return blindCast< - UnboundOrm, - 'OrmClient indexed by a literal key widens NsId to string; Collection is invariant in NsId via row/mutation-input types, so the indexed-access type cannot be proven to match the literal-keyed OrmNamespace without this cast' - >(value); +function unboundNamespace(builderOutput: { readonly [UNBOUND_NAMESPACE_ID]?: unknown }): T { + return blindCast( + builderOutput[UNBOUND_NAMESPACE_ID], + ); } export interface SqliteTransactionContext> extends TransactionContext { readonly sql: UnboundSql; readonly orm: UnboundOrm; - readonly enums: SqliteStaticContext['enums']; + readonly enums: UnboundEnums; + tempTable(): TempTableBuilder; +} + +export interface SqliteConnectionContext> + extends ConnectionContext { + readonly sql: UnboundSql; + readonly orm: UnboundOrm; + readonly enums: UnboundEnums; + tempTable(): TempTableBuilder; } export interface SqliteClient> { readonly sql: UnboundSql; readonly orm: UnboundOrm; - readonly enums: SqliteStaticContext['enums']; - readonly raw: RawLane; + readonly enums: UnboundEnums; + readonly raw: RawSqlTag; readonly context: ExecutionContext; - readonly contract: TContract; readonly stack: SqlExecutionStackWithDriver; connect(bindingInput?: { readonly path: string }): Promise; runtime(): Runtime; - prepare, Row, CT extends CodecTypesBase = ExtractCodecTypes>( + prepare< + D extends Declaration, + Row, + CT extends CodecTypesBase = ExtractCodecTypes & CodecTypesBase, + >( declaration: D, callback: (sql: UnboundSql, params: BindSiteParams) => SqlQueryPlan, - ): Promise, Row>>; + ): Promise, Row>>; transaction(fn: (tx: SqliteTransactionContext) => PromiseLike): Promise; + connection(fn: (conn: SqliteConnectionContext) => PromiseLike): Promise; close(): Promise; [Symbol.asyncDispose](): Promise; } @@ -113,12 +176,224 @@ export type SqliteOptions> = function resolveContract>( options: SqliteOptions, ): TContract { - const serializer = new SqlContractSerializer(); - if ('contractJson' in options && options.contractJson !== undefined) { - return serializer.deserializeContract(options.contractJson) as TContract; + const contractInput = + 'contractJson' in options && options.contractJson !== undefined + ? options.contractJson + : (options as SqliteOptionsWithContract).contract; + return new SqlContractSerializer().deserializeContract(contractInput) as TContract; +} + +function quoteIdentifier(name: string): string { + return `"${name.replaceAll('"', '""')}"`; +} + +function toSqlLiteral(value: string | number | boolean | null): string { + if (value === null) return 'NULL'; + if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'; + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(`Cannot use non-finite number as SQL literal: ${value}`); + } + return String(value); } - const contract = (options as SqliteOptionsWithContract).contract; - return serializer.deserializeContract(serializer.serializeContract(contract)) as TContract; + return `'${value.replaceAll("'", "''")}'`; +} + +function resolveTempTableName(): string { + const suffix = crypto.randomUUID().replaceAll('-', '').slice(0, 20); + return `pn_temp_${suffix}`; +} + +function createTempTableBuilder( + execCtx: Pick, + registerCleanupHook: (hook: () => Promise) => void, + contract: Contract, + adapter: SqlRuntimeAdapterInstance, +): TempTableBuilder { + const normalizeQuerySource = >( + query: TempTableAsInput, + ): TempTableQuerySource => { + if ('buildAst' in query && 'getRowFields' in query) { + return query; + } + return query[INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE](); + }; + + const asJoinSource = >( + tableName: string, + alias: string, + rowFields: Row, + ): TempTableJoinSource => { + const source = { + getJoinOuterScope: () => ({ + topLevel: rowFields, + namespaces: { [alias]: rowFields } as Record, + }), + buildAst: () => TableSource.named(tableName, alias), + }; + return blindCast, 'source implements TempTableJoinSource duck-type'>( + source, + ); + }; + + const createAppend = + (quotedName: string) => + async (input: TempTableAppendInput>): Promise => { + if (Array.isArray(input)) { + const rows = blindCast< + readonly (readonly (string | number | boolean | null)[])[], + 'Array.isArray true — input is a raw rows array' + >(input); + if (rows.length === 0) return; + const valueRows = rows.map((row) => `(${row.map(toSqlLiteral).join(', ')})`).join(', '); + const insertSql = `INSERT INTO ${quotedName} VALUES ${valueRows}`; + const insertAst = RawSqlExpr.of([insertSql], []); + const insertQueryPlan = planFromAst(insertAst, contract, 'raw.temp-table'); + await execCtx + .execute( + Object.freeze({ + sql: insertAst.fragments[0] ?? '', + params: [] as unknown[], + ast: insertAst, + meta: insertQueryPlan.meta, + }), + ) + .toArray(); + } else { + const source = normalizeQuerySource( + blindCast< + TempTableAsInput>, + 'Array.isArray false — input is a query source' + >(input), + ); + const queryPlan = planFromAst(source.buildAst(), contract, 'dsl'); + const lowered = adapter.lower(queryPlan.ast, { contract, params: queryPlan.params }); + const params = lowered.params.map((slot) => { + if (slot.kind === 'literal') return slot.value; + throw new Error('tempTable.append(...) does not accept bind-site parameters.'); + }); + const insertSql = `INSERT INTO ${quotedName} ${lowered.sql}`; + const insertAst = RawSqlExpr.of([insertSql], []); + const insertQueryPlan = planFromAst(insertAst, contract, 'raw.temp-table'); + await execCtx + .execute( + Object.freeze({ + sql: insertAst.fragments[0] ?? '', + params, + ast: insertAst, + meta: insertQueryPlan.meta, + }), + ) + .toArray(); + } + }; + + return { + async as>( + query: TempTableAsInput, + ): Promise> { + const source = normalizeQuerySource(query); + const tableName = resolveTempTableName(); + const quotedTableName = quoteIdentifier(tableName); + const queryPlan = planFromAst(source.buildAst(), contract, 'dsl'); + const lowered = adapter.lower(queryPlan.ast, { + contract, + params: queryPlan.params, + }); + const params = lowered.params.map((slot) => { + if (slot.kind === 'literal') return slot.value; + throw new Error('tempTable.as(...) does not accept bind-site parameters.'); + }); + + const createAst = RawSqlExpr.of( + [`CREATE TEMP TABLE ${quotedTableName} AS ${lowered.sql}`], + [], + ); + const createQueryPlan = planFromAst(createAst, contract, 'raw.temp-table'); + const createPlan = Object.freeze({ + sql: createAst.fragments[0] ?? '', + params, + ast: createAst, + meta: createQueryPlan.meta, + }); + await execCtx.execute(createPlan).toArray(); + + const dropPlan = Object.freeze({ + sql: `DROP TABLE IF EXISTS ${quotedTableName}`, + params: [], + ast: queryPlan.ast, + meta: queryPlan.meta, + }); + let dropped = false; + const drop = async (): Promise => { + if (dropped) return; + dropped = true; + await execCtx.execute(dropPlan).toArray(); + }; + registerCleanupHook(drop); + + const rowFields = blindCast generic'>( + source.getRowFields(), + ); + const defaultJoin = asJoinSource(tableName, tableName, rowFields); + + return blindCast< + TempTableHandle, + 'temp table handle created from Subquery preserves the same row field shape' + >({ + ...defaultJoin, + name: tableName, + fields: rowFields, + append: createAppend(quotedTableName), + drop, + [Symbol.asyncDispose]: drop, + }); + }, + + async from(columns: readonly TempTableColumnDef[]): Promise { + const tableName = resolveTempTableName(); + const quotedTableName = quoteIdentifier(tableName); + + const colDefs = columns.map((c) => `${quoteIdentifier(c.name)} ${c.type}`).join(', '); + const createSql = `CREATE TEMP TABLE ${quotedTableName} (${colDefs})`; + const createAst = RawSqlExpr.of([createSql], []); + const createQueryPlan = planFromAst(createAst, contract, 'raw.temp-table'); + const createPlan = Object.freeze({ + sql: createAst.fragments[0] ?? '', + params: [] as unknown[], + ast: createAst, + meta: createQueryPlan.meta, + }); + await execCtx.execute(createPlan).toArray(); + + const dropAst = RawSqlExpr.of([`DROP TABLE IF EXISTS ${quotedTableName}`], []); + const dropQueryPlan = planFromAst(dropAst, contract, 'raw.temp-table'); + const dropPlan = Object.freeze({ + sql: dropAst.fragments[0] ?? '', + params: [] as unknown[], + ast: dropAst, + meta: dropQueryPlan.meta, + }); + let dropped = false; + const drop = async (): Promise => { + if (dropped) return; + dropped = true; + await execCtx.execute(dropPlan).toArray(); + }; + registerCleanupHook(drop); + + const emptyFields = {} as Record; + const defaultJoin = asJoinSource(tableName, tableName, emptyFields); + return blindCast({ + ...defaultJoin, + name: tableName, + fields: emptyFields, + append: createAppend(quotedTableName), + drop, + [Symbol.asyncDispose]: drop, + }); + }, + }; } export default function sqlite>( @@ -132,28 +407,28 @@ export default function sqlite>( ): SqliteClient { const contract = resolveContract(options); let binding = resolveOptionalSqliteBinding(options); - const stack = createSqlExecutionStack({ target: sqliteTarget, adapter: sqliteAdapter, driver: sqliteDriver, - extensions: options.extensions ?? [], + extensionPacks: options.extensions ?? [], }); + const stackInstance = instantiateExecutionStack(stack); - const context = createExecutionContext({ + const context = createExecutionContext({ contract, stack, - driver: sqliteDriver, }); - const { - sql, - raw: rawSqlTag, - enums, - }: SqliteStaticContext = buildSqliteStaticContext( - context, - stack.adapter.rawCodecInferer, - ); + const rawCodecInferer = stack.adapter.rawCodecInferer; + const rawSqlTag: RawSqlTag = createRawSql(rawCodecInferer); + + const sql: UnboundSql = unboundNamespace( + sqlBuilder({ context, rawCodecInferer }), + ); + const enums: UnboundEnums = unboundNamespace( + Object.freeze(buildNamespacedEnums(contract.domain)), + ); let runtimeInstance: Runtime | undefined; let runtimeDriver: { connect(binding: unknown): Promise } | undefined; let driverConnected = false; @@ -165,7 +440,7 @@ export default function sqlite>( const connectDriver = async (resolvedBinding: SqliteBinding): Promise => { if (driverConnected) return; - if (!runtimeDriver) throw new InternalError('SQLite runtime driver missing'); + if (!runtimeDriver) throw new Error('SQLite runtime driver missing'); if (connectPromise) return connectPromise; connectPromise = runtimeDriver .connect(resolvedBinding) @@ -182,9 +457,7 @@ export default function sqlite>( const getRuntime = (): Runtime => { if (closed) { - throw sqliteError('DRIVER.NOT_CONNECTED', 'SQLite client is closed', { - meta: { extension: 'sqlite' }, - }); + throw new Error('SQLite client is closed'); } if (backgroundConnectError !== undefined) { @@ -195,10 +468,9 @@ export default function sqlite>( return runtimeInstance; } - const stackInstance = instantiateExecutionStack(stack); const driverDescriptor = stack.driver; if (!driverDescriptor) { - throw new InternalError('Driver descriptor missing from execution stack'); + throw new Error('Driver descriptor missing from execution stack'); } const driver = driverDescriptor.create(); @@ -219,13 +491,10 @@ export default function sqlite>( return runtimeInstance; }; - const orm: UnboundOrm = unboundOrm( + const orm: UnboundOrm = unboundNamespace( ormBuilder({ context, runtime: { - query(plan) { - return getRuntime().query(plan); - }, execute(plan) { return getRuntime().execute(plan); }, @@ -242,19 +511,14 @@ export default function sqlite>( enums, raw: rawSqlTag, context, - contract, stack, async connect(bindingInput) { if (closed) { - throw sqliteError('DRIVER.NOT_CONNECTED', 'SQLite client is closed', { - meta: { extension: 'sqlite' }, - }); + throw new Error('SQLite client is closed'); } if (driverConnected || connectPromise) { - throw sqliteError('DRIVER.ALREADY_CONNECTED', 'SQLite client already connected', { - meta: { extension: 'sqlite' }, - }); + throw new Error('SQLite client already connected'); } backgroundConnectError = undefined; @@ -264,10 +528,8 @@ export default function sqlite>( } if (binding === undefined) { - throw sqliteError( - 'RUNTIME.BINDING_MISSING', + throw new Error( 'SQLite binding not configured. Pass path to sqlite(...) or call db.connect({ path }).', - { meta: { extension: 'sqlite' } }, ); } @@ -285,11 +547,11 @@ export default function sqlite>( prepare< D extends Declaration, Row, - CT extends CodecTypesBase = ExtractCodecTypes, + CT extends CodecTypesBase = ExtractCodecTypes & CodecTypesBase, >( declaration: D, callback: (sql: UnboundSql, params: BindSiteParams) => SqlQueryPlan, - ): Promise, Row>> { + ): Promise, Row>> { return getRuntime().prepare(declaration, (params) => callback(sql, params)); }, @@ -301,25 +563,16 @@ export default function sqlite>( return Promise.reject(err); } return withTransaction(runtime, (txCtx) => { - const rawCodecInferer = stack.adapter.rawCodecInferer; - const txSqlNamespace = sqlBuilder({ context, rawCodecInferer })[ - UNBOUND_NAMESPACE_ID - ]; - assertDefined( - txSqlNamespace, - 'the unbound namespace always exists on a sqlite builder output', + const txSql: UnboundSql = unboundNamespace( + sqlBuilder({ + context, + rawCodecInferer, + }), ); - const txSql: UnboundSql = blindCast< - UnboundSql, - 'Db indexed by a literal key widens NsId to string; TableProxy is invariant in NsId via insert()/update() parameter positions, so the indexed-access type cannot be proven to match the literal-keyed Namespace without this cast' - >(txSqlNamespace); - const txOrm: UnboundOrm = unboundOrm( + const txOrm: UnboundOrm = unboundNamespace( ormBuilder({ runtime: { - query(plan) { - return txCtx.query(plan); - }, execute(plan) { return txCtx.execute(plan); }, @@ -334,13 +587,70 @@ export default function sqlite>( // Spreading would evaluate the getter once and freeze its value. const tx: SqliteTransactionContext = Object.assign( castAs(Object.create(txCtx)), - { sql: txSql, orm: txOrm, enums }, + { + sql: txSql, + orm: txOrm, + enums, + tempTable(): TempTableBuilder { + return createTempTableBuilder( + txCtx, + (hook) => txCtx.registerPreCommitHook(hook), + context.contract, + stackInstance.adapter, + ); + }, + }, ); return fn(tx); }); }, + connection(fn: (conn: SqliteConnectionContext) => PromiseLike): Promise { + try { + return withConnection(getRuntime(), (connCtx) => { + const connSql: UnboundSql = unboundNamespace( + sqlBuilder({ + context, + rawCodecInferer, + }), + ); + + const connOrm: UnboundOrm = unboundNamespace( + ormBuilder({ + runtime: { + execute(plan) { + return connCtx.execute(plan); + }, + }, + context, + }), + ); + + const conn: SqliteConnectionContext = Object.assign( + castAs(Object.create(connCtx)), + { + sql: connSql, + orm: connOrm, + enums, + tempTable(): TempTableBuilder { + return createTempTableBuilder( + connCtx, + (hook) => connCtx.registerReleaseHook(hook), + context.contract, + stackInstance.adapter, + ); + }, + }, + ); + + return fn(conn); + }); + } catch (err) { + return Promise.reject(err); + } + }, + close(): Promise { if (closePromise) return closePromise; closed = true; diff --git a/packages/3-extensions/sqlite/test/transaction.test.ts b/packages/3-extensions/sqlite/test/transaction.test.ts index 40d9ad29afcb..ea87afd2b6ce 100644 --- a/packages/3-extensions/sqlite/test/transaction.test.ts +++ b/packages/3-extensions/sqlite/test/transaction.test.ts @@ -1,30 +1,31 @@ -import type { Contract } from '@internal/contract/types'; -import { coreHash, profileHash } from '@internal/contract/types'; -import { SqlStorage } from '@internal/sql-contract/types'; -import { sqliteCreateNamespace } from '@internal/target-sqlite/control'; -import { applicationDomainOf } from '@repo/test-utils'; +import type { ScopeField, Subquery } from '@prisma-next/sql-builder/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { + ProjectionItem, + RawSqlExpr, + SelectAst, + TableSource, +} from '@prisma-next/sql-relational-core/ast'; +import { planFromAst } from '@prisma-next/sql-relational-core/plan'; +import { createContract } from '@prisma-next/test-utils'; +import { blindCast } from '@prisma-next/utils/casts'; import { describe, expect, it } from 'vitest'; // No third-party mocks needed: node:sqlite (built-in) drives the real driver. import sqlite from '../src/runtime/sqlite'; -const contract: Contract = { - target: 'sqlite', - targetFamily: 'sql', - profileHash: profileHash('sqlite-transaction-test'), - domain: applicationDomainOf({ models: {} }), - roots: {}, - storage: new SqlStorage({ - storageHash: coreHash('sqlite-transaction-test'), - namespaces: { - __unbound__: sqliteCreateNamespace({ id: '__unbound__', entries: { table: {} } }), - }, - }), - extensions: {}, - capabilities: {}, - meta: {}, -}; +const contract = createContract({ target: 'sqlite' }); + +function rawExecPlan(sql: string) { + const ast = RawSqlExpr.of([sql], []); + return Object.freeze({ + sql, + params: [] as unknown[], + ast, + meta: planFromAst(ast, contract, 'raw.temp-table').meta, + }); +} describe('sqlite transaction()', () => { it('transaction() runs the callback and returns its result', async () => { @@ -65,6 +66,65 @@ describe('sqlite transaction()', () => { await db.close(); }); + it('transaction tempTable() creates and drops a typed temp table with generated name', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + const subquery = blindCast< + Subquery<{ id: ScopeField }>, + 'test fixture for temp-table typed subquery' + >({ + buildAst: () => + SelectAst.from(TableSource.named('sqlite_master')).withProjection([ + ProjectionItem.of('id', db.raw`1`.returns('sqlite/integer@1').buildAst()), + ]), + getRowFields: () => ({ id: { codecId: 'sqlite/integer@1', nullable: false } }), + }); + + await db.transaction(async (tx) => { + const temp = await tx.tempTable().as(subquery); + expect(temp.name).toMatch(/^pn_temp_[a-f0-9]+$/); + expect(temp.fields['id']?.codecId).toBe('sqlite/integer@1'); + expect('buildAst' in temp).toBe(true); + expect('getJoinOuterScope' in temp).toBe(true); + await temp.drop(); + }); + + await db.close(); + }); + + it('transaction tempTable() uses an internal table name', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + const subquery = blindCast< + Subquery<{ id: ScopeField; email: ScopeField }>, + 'test fixture for temp-table typed subquery' + >({ + buildAst: () => + SelectAst.from(TableSource.named('sqlite_master')).withProjection([ + ProjectionItem.of('id', db.raw`1`.returns('sqlite/integer@1').buildAst()), + ProjectionItem.of('email', db.raw`'x@example.com'`.returns('sqlite/text@1').buildAst()), + ]), + getRowFields: () => ({ + id: { codecId: 'sqlite/integer@1', nullable: false }, + email: { codecId: 'sqlite/text@1', nullable: false }, + }), + }); + + await db.transaction(async (tx) => { + const temp = await tx.tempTable().as(subquery); + expect(temp.name).toMatch(/^pn_temp_[a-f0-9]+$/); + expect(temp.fields).toEqual({ + id: { codecId: 'sqlite/integer@1', nullable: false }, + email: { codecId: 'sqlite/text@1', nullable: false }, + }); + await temp.drop(); + }); + + await db.close(); + }); + it('transaction() lazily creates runtime on first use', async () => { const db = sqlite({ contract, path: ':memory:' }); await db.connect({ path: ':memory:' }); @@ -81,4 +141,260 @@ describe('sqlite transaction()', () => { await expect(db.transaction(async () => 'value')).rejects.toThrow('SQLite client is closed'); }); + + it('transaction tempTable().from() creates a table with explicit column types', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + await db.transaction(async (tx) => { + const handle = await tx.tempTable().from([ + { name: 'id', type: 'INTEGER' }, + { name: 'label', type: 'TEXT' }, + ]); + expect(handle.name).toMatch(/^pn_temp_[a-f0-9]+$/); + expect(typeof handle.drop).toBe('function'); + expect(typeof handle[Symbol.asyncDispose]).toBe('function'); + }); + + await db.close(); + }); + + it('transaction tempTable().from() inserts provided rows and table is queryable', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + await db.transaction(async (tx) => { + const handle = await tx.tempTable().from([ + { name: 'id', type: 'INTEGER' }, + { name: 'name', type: 'TEXT' }, + ]); + await handle.append([ + ['1', 'Alice'], + ['2', 'Bob'], + [null, 'Charlie'], + ]); + + const result = await tx + .execute(rawExecPlan(`SELECT COUNT(*) AS cnt FROM "${handle.name}"`)) + .toArray(); + expect(result).toHaveLength(1); + const row = result[0] as { cnt: unknown }; + expect(Number(row.cnt)).toBe(3); + }); + + await db.close(); + }); + + it('transaction tempTable().from() with auto-generated name has pn_temp_ prefix', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + await db.transaction(async (tx) => { + const handle = await tx.tempTable().from([{ name: 'val', type: 'TEXT' }]); + expect(handle.name).toMatch(/^pn_temp_[a-f0-9]+$/); + }); + + await db.close(); + }); + + it('transaction tempTable().from() escapes single-quote strings safely', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + await db.transaction(async (tx) => { + const handle = await tx.tempTable().from([{ name: 'note', type: 'TEXT' }]); + await handle.append([["it's fine"], ["O'Brien"]]); + + const result = await tx + .execute(rawExecPlan(`SELECT COUNT(*) AS cnt FROM "${handle.name}"`)) + .toArray(); + expect(Number((result[0] as { cnt: unknown }).cnt)).toBe(2); + await handle.drop(); + }); + + await db.close(); + }); + + it('tempTable().from() handle supports append() with raw rows', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + await db.transaction(async (tx) => { + const handle = await tx.tempTable().from([ + { name: 'id', type: 'INTEGER' }, + { name: 'val', type: 'TEXT' }, + ]); + await handle.append([['1', 'alpha']]); + + await handle.append([ + ['2', 'beta'], + ['3', 'gamma'], + ]); + + const result = await tx + .execute(rawExecPlan(`SELECT COUNT(*) AS cnt FROM "${handle.name}"`)) + .toArray(); + expect(Number((result[0] as { cnt: unknown }).cnt)).toBe(3); + }); + + await db.close(); + }); + + it('tempTable().as() handle supports append() with a subquery', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + await db.transaction(async (tx) => { + // Seed a temp table with one row so the subquery returns data + const seed = await tx.tempTable().from([{ name: 'id', type: 'INTEGER' }]); + await seed.append([['1']]); + + const seedSubquery = blindCast, 'test fixture'>({ + buildAst: () => + SelectAst.from(TableSource.named(seed.name)).withProjection([ + ProjectionItem.of('id', db.raw`id`.returns('sqlite/integer@1').buildAst()), + ]), + getRowFields: () => ({ id: { codecId: 'sqlite/integer@1', nullable: false } }), + }); + + const handle = await tx.tempTable().as(seedSubquery); + await handle.append(seedSubquery); + + const result = await tx + .execute(rawExecPlan(`SELECT COUNT(*) AS cnt FROM "${handle.name}"`)) + .toArray(); + expect(Number((result[0] as { cnt: unknown }).cnt)).toBe(2); + }); + + await db.close(); + }); + + it('tempTable().append() with empty rows is a no-op', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + await db.transaction(async (tx) => { + const handle = await tx.tempTable().from([{ name: 'x', type: 'INTEGER' }]); + await handle.append([['42']]); + + await handle.append([]); + + const result = await tx + .execute(rawExecPlan(`SELECT COUNT(*) AS cnt FROM "${handle.name}"`)) + .toArray(); + expect(Number((result[0] as { cnt: unknown }).cnt)).toBe(1); + }); + + await db.close(); + }); +}); + +describe('sqlite connection()', () => { + it('connection() runs the callback and returns its result', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + const result = await db.connection(async () => 'conn-value'); + + expect(result).toBe('conn-value'); + await db.close(); + }); + + it('connection() provides sql, orm, enums on the connection context', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + let received: { sql?: unknown; orm?: unknown; enums?: unknown } | undefined; + await db.connection(async (conn) => { + received = conn; + }); + + expect(received).toBeDefined(); + expect(received!.sql).toBeDefined(); + expect(received!.orm).toBeDefined(); + expect(received!.enums).toBeDefined(); + await db.close(); + }); + + it('connection tempTable() creates a typed temp table and cleanup hook runs on release', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + const subquery = blindCast< + Subquery<{ id: ScopeField }>, + 'test fixture for temp-table typed subquery' + >({ + buildAst: () => + SelectAst.from(TableSource.named('sqlite_master')).withProjection([ + ProjectionItem.of('id', db.raw`1`.returns('sqlite/integer@1').buildAst()), + ]), + getRowFields: () => ({ id: { codecId: 'sqlite/integer@1', nullable: false } }), + }); + + let tempTableName: string | undefined; + await db.connection(async (conn) => { + const temp = await conn.tempTable().as(subquery); + tempTableName = temp.name; + expect(temp.name).toMatch(/^pn_temp_[a-f0-9]+$/); + expect(temp.fields['id']?.codecId).toBe('sqlite/integer@1'); + + // Table is accessible on this connection (query executes without error) + const rows = await conn.execute(rawExecPlan(`SELECT * FROM ${temp.name}`)).toArray(); + expect(Array.isArray(rows)).toBe(true); + }); + + expect(tempTableName).toBeDefined(); + await db.close(); + }); + + it('connection tempTable() cleanup hook drops the table before release', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + const subquery = blindCast< + Subquery<{ id: ScopeField }>, + 'test fixture for cleanup hook subquery' + >({ + buildAst: () => + SelectAst.from(TableSource.named('sqlite_master')).withProjection([ + ProjectionItem.of('id', db.raw`1`.returns('sqlite/integer@1').buildAst()), + ]), + getRowFields: () => ({ id: { codecId: 'sqlite/integer@1', nullable: false } }), + }); + + let droppedName: string | undefined; + await db.connection(async (conn) => { + const temp = await conn.tempTable().as(subquery); + droppedName = temp.name; + }); + + expect(droppedName).toMatch(/^pn_temp_[a-f0-9]+$/); + + // After release, temp table must not be visible on a fresh connection + await db.connection(async (conn) => { + const result = await conn + .execute( + rawExecPlan( + `SELECT name FROM sqlite_master WHERE type='table' AND name='${droppedName}'`, + ), + ) + .toArray(); + expect(result).toHaveLength(0); + }); + + await db.close(); + }); + + it('connection() destroys the connection on callback error', async () => { + const db = sqlite({ contract, path: ':memory:' }); + await db.connect({ path: ':memory:' }); + + await expect( + db.connection(async () => { + throw new Error('callback-error'); + }), + ).rejects.toThrow('callback-error'); + + await db.close(); + }); }); diff --git a/packages/3-extensions/sqlite/test/transaction.types.test-d.ts b/packages/3-extensions/sqlite/test/transaction.types.test-d.ts index b83e4cf25884..d9dd50286bd4 100644 --- a/packages/3-extensions/sqlite/test/transaction.types.test-d.ts +++ b/packages/3-extensions/sqlite/test/transaction.types.test-d.ts @@ -1,7 +1,14 @@ -import type { Contract } from '@internal/contract/types'; -import type { SqlStorage } from '@internal/sql-contract/types'; +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE } from '@prisma-next/sql-orm-client'; import { expectTypeOf, test } from 'vitest'; -import type { SqliteClient, SqliteTransactionContext } from '../src/runtime/sqlite'; +import type { + SqliteClient, + SqliteConnectionContext, + SqliteTransactionContext, + TempTableAppendInput, + TempTableColumnDef, +} from '../src/runtime/sqlite'; type TestContract = Contract; @@ -33,3 +40,122 @@ test('tx.orm has the same type as db.orm', () => { type TxOrm = SqliteTransactionContext['orm']; expectTypeOf().toEqualTypeOf(); }); + +test('transaction context exposes tempTable()', () => { + type HasTempTable = 'tempTable' extends keyof SqliteTransactionContext + ? true + : false; + expectTypeOf().toEqualTypeOf(); +}); + +test('transaction tempTable() accepts no arguments', () => { + type Params = Parameters['tempTable']>; + expectTypeOf().toEqualTypeOf<[]>(); +}); + +test('tempTable().as returns a metadata-rich handle', () => { + type Builder = ReturnType['tempTable']>; + type HandlePromise = ReturnType; + expectTypeOf>().toMatchTypeOf<{ + name: string; + fields: Record; + drop(): Promise; + [Symbol.asyncDispose](): Promise; + }>(); +}); + +test('tempTable().as accepts internally-convertible ORM-like inputs', () => { + type Builder = ReturnType['tempTable']>; + type AsInput = Parameters[0]; + type Convertible = { + [INTERNAL_TO_TEMP_TABLE_QUERY_SOURCE](): { + buildAst(): never; + getRowFields(): Record; + }; + }; + + type AcceptsConvertible = Convertible extends AsInput ? true : false; + expectTypeOf().toEqualTypeOf(); +}); + +test('TempTableColumnDef has name and type fields', () => { + expectTypeOf().toMatchTypeOf<{ name: string; type: string }>(); +}); + +test('tempTable().from() accepts only column defs', () => { + type Builder = ReturnType['tempTable']>; + type FromParams = Parameters; + + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toEqualTypeOf<[columns: readonly TempTableColumnDef[]]>(); +}); + +test('tempTable().from() returns a TempTableHandle promise', () => { + type Builder = ReturnType['tempTable']>; + type HandlePromise = ReturnType; + expectTypeOf>().toMatchTypeOf<{ + name: string; + drop(): Promise; + [Symbol.asyncDispose](): Promise; + }>(); +}); + +test('TempTableHandle.append() accepts a typed subquery that matches Row', () => { + type Builder = ReturnType['tempTable']>; + type Handle = Awaited>; + type AppendParam = Parameters[0]; + + expectTypeOf().toMatchTypeOf(); +}); + +test('TempTableAppendInput accepts raw rows', () => { + type RawRows = readonly (readonly (string | number | boolean | null)[])[]; + type IsAccepted = RawRows extends TempTableAppendInput ? true : false; + expectTypeOf().toEqualTypeOf(); +}); + +test('TempTableHandle.append() returns Promise', () => { + type Builder = ReturnType['tempTable']>; + type Handle = Awaited>; + type AppendReturn = ReturnType; + expectTypeOf().toEqualTypeOf>(); +}); + +test('db.connection infers the callback return type correctly', () => { + const db = {} as SqliteClient; + + const numResult = db.connection(async (_conn) => 42); + expectTypeOf(numResult).toEqualTypeOf>(); +}); + +test('connection context exposes tempTable()', () => { + type HasTempTable = 'tempTable' extends keyof SqliteConnectionContext + ? true + : false; + expectTypeOf().toEqualTypeOf(); +}); + +test('connection tempTable() accepts no arguments', () => { + type Params = Parameters['tempTable']>; + expectTypeOf().toEqualTypeOf<[]>(); +}); + +test('connection context exposes sql with same type as db.sql', () => { + type DbSql = SqliteClient['sql']; + type ConnSql = SqliteConnectionContext['sql']; + expectTypeOf().toEqualTypeOf(); +}); + +test('connection context does not expose release or destroy', () => { + type HasRelease = 'release' extends keyof SqliteConnectionContext ? true : false; + type HasDestroy = 'destroy' extends keyof SqliteConnectionContext ? true : false; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +}); + +test('connection context exposes registerReleaseHook', () => { + type HasHook = 'registerReleaseHook' extends keyof SqliteConnectionContext + ? true + : false; + expectTypeOf().toEqualTypeOf(); +}); diff --git a/packages/3-extensions/supabase/src/runtime/supabase-runtime.ts b/packages/3-extensions/supabase/src/runtime/supabase-runtime.ts index f39068a965b9..653296144f91 100644 --- a/packages/3-extensions/supabase/src/runtime/supabase-runtime.ts +++ b/packages/3-extensions/supabase/src/runtime/supabase-runtime.ts @@ -1,44 +1,22 @@ -import type { Contract } from '@internal/contract/types'; -import type { RuntimeExecuteOptions } from '@internal/framework-components/runtime'; -import { AsyncIterableResult } from '@internal/framework-components/runtime'; -import { type PostgresRuntime, PostgresRuntimeImpl } from '@internal/postgres/runtime'; -import type { SqlStorage } from '@internal/sql-contract/types'; -import type { SqlQueryable, SqlStatementStats } from '@internal/sql-relational-core/ast'; -import type { SqlExecutionPlan, SqlQueryPlan } from '@internal/sql-relational-core/plan'; +import type { Contract } from '@prisma-next/contract/types'; +import type { RuntimeExecuteOptions } from '@prisma-next/framework-components/runtime'; +import { AsyncIterableResult } from '@prisma-next/framework-components/runtime'; +import { type PostgresRuntime, PostgresRuntimeImpl } from '@prisma-next/postgres/runtime'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { SqlExecutionPlan, SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; import type { - PreparedExecution, - PreparedExecutionImpl, PreparedStatement, PreparedStatementImpl, RuntimeConnection, RuntimeTransaction, -} from '@internal/sql-runtime'; -import type { - PreparedStatementExecuteTarget, - PreparedStatementQueryTarget, -} from '@internal/sql-runtime/internal/prepared-query'; -import { - preparedStatementExecute, - preparedStatementQuery, -} from '@internal/sql-runtime/internal/prepared-query'; -import { blindCast } from '@internal/utils/casts'; -import type { SupabaseRole } from '../contract/roles'; +} from '@prisma-next/sql-runtime'; +import { blindCast } from '@prisma-next/utils/casts'; -export interface SupabaseRuntime extends PostgresRuntime { - queryWithRole( - plan: SqlExecutionPlan | SqlQueryPlan, - binding: SupabaseRoleBinding, - options?: RuntimeExecuteOptions, - ): AsyncIterableResult; - executeWithRole( - plan: SqlExecutionPlan | SqlQueryPlan, - binding: SupabaseRoleBinding, - options?: RuntimeExecuteOptions, - ): Promise; -} +export interface SupabaseRuntime extends PostgresRuntime {} export interface SupabaseRoleBinding { - readonly role: SupabaseRole; + // TODO(TML-2501): role names move to the Supabase extension contract (roles as first-class IR) when postgres-rls lands. + readonly role: 'anon' | 'authenticated' | 'service_role'; readonly claims?: Record; } @@ -60,118 +38,53 @@ export class SupabaseRuntimeImpl< const conn = await this.acquireRawConnection(); try { - await conn.execute({ - sql: 'SELECT set_config($1, $2, false)', - params: ['role', binding.role], - }); - await conn.execute({ - sql: 'SELECT set_config($1, $2, false)', - params: ['request.jwt.claims', JSON.stringify(binding.claims ?? {})], - }); + await conn.query('SELECT set_config($1, $2, false)', ['role', binding.role]); + await conn.query('SELECT set_config($1, $2, false)', [ + 'request.jwt.claims', + JSON.stringify(binding.claims ?? {}), + ]); } catch (err) { await conn.destroy(err).catch(() => undefined); throw err; } const self = this; + const releaseHooks: Array<() => Promise> = []; - const session: RoleSession & PreparedStatementQueryTarget & PreparedStatementExecuteTarget = { - query( + const session: RoleSession = { + execute( plan: (SqlExecutionPlan | SqlQueryPlan) & { readonly _row?: Row }, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return self.queryAgainstQueryable(plan, conn, { ...options, scope: 'connection' }); - }, - execute( - plan: SqlExecutionPlan | SqlQueryPlan, - options?: RuntimeExecuteOptions, - ): Promise { - return self.executeStatisticsAgainstQueryable(plan, conn, { - ...options, - scope: 'connection', - }); + return self.executeAgainstQueryable(plan, conn, { ...options, scope: 'connection' }); }, - [preparedStatementQuery]( - prepared: PreparedStatement, + + executePrepared( + ps: PreparedStatement, params: Params, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return self.runPreparedQueryAgainstRoleQueryable( - prepared, - params, - conn, - options, - 'connection', - ); - }, - [preparedStatementExecute]( - prepared: PreparedExecution, - params: Params, - options?: RuntimeExecuteOptions, - ): Promise { - return self.runPreparedExecuteAgainstRoleQueryable( - prepared, - params, + return self.executePreparedAgainstQueryable( + blindCast< + PreparedStatementImpl, + 'PreparedStatement is PreparedStatementImpl; the impl class is the only concrete form' + >(ps), + blindCast< + Record, + 'params are structurally Record at runtime' + >(params), conn, - options, - 'connection', + { ...options, scope: 'connection' }, ); }, async transaction(): Promise { const tx = await conn.beginTransaction(); - const roleTransaction: RuntimeTransaction & - PreparedStatementQueryTarget & - PreparedStatementExecuteTarget = { - async commit(): Promise { - await tx.commit(); - }, - async rollback(): Promise { - await tx.rollback(); - }, - query( - plan: (SqlExecutionPlan | SqlQueryPlan) & { readonly _row?: Row }, - options?: RuntimeExecuteOptions, - ): AsyncIterableResult { - return self.queryAgainstQueryable(plan, tx, { ...options, scope: 'transaction' }); - }, - execute( - plan: SqlExecutionPlan | SqlQueryPlan, - options?: RuntimeExecuteOptions, - ): Promise { - return self.executeStatisticsAgainstQueryable(plan, tx, { - ...options, - scope: 'transaction', - }); - }, - [preparedStatementQuery]( - prepared: PreparedStatement, - params: Params, - options?: RuntimeExecuteOptions, - ): AsyncIterableResult { - return self.runPreparedQueryAgainstRoleQueryable( - prepared, - params, - tx, - options, - 'transaction', - ); - }, - [preparedStatementExecute]( - prepared: PreparedExecution, - params: Params, - options?: RuntimeExecuteOptions, - ): Promise { - return self.runPreparedExecuteAgainstRoleQueryable( - prepared, - params, - tx, - options, - 'transaction', - ); - }, - }; - return roleTransaction; + return self.wrapTransaction(tx); + }, + + registerReleaseHook(hook: () => Promise): void { + releaseHooks.push(hook); }, /** @@ -179,8 +92,11 @@ export class SupabaseRuntimeImpl< * If RESET ALL fails, destroys the connection instead — pool-poisoning guarantee. */ async release(): Promise { + for (const hook of releaseHooks) { + await hook(); + } try { - await conn.execute({ sql: 'RESET ALL' }); + await conn.query('RESET ALL'); await conn.release(); } catch (resetError) { await conn.destroy(resetError).catch(() => undefined); @@ -196,10 +112,10 @@ export class SupabaseRuntimeImpl< } /** - * Opens a role session, queries the plan, then releases after the stream drains. + * Opens a role session, executes the plan, then releases after the stream drains. * On mid-stream error, destroys the session instead of releasing. */ - queryWithRole( + executeWithRole( plan: SqlExecutionPlan | SqlQueryPlan, binding: SupabaseRoleBinding, options?: RuntimeExecuteOptions, @@ -210,7 +126,7 @@ export class SupabaseRuntimeImpl< const session = await self.openRoleSession(binding); let errored = false; try { - for await (const row of session.query(plan, options)) { + for await (const row of session.execute(plan, options)) { yield row; } } catch (err) { @@ -226,60 +142,4 @@ export class SupabaseRuntimeImpl< return new AsyncIterableResult(generator()); } - - async executeWithRole( - plan: SqlExecutionPlan | SqlQueryPlan, - binding: SupabaseRoleBinding, - options?: RuntimeExecuteOptions, - ): Promise { - const session = await this.openRoleSession(binding); - try { - const stats = await session.execute(plan, options); - await session.release(); - return stats; - } catch (err) { - await session.destroy(err).catch(() => undefined); - throw err; - } - } - - private runPreparedExecuteAgainstRoleQueryable( - prepared: PreparedExecution, - params: Params, - queryable: SqlQueryable, - options: RuntimeExecuteOptions | undefined, - scope: 'connection' | 'transaction', - ): Promise { - return this.runPreparedExecuteAgainstQueryable( - blindCast< - PreparedExecutionImpl, - 'SQL runtime prepare returns PreparedExecutionImpl instances for statistics plans' - >(prepared), - blindCast, 'Prepared params follow their declared record shape'>( - params, - ), - queryable, - { ...options, scope }, - ); - } - - private runPreparedQueryAgainstRoleQueryable( - prepared: PreparedStatement, - params: Params, - queryable: SqlQueryable, - options: RuntimeExecuteOptions | undefined, - scope: 'connection' | 'transaction', - ): AsyncIterableResult { - return this.runPreparedQueryAgainstQueryable( - blindCast< - PreparedStatementImpl, - 'SQL runtime prepare returns PreparedStatementImpl instances' - >(prepared), - blindCast, 'Prepared params follow their declared record shape'>( - params, - ), - queryable, - { ...options, scope }, - ); - } } diff --git a/projects/temp-tables-in-transactions/spec.md b/projects/temp-tables-in-transactions/spec.md new file mode 100644 index 000000000000..b46840681399 --- /dev/null +++ b/projects/temp-tables-in-transactions/spec.md @@ -0,0 +1,280 @@ +# Typed Temp Tables in Transactions + +## Purpose + +Transaction-scoped temp tables are a well-established SQL performance primitive. The core idea is to materialise an intermediate result set once into a temporary table, then join or query against it multiple times within the same transaction — instead of re-running the same subquery or CTE on every reference. + +### Why temp tables matter + +Without temp tables, repeating a complex filter or derived result set in multiple joins forces the query planner to re-evaluate the same expression for every reference: + +```sql +-- Without temp table: the inner SELECT is executed twice +SELECT u.name FROM users u + JOIN ( SELECT id FROM users WHERE active = true ) active ON active.id = u.id + JOIN posts p ON p.user_id = u.id; +-- Same expensive subquery runs again in every JOIN +``` + +With a temp table, the result is materialised once, indexed by the engine for the transaction lifetime, and reused cheaply: + +```sql +CREATE TEMP TABLE active_users AS SELECT id FROM users WHERE active = true; +-- Now both joins hit the temp table — one scan, not two executions +SELECT u.name FROM users u JOIN active_users a ON a.id = u.id ...; +SELECT u.email FROM users u JOIN active_users a ON a.id = u.id ...; +``` + +Concrete use cases where this matters: + +- **Complex intermediate derivations** — e.g. a scored ranking or partition window that is used in multiple follow-up queries in the same business transaction. +- **Bulk import pipelines** — materialise the incoming data rows once, then run validation queries, conflict detection, and inserts all against the temp table rather than re-parsing the input multiple times. Use `from(columns)` to define the table schema and `append(rows)` to stream in the data: + + ```ts + const staging = await tx.tempTable({ name: 'staging' }).from([ + { name: 'id', type: 'int4' }, + { name: 'email', type: 'text' }, + ]); + await staging.append(importedRows); // load data + await staging.append(moreRows); // add more data if needed + // … run validations, conflict checks, final INSERT … + ``` +- **Multi-step analytics** — e.g. compute an aggregated cohort once and then join it against multiple fact tables in the same transaction. +- **Avoiding CTE re-evaluation** — some query engines do not guarantee CTEs are materialised; a temp table provides the same guarantee unconditionally. + +Temp tables are session/connection-scoped: they are visible for the lifetime of the connection on which they were created and do not leak to other connection-pool members. When a transaction rolls back the temp table is dropped automatically; on commit it persists until explicitly dropped or the connection closes. Explicit `drop()` or `await using` is the recommended cleanup pattern. + +### Why this belongs in the runtime extension + +Users reach for raw SQL or ad-hoc `executeRaw` calls the moment they need a temp table today, because there is no typed surface. This PR adds a first-class, type-safe `tempTable()` API to the transaction context of both the Postgres and SQLite runtime extensions. The typed handle the API returns is a proper join source — composable with the existing SQL builder's `innerJoin`, `leftJoin`, and `FROM` APIs — so the temp table integrates into the full query composition surface rather than forcing a context switch to raw SQL. + +## At a glance + +**Today** there is no typed temp-table API: + +```ts +await db.transaction(async (tx) => { + // Only option: raw SQL, no type propagation, no join-source composability + await tx.execute({ sql: 'CREATE TEMP TABLE t AS SELECT id FROM users WHERE active', params: [] }); +}); +``` + +**After this PR:** + +```ts +await db.transaction(async (tx) => { + // SQL builder source + const source = tx.sql.public.user.select('id', 'email').where((f, fns) => fns.eq(f.active, true)); + await using temp = await tx.tempTable({ name: 'active_users' }).as(source); + + // temp is a typed join source — Row is inferred from the select projection + const rows = await tx.execute( + SelectAst.from(temp.buildAst()).withProjection([...]), + ); + + // Reuse in a JOIN without re-evaluating the original subquery + const joined = await tx.execute( + tx.sql.public.post + .innerJoin(temp, (f, fns) => fns.eq(f['user_id'], f['active_users']!['id'])) + .select('title') + .build(), + ); +}); +``` + +ORM collections are also accepted directly as sources: + +```ts +const source = tx.orm.public.User.select('id', 'email').where({ active: true }); +const temp = await tx.tempTable({ name: 'active_users' }).as(source); +``` + +## User-facing API + +The transaction context exposes: + +```ts +tx.tempTable(options?: string | { name?: string }): TempTableBuilder +``` + +The builder exposes: + +```ts +as(query: TempTableQuerySource): Promise> +from(columns: TempTableColumnDef[]): Promise +``` + +`as(query)` derives the table schema and initial data from a typed subquery; the returned handle is parameterised over the row shape. `from(columns)` creates an empty table with an explicitly specified column list; data is loaded afterwards via `append()`.` + +`TempTableHandle` exposes: + +- **Join-source shape** — the handle is a first-class join source, usable directly in `innerJoin`, `leftJoin`, and `FROM` via `temp.buildAst()`. +- `name: string` — the resolved table name (auto-generated or provided). +- `fields: Row` — the typed field metadata for all columns in the temp table. +- `append(input: TempTableAppendInput): Promise` — append rows to the table after creation. Accepts either a typed query source (same protocol as `as(...)`) or raw scalar rows (`(string | number | boolean | null)[][]`). Raw-row input is not type-checked against `Row`; typed query input is. Passing an empty array is a no-op. +- `drop(): Promise` — explicit cleanup. +- `[Symbol.asyncDispose](): Promise` — `await using` support, mirrors `drop()`. + +### Query sources accepted by `as(...)` + +`as(...)` accepts any value that implements the `TempTableQuerySource` interface — an open protocol that any query DSL (including third-party packages) can implement by exposing `buildAst()` and `getRowFields()`. Both the SQL builder and the ORM collection implement this interface: + +```ts +// SQL builder — implements TempTableQuerySource directly +tx.sql.public.user.select('id', 'email').where((f, fns) => fns.eq(f.active, true)) + +// ORM collection — also implements TempTableQuerySource +tx.orm.public.User.select('id', 'email').where({ active: true }) +``` + +### Subquery source in detail + +Both source kinds share the same underlying shape: they produce a `SELECT` AST and a typed row-field map. The SQL builder query exposes this directly; an ORM collection is normalised to it internally. + +**SQL builder — column projection and filter:** + +```ts +// Postgres (namespaced) +const source = tx.sql.public.user + .select('id', 'email') + .where((f, fns) => fns.eq(f.email, 'alice@example.com')); + +// SQLite (unbound namespace) +const source = tx.sql.users + .select('id', 'email') + .where((f, fns) => fns.eq(f.email, 'alice@example.com')); + +const temp = await tx.tempTable({ name: 'filtered_users' }).as(source); +``` + +**ORM collection — same result, different surface:** + +```ts +// Postgres +const source = tx.orm.public.User.select('id', 'email').where({ email: 'alice@example.com' }); + +// SQLite +const source = tx.orm.User.select('id', 'email').where({ email: 'alice@example.com' }); + +const temp = await tx.tempTable({ name: 'filtered_users' }).as(source); +``` + +**Using the handle as a `FROM` source:** + +The handle's `buildAst()` returns a `TableSource` pointing to the temp table by name. Use it with `SelectAst.from(...)` to compose a typed `SELECT` over the materialised rows: + +```ts +const rows = await tx.execute( + planFromAst( + SelectAst.from(temp.buildAst()).withProjection([ + ProjectionItem.of('id', ColumnRef.of(temp.name, 'id')), + ProjectionItem.of('email', ColumnRef.of(temp.name, 'email')), + ]), + db.context.contract, + 'dsl', + ), +); +// rows: Array<{ id: number; email: string }> +``` + +**Using the handle in a `JOIN`:** + +The handle satisfies the SQL builder's join-source protocol. Pass it directly to `innerJoin` / `leftJoin`; the field proxy gains the temp-table namespace automatically: + +```ts +// Postgres +const joinedRows = await tx.execute( + tx.sql.public.user + .innerJoin(temp, (f, fns) => fns.eq(f['id'], f['filtered_users']!['id'])) + .select('name', 'created_at') + .build(), +); + +// SQLite +const joinedRows = await tx.execute( + tx.sql.users + .innerJoin(temp, (f, fns) => fns.eq(f['id'], f['filtered_users']!['id'])) + .select('name') + .build(), +); +``` + +Both FROM and JOIN can be used with the **same** handle within the same transaction — the source query is not re-evaluated. + +## Name semantics + +- If `name` is omitted, a collision-resistant name is generated: `pn_temp_<20 hex chars>`. +- Validation rules (allowed characters, maximum length) are defined per adapter via `AdapterProfile` so each target can enforce its own constraints. The Postgres adapter rejects names that do not satisfy `[A-Za-z_][A-Za-z0-9_]*` or exceed 63 characters (`NAMEDATALEN` limit); the SQLite adapter applies the same rules for consistency. + +## Behavior + +- Temp tables are session/connection-scoped. They are visible for the lifetime of the connection on which they were created and do not leak to other connection-pool members. Scoping on commit depends on the target — see **Cleanup strategy** below. +- The returned handle can be reused multiple times in the same transaction without re-executing the source query. +- `drop()` removes the temp table explicitly (early cleanup). It is safe to call more than once. +- `Symbol.asyncDispose` enables the `await using` pattern as a safer cleanup alternative to explicit `try/finally` with `drop()`. +- `include(...)` projections are not supported when an ORM collection is used as the source — only scalar column selections are translated. + +## ORM collection as a query source + +ORM collections implement `TempTableQuerySource` directly. The temp table API has no dependency on the ORM or the `Connection` class — it only sees the common `TempTableQuerySource` interface. + +- `select(...)` on the collection limits the columns that appear in the temp table and in `handle.fields`. +- If no `select(...)` is provided, the ORM's default scalar projection is used. +- `include(...)` projections are not supported as a source; only scalar column selections are translated. +- A public, standalone `asSubquery()` API on `Collection` is **out of scope for this PR** and is tracked as a follow-up if a general ORM-to-query-source conversion primitive is needed. + +## Cleanup strategy + +Cleanup on commit is target-specific and is signalled via `AdapterProfile.capabilities.tempTable.onCommitDrop`. + +### Postgres — `ON COMMIT DROP` + +The Postgres adapter sets `capabilities.tempTable.onCommitDrop = true`. The `CREATE TEMP TABLE` statement is emitted with the `ON COMMIT DROP` clause: + +```sql +CREATE TEMP TABLE active_users ON COMMIT DROP AS SELECT id, email FROM users WHERE active = TRUE; +``` + +Postgres drops the table automatically when the transaction commits or rolls back. No pre-commit hook is registered. + +### SQLite — pre-commit `DROP TABLE` + +SQLite has no `ON COMMIT DROP`. The SQLite adapter sets `capabilities.tempTable.onCommitDrop = false`. The `CREATE TEMP TABLE` statement is emitted without the clause: + +```sql +CREATE TEMP TABLE active_users AS SELECT id, email FROM users WHERE active = 1; +``` + +When `as(...)` creates the table it registers a pre-commit hook on the `RuntimeTransaction`: + +```ts +transaction.registerPreCommitHook(async () => { + await driverTx.query(`DROP TABLE IF EXISTS "${tableName}"`); +}); +``` + +`RuntimeTransaction.commit()` drains all registered hooks in order before issuing `COMMIT`: + +```ts +async commit(): Promise { + for (const hook of preCommitHooks) { + await hook(); + } + await driverTx.commit(); +} +``` + +If a hook throws, the commit is aborted and the error propagates; the caller can then call `rollback()`. On `rollback()` hooks are not invoked — SQLite discards the temp table with the transaction automatically. + +`drop()` / `await using` removes the hook's table early; the hook is a no-op if the table is already gone (`DROP TABLE IF EXISTS` is idempotent). + +- No `asRaw(...)` API — raw SQL strings are not accepted as a source; use the SQL builder's `raw\`...\`` tag to compose raw expressions into a structured query first. +- No temp-table alias method on the returned handle — the handle's `name` is the canonical table name and also serves as the default namespace alias in join field proxies. +- No support outside a transaction — the API is intentionally limited to transaction contexts; transactions pin execution to a single connection, which is required for session-scoped temp tables to be reachable across multiple queries. Outside a transaction, different pool members may serve sequential calls, so the temp table would not be visible between them. +- No automatic index creation — index definition on the temp table is not part of this PR. +- No cross-transaction sharing — each transaction gets its own isolated temp-table namespace. + +## Open questions + +1. **Explicit index support.** High-frequency patterns (e.g. joining a temp table on a non-PK column) benefit from a temp index. A follow-up `withIndex(column)` builder step is the natural extension point but is not part of this scope. +2. **Public ORM-to-query-source API.** The `asSubquery()`-style conversion is currently internal. If downstream consumers (e.g. middleware, custom lanes) need the same conversion outside of `tempTable().as(...)`, it should be extracted into a public ORM surface — tracked separately. diff --git a/test/e2e/framework/test/sqlite/transaction.test.ts b/test/e2e/framework/test/sqlite/transaction.test.ts index fd010a3aecce..1711c15159d5 100644 --- a/test/e2e/framework/test/sqlite/transaction.test.ts +++ b/test/e2e/framework/test/sqlite/transaction.test.ts @@ -3,8 +3,10 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; -import sqlite from '@prisma/orm-sqlite/runtime'; -import { timeouts } from '@repo/test-utils'; +import { ColumnRef, ProjectionItem, SelectAst } from '@prisma-next/sql-relational-core/ast'; +import { planFromAst } from '@prisma-next/sql-relational-core/plan'; +import sqlite from '@prisma-next/sqlite/runtime'; +import { timeouts } from '@prisma-next/test-utils'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { Contract } from './fixtures/generated/contract.d'; import { createSchema, seedData } from './utils'; @@ -49,7 +51,12 @@ describe('transaction e2e via sqlite() facade', { timeout: timeouts.databaseOper handle = setupHandle(); // Schema and seed via the raw DatabaseSync handle before connecting the facade. const { rawDb, db } = handle; - createSchema(rawDb, db.contract); + // Deserialize the contract for schema creation (mirrors utils.ts approach). + const { TestSqlContractSerializer } = await import( + '../../../../../packages/2-sql/9-family/test/test-sql-contract-serializer' + ); + const contract = new TestSqlContractSerializer().deserializeContract(contractJson) as Contract; + createSchema(rawDb, contract); seedData(rawDb); // Connect the facade and warm up the runtime before any transactions. // SQLite uses a single connection; contract verification acquires its own @@ -128,13 +135,99 @@ describe('transaction e2e via sqlite() facade', { timeout: timeouts.databaseOper // it so we can consume it after the transaction ends. const escaped = await db.transaction(async (tx) => { await tx.orm.User.create({ id: 400, name: 'EscapeUser', email: 'escape@example.com' }); - // Build a query plan through tx.sql and call tx.query to get an + // Build a query plan through tx.sql and call tx.execute to get an // AsyncIterableResult; do not await it — just capture the reference. - return { rows: tx.query(tx.sql.users.select('id').build()) }; + return { rows: tx.execute(tx.sql.users.select('id').build()) }; }); await expect(escaped.rows.toArray()).rejects.toMatchObject({ code: 'RUNTIME.TRANSACTION_CLOSED', }); }); + + it('tempTable() materializes a tx.sql subquery and is reusable in FROM and JOIN statements in the same transaction', async () => { + const { db } = handle; + + await db.transaction(async (tx) => { + const created = await tx.orm.User.create({ + id: 450, + name: 'TempSource', + email: 'temp-source@example.com', + }); + + const source = tx.sql.users + .select('id', 'email') + .where((f, fns) => fns.eq(f.email, 'temp-source@example.com')); + + const temp = await tx.tempTable().as(source); + const rows = await tx + .execute( + planFromAst( + SelectAst.from(temp.buildAst()).withProjection([ + ProjectionItem.of('id', ColumnRef.of(temp.name, 'id')), + ProjectionItem.of('email', ColumnRef.of(temp.name, 'email')), + ]), + db.context.contract, + 'dsl', + ), + ) + .toArray(); + + const joinedRows = await tx + .execute( + tx.sql.users + .innerJoin(temp, (f, fns) => fns.eq(f['users']!['id'], f[temp.name]!['id'])) + .select('name') + .build(), + ) + .toArray(); + + expect(rows).toEqual([{ id: created.id, email: 'temp-source@example.com' }]); + expect(joinedRows).toEqual([{ name: 'TempSource' }]); + await temp.drop(); + }); + }); + + it('tempTable() accepts ORM collection sources directly and is reusable in FROM and JOIN statements', async () => { + const { db } = handle; + + await db.transaction(async (tx) => { + const created = await tx.orm.User.create({ + id: 451, + name: 'TempOrmSource', + email: 'temp-orm-source@example.com', + }); + + const source = tx.orm.User.select('id', 'email').where({ + email: 'temp-orm-source@example.com', + }); + + const temp = await tx.tempTable().as(source); + const rows = await tx + .execute( + planFromAst( + SelectAst.from(temp.buildAst()).withProjection([ + ProjectionItem.of('id', ColumnRef.of(temp.name, 'id')), + ProjectionItem.of('email', ColumnRef.of(temp.name, 'email')), + ]), + db.context.contract, + 'dsl', + ), + ) + .toArray(); + + const joinedRows = await tx + .execute( + tx.sql.users + .innerJoin(temp, (f, fns) => fns.eq(f['users']!['id'], f[temp.name]!['id'])) + .select('name') + .build(), + ) + .toArray(); + + expect(rows).toEqual([{ id: created.id, email: 'temp-orm-source@example.com' }]); + expect(joinedRows).toEqual([{ name: 'TempOrmSource' }]); + await temp.drop(); + }); + }); }); diff --git a/test/e2e/framework/test/transaction-orm.test.ts b/test/e2e/framework/test/transaction-orm.test.ts index eabffef9bd1d..5ea158cf1d3c 100644 --- a/test/e2e/framework/test/transaction-orm.test.ts +++ b/test/e2e/framework/test/transaction-orm.test.ts @@ -1,12 +1,14 @@ import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import arktypeJson from '@prisma/orm-extension-arktype-json/runtime'; -import pgvector from '@prisma/orm-extension-pgvector/runtime'; -import type { Runtime } from '@prisma/orm-postgres/family-runtime'; -import postgres from '@prisma/orm-postgres/runtime'; -import type { Varchar } from '@prisma/orm-postgres/target/codec-types'; -import { timeouts, withDevDatabase } from '@repo/test-utils'; +import arktypeJson from '@prisma-next/extension-arktype-json/runtime'; +import pgvector from '@prisma-next/extension-pgvector/runtime'; +import postgres from '@prisma-next/postgres/runtime'; +import { ColumnRef, ProjectionItem, SelectAst } from '@prisma-next/sql-relational-core/ast'; +import { planFromAst } from '@prisma-next/sql-relational-core/plan'; +import type { Runtime } from '@prisma-next/sql-runtime'; +import type { Varchar } from '@prisma-next/target-postgres/codec-types'; +import { timeouts, withDevDatabase } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import type { Contract } from './fixtures/generated/contract.d'; import { runDbInit } from './utils'; @@ -169,4 +171,84 @@ describe('transaction ORM integration', { timeout: timeouts.spinUpPpgDev }, () = expect(post).toBeNull(); }); }); + + it('tempTable() materializes a tx.sql subquery and is reusable in FROM and JOIN statements in the same transaction', async () => { + await withPostgresClient(async (db) => { + await db.transaction(async (tx) => { + const email = v('temp-source@example.com'); + const created = await tx.orm.public.User.create({ email }); + + const source = tx.sql.public.user + .select('id', 'email') + .where((f, fns) => fns.eq(f.email, email)); + + const temp = await tx.tempTable().as(source); + + const rows = await tx + .execute( + planFromAst( + SelectAst.from(temp.buildAst()).withProjection([ + ProjectionItem.of('id', ColumnRef.of(temp.name, 'id')), + ProjectionItem.of('email', ColumnRef.of(temp.name, 'email')), + ]), + db.context.contract, + 'dsl', + ), + ) + .toArray(); + + const joinedRows = await tx + .execute( + tx.sql.public.user + .innerJoin(temp, (f, fns) => fns.eq(f['user']!['id'], f[temp.name]!['id'])) + .select('created_at') + .build(), + ) + .toArray(); + + expect(rows).toEqual([{ id: created.id, email: 'temp-source@example.com' }]); + expect(joinedRows).toHaveLength(1); + await temp.drop(); + }); + }); + }); + + it('tempTable() accepts ORM collection sources directly and is reusable in FROM and JOIN statements', async () => { + await withPostgresClient(async (db) => { + await db.transaction(async (tx) => { + const email = v('temp-orm-source@example.com'); + const created = await tx.orm.public.User.create({ email }); + + const source = tx.orm.public.User.select('id', 'email').where({ email }); + + const temp = await tx.tempTable().as(source); + + const rows = await tx + .execute( + planFromAst( + SelectAst.from(temp.buildAst()).withProjection([ + ProjectionItem.of('id', ColumnRef.of(temp.name, 'id')), + ProjectionItem.of('email', ColumnRef.of(temp.name, 'email')), + ]), + db.context.contract, + 'dsl', + ), + ) + .toArray(); + + const joinedRows = await tx + .execute( + tx.sql.public.user + .innerJoin(temp, (f, fns) => fns.eq(f['user']!['id'], f[temp.name]!['id'])) + .select('created_at') + .build(), + ) + .toArray(); + + expect(rows).toEqual([{ id: created.id, email: 'temp-orm-source@example.com' }]); + expect(joinedRows).toHaveLength(1); + await temp.drop(); + }); + }); + }); });