From fe4127ec4abe7f779036528a1a9f3a9bc81cbb03 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 4 Jun 2026 14:41:09 +0800 Subject: [PATCH 1/3] feat(postgres): use core SQL/XML XMLPARSE expression Wire PostgreSQL XMLPARSE support through the core SQL/XML expression protocol and remove the old PostgreSQL-specific function factory. Co-Authored-By: Claude Opus 4.7 --- .../backend/impl/postgres/dialect.py | 12 ++- .../impl/postgres/function_versions.py | 1 - .../impl/postgres/functions/__init__.py | 2 - .../backend/impl/postgres/functions/xml.py | 80 +------------------ .../backend/postgres/test_xml_integration.py | 2 +- .../backend/postgres/test_xml_types.py | 21 ++--- .../backend/test_dialect_function_support.py | 15 +++- .../test_postgres_protocol_conformance.py | 1 + 8 files changed, 39 insertions(+), 95 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index c49a8534..45a801af 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -14,6 +14,7 @@ from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.dialect.mixins import ( + SQLXMLMixin, CollationMixin, CTEMixin, FilterClauseMixin, @@ -43,6 +44,7 @@ ConstraintMixin, ) from rhosocial.activerecord.backend.dialect.protocols import ( + SQLXMLSupport, CollationSupport, CTESupport, FilterClauseSupport, @@ -226,6 +228,7 @@ class PostgresDialect( SQLDialectBase, + SQLXMLMixin, CollationMixin, SetOperationMixin, TruncateMixin, @@ -316,6 +319,7 @@ class PostgresDialect( PostgresStoredProcedureMixin, PostgresAdvisoryLockMixin, # Protocol supports + SQLXMLSupport, CollationSupport, SetOperationSupport, TruncateSupport, @@ -471,6 +475,10 @@ def get_server_version(self) -> Tuple[int, int, int]: """Return the PostgreSQL version this dialect is configured for.""" return self.version + def supports_xmlparse(self) -> bool: + """XMLPARSE is supported since PostgreSQL 8.3.""" + return self.version >= (8, 3, 0) + def supports_collate_expression(self) -> bool: """PostgreSQL supports expression-level COLLATE.""" return True @@ -1637,9 +1645,11 @@ def supports_functions(self) -> Dict[str, "FunctionSupportInfo"]: ) from rhosocial.activerecord.backend.impl.postgres import functions as postgres_functions + expression_constructors = {"xmlparse"} result: Dict[str, FunctionSupportInfo] = {} for func_name in core_functions: - result[func_name] = self._check_function_support(func_name) + if func_name not in expression_constructors: + result[func_name] = self._check_function_support(func_name) postgres_funcs = getattr(postgres_functions, "__all__", []) for func_name in postgres_funcs: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/function_versions.py b/src/rhosocial/activerecord/backend/impl/postgres/function_versions.py index 9255eed3..fdcb2cae 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/function_versions.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/function_versions.py @@ -171,7 +171,6 @@ def is_extension_function(self) -> bool: } XML_FUNCTION_VERSIONS: Dict[str, FunctionVersionRequirement] = { - "xmlparse": FunctionVersionRequirement(min_pg_version=(8, 3, 0)), "xpath_query": FunctionVersionRequirement(min_pg_version=(8, 3, 0)), "xpath_exists": FunctionVersionRequirement(min_pg_version=(8, 3, 0)), "xml_is_well_formed": FunctionVersionRequirement(min_pg_version=(9, 1, 0)), diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py index f9660e95..da294485 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py @@ -162,7 +162,6 @@ # XML functions from .xml import ( - xmlparse, xpath_query, xpath_exists, xml_is_well_formed, @@ -601,7 +600,6 @@ "tsvector_setweight", "tsvector_length", # XML functions - "xmlparse", "xpath_query", "xpath_exists", "xml_is_well_formed", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py index 2a72f6f9..a8d42dd0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py @@ -3,23 +3,17 @@ PostgreSQL XML function factories. This module provides SQL expression generators for PostgreSQL XML -functions. All functions return FunctionCall expression objects that -integrate with the expression-dialect architecture. +functions. SQL/XML expression constructors such as XMLPARSE live in +rhosocial.activerecord.backend.expression.functions.xml. PostgreSQL Documentation: https://www.postgresql.org/docs/current/functions-xml.html Supported functions: -- xmlparse() - Parse XML content - xpath_query() - Execute XPath query on XML - xpath_exists() - Test if XPath expression matches - xml_is_well_formed() - Check if XML is well-formed Examples: - Parse XML document: - >>> func = xmlparse(dialect, 'value') - >>> func.to_sql() - ('XMLPARSE(DOCUMENT, %s, PRESERVE WHITESPACE)', ('value',)) - Query XML with XPath: >>> func = xpath_query(dialect, "/root/item", "xml_column") >>> func.to_sql() @@ -62,75 +56,6 @@ def _convert_to_expression( return core.Literal(dialect, expr) -class _KeywordLiteral(bases.BaseExpression): - """Represents a SQL keyword that should be rendered as-is (unquoted, unparameterized). - - Used for SQL keywords that appear inside function call arguments, - such as DOCUMENT, CONTENT, PRESERVE WHITESPACE in XMLPARSE. - """ - - def __init__(self, dialect: "SQLDialectBase", keyword: str): - super().__init__(dialect) - self.keyword = keyword - - def to_sql(self) -> "bases.SQLQueryAndParams": - return self.keyword, () - - -class _XMLParseCall(core.FunctionCall): - def to_sql(self) -> "bases.SQLQueryAndParams": - doc_type_sql, doc_type_params = self.args[0].to_sql() - content_sql, content_params = self.args[1].to_sql() - params = doc_type_params + content_params - sql = f"XMLPARSE({doc_type_sql} {content_sql}" - - if len(self.args) > 2: - whitespace_sql, whitespace_params = self.args[2].to_sql() - sql += f" {whitespace_sql}" - params += whitespace_params - - return f"{sql})", params - - -def xmlparse( - dialect: "SQLDialectBase", - content: Union[PostgresXML, str, "bases.BaseExpression"], - document: bool = True, - preserve_whitespace: bool = False, -) -> core.FunctionCall: - """Generate XMLPARSE expression. - - XMLPARSE parses XML content from a string. It produces either - a DOCUMENT or CONTENT value, optionally preserving whitespace. - - Args: - dialect: The SQL dialect instance - content: XML content string, PostgresXML instance, or expression - document: Parse as DOCUMENT (default) or CONTENT - preserve_whitespace: Preserve whitespace (default False) - - Returns: - FunctionCall: SQL expression for XMLPARSE - - Example: - >>> func = xmlparse(dialect, 'value') - >>> func.to_sql() - ('XMLPARSE(DOCUMENT, %s)', ('value',)) - - >>> func = xmlparse(dialect, '', document=False, preserve_whitespace=True) - >>> func.to_sql() - ('XMLPARSE(CONTENT, %s, PRESERVE WHITESPACE)', ('',)) - """ - doc_type = "DOCUMENT" if document else "CONTENT" - content_expr = _convert_to_expression(dialect, content) - - args = [_KeywordLiteral(dialect, doc_type), content_expr] - - if preserve_whitespace: - args.append(_KeywordLiteral(dialect, "PRESERVE WHITESPACE")) - - return _XMLParseCall(dialect, "XMLPARSE", *args) - def xpath_query( dialect: "SQLDialectBase", @@ -237,7 +162,6 @@ def xml_is_well_formed( __all__ = [ - "xmlparse", "xpath_query", "xpath_exists", "xml_is_well_formed", diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_integration.py index 6ac814de..8d5bd451 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_integration.py @@ -12,10 +12,10 @@ import pytest_asyncio from rhosocial.activerecord.backend.expression import core +from rhosocial.activerecord.backend.expression.functions.xml import xmlparse from rhosocial.activerecord.backend.impl.postgres.adapters.xml import PostgresXMLAdapter from rhosocial.activerecord.backend.impl.postgres.functions.xml import ( xml_is_well_formed, - xmlparse, xpath_exists, xpath_query, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py index 2376d92b..776e0b33 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py @@ -9,12 +9,12 @@ import pytest from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.expression import core +from rhosocial.activerecord.backend.expression import XMLParseExpression, core +from rhosocial.activerecord.backend.expression.functions.xml import xmlparse from rhosocial.activerecord.backend.impl.postgres.types.xml import ( PostgresXML, ) from rhosocial.activerecord.backend.impl.postgres.functions.xml import ( - xmlparse, xpath_query, xpath_exists, xml_is_well_formed, @@ -167,27 +167,28 @@ def test_xmlparse_document(self): """Test xmlparse with DOCUMENT.""" dialect = PostgresDialect((14, 0, 0)) result = xmlparse(dialect, '', document=True) - assert isinstance(result, core.FunctionCall) + assert isinstance(result, XMLParseExpression) sql, params = result.to_sql() - assert 'XMLPARSE' in sql - assert 'DOCUMENT' in sql + assert sql == "XMLPARSE(DOCUMENT %s)" + assert params == ('',) def test_xmlparse_content(self): """Test xmlparse with CONTENT.""" dialect = PostgresDialect((14, 0, 0)) result = xmlparse(dialect, '', document=False) - assert isinstance(result, core.FunctionCall) + assert isinstance(result, XMLParseExpression) sql, params = result.to_sql() - assert 'XMLPARSE' in sql - assert 'CONTENT' in sql + assert sql == "XMLPARSE(CONTENT %s)" + assert params == ('',) def test_xmlparse_preserve_whitespace(self): """Test xmlparse with preserve whitespace.""" dialect = PostgresDialect((14, 0, 0)) result = xmlparse(dialect, '', preserve_whitespace=True) - assert isinstance(result, core.FunctionCall) + assert isinstance(result, XMLParseExpression) sql, params = result.to_sql() - assert 'PRESERVE WHITESPACE' in sql + assert sql == "XMLPARSE(DOCUMENT %s PRESERVE WHITESPACE)" + assert params == ('',) def test_xpath_query_basic(self): """Test xpath_query basic.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py index 84813be1..33275e1a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py @@ -5,7 +5,6 @@ This module tests the supports_functions() method and version-dependent function availability detection in PostgresDialect. """ -import pytest from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.function_versions import FunctionSupportInfo @@ -169,7 +168,7 @@ def test_bit_count_requires_pg_9_5(self): def test_xml_functions_require_pg_8_3(self): """Test that XML functions require PostgreSQL 8.3+.""" - xml_functions = ["xmlparse", "xpath_query", "xpath_exists"] + xml_functions = ["xpath_query", "xpath_exists"] dialect_old = PostgresDialect(version=(8, 2, 99)) result_old = dialect_old.supports_functions() @@ -181,6 +180,18 @@ def test_xml_functions_require_pg_8_3(self): for func in xml_functions: assert result_new.get(func).supported + def test_xmlparse_is_expression_capability(self): + """Test that XMLPARSE is exposed as SQL/XML capability.""" + dialect_old = PostgresDialect(version=(8, 2, 99)) + result_old = dialect_old.supports_functions() + assert "xmlparse" not in result_old + assert dialect_old.supports_xmlparse() is False + + dialect_new = PostgresDialect(version=(8, 3, 0)) + result_new = dialect_new.supports_functions() + assert "xmlparse" not in result_new + assert dialect_new.supports_xmlparse() is True + def test_xml_is_well_formed_requires_pg_9_1(self): """Test that xml_is_well_formed requires PostgreSQL 9.1+.""" dialect_old = PostgresDialect(version=(9, 0, 99)) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py index 32952cee..e8c1fac5 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py @@ -77,6 +77,7 @@ def get_postgres_protocols(): from typing import runtime_checkable protocols = [ + dialect_protocols.SQLXMLSupport, dialect_protocols.CollationSupport, dialect_protocols.CTESupport, dialect_protocols.FilterClauseSupport, From be65652dbf6169e8977f2495df02f4c9dd541b01 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 4 Jun 2026 17:02:22 +0800 Subject: [PATCH 2/3] feat(postgres): add SQL/XML standard expression support Enable PostgreSQL SQL/XML capabilities through core expression protocols, keep PostgreSQL-specific XPath helpers separate, and add self-contained examples that exercise expression-generated SQL. Co-Authored-By: Claude Opus 4.7 --- .../backend/impl/postgres/dialect.py | 74 +++- .../impl/postgres/examples/sqlxml/__init__.py | 1 + .../impl/postgres/examples/sqlxml/item_1.xml | 1 + .../impl/postgres/examples/sqlxml/item_2.xml | 1 + .../impl/postgres/examples/sqlxml/root.xml | 4 + .../examples/sqlxml/standard_expressions.py | 331 ++++++++++++++++++ .../backend/impl/postgres/functions/xml.py | 104 +----- .../backend/test_dialect_function_support.py | 43 ++- .../test_postgres_protocol_conformance.py | 16 + 9 files changed, 480 insertions(+), 95 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_1.xml create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_2.xml create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/root.xml create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 45a801af..2863d622 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -45,6 +45,11 @@ ) from rhosocial.activerecord.backend.dialect.protocols import ( SQLXMLSupport, + SQLXMLParsingSupport, + SQLXMLSerializationSupport, + SQLXMLConstructionSupport, + SQLXMLAggregationSupport, + SQLXMLQueryingSupport, CollationSupport, CTESupport, FilterClauseSupport, @@ -320,6 +325,11 @@ class PostgresDialect( PostgresAdvisoryLockMixin, # Protocol supports SQLXMLSupport, + SQLXMLParsingSupport, + SQLXMLSerializationSupport, + SQLXMLConstructionSupport, + SQLXMLAggregationSupport, + SQLXMLQueryingSupport, CollationSupport, SetOperationSupport, TruncateSupport, @@ -479,6 +489,54 @@ def supports_xmlparse(self) -> bool: """XMLPARSE is supported since PostgreSQL 8.3.""" return self.version >= (8, 3, 0) + def supports_xmlserialize(self) -> bool: + """XMLSERIALIZE is supported since PostgreSQL 8.3.""" + return self.version >= (8, 3, 0) + + def supports_xmlelement(self) -> bool: + """XMLELEMENT is supported since PostgreSQL 8.3.""" + return self.version >= (8, 3, 0) + + def supports_xmlattributes(self) -> bool: + """XMLATTRIBUTES is supported since PostgreSQL 8.3.""" + return self.version >= (8, 3, 0) + + def supports_xmlforest(self) -> bool: + """XMLFOREST is supported since PostgreSQL 8.3.""" + return self.version >= (8, 3, 0) + + def supports_xmlconcat(self) -> bool: + """XMLCONCAT is supported since PostgreSQL 8.3.""" + return self.version >= (8, 3, 0) + + def supports_xmlcomment(self) -> bool: + """XMLCOMMENT is supported since PostgreSQL 8.3.""" + return self.version >= (8, 3, 0) + + def supports_xmlpi(self) -> bool: + """XMLPI is supported since PostgreSQL 8.3.""" + return self.version >= (8, 3, 0) + + def supports_xmlroot(self) -> bool: + """XMLROOT is supported since PostgreSQL 8.3.""" + return self.version >= (8, 3, 0) + + def supports_xmlagg(self) -> bool: + """XMLAGG is supported since PostgreSQL 8.3.""" + return self.version >= (8, 3, 0) + + def supports_xmlquery(self) -> bool: + """PostgreSQL does not implement standard SQL/XML XMLQUERY.""" + return False + + def supports_xmlexists(self) -> bool: + """XMLEXISTS is supported since PostgreSQL 8.4.""" + return self.version >= (8, 4, 0) + + def supports_xmltable(self) -> bool: + """XMLTABLE is supported since PostgreSQL 10.""" + return self.version >= (10, 0, 0) + def supports_collate_expression(self) -> bool: """PostgreSQL supports expression-level COLLATE.""" return True @@ -1645,7 +1703,21 @@ def supports_functions(self) -> Dict[str, "FunctionSupportInfo"]: ) from rhosocial.activerecord.backend.impl.postgres import functions as postgres_functions - expression_constructors = {"xmlparse"} + expression_constructors = { + "xmlagg", + "xmlattributes", + "xmlcomment", + "xmlconcat", + "xmlelement", + "xmlexists", + "xmlforest", + "xmlparse", + "xmlpi", + "xmlquery", + "xmlroot", + "xmlserialize", + "xmltable", + } result: Dict[str, FunctionSupportInfo] = {} for func_name in core_functions: if func_name not in expression_constructors: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/__init__.py new file mode 100644 index 00000000..0f2b3b02 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/__init__.py @@ -0,0 +1 @@ +"""SQL/XML expression examples for PostgreSQL.""" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_1.xml b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_1.xml new file mode 100644 index 00000000..0db8a159 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_1.xml @@ -0,0 +1 @@ +alpha diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_2.xml b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_2.xml new file mode 100644 index 00000000..6e2f0f8b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_2.xml @@ -0,0 +1 @@ +beta diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/root.xml b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/root.xml new file mode 100644 index 00000000..3b6e9eea --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/root.xml @@ -0,0 +1,4 @@ + + alpha + beta + diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py new file mode 100644 index 00000000..dba2aac5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py @@ -0,0 +1,331 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py +"""PostgreSQL SQL/XML standard expression examples. + +By default this script only renders SQL and parameters. Pass --execute to run +safe SELECT examples against PostgreSQL using PGHOST / PGPORT / PGDATABASE / +PGUSER / PGPASSWORD environment variables. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Iterable, Optional, Tuple, Union + +from rhosocial.activerecord.backend.expression import ( + Column, + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, + CreateTableExpression, + DropTableExpression, + InsertExpression, + Literal, + OrderByClause, + QueryExpression, + TableExpression, + ValuesSource, + XMLStandaloneOption, + XMLTableColumn, + xmlagg, + xmlattributes, + xmlcomment, + xmlconcat, + xmlelement, + xmlexists, + xmlforest, + xmlparse, + xmlpi, + xmlroot, + xmlserialize, + xmltable, +) +from rhosocial.activerecord.backend.impl.postgres import ( + PostgresBackend, + PostgresConnectionConfig, + PostgresDialect, +) + +SQLParam = Optional[Union[str, int, float, bool]] +SQLParams = Tuple[SQLParam, ...] +SQLQueryAndParams = Tuple[str, SQLParams] +EXAMPLE_TABLE = "sqlxml_example_items" +EXAMPLE_DIR = Path(__file__).resolve().parent + + +def load_xml(filename: str) -> str: + return (EXAMPLE_DIR / filename).read_text(encoding="utf-8").strip() + + +class SQLXMLExample: + def __init__( + self, + title: str, + sql_and_params: SQLQueryAndParams, + executable: bool = True, + ): + self.title: str = title + self.sql_and_params: SQLQueryAndParams = sql_and_params + self.executable: bool = executable + + +def text_literal(dialect: PostgresDialect, value: str) -> Literal: + expr = Literal(dialect, value) + expr.cast("TEXT") + return expr + + +def xml_literal(dialect: PostgresDialect, value: str) -> Literal: + expr = Literal(dialect, value) + expr.cast("XML") + return expr + + +def render(example: SQLXMLExample) -> None: + sql, params = example.sql_and_params + print(f"\n=== {example.title} ===") + print(sql) + print(f"params: {params}") + + +def select(dialect: PostgresDialect, expr) -> SQLQueryAndParams: + sql, params = QueryExpression(dialect, [expr]).to_sql() + return sql, tuple(params) + + +def select_from_examples(dialect: PostgresDialect, expr) -> SQLQueryAndParams: + sql, params = QueryExpression( + dialect, + [expr], + from_=TableExpression(dialect, EXAMPLE_TABLE), + ).to_sql() + return sql, tuple(params) + + +def build_examples(dialect: PostgresDialect) -> Iterable[SQLXMLExample]: + payload = Column(dialect, "payload") + item_id = Column(dialect, "id") + root_xml = load_xml("root.xml") + + parsed_document = xmlparse(dialect, xml_literal(dialect, root_xml)) + parsed_content = xmlparse( + dialect, + xml_literal(dialect, "alphabeta"), + document=False, + ) + + yield SQLXMLExample("XMLPARSE document literal", select(dialect, parsed_document)) + yield SQLXMLExample("XMLPARSE content literal", select(dialect, parsed_content)) + yield SQLXMLExample( + "XMLSERIALIZE content as text", + select(dialect, xmlserialize(dialect, parsed_document, "TEXT")), + ) + + yield SQLXMLExample( + "XMLELEMENT with XMLATTRIBUTES", + select( + dialect, + xmlelement( + dialect, + "product", + text_literal(dialect, "PostgreSQL"), + attributes=xmlattributes( + dialect, + (text_literal(dialect, "pg-001"), "sku"), + (text_literal(dialect, "active"), "status"), + ), + ), + ), + ) + + yield SQLXMLExample( + "XMLFOREST values", + select( + dialect, + xmlforest( + dialect, + (text_literal(dialect, "Ada"), "first_name"), + (text_literal(dialect, "Lovelace"), "last_name"), + (Literal(dialect, 1815), "birth_year"), + ), + ), + ) + + yield SQLXMLExample( + "XMLCONCAT constructor output", + select( + dialect, + xmlconcat( + dialect, + xmlelement(dialect, "first", text_literal(dialect, "alpha")), + xmlelement(dialect, "second", text_literal(dialect, "beta")), + ), + ), + ) + + yield SQLXMLExample( + "XMLCOMMENT and XMLPI", + select( + dialect, + xmlconcat( + dialect, + xmlcomment(dialect, text_literal(dialect, "generated by rhosocial-activerecord")), + xmlpi( + dialect, + "stylesheet", + text_literal(dialect, "type=\"text/xsl\" href=\"style.xsl\""), + ), + ), + ), + ) + + yield SQLXMLExample( + "XMLROOT with version and standalone", + select( + dialect, + xmlroot( + dialect, + parsed_document, + version="1.0", + standalone=XMLStandaloneOption.YES, + ), + ), + ) + + yield SQLXMLExample( + "XMLAGG over prepared table", + select_from_examples( + dialect, + xmlagg( + dialect, + payload, + order_by=OrderByClause(dialect, [(item_id, "ASC")]), + ), + ), + ) + + yield SQLXMLExample( + "XMLEXISTS predicate", + select(dialect, xmlexists(dialect, text_literal(dialect, "/root/item[@id=\"1\"]"), passing=[parsed_document])), + executable=False, + ) + + yield SQLXMLExample( + "XMLTABLE rows", + xmltable( + dialect, + text_literal(dialect, "/root/item"), + [ + XMLTableColumn("id", "INTEGER", path=text_literal(dialect, "@id")), + XMLTableColumn("name", "TEXT", path=text_literal(dialect, "text()")), + ], + passing=[parsed_document], + ).to_sql(), + executable=False, + ) + + +def load_config() -> PostgresConnectionConfig: + return PostgresConnectionConfig( + host=os.environ.get("PGHOST", "localhost"), + port=int(os.environ.get("PGPORT", "5432")), + database=os.environ.get("PGDATABASE", "test_db"), + username=os.environ.get("PGUSER", "postgres"), + password=os.environ.get("PGPASSWORD", ""), + sslmode=os.environ.get("PGSSLMODE", "prefer"), + ) + + +def execute_expression(backend: PostgresBackend, expr) -> None: + sql, params = expr.to_sql() + backend.execute(sql, tuple(params)) + + +def prepare_example_data(backend: PostgresBackend, dialect: PostgresDialect) -> None: + execute_expression(backend, DropTableExpression(dialect, EXAMPLE_TABLE, if_exists=True)) + execute_expression( + backend, + CreateTableExpression( + dialect, + EXAMPLE_TABLE, + [ + ColumnDefinition( + "id", + "INTEGER", + [ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], + ), + ColumnDefinition( + "payload", + "XML", + [ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), + ], + ), + ) + execute_expression( + backend, + InsertExpression( + dialect, + EXAMPLE_TABLE, + ValuesSource( + dialect, + [ + [Literal(dialect, 1), xmlparse(dialect, xml_literal(dialect, load_xml("item_1.xml")))], + [Literal(dialect, 2), xmlparse(dialect, xml_literal(dialect, load_xml("item_2.xml")))], + ], + ), + columns=["id", "payload"], + ), + ) + + +def cleanup_example_data(backend: PostgresBackend, dialect: PostgresDialect) -> None: + execute_expression(backend, DropTableExpression(dialect, EXAMPLE_TABLE, if_exists=True)) + + +def run_select_examples(dialect: PostgresDialect, examples: Iterable[SQLXMLExample]) -> None: + backend = PostgresBackend(connection_config=load_config()) + backend.connect() + try: + prepare_example_data(backend, dialect) + for example in examples: + print(f"\n=== execute: {example.title} ===") + if not example.executable: + print("skip execution: rendered expression is not a standalone query") + continue + sql, params = example.sql_and_params + print(backend.execute(sql, params).data) + finally: + try: + cleanup_example_data(backend, dialect) + finally: + backend.disconnect() + + +def main() -> None: + execute = "--execute" in sys.argv + dialect = PostgresDialect((16, 0, 0)) + examples = list(build_examples(dialect)) + + print("PostgreSQL SQL/XML standard capability flags:") + print(f" XMLPARSE: {dialect.supports_xmlparse()}") + print(f" XMLSERIALIZE: {dialect.supports_xmlserialize()}") + print(f" XMLELEMENT: {dialect.supports_xmlelement()}") + print(f" XMLAGG: {dialect.supports_xmlagg()}") + print(f" XMLEXISTS: {dialect.supports_xmlexists()}") + print(f" XMLTABLE: {dialect.supports_xmltable()}") + print( + f" XMLQUERY: {dialect.supports_xmlquery()} " + "(not declared as PostgreSQL standard support)" + ) + + for example in examples: + render(example) + + if execute: + run_select_examples(dialect, examples) + + +if __name__ == "__main__": + main() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py index a8d42dd0..4810f5a6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py @@ -2,7 +2,7 @@ """ PostgreSQL XML function factories. -This module provides SQL expression generators for PostgreSQL XML +This module provides SQL expression generators for PostgreSQL-specific XML functions. SQL/XML expression constructors such as XMLPARSE live in rhosocial.activerecord.backend.expression.functions.xml. @@ -12,22 +12,6 @@ - xpath_query() - Execute XPath query on XML - xpath_exists() - Test if XPath expression matches - xml_is_well_formed() - Check if XML is well-formed - -Examples: - Query XML with XPath: - >>> func = xpath_query(dialect, "/root/item", "xml_column") - >>> func.to_sql() - ('xpath(%s, xml_column)', ('/root/item',)) - - Test if XPath exists: - >>> func = xpath_exists(dialect, "/root/item", "xml_column") - >>> func.to_sql() - ('xpath_exists(%s, xml_column)', ('/root/item',)) - - Check if well-formed: - >>> func = xml_is_well_formed(dialect, '') - >>> func.to_sql() - ('xml_is_well_formed(%s)', ('',)) """ from typing import Dict, Optional, Union, TYPE_CHECKING @@ -37,7 +21,6 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase -# Import type for annotations from ..types.xml import PostgresXML @@ -48,14 +31,18 @@ def _convert_to_expression( """Convert an input value to an appropriate BaseExpression.""" if isinstance(expr, bases.BaseExpression): return expr - elif isinstance(expr, PostgresXML): + if isinstance(expr, PostgresXML): return core.Literal(dialect, expr.content) - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) + return core.Literal(dialect, expr) +def _namespaces_to_expression( + dialect: "SQLDialectBase", + namespaces: Dict[str, str], +) -> "bases.BaseExpression": + namespace_pairs = [[prefix, uri] for prefix, uri in namespaces.items()] + return core.Literal(dialect, namespace_pairs) + def xpath_query( dialect: "SQLDialectBase", @@ -63,36 +50,13 @@ def xpath_query( xml_value: Union[PostgresXML, str, "bases.BaseExpression"], namespaces: Optional[Dict[str, str]] = None, ) -> core.FunctionCall: - """Generate XPath query expression. - - The xpath function evaluates the XPath expression against the XML value, - optionally using namespace mappings. - - Args: - dialect: The SQL dialect instance - xpath: XPath expression string or expression - xml_value: XML value, PostgresXML instance, or column expression - namespaces: Optional namespace mappings (dict of prefix -> URI) - - Returns: - FunctionCall: SQL expression for xpath function - - Example: - >>> func = xpath_query(dialect, "/root/item", "xml_column") - >>> func.to_sql() - ('xpath(%s, xml_column)', ('/root/item',)) - """ + """Generate PostgreSQL xpath expression.""" xpath_expr = _convert_to_expression(dialect, xpath) xml_expr = _convert_to_expression(dialect, xml_value) args = [xpath_expr, xml_expr] - if namespaces: - ns_parts = [] - for prefix, uri in namespaces.items(): - ns_parts.append(f"{prefix}={uri}") - ns_literal = core.Literal(dialect, ",".join(ns_parts)) - args.append(ns_literal) + args.append(_namespaces_to_expression(dialect, namespaces)) return core.FunctionCall(dialect, "xpath", *args) @@ -103,36 +67,13 @@ def xpath_exists( xml_value: Union[PostgresXML, str, "bases.BaseExpression"], namespaces: Optional[Dict[str, str]] = None, ) -> core.FunctionCall: - """Generate xpath_exists expression. - - The xpath_exists function checks whether the XPath expression - matches any nodes in the XML value. - - Args: - dialect: The SQL dialect instance - xpath: XPath expression string or expression - xml_value: XML value, PostgresXML instance, or column expression - namespaces: Optional namespace mappings (dict of prefix -> URI) - - Returns: - FunctionCall: SQL expression for xpath_exists function - - Example: - >>> func = xpath_exists(dialect, "/root/item", "xml_column") - >>> func.to_sql() - ('xpath_exists(%s, xml_column)', ('/root/item',)) - """ + """Generate PostgreSQL xpath_exists expression.""" xpath_expr = _convert_to_expression(dialect, xpath) xml_expr = _convert_to_expression(dialect, xml_value) args = [xpath_expr, xml_expr] - if namespaces: - ns_parts = [] - for prefix, uri in namespaces.items(): - ns_parts.append(f"{prefix}={uri}") - ns_literal = core.Literal(dialect, ",".join(ns_parts)) - args.append(ns_literal) + args.append(_namespaces_to_expression(dialect, namespaces)) return core.FunctionCall(dialect, "xpath_exists", *args) @@ -141,22 +82,7 @@ def xml_is_well_formed( dialect: "SQLDialectBase", content: Union[PostgresXML, str, "bases.BaseExpression"], ) -> core.FunctionCall: - """Generate xml_is_well_formed expression (PostgreSQL 9.1+). - - Checks whether the input text is well-formed XML. - - Args: - dialect: The SQL dialect instance - content: XML content string, PostgresXML instance, or expression - - Returns: - FunctionCall: SQL expression for xml_is_well_formed function - - Example: - >>> func = xml_is_well_formed(dialect, '') - >>> func.to_sql() - ('xml_is_well_formed(%s)', ('',)) - """ + """Generate PostgreSQL xml_is_well_formed expression.""" content_expr = _convert_to_expression(dialect, content) return core.FunctionCall(dialect, "xml_is_well_formed", content_expr) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py index 33275e1a..cf057a1e 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py @@ -180,17 +180,50 @@ def test_xml_functions_require_pg_8_3(self): for func in xml_functions: assert result_new.get(func).supported - def test_xmlparse_is_expression_capability(self): - """Test that XMLPARSE is exposed as SQL/XML capability.""" + def test_sqlxml_expression_constructors_are_capabilities(self): + """Test that SQL/XML constructors are exposed as capabilities.""" + constructors = { + "xmlagg", + "xmlattributes", + "xmlcomment", + "xmlconcat", + "xmlelement", + "xmlexists", + "xmlforest", + "xmlparse", + "xmlpi", + "xmlquery", + "xmlroot", + "xmlserialize", + "xmltable", + } + dialect_old = PostgresDialect(version=(8, 2, 99)) result_old = dialect_old.supports_functions() - assert "xmlparse" not in result_old + assert constructors.isdisjoint(result_old) assert dialect_old.supports_xmlparse() is False + assert dialect_old.supports_xmlserialize() is False + assert dialect_old.supports_xmlelement() is False + assert dialect_old.supports_xmlagg() is False + assert dialect_old.supports_xmlexists() is False + assert dialect_old.supports_xmltable() is False - dialect_new = PostgresDialect(version=(8, 3, 0)) + dialect_new = PostgresDialect(version=(10, 0, 0)) result_new = dialect_new.supports_functions() - assert "xmlparse" not in result_new + assert constructors.isdisjoint(result_new) assert dialect_new.supports_xmlparse() is True + assert dialect_new.supports_xmlserialize() is True + assert dialect_new.supports_xmlelement() is True + assert dialect_new.supports_xmlattributes() is True + assert dialect_new.supports_xmlforest() is True + assert dialect_new.supports_xmlconcat() is True + assert dialect_new.supports_xmlcomment() is True + assert dialect_new.supports_xmlpi() is True + assert dialect_new.supports_xmlroot() is True + assert dialect_new.supports_xmlagg() is True + assert dialect_new.supports_xmlquery() is False + assert dialect_new.supports_xmlexists() is True + assert dialect_new.supports_xmltable() is True def test_xml_is_well_formed_requires_pg_9_1(self): """Test that xml_is_well_formed requires PostgreSQL 9.1+.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py index e8c1fac5..b808783f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py @@ -78,6 +78,11 @@ def get_postgres_protocols(): protocols = [ dialect_protocols.SQLXMLSupport, + dialect_protocols.SQLXMLParsingSupport, + dialect_protocols.SQLXMLSerializationSupport, + dialect_protocols.SQLXMLConstructionSupport, + dialect_protocols.SQLXMLAggregationSupport, + dialect_protocols.SQLXMLQueryingSupport, dialect_protocols.CollationSupport, dialect_protocols.CTESupport, dialect_protocols.FilterClauseSupport, @@ -167,6 +172,17 @@ def test_no_interface_overlap_between_protocols(self): ('PostgresLockingSupport', 'LockingSupport'), ('TriggerSupport', 'PostgresTriggerSupport'), ('PostgresTriggerSupport', 'TriggerSupport'), + # SQLXMLSupport aggregates standard SQL/XML capability-family protocols + ('SQLXMLSupport', 'SQLXMLParsingSupport'), + ('SQLXMLParsingSupport', 'SQLXMLSupport'), + ('SQLXMLSupport', 'SQLXMLSerializationSupport'), + ('SQLXMLSerializationSupport', 'SQLXMLSupport'), + ('SQLXMLSupport', 'SQLXMLConstructionSupport'), + ('SQLXMLConstructionSupport', 'SQLXMLSupport'), + ('SQLXMLSupport', 'SQLXMLAggregationSupport'), + ('SQLXMLAggregationSupport', 'SQLXMLSupport'), + ('SQLXMLSupport', 'SQLXMLQueryingSupport'), + ('SQLXMLQueryingSupport', 'SQLXMLSupport'), # PG enhanced protocols extend generic protocols with same method names ('JSONSupport', 'PostgresJSONBEnhancedSupport'), ('PostgresJSONBEnhancedSupport', 'JSONSupport'), From 0502c9a265f2c11550b712cf0f3f6ceec9289b6b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 4 Jun 2026 21:20:05 +0800 Subject: [PATCH 3/3] chore: add changelog fragment for PR #41 Co-Authored-By: Claude Opus 4.7 --- changelog.d/41.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/41.added.md diff --git a/changelog.d/41.added.md b/changelog.d/41.added.md new file mode 100644 index 00000000..04b95749 --- /dev/null +++ b/changelog.d/41.added.md @@ -0,0 +1 @@ +Added PostgreSQL support for core SQL/XML standard expressions, including XML construction, serialization, aggregation, and query/table expression capability declarations.