This is an automated email from the ASF dual-hosted git repository.

jrgemignani pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/age.git


The following commit(s) were added to refs/heads/master by this push:
     new 9960e9c6 Add agtype <-> jsonb bidirectional casts (#350) (#2361)
9960e9c6 is described below

commit 9960e9c6034ed301b90e6e6777c80b7c95b45cf0
Author: Greg Felice <[email protected]>
AuthorDate: Wed May 13 20:02:14 2026 -0400

    Add agtype <-> jsonb bidirectional casts (#350) (#2361)
    
    * 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) <[email protected]>
    
    * 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) <[email protected]>
    
    * Register agtype <-> jsonb casts in upgrade template
    
    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--<INIT>--<CURR>.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).
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
---
 Makefile                               |   3 +-
 age--1.7.0--y.y.y.sql                  |  31 ++++
 regress/expected/agtype_jsonb_cast.out | 261 +++++++++++++++++++++++++++++++++
 regress/sql/agtype_jsonb_cast.sql      | 137 +++++++++++++++++
 sql/agtype_coercions.sql               |  27 ++++
 5 files changed, 458 insertions(+), 1 deletion(-)

diff --git a/Makefile b/Makefile
index 3c5a28c2..7e5fb3d2 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/age--1.7.0--y.y.y.sql b/age--1.7.0--y.y.y.sql
index a3cdea27..52044777 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);
diff --git a/regress/expected/agtype_jsonb_cast.out 
b/regress/expected/agtype_jsonb_cast.out
new file mode 100644
index 00000000..49bf1f88
--- /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 00000000..c3a6a8e2
--- /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 933375fc..7763360f 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 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);
+
 CREATE FUNCTION ag_catalog.agtype_array_to_agtype(agtype[])
     RETURNS agtype
     LANGUAGE c

Reply via email to