Skip to content

JSON / JSONB

UQL provides first-class support for JSON/JSONB fields across PostgreSQL, MySQL, MariaDB, and SQLite: query, update, and sort by nested JSON properties with the same type-safe API on every dialect.

Wrap JSONB field types with Json<T> to enable full type safety: IDE autocompletion for dot-notation paths, $set keys, $unset keys, and $push/$pull targets.

import { Entity, Id, Field, type Json } from 'uql-orm';
@Entity()
export class Company {
@Id({ type: Number })
id?: number;
@Field({ type: String })
name?: string;
@Field({ type: 'jsonb' })
settings?: Json<{
theme?: string;
locale?: string;
isArchived?: boolean;
seats?: number;
tags?: string[];
}>;
}

Every example on this page runs against this single row, so you can follow how its JSON document changes step by step.

You write
import { pool } from './uql.config.js';
import { Company } from './shared/models/index.js';
const id = await pool.insertOne(Company, {
name: 'Acme',
settings: { theme: 'dark', locale: 'en', isArchived: false, seats: 12, tags: ['legacy', 'stale-tag'] },
});
INSERT INTO "Company" ("name", "settings") VALUES ($1, $2::jsonb) RETURNING "id" "id"
-- values: ['Acme', '{"theme":"dark","locale":"en","isArchived":false,"seats":12,"tags":["legacy","stale-tag"]}']

The document is stringified in the ORM and bound as a single parameter, so an insert writes the whole value at once. Inserts and upserts take plain values only: the operators below belong to update payloads.


Query nested JSON properties using dot-notation paths in $where. Every comparison operator applicable to the path’s value type is supported.

Dot-notation keys are fully typed: paths are restricted to real JSON fields, and each path resolves its value type from Json<T>, so a typo’d path ('settings.thme'), a dot-path on a non-JSON field, an operator that does not apply to the path’s type ($size on a string-typed path), or a mismatched value (a number where the path is a string) is a compile error. An untyped Json<unknown> field keeps its flexibility: any field.suffix path is accepted with permissive values.

You write
const companies = await pool.findMany(Company, {
$where: {
'settings.isArchived': { $ne: true },
'settings.theme': 'dark',
},
});
// -> matches the row above: isArchived is false and theme is 'dark'
SELECT * FROM "Company"
WHERE ("settings"->'isArchived') IS DISTINCT FROM $1::jsonb
AND ("settings"->>'theme') = $2
-- values: ['true', 'dark']

Atomically merge or remove keys in JSON fields directly from update payloads. No need to overwrite the entire JSON value.

Each example below starts from the row inserted above, and its trailing comment shows the resulting settings document.

Assign top-level keys of an existing JSON field. Keys not named are preserved.

You write
await pool.updateOneById(Company, id, {
settings: { $set: { theme: 'light' } }, // -> theme: 'light', every other key untouched
});
UPDATE "Company" SET "settings" = COALESCE("settings", '{}'::jsonb) || $1::jsonb WHERE "id" = $2
-- values: ['{"theme":"light"}', id]

Remove specific keys from a JSON field.

You write
await pool.updateOneById(Company, id, {
settings: { $unset: ['locale'] }, // -> the locale key is gone
});
UPDATE "Company" SET "settings" = ("settings") - $1::text[] WHERE "id" = $2
-- values: [['locale'], id]

Append a value to the end of a JSON array. Only keys whose type is an array are valid $push targets (type-checked at compile time). If the key does not exist yet, it is created with a single-element array - identically on every dialect.

You write
await pool.updateOneById(Company, id, {
settings: { $push: { tags: 'new-tag' } }, // -> tags: ['legacy', 'stale-tag', 'new-tag']
});
UPDATE "Company" SET "settings" = jsonb_set("settings", '{tags}', COALESCE(("settings")->'tags', '[]'::jsonb) || jsonb_build_array($1::jsonb)) WHERE "id" = $2
-- values: ['"new-tag"', id]

Remove every element equal to the given value. Like $push, only array-typed keys are valid targets and the value is typed as the array’s element.

You write
await pool.updateOneById(Company, id, {
settings: { $pull: { tags: 'stale-tag' } }, // -> tags: ['legacy']
});
UPDATE "Company" SET "settings" = jsonb_set("settings", '{tags}', COALESCE((
SELECT jsonb_agg(_uql_pull.val ORDER BY _uql_pull.ord)
FROM jsonb_array_elements("settings"->'tags') WITH ORDINALITY AS _uql_pull(val, ord)
WHERE _uql_pull.val <> $1::jsonb), '[]'::jsonb), false) WHERE "id" = $2
-- values: ['"stale-tag"', id]

A $pull on a key that does not exist (or on a NULL column) is a no-op: it never creates the key and never nulls the document. Removing the last element leaves an empty array, not a missing key.

All four operators can be freely combined in a single, atomic update. They are applied in a fixed order - $pull -> $set -> $push -> $unset - so every combination produces the same result on every dialect.

You write
await pool.updateOneById(Company, id, {
settings: { $set: { theme: 'light' }, $push: { tags: 'new-tag' }, $unset: ['locale'] },
// -> { theme: 'light', isArchived: false, seats: 12, tags: ['legacy', 'stale-tag', 'new-tag'] }
});

That order is what makes “replace an element” a single atomic statement: the $pull filters the stored array and the $push appends to that result.

Atomically replace a tag
await pool.updateOneById(Company, id, {
settings: { $pull: { tags: 'stale-tag' }, $push: { tags: 'fresh-tag' } }, // -> tags: ['legacy', 'fresh-tag']
});

Combining two operators on the same key works as well, and follows the same order: a $set replaces the array outright, so a $push beside it appends to the value you just set.

Set then append, on one key
await pool.updateOneById(Company, id, {
settings: { $set: { tags: ['kept'] }, $push: { tags: 'appended' } }, // -> tags: ['kept', 'appended']
});

Sort by nested JSON field values using the same dot-notation syntax.

You write
const companies = await pool.findMany(Company, {
$sort: { 'settings.seats': 'desc' },
});
SELECT * FROM "Company" ORDER BY ("settings"->>'seats') DESC

All JSON features work across four SQL dialects:

Feature PostgreSQL MySQL MariaDB SQLite
Dot-notation filtering ->>'key' ->>'key' JSON_VALUE() json_extract()
$set || ::jsonb JSON_SET() JSON_SET() json_set()
$unset - ::text[] JSON_REMOVE() JSON_REMOVE() json_remove()
$push jsonb_set() + || JSON_MERGE_PRESERVE() JSON_MERGE_PRESERVE() json_insert()
$pull jsonb_agg() filter JSON_TABLE() filter JSON_TABLE() + JSON_EQUALS() json_each() filter
Dot-notation sorting ->>'key' ->>'key' JSON_VALUE() json_extract()
$size jsonb_array_length() JSON_LENGTH() JSON_LENGTH() json_array_length()
$all @> ::jsonb JSON_CONTAINS() JSON_CONTAINS() json_each()
$elemMatch jsonb_array_elements JSON_TABLE() JSON_TABLE() json_each()

This page targets modern, actively maintained database lines. Baselines below reflect the current compatibility target for generated SQL:

Dialect Practical baseline Notes
PostgreSQL 16+ Uses jsonb operators/functions (->>, `
MySQL 8.4+ Uses ->>, JSON_SET, JSON_REMOVE, JSON_MERGE_PRESERVE, JSON_TABLE ($pull needs 8.0.4+)
MariaDB 12.2+ Uses JSON_VALUE for dot-notation path extraction (not ->>), plus JSON_SET, JSON_REMOVE, JSON_MERGE_PRESERVE, JSON_TABLE ($pull needs 10.7+ for JSON_EQUALS)
SQLite 3.45+ Uses json_extract, json_set, json_remove, json_insert(..., '$[#]', ...) for append, and json_each/-> for $pull (needs 3.38+)