Nested data as JSON columns, with hidden array attributes — design¶
Status: final design, 2026-10-09. Answers REQ-1965 as rewritten on origin/main the same day. No
product code. Supersedes the earlier text of this file and json-columns-v3-note.md. On record
beside it: array-types-design.md (typed arrays, not adopted) and
array-of-primitives-addendum.md (an array type on the wire, not adopted).
The design in six lines:
- Semantic SQL has no array, struct or map type. Nested data is a JSON column, listed as JSON by every catalog and wire, on every engine and every source read directly.
- A datasource hands a native array, struct or map over as JSON, by a cast the source or engine executes.
- A column carries three hidden attributes, recorded from the source at registration: array depth (integer), innermost element type, native type.
- One portable set of JSON operations, translated per backend from one rule table. For an array of a primitive the attributes drive casts in and out and the native form.
- A refusal by name is for what a backend cannot do correctly, never for what it does slowly.
- What is correct but slow runs, and the query plan says so.
Evidence tags: [code] read in the repo at the cited line; [doc] vendor page fetched today;
[probe] run locally today; [mem] from memory, not fetched, unverified. Sources in section 16.
0. The claim, and where "always" stops¶
Claim: with the attributes available to the translation, a statement is always translated to the source's own query and returns values in the expected type, and Provisa converts no value.
| Part of the claim | Holds | Stops at |
|---|---|---|
| Translated to a native query | every backend that has either JSON functions or a native array form for the operation | backends with neither, and operations a backend lacks (section 4.3): refused by name at compile |
| Results in the expected type | an innermost element of an array of a primitive, extracted or unnested, is cast in the generated SQL to the element type | the whole column value is JSON text on SQL wires; inside a struct, map or array of structs nothing is typed and the user writes the cast |
| Exact decimals | a decimal element with a declared precision is written into the JSON as a string and cast back | inside a struct or map, where a decimal is a JSON number: DuckDB and SQLite read one through a double (12345678901234567.89 comes back …567.68) [probe] |
| Native speed and indexes | reads at the source of a natively-array column, for element, length, contains and unnest (section 4.2) | a federating engine over a live source: pushdown is the engine's choice. The statement runs; the plan says so (section 10) |
| The attributes are right | recorded from the source's own type at registration | the source's type changing afterwards: reads of the column refused by name until it is registered again (section 5.4) |
| Provisa converts no value | once a source or engine has produced a value, on every route (section 8) | API and stream sources, where Provisa's own caller fetches the payload; two existing touches removed by piece 6 (section 8) |
| No per-request derivation (REQ-1877) | the attributes are model data, read when the plan is compiled and kept with the governed plan | — |
1. What "json is first-class" covers today¶
| Area | Today | Where |
|---|---|---|
| IR type | json; jsonb aliases to it; Trino and DuckDB overlays |
provisa/core/ir_types.py:68,83-84,110,150 [code] |
| Native array / list / row / struct / map | registered as text, not json. Only Trino's spelling reaches that map; VARCHAR[], _int4, ARRAY, Array(String), ARRAY<STRING> raise in to_ir |
ir_types.py:115-117,151-153,244; provisa/api/admin/introspect.py:72,1261 [code], [probe] |
| Physical type per store | PostgreSQL JSON (not jsonb), MySQL JSON, SQLite JSON (text), SQL Server NVARCHAR(max), DuckDB JSON, Trino JSON, Redshift JSON; Oracle: raises UnsupportedCompilationError |
SQLAlchemy generic JSON through to_physical [probe] |
BigQuery JSON; Snowflake VARIANT; ClickHouse Nullable(String); Databricks STRING; Fabric/Synapse warehouse VARCHAR(8000) |
bigquery_store.py:60, snowflake_store.py:70, clickhouse_store.py:56, databricks_store.py:66, mssql_warehouse_runtime.py:79 [code] |
|
| Arrow landing | string | provisa/core/ir_arrow.py:50 [code] |
| Landing a JSON value | parsed per row in Python before insert, so SQLAlchemy does not double-encode | provisa/federation/materialize_exec.py:135-138 [code] |
| JSON path columns | Column.path compiles to ->> / json_extract_scalar |
provisa/core/models.py:838, provisa/compiler/sql_gen.py:324 [code] |
| DIRECT route with a JSON path | allowed on PostgreSQL only; every other direct source is sent to the engine: "query uses JSON path extraction, unsupported for direct" | provisa/transpiler/router.py:273-283 [code] |
| JSON constructors | hand rewrites for Trino and DuckDB only: json_build_object, json_agg/JSON_ARRAYAGG, to_json |
provisa/transpiler/transpile.py:44-48,119-257 [code] |
| GraphQL | JSON scalar; typed sub-fields through object_fields on the column |
provisa/compiler/type_map.py:191-192, models.py:809-816,845 [code] |
| JSON Schema → GraphQL | exists for tracked functions (REQ-304): one level of scalar properties, or an array of such objects; schema inferred from a pasted sample in the UI (REQ-305) | provisa/compiler/actions_schema.py:77-106; provisa-ui/src/pages/CommandsPage.tsx [code] |
| pgwire | JSON and JSONB wire types; STRUCT, MAP and unknown [] types sent as JSON |
provisa/pgwire/server.py:355-358,401-402 [code] |
| Flight catalog | json, jsonb listed as utf8 |
provisa/api/flight/catalog.py:61-62 [code] |
| GraphQL result | any string starting with { or [ is parsed as JSON, whatever the column's type |
provisa/executor/serialize.py:67-70 [code] |
What sqlglot 30.9.0 does with PostgreSQL JSON syntax [probe]:
| Input | Result |
|---|---|
c -> 0, c ->> 0, c ->> 'a' |
translated for every dialect, with defects: T-SQL gets ISNULL(JSON_QUERY(..), JSON_VALUE(..)) for both operators; ClickHouse gets JSONExtractString for -> (a string, not JSON); Oracle gets JSON_EXTRACT, which Oracle does not have [mem]; Snowflake gets PARSE_JSON(c) around a column that is already VARIANT |
c #>> '{a,1}', jsonb_array_length(c), c @> '[..]', jsonb_array_elements(c), jsonb_build_array(..), jsonb_agg(..), to_jsonb(..) |
passed through unchanged to every dialect: not valid there |
JSON_VALUE, JSON_QUERY, JSON_EXISTS |
passed through unchanged; valid only where the backend has the standard function |
What is missing for "JSON for all engines and direct query sources":
- A translation Provisa owns for the JSON operations, per dialect. Today: PostgreSQL native, two engines hand-patched, DIRECT refused elsewhere.
- Native arrays, structs and maps registered as
jsonwith a to-JSON cast, instead oftext. - The three hidden attributes on the column.
- Physical type gaps: PostgreSQL lands
JSON, which has no index support (jsonbsupports GIN, btree and hash;jsondoes not[doc]); Fabric/Synapse landsVARCHAR(8000); Oracle has no mapping. - At the GraphQL boundary, JSON parsing decided by the column's declared type, not by the first character of a string (a defect on its own, section 14).
2. The model¶
2.1 The three hidden attributes¶
| Attribute | Value | Read by |
|---|---|---|
| array depth | integer. 0: not an array of a primitive. 1: array of a primitive. 2: array of arrays of a primitive. And so on | every translator: which casts apply, whether a native form exists |
| element type | the semantic SQL type of the innermost element, with precision and scale where it has them (REQ-1961 spelling): text, bigint, numeric(18,2), timestamp. Empty at depth 0 |
cast out, cast in, the element pre-cast of the datasource cast |
| native type | the source's type in its own spelling: varchar(20)[], timestamptz[], array(row(a integer, b varchar)), STRUCT<..> |
the three casts that depend on the exact spelling (section 5.3), the to-JSON cast for depth-0 native columns, drift |
| Source column | depth | element type | native type |
|---|---|---|---|
| array of one primitive type, any nesting | ≥ 1 | the primitive | recorded |
| struct, map, array of structs, array of mixed types | 0 | — | recorded |
JSON at its source (MySQL JSON, PostgreSQL jsonb, a text column holding JSON, an API payload) |
none of the three | ||
| any scalar column | not a JSON column; outside this design |
Rules:
- Visible type:
data_typeisjsonfor all of the above. Registration suggestsjson, never text, and never raises on a native nested type. - The attributes come from a native type only. A registrar does not declare them on a column that is JSON at its source.
- Stored with the column, beside
data_type(provisa/core/models.py:822[code]), asobject_fieldsis (provisa/core/schema_org.py:341[code]). V1: the table definition changes; no migration. - Hidden: not offered in the registration form, not listed by catalogs. A read-only line in the
column detail ("array of text, depth 1, native
text[]") is allowed for support. - Whether a column is natively nested and so needs the to-JSON cast is: native type recorded.
Column.object_fields (models.py:809-816,845) is not touched. It declares typed sub-fields of
an object column for GraphQL, with alias, description and visible_to per sub-field, and keeps
doing that. GraphQL serves a JSON column as the opaque JSON scalar. There is no JSON Schema.
2.2 Does any backend have an array of more than one element type¶
No supported backend has an array type parameterised by several element types. Arrays are
homogeneous: DuckDB lists "must all have the same underlying type" [doc]; Trino ARRAY is "an
array of the given component type" [doc]; PostgreSQL, ClickHouse Array(T), BigQuery
ARRAY<T>, Databricks and Hive ARRAY<T> take one element type [doc].
| Nearest thing | What it is | Registers as |
|---|---|---|
Snowflake ARRAY |
"Each value in a semi-structured array is of type VARIANT" [doc]: elements may differ at run time |
depth 0, plain JSON. Structured ARRAY(T) is depth 1 |
Redshift SUPER |
arrays of scalar or complex values "without having any restrictions for regularity" [doc] |
JSON at its source |
ClickHouse Array(Variant(T1, T2)) |
a Variant value is "either type T1 or T2 ... or none of them" [doc]; arrays of variants are shown [doc] |
depth 0 |
DuckDB UNION |
a tagged union holding "one of multiple alternative values" [doc]; a list of unions is not stated on the page |
depth 0 |
Trino ROW, ClickHouse Tuple, PostgreSQL composite |
fixed positions, each with its own type; Trino: "allows mixed types" [doc] |
depth 0 (a struct) |
PostgreSQL anyarray |
a pseudo-type for function signatures, not a column type [mem] |
not a column |
| JSON arrays | heterogeneous everywhere | JSON at its source |
3. The datasource translation¶
A native array, struct or map column is registered as json. Every reference to it in generated
SQL is the source's own to-JSON expression, executed by the source or engine. Provisa executes
nothing.
| Backend | Introspected spelling | To-JSON expression | Notes |
|---|---|---|---|
| PostgreSQL family | format_type() → text[], composite name |
to_jsonb(c) |
"Arrays and composites are converted recursively to arrays and objects (multidimensional arrays become arrays of arrays in JSON)" [doc]. Multidimensional arrays stop being a special case |
| DuckDB | VARCHAR[], STRUCT(..), MAP(..) [probe] |
to_json(c) |
lists, nested lists, structs, maps verified [probe] |
| Trino | array(T), row(..), map(K,V) |
CAST(c AS JSON) |
ARRAY, MAP, ROW convert when their element types do; scalar list fetched: BOOLEAN, integers, REAL, DOUBLE, VARCHAR [doc] |
| ClickHouse | Array(T), Tuple(..), Map(K,V) |
toJSONString(c) [mem] |
|
| Snowflake | ARRAY, OBJECT, VARIANT, structured types |
none needed: already semi-structured; TO_JSON(c) for text [mem] |
|
| BigQuery | ARRAY<T>, STRUCT<..> |
TO_JSON(c, stringify_wide_numbers => TRUE) |
accepts ARRAY and STRUCT [doc] |
| Databricks | array<T>, struct<..>, map<K,V> |
to_json(c) [mem] |
|
| Hive / HiveServer2 | array<T>, struct, map |
not verified | whole-value read refused until a to-JSON form is confirmed |
| Redshift | super |
JSON_SERIALIZE(c) [mem] |
|
| CockroachDB | T[] |
to_jsonb(c) [mem] |
no nested arrays [doc] |
| MySQL family, SQL Server family, Oracle, SQLite, Exasol | no native arrays | none: the column is JSON or JSON text already; none of the three attributes | |
| API and file sources (OpenAPI, GraphQL remote, gRPC remote, Kafka, MongoDB, Elasticsearch) | arrays and objects in the payload | none: already JSON; none of the three attributes | provisa/openapi/mapper.py, provisa/kafka/source.py:184-208 [code] |
Exact types inside the JSON. Each backend prints temporal and decimal values its own way, and a client reads the JSON text, so the translation fixes the spelling before the to-JSON call. It is generated from the element type, for columns of depth 1 or more, as SQL:
| Element type | Spelling written | Why not the backend's default |
|---|---|---|
| numeric(p,s) | JSON string, exact digits | DuckDB writes a JSON number with exact digits but reads a JSON number back through a double [probe]; SQLite reads it as real [probe]; PostgreSQL jsonb keeps numbers as numeric [doc]. A string is exact on all of them |
| timestamp | "YYYY-MM-DDTHH:MM:SS.ffffff", UTC |
DuckDB writes "2026-10-09 12:34:56.123456" with a space [probe] |
| date, time, uuid, interval | ISO 8601 text | |
| binary | lower-case hex | DuckDB writes "\\x00ff" [probe] |
| float, double | JSON number | DuckDB writes NaN unquoted, which is not JSON [probe]; jsonb rejects NaN and infinity [doc]. Refused by name: json.non_finite_number |
| integer up to 64 bits | JSON number | exact through DuckDB (9007199254740993 verified [probe]), SQLite [probe] |
Form: cast the native value to the same shape with those element types as text, then to JSON.
DuckDB: to_json(list_transform(c, x -> strftime(x, ...))) or a shape cast
CAST(c AS STRUCT(a VARCHAR, b VARCHAR[])). PostgreSQL: to_jsonb(c::text[]) for a numeric
array. Columns whose elements are text, integer or boolean need no pre-cast.
A column of depth 0 with a native type (struct, map, array of structs, mixed array) gets the plain to-JSON expression with no pre-cast: nothing inside it is typed. Its numbers are JSON numbers and its timestamps are in the backend's own spelling.
A timestamp element keeps its instant: the pre-cast applies the native type's own transform
(a timestamp with time zone is taken to UTC, provisa/core/ir_types.py:164-172 [code]) before
it is written.
Replicas. A replica of such a column is landed by the same expression in the landing statement:
the store receives JSON and stores its physical JSON type (jsonb on PostgreSQL). The replica's
column keeps depth and element type, so casts in and out still apply; reads of a replica use the
JSON form.
4. The portable JSON operations¶
4.1 What semantic SQL offers¶
Semantic SQL is PostgreSQL dialect. Offer the SQL/JSON standard functions, which PostgreSQL has
[doc], and accept PostgreSQL's operators as other spellings of the same internal operations.
JSON positions are 0-based, as in JSON path and in PostgreSQL's ->.
| Internal operation | Standard spelling | PostgreSQL spelling also accepted | Result type |
|---|---|---|---|
| element or field, as JSON | JSON_QUERY(c, '$[0]') |
c -> 0, c -> 'a' |
json |
| scalar, typed | JSON_VALUE(c, '$[0]' RETURNING T); for an innermost element of an array of a primitive RETURNING may be omitted and is then the element type |
c ->> 0 (text, as PostgreSQL) |
T |
| length | — | jsonb_array_length(c) |
integer |
| contains value | JSON_EXISTS(c, '$[*] ? (@ == $v)' PASSING x AS v) |
c @> '["x"]', c ? 'x' |
boolean |
| each element, with position | JSON_TABLE(c, '$[*]' COLUMNS (ord FOR ORDINALITY, v T PATH '$')) |
jsonb_array_elements(c) WITH ORDINALITY, jsonb_array_elements_text |
rows |
| path exists | JSON_EXISTS(c, path) |
c ? 'key' |
boolean |
| construct array / object | JSON_ARRAY(..), JSON_OBJECT(..) |
jsonb_build_array, jsonb_build_object |
json |
| aggregate into an array | JSON_ARRAYAGG(x ORDER BY k) |
jsonb_agg(x ORDER BY k) |
json |
| map keys | — | jsonb_object_keys(c) |
rows |
Every one is rewritten by a rule table Provisa owns, keyed by (operation, backend, JSON form or native form), before sqlglot generates SQL. A missing cell is a refusal. A unit test enumerates the table so no cell falls through to sqlglot's pass-through.
4.2 Native form: depth 1 or more, read at the source¶
When the statement runs on the source and the column there is natively an array of a primitive, element access, length, contains and unnest are translated to the native operation and no JSON is built. The "field" column of the table is not used by this design: a struct or map is depth 0 and always goes through the to-JSON expression. Depth above 1 is section 6. Positions shift by one where the backend counts from 1. This is what keeps the source's own indexes usable.
| Backend | element [i] |
field | length | contains v |
each with position | index that serves contains |
|---|---|---|---|---|---|---|
| PostgreSQL family | c[i+1] |
(c).f |
cardinality(c) |
c @> ARRAY[v] |
none: always the JSON form, jsonb_array_elements(to_jsonb(c)) WITH ORDINALITY (ruling, section 6.2) |
GIN array_ops: &&, @>, <@, = [doc]. v = ANY(c) is not served, so it is not emitted |
| CockroachDB | same | — | array_length(c,1) |
c @> ARRAY[v] |
unnest |
inverted index [doc] |
| DuckDB | c[i+1] |
c.f, c[k] |
len(c) |
list_contains(c, v) |
unnest with generate_subscripts |
none |
| Trino | element_at(c, i+1) |
c.f, element_at(c, k) |
cardinality(c) |
contains(c, v) |
UNNEST(c) WITH ORDINALITY [doc] |
none |
| ClickHouse | c[i+1], guarded by length |
tuple element, c[k] |
length(c) |
has(c, v) |
ARRAY JOIN with arrayEnumerate |
bloom-filter skip index [mem] |
| Snowflake | GET(c, i) |
c:f |
ARRAY_SIZE(c) |
ARRAY_CONTAINS(v::variant, c) |
LATERAL FLATTEN |
search optimization [mem] |
| BigQuery | c[SAFE_OFFSET(i)] |
c.f |
ARRAY_LENGTH(c) |
v IN UNNEST(c) |
UNNEST(c) WITH OFFSET |
search index [mem] |
| Databricks | try_element_at(c, i+1) |
c.f, c[k] |
size(c) |
array_contains(c, v) |
posexplode(c) |
none |
How the rewrite is arranged so it cannot be wrong: every reference to a native-origin column starts as its to-JSON expression (section 3), which is always correct. A recognised pattern, operation over that expression, is then replaced by the native form. Anything not recognised keeps the to-JSON expression. Aggregate into an array and construct stay in JSON form on every backend.
A native form is used only where it returns what the JSON form would: c ->> 0 on a
timestamp[] returns the canonical text of section 3, so its native form is the formatted
element, not a bare c[1]::text.
4.3 JSON form, and refusals¶
For a column that is JSON in the backend (a source's own JSON column, or a replica).
| Backend | scalar typed | element as JSON | length | contains | each with position | aggregate |
|---|---|---|---|---|---|---|
PostgreSQL jsonb |
JSON_VALUE(.. RETURNING T) [doc] |
-> |
jsonb_array_length [doc] |
@>, GIN [doc] |
jsonb_array_elements .. WITH ORDINALITY; JSON_TABLE [doc] |
jsonb_agg |
| DuckDB | (c ->> '$[i]')::T |
-> |
json_array_length [doc] |
json_contains [doc] |
json_each, key is the position [doc] |
json_group_array [doc] |
| Trino | json_value(.. RETURNING T) [doc] |
json_query [doc] |
json_array_length [doc] |
json_array_contains [doc] |
json_table [doc] |
CAST(array_agg(..) AS JSON) |
| MySQL, TiDB | JSON_VALUE(.. RETURNING T); default is VARCHAR(512) [doc] |
JSON_EXTRACT |
JSON_LENGTH |
v MEMBER OF (c); multi-valued index [doc] |
JSON_TABLE .. FOR ORDINALITY |
JSON_ARRAYAGG |
| MariaDB | JSON_VALUE + cast |
JSON_EXTRACT |
JSON_LENGTH |
JSON_CONTAINS |
JSON_TABLE (10.6+) [mem] |
JSON_ARRAYAGG |
| SQL Server 2016+ | CAST(JSON_VALUE(..) AS T) |
JSON_QUERY [doc] |
(SELECT COUNT(*) FROM OPENJSON(c)) |
EXISTS (.. OPENJSON ..); JSON_CONTAINS and JSON index on 2025 [doc] |
CROSS APPLY OPENJSON(c), [key] |
JSON_ARRAYAGG (2025, Azure) [doc]; refused earlier |
| Oracle | JSON_VALUE(.. RETURNING T) |
JSON_QUERY |
JSON_VALUE(c, '$.size()') |
JSON_EXISTS; multivalue index [doc] |
JSON_TABLE .. FOR ORDINALITY |
JSON_ARRAYAGG |
| SQLite (source only) | CAST(c ->> '$[i]' AS T) |
-> [doc] |
json_array_length [doc] |
EXISTS (.. json_each ..) |
json_each, key [doc] |
json_group_array [doc] |
| Snowflake | GET(c, i)::T |
GET |
ARRAY_SIZE |
ARRAY_CONTAINS |
LATERAL FLATTEN |
ARRAY_AGG .. WITHIN GROUP |
BigQuery JSON |
CAST(JSON_VALUE(..) AS T) [doc] |
JSON_QUERY |
ARRAY_LENGTH(JSON_QUERY_ARRAY(c)) [doc] |
via UNNEST(JSON_QUERY_ARRAY(c)) |
same, WITH OFFSET |
TO_JSON(ARRAY_AGG(..)) |
| ClickHouse (String) | JSONExtract(c, i+1, 'T') [mem] |
JSONExtractRaw |
JSONLength |
has(JSONExtract(c, 'Array(T)'), v) |
ARRAY JOIN over the extract |
toJSONString(groupArray(..)) |
| Databricks (STRING) | c:[i]::T |
c:[i] |
size(from_json(c, ..)) |
array_contains(from_json(c, ..), v) |
posexplode(from_json(c, ..)) |
to_json(collect_list(..)); with ORDER BY refused |
| Exasol | JSON_VALUE + cast [doc] |
refused | refused | via JSON_EXTRACT .. EMITS rows [doc] |
JSON_EXTRACT(c, '$[#]') EMITS (..); position refused |
refused |
Cells without a tag are [mem]; each is pinned by a test against the real backend before its
operation ships there.
Refused by name (query.json_operation_unsupported, parameters operation, backend, column),
at compile. Each of these is something the backend cannot do, not something it does slowly:
- any operation on a backend with no verified JSON functions and no native form: SAP HANA,
Fabric Warehouse, SingleStore, Db2, Teradata, Vertica, Firebird, MonetDB, SAP ASE, SQL
Anywhere, the generic
sqlalchemykind, until each is verified. A whole-value read of a JSON or text column still works there. - the cells marked refused above.
- comparing or ordering whole JSON values where the backend has no JSON comparison (text comparison of JSON is not offered as a substitute).
- an array operator of PostgreSQL applied to a JSON column (
v = ANY(c),unnest(c),c[1]): refused, naming the JSON operation to use.
4.4 Index and pushdown facts the rule table carries¶
Backend classes (carried over from version 1) and what serves an element filter. The plan notes of section 10 and the documentation are generated from these cells.
| Class | Backends | Element filter (contains) |
Index kind |
|---|---|---|---|
| Native, nested | DuckDB, Trino, Databricks, Hive | native operation, scan | none |
| ClickHouse | has, scan |
bloom-filter skip index [mem] |
|
Snowflake, Redshift (SUPER) |
native, scan | Snowflake search optimization [mem] |
|
| Native, one level | PostgreSQL, Greenplum, YugabyteDB, openGauss | c @> ARRAY[v] |
GIN array_ops [doc] |
| CockroachDB | c @> ARRAY[v] |
inverted index [doc] |
|
| BigQuery | v IN UNNEST(c), scan |
search index [mem] |
|
| JSON, indexable | PostgreSQL jsonb |
@> |
GIN jsonb_ops / jsonb_path_ops [doc] |
| MySQL 8.0.17+, TiDB | MEMBER OF, JSON_CONTAINS, JSON_OVERLAPS |
multi-valued index; an empty array has no entry; no range scan [doc] |
|
| Oracle | JSON_EXISTS |
multivalue function-based index [doc] |
|
| SQL Server 2025, Azure SQL | JSON_CONTAINS |
JSON index with OPTIMIZE_FOR_ARRAY_SEARCH [doc] |
|
| JSON, no index | MariaDB, SQL Server 2016–2022, Synapse, SQLite, Exasol, DuckDB JSON, Trino JSON, ClickHouse String, Databricks STRING | JSON functions, scan | none |
| Not verified | SAP HANA, Fabric Warehouse, SingleStore, Db2, Teradata, Vertica, Firebird, MonetDB, SAP ASE, SQL Anywhere, generic sqlalchemy |
operations refused by name |
Pushdown through a federating engine over an attached source is recorded per (engine kind, source kind, operation). Every cell starts as "not pushed down" and is changed only by a test that reads the engine's plan for that pair; none was verified today.
5. Casts in and out, driven by the attributes¶
Applies to columns of depth 1 or more. T is the element type.
5.1 Out¶
| Expression | Generated |
|---|---|
JSON_VALUE(c, '$[0]') on a depth-1 timestamp array |
JSON form: the backend's typed extraction to to_physical(T, dialect). Native form: c[1], with the native type's transform |
JSON_TABLE(c, '$[*]' COLUMNS (v PATH '$')) |
v typed T |
a numeric(p,s) element |
JSON form: the string cast to DECIMAL(p,s) in the engine. Native form: the element itself |
| the whole column | the to-JSON expression of section 3; type json |
The plan states the result's column types from the attributes. No surface types a column from the rows it receives.
5.2 In¶
| Expression | Generated |
|---|---|
JSON_VALUE(c, '$[0]') > '2026-01-01' |
the literal cast to T |
| contains with a parameter | native form: the parameter cast to the column's native element type; MySQL: CAST(? AS CHAR) MEMBER OF (c) |
a literal that does not read as T |
refused at compile (query.json_literal_type) |
| a write to a natively-array column | the JSON given is cast to the native type by the source: PostgreSQL ARRAY(SELECT jsonb_array_elements_text($1))::<native type>, DuckDB from_json, Trino CAST(json_parse(?) AS <native type>) |
5.3 Where the native type, not the element type, decides the cast¶
| Case | Why |
|---|---|
| timestamp with time zone against timestamp without | both are semantic timestamp; the UTC transform is keyed by the native type and applied to the wrong kind it shifts the value |
| PostgreSQL array operators | both sides must be the same array type: varchar[] @> text[] has no operator [mem]; the literal side is cast to the native element type |
| writes | the target of the cast is the native type (char(10)[], DuckDB INTEGER[3]) |
5.4 Drift¶
Schema reconcile re-introspects. Where the source's type no longer equals the recorded native
type, the column is marked and every read that references it is refused by name
(model.json_column_drift: table, column, recorded type, current type) until it is registered
again. Between reconciles a changed type surfaces as the source's own cast error, since every
cast is explicit in the SQL. No cast is adjusted to fit.
5.5 How the attributes reach the translation, and REQ-1877¶
Per-column types already travel in the governance context as (name, type) pairs
(gov_ctx.all_columns, read at provisa/compiler/stage2.py:488,517,558 [code]); the
attributes travel beside them. Nothing under provisa/transpiler reads column types today, and
the rewrite needs the routed backend, known only after routing. So stage 2 records on the plan
the attributes of each JSON column the statement references, and the rule-table rewrite in the
transpile stage reads that map with the dialect and whether the address read is the source or a
replica. Derived once, kept with the governed plan. Re-registering a column bumps the schema
version, which already invalidates kept plans.
6. Depth above 1¶
6.1 Which backends state depth in the type¶
| Backend | Depth in the type | Recorded depth |
|---|---|---|
| DuckDB | INTEGER[][] [probe] |
as stated |
| Trino | array(array(T)) [doc] |
as stated |
| ClickHouse | Array(Array(T)) [doc] |
as stated |
| Databricks, Hive | array<array<T>> [doc] |
as stated |
| Snowflake structured | ARRAY(ARRAY(T)): the element may be a structured ARRAY [doc] |
as stated |
| BigQuery | "Arrays of arrays aren't allowed" [doc]; nesting needs a struct between, which is an array of structs |
1 at most; the struct form is depth 0 |
| CockroachDB | "does not support nested arrays" [doc] |
1 |
| PostgreSQL, Greenplum, YugabyteDB, openGauss | no. The type records no dimensions; pg_attribute.attndims is "not enforced, so any nonzero value effectively means it's an array" [doc] |
always 1 |
6.2 PostgreSQL: a multidimensional value in a depth-1 column¶
| Read | What happens |
|---|---|
| whole value | to_jsonb writes "arrays of arrays" [doc]: correct JSON, deeper than the recorded depth says |
| JSON form (replica, or any expression kept as to-JSON) | follows the JSON: $[0] is an inner array, so JSON_VALUE of it is not a scalar and gives NULL; each yields inner arrays |
| native form, element / length / contains | PostgreSQL's own rules: a 2-D array subscripted once gives NULL, as the JSON form does; @> looks at innermost elements [mem] |
Ruling (maintainer, 2026-10-09): on the PostgreSQL family unnest is always done through JSON,
to_jsonb first and then the JSON each. There is no native unnest there, so the rows an unnest
returns never depend on the dimensions of the value. Element access, length and contains keep
their native form. The array_ndims(c) = 1 data-quality check stays available to a registrar
who wants to know whether a column holds such values.
6.3 How operations address a level¶
The JSON path states the level. d is the recorded depth.
| Operation | Addressing | Typed |
|---|---|---|
| element | $[i] is a level-1 element; $[i][j] level 2 |
cast to T when the path has d array steps; with fewer steps the result is JSON (JSON_QUERY). JSON_VALUE with fewer than d steps is refused at compile (query.json_path_not_scalar: column, depth) |
| length | jsonb_array_length(c) is the outer length; of an inner array: jsonb_array_length(c -> i) |
integer |
| contains | JSON_EXISTS(c, '$[*] ? (@ == $v)') at depth 1; '$[*][*] ? (@ == $v)' asks whether any inner array holds v |
$v cast to T when the path has d wildcard steps |
| unnest | JSON_TABLE(c, '$[*]' ...) gives one row per level-1 element (an inner array at d > 1); NESTED PATH '$[*]' or '$[*][*]' goes down a level, each level with its own FOR ORDINALITY |
innermost column typed T |
| construct, aggregate | JSON form on every backend; nested by nesting the calls | json |
6.4 Native form at depth above 1¶
| Backend | element [i][j] |
contains at the innermost level | unnest |
|---|---|---|---|
| DuckDB | c[i+1][j+1] |
list_contains(flatten(c), v) [mem] |
unnest(c) one level per call |
| Trino | element_at(element_at(c, i+1), j+1) |
contains(flatten(c), v); flatten takes array(array(T)) [doc] |
UNNEST once per level, each WITH ORDINALITY |
| ClickHouse | c[i+1][j+1], guarded by lengths |
has(arrayFlatten(c), v) [mem] |
ARRAY JOIN per level |
| Databricks | try_element_at(try_element_at(c, i+1), j+1) |
array_contains(flatten(c), v) [mem] |
posexplode per level |
| Snowflake structured | GET(GET(c, i), j) |
ARRAY_CONTAINS(v, ARRAY_FLATTEN(c)) [mem] |
FLATTEN per level |
| PostgreSQL family, CockroachDB, BigQuery | none: recorded depth never exceeds 1 |
Each rule is the depth-1 rule applied once per level, so the rule table has no separate rows per
depth: an entry carries max_native_depth (1 for the PostgreSQL family, CockroachDB and
BigQuery; unbounded elsewhere), and above it the operation uses the to-JSON expression. No index
serves a contains at depth above 1 on any backend listed; the plan note says so.
7. Wires and surfaces¶
| Surface | A JSON column arrives as |
|---|---|
| pgwire | jsonb (OID 3802), JSON text; the catalog lists jsonb |
| Flight / Arrow, JDBC, DuckDB extension | utf8 holding JSON text; catalog type json, the same as a read returns (REQ-1961 rule) |
Extracted scalars and JSON_TABLE columns, all SQL wires |
their own typed columns |
| GraphQL | the opaque JSON scalar, as today (provisa/compiler/type_map.py:191-192); object_fields sub-fields as today |
| GraphQL, REST, JSON:API response | JSON. Where the column has no object_fields sub-selection to resolve, the engine's JSON text is passed into the response unparsed (orjson.Fragment; orjson is a dependency, pyproject.toml:21); otherwise parsed as today |
| gRPC | string field holding JSON |
| MCP | JSON value |
| Cypher / Bolt | a JSON string; list functions in the query map to the section 4 operations |
| Row rules | any section 4 operation; native form at the source, so an index can serve the rule |
| Masks | whole value: constant NULL. Regex, truncate and fake kinds do not apply to a JSON column |
| Column grants | column visible_to; sub-field visible_to of object_fields as today |
| Results grid (REQ-1937) | a cell renders the JSON; is-empty and is-not-empty filters. Typed columns from JSON_VALUE / JSON_TABLE filter as any typed column |
| Search index, metadata export | type json |
| Data-quality, profiler | checks and measures written with the section 4 operations |
The hidden attributes appear on no surface.
8. Per-route table: who touches a value¶
| Route | Column natively array/struct/map | Column JSON in the backend |
|---|---|---|
| ENGINE, native engine or store | engine runs native operations; engine builds JSON text only where the whole value is projected | engine runs JSON functions |
| ENGINE over a live-attached source | engine's native operations over the attached column; pushdown to the source is the engine's choice | engine's JSON functions over the pulled column |
| DIRECT | source runs native operations and its own to-JSON | source runs its JSON functions |
| API route | rows land in the engine cache as JSON (provisa/api_source/engine_cache.py:188); then as ENGINE |
same |
| Response cache hit | stored result | stored result |
| Provisa | passes bytes | passes bytes |
Remaining touches, both existing and both removable: the JSON land path parses each JSON value
in Python before insert (materialize_exec.py:135-138); GraphQL result assembly parses JSON
strings (serialize.py:67-70). Section 3 (landing by SQL expression) and section 7 (splice)
remove them.
Measurements, as numbers for the documentation (neither is a gate):
- M1. Does any route still decode in Provisa? No: a pgwire, JDBC or Arrow client receives
jsonand the bytes pass through on every route above. What M1 measures instead is the price of the ruling at the source:SELECT id, labelsDIRECT from PostgreSQL wherelabelsistext[], 5M rows, over Flight and pgwire,to_jsonb(labels)against the same column read as a native array. Report the source's CPU and the bytes on the wire. - M2. Contains at 0.1% selectivity on the same table: PostgreSQL DIRECT with a GIN index
(native form); DuckDB engine over the live attach; MySQL
JSONwith and without a multi-valued index; a replica asjsonbwith GIN; the child-table join. Report latency and rows read. These are the figures section 9's guidance quotes.
9. The tradeoffs, as the documentation states them¶
Each is stated once. The right-hand column is the recommended practice.
| Tradeoff | What the user sees | Practice |
|---|---|---|
| A JSON column is a string to SQL clients | a JDBC, BI, Arrow or pgwire client receives the whole column as JSON text, typed json. It does not receive a list or a struct |
select the parts wanted as columns: JSON_VALUE(c, '$.a') arrives typed; JSON_TABLE gives one typed row per element. BI tools then see ordinary columns |
| An element filter may not use an index | contains, JSON_EXISTS and filters on unnested rows run as a scan on backends with no index for them (section 4.4, column "index"). They return the right answer |
for a list that is filtered or joined often, register a child table (one row per element, with the position) and join it; or keep a replica on an engine that indexes JSON (PostgreSQL jsonb with GIN, MySQL multi-valued index) |
| A filter may not be pushed down | when a federating engine reads a live source, a JSON or array predicate may be evaluated in the engine after it pulls the column | query the source directly where routing allows; or replicate the table |
| Unnesting a large JSON column is work | JSON_TABLE over every row parses every value in the backend |
filter first, unnest after; or the child table |
| Exactness inside JSON | in an array of a primitive, elements are exact: decimals with a declared precision, dates, timestamps and uuids travel as strings and are cast back. Inside a struct, a map or a source's own JSON a number is a JSON number, floating point on several backends | declare precision on numeric columns (REQ-1961); inside structs and in a source's own JSON, keep money as strings and write the cast |
| Nothing is typed inside a struct or map | the whole value and the untyped operations work; a typed value from inside needs RETURNING or a cast the user writes |
JSON_VALUE(c, '$.amount' RETURNING numeric(18,2)) |
| Whole values do not compare | two JSON arrays cannot be ordered, and compare for equality only where the backend compares JSON | compare extracted parts |
| Some operations are missing on some backends | the statement is refused at compile, naming the operation, the backend and the column (section 4.3) | run it through an engine that has the operation, or replicate the table |
| The source serialises | a native array read whole is converted to JSON text by the source | M1 gives the cost; select parts where the whole value is not needed |
On record, what a typed array would have given and this design does not: a list type on SQL
wires; binary transfer of whole arrays; PostgreSQL array syntax (= ANY, unnest, c[1]) on
these columns; element statistics for planners over replicas.
10. The query plan points out slow JSON operations¶
Requirement (maintainer, 2026-10-09): the plan states, for a statement that uses a JSON operation, when that operation will not use an index or will not be pushed down on the backend it is routed to, naming the column, the operation and the backend, with the documented alternative. It is advice. It never blocks or changes the statement.
10.1 Surfaces that exist¶
| Surface | Where | Carries today |
|---|---|---|
| Explain endpoint | provisa/api/data/endpoint_dev.py:340-372 → analyze_sql (provisa/executor/explain.py:414-463) [code] |
route, route_reason, dialect, sources, optimizations, sql, semantic_sql, plan (engine's EXPLAIN, normalised), mermaid |
| SQL editor plan view | provisa-ui/src/pages/SqlPage.tsx:24,167,309 [code] |
renders that response |
MCP tool explain_sql |
provisa/api/mcp/tools.py:867 [code] |
the same response |
| The plan object | optimizations, warnings on _Plan (provisa/pgwire/_pipeline.py:198,1077) [code] |
kept with the governed plan |
| Statement warnings (REQ-1350) | provisa/core/statement_warnings.py:36-45 [code] |
{code, params, message} on every surface's warning channel |
EXPLAIN typed over pgwire, GraphQL explain |
not found today |
10.2 Design¶
- The rule table (section 4.4) carries, per (operation, backend, form), two facts as data:
index(the index kind that can serve it, or none) andpushdown(whether a federating engine of this kind passes the operation to an attached source of that kind). The notes and the documentation tables are both generated from it. - When the transpile stage picks a rule for an operation, it appends a note where
indexis none orpushdownis false for the route chosen. Route and backend are known there. The notes are a field of the plan (advice), derived once and kept with the governed plan, asoptimizationsis. Nothing is derived per request (REQ-1877). - A note has the shape statement warnings already have, so it is localised from the same catalog and is machine-readable:
| Code | Params | English text |
|---|---|---|
plan.json_no_index |
column, operation, backend, alternative |
"contains on orders.tags runs as a scan on mysql: no index serves it there. Alternative: a child table, or a replica on an engine that indexes JSON." |
plan.json_index_possible |
column, operation, backend, index_kind |
"contains on orders.tags can use a GIN index on postgresql if one exists. The engine plan below shows whether it did." |
plan.json_not_pushed_down |
column, operation, engine, source |
"contains on orders.tags is evaluated by duckdb after reading the column from mysql; it is not pushed to the source. Alternative: read the source directly, or replicate the table." |
plan.json_serialised_by_source |
column, backend |
"orders.tags is a native array on postgresql and is returned whole as JSON text. Select the parts needed to avoid the conversion." |
analyze_sqlreturns them asadvice: [{code, params, message}], and the diagram shows each beside the Provisa nodes it already draws above the engine's plan. The engine's own EXPLAIN remains the statement of what happened (aSeq Scanor an index scan); the note states what the backend is able to do. Provisa does not look at the source's indexes.- Notes appear with the plan only. They do not ride the warning channel of an execution.
- A note is given where the backend cannot serve the operation from an index, or where pushdown is not known. Every pushdown cell starts as not known.
11. Governance of the change¶
V1: no migrations, no shims.
| Question | Answer |
|---|---|
A column registered as text today that is natively an array, struct or map |
at the next registration or reconcile it becomes json with its attributes. Its replica is rebuilt |
Things declared against the text reading (regex mask, LIKE in a row rule or view) |
fail model validation then, each named |
object_fields, _json_schema_to_gql_type |
unchanged |
| Requirement text | REQ-1965 is the requirement. REQ-980 ("remain collapsed to text") and REQ-846 need a line pointing at it |
Guards and tests the build will trip (found by content search, to read before starting):
tests/unit/test_ir_types.py, test_discovery_through_driver.py, test_ops_domain.py (array →
text); tests/unit/pgwire/test_server.py, test_extended_protocol_portals.py, test_catalog.py
(list wire types, first-row inference); test_no_per_request_sql_derivation.py (REQ-1877); the
engine-kinds equality test (the rule table needs the same held-equal test per engine kind);
import layering (attribute and type parsing in provisa/core, rule table in the engine and
transpiler layers); the length ratchet on transpile.py and pgwire/server.py.
12. Build order and the rule-table shape¶
L = unit tests run locally (DuckDB in-process, SQLite files). C = container or credential lanes in CI.
| # | Piece | Value alone | Tests |
|---|---|---|---|
| 1 | Registration. A parser turns every native spelling into a structured native type (kind: scalar / array / struct / map; element; fields; precision, scale). One function derives from it: data_type (json for any nested kind), depth, element type, native type. The three attributes on Column, in storage, loader and admin API; the suggested type in the registration picker goes through the same function. PostgreSQL family via pg_catalog (format_type, typcategory, typelem) |
registering a native array, struct or map never raises and never stores text |
L |
| 2 | The datasource cast: to-JSON expression per backend, with the element pre-cast for depth ≥ 1 (decimal strings, timestamps, hex binary; NaN and infinity raise by name). Whole-value read on every wire; landing a replica by the same expression; jsonb on PostgreSQL. DuckDB, PostgreSQL, Trino first |
JSON on every wire from native nested columns | L for DuckDB; C |
| 3 | The rule table, JSON form, for PostgreSQL, DuckDB, Trino; casts in and out from the attributes; path-depth checks; the route rule at router.py:273 replaced by the table; plan notes in analyze_sql, the SQL editor and the MCP tool |
JSON operations in semantic SQL, DIRECT where the source can; the plan says what will scan | L snapshot and DuckDB execution; C |
| 4 | Native form at depth 1 for PostgreSQL (element, length, contains; never unnest), DuckDB, Trino; then depth above 1 where the backend states it; M1 and M2 | source indexes serve JSON-spelled predicates | L; C; perf-bench |
| 5 | JSON form for MySQL, MariaDB, SQL Server, SQLite source; physical type fixes (Fabric, Oracle) | sources without arrays, DIRECT | L for SQLite; C |
| 6 | Landing by SQL expression instead of the per-row parse; JSON passed through to GraphQL/REST where no sub-field handling needs it parsed; the text-column parsing defect at serialize.py:67-72 |
no value converted where avoidable | L |
| 7 | Surfaces: grid rendering and empty filters for JSON cells, gRPC, MCP, Bolt as a JSON string | JSON columns on every client | L; Playwright in CI |
| 8 | Drift refusal at reconcile | a changed source type cannot give a wrong cast | L |
| 9 | Remaining backends: ClickHouse, Snowflake, BigQuery, Databricks, Oracle, Exasol, each unverified kind, each pushdown cell | breadth | C |
Rule-table shape. One table, total over its key; a missing entry is a refusal, and a unit test enumerates it.
| Part | Content |
|---|---|
| Key | (operation, backend, form). operation ∈ element, scalar, length, contains, each, exists, construct_array, construct_object, aggregate_array, keys, to_json, from_json. backend = the dialect or engine kind the statement is routed to. form ∈ json, native |
Entry: emit |
a function from the operands (sqlglot expressions), the column's attributes and the path to a sqlglot expression. Not a string template |
Entry: refused |
set instead of emit where the backend cannot do it; carries the reason text for query.json_operation_unsupported |
Entry: index |
the index kind that can serve this entry on this backend, or none |
Entry: pushdown |
per attached-source kind: known pushed, or not known. Starts not known |
Entry: max_native_depth |
native form only: 1 or unbounded |
Entry: verified |
whether a test against the real backend pins this entry. An unverified entry of an unverified backend is a refusal |
| Per backend | to_json(native type), the element pre-cast per element type, index base, the physical JSON type |
| Selection | form = native when depth ≥ 1, the address read is the source, the source kind has arrays, and the path depth is within max_native_depth; otherwise json. Every column reference starts as its to-JSON expression; a recognised operation over it is replaced by the native entry; anything else keeps to-JSON |
| Outputs per statement | the rewritten expression tree; the result column types; the plan notes |
Documentation, written with the piece that makes it true (doc-writer agent):
| Page | Sections | With piece |
|---|---|---|
docs/modeling.md |
"JSON columns": what a native array, struct or map registers as; what is typed (arrays of a primitive) and what is not; drift | 1, 2 |
docs/sources.md |
per source kind: native types that become JSON, the spelling of exact values, PostgreSQL multidimensional values, what is not available | 2, 5, 9 |
new docs/json.md |
the operations with one example each; typed extraction; unnesting, by level; the tradeoff table of section 9 with its practices; "when to use a child table"; the per-backend table of what can use an index, what is pushed down and what is refused, generated from the rule table; M1 and M2 figures | 3, 4 |
docs/engines.md |
per engine: JSON physical type, index kinds, pushdown to attached sources | 3, 5, 9 |
docs/pgwire.md, docs/api-reference.md, docs/cypher.md |
a JSON column on each wire; what BI and JDBC clients receive and how to select typed parts | 2, 7 |
SQL editor help, docs/admin.md |
reading the plan notes | 3 |
docs/glossary.md |
JSON column, array of a primitive, child table | 1 |
13. Settled choices, and what remains open¶
Settled (maintainer, 2026-10-09, in REQ-1965): JSON columns only; three hidden attributes with
depth as an integer; structs, maps, arrays of structs and mixed arrays plain JSON with nothing
typed inside; attributes from a native type only; decimals with a declared precision as strings;
SQL/JSON standard functions with PostgreSQL operators as the same operations; drift refused by
name; plan notes with the plan only; Bolt a JSON string; GraphQL the opaque JSON scalar;
PostgreSQL lands jsonb; NaN and infinity raise by name.
Settled after the first final commit (maintainer, 2026-10-09):
| # | Point | Ruling |
|---|---|---|
| 1 | PostgreSQL multidimensional values in a depth-1 column (section 6.2) | unnest on the PostgreSQL family is always via JSON (to_jsonb, then each); no native unnest there |
| 2 | JSON_VALUE with fewer array steps than the column's depth |
a compile error naming the column and its depth (query.json_path_not_scalar) |
| 3 | Backends whose to-JSON or JSON functions are unverified | registered, read refused by name, until a test pins the entry |
Nothing is open.
14. Defects that stand on their own¶
Independent of this design. None filed.
| # | Defect | Where | Reproduced |
|---|---|---|---|
| 1 | to_ir raises unknown SQL type for native array spellings: DuckDB VARCHAR[], INTEGER[][]; PostgreSQL _int4, ARRAY; ClickHouse Array(String); BigQuery ARRAY<STRING>; Databricks array<string>. The DuckDB overlay's keys list/struct/map never match what DuckDB reports (VARCHAR[], [probe] of information_schema). Only Trino's array(T) is mapped |
provisa/core/ir_types.py:151-153,244-255 |
yes, by calling to_ir |
| 2 | pgwire types a list column from the first non-null row: integers → int array, strings → text array, anything else → JSON. An empty first list, or a first row whose list is empty, gives JSON while a later statement gives an array. Declared [] types other than INTEGER[] and VARCHAR[] are sent as JSON |
provisa/pgwire/server.py:406-431,325-326,401-402 |
read in code, not run |
| 3 | The Arrow list child field is named l by DuckDB and item by pyarrow, so the same list column has two schemas depending on the route that produced it |
observed from duckdb 1.5.5 to_arrow_table(); rows-to-Arrow path at provisa/federation/runtime_support.py:140-160 uses pyarrow inference |
yes for the two names; not on a Flight read end to end |
| 4 | GraphQL result assembly parses any string beginning with { or [ as JSON, whatever the column's declared type; a text value such as [draft] is attempted and a text value that is valid JSON is returned as an object |
provisa/executor/serialize.py:67-72 |
read in code, not run |
| 5 | A DIRECT read that uses a JSON path is sent to the engine for every dialect except PostgreSQL, including dialects with JSON functions (MySQL, SQL Server, SQLite) | provisa/transpiler/router.py:273-283 |
read in code |
| 6 | IR json lands as JSON on PostgreSQL (no index, no containment), VARCHAR(8000) on Fabric/Synapse (length limit), and has no Oracle mapping (to_physical("json","oracle") raises UnsupportedCompilationError) |
provisa/core/ir_types.py:68,308-320; provisa/federation/mssql_warehouse_runtime.py:79 |
yes for PostgreSQL and Oracle, by calling to_physical; Fabric read in code |
| 7 | sqlglot output accepted unchecked for array and JSON operations: Trino a \|\| b on arrays becomes a string concatenation; Databricks array_agg(x ORDER BY k) loses its order; PostgreSQL JSON functions other than ->/->> pass through unchanged to every dialect |
provisa/transpiler/transpile.py:41-48 and the generic transpile |
yes, by transpiling |
| 8 | The JSON land path parses each JSON value in Python per row, against the set-based rule stated in ir_types.py:157-160 |
provisa/federation/materialize_exec.py:135-138 |
read in code |
| 9 | The type the registration picker suggests is the raw native spelling lower-cased, not passed through to_ir; the raise surfaces later, at landing or import |
provisa/api/admin/schema_query.py:2338-2343,2368-2371,2410-2415; raises at provisa/federation/residency.py:87, provisa/api/admin/import_typing.py:53, provisa/federation/replica_routing.py:385 |
found and cited by teammate json; not re-read by me |
| 10 | The UI's IR type map shows json/jsonb as "text" |
provisa-ui/src/irTypes.ts:36-37 |
found by teammate json; not re-read by me |
| 11 | Further spellings that raise in to_ir: PostgreSQL USER-DEFINED, text[]; Snowflake ARRAY/OBJECT/VARIANT; Redshift super; ClickHouse Tuple/Map/Nested; BigQuery STRUCT<..>; Databricks struct/map. DuckDB STRUCT(..)/MAP(..) do reach text (correcting row 1: not only Trino's spelling is mapped) |
provisa/core/ir_types.py:244-255 |
found by teammate json |
15. The mail sources¶
Their canonical schema is not in code yet (feat/gmail-source holds settings and OAuth grants
only); this follows the lead's description: list columns as JSON text, child tables for joins.
The list columns are JSON at their source (an API payload), so they carry none of the three
attributes: they are plain JSON columns, readable whole and with the untyped operations, and an
element is text unless the user casts it. For string lists that is already the right type. The
child tables stay as the indexed join path, the practice section 9 recommends. To hold: the JSON
is an array of scalar strings where it can be; an empty list is [] and an absent field is SQL
NULL; the child table carries the element position.
16. Probes and sources¶
Local probes, 2026-10-09, pyarrow 24.0.0, duckdb 1.5.5, sqlglot 30.9.0, SQLite 3.42.0:
| Probe | Result |
|---|---|
DuckDB to_json of DECIMAL(20,2)[], TIMESTAMP[], BLOB[], nested list, struct, map, DOUBLE[] with NaN |
[12345678901234567.89,0.10], ["2026-10-09 12:34:56.123456"], ["\\x00ff"], [[1,2],[3]], {"a":1,"b":["2026-01-01"]}, {"k":1}, [1.5,NaN] |
DuckDB JSON number → DECIMAL(20,2) |
12345678901234567.68 (inexact); from a JSON string: exact |
DuckDB JSON [9007199254740993] → BIGINT[] |
exact |
SQLite json_extract('[12345678901234567.89]', '$[0]') |
1.2345678901234568e+16, type real; 64-bit integer exact |
to_ir on VARCHAR[], _int4, ARRAY, Array(String), ARRAY<STRING>, array<string> |
raises; Trino array(varchar) → text |
to_physical("json", d) |
postgresql/mysql/sqlite/duckdb/trino/redshift JSON; mssql NVARCHAR(max); oracle raises |
| sqlglot JSON transpilation | section 1 table |
Vendor pages fetched:
- PostgreSQL JSON functions: https://www.postgresql.org/docs/current/functions-json.html
- PostgreSQL JSON types: https://www.postgresql.org/docs/current/datatype-json.html
- PostgreSQL arrays: https://www.postgresql.org/docs/current/arrays.html
- PostgreSQL pg_attribute: https://www.postgresql.org/docs/current/catalog-pg-attribute.html
- ClickHouse Variant: https://clickhouse.com/docs/sql-reference/data-types/variant
- DuckDB UNION: https://duckdb.org/docs/current/sql/data_types/union.html
- Trino types: https://trino.io/docs/current/language/types.html
- Snowflake structured types: https://docs.snowflake.com/en/sql-reference/data-types-structured
- PostgreSQL GIN: https://www.postgresql.org/docs/current/gin.html
- DuckDB JSON functions: https://duckdb.org/docs/current/data/json/json_functions.html
- DuckDB LIST: https://duckdb.org/docs/current/sql/data_types/list.html
- Trino JSON functions: https://trino.io/docs/current/functions/json.html
- Trino array functions: https://trino.io/docs/current/functions/array.html
- MySQL JSON search functions: https://dev.mysql.com/doc/refman/8.4/en/json-search-functions.html
- MySQL multi-valued indexes: https://dev.mysql.com/doc/refman/8.4/en/create-index.html
- TiDB index selection: https://docs.pingcap.com/tidb/stable/choose-index/
- MariaDB JSON type: https://mariadb.com/kb/en/json-data-type/
- SQL Server JSON: https://learn.microsoft.com/en-us/sql/relational-databases/json/json-data-sql-server
- SQL Server CREATE JSON INDEX: https://learn.microsoft.com/en-us/sql/t-sql/statements/create-json-index-transact-sql
- Oracle indexes for JSON: https://docs.oracle.com/en/database/oracle/oracle-database/23/adjsn/indexes-for-json-data.html
- SQLite JSON: https://www.sqlite.org/json1.html
- Exasol JSON_EXTRACT: https://docs.exasol.com/db/latest/sql_references/functions/alphabeticallistfunctions/json_extract.htm
- BigQuery JSON functions: https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/json_functions
- BigQuery data types: https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/data-types
- ClickHouse Array: https://clickhouse.com/docs/sql-reference/data-types/array
- Snowflake semi-structured types: https://docs.snowflake.com/en/sql-reference/data-types-semistructured
- Databricks ARRAY: https://docs.databricks.com/aws/en/sql/language-manual/data-types/array-type
- CockroachDB ARRAY: https://docs.cockroachlabs.com/docs/stable/array
- Redshift SUPER: https://docs.aws.amazon.com/redshift/latest/dg/r_SUPER_type.html
Not verified today: every cell tagged [mem]; Trino's cast to JSON for DECIMAL, DATE and
TIMESTAMP elements (the fetched list names only boolean, integers, REAL, DOUBLE, VARCHAR; the
pre-cast to text of section 3 does not depend on it); Hive's to-JSON form; SAP HANA and the
long-tail engine kinds; how the Bolt surface carries a JSON column today; the JDBC driver; the
mail sources' canonical schema (not in code on feat/gmail-source); whether a federating
engine pushes a native array predicate to its attached source.