From 0997a6a90cdeda108b44e4ff727933bebda048e7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 3 Jun 2026 23:12:14 +0800 Subject: [PATCH 1/5] feat(postgres): add expression-level COLLATE support Add PostgreSQL collation validation and dialect formatting so COLLATE expressions remain schema-aware and safe for query composition. Co-Authored-By: Claude Opus 4.7 --- .../backend/impl/postgres/__init__.py | 1 + .../backend/impl/postgres/collation.py | 43 +++++++++++ .../backend/impl/postgres/dialect.py | 19 +++++ .../test_postgres_protocol_conformance.py | 1 + .../query/test_collation_expression.py | 71 +++++++++++++++++++ 5 files changed, 135 insertions(+) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/collation.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py index 58696e0b..83276d75 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py @@ -18,6 +18,7 @@ """ from .backend import PostgresBackend, AsyncPostgresBackend +from .collation import PostgresCollation from .config import PostgresConnectionConfig from .dialect import PostgresDialect from .transaction import PostgresTransactionManager, AsyncPostgresTransactionManager diff --git a/src/rhosocial/activerecord/backend/impl/postgres/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/collation.py new file mode 100644 index 00000000..ec422542 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/collation.py @@ -0,0 +1,43 @@ +# src/rhosocial/activerecord/backend/impl/postgres/collation.py +""" +PostgreSQL collation names supported by the dialect whitelist. +""" + +from enum import Enum +from typing import Optional, Tuple + + +class PostgresCollation(Enum): + """Common PostgreSQL collations for expression-level COLLATE.""" + + C = "C" + POSIX = "POSIX" + UCS_BASIC = "ucs_basic" + UND_X_ICU = "und-x-icu" + + +_BUILTIN_COLLATIONS = { + PostgresCollation.C.value, + PostgresCollation.POSIX.value, + PostgresCollation.UCS_BASIC.value, +} + +_VERSION_SENSITIVE_COLLATIONS = { + PostgresCollation.UND_X_ICU.value: (10, 0, 0), +} + +_POSTGRES_COLLATIONS = _BUILTIN_COLLATIONS | set(_VERSION_SENSITIVE_COLLATIONS) + + +def validate_postgres_collation_name( + name: str, + version: Optional[Tuple[int, int, int]] = None, +) -> str: + if name not in _POSTGRES_COLLATIONS: + raise ValueError(f"Unsupported PostgreSQL collation: {name!r}") + required_version = _VERSION_SENSITIVE_COLLATIONS.get(name) + if required_version is not None and version is not None and version < required_version: + raise ValueError( + f"PostgreSQL collation requires PostgreSQL {required_version[0]}.{required_version[1]}+: {name!r}" + ) + return name diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index a38bdf83..49cb1cd0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -13,6 +13,7 @@ from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.dialect.mixins import ( + CollationMixin, CTEMixin, FilterClauseMixin, WindowFunctionMixin, @@ -41,6 +42,7 @@ ConstraintMixin, ) from rhosocial.activerecord.backend.dialect.protocols import ( + CollationSupport, CTESupport, FilterClauseSupport, WindowFunctionSupport, @@ -133,6 +135,8 @@ PostgresIntrospectionCapabilityMixin, ) +from .collation import validate_postgres_collation_name + # PostgreSQL-specific imports from .protocols import ( PostgresExtensionSupport, @@ -221,6 +225,7 @@ class PostgresDialect( SQLDialectBase, + CollationMixin, SetOperationMixin, TruncateMixin, ILIKEMixin, @@ -310,6 +315,7 @@ class PostgresDialect( PostgresStoredProcedureMixin, PostgresAdvisoryLockMixin, # Protocol supports + CollationSupport, SetOperationSupport, TruncateSupport, ILIKESupport, @@ -464,6 +470,19 @@ def get_server_version(self) -> Tuple[int, int, int]: """Return the PostgreSQL version this dialect is configured for.""" return self.version + def supports_collate_expression(self) -> bool: + """PostgreSQL supports expression-level COLLATE.""" + return True + + def format_collation_name(self, collation) -> str: + """Format PostgreSQL collation names with whitelist validation.""" + if collation.keyword is not None: + raise ValueError(f"Unsupported PostgreSQL collation keyword: {collation.keyword!r}") + validate_postgres_collation_name(collation.name, getattr(self, "version", None)) + if collation.schema is not None: + return f"{self.format_identifier(collation.schema)}.{self.format_identifier(collation.name)}" + return self.format_identifier(collation.name) + # region Protocol Support Checks based on version def supports_basic_cte(self) -> bool: """Basic CTEs are supported since PostgreSQL 8.4.""" 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 cd445d76..32952cee 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.CollationSupport, dialect_protocols.CTESupport, dialect_protocols.FilterClauseSupport, dialect_protocols.WindowFunctionSupport, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py new file mode 100644 index 00000000..ee501107 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py @@ -0,0 +1,71 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py +""" +Tests for expression-level COLLATE support on PostgreSQL. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import CollationName, Column, Literal +from rhosocial.activerecord.backend.impl.postgres import PostgresCollation, PostgresDialect + + +@pytest.fixture +def dialect(): + return PostgresDialect(version=(15, 0, 0)) + + +class TestPostgresCollationExpression: + def test_column_collate_generates_sql(self, dialect): + expr = Column(dialect, "name", table="users").collate(PostgresCollation.C) + + sql, params = expr.to_sql() + + assert sql == '"users"."name" COLLATE "C"' + assert params == () + + def test_literal_collate_preserves_parameter_binding(self, dialect): + expr = Literal(dialect, "Alice").collate(PostgresCollation.POSIX) + + sql, params = expr.to_sql() + + assert sql == '%s COLLATE "POSIX"' + assert params == ("Alice",) + + def test_schema_qualified_collation_generates_sql(self, dialect): + expr = Column(dialect, "name").collate( + CollationName(PostgresCollation.C.value, schema="pg_catalog") + ) + + sql, params = expr.to_sql() + + assert sql == '"name" COLLATE "pg_catalog"."C"' + assert params == () + + def test_rejects_unsupported_collation(self, dialect): + expr = Column(dialect, "name").collate("unknown_ci") + + with pytest.raises(ValueError, match="Unsupported PostgreSQL collation"): + expr.to_sql() + + def test_rejects_version_sensitive_collation_on_older_version(self): + dialect = PostgresDialect(version=(9, 6, 0)) + expr = Column(dialect, "name").collate(PostgresCollation.UND_X_ICU) + + with pytest.raises(ValueError, match="requires PostgreSQL 10.0"): + expr.to_sql() + + def test_collate_executes_order_by(self, annotated_query_fixtures): + SearchableItem = ( + annotated_query_fixtures[0] + if isinstance(annotated_query_fixtures, tuple) + else annotated_query_fixtures + ) + SearchableItem(name="b", tags=[]).save() + SearchableItem(name="A", tags=[]).save() + SearchableItem(name="c", tags=[]).save() + + results = SearchableItem.query().order_by( + SearchableItem.c.name.collate(PostgresCollation.C) + ).all() + + assert [item.name for item in results] == ["A", "b", "c"] From 9185a823a89b894fe5ca54c98b8b116727ec0c40 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 3 Jun 2026 23:34:09 +0800 Subject: [PATCH 2/5] refactor(postgres): validate COLLATE names via dialect hook Return the PostgreSQL collation SQL representation from validate_collation_name so COLLATE formatting validates backend-specific names in one place. Co-Authored-By: Claude Opus 4.7 --- src/rhosocial/activerecord/backend/impl/postgres/dialect.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 49cb1cd0..ce8a3685 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -474,8 +474,8 @@ def supports_collate_expression(self) -> bool: """PostgreSQL supports expression-level COLLATE.""" return True - def format_collation_name(self, collation) -> str: - """Format PostgreSQL collation names with whitelist validation.""" + def validate_collation_name(self, collation) -> str: + """Validate PostgreSQL collation names and return their SQL representation.""" if collation.keyword is not None: raise ValueError(f"Unsupported PostgreSQL collation keyword: {collation.keyword!r}") validate_postgres_collation_name(collation.name, getattr(self, "version", None)) From c151e76be52afa2a5dbfc9c534c8e6cdeeab99f6 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 3 Jun 2026 23:43:12 +0800 Subject: [PATCH 3/5] style(postgres): type COLLATE validation hook parameter Annotate validate_collation_name with CollationName so the PostgreSQL dialect hook documents the structured collation argument it receives. Co-Authored-By: Claude Opus 4.7 --- src/rhosocial/activerecord/backend/impl/postgres/dialect.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index ce8a3685..ede1b00d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -9,6 +9,7 @@ from typing import Any, Dict, Tuple, Optional, TYPE_CHECKING if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.collation import CollationName from .function_versions import FunctionSupportInfo, FunctionVersionRequirement from rhosocial.activerecord.backend.dialect.base import SQLDialectBase @@ -474,7 +475,7 @@ def supports_collate_expression(self) -> bool: """PostgreSQL supports expression-level COLLATE.""" return True - def validate_collation_name(self, collation) -> str: + def validate_collation_name(self, collation: "CollationName") -> str: """Validate PostgreSQL collation names and return their SQL representation.""" if collation.keyword is not None: raise ValueError(f"Unsupported PostgreSQL collation keyword: {collation.keyword!r}") From 99fd928dda67e7e7b70c7868895891631ac7803f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 4 Jun 2026 00:20:37 +0800 Subject: [PATCH 4/5] refactor(postgres): accept collation expression in validation Adapt COLLATE validation to consume the full CollateExpression while preserving PostgreSQL schema-qualified collation handling. Co-Authored-By: Claude Opus 4.7 --- .../backend/impl/postgres/dialect.py | 19 +++++++++++-------- .../query/test_collation_expression.py | 6 ++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index ede1b00d..c49a8534 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -9,7 +9,7 @@ from typing import Any, Dict, Tuple, Optional, TYPE_CHECKING if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.collation import CollationName + from rhosocial.activerecord.backend.expression.collation import CollateExpression from .function_versions import FunctionSupportInfo, FunctionVersionRequirement from rhosocial.activerecord.backend.dialect.base import SQLDialectBase @@ -475,14 +475,17 @@ def supports_collate_expression(self) -> bool: """PostgreSQL supports expression-level COLLATE.""" return True - def validate_collation_name(self, collation: "CollationName") -> str: + def validate_collation_name(self, expr: "CollateExpression") -> str: """Validate PostgreSQL collation names and return their SQL representation.""" - if collation.keyword is not None: - raise ValueError(f"Unsupported PostgreSQL collation keyword: {collation.keyword!r}") - validate_postgres_collation_name(collation.name, getattr(self, "version", None)) - if collation.schema is not None: - return f"{self.format_identifier(collation.schema)}.{self.format_identifier(collation.name)}" - return self.format_identifier(collation.name) + schema = expr.collation_options.get("schema") + unsupported = set(expr.collation_options) - {"schema"} + if unsupported: + options = ", ".join(sorted(unsupported)) + raise UnsupportedFeatureError(self.name, f"COLLATE options: {options}") + validate_postgres_collation_name(expr.collation_name, getattr(self, "version", None)) + if schema is not None: + return f"{self.format_identifier(str(schema))}.{self.format_identifier(expr.collation_name)}" + return self.format_identifier(expr.collation_name) # region Protocol Support Checks based on version def supports_basic_cte(self) -> bool: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py index ee501107..93b67fbd 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py @@ -5,7 +5,7 @@ import pytest -from rhosocial.activerecord.backend.expression import CollationName, Column, Literal +from rhosocial.activerecord.backend.expression import Column, Literal from rhosocial.activerecord.backend.impl.postgres import PostgresCollation, PostgresDialect @@ -32,9 +32,7 @@ def test_literal_collate_preserves_parameter_binding(self, dialect): assert params == ("Alice",) def test_schema_qualified_collation_generates_sql(self, dialect): - expr = Column(dialect, "name").collate( - CollationName(PostgresCollation.C.value, schema="pg_catalog") - ) + expr = Column(dialect, "name").collate(PostgresCollation.C.value, schema="pg_catalog") sql, params = expr.to_sql() From bd39475a60de707b361cc322fbe4af6e9473baac Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 4 Jun 2026 12:45:44 +0800 Subject: [PATCH 5/5] chore: add changelog fragment for PR #40 Co-Authored-By: Claude Opus 4.7 --- changelog.d/40.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/40.added.md diff --git a/changelog.d/40.added.md b/changelog.d/40.added.md new file mode 100644 index 00000000..f91642a1 --- /dev/null +++ b/changelog.d/40.added.md @@ -0,0 +1 @@ +Added PostgreSQL expression-level `COLLATE` support with schema-qualified collation validation.