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.
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py
index c49a8534..2863d622 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,12 @@
ConstraintMixin,
)
from rhosocial.activerecord.backend.dialect.protocols import (
+ SQLXMLSupport,
+ SQLXMLParsingSupport,
+ SQLXMLSerializationSupport,
+ SQLXMLConstructionSupport,
+ SQLXMLAggregationSupport,
+ SQLXMLQueryingSupport,
CollationSupport,
CTESupport,
FilterClauseSupport,
@@ -226,6 +233,7 @@
class PostgresDialect(
SQLDialectBase,
+ SQLXMLMixin,
CollationMixin,
SetOperationMixin,
TruncateMixin,
@@ -316,6 +324,12 @@ class PostgresDialect(
PostgresStoredProcedureMixin,
PostgresAdvisoryLockMixin,
# Protocol supports
+ SQLXMLSupport,
+ SQLXMLParsingSupport,
+ SQLXMLSerializationSupport,
+ SQLXMLConstructionSupport,
+ SQLXMLAggregationSupport,
+ SQLXMLQueryingSupport,
CollationSupport,
SetOperationSupport,
TruncateSupport,
@@ -471,6 +485,58 @@ 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_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
@@ -1637,9 +1703,25 @@ def supports_functions(self) -> Dict[str, "FunctionSupportInfo"]:
)
from rhosocial.activerecord.backend.impl.postgres import functions as postgres_functions
+ 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:
- 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/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, "- alpha
- beta
"),
+ 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/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..4810f5a6 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py
@@ -2,38 +2,16 @@
"""
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.
+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.
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()
- ('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
@@ -43,7 +21,6 @@
if TYPE_CHECKING:
from rhosocial.activerecord.backend.dialect import SQLDialectBase
-# Import type for annotations
from ..types.xml import PostgresXML
@@ -54,82 +31,17 @@ 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)
-
-
-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}"
+ return core.Literal(dialect, expr)
- 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(
+def _namespaces_to_expression(
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)
+ 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(
@@ -138,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)
@@ -178,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)
@@ -216,28 +82,12 @@ 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)
__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..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
@@ -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,51 @@ def test_xml_functions_require_pg_8_3(self):
for func in xml_functions:
assert result_new.get(func).supported
+ 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 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=(10, 0, 0))
+ result_new = dialect_new.supports_functions()
+ 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+."""
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..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
@@ -77,6 +77,12 @@ def get_postgres_protocols():
from typing import runtime_checkable
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,
@@ -166,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'),