From 1310a945d767cbb39f7ecbbddfbbd5ce1305a452 Mon Sep 17 00:00:00 2001 From: Greg Felice Date: Wed, 25 Mar 2026 00:01:03 -0400 Subject: [PATCH 1/3] Add agtype <-> jsonb bidirectional casts (issue #350) Register explicit casts between agtype and jsonb, enabling: SELECT properties(n)::jsonb FROM cypher(...) AS (n agtype); SELECT '{"key": "value"}'::jsonb::agtype; -- Use jsonb operators on graph data: SELECT (props::jsonb)->>'name' FROM cypher(...) AS (props agtype); Implementation uses SQL language functions that go through proven text-intermediate paths: agtype -> jsonb: agtype_to_json() -> json::jsonb jsonb -> agtype: jsonb::text -> text::agtype This approach is safe because agtype extends jsonb's binary format with types (AGTV_INTEGER, AGTV_FLOAT, AGTV_VERTEX, AGTV_EDGE, AGTV_PATH) that jsonb does not recognize, making direct binary conversion unreliable. The text roundtrip handles all value types correctly including graph types (vertex/edge properties are extracted as JSON objects). All 31 regression tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- sql/agtype_coercions.sql | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/sql/agtype_coercions.sql b/sql/agtype_coercions.sql index 933375fc1..5d3a747b2 100644 --- a/sql/agtype_coercions.sql +++ b/sql/agtype_coercions.sql @@ -174,6 +174,33 @@ AS 'MODULE_PATHNAME'; CREATE CAST (agtype AS json) WITH FUNCTION ag_catalog.agtype_to_json(agtype); +-- agtype -> jsonb (explicit) +-- Uses text intermediate because agtype extends jsonb's binary format +-- with types (AGTV_INTEGER, AGTV_FLOAT, AGTV_VERTEX, AGTV_EDGE, AGTV_PATH) +-- that jsonb does not recognize. +CREATE FUNCTION ag_catalog.agtype_to_jsonb(agtype) + RETURNS jsonb + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'SELECT ag_catalog.agtype_to_json($1)::jsonb'; + +CREATE CAST (agtype AS jsonb) + WITH FUNCTION ag_catalog.agtype_to_jsonb(agtype); + +-- jsonb -> agtype (explicit) +CREATE FUNCTION ag_catalog.jsonb_to_agtype(jsonb) + RETURNS agtype + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'SELECT $1::text::agtype'; + +CREATE CAST (jsonb AS agtype) + WITH FUNCTION ag_catalog.jsonb_to_agtype(jsonb); + CREATE FUNCTION ag_catalog.agtype_array_to_agtype(agtype[]) RETURNS agtype LANGUAGE c From 212d78353d8cebd77d239f4289f1a8a499dd9e23 Mon Sep 17 00:00:00 2001 From: Greg Felice Date: Wed, 25 Mar 2026 17:20:10 -0400 Subject: [PATCH 2/3] Address Copilot review: fix comment, add regression tests for jsonb casts - Fix comment in agtype_coercions.sql: document json-intermediate path (agtype_to_json -> json::jsonb) instead of incorrect "text intermediate" - Add agtype_jsonb_cast regression test covering: string/null/array/object agtype->jsonb, all jsonb scalar types->agtype, roundtrips, vertex/edge ->jsonb with structural key checks, NULL handling - Register agtype_jsonb_cast in Makefile REGRESS list Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 3 +- regress/expected/agtype_jsonb_cast.out | 261 +++++++++++++++++++++++++ regress/sql/agtype_jsonb_cast.sql | 137 +++++++++++++ sql/agtype_coercions.sql | 6 +- 4 files changed, 403 insertions(+), 4 deletions(-) create mode 100644 regress/expected/agtype_jsonb_cast.out create mode 100644 regress/sql/agtype_jsonb_cast.sql diff --git a/Makefile b/Makefile index 3c5a28c29..7e5fb3d2d 100644 --- a/Makefile +++ b/Makefile @@ -180,7 +180,8 @@ REGRESS = scan \ map_projection \ direct_field_access \ security \ - reserved_keyword_alias + reserved_keyword_alias \ + agtype_jsonb_cast ifneq ($(EXTRA_TESTS),) REGRESS += $(EXTRA_TESTS) diff --git a/regress/expected/agtype_jsonb_cast.out b/regress/expected/agtype_jsonb_cast.out new file mode 100644 index 000000000..49bf1f887 --- /dev/null +++ b/regress/expected/agtype_jsonb_cast.out @@ -0,0 +1,261 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +-- +-- agtype -> jsonb casts +-- +-- String scalar +SELECT '"hello"'::agtype::jsonb; + jsonb +--------- + "hello" +(1 row) + +-- Null +SELECT 'null'::agtype::jsonb; + jsonb +------- + null +(1 row) + +-- Array +SELECT '[1, "two", null]'::agtype::jsonb; + jsonb +------------------ + [1, "two", null] +(1 row) + +-- Nested array +SELECT '[[1, 2], [3, 4]]'::agtype::jsonb; + jsonb +------------------ + [[1, 2], [3, 4]] +(1 row) + +-- Empty array +SELECT '[]'::agtype::jsonb; + jsonb +------- + [] +(1 row) + +-- Object +SELECT '{"name": "Alice", "age": 30}'::agtype::jsonb; + jsonb +------------------------------ + {"age": 30, "name": "Alice"} +(1 row) + +-- Nested object +SELECT '{"a": {"b": {"c": 1}}}'::agtype::jsonb; + jsonb +------------------------ + {"a": {"b": {"c": 1}}} +(1 row) + +-- Object with array values +SELECT '{"tags": ["a", "b"], "count": 2}'::agtype::jsonb; + jsonb +---------------------------------- + {"tags": ["a", "b"], "count": 2} +(1 row) + +-- Empty object +SELECT '{}'::agtype::jsonb; + jsonb +------- + {} +(1 row) + +-- +-- jsonb -> agtype casts +-- +-- String scalar +SELECT '"hello"'::jsonb::agtype; + agtype +--------- + "hello" +(1 row) + +-- Numeric scalar +SELECT '42'::jsonb::agtype; + agtype +-------- + 42 +(1 row) + +-- Float scalar +SELECT '3.14'::jsonb::agtype; + agtype +-------- + 3.14 +(1 row) + +-- Boolean +SELECT 'true'::jsonb::agtype; + agtype +-------- + true +(1 row) + +-- Null +SELECT 'null'::jsonb::agtype; + agtype +-------- + null +(1 row) + +-- Array +SELECT '[1, "two", null]'::jsonb::agtype; + agtype +------------------ + [1, "two", null] +(1 row) + +-- Nested array +SELECT '[[1, 2], [3, 4]]'::jsonb::agtype; + agtype +------------------ + [[1, 2], [3, 4]] +(1 row) + +-- Empty array +SELECT '[]'::jsonb::agtype; + agtype +-------- + [] +(1 row) + +-- Object +SELECT '{"name": "Alice", "age": 30}'::jsonb::agtype; + agtype +------------------------------ + {"age": 30, "name": "Alice"} +(1 row) + +-- Nested object +SELECT '{"a": {"b": {"c": 1}}}'::jsonb::agtype; + agtype +------------------------ + {"a": {"b": {"c": 1}}} +(1 row) + +-- Empty object +SELECT '{}'::jsonb::agtype; + agtype +-------- + {} +(1 row) + +-- +-- Roundtrip: jsonb -> agtype -> jsonb +-- +SELECT ('{"key": "value"}'::jsonb::agtype)::jsonb; + jsonb +------------------ + {"key": "value"} +(1 row) + +SELECT ('[1, 2, 3]'::jsonb::agtype)::jsonb; + jsonb +----------- + [1, 2, 3] +(1 row) + +SELECT ('null'::jsonb::agtype)::jsonb; + jsonb +------- + null +(1 row) + +-- +-- Graph data -> jsonb (vertex and edge) +-- +SELECT create_graph('agtype_jsonb_test'); +NOTICE: graph "agtype_jsonb_test" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('agtype_jsonb_test', $$ + CREATE (a:Person {name: 'Alice', age: 30})-[:KNOWS {since: 2020}]->(b:Person {name: 'Bob', age: 25}) + RETURN a, b +$$) AS (a agtype, b agtype); + a | b +------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "Person", "properties": {"age": 30, "name": "Alice"}}::vertex | {"id": 844424930131970, "label": "Person", "properties": {"age": 25, "name": "Bob"}}::vertex +(1 row) + +-- Vertex to jsonb: check structure +SELECT v::jsonb ? 'label' AS has_label, + v::jsonb ? 'properties' AS has_properties, + (v::jsonb -> 'properties' ->> 'name') AS name +FROM cypher('agtype_jsonb_test', $$ + MATCH (n:Person) RETURN n ORDER BY n.name +$$) AS (v agtype); + has_label | has_properties | name +-----------+----------------+------- + t | t | Alice + t | t | Bob +(2 rows) + +-- Edge to jsonb: check structure +SELECT e::jsonb ? 'label' AS has_label, + (e::jsonb ->> 'label') AS label, + (e::jsonb -> 'properties' ->> 'since') AS since +FROM cypher('agtype_jsonb_test', $$ + MATCH ()-[r:KNOWS]->() RETURN r +$$) AS (e agtype); + has_label | label | since +-----------+-------+------- + t | KNOWS | 2020 +(1 row) + +-- +-- NULL handling +-- +SELECT NULL::agtype::jsonb; + jsonb +------- + +(1 row) + +SELECT NULL::jsonb::agtype; + agtype +-------- + +(1 row) + +-- +-- Cleanup +-- +SELECT drop_graph('agtype_jsonb_test', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table agtype_jsonb_test._ag_label_vertex +drop cascades to table agtype_jsonb_test._ag_label_edge +drop cascades to table agtype_jsonb_test."Person" +drop cascades to table agtype_jsonb_test."KNOWS" +NOTICE: graph "agtype_jsonb_test" has been dropped + drop_graph +------------ + +(1 row) + diff --git a/regress/sql/agtype_jsonb_cast.sql b/regress/sql/agtype_jsonb_cast.sql new file mode 100644 index 000000000..c3a6a8e27 --- /dev/null +++ b/regress/sql/agtype_jsonb_cast.sql @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +LOAD 'age'; +SET search_path TO ag_catalog; + +-- +-- agtype -> jsonb casts +-- + +-- String scalar +SELECT '"hello"'::agtype::jsonb; + +-- Null +SELECT 'null'::agtype::jsonb; + +-- Array +SELECT '[1, "two", null]'::agtype::jsonb; + +-- Nested array +SELECT '[[1, 2], [3, 4]]'::agtype::jsonb; + +-- Empty array +SELECT '[]'::agtype::jsonb; + +-- Object +SELECT '{"name": "Alice", "age": 30}'::agtype::jsonb; + +-- Nested object +SELECT '{"a": {"b": {"c": 1}}}'::agtype::jsonb; + +-- Object with array values +SELECT '{"tags": ["a", "b"], "count": 2}'::agtype::jsonb; + +-- Empty object +SELECT '{}'::agtype::jsonb; + +-- +-- jsonb -> agtype casts +-- + +-- String scalar +SELECT '"hello"'::jsonb::agtype; + +-- Numeric scalar +SELECT '42'::jsonb::agtype; + +-- Float scalar +SELECT '3.14'::jsonb::agtype; + +-- Boolean +SELECT 'true'::jsonb::agtype; + +-- Null +SELECT 'null'::jsonb::agtype; + +-- Array +SELECT '[1, "two", null]'::jsonb::agtype; + +-- Nested array +SELECT '[[1, 2], [3, 4]]'::jsonb::agtype; + +-- Empty array +SELECT '[]'::jsonb::agtype; + +-- Object +SELECT '{"name": "Alice", "age": 30}'::jsonb::agtype; + +-- Nested object +SELECT '{"a": {"b": {"c": 1}}}'::jsonb::agtype; + +-- Empty object +SELECT '{}'::jsonb::agtype; + +-- +-- Roundtrip: jsonb -> agtype -> jsonb +-- + +SELECT ('{"key": "value"}'::jsonb::agtype)::jsonb; +SELECT ('[1, 2, 3]'::jsonb::agtype)::jsonb; +SELECT ('null'::jsonb::agtype)::jsonb; + +-- +-- Graph data -> jsonb (vertex and edge) +-- + +SELECT create_graph('agtype_jsonb_test'); + +SELECT * FROM cypher('agtype_jsonb_test', $$ + CREATE (a:Person {name: 'Alice', age: 30})-[:KNOWS {since: 2020}]->(b:Person {name: 'Bob', age: 25}) + RETURN a, b +$$) AS (a agtype, b agtype); + +-- Vertex to jsonb: check structure +SELECT v::jsonb ? 'label' AS has_label, + v::jsonb ? 'properties' AS has_properties, + (v::jsonb -> 'properties' ->> 'name') AS name +FROM cypher('agtype_jsonb_test', $$ + MATCH (n:Person) RETURN n ORDER BY n.name +$$) AS (v agtype); + +-- Edge to jsonb: check structure +SELECT e::jsonb ? 'label' AS has_label, + (e::jsonb ->> 'label') AS label, + (e::jsonb -> 'properties' ->> 'since') AS since +FROM cypher('agtype_jsonb_test', $$ + MATCH ()-[r:KNOWS]->() RETURN r +$$) AS (e agtype); + +-- +-- NULL handling +-- + +SELECT NULL::agtype::jsonb; +SELECT NULL::jsonb::agtype; + +-- +-- Cleanup +-- + +SELECT drop_graph('agtype_jsonb_test', true); diff --git a/sql/agtype_coercions.sql b/sql/agtype_coercions.sql index 5d3a747b2..7763360f2 100644 --- a/sql/agtype_coercions.sql +++ b/sql/agtype_coercions.sql @@ -175,9 +175,9 @@ CREATE CAST (agtype AS json) WITH FUNCTION ag_catalog.agtype_to_json(agtype); -- agtype -> jsonb (explicit) --- Uses text intermediate because agtype extends jsonb's binary format --- with types (AGTV_INTEGER, AGTV_FLOAT, AGTV_VERTEX, AGTV_EDGE, AGTV_PATH) --- that jsonb does not recognize. +-- Uses json intermediate (agtype_to_json -> json::jsonb) because agtype +-- extends jsonb's binary format with types (AGTV_INTEGER, AGTV_FLOAT, +-- AGTV_VERTEX, AGTV_EDGE, AGTV_PATH) that jsonb does not recognize. CREATE FUNCTION ag_catalog.agtype_to_jsonb(agtype) RETURNS jsonb LANGUAGE sql From 506138f428af8dd2a88de79ee447eaef35a6a115 Mon Sep 17 00:00:00 2001 From: Greg Felice Date: Wed, 13 May 2026 15:50:35 -0400 Subject: [PATCH 3/3] Register agtype <-> jsonb casts in upgrade template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The age_upgrade regression test (added after this PR was originally opened) verifies that every SQL object in the install SQL also appears in the version upgrade template (age----.sql). Surfaced during rebase onto current master: agtype_to_jsonb / jsonb_to_agtype and their casts were missing from the template, causing 4 entries in the "missing_function" / "missing_cast" check. Adds the two functions and two casts to the end of the template, following the file's own "add all additions to the end of this file" convention. Cassert installcheck now 34/34 AGE tests green (pgvector skipped — env-only). --- age--1.7.0--y.y.y.sql | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/age--1.7.0--y.y.y.sql b/age--1.7.0--y.y.y.sql index a3cdea279..520447770 100644 --- a/age--1.7.0--y.y.y.sql +++ b/age--1.7.0--y.y.y.sql @@ -459,3 +459,34 @@ BEGIN END LOOP; END; $$; + +-- +-- agtype <-> jsonb bidirectional casts +-- + +-- agtype -> jsonb (explicit) +-- Uses json intermediate (agtype_to_json -> json::jsonb) because agtype +-- extends jsonb's binary format with types (AGTV_INTEGER, AGTV_FLOAT, +-- AGTV_VERTEX, AGTV_EDGE, AGTV_PATH) that jsonb does not recognize. +CREATE FUNCTION ag_catalog.agtype_to_jsonb(agtype) + RETURNS jsonb + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'SELECT ag_catalog.agtype_to_json($1)::jsonb'; + +CREATE CAST (agtype AS jsonb) + WITH FUNCTION ag_catalog.agtype_to_jsonb(agtype); + +-- jsonb -> agtype (explicit) +CREATE FUNCTION ag_catalog.jsonb_to_agtype(jsonb) + RETURNS agtype + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'SELECT $1::text::agtype'; + +CREATE CAST (jsonb AS agtype) + WITH FUNCTION ag_catalog.jsonb_to_agtype(jsonb);