mendix-bulk-oql-dml · git:20260815.4828f81 · 2026-08-15 · sha256 2054f3eb80843fd0

mendix-bulk-oql-dml git:20260815.4828f81A

Immutable. This exact content is served forever at /api/v1/blob/2054f3eb80843fd0.

---
name: mendix-bulk-oql-dml
description: Run set-based INSERT, UPDATE and DELETE against Mendix entities through OQL statements, which the runtime supports and Studio Pro cannot author. Use when a microflow would otherwise retrieve a large list and loop — applying rules across a table, copying a year of records, staging and promoting an import, archiving, backfilling a column — or when a nested retrieve-loop-commit is the reason a screen or a scheduled job is slow.
---

# Bulk DML through OQL statements

## What this is

The Mendix runtime executes OQL **statements**, not just queries. Three calls, all
in `com.mendix.public-api.jar`:

```java
com.mendix.core.Core.createOqlStatement(String)   // -> OqlStatement
OqlStatement.setVariable(name, value)             // -> OqlStatement, chainable
OqlStatement.execute(IContext)                    // -> int rows affected
```

Studio Pro has no activity for this, so the only way to reach it is a Java action.
[`mdl/oql-dml-actions.mdl`](mdl/oql-dml-actions.mdl) is three of them, authored in
MDL with inline Java, ready to apply to any project:

| Action | Use |
|---|---|
| `OQL_Execute(Statement)` | One statement, no variables. Returns rows affected, throws on failure. |
| `OQL_ExecuteWith(Statement, Name/Value/Type ×4)` | Same, with up to four bound, typed variables. |
| `OQL_Try(Statement)` | Returns `OK rows=N` or `ERR …` instead of throwing. For probing, not for production paths. |

```bash
mxcli exec .claude/skills/mendix-bulk-oql-dml/mdl/oql-dml-actions.mdl -p MyApp.mpr
```

## Check the version first

Each statement type arrived in a different runtime release, and the last one is
recent enough that "it works on my app" is not transferable:

| Statement | Available from |
|---|---|
| `DELETE` | 11.1.0 |
| `UPDATE` | 11.3.0 — associations 11.4.0 |
| `INSERT … SELECT` | 11.6.0 — associations 11.7.0 |
| `INSERT … VALUES` | 11.13.0 |

Pin the runtime version deliberately before building on this.

## When to use it, and when not

Use it when the work is **a set** and nobody is looking at the rows: applying
rules over a table, copying a year of records, promoting a staged import,
archiving, backfilling a new column. One statement replaces a retrieve of every
match into memory, a loop, and a commit per object.

Do not use it for a single object a user is editing. And know what it skips —
this is the thing to say out loud before choosing it:

> A statement runs in the database, inside the calling microflow's transaction.
> It does not pass through the object cache, so **no event handlers fire, no
> validation rules run, an object already retrieved keeps its old values, and a
> client holding one is not refreshed.**

If the entity's correctness depends on a before-commit handler, either move that
logic into the statement or do not use a statement.

## Making the result visible

A grid over rows a statement just rewrote keeps showing the old ones, because
nothing told the client. The pattern that fixes it, used on both screens in this
project:

1. The screen has a small non-persistent context object.
2. The grid's datasource is a **database** source constrained on that object
   (`where [Batch = $currentObject/Batch]`).
3. The action ends with `commit $Context refresh;`.

Refreshing the context re-runs the grid's query. Without step 3 the screen is
quietly wrong, which is worse than obviously wrong.

## Patterns that work

Full statements, from three working use cases, are in
[`references/patterns.md`](references/patterns.md):

- **First-match-wins rules** — one `UPDATE` per rule, in order. The precedence
  lives in the WHERE clause: each statement only touches rows still unclaimed,
  so a later rule cannot take a row an earlier one took. No flags, no loop.
- **Copy a year** — `INSERT … SELECT` per month. Idempotent by deleting the
  target window first, in the same transaction.
- **Stage and promote** — land rows in a loader entity, validate them *where
  they landed* with one `UPDATE` per check stamping a reason on the failures,
  then promote the survivors with a single `INSERT … SELECT`. The rejects stay
  behind with their reason, which is the whole argument for a loader table.

## Before writing a statement

Read [`references/gotchas.md`](references/gotchas.md). Four things cost real time
here, and one of them wrote bad data:

1. **An association compared to `null` in a WHERE matches nothing** — in both
   spellings, with no error. A validation written that way passes every row.
2. **Alias every column in an `INSERT … SELECT`.** Two association paths both
   end in `/id` and collide as `Duplicate column name: ID`, naming a column that
   is not in your statement.
3. **Association columns must be module-qualified** — `Ledger.Order_Customer`,
   not `Order_Customer`.
4. **No `substring`.** String surgery has to be done by the caller, which is why
   "copy a year" is twelve statements rather than one.

## Probing safely

The grammar is not discoverable from the model, so find out by running. Use
`OQL_Try` with statements whose WHERE cannot match, from a microflow that logs
each result, and read the log. Sixteen statements in one pass is what mapped the
matrix in `references/patterns.md`.

Two warnings if you use the after-startup microflow as the harness, both learned
the hard way: a statement that throws there **takes the whole app down**, and
the action is one transaction, so a failure at the end **rolls back everything
before it** — including work whose log lines already said it succeeded.