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

MuhammadTahaNaveed 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 bdc8b6dd fix(python-driver): quote-escape column aliases in 
buildCypher() (#2373)
bdc8b6dd is described below

commit bdc8b6ddabe210ec02a77d232b742ced9c0d438e
Author: Ueslei Lima <[email protected]>
AuthorDate: Mon Apr 20 06:16:33 2026 -0300

    fix(python-driver): quote-escape column aliases in buildCypher() (#2373)
    
    * fix(python-driver): quote-escape column aliases in buildCypher()
    
    Always double-quote column names in the AS (...) clause generated by
    buildCypher() and _validate_column(). This prevents PostgreSQL parse
    errors when column aliases happen to be reserved words (e.g. 'count',
    'order', 'type', 'group', 'select').
    
    Before: SELECT * from cypher(NULL,NULL) as (count agtype);
    After:  SELECT * from cypher(NULL,NULL) as ("count" agtype);
    
    PostgreSQL always accepts double-quoted identifiers, so quoting
    unconditionally is safe for all names — no reserved word list needed.
    
    Closes #2370
    
    * Address review feedback: document type quoting, improve tests
    
    - _validate_column: add comment explaining why type_name is
      intentionally left unquoted (PG type names are case-insensitive)
    - buildCypher: fix graphName == None to idiomatic 'is None'
    - test_reserved_word_count: replace fragile assertNotIn with regex
    - Add test for reserved word in "name type" pair (e.g. "order agtype")
    - Add comment explaining intentional _validate_column private import
    
    * Fix test_security.py for quoted column names, improve comment
    
    - Update test_security.py assertions to expect double-quoted column
      names (consistent with the always-quote-column-names change)
    - Reword type_name comment to clarify case-folding semantics
    
    Made-with: Cursor
---
 drivers/python/age/age.py       | 23 ++++++++-----
 drivers/python/test_age_py.py   | 73 +++++++++++++++++++++++++++++++++++++++++
 drivers/python/test_security.py | 14 ++++----
 3 files changed, 95 insertions(+), 15 deletions(-)

diff --git a/drivers/python/age/age.py b/drivers/python/age/age.py
index f85b1ab1..ae76bcf5 100644
--- a/drivers/python/age/age.py
+++ b/drivers/python/age/age.py
@@ -216,14 +216,19 @@ def _validate_column(col: str) -> str:
         name, type_name = parts
         validate_identifier(name, "Column name")
         validate_identifier(type_name, "Column type")
-        return f"{name} {type_name}"
+        # Only the column name is double-quoted.  The type name is left
+        # unquoted so PostgreSQL applies its default identifier folding
+        # for type names in column definitions.  Double-quoting would
+        # make the type name case-sensitive and could change type
+        # resolution in surprising ways for user-defined types.
+        return f'"{name}" {type_name}'
     else:
         validate_identifier(col, "Column name")
-        return f"{col} agtype"
+        return f'"{col}" agtype'
 
 
 def buildCypher(graphName:str, cypherStmt:str, columns:list) ->str:
-    if graphName == None:
+    if graphName is None:
         raise _EXCEPTION_GraphNotSet
 
     columnExp=[]
@@ -233,16 +238,18 @@ def buildCypher(graphName:str, cypherStmt:str, 
columns:list) ->str:
             if validated:
                 columnExp.append(validated)
     else:
-        columnExp.append('v agtype')
+        columnExp.append('"v" agtype')
 
     # Design note: String concatenation is used here instead of
     # psycopg.sql.Identifier() because column specifications are
-    # "name type" pairs (e.g. "v agtype") that don't map directly to
+    # "name type" pairs (e.g. '"v" agtype') that don't map directly to
     # sql.Identifier(). Each component has already been validated by
     # _validate_column() → validate_identifier(), which restricts
-    # names to ^[A-Za-z_][A-Za-z0-9_]*$ and max 63 chars. The
-    # graphName and cypherStmt are NOT embedded here — this template
-    # only contains the validated column list and static SQL keywords.
+    # names to ^[A-Za-z_][A-Za-z0-9_]*$ and max 63 chars. Column names
+    # are always double-quoted to avoid conflicts with PostgreSQL
+    # reserved words (e.g. "count", "order", "type"). The graphName
+    # and cypherStmt are NOT embedded here — this template only
+    # contains the validated column list and static SQL keywords.
     stmtArr = []
     stmtArr.append("SELECT * from cypher(NULL,NULL) as (")
     stmtArr.append(','.join(columnExp))
diff --git a/drivers/python/test_age_py.py b/drivers/python/test_age_py.py
index 960d28d4..12dd7bd5 100644
--- a/drivers/python/test_age_py.py
+++ b/drivers/python/test_age_py.py
@@ -13,12 +13,18 @@
 # specific language governing permissions and limitations
 # under the License.
 import json
+import re
 
 from age.models import Vertex
 import unittest
 import unittest.mock
 import decimal
 import age
+# _validate_column is private but tested directly because its quoting
+# behavior is security-relevant and the public surface (buildCypher)
+# makes it difficult to isolate quoting assertions.
+from age.age import buildCypher, _validate_column
+from age.exceptions import InvalidIdentifier
 import argparse
 
 TEST_HOST = "localhost"
@@ -99,6 +105,72 @@ class TestSetUpAge(unittest.TestCase):
         )
 
 
+class TestBuildCypher(unittest.TestCase):
+    """Unit tests for buildCypher() and _validate_column() — no DB required."""
+
+    def test_simple_column(self):
+        result = buildCypher("g", "MATCH (n) RETURN n", ["n"])
+        self.assertIn('"n" agtype', result)
+
+    def test_column_with_type(self):
+        result = buildCypher("g", "MATCH (n) RETURN n", ["n agtype"])
+        self.assertIn('"n" agtype', result)
+
+    def test_reserved_word_count(self):
+        """Issue #2370: 'count' is a PostgreSQL reserved word."""
+        result = buildCypher("g", "MATCH (n) RETURN count(n)", ["count"])
+        self.assertIn('"count" agtype', result)
+        # Verify 'count' never appears unquoted as a column name
+        self.assertIsNone(
+            re.search(r'(?<!")\bcount\s+agtype\b', result),
+            f"'count' must be quoted in: {result}"
+        )
+
+    def test_reserved_word_order(self):
+        """Issue #2370: 'order' is a PostgreSQL reserved word."""
+        result = buildCypher("g", "MATCH (n) RETURN n.order", ["order"])
+        self.assertIn('"order" agtype', result)
+
+    def test_reserved_word_type(self):
+        """Issue #2370: 'type' is a PostgreSQL reserved word."""
+        result = buildCypher("g", "MATCH ()-[r]->() RETURN type(r)", ["type"])
+        self.assertIn('"type" agtype', result)
+
+    def test_reserved_word_select(self):
+        """Issue #2370: 'select' is a PostgreSQL reserved word."""
+        result = buildCypher("g", "MATCH (n) RETURN n", ["select"])
+        self.assertIn('"select" agtype', result)
+
+    def test_reserved_word_group(self):
+        """Issue #2370: 'group' is a PostgreSQL reserved word."""
+        result = buildCypher("g", "MATCH (n) RETURN n", ["group"])
+        self.assertIn('"group" agtype', result)
+
+    def test_multiple_columns(self):
+        result = buildCypher("g", "MATCH (n) RETURN n.name, count(n)", 
["name", "count"])
+        self.assertIn('"name" agtype', result)
+        self.assertIn('"count" agtype', result)
+
+    def test_default_column(self):
+        result = buildCypher("g", "MATCH (n) RETURN n", None)
+        self.assertIn('"v" agtype', result)
+
+    def test_invalid_column_rejected(self):
+        with self.assertRaises(InvalidIdentifier):
+            buildCypher("g", "MATCH (n) RETURN n", ["invalid;col"])
+
+    def test_reserved_word_in_name_type_pair(self):
+        """Quoting applies even when the column is specified as 'name type'."""
+        result = buildCypher("g", "MATCH (n) RETURN n.order", ["order agtype"])
+        self.assertIn('"order" agtype', result)
+
+    def test_validate_column_quoting(self):
+        self.assertEqual(_validate_column("v"), '"v" agtype')
+        self.assertEqual(_validate_column("v agtype"), '"v" agtype')
+        self.assertEqual(_validate_column("count"), '"count" agtype')
+        self.assertEqual(_validate_column("my_col"), '"my_col" agtype')
+
+
 class TestAgeBasic(unittest.TestCase):
     ag = None
     args: argparse.Namespace = argparse.Namespace(
@@ -558,6 +630,7 @@ if __name__ == "__main__":
     suite = unittest.TestSuite()
     loader = unittest.TestLoader()
     suite.addTests(loader.loadTestsFromTestCase(TestSetUpAge))
+    suite.addTests(loader.loadTestsFromTestCase(TestBuildCypher))
     suite.addTest(TestAgeBasic("testExec"))
     suite.addTest(TestAgeBasic("testQuery"))
     suite.addTest(TestAgeBasic("testChangeData"))
diff --git a/drivers/python/test_security.py b/drivers/python/test_security.py
index 55347868..01afee79 100644
--- a/drivers/python/test_security.py
+++ b/drivers/python/test_security.py
@@ -167,10 +167,10 @@ class TestColumnValidation(unittest.TestCase):
     """Test _validate_column prevents injection through column specs."""
 
     def test_plain_column_name(self):
-        self.assertEqual(_validate_column('v'), 'v agtype')
+        self.assertEqual(_validate_column('v'), '"v" agtype')
 
     def test_column_with_type(self):
-        self.assertEqual(_validate_column('n agtype'), 'n agtype')
+        self.assertEqual(_validate_column('n agtype'), '"n" agtype')
 
     def test_empty_column(self):
         self.assertEqual(_validate_column(''), '')
@@ -198,20 +198,20 @@ class TestBuildCypher(unittest.TestCase):
 
     def test_default_column(self):
         result = buildCypher('test_graph', 'MATCH (n) RETURN n', None)
-        self.assertIn('v agtype', result)
+        self.assertIn('"v" agtype', result)
 
     def test_single_column(self):
         result = buildCypher('test_graph', 'MATCH (n) RETURN n', ['n'])
-        self.assertIn('n agtype', result)
+        self.assertIn('"n" agtype', result)
 
     def test_typed_column(self):
         result = buildCypher('test_graph', 'MATCH (n) RETURN n', ['n agtype'])
-        self.assertIn('n agtype', result)
+        self.assertIn('"n" agtype', result)
 
     def test_multiple_columns(self):
         result = buildCypher('test_graph', 'MATCH (n) RETURN n', ['a', 'b'])
-        self.assertIn('a agtype', result)
-        self.assertIn('b agtype', result)
+        self.assertIn('"a" agtype', result)
+        self.assertIn('"b" agtype', result)
 
     def test_rejects_injection_in_column(self):
         with self.assertRaises(InvalidIdentifier):

Reply via email to