Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7fa642a
Reject duplicate discriminator values across validation surfaces
SevInf Oct 1, 2026
f5324d1
SQL ORM variant() selects a variant by discriminator value
SevInf Oct 2, 2026
5c8d7ed
Align variant() error metadata with ORM.ARGUMENT_INVALID convention
SevInf Oct 2, 2026
ba0beb2
Mongo ORM variant() selects a variant by discriminator value
SevInf Oct 2, 2026
302f334
Select variants by discriminator value in call sites, docs and upgrad…
SevInf Oct 2, 2026
d3d7a58
Detect every variant() call in the discriminator-value upgrade fragment
SevInf Oct 2, 2026
f38c052
Expect codec-wrapped where operands in the Mongo variant re-narrowing…
SevInf Oct 5, 2026
9a3dfc9
Type the SQL variant() overloads by discriminator value
SevInf Oct 6, 2026
f1a1af6
Pass discriminator values in the prisma-8-demo declaration library fi…
SevInf Oct 6, 2026
fa171e0
Document that variant() also drops where() filters on the discriminator
SevInf Oct 6, 2026
0c9bc30
Export the Mongo ORM variant value types
SevInf Oct 6, 2026
7ea3835
Assert the duplicate discriminator value error metadata in the Mongo …
SevInf Oct 6, 2026
7e80078
variant() is called once, on a collection with no variant selected
SevInf Oct 6, 2026
d15252a
Document that variant() is called once, on the base collection
SevInf Oct 6, 2026
e74f999
Drop the where() removal note from the variant upgrade fragment
SevInf Oct 6, 2026
113ab87
Rename VariantValues to DiscriminatorValues in both ORMs
SevInf Oct 6, 2026
d6be84a
Name the duplicate discriminator value reason duplicate-discriminator…
SevInf Oct 6, 2026
510c5dd
Check create() itself on SQL variant helpers
SevInf Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ The migration-file CLI (`prisma migration`) received a flag it does not recognis

### CONTRACT.ARGUMENT_INVALID

A builder or helper on the contract-authoring surface is called with a bad argument: a composed authoring helper receives too many arguments or a malformed trailing options object, `field.sql({ id })` / `field.sql({ unique })` is used without a matching inline `.id(...)` / `.unique(...)` declaration, `model("Name", ...)` is called without a model definition, a nanoid ID generator is given a size outside 2–255, a TypeScript `type.*` type constructor receives an argument its data type's parameter schema refuses (meta: `helperPath`, `argumentIndex`; see `CONTRACT.TYPE_PARAMS_INVALID` for how PSL and the contract build report the same bound), or an authored index combines its cross-field parameters invalidly (fields and an expression together or neither, an expression without `name:`/`map:`, or `map:` combined with `name:`). Also raised when a contract targets SQLite and declares an expression or partial index: SQLite's namespace construction rejects `expression:`/`where:` because the target does not support them. Also raised when a column with a literal default has type parameters that its codec does not accept beyond its data type's parameters, which the build checks first with `CONTRACT.TYPE_PARAMS_INVALID` (meta: `modelName`, `fieldName`, `codecId`, `reason: 'type-params-invalid'`; the codec's error is the `cause`). Raised while authoring/building the contract, before emit. Payload: varies per site.
A builder or helper on the contract-authoring surface is called with a bad argument: a composed authoring helper receives too many arguments or a malformed trailing options object, `field.sql({ id })` / `field.sql({ unique })` is used without a matching inline `.id(...)` / `.unique(...)` declaration, `model("Name", ...)` is called without a model definition, a nanoid ID generator is given a size outside 2–255, a TypeScript `type.*` type constructor receives an argument its data type's parameter schema refuses (meta: `helperPath`, `argumentIndex`; see `CONTRACT.TYPE_PARAMS_INVALID` for how PSL and the contract build report the same bound), or an authored index combines its cross-field parameters invalidly (fields and an expression together or neither, an expression without `name:`/`map:`, or `map:` combined with `name:`). Also raised by the Mongo TypeScript builder when two variants of a polymorphic base declare the same discriminator value (meta: `modelName`, `value`, `variants`, `reason: 'duplicate-discriminator-value'`). Also raised when a contract targets SQLite and declares an expression or partial index: SQLite's namespace construction rejects `expression:`/`where:` because the target does not support them. Also raised when a column with a literal default has type parameters that its codec does not accept beyond its data type's parameters, which the build checks first with `CONTRACT.TYPE_PARAMS_INVALID` (meta: `modelName`, `fieldName`, `codecId`, `reason: 'type-params-invalid'`; the codec's error is the `cause`). Raised while authoring/building the contract, before emit. Payload: varies per site.

### CONTRACT.AGGREGATE_DESCRIPTOR_AMBIGUOUS

Expand Down Expand Up @@ -920,7 +920,7 @@ An aggregate was invoked for an operation/input pair the composed target declare

### ORM.ARGUMENT_INVALID

A method argument on the ORM client, or on the `sql()` / Mongo query-builder DSLs, is malformed or missing a required part: a `null` where-arg, `upsert()` without conflict columns or without a create value for a conflict column, a custom collection registered as an instance / against a nonexistent model in `orm({ collections })`, invalid builder argument shapes, `$and`/`$or` with no expressions, a limit, offset or skip that is negative or not an integer, malformed lookup/group/update specs, or a row-locking method (`forUpdate()`, `forNoKeyUpdate()`, `forShare()`, `forKeyShare()`) given both `nowait` and `skipLocked`. For SQL, the limit/offset check runs in relational-core when the `SelectAst` is constructed, so every SQL lane and target raises it before any SQL is rendered. That check also refuses integers above `Number.MAX_SAFE_INTEGER`, and does not check a limit or offset bound as a parameter. Payload: `method`, `argument` (`limit` or `offset` for the SQL limit/offset check), `model`, `column`, `key`.
A method argument on the ORM client, or on the `sql()` / Mongo query-builder DSLs, is malformed or missing a required part: a `null` where-arg, `upsert()` without conflict columns or without a create value for a conflict column, a custom collection registered as an instance / against a nonexistent model in `orm({ collections })`, invalid builder argument shapes, `$and`/`$or` with no expressions, a limit, offset or skip that is negative or not an integer, malformed lookup/group/update specs, a row-locking method (`forUpdate()`, `forNoKeyUpdate()`, `forShare()`, `forKeyShare()`) given both `nowait` and `skipLocked`, or a `variant()` call whose value is not a declared discriminator value of the receiver model, or on a model with no discriminator (SQL and Mongo ORMs; the payload adds `value` and `declaredValues`). For SQL, the limit/offset check runs in relational-core when the `SelectAst` is constructed, so every SQL lane and target raises it before any SQL is rendered. That check also refuses integers above `Number.MAX_SAFE_INTEGER`, and does not check a limit or offset bound as a parameter. Payload: `method`, `argument` (`limit` or `offset` for the SQL limit/offset check), `model`, `column`, `key`.

### ORM.CAPABILITY_MISSING

Expand Down Expand Up @@ -984,7 +984,7 @@ A mutation that expected the database to return a row got none: `create()`/`upse

### ORM.OPERATION_UNSUPPORTED

A valid ORM method was called in a configuration that does not support it: mutating an MTI variant collection with a method that requires `createAll()`, passing `onConflict: 'skip'` to `createAll()` on an MTI variant collection, Mongo `upsert()` with dot-path field operations, a Mongo `upsert()` whose `create` sets a field that has an update default and whose update pulls by a match document (that upsert runs as one update pipeline, which can pull only a single value), or a Mongo mutation carrying windowing (`orderBy`/`offset`/`limit`) or includes. Payload: `method`, `model`, `reason`, `field`.
A valid ORM method was called in a configuration that does not support it: mutating an MTI variant collection with a method that requires `createAll()`, passing `onConflict: 'skip'` to `createAll()` on an MTI variant collection, Mongo `upsert()` with dot-path field operations, a Mongo `upsert()` whose `create` sets a field that has an update default and whose update pulls by a match document (that upsert runs as one update pipeline, which can pull only a single value), a Mongo mutation carrying windowing (`orderBy`/`offset`/`limit`) or includes, or `variant()` called on a collection that already has a variant selected (SQL and Mongo ORMs; call it on the base collection instead; `reason: 'variant-already-selected'`, with `variant` and `selectedValue` naming the selected variant model and its discriminator value). Payload: `method`, `model`, `reason`, `field`.

### ORM.RELATION_LINK_DUPLICATE

Expand Down
4 changes: 3 additions & 1 deletion docs/reference/model-and-result-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,12 @@ Adding a schema later never renames an existing type.

A polymorphic base emits three shapes: the base member, one member per variant, and an `Any<Base>` union of the variant members. The base's discriminator field is the union of the variant literals; each variant narrows it to its own literal and adds its own fields. A relation whose target is a polymorphic base is typed as the `Any<Base>` union, because the ORM returns the variant union for such an include.

`.variant(value)` narrows a polymorphic collection to the variant whose discriminator value is `value`. It takes the value the variant declares (`'bug'` from `@@base(Task, "bug")`), not the variant's model name. Call it once, on the base collection: a collection that already has a variant selected refuses a second `.variant()`.

```ts
type TaskType = Models.public_Task['type']; // 'bug' | 'feature' | 'epic'

const bugs = db.orm.public.Task.variant('Bug');
const bugs = db.orm.public.Task.variant('bug');
type Bug = ResultType<typeof bugs>; // Scalars<Models.public_Bug>

type AnyTask = ResultType<typeof db.orm.public.Task>; // Scalars<Models.public_AnyTask>
Expand Down
4 changes: 2 additions & 2 deletions examples/mongo-blog-leaderboard/src/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export async function seed(orm: Db['orm']) {
const [alice, bob, carol] = createdUsers;
if (!alice || !bob || !carol) throw new Error('Failed to seed users');

const articles = orm.posts.variant('Article');
const tutorials = orm.posts.variant('Tutorial');
const articles = orm.posts.variant('article');
const tutorials = orm.posts.variant('tutorial');

await articles.createAll([
{
Expand Down
4 changes: 2 additions & 2 deletions examples/mongo-demo/src/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export async function seed(orm: Db['orm']) {
const carol = createdUsers[2];
if (!alice || !bob || !carol) throw new Error('Failed to seed users');

const articles = orm.posts.variant('Article');
const tutorials = orm.posts.variant('Tutorial');
const articles = orm.posts.variant('article');
const tutorials = orm.posts.variant('tutorial');

await articles.createAll([
{
Expand Down
4 changes: 2 additions & 2 deletions examples/mongo-demo/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ export async function getPosts(orm: Db['orm']) {
}

export async function getArticles(orm: Db['orm']) {
return orm.posts.variant('Article').all();
return orm.posts.variant('article').all();
}

export async function getTutorials(orm: Db['orm']) {
return orm.posts.variant('Tutorial').all();
return orm.posts.variant('tutorial').all();
}

export async function getUsers(orm: Db['orm']) {
Expand Down
4 changes: 2 additions & 2 deletions examples/mongo-demo/test/blog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,13 @@ describe('mongo-demo blog integration', { timeout: timeouts.spinUpMongoMemorySer
},
]);

const articles = await orm.posts.variant('Article').all();
const articles = await orm.posts.variant('article').all();
expect(articles).toHaveLength(2);
for (const a of articles) {
expect(a.kind).toBe('article');
}

const tutorials = await orm.posts.variant('Tutorial').all();
const tutorials = await orm.posts.variant('tutorial').all();
expect(tutorials).toHaveLength(1);
expect(tutorials[0]).toMatchObject({ title: 'Tutorial One', kind: 'tutorial' });
});
Expand Down
4 changes: 2 additions & 2 deletions examples/prisma-8-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ The demo includes ORM client examples under `src/orm-client/`:
- `ormClientGetDashboardUsers(emailDomain, postTitleTerm, limit, postsPerUser, runtime)` — compound `and/or/not` filters + relation filters + `select()` and `include()` composition
- `ormClientGetPostFeed(postTitleTerm, limit, runtime)` — to-one include (`post -> user`) with projected fields
- `ormClientGetUserTaskBoard(limit, runtime)` — **polymorphic-target include**: `User.include('tasks')` where `Task` is a discriminated base; each included row is decoded into its variant shape (`Bug` → `severity`/`stepsToRepro`, `Feature` → `priority`/`targetRelease`) in a single read
- `ormClientGetUserBugTriage(severity, limit, runtime)` — `.variant('Bug')`-narrowed include filtered by the Bug-only `severity` column
- `ormClientGetFeatureRoadmap(targetRelease, limit, runtime)` — `.variant('Feature')`-narrowed include filtered by the Feature-only `targetRelease` column (a multi-table-inheritance variant column reached through the variant join)
- `ormClientGetUserBugTriage(severity, limit, runtime)` — `.variant('bug')`-narrowed include filtered by the Bug-only `severity` column
- `ormClientGetFeatureRoadmap(targetRelease, limit, runtime)` — `.variant('feature')`-narrowed include filtered by the Feature-only `targetRelease` column (a multi-table-inheritance variant column reached through the variant join)
- `ormClientGetPostTags(postId, runtime)` — **many-to-many include**: `Post.include('tags', …)` traversing the `post_tag` junction transparently
- `ormClientGetTagPosts(tagId, runtime)` — the same junction walked from the other side (`Tag.include('posts', …)`)
- `ormClientGetPostsByTagFilter(mode, label, runtime)` — `some`/`none`/`every` relation filter predicates on the N:M `tags` relation (EXISTS through the junction)
Expand Down
4 changes: 2 additions & 2 deletions examples/prisma-8-demo/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@
* each task comes back shaped per its variant
* (Bug: severity/stepsToRepro, Feature: priority/targetRelease)
* - repo-bug-triage [severity] [limit]
* Users with a `.variant('Bug')`-narrowed include,
* Users with a `.variant('bug')`-narrowed include,
* filtered by the Bug-only `severity` column
* - repo-feature-roadmap <targetRelease> [limit]
* Users with a `.variant('Feature')`-narrowed include,
* Users with a `.variant('feature')`-narrowed include,
* filtered by the Feature-only `targetRelease` column
* - repo-post-tags <postId> Include a post's tags (N:M read through the junction)
* - repo-tag-posts <tagId> Include a tag's posts (N:M read, reverse direction)
Expand Down
4 changes: 2 additions & 2 deletions examples/prisma-8-demo/src/orm-client/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ export class TagCollection extends Collection<Contract, 'Tag'> {

export class TaskCollection extends Collection<Contract, 'Task'> {
bugs() {
return this.variant('Bug');
return this.variant('bug');
}

features() {
return this.variant('Feature');
return this.variant('feature');
}

forUser(userId: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export async function ormClientGetFeatureRoadmap(
return db.User.select('id', 'displayName')
.include('tasks', (tasks) =>
tasks
.variant('Feature')
.variant('feature')
.where((feature) => feature.targetRelease.eq(targetRelease))
.orderBy((feature) => feature.createdAt.asc()),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createOrmClient } from './client';
/**
* Bug triage view: each user with only their `Bug` tasks of a given severity.
*
* `.variant('Bug')` narrows the polymorphic include to a single variant, so the
* `.variant('bug')` narrows the polymorphic include to a single variant, so the
* included rows are `Bug`-shaped and the refinement's `where` can filter on the
* variant's own column (`severity`, which lives in the joined `bug` table). The
* include takes the default projection, so each row comes back in its full
Expand All @@ -15,7 +15,7 @@ export async function ormClientGetUserBugTriage(severity: string, limit: number,
return db.User.select('id', 'displayName')
.include('tasks', (tasks) =>
tasks
.variant('Bug')
.variant('bug')
.where((bug) => bug.severity.eq(severity))
.orderBy((bug) => bug.createdAt.asc()),
)
Expand Down
4 changes: 2 additions & 2 deletions examples/prisma-8-demo/test/fixtures/declaration-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,11 @@ export class PostLibrary extends Collection<Contract, 'Post'> {

export class TaskLibrary extends Collection<Contract, 'Task'> {
bugs() {
return this.variant('Bug');
return this.variant('bug');
}

bugRows() {
return this.variant('Bug').all();
return this.variant('bug').all();
}
}

Expand Down
8 changes: 4 additions & 4 deletions examples/retail-store/src/data/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function createViewProductEvent(
db: Db,
event: EventBase & FieldInputTypes['__unbound__']['ViewProductEvent'],
) {
return db.orm.events.variant('ViewProductEvent').create({
return db.orm.events.variant('view-product').create({
userId: event.userId,
sessionId: event.sessionId,
timestamp: event.timestamp,
Expand All @@ -24,7 +24,7 @@ export function createSearchEvent(
db: Db,
event: EventBase & FieldInputTypes['__unbound__']['SearchEvent'],
) {
return db.orm.events.variant('SearchEvent').create({
return db.orm.events.variant('search').create({
userId: event.userId,
sessionId: event.sessionId,
timestamp: event.timestamp,
Expand All @@ -36,7 +36,7 @@ export function createAddToCartEvent(
db: Db,
event: EventBase & FieldInputTypes['__unbound__']['AddToCartEvent'],
) {
return db.orm.events.variant('AddToCartEvent').create({
return db.orm.events.variant('add-to-cart').create({
userId: event.userId,
sessionId: event.sessionId,
timestamp: event.timestamp,
Expand All @@ -50,7 +50,7 @@ export function findEventsByUser(db: Db, userId: string) {
}

export function findSearchEventsByUser(db: Db, userId: string) {
return db.orm.events.variant('SearchEvent').where(MongoFieldFilter.eq('userId', userId)).all();
return db.orm.events.variant('search').where(MongoFieldFilter.eq('userId', userId)).all();
}

interface EventTypeCount {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { blindCast } from '@internal/utils/casts';
import { ContractValidationError } from './contract-validation-error';
import type { CrossReference } from './cross-reference';
import type { ContractWithDomain } from './domain-envelope';
import type { ContractVariantEntry } from './domain-types';
import { asNamespaceId, type NamespaceId } from './namespace-id';

export interface DomainRelationShape {
Expand All @@ -15,7 +16,7 @@ export interface DomainModelShape {
readonly fields: Record<string, unknown>;
readonly relations?: Record<string, DomainRelationShape>;
readonly discriminator?: { readonly field: string };
readonly variants?: Record<string, unknown>;
readonly variants?: Record<string, ContractVariantEntry>;
readonly base?: CrossReference;
readonly owner?: string;
}
Expand Down Expand Up @@ -205,6 +206,20 @@ function validateDiscriminators(modelIndex: ModelIndex, errors: string[]): void
}
}

if (model.variants) {
const variantsByValue = new Map<string, string>();
for (const [variantName, { value }] of Object.entries(model.variants)) {
Comment thread
SevInf marked this conversation as resolved.
const existingVariant = variantsByValue.get(value);
if (existingVariant !== undefined) {
errors.push(
`Discriminator value "${value}" is used by both "${namespaceId}:${existingVariant}" and "${namespaceId}:${variantName}" on base model "${namespaceId}:${modelName}"`,
);
continue;
}
variantsByValue.set(value, variantName);
}
}

if (model.variants && Object.keys(model.variants).length > 0 && !model.discriminator) {
errors.push(`Model "${namespaceId}:${modelName}" has variants but no discriminator`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,42 @@ describe('validateContractDomain()', () => {
/model.*Child.*base.*must not.*variants/i,
);
});

it('accepts variants with distinct discriminator values', () => {
const contract = makeValidContract({
models: {
Item: makeMinimalModel({
fields: {
type: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/string@1' } },
},
discriminator: { field: 'type' },
variants: { Bug: { value: 'bug' }, Feature: { value: 'feature' } },
}),
Bug: makeMinimalModel({ base: crossRef('Item') }),
Feature: makeMinimalModel({ base: crossRef('Item') }),
},
});
expect(() => validateContractDomain(contract)).not.toThrow();
});

it('rejects variants that share a discriminator value', () => {
const contract = makeValidContract({
models: {
Item: makeMinimalModel({
fields: {
type: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/string@1' } },
},
discriminator: { field: 'type' },
variants: { Bug: { value: 'bug' }, OtherBug: { value: 'bug' } },
}),
Bug: makeMinimalModel({ base: crossRef('Item') }),
OtherBug: makeMinimalModel({ base: crossRef('Item') }),
},
});
expect(() => validateContractDomain(contract)).toThrow(
'Discriminator value "bug" is used by both "__unbound__:Bug" and "__unbound__:OtherBug" on base model "__unbound__:Item"',
);
});
});

it('does not reject orphaned models (advisory, removed from runtime validation)', () => {
Expand Down
Loading
Loading