@prisma-next/sql-runtime 0.5.0-dev.1 → 0.5.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,5 @@
1
1
  import type { ExecutionPlan } from '@prisma-next/contract/types';
2
2
  import { runtimeError } from '@prisma-next/framework-components/runtime';
3
- import type { Middleware, MiddlewareContext } from '@prisma-next/runtime-executor';
4
3
  import { evaluateRawGuardrails } from '@prisma-next/runtime-executor';
5
4
  import {
6
5
  type AnyFromSource,
@@ -8,6 +7,7 @@ import {
8
7
  isQueryAst,
9
8
  } from '@prisma-next/sql-relational-core/ast';
10
9
  import { ifDefined } from '@prisma-next/utils/defined';
10
+ import type { SqlMiddleware, SqlMiddlewareContext } from './sql-middleware';
11
11
 
12
12
  export interface LintsOptions {
13
13
  readonly severities?: {
@@ -138,14 +138,14 @@ function getConfiguredSeverity(code: string, options?: LintsOptions): 'warn' | '
138
138
  * Fallback: When ast is missing, `fallbackWhenAstMissing: 'raw'` uses heuristic
139
139
  * SQL parsing; `'skip'` skips all lints. Default is `'raw'`.
140
140
  */
141
- export function lints<TContract = unknown>(options?: LintsOptions): Middleware<TContract> {
141
+ export function lints(options?: LintsOptions): SqlMiddleware {
142
142
  const fallback = options?.fallbackWhenAstMissing ?? 'raw';
143
143
 
144
144
  return Object.freeze({
145
145
  name: 'lints',
146
146
  familyId: 'sql' as const,
147
147
 
148
- async beforeExecute(plan: ExecutionPlan, ctx: MiddlewareContext<TContract>) {
148
+ async beforeExecute(plan: ExecutionPlan, ctx: SqlMiddlewareContext) {
149
149
  if (isQueryAst(plan.ast)) {
150
150
  const findings = evaluateAstLints(plan.ast);
151
151
 
@@ -1,17 +1,46 @@
1
- import type { Contract, ExecutionPlan } from '@prisma-next/contract/types';
1
+ import type { Contract, ExecutionPlan, PlanMeta } from '@prisma-next/contract/types';
2
2
  import type {
3
3
  AfterExecuteResult,
4
4
  RuntimeMiddleware,
5
5
  RuntimeMiddlewareContext,
6
6
  } from '@prisma-next/framework-components/runtime';
7
7
  import type { SqlStorage } from '@prisma-next/sql-contract/types';
8
+ import type { AnyQueryAst } from '@prisma-next/sql-relational-core/ast';
8
9
 
9
10
  export interface SqlMiddlewareContext extends RuntimeMiddlewareContext {
10
11
  readonly contract: Contract<SqlStorage>;
11
12
  }
12
13
 
14
+ /**
15
+ * Pre-lowering query view passed to `beforeCompile`. Carries the typed SQL
16
+ * AST and plan metadata; `sql`/`params` are produced later by the adapter.
17
+ */
18
+ export interface DraftPlan {
19
+ readonly ast: AnyQueryAst;
20
+ readonly meta: PlanMeta;
21
+ }
22
+
13
23
  export interface SqlMiddleware extends RuntimeMiddleware {
14
- readonly familyId: 'sql';
24
+ readonly familyId?: 'sql';
25
+ /**
26
+ * Rewrite the query AST before it is lowered to SQL. Middlewares run in
27
+ * registration order; each sees the predecessor's output, so rewrites
28
+ * compose (e.g. soft-delete + tenant isolation).
29
+ *
30
+ * Return `undefined` (or a draft whose `ast` reference equals the input's)
31
+ * to pass through. Return a draft with a new `ast` reference to replace it;
32
+ * the runtime emits a `middleware.rewrite` debug log event and continues
33
+ * with the new draft. `adapter.lower()` runs once after the chain.
34
+ *
35
+ * Use `AstRewriter` / `SelectAst.withWhere` / `AndExpr.of` etc. to build
36
+ * the rewritten AST. Predicates and literals go through parameterized
37
+ * constructors by default — no SQL-injection surface is added. **Warning:**
38
+ * constructing `LiteralExpr.of(userInput)` from untrusted input bypasses
39
+ * that guarantee; use `ParamRef.of(userInput, ...)` instead.
40
+ *
41
+ * See `docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md`.
42
+ */
43
+ beforeCompile?(draft: DraftPlan, ctx: SqlMiddlewareContext): Promise<DraftPlan | undefined>;
15
44
  beforeExecute?(plan: ExecutionPlan, ctx: SqlMiddlewareContext): Promise<void>;
16
45
  onRow?(
17
46
  row: Record<string, unknown>,
@@ -6,7 +6,6 @@ import type {
6
6
  import { checkMiddlewareCompatibility } from '@prisma-next/framework-components/runtime';
7
7
  import type {
8
8
  Log,
9
- Middleware,
10
9
  RuntimeCore,
11
10
  RuntimeCoreOptions,
12
11
  RuntimeTelemetryEvent,
@@ -33,6 +32,8 @@ import { decodeRow } from './codecs/decoding';
33
32
  import { encodeParams } from './codecs/encoding';
34
33
  import { validateCodecRegistryCompleteness } from './codecs/validation';
35
34
  import { lowerSqlPlan } from './lower-sql-plan';
35
+ import { runBeforeCompileChain } from './middleware/before-compile-chain';
36
+ import type { SqlMiddleware } from './middleware/sql-middleware';
36
37
  import type {
37
38
  ExecutionContext,
38
39
  SqlRuntimeAdapterInstance,
@@ -45,7 +46,7 @@ export interface RuntimeOptions<TContract extends Contract<SqlStorage> = Contrac
45
46
  readonly adapter: Adapter<AnyQueryAst, Contract<SqlStorage>, LoweredStatement>;
46
47
  readonly driver: SqlDriver<unknown>;
47
48
  readonly verify: RuntimeVerifyOptions;
48
- readonly middleware?: readonly Middleware<TContract>[];
49
+ readonly middleware?: readonly SqlMiddleware[];
49
50
  readonly mode?: 'strict' | 'permissive';
50
51
  readonly log?: Log;
51
52
  }
@@ -64,7 +65,7 @@ export interface CreateRuntimeOptions<
64
65
  readonly context: ExecutionContext<TContract>;
65
66
  readonly driver: SqlDriver<unknown>;
66
67
  readonly verify: RuntimeVerifyOptions;
67
- readonly middleware?: readonly Middleware<TContract>[];
68
+ readonly middleware?: readonly SqlMiddleware[];
68
69
  readonly mode?: 'strict' | 'permissive';
69
70
  readonly log?: Log;
70
71
  }
@@ -125,7 +126,7 @@ export type { RuntimeTelemetryEvent, RuntimeVerifyOptions, TelemetryOutcome };
125
126
  class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorage>>
126
127
  implements Runtime
127
128
  {
128
- private readonly core: RuntimeCore<TContract, SqlDriver<unknown>>;
129
+ private readonly core: RuntimeCore<TContract, SqlDriver<unknown>, SqlMiddleware>;
129
130
  private readonly contract: TContract;
130
131
  private readonly adapter: Adapter<AnyQueryAst, Contract<SqlStorage>, LoweredStatement>;
131
132
  private readonly codecRegistry: CodecRegistry;
@@ -148,7 +149,7 @@ class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorag
148
149
 
149
150
  const familyAdapter = new SqlFamilyAdapter(context.contract, adapter.profile);
150
151
 
151
- const coreOptions: RuntimeCoreOptions<TContract, SqlDriver<unknown>> = {
152
+ const coreOptions: RuntimeCoreOptions<TContract, SqlDriver<unknown>, SqlMiddleware> = {
152
153
  familyAdapter,
153
154
  driver,
154
155
  verify,
@@ -172,12 +173,27 @@ class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorag
172
173
  }
173
174
  }
174
175
 
175
- private toExecutionPlan<Row>(plan: ExecutionPlan<Row> | SqlQueryPlan<Row>): ExecutionPlan<Row> {
176
+ private async toExecutionPlan<Row>(
177
+ plan: ExecutionPlan<Row> | SqlQueryPlan<Row>,
178
+ ): Promise<ExecutionPlan<Row>> {
176
179
  const isSqlQueryPlan = (p: ExecutionPlan<Row> | SqlQueryPlan<Row>): p is SqlQueryPlan<Row> => {
177
180
  return 'ast' in p && !('sql' in p);
178
181
  };
179
182
 
180
- return isSqlQueryPlan(plan) ? lowerSqlPlan(this.adapter, this.contract, plan) : plan;
183
+ if (!isSqlQueryPlan(plan)) {
184
+ return plan;
185
+ }
186
+
187
+ const rewrittenDraft = await runBeforeCompileChain(
188
+ this.core.middleware,
189
+ { ast: plan.ast, meta: plan.meta },
190
+ this.core.middlewareContext,
191
+ );
192
+
193
+ const planToLower: SqlQueryPlan<Row> =
194
+ rewrittenDraft.ast === plan.ast ? plan : { ...plan, ast: rewrittenDraft.ast };
195
+
196
+ return lowerSqlPlan(this.adapter, this.contract, planToLower);
181
197
  }
182
198
 
183
199
  private executeAgainstQueryable<Row = Record<string, unknown>>(
@@ -185,11 +201,11 @@ class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorag
185
201
  queryable: CoreQueryable,
186
202
  ): AsyncIterableResult<Row> {
187
203
  this.ensureCodecRegistryValidated(this.contract);
188
- const executablePlan = this.toExecutionPlan(plan);
189
204
 
190
205
  const iterator = async function* (
191
206
  self: SqlRuntimeImpl<TContract>,
192
207
  ): AsyncGenerator<Row, void, unknown> {
208
+ const executablePlan = await self.toExecutionPlan(plan);
193
209
  const encodedParams = encodeParams(executablePlan, self.codecRegistry);
194
210
  const planWithEncodedParams: ExecutionPlan<Row> = {
195
211
  ...executablePlan,
@@ -0,0 +1,223 @@
1
+ import type { Contract, PlanMeta } from '@prisma-next/contract/types';
2
+ import type { SqlStorage } from '@prisma-next/sql-contract/types';
3
+ import {
4
+ AndExpr,
5
+ BinaryExpr,
6
+ ColumnRef,
7
+ LiteralExpr,
8
+ SelectAst,
9
+ TableSource,
10
+ } from '@prisma-next/sql-relational-core/ast';
11
+ import { timeouts } from '@prisma-next/test-utils';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { runBeforeCompileChain } from '../src/middleware/before-compile-chain';
14
+ import type {
15
+ DraftPlan,
16
+ SqlMiddleware,
17
+ SqlMiddlewareContext,
18
+ } from '../src/middleware/sql-middleware';
19
+
20
+ function createContext(): SqlMiddlewareContext & {
21
+ log: { debug: ReturnType<typeof vi.fn> };
22
+ } {
23
+ const debug = vi.fn();
24
+ return {
25
+ contract: {} as Contract<SqlStorage>,
26
+ mode: 'strict' as const,
27
+ now: () => 0,
28
+ log: {
29
+ info: vi.fn(),
30
+ warn: vi.fn(),
31
+ error: vi.fn(),
32
+ debug,
33
+ },
34
+ };
35
+ }
36
+
37
+ const meta: PlanMeta = {
38
+ target: 'postgres',
39
+ storageHash: 'sha256:test',
40
+ lane: 'dsl',
41
+ paramDescriptors: [],
42
+ };
43
+
44
+ function createDraft(): DraftPlan {
45
+ const users = TableSource.named('users');
46
+ return {
47
+ ast: SelectAst.from(users).withProjection([]),
48
+ meta,
49
+ };
50
+ }
51
+
52
+ describe('runBeforeCompileChain', () => {
53
+ it(
54
+ 'returns the initial draft unchanged when no middleware rewrites',
55
+ async () => {
56
+ const draft = createDraft();
57
+ const ctx = createContext();
58
+ const mw: SqlMiddleware = {
59
+ name: 'noop',
60
+ familyId: 'sql',
61
+ async beforeCompile() {
62
+ return undefined;
63
+ },
64
+ };
65
+
66
+ const result = await runBeforeCompileChain([mw], draft, ctx);
67
+
68
+ expect(result).toBe(draft);
69
+ expect(ctx.log.debug).not.toHaveBeenCalled();
70
+ },
71
+ timeouts.default,
72
+ );
73
+
74
+ it(
75
+ 'treats a returned draft with same ast reference as passthrough',
76
+ async () => {
77
+ const draft = createDraft();
78
+ const ctx = createContext();
79
+ const mw: SqlMiddleware = {
80
+ name: 'sameRef',
81
+ familyId: 'sql',
82
+ async beforeCompile(d) {
83
+ return { ...d };
84
+ },
85
+ };
86
+
87
+ const result = await runBeforeCompileChain([mw], draft, ctx);
88
+
89
+ expect(result.ast).toBe(draft.ast);
90
+ expect(ctx.log.debug).not.toHaveBeenCalled();
91
+ },
92
+ timeouts.default,
93
+ );
94
+
95
+ it(
96
+ 'replaces the current draft when a middleware returns a new ast ref',
97
+ async () => {
98
+ const draft = createDraft();
99
+ const ctx = createContext();
100
+ const addWhere = BinaryExpr.eq(ColumnRef.of('users', 'deleted_at'), LiteralExpr.of(null));
101
+ const mw: SqlMiddleware = {
102
+ name: 'softDelete',
103
+ familyId: 'sql',
104
+ async beforeCompile(d) {
105
+ if (d.ast.kind !== 'select') return;
106
+ return { ...d, ast: d.ast.withWhere(addWhere) };
107
+ },
108
+ };
109
+
110
+ const result = await runBeforeCompileChain([mw], draft, ctx);
111
+
112
+ expect(result.ast).not.toBe(draft.ast);
113
+ expect(result.ast.kind).toBe('select');
114
+ expect((result.ast as SelectAst).where).toBe(addWhere);
115
+ },
116
+ timeouts.default,
117
+ );
118
+
119
+ it(
120
+ 'chains rewrites in registration order',
121
+ async () => {
122
+ const draft = createDraft();
123
+ const ctx = createContext();
124
+ const order: string[] = [];
125
+
126
+ const predA = BinaryExpr.eq(ColumnRef.of('users', 'a'), LiteralExpr.of(1));
127
+ const predB = BinaryExpr.eq(ColumnRef.of('users', 'b'), LiteralExpr.of(2));
128
+
129
+ const mwA: SqlMiddleware = {
130
+ name: 'addA',
131
+ familyId: 'sql',
132
+ async beforeCompile(d) {
133
+ order.push('A');
134
+ if (d.ast.kind !== 'select') return;
135
+ return { ...d, ast: d.ast.withWhere(predA) };
136
+ },
137
+ };
138
+ const mwB: SqlMiddleware = {
139
+ name: 'addB',
140
+ familyId: 'sql',
141
+ async beforeCompile(d) {
142
+ order.push('B');
143
+ if (d.ast.kind !== 'select') return;
144
+ const current = d.ast.where;
145
+ const combined = current ? AndExpr.of([current, predB]) : predB;
146
+ return { ...d, ast: d.ast.withWhere(combined) };
147
+ },
148
+ };
149
+
150
+ const result = await runBeforeCompileChain([mwA, mwB], draft, ctx);
151
+
152
+ expect(order).toEqual(['A', 'B']);
153
+ expect(result.ast.kind).toBe('select');
154
+ const where = (result.ast as SelectAst).where;
155
+ expect(where?.kind).toBe('and');
156
+ },
157
+ timeouts.default,
158
+ );
159
+
160
+ it(
161
+ 'emits a debug log event per rewrite with middleware name and lane',
162
+ async () => {
163
+ const draft = createDraft();
164
+ const ctx = createContext();
165
+ const pred = BinaryExpr.eq(ColumnRef.of('users', 'a'), LiteralExpr.of(1));
166
+ const mw: SqlMiddleware = {
167
+ name: 'rewriteOne',
168
+ familyId: 'sql',
169
+ async beforeCompile(d) {
170
+ if (d.ast.kind !== 'select') return;
171
+ return { ...d, ast: d.ast.withWhere(pred) };
172
+ },
173
+ };
174
+
175
+ await runBeforeCompileChain([mw, mw], draft, ctx);
176
+
177
+ expect(ctx.log.debug).toHaveBeenCalledTimes(2);
178
+ expect(ctx.log.debug).toHaveBeenCalledWith({
179
+ event: 'middleware.rewrite',
180
+ middleware: 'rewriteOne',
181
+ lane: 'dsl',
182
+ });
183
+ },
184
+ timeouts.default,
185
+ );
186
+
187
+ it(
188
+ 'skips middleware without beforeCompile',
189
+ async () => {
190
+ const draft = createDraft();
191
+ const ctx = createContext();
192
+ const observerOnly: SqlMiddleware = {
193
+ name: 'observer',
194
+ familyId: 'sql',
195
+ async beforeExecute() {},
196
+ };
197
+
198
+ const result = await runBeforeCompileChain([observerOnly], draft, ctx);
199
+
200
+ expect(result).toBe(draft);
201
+ expect(ctx.log.debug).not.toHaveBeenCalled();
202
+ },
203
+ timeouts.default,
204
+ );
205
+
206
+ it(
207
+ 'propagates errors thrown inside beforeCompile',
208
+ async () => {
209
+ const draft = createDraft();
210
+ const ctx = createContext();
211
+ const mw: SqlMiddleware = {
212
+ name: 'thrower',
213
+ familyId: 'sql',
214
+ async beforeCompile() {
215
+ throw new Error('boom');
216
+ },
217
+ };
218
+
219
+ await expect(runBeforeCompileChain([mw], draft, ctx)).rejects.toThrow('boom');
220
+ },
221
+ timeouts.default,
222
+ );
223
+ });
@@ -1,5 +1,6 @@
1
- import type { ExecutionPlan, PlanMeta } from '@prisma-next/contract/types';
2
- import type { AfterExecuteResult, MiddlewareContext } from '@prisma-next/runtime-executor';
1
+ import type { Contract, ExecutionPlan, PlanMeta } from '@prisma-next/contract/types';
2
+ import type { AfterExecuteResult } from '@prisma-next/runtime-executor';
3
+ import type { SqlStorage } from '@prisma-next/sql-contract/types';
3
4
  import {
4
5
  AggregateExpr,
5
6
  ColumnRef,
@@ -11,15 +12,14 @@ import {
11
12
  import { timeouts } from '@prisma-next/test-utils';
12
13
  import { describe, expect, it, vi } from 'vitest';
13
14
  import { budgets } from '../src/middleware/budgets';
15
+ import type { SqlMiddlewareContext } from '../src/middleware/sql-middleware';
14
16
 
15
17
  const userTable = TableSource.named('user');
16
18
  const idCol = ColumnRef.of('user', 'id');
17
19
 
18
- function createMiddlewareContext(
19
- overrides?: Partial<MiddlewareContext<unknown>>,
20
- ): MiddlewareContext<unknown> {
20
+ function createMiddlewareContext(overrides?: Partial<SqlMiddlewareContext>): SqlMiddlewareContext {
21
21
  return {
22
- contract: {},
22
+ contract: {} as Contract<SqlStorage>,
23
23
  mode: 'strict' as const,
24
24
  now: () => Date.now(),
25
25
  log: {
@@ -1,5 +1,5 @@
1
- import type { ExecutionPlan, PlanMeta } from '@prisma-next/contract/types';
2
- import type { MiddlewareContext } from '@prisma-next/runtime-executor';
1
+ import type { Contract, ExecutionPlan, PlanMeta } from '@prisma-next/contract/types';
2
+ import type { SqlStorage } from '@prisma-next/sql-contract/types';
3
3
  import {
4
4
  BinaryExpr,
5
5
  ColumnRef,
@@ -14,10 +14,11 @@ import {
14
14
  import { timeouts } from '@prisma-next/test-utils';
15
15
  import { describe, expect, it, vi } from 'vitest';
16
16
  import { lints } from '../src/middleware/lints';
17
+ import type { SqlMiddlewareContext } from '../src/middleware/sql-middleware';
17
18
 
18
- function createMiddlewareContext(): MiddlewareContext<unknown> {
19
+ function createMiddlewareContext(): SqlMiddlewareContext {
19
20
  return {
20
- contract: {},
21
+ contract: {} as Contract<SqlStorage>,
21
22
  mode: 'strict' as const,
22
23
  now: () => Date.now(),
23
24
  log: {
@@ -9,12 +9,21 @@ import {
9
9
  import type { SqlStorage } from '@prisma-next/sql-contract/types';
10
10
  import type {
11
11
  CodecRegistry,
12
- SelectAst,
13
12
  SqlDriver,
14
13
  SqlExecuteRequest,
15
14
  } from '@prisma-next/sql-relational-core/ast';
16
- import { codec, createCodecRegistry } from '@prisma-next/sql-relational-core/ast';
15
+ import {
16
+ BinaryExpr,
17
+ ColumnRef,
18
+ codec,
19
+ createCodecRegistry,
20
+ LiteralExpr,
21
+ SelectAst,
22
+ TableSource,
23
+ } from '@prisma-next/sql-relational-core/ast';
24
+ import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan';
17
25
  import { describe, expect, it, vi } from 'vitest';
26
+ import type { SqlMiddleware } from '../src/middleware/sql-middleware';
18
27
  import type {
19
28
  SqlRuntimeAdapterDescriptor,
20
29
  SqlRuntimeAdapterInstance,
@@ -380,18 +389,167 @@ describe('createRuntime', () => {
380
389
 
381
390
  it('rejects a Mongo middleware with a clear error', () => {
382
391
  const { stackInstance, context, driver } = createTestSetup();
392
+ // Simulate a caller bypassing the SqlMiddleware type constraint (e.g. dynamically-loaded
393
+ // middleware). Static typing already rejects familyId: 'mongo'; this tests the runtime guard.
394
+ const mongoMiddleware = { name: 'mongo-mw', familyId: 'mongo' } as unknown as SqlMiddleware;
383
395
  expect(() =>
384
396
  createRuntime({
385
397
  stackInstance,
386
398
  context,
387
399
  driver,
388
400
  verify: { mode: 'onFirstUse', requireMarker: false },
389
- middleware: [{ name: 'mongo-mw', familyId: 'mongo' }],
401
+ middleware: [mongoMiddleware],
390
402
  }),
391
403
  ).toThrow(
392
404
  "Middleware 'mongo-mw' requires family 'mongo' but the runtime is configured for family 'sql'",
393
405
  );
394
406
  });
407
+
408
+ it('invokes beforeCompile and lowers the rewritten AST', async () => {
409
+ const { stackInstance, context, driver } = createTestSetup();
410
+ const debug = vi.fn();
411
+ const softDeletePredicate = BinaryExpr.eq(
412
+ ColumnRef.of('users', 'deleted_at'),
413
+ LiteralExpr.of(null),
414
+ );
415
+ const softDelete: SqlMiddleware = {
416
+ name: 'softDelete',
417
+ familyId: 'sql',
418
+ async beforeCompile(draft) {
419
+ if (draft.ast.kind !== 'select') return;
420
+ return { ...draft, ast: draft.ast.withWhere(softDeletePredicate) };
421
+ },
422
+ };
423
+
424
+ const runtime = createRuntime({
425
+ stackInstance,
426
+ context,
427
+ driver,
428
+ verify: { mode: 'onFirstUse', requireMarker: false },
429
+ middleware: [softDelete],
430
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug },
431
+ });
432
+
433
+ const queryPlan: SqlQueryPlan = {
434
+ ast: SelectAst.from(TableSource.named('users')).withProjection([]),
435
+ params: [],
436
+ meta: {
437
+ target: 'postgres',
438
+ storageHash: testContract.storage.storageHash,
439
+ lane: 'dsl',
440
+ paramDescriptors: [],
441
+ },
442
+ };
443
+
444
+ await runtime.execute(queryPlan).toArray();
445
+
446
+ expect(driver.__spies.rootExecute).toHaveBeenCalledTimes(1);
447
+ const request = driver.__spies.rootExecute.mock.calls[0]?.[0] as SqlExecuteRequest;
448
+ expect(request.sql).toContain('deleted_at');
449
+ expect(debug).toHaveBeenCalledWith({
450
+ event: 'middleware.rewrite',
451
+ middleware: 'softDelete',
452
+ lane: 'dsl',
453
+ });
454
+ });
455
+
456
+ it('invokes adapter.lower exactly once per execute regardless of chain length', async () => {
457
+ const adapter = createStubAdapter();
458
+ const lowerSpy = vi.spyOn(adapter, 'lower');
459
+ const driver = createMockDriver();
460
+
461
+ const targetDescriptor = createTestTargetDescriptor();
462
+ const adapterDescriptor = createTestAdapterDescriptor(adapter);
463
+ const stack = createSqlExecutionStack({
464
+ target: targetDescriptor,
465
+ adapter: adapterDescriptor,
466
+ extensionPacks: [],
467
+ });
468
+ const stackInstance = instantiateExecutionStack(stack) as ExecutionStackInstance<
469
+ 'sql',
470
+ 'postgres',
471
+ SqlRuntimeAdapterInstance<'postgres'>,
472
+ RuntimeDriverInstance<'sql', 'postgres'>,
473
+ RuntimeExtensionInstance<'sql', 'postgres'>
474
+ >;
475
+ const context = createExecutionContext({
476
+ contract: testContract,
477
+ stack: { target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [] },
478
+ });
479
+
480
+ const rewriteA: SqlMiddleware = {
481
+ name: 'rewriteA',
482
+ familyId: 'sql',
483
+ async beforeCompile(draft) {
484
+ if (draft.ast.kind !== 'select') return undefined;
485
+ return {
486
+ ...draft,
487
+ ast: draft.ast.withWhere(BinaryExpr.eq(ColumnRef.of('users', 'a'), LiteralExpr.of(1))),
488
+ };
489
+ },
490
+ };
491
+ const rewriteB: SqlMiddleware = {
492
+ name: 'rewriteB',
493
+ familyId: 'sql',
494
+ async beforeCompile(draft) {
495
+ if (draft.ast.kind !== 'select') return undefined;
496
+ return {
497
+ ...draft,
498
+ ast: draft.ast.withWhere(BinaryExpr.eq(ColumnRef.of('users', 'b'), LiteralExpr.of(2))),
499
+ };
500
+ },
501
+ };
502
+
503
+ const runtime = createRuntime({
504
+ stackInstance,
505
+ context,
506
+ driver,
507
+ verify: { mode: 'onFirstUse', requireMarker: false },
508
+ middleware: [rewriteA, rewriteB],
509
+ });
510
+
511
+ const queryPlan: SqlQueryPlan = {
512
+ ast: SelectAst.from(TableSource.named('users')).withProjection([]),
513
+ params: [],
514
+ meta: {
515
+ target: 'postgres',
516
+ storageHash: testContract.storage.storageHash,
517
+ lane: 'dsl',
518
+ paramDescriptors: [],
519
+ },
520
+ };
521
+
522
+ await runtime.execute(queryPlan).toArray();
523
+
524
+ expect(lowerSpy).toHaveBeenCalledTimes(1);
525
+ const loweredAst = lowerSpy.mock.calls[0]?.[0] as SelectAst;
526
+ expect(loweredAst.where?.kind).toBe('binary');
527
+ });
528
+
529
+ it('skips beforeCompile for raw execution plans with no AST', async () => {
530
+ const { stackInstance, context, driver } = createTestSetup();
531
+ const debug = vi.fn();
532
+ const beforeCompile = vi.fn();
533
+ const observer: SqlMiddleware = {
534
+ name: 'observer',
535
+ familyId: 'sql',
536
+ beforeCompile,
537
+ };
538
+
539
+ const runtime = createRuntime({
540
+ stackInstance,
541
+ context,
542
+ driver,
543
+ verify: { mode: 'onFirstUse', requireMarker: false },
544
+ middleware: [observer],
545
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug },
546
+ });
547
+
548
+ await runtime.execute(createRawExecutionPlan()).toArray();
549
+
550
+ expect(beforeCompile).not.toHaveBeenCalled();
551
+ expect(debug).not.toHaveBeenCalled();
552
+ });
395
553
  });
396
554
 
397
555
  describe('withTransaction', () => {