Skip to content

feat(core): allow building trigger bodies via query builder - #8373

Draft
B4nan wants to merge 6 commits into
nextfrom
claude/trigger-qb-bodies
Draft

B4nan wants to merge 6 commits into
nextfrom
claude/trigger-qb-bodies

Conversation

@B4nan

@B4nan B4nan commented Sep 30, 2026

Copy link
Copy Markdown
Member

Prototype for #8372. Trigger body callbacks now get a third { new, old, em } argument, so a DML-only trigger (like writing a history row) can be built with the query builder and rendered for each dialect from one definition:

triggers: [{
  timing: 'after',
  events: ['update'],
  body: (_, __, { old, new: row, em }) =>
    em.createQueryBuilder(ThingHistory)
      .insert({ thingId: old.id, oldName: old.name, newName: row.name, op: 'update' }),
}]
  • new/old map property names to the pseudo-row column references (new."col", or inserted.[col]/deleted.[col] on MSSQL).
  • The schema generator evaluates the callbacks now, not metadata discovery, because they need the platform and the entity manager.
  • Query builder statements are rendered without the returning/output clause. PostgreSQL appends return coalesce(new, old). MSSQL rewrites insert ... values (inserted.x) to insert ... select inserted.x from inserted, joining deleted on the PK when both rows are used.

Still open: em is typed as any, so the target entity only gets type-checked when you cast it to SqlEntityManager. The row references don't carry column types, and reading old in an insert-only trigger isn't a type error. On MSSQL, only single-row inserts can reference the rows. No docs yet.

Adds a per-entity `forceUndefined` option, available as `@Entity({
forceUndefined: true })`, `new EntitySchema({ forceUndefined: true })`
and `defineEntity({ forceUndefined: true })`. It takes precedence over
the global `forceUndefined` ORM option and base entities pass it down.
Hydration, the entity factory, orphaned reference handling and change
set normalization read it from the metadata.

With `defineEntity`, the option also drops `null` from the inferred
types, so `.nullable()` and `.strictNullable()` properties infer as `T |
undefined` instead of `T | null | undefined`. The marker lands in
`[Config]` (`TypeConfig` gains `forceUndefined`), same as `forceObject`,
and `extends` picks it up from the base entity's `[Config]`. Decorator
and `EntitySchema` entities declare their property types by hand, so for
them the option only changes the runtime.

The type-level swap runs once on the resolved entity type in the
overload return position instead of per property. Anything inside the
property chain gets re-instantiated on every inference round of the
call, which pushed the type benchmark up by 30% or more. The final shape
stays within the 10% threshold on every case.

Closes #8267
…8327)

Conditions on a polymorphic relation like `{ imageable: { title: 'foo' }
}` used to resolve only against the first target, so properties of the
other targets threw as unknown. Now they go to the targets that define
all the queried properties. A single matching target is joined directly.
When several match, each is left joined and the branches are combined
with `$or`, with each branch also requiring the target PK to be
non-null.

```ts
// only `Article` has `title`
await em.find(Image, { imageable: { title: 'foo' } });
// both `Product` and `Article` have `name`
await em.find(Image, { imageable: { name: 'shared' } });
```

Primary key conditions follow the same rule. A PK that every target
defines, like `{ imageable: { id: 1 } }`, is still compared on the FK
column without a join and matches any type. A PK only some targets
define, like `{ source: { code: 7 } }`, joins those targets so the type
gets checked too.

Ordering still resolves against the first target only. MongoDB throws
for these conditions, same as for other non-reference polymorphic
conditions.

The branch includes a merge of `master` (#8326, #8328), so those commits
show up here until `master` is merged into `next`.
MongoDB has no joins, so conditions like `{ author: { name: 'Jon' } }`
or `{ tags: { name: 't' } }`, and any condition on an inverse side like
`{ books: { title: 'b1' } }`, were sent as is and silently matched
nothing. When one of them sat in an `$or` next to a matching branch, the
query looked like it worked. They now throw an error that names the
relation.

Conditions by PK keep working. The serialized PK is now supported as
well: `{ author: { id: hexString } }` used to match nothing and now
compares the reference like `{ author: { _id } }` does.

This targets `next`, because queries that looked like they worked before
(a relation condition inside an `$or` that other branches satisfied) now
throw.
…8333)

Ordering by a property of a polymorphic relation target used to resolve
only against the first target, so `{ imageable: { title: 'asc' } }`
threw when `title` lives on another target. Ordering by a property
several targets define only used the first target's column.

Each ordered property now goes to the targets that define it. A single
target is left joined and ordered by its column. With several targets,
each one is left joined (reusing joins from conditions on the same
targets) and rows are ordered by `coalesce(...)` of their columns, so
different types sort together:

```ts
await em.find(Image, {}, { orderBy: { imageable: { name: 'asc' } } });
// order by coalesce(`p1`.`name`, `a2`.`name`) asc
```

Relations and formulas of the targets use the first target that defines
them, and a PK that every target defines still orders by the FK column
without a join. Ordering by a property of the first target no longer
drops rows that point to other targets on non-nullable relations: it
used an inner join. That part is also fixed separately on master.

Limitations: the `orderBy` type only accepts properties all targets
define, so a property of a single target needs a cast. Distributing
`QueryOrderMap` over unions broke generic code in the core. When three
or more ordered fields alternate between different target sets, fields
with the same set are grouped, which can change their priority.
…columns (#8353)

Adds a `sequence: { startWith, incrementBy }` option for autoincrement
columns (decorators, `defineEntity` via `.sequence()`, `EntitySchema`).

```ts
@PrimaryKey({ sequence: { startWith: 1000, incrementBy: 10 } })
id!: number;
```

In `defineEntity`, a number is a shorthand for the start value:
`p.integer().primary().sequence(1000)`.

The options are applied when the column is created, and
`orm.schema.clear()` resets the counter back to `startWith`. The schema
generator never diffs them on any driver, so changing them on an
existing table produces no migration. Moving a live counter stays an
explicit migration.

That includes an autoincrement column added to an existing table. On
PostgreSQL and MySQL its existing rows get numbered from `startWith` as
well (PostgreSQL expands the `serial` column to a sequence created with
the options upfront). On PostgreSQL, a column that becomes autoincrement
starts at `startWith`, or continues after its highest existing value if
that is higher.

| Driver | On create |
|---|---|
| PostgreSQL `serial` | `alter sequence … start with … increment by …
restart` (via `pg_get_serial_sequence`) |
| PostgreSQL identity | `generated … as identity (start with … increment
by …)` |
| MySQL, MariaDB | `auto_increment = …` table option |
| SQLite, libSQL | row in `sqlite_sequence` |
| MSSQL | `identity(start, increment)` |
| Oracle | `generated by default as identity (start with … increment by
…)` |

Descending increments and start values past the default sequence bounds
(e.g. `startWith: 0`) work too. PostgreSQL and Oracle get the matching
`minvalue`/`maxvalue` for them.

MySQL, MariaDB and SQLite have no per-column increment, so `incrementBy`
throws a metadata error there. So does `sequence` on a property that
isn't autoincrement, or together with identity options written in
`generated`.

The entity generator reads the options back on PostgreSQL (serial and
identity), MSSQL and Oracle, and emits `sequence` in all definition
styles. MySQL, MariaDB and SQLite only store the next counter value, so
there's nothing to read there.

Two related changes:
- The schema comparator ignores identity option lists in `generated`
(`identity (start with …)`), so they follow the same create-only rule
instead of dropping and re-adding the column.
- MSSQL `clear()` picks the reseed value from
`sys.identity_columns.last_value` instead of the number of deleted rows.
This also fixes repeated `clear()` calls leaving the next id at 2 for
plain autoincrement PKs.

Closes #8350
Trigger body callbacks now receive a third `{ new, old, em }` argument. `new`/`old` map property names to the dialect's pseudo-row column references, and the callback can return a query builder, which is rendered without the `returning` clause (plus the `return` statement PostgreSQL requires). On MSSQL, inserts referencing the rows are rewritten to `insert ... select ... from inserted/deleted`.
@codecov

codecov Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.33962% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.66%. Comparing base (6ab3774) to head (df56a0c).

Files with missing lines Patch % Lines
packages/mssql/src/MsSqlSchemaHelper.ts 88.23% 2 Missing ⚠️
packages/sql/src/schema/DatabaseSchema.ts 96.29% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             next    #8373      +/-   ##
==========================================
- Coverage   99.67%   99.66%   -0.01%     
==========================================
  Files         293      293              
  Lines       32166    32211      +45     
  Branches     9238     9248      +10     
==========================================
+ Hits        32060    32102      +42     
- Misses        102      105       +3     
  Partials        4        4              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@boenrobot

boenrobot commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Query builder statements are rendered without the returning/output clause. PostgreSQL appends return coalesce(new, old). MSSQL rewrites insert ... values (inserted.x) to insert ... select inserted.x from inserted, joining deleted on the PK when both rows are used.

I am not familiar enough with the quirks of MSSQL and PGSQL, so I feel compelled to ask... why these adjustments to query builder output? Do those triggers have some feature that a MySQL/MariaDB and Oracle trigger does not?

@B4nan

B4nan commented Sep 30, 2026

Copy link
Copy Markdown
Member Author

It's the other way around, those adjustments are about what postgres and mssql triggers lack or do differently:

  • The QB adds returning/output to inserts on its own, so the ORM can read back generated PKs. Inside a trigger body there is nothing to return it to. Postgres fails with "query has no destination for result data", and sqlite doesn't allow returning in triggers at all. MySQL/MariaDB don't use returning in the QB, so nothing gets stripped there.
  • Postgres triggers aren't standalone blocks. They execute a plpgsql function that returns trigger, and that function has to end with a return. For before row triggers the returned row is what gets written, otherwise the value is ignored. MySQL/MariaDB/SQLite trigger bodies are plain begin ... end blocks with no return value.
  • MSSQL has no row-level triggers. They fire once per statement and expose the affected rows as the inserted/deleted pseudo-tables, there are no NEW/OLD row variables. So values (inserted.x) is invalid and you have to select from the pseudo-table instead, which also handles multi-row statements.

Oracle triggers aren't supported by the ORM yet.

@boenrobot

boenrobot commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

I see. Makes sense.

Though it might be good to also expose these transformations in a method to the query builder, for the sake of users who may start with a query builder, and later change to a raw query, because of some adjustment that the builder can't handle... like having two DML queries, rather than one.

idk, something like a .toTriggerStatement() or something, that would return a Raw fragment (or string?) with those transformations applied. The docs can then simply say "toTriggerStatement() is implicitly called if a query builder is returned", rather than specify all the transformations.

B4nan added a commit that referenced this pull request Sep 30, 2026
Adds entity trigger support to the Oracle driver. It used to fall back
to the generic `SchemaHelper.createTrigger`, which isn't valid Oracle
DDL, and triggers were never introspected.

- `createTrigger` emits `create or replace trigger "name" BEFORE|AFTER
INSERT OR UPDATE OR DELETE on "table" [for each row] [when (...)] begin
... end;`, qualified with the schema when it isn't the default one.
Oracle allows multiple events and both row and statement level in one
trigger. `instead of` (views only), `truncate`, and `when` on a
statement-level trigger throw an error.
- `dropTrigger` uses `drop trigger if exists`, like the other Oracle
drop statements (23c+).
- Introspection reads `all_triggers` (the LONG `trigger_body` comes back
as a string), so a freshly created trigger produces no diff.
- The schema generator splits statements on `;\n`, which dropped the
closing `;` of a PL/SQL block whenever another statement followed it in
the same batch. `startsBatch` now also receives the previous line, and
Oracle starts a new batch after a line ending in `end;`. Migrations
already worked because each statement runs on its own.

Once #8373 lands, Oracle needs a `triggerRowReference` override that
renders `:new."col"` / `:old."col"`.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants