Nested Values: selectors and lambdas
Lists, structs, maps and JSON are values like any other: a column can hold one, and a query can reach into it with a selector or pass a lambda to a function that works on a list. This document says what KQL promises; the table at the end says which dialect keeps which promise. Where a dialect cannot, the query is refused with a message, never answered with something close.
Selectors
A selector follows a value and is written @:
| Written | On a list | On a struct | On a map | On JSON |
|---|---|---|---|---|
@1, @-1 |
the element; from the end when negative | the field by position | the value of the integer key | the array position |
@2:, @:3, @1:3 |
the list of the elements, both ends included | refused | refused | refused |
@name, @'a key' |
refused | the field | the value of the text key | the object member |
- Positions count from 1, and from the end when negative. There is no position 0; it is refused when the query is checked. JSON paths count from 0 in SQL; the rendering shifts them.
- A position outside the list, a key the map does not have, a member JSON does not have: NULL,
not an error. A range is cut to the list (
@-5:on three elements is all three). - A struct field is found by the name the type declares, without regard to case.
- A selection is a value: it can be filtered on, compared, passed to a function, and followed by
another selector (
t.items@1@opts@2). The type of every step is checked against what it meets.
Lambdas
list_transform, list_filter and list_reduce take a lambda. Its parameters are named, and used
by that name: list_transform(t.scores, x -> x * 2). A selection from a parameter is the usual
postfix one: it@price.
list_transformandlist_filter: one parameter (the element) or two (element, position from 1).list_reduce: two parameters (state, element); the first element is the initial state, so a list of one gives that element.- A bare name in a lambda body is its parameter. As a function argument, a bare name outside a lambda
that declares it is an alias, as in
count(o). - A lambda sees the columns of the query as well as its parameters. A parameter may not be named like
an alias visible where the lambda stands (
t.priceis a field of the alias,t@pricea selection from the parameter), nor like a parameter of a lambda around it; both are refused when the query is checked. - A NULL list stays NULL.
What a list answers about itself
| Function | Answers |
|---|---|
list_size(l) |
the number of elements, NULL elements included |
list_contains(l, v) or v IN l |
whether an element equals v; true or false for a list that is there |
list_sum(l), list_min(l), list_max(l) |
over the elements that are not NULL |
list_avg(l) |
the mean of those, as a floating point number |
- A list that is NULL gives NULL; a list with no element left to count (empty, or only NULLs) gives NULL for the sum, the mean, the smallest and the largest.
x IN lis the membership of a value in a list column,x IN (1, 2)stays the membership in a list of literals. The value meets the elements of the list, so1 IN t.tagsover a list of texts is refused. Negate withNOT (x IN l).- A list is not a text:
length(t.tags)is refused and nameslist_size. - The list may be anything that is a list, a column or what a lambda made:
list_size(list_filter( t.scores, x -> x > 5)). - Available where lists are: DuckDB, PostgreSQL, Trino, Snowflake and Oracle. The aggregates of
Oracle ignore the order a nested table has none of; its
list_avgis a floating point number. A position on an Oracle collection carries a test of the collection for NULL; see Oracle: an element of a VARRAY.
Shaping and making lists
| Function | Answers |
|---|---|
list_sort(l), list_sort(l, 'desc') |
the list in order; NULL elements come last in either order |
list_distinct(l) |
the elements without duplicates and without NULLs, in ascending order |
list_concat(a, b) |
a followed by b; a NULL list counts as empty, the answer is NULL only when both are |
list_any(l, x -> test), list_all(l, x -> test) |
whether some, or every, element passes the test; a test that is NULL does not pass; an empty list has no element that passes and every element of it does |
list(a, b, ...) |
a list of the values, in the order they are written |
list_agg(x) |
an aggregate: the values of the group as a list, NULLs left out, no order promised, NULL where nothing was collected |
- A list from
list_agghas no order, so one that is shown or compared is sorted:list_sort(list_agg(t.nr)). On Oracle this is the only thing besides showing it that can be done with it: TABLE() cannot read an aggregate in a subquery (ORA-00934), and the refusal says so. list_anyandlist_allwork on a list of structs on every dialect, including Oracle, which cannot build a list of objects forlist_filter.- An aggregate over a selection,
sum(t.scores@2), groups the other items of the query like an aggregate over a column does. - An expression of constants in a query with an aggregate is a key of the GROUP BY on every dialect;
Oracle refuses a subquery there, so
list_size(list(1, 2, 3))is written in a query of its own.
Unnest: one row per element
A list cannot be grouped by what is inside it: "revenue by sku" needs one row per element. unnest
makes a list into rows, and is written where a source is, after the source the list comes from:
FIND check_complex t, t unnest(t.items) i
FETCH i.sku, sum(i.qty * i.price) revenueEach element is a row of the alias i. The columns of the alias are the fields of the element,
value for an element that is no struct (FIND check_complex t, t unnest(t.tags) g FETCH g.value),
and ordinal, the position in the list from 1. Nothing is added to the catalog: the rows are typed
from the list, when the query is checked, so every list works the same.
- The list is an expression. A column,
t.items; a list in a struct,t.address@phones; what a lambda made,unnest(list_filter(t.items, x -> x@qty > 1)) i; or a list inside the rows of an earlier unnest, a field of its alias:t unnest(t.items) i, i unnest(i.opts) o. - An empty or NULL list has no rows, and the parent is not in the answer. The outer form keeps
it, with NULLs for the element:
t + unnest(t.items) i, as every+link does. - The model's words name the fields: under a model that calls
skuartikel,i.artikelis the field, and so isi.sku. Headers and labels take the member's label (Members and notions). - A filter on the rows of an inner unnest keeps the matching elements, as it does on any source. On an outer one the condition belongs to the join, so that the parent without a matching element is kept; where an engine cannot put it there it is refused (see the table).
count(i)is refused: the rows have no key to count. Count a field of the element,count(i.sku).ordinalis asked of the database only where the query reads it, on DuckDB, PostgreSQL and Trino. On DuckDBWITH ORDINALITYmade the same query about twenty times slower on two million rows (1,442 against 73 ms, see the plan); PostgreSQL showed no difference, and Trino was not measured. The rows are otherwise the same. MariaDB always declaresFOR ORDINALITY, and the other engines compute the position from what they have (see the table).
What unnest refuses, with a position and a message:
| use | message |
|---|---|
another function as a source, t explode(t.items) i |
only unnest(list) makes a list into rows |
| anything but one list as its argument: two, a lambda, a condition, a bare alias | unnest takes one list |
FIND unnest(...) i |
write it after the source the list comes from |
inside EXISTS |
unnest cannot start an EXISTS |
with VIA or columns |
a list made into rows is joined to the source it comes from |
unnest(t.nr), no list |
no overload accepts the argument types |
| JSON the catalog does not declare as an array | the catalog says where the array is, as in .a.b[] |
in an expression, FETCH unnest(t.items) |
it makes a list into rows, so it is written as a source |
What each dialect does
The SQL is a lateral join of the list, so the list may be a column of a source to its left:
| inner | outer | position | a condition on the rows of an outer unnest | |
|---|---|---|---|---|
| DuckDB | JOIN UNNEST(l) AS i(item) ON TRUE |
LEFT OUTER JOIN |
WITH ORDINALITY |
in the ON |
| PostgreSQL | JOIN LATERAL unnest(l) AS i(f1, …) |
LEFT OUTER JOIN LATERAL |
WITH ORDINALITY |
in the ON |
| Trino | CROSS JOIN UNNEST(l) AS i(f1, …) |
LEFT OUTER JOIN … ON TRUE |
WITH ORDINALITY |
refused |
| Snowflake | , LATERAL FLATTEN(INPUT => l) i |
OUTER => TRUE |
INDEX + 1 |
refused |
| Oracle | CROSS APPLY TABLE(l) i |
OUTER APPLY |
refused | refused |
| MariaDB | JOIN JSON_TABLE(l, '$[*]' COLUMNS (…)) i ON TRUE |
LEFT OUTER JOIN |
FOR ORDINALITY |
in the ON |
| SQLite | JOIN json_each(l) i ON json_type(l) = 'array' |
LEFT OUTER JOIN |
key + 1 |
in the ON |
| SQL Server | CROSS APPLY OPENJSON(…) i |
OUTER APPLY |
[key] + 1 |
refused |
Measured, and kept rather than hidden:
- Trino accepts
CROSS JOIN UNNESTandLEFT JOIN UNNEST … ON TRUEonly, and answers a lateral subquery overUNNESTwith "correlated subquery is not supported" (Trino 481); a query that fails that way leaves the connection unusable for the next statement, so it is refused before. - Snowflake
FLATTENleaves a NULL element out:['x', NULL, 'z']has two rows, and the position of'z'is 3. - Oracle has no position for the elements of a collection in a lateral join:
ROWNUMnumbers the rows of the whole statement, andROW_NUMBER()needs the key of the parent.ordinalis refused. A list of objects cannot be filtered by a lambda to be unnested (the generic collection types hold text, numbers and dates), as before. - SQLite, MariaDB and SQL Server have no lists. They unnest an array inside a JSON column that
the catalog declares (
.a.b[]: INTEGER, see below):unnest(t.doc@a@b) b. The elements are scalars, or objects, whose members are the columns of the rows (read as scalars, so a member that is itself an object or list is not meant for it); a row whose document has no array there has no rows, and JSON that is not declared as an array is refused. Whereunnestis given a JSON array on a dialect that has lists, the list is the converted one, and the rows are those of any other list.
Flatten views: a table in the database
unnest needs no preparation. A view that flattens a list stays the answer where a table is
wanted: heavy use of the same rows (a view can be materialized and indexed, an unnest is read
again by every query), a name of its own, or an entity with labels, units and a relation to its
parent that the semantic layer and the charts work with. The view is an ordinary table of the catalog.
The JSON engines read the document again for every query, and nothing was measured on long lists
there.
FlattenViews (module tools) reads a db.json, and writes the DDL and the catalog entries:
./gradlew :tools:flattenViews -Pflatten.args="duckdb db.json model.json check_complex out-dir"A view <table>_<column> has the parent's key, ordinal (the position in the list, from 1) and one
column per struct field, or value for a list of scalars. The relation <column>_of_<table> runs from
the view to the parent:
FIND check_complex_items i, i VIA items_of_check_complex check_complex t
FETCH t.nr, i.sku, sum(i.qty * i.price) revenueSQL Server, MariaDB and SQLite have no lists, but an array inside a JSON column that the catalog
declares (.a.b[]: INTEGER) is flattened the same way, named by its path:
./gradlew :tools:flattenViews -Pflatten.args="sqlite db.json model.json check_complex out-dir doc:.a.b"gives check_complex_doc_a_b (json_each, JSON_TABLE or CROSS APPLY OPENJSON) with the relation
doc_a_b_of_check_complex; the elements are scalars or objects, whose scalar members are columns. A row
whose document has no array there has no elements.
The generator needs a table key. It handles lists of structs and of scalars for DuckDB, PostgreSQL,
Trino, Snowflake and Oracle, and refuses lists of lists and of maps. The complex test beds carry the
three views check_complex_tags, _items and _events. Two differences remain: Snowflake FLATTEN
drops NULL elements, and the ordinal of an Oracle nested table follows storage order.
What the dialects declare
What an engine can do with nested values is declared by the dialect (SqlDialect.nestedSupport()),
and a use it cannot express is a violation of the query, with a position, before any SQL is built.
This table is generated from those declarations.
| list | struct | map | JSON | JSON from the end | lambda over a JSON array | compare a list | unnest position | unnest condition | |
|---|---|---|---|---|---|---|---|---|---|
| DuckDB | yes | yes | yes | yes | yes | yes | yes | yes | yes |
| Oracle | yes | yes | no | yes | yes | no | no | no | no |
| Snowflake | yes | yes | yes | yes | no | yes | yes | yes | no |
| SQL Server | no | no | no | yes | no | no list | no list | yes | no |
| PostgreSQL | yes | yes | no | yes | yes | yes | yes | yes | yes |
| MariaDB | no | no | no | yes | yes | no list | no list | yes | yes |
| SQLite | no | no | no | yes | yes | no list | no list | yes | yes |
| Trino | yes | yes | yes | yes | no | yes | yes | yes | no |
What the dialects do
The details behind the declarations above, by hand: what each engine does with what it accepts. Where the two disagree, the table above is the declaration and this one is out of date.
| list | range | struct | map | JSON | JSON from the end | transform, filter | position parameter | reduce | |
|---|---|---|---|---|---|---|---|---|---|
| DuckDB | yes | yes | yes | yes | yes | yes | yes | yes | yes |
| PostgreSQL | yes (an array of arrays only by a position in every dimension) | yes | yes | as JSON | yes | yes | yes, not to a list of lists | yes | no |
| Trino | yes | yes | yes | yes | yes | no | yes | transform only | yes |
| Snowflake | yes | yes | yes | yes | yes | no | yes; not by a parameter of the lambda around | yes | yes |
| Oracle | yes (VARRAY, nested table) | text, number, date | yes (object types) | as JSON | yes | yes | to text, number, date | yes | no |
| SQL Server | no | no | no | no | yes | no | no | no | no |
| MariaDB | no | no | no | no | yes | yes | no | no | no |
| SQLite | no | no | no | no | yes | yes | no | no | no |
"as JSON": the dialect has no map type; the column is a JSON object. A catalog that declares what the
object holds (value, and key INTEGER when the keys are integers written as text) makes it a map for
a query: t.attrs@color is a text and t.lookup@1 the member "1", as on a dialect with maps. Without
that declaration @1 on it is an array position, and an integer key is written @'1'.
Casting JSON
A JSON member is JSON, not a number or a text, so it cannot be compared or summed as it is. The casts
read the scalar at the path: to_integer(t.doc@n) > 0, sum(to_float(t.doc@price)),
to_text(t.attrs@color) = 'red'. A JSON string gives its text without the quotes and a number its
digits, on every dialect, so a cast answers the same everywhere; a member that is not there gives
NULL. The casts are to_boolean, to_float, to_double, to_integer, to_bigint, to_smallint,
to_decimal, to_text and to_varchar. A JSON object or list has no scalar: to_text of one is its
JSON text on DuckDB, PostgreSQL, Snowflake, SQLite, SQL Server and MariaDB, and NULL on Trino and
Oracle.
Describing a nested column in the catalog
A column says its type, and what the type is made of, in its concept: "concept" on the column, or
"conceptRef" with the name of a concept of the schema. A STRUCT concept has entries, one for
each field, in order; an ARRAY has the entry element (and an arraySize when its size is fixed),
a MAP the entries key and value. An entry is a string, the spelling of a type ("VARCHAR") or
the name of a concept of the schema, or an object: a concept of its own that belongs to this entry
alone and is shared with nobody.
{ "name": "address",
"concept": { "label": "Address", "typeFamily": "STRUCT",
"dialectType": "STRUCT(street VARCHAR, geo STRUCT(lat DOUBLE, lon DOUBLE), phones VARCHAR[])",
"entries": {
"street": "VARCHAR",
"geo": { "typeFamily": "STRUCT", "entries": {
"lat": { "label": "Latitude", "typeFamily": "FLOAT", "dialectType": "DOUBLE", "unit": "deg" },
"lon": "DOUBLE" } },
"phones": { "typeFamily": "ARRAY", "entries": { "element": "VARCHAR" } } } } }A concept has a label, a description, a comment, a unit, a quantity, a presentation, an encoding
("typeEncoding": "EPOCH:SECONDS" on a BIGINT that is a timestamp) and a dialectType, which a
leaf may leave out only where it is a spelling in an entry. The entries are the type. The
dialectType of a container, if it is a spelling of the same type, is checked against them and
refused when it is not. The part a selector chain ends at is found with
ConceptNodes.node(column, steps): t.address@geo@lat is the lat entry of the geo entry of
address, and what the catalog says there (its label, its unit, its quantity) is what a result
column made of it is explained by. The flatten views take the same annotations from the element's
entries, so the columns of "revenue by sku" are labelled and measured as the list's fields are.
Each part can carry any of these; the complex catalogs have them on all of theirs. A part that is a
spelling says nothing but its type. A container needs its entries: a spelling alone does not type
a column.
Declaring what is in a JSON column
JSON has no structure the database can describe, so a JSON column may list the members it is known
to hold: its concept is the container the content is, a STRUCT for an object, an ARRAY for a list,
a MAP for an object of one kind of value (the keys are text, or INTEGER when they are integers
written as text), with the encoding JSON, which says the value is stored as JSON in whatever type
its dialectType names (JSON, JSONB, VARIANT, VARCHAR, TEXT). The members are scalars of
the families BOOLEAN, INTEGER, FLOAT, DECIMAL or TEXT, or objects and arrays of them:
{ "name": "doc",
"concept": { "typeFamily": "STRUCT", "typeEncoding": "JSON", "dialectType": "JSON",
"entries": {
"n": "INTEGER",
"a": { "typeFamily": "STRUCT", "entries": {
"b": { "typeFamily": "ARRAY", "entries": { "element": "INTEGER" } } } } } } }Nothing is said about the members not listed.
A selection that reaches a declared scalar is that scalar: t.doc@n > 0, t.doc@n + 1,
sum(t.doc@a@b@1) and GROUP BY t.doc@n need no cast; the renderer reads the JSON scalar and casts
it, the way to_integer(t.doc@n) does. A step to a declared object or list is JSON again, carrying
the shape of that member; a member that is not declared is JSON of unknown content, as before. The
declaration is trusted, not checked: a value of another type gives what the cast gives (NULL or an
error, the dialect's).
A declared list of scalars (.a.b[]: INTEGER) is a list: list_size(t.doc@a@b),
list_sum(t.doc@a@b) and list_transform(t.doc@a@b, x -> x * 10) work, and the dialect converts the
JSON array into its own list (DuckDB and Trino a cast, PostgreSQL jsonb_array_elements_text,
Snowflake TRANSFORM, Oracle JSON_TABLE). A list of objects or of lists stays JSON. Oracle
applies the functions but refuses a lambda over such a list, because the list is built where Oracle
returns nothing. SQL Server, MariaDB and SQLite have no list functions, so the array stays JSON.
Differences that are kept, not hidden
- JSON scalars. DuckDB, PostgreSQL, Trino, Oracle and SQLite answer a JSON string as JSON, with its quotes, unless it is cast (see above). SQL Server and MariaDB answer it as plain text: their functions do not say whether a value was a string or a number. Objects and lists are JSON text everywhere; the spacing inside them is the engine's own.
- Reduce on an empty list is an error on DuckDB and NULL on Trino.
- Oracle builds a list only of text, number or date: a result that is a list of structs or of lists is refused. Its generic collection types hold text up to 4000 characters and turn a double into a NUMBER. The order of the elements of a nested table is not defined by the database; a VARRAY keeps its order, and the position of an element of a nested table is the order the database happens to return.
- PostgreSQL arrays do not nest: a list of lists is one multi-dimensional array, which must be rectangular, so a lambda cannot return a list of lists.
- Snowflake returns a nested value as JSON text; it is read back by the type of the column.
How it is written in SQL
Lambda parameters are bound to what the dialect can name: a lambda parameter where it has lambdas,
a column of a derived table where it has not (Oracle, PostgreSQL). Where SQL has to name a list more
than once (its size and its elements, a position parameter), a list that is an expression is
evaluated once in a derived table of one row, so nesting does not multiply the SQL and a function
such as random() is not called again for each mention. Oracle and MariaDB cannot do that: Oracle
evaluates a derived table that refers to the outer query once for the whole query instead of once
per row, so every row would get the first row's list (measured), and MariaDB refuses the reference
("Unknown table"). They write the expression wherever it is needed, and the SQL of a lambda whose
list is itself a lambda grows with every nesting there; keep such chains short.
Oracle: an element of a VARRAY
Oracle has no subscript for a collection in SQL, so a position is a scalar subquery over TABLE(list)
numbered by ROWNUM. On Oracle Database Free 23.26.2 that subquery, written the obvious way, is run
once for the whole statement: every row gets the answer of the first row, and a row whose list is
empty or NULL gets it too. This is a behaviour of the database, reproduced here without koryki:
CREATE TYPE num_list AS VARRAY(10) OF NUMBER;
CREATE TABLE t (nr INTEGER PRIMARY KEY, v num_list);
INSERT INTO t VALUES (1, num_list(10, 20));
INSERT INTO t VALUES (2, num_list());
INSERT INTO t VALUES (3, NULL);
INSERT INTO t VALUES (4, num_list(30));
-- the first element: 10, NULL, NULL, 30 is right; Oracle answers 10, 10, 10, 10
SELECT t.nr,
(SELECT e.x FROM (SELECT COLUMN_VALUE x, ROWNUM rn FROM TABLE(t.v) z) e WHERE e.rn = 1)
FROM t ORDER BY t.nr;
-- with a test of the list in the subquery the answer is right: 10, NULL, NULL, 30
SELECT t.nr,
(SELECT e.x FROM (SELECT COLUMN_VALUE x, ROWNUM rn FROM TABLE(t.v) z) e
WHERE e.rn = 1 AND t.v IS NOT NULL)
FROM t ORDER BY t.nr;What was measured:
- The plan says why.
EXPLAIN PLANof the first query shows the subquery'sVIEWoverCOLLECTION ITERATOR PICKLER FETCHas a branch of its own, with no bind variable: the reference tot.vinsideTABLE()is not counted as a correlation. With the test, the plan has aFILTERon:B1 IS NOT NULLabove it, the correlation is there, and the subquery runs for each row. With a single row (WHERE t.nr = 2) the first query is right, since the first row is that row. - What the guard does not change. An empty list is not NULL, so
t.v IS NOT NULLis true for it and the subquery finds no element, as it should; for a NULL list the subquery answers NULL as before. - Where it happens. A list in a column of a table, a VARRAY, inline or stored as a LOB (
VARRAY(2000)behaves asVARRAY(10)). It does not happen for a NESTED TABLE column counted withCOUNT(*), for a subquery without theROWNUMview (Oracle merges the view then), and, which we cannot explain, for a table that also has a JSON column:check_complexhas one and never showed the fault,check_sharedhas none and did. A query that is right today on a table is therefore no proof that it is right on another, which is why the test is always written. - What was checked on the same data and is right without it.
list_size,list_any,list_transform(they build a collection withCAST(MULTISET(...)), which is a correlation of its own), and a position from the end (@-1, which names the list in the subquery'sWHERE). - The cost. The test repeats the list expression, which for a list made by a lambda is a long one. It does not change the meaning.
The bed check_shared (SharedConceptOracleTest) is the regression test: it has a list that is
empty after one that is not. A second one does not depend on a bed somebody wrote:
GeneratedListsOracleEngineTest makes sixty rows from a seed (lists that are NULL, empty, of one
element or several, with duplicates, NULL elements and text that is not ASCII), loads them into four
shapes of table (plain, with a JSON column, with a nested table, with a VARRAY stored as a LOB), and
asks each position on its own, the elements as rows, and sums and groups over them, comparing the
answer with what the rows say. Run without the guard, it fails on the plain, the nested table and the
LOB shape, with the element of the first row in every row, and passes on the table with the JSON
column: the behaviour above, found without knowing where to look. The same test runs on DuckDB, where
it passes, which is what keeps the expected answers honest. We have not reported this to Oracle and know of no bug number; the
reproduction above is what to send.