diff --git a/docs/architecture docs/adrs/ADR 306 - One plain-data definition and one builder per family.md b/docs/architecture docs/adrs/ADR 306 - One plain-data definition and one builder per family.md index 45c4c9375266..85f77a27ddc0 100644 --- a/docs/architecture docs/adrs/ADR 306 - One plain-data definition and one builder per family.md +++ b/docs/architecture docs/adrs/ADR 306 - One plain-data definition and one builder per family.md @@ -57,14 +57,14 @@ The builder checks the assembled contract last. Those refusals (kind `validator` - **PSL.** The interpreter reads the schema into the definition. It keeps the rules about how a schema is written, such as attribute arguments, `@id` placement, back-relation pairing and unresolved references. It reports each refusal as a diagnostic at the place its key names, with PSL's codes and wording. - **Prisma 6.** The reader builds with `writesCollectionValidators: false`, because a Prisma 6 database has none. It reports each refusal in its own diagnostics. -**The builder builds the definition it is given.** It checks the definition and refuses what is invalid, but it does not merge, drop or tidy what a source wrote. Tidying is a convenience of the authoring surface the user writes in. For example, Prisma 8 PSL and TypeScript `defineContract` merge an index the user wrote twice without a name to tell the two apart, and they do it before they call the builder. A source that reads an earlier Prisma version's schema exists to produce the contract of the database that version built, so `db sign` accepts it unchanged (ADR 252). It tidies nothing, and the builder keeps everything it describes, even an index the database holds that is redundant, such as an index on `[email, name]` beside one on `[email]`. Prisma 6 itself refuses two indexes on the same fields, so a Prisma 6 database never holds such a pair. MongoDB's builder still merges identical and redundant indexes itself, for every source, the Prisma 6 reader included; TML-3774 moves the merge into PSL and TypeScript and makes the Prisma 6 reader refuse two indexes on the same fields, as Prisma 6 does. SQL's builder still merges duplicate and redundant unnamed indexes itself, and the Prisma 7 reader escapes that only by naming every index; TML-3770 moves the merge into SQL's authoring sources. +**The builder builds the definition it is given.** It checks the definition and refuses what is invalid, but it does not merge, drop or tidy what a source wrote. Tidying is a convenience of the authoring surface the user writes in. For example, Prisma 8 PSL and TypeScript `defineContract` merge an index the user wrote twice without a name to tell the two apart, and they do it before they call the builder. A source that reads an earlier Prisma version's schema exists to produce the contract of the database that version built, so `db sign` accepts it unchanged (ADR 252). It tidies nothing, and the builder keeps everything it describes, even an index the database holds that is redundant, such as an index on `[email, name]` beside one on `[email]`. Prisma 6 itself refuses two indexes of one model, of any kind, whose field paths are the same, in the same order, with the same written sort (an unwritten sort is not `sort: Asc`), so a Prisma 6 database never holds such a pair. The Prisma 6 reader refuses the same pairs, as Prisma 6 does, and merges nothing. SQL's builder still merges duplicate and redundant unnamed indexes itself, and the Prisma 7 reader escapes that only by naming every index; TML-3770 moves the merge into SQL's authoring sources. ## Consequences - One rule, one implementation. A rule added to the builder applies to every source. - A source can report every refusal at once, at its own position, because refusals are data, not thrown errors. - Sources cannot differ in what they accept. A parity suite writes each case in TypeScript and in PSL and checks that the two emitted `contract.json` files are byte-identical. -- Index order is the source's own. The builder does not reorder indexes, because the storage hash depends on their order. When two indexes in one collection are identical, or one is a non-unique index made redundant by a unique index on the same keys and otherwise identical options, the builder removes one and keeps the survivor where it was written. That merge leaves the builder with TML-3774. +- Index order is the source's own. The builder does not reorder indexes, because the storage hash depends on their order. MongoDB PSL and TypeScript `defineContract` merge indexes before they call the builder, with `mergeRedundantIndexes`: when two indexes in one collection are identical, or one is a non-unique index made redundant by a unique index on the same keys and otherwise identical options, they remove one and keep the survivor where it was written. The builder itself refuses two identical indexes in one collection. SQL does not follow this ADR fully yet. Its builder throws its first refusal, so SQL PSL places only the errors it catches and matches (TML-3730). SQL PSL also adds polymorphism to the contract after the builder runs, instead of carrying it in the definition (TML-2228). diff --git a/docs/architecture docs/subsystems/10. MongoDB Family.md b/docs/architecture docs/subsystems/10. MongoDB Family.md index 128fe1aa3b02..6670d284f8f8 100644 --- a/docs/architecture docs/subsystems/10. MongoDB Family.md +++ b/docs/architecture docs/subsystems/10. MongoDB Family.md @@ -256,7 +256,7 @@ The builder decides these behaviours the same way for every source: | Behaviour | Every source | | --- | --- | -| Identical or redundant indexes in one collection, after variant scoping | Merged into one. Two indexes are identical when they have the same keys and options, counting an option set to `false` as unset (`indexLookupKey`, which the planner also uses). A non-unique index is redundant beside a unique one that is otherwise identical. The unique one survives, else the first, and it keeps the position where it was written. `@unique` and `@@index` on one field give one unique index. The merge runs before the second text index is refused | +| Two identical indexes in one collection, after variant scoping | Refused. Two indexes are identical when they have the same keys and options, counting an option set to `false` as unset (`indexLookupKey`, which the planner also uses). A unique and a plain index on the same keys are both kept | | `extensions` | The stack's extension packs | | Roots | Explicit roots when the source gives them, else one per model with a collection, no owner and no base | | A variant's collection | Its base's; another collection is refused | @@ -268,6 +268,8 @@ The builder decides these behaviours the same way for every source: | Generated values | As on SQL: an optional generated field is accepted; a list that generates a value is refused | | Index order | Each source's own; the builder does not reorder, because the storage hash depends on the order | +MongoDB PSL and TypeScript `defineContract` merge indexes before they call the builder, with `mergeRedundantIndexes` from `@internal/mongo-contract-ts`. Of two identical indexes in one collection the first is kept. A non-unique index beside a unique one that is otherwise identical is removed, so `@unique` and `@@index` on one field give one unique index. The index that is kept stays where it was written. The Prisma 6 reader merges nothing: it refuses two indexes of one model with the same fields and the same written sort, as Prisma 6 does (`PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS`). + A parity suite, `test/integration/test/authoring/mongo-parity.test.ts`, writes each case in TypeScript and in PSL and checks that the two emitted `contract.json` files are byte-identical. ## Contract diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 12694ed39b10..359e2347dadb 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -535,7 +535,7 @@ The contract module at the configured path loads but exposes neither a `default` ### CONTRACT.NAME_DUPLICATE -Two declarations claim the same name: duplicate namespace entries, model names, value objects, relations, tables (two models mapping to one table, two table nodes for one table, or duplicate table in a namespace), column mappings (two fields to one column, a field and a table node's column node for one column, or two column nodes for one column), stored names (two fields of one MongoDB model stored under one name with `.mongo({ field })`: `Model "" stores both "" and "" as "".`, `kind: 'field'`), value-sets (enum and pack entity minting the same value-set), or pack entities of the same kind and name in one namespace. A Postgres extension is unique across the whole contract: one declared in two namespaces is refused while building a contract and again when a contract is read (`kind: 'extension'`, `namespaces`). Payload: `kind`, `name`, `namespaceId`, `tableName` (for a column), `first`, `second`. A MongoDB variant field stored under a name a field of its base is stored under, the discriminator included, is refused too (`reason: 'stored-name-shared-with-base'`, with `first` the base's field and `second` the variant's): a variant's documents hold its base's fields, so the two would overwrite each other. Mongo PSL reports it as `PSL_DUPLICATE_STORED_NAME`. +Two declarations claim the same name: duplicate namespace entries, model names, value objects, relations, tables (two models mapping to one table, two table nodes for one table, or duplicate table in a namespace), column mappings (two fields to one column, a field and a table node's column node for one column, or two column nodes for one column), stored names (two fields of one MongoDB model stored under one name with `.mongo({ field })`: `Model "" stores both "" and "" as "".`, `kind: 'field'`), indexes (two identical indexes in one MongoDB collection: `kind: 'index'`, with `collection`; TypeScript `defineContract` merges identical indexes before the build, so only a definition passed to `buildMongoContractFromDefinition` directly reaches this), value-sets (enum and pack entity minting the same value-set), or pack entities of the same kind and name in one namespace. A Postgres extension is unique across the whole contract: one declared in two namespaces is refused while building a contract and again when a contract is read (`kind: 'extension'`, `namespaces`). Payload: `kind`, `name`, `namespaceId`, `tableName` (for a column), `first`, `second`. A MongoDB variant field stored under a name a field of its base is stored under, the discriminator included, is refused too (`reason: 'stored-name-shared-with-base'`, with `first` the base's field and `second` the variant's): a variant's documents hold its base's fields, so the two would overwrite each other. Mongo PSL reports it as `PSL_DUPLICATE_STORED_NAME`. ### CONTRACT.NAMESPACE_INVALID @@ -715,6 +715,17 @@ The model's `@id` is not a required `String @db.ObjectId` stored as `_id`. Decla An `@ignore`d field is used by `@unique`, `@@unique`, `@@index`, `@@fulltext`, or a relation's `fields:` or `references:`, and Prisma 6 still creates that index or reads that key. Remove `@ignore` from the field, which adds it to the Prisma 6 client, or remove what uses it: removing an index makes Prisma 6 `db push` drop it, and removing a relation field removes that relation from the Prisma 6 client. Reported by the Prisma 6 MongoDB contract source (`prisma6Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +### PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS + +A Prisma 6 schema holds an index that already exists, in one of two senses. Remove one of the two indexes. + +- **Prisma 6 refuses the pair.** Two indexes of one model, of any kind (`@unique`, `@@unique`, `@@index`, `@@fulltext`), whose fields have, position by position, the same names and the same written sort, such as `@unique` and `@@index([email])` on one field. An unwritten sort is not the same as `sort: Asc`. Prisma 6 refuses such a schema with "Index already exists in the model.", so no Prisma 6 database holds the pair. The code is reported at each index of the pair; the message names the other index, written as the schema writes it with any sort, and quotes Prisma 6. +- **MongoDB cannot hold the pair.** Two indexes that Prisma 6 accepts but that are identical in MongoDB, with the same keys and options, in one collection: `@@index([name])` beside `@@index([name(sort: Asc)])`, or the same index on two models stored in one collection. The code is reported at the second index, and the message names both. + +The same fields in another order or with another sort are accepted. So is `@unique` beside `@@index([email(sort: Asc)])`, which gives a unique and a plain index on the same keys, as Prisma 6 `db push` creates them. + +Reported by the Prisma 6 MongoDB contract source (`prisma6Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. + ### PSL.PRISMA6_MONGO_INDEX_ARGUMENT_UNSUPPORTED An index argument a Mongo contract index cannot carry, such as `length`, an unknown argument, or a dotted path whose first segment is not a composite-type field (`title.first`), which Prisma 6 refuses as an unknown field. `sort` is read, and `map` and `name` are accepted and dropped, since Mongo verify matches indexes by keys and options. Remove the argument. Reported by the Prisma 6 MongoDB contract source (`prisma6Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. diff --git a/drive/calibration/dod.md b/drive/calibration/dod.md index 2b99a90b3e31..ccaee4f98ea6 100644 --- a/drive/calibration/dod.md +++ b/drive/calibration/dod.md @@ -83,7 +83,7 @@ In addition to the canonical slice DoD: - [ ] After merging `main`: if the merge resolved conflicts with a change `main` made to the same code, run a review round on `git diff
` before asking for review ([`failure-modes.md § F51`](./failure-modes.md#f51-a-merge-of-main-that-resolves-a-large-overlapping-change-goes-to-review-as-if-it-were-not-new-work)); if `main` had a release bump, `git diff HEAD -- upgrade-instructions/releases/` is empty ([`F52`](./failure-modes.md#f52-a-release-bump-archives-pending-upgrade-instructions-and-the-branchs-later-edits-land-in-the-released-copy)). (Added 2026-10-28, datetime-convenience-types.) - [ ] If the slice changes what an existing type name means: every PSL and TypeScript source under `test/` and `examples/` that writes the name is checked, and the integration files that emit them are run ([`F53`](./failure-modes.md#f53-a-name-changes-meaning-and-a-hand-written-source-that-fixturescheck-does-not-emit-keeps-the-old-meaning)). (Added 2026-10-28, datetime-convenience-types.) -- [ ] Before enabling auto-merge: every review thread on the PR is resolved, by a fix or by a reply that says why not. List the open ones with `gh api graphql --paginate -F number= -f query='query($number:Int!,$endCursor:String){repository(owner:"prisma",name:"orm"){pullRequest(number:$number){reviewThreads(first:100,after:$endCursor){pageInfo{hasNextPage endCursor} nodes{id isResolved path}}}}}' --jq '.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)|"\(.id) \(.path)"'`, which prints nothing when every thread is resolved; bot reviewers count. (Added 2026-10-27, mongo-authoring-structure: slice 1 merged with four open CodeRabbit threads, found only at close-out.) +- [ ] Before enabling auto-merge: every review thread on the PR is resolved, by a fix or by a reply that says why not; bot reviewers count. The command that lists open threads is in [`pr/README.md § Merge queue needs every review thread resolved`](../pr/README.md#merge-queue-needs-every-review-thread-resolved). (Added 2026-10-27, mongo-authoring-structure: slice 1 merged with four open CodeRabbit threads, found only at close-out.) - [ ] If the slice changes code that `defineContract`, a pack descriptor or the runtime reaches: run `pnpm size:build` in `examples/bundle-size` (it builds the workspace, then bundles) on the branch and on the PR base ref (`main`, or the base branch of a stacked PR), compare the metafiles, and explain any growth in the PR description before CI's size report asks. (Added 2026-10-27, mongo-authoring-structure: both slices grew a client bundle and the cause was traced only after CI reported it.) ### QA-side items diff --git a/drive/pr/README.md b/drive/pr/README.md index 05c765e0df02..663fa6c227bb 100644 --- a/drive/pr/README.md +++ b/drive/pr/README.md @@ -24,7 +24,7 @@ A PR open against the wrong branch is recoverable but visible; this gate costs n ## Merge queue needs every review thread resolved -The `main` ruleset requires conversation resolution. A PR with auto-merge enabled and every check green still sits at `BLOCKED` while any review thread is unresolved, including CodeRabbit's. Before enabling auto-merge, list unresolved threads: `reviewThreads` takes no `isResolved` filter, so query `reviewThreads(first: 100) { pageInfo { hasNextPage endCursor } nodes { id isResolved } }` through the GraphQL API, page with `after:` while `hasNextPage` is true, and keep the nodes whose `isResolved` is false. Then reply to each with the fix commit or the reason for no change, and resolve them. Seen on #30350 and #30380 (2026-09-22/23). +The `main` ruleset requires conversation resolution. A PR with auto-merge enabled and every check green still sits at `BLOCKED` while any review thread is unresolved, including CodeRabbit's. Before enabling auto-merge, list the unresolved threads. `reviewThreads` takes no `isResolved` filter, so page through every thread and keep the unresolved ones: `gh api graphql --paginate -F number= -f query='query($number:Int!,$endCursor:String){repository(owner:"prisma",name:"orm"){pullRequest(number:$number){reviewThreads(first:100,after:$endCursor){pageInfo{hasNextPage endCursor} nodes{id isResolved path}}}}}' --jq '.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)|"\(.id) \(.path)"'`. It prints nothing when every thread is resolved. Reply to each open thread with the fix commit or the reason for no change, and resolve it. Seen on #30350 and #30380 (2026-09-22/23). ## PR title convention diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/README.md b/packages/2-mongo-family/2-authoring/contract-prisma6/README.md index 56d70af279a3..1bf954aa76e5 100644 --- a/packages/2-mongo-family/2-authoring/contract-prisma6/README.md +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/README.md @@ -7,7 +7,7 @@ Reads a Prisma 6 MongoDB `schema.prisma` as a Prisma 8 contract source. During t ## Responsibilities - `prisma6Contract(path, options)` returns a `ContractConfig` (format `psl`, because a Prisma 6 schema is PSL text) whose `source.load` reads the input and parses every `.prisma` file with `@internal/psl-parser` (no `// use prisma-8` directive is needed). The source declares `parserOptions: { grammar: 'prisma-7' }` (Prisma 6 schemas use the Prisma 7 grammar), so the parser reads a `view` body as model fields and the reader can refuse the view by name. A file input reads that file; a directory input reads every regular `.prisma` file under it, nested directories and symbolic links included, sorted by path. The default `output` is `contract.json` beside the input; `options.output` overrides it. -- The reader reads the schema into a `ContractDefinition` and builds it with `buildMongoContractFromDefinition` from `@internal/mongo-contract-ts`, the builder every MongoDB source calls, pairing back-relations with `pairMongoBackRelations` from `@internal/mongo-contract-psl` first. It reports each refusal of the builder in its own diagnostics at the field, index or enum line it is about (`src/refusal-diagnostics.ts`), and checks the contract as `contract emit` does. Two identical indexes in one collection, such as `@unique` and `@@unique` on one field, become one index before the build, because the database holds one index for both. It never rewrites the schema into Prisma 8 PSL. Every construct it does not support is a diagnostic with a span; nothing is changed silently, and a schema with any diagnostic gives no contract. Diagnostics are listed in source order (files in the order they are read, then by position). A schema whose datasource is not MongoDB gets only `PSL.PRISMA6_MONGO_PROVIDER_MISMATCH` (and any parse errors); its models are not read. +- The reader reads the schema into a `ContractDefinition` and builds it with `buildMongoContractFromDefinition` from `@internal/mongo-contract-ts`, the builder every MongoDB source calls, pairing back-relations with `pairMongoBackRelations` from `@internal/mongo-contract-psl` first. It reports each refusal of the builder in its own diagnostics at the field, index or enum line it is about (`src/refusal-diagnostics.ts`), and checks the contract as `contract emit` does. It merges no indexes. It refuses what Prisma 6 refuses with "Index already exists in the model.": two indexes of one model, of any kind (`@unique`, `@@unique`, `@@index`, `@@fulltext`), whose fields have, position by position, the same names and the same written sort. An unwritten sort is not the same as `sort: Asc`. Each index of such a pair is reported, with `PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS`. Two indexes Prisma 6 accepts but MongoDB cannot hold, because they are identical (same keys and options) in one collection, such as `@@index([name])` beside `@@index([name(sort: Asc)])` or the same index on two models stored in one collection, are refused by the builder and reported with the same code, naming both indexes. A unique and a plain index that Prisma 6 tells apart, such as `@unique` beside `@@index([email(sort: Asc)])`, are both kept. It never rewrites the schema into Prisma 8 PSL. Every construct it does not support is a diagnostic with a span; nothing is changed silently, and a schema with any diagnostic gives no contract. Diagnostics are listed in source order (files in the order they are read, then by position). A schema whose datasource is not MongoDB gets only `PSL.PRISMA6_MONGO_PROVIDER_MISMATCH` (and any parse errors); its models are not read. - The contract carries no `$jsonSchema` validators: the reader builds with `writesCollectionValidators: false`, because Prisma 6 databases have none, and verify reports a declared validator that the database lacks. Only the refusals about listing an enum in a validator are left out; the `_id`, codec data type and embedded variant refusals still apply. - `Prisma6TargetBinding` (`src/target-binding.ts`) declares the target facts: the datasource providers, the codec for each Prisma 6 scalar, the ObjectId codec, and the timestamp generator. The Mongo target's binding is `prisma6MongoBinding` in `@internal/target-mongo/prisma6-binding`. @@ -20,7 +20,7 @@ Reads a Prisma 6 MongoDB `schema.prisma` as a Prisma 8 contract source. During t | Scalars, native types, lists, composite types, enums | The codecs the binding names, each the codec for the BSON type Prisma 6 stores with the application type the Prisma 6 client presents: a plain `Int` and `Int @db.Long` are a BSON long read as a `number`, so the Mongo binding maps them to `mongo/int64Number@1`; `@db.Int` maps to `mongo/int32@1`, and `BigInt` to `mongo/int64@1` (a `bigint`). Every native type Prisma 6.19 accepts on MongoDB has a row in the binding's native-type table (`@db.String`, `@db.ObjectId` on `String` and `Bytes`, `@db.Bool`, `@db.Int`, `@db.Long` on `Int` and `BigInt`, `@db.Double`, `@db.Date`, `@db.BinData`, `@db.Json`) except `DateTime @db.Timestamp`, whose BSON timestamp no Prisma 8 codec reads; it and any native type Prisma 6 rejects are `PSL.PRISMA6_MONGO_NATIVE_TYPE_UNSUPPORTED`. `many: true` for lists; value objects; enums with the text codec and the member `@map` value. | | `DateTime @default(now())`, `DateTime @updatedAt` | `onCreate`, or `onCreate` and `onUpdate`, with the timestamp generator. Other defaults, optional generated fields, and `@updatedAt` on other types are hard errors. | | `@relation(fields, references)` on a to-one field, back-relations | `N:1`, paired as the Prisma 8 Mongo interpreter pairs them. Referential actions and list relations with keys are hard errors. | -| `@unique`, `@@unique`, `@@index`, `@@fulltext` | Indexes, with `sort:`; names are dropped; two identical indexes in one collection become one; `length:` and a second `@@fulltext` in a collection are hard errors. | +| `@unique`, `@@unique`, `@@index`, `@@fulltext` | Indexes, with `sort:`; names are dropped; two indexes of one model with the same fields in the same order and the same written sort, whatever their kind, are `PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS` at each of them, as in Prisma 6 (an unwritten sort differs from `sort: Asc`); the same fields in another order or with another sort are accepted; `length:` and a second `@@fulltext` in a collection are hard errors. | | Index path into a composite type, `@@index([address.city])` or `@@index([address.city(sort: Asc)])` | `PSL.PRISMA6_MONGO_COMPOSITE_INDEX_PATH_UNSUPPORTED`. A dotted path whose first segment is not a composite-type field (`@@index([title.first])`) is `PSL.PRISMA6_MONGO_INDEX_ARGUMENT_UNSUPPORTED`, as Prisma 6 refuses it too. | | `@map`, `@@map`, `@ignore`, `@@ignore` | Stored names; ignored fields and models are omitted. | | `@@schema`, `view`, unknown attributes and blocks | Hard errors. | diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/src/diagnostics.ts b/packages/2-mongo-family/2-authoring/contract-prisma6/src/diagnostics.ts index de6702a08e88..37241aa5dfc0 100644 --- a/packages/2-mongo-family/2-authoring/contract-prisma6/src/diagnostics.ts +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/src/diagnostics.ts @@ -16,6 +16,7 @@ type Prisma6MongoSubcode = | 'REFERENTIAL_ACTION_UNSUPPORTED' | 'LIST_RELATION_UNSUPPORTED' | 'INDEX_ARGUMENT_UNSUPPORTED' + | 'INDEX_ALREADY_EXISTS' | 'TEXT_INDEX_LIMIT' | 'GENERATED_VALUE_CONFLICT' | 'IGNORED_FIELD_REFERENCED' diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-prisma6/src/interpreter.ts index ec61aa6efa05..49feb8bcb535 100644 --- a/packages/2-mongo-family/2-authoring/contract-prisma6/src/interpreter.ts +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/src/interpreter.ts @@ -72,7 +72,12 @@ import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok, type Result } from '@internal/utils/result'; import { basename } from 'pathe'; import { prisma6Diagnostic } from './diagnostics'; -import { type IndexAttribute, parseFieldUnique, parseIndexAttribute } from './indexes'; +import { + type IndexAttribute, + type IndexDirection, + parseFieldUnique, + parseIndexAttribute, +} from './indexes'; import { fieldSiteKey, type IndexSite, @@ -1487,6 +1492,24 @@ function readRelationFields( } } +/** + * The indexes of one model that Prisma 6 refuses with "Index already exists in the model.": each index whose fields, in order, have the same names and the same written sort as another index of the model, whatever the kind of either index. An unwritten sort differs from `sort: Asc`. + */ +function clashingIndexes( + attributes: readonly IndexAttribute[], +): ReadonlyMap { + const writtenKey = (attribute: IndexAttribute) => + JSON.stringify(attribute.fields.map((field) => [field.name, field.direction ?? null])); + const clashes = new Map(); + for (const attribute of attributes) { + const others = attributes.filter( + (other) => other !== attribute && writtenKey(other) === writtenKey(attribute), + ); + if (others.length > 0) clashes.set(attribute, others); + } + return clashes; +} + /** A model's indexes as the builder takes them, with where each is written. */ function buildIndexes( build: ModelBuild, @@ -1495,7 +1518,9 @@ function buildIndexes( const { symbol, sourceId } = build.located; const indexes: (IndexSite & { readonly index: IndexNode })[] = []; let textIndexes = 0; - for (const attribute of [...build.uniqueFields, ...build.modelIndexes]) { + const attributes = [...build.uniqueFields, ...build.modelIndexes]; + const clashing = clashingIndexes(attributes); + for (const attribute of attributes) { const names = attribute.fields.map((field) => field.name); const usedBy: IgnoredFieldUse = { kind: 'index', @@ -1521,6 +1546,19 @@ function buildIndexes( }); continue; } + const clashesWith = clashing.get(attribute); + if (clashesWith !== undefined) { + const others = clashesWith.map((other) => indexLabel(build, other)).join(' and '); + diagnostics.push( + prisma6Diagnostic( + 'PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS', + `${indexLabel(build, attribute)} has the same fields, with the same sort, as ${others}. Prisma 6 refuses this: "Index already exists in the model." Remove one of them.`, + sourceId, + attribute.span, + ), + ); + continue; + } const text = attribute.kind === 'fulltext'; if (text) { textIndexes++; @@ -1529,17 +1567,36 @@ function buildIndexes( continue; } } + const keys = attribute.fields.map((field) => [ + field.name, + field.direction ?? (text ? 'text' : 1), + ]); const index: IndexNode = { - fields: Object.fromEntries( - attribute.fields.map((field) => [field.name, field.direction ?? (text ? 'text' : 1)]), - ), + fields: Object.fromEntries(keys), ...(attribute.kind === 'unique' ? { options: { unique: true } } : {}), }; - indexes.push({ index, sourceId, span: attribute.span }); + indexes.push({ index, sourceId, span: attribute.span, label: indexLabel(build, attribute) }); } return indexes; } +function sortOf(direction: IndexDirection | undefined): string { + if (direction === undefined) { + return ''; + } + return `(sort: ${direction === 1 ? 'Asc' : 'Desc'})`; +} + +function indexLabel(build: ModelBuild, attribute: IndexAttribute): string { + const model = build.located.symbol.name; + if (build.uniqueFields.includes(attribute)) { + const [field] = attribute.fields; + return `@unique${sortOf(field?.direction)} on field "${model}.${field?.name ?? ''}"`; + } + const fields = attribute.fields.map(({ name, direction }) => `${name}${sortOf(direction)}`); + return `@@${attribute.kind}([${fields.join(', ')}]) on model "${model}"`; +} + function textIndexLimitDiagnostic( modelName: string, sourceId: string, diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/src/refusal-diagnostics.ts b/packages/2-mongo-family/2-authoring/contract-prisma6/src/refusal-diagnostics.ts index a0974e3cb3c0..46d568d4e5f7 100644 --- a/packages/2-mongo-family/2-authoring/contract-prisma6/src/refusal-diagnostics.ts +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/src/refusal-diagnostics.ts @@ -8,12 +8,17 @@ import type { PslSpan } from '@internal/psl-parser'; import { prisma6Diagnostic } from './diagnostics'; /** Where a part of a Prisma 6 schema is written. */ -export interface IndexSite { +export interface Site { readonly sourceId: string; readonly span: PslSpan; } -export interface FieldSite extends IndexSite { +export interface IndexSite extends Site { + /** How the schema writes the index, such as `@@index([email]) on model "User"`. */ + readonly label: string; +} + +export interface FieldSite extends Site { readonly storedName: string; } @@ -81,6 +86,17 @@ export function refusalDiagnostic( site.span, ); } + case 'indexDuplicate': { + const site = sites.indexes.get(refusal.key.model)?.[refusal.key.position]; + const first = sites.indexes.get(refusal.firstModel)?.[refusal.firstPosition]; + if (site === undefined || first === undefined) break; + return prisma6Diagnostic( + 'PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS', + `${site.label} and ${first.label} are the same index, with the same keys and options, in collection "${refusal.collection}". MongoDB cannot hold two identical indexes in one collection. Remove one of them.`, + site.sourceId, + site.span, + ); + } case 'generatedConflict': { const site = fieldSiteOf(sites, refusal.key); if (site === undefined) break; diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/fulltext-and-index/schema.prisma b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/fulltext-and-index/schema.prisma new file mode 100644 index 000000000000..e16273b6686f --- /dev/null +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/fulltext-and-index/schema.prisma @@ -0,0 +1,12 @@ +datasource db { + provider = "mongodb" + url = env("DATABASE_URL") +} + +model Post { + id String @id @default(auto()) @map("_id") @db.ObjectId + title String + + @@index([title]) + @@fulltext([title]) +} diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/index-on-two-models/schema.prisma b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/index-on-two-models/schema.prisma new file mode 100644 index 000000000000..5dd6d4d3b688 --- /dev/null +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/index-on-two-models/schema.prisma @@ -0,0 +1,20 @@ +datasource db { + provider = "mongodb" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(auto()) @map("_id") @db.ObjectId + email String + + @@index([email]) + @@map("people") +} + +model Admin { + id String @id @default(auto()) @map("_id") @db.ObjectId + email String + + @@index([email]) + @@map("people") +} diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/same-fields-other-order/schema.prisma b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/same-fields-other-order/schema.prisma new file mode 100644 index 000000000000..886ab75f35d7 --- /dev/null +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/same-fields-other-order/schema.prisma @@ -0,0 +1,14 @@ +datasource db { + provider = "mongodb" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(auto()) @map("_id") @db.ObjectId + email String @unique(sort: Desc) + name String + + @@index([email, name]) + @@index([name, email]) + @@index([email]) +} diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/unique-and-index-sort-asc/schema.prisma b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/unique-and-index-sort-asc/schema.prisma new file mode 100644 index 000000000000..8861480005a7 --- /dev/null +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/unique-and-index-sort-asc/schema.prisma @@ -0,0 +1,11 @@ +datasource db { + provider = "mongodb" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(auto()) @map("_id") @db.ObjectId + email String @unique + + @@index([email(sort: Asc)]) +} diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/unique-sort-asc-and-unique/schema.prisma b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/unique-sort-asc-and-unique/schema.prisma new file mode 100644 index 000000000000..d50925b89709 --- /dev/null +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder-cases/unique-sort-asc-and-unique/schema.prisma @@ -0,0 +1,11 @@ +datasource db { + provider = "mongodb" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(auto()) @map("_id") @db.ObjectId + email String @unique(sort: Asc) + + @@unique([email]) +} diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder.test.ts b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder.test.ts index 954731688a24..bb44bf4b3145 100644 --- a/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/test/builder.test.ts @@ -6,48 +6,154 @@ import { loadPrisma6Schema, serializeMongoContract } from './support'; const casesDir = join(dirname(fileURLToPath(import.meta.url)), 'builder-cases'); describe('the Prisma 6 reader on the contract builder', () => { + function indexesOf(serialized: unknown, collection: string): unknown { + return ( + serialized as { + storage: { + namespaces: { + __unbound__: { entries: { collection: Record } }; + }; + }; + } + ).storage.namespaces.__unbound__.entries.collection[collection]?.indexes; + } + + async function diagnosticsOf(caseName: string) { + const result = await loadPrisma6Schema(join(casesDir, caseName, 'schema.prisma')); + if (result.ok) throw new Error('Expected the schema to be refused'); + return result.failure.diagnostics.map(({ code, message, span }) => ({ + code, + message, + line: span?.start.line, + })); + } + it.each<[string, string, string, readonly unknown[]]>([ [ - '@unique and @@unique on one field', - 'unique-twice', + 'the same fields in another order or with another sort', + 'same-fields-other-order', 'User', - [{ kind: 'mongo-index', keys: [{ field: 'email', direction: 1 }], unique: true }], + [ + { kind: 'mongo-index', keys: [{ field: 'email', direction: -1 }], unique: true }, + { + kind: 'mongo-index', + keys: [ + { field: 'email', direction: 1 }, + { field: 'name', direction: 1 }, + ], + }, + { + kind: 'mongo-index', + keys: [ + { field: 'name', direction: 1 }, + { field: 'email', direction: 1 }, + ], + }, + { kind: 'mongo-index', keys: [{ field: 'email', direction: 1 }] }, + ], ], [ - '@unique and @@index on one field', - 'unique-and-index', + '@unique and @@index with sort: Asc on one field, which Prisma 6 tells apart', + 'unique-and-index-sort-asc', 'User', - [{ kind: 'mongo-index', keys: [{ field: 'email', direction: 1 }], unique: true }], - ], - [ - '@@fulltext on one field of two models in one collection', - 'fulltext-on-two-models', - 'people', - [{ kind: 'mongo-index', keys: [{ field: 'name', direction: 'text' }] }], + [ + { kind: 'mongo-index', keys: [{ field: 'email', direction: 1 }], unique: true }, + { kind: 'mongo-index', keys: [{ field: 'email', direction: 1 }] }, + ], ], [ "@unique on a later model and @@index on an earlier model's field in one collection", 'unique-on-later-model', 'people', [ + { kind: 'mongo-index', keys: [{ field: 'email', direction: 1 }] }, { kind: 'mongo-index', keys: [{ field: 'name', direction: 1 }] }, { kind: 'mongo-index', keys: [{ field: 'email', direction: 1 }], unique: true }, ], ], - ])('merges an identical or redundant index for %s', async (_, caseName, collection, indexes) => { + ])('keeps every index for %s', async (_, caseName, collection, indexes) => { const result = await loadPrisma6Schema(join(casesDir, caseName, 'schema.prisma')); if (!result.ok) throw new Error(JSON.stringify(result.failure.diagnostics)); - const serialized = serializeMongoContract(result.value) as { - storage: { - namespaces: { - __unbound__: { entries: { collection: Record } }; - }; - }; - }; - expect( - serialized.storage.namespaces.__unbound__.entries.collection[collection]?.indexes, - ).toEqual(indexes); + expect(indexesOf(serializeMongoContract(result.value), collection)).toEqual(indexes); + }); + + it.each<[string, string, readonly [readonly [number, string], readonly [number, string]]]>([ + [ + '@unique and @@unique on one field', + 'unique-twice', + [ + [8, '@unique on field "User.email"'], + [10, '@@unique([email]) on model "User"'], + ], + ], + [ + '@unique and @@index on one field', + 'unique-and-index', + [ + [8, '@unique on field "User.email"'], + [10, '@@index([email]) on model "User"'], + ], + ], + [ + '@@index and @@fulltext on one field', + 'fulltext-and-index', + [ + [10, '@@index([title]) on model "Post"'], + [11, '@@fulltext([title]) on model "Post"'], + ], + ], + ])( + 'refuses %s at both indexes, naming the other, as Prisma 6 does', + async (_, caseName, [first, second]) => { + const message = (index: string, other: string) => + `${index} has the same fields, with the same sort, as ${other}. Prisma 6 refuses this: "Index already exists in the model." Remove one of them.`; + expect(await diagnosticsOf(caseName)).toEqual([ + { + code: 'PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS', + message: message(first[1], second[1]), + line: first[0], + }, + { + code: 'PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS', + message: message(second[1], first[1]), + line: second[0], + }, + ]); + }, + ); + + it('refuses @unique(sort: Asc) beside @@unique on one field, which MongoDB holds as one index, showing the written sort', async () => { + expect(await diagnosticsOf('unique-sort-asc-and-unique')).toEqual([ + { + code: 'PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS', + message: + '@@unique([email]) on model "User" and @unique(sort: Asc) on field "User.email" are the same index, with the same keys and options, in collection "User". MongoDB cannot hold two identical indexes in one collection. Remove one of them.', + line: 10, + }, + ]); + }); + + it('refuses the same @@index on two models in one collection, naming both', async () => { + expect(await diagnosticsOf('index-on-two-models')).toEqual([ + { + code: 'PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS', + message: + '@@index([email]) on model "Admin" and @@index([email]) on model "User" are the same index, with the same keys and options, in collection "people". MongoDB cannot hold two identical indexes in one collection. Remove one of them.', + line: 18, + }, + ]); + }); + + it('refuses @@fulltext on one field of two models in one collection', async () => { + expect(await diagnosticsOf('fulltext-on-two-models')).toEqual([ + { + code: 'PSL.PRISMA6_MONGO_TEXT_INDEX_LIMIT', + message: + 'Model "Admin" has a @@fulltext, but another model stored in the same collection has one too; MongoDB allows one text index per collection. Merge the fields into one @@fulltext.', + line: 18, + }, + ]); }); it('reports a builder refusal where the schema writes its subject', async () => { diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/test/fixtures.test.ts b/packages/2-mongo-family/2-authoring/contract-prisma6/test/fixtures.test.ts index 7fa572b5e7d2..cae5ca3a4acc 100644 --- a/packages/2-mongo-family/2-authoring/contract-prisma6/test/fixtures.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/test/fixtures.test.ts @@ -17,6 +17,7 @@ const CASES: readonly string[] = [ 'id-not-objectid', 'ignore', 'ignored-field-referenced', + 'index-already-exists', 'index-argument-unsupported', 'indexes', 'list-relation-unsupported', diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/test/fixtures/index-already-exists/expected-diagnostics.json b/packages/2-mongo-family/2-authoring/contract-prisma6/test/fixtures/index-already-exists/expected-diagnostics.json new file mode 100644 index 000000000000..447839e14bc1 --- /dev/null +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/test/fixtures/index-already-exists/expected-diagnostics.json @@ -0,0 +1,20 @@ +[ + { + "code": "PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS", + "file": "schema.prisma", + "line": 8, + "message": "@unique on field \"User.email\" has the same fields, with the same sort, as @@unique([email]) on model \"User\". Prisma 6 refuses this: \"Index already exists in the model.\" Remove one of them." + }, + { + "code": "PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS", + "file": "schema.prisma", + "line": 11, + "message": "@@unique([email]) on model \"User\" has the same fields, with the same sort, as @unique on field \"User.email\". Prisma 6 refuses this: \"Index already exists in the model.\" Remove one of them." + }, + { + "code": "PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS", + "file": "schema.prisma", + "line": 13, + "message": "@@index([name]) on model \"User\" and @@index([name(sort: Asc)]) on model \"User\" are the same index, with the same keys and options, in collection \"User\". MongoDB cannot hold two identical indexes in one collection. Remove one of them." + } +] diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/test/fixtures/index-already-exists/schema.prisma b/packages/2-mongo-family/2-authoring/contract-prisma6/test/fixtures/index-already-exists/schema.prisma new file mode 100644 index 000000000000..1a8323f28a6b --- /dev/null +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/test/fixtures/index-already-exists/schema.prisma @@ -0,0 +1,14 @@ +datasource db { + provider = "mongodb" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(auto()) @map("_id") @db.ObjectId + email String @unique + name String + + @@unique([email]) + @@index([name(sort: Asc)]) + @@index([name]) +} diff --git a/packages/2-mongo-family/2-authoring/contract-psl/README.md b/packages/2-mongo-family/2-authoring/contract-psl/README.md index fab717e7a3fd..b6b9d6da0a22 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/README.md +++ b/packages/2-mongo-family/2-authoring/contract-psl/README.md @@ -4,7 +4,7 @@ PSL-to-Mongo contract interpreter for Prisma 8. Transforms Prisma Schema Languag ## Responsibilities -- **PSL interpretation**: `interpretPslDocumentToMongoContract()` reads a parsed PSL document into a `ContractDefinition` and builds it with `buildMongoContractFromDefinition` from `@internal/mongo-contract-ts`, the builder every MongoDB source calls. The interpreter keeps the rules about how a schema is written: attribute arguments, `@id` placement, back-relation pairing, type alias attributes and unresolved references. The builder checks every other rule, and `src/refusal-diagnostics.ts` reports each of its refusals at the field, index, `@@discriminator`, `@@base` value or enum line its key names, with PSL's codes and wording. A field's own `@unique` comes first in a model's indexes, then `@@index`, `@@unique` and `@@textIndex` in the order they are written, and variants come after their base, so each collection lists its indexes as before +- **PSL interpretation**: `interpretPslDocumentToMongoContract()` reads a parsed PSL document into a `ContractDefinition` and builds it with `buildMongoContractFromDefinition` from `@internal/mongo-contract-ts`, the builder every MongoDB source calls. The interpreter keeps the rules about how a schema is written: attribute arguments, `@id` placement, back-relation pairing, type alias attributes and unresolved references. The builder checks every other rule, and `src/refusal-diagnostics.ts` reports each of its refusals at the field, index, `@@discriminator`, `@@base` value or enum line its key names, with PSL's codes and wording. A field's own `@unique` comes first in a model's indexes, then `@@index`, `@@unique` and `@@textIndex` in the order they are written, and variants come after their base, so each collection lists its indexes as before. Before the build, the interpreter merges identical and redundant indexes with `mergeRedundantIndexes` from `@internal/mongo-contract-ts`, so `@unique` and `@@index` on one field give one unique index - **Attribute registry**: `mongoAttributeSpecs` registers every Mongo built-in (`@@map`, `@@discriminator`, `@@base`, `@@index`, `@@unique`, `@@textIndex`, `@id`, `@unique`, `@map`, `@relation`) as spec factories over the uniform `AttributeSpecContext`; the family descriptor contributes it under `authoring.attributeSpecs`, and the interpreter sources every spec from it - **Scalar type mapping**: `createMongoScalarTypeDescriptors()` provides the default PSL-type → Mongo codec ID mapping (e.g. `String` → `mongo/string@1`, `ObjectId` → `mongo/objectId@1`) - **Contract provider**: `mongoContract(path, { target, extensionPacks? })` (exported from `./provider`) takes the stack's target pack and extension packs, which the contract records, and integrates with the CLI's `prisma contract emit` command, reading a `.prisma` schema file and producing a `ContractConfig` diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts index 135e653d9ce1..aba62b56cdcd 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts @@ -42,7 +42,9 @@ import { type FieldNode, type IndexNode, type ModelNode, + mergeRedundantIndexes, type RelationNode, + type RemovedIndex, type ValueObjectNode, } from '@internal/mongo-contract-ts/contract-builder'; import type { @@ -983,6 +985,23 @@ function processEnumDeclarations(input: { return handles; } +function withoutMergedIndexSites( + modelSites: ReadonlyMap, + removedIndexes: readonly RemovedIndex[], +): ReadonlyMap { + return new Map( + [...modelSites].map(([modelName, sites]) => { + const removed = new Set( + removedIndexes.filter(({ model }) => model === modelName).map(({ position }) => position), + ); + return [ + modelName, + { ...sites, indexes: sites.indexes.filter((_, position) => !removed.has(position)) }, + ]; + }), + ); +} + /** Whether PSL already reported the cause of a refusal, so the builder's refusal would repeat it. */ function alreadyReported( refusal: BuildRefusal, @@ -1429,7 +1448,7 @@ export function interpretPslDocumentToMongoContract( ...node, relations: relationsByModel.get(node.modelName) ?? [], })); - const definition: ContractDefinition = { + const written: ContractDefinition = { target: input.target, ...ifDefined('extensions', input.extensionPacks), // Variants come last, so each collection lists a variant's indexes after its base's, as earlier releases did. @@ -1442,8 +1461,13 @@ export function interpretPslDocumentToMongoContract( writesCollectionValidators: true, warnings: undefined, }; + const { definition, removedIndexes } = mergeRedundantIndexes( + written, + codecLookup, + input.dataTypes, + ); const sites: SchemaSites = { - models: modelSites, + models: withoutMergedIndexSites(modelSites, removedIndexes), fields: fieldSites, enums: enumSites, }; diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/refusal-diagnostics.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/refusal-diagnostics.ts index 3619b4526a89..1b7fd3926890 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/refusal-diagnostics.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/refusal-diagnostics.ts @@ -275,7 +275,8 @@ export function refusalDiagnostic( } case 'indexFieldUnknown': case 'indexShape': - case 'indexScopeConflict': { + case 'indexScopeConflict': + case 'indexDuplicate': { const { model, position } = refusal.key; const site = modelSitesOf(sites, model).indexes[position]; if (site === undefined) @@ -301,6 +302,10 @@ export function refusalDiagnostic( 'PSL_INVALID_INDEX', `Variant "${model}" index conflicts with discriminator scope: ${refusal.reason}`, ); + case 'indexDuplicate': + throw new InternalError( + `PSL merges identical indexes before the build, so index ${position} of "${model}" cannot duplicate one of model "${refusal.firstModel}" in collection "${refusal.collection}". This is a defect in the PSL interpreter.`, + ); } break; } diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.index-merge.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.index-merge.test.ts index 291117474540..7947a3035901 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.index-merge.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.index-merge.test.ts @@ -201,4 +201,26 @@ model Feature { { kind: 'mongo-index', keys: a, partialFilterExpression: { type: 'feature' } }, ]); }); + + it('reports a refused index after a merged one at the line that writes it', () => { + const result = interpretMongoContract( + `model User { + id ObjectId @id @map("_id") + a String @unique + b String + c String + @@index([a]) + @@textIndex([b]) + @@textIndex([c]) + @@map("users") +} +`, + lookups, + ); + if (result.ok) throw new Error('Expected the schema to be refused'); + + expect( + result.failure.diagnostics.map(({ code, span }) => ({ code, line: span?.start.line })), + ).toEqual([{ code: 'PSL_INVALID_INDEX', line: 8 }]); + }); }); diff --git a/packages/2-mongo-family/2-authoring/contract-ts/README.md b/packages/2-mongo-family/2-authoring/contract-ts/README.md index 3d10362c7c25..65ca98075d19 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/README.md +++ b/packages/2-mongo-family/2-authoring/contract-ts/README.md @@ -13,7 +13,8 @@ This package provides the Mongo `contract.ts` DSL: It also holds the builder every MongoDB contract source calls ([ADR 306](../../../../docs/architecture%20docs/adrs/ADR%20306%20-%20One%20plain-data%20definition%20and%20one%20builder%20per%20family.md)): - `ContractDefinition` (`src/contract-definition.ts`) is a contract as plain data, with every name resolved. `defineContract` lowers its builder objects to it (`src/contract-lowering.ts`); Mongo PSL and the Prisma 6 reader produce it from their schemas. -- `buildMongoContractFromDefinition(definition, codecLookup, dataTypes)` (`src/build-contract.ts`, with stages in the `build-*.ts` files) checks every MongoDB family rule and returns the contract with its warnings, or every refusal with its warnings. Each refusal names its subject by a key the definition carries (`src/build-refusals.ts`). `defineContract` throws the first refusal as a `CONTRACT.*` error (`src/refusal-errors.ts`) and reports the warnings the source collected. +- `buildMongoContractFromDefinition(definition, codecLookup, dataTypes)` (`src/build-contract.ts`, with stages in the `build-*.ts` files) checks every MongoDB family rule and returns the contract with its warnings, or every refusal with its warnings. Each refusal names its subject by a key the definition carries (`src/build-refusals.ts`). `defineContract` throws the first refusal as a `CONTRACT.*` error (`src/refusal-errors.ts`) and reports the warnings the source collected. The builder merges nothing: two identical indexes in one collection are refused (`indexDuplicate`). +- `mergeRedundantIndexes(definition, codecLookup, dataTypes)` (`src/merge-redundant-indexes.ts`) returns the definition without the indexes another index in the same collection makes unnecessary: the later of two identical indexes, or a non-unique index beside a unique one that is otherwise identical. It compares storage indexes with `indexLookupKey`, after variant scoping, and leaves indexes the builder refuses for the builder. `defineContract` and Mongo PSL call it before the builder; the Prisma 6 reader does not. ## Current Slice diff --git a/packages/2-mongo-family/2-authoring/contract-ts/src/build-collections.ts b/packages/2-mongo-family/2-authoring/contract-ts/src/build-collections.ts index 66511af0e64f..eed1a41c5279 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/src/build-collections.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/src/build-collections.ts @@ -1,4 +1,5 @@ import { + type ApplyScopeResult, applyPolymorphicScopeToMongoIndex, indexLookupKey, type MongoCollectionInput, @@ -7,6 +8,7 @@ import { type MongoCollectionOptionsInput, MongoIndex, type MongoIndexInput, + type PolymorphicIndexScope, } from '@internal/mongo-contract'; import { canonicalStringify } from '@internal/utils/canonical-stringify'; import { blindCast } from '@internal/utils/casts'; @@ -40,6 +42,17 @@ function toStorageIndex(index: IndexNode, fields: readonly FieldNode[]): MongoIn ); } +function scopedStorageIndex( + written: IndexNode, + fields: readonly FieldNode[], + polymorphicScope: PolymorphicIndexScope | undefined, +): ApplyScopeResult { + const storageIndex = toStorageIndex(written, fields); + return polymorphicScope === undefined + ? { kind: 'ok', index: storageIndex } + : applyPolymorphicScopeToMongoIndex(storageIndex, polymorphicScope); +} + function toStorageCollectionOptions( opts: MongoCollectionOptionsAuthoringInput, ): MongoCollectionOptions { @@ -109,7 +122,7 @@ function resolveVariantScope( model: ModelNode, modelsByName: ReadonlyMap, variantValues: ReadonlyMap, -): { discriminatorField: string; discriminatorValue: string } | undefined { +): PolymorphicIndexScope | undefined { if (!model.base) return undefined; const base = modelsByName.get(model.base.model); const discriminatorField = base?.discriminator?.field; @@ -123,6 +136,48 @@ function resolveVariantScope( }; } +/** What the builder makes of one index a model writes, before it compares the index with the others in its collection. */ +export type CheckedIndex = + | { readonly kind: 'accepted'; readonly written: IndexNode; readonly index: MongoIndex } + | { readonly kind: 'refused'; readonly refusal: BuildRefusal }; + +/** + * Each index the model writes, in order: the storage index, with a variant's discriminator scope applied, or the builder's refusal of it for an unknown field, a shape MongoDB refuses or a scope conflict. + */ +export function checkModelIndexes( + model: ModelNode, + modelsByName: ReadonlyMap, + variantValues: ReadonlyMap, +): readonly CheckedIndex[] { + const polymorphicScope = resolveVariantScope(model, modelsByName, variantValues); + return (model.indexes ?? []).map((written, position): CheckedIndex => { + const key: IndexKey = { kind: 'index', model: model.modelName, position }; + const signature = canonicalStringify(written); + const missingField = findMissingIndexField(written, model.fields); + if (missingField !== undefined) { + return { + kind: 'refused', + refusal: { kind: 'indexFieldUnknown', key, fieldName: missingField, signature }, + }; + } + const shapeProblem = indexShapeProblem(written); + if (shapeProblem !== undefined) { + return { + kind: 'refused', + refusal: { kind: 'indexShape', key, rule: shapeProblem, signature }, + }; + } + const scoped = scopedStorageIndex(written, model.fields, polymorphicScope); + if (scoped.kind === 'conflict') { + return { + kind: 'refused', + refusal: { kind: 'indexScopeConflict', key, reason: scoped.reason, signature }, + }; + } + return { kind: 'accepted', written, index: scoped.index }; + }); +} + function checkNoCollectionSettings(model: ModelNode, refusals: BuildRefusal[]): void { const key = { kind: 'collectionSettings', model: model.modelName } as const; if (model.indexes && model.indexes.length > 0) { @@ -148,7 +203,7 @@ export function buildCollections(input: { }): Record { const { models, variantValues, refusals } = input; const intermediate: Record = {}; - const indexesByCollection = new Map(); + const indexOwnersByCollection = new Map>(); const textIndexCollections = new Set(); const modelsByName = new Map(models.map((model) => [model.modelName, model])); @@ -160,8 +215,9 @@ export function buildCollections(input: { } const collectionKey = { kind: 'collectionSettings', model: modelName } as const; const existingCollection: MongoCollectionInput = intermediate[collection] ?? {}; - const collectionIndexes = indexesByCollection.get(collection) ?? []; - indexesByCollection.set(collection, collectionIndexes); + const existingIndexes = existingCollection.indexes ?? []; + const indexOwners = indexOwnersByCollection.get(collection) ?? new Map(); + indexOwnersByCollection.set(collection, indexOwners); if (existingCollection.options && model.collectionOptions) { refusals.push({ @@ -180,66 +236,48 @@ export function buildCollections(input: { }); } - const polymorphicScope = resolveVariantScope(model, modelsByName, variantValues); - let indexesChanged = false; - (model.indexes ?? []).forEach((written, position) => { - const key: IndexKey = { kind: 'index', model: modelName, position }; - const signature = canonicalStringify(written); - const missingField = findMissingIndexField(written, model.fields); - if (missingField !== undefined) { - refusals.push({ kind: 'indexFieldUnknown', key, fieldName: missingField, signature }); - return; - } - const shapeProblem = indexShapeProblem(written); - if (shapeProblem !== undefined) { - refusals.push({ kind: 'indexShape', key, rule: shapeProblem, signature }); - return; - } - const storageIndex = toStorageIndex(written, model.fields); - const scoped = - polymorphicScope === undefined - ? { kind: 'ok' as const, index: storageIndex } - : applyPolymorphicScopeToMongoIndex(storageIndex, polymorphicScope); - if (scoped.kind === 'conflict') { - refusals.push({ kind: 'indexScopeConflict', key, reason: scoped.reason, signature }); + const storageIndexes: MongoIndex[] = []; + checkModelIndexes(model, modelsByName, variantValues).forEach((checked, position) => { + if (checked.kind === 'refused') { + refusals.push(checked.refusal); return; } - const lookupKey = indexLookupKey({ ...scoped.index, unique: undefined }); - const matchingAt = collectionIndexes.findIndex( - (held) => indexLookupKey({ ...held, unique: undefined }) === lookupKey, - ); - const held = collectionIndexes[matchingAt]; - if (held !== undefined) { - if (held.unique === true || scoped.index.unique !== true) return; - collectionIndexes.splice(matchingAt, 1); - } else if (isTextIndex(written)) { + const key: IndexKey = { kind: 'index', model: modelName, position }; + const signature = canonicalStringify(checked.written); + if (isTextIndex(checked.written)) { if (textIndexCollections.has(collection)) { refusals.push({ kind: 'indexShape', key, rule: 'second-text-index', signature }); return; } textIndexCollections.add(collection); } - collectionIndexes.push(scoped.index); - indexesChanged = true; + const lookupKey = indexLookupKey(checked.index); + const first = indexOwners.get(lookupKey); + if (first !== undefined) { + refusals.push({ + kind: 'indexDuplicate', + key, + collection, + firstModel: first.model, + firstPosition: first.position, + signature, + }); + return; + } + indexOwners.set(lookupKey, key); + storageIndexes.push(checked.index); }); const storageOptions = model.collectionOptions ? toStorageCollectionOptions(model.collectionOptions) : undefined; - const controlPatch = model.controlPolicy !== undefined ? { control: model.controlPolicy } : {}; - - intermediate[collection] = indexesChanged - ? { - ...existingCollection, - indexes: [...collectionIndexes], - ...(storageOptions ? { options: storageOptions } : {}), - ...controlPatch, - } - : storageOptions - ? { ...existingCollection, options: storageOptions, ...controlPatch } - : Object.keys(controlPatch).length > 0 - ? { ...existingCollection, ...controlPatch } - : existingCollection; + + intermediate[collection] = { + ...existingCollection, + ...(storageIndexes.length > 0 ? { indexes: [...existingIndexes, ...storageIndexes] } : {}), + ...ifDefined('options', storageOptions), + ...ifDefined('control', model.controlPolicy), + }; } return intermediate; diff --git a/packages/2-mongo-family/2-authoring/contract-ts/src/build-refusals.ts b/packages/2-mongo-family/2-authoring/contract-ts/src/build-refusals.ts index 88b1e3c35e19..e72f3504ef53 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/src/build-refusals.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/src/build-refusals.ts @@ -121,6 +121,14 @@ export type BuildRefusal = readonly reason: string; readonly signature: string; } + | { + readonly kind: 'indexDuplicate'; + readonly key: IndexKey; + readonly collection: string; + readonly firstModel: string; + readonly firstPosition: number; + readonly signature: string; + } | { readonly kind: 'generatedOnValueObject'; readonly key: FieldKey } | { readonly kind: 'generatorUnknown'; diff --git a/packages/2-mongo-family/2-authoring/contract-ts/src/contract-builder.ts b/packages/2-mongo-family/2-authoring/contract-ts/src/contract-builder.ts index 625516f99374..bfbd77b3d350 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/src/contract-builder.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/src/contract-builder.ts @@ -80,6 +80,7 @@ import { import { contractError } from './contract-errors'; import { buildContractDefinition, type DslContractInput } from './contract-lowering'; import type { EnumTypeHandle } from './enum-type'; +import { mergeRedundantIndexes } from './merge-redundant-indexes'; import { authoringWarningOf, refusalError } from './refusal-errors'; import { refusingRemovedFieldPresets } from './removed-field-presets'; @@ -1599,7 +1600,11 @@ function buildContractFromDsl( const { codecLookup, dataTypes } = lookups ?? stackLookupsOf([input.family, input.target, ...Object.values(input.extensions ?? {})]); - const built = buildMongoContractFromDefinition(definition, codecLookup, dataTypes); + const built = buildMongoContractFromDefinition( + mergeRedundantIndexes(definition, codecLookup, dataTypes).definition, + codecLookup, + dataTypes, + ); flushAuthoringWarnings( (built.ok ? built.value.warnings : built.failure.warnings).map(authoringWarningOf), ); diff --git a/packages/2-mongo-family/2-authoring/contract-ts/src/exports/contract-builder.ts b/packages/2-mongo-family/2-authoring/contract-ts/src/exports/contract-builder.ts index 682e23dd4de1..b19b1f1ff607 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/src/exports/contract-builder.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/src/exports/contract-builder.ts @@ -46,3 +46,5 @@ export type { } from '../contract-dsl'; export type { BoundEnumType, CodecTypeMap, EnumMember, EnumTypeHandle } from '../enum-type'; export { bindEnumType, enumType, isEnumTypeHandle, member } from '../enum-type'; +export type { MergedIndexes, RemovedIndex } from '../merge-redundant-indexes'; +export { mergeRedundantIndexes } from '../merge-redundant-indexes'; diff --git a/packages/2-mongo-family/2-authoring/contract-ts/src/merge-redundant-indexes.ts b/packages/2-mongo-family/2-authoring/contract-ts/src/merge-redundant-indexes.ts new file mode 100644 index 000000000000..0eb2a75537bd --- /dev/null +++ b/packages/2-mongo-family/2-authoring/contract-ts/src/merge-redundant-indexes.ts @@ -0,0 +1,94 @@ +import type { DataTypeSupport } from '@internal/framework-components/authoring'; +import type { CodecLookupWithDescriptors } from '@internal/framework-components/codec'; +import { indexLookupKey } from '@internal/mongo-contract'; +import { checkModelIndexes } from './build-collections'; +import { buildEnums } from './build-enums'; +import { decidePolymorphism, inheritVariantCollections } from './build-polymorphism'; +import type { BuildRefusal } from './build-refusals'; +import type { ContractDefinition, ModelNode } from './contract-definition'; + +/** An index the merge removed: the model that wrote it and its position in that model's indexes as written. */ +export interface RemovedIndex { + readonly model: string; + readonly position: number; +} + +/** The definition without its identical and redundant indexes, and which indexes were removed. */ +export interface MergedIndexes { + readonly definition: ContractDefinition; + readonly removedIndexes: readonly RemovedIndex[]; +} + +interface HeldIndex extends RemovedIndex { + readonly lookupKey: string; + readonly unique: boolean; +} + +function positionKey({ model, position }: RemovedIndex): string { + return JSON.stringify([model, position]); +} + +/** + * Removes each identical or redundant index in a collection, for the Prisma 8 authoring sources to call before the builder. Two indexes match when their storage indexes, variant scope applied, have the same `indexLookupKey` with `unique` left out. Of a matching pair the first is kept, unless only the later one is unique; then the later one is kept and the earlier removed. Indexes the builder refuses are left for it to refuse. A source that reports the builder's refusals by index position maps them back through `removedIndexes`. + */ +export function mergeRedundantIndexes( + definition: ContractDefinition, + codecLookup: CodecLookupWithDescriptors, + dataTypes: DataTypeSupport, +): MergedIndexes { + const refusalsLeftToTheBuilder: BuildRefusal[] = []; + const models = inheritVariantCollections(definition.models, refusalsLeftToTheBuilder); + const variantValues = decidePolymorphism({ + models, + enums: buildEnums(definition, codecLookup, refusalsLeftToTheBuilder), + codecLookup, + dataTypes, + refusals: refusalsLeftToTheBuilder, + }); + const modelsByName = new Map(models.map((model) => [model.modelName, model])); + const heldByCollection = new Map(); + const removedIndexes: RemovedIndex[] = []; + + for (const model of models) { + const { collection } = model; + if (!collection) continue; + const held = heldByCollection.get(collection) ?? []; + heldByCollection.set(collection, held); + checkModelIndexes(model, modelsByName, variantValues).forEach((checked, position) => { + if (checked.kind === 'refused') return; + const index: HeldIndex = { + model: model.modelName, + position, + lookupKey: indexLookupKey({ ...checked.index, unique: undefined }), + unique: checked.index.unique === true, + }; + const matchingAt = held.findIndex((candidate) => candidate.lookupKey === index.lookupKey); + const match = held[matchingAt]; + if (match !== undefined) { + if (match.unique || !index.unique) { + removedIndexes.push({ model: index.model, position: index.position }); + return; + } + removedIndexes.push({ model: match.model, position: match.position }); + held.splice(matchingAt, 1); + } + held.push(index); + }); + } + + if (removedIndexes.length === 0) return { definition, removedIndexes }; + const removed = new Set(removedIndexes.map(positionKey)); + const withoutRemoved = (model: ModelNode): ModelNode => + model.indexes === undefined + ? model + : { + ...model, + indexes: model.indexes.filter( + (_, position) => !removed.has(positionKey({ model: model.modelName, position })), + ), + }; + return { + definition: { ...definition, models: definition.models.map(withoutRemoved) }, + removedIndexes, + }; +} diff --git a/packages/2-mongo-family/2-authoring/contract-ts/src/refusal-errors.ts b/packages/2-mongo-family/2-authoring/contract-ts/src/refusal-errors.ts index aa80b44f1ad9..32c580f3bf0a 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/src/refusal-errors.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/src/refusal-errors.ts @@ -347,6 +347,20 @@ export function refusalError(refusal: BuildRefusal): StructuredError { }, }, ); + case 'indexDuplicate': + return contractError( + 'CONTRACT.NAME_DUPLICATE', + `Collection "${refusal.collection}" defines duplicate index ${refusal.signature}. First declared on model "${refusal.firstModel}" and duplicated on model "${refusal.key.model}".`, + { + meta: { + kind: 'index', + name: refusal.signature, + collection: refusal.collection, + first: refusal.firstModel, + second: refusal.key.model, + }, + }, + ); case 'generatedOnValueObject': return defaultError( refusal.key, diff --git a/packages/2-mongo-family/2-authoring/contract-ts/test/build-contract.test.ts b/packages/2-mongo-family/2-authoring/contract-ts/test/build-contract.test.ts index 7df3fa449b83..b64a60ec859f 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/test/build-contract.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/test/build-contract.test.ts @@ -237,6 +237,23 @@ describe('buildMongoContractFromDefinition', () => { fields: { severity: { field: 'severity' } }, }); }); + + it('keeps both a unique and a plain index on the same keys, in the order they are written', () => { + const contract = contractOf({ + models: [ + user([scalar('a')], { + indexes: [{ fields: { a: 1 } }, { fields: { a: 1 }, options: { unique: true } }], + }), + ], + }); + const users = + contract.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries['collection']?.['users']; + + expect(JSON.parse(JSON.stringify(users))['indexes']).toEqual([ + { kind: 'mongo-index', keys: [{ field: 'a', direction: 1 }] }, + { kind: 'mongo-index', keys: [{ field: 'a', direction: 1 }], unique: true }, + ]); + }); }); describe('buildMongoContractFromDefinition refusals', () => { @@ -375,6 +392,43 @@ describe('buildMongoContractFromDefinition refusals', () => { }, [{ kind: 'indexScopeConflict', key: { kind: 'index', model: 'Bug', position: 0 } }], ], + [ + 'only the scope conflict of a variant text index whose filter contradicts its discriminator value', + { + models: [ + task, + { + ...bug, + fields: [scalar('severity'), scalar('notes')], + indexes: [ + { fields: { severity: 'text' }, options: { partialFilterExpression: { type: 'x' } } }, + { fields: { notes: 'text' } }, + ], + }, + ], + }, + [{ kind: 'indexScopeConflict', key: { kind: 'index', model: 'Bug', position: 0 } }], + ], + [ + 'an index its model writes twice', + { models: [user([scalar('a')], { indexes: [{ fields: { a: 1 } }, { fields: { a: 1 } }] })] }, + [{ kind: 'indexDuplicate', key: { kind: 'index', model: 'User', position: 1 } }], + ], + [ + 'an index another model in the same collection writes too', + { + models: [ + user([scalar('a')], { indexes: [{ fields: { a: 1 } }] }), + { + modelName: 'Admin', + collection: 'users', + fields: [scalar('a')], + indexes: [{ fields: { a: 1 } }], + }, + ], + }, + [{ kind: 'indexDuplicate', key: { kind: 'index', model: 'Admin', position: 0 } }], + ], [ 'a generated value on a value object field', { @@ -614,6 +668,31 @@ describe('buildMongoContractFromDefinition refusals with their whole data', () = return built.ok ? [] : built.failure.refusals; }; + it('refuses an index another model in the same collection writes, naming the first model', () => { + expect( + refusalsWith({ + models: [ + user([scalar('a')], { indexes: [{ fields: { a: 1 } }] }), + { + modelName: 'Admin', + collection: 'users', + fields: [scalar('a')], + indexes: [{ fields: { a: 1 } }], + }, + ], + }), + ).toEqual([ + { + kind: 'indexDuplicate', + key: { kind: 'index', model: 'Admin', position: 0 }, + collection: 'users', + firstModel: 'User', + firstPosition: 0, + signature: '{"fields":{"a":1}}', + }, + ]); + }); + it("refuses type parameters the field's data type does not accept", () => { expect( refusalsWith({ diff --git a/packages/2-mongo-family/2-authoring/contract-ts/test/build-collections.index-merge.test.ts b/packages/2-mongo-family/2-authoring/contract-ts/test/merge-redundant-indexes.test.ts similarity index 67% rename from packages/2-mongo-family/2-authoring/contract-ts/test/build-collections.index-merge.test.ts rename to packages/2-mongo-family/2-authoring/contract-ts/test/merge-redundant-indexes.test.ts index 68872c67cf64..87750da0277b 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/test/build-collections.index-merge.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/test/merge-redundant-indexes.test.ts @@ -4,7 +4,13 @@ import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { describe, expect, it } from 'vitest'; import { buildMongoContractFromDefinition } from '../src/build-contract'; import { field } from '../src/contract-builder'; -import type { FieldNode, IndexNode, ModelNode } from '../src/contract-definition'; +import type { + ContractDefinition, + FieldNode, + IndexNode, + ModelNode, +} from '../src/contract-definition'; +import { mergeRedundantIndexes } from '../src/merge-redundant-indexes'; import { mongoFamilyPack, mongoTargetPack } from './mongo-test-packs'; const codecLookup = extractCodecLookup([mongoFamilyPack, mongoTargetPack]); @@ -30,12 +36,21 @@ function inUsers(modelName: string, indexes: readonly IndexNode[]): ModelNode { return { modelName, collection: 'users', fields, indexes }; } +function definitionOf(models: readonly ModelNode[]): ContractDefinition { + return { + target: mongoTargetPack, + models, + writesCollectionValidators: false, + warnings: undefined, + }; +} + +function merged(models: readonly ModelNode[]): ContractDefinition { + return mergeRedundantIndexes(definitionOf(models), codecLookup, dataTypes).definition; +} + function indexesOf(models: readonly ModelNode[]): unknown { - const built = buildMongoContractFromDefinition( - { target: mongoTargetPack, models, writesCollectionValidators: false, warnings: undefined }, - codecLookup, - dataTypes, - ); + const built = buildMongoContractFromDefinition(merged(models), codecLookup, dataTypes); if (!built.ok) throw new Error(`refused: ${JSON.stringify(built.failure.refusals)}`); const collections = built.value.contract.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries['collection']; @@ -48,7 +63,7 @@ const ab = [ { field: 'b', direction: 1 }, ]; -describe('the builder merges identical and redundant indexes in a collection', () => { +describe('mergeRedundantIndexes', () => { it.each<[string, readonly IndexNode[]]>([ [ 'the unique index first', @@ -238,4 +253,96 @@ describe('the builder merges identical and redundant indexes in a collection', ( }, ]); }); + + it("keeps one of two identical indexes a variant writes, scoped to the variant's value", () => { + expect( + indexesOf([ + { + ...inUsers('User', []), + fields: fields.slice(0, 2), + discriminator: { field: 'type' }, + }, + { + modelName: 'Bug', + fields: [scalar('c')], + indexes: [{ fields: { c: 1 } }, { fields: { c: 1 } }], + base: { model: 'User', value: { kind: 'string', text: 'bug' } }, + }, + ]), + ).toEqual([ + { + kind: 'mongo-index', + keys: [{ field: 'c', direction: 1 }], + partialFilterExpression: { type: 'bug' }, + }, + ]); + }); + + it('removes the merged indexes from the models that wrote them and reports their positions', () => { + const plain: IndexNode = { fields: { a: 1 } }; + const other: IndexNode = { fields: { b: 1 } }; + const unique: IndexNode = { fields: { a: 1 }, options: { unique: true } }; + const result = mergeRedundantIndexes( + definitionOf([inUsers('User', [plain, other]), inUsers('Admin', [unique, other])]), + codecLookup, + dataTypes, + ); + + expect({ + indexes: result.definition.models.map((model) => model.indexes), + removedIndexes: result.removedIndexes, + }).toEqual({ + indexes: [[other], [unique]], + removedIndexes: [ + { model: 'User', position: 0 }, + { model: 'Admin', position: 1 }, + ], + }); + }); + + it('returns the definition it is given when no index merges', () => { + const definition = definitionOf([ + inUsers('User', [{ fields: { a: 1 } }, { fields: { b: 1 } }]), + ]); + expect(mergeRedundantIndexes(definition, codecLookup, dataTypes)).toEqual({ + definition, + removedIndexes: [], + }); + }); + + it.each<[string, IndexNode]>([ + ['on an unknown field', { fields: { nope: 1 } }], + ['with a shape MongoDB refuses', { fields: { '$**': 1 }, options: { unique: true } }], + ])('leaves two identical indexes %s for the builder to refuse', (_, index) => { + expect(merged([inUsers('User', [index, index])]).models[0]?.indexes).toEqual([index, index]); + }); + + it('leaves two identical variant indexes whose filter contradicts the discriminator value for the builder to refuse', () => { + const conflicting: IndexNode = { + fields: { c: 1 }, + options: { partialFilterExpression: { type: 'x' } }, + }; + const models = merged([ + { + ...inUsers('User', []), + fields: fields.slice(0, 2), + discriminator: { field: 'type' }, + }, + { + modelName: 'Bug', + fields: [scalar('c')], + indexes: [conflicting, conflicting], + base: { model: 'User', value: { kind: 'string', text: 'bug' } }, + }, + ]).models; + expect(models[1]?.indexes).toEqual([conflicting, conflicting]); + }); + + it('skips a model stored in no collection', () => { + const embedded: ModelNode = { modelName: 'Address', fields, indexes: [{ fields: { a: 1 } }] }; + expect(merged([embedded, embedded]).models.map((model) => model.indexes)).toEqual([ + [{ fields: { a: 1 } }], + [{ fields: { a: 1 } }], + ]); + }); }); diff --git a/test/integration/test/cli-journeys/prisma6-source.e2e.test.ts b/test/integration/test/cli-journeys/prisma6-source.e2e.test.ts index e35dd44535f5..690de4e1f2ed 100644 --- a/test/integration/test/cli-journeys/prisma6-source.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma6-source.e2e.test.ts @@ -4,8 +4,9 @@ * `prisma6Schema('./schema.prisma')` runs `contract emit`, `db sign`, and * `db verify` through the real command family against a database laid out the * way Prisma 6 `db push` lays it out: a collection per model, no validators, - * and indexes under Prisma 6's names. Verify reports zero findings in both - * modes, since it matches indexes by keys and options, not by name. An index + * and indexes under Prisma 6's names, including a unique and a plain index on + * the same keys. Verify reports zero findings in both modes, since it matches + * indexes by keys and options, not by name. An index * the contract does not declare is a warning in lenient mode and an issue in * strict mode. */ @@ -40,6 +41,7 @@ function buildMongoUri(baseUri: string, dbName: string): string { async function pushLikePrisma6(db: Db): Promise { await db.createCollection('User'); await db.collection('User').createIndex({ email: 1 }, { unique: true, name: 'User_email_key' }); + await db.collection('User').createIndex({ email: 1 }, { name: 'User_email_idx' }); await db.createCollection('Post'); await db.collection('Post').createIndex({ authorId: 1 }, { name: 'Post_authorId_idx' }); } @@ -144,7 +146,8 @@ withTempDir(({ createTempDir }) => { User: { kind: 'mongo-collection', indexes: [ - expect.objectContaining({ keys: [{ field: 'email', direction: 1 }], unique: true }), + { kind: 'mongo-index', keys: [{ field: 'email', direction: 1 }], unique: true }, + { kind: 'mongo-index', keys: [{ field: 'email', direction: 1 }] }, ], }, Post: { diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/prisma6-schema.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/prisma6-schema.prisma index e7d7b0f1be3f..23215b3062aa 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/prisma6-schema.prisma +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/prisma6-schema.prisma @@ -8,6 +8,8 @@ model User { email String @unique legacy String @ignore posts Post[] + + @@index([email(sort: Asc)]) } model Post { diff --git a/test/integration/test/upgrade-instructions/mongo-index-merge-in-sources/detection.test.ts b/test/integration/test/upgrade-instructions/mongo-index-merge-in-sources/detection.test.ts new file mode 100644 index 000000000000..bd5d3a0920da --- /dev/null +++ b/test/integration/test/upgrade-instructions/mongo-index-merge-in-sources/detection.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { detectionPatterns } from '../guide-detections'; + +const APP_NOTE = 'upgrade-instructions/pending/mongo-index-merge-in-sources/app/instructions.md'; +const EXTENSION_NOTE = + 'upgrade-instructions/pending/mongo-index-merge-in-sources/extension/instructions.md'; + +const matcher = (path: string, id: string) => (content: string) => + detectionPatterns(path, id).some((pattern) => pattern.test(content)); + +const prisma6Model = (body: string) => + `model User {\n id String @id @default(auto()) @map("_id") @db.ObjectId\n${body}\n}\n`; + +describe('the app mongo-prisma6-indexes-as-prisma6-reads-them detection', () => { + const matches = matcher(APP_NOTE, 'mongo-prisma6-indexes-as-prisma6-reads-them'); + + it('matches a Prisma 6 schema with an index, and not other schemas', () => { + expect([ + matches(prisma6Model(' email String @unique')), + matches(prisma6Model(' email String\n\n @@index([email])')), + matches(prisma6Model(' email String')), + matches('model User {\n id ObjectId @id @map("_id")\n email String @unique\n}\n'), + matches('model User {\n id Int @id\n email String @unique\n}\n'), + ]).toEqual([true, true, false, false, false]); + }); +}); + +describe('the extension mongo-build-refusal-index-duplicate-restored detection', () => { + const matches = matcher(EXTENSION_NOTE, 'mongo-build-refusal-index-duplicate-restored'); + + it('matches code that reads builder refusals or calls the builder, and not defineContract', () => { + expect([ + matches( + "import type { BuildRefusal } from '@prisma/orm-family-mongo/contract-ts/contract-builder';", + ), + matches('const result = buildMongoContractFromDefinition(definition);'), + matches('export const contract = defineContract({ models });'), + ]).toEqual([true, true, false]); + }); +}); diff --git a/upgrade-instructions/pending/mongo-index-merge-in-sources/app/instructions.md b/upgrade-instructions/pending/mongo-index-merge-in-sources/app/instructions.md new file mode 100644 index 000000000000..c7b7c2ce832e --- /dev/null +++ b/upgrade-instructions/pending/mongo-index-merge-in-sources/app/instructions.md @@ -0,0 +1,29 @@ +--- +changes: + - id: mongo-prisma6-indexes-as-prisma6-reads-them + summary: | + A Prisma 6 MongoDB schema read by `prisma6Schema()` no longer has its indexes merged. Two indexes of one model with the same fields and the same written sort, of any kind (`@unique`, `@@unique`, `@@index`, `@@fulltext`), are refused with `PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS`, as Prisma 6 refuses them. A unique and a plain index on the same keys that Prisma 6 accepts, such as `@unique` beside `@@index([email(sort: Asc)])`, are both kept, so the contract describes the database Prisma 6 built and gets a new storage hash. Prisma 8 PSL and TypeScript `defineContract` still merge identical and redundant indexes; their contracts do not change. + detection: + glob: "**/*.prisma" + matches: + - '(?