JSON

dig reads inside a JSON document, by the name of what Hash does. A string or symbol steps into an object, an integer into an array, and what comes back is still JSON — the way Hash#dig hands back the structure itself — for a document to be dug into further or asked the JSON questions. dig_text gives the value as text instead, which is what a comparison wants:

Post.where { :meta.dig_text(:author, :name) == "alice" }
Post.select { :meta.dig(:author).as(:author) }
Post.where { :meta.key?(:draft) }
Post.where { :meta.contains?(status: "open") }

No two adapters spell any of this alike, and the block is the same on all three:

PostgreSQL SQLite MySQL
dig(:a, :b) #> '{a,b}' -> '$.a.b' JSON_EXTRACT(…, '$.a.b')
dig_text(:a, :b) #>> '{a,b}' ->> '$.a.b' JSON_UNQUOTE(JSON_EXTRACT(…, '$.a.b'))
key?(:a) ? 'a' json_type(…, '$.a') IS NOT NULL JSON_CONTAINS_PATH(…, 'one', '$.a')
contains?(…) @> JSON_CONTAINS

MariaDB answers to the mysql2 adapter and has none of -> or ->>, so the MySQL family goes through the functions, which both have.

dig_text gives text everywhere. SQLite's ->> would otherwise hand back the value with its type, so a comparison that worked there would fail on the other two; a number is compared through a cast on all three:

Post.where { :meta.dig_text(:n) == "5" }
Post.where { cast(:meta.dig_text(:n), "integer") > 6 }   # 'signed' on MySQL

The type is the adapter's own name for it, here as everywhere cast is used.

Strings and numbers are where the adapters agree. A JSON boolean comes back as "1" on SQLite, which turns true into SQL's 1 before the text cast, and as "true" on the other two; a JSON null is SQL NULL everywhere but MariaDB, which spells it "null". A key that is not there is NULL on all three.

Comparing dig_text's value with anything but a string raises ArgumentError rather than being left to the adapters, which answer it three ways: dig_text(:n) == 5 is true on SQLite, an error on PostgreSQL and true on MySQL, and dig_text(:flag) == true is true, an error and false. cast is what says which type was meant, and then all three agree.

A JSON comparison — dig's side, and bury's and except's — belongs to the JSON types: on PostgreSQL's jsonb and MySQL's JSON alike, numbers compare as numbers and documents structurally, key order and spelling aside, so a dug value compares with a Ruby one directly. SQLite and MariaDB have only the text of each, which is a different question, and raise NotImplementedError as the SQL is written. in? and between? are the two MySQL leaves out of its JSON comparisons, so there they are spelled as the comparisons they mean — the range as its bounds, the list as one equality per element, which names the dug value once per element the way SQLite's XOR names its operands twice:

Post.where { :meta.dig(:stars) >= 10 }              # PostgreSQL and MySQL
Post.where { :meta.dig(:author) == { "name" => "alice" } }
Post.where { :meta.dig(:stars).in?([5, 10]) }
Post.where { cast(:meta.dig_text(:stars), "integer") >= 10 }   # everywhere

A column, a function or another dug value on the right goes through untouched on every adapter. Arithmetic and the bit operators are refused outright on both sides — dig_text(:n) + 1 is 6 on SQLite, an error on PostgreSQL and 6.0 on MariaDB — and cast settles those too.

bury sets what dig reads: the last argument is the value and the rest are the path to it. The document comes back changed rather than being written anywhere, so update_all is what makes it stick:

Post.update_all { { meta: :meta.bury(:author, :name, "alice") } }
# SET "meta" = jsonb_set("meta", '{author,name}', '"alice"')
# ...          JSON_SET("meta", '$.author.name', 'alice')   elsewhere

Post.update_all { { meta: :meta.bury(:tags, ["ruby", "sql"]) } }
Post.update_all { { meta: :meta.bury(:copy, :meta.dig(:n)) } }

A whole document goes in as one — an object or an array rather than the string that spells it — which each adapter takes its own way round, and a boolean goes in as JSON too, which SQLite would otherwise write as its 1. bury is not a Ruby method; it is the name Ruby considered for the other end of dig, and SQL has no one name to borrow here, since PostgreSQL says jsonb_set where the others say JSON_SET.

except takes keys out again, and takes them as Hash#except does — keys of the document, however many, rather than a path, which is bury's way of reaching further in. It gives back the document changed, so it chains with bury and goes where bury goes:

Post.update_all { { meta: :meta.except(:draft) } }
# SET "meta" = "meta" - CAST('{"draft"}' AS text[])
# ...          JSON_REMOVE("meta", '$.draft')   elsewhere

Post.update_all { { meta: :meta.bury(:author, :name, "alice").except(:tmp) } }

A key that is not there is not an error, as it is not to Hash#except. The cast is not decoration: jsonb has three subtractions — a key, an array of keys, an element by index — and an array literal written without a type is read as the first of them, so "meta" - '{draft}' takes out the key spelled {draft}, which is nothing, and says nothing about it.

A key deeper in is reached through the chain: dig reads the part out, except takes the key from it, and bury puts it back:

Post.update_all { { meta: :meta.bury(:author, :meta.dig(:author).except(:email)) } }

What dig gives is a document, so the JSON operations read it — the same question asked of a part of the document rather than of all of it:

Post.where { :meta.dig(:author).key?(:email) }
Post.where { :meta.dig(:author).dig_text(:name) == "alice" }
Post.update_all { { meta: :meta.dig(:author).bury(:name, "alice") } }

Containment reads it too, on the adapters that have containment at all:

Post.where { :meta.dig(:tags).contains?(["ruby"]) }

Asking the same of dig_text raises ArgumentError: what it gives is text, and reading text back as a document is where the adapters part company — SQLite parses it, MySQL takes it as written, and PostgreSQL has no such function for text at all.

contains? has no equivalent on SQLite and raises NotImplementedError there — later than the rest, since the adapter is only known when the SQL is built. On PostgreSQL, dig and dig_text are all the json type carries; key?, contains?, bury and except want a jsonb column.

A key that is not a plain name travels as itself rather than being refused: dig(:'odd key') becomes '{odd key}' or $."odd key".

keys gives the keys of the document, as Hash#keys does — a JSON array of them. Only the MySQL family has a function for it; the other two reach the same array through a subquery over their key-listing functions, guarded by type so that all four answer alike: the keys of anything that is not an object are NULL — rather than SQLite's array indices or PostgreSQL's error — and the keys of {} are [] rather than PostgreSQL's NULL:

Post.select { :meta.keys.as(:fields) }
Post.select { :meta.dig(:author).keys.as(:author_fields) }
# JSON_KEYS("meta")                                              MySQL
# CASE WHEN jsonb_typeof("meta") = 'object' THEN COALESCE((…))   PostgreSQL
# CASE WHEN json_type("meta") = 'object' THEN (SELECT …)         SQLite

The order the keys come in is the adapters' own: the JSON types give their normalized order and the text ones the stored order — the same divide every JSON comparison here rides on.

json_array and json_object build a document in the row — json_array from the values given, json_object from a Ruby hash. The names are the standard's, which SQLite and the MySQL family say as written; PostgreSQL is asked to build jsonb. A hash rather than SQL's alternating keys and values, because a bare symbol means a column in every block here: the keys are Ruby's and the values are expressions, so title: :title reads the column in under its own name with no rule to remember:

Post.select { json_object(title: :title, stars: :meta.dig(:stars)).as(:summary) }
# jsonb_build_object('title', "title", 'stars', "meta" #> '{stars}')   PostgreSQL
# JSON_OBJECT('title', "title", 'stars', JSON_EXTRACT(…))              elsewhere

Post.where { :meta.dig(:author) == json_object(name: :name) }

A Ruby value among the arguments goes in as its JSON self — a string or a number as themselves, nil as null, and a boolean or a whole document through the same route bury takes them, so SQLite's true is not its 1. A key that is not a string or a symbol is refused, before the adapters answer a NULL key three ways. The empty calls stand — json_array() is [] and json_object() is {} on all four — and what comes back is JSON as dig's is, so the operations and comparisons above read it.

json_arrayagg and json_objectagg gather rows into one JSON document — a value from each row into an array, a key and a value into an object. The names are the SQL standard's, which the MySQL family says as written; PostgreSQL is asked the jsonb pair and SQLite its own:

Post.group { :author_id }.select { json_arrayagg(:title).as(:titles) }
# jsonb_agg("title")          PostgreSQL
# json_group_array("title")   SQLite
# JSON_ARRAYAGG("title")      MySQL

Post.select { json_objectagg(:title, :meta.dig(:stars)).as(:stars) }

Post.group { :author_id }.
  select { json_arrayagg(json_object(title: :title, stars: :meta.dig(:stars))).as(:posts) }

What they give is JSON as dig's is, so it compares the way a dug value does, and filter and over come along as with any aggregate — with two refusals where a respelling would change the meaning rather than the spelling. The MySQL family has no FILTER, and the CASE that stands in for it elsewhere would leave a JSON null in the document for every row it drops, so there filter raises NotImplementedError; MariaDB takes every other aggregate as a window function but not these two, so over raises there too.

The documents agree across adapters, up to the edges of their JSON types. Over no rows at all SQLite answers [] and {} where the others answer NULL, as their aggregates do. A key aggregated twice keeps the last pair on the JSON types — jsonb and MySQL's — and every pair on the text ones, SQLite and MariaDB, and a NULL key is an error on the former pair and a dropped pair on the latter. And a bare JSON column is text to SQLite's json_group_array, so it lands as the string that spells the document rather than nesting as it does on the other three; a dug value nests everywhere, so json_arrayagg(:meta.dig(:author)) is the portable way to collect part of a document.