Skip to content

tml-2912: unify SQL check-constraint mechanism + drift-manage array element checks#902

Open
SevInf wants to merge 18 commits into
mainfrom
scalar-lists-check-unify
Open

tml-2912: unify SQL check-constraint mechanism + drift-manage array element checks#902
SevInf wants to merge 18 commits into
mainfrom
scalar-lists-check-unify

Conversation

@SevInf

@SevInf SevInf commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What & why

Reviewing #870, the maintainer flagged (relational-core/.../ddl-types.ts:212) that the scalar-array element-non-null CHECK introduced a second, parallel check-constraint mechanism (CheckExpressionConstraint) alongside the existing enum value-set checks. This PR unifies them into one mechanism and makes the element-non-null checks drift-managed, exactly like enum value-set checks.

Stacked on #870 — base is scalar-lists-slice2, so this diff is only the unification. Merge #870 first.

Decision (Option A)

One check-constraint concept with a discriminated payload:

  • SqlCheckConstraintIR / the migration addCheckConstraint op carry valueSet ({column, permittedValues}) or expression ({expression}).
  • A single projectContractChecks helper feeds the three lock-step sites (verify / expected-IR / planner); it projects an array_position("<col>", NULL) IS NULL expression check for every many: true column.
  • Element checks now flow through the same ALTER-based, diff → AddCheckConstraintCall path the enum checks use, and are introspected + compared on verify.
  • CheckExpressionConstraint (the parallel type, inline at CREATE TABLE) is deleted — one mechanism remains.

This also closes the strict-verify gap the earlier slice deferred: element checks were previously never introspected/diffed, so --strict verify false-flagged them. They now round-trip; a migrate → introspect → strict re-verify integration test asserts zero drift.

Value-set verification semantics are unchanged from main — the = ANY (ARRAY…)/IN (…) parsing and set-based comparison are preserved; the discriminant only adds the expression variant on top.

Breaking change

The migration builder call changes from this.addCheckConstraint({ column, values }) to a discriminated { payload: { kind: 'valueSet', column, values } } (and gains a kind: 'expression' form). Re-generation emits the new shape; hand-preserved migration files need the edit. Recorded as an upgrade instruction (migration-addcheckconstraint-payload) with detection so the upgrade skill can migrate consumers.

Verification

  • build 68/68 · typecheck 143/143 · fixtures:check byte-clean · cast-ratchet delta 0 · lint:packages clean · check:upgrade-coverage clean
  • sql-family 383 · target-postgres 500 · adapter-postgres 670 (+4 expected-fail), incl. the multi-word-cast (timestamp with time zone) parse + strict re-verify round-trip

🤖 Generated with Claude Code

@SevInf SevInf requested a review from a team as a code owner July 2, 2026 14:01
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 420b0d4e-9bad-40a2-8f14-7b70e63c45c8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch scalar-lists-check-unify

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@prisma-next/extension-author-tools

npm i https://pkg.pr.new/@prisma-next/extension-author-tools@902

@prisma-next/mongo-runtime

npm i https://pkg.pr.new/@prisma-next/mongo-runtime@902

@prisma-next/family-mongo

npm i https://pkg.pr.new/@prisma-next/family-mongo@902

@prisma-next/sql-runtime

npm i https://pkg.pr.new/@prisma-next/sql-runtime@902

@prisma-next/family-sql

npm i https://pkg.pr.new/@prisma-next/family-sql@902

@prisma-next/extension-arktype-json

npm i https://pkg.pr.new/@prisma-next/extension-arktype-json@902

@prisma-next/middleware-cache

npm i https://pkg.pr.new/@prisma-next/middleware-cache@902

@prisma-next/mongo

npm i https://pkg.pr.new/@prisma-next/mongo@902

@prisma-next/extension-paradedb

npm i https://pkg.pr.new/@prisma-next/extension-paradedb@902

@prisma-next/extension-pgvector

npm i https://pkg.pr.new/@prisma-next/extension-pgvector@902

@prisma-next/extension-postgis

npm i https://pkg.pr.new/@prisma-next/extension-postgis@902

@prisma-next/postgres

npm i https://pkg.pr.new/@prisma-next/postgres@902

@prisma-next/sql-orm-client

npm i https://pkg.pr.new/@prisma-next/sql-orm-client@902

@prisma-next/sqlite

npm i https://pkg.pr.new/@prisma-next/sqlite@902

@prisma-next/extension-supabase

npm i https://pkg.pr.new/@prisma-next/extension-supabase@902

@prisma-next/target-mongo

npm i https://pkg.pr.new/@prisma-next/target-mongo@902

@prisma-next/adapter-mongo

npm i https://pkg.pr.new/@prisma-next/adapter-mongo@902

@prisma-next/driver-mongo

npm i https://pkg.pr.new/@prisma-next/driver-mongo@902

@prisma-next/contract

npm i https://pkg.pr.new/@prisma-next/contract@902

@prisma-next/utils

npm i https://pkg.pr.new/@prisma-next/utils@902

@prisma-next/config

npm i https://pkg.pr.new/@prisma-next/config@902

@prisma-next/errors

npm i https://pkg.pr.new/@prisma-next/errors@902

@prisma-next/framework-components

npm i https://pkg.pr.new/@prisma-next/framework-components@902

@prisma-next/operations

npm i https://pkg.pr.new/@prisma-next/operations@902

@prisma-next/ts-render

npm i https://pkg.pr.new/@prisma-next/ts-render@902

@prisma-next/contract-authoring

npm i https://pkg.pr.new/@prisma-next/contract-authoring@902

@prisma-next/ids

npm i https://pkg.pr.new/@prisma-next/ids@902

@prisma-next/psl-parser

npm i https://pkg.pr.new/@prisma-next/psl-parser@902

@prisma-next/psl-printer

npm i https://pkg.pr.new/@prisma-next/psl-printer@902

@prisma-next/cli

npm i https://pkg.pr.new/@prisma-next/cli@902

@prisma-next/cli-telemetry

npm i https://pkg.pr.new/@prisma-next/cli-telemetry@902

@prisma-next/config-loader

npm i https://pkg.pr.new/@prisma-next/config-loader@902

@prisma-next/emitter

npm i https://pkg.pr.new/@prisma-next/emitter@902

@prisma-next/language-server

npm i https://pkg.pr.new/@prisma-next/language-server@902

@prisma-next/migration-tools

npm i https://pkg.pr.new/@prisma-next/migration-tools@902

prisma-next

npm i https://pkg.pr.new/prisma-next@902

@prisma-next/vite-plugin-contract-emit

npm i https://pkg.pr.new/@prisma-next/vite-plugin-contract-emit@902

@prisma-next/mongo-codec

npm i https://pkg.pr.new/@prisma-next/mongo-codec@902

@prisma-next/mongo-contract

npm i https://pkg.pr.new/@prisma-next/mongo-contract@902

@prisma-next/mongo-value

npm i https://pkg.pr.new/@prisma-next/mongo-value@902

@prisma-next/mongo-contract-psl

npm i https://pkg.pr.new/@prisma-next/mongo-contract-psl@902

@prisma-next/mongo-contract-ts

npm i https://pkg.pr.new/@prisma-next/mongo-contract-ts@902

@prisma-next/mongo-emitter

npm i https://pkg.pr.new/@prisma-next/mongo-emitter@902

@prisma-next/mongo-schema-ir

npm i https://pkg.pr.new/@prisma-next/mongo-schema-ir@902

@prisma-next/mongo-query-ast

npm i https://pkg.pr.new/@prisma-next/mongo-query-ast@902

@prisma-next/mongo-orm

npm i https://pkg.pr.new/@prisma-next/mongo-orm@902

@prisma-next/mongo-query-builder

npm i https://pkg.pr.new/@prisma-next/mongo-query-builder@902

@prisma-next/mongo-lowering

npm i https://pkg.pr.new/@prisma-next/mongo-lowering@902

@prisma-next/mongo-wire

npm i https://pkg.pr.new/@prisma-next/mongo-wire@902

@prisma-next/sql-contract

npm i https://pkg.pr.new/@prisma-next/sql-contract@902

@prisma-next/sql-errors

npm i https://pkg.pr.new/@prisma-next/sql-errors@902

@prisma-next/sql-operations

npm i https://pkg.pr.new/@prisma-next/sql-operations@902

@prisma-next/sql-schema-ir

npm i https://pkg.pr.new/@prisma-next/sql-schema-ir@902

@prisma-next/sql-contract-psl

npm i https://pkg.pr.new/@prisma-next/sql-contract-psl@902

@prisma-next/sql-contract-ts

npm i https://pkg.pr.new/@prisma-next/sql-contract-ts@902

@prisma-next/sql-contract-emitter

npm i https://pkg.pr.new/@prisma-next/sql-contract-emitter@902

@prisma-next/sql-lane-query-builder

npm i https://pkg.pr.new/@prisma-next/sql-lane-query-builder@902

@prisma-next/sql-relational-core

npm i https://pkg.pr.new/@prisma-next/sql-relational-core@902

@prisma-next/sql-builder

npm i https://pkg.pr.new/@prisma-next/sql-builder@902

@prisma-next/target-postgres

npm i https://pkg.pr.new/@prisma-next/target-postgres@902

@prisma-next/target-sqlite

npm i https://pkg.pr.new/@prisma-next/target-sqlite@902

@prisma-next/adapter-postgres

npm i https://pkg.pr.new/@prisma-next/adapter-postgres@902

@prisma-next/adapter-sqlite

npm i https://pkg.pr.new/@prisma-next/adapter-sqlite@902

@prisma-next/driver-postgres

npm i https://pkg.pr.new/@prisma-next/driver-postgres@902

@prisma-next/driver-sqlite

npm i https://pkg.pr.new/@prisma-next/driver-sqlite@902

commit: 95f4968

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
postgres / no-emit 160.78 KB (+0.19% 🔺)
postgres / emit 147.79 KB (+0.16% 🔺)
mongo / no-emit 97.99 KB (0%)
mongo / emit 89.37 KB (0%)
cf-worker / no-emit 188.9 KB (+0.18% 🔺)
cf-worker / emit 174.38 KB (+0.3% 🔺)

@SevInf SevInf force-pushed the scalar-lists-slice2 branch from 283c5ba to d4c5290 Compare July 2, 2026 14:24
@SevInf SevInf force-pushed the scalar-lists-check-unify branch 5 times, most recently from ec19c95 to d7a9d4b Compare July 2, 2026 16:39
@SevInf SevInf force-pushed the scalar-lists-slice2 branch from bf74745 to f9eb395 Compare July 2, 2026 16:45
@SevInf SevInf force-pushed the scalar-lists-check-unify branch from d7a9d4b to fbeb7a1 Compare July 2, 2026 16:45
SevInf and others added 15 commits July 2, 2026 17:07
…slice 2 D1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…ce 2 D2)

Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…pability (slice 2 D3)

Thread a merged CapabilityMatrix (built via mergeCapabilityMatrices over
[target, adapter, ...extensionPacks], the same merge enrichContract performs)
from the ControlStack through ContractSourceContext into the PSL interpreter,
and reject a scalar-list field whose target does not report sql.scalarList with
PSL_SCALAR_LIST_UNSUPPORTED_TARGET. An absent matrix means do not gate, so
non-adapter authoring paths stay valid; the CLI emit path always populates it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
… D1b)

Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…r PSL lists (slice 2 D4)

Part A (FR13, AC8 PSL half): every native scalar-array column gets a
deterministic CHECK (array_position(col, NULL) IS NULL) constraint named
<table>_<column>_elem_not_null, emitted at the migration/DDL layer in the
postgres issue-planner createTable path via a new CheckExpressionConstraint
DDL node. Introspection-based verify skips non-enum check predicates, so the
constraint round-trips without false drift and is invisible to infer.

Part B (FR14, AC9): PSL array-typed @default lowering. @default([]) -> empty
literal array (DEFAULT *{}*); @default(["a","b"]) -> literal array encoded
element-wise against the element codec (build-contract encodeColumnDefault is
now many-aware). A scalar literal default on a list field is rejected at
authoring time with PSL_LIST_DEFAULT_NOT_ARRAY (closes slice-1 D2 carry-in).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…ice 2 D5)

AC1: a `posts.tags String[]` schema authored in PSL emits a native array
storage column (pg/text@1, many:true, not jsonb), migrates onto a fresh
Postgres database as text[] with no manual edits, and round-trips through
`contract infer` back to a `tags String[]` PSL field.

AC2/NFR1: DateTime[]/Bytes[]/Decimal[] list fields authored in PSL (not
hand-built typed contracts) insert and select rows whose decoded element
values deep-equal the originals, proving per-element codec application
through the whole authored path.

NFR4: the same list schema yields matching observable semantics on SQL and
Mongo — both author cleanly, both generate a ReadonlyArray<...> domain type
over the string element codec, and list element values round-trip. The live
Mongo decode runs on mongodb-memory-server (CI; nixos binary download is
local env-noise).

Also hardens the introspection-side array-literal parser
(parseArrayLiteralBody in default-normalizer.ts) so a quoted element
containing a comma or an escaped quote round-trips through the migration-diff
layer without spurious drift (slice-1 carry-in).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…ing test (rebase on #864)

Main #864 made SqlNamespace abstract and createNamespace required on
InterpretPslDocumentToSqlContractInput. Thread createTestSqlNamespace
through the three D3 capability-gating interpret calls, matching the
sibling interpreter tests updated by that refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
Respond to reviewer feedback on the scalar-list authoring path:

- Simplify the `many` emission to the file's own
  `...(rf.many ? { many: true as const } : {})` idiom; `many` stays
  omitted (never `false`) for non-list fields, keeping canonical
  emission byte-stable.

- Consume the parser's structured expression AST for list-field
  `@default([...])` instead of re-parsing a stringified form. The
  PSL parser now decodes each attribute-arg expression into a
  `ResolvedExpr` discriminated union (string/number/boolean/array/
  object/call/identifier) exposed on `ResolvedAttributeArg`, and the
  SQL column resolver reads it directly. Deletes the hand-rolled
  `splitTopLevelArrayElements` tokenizer and the array-literal string
  parsing; array/scalar/function-default behavior is preserved,
  including quoted elements containing commas.

- Make the adapter capability matrix required end-to-end so the
  scalar-list gate has no test-only undefined branch and fails closed:
  an empty matrix rejects scalar lists. `ContractSourceContext`,
  `InterpretPslDocumentToSqlContractInput`, and the internal
  resolution inputs now require `capabilities`; production already
  threads it via the control stack. Test call sites pass an explicit
  matrix; the former "does not gate when no matrix is threaded" case
  becomes a fail-closed empty-matrix rejection.

- Drop transient milestone codes from the scalar-list integration
  test comments and titles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…#870

The PR touches one packages/3-extensions/ test file (threading the now-
required capabilities matrix into a test ContractSourceContext), which trips
the check:upgrade-coverage per-pr-declaration gate. Record it as an incidental
substrate diff: the affected types are framework-internal contract-emission
seams the control stack always populates; no extension-author API changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…teArg (slice 2 N1)

Replace the decoded ResolvedExpr union with the parser ExpressionAst node
directly on ResolvedAttributeArg, per code-owner review. List-default parsing
narrows via the AST classes (ArrayLiteralAst/StringLiteralExprAst/...) instead
of a bespoke decoded shape. The stringified value is kept, as it is still
consumed by scalar/function-call/relation-name/authoring-arg parsing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…nd ORM client

Add a sibling to the DateTime[]/Bytes[]/Decimal[] roundtrip covering the plain
scalar element types: author `tags String[]`/`scores Int[]` in PSL, migrate onto
real Postgres, insert, and SELECT back, asserting element-wise decode for
pg/text@1 and pg/int4@1.

Add an ORM-client read-back test: over the same PSL-authored contract, read the
native array columns through orm().<ns>.<model>.select(...).all(), proving the
ORM projects and decodes scalar many:true columns as JS arrays (not just to-many
relations). The PSL path yields a generic Contract<SqlStorage>, so accessors are
index signatures; the narrow row-type array inference is covered by the emitted
contract path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…ntract

Replace the runtime-authored, generically-typed ORM scalar-list read test with
a strongly-typed write->read round-trip over an emitted contract fixture.

The previous test authored the contract at runtime via authorSqlContractFromPsl,
yielding a widened Contract<SqlStorage> whose ORM namespace/model accessors were
index signatures (bracket access) and whose row type was loose; it also seeded
via a raw InsertAst rather than the ORM.

New coverage:
- Add fixture test/sql-orm-client/fixtures/scalar-lists/ (Item with tags
  String[] / scores Int[]) emitted to a committed contract.json + contract.d.ts.
  Storage columns are native arrays (pg/text@1 / pg/int4@1, many: true) and the
  generated types render each field as ReadonlyArray<...>.
- Wire the fixture's config into the integration `emit` script so it is
  deterministically regenerable and covered by fixtures:check.
- Rewrite orm-list-read.integration.test.ts to migrate the emitted contract onto
  a real Postgres database, seed a row through the typed ORM (db.public.Item
  .create with tags/scores arrays), read it back through dotted strongly-typed
  accessors, assert the whole row shape, and assert at the type level that the
  read row infers tags: ReadonlyArray<string> and scores: ReadonlyArray<number>.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
Remove two inline test comments that merely restated the field types /
assertion already visible in the code (no rationale carried).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
… migration)

Slice 2 lowered PSL scalar lists to native Postgres arrays, flipping the
telemetry-backend extensions/flags fields from jsonb to text[] and drifting
the emitted storageHash away from the committed migration and production DB.
Author both fields as Json so they emit as pg/jsonb@1 / jsonb again, restoring
the pre-slice-2 storageHash without touching the committed migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…d usage

Remove the capability-matrix field JSDocs (and the scalar-list gate comment)
that described how the field is produced (control stack / enrichContract) and
consumed (authoring-time gating) elsewhere. The `capabilities: CapabilityMatrix`
field and the gate diagnostic are self-documenting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
@SevInf SevInf force-pushed the scalar-lists-slice2 branch from f9eb395 to 31880a9 Compare July 2, 2026 17:07
SevInf and others added 2 commits July 2, 2026 17:07
…nage element checks

Collapse the two parallel check-constraint mechanisms into one and make the
scalar-array element-non-null CHECK drift-managed like enum value-set checks,
closing the L10 strict-verify gap.

- Discriminate SqlCheckConstraintIR(Input) into valueSet | expression leaves
  (abstract base + two frozen subclasses + `sqlCheckConstraintIR` factory).
- Payload-discriminate AddCheckConstraintCall / addCheckConstraint /
  migration descriptor: `{kind:'valueSet',column,values} | {kind:'expression',expression}`.
- Add one shared `projectContractChecks` + a single element-non-null
  name/predicate canonicalizer in the family layer; wire it into the verifier,
  expected-IR builder, and planner so all three agree byte-for-byte.
- Recognize `array_position(col, NULL) IS NULL` in parseCheckConstraintDef and
  re-canonicalize (handles the stored NULL::type cast + quoting) so drift is
  compared canonical-to-canonical.
- Generalize verifyCheckConstraints (expression equality, cross-kind mismatch,
  per-kind messages) and route element checks through the diff →
  AddCheckConstraintCall (expression) instead of inline CREATE TABLE synthesis.
- Remove the now-dead CheckExpressionConstraint path (DDL union, factory,
  renderers, SQLite throws), leaving one check-constraint mechanism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…0.15

Unifying the check-constraint mechanisms changed the migration builder call
`this.addCheckConstraint({column, values})` to a discriminated
`{payload: {kind: "valueSet", column, values}}` (and adds a `kind: "expression"`
form). Record the consumer transform + detection so the upgrade skill can migrate
hand-preserved migration files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
…tion CHECK recognizer

The Shape 3 array_position element-non-null recognizer used a cast
fragment `(?:::[^\s)]+)?` that stopped at the first whitespace, so
multi-word Postgres type names (e.g. `timestamp with time zone` for
DateTime[]) failed to match and the column dropped out of drift
management, breaking schema verification after migrate. Anchor the
cast with a non-greedy `(?:::.+?)?` up to the closing `) IS NULL`; the
cast is discarded and the predicate re-canonicalized, so the wider
match is safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
@SevInf SevInf force-pushed the scalar-lists-check-unify branch from fbeb7a1 to 95f4968 Compare July 2, 2026 17:07
Base automatically changed from scalar-lists-slice2 to main July 2, 2026 17:24
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.

1 participant