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 01ee9414 feat(python-driver): add public API for connection pooling 
and model dict conversion (#2374)
01ee9414 is described below

commit 01ee9414f9b483782cbf50cfe0744860bcf375ae
Author: Ueslei Lima <[email protected]>
AuthorDate: Wed May 13 12:16:14 2026 -0300

    feat(python-driver): add public API for connection pooling and model dict 
conversion (#2374)
    
    * feat(python-driver): add public API for connection pooling and model dict 
conversion
    
    Add two enhancements to the Python driver's public API:
    
    1. Add configure_connection() function that registers AGE agtype adapters
       on an existing psycopg connection without creating a new one. This
       enables use with external connection pools (e.g. psycopg_pool) and
       managed PostgreSQL services where LOAD 'age' may be restricted.
       Also explicitly export AgeLoader and ClientCursor as public symbols
       in age/__init__.py. (#2369)
    
    2. Add to_dict() methods to Vertex, Edge, and Path model classes for
       conversion to plain Python dicts. This enables direct JSON
       serialization with json.dumps() without requiring custom conversion
       logic. (#2371)
    
       - Vertex.to_dict() returns {id, label, properties}
       - Edge.to_dict() returns {id, label, start_id, end_id, properties}
       - Path.to_dict() returns a list of to_dict() results
    
    Closes #2369
    Closes #2371
    
    * Fix configure_connection: correct parameter semantics, add 
load_from_plugins
    
    - Replace confusing `skip_load` (double-negative) with `load` (positive
      boolean, default False). The default now correctly matches the intent:
      no LOAD by default for connection pool / managed PostgreSQL use cases.
    - Add `load_from_plugins` parameter for parity with setUpAge().
    - Fix docstring to accurately describe parameter behavior.
    - Add 6 unit tests for configure_connection covering: default no-load,
      explicit load, load_from_plugins, search_path always set, adapter
      registration, and graph_name check delegation.
    
    Made-with: Cursor
    
    * Address review feedback for configure_connection and to_dict
    
    - Move TypeInfo.fetch() inside cursor block so search_path change is
      visible regardless of transaction isolation mode
    - Raise ValueError when load_from_plugins=True but load=False
    - Add type annotations to configure_connection signature
    - Document shallow-copy semantics in Vertex/Edge to_dict()
    - Path.to_dict() uses str() fallback for non-AGObj entities to
      guarantee JSON-serializable output
    - Add test for AgeNotSet when TypeInfo.fetch returns None
    - Add test for load_from_plugins=True without load=True
    - Replace fragile string assertions with assert_called_with/assert_any_call
    
    Made-with: Cursor
    
    * Fix Path.to_dict() to preserve JSON-native types, add tests to suite
    
    - Path.to_dict(): leave dict/list/str/int/float/bool/None unchanged
      instead of converting to str(); handle entities=None safely
    - Add TestModelToDict, TestPublicImports, TestConfigureConnection to
      the __main__ suite so they run via direct script execution
    
    Made-with: Cursor
    
    * fix(python-driver): Python 3.9-safe hints and correct $user in search_path
    
    - Use Optional[str] for configure_connection graph_name (PEP 604 unions are
      invalid on Python 3.9).
    - Import Any/Optional from typing for annotations.
    - Quote $user in SET search_path; align unit test expectations.
    
    Made-with: Cursor
    
    * test(python-driver): add configure_connection + to_dict integration test
    
    Existing tests for the new public API are unit-only:
    - TestConfigureConnection mocks the psycopg connection, so it never
      proves that AgeLoader actually registers against real agtype OIDs.
    - TestModelToDict hand-constructs Vertex/Edge/Path via kwargs, so it
      never serialises objects produced by the ANTLR parser.
    
    Add a single TestAgeBasic.testConfigureConnection that:
    - opens a raw psycopg connection (bypassing age.connect()),
    - calls configure_connection(..., load=True) on it,
    - runs a Cypher CREATE/RETURN through the configured connection,
    - asserts the returned values are real Vertex/Edge instances and that
      their to_dict() output is JSON-serialisable with the expected
      label/start_id/end_id/properties shape,
    - repeats the round-trip for a Path returned by MATCH.
    
    This is the smallest test that proves the configure_connection +
    to_dict pipeline works end-to-end against a live AGE database.
    
    Made-with: Cursor
---
 drivers/python/age/__init__.py |   1 +
 drivers/python/age/age.py      |  67 ++++++++++++
 drivers/python/age/models.py   |  40 +++++++
 drivers/python/test_age_py.py  | 234 ++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 341 insertions(+), 1 deletion(-)

diff --git a/drivers/python/age/__init__.py b/drivers/python/age/__init__.py
index caee6a43..2da5ef10 100644
--- a/drivers/python/age/__init__.py
+++ b/drivers/python/age/__init__.py
@@ -16,6 +16,7 @@
 import psycopg.conninfo as conninfo
 from . import age
 from .age import *
+from .age import AgeLoader, ClientCursor, configure_connection
 from .models import *
 from .builder import ResultHandler, DummyResultHandler, parseAgeValue, 
newResultHandler
 from . import VERSION 
diff --git a/drivers/python/age/age.py b/drivers/python/age/age.py
index ae76bcf5..a580936a 100644
--- a/drivers/python/age/age.py
+++ b/drivers/python/age/age.py
@@ -14,6 +14,8 @@
 # under the License.
 
 import re
+from typing import Any, Optional
+
 import psycopg
 from psycopg.types import TypeInfo
 from psycopg import sql
@@ -170,6 +172,71 @@ def setUpAge(conn:psycopg.connection, graphName:str, 
load_from_plugins:bool=Fals
         if graphName != None:
             checkGraphCreated(conn, graphName)
 
+
+def configure_connection(
+    conn: psycopg.connection,
+    graph_name: Optional[str] = None,
+    load: bool = False,
+    load_from_plugins: bool = False,
+) -> None:
+    """Register AGE agtype adapters on an existing connection.
+
+    This enables use of AGE with externally-managed connections, such as
+    those from psycopg_pool.ConnectionPool.  By default the function does
+    **not** execute ``LOAD 'age'``, making it safe for managed PostgreSQL
+    services (Azure, AWS RDS) where the extension is pre-loaded via
+    ``shared_preload_libraries``.
+
+    Performs:
+    - ``SET search_path`` to include ``ag_catalog``
+    - Fetches agtype OIDs and registers ``AgeLoader``
+    - Optionally loads the AGE extension (``load=True``)
+    - Optionally checks/creates the graph
+
+    Args:
+        conn: An existing psycopg connection.
+        graph_name: Optional graph name to check/create.
+        load: If True, execute ``LOAD 'age'`` (or the plugins path).
+            Default False — suitable for environments where AGE is
+            already loaded.
+        load_from_plugins: If True (and ``load=True``), use
+            ``LOAD '$libdir/plugins/age'`` instead of ``LOAD 'age'``.
+
+    Raises:
+        ValueError: If ``load_from_plugins=True`` but ``load=False``.
+        AgeNotSet: If the agtype type is not found in the database.
+    """
+    if load_from_plugins and not load:
+        raise ValueError(
+            "load_from_plugins=True requires load=True. "
+            "Set load=True to enable extension loading."
+        )
+
+    with conn.cursor() as cursor:
+        if load:
+            if load_from_plugins:
+                cursor.execute("LOAD '$libdir/plugins/age';")
+            else:
+                cursor.execute("LOAD 'age';")
+
+        cursor.execute('SET search_path = ag_catalog, "$user", public;')
+
+        ag_info = TypeInfo.fetch(conn, 'agtype')
+
+    if not ag_info:
+        raise AgeNotSet(
+            "AGE agtype type not found. Ensure the AGE extension is "
+            "installed and loaded in the current database. "
+            "Run CREATE EXTENSION age; first."
+        )
+
+    conn.adapters.register_loader(ag_info.oid, AgeLoader)
+    conn.adapters.register_loader(ag_info.array_oid, AgeLoader)
+
+    if graph_name is not None:
+        checkGraphCreated(conn, graph_name)
+
+
 # Create the graph, if it does not exist
 def checkGraphCreated(conn:psycopg.connection, graphName:str):
     validate_graph_name(graphName)
diff --git a/drivers/python/age/models.py b/drivers/python/age/models.py
index 6d909548..62215c16 100644
--- a/drivers/python/age/models.py
+++ b/drivers/python/age/models.py
@@ -118,6 +118,20 @@ class Path(AGObj):
 
         return buf.getvalue()
 
+    def to_dict(self) -> list:
+        # AGObj elements are recursively converted; JSON-native types
+        # (dict, list, str, int, float, bool, None) pass through unchanged.
+        # Non-serializable objects fall back to str() as a safety net.
+        result = []
+        for e in (self.entities or []):
+            if isinstance(e, AGObj):
+                result.append(e.to_dict())
+            elif isinstance(e, (dict, list, str, int, float, bool, 
type(None))):
+                result.append(e)
+            else:
+                result.append(str(e))
+        return result
+
 
     
 
@@ -146,6 +160,18 @@ class Vertex(AGObj):
     def __repr__(self) -> str:
         return self.toString()
 
+    def to_dict(self) -> dict:
+        """Return a plain dict suitable for JSON serialization.
+
+        Properties are shallow-copied; nested mutable values will share
+        references with the original Vertex.
+        """
+        return {
+            "id": self.id,
+            "label": self.label,
+            "properties": dict(self.properties) if self.properties else {},
+        }
+
     def toString(self) -> str: 
         return nodeToString(self)
 
@@ -186,6 +212,20 @@ class Edge(AGObj):
     def __repr__(self) -> str:
         return self.toString()
 
+    def to_dict(self) -> dict:
+        """Return a plain dict suitable for JSON serialization.
+
+        Properties are shallow-copied; nested mutable values will share
+        references with the original Edge.
+        """
+        return {
+            "id": self.id,
+            "label": self.label,
+            "start_id": self.start_id,
+            "end_id": self.end_id,
+            "properties": dict(self.properties) if self.properties else {},
+        }
+
     def extraStrFormat(node, buf):
         if node.start_id != None:
             buf.write(", start_id:")
diff --git a/drivers/python/test_age_py.py b/drivers/python/test_age_py.py
index 12dd7bd5..bb92f7d5 100644
--- a/drivers/python/test_age_py.py
+++ b/drivers/python/test_age_py.py
@@ -15,7 +15,7 @@
 import json
 import re
 
-from age.models import Vertex
+from age.models import Vertex, Edge, Path
 import unittest
 import unittest.mock
 import decimal
@@ -171,6 +171,160 @@ class TestBuildCypher(unittest.TestCase):
         self.assertEqual(_validate_column("my_col"), '"my_col" agtype')
 
 
+class TestModelToDict(unittest.TestCase):
+    """Unit tests for Vertex/Edge/Path to_dict() — no DB required."""
+
+    def test_vertex_to_dict(self):
+        v = Vertex(id=123, label="Person", properties={"name": "Alice", "age": 
30})
+        d = v.to_dict()
+        self.assertEqual(d["id"], 123)
+        self.assertEqual(d["label"], "Person")
+        self.assertEqual(d["properties"], {"name": "Alice", "age": 30})
+        # Verify it's a plain dict (JSON-serializable)
+        json_str = json.dumps(d)
+        self.assertIn("Alice", json_str)
+
+    def test_vertex_to_dict_empty_properties(self):
+        v = Vertex(id=1, label="Empty", properties=None)
+        d = v.to_dict()
+        self.assertEqual(d["properties"], {})
+
+    def test_edge_to_dict(self):
+        e = Edge(id=456, label="KNOWS", properties={"since": 2020})
+        e.start_id = 123
+        e.end_id = 789
+        d = e.to_dict()
+        self.assertEqual(d["id"], 456)
+        self.assertEqual(d["label"], "KNOWS")
+        self.assertEqual(d["start_id"], 123)
+        self.assertEqual(d["end_id"], 789)
+        self.assertEqual(d["properties"], {"since": 2020})
+        json_str = json.dumps(d)
+        self.assertIn("KNOWS", json_str)
+
+    def test_path_to_dict(self):
+        v1 = Vertex(id=1, label="A", properties={"name": "start"})
+        e = Edge(id=10, label="r", properties={"w": 1})
+        e.start_id = 1
+        e.end_id = 2
+        v2 = Vertex(id=2, label="B", properties={"name": "end"})
+        p = Path([v1, e, v2])
+        d = p.to_dict()
+        self.assertEqual(len(d), 3)
+        self.assertEqual(d[0]["label"], "A")
+        self.assertEqual(d[1]["label"], "r")
+        self.assertEqual(d[1]["start_id"], 1)
+        self.assertEqual(d[2]["label"], "B")
+        # Verify the whole path is JSON-serializable
+        json_str = json.dumps(d)
+        self.assertIn("start", json_str)
+
+    def test_vertex_to_dict_is_plain_dict(self):
+        """to_dict() returns standard dict, not a model object."""
+        v = Vertex(id=1, label="X", properties={"k": "v"})
+        d = v.to_dict()
+        self.assertIsInstance(d, dict)
+        self.assertIsInstance(d["properties"], dict)
+
+
+class TestPublicImports(unittest.TestCase):
+    """Verify that public API symbols are importable without type: ignore."""
+
+    def test_import_configure_connection(self):
+        from age import configure_connection
+        self.assertTrue(callable(configure_connection))
+
+    def test_import_age_loader(self):
+        from age import AgeLoader
+        self.assertIsNotNone(AgeLoader)
+
+    def test_import_client_cursor(self):
+        from age import ClientCursor
+        self.assertIsNotNone(ClientCursor)
+
+
+class TestConfigureConnection(unittest.TestCase):
+    """Unit tests for configure_connection() — no DB required."""
+
+    def _make_mock_conn(self):
+        mock_conn = unittest.mock.MagicMock()
+        mock_cursor = unittest.mock.MagicMock()
+        mock_conn.cursor.return_value.__enter__ = 
unittest.mock.Mock(return_value=mock_cursor)
+        mock_conn.cursor.return_value.__exit__ = 
unittest.mock.Mock(return_value=False)
+        mock_conn.adapters = unittest.mock.MagicMock()
+        mock_type_info = unittest.mock.MagicMock()
+        mock_type_info.oid = 1
+        mock_type_info.array_oid = 2
+        return mock_conn, mock_cursor, mock_type_info
+
+    def test_default_does_not_load(self):
+        """By default, configure_connection should NOT execute LOAD."""
+        mock_conn, mock_cursor, mock_type_info = self._make_mock_conn()
+        with unittest.mock.patch("age.age.TypeInfo.fetch", 
return_value=mock_type_info), \
+             unittest.mock.patch("age.age.checkGraphCreated"):
+            age.age.configure_connection(mock_conn)
+        mock_cursor.execute.assert_called_once_with(
+            'SET search_path = ag_catalog, "$user", public;'
+        )
+
+    def test_load_true_executes_load(self):
+        """When load=True, LOAD 'age' must be executed."""
+        mock_conn, mock_cursor, mock_type_info = self._make_mock_conn()
+        with unittest.mock.patch("age.age.TypeInfo.fetch", 
return_value=mock_type_info), \
+             unittest.mock.patch("age.age.checkGraphCreated"):
+            age.age.configure_connection(mock_conn, load=True)
+        mock_cursor.execute.assert_any_call("LOAD 'age';")
+
+    def test_load_from_plugins(self):
+        """When load=True and load_from_plugins=True, use plugins path."""
+        mock_conn, mock_cursor, mock_type_info = self._make_mock_conn()
+        with unittest.mock.patch("age.age.TypeInfo.fetch", 
return_value=mock_type_info), \
+             unittest.mock.patch("age.age.checkGraphCreated"):
+            age.age.configure_connection(mock_conn, load=True, 
load_from_plugins=True)
+        mock_cursor.execute.assert_any_call("LOAD '$libdir/plugins/age';")
+
+    def test_load_from_plugins_without_load_raises(self):
+        """load_from_plugins=True without load=True must raise ValueError."""
+        mock_conn, _, _ = self._make_mock_conn()
+        with self.assertRaises(ValueError):
+            age.age.configure_connection(mock_conn, load_from_plugins=True)
+
+    def test_always_sets_search_path(self):
+        """search_path must always be set regardless of load parameter."""
+        mock_conn, mock_cursor, mock_type_info = self._make_mock_conn()
+        with unittest.mock.patch("age.age.TypeInfo.fetch", 
return_value=mock_type_info), \
+             unittest.mock.patch("age.age.checkGraphCreated"):
+            age.age.configure_connection(mock_conn)
+        mock_cursor.execute.assert_any_call(
+            'SET search_path = ag_catalog, "$user", public;'
+        )
+
+    def test_registers_agtype_adapters(self):
+        """AgeLoader must be registered for agtype OIDs."""
+        mock_conn, mock_cursor, mock_type_info = self._make_mock_conn()
+        with unittest.mock.patch("age.age.TypeInfo.fetch", 
return_value=mock_type_info), \
+             unittest.mock.patch("age.age.checkGraphCreated"):
+            age.age.configure_connection(mock_conn)
+        mock_conn.adapters.register_loader.assert_any_call(1, 
age.age.AgeLoader)
+        mock_conn.adapters.register_loader.assert_any_call(2, 
age.age.AgeLoader)
+
+    def test_graph_name_triggers_check(self):
+        """When graph_name is provided, checkGraphCreated must be called."""
+        mock_conn, mock_cursor, mock_type_info = self._make_mock_conn()
+        with unittest.mock.patch("age.age.TypeInfo.fetch", 
return_value=mock_type_info), \
+             unittest.mock.patch("age.age.checkGraphCreated") as mock_check:
+            age.age.configure_connection(mock_conn, graph_name="my_graph")
+        mock_check.assert_called_once_with(mock_conn, "my_graph")
+
+    def test_age_not_set_when_type_info_is_none(self):
+        """AgeNotSet must be raised when TypeInfo.fetch returns None."""
+        from age.exceptions import AgeNotSet
+        mock_conn, _, _ = self._make_mock_conn()
+        with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=None):
+            with self.assertRaises(AgeNotSet):
+                age.age.configure_connection(mock_conn)
+
+
 class TestAgeBasic(unittest.TestCase):
     ag = None
     args: argparse.Namespace = argparse.Namespace(
@@ -591,6 +745,78 @@ class TestAgeBasic(unittest.TestCase):
             self.assertFalse(as_str.endswith(", }}::VERTEX"))
         print("Vertex.toString() 'properties' field is formatted properly.")
 
+    def testConfigureConnection(self):
+        """Integration: configure_connection() on an externally-opened
+        connection must register agtype adapters so cypher queries return
+        real Vertex/Edge/Path objects, and to_dict() must round-trip those
+        parser-produced objects through json.dumps()."""
+        print("\n-------------------------------------------------------")
+        print("Test 8: configure_connection + to_dict end-to-end.....")
+        print("-------------------------------------------------------\n")
+
+        import psycopg
+
+        from age import configure_connection
+
+        dsn = "host={host} port={port} dbname={database} user={user} 
password={password}".format(
+            **vars(self.args)
+        )
+        # Deliberately bypass age.connect(): the whole point of
+        # configure_connection() is to enable AGE on a caller-managed
+        # connection (e.g. one obtained from psycopg_pool.ConnectionPool).
+        raw = psycopg.connect(dsn)
+        try:
+            configure_connection(raw, graph_name=self.args.graphName, 
load=True)
+
+            graph = self.args.graphName
+            with raw.cursor() as cur:
+                cur.execute(
+                    f"SELECT * FROM cypher('{graph}', $$ "
+                    "CREATE (a:Person {name: 'Alice'})-[r:KNOWS {since: 
2020}]->(b:Person {name: 'Bob'}) "
+                    "RETURN a, r, b "
+                    "$$) AS (a agtype, r agtype, b agtype);"
+                )
+                row = cur.fetchone()
+                raw.commit()
+
+            v_a, e, v_b = row
+            self.assertIsInstance(v_a, Vertex)
+            self.assertIsInstance(e, Edge)
+            self.assertIsInstance(v_b, Vertex)
+            self.assertEqual(v_a["name"], "Alice")
+            self.assertEqual(v_b["name"], "Bob")
+            self.assertEqual(e["since"], 2020)
+
+            payload = {
+                "start": v_a.to_dict(),
+                "edge": e.to_dict(),
+                "end": v_b.to_dict(),
+            }
+            serialised = json.loads(json.dumps(payload))
+            self.assertEqual(serialised["start"]["label"], "Person")
+            self.assertEqual(serialised["edge"]["label"], "KNOWS")
+            self.assertEqual(serialised["edge"]["start_id"], v_a.id)
+            self.assertEqual(serialised["edge"]["end_id"], v_b.id)
+            self.assertEqual(serialised["start"]["properties"]["name"], 
"Alice")
+
+            with raw.cursor() as cur:
+                cur.execute(
+                    f"SELECT * FROM cypher('{graph}', $$ "
+                    "MATCH p=(:Person)-[:KNOWS]->(:Person) RETURN p "
+                    "$$) AS (p agtype);"
+                )
+                path = cur.fetchone()[0]
+
+            self.assertIsInstance(path, Path)
+            path_dict = json.loads(json.dumps(path.to_dict()))
+            self.assertEqual(len(path_dict), 3)
+            self.assertEqual(path_dict[0]["label"], "Person")
+            self.assertEqual(path_dict[1]["label"], "KNOWS")
+            self.assertEqual(path_dict[2]["label"], "Person")
+            print("\nTest 8 Successful....")
+        finally:
+            raw.close()
+
 
 if __name__ == "__main__":
     parser = argparse.ArgumentParser()
@@ -628,9 +854,14 @@ if __name__ == "__main__":
 
     args = parser.parse_args()
     suite = unittest.TestSuite()
+    # Unit tests (no DB required)
     loader = unittest.TestLoader()
     suite.addTests(loader.loadTestsFromTestCase(TestSetUpAge))
     suite.addTests(loader.loadTestsFromTestCase(TestBuildCypher))
+    suite.addTests(loader.loadTestsFromTestCase(TestModelToDict))
+    suite.addTests(loader.loadTestsFromTestCase(TestPublicImports))
+    suite.addTests(loader.loadTestsFromTestCase(TestConfigureConnection))
+    # Integration tests (require DB)
     suite.addTest(TestAgeBasic("testExec"))
     suite.addTest(TestAgeBasic("testQuery"))
     suite.addTest(TestAgeBasic("testChangeData"))
@@ -638,5 +869,6 @@ if __name__ == "__main__":
     suite.addTest(TestAgeBasic("testMultipleEdges"))
     suite.addTest(TestAgeBasic("testCollect"))
     suite.addTest(TestAgeBasic("testSerialization"))
+    suite.addTest(TestAgeBasic("testConfigureConnection"))
     TestAgeBasic.args = args
     unittest.TextTestRunner().run(suite)

Reply via email to