SQL builder: an outer query's column used inside a subquery renders without its table and binds to the inner table
What happened
In an application we are building on Prisma 8, we wrote a correlated EXISTS with the typed SQL builder. Inside the subquery we read the outer query's column through the top-level shortcut, f.id. The builder rendered it as a bare "id", with no table. Postgres resolves a bare name against the subquery's own table first. The inner table also has an id column, so the condition became actor."contactId" = actor.id. The EXISTS no longer depended on the outer row. There was no type error and no runtime error. The query returned the wrong rows.
The same column written through the table's namespace, f.contact.id, renders as "contact"."id" and returns the right rows.
Reproduction
Versions: @prisma/orm-postgres 8.0.0-rc.12, prisma 8.0.0-rc.17, TypeScript 6.0.3, Node 24.13.0. The database is @electric-sql/pglite 0.5.8, so no server is needed. The same SQL comes out of @prisma/orm-postgres 8.0.0-rc.13-dev.2. The cause below is read from main at d136137.
contract.prisma:
// use prisma-8
enum actor_kind {
@@type("pg/text@1")
human = "human"
agent = "agent"
}
model Contact {
id String @id
name String
@@map("contact")
}
model Actor {
id String @id
kind actor_kind
contact Contact? @relation(fields: [contactId], references: [id])
contactId String?
@@map("actor")
}prisma.config.ts:
import { defineConfig } from '@prisma/orm-postgres/config';
import { definePrismaConfig } from 'prisma/config';
export default definePrismaConfig({
orm: defineConfig({ contract: './contract.prisma' }),
});repro.ts:
import { PGlite } from '@electric-sql/pglite';
import { createPostgresAdapter } from '@prisma/orm-postgres/adapter/adapter';
import postgres from '@prisma/orm-postgres/runtime';
import type { Contract } from './contract.d.ts';
import contractJson from './contract.json' with { type: 'json' };
const { sql, contract } = postgres<Contract>({ contractJson });
// Contacts that have at least one actor.
const shortcut = sql.public.contact
.select('id', 'name')
.where((f, fns) =>
fns.exists(sql.public.actor.select('id').where((a, inner) => inner.eq(a.contactId, f.id))),
);
const namespaced = sql.public.contact
.select('id', 'name')
.where((f, fns) =>
fns.exists(
sql.public.actor.select('id').where((a, inner) => inner.eq(a.contactId, f.contact.id)),
),
);
const db = new PGlite();
await db.exec(`
CREATE TABLE contact (id text PRIMARY KEY, name text NOT NULL);
CREATE TABLE actor (id text PRIMARY KEY, kind text NOT NULL, "contactId" text REFERENCES contact(id));
INSERT INTO contact VALUES ('c1', 'Ada'), ('c2', 'Bob');
INSERT INTO actor VALUES ('a1', 'human', 'c1');
`);
const adapter = createPostgresAdapter();
for (const [name, query] of Object.entries({ shortcut, namespaced })) {
const { sql: text } = adapter.lower(query.build().ast, { contract });
const { rows } = await db.query(text);
console.log(name, text, JSON.stringify(rows));
}Commands:
npm init -y
npm pkg set type=module
npm install @prisma/[email protected] [email protected] @electric-sql/[email protected]
npx prisma contract emit
node repro.tsOutput:
shortcut SELECT "id" AS "id", "name" AS "name" FROM "public"."contact" WHERE EXISTS (SELECT "id" AS "id" FROM "public"."actor" WHERE "contactId" = "id") []
namespaced SELECT "id" AS "id", "name" AS "name" FROM "public"."contact" WHERE EXISTS (SELECT "id" AS "id" FROM "public"."actor" WHERE "contactId" = "contact"."id") [{"id":"c1","name":"Ada"}]Contact c1 has an actor, so both queries should return it. The first returns nothing.
An update has the same problem. With a third table ask that also has an id column, sql.public.ask.update(...).where((f, fns) => fns.exists(sql.public.contact.select('id').where((c, inner) => inner.eq(c.id, f.id)))) renders WHERE EXISTS (SELECT "id" AS "id" FROM "public"."contact" WHERE "id" = "id"). That is true for every row once contact has any row, so the update changed all three rows of our test ask table instead of none.
What was expected
The outer column renders with its table, as "contact"."id". If that is not possible, the type checker rejects the outer query's top-level field inside the subquery's callback.
Cause
packages/2-sql/4-lanes/sql-builder/src/runtime/field-proxy.ts:12-16: a top-level field such asf.idbecomesIdentifierRef.of(prop). Only a namespaced field such asf.contact.idbecomesColumnRef.of(namespace, column)(line 40).packages/2-sql/4-lanes/relational-core/src/ast/types.ts:550-562:IdentifierRefholds only the column name, so the table is lost when the field is read.packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts:675-676: the renderer prints anidentifier-refas a bare quoted name. Acolumn-refgoes throughrenderColumn(line 510) and keeps its table. The SQLite adapter rendersidentifier-refthe same way (packages/3-targets/6-adapters/sqlite/src/core/adapter.ts:365); we did not test SQLite.packages/2-sql/4-lanes/sql-builder/src/expression.ts:29-37:FieldProxyreturns anExpressionthat does not record which query it came from, so the outerf.idtype-checks inside the innerwhere.
A bare name is correct while the query has one table in scope. It becomes wrong when a closure carries the expression into a subquery, and by then the expression no longer knows its table.
The unit test packages/2-sql/4-lanes/sql-builder/test/runtime/builders.test.ts:469-479 uses this exact pattern, pfns.eq(pf.user_id, f.id), but only checks that the where is an ExistsExpr. The integration test test/integration/test/sql-builder/subquery.test.ts:14 uses the namespaced form on both sides, so it passes.
A related case: the namespaced form also goes wrong when the subquery reads the same table as the outer query. To find contacts that share a name with another contact, fns.exists(sql.public.contact.select('id').where((c, inner) => inner.and(inner.eq(c.name, f.contact.name), inner.ne(c.id, f.contact.id)))) renders FROM "public"."contact" WHERE ("name" = "contact"."name" AND "id" != "contact"."id"). There "contact" names the inner table, so each row is compared with itself and the query returns nothing. Writing the inner table as sql.public.contact.as('other') gives the right rows.
Suggested fix
- In
createFieldProxy, return aColumnReffor a top-level field that comes from a table in scope, qualified with the one namespace that holds it.MergeScopesalready drops names that are ambiguous, so there is at most one. KeepIdentifierRefonly for select-list aliases, whichorderBy,groupByanddistinctOnneed (packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts:395,425,459). - When a subquery reads a table whose name is already used by the outer query, give the inner table a fresh alias, or refuse the query with an error that asks for
.as(...). - Add an integration test that runs a correlated
EXISTSwritten with the top-level shortcut, where the inner and outer tables share a column name.
Why it matters
A correlated EXISTS that loses its correlation returns wrong rows, and in an update it changes every row, with no error when the query is built, type-checked or run. The top-level shortcut is the form the shipped docs use in their where examples, and nothing tells the user it is unsafe inside a subquery.
Source: prisma/prisma