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.
Entity Setup
Section titled “Entity Setup”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[]; }>;}Starting Data
Section titled “Starting Data”Every example on this page runs against this single row, so you can follow how its JSON document changes step by step.
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"]}']INSERT INTO `Company` (`name`, `settings`) VALUES (?, ?)INSERT INTO `Company` (`name`, `settings`) VALUES (?, ?) RETURNING `id` `id`INSERT INTO `Company` (`name`, `settings`) VALUES (?, ?) RETURNING `id` `id`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.
Filtering (Dot-Notation)
Section titled “Filtering (Dot-Notation)”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.
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']SELECT * FROM `Company`WHERE NOT (`settings`->'$.isArchived' <=> CAST(? AS JSON)) AND (`settings`->>'$.theme') = ?SELECT * FROM `Company`WHERE NOT (JSON_EXTRACT(`settings`, '$.isArchived') <=> JSON_EXTRACT(?, '$')) AND JSON_VALUE(`settings`, '$.theme') = ?SELECT * FROM `Company`WHERE (`settings`->'isArchived') IS NOT json(?) AND json_extract(`settings`, '$.theme') = ?Updating ($set / $unset / $push / $pull)
Section titled “Updating ($set / $unset / $push / $pull)”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.
$set: Assign Keys
Section titled “$set: Assign Keys”Assign top-level keys of an existing JSON field. Keys not named are preserved.
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]UPDATE `Company` SET `settings` = JSON_SET(COALESCE(`settings`, '{}'), '$.theme', CAST(? AS JSON)) WHERE `id` = ?-- values: ['"light"', id]UPDATE `Company` SET `settings` = JSON_SET(COALESCE(`settings`, '{}'), '$.theme', JSON_EXTRACT(?, '$')) WHERE `id` = ?-- values: ['"light"', id]UPDATE `Company` SET `settings` = json_set(COALESCE(`settings`, '{}'), '$.theme', json(?)) WHERE `id` = ?-- values: ['"light"', id]$unset: Remove Keys
Section titled “$unset: Remove Keys”Remove specific keys from a JSON field.
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]UPDATE `Company` SET `settings` = JSON_REMOVE(`settings`, '$.locale') WHERE `id` = ?UPDATE `Company` SET `settings` = JSON_REMOVE(`settings`, '$.locale') WHERE `id` = ?UPDATE `Company` SET `settings` = json_remove(`settings`, '$.locale') WHERE `id` = ?$push: Append to Array
Section titled “$push: Append to Array”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.
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]UPDATE `Company` SET `settings` = JSON_MERGE_PRESERVE(`settings`, JSON_OBJECT('tags', JSON_ARRAY(CAST(? AS JSON)))) WHERE `id` = ?-- values: ['"new-tag"', id]UPDATE `Company` SET `settings` = JSON_MERGE_PRESERVE(`settings`, JSON_OBJECT('tags', JSON_ARRAY(JSON_EXTRACT(?, '$')))) WHERE `id` = ?-- values: ['"new-tag"', id]UPDATE `Company` SET `settings` = json_insert(`settings`, '$.tags[#]', json(?)) WHERE `id` = ?-- values: ['"new-tag"', id]$pull: Remove From Array
Section titled “$pull: Remove From Array”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.
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]UPDATE `Company` SET `settings` = JSON_REPLACE(`settings`, '$.tags', ( SELECT COALESCE(JSON_ARRAYAGG(_uql_pull.v), JSON_ARRAY()) FROM JSON_TABLE(`settings`, '$.tags[*]' COLUMNS (v JSON PATH '$')) _uql_pull WHERE _uql_pull.v <> CAST(? AS JSON))) WHERE `id` = ?-- values: ['"stale-tag"', id]UPDATE `Company` SET `settings` = JSON_REPLACE(`settings`, '$.tags', ( SELECT COALESCE(JSON_ARRAYAGG(JSON_COMPACT(_uql_pull.v)), JSON_ARRAY()) FROM JSON_TABLE(`settings`, '$.tags[*]' COLUMNS (v JSON PATH '$')) _uql_pull WHERE NOT JSON_EQUALS(_uql_pull.v, JSON_EXTRACT(?, '$')))) WHERE `id` = ?-- values: ['"stale-tag"', id]UPDATE `Company` SET `settings` = json_replace(`settings`, '$.tags', ( SELECT json_group_array(json(`settings` -> _uql_pull.fullkey)) FROM json_each(`settings`, '$.tags') _uql_pull WHERE `settings` -> _uql_pull.fullkey <> json(?))) WHERE `id` = ?-- 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.
Combining Operators
Section titled “Combining Operators”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.
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.
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.
await pool.updateOneById(Company, id, { settings: { $set: { tags: ['kept'] }, $push: { tags: 'appended' } }, // -> tags: ['kept', 'appended']});Sorting (Dot-Notation)
Section titled “Sorting (Dot-Notation)”Sort by nested JSON field values using the same dot-notation syntax.
const companies = await pool.findMany(Company, { $sort: { 'settings.seats': 'desc' },});SELECT * FROM "Company" ORDER BY ("settings"->>'seats') DESCSELECT * FROM `Company` ORDER BY (`settings`->>'$.seats') DESCSELECT * FROM `Company` ORDER BY JSON_VALUE(`settings`, '$.seats') DESCSELECT * FROM `Company` ORDER BY json_extract(`settings`, '$.seats') DESCSupported Dialects
Section titled “Supported Dialects”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() |
Dialect Compatibility
Section titled “Dialect Compatibility”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+) |
Next Steps
Section titled “Next Steps”- Comparison Operators: The operator set dot-notation paths draw from.
- Decorators: Declaring
json/jsonbfields and other column types. - Full-Text Search: Searching text columns instead of JSON paths.
- Querier API: The read and write methods these payloads go to.