Skip to content

TML-3288: Index, check and policy SQL is written as a sql literal - #30550

Merged
wmadden-electric merged 244 commits into
mainfrom
tml-3288-sql-expression-places
Oct 8, 2026
Merged

wmadden-electric merged 244 commits into
mainfrom
tml-3288-sql-expression-places

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Linked issue

Refs TML-3288, the third slice of the Linear project SQL expression literals. Records the decision as ADR 268.

Summary

Raw SQL in a Prisma 8 schema is now written one way everywhere: as a sql literal. Index predicates and expressions, full-text index predicates, check expressions and policy predicates take the same literal @default already took, and refuse a quoted string with a message that gives the rewrite.

At a glance

Before this PR, raw SQL outside @default was a quoted string full of \":

model Profile {
  id         String    @id @default(sql`gen_random_uuid()`)
  userId     String
  archivedAt DateTime?

  @@index([userId], where: "\"archivedAt\" IS NULL", name: "profile_user_active")
  @@check(expression: "char_length(\"userId\") > 0", name: "profile_user_id_present")
}

After it, every place takes the same sql literal as @default:

model Profile {
  id         String    @id @default(sql`gen_random_uuid()`)
  userId     String
  archivedAt DateTime?

  @@index([userId], where: sql`"archivedAt" IS NULL`, name: "profile_user_active")
  @@check(expression: sql`char_length("userId") > 0`, name: "profile_user_id_present")
}

policy_update profile_owner_write {
  target    = Profile
  roles     = [authenticated]
  using     = sql`"userId"::uuid = auth.uid()`
  withCheck = sql`"userId"::uuid = auth.uid()`
}

A quoted string in any of these places is refused, and the message gives the literal to write:

PSL_VALUE_TYPE_INCOMPATIBLE: Expected sql`...`; write sql`"archivedAt" IS NULL`

A codemod in the upgrade instructions rewrites an existing schema:

node scripts/rewrite-sql-strings.mjs '**/*.prisma'

Decision

Raw SQL is a value of one data type, sql/expression, and every place that holds raw SQL says it receives that type. The SQL family registers the type with the tag sql and declares that nothing converts into it. So the general rule that admits a written value, the one that refuses @default("abc") on an Int column, admits only a sql literal in these places and refuses a quoted string, a number or a boolean. There is no separate "strings not allowed" check, and Prisma never parses the SQL inside the literal.

The places are @@index(where:), @@index(expression:), @@fullTextIndex(where:), @@check(expression:), and a policy's using and withCheck. @default already took sql literals and keeps its own checks on the text (no ;, no SELECT, and so on); the other places check nothing, because a row-level-security predicate often holds EXISTS (SELECT ...).

ADR 268 records the decision and its rejected alternatives. ADRs 129, 231, 234, 236, 243, 244, 249, 254 and 262 are amended to match.

How it fits together

  1. A sql literal is a value of a data type. TML-3296: sql is the tag of the SQL family's data type sql/expression #30534 made sql/expression a data type the SQL family owns. TML-3367: Attribute arguments declare the data type they receive #30539 (shipped in rc.16) built dataTypeValue, an argument type that admits a written value by the conversion rule and reports a refusal at the value.
  2. Each place names the type it receives. indexModelSpec, checkModelSpec, postgresFullTextIndexSpec and the three policy specs now build dataTypeValue(SQL_EXPRESSION_DATA_TYPE_ID, ctx.dataTypes) from their spec context. For the policy predicates this needs the block spec context to carry the stack's data types: BlockSpecContext is now { symbols, dataTypes }, filled on every path that binds a block (the SQL and Mongo interpreters, the binder, the language server).
  3. The conversion rule does the refusing. A quoted string is a text value; sql/expression converts from nothing; the general refusal fires. Because the receiving type has a tag, the message adds the exact literal to write, when that literal would read back as the same text.
  4. The contract stores the canonical text. Reading a literal removes indentation shared by every line, blank lines at the start and end, carriage returns and whitespace-only lines. This PR fixes one detail of that rule from TML-3296: sql is the tag of the SQL family's data type sql/expression #30534, which removed only one blank line at each end and so was not stable when applied twice. Contracts do not change shape: Index.where, CheckConstraint.expression and the policy predicates stay strings. Every fixture in the repository emits an identical contract.json.
  5. Names do not change. Index, check and policy names end in a hash of their SQL, and that hash already ignores everything canonicalization removes. A user's raw SQL that was not canonical changes its stored text once, and so the storage hash once; what migration plan then does is in "Compatibility" below.
  6. Writing back reads back. contract infer and contract print write these places as sql literals. printSqlExpressionLiteral throws for text that would read back changed, so no printer can produce it by accident. contract infer prints a Prisma-named object with its canonical text (same name) and skips an object named with map: or @@map whose text would not read back, with a note saying how to recover; contract print refuses it.
  7. Tools learn the form together. The editor completes sql at every argument that receives sql/expression, including a policy's using, and colours the literal; @@check( completes to check(expression: sql`${1:expression}`). The Supabase pack contract, examples/supabase, every .prisma fixture, the docs and the prisma-8 skill references move to the new form in this PR.

Compatibility, migration and risk

  • Schema authors (breaking): a quoted string in the six places is refused. Run the codemod from upgrade-instructions/pending/sql-expression-literals-psl/app/, or rewrite by hand as each message says. The fragment describes the change from rc.16.
  • Stored text may change once. For a Prisma-named index, check or policy, one migration plan records the new text and has no operations. An index or check named with map: makes migration plan stop with a conflict that asks for a migration written with migration new, which has no operations. A policy named with @@map is dropped and created again, because migration plan allows destructive operations. A planner test pins each case.
  • Extension authors: BlockSpecContext.dataTypes is required, and interpretExtensionBlocks, createBinder, createSqlBinder and createMongoBinder take dataTypes. ControlDefaultRegistries is deleted; a spec reads ctx.defaultFunctionRegistry and ctx.dataTypes. The SQL attribute spec factories all take the context. canonicalizeTaggedLiteralBody is exported from @internal/framework-components/authoring only. The extension fragment has the details.
  • contract print refuses an exact-named index, check or policy whose SQL would not read back, with CONTRACT.PRINT_UNSUPPORTED.

Reviewer notes

  • Largest diffs: contract-psl/src/sql-attribute-specs.ts and interpreter.ts, target-postgres/src/core/authoring.ts (full-text index and policies), the infer and print code under psl-build/, psl-infer/ and psl-print/, and the rewritten .prisma fixtures.
  • Unrelated change, kept on purpose: .claude/scripts/enforce-tools.mjs blocks agent shells from running the full integration and end-to-end suites locally, as Will asked. It came in with commits 0e83ea5e3f and ff0a677271. The second also fixes the codemod (an unclosed backtick no longer stops it skipping later // comments) and edits ADR 129, ADR 268 and the Supabase skill reference, although its title names only the hook.
  • Line comments: a body whose last line ends in a -- comment renders valid DDL since TML-3287: Raw SQL ending in a line comment renders valid DDL #30546; the new CLI journey now includes such a body.
  • Three tarball tests cannot run on the author's machine (pnpm install in their scratch project refuses @vercel/detect-agent as a trust downgrade). CI is the check for them.
  • Project files: projects/sql-expression-literals/ holds the spec, design, plan, review reports and status. It is deleted at project close-out.

Behavior changes and evidence

  • The six places take sql literals and refuse other literals. Evidence: contract-psl/test/interpreter.sql-expression-places.test.ts, target-postgres/test/psl-full-text-index.test.ts, psl-policy-predicates.test.ts, and the guard sql-expression-places.test.ts, which fails if a raw-SQL argument is written with str().
  • Block specs receive the stack's data types, including block value completion and block keyword snippets. Evidence: psl-parser/test/block-spec-context.test.ts, language-server/test/block-spec-context.test.ts, and completion-provider.test.ts, where using = | in a policy_select block offers sql on the real Postgres stack.
  • Names do not change for canonical text. Evidence: target-postgres/test/sql-expression-wire-names.test.ts.
  • Infer and print write sql literals and never one that reads back changed. Evidence: psl-infer/infer-sql-expression-literals.test.ts, psl-print/refusals-sql-text.test.ts, test/migrations/sql-text-canonical-planner.test.ts, and the CLI journeys sql-expression-literals.e2e.test.ts, infer-roundtrip-fidelity* and sign-the-database.
  • The codemod. Evidence: scripts/codemods/rewrite-sql-strings.test.mjs (each position, both quote kinds, escapes, comments, literals inside strings, the byte-identical fragment copies).

Testing performed

On the final head:

  • pnpm build, pnpm typecheck, pnpm lint, pnpm lint:deps, pnpm lint:casts (no increase), pnpm lint:throws (no increase), pnpm lint:skills, pnpm lint:framework-vocabulary, pnpm lint:rules:footprint, pnpm check:error-reference, pnpm fixtures:check (no contract.json change), pnpm test:scripts, and pnpm check:upgrade-coverage --mode pr against the merge base.
  • The package test files each change touches, and these integration files alone: the sql-expression-literals CLI journey (now with texts ending in a -- comment), lsp-sql-completion-in-blocks, test/authoring/**, test/psl-print/**, infer-roundtrip-fidelity*, sign-the-database, and the expression-index and RLS journeys. The full integration and end-to-end suites run in CI and the merge queue.
  • Every test added in the last review round was shown to fail with its defect planted.
  • Manual QA: the slice 2b script in projects/sql-expression-literals/manual-qa.md reads each new message as a user would; its expected column now quotes the current messages.
  • Three rounds of architect and code review, with every finding fixed or answered: projects/sql-expression-literals/slice-reviews/2b/, 2b-round-2/ and 2b-round-3/.

Alternatives considered

  • Accept quoted strings beside sql literals. Rejected: two ways to write one value, and the escaped quotes stay.
  • A "tagged literal" argument kind with its own error for quoted strings. Rejected: it describes syntax, not types; the conversion rule already refuses a value of another type, with one mechanism for every tag.
  • Apply @default's checks on SQL text to every place. Rejected: they refuse valid row-level-security predicates such as EXISTS (SELECT ...).
  • A separate typing step for policy values in the family interpreter. Rejected: block specs already parse values with the same building blocks as attributes, so a second mechanism would duplicate it.
  • Print text that does not read back and let verification report it. Rejected: a printed schema must emit the contract it came from.
  • Prefixed tags such as pg.sql. Rejected in TML-3296: sql is the tag of the SQL family's data type sql/expression #30534: the stack already names the target.

Skill update

The prisma-8 skill references contract.md, queries-postgres.md and supabase.md write raw SQL as sql literals. The breaking change for schema authors, with the codemod, is in upgrade-instructions/pending/sql-expression-literals-psl/.

Checklist

  • All commits are signed off (git commit -s) per the DCO.
  • I read CONTRIBUTING.md and the change is scoped to one logical concern, apart from the hook change named above.
  • Tests are updated.
  • The PR title is in TML-NNNN: <sentence-case title> form.
  • The Skill update section above is filled in.

Agent: hammurabi-31 (taking over from charon-96 and marconi-29)

🤖 Generated with Claude Code

Spec, design, plan, decisions, research and the status file for resuming
the work. Raw SQL in a schema is a value of the data type sql/expression.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…d literal printer

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…emoved

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…odes

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…type slice

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…on data type slice

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…stack

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The cast rule already keeps the sql/expression entry out of literal defaults. Also corrects the wrapNamespaceBlock line reference in the design (F09).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The code now reports only a single value written on a list column, which is a refusal about shape, not about type.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
A cast and a list cast that throw are PSL_INVALID_LITERAL. Every refusal row now asserts its whole message.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
It reads the canonical value through sqlTextFromCanonical, as the six places in slice 2b will. The reserved-name message names the tag through SQL_EXPRESSION_TAG.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…s user

lowerTaggedLiteral lowered nothing any more, and its refusal had the type of a default-function result. The sql/expression code moves out of data-type-default.ts, which holds no per-type code.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The sqlExpressionDataType doc comment points at ADR 254. sqlTextReadsBack moves to slice 2b, next to its caller. The authoring entry exports only printTaggedLiteral from tagged-literal; each other function is added in the slice that first imports it from there.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The code comment, the entry documentation the editor shows, and ADR 254 said "SQL text", which would admit a statement or a query. The extensions doc no longer teaches a view query as a sql literal or describes the removed prefixed tags.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…nd text

ADR 129 is retitled "Tagged literals write values of data types" and no longer says a tag names the pack that owns the text. ADR 254 states the prefix rule once, by the owner of the data type, and ADR 129 and the index link to it. The body is what is written between the quotes; the text is the canonical value. ADR 254 now says @default reports its cast-rule codes at the attribute. The error reference drops "a list holding another list", which PSL cannot write.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
They used pg.sql and sqlite.sql, which no longer exist, as the example of a valid dotted tag.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
assertNothingCastsFromSqlExpression throws CONTRACT.DATA_TYPE_CASTS_FROM_SQL_EXPRESSION for a cast or a list cast from sql/expression. The family instance runs it on every data type the stack registers, so an extension cannot turn a sql literal into a value of another type. runtimeError is exported from the shared codec entry so shared-plane code can raise the same kind of error as stack assembly.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
For Postgres with every shipped extension pack, and for SQLite: the stack registers the family objects themselves, and no registered data type casts from sql/expression.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Deleting the adapter authoring tests removed the only tests of the Postgres and SQLite number classifiers, the boolean reader, and JSON and numeric read and print. The cases move to the targets, which own the entries; only the cases about lowering keys are dropped.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…od test so it fails without the fix

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… as canonicalization now does

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ng and infer skips only exact-named objects

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… has it and owns its change id

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…k lines at both ends, and a sql literal has two escapes

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…f data types

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…fusal messages

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…journey in a -- line comment

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…est that drives the language server in a Postgres project

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric added this pull request to the merge queue Oct 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 8, 2026
Brings TML-3466, TML-3382, TML-3475, TML-3476, TML-3512 and the one-config-file project. No conflicts and no released fragment touched; the semantic clashes are fixed in the commits that follow.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…pect a policy drop to be widening

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ql literals

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…o data

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric added this pull request to the merge queue Oct 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 8, 2026
Main's TML-3431 stores a Postgres full-text index as data and lets it span several weighted fields. The conflicts keep main's structure and this branch's rule that raw SQL is a sql literal of the data type sql/expression.

- postgres/src/core/authoring.ts: main's multi-field @@fullTextIndex spec and lowering. The spec is a function of the spec context again, so where: receives dataTypeValue(SQL_EXPRESSION_DATA_TYPE_ID, ctx.dataTypes), and lowering stores where through sqlTextFromCanonical.
- postgres/src/core/psl-print/model-attributes.ts: main's shared naming and full-text branch. The read-back refusal for an index's SQL texts now runs before the full-text branch, so it also covers a full-text index's where.
- postgres/test/psl-full-text-index.test.ts: main's tests, with where written as a sql literal; this branch's refusal tests for a plain-string, number or boolean where are kept.
- postgres/test/migrations/full-text-index-planning.test.ts: both sides' imports, since both are used.
- postgres/README.md and skills/prisma-8/references/contract.md: main's prose and weighted example, plus this branch's partial-index example with a sql literal; the README keeps the paragraph that every raw-SQL argument takes a sql literal, and its expression-index example is a sql literal.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… check

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…:) as a name, not SQL

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 7ae50f1 Oct 8, 2026
24 checks passed
@wmadden-electric
wmadden-electric deleted the tml-3288-sql-expression-places branch October 8, 2026 17:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants