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 using a consistent, type-safe API. UQL generates dialect-specific SQL automatically.

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, Json } from 'uql-orm';
@Entity()
export class Company {
@Id()
id?: number;
@Field()
name?: string;
@Field({ type: 'jsonb' })
kind?: Json<{ public?: number; private?: number; tags?: string[] }>;
@Field({ type: 'jsonb' })
settings?: Json<{ isArchived?: boolean; theme?: string; locale?: string }>;
}

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 querier.findMany(Company, {
$where: {
'settings.isArchived': { $ne: true },
'settings.theme': 'dark',
},
});
Generated SQL (PostgreSQL, node-pg)
SELECT * FROM "Company"
WHERE ("settings"->>'isArchived') IS DISTINCT FROM $1
AND "settings"->>'theme' = $2
Generated SQL (MySQL)
SELECT * FROM `Company`
WHERE (`settings`->>'isArchived') <> ?
AND (`settings`->>'theme') = ?
Generated SQL (MariaDB)
SELECT * FROM `Company`
WHERE JSON_VALUE(`settings`, '$.isArchived') <> ?
AND JSON_VALUE(`settings`, '$.theme') = ?
Generated SQL (SQLite)
SELECT * FROM `Company`
WHERE json_extract(`settings`, '$.isArchived') IS NOT ?
AND json_extract(`settings`, '$.theme') = ?

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

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

You write
await querier.updateOneById(Company, id, {
kind: { $set: { public: 1 } },
});
Generated SQL (PostgreSQL, node-pg)
UPDATE "Company" SET "kind" = COALESCE("kind", '{}'::jsonb) || $1::jsonb WHERE "id" = $2
-- values: ['{"public":1}', id]
Generated SQL (MySQL)
UPDATE `Company` SET `kind` = JSON_SET(COALESCE(`kind`, '{}'), '$.public', CAST(? AS JSON)) WHERE `id` = ?
-- values: ['1', id]
Generated SQL (MariaDB)
UPDATE `Company` SET `kind` = JSON_SET(COALESCE(`kind`, '{}'), '$.public', CAST(? AS JSON)) WHERE `id` = ?
-- values: ['1', id]
Generated SQL (SQLite)
UPDATE `Company` SET `kind` = json_set(COALESCE(`kind`, '{}'), '$.public', json(?)) WHERE `id` = ?
-- values: ['1', id]

Remove specific keys from a JSON field.

You write
await querier.updateOneById(Company, id, {
kind: { $unset: ['private'] },
});
Generated SQL (PostgreSQL, node-pg)
UPDATE "Company" SET "kind" = ("kind") - $1::text[] WHERE "id" = $2
-- values: [['private'], id]
Generated SQL (MySQL)
UPDATE `Company` SET `kind` = JSON_REMOVE(`kind`, '$.private') WHERE `id` = ?
Generated SQL (MariaDB)
UPDATE `Company` SET `kind` = JSON_REMOVE(`kind`, '$.private') WHERE `id` = ?
Generated SQL (SQLite)
UPDATE `Company` SET `kind` = json_remove(`kind`, '$.private') WHERE `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 querier.updateOneById(Company, id, {
kind: { $push: { tags: 'new-tag' } },
});
Generated SQL (PostgreSQL, node-pg)
UPDATE "Company" SET "kind" = jsonb_set("kind", '{tags}', COALESCE("kind"->'tags', '[]'::jsonb) || jsonb_build_array($1::jsonb)) WHERE "id" = $2
-- values: ['"new-tag"', id]
Generated SQL (MySQL)
UPDATE `Company` SET `kind` = JSON_MERGE_PRESERVE(`kind`, JSON_OBJECT('tags', JSON_ARRAY(CAST(? AS JSON)))) WHERE `id` = ?
-- values: ['"new-tag"', id]
Generated SQL (MariaDB)
UPDATE `Company` SET `kind` = JSON_MERGE_PRESERVE(`kind`, JSON_OBJECT('tags', JSON_ARRAY(JSON_EXTRACT(?, '$')))) WHERE `id` = ?
-- values: ['"new-tag"', id]
Generated SQL (SQLite)
UPDATE `Company` SET `kind` = json_insert(`kind`, '$.tags[#]', json(?)) WHERE `id` = ?
-- 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 querier.updateOneById(Company, id, {
kind: { $pull: { tags: 'stale-tag' } },
});
Generated SQL (PostgreSQL, node-pg)
UPDATE "Company" SET "kind" = jsonb_set("kind", '{tags}', COALESCE((
SELECT jsonb_agg(uql_pull.val ORDER BY uql_pull.ord)
FROM jsonb_array_elements("kind"->'tags') WITH ORDINALITY AS uql_pull(val, ord)
WHERE uql_pull.val <> $1::jsonb), '[]'::jsonb), false) WHERE "id" = $2
-- values: ['"stale-tag"', id]
Generated SQL (MySQL)
UPDATE `Company` SET `kind` = JSON_REPLACE(`kind`, '$.tags', (
SELECT COALESCE(JSON_ARRAYAGG(uql_pull.v), JSON_ARRAY())
FROM JSON_TABLE(`kind`, '$.tags[*]' COLUMNS (v JSON PATH '$')) uql_pull
WHERE uql_pull.v <> CAST(? AS JSON))) WHERE `id` = ?
-- values: ['"stale-tag"', id]
Generated SQL (MariaDB)
UPDATE `Company` SET `kind` = JSON_REPLACE(`kind`, '$.tags', (
SELECT COALESCE(JSON_ARRAYAGG(JSON_COMPACT(uql_pull.v)), JSON_ARRAY())
FROM JSON_TABLE(`kind`, '$.tags[*]' COLUMNS (v JSON PATH '$')) uql_pull
WHERE NOT JSON_EQUALS(uql_pull.v, JSON_EXTRACT(?, '$')))) WHERE `id` = ?
-- values: ['"stale-tag"', id]
Generated SQL (SQLite)
UPDATE `Company` SET `kind` = json_replace(`kind`, '$.tags', (
SELECT json_group_array(json(`kind` -> uql_pull.fullkey))
FROM json_each(`kind`, '$.tags') uql_pull
WHERE `kind` -> 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.

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 querier.updateOneById(Company, id, {
kind: { $set: { public: 1 }, $push: { tags: 'new-tag' }, $unset: ['private'] },
});

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 querier.updateOneById(Company, id, {
kind: { $pull: { tags: 'old' }, $push: { tags: 'new' } },
});

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 querier.updateOneById(Company, id, {
kind: { $set: { tags: ['kept'] }, $push: { tags: 'appended' } }, // -> ['kept', 'appended']
});

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

You write
const companies = await querier.findMany(Company, {
$sort: { 'kind.public': 'desc' },
});
Generated SQL (PostgreSQL, node-pg)
SELECT * FROM "Company" ORDER BY "kind"->>'public' DESC
Generated SQL (MySQL)
SELECT * FROM `Company` ORDER BY (`kind`->>'public') DESC
Generated SQL (MariaDB)
SELECT * FROM `Company` ORDER BY JSON_VALUE(`kind`, '$.public') DESC
Generated SQL (SQLite)
SELECT * FROM `Company` ORDER BY json_extract(`kind`, '$.public') 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+)