From 681a63e0251b2a2d30386b4cc3f4f68022ffbb47 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 12 Jun 2026 22:48:23 +0800 Subject: [PATCH 1/9] feat(postgres): add PGQ support with property graph query scenarios - Fix version regex to handle beta versions (e.g. 19beta1) - Add PostgresPropertyGraphQueryMixin for PGQ support - Add format tests for PGQ expressions - Add scenario tests for real PGQ queries against PG19 - Update dialect and mixins init --- scripts/verify_pgq.py | 255 +++++++++++ scripts/verify_property_graph_query.py | 255 +++++++++++ .../impl/postgres/backend/async_backend.py | 2 +- .../backend/impl/postgres/backend/sync.py | 4 +- .../backend/impl/postgres/dialect.py | 12 +- .../backend/impl/postgres/mixins/__init__.py | 2 + .../postgres/mixins/property_graph_query.py | 23 + .../test_property_graph_query_format.py | 275 ++++++++++++ .../test_property_graph_query_scenarios.py | 413 ++++++++++++++++++ 9 files changed, 1232 insertions(+), 9 deletions(-) create mode 100644 scripts/verify_pgq.py create mode 100644 scripts/verify_property_graph_query.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py diff --git a/scripts/verify_pgq.py b/scripts/verify_pgq.py new file mode 100644 index 00000000..87151bec --- /dev/null +++ b/scripts/verify_pgq.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" +PostgreSQL 19 SQL/PGQ 功能验证脚本。 + +验证 Postgres 19 的 Property Graph Query 能力, +包括 DDL、GRAPH_TABLE 查询、模式匹配语法等。 + +用法: + python verify_pgq.py [host] [port] [user] [password] [dbname] +""" + +import sys +import psycopg + + +def main(): + host = sys.argv[1] if len(sys.argv) > 1 else "localhost" + port = int(sys.argv[2]) if len(sys.argv) > 2 else 16690 + user = sys.argv[3] if len(sys.argv) > 3 else "root" + password = sys.argv[4] if len(sys.argv) > 4 else "password" + dbname = sys.argv[5] if len(sys.argv) > 5 else "test_db" + + conn = psycopg.connect(host=host, port=port, user=user, + password=password, dbname=dbname) + cur = conn.cursor() + passed = 0 + failed = 0 + + def test(name, fn): + nonlocal passed, failed + try: + fn(cur) + print(f" [PASS] {name}") + passed += 1 + except Exception as e: + print(f" [FAIL] {name}: {e}") + conn.rollback() + failed += 1 + + # ========== 0. Setup ========== + cur.execute("DROP TABLE IF EXISTS knows, people CASCADE") + conn.commit() + cur.execute("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT)") + cur.execute("""CREATE TABLE knows ( + id INTEGER PRIMARY KEY, + person_a INTEGER REFERENCES people(id), + person_b INTEGER REFERENCES people(id), + since TEXT + )""") + conn.commit() + + # ========== 1. Version ========== + def test_version(cur): + cur.execute("SELECT version()") + ver = cur.fetchone()[0] + cur.execute("SHOW server_version_num") + ver_num = cur.fetchone()[0] + assert int(ver_num) >= 190000, f"Need PG 19+, got {ver_num}" + assert "19" in ver, f"Unexpected version: {ver}" + + # ========== 2. CREATE PROPERTY GRAPH ========== + def test_create_pg(cur): + cur.execute(""" + CREATE PROPERTY GRAPH test_graph + VERTEX TABLES (people LABEL person PROPERTIES (id, name)) + EDGE TABLES ( + knows SOURCE KEY (person_a) REFERENCES people (id) + DESTINATION KEY (person_b) REFERENCES people (id) + LABEL knows + ) + """) + conn.commit() + + # ========== 3. Insert test data ========== + def test_insert(cur): + cur.execute(""" + INSERT INTO people VALUES (1, 'Alice'), (2, 'Bob'), (3, 'Charlie') + ON CONFLICT DO NOTHING + """) + cur.execute(""" + INSERT INTO knows VALUES (1, 1, 2, '2024-01-01'), (2, 2, 3, '2024-02-01') + ON CONFLICT DO NOTHING + """) + conn.commit() + + # ========== 4. Single-hop GRAPH_TABLE ========== + def test_single_hop(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice') + -[k IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 5. Two-hop ========== + def test_two_hop(cur): + cur.execute(""" + SELECT b_name, c_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice') + -[k1 IS knows]->(b IS person) + -[k2 IS knows]->(c IS person) + COLUMNS (b.name AS b_name, c.name AS c_name) + ) + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0] == ("Bob", "Charlie"), f"Got {rows}" + + # ========== 6. Abbreviated edge ========== + def test_abbrev_edge(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice')->(b IS person) + COLUMNS (b.name AS b_name) + ) + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 7. Anonymous edge ========== + def test_anon_edge(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice')-[]->(b IS person) + COLUMNS (b.name AS b_name) + ) + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 8. Multi-label OR ========== + def test_multi_label(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person|person WHERE a.name = 'Alice') + -[IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 9. Composite with ORDER BY ========== + def test_composite(cur): + cur.execute(""" + SELECT g.b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice') + -[k IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) AS g ORDER BY g.b_name + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 10. WHERE on graph result ========== + def test_where_result(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice') + -[k IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) AS g WHERE g.b_name = 'Bob' + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 11. Parameter binding ========== + def test_params(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = %s) + -[k IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) + """, ("Alice",)) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 12. Catalog check ========== + def test_catalog(cur): + cur.execute("SELECT oid, relname FROM pg_class WHERE relkind = 'g'") + rows = cur.fetchall() + names = [r[1] for r in rows] + assert "test_graph" in names, f"Property graph not in pg_class: {names}" + + # ========== 13. EXPLAIN shows joins ========== + def test_explain(cur): + cur.execute(""" + EXPLAIN (VERBOSE) SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = %s) + -[k IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) + """, ("Alice",)) + plan = cur.fetchall() + plan_text = "\n".join(r[0] for r in plan) + # Should contain relational join operations + assert any(kw in plan_text for kw in ["Join", "Nested Loop", "Hash", "Merge"]), \ + f"EXPLAIN shows no joins:\n{plan_text}" + + # ========== 14. Variable-length path ========== + def test_var_len_rejected(cur): + try: + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice') + -[IS knows]->{1,2} (b IS person) + COLUMNS (b.name AS b_name) + ) + """) + assert False, "Variable-length path should fail" + except Exception as e: + err = str(e) + assert "quantifier" in err or "not supported" in err, \ + f"Unexpected error for variable-length path: {err}" + conn.rollback() + + # ========== 15. DROP ========== + def test_drop(cur): + cur.execute("DROP PROPERTY GRAPH test_graph") + conn.commit() + + # ========== Run ========== + print(f"\nPostgreSQL 19 PGQ Verification ({host}:{port}/{dbname})") + print("=" * 50) + for name, fn in [ + ("1. Version check (PG 19+)", test_version), + ("2. CREATE PROPERTY GRAPH", test_create_pg), + ("3. Insert test data", test_insert), + ("4. Single-hop GRAPH_TABLE", test_single_hop), + ("5. Two-hop traversal", test_two_hop), + ("6. Abbreviated edge ->", test_abbrev_edge), + ("7. Anonymous edge []", test_anon_edge), + ("8. Multi-label OR |", test_multi_label), + ("9. Composite with ORDER BY", test_composite), + ("10. WHERE on graph result", test_where_result), + ("11. Parameter binding (%s)", test_params), + ("12. pg_class relkind='g'", test_catalog), + ("13. EXPLAIN shows joins", test_explain), + ("14. Variable-length rejected", test_var_len_rejected), + ("15. DROP PROPERTY GRAPH", test_drop), + ]: + test(name, fn) + + print("=" * 50) + print(f"Result: {passed} passed, {failed} failed out of {passed + failed}") + cur.close() + conn.close() + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify_property_graph_query.py b/scripts/verify_property_graph_query.py new file mode 100644 index 00000000..87151bec --- /dev/null +++ b/scripts/verify_property_graph_query.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" +PostgreSQL 19 SQL/PGQ 功能验证脚本。 + +验证 Postgres 19 的 Property Graph Query 能力, +包括 DDL、GRAPH_TABLE 查询、模式匹配语法等。 + +用法: + python verify_pgq.py [host] [port] [user] [password] [dbname] +""" + +import sys +import psycopg + + +def main(): + host = sys.argv[1] if len(sys.argv) > 1 else "localhost" + port = int(sys.argv[2]) if len(sys.argv) > 2 else 16690 + user = sys.argv[3] if len(sys.argv) > 3 else "root" + password = sys.argv[4] if len(sys.argv) > 4 else "password" + dbname = sys.argv[5] if len(sys.argv) > 5 else "test_db" + + conn = psycopg.connect(host=host, port=port, user=user, + password=password, dbname=dbname) + cur = conn.cursor() + passed = 0 + failed = 0 + + def test(name, fn): + nonlocal passed, failed + try: + fn(cur) + print(f" [PASS] {name}") + passed += 1 + except Exception as e: + print(f" [FAIL] {name}: {e}") + conn.rollback() + failed += 1 + + # ========== 0. Setup ========== + cur.execute("DROP TABLE IF EXISTS knows, people CASCADE") + conn.commit() + cur.execute("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT)") + cur.execute("""CREATE TABLE knows ( + id INTEGER PRIMARY KEY, + person_a INTEGER REFERENCES people(id), + person_b INTEGER REFERENCES people(id), + since TEXT + )""") + conn.commit() + + # ========== 1. Version ========== + def test_version(cur): + cur.execute("SELECT version()") + ver = cur.fetchone()[0] + cur.execute("SHOW server_version_num") + ver_num = cur.fetchone()[0] + assert int(ver_num) >= 190000, f"Need PG 19+, got {ver_num}" + assert "19" in ver, f"Unexpected version: {ver}" + + # ========== 2. CREATE PROPERTY GRAPH ========== + def test_create_pg(cur): + cur.execute(""" + CREATE PROPERTY GRAPH test_graph + VERTEX TABLES (people LABEL person PROPERTIES (id, name)) + EDGE TABLES ( + knows SOURCE KEY (person_a) REFERENCES people (id) + DESTINATION KEY (person_b) REFERENCES people (id) + LABEL knows + ) + """) + conn.commit() + + # ========== 3. Insert test data ========== + def test_insert(cur): + cur.execute(""" + INSERT INTO people VALUES (1, 'Alice'), (2, 'Bob'), (3, 'Charlie') + ON CONFLICT DO NOTHING + """) + cur.execute(""" + INSERT INTO knows VALUES (1, 1, 2, '2024-01-01'), (2, 2, 3, '2024-02-01') + ON CONFLICT DO NOTHING + """) + conn.commit() + + # ========== 4. Single-hop GRAPH_TABLE ========== + def test_single_hop(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice') + -[k IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 5. Two-hop ========== + def test_two_hop(cur): + cur.execute(""" + SELECT b_name, c_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice') + -[k1 IS knows]->(b IS person) + -[k2 IS knows]->(c IS person) + COLUMNS (b.name AS b_name, c.name AS c_name) + ) + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0] == ("Bob", "Charlie"), f"Got {rows}" + + # ========== 6. Abbreviated edge ========== + def test_abbrev_edge(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice')->(b IS person) + COLUMNS (b.name AS b_name) + ) + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 7. Anonymous edge ========== + def test_anon_edge(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice')-[]->(b IS person) + COLUMNS (b.name AS b_name) + ) + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 8. Multi-label OR ========== + def test_multi_label(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person|person WHERE a.name = 'Alice') + -[IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 9. Composite with ORDER BY ========== + def test_composite(cur): + cur.execute(""" + SELECT g.b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice') + -[k IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) AS g ORDER BY g.b_name + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 10. WHERE on graph result ========== + def test_where_result(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice') + -[k IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) AS g WHERE g.b_name = 'Bob' + """) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 11. Parameter binding ========== + def test_params(cur): + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = %s) + -[k IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) + """, ("Alice",)) + rows = cur.fetchall() + assert len(rows) == 1 and rows[0][0] == "Bob", f"Got {rows}" + + # ========== 12. Catalog check ========== + def test_catalog(cur): + cur.execute("SELECT oid, relname FROM pg_class WHERE relkind = 'g'") + rows = cur.fetchall() + names = [r[1] for r in rows] + assert "test_graph" in names, f"Property graph not in pg_class: {names}" + + # ========== 13. EXPLAIN shows joins ========== + def test_explain(cur): + cur.execute(""" + EXPLAIN (VERBOSE) SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = %s) + -[k IS knows]->(b IS person) + COLUMNS (b.name AS b_name) + ) + """, ("Alice",)) + plan = cur.fetchall() + plan_text = "\n".join(r[0] for r in plan) + # Should contain relational join operations + assert any(kw in plan_text for kw in ["Join", "Nested Loop", "Hash", "Merge"]), \ + f"EXPLAIN shows no joins:\n{plan_text}" + + # ========== 14. Variable-length path ========== + def test_var_len_rejected(cur): + try: + cur.execute(""" + SELECT b_name FROM GRAPH_TABLE (test_graph + MATCH (a IS person WHERE a.name = 'Alice') + -[IS knows]->{1,2} (b IS person) + COLUMNS (b.name AS b_name) + ) + """) + assert False, "Variable-length path should fail" + except Exception as e: + err = str(e) + assert "quantifier" in err or "not supported" in err, \ + f"Unexpected error for variable-length path: {err}" + conn.rollback() + + # ========== 15. DROP ========== + def test_drop(cur): + cur.execute("DROP PROPERTY GRAPH test_graph") + conn.commit() + + # ========== Run ========== + print(f"\nPostgreSQL 19 PGQ Verification ({host}:{port}/{dbname})") + print("=" * 50) + for name, fn in [ + ("1. Version check (PG 19+)", test_version), + ("2. CREATE PROPERTY GRAPH", test_create_pg), + ("3. Insert test data", test_insert), + ("4. Single-hop GRAPH_TABLE", test_single_hop), + ("5. Two-hop traversal", test_two_hop), + ("6. Abbreviated edge ->", test_abbrev_edge), + ("7. Anonymous edge []", test_anon_edge), + ("8. Multi-label OR |", test_multi_label), + ("9. Composite with ORDER BY", test_composite), + ("10. WHERE on graph result", test_where_result), + ("11. Parameter binding (%s)", test_params), + ("12. pg_class relkind='g'", test_catalog), + ("13. EXPLAIN shows joins", test_explain), + ("14. Variable-length rejected", test_var_len_rejected), + ("15. DROP PROPERTY GRAPH", test_drop), + ]: + test(name, fn) + + print("=" * 50) + print(f"Result: {passed} passed, {failed} failed out of {passed + failed}") + cur.close() + conn.close() + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py index f1c6bf1f..1e0b4a30 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py @@ -355,7 +355,7 @@ async def get_server_version(self) -> Optional[Tuple[int, int, int]]: # Extract version from string like "PostgreSQL 13.2..." import re - match = re.search(r"PostgreSQL (\d+)\.(\d+)(?:\.(\d+))?", version_str) + match = re.search(r"PostgreSQL (\d+)(?:\.(\d+))?(?:[^\d]*(\d+))?", version_str) if match: major = int(match.group(1)) minor = int(match.group(2)) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py index 0d5e91dd..b23d26fa 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py @@ -356,10 +356,10 @@ def get_server_version(self) -> Optional[Tuple[int, int, int]]: # Extract version from string like "PostgreSQL 13.2..." import re - match = re.search(r"PostgreSQL (\d+)\.(\d+)(?:\.(\d+))?", version_str) + match = re.search(r"PostgreSQL (\d+)(?:\.(\d+))?(?:[^\d]*(\d+))?", version_str) if match: major = int(match.group(1)) - minor = int(match.group(2)) + minor = int(match.group(2)) if match.group(2) else 0 patch = int(match.group(3)) if match.group(3) else 0 return (major, minor, patch) else: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 519bc190..a21da82c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -26,6 +26,7 @@ ArrayMixin, ExplainMixin, GraphMixin, + GraphTableMixin, LockingMixin, MergeMixin, OrderedSetAggregationMixin, @@ -71,6 +72,7 @@ ArraySupport, ExplainSupport, GraphSupport, + GraphTableSupport, MergeSupport, OrderedSetAggregationSupport, QualifyClauseSupport, @@ -101,6 +103,7 @@ PostgresHstoreMixin, # Native feature mixins PostgresPartitionMixin, + PostgresPropertyGraphQueryMixin, PostgresIndexMixin, PostgresVacuumMixin, PostgresQueryOptimizationMixin, @@ -260,7 +263,9 @@ class PostgresDialect( AdvancedGroupingMixin, ArrayMixin, ExplainMixin, + PostgresPropertyGraphQueryMixin, GraphMixin, + GraphTableMixin, PostgresLockingMixin, LockingMixin, MergeMixin, @@ -366,6 +371,7 @@ class PostgresDialect( ArraySupport, ExplainSupport, GraphSupport, + GraphTableSupport, MergeSupport, OrderedSetAggregationSupport, QualifyClauseSupport, @@ -710,12 +716,6 @@ def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: return "EXPLAIN (" + ", ".join(opts) + ") " + statement_sql, statement_params return f"EXPLAIN {statement_sql}", statement_params - def supports_graph_match(self) -> bool: - """Whether graph query MATCH clause is supported.""" - # PostgreSQL doesn't have native MATCH clause like some other systems - # Though graph querying can be done with extensions like Apache AGE - return False - def supports_for_update(self) -> bool: """Whether FOR UPDATE clause is supported in SELECT statements. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index 971b5c58..6492c81e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -76,6 +76,7 @@ from .sql_syntax import PostgresSQLSyntaxMixin from .logical_replication import PostgresLogicalReplicationMixin from .parallel_query import PostgresParallelQueryMixin +from .property_graph_query import PostgresPropertyGraphQueryMixin # Introspection capability mixin from .introspection import PostgresIntrospectionCapabilityMixin @@ -142,6 +143,7 @@ "PostgresSQLSyntaxMixin", "PostgresLogicalReplicationMixin", "PostgresParallelQueryMixin", + "PostgresPropertyGraphQueryMixin", # Introspection capability mixin "PostgresIntrospectionCapabilityMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py new file mode 100644 index 00000000..8af4c289 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py @@ -0,0 +1,23 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py +"""PostgreSQL Property Graph Query (SQL/PGQ) support mixin. + +PostgreSQL 19+ natively supports SQL/PGQ: +- GRAPH_TABLE (MATCH ... COLUMNS ...) +- CREATE / DROP / ALTER PROPERTY GRAPH +- Vertex/edge pattern matching with WHERE +""" + + +class PostgresPropertyGraphQueryMixin: + """PostgreSQL Property Graph Query support implementation. + + SQL/PGQ is a native feature since PostgreSQL 19. + """ + + def supports_graph_match(self) -> bool: + """MATCH clause is supported since PostgreSQL 19.""" + return self.version >= (19, 0, 0) + + def supports_graph_table(self) -> bool: + """GRAPH_TABLE expression is supported since PostgreSQL 19.""" + return self.version >= (19, 0, 0) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py new file mode 100644 index 00000000..73555754 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py @@ -0,0 +1,275 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_pgq_format.py +""" +Tests for PostgreSQL PGQ dialect version gating and SQL formatting. + +Uses requires_protocol markers for protocol-level capability documentation. +""" +import pytest +from rhosocial.activerecord.backend.dialect.protocols import GraphSupport, GraphTableSupport +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.expression import ( + GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, + GraphColumn, ColumnsClause, GraphTableExpression, + TablePropertiesClause, VertexTable, EdgeTable, + CreatePropertyGraphExpression, DropPropertyGraphExpression, + AlterPropertyGraphExpression, +) +from rhosocial.activerecord.backend.expression.query_parts import WhereClause +from rhosocial.activerecord.backend.expression.core import Column, Literal + + +class TestPGQProtocolVersionGating: + """Version gating tests for PGQ support in PostgreSQL.""" + + def test_supports_graph_match_pg19_true(self): + d = PostgresDialect((19, 0, 0)) + assert d.supports_graph_match() is True + + def test_supports_graph_match_pg15_false(self): + d = PostgresDialect((15, 0, 0)) + assert d.supports_graph_match() is False + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_supports_graph_table_pg19_true(self): + d = PostgresDialect((19, 0, 0)) + assert d.supports_graph_table() is True + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_supports_graph_table_pg15_false(self): + d = PostgresDialect((15, 0, 0)) + assert d.supports_graph_table() is False + + @pytest.mark.requires_protocol((GraphSupport, "supports_graph_match")) + def test_graph_support_protocol_implemented_pg19(self): + d = PostgresDialect((19, 0, 0)) + assert isinstance(d, GraphSupport) + assert isinstance(d, GraphTableSupport) + + def test_graph_support_protocol_implemented_pg15(self): + d = PostgresDialect((15, 0, 0)) + assert isinstance(d, GraphSupport) + assert isinstance(d, GraphTableSupport) + + +@pytest.fixture +def pg19_dialect(): + return PostgresDialect((19, 0, 0)) + + +class TestPGQGraphVertexFormat: + """SQL formatting tests for GraphVertex with PG19 dialect.""" + + def test_basic(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, "p", "person") + sql, params = v.to_sql() + assert sql == '(p IS "person")' + assert params == () + + def test_with_where(self, pg19_dialect: PostgresDialect): + where = WhereClause(pg19_dialect, + condition=Column(pg19_dialect, "age") > Literal(pg19_dialect, 18)) + v = GraphVertex(pg19_dialect, "p", "person", where=where) + sql, params = v.to_sql() + assert "(p IS" in sql + assert "WHERE" in sql + assert params == (18,) + + +class TestPGQGraphEdgeFormat: + """SQL formatting tests for GraphEdge with PG19 dialect.""" + + def test_right(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + sql, params = e.to_sql() + assert sql == '-[e IS "knows"]->' + + def test_left(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.LEFT) + assert e.to_sql()[0] == '<-[e IS "knows"]-' + + def test_any(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.ANY) + assert e.to_sql()[0] == '<-[e IS "knows"]->' + + def test_anonymous(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, direction=GraphEdgeDirection.RIGHT) + assert e.to_sql()[0] == '-[]->' + + def test_variable_only(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, variable="e", direction=GraphEdgeDirection.RIGHT) + assert e.to_sql()[0] == '-[e]->' + + +class TestPGQMatchClauseFormat: + """SQL formatting tests for MatchClause with PG19 dialect.""" + + def test_single_vertex(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, "p", "person") + m = MatchClause(pg19_dialect, v) + sql, params = m.to_sql() + assert "MATCH" in sql + assert "(p IS" in sql + + def test_path_pattern(self, pg19_dialect: PostgresDialect): + a = GraphVertex(pg19_dialect, "a", "person") + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + b = GraphVertex(pg19_dialect, "b", "person") + m = MatchClause(pg19_dialect, a, e, b) + sql, params = m.to_sql() + assert "MATCH" in sql + assert "(a IS" in sql + assert "[e IS" in sql + assert "(b IS" in sql + + +class TestPGQGraphTableFormat: + """SQL formatting tests for GraphTableExpression with PG19 dialect.""" + + def test_basic(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, "p", "person") + cols = ColumnsClause(pg19_dialect, GraphColumn("p", "name")) + m = MatchClause(pg19_dialect, v) + gt = GraphTableExpression(pg19_dialect, "g", m, cols) + sql, params = gt.to_sql() + assert 'GRAPH_TABLE ("g" MATCH' in sql + assert "COLUMNS" in sql + + def test_with_where(self, pg19_dialect: PostgresDialect): + where = WhereClause(pg19_dialect, + condition=Column(pg19_dialect, "age") > Literal(pg19_dialect, 18)) + v = GraphVertex(pg19_dialect, "p", "person", where=where) + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + b = GraphVertex(pg19_dialect, "b", "person") + cols = ColumnsClause(pg19_dialect, GraphColumn("b", "name")) + m = MatchClause(pg19_dialect, v, e, b) + gt = GraphTableExpression(pg19_dialect, "g", m, cols) + sql, params = gt.to_sql() + assert "WHERE" in sql + assert params == (18,) + + +class TestPGQDDLFormat: + """SQL formatting tests for PGQ DDL expressions with PG19 dialect.""" + + def test_create_property_graph(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "people", + labels=["person"], + key_columns=["id"], + properties=TablePropertiesClause(pg19_dialect, columns=["id", "name"])) + et = EdgeTable(pg19_dialect, "knows", ["person_a"], ["person_b"], + references_source=("people", ["id"]), + references_destination=("people", ["id"]), + labels=["knows"], + properties=TablePropertiesClause(pg19_dialect, columns=["since"])) + expr = CreatePropertyGraphExpression(pg19_dialect, "test_graph", [vt], [et]) + sql, params = expr.to_sql() + assert "CREATE PROPERTY GRAPH" in sql + assert '"people"' in sql + assert '"knows"' in sql + assert "SOURCE KEY" in sql + assert "DESTINATION KEY" in sql + + def test_drop_property_graph(self, pg19_dialect: PostgresDialect): + expr = DropPropertyGraphExpression(pg19_dialect, "test_graph", if_exists=True) + sql, params = expr.to_sql() + assert 'DROP PROPERTY GRAPH IF EXISTS "test_graph"' in sql + + def test_drop_cascade(self, pg19_dialect: PostgresDialect): + expr = DropPropertyGraphExpression(pg19_dialect, "test_graph", cascade=True) + sql, params = expr.to_sql() + assert "CASCADE" in sql + + def test_edge_table_no_references(self, pg19_dialect: PostgresDialect): + et = EdgeTable(pg19_dialect, "knows", ["pid"], ["fid"]) + sql, params = et.to_sql() + assert 'SOURCE KEY ("pid")' in sql + assert 'DESTINATION KEY ("fid")' in sql + assert "REFERENCES" not in sql + + def test_edge_table_with_key_columns(self, pg19_dialect: PostgresDialect): + et = EdgeTable(pg19_dialect, "knows", ["pid"], ["fid"], + key_columns=["id"], + references_source=("people", ["id"]), + references_destination=("people", ["id"])) + sql, params = et.to_sql() + assert 'KEY ("id")' in sql + + def test_alter_add_vertex(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "new_table", labels=["NewLabel"]) + expr = AlterPropertyGraphExpression(pg19_dialect, "g", "ADD", "VERTEX TABLES", + vertex_tables=[vt]) + sql, params = expr.to_sql() + assert "ALTER PROPERTY GRAPH" in sql + assert "ADD" in sql + + def test_alter_with_edge_tables(self, pg19_dialect: PostgresDialect): + et = EdgeTable(pg19_dialect, "knows", ["pid"], ["fid"]) + expr = AlterPropertyGraphExpression(pg19_dialect, "g", "DROP", "EDGE TABLES", + edge_tables=[et]) + sql, params = expr.to_sql() + assert "DROP" in sql + assert '"knows"' in sql + + def test_alter_with_both_tables(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "person") + et = EdgeTable(pg19_dialect, "knows", ["pid"], ["fid"]) + expr = AlterPropertyGraphExpression(pg19_dialect, "g", "ADD", "TABLES", + vertex_tables=[vt], edge_tables=[et]) + sql, params = expr.to_sql() + assert '"person"' in sql + assert '"knows"' in sql + + +class TestPGQUnsupportedFormat: + """Tests that PG15 raises errors for PGQ formatting.""" + + @pytest.fixture + def pg15_dialect(self): + return PostgresDialect((15, 0, 0)) + + def test_graph_vertex_unsupported(self, pg15_dialect: PostgresDialect): + v = GraphVertex(pg15_dialect, "p", "person") + with pytest.raises(Exception): + v.to_sql() + + def test_graph_edge_unsupported(self, pg15_dialect: PostgresDialect): + e = GraphEdge(pg15_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + with pytest.raises(Exception): + e.to_sql() + + def test_graph_table_unsupported(self, pg15_dialect: PostgresDialect): + v = GraphVertex(pg15_dialect, "p", "person") + cols = ColumnsClause(pg15_dialect, GraphColumn("p", "name")) + m = MatchClause(pg15_dialect, v) + gt = GraphTableExpression(pg15_dialect, "g", m, cols) + with pytest.raises(Exception): + gt.to_sql() + + def test_vertex_table_unsupported(self, pg15_dialect: PostgresDialect): + vt = VertexTable(pg15_dialect, "person") + with pytest.raises(UnsupportedFeatureError): + vt.to_sql() + + def test_edge_table_unsupported(self, pg15_dialect: PostgresDialect): + et = EdgeTable(pg15_dialect, "knows", ["pid"], ["fid"]) + with pytest.raises(UnsupportedFeatureError): + et.to_sql() + + def test_create_property_graph_unsupported(self, pg15_dialect: PostgresDialect): + vt = VertexTable(pg15_dialect, "person") + expr = CreatePropertyGraphExpression(pg15_dialect, "g", [vt]) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_drop_property_graph_unsupported(self, pg15_dialect: PostgresDialect): + expr = DropPropertyGraphExpression(pg15_dialect, "g") + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_alter_property_graph_unsupported(self, pg15_dialect: PostgresDialect): + vt = VertexTable(pg15_dialect, "person") + expr = AlterPropertyGraphExpression(pg15_dialect, "g", "ADD", "VERTEX TABLES", + vertex_tables=[vt]) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py new file mode 100644 index 00000000..b8dc259d --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py @@ -0,0 +1,413 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py +""" +Real-world PGQ scenario tests for PostgreSQL 19+ using the expression system. + +All SQL is constructed via expression classes. No raw SQL strings are used. +""" +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.dialect.protocols import GraphTableSupport +from rhosocial.activerecord.backend.expression import ( + GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, + GraphColumn, ColumnsClause, GraphTableExpression, + TablePropertiesClause, VertexTable, EdgeTable, + CreatePropertyGraphExpression, DropPropertyGraphExpression, + CreateTableExpression, DropTableExpression, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, + InsertExpression, ValuesSource, Literal, + QueryExpression, WildcardExpression, + FunctionCall, TableExpression, + ExplainExpression, ExplainOptions, +) +from rhosocial.activerecord.backend.expression.query_parts import ( + WhereClause, OrderByClause, GroupByHavingClause, LimitOffsetClause, +) +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.query_parts import JoinExpression, JoinType +from rhosocial.activerecord.backend.expression.literals import Identifier + + +GRAPH_NAME = "social_graph" + + +@pytest.fixture +def social_data(postgres_backend): + backend = postgres_backend + dialect = backend.dialect + if not dialect.supports_graph_table(): + pytest.skip("PGQ not supported in this PostgreSQL version") + + for t in ("likes", "posts", "follows", "people"): + backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + people_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", "TEXT"), + ColumnDefinition("email", "TEXT"), + ColumnDefinition("city", "TEXT"), + ] + backend.execute(*CreateTableExpression(dialect, "people", people_cols).to_sql()) + + follows_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("follower_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("followed_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("since", "TEXT"), + ] + backend.execute(*CreateTableExpression(dialect, "follows", follows_cols).to_sql()) + + posts_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("author_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("content", "TEXT"), + ColumnDefinition("created_at", "TEXT"), + ] + backend.execute(*CreateTableExpression(dialect, "posts", posts_cols).to_sql()) + + likes_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("user_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("post_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("posts", ["id"]))]), + ColumnDefinition("created_at", "TEXT"), + ] + backend.execute(*CreateTableExpression(dialect, "likes", likes_cols).to_sql()) + + people_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, "Alice"), Literal(dialect, "alice@example.com"), Literal(dialect, "NYC")], + [Literal(dialect, 2), Literal(dialect, "Bob"), Literal(dialect, "bob@example.com"), Literal(dialect, "NYC")], + [Literal(dialect, 3), Literal(dialect, "Charlie"), Literal(dialect, "charlie@example.com"), Literal(dialect, "LA")], + [Literal(dialect, 4), Literal(dialect, "Diana"), Literal(dialect, "diana@example.com"), Literal(dialect, "NYC")], + [Literal(dialect, 5), Literal(dialect, "Eve"), Literal(dialect, "eve@example.com"), Literal(dialect, "LA")], + ]) + backend.execute(*InsertExpression(dialect, "people", source=people_data).to_sql()) + + follows_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 1), Literal(dialect, 2), Literal(dialect, "2024-01-01")], + [Literal(dialect, 2), Literal(dialect, 2), Literal(dialect, 3), Literal(dialect, "2024-02-01")], + [Literal(dialect, 3), Literal(dialect, 1), Literal(dialect, 3), Literal(dialect, "2024-03-01")], + [Literal(dialect, 4), Literal(dialect, 4), Literal(dialect, 1), Literal(dialect, "2024-04-01")], + [Literal(dialect, 5), Literal(dialect, 3), Literal(dialect, 5), Literal(dialect, "2024-05-01")], + ]) + backend.execute(*InsertExpression(dialect, "follows", source=follows_data).to_sql()) + + posts_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 2), Literal(dialect, "Hello world"), Literal(dialect, "2024-06-01")], + [Literal(dialect, 2), Literal(dialect, 2), Literal(dialect, "PGQ is cool"), Literal(dialect, "2024-06-02")], + [Literal(dialect, 3), Literal(dialect, 3), Literal(dialect, "Graph databases"), Literal(dialect, "2024-06-03")], + [Literal(dialect, 4), Literal(dialect, 1), Literal(dialect, "My first post"), Literal(dialect, "2024-06-04")], + ]) + backend.execute(*InsertExpression(dialect, "posts", source=posts_data).to_sql()) + + likes_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 1), Literal(dialect, 1), Literal(dialect, "2024-06-02")], + [Literal(dialect, 2), Literal(dialect, 3), Literal(dialect, 1), Literal(dialect, "2024-06-02")], + [Literal(dialect, 3), Literal(dialect, 1), Literal(dialect, 2), Literal(dialect, "2024-06-03")], + [Literal(dialect, 4), Literal(dialect, 4), Literal(dialect, 4), Literal(dialect, "2024-06-05")], + [Literal(dialect, 5), Literal(dialect, 5), Literal(dialect, 1), Literal(dialect, "2024-06-06")], + ]) + backend.execute(*InsertExpression(dialect, "likes", source=likes_data).to_sql()) + + vt_people = VertexTable(dialect, "people", + labels=["person"], + properties=TablePropertiesClause(dialect, columns=["id", "name", "city"])) + vt_posts = VertexTable(dialect, "posts", + labels=["post"], + properties=TablePropertiesClause(dialect, columns=["id", "content"])) + et_follows = EdgeTable(dialect, "follows", ["follower_id"], ["followed_id"], + references_source=("people", ["id"]), + references_destination=("people", ["id"]), + labels=["follows"]) + et_posts = EdgeTable(dialect, "posts", ["author_id"], ["id"], + references_source=("people", ["id"]), + references_destination=("posts", ["id"]), + labels=["authored"], + alias="authored") + et_likes = EdgeTable(dialect, "likes", ["user_id"], ["post_id"], + references_source=("people", ["id"]), + references_destination=("posts", ["id"]), + labels=["likes"]) + create_expr = CreatePropertyGraphExpression( + dialect, GRAPH_NAME, [vt_people, vt_posts], [et_follows, et_posts, et_likes] + ) + try: + backend.execute(*create_expr.to_sql()) + except Exception as e: + for t in ("likes", "posts", "follows", "people"): + backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + raise e + + yield GRAPH_NAME + + backend.execute(*DropPropertyGraphExpression(dialect, GRAPH_NAME, if_exists=True).to_sql()) + for t in ("likes", "posts", "follows", "people"): + backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + +class TestSocialGraph: + """Social Network PGQ scenario using expression system.""" + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_single_hop_followers(self, postgres_backend, social_data): + """Q1: Who does Alice follow?""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person", + where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + match = MatchClause(dialect, a, f, b) + cols = ColumnsClause(dialect, GraphColumn("b", "name", "b_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "b_name")], + from_=gt, + order_by=OrderByClause(dialect, [Column(dialect, "b_name")])) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + names = [r["b_name"] for r in rows] + assert names == ["Bob", "Charlie"] + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_two_hop_recommendation(self, postgres_backend, social_data): + """Q2: Friends of friends (two-hop).""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person", + where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + f1 = GraphEdge(dialect, "f1", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + f2 = GraphEdge(dialect, "f2", "follows", GraphEdgeDirection.RIGHT) + c = GraphVertex(dialect, "c", "person") + match = MatchClause(dialect, a, f1, b, f2, c) + cols = ColumnsClause(dialect, GraphColumn("c", "name", "c_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "c_name")], + from_=gt, + where=WhereClause(dialect, condition=Column(dialect, "c_name") != Literal(dialect, "Alice")), + order_by=OrderByClause(dialect, [Column(dialect, "c_name")])) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + assert len(rows) >= 1 + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_likes_on_posts(self, postgres_backend, social_data): + """Q3: Who liked Alice's posts?""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person", + where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + p = GraphEdge(dialect, "p", "authored", GraphEdgeDirection.RIGHT) + post = GraphVertex(dialect, "post", "post") + l = GraphEdge(dialect, "l", "likes", GraphEdgeDirection.LEFT) + liker = GraphVertex(dialect, "liker", "person") + match = MatchClause(dialect, a, p, post, l, liker) + cols = ColumnsClause(dialect, GraphColumn("liker", "name", "liker_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "liker_name")], + from_=gt, + order_by=OrderByClause(dialect, [Column(dialect, "liker_name")])) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + assert len(rows) >= 1 + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_graph_table_with_group_by(self, postgres_backend, social_data): + """GRAPH_TABLE + GROUP BY.""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person") + p = GraphEdge(dialect, "p", "authored", GraphEdgeDirection.RIGHT) + post = GraphVertex(dialect, "post", "post") + l = GraphEdge(dialect, "l", "likes", GraphEdgeDirection.LEFT) + liker = GraphVertex(dialect, "liker", "person") + match = MatchClause(dialect, a, p, post, l, liker) + cols = ColumnsClause(dialect, + GraphColumn("a", "name", "author"), + GraphColumn("liker", "name", "liker_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[ + Column(dialect, "author"), + FunctionCall(dialect, "COUNT", Column(dialect, "liker_name"), alias="like_count"), + ], + from_=gt, + group_by_having=GroupByHavingClause(dialect, group_by=[Column(dialect, "author")]), + order_by=OrderByClause(dialect, [(Column(dialect, "like_count"), "DESC")])) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + assert len(rows) >= 1 + assert rows[0]["like_count"] >= 1 + + +class TestCommerceGraph: + """Commerce/Supply Chain PGQ scenario.""" + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_graph_table_combined_with_regular_sql(self, postgres_backend, social_data): + """Composite: GRAPH_TABLE + JOIN + ORDER BY.""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person") + f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + match = MatchClause(dialect, a, f, b) + cols = ColumnsClause(dialect, + GraphColumn("a", "name", "follower"), + GraphColumn("b", "name", "followed"), + GraphColumn("f", "since", "since")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + join = JoinExpression(dialect, + left_table=gt, + right_table=TableExpression(dialect, "people", alias="p"), + join_type="INNER JOIN", + condition=Column(dialect, "follower", "g") == Column(dialect, "name", "p")) + + query = QueryExpression(dialect, + select=[WildcardExpression(dialect)], + from_=join, + order_by=OrderByClause(dialect, [Column(dialect, "follower", "g")]), + limit_offset=LimitOffsetClause(dialect, limit=10)) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + assert len(rows) >= 1 + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_parameter_binding(self, postgres_backend, social_data): + """Parameter binding via expression system.""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person", + where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + match = MatchClause(dialect, a, f, b) + cols = ColumnsClause(dialect, GraphColumn("b", "name", "b_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "b_name")], + from_=gt, + order_by=OrderByClause(dialect, [Column(dialect, "b_name")])) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + names = [r["b_name"] for r in rows] + assert names == ["Bob", "Charlie"] + + +class TestAsyncSocialGraph: + """Async versions of social graph scenarios using expression system.""" + + @pytest_asyncio.fixture + async def async_social_data(self, async_postgres_backend): + backend = async_postgres_backend + dialect = backend.dialect + if not dialect.supports_graph_table(): + pytest.skip("PGQ not supported") + + for t in ("likes", "posts", "follows", "people"): + await backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + people_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", "TEXT"), + ColumnDefinition("email", "TEXT"), + ColumnDefinition("city", "TEXT"), + ] + await backend.execute(*CreateTableExpression(dialect, "people", people_cols).to_sql()) + + follows_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("follower_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("followed_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("since", "TEXT"), + ] + await backend.execute(*CreateTableExpression(dialect, "follows", follows_cols).to_sql()) + + people_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, "Alice"), Literal(dialect, "a@x.com"), Literal(dialect, "NYC")], + [Literal(dialect, 2), Literal(dialect, "Bob"), Literal(dialect, "b@x.com"), Literal(dialect, "NYC")], + ]) + await backend.execute(*InsertExpression(dialect, "people", source=people_data).to_sql()) + + follows_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 1), Literal(dialect, 2), Literal(dialect, "2024-01-01")], + ]) + await backend.execute(*InsertExpression(dialect, "follows", source=follows_data).to_sql()) + + vt = VertexTable(dialect, "people", labels=["person"]) + et = EdgeTable(dialect, "follows", ["follower_id"], ["followed_id"], + references_source=("people", ["id"]), + references_destination=("people", ["id"]), + labels=["follows"]) + create_expr = CreatePropertyGraphExpression(dialect, "async_graph", [vt], [et]) + await backend.execute(*create_expr.to_sql()) + yield "async_graph" + await backend.execute(*DropPropertyGraphExpression(dialect, "async_graph", if_exists=True).to_sql()) + for t in ("follows", "people"): + await backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + @pytest.mark.asyncio + async def test_async_single_hop(self, async_postgres_backend, async_social_data): + dialect = async_postgres_backend.dialect + a = GraphVertex(dialect, "a", "person", + where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + match = MatchClause(dialect, a, f, b) + cols = ColumnsClause(dialect, GraphColumn("b", "name", "b_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "b_name")], + from_=gt) + sql, params = query.to_sql() + rows = await async_postgres_backend.fetch_all(sql, params) + assert len(rows) == 1 + assert rows[0]["b_name"] == "Bob" + + +class TestPGQExplain: + """Verify PG 19 rewrites GRAPH_TABLE into relational joins.""" + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_explain_graph_table(self, postgres_backend, social_data): + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person") + f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + match = MatchClause(dialect, a, f, b) + cols = ColumnsClause(dialect, GraphColumn("b", "name", "b_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "b_name")], + from_=gt) + explain = ExplainExpression(dialect, statement=query, + options=ExplainOptions(costs=False)) + sql, params = explain.to_sql() + rows = postgres_backend.fetch_all(sql, params) + explain_text = " ".join(r["QUERY PLAN"] for r in rows) + assert "Nested Loop" in explain_text or "Hash Join" in explain_text From 2918976c6e0ad367e5b1706b854a8c6dc9ece624 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 12 Jun 2026 22:57:28 +0800 Subject: [PATCH 2/9] test(postgres): add 19 PGQ format tests for 100% expression coverage Cover all expression edge cases and variants: - anonymous vertex, invalid variable name (ValueError) - undirected edge (NONE), table-only edge, variable-only edge - empty MATCH path, anonymous vertex in MATCH - GraphTableExpression alias, GraphColumn without alias - CREATE IF NOT EXISTS, CREATE without edge tables - VertexTable/EdgeTable with alias - TablePropertiesClause ALL COLUMNS / NONE - ALTER DROP VERTEX TABLES - UnsupportedFeatureError for all TablePropertiesClause variants Total: 33 -> 52 format tests, all passing. --- .../test_property_graph_query_format.py | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py index 73555754..723d351c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py @@ -66,6 +66,12 @@ def test_basic(self, pg19_dialect: PostgresDialect): assert sql == '(p IS "person")' assert params == () + def test_anonymous(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, variable=None, table="person") + sql, params = v.to_sql() + assert sql == '("person")' + assert params == () + def test_with_where(self, pg19_dialect: PostgresDialect): where = WhereClause(pg19_dialect, condition=Column(pg19_dialect, "age") > Literal(pg19_dialect, 18)) @@ -75,6 +81,13 @@ def test_with_where(self, pg19_dialect: PostgresDialect): assert "WHERE" in sql assert params == (18,) + def test_invalid_variable_name(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, "bad var", "person") + with pytest.raises(ValueError, match="Invalid variable name"): + v.to_sql() + + + class TestPGQGraphEdgeFormat: """SQL formatting tests for GraphEdge with PG19 dialect.""" @@ -96,10 +109,24 @@ def test_anonymous(self, pg19_dialect: PostgresDialect): e = GraphEdge(pg19_dialect, direction=GraphEdgeDirection.RIGHT) assert e.to_sql()[0] == '-[]->' + def test_none_direction(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.NONE) + assert e.to_sql()[0] == '-[e IS "knows"]-' + + def test_table_without_variable(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, table="knows", direction=GraphEdgeDirection.RIGHT) + sql = e.to_sql()[0] + assert sql == '-[]->' + def test_variable_only(self, pg19_dialect: PostgresDialect): e = GraphEdge(pg19_dialect, variable="e", direction=GraphEdgeDirection.RIGHT) assert e.to_sql()[0] == '-[e]->' + def test_invalid_variable_name(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, variable="bad var", table="knows", direction=GraphEdgeDirection.RIGHT) + with pytest.raises(ValueError, match="Invalid variable name"): + e.to_sql() + class TestPGQMatchClauseFormat: """SQL formatting tests for MatchClause with PG19 dialect.""" @@ -122,6 +149,19 @@ def test_path_pattern(self, pg19_dialect: PostgresDialect): assert "[e IS" in sql assert "(b IS" in sql + def test_empty_path(self, pg19_dialect: PostgresDialect): + m = MatchClause(pg19_dialect) + sql, params = m.to_sql() + assert sql == "MATCH " + assert params == () + + def test_anonymous_vertex(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, variable=None, table="person") + m = MatchClause(pg19_dialect, v) + sql, params = m.to_sql() + assert sql == 'MATCH ("person")' + assert params == () + class TestPGQGraphTableFormat: """SQL formatting tests for GraphTableExpression with PG19 dialect.""" @@ -148,6 +188,21 @@ def test_with_where(self, pg19_dialect: PostgresDialect): assert "WHERE" in sql assert params == (18,) + def test_with_alias(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, "p", "person") + cols = ColumnsClause(pg19_dialect, GraphColumn("p", "name")) + m = MatchClause(pg19_dialect, v) + gt = GraphTableExpression(pg19_dialect, "g", m, cols, alias="t") + sql, params = gt.to_sql() + assert 'GRAPH_TABLE ("g" MATCH' in sql + assert 'AS "t"' in sql + + def test_graph_column_without_alias(self, pg19_dialect: PostgresDialect): + cols = ColumnsClause(pg19_dialect, GraphColumn("p", "name")) + sql, params = cols.to_sql() + assert sql == 'COLUMNS ("p"."name")' + assert params == () + class TestPGQDDLFormat: """SQL formatting tests for PGQ DDL expressions with PG19 dialect.""" @@ -170,6 +225,51 @@ def test_create_property_graph(self, pg19_dialect: PostgresDialect): assert "SOURCE KEY" in sql assert "DESTINATION KEY" in sql + def test_create_property_graph_if_not_exists(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "person") + expr = CreatePropertyGraphExpression(pg19_dialect, "my_graph", [vt], + if_not_exists=True) + sql, params = expr.to_sql() + assert "CREATE PROPERTY GRAPH IF NOT EXISTS" in sql + + def test_create_property_graph_no_edges(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "person") + expr = CreatePropertyGraphExpression(pg19_dialect, "my_graph", [vt]) + sql, params = expr.to_sql() + assert "EDGE TABLES" not in sql + assert 'VERTEX TABLES ("person"' in sql + + def test_vertex_table_with_alias(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "person", alias="p", + labels=["Person"], + key_columns=["id"]) + sql, params = vt.to_sql() + assert '"person" AS "p"' in sql or '"person" AS p' in sql + assert "LABEL" in sql + assert "KEY" in sql + + def test_edge_table_with_alias(self, pg19_dialect: PostgresDialect): + et = EdgeTable(pg19_dialect, "knows", ["pid"], ["fid"], + alias="k", + labels=["Knows"]) + sql, params = et.to_sql() + assert '"knows" AS "k"' in sql or '"knows" AS k' in sql + assert "SOURCE KEY" in sql + assert "DESTINATION KEY" in sql + assert "LABEL" in sql + + def test_properties_all_columns(self, pg19_dialect: PostgresDialect): + p = TablePropertiesClause(pg19_dialect, columns=None) + sql, params = p.to_sql() + assert sql == "PROPERTIES ALL COLUMNS" + assert params == () + + def test_properties_none(self, pg19_dialect: PostgresDialect): + p = TablePropertiesClause(pg19_dialect, columns=[]) + sql, params = p.to_sql() + assert sql == "PROPERTIES NONE" + assert params == () + def test_drop_property_graph(self, pg19_dialect: PostgresDialect): expr = DropPropertyGraphExpression(pg19_dialect, "test_graph", if_exists=True) sql, params = expr.to_sql() @@ -203,6 +303,15 @@ def test_alter_add_vertex(self, pg19_dialect: PostgresDialect): assert "ALTER PROPERTY GRAPH" in sql assert "ADD" in sql + def test_alter_drop_vertex_tables(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "old_table") + expr = AlterPropertyGraphExpression(pg19_dialect, "g", "DROP", "VERTEX TABLES", + vertex_tables=[vt]) + sql, params = expr.to_sql() + assert "DROP" in sql + assert "VERTEX TABLES" in sql + assert '"old_table"' in sql + def test_alter_with_edge_tables(self, pg19_dialect: PostgresDialect): et = EdgeTable(pg19_dialect, "knows", ["pid"], ["fid"]) expr = AlterPropertyGraphExpression(pg19_dialect, "g", "DROP", "EDGE TABLES", @@ -273,3 +382,18 @@ def test_alter_property_graph_unsupported(self, pg15_dialect: PostgresDialect): vertex_tables=[vt]) with pytest.raises(UnsupportedFeatureError): expr.to_sql() + + def test_table_properties_clause_unsupported(self, pg15_dialect: PostgresDialect): + p = TablePropertiesClause(pg15_dialect, columns=["id"]) + with pytest.raises(UnsupportedFeatureError): + p.to_sql() + + def test_table_properties_all_columns_unsupported(self, pg15_dialect: PostgresDialect): + p = TablePropertiesClause(pg15_dialect, columns=None) + with pytest.raises(UnsupportedFeatureError): + p.to_sql() + + def test_table_properties_none_unsupported(self, pg15_dialect: PostgresDialect): + p = TablePropertiesClause(pg15_dialect, columns=[]) + with pytest.raises(UnsupportedFeatureError): + p.to_sql() From 77686e1fead1faaa5537fe795ae0cb883390da03 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 12 Jun 2026 22:59:50 +0800 Subject: [PATCH 3/9] ci(postgres): add standalone Python 3.14 / PostgreSQL 19beta1 test job PG 19 has no extensions yet, so it gets its own job without postgres-exts service. Uses postgres:19beta1 official image. Main test matrix (PG 9-18) unchanged. --- .github/workflows/test.yml | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 074e32a1..ffa3fb4a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -452,3 +452,73 @@ jobs: flags: unittests,python-${{ matrix.python-version }},postgres-${{ matrix.postgres-version }} name: codecov-postgres-${{ matrix.python-version }}-${{ matrix.postgres-version }} fail_ci_if_error: false + + test-postgres-19: + name: Python 3.14 / postgres 19beta1 (PGQ) + runs-on: ubuntu-latest + continue-on-error: true + + services: + postgres: + image: postgres:19beta1 + env: + POSTGRES_DB: test_db + POSTGRES_USER: root + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Check out the repository + uses: actions/checkout@v6 + + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Install activerecord and testsuite + run: | + pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev28#egg=rhosocial-activerecord + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev15#egg=rhosocial-activerecord-testsuite + + - name: Create postgres scenario config + run: | + cat > postgres_scenarios.yaml << EOF + scenarios: + postgres_default: + host: 127.0.0.1 + port: 5432 + database: test_db + username: root + password: password + EOF + echo "POSTGRES_SCENARIOS_CONFIG_PATH=$(pwd)/postgres_scenarios.yaml" >> $GITHUB_ENV + + - name: Install postgres client for health check + run: | + sudo apt-get update + sudo apt-get install --yes postgresql-client + + - name: Wait for postgres service + run: | + until pg_isready -h 127.0.0.1 -p 5432 -U root; do + echo "Waiting for postgres..." + sleep 2 + done + echo "postgres is ready!" + + - name: Run tests + run: | + export PYTHONPATH=src:tests + python -m pytest tests/ --tb=short -n 2 --dist=loadgroup From 824754169da41c20e536c09966d731c9b57a082d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 14 Jun 2026 00:47:36 +0800 Subject: [PATCH 4/9] ci(postgres): add --scenario-parallel to PGQ test job --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ffa3fb4a..ec4514d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -521,4 +521,4 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests - python -m pytest tests/ --tb=short -n 2 --dist=loadgroup + python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup From edb6cc942a050eacc1f234b8f0a715f76e2dbbda Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 14 Jun 2026 00:57:37 +0800 Subject: [PATCH 5/9] fix(postgres): handle PG beta version strings without dot separator in async version parsing --- .../activerecord/backend/impl/postgres/backend/async_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py index 1e0b4a30..7aba7eb1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py @@ -358,7 +358,7 @@ async def get_server_version(self) -> Optional[Tuple[int, int, int]]: match = re.search(r"PostgreSQL (\d+)(?:\.(\d+))?(?:[^\d]*(\d+))?", version_str) if match: major = int(match.group(1)) - minor = int(match.group(2)) + minor = int(match.group(2)) if match.group(2) else 0 patch = int(match.group(3)) if match.group(3) else 0 return (major, minor, patch) else: From cd16b015fea5564a786496503723548697b26a3d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 14 Jun 2026 01:00:00 +0800 Subject: [PATCH 6/9] test(postgres): normalize LSN assertion to handle PG19 zero-padding --- .../feature/backend/postgres/test_lsn_integration.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_lsn_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_lsn_integration.py index 46da2562..4883bd0a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_lsn_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_lsn_integration.py @@ -78,7 +78,9 @@ def test_insert_lsn_from_int(self, postgres_backend, lsn_test_table): f"SELECT lsn_value::text AS lsn_value FROM {lsn_test_table}" ) - assert result["lsn_value"] == "16/1" + # Normalize LSN zero-padding (PG19beta1 uses 8-digit hex parts) + parts = result["lsn_value"].split("/") + assert int(parts[0], 16) == 0x16 and int(parts[1], 16) == 0x1 def test_lsn_null_round_trip(self, postgres_backend, lsn_test_table): """Insert a NULL pg_lsn value and verify the fetched value is None.""" From 7c44cb86646222cdef82b50fbaf25d27dc2ecac2 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 14 Jun 2026 01:11:14 +0800 Subject: [PATCH 7/9] test(postgres): fix async PGQ fixture graph name mismatch --- .../backend/postgres/test_property_graph_query_scenarios.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py index b8dc259d..be4e111c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py @@ -361,10 +361,10 @@ async def async_social_data(self, async_postgres_backend): references_source=("people", ["id"]), references_destination=("people", ["id"]), labels=["follows"]) - create_expr = CreatePropertyGraphExpression(dialect, "async_graph", [vt], [et]) + create_expr = CreatePropertyGraphExpression(dialect, GRAPH_NAME, [vt], [et]) await backend.execute(*create_expr.to_sql()) - yield "async_graph" - await backend.execute(*DropPropertyGraphExpression(dialect, "async_graph", if_exists=True).to_sql()) + yield GRAPH_NAME + await backend.execute(*DropPropertyGraphExpression(dialect, GRAPH_NAME, if_exists=True).to_sql()) for t in ("follows", "people"): await backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) From 10046b48d99e3a0231338142e7f234512aafecb0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 14 Jun 2026 01:23:10 +0800 Subject: [PATCH 8/9] test(postgres): add version-aware LSN assertion for PG19 zero-padded format --- .../feature/backend/postgres/test_lsn_integration.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_lsn_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_lsn_integration.py index 4883bd0a..f01a46d8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_lsn_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_lsn_integration.py @@ -78,9 +78,13 @@ def test_insert_lsn_from_int(self, postgres_backend, lsn_test_table): f"SELECT lsn_value::text AS lsn_value FROM {lsn_test_table}" ) - # Normalize LSN zero-padding (PG19beta1 uses 8-digit hex parts) - parts = result["lsn_value"].split("/") - assert int(parts[0], 16) == 0x16 and int(parts[1], 16) == 0x1 + # PG19+ standardizes LSN output to %X/%08X format (zero-padded) + # PG18 and earlier omit leading zeros (e.g. "16/1") + version = postgres_backend.get_server_version() + if version >= (19, 0, 0): + assert result["lsn_value"] == "16/00000001" + else: + assert result["lsn_value"] == "16/1" def test_lsn_null_round_trip(self, postgres_backend, lsn_test_table): """Insert a NULL pg_lsn value and verify the fetched value is None.""" From 10b36a284c0c9a4591bd37838a287bff01b49470 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 14 Jun 2026 01:26:47 +0800 Subject: [PATCH 9/9] chore: add changelog fragment for PR #44 --- changelog.d/44.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/44.added.md diff --git a/changelog.d/44.added.md b/changelog.d/44.added.md new file mode 100644 index 00000000..32ce588f --- /dev/null +++ b/changelog.d/44.added.md @@ -0,0 +1 @@ +Add SQL/PGQ property graph query support and PostgreSQL 19 compatibility enhancements