Repository navigation
TML-3289: Raw SQL in a TypeScript contract is a sql… value, as it is in PSL - #30558
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository: prisma/orm/.coderabbit.yml Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (21)
📒 Files selected for processing (1)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthroughThe TypeScript ChangesSQL expression authoring
Priority: ⬇️ Low Estimated code review effort: 4 (Complex) | ~45 minutes Change: Feature Suggested reviewers:
|
| Check name | Status | Explanation | Resolution |
|---|---|---|---|
| Docstring Coverage | Docstring coverage is 34.69% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 49 functions across 50 files. | Write docstrings for the functions missing them to satisfy the coverage threshold. |
✅ Passed checks (4 passed)
| Check name | Status | Explanation |
|---|---|---|
| Linked Issues check | ✅ Passed | Check skipped because no linked issues were found for this pull request. |
| Out of Scope Changes check | ✅ Passed | Check skipped because no linked issues were found for this pull request. |
| Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. |
| Title check | ✅ Passed | The title clearly and concisely describes the main change: TypeScript raw SQL inputs now use sql-tagged values, matching PSL. |
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
- Commit to this branch
- Create a new PR
🧪 Generate unit tests (beta)
- Commit to this branch
- Create a new PR
- Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Autopilot is currently an internal CodeRabbit preview.
Comment @coderabbitai help to get the list of available commands.
@prisma/orm-extension-arktype-json
@prisma/orm-extension-middleware-cache
@prisma/orm-extension-paradedb
@prisma/orm-extension-pgvector
@prisma/orm-extension-postgis
@prisma/orm-extension-supabase
@prisma/orm-family-mongo
@prisma/orm-family-sql
@prisma/orm-framework
@prisma/orm-mongo
@prisma/orm-postgres
@prisma/orm-sqlite
@prisma/orm-target-mongo
@prisma/orm-target-postgres
@prisma/orm-target-sqlite
@prisma/orm-toolchain
commit: |
size-limit report 📦
|
…30619) ## Linked issue [TML-3483](https://linear.app/prisma-company/issue/TML-3483). Related: [TML-3020](https://linear.app/prisma-company/issue/TML-3020) (the eleven older duplicated numbers, not touched here). ## Summary `main` had three files named `ADR 258 - …`, so "see ADR 258" did not say which decision it meant: ```text ADR 258 - List cardinality has independent container and element nullability.md ADR 258 - A model names its storage verbatim, and a rename is an operation.md ADR 258 - A collection keeps its class through the chain.md ``` The same happened to 207, 255 and 259 in the last two weeks. The ADR that merged first keeps the number. Each ADR that merged later moves to a new number: | Was | Now | ADR | | --- | --- | --- | | 255 | 262 | Block specs bind top-level block values | | 207 | 263 | A serverless Postgres connection has the same query interface as a `postgres()` client | | 258 | 264 | A model names its storage verbatim, and a rename is an operation | | 258 | 265 | A collection keeps its class through the chain | | 259 | 266 | The cache middleware passes data to its store, and the store decides how to cache | The new numbers start at 262 because `main` now has ADR 260 and open pull requests already use 256 and 261. The query fragments ADR (259) cited "ADR 260" for the collection-scopes ADR in #30428, which has not merged. ADR 260 on `main` is now the `afterTransaction` stage, so that citation is removed. #30428 can add it back with its final number. Every link and every mention of a moved ADR in docs, package READMEs and project files now uses the new number. Mentions in project research notes that describe the numbering at an earlier point in time are left as they were. `ADR-INDEX.md` gains rows for three ADRs it was missing: 259 (query fragments), 264 (storage names) and 265 (collection class). ## Open pull requests this affects These pull requests change a file that moves here. Git should carry their changes to the new file name, but links they add to the old name need updating: - #30550 and #30613 change ADR 255 (block specs), now ADR 262. - #30428 changes ADR 258 (collection class), now ADR 265. These open pull requests add an ADR whose number is already taken, so they clash again when they merge: - ADR 256: #30535 (language-server file watching). `main` already has ADR 256 (mutation-default generators). - ADR 260: #30428 (collection scopes) and #30550 / #30558 (raw SQL is a value of `sql-expression`). `main` already has ADR 260 (`afterTransaction` stage). ## Testing performed - Checked every Markdown link in the changed files that points to an ADR. All resolve. Two links that were already broken on `main` (ADR 009 and ADR 015) are unrelated and unchanged. - Searched the repository for the old file names and the old numbers. The only remaining matches are the earlier-point-in-time research notes described above. ## Skill update n/a — internal only. ## Upgrade instructions [`upgrade-instructions/pending/adr-numbers-unique/extension/`](upgrade-instructions/pending/adr-numbers-unique/extension/instructions.md) declares `changes: []`. The three package READMEs change only their ADR links, so extension authors have nothing to do. ## Checklist - [x] All commits are signed off (`git commit -s`) per the [DCO](../CONTRIBUTING.md#developer-certificate-of-origin-dco). - [x] I read [CONTRIBUTING.md](../CONTRIBUTING.md) and the change is scoped to one logical concern. - [x] Tests are updated (or `n/a` if the change is doc-only / refactor with no behavioural delta). n/a, docs only. - [x] The PR title is in `TML-NNNN: <sentence-case title>` form. - [x] The **Skill update** section above is filled in. ## Notes for the reviewer Alternatives considered: - **Move whichever ADR has fewer references.** For 255 that would move relation ordering instead of block specs, and no open pull request touches it. Rejected because a rule based on merge order is simple to apply the next time, and an earlier fix (ADR 255 to ADR 256 for mutation-default generators) already moved the later ADR. - **Also renumber the eleven older duplicates (159 to 224).** They have been cited by number for months, and deciding which ADR each bare mention means needs reading each citation. That stays with TML-3020. Agent: etain-65 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Updated ADR references across guides, architecture documentation, and package READMEs to reflect current numbering. * Added ADRs to the architecture index and corrected links covering serverless PostgreSQL connections, block specifications, storage naming, query collections, and cache middleware. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… value, as it is in PSL
57f4c40 to
7bdab1c
Compare
Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
At a glance
Every field that holds raw SQL takes a
sqlvalue.where: '"archivedAt" IS NULL'no longer compiles. The contract this emits is byte for byte the contract the same schema emits when written in PSL:Decision
In the TypeScript contract builder, raw SQL is a
SqlExpression, made with thesqltemplate tag. Every builder field that holds raw SQL accepts only that type:constraints.index(...):whereandexpressioncheck(...):expressionfullTextIndex(...):wherepolicySelect,policyInsert,policyUpdate,policyDelete,policyAll:usingandwithCheck.default(...), which already tooksqlvaluesA string in any of them is a type error. This is the TypeScript half of ADR 268; #30550 did the PSL half.
Why
Contracts hold SQL that Prisma does not parse. An index's
where, an expression index, a CHECK constraint, a row-level-security policy'susingandwithCheck, and a raw column default are SQL text. The contract stores the text, and the migration planner places it inside DDL, such asCREATE INDEX … WHERE (<text>).PSL writes that SQL as a
sqlliteral, and cleans up its text. Since #30550, every one of those places in PSL takes asql`...`literal and nothing else. A literal may span several lines, so PSL canonicalizes its text before storing it: it removes the indentation shared by every line, drops blank lines at the start and end, and removes carriage returns. The indented PSL above stores"archivedAt" IS NULL\n AND "userId" <> ''.A TypeScript string does not get that clean-up. A template string keeps its indentation and its surrounding blank lines. So the same SQL, written in PSL and in TypeScript, stored different text in
contract.json, and the two contracts had different storage hashes. ADR 129 promises that PSL and TypeScript emit identical contracts for the same schema; multi-line SQL broke that promise.The
sqltag canonicalizes with the same function PSL uses.sql`...`returns aSqlExpressionwhosetextis canonical. TheSqlExpressionconstructor canonicalizes too, so no way of making a value skips it. A NUL character or a text over 64 KiB throwsCONTRACT.SQL_EXPRESSION_INVALID, with the same message PSL gives.sqlvalues compose; other values do not. TypeScript contracts reuse a predicate across policies, asownerdoes above. The tag accepts othersqlvalues inside${…}and joins their text in. Each later line of an inserted value takes the indentation of the template line it sits on, so the joined text is what the author sees, and the result is canonicalized once more as a whole. Anything else inside${…}, such as a string or a number, throwsCONTRACT.SQL_EXPRESSION_INTERPOLATION. This differs from the query lane'sdb.raw.sql, which binds${…}values as query parameters: contract SQL becomes DDL, and DDL has no parameters..default()keeps the checks that only defaults need. A default must not benow()orautoincrement()written as raw SQL, and must not contain;, a comment,$$orSELECT, because of how defaults are rendered and compared. Those checks used to live in the tag. The tag now also serves policies, whose predicates often holdEXISTS (SELECT …), so the checks moved into.default(), which throwsCONTRACT.DEFAULT_INVALID.JavaScript that is not type-checked still gets a clear error. When the builder turns a model into contract storage, it reads each raw-SQL field through
requireSqlExpression. Anything that is not asqlvalue throwsCONTRACT.ARGUMENT_INVALID, naming the object and the field:Policy "post_owner_write" using must be a sql`...` value.An unnamed index is named by its model (Index on "Post" where), and an unnamed full-text index by its fields (Full-text index on fields "title", "body" where).A value from a second copy of the package still works.
SqlExpressionis a class, so a hand-written{ text: 'x' }is not assignable to it. The class carries aSymbol.formarker, so a value made by another installed copy of the package is still recognized.readSqlExpressionrebuilds such a value with this copy's constructor, so its text is canonicalized too, in every field and inside${…}.contract printandcontract inferhandle defaults whose text would not read back. A default whose string constant holds, for example, a carriage return would change if printed as asqlliteral and read back.contract printrefuses such a default, as it already refuses such an index or policy.contract inferstill prints it, because a skipped default would be dropped by the next migration, and adds a note to check its string constants.The contract format does not change. The contract still stores the text as a string; only the builder's field types change.
What changes for users
sql`…`. The upgrade instructions inupgrade-instructions/pending/sql-expression-literals-ts/app/list every field. A text that was not canonical before, such as an indented multi-line template string, is stored differently once, so the storage hash changes once; the instructions say to runmigration planonce. Index, check and policy names do not change.CONTRACT.DEFAULT_SQL_INTERPOLATIONis replaced byCONTRACT.SQL_EXPRESSION_INTERPOLATION;CONTRACT.SQL_EXPRESSION_INVALIDis new. Both are in the error reference.SqlExpression.sql,SqlExpression,isSqlExpression,readSqlExpressionandrequireSqlExpressioncome from@internal/sql-contract/sql-expression; the contract-builder facades re-exportsqland theSqlExpressiontype. The SQL family registerssql/expressionthrough one value,sqlExpressionRegistration. Seeupgrade-instructions/pending/sql-expression-literals-ts/extension/.sqlvalues too). The deprecated.defaultSql()and.default({ kind: 'function', expression })still take a string and store it without canonicalization; TML-3286 removes.defaultSql()at 8.0.0.Where to look
packages/2-sql/1-core/contract/src/sql-expression.ts: the class, the tag,readSqlExpressionandrequireSqlExpression.packages/2-sql/2-authoring/contract-ts/src/contract-dsl.tsandcontract-lowering.ts: the field types,.default(), and reading each field. Incontract-lowering.ts, asqlvalue is checked before a deferred index expression'srender, because'render' in someStringthrows aTypeError.packages/3-extensions/postgres/src/contract/rls.tsandfull-text-index.ts;packages/3-targets/3-targets/postgres/src/core/authoring.ts: policies and full-text indexes.test/integration/test/authoring/parity/sql-expressions/: the same schema in PSL and TypeScript, with indented multi-line text, tab indentation, a backslash, an escaped backtick, an interpolated predicate holding a--comment, a multi-line predicate interpolated into an indentedwithCheck, a full-text index with several weight groups, and a raw default. The test checks both emit the samecontract.json.prisma-8skill referencecontract.md.projects/sql-expression-literals/holds the project's spec, design, plan, reviews, manual QA and status. It is deleted at project close-out.Testing performed
pnpm build,pnpm typecheck,pnpm lint,pnpm lint:deps,pnpm lint:casts,pnpm lint:throws,pnpm check:error-reference,pnpm lint:framework-vocabulary,pnpm lint:skills,pnpm fixtures:check(nocontract.jsonchange in any existing fixture),pnpm test:scripts, andpnpm check:upgrade-coverage --mode pragainst the merge base.pnpm test:packages: everything passes except three tarball tests, which cannot install a package on the author's machine because the npm registry refuses it; CI runs them.test/authoring/**(including the parity fixture),test/sql-builder/**,test/psl-print/**, the RLS tests, and the SQL expression literals CLI journey. The full integration suite runs in CI.projects/sql-expression-literals/manual-qa.mdreads every refusal above as a user sees it, at run time and at compile time. The run of 2026-10-08 matches every expected result.projects/sql-expression-literals/slice-reviews/3/,3-round-2/and3-round-3/, and every finding is fixed.Skill update
The
prisma-8skill referencecontract.mdwrites TypeScript raw SQL assqlvalues. The upgrade instructions for app and extension authors are inupgrade-instructions/pending/sql-expression-literals-ts/.Linked issue
Refs TML-3289, part of the Linear project SQL expression literals.
Checklist
git commit -s) per the DCO.TML-NNNN: <sentence-case title>form.Alternatives considered
sqlvalues. Rejected. There would be two ways to write the same thing, and a string skips canonicalization, so PSL and TypeScript would again store different text for multi-line SQL.{ text }instead of a class. Rejected. A hand-written object would pass the type check without being canonicalized, and.default()could not tell it from a JSON default value.SELECT" would refuse valid policy predicates such asEXISTS (SELECT …).${…}take strings and numbers. Rejected. That is building SQL by string concatenation, which the typed value exists to prevent, and DDL has no parameters to bind them to.contract infer, a default whose text would not read back, as for an index. Rejected. A skipped default is dropped by the next migration; printing it with a note keeps it and tells the user what to check.Agent: lagertha-65
🤖 Generated with Claude Code