Skip to content

Update Operators

MongoDB-style atomic update operators for patch, provided by the updateOperators() hook. Unlike query operators (which build WHERE clauses), these build SET clauses — they modify a column based on its current value instead of overwriting it, computed in the database in a single statement.

OperatorSQLDescription
$inccol = col + valueIncrement (or decrement)
$mulcol = col * valueMultiply
$mincol = least(col, value)*Keep the smaller of current and value
$maxcol = greatest(col, value)*Keep the larger of current and value
$pushappend to an array columnAdd element(s) to an array
$pullremove from an array columnRemove element(s) from an array

* $min / $max compile to a portable CASE expression (case when col is null or col > value then value else col end) rather than LEAST/GREATEST, so they work identically on PostgreSQL, MySQL and SQLite. A NULL column is treated as "no value yet" and initialized to value.

Setup

updateOperators() is an opt-in hook. Register it on patch — and on update too, so misuse there fails loudly (see below). It works as a before or an around hook:

ts
import { updateOperators } from "@fratzinger/feathers-kysely";

app.service("users").hooks({
  before: {
    patch: [updateOperators()],
    update: [updateOperators()],
  },
});

Usage

$inc increments a column atomically (a negative value decrements):

ts
// SET views = views + 1
await app.service("articles").patch(id, { $inc: { views: 1 } });

// SET stock = stock - 5
await app.service("products").patch(id, { $inc: { stock: -5 } });

$mul multiplies a column:

ts
// SET price = price * 2
await app.service("products").patch(id, { $mul: { price: 2 } });

$min / $max clamp a column to the smaller / larger of its current value and the given value — useful for low-water / high-water marks (lowest price seen, highest score). A NULL column is initialized to the value:

ts
// only lowers the floor, never raises it
await app.service("products").patch(id, { $min: { lowestPrice: 9.99 } });

// only raises the high score, never lowers it
await app.service("games").patch(id, { $max: { highScore: 4200 } });

Operators combine with each other and with plain values in a single patch:

ts
// SET name = 'Sale', price = price * 0.8, sold = sold + 1
await app.service("products").patch(id, {
  name: "Sale",
  $mul: { price: 0.8 },
  $inc: { sold: 1 },
});

Because the work happens in the database, it is safe under concurrency — two simultaneous { $inc: { views: 1 } } patches both count, with no read-modify-write race. It also applies to multi-patch (every matched row is updated):

ts
// every active user's loginCount goes up by 1
await app
  .service("users")
  .patch(null, { $inc: { loginCount: 1 } }, { query: { active: true } });

Array operators ($push / $pull)

$push appends to an array column, $pull removes matching elements. A scalar value affects one element; an array value affects each:

ts
// append one tag, or several
await app.service("posts").patch(id, { $push: { tags: "kysely" } });
await app.service("posts").patch(id, { $push: { tags: ["sql", "node"] } });

// remove one value (all occurrences), or several
await app.service("posts").patch(id, { $pull: { tags: "draft" } });
await app.service("posts").patch(id, { $pull: { tags: ["draft", "wip"] } });

Column type detection

The SQL differs completely between a native Postgres array (text[], integer[], …) and a json / jsonb column, so the operator must know the column's storage. It resolves the type per column the same way the query operators do — from a getPropertyType option, or an x-db-type annotation in the service's properties:

ts
new KyselyService({
  Model: db,
  name: "posts",
  properties: {
    tags: { type: "array", "x-db-type": "text[]" }, // native Postgres array
    labels: { type: "array", "x-db-type": "jsonb" }, // jsonb array
  },
});

If a column's storage can't be determined, $push / $pull throw a BadRequest rather than guessing.

Support matrix

Column storage$push$pull
Postgres native array✅ Postgres only✅ Postgres only
json / jsonb✅ all dialectsPostgres only
  • Native array ops use array_append / array_remove / || and exist only on PostgreSQL (other dialects throw BadRequest).
  • JSON $push works on all dialects (|| on Postgres, json_array_append on MySQL, json_insert on SQLite).
  • JSON $pull is Postgres-only (a jsonb_agg filter); on MySQL / SQLite it throws BadRequest, as it can't be expressed as a single statement.

patch only

Operators are only meaningful for patch (a partial update). update replaces the entire record, so an operator there is almost certainly a mistake — using one on update throws a BadRequest. Register the hook on update as well to get that clear error rather than a confusing database failure.

Validation

Invalid payloads throw a BadRequest:

  • the operator value must be an object ({ $inc: 5 } is rejected);
  • for $inc / $mul / $min / $max, each target value must be a finite number ({ $inc: { age: "x" } }, NaN, and Infinity are rejected);
  • for $push / $pull, the value must be defined, and the column's storage must be detectable (see above).

TypeScript

PatchData<T> is Partial<T>, so the operator keys are not part of the static type. Cast at the call site when needed:

ts
await app.service("products").patch(id, { $inc: { stock: -1 } } as any);

Dialects

$inc / $mul / $min / $max work on all dialects (PostgreSQL, MySQL, SQLite) — the generated arithmetic / CASE is standard SQL, and every value is bound as a parameter. $push / $pull are dialect- and storage-specific; see the support matrix above.