From 45b0c83ffce3c1b0129043c8e47e1d9d394cc014 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 19 Sep 2026 20:40:04 +0800 Subject: [PATCH 01/42] fix(postgres): drop index by name without ON --- .github/workflows/test.yml | 8 ++++---- .../backend/impl/postgres/mixins/ddl/index.py | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2100cb73..44f826f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,7 +68,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create postgres scenario config @@ -279,7 +279,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create postgres scenario config @@ -391,7 +391,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create postgres scenario config @@ -490,7 +490,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create postgres scenario config diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py index fe60a722..85e6a0ce 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py @@ -172,6 +172,10 @@ def supports_partial_index(self) -> bool: """PostgreSQL supports partial indexes with WHERE clause.""" return True + def supports_drop_index_on_table(self) -> bool: + """PostgreSQL drops indexes by name without an ON
clause.""" + return False + def supports_functional_index(self) -> bool: """PostgreSQL supports expression/functional indexes.""" return True From 33f1e28fa0f0a5cef16fd90ceba15ddb2b443c59 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 08:43:51 +0800 Subject: [PATCH 02/42] feat(postgres): add PostgresPartitionClause derived from generic clause Add a PostgreSQL-owned partition clause type derived from the generic PartitionClause for capability gating and future PG-only partition parameters; concrete partitions remain created via the separate CREATE TABLE ... PARTITION OF statement. Export it and cover it with tests. --- .../impl/postgres/expression/__init__.py | 2 + .../impl/postgres/expression/ddl/__init__.py | 2 + .../impl/postgres/expression/ddl/partition.py | 17 ++++++++ .../backend/postgres/test_ddl_expressions.py | 41 +++++++++++++++++++ 4 files changed, 62 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 98684799..9801f3f3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -22,6 +22,7 @@ from .ddl import ( PostgresVacuumExpression, PostgresAnalyzeExpression, + PostgresPartitionClause, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, @@ -114,6 +115,7 @@ # ddl "PostgresVacuumExpression", "PostgresAnalyzeExpression", + "PostgresPartitionClause", "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", "PostgresAttachPartitionExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index 2f45fd40..727ba7e6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -62,6 +62,7 @@ from .vacuum import PostgresVacuumExpression, PostgresAnalyzeExpression from .partition import ( PartitionValue, + PostgresPartitionClause, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, @@ -153,6 +154,7 @@ "PostgresAnalyzeExpression", # partition "PartitionValue", + "PostgresPartitionClause", "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", "PostgresAttachPartitionExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py index c71501fe..d846c8b2 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py @@ -20,6 +20,7 @@ from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements import PartitionClause if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -27,6 +28,7 @@ __all__ = [ "PartitionValue", + "PostgresPartitionClause", "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", "PostgresAttachPartitionExpression", @@ -34,6 +36,21 @@ ] +class PostgresPartitionClause(PartitionClause): + """PostgreSQL ``PARTITION BY {RANGE|LIST|HASH} (...)`` clause. + + PostgreSQL's declarative partitioning shares the generic clause shape, so + this subclass currently adds no fields; it exists as the PostgreSQL-owned + clause type (for capability gating and future PG-only partition parameters) + and to distinguish PG-declared candidates during backend selection. + + Concrete partitions are **not** declared inline: PostgreSQL creates them + through the separate ``CREATE TABLE ... PARTITION OF`` statement + (``PostgresCreatePartitionExpression``). This class therefore remains + clause-only and never carries ``PartitionDefinition`` values. + """ + + class PartitionValue(BaseExpression): """Represents a partition bound value in PostgreSQL DDL. diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py index c729efa5..fc6e3a34 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py @@ -365,6 +365,47 @@ def test_key_partitioning_is_rejected(self, dialect): keys=[Column(dialect, "id")], ) + def test_postgres_partition_clause_is_generic_subclass(self, dialect): + """PostgresPartitionClause derives from the generic PartitionClause.""" + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresPartitionClause, + ) + + clause = PostgresPartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ) + assert isinstance(clause, PartitionClause) + sql, params = clause.to_sql() + assert sql == ' PARTITION BY RANGE ("created_at")' + assert params == () + + def test_postgres_partition_clause_in_create_table(self, dialect): + """CreateTableExpression accepts the PostgreSQL-owned clause subclass.""" + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresPartitionClause, + ) + + expr = CreateTableExpression( + dialect=dialect, + table="events", + columns=[ + ColumnDefinition( + dialect, + "created_at", TimestampType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + ) + ], + partition=PostgresPartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ), + ) + sql, _ = expr.to_sql() + assert "PARTITION BY RANGE" in sql + def test_multi_column_range_partitioned_parent_table(self, dialect): """Test creating a RANGE-partitioned parent table with multiple partition keys.""" expr = CreateTableExpression( From 9b58331205739e6eb071ba37b4f69a563dc49eee Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 11:05:24 +0800 Subject: [PATCH 03/42] feat(postgres): add PostgresColumnDefinition and typed column options Introduce the PostgreSQL-specific column definition (and options) derived from the generic ColumnDefinition / ColumnOptions, carrying typed COMPRESSION / STORAGE / STATISTICS attributes (no dialect_options bag). The PostgreSQL format_column_definition accepts both the generic and PostgreSQL column definitions and renders the PostgreSQL-only attributes; it no longer reads dialect_options["identity"] (the typed identity field is authoritative). Export the new classes and cover them with tests. --- .../impl/postgres/expression/__init__.py | 6 + .../impl/postgres/expression/ddl/__init__.py | 9 ++ .../impl/postgres/expression/ddl/column.py | 138 ++++++++++++++++++ .../backend/impl/postgres/mixins/ddl_table.py | 20 ++- .../test_postgres_column_definition.py | 91 ++++++++++++ 5 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 9801f3f3..835eb459 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -20,6 +20,9 @@ ) from .ilike import ILIKEExpression from .ddl import ( + PostgresColumnStorage, + PostgresColumnDefinition, + PostgresColumnOptions, PostgresVacuumExpression, PostgresAnalyzeExpression, PostgresPartitionClause, @@ -115,6 +118,9 @@ # ddl "PostgresVacuumExpression", "PostgresAnalyzeExpression", + "PostgresColumnStorage", + "PostgresColumnDefinition", + "PostgresColumnOptions", "PostgresPartitionClause", "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index 727ba7e6..f465937c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -60,6 +60,11 @@ """ from .vacuum import PostgresVacuumExpression, PostgresAnalyzeExpression +from .column import ( + PostgresColumnStorage, + PostgresColumnDefinition, + PostgresColumnOptions, +) from .partition import ( PartitionValue, PostgresPartitionClause, @@ -152,6 +157,10 @@ # vacuum "PostgresVacuumExpression", "PostgresAnalyzeExpression", + # column + "PostgresColumnStorage", + "PostgresColumnDefinition", + "PostgresColumnOptions", # partition "PartitionValue", "PostgresPartitionClause", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py new file mode 100644 index 00000000..68ac7a91 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py @@ -0,0 +1,138 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py +"""PostgreSQL-specific column definition expressions. + +PostgreSQL extends the standard column definition with per-column storage +attributes that have no generic equivalent: + +* ``COMPRESSION `` — column compression (PG 14+): ``pglz`` / ``lz4``. +* ``STORAGE {PLAIN|EXTERNAL|EXTENDED|MAIN|DEFAULT}`` — TOAST storage strategy. +* ``STATISTICS `` — per-column statistics target (0..10000). + +These live on ``PostgresColumnDefinition`` (deriving the generic +``ColumnDefinition``) and are rendered by the PostgreSQL +``format_column_definition`` override. They are declared through +``PostgresColumnOptions`` (deriving the generic ``ColumnOptions``). +""" + +from enum import Enum +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.base.ddl.options import ColumnOptions + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresColumnStorage", + "PostgresColumnDefinition", + "PostgresColumnOptions", +] + + +class PostgresColumnStorage(Enum): + """PostgreSQL TOAST ``STORAGE`` strategies.""" + + PLAIN = "PLAIN" + EXTERNAL = "EXTERNAL" + EXTENDED = "EXTENDED" + MAIN = "MAIN" + DEFAULT = "DEFAULT" + + +class PostgresColumnDefinition(ColumnDefinition): + """A PostgreSQL column definition extending the generic one. + + Adds PostgreSQL-only typed attributes: ``compression``, ``storage`` and + ``statistics``. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + data_type, + constraints=None, + comment: Optional[str] = None, + generated_expression=None, + identity: Optional[str] = None, + identity_start: Optional[int] = None, + identity_increment: Optional[int] = None, + identity_clause=None, + *, + compression: Optional[str] = None, + storage: Optional[PostgresColumnStorage] = None, + statistics: Optional[int] = None, + ): + super().__init__( + dialect, + name, + data_type, + constraints=constraints, + comment=comment, + generated_expression=generated_expression, + identity=identity, + identity_start=identity_start, + identity_increment=identity_increment, + identity_clause=identity_clause, + ) + if storage is not None and not isinstance(storage, PostgresColumnStorage): + raise TypeError( + "storage must be a PostgresColumnStorage value, " + f"got {type(storage).__name__}" + ) + if statistics is not None and ( + not isinstance(statistics, int) or isinstance(statistics, bool) + or statistics < 0 or statistics > 10000 + ): + raise ValueError("statistics must be an integer in 0..10000") + self.compression = compression + self.storage = storage + self.statistics = statistics + + +class PostgresColumnOptions(ColumnOptions): + """PostgreSQL per-column options declaration.""" + + def __init__( + self, + *, + identity_start: Optional[int] = None, + identity_increment: Optional[int] = None, + compression: Optional[str] = None, + storage: Optional[PostgresColumnStorage] = None, + statistics: Optional[int] = None, + ): + super().__init__( + identity_start=identity_start, + identity_increment=identity_increment, + ) + if storage is not None and not isinstance(storage, PostgresColumnStorage): + raise TypeError( + "storage must be a PostgresColumnStorage value, " + f"got {type(storage).__name__}" + ) + if statistics is not None and ( + not isinstance(statistics, int) or isinstance(statistics, bool) + or statistics < 0 or statistics > 10000 + ): + raise ValueError("statistics must be an integer in 0..10000") + self.compression = compression + self.storage = storage + self.statistics = statistics + + def column_definition_class(self): + """Build a ``PostgresColumnDefinition`` for these options.""" + return PostgresColumnDefinition + + def apply_to(self, column) -> None: + """Transfer the PostgreSQL-only fields onto the column definition.""" + if not isinstance(column, PostgresColumnDefinition): + raise TypeError( + "PostgresColumnOptions.apply_to requires a PostgresColumnDefinition, " + f"got {type(column).__name__}" + ) + column.compression = self.compression + column.storage = self.storage + column.statistics = self.statistics diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py index 77d6e8fb..722ee7e8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py @@ -134,7 +134,17 @@ def format_create_table_like_statement(self, expr) -> Tuple[str, tuple]: return " ".join(parts), tuple(table_params) + tuple(source_params) def format_column_definition(self, col_def) -> Tuple[str, tuple]: + """Format a single column definition with PostgreSQL-specific syntax. + + Accepts both the generic ``ColumnDefinition`` and the PostgreSQL + ``PostgresColumnDefinition``; the latter's PostgreSQL-only attributes + (``compression`` / ``storage`` / ``statistics``) are rendered here. + """ from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.column import ( + PostgresColumnDefinition, + ) + all_params: List[Any] = [] type_sql, _ = col_def.data_type.to_sql() if not re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", type_sql): @@ -145,9 +155,6 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: col_sql = f"{self.format_identifier(col_def.name)} {type_sql}" identity = getattr(col_def, 'identity', None) - if not identity: - dialect_opts = col_def.dialect_options or {} - identity = dialect_opts.get("identity") if identity: if identity.upper() in ("ALWAYS", "BY DEFAULT"): col_sql += f" GENERATED {identity.upper()} AS IDENTITY" @@ -168,4 +175,11 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: gen_sql, gen_params = col_def.generated_expression.to_sql() col_sql += gen_sql all_params.extend(gen_params) + if isinstance(col_def, PostgresColumnDefinition): + if col_def.compression: + col_sql += f" COMPRESSION {col_def.compression}" + if col_def.storage is not None: + col_sql += f" STORAGE {col_def.storage.value}" + if col_def.statistics is not None: + col_sql += f" STATISTICS {col_def.statistics}" return col_sql, tuple(all_params) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py new file mode 100644 index 00000000..6cc7ab8b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py @@ -0,0 +1,91 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py +"""Tests for the PostgreSQL-specific column definition expressions.""" + +import pytest + +from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression.types import TextType +from rhosocial.activerecord.backend.impl.postgres import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression import ( + PostgresColumnDefinition, + PostgresColumnOptions, + PostgresColumnStorage, +) + + +@pytest.fixture +def dialect(): + return PostgresDialect((15, 0, 0)) + + +def _column(dialect, **kwargs): + return PostgresColumnDefinition(dialect, "body", TextType(dialect), **kwargs) + + +def test_derives_generic_column_definition(): + assert ColumnDefinition in PostgresColumnDefinition.__mro__ + + +def test_compression(dialect): + sql, _ = _column(dialect, compression="lz4").to_sql() + assert sql == '"body" TEXT COMPRESSION lz4' + + +def test_storage(dialect): + sql, _ = _column(dialect, storage=PostgresColumnStorage.EXTENDED).to_sql() + assert sql == '"body" TEXT STORAGE EXTENDED' + + +def test_statistics(dialect): + sql, _ = _column(dialect, statistics=500).to_sql() + assert sql == '"body" TEXT STATISTICS 500' + + +def test_combined(dialect): + sql, _ = _column( + dialect, + compression="pglz", + storage=PostgresColumnStorage.EXTERNAL, + statistics=100, + ).to_sql() + assert sql == '"body" TEXT COMPRESSION pglz STORAGE EXTERNAL STATISTICS 100' + + +def test_generic_column_still_renders_on_postgres(dialect): + generic = ColumnDefinition(dialect, "body", TextType(dialect), comment="c") + sql, _ = generic.to_sql() + assert sql == '"body" TEXT COMMENT \'c\'' + + +def test_invalid_storage_type(dialect): + with pytest.raises(TypeError, match="PostgresColumnStorage"): + _column(dialect, storage="EXTENDED") + + +def test_statistics_out_of_range(): + with pytest.raises(ValueError, match="0..10000"): + PostgresColumnOptions(statistics=20000) + + +def test_options_select_postgres_column_class(): + assert PostgresColumnOptions(compression="lz4").column_definition_class() is ( + PostgresColumnDefinition + ) + + +def test_options_apply_to(dialect): + options = PostgresColumnOptions( + compression="lz4", storage=PostgresColumnStorage.MAIN, statistics=10 + ) + col = PostgresColumnDefinition(dialect, "c", TextType(dialect)) + options.apply_to(col) + assert col.compression == "lz4" + assert col.storage is PostgresColumnStorage.MAIN + assert col.statistics == 10 + + +def test_options_apply_to_rejects_generic_column(dialect): + options = PostgresColumnOptions(compression="lz4") + generic = ColumnDefinition(dialect, "c", TextType(dialect)) + with pytest.raises(TypeError, match="PostgresColumnDefinition"): + options.apply_to(generic) From 0a1e635fcf764ce489c085521e7579d3d3ec603a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 12:02:49 +0800 Subject: [PATCH 04/42] feat(postgres): add PostgresCreateTableOptions and render UNLOGGED Introduce PostgresCreateTableOptions (deriving the generic CreateTableOptions) carrying the PostgreSQL-only UNLOGGED header modifier. The generic format_create_table_options no longer renders UNLOGGED; PostgreSQL overrides it to add UNLOGGED and accepts both the generic and PostgreSQL options. UNLOGGED/TEMPORARY mutual-exclusion is checked via isinstance. --- .../impl/postgres/expression/ddl/__init__.py | 3 ++ .../postgres/expression/ddl/table_options.py | 38 +++++++++++++++++++ .../backend/impl/postgres/mixins/ddl_table.py | 26 ++++++++++++- 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_options.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index f465937c..0ffcbc26 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -60,6 +60,7 @@ """ from .vacuum import PostgresVacuumExpression, PostgresAnalyzeExpression +from .table_options import PostgresCreateTableOptions from .column import ( PostgresColumnStorage, PostgresColumnDefinition, @@ -157,6 +158,8 @@ # vacuum "PostgresVacuumExpression", "PostgresAnalyzeExpression", + # table options + "PostgresCreateTableOptions", # column "PostgresColumnStorage", "PostgresColumnDefinition", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_options.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_options.py new file mode 100644 index 00000000..78f25029 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_options.py @@ -0,0 +1,38 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_options.py +"""PostgreSQL-specific CREATE TABLE options. + +PostgreSQL adds the ``UNLOGGED`` header modifier to ``CREATE TABLE``, which +has no generic equivalent. It lives on ``PostgresCreateTableOptions`` (deriving +the generic ``CreateTableOptions``) and is rendered by the PostgreSQL +``format_create_table_options`` override. +""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import CreateTableOptions + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresCreateTableOptions", +] + + +class PostgresCreateTableOptions(CreateTableOptions): + """A PostgreSQL CREATE TABLE options declaration extending the generic one. + + Adds the PostgreSQL-only ``unlogged`` header modifier. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + *, + or_replace: bool = False, + comment: Optional[str] = None, + unlogged: bool = False, + ): + super().__init__(dialect, or_replace=or_replace, comment=comment) + self.unlogged = unlogged diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py index 722ee7e8..38ec38da 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py @@ -40,6 +40,27 @@ def supports_create_table_like(self) -> bool: """PostgreSQL supports CREATE TABLE (LIKE ...) with INCLUDING/EXCLUDING options.""" return True + def format_create_table_options(self, expr) -> Tuple[str, tuple]: + """Format the CREATE header modifiers for PostgreSQL. + + Accepts both the generic ``CreateTableOptions`` (renders ``OR REPLACE``) + and the PostgreSQL ``PostgresCreateTableOptions`` (adds ``UNLOGGED``). + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.table_options import ( + PostgresCreateTableOptions, + ) + + base_sql, params = super().format_create_table_options(expr) + parts = [base_sql] if base_sql else [] + if isinstance(expr, PostgresCreateTableOptions) and expr.unlogged: + if not self.supports_unlogged_table(): + raise UnsupportedFeatureError(self.name, "CREATE UNLOGGED TABLE") + parts.append("UNLOGGED") + return " ".join(parts), params + def format_create_table_statement(self, expr) -> Tuple[str, tuple]: """Render CREATE TABLE for PostgreSQL. @@ -59,9 +80,12 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: # Validate through the PartitionClause -> format_partition_clause chain. expr.partition.to_sql() + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.table_options import ( + PostgresCreateTableOptions, + ) table_options = getattr(expr, "table_options", None) if ( - table_options is not None + isinstance(table_options, PostgresCreateTableOptions) and table_options.unlogged and getattr(expr, "temporary", False) ): From 0a530c616b7cf35c0a71ceaf5a83fa592ba80723 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 12:46:48 +0800 Subject: [PATCH 05/42] test(postgres): use PostgresCreateTableOptions for unlogged tests UNLOGGED now lives on the typed PostgresCreateTableOptions; update the CreateTableOptions(unlogged=...) tests and the capability docstring. --- .../backend/impl/postgres/protocols/table.py | 4 ++-- .../backend/dialect/test_alter_table_if_exists.py | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py index 769fd361..3b8fd30f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py @@ -46,8 +46,8 @@ def supports_unlogged_table(self) -> bool: """Whether CREATE UNLOGGED TABLE is supported. UNLOGGED tables skip WAL writes (9.6+), trading crash safety for - speed; callers opt in via ``CreateTableOptions(unlogged=True)`` on - ``CreateTableExpression``. + speed; callers opt in via ``PostgresCreateTableOptions(unlogged=True)`` + on ``CreateTableExpression``. """ ... diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py index 18fa6841..b7b719c0 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py @@ -218,10 +218,10 @@ class TestPostgresCreateUnloggedTable: @staticmethod def _options(dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableOptions, + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateTableOptions, ) - return CreateTableOptions(dialect, unlogged=True) + return PostgresCreateTableOptions(dialect, unlogged=True) def test_unlogged_renders_qualifier(self, dialect): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( @@ -276,7 +276,9 @@ def test_version_gate_94(self): ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( CreateTableExpression, - CreateTableOptions, + ) + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateTableOptions, ) low = PostgresDialect(version=(9, 4, 0)) @@ -284,7 +286,7 @@ def test_version_gate_94(self): low, table="audit", columns=[ColumnDefinition(low, "id", TextType(dialect=low))], - table_options=CreateTableOptions(low, unlogged=True), + table_options=PostgresCreateTableOptions(low, unlogged=True), ) with pytest.raises(UnsupportedFeatureError): expr.to_sql() From a32f3d1e48a3298c4f998135dab67a8b3acc9bf2 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 13:11:03 +0800 Subject: [PATCH 06/42] refactor(postgres): derive typed index expressions, drop index dialect_options Add PostgresIndexDefinition (opclasses/nulls_not_distinct/with_options) and PostgresCreateIndexExpression; PostgresDropIndexExpression uses the generic typed concurrent flag. The index mixin reads typed fields instead of the dialect_options bag. --- .../impl/postgres/expression/__init__.py | 6 + .../impl/postgres/expression/ddl/__init__.py | 53 ++++---- .../impl/postgres/expression/ddl/index.py | 62 +++++----- .../expression/ddl/index_definition.py | 115 ++++++++++++++++++ .../backend/impl/postgres/mixins/ddl/index.py | 18 +-- .../backend/postgres/extensions/test_bloom.py | 7 +- .../extensions/test_btree_gin_integration.py | 32 ++--- .../extensions/test_btree_gist_integration.py | 32 ++--- .../feature/backend/postgres/test_index_pg.py | 35 +++--- 9 files changed, 245 insertions(+), 115 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index_definition.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 835eb459..f90fa4a2 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -33,6 +33,9 @@ PostgresAlterIndexExpression, PostgresAlterIndexActionType, PostgresReindexExpression, + PostgresCreateIndexExpression, + PostgresIndexDefinition, + PostgresDropIndexExpression, PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, PostgresCommentExpression, @@ -129,6 +132,9 @@ "PostgresAlterIndexExpression", "PostgresAlterIndexActionType", "PostgresReindexExpression", + "PostgresCreateIndexExpression", + "PostgresIndexDefinition", + "PostgresDropIndexExpression", "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", "PostgresCommentExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index 0ffcbc26..04133bba 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -6,6 +6,7 @@ - vacuum.py - VACUUM/ANALYZE expressions - partition.py - Partition DDL expressions - index.py - Index DDL expressions +- index_definition.py - PostgreSQL index definition / CREATE INDEX expressions - statistics.py - Statistics DDL expressions - comment.py - COMMENT expressions - mv.py - Materialized view expressions @@ -21,42 +22,40 @@ - routine.py - CREATE/DROP FUNCTION / AGGREGATE expressions - publication.py - CREATE/DROP PUBLICATION / SUBSCRIPTION expressions -Missing expressions — why? +PostgreSQL index expressions ============================ -This package does **not** provide ``PostgresCreateIndexExpression`` or -``PostgresDropIndexExpression``. Those were removed because the generic -``CreateIndexExpression`` / ``DropIndexExpression`` (from -``rhosocial.activerecord.backend.expression.statements.ddl_index``) already -accept all common parameters **and** a ``dialect_options`` dict. +The generic ``CreateIndexExpression`` / ``DropIndexExpression`` (from +``rhosocial.activerecord.backend.expression.statements.ddl_index``) carry all +common parameters. -PG‑specific features such as ``NULLS NOT DISTINCT`` or ``CONCURRENTLY`` on -DROP INDEX are passed through ``dialect_options`` and consumed by the dialect -(``PostgresIndexMixin``). See ``index.py`` for the supported keys. +PostgreSQL-only features are supplied through typed fields, not a +``dialect_options`` bag: + +* ``PostgresCreateIndexExpression`` adds ``opclasses``, + ``nulls_not_distinct`` and ``with_options`` for ``CREATE INDEX``. +* ``PostgresIndexDefinition`` adds the same options to an inline table index + definition. +* ``PostgresDropIndexExpression`` marks PostgreSQL ownership of a + ``DROP INDEX`` (the generic expression already carries ``concurrent``). + +The typed fields are consumed by ``PostgresIndexMixin`` (see ``index.py``). Example:: - from rhosocial.activerecord.backend.expression.statements.ddl_index import ( - CreateIndexExpression, - DropIndexExpression, - ) from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateIndexExpression, + ) d = PostgresDialect((15, 0, 0)) - # NULLS NOT DISTINCT via dialect_options - expr = CreateIndexExpression( + # NULLS NOT DISTINCT via a typed field + expr = PostgresCreateIndexExpression( d, "idx_uniq_abc", "t", ["a", "b"], unique=True, - dialect_options={"nulls_not_distinct": True}, + nulls_not_distinct=True, ) sql, _ = expr.to_sql() # → CREATE UNIQUE INDEX … NULLS NOT DISTINCT - - # DROP INDEX CONCURRENTLY via dialect_options (PG 18+) - expr = DropIndexExpression( - d, "idx_old", - dialect_options={"concurrent": True}, - ) - sql, _ = expr.to_sql() # → DROP INDEX CONCURRENTLY … """ from .vacuum import PostgresVacuumExpression, PostgresAnalyzeExpression @@ -79,6 +78,11 @@ PostgresAlterIndexActionType, PostgresReindexExpression, ) +from .index_definition import ( + PostgresCreateIndexExpression, + PostgresIndexDefinition, + PostgresDropIndexExpression, +) from .statistics import ( PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, @@ -175,6 +179,9 @@ "PostgresAlterIndexExpression", "PostgresAlterIndexActionType", "PostgresReindexExpression", + "PostgresCreateIndexExpression", + "PostgresIndexDefinition", + "PostgresDropIndexExpression", # statistics "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py index 9a5be396..786e34ce 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py @@ -11,54 +11,48 @@ - TABLESPACE option: PostgreSQL 14+ - CONCURRENTLY option: PostgreSQL 12+ -CREATE / DROP INDEX — use the generic expressions with ``dialect_options`` -========================================================================== -This module intentionally does **not** define ``PostgresCreateIndexExpression`` -or ``PostgresDropIndexExpression``. The generic classes from the core -package already serve this purpose: - - from rhosocial.activerecord.backend.expression.statements.ddl_index import ( - CreateIndexExpression, - DropIndexExpression, - ) - -PostgreSQL‑specific options are supplied via the ``dialect_options`` dict and -consumed by ``PostgresIndexMixin.format_create_index_statement`` / -``format_drop_index_statement``. Supported keys: - -======================= ======================================================= -Key Description -======================= ======================================================= -``nulls_not_distinct`` ``bool`` — add ``NULLS NOT DISTINCT`` (PG 15+, unique - index only). Passed to ``CreateIndexExpression``. -``opclasses`` ``dict[str, str]`` — operator classes per column, e.g. - ``{"a": "text_pattern_ops"}``. Passed to - ``CreateIndexExpression``. -``with`` ``dict[str, Any]`` — storage parameters, e.g. - ``{"fillfactor": 70}``. Passed to - ``CreateIndexExpression``. -``concurrent`` ``bool`` — add ``CONCURRENTLY`` to ``DROP INDEX`` - (PG 18+). Passed to ``DropIndexExpression``. - (For ``CREATE INDEX`` the generic class already has - a ``concurrent`` named parameter.) -======================= ======================================================= +CREATE / DROP INDEX — typed index expressions +============================================== +``PostgresCreateIndexExpression`` / ``PostgresIndexDefinition`` / +``PostgresDropIndexExpression`` live in ``index_definition.py``. The generic +core classes carry all common parameters; PostgreSQL adds typed (not +``dialect_options``) fields: + +======================== ======================================================= +Field Description +======================== ======================================================= +``opclasses`` ``dict[str, str]`` — operator classes per column, e.g. + ``{"a": "text_pattern_ops"}``. +``nulls_not_distinct`` ``bool`` — add ``NULLS NOT DISTINCT`` (PG 15+, unique + index only). +``with_options`` ``dict[str, Any]`` — storage parameters, e.g. + ``{"fillfactor": 70}``. +``concurrent`` ``bool`` — add ``CONCURRENTLY`` (CREATE INDEX PG 11+ / + DROP INDEX PG 18+); present on the generic classes. +======================== ======================================================= + +They are consumed by ``PostgresIndexMixin.format_create_index_statement`` / +``format_drop_index_statement``. Example:: from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateIndexExpression, + ) d = PostgresDialect((15, 0, 0)) - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( d, "idx_uniq_abc", "t", ["a", "b"], unique=True, - dialect_options={"nulls_not_distinct": True}, + nulls_not_distinct=True, ) sql, _ = expr.to_sql() # → CREATE UNIQUE INDEX … NULLS NOT DISTINCT """ from enum import Enum -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Any, Dict, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index_definition.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index_definition.py new file mode 100644 index 00000000..0bb21df3 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index_definition.py @@ -0,0 +1,115 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index_definition.py +"""PostgreSQL-specific index expressions. + +PostgreSQL adds index options with no generic equivalent: + +* ``opclasses`` — operator classes per column (e.g. ``{"a": "text_pattern_ops"}``). +* ``nulls_not_distinct`` — ``NULLS NOT DISTINCT`` on a UNIQUE index (PG 15+). +* ``with_options`` — index storage parameters, e.g. ``{"fillfactor": 70}``. + +They live on ``PostgresCreateIndexExpression`` (standalone ``CREATE INDEX``) +and ``PostgresIndexDefinition`` (inline table index definition), both deriving +their generic core counterparts, and are rendered by the PostgreSQL +``PostgresIndexMixin`` overrides. +""" + +from typing import Any, Dict, List, Optional + +from rhosocial.activerecord.backend.expression.bases import SQLPredicate +from rhosocial.activerecord.backend.expression.statements.ddl_index import ( + CreateIndexExpression, + DropIndexExpression, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import IndexDefinition + + +class PostgresCreateIndexExpression(CreateIndexExpression): + """A PostgreSQL ``CREATE INDEX`` statement extending the generic one. + + Adds the PostgreSQL-only typed options consumed by + ``PostgresIndexMixin.format_create_index_statement``: + + * ``opclasses`` — operator classes per column (e.g. ``{"a": "text_pattern_ops"}``). + * ``nulls_not_distinct`` — ``NULLS NOT DISTINCT`` on a UNIQUE index (PG 15+). + * ``with_options`` — index storage parameters, e.g. ``{"fillfactor": 70}``. + """ + + def __init__( + self, + dialect: Any, + index_name: str, + table_name: str, + columns: List[Any], + unique: bool = False, + if_not_exists: bool = False, + index_type: Optional[str] = None, + where: Optional[SQLPredicate] = None, + include: Optional[List[str]] = None, + tablespace: Optional[str] = None, + concurrent: bool = False, + *, + opclasses: Optional[Dict[str, str]] = None, + nulls_not_distinct: bool = False, + with_options: Optional[Dict[str, Any]] = None, + ): + super().__init__( + dialect, + index_name=index_name, + table_name=table_name, + columns=columns, + unique=unique, + if_not_exists=if_not_exists, + index_type=index_type, + where=where, + include=include, + tablespace=tablespace, + concurrent=concurrent, + ) + self.opclasses = opclasses or {} + self.nulls_not_distinct = nulls_not_distinct + self.with_options = with_options or {} + + +class PostgresIndexDefinition(IndexDefinition): + """A PostgreSQL index definition extending the generic one. + + Adds the PostgreSQL-only ``opclasses`` / ``nulls_not_distinct`` / ``with`` + options. + """ + + def __init__( + self, + dialect: Any, + name: str, + columns: List[Any], + *, + unique: bool = False, + type: Optional[str] = None, + partial_condition: Optional[SQLPredicate] = None, + include_columns: Optional[List[str]] = None, + opclasses: Optional[Dict[str, str]] = None, + nulls_not_distinct: bool = False, + with_options: Optional[Dict[str, Any]] = None, + ): + super().__init__( + dialect, + name=name, + columns=columns, + unique=unique, + type=type, + partial_condition=partial_condition, + include_columns=include_columns, + ) + self.opclasses = opclasses or {} + self.nulls_not_distinct = nulls_not_distinct + self.with_options = with_options or {} + + +class PostgresDropIndexExpression(DropIndexExpression): + """A PostgreSQL DROP INDEX statement extending the generic one. + + Adds the PostgreSQL-only ``concurrent`` flag (``DROP INDEX CONCURRENTLY``, + PG 18+); the generic ``DropIndexExpression`` already carries ``concurrent``, + so this subclass mainly marks PostgreSQL ownership for the DDL gates. + """ + diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py index 85e6a0ce..afcc4d99 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py @@ -332,11 +332,11 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ """Format CREATE INDEX statement with PostgreSQL-specific options. Extends the generic format_create_index_statement to support: - - Operator classes via dialect_options["opclasses"] + - Operator classes via the typed ``opclasses`` field - CONCURRENTLY clause - INCLUDE clause with version checks - - NULLS NOT DISTINCT via dialect_options["nulls_not_distinct"] - - WITH storage parameters via dialect_options["with"] + - NULLS NOT DISTINCT via the typed ``nulls_not_distinct`` field + - WITH storage parameters via the typed ``with_options`` field """ all_params = [] parts = ["CREATE"] @@ -361,7 +361,7 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ parts.append(f"USING {expr.index_type}") # Columns with optional operator classes - opclasses = expr.dialect_options.get("opclasses", {}) + opclasses = getattr(expr, "opclasses", None) or {} col_parts = [] for col in expr.columns: if isinstance(col, ToSQLProtocol): @@ -389,7 +389,7 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ parts.append(f"INCLUDE ({include_cols})") # NULLS NOT DISTINCT (PG 15+, requires UNIQUE) - if expr.dialect_options.get("nulls_not_distinct"): + if getattr(expr, "nulls_not_distinct", False): if not expr.unique: raise ValueError("NULLS NOT DISTINCT is only valid for UNIQUE indexes") if expr.concurrent and not self.supports_concurrent_unique_nulls_not_distinct(): @@ -400,8 +400,8 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ raise ValueError("NULLS NOT DISTINCT requires PostgreSQL 15+") parts.append("NULLS NOT DISTINCT") - # WITH options via dialect_options - with_options = expr.dialect_options.get("with") + # WITH options via typed fields + with_options = getattr(expr, "with_options", None) or {} if with_options: opts = ", ".join(f"{k} = {v}" for k, v in with_options.items()) parts.append(f"WITH ({opts})") @@ -420,11 +420,11 @@ def format_drop_index_statement(self, expr: "DropIndexExpression") -> Tuple[str, """Format DROP INDEX statement with PostgreSQL-specific options. Extends the generic format_drop_index_statement to support: - - CONCURRENTLY clause (PG 18+) via dialect_options["concurrent"] + - CONCURRENTLY clause (PG 18+) via the typed ``concurrent`` field """ parts = ["DROP INDEX"] - if expr.dialect_options.get("concurrent"): + if getattr(expr, "concurrent", False): if not self.supports_streaming_btree_index_build(): raise ValueError("DROP INDEX CONCURRENTLY requires PostgreSQL 18+") parts.append("CONCURRENTLY") diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom.py index 539d37da..49550b40 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom.py @@ -9,6 +9,9 @@ from rhosocial.activerecord.backend.expression import CreateIndexExpression from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateIndexExpression, +) class TestPostgresBloomMixin: @@ -35,13 +38,13 @@ def test_format_bloom_index(self): def test_format_bloom_index_with_fill_factor(self): """CreateIndexExpression with fillfactor should include WITH (fillfactor = ...).""" - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( self.dialect, index_name="idx_name", table_name="table_name", columns=["col1", "col2"], index_type="bloom", - dialect_options={"with": {"fillfactor": 90}}, + with_options={"fillfactor": 90}, ) sql, _ = expr.to_sql() assert "bloom" in sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py index aef65b2c..0466260f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py @@ -8,8 +8,8 @@ - GIN index creation with btree_gin operator class on TEXT columns (text_ops) - Query data access through GIN index -btree_gin has no function factories. It works through CreateIndexExpression with -index_type="GIN" and dialect_options={"opclasses": {"column": "int4_ops"}} for +btree_gin has no function factories. It works through PostgresCreateIndexExpression +with index_type="GIN" and opclasses={"column": "int4_ops"} for specifying btree_gin operator classes that enable B-tree equivalence on GIN indexes. The btree_gin extension provides opclasses like int4_ops, int8_ops, text_ops, etc. that allow GIN indexes to support equality comparisons on scalar data types. @@ -32,7 +32,9 @@ QueryExpression, TableExpression, Column, - CreateIndexExpression, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateIndexExpression, ) from rhosocial.activerecord.backend.expression.statements import ValuesSource from rhosocial.activerecord.backend.expression.core import Literal @@ -114,14 +116,14 @@ def test_btree_gin_int4_ops_index(self, btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_value", table_name="test_btree_gin", columns=["value"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"value": "int4_ops"}}, + opclasses={"value": "int4_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -145,14 +147,14 @@ def test_btree_gin_text_ops_index(self, btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on TEXT column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_name", table_name="test_btree_gin", columns=["name"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"name": "text_ops"}}, + opclasses={"name": "text_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -176,14 +178,14 @@ def test_btree_gin_index_query(self, btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_value", table_name="test_btree_gin", columns=["value"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"value": "int4_ops"}}, + opclasses={"value": "int4_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -270,14 +272,14 @@ async def test_async_btree_gin_int4_ops_index(self, async_btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_value_async", table_name="test_btree_gin_async", columns=["value"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"value": "int4_ops"}}, + opclasses={"value": "int4_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) @@ -302,14 +304,14 @@ async def test_async_btree_gin_text_ops_index(self, async_btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on TEXT column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_name_async", table_name="test_btree_gin_async", columns=["name"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"name": "text_ops"}}, + opclasses={"name": "text_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) @@ -334,14 +336,14 @@ async def test_async_btree_gin_index_query(self, async_btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_value_async", table_name="test_btree_gin_async", columns=["value"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"value": "int4_ops"}}, + opclasses={"value": "int4_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py index 6b66c2d5..9a0fbe3a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py @@ -8,8 +8,8 @@ - GiST index creation with btree_gist operator class on INTEGER columns (gist_int4_ops) - Range queries using ComparisonPredicate on GiST-indexed columns -btree_gist has no function factories. It works through CreateIndexExpression with -index_type="GIST" and dialect_options={"opclasses": {"column": "gist_int4_ops"}} for +btree_gist has no function factories. It works through PostgresCreateIndexExpression +with index_type="GIST" and opclasses={"column": "gist_int4_ops"} for specifying btree_gist operator classes that enable B-tree equivalence on GiST indexes. The btree_gist extension provides opclasses like gist_int4_ops, gist_timestamp_ops, gist_text_ops, etc. that allow GiST indexes to support B-tree-like operations. @@ -33,7 +33,9 @@ QueryExpression, TableExpression, Column, - CreateIndexExpression, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateIndexExpression, ) from rhosocial.activerecord.backend.expression.statements import ValuesSource from rhosocial.activerecord.backend.expression.core import Literal @@ -117,14 +119,14 @@ def test_btree_gist_timestamp_index(self, btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on TIMESTAMP column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_created_at", table_name="test_btree_gist", columns=["created_at"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"created_at": "gist_timestamp_ops"}}, + opclasses={"created_at": "gist_timestamp_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -148,14 +150,14 @@ def test_btree_gist_int4_index(self, btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_value", table_name="test_btree_gist", columns=["value"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"value": "gist_int4_ops"}}, + opclasses={"value": "gist_int4_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -179,14 +181,14 @@ def test_btree_gist_range_query(self, btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on TIMESTAMP column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_created_at", table_name="test_btree_gist", columns=["created_at"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"created_at": "gist_timestamp_ops"}}, + opclasses={"created_at": "gist_timestamp_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -282,14 +284,14 @@ async def test_async_btree_gist_timestamp_index(self, async_btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on TIMESTAMP column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_created_at_async", table_name="test_btree_gist_async", columns=["created_at"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"created_at": "gist_timestamp_ops"}}, + opclasses={"created_at": "gist_timestamp_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) @@ -314,14 +316,14 @@ async def test_async_btree_gist_int4_index(self, async_btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_value_async", table_name="test_btree_gist_async", columns=["value"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"value": "gist_int4_ops"}}, + opclasses={"value": "gist_int4_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) @@ -346,14 +348,14 @@ async def test_async_btree_gist_range_query(self, async_btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on TIMESTAMP column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_created_at_async", table_name="test_btree_gist_async", columns=["created_at"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"created_at": "gist_timestamp_ops"}}, + opclasses={"created_at": "gist_timestamp_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py index 75d08005..27909d03 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py @@ -31,6 +31,7 @@ PostgresAlterIndexExpression, PostgresAlterIndexActionType, PostgresReindexExpression, + PostgresCreateIndexExpression, ) @@ -640,42 +641,42 @@ def test_unique(self): def test_nulls_not_distinct(self): d = PostgresDialect((15, 0, 0)) - expr = CreateIndexExpression( - d, "idx_u", "t", ["a"], unique=True, dialect_options={"nulls_not_distinct": True} + expr = PostgresCreateIndexExpression( + d, "idx_u", "t", ["a"], unique=True, nulls_not_distinct=True ) sql, _ = expr.to_sql() assert sql == 'CREATE UNIQUE INDEX "idx_u" ON "t" ("a") NULLS NOT DISTINCT' def test_nulls_not_distinct_pg14_raises(self): d = PostgresDialect((14, 0, 0)) - expr = CreateIndexExpression( - d, "idx_u", "t", ["a"], unique=True, dialect_options={"nulls_not_distinct": True} + expr = PostgresCreateIndexExpression( + d, "idx_u", "t", ["a"], unique=True, nulls_not_distinct=True ) with pytest.raises(ValueError, match="NULLS NOT DISTINCT requires PostgreSQL 15"): expr.to_sql() def test_nulls_not_distinct_non_unique_raises(self): d = PostgresDialect((15, 0, 0)) - expr = CreateIndexExpression( - d, "idx_u", "t", ["a"], unique=False, dialect_options={"nulls_not_distinct": True} + expr = PostgresCreateIndexExpression( + d, "idx_u", "t", ["a"], unique=False, nulls_not_distinct=True ) with pytest.raises(ValueError, match="NULLS NOT DISTINCT is only valid for UNIQUE"): expr.to_sql() def test_concurrent_and_nulls_not_distinct_pg15_raises(self): d = PostgresDialect((15, 0, 0)) - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( d, "idx_u", "t", ["a"], - unique=True, concurrent=True, dialect_options={"nulls_not_distinct": True}, + unique=True, concurrent=True, nulls_not_distinct=True, ) with pytest.raises(ValueError, match="CONCURRENTLY.*NULLS NOT DISTINCT.*PostgreSQL 16"): expr.to_sql() def test_concurrent_and_nulls_not_distinct_pg16(self): d = PostgresDialect((16, 0, 0)) - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( d, "idx_u", "t", ["a"], - unique=True, concurrent=True, dialect_options={"nulls_not_distinct": True}, + unique=True, concurrent=True, nulls_not_distinct=True, ) sql, _ = expr.to_sql() assert "NULLS NOT DISTINCT" in sql @@ -699,19 +700,19 @@ def test_drop_if_exists(self): def test_drop_concurrently(self): d = PostgresDialect((18, 0, 0)) - expr = DropIndexExpression(d, "idx_test", dialect_options={"concurrent": True}) + expr = DropIndexExpression(d, "idx_test", concurrent=True) sql, _ = expr.to_sql() assert sql == 'DROP INDEX CONCURRENTLY "idx_test"' def test_drop_concurrently_pg17_raises(self): d = PostgresDialect((17, 0, 0)) - expr = DropIndexExpression(d, "idx_test", dialect_options={"concurrent": True}) + expr = DropIndexExpression(d, "idx_test", concurrent=True) with pytest.raises(ValueError, match="DROP INDEX CONCURRENTLY requires PostgreSQL 18"): expr.to_sql() def test_drop_concurrently_if_exists(self): d = PostgresDialect((18, 0, 0)) - expr = DropIndexExpression(d, "idx_test", if_exists=True, dialect_options={"concurrent": True}) + expr = DropIndexExpression(d, "idx_test", if_exists=True, concurrent=True) sql, _ = expr.to_sql() assert sql == 'DROP INDEX CONCURRENTLY IF EXISTS "idx_test"' @@ -902,18 +903,18 @@ def test_where(self): def test_with_options(self): d = PostgresDialect() - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( d, "idx_w", "t", ["a"], - dialect_options={"with": {"fillfactor": 70}}, + with_options={"fillfactor": 70}, ) sql, _ = expr.to_sql() assert "WITH (fillfactor = 70)" in sql def test_opclasses(self): d = PostgresDialect() - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( d, "idx_o", "t", ["a", "b"], - dialect_options={"opclasses": {"a": "text_pattern_ops"}}, + opclasses={"a": "text_pattern_ops"}, ) sql, _ = expr.to_sql() assert '"a" text_pattern_ops' in sql From fafbaf39e93727a2d2afa47f4e7a4b44a56cb96c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 13:53:41 +0800 Subject: [PATCH 07/42] refactor(postgres): typed ALTER using, EXCLUDE constraint, vector index Add PostgresAlterColumn (typed using) and PostgresExcludeConstraint (typed elements/using/where); read typed constraint validation; build vector indexes with PostgresCreateIndexExpression. --- .../impl/postgres/expression/__init__.py | 4 ++ .../impl/postgres/expression/ddl/__init__.py | 4 ++ .../postgres/expression/ddl/alter_column.py | 54 +++++++++++++++++ .../expression/ddl/exclude_constraint.py | 54 +++++++++++++++++ .../impl/postgres/functions/vector_search.py | 11 ++-- .../impl/postgres/mixins/ddl/column.py | 5 +- .../impl/postgres/mixins/ddl/constraint.py | 59 ++++++++----------- .../dialect/test_alter_table_if_exists.py | 22 +++---- .../backend/postgres/test_ddl_alter_config.py | 16 ++--- .../backend/test_postgres_dialect_security.py | 50 ++++++---------- 10 files changed, 188 insertions(+), 91 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/alter_column.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index f90fa4a2..a47f0a83 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -23,6 +23,7 @@ PostgresColumnStorage, PostgresColumnDefinition, PostgresColumnOptions, + PostgresAlterColumn, PostgresVacuumExpression, PostgresAnalyzeExpression, PostgresPartitionClause, @@ -36,6 +37,7 @@ PostgresCreateIndexExpression, PostgresIndexDefinition, PostgresDropIndexExpression, + PostgresExcludeConstraint, PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, PostgresCommentExpression, @@ -124,6 +126,7 @@ "PostgresColumnStorage", "PostgresColumnDefinition", "PostgresColumnOptions", + "PostgresAlterColumn", "PostgresPartitionClause", "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", @@ -135,6 +138,7 @@ "PostgresCreateIndexExpression", "PostgresIndexDefinition", "PostgresDropIndexExpression", + "PostgresExcludeConstraint", "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", "PostgresCommentExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index 04133bba..23440819 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -65,6 +65,7 @@ PostgresColumnDefinition, PostgresColumnOptions, ) +from .alter_column import PostgresAlterColumn from .partition import ( PartitionValue, PostgresPartitionClause, @@ -83,6 +84,7 @@ PostgresIndexDefinition, PostgresDropIndexExpression, ) +from .exclude_constraint import PostgresExcludeConstraint from .statistics import ( PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, @@ -168,6 +170,7 @@ "PostgresColumnStorage", "PostgresColumnDefinition", "PostgresColumnOptions", + "PostgresAlterColumn", # partition "PartitionValue", "PostgresPartitionClause", @@ -182,6 +185,7 @@ "PostgresCreateIndexExpression", "PostgresIndexDefinition", "PostgresDropIndexExpression", + "PostgresExcludeConstraint", # statistics "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/alter_column.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/alter_column.py new file mode 100644 index 00000000..e97ad8a0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/alter_column.py @@ -0,0 +1,54 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/alter_column.py +"""PostgreSQL-specific ALTER TABLE column actions. + +PostgreSQL's ``ALTER COLUMN ... SET DATA TYPE ... USING `` accepts a +conversion expression that has no generic equivalent: + +* ``USING ()`` — how to convert existing values to the new type. + +It lives on ``PostgresAlterColumn`` (deriving the generic ``AlterColumn``) and +is rendered by the PostgreSQL ``format_alter_column_action`` override. +PostgreSQL does **not** share it with any other backend. +""" + +from typing import Any, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import ( + AlterColumn, + ColumnAlterOperation, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresAlterColumn", +] + + +class PostgresAlterColumn(AlterColumn): + """A PostgreSQL ``ALTER COLUMN`` action extending the generic one. + + Adds the PostgreSQL-only ``using`` conversion expression for + ``SET DATA TYPE``. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + column_name: str, + operation: Union[ColumnAlterOperation, str], + *, + new_value: Any = None, + cascade: bool = False, + using: Any = None, + ): + super().__init__( + dialect, + column_name, + operation, + new_value=new_value, + cascade=cascade, + ) + self.using = using diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py new file mode 100644 index 00000000..5f4cbf9b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py @@ -0,0 +1,54 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py +"""PostgreSQL EXCLUDE constraint definition. + +PostgreSQL adds the ``EXCLUDE`` table constraint with no generic equivalent. +The excluded elements (expression/operator pairs), the index access method and +the optional partial predicate live on ``PostgresExcludeConstraint`` (deriving +the generic ``TableConstraint``) and are rendered by the PostgreSQL +``_format_exclude_constraint``. +""" + +from typing import Any, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import SQLPredicate +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + TableConstraint, + TableConstraintType, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class PostgresExcludeConstraint(TableConstraint): + """A PostgreSQL EXCLUDE constraint extending the generic one. + + ``elements`` is a list of ``(expression, operator)`` pairs where the + expression is either a column name (``str``) or a ``BaseExpression``; + ``using`` selects the index access method (default ``gist``) and ``where`` + is an optional partial-exclusion predicate. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: Optional[str] = None, + elements: Optional[List[Tuple[Any, str]]] = None, + *, + using: str = "gist", + where: Optional[SQLPredicate] = None, + deferrable: Optional[bool] = None, + initially_deferred: Optional[bool] = None, + validation: Any = None, + ): + super().__init__( + dialect, + TableConstraintType.EXCLUDE, + name=name, + deferrable=deferrable, + initially_deferred=initially_deferred, + validation=validation, + ) + self.elements = elements or [] + self.using = using + self.where = where diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py index 44e856b1..e46172dd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py @@ -41,6 +41,9 @@ from rhosocial.activerecord.backend.expression.statements.ddl_index import ( CreateIndexExpression, ) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.index_definition import ( + PostgresCreateIndexExpression, +) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -202,15 +205,13 @@ def create_vector_index( if lists is not None: with_options["lists"] = lists - return CreateIndexExpression( + return PostgresCreateIndexExpression( dialect=dialect, index_name=idx_name, table_name=table_name, columns=[column_name], index_type=index_type_l.upper(), if_not_exists=if_not_exists, - dialect_options={ - "opclasses": {column_name: _METRIC_OPCLASS[metric]}, - "with": with_options, - }, + opclasses={column_name: _METRIC_OPCLASS[metric]}, + with_options=with_options, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py index ee2550ff..87bcb12a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py @@ -64,7 +64,7 @@ def format_alter_column_action(self, action) -> Tuple[str, tuple]: ``SET DATA TYPE``). PostgreSQL additionally supports an optional ``USING (conversion_expression)`` on ``SET DATA TYPE`` — an extension the core dialect does not emit. This override injects it - from ``action.dialect_options["using"]``. + from the PostgreSQL ``PostgresAlterColumn`` typed ``using`` field. Args: action: An ``AlterColumn`` action (core ``ddl_alter`` module). @@ -78,8 +78,7 @@ def format_alter_column_action(self, action) -> Tuple[str, tuple]: """ sql, params = super().format_alter_column_action(action) - dialect_options = getattr(action, "dialect_options", None) or {} - using = dialect_options.get("using") + using = getattr(action, "using", None) if using is None: return sql, params diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py index d71b6a99..8ba42acc 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py @@ -72,10 +72,8 @@ def format_add_table_constraint_action( parts.append(exclude_sql) # NOT VALID suffix - if action.constraint.dialect_options: - validation = action.constraint.dialect_options.get('validation') - if validation == ConstraintValidation.NOVALIDATE: - parts.append("NOT VALID") + if getattr(action.constraint, 'validation', None) == ConstraintValidation.NOVALIDATE: + parts.append("NOT VALID") return f"ADD {' '.join(parts)}", tuple(params) @@ -83,10 +81,8 @@ def format_add_table_constraint_action( sql, params = super().format_add_table_constraint_action(action) # PostgreSQL NOT VALID suffix - if action.constraint.dialect_options: - validation = action.constraint.dialect_options.get('validation') - if validation == ConstraintValidation.NOVALIDATE: - sql += " NOT VALID" + if getattr(action.constraint, 'validation', None) == ConstraintValidation.NOVALIDATE: + sql += " NOT VALID" return sql, params @@ -95,21 +91,19 @@ def _format_exclude_constraint( ) -> Tuple[str, tuple]: """Format EXCLUDE constraint (PostgreSQL-specific). - EXCLUDE constraints use the dialect_options dict to specify: - - 'exclude_elements': List of (expression, operator) tuples + EXCLUDE constraints are built as ``PostgresExcludeConstraint``: + - ``elements``: List of (expression, operator) tuples e.g., [('range', '&&')] for EXCLUDE USING gist (range WITH &&) - - 'using': The index access method (default 'gist') + - ``using``: The index access method (default 'gist') e.g., 'gist', 'btree', 'spgist' - - 'where': Optional predicate for partial exclusion constraints + - ``where``: Optional predicate for partial exclusion constraints Example: - TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, + PostgresExcludeConstraint( + dialect, name='exclude_range_overlap', - dialect_options={ - 'exclude_elements': [('range', '&&')], - 'using': 'gist', - } + elements=[('range', '&&')], + using='gist', ) # Generates: EXCLUDE USING gist (range WITH &&) @@ -123,7 +117,7 @@ def _format_exclude_constraint( # USING clause - validate index access method. valid_using = frozenset({"gist", "btree", "spgist", "hash", "gin", "brin"}) - using = constraint.dialect_options.get("using", "gist") if constraint.dialect_options else "gist" + using = getattr(constraint, "using", "gist") or "gist" if using not in valid_using: raise ValueError( f"Invalid index access method '{using}': must be one of {valid_using}" @@ -137,25 +131,24 @@ def _format_exclude_constraint( "~=", "@@", "?|", "?&", "is", "is not", }) exclude_elements = [] - if constraint.dialect_options and "exclude_elements" in constraint.dialect_options: - for expr, op in constraint.dialect_options["exclude_elements"]: - if op not in valid_ops: - raise ValueError( - f"Invalid exclude operator '{op}': must be one of {valid_ops}" - ) - if isinstance(expr, str): - exclude_elements.append(f"{self.format_identifier(expr)} WITH {op}") - else: - expr_sql, expr_params = expr.to_sql() - params.extend(expr_params) - exclude_elements.append(f"{expr_sql} WITH {op}") + for expr, op in getattr(constraint, "elements", None) or []: + if op not in valid_ops: + raise ValueError( + f"Invalid exclude operator '{op}': must be one of {valid_ops}" + ) + if isinstance(expr, str): + exclude_elements.append(f"{self.format_identifier(expr)} WITH {op}") + else: + expr_sql, expr_params = expr.to_sql() + params.extend(expr_params) + exclude_elements.append(f"{expr_sql} WITH {op}") if exclude_elements: parts.append(f"({', '.join(exclude_elements)})") # WHERE clause for partial exclusion constraint - if constraint.dialect_options and 'where' in constraint.dialect_options: - where_expr = constraint.dialect_options['where'] + where_expr = getattr(constraint, "where", None) + if where_expr is not None: where_sql, where_params = where_expr.to_sql() params.extend(where_params) parts.append(f"WHERE ({where_sql})") diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py index b7b719c0..3d335e1d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py @@ -122,11 +122,11 @@ def dialect(self): return PostgresDialect(version=(15, 0, 0)) def test_set_data_type_plain(self, dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterColumn, + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterColumn, ) - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DATA TYPE", new_value="NUMERIC(10,2)" ) sql, params = action.to_sql() @@ -135,16 +135,16 @@ def test_set_data_type_plain(self, dialect): def test_set_data_type_with_using(self, dialect): from rhosocial.activerecord.backend.expression import Column, Literal - from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterColumn, + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterColumn, ) - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DATA TYPE", new_value="NUMERIC(10,2)", - dialect_options={"using": Column(dialect, "price") + Literal(dialect, 1)}, + using=Column(dialect, "price") + Literal(dialect, 1), ) sql, serialized = action.to_sql() assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql @@ -153,16 +153,16 @@ def test_set_data_type_with_using(self, dialect): def test_set_data_type_using_rejected_for_other_ops(self, dialect): from rhosocial.activerecord.backend.expression import Column - from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterColumn, + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterColumn, ) - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DEFAULT", new_value="0", - dialect_options={"using": Column(dialect, "price")}, + using=Column(dialect, "price"), ) with pytest.raises(ValueError, match="USING"): action.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py index a7ffff4b..f544d63a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py @@ -10,7 +10,6 @@ from rhosocial.activerecord.backend.expression import Column, Literal from rhosocial.activerecord.backend.expression.statements import ( AddColumn, - AlterColumn, ColumnDefinition, DropColumn, DropTableConstraint, @@ -28,6 +27,7 @@ PostgresForceRlsExpression, PostgresAlterTableSettingsExpression, PostgresClusterExpression, + PostgresAlterColumn, ) from rhosocial.activerecord.backend.expression.types import ( TextType, @@ -199,12 +199,12 @@ def dialect(self): return PostgresDialect(version=(14, 0, 0)) def test_using_clause(self, dialect): - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DATA TYPE", new_value="NUMERIC(10,2)", - dialect_options={"using": Column(dialect, "price") + Literal(dialect, 1)}, + using=Column(dialect, "price") + Literal(dialect, 1), ) sql, serialized = action.to_sql() assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql @@ -212,13 +212,13 @@ def test_using_clause(self, dialect): assert serialized == (1,) def test_using_clause_with_cascade(self, dialect): - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DATA TYPE", new_value="NUMERIC(10,2)", cascade=True, - dialect_options={"using": Column(dialect, "price")}, + using=Column(dialect, "price"), ) sql, serialized = action.to_sql() assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql @@ -227,19 +227,19 @@ def test_using_clause_with_cascade(self, dialect): def test_using_rejected_for_non_set_data_type(self, dialect): """USING is only valid on SET DATA TYPE.""" - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DEFAULT", new_value="0", - dialect_options={"using": Column(dialect, "price")}, + using=Column(dialect, "price"), ) with pytest.raises(ValueError, match="USING"): action.to_sql() def test_without_using_unaffected(self, dialect): """No USING -> output matches the standard form.""" - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DATA TYPE", diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py index 60aeae37..6b34ee4b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py @@ -17,6 +17,9 @@ TableConstraint, TableConstraintType, ) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresExcludeConstraint, +) from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.backend.expression.operators import ( BinaryExpression, @@ -100,14 +103,11 @@ def test_postgres_format_cast_expression_valid(dialect): def test_exclude_constraint_valid_using_methods(dialect): """Test valid index access methods are accepted.""" - constraint = TableConstraint( + constraint = PostgresExcludeConstraint( dialect, - constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", - dialect_options={ - "using": "gist", - "exclude_elements": [("int4range", "&&")], - }, + elements=[("int4range", "&&")], + using="gist", ) sql, params = dialect._format_exclude_constraint(constraint) @@ -124,14 +124,11 @@ def test_exclude_constraint_valid_operators(dialect): ] for elem, op in valid_ops: - constraint = TableConstraint( - dialect, - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "exclude_elements": [(elem, op)], - }, - ) + constraint = PostgresExcludeConstraint( + dialect, + name="test_exclude", + elements=[(elem, op)], + ) sql, params = dialect._format_exclude_constraint(constraint) assert f"WITH {op}" in sql @@ -139,14 +136,11 @@ def test_exclude_constraint_valid_operators(dialect): def test_exclude_constraint_rejects_invalid_using(dialect): """Test that invalid index access method is rejected.""" - constraint = TableConstraint( + constraint = PostgresExcludeConstraint( dialect, - constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", - dialect_options={ - "using": "invalid_method", - "exclude_elements": [("range", "&&")], - }, + elements=[("range", "&&")], + using="invalid_method", ) with pytest.raises(ValueError, match="Invalid index access method"): @@ -155,13 +149,10 @@ def test_exclude_constraint_rejects_invalid_using(dialect): def test_exclude_constraint_rejects_invalid_operator(dialect): """Test that invalid exclude operator is rejected.""" - constraint = TableConstraint( + constraint = PostgresExcludeConstraint( dialect, - constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", - dialect_options={ - "exclude_elements": [("range", "'; DROP TABLE users--")], - }, + elements=[("range", "'; DROP TABLE users--")], ) with pytest.raises(ValueError, match="Invalid exclude operator"): @@ -170,14 +161,11 @@ def test_exclude_constraint_rejects_invalid_operator(dialect): def test_exclude_constraint_sql_injection_prevention(dialect): """Test that SQL injection attempts are blocked.""" - constraint = TableConstraint( + constraint = PostgresExcludeConstraint( dialect, - constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", - dialect_options={ - "using": "gist", - "exclude_elements": [("range", "&&")], - }, + elements=[("range", "&&")], + using="gist", ) sql, params = dialect._format_exclude_constraint(constraint) From 173cc557b8714f446173be1c1e03865b4b90402d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 14:46:33 +0800 Subject: [PATCH 08/42] refactor(postgres): drop dead dialect_options pass-through The materialized-view pass-through and the partition test provider no longer use the dialect_options bag. --- .../activerecord/backend/impl/postgres/expression/ddl/mv.py | 5 +---- tests/providers/partition.py | 3 ++- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py index f904d52d..123072fa 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py @@ -11,7 +11,7 @@ - WITH NO DATA: PostgreSQL 9.4+ """ -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.statements.ddl_view import RefreshMaterializedViewExpression @@ -74,15 +74,12 @@ def __init__( schema: Optional[str] = None, concurrently: bool = False, with_data: Optional[bool] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__( dialect, view_name=name, concurrent=concurrently, with_data=with_data, - dialect_options=dialect_options, ) self.schema = schema diff --git a/tests/providers/partition.py b/tests/providers/partition.py index dd6bc575..3ee7c382 100644 --- a/tests/providers/partition.py +++ b/tests/providers/partition.py @@ -18,6 +18,7 @@ from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend from rhosocial.activerecord.backend.impl.postgres.expression import ( PostgresAttachPartitionExpression, + PostgresColumnDefinition, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresPartitionMetadataExpression, @@ -252,7 +253,7 @@ def _create_parent_sql(self, dialect): dialect=dialect, table=self.TABLE_NAME, columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), dialect_options={"identity": "BY DEFAULT"}), + PostgresColumnDefinition(dialect, "id", BigIntType(dialect=dialect), identity="BY DEFAULT"), ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "tenant_id", IntegerType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "payload", TextType(dialect=dialect)), From ce29c9d2c0eb4f0f381fcbd0ee168eb9b81abe94 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 17:10:26 +0800 Subject: [PATCH 09/42] refactor(postgres): drop reserved dialect_options bag from DDL expressions Remove the unused/reserved dialect_options parameter and attribute from PostgreSQL DDL expression classes (ALTER INDEX, REINDEX, VACUUM, partition, type/domain/collation/publication/routine/statistics/extension/foreign table/comment). No formatter reads them; typed fields carry real options. --- .../impl/postgres/expression/ddl/collation.py | 5 +--- .../impl/postgres/expression/ddl/comment.py | 5 +--- .../impl/postgres/expression/ddl/domain.py | 8 +----- .../impl/postgres/expression/ddl/extension.py | 8 +----- .../postgres/expression/ddl/foreign_table.py | 5 +--- .../impl/postgres/expression/ddl/index.py | 5 ---- .../postgres/expression/ddl/multirange.py | 8 +----- .../impl/postgres/expression/ddl/partition.py | 13 +--------- .../postgres/expression/ddl/publication.py | 8 +----- .../impl/postgres/expression/ddl/routine.py | 8 +----- .../postgres/expression/ddl/statistics.py | 8 +----- .../postgres/expression/ddl/table_settings.py | 5 +--- .../impl/postgres/expression/ddl/type.py | 26 +------------------ .../impl/postgres/expression/ddl/vacuum.py | 8 +----- 14 files changed, 13 insertions(+), 107 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py index 85c2e009..3da0ef68 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py @@ -11,7 +11,7 @@ - CREATE/DROP COLLATION: PostgreSQL 9.6+ (all supported versions) """ -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -36,7 +36,6 @@ class PostgresCreateCollationExpression(BaseExpression): provider: Optional ``PROVIDER`` (e.g. ``libc`` or ``icu``). version: Optional ``VERSION``. if_not_exists: When True, add ``IF NOT EXISTS``. - dialect_options: Reserved. """ @@ -51,7 +50,6 @@ def __init__( provider: Optional[str] = None, version: Optional[str] = None, if_not_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -62,7 +60,6 @@ def __init__( self.provider = provider self.version = version self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py index f1025d50..59ba7d03 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py @@ -9,7 +9,7 @@ - COMMENT: PostgreSQL 7.2+ """ -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -73,15 +73,12 @@ def __init__( object_name: str, comment: Optional[str], schema: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.object_type = object_type self.object_name = object_name self.comment = comment self.schema = schema - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py index cf2f462b..cb7d02f3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py @@ -12,7 +12,7 @@ """ from enum import Enum -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -49,7 +49,6 @@ class PostgresCreateDomainExpression(BaseExpression): collation: Optional collation name. default: Optional literal default value expression. constraints: Optional list of constraint clauses (e.g. ``CHECK (...)``). - dialect_options: Reserved for extensions. """ @@ -62,7 +61,6 @@ def __init__( collation: Optional[str] = None, default: Optional[str] = None, constraints: Optional[List[str]] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -71,7 +69,6 @@ def __init__( self.collation = collation self.default = default self.constraints = constraints or [] - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -89,7 +86,6 @@ class PostgresAlterDomainExpression(BaseExpression): new_value: For ``SET DEFAULT``, the new literal default. Otherwise unused. new_name: For ``RENAME TO``, the new domain name. - dialect_options: Reserved. Returns: Formatting is delegated to @@ -105,7 +101,6 @@ def __init__( schema: Optional[str] = None, new_value: Any = None, new_name: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -113,7 +108,6 @@ def __init__( self.action = action self.new_value = new_value self.new_name = new_name - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py index 0639a43b..2a1b99b1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py @@ -11,7 +11,7 @@ - DROP EXTENSION: PostgreSQL 9.1+ """ -from typing import Optional, Dict, Any, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -67,8 +67,6 @@ def __init__( version: Optional[str] = None, if_not_exists: bool = True, cascade: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -76,7 +74,6 @@ def __init__( self.version = version self.if_not_exists = if_not_exists self.cascade = cascade - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -119,8 +116,6 @@ def __init__( if_exists: bool = True, cascade: bool = False, restrict: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -128,7 +123,6 @@ def __init__( self.if_exists = if_exists self.cascade = cascade self.restrict = restrict - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py index 4231b97d..43491e42 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py @@ -10,7 +10,7 @@ - CREATE/DROP FOREIGN TABLE: PostgreSQL 9.6+ """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -34,7 +34,6 @@ class PostgresCreateForeignTableExpression(BaseExpression): options: Optional ``OPTIONS ( option 'value', ... )`` list of ``"option 'value'"`` strings. if_not_exists: When True, add ``IF NOT EXISTS``. - dialect_options: Reserved. """ @@ -47,7 +46,6 @@ def __init__( columns: Optional[List[str]] = None, options: Optional[List[str]] = None, if_not_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -56,7 +54,6 @@ def __init__( self.columns = columns or [] self.options = options or [] self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py index 786e34ce..5d5eb035 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py @@ -118,7 +118,6 @@ def __init__( source_tablespace: Optional[str] = None, target_tablespace: Optional[str] = None, nowait: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.index_name = index_name @@ -132,7 +131,6 @@ def __init__( self.source_tablespace = source_tablespace self.target_tablespace = target_tablespace self.nowait = nowait - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -195,8 +193,6 @@ def __init__( tablespace: Optional[str] = None, concurrently: bool = False, verbose: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.target_type = target_type @@ -205,7 +201,6 @@ def __init__( self.tablespace = tablespace self.concurrently = concurrently self.verbose = verbose - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py index ec0c4f4b..b8257c30 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py @@ -6,7 +6,7 @@ - multirange_agg(): PostgreSQL 14+ """ -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -43,14 +43,11 @@ def __init__( name: str, range_type: str, schema: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.range_type = range_type self.schema = schema - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -88,15 +85,12 @@ def __init__( table_name: str, where_clause: Optional[str] = None, schema: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.range_column = range_column self.table_name = table_name self.where_clause = where_clause self.schema = schema - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py index d846c8b2..aedf302f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py @@ -17,7 +17,7 @@ from datetime import date, datetime from decimal import Decimal from math import isfinite -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Any, Dict, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.statements import PartitionClause @@ -158,8 +158,6 @@ def __init__( schema: Optional[str] = None, tablespace: Optional[str] = None, if_not_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.partition_name = partition_name @@ -169,7 +167,6 @@ def __init__( self.schema = schema self.tablespace = tablespace self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -213,8 +210,6 @@ def __init__( schema: Optional[str] = None, concurrently: bool = False, finalize: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.partition_name = partition_name @@ -222,7 +217,6 @@ def __init__( self.schema = schema self.concurrently = concurrently self.finalize = finalize - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -272,8 +266,6 @@ def __init__( partition_values: Dict[str, Any], schema: Optional[str] = None, concurrently: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.partition_name = partition_name @@ -282,7 +274,6 @@ def __init__( self.partition_values = partition_values self.schema = schema self.concurrently = concurrently - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -306,7 +297,6 @@ def __init__( schema: Optional[str] = None, *, include_partitions: bool = True, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not parent_table: @@ -314,7 +304,6 @@ def __init__( self.parent_table = parent_table self.schema = schema self.include_partitions = include_partitions - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py index a6a8024f..1bed99b7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py @@ -14,7 +14,7 @@ - CREATE/DROP SUBSCRIPTION: PostgreSQL 10+ """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -41,7 +41,6 @@ class PostgresCreatePublicationExpression(BaseExpression): all_tables: When True, publish all tables (``FOR ALL TABLES``). options: Optional list of ``WITH (...)`` options (e.g. ``publish` strings). - dialect_options: Reserved. """ @@ -52,14 +51,12 @@ def __init__( tables: Optional[List[str]] = None, all_tables: bool = False, options: Optional[List[str]] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.tables = tables self.all_tables = all_tables self.options = options or [] - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -104,7 +101,6 @@ class PostgresCreateSubscriptionExpression(BaseExpression): connection: Connection string for ``CONNECTION '...'``. publications: Publication names to subscribe to. options: Optional ``WITH (``...)`` creation options. - dialect_options: Reserved. """ @@ -115,14 +111,12 @@ def __init__( connection: str, publications: List[str], options: Optional[List[str]] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.connection = connection self.publications = publications self.options = options or [] - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py index ca7cf395..18c54b46 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py @@ -16,7 +16,7 @@ ``PostgresStoredProcedureMixin``; this module covers FUNCTION and AGGREGATE. """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -48,7 +48,6 @@ class PostgresCreateFunctionExpression(BaseExpression): cost: Estimated execution cost (optional). rows: Estimated rows returned (optional). is_strict: When True, add ``STRICT``. - dialect_options: Reserved. """ @@ -66,7 +65,6 @@ def __init__( cost: Optional[float] = None, rows: Optional[int] = None, strict: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -80,7 +78,6 @@ def __init__( self.cost = cost self.rows = rows self.strict = strict - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -137,7 +134,6 @@ class PostgresCreateAggregateExpression(BaseExpression): schema: Optional schema for the aggregate. finalfunc: Optional final function name. initcond: Optional initial condition value. - dialect_options: Reserved. """ @@ -150,7 +146,6 @@ def __init__( schema: Optional[str] = None, finalfunc: Optional[str] = None, initcond: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -159,7 +154,6 @@ def __init__( self.stype = stype self.finalfunc = finalfunc self.initcond = initcond - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py index afdd2cd9..37f597df 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py @@ -12,7 +12,7 @@ - MCV lists: PostgreSQL 12+ """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -73,8 +73,6 @@ def __init__( schema: Optional[str] = None, statistics_type: Optional[str] = None, if_not_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -83,7 +81,6 @@ def __init__( self.schema = schema self.statistics_type = statistics_type self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -120,14 +117,11 @@ def __init__( name: str, schema: Optional[str] = None, if_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.schema = schema self.if_exists = if_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py index 67142b67..e5aaeb10 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py @@ -11,7 +11,7 @@ """ from enum import Enum -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -70,15 +70,12 @@ def __init__( schema: Optional[str] = None, mode: Optional[LoggingMode] = None, access_method: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.table_name = table_name self.schema = schema self.mode = mode self.access_method = access_method - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py index af3d5317..05d47443 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py @@ -14,7 +14,7 @@ - RANGE type: PostgreSQL 9.2+ """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -87,15 +87,12 @@ def __init__( values: List[str], schema: Optional[str] = None, if_not_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.values = values self.schema = schema self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -143,15 +140,12 @@ def __init__( schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.schema = schema self.if_exists = if_exists self.cascade = cascade - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -202,8 +196,6 @@ def __init__( schema: Optional[str] = None, before: Optional[str] = None, after: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.type_name = type_name @@ -211,7 +203,6 @@ def __init__( self.schema = schema self.before = before self.after = after - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -407,13 +398,10 @@ def __init__( dialect: "SQLDialectBase", name: str, schema: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.schema = schema - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -439,12 +427,9 @@ def __init__( self, dialect: "SQLDialectBase", values: List[str], - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.values = values - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -478,15 +463,12 @@ def __init__( values: List[str], schema: Optional[str] = None, if_not_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.values = values self.schema = schema self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -518,15 +500,12 @@ def __init__( schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.schema = schema self.if_exists = if_exists self.cascade = cascade - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -562,8 +541,6 @@ def __init__( schema: Optional[str] = None, before: Optional[str] = None, after: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.type_name = type_name @@ -571,7 +548,6 @@ def __init__( self.schema = schema self.before = before self.after = after - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py index 0a28b660..95c3dc7c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py @@ -14,7 +14,7 @@ - TRUNCATE option: PostgreSQL 14+ """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -83,8 +83,6 @@ def __init__( skip_locked: bool = False, truncate: bool = False, columns: Optional[List[str]] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.table_name = table_name @@ -99,7 +97,6 @@ def __init__( self.skip_locked = skip_locked self.truncate = truncate self.columns = columns - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -149,8 +146,6 @@ def __init__( verbose: bool = False, skip_locked: bool = False, columns: Optional[List[str]] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.table_name = table_name @@ -158,7 +153,6 @@ def __init__( self.verbose = verbose self.skip_locked = skip_locked self.columns = columns - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: From a0fac010a41f8d85aa3922feee7d6fca98dcf136 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 18:08:44 +0800 Subject: [PATCH 10/42] refactor(postgres): drop reserved dialect_options from ILIKE expression --- .../activerecord/backend/impl/postgres/expression/ilike.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py index 753496ef..fa827ad9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py @@ -1,7 +1,7 @@ # src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py """PostgreSQL ILIKE expression class.""" -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Any, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -36,14 +36,11 @@ def __init__( column: Any, pattern: str, negate: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.column = column self.pattern = pattern self.negate = negate - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: From f5b43bc38c516c95e140227d78e8853165bf7a1b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 19:54:08 +0800 Subject: [PATCH 11/42] refactor(postgres): drop dialect_options from data-type value objects Core DataType no longer carries a dialect_options bag; backend type subclasses stop forwarding it. Update the type-protocol tests. --- .../backend/impl/postgres/expression/types.py | 27 ++--- .../types/test_postgres_type_protocol.py | 101 ++---------------- 2 files changed, 17 insertions(+), 111 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py index c2b99f89..afac3ea1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py @@ -17,7 +17,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional, Set, TYPE_CHECKING +from typing import Optional, Set, TYPE_CHECKING from rhosocial.activerecord.backend.expression.types import ( ArrayType, @@ -133,9 +133,8 @@ class PostgresBitType(DataType): name = "postgres_bit" n: Optional[int] = None - def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None): + super().__init__(dialect) self.n = n def _type_params(self) -> tuple: @@ -148,9 +147,8 @@ class PostgresVarBitType(DataType): name = "postgres_varbit" n: Optional[int] = None - def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None): + super().__init__(dialect) self.n = n def _type_params(self) -> tuple: @@ -358,9 +356,8 @@ class PostgresVectorType(DataType): name = "postgres_vector" dim: int - def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None): + super().__init__(dialect) self.dim = dim def _type_params(self) -> tuple: @@ -380,9 +377,8 @@ class PostgresHalfvecType(DataType): name = "postgres_halfvec" dim: int - def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None): + super().__init__(dialect) self.dim = dim def _type_params(self) -> tuple: @@ -402,9 +398,8 @@ class PostgresSparsevecType(DataType): name = "postgres_sparsevec" dim: int - def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None): + super().__init__(dialect) self.dim = dim def _type_params(self) -> tuple: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py index 4ecf2bd8..e9f63af2 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py @@ -14,80 +14,31 @@ BigIntType, BlobType, BooleanType, - CharType, DataType, DecimalType, - DoubleType, FloatType, IntegerType, - IntType, JsonBType, JsonType, - RealType, SmallIntType, TextType, TimeType, TimestampType, - TinyIntType, VarCharType, ) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression.types import ( - PostgresArrayType, - PostgresBigSerialType, PostgresBitType, - PostgresBoxType, PostgresByteaType, - PostgresCIDType, - PostgresCharacterVaryingType, - PostgresCidrType, - PostgresCircleType, - PostgresCitextType, - PostgresCubeType, - PostgresDateMultirangeType, - PostgresDateRangeType, - PostgresGeographyType, - PostgresGeometryType, PostgresHalfvecType, - PostgresHstoreType, PostgresInetType, - PostgresInt4MultirangeType, - PostgresInt4RangeType, - PostgresInt8MultirangeType, - PostgresInt8RangeType, PostgresJsonPathType, - PostgresLineSegmentType, - PostgresLineType, - PostgresLtreeType, - PostgresMacAddr8Type, - PostgresMacAddrType, - PostgresMoneyType, - PostgresNumMultirangeType, - PostgresNumRangeType, - PostgresOIDType, - PostgresPathType, - PostgresPgLSNType, PostgresPointType, - PostgresPolygonType, - PostgresRasterType, - PostgresRegClassType, - PostgresRegTypeType, PostgresSerialType, - PostgresSmallSerialType, PostgresSparsevecType, - PostgresTIDType, - PostgresTSQueryType, - PostgresTSVectorType, - PostgresTsMultirangeType, PostgresTsRangeType, - PostgresTsTzMultirangeType, - PostgresTsTzRangeType, PostgresUUIDType, - PostgresVarBitType, PostgresVectorType, - PostgresXID8Type, - PostgresXIDType, - PostgresXMLType, ) @@ -104,13 +55,13 @@ def dialect(): def test_format_data_type_postgres_enum_exists(dialect): """format_data_type_postgres_enum method exists on the dialect.""" assert hasattr(dialect, "format_data_type_postgres_enum") - assert callable(getattr(dialect, "format_data_type_postgres_enum")) + assert callable(dialect.format_data_type_postgres_enum) def test_supports_data_type_postgres_enum_exists(dialect): """supports_data_type_postgres_enum method exists on the dialect.""" assert hasattr(dialect, "supports_data_type_postgres_enum") - assert callable(getattr(dialect, "supports_data_type_postgres_enum")) + assert callable(dialect.supports_data_type_postgres_enum) assert dialect.supports_data_type_postgres_enum() is True @@ -278,63 +229,23 @@ def test_suggested_data_types_keys_disjoint_from_supported(dialect): # --------------------------------------------------------------------------- -# W1: dialect_options forwarding and equality +# W1: type-param equality (dialect_options bag removed) # --------------------------------------------------------------------------- -def test_bit_type_dialect_options_forwarded(): - t1 = PostgresBitType(n=8, dialect_options={"flag": True}) - t2 = PostgresBitType(n=8, dialect_options={"flag": True}) - t3 = PostgresBitType(n=8, dialect_options={"flag": False}) - t4 = PostgresBitType(n=8) +def test_type_constructor_rejects_dialect_options(): + with pytest.raises(TypeError): + PostgresBitType(n=8, dialect_options={"flag": True}) - # Same options → equal - assert t1 == t2 - assert hash(t1) == hash(t2) - - # Different options → not equal - assert t1 != t3 - assert t1 != t4 - - -def test_vector_type_dialect_options_forwarded(): - t1 = PostgresVectorType(dim=384, dialect_options={"metric": "cosine"}) - t2 = PostgresVectorType(dim=384, dialect_options={"metric": "cosine"}) - t3 = PostgresVectorType(dim=384, dialect_options={"metric": "l2"}) - - assert t1 == t2 - assert hash(t1) == hash(t2) - assert t1 != t3 -def test_halfvec_type_dialect_options_forwarded(): - t1 = PostgresHalfvecType(dim=128, dialect_options={"quantize": True}) - t2 = PostgresHalfvecType(dim=128, dialect_options={"quantize": True}) - t3 = PostgresHalfvecType(dim=128) - assert t1 == t2 - assert hash(t1) == hash(t2) - assert t1 != t3 -def test_sparsevec_type_dialect_options_forwarded(): - t1 = PostgresSparsevecType(dim=16, dialect_options={"sparse": True}) - t2 = PostgresSparsevecType(dim=16, dialect_options={"sparse": True}) - t3 = PostgresSparsevecType(dim=16) - assert t1 == t2 - assert hash(t1) == hash(t2) - assert t1 != t3 -def test_varbit_type_dialect_options_forwarded(): - t1 = PostgresVarBitType(n=16, dialect_options={"strict": True}) - t2 = PostgresVarBitType(n=16, dialect_options={"strict": True}) - t3 = PostgresVarBitType(n=16) - assert t1 == t2 - assert hash(t1) == hash(t2) - assert t1 != t3 # --------------------------------------------------------------------------- From a7fa502e15036c13a892ee0d660567ed46200a7a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 22:32:40 +0800 Subject: [PATCH 12/42] test(postgres): pin generated-column capability bits --- .../test_generated_column_capabilities.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/test_generated_column_capabilities.py diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_generated_column_capabilities.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_generated_column_capabilities.py new file mode 100644 index 00000000..ae0fe338 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_generated_column_capabilities.py @@ -0,0 +1,21 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_generated_column_capabilities.py +"""Explicit PostgreSQL generated-column capability assertions. + +The generic ``GeneratedColumnExpression``/formatter already covers these +features; this pins the capability bits and their version gate. +""" + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +def test_generated_columns_gated_on_pg12(): + assert PostgresDialect(version=(12, 0, 0)).supports_generated_columns() is True + assert PostgresDialect(version=(11, 0, 0)).supports_generated_columns() is False + + +def test_stored_generated_columns_follow_generated_columns(): + assert PostgresDialect(version=(12, 0, 0)).supports_stored_generated_columns() is True + + +def test_virtual_generated_columns_unsupported(): + assert PostgresDialect(version=(15, 0, 0)).supports_virtual_generated_columns() is False From c4c330fa29f81e7bbaa20b1d438a00b9304a0da2 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 00:17:06 +0800 Subject: [PATCH 13/42] fix(postgres): wire PostgresDatabaseMixin into the dialect --- .../backend/impl/postgres/dialect.py | 2 ++ .../backend/impl/postgres/mixins/__init__.py | 2 ++ .../backend/test_postgres_database_ddl.py | 29 +++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_database_ddl.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 314f4629..6b600103 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -122,6 +122,7 @@ PostgresJoinMixin, PostgresTruncateMixin, PostgresSchemaMixin, + PostgresDatabaseMixin, PostgresSequenceMixin, PostgresTransactionMixin, PostgresViewMixin, @@ -313,6 +314,7 @@ class PostgresDialect( PostgresJoinMixin, PostgresTruncateMixin, PostgresSchemaMixin, + PostgresDatabaseMixin, PostgresSequenceMixin, PostgresTransactionMixin, PostgresViewMixin, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index bae798e1..37342822 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -108,6 +108,7 @@ from .ilike import PostgresILIKEMixin from .join import PostgresJoinMixin from .truncate import PostgresTruncateMixin +from .ddl_database import PostgresDatabaseMixin from .schema import PostgresSchemaMixin from .sequence import PostgresSequenceMixin from .transaction import PostgresTransactionMixin @@ -214,6 +215,7 @@ "PostgresJoinMixin", "PostgresTruncateMixin", "PostgresSchemaMixin", + "PostgresDatabaseMixin", "PostgresSequenceMixin", "PostgresTransactionMixin", "PostgresViewMixin", diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_database_ddl.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_database_ddl.py new file mode 100644 index 00000000..ff0800b9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_database_ddl.py @@ -0,0 +1,29 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_database_ddl.py +"""Explicit PostgresDialect database DDL capability + rendering tests.""" + +from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseExpression, + DropDatabaseExpression, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +def _dialect(): + return PostgresDialect(version=(15, 0, 0)) + + +def test_database_capabilities(): + dialect = _dialect() + assert dialect.supports_create_database() is True + assert dialect.supports_drop_database() is True + + +def test_create_database_renders(): + sql, params = CreateDatabaseExpression(_dialect(), database_name="app").to_sql() + assert "CREATE DATABASE" in sql + assert params == () + + +def test_drop_database_renders(): + sql, _ = DropDatabaseExpression(_dialect(), database_name="app").to_sql() + assert "DROP DATABASE" in sql From fea7b781e367c0c73dc64a3c7133889787db5317 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 00:29:03 +0800 Subject: [PATCH 14/42] docs(postgres): update examples to typed options / IdentityClause --- .../backend/impl/postgres/examples/delete/basic.py | 2 -- .../backend/impl/postgres/examples/extensions/bloom.py | 2 +- .../backend/impl/postgres/examples/extensions/btree_gin.py | 2 +- .../backend/impl/postgres/examples/extensions/btree_gist.py | 2 +- .../backend/impl/postgres/examples/extensions/pg_trgm.py | 2 +- .../backend/impl/postgres/examples/insert/with_returning.py | 3 --- .../postgres/examples/schema_diff/postgres10_identity.py | 5 +++-- 7 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py index c34770f2..896b65aa 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py @@ -97,7 +97,6 @@ Column(dialect, 'name'), Literal(dialect, 'Alice'), ), - dialect_options={}, ) sql, params = delete_expr.to_sql() @@ -121,7 +120,6 @@ Literal(dialect, 'Bob'), ), returning=ReturningClause(dialect, [Column(dialect, 'id'), Column(dialect, 'name')]), - dialect_options={}, ) sql, params = delete_returning.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/bloom.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/bloom.py index 59c3c26a..f8a8efdb 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/bloom.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/bloom.py @@ -169,7 +169,7 @@ columns=["name", "email"], index_type="BLOOM", if_not_exists=True, - dialect_options={"with": {"length": 64, "col1": 2, "col2": 2}}, + with_options={"length": 64, "col1": 2, "col2": 2}, ) sql, params = create_idx_opts.to_sql() print("\n--- CREATE BLOOM INDEX WITH OPTIONS ---") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gin.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gin.py index 643a8e23..2e1333fe 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gin.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gin.py @@ -132,7 +132,7 @@ columns=["name"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"name": "int4_ops"}}, + opclasses={"name": "int4_ops"}, ) sql, params = create_idx.to_sql() print("\n--- CREATE GIN INDEX with btree_gin operator class ---") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gist.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gist.py index 2a6c48bc..b2201ee0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gist.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gist.py @@ -133,7 +133,7 @@ columns=["created_at"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"created_at": "int4_ops"}}, + opclasses={"created_at": "int4_ops"}, ) sql, params = create_idx.to_sql() print("\n--- CREATE GIST INDEX with btree_gist operator class ---") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pg_trgm.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pg_trgm.py index bb407575..612284f6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pg_trgm.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pg_trgm.py @@ -233,7 +233,7 @@ columns=["title"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"title": "gin_trgm_ops"}}, + opclasses={"title": "gin_trgm_ops"}, ) sql, params = create_idx.to_sql() print("\n--- CREATE GIN INDEX ---") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py index 7353b070..89c056ac 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py @@ -78,7 +78,6 @@ source=ValuesSource(dialect, [[Literal(dialect, 'Alice'), Literal(dialect, 'alice@example.com')]]), columns=['name', 'email'], returning=ReturningClause(dialect, [Column(dialect, 'id')]), - dialect_options={}, ) sql, params = insert_expr.to_sql() @@ -99,7 +98,6 @@ source=ValuesSource(dialect, [[Literal(dialect, 'Bob'), Literal(dialect, 'bob@example.com')]]), columns=['name', 'email'], returning=ReturningClause(dialect, [Column(dialect, 'id'), Column(dialect, 'name')]), - dialect_options={}, ) sql, params = insert_multi.to_sql() @@ -116,7 +114,6 @@ ]), columns=['name', 'email'], returning=ReturningClause(dialect, [Column(dialect, 'id'), Column(dialect, 'name')]), - dialect_options={}, ) sql, params = batch_insert.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py index b1d11b9d..5e0e63de 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py @@ -19,6 +19,7 @@ import os from rhosocial.activerecord.backend.impl.postgres import PostgresBackend from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression.statements import IdentityClause from rhosocial.activerecord.backend.expression import ( CreateTableExpression, DropTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, @@ -61,14 +62,14 @@ backend.execute(sql, params) # PostgreSQL 10+: GENERATED AS IDENTITY -# Note: dialect_options={'identity': 'ALWAYS'} triggers GENERATED ALWAYS AS IDENTITY +# Note: IdentityClause(generation='ALWAYS') triggers GENERATED ALWAYS AS IDENTITY # in the PostgreSQL dialect's format_column_definition try: pg10_table = CreateTableExpression( dialect=dialect, table="pg10_style", columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)], - dialect_options={"identity": "ALWAYS"}), + identity_clause=IdentityClause(dialect, generation="ALWAYS")), ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), ] ) From e68f4aeb225281cfa6d13ae986f6456cd19263cf Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 21:53:26 +0800 Subject: [PATCH 15/42] refactor(ddl): drop legacy identity fields from column classes Remove identity/identity_start/identity_increment/identity_clause from XxxColumnDefinition and XxxColumnOptions to match core's column-attribute migration; XxxColumnDefinition now forwards attributes. --- .../impl/postgres/expression/ddl/column.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py index 68ac7a91..fa3a29b5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py @@ -56,10 +56,7 @@ def __init__( constraints=None, comment: Optional[str] = None, generated_expression=None, - identity: Optional[str] = None, - identity_start: Optional[int] = None, - identity_increment: Optional[int] = None, - identity_clause=None, + attributes=None, *, compression: Optional[str] = None, storage: Optional[PostgresColumnStorage] = None, @@ -72,10 +69,7 @@ def __init__( constraints=constraints, comment=comment, generated_expression=generated_expression, - identity=identity, - identity_start=identity_start, - identity_increment=identity_increment, - identity_clause=identity_clause, + attributes=attributes, ) if storage is not None and not isinstance(storage, PostgresColumnStorage): raise TypeError( @@ -98,16 +92,11 @@ class PostgresColumnOptions(ColumnOptions): def __init__( self, *, - identity_start: Optional[int] = None, - identity_increment: Optional[int] = None, compression: Optional[str] = None, storage: Optional[PostgresColumnStorage] = None, statistics: Optional[int] = None, ): - super().__init__( - identity_start=identity_start, - identity_increment=identity_increment, - ) + super().__init__() if storage is not None and not isinstance(storage, PostgresColumnStorage): raise TypeError( "storage must be a PostgresColumnStorage value, " From a9d2dda42042fcfe7013281d1910f2ad52df04ba Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 22:16:08 +0800 Subject: [PATCH 16/42] feat(ddl): render column attributes; backend identity syntax Render the dialect-free column attributes channel in each backend's format_column_definition (it was silently dropped), and give backends whose identity syntax differs from the SQL-standard form a format_identity_clause override (MySQL/MariaDB AUTO_INCREMENT, SQL Server IDENTITY(seed, inc)). Oracle gates column COLLATE to 12.2+; MariaDB drops the duplicate dialect formatter so the attribute-aware mixin is authoritative. --- .../backend/impl/postgres/mixins/ddl_table.py | 9 +++------ tests/providers/partition.py | 3 ++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py index 38ec38da..79642056 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py @@ -178,12 +178,9 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: ) col_sql = f"{self.format_identifier(col_def.name)} {type_sql}" - identity = getattr(col_def, 'identity', None) - if identity: - if identity.upper() in ("ALWAYS", "BY DEFAULT"): - col_sql += f" GENERATED {identity.upper()} AS IDENTITY" - else: - raise ValueError(f"Invalid identity option '{identity}': must be 'ALWAYS' or 'BY DEFAULT'") + attr_sql, attr_params = self.format_column_attributes(col_def) + col_sql += attr_sql + all_params.extend(attr_params) auto_identity_added = False for constraint in col_def.constraints: if not auto_identity_added and getattr(constraint, 'is_auto_increment', False): diff --git a/tests/providers/partition.py b/tests/providers/partition.py index 3ee7c382..c3815601 100644 --- a/tests/providers/partition.py +++ b/tests/providers/partition.py @@ -6,6 +6,7 @@ from typing import Any, ClassVar, Dict, List, Optional, Tuple, Type from rhosocial.activerecord.base.field_proxy import FieldProxy +from rhosocial.activerecord.base.ddl.attributes import IdentityAttribute from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, @@ -253,7 +254,7 @@ def _create_parent_sql(self, dialect): dialect=dialect, table=self.TABLE_NAME, columns=[ - PostgresColumnDefinition(dialect, "id", BigIntType(dialect=dialect), identity="BY DEFAULT"), + PostgresColumnDefinition(dialect, "id", BigIntType(dialect=dialect), attributes=[IdentityAttribute(generation="BY DEFAULT")]), ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "tenant_id", IntegerType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "payload", TextType(dialect=dialect)), From c202d9304bb42dfb883714f2c734b600fe1e26a4 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 22:31:30 +0800 Subject: [PATCH 17/42] test(ddl): classify ColumnAttributeSupport; use deriver.creation_plan() Core added the ColumnAttributeSupport protocol and renamed the deriver plan entry to creation_plan(); update protocol conformance and derived-DDL tests. --- .../feature/backend/test_postgres_protocol_conformance.py | 1 + 1 file changed, 1 insertion(+) 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 93b913ff..d3154de1 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 @@ -85,6 +85,7 @@ def get_postgres_protocols(): dialect_protocols.SQLXMLQueryingSupport, dialect_protocols.CollationSupport, dialect_protocols.CTESupport, + dialect_protocols.ColumnAttributeSupport, dialect_protocols.FilterClauseSupport, dialect_protocols.WindowFunctionSupport, dialect_protocols.JSONSupport, From b65703593a182d8ee0e5a6b40923d6c5b2606c5d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 22 Sep 2026 02:11:23 +0800 Subject: [PATCH 18/42] feat(postgres): declare supports_table_inherits PostgreSQL supports table inheritance via INHERITS (...); enable the core capability gate. --- .../activerecord/backend/impl/postgres/mixins/ddl_table.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py index 79642056..c9af1cab 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py @@ -36,6 +36,10 @@ def supports_table_tablespace(self) -> bool: """TABLESPACE specification is supported in all versions.""" return True + def supports_table_inherits(self) -> bool: + """PostgreSQL supports table inheritance via ``INHERITS (...)``.""" + return True + def supports_create_table_like(self) -> bool: """PostgreSQL supports CREATE TABLE (LIKE ...) with INCLUDING/EXCLUDING options.""" return True From 53bd651a36ad63dc2a119b1d1d05ad1d114d8a49 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 22 Sep 2026 02:30:05 +0800 Subject: [PATCH 19/42] fix(postgres): drop duplicate supports_table_inherits The capability is already declared as supports_table_inheritance on PostgresTableMixin/PostgresTableSupport. --- .../activerecord/backend/impl/postgres/mixins/ddl_table.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py index c9af1cab..79642056 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py @@ -36,10 +36,6 @@ def supports_table_tablespace(self) -> bool: """TABLESPACE specification is supported in all versions.""" return True - def supports_table_inherits(self) -> bool: - """PostgreSQL supports table inheritance via ``INHERITS (...)``.""" - return True - def supports_create_table_like(self) -> bool: """PostgreSQL supports CREATE TABLE (LIKE ...) with INCLUDING/EXCLUDING options.""" return True From 43e6763a3eefe3834eb87a9b71a1b4b573d5b5a7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 22 Sep 2026 08:13:04 +0800 Subject: [PATCH 20/42] test(postgres): live Model.truncate() execution (sync + async) --- .../backend/postgres/test_model_truncate.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py new file mode 100644 index 00000000..3b0b7499 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py @@ -0,0 +1,64 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py +"""Live execution tests for the model-level ``truncate()`` operation.""" + +import pytest +import pytest_asyncio + +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + + +TABLE = "model_truncate_events" + + +class TestSyncModelTruncate: + @pytest.fixture + def table(self, postgres_backend): + postgres_backend.execute(f"DROP TABLE IF EXISTS {TABLE}") + postgres_backend.execute( + f"CREATE TABLE {TABLE} (id SERIAL PRIMARY KEY, name VARCHAR(50))" + ) + postgres_backend.execute(f"INSERT INTO {TABLE} (name) VALUES ('a'), ('b'), ('c')") + yield + postgres_backend.execute(f"DROP TABLE IF EXISTS {TABLE}") + + def test_truncate_removes_all_rows(self, postgres_backend, table): + class Event(ActiveRecord): + __table_name__ = TABLE + + id: int + name: str + + Event.__backend__ = postgres_backend + Event.truncate() + assert postgres_backend.fetch_one( + f"SELECT COUNT(*) AS cnt FROM {TABLE}" + )["cnt"] == 0 + + +class TestAsyncModelTruncate: + @pytest_asyncio.fixture + async def table(self, async_postgres_backend): + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {TABLE}") + await async_postgres_backend.execute( + f"CREATE TABLE {TABLE} (id SERIAL PRIMARY KEY, name VARCHAR(50))" + ) + await async_postgres_backend.execute( + f"INSERT INTO {TABLE} (name) VALUES ('a'), ('b'), ('c')" + ) + yield + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {TABLE}") + + @pytest.mark.asyncio + async def test_truncate_removes_all_rows(self, async_postgres_backend, table): + class Event(AsyncActiveRecord): + __table_name__ = TABLE + + id: int + name: str + + Event.__backend__ = async_postgres_backend + await Event.truncate() + count = await async_postgres_backend.fetch_one( + f"SELECT COUNT(*) AS cnt FROM {TABLE}" + ) + assert count["cnt"] == 0 From 445e647c2330f5b965f5ce1b053af04232805792 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 23 Sep 2026 17:46:01 +0800 Subject: [PATCH 21/42] fix(postgres): remove invalid inline column COMMENT; declare supports_comment_on format_column_definition no longer inlines COMMENT 'x' (syntax PostgreSQL itself rejects); a declared column comment raises UnsupportedFeatureError instead of producing broken DDL. PostgresCommentMixin declares supports_comment_on() and is composed before TableMixin so format_comment_statement is not shadowed by the generic one; it accepts any comment expression and quotes dotted targets segment-by-segment. The comment protocol declares supports_comment_on as well. --- .../backend/impl/postgres/dialect.py | 2 +- .../impl/postgres/mixins/ddl/comment.py | 31 ++++++++++++++----- .../backend/impl/postgres/mixins/ddl_table.py | 12 +++++-- .../impl/postgres/protocols/ddl/comment.py | 11 +++++++ .../backend/postgres/test_ddl_expressions.py | 8 ++--- .../test_postgres_column_definition.py | 10 ++++-- 6 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 6b600103..e04849ae 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -356,6 +356,7 @@ class PostgresDialect( PostgresMaterializedViewMixin, PostgresAlterColumnModifierMixin, # Before TableMixin/ConstraintMixin to override format_*_action PostgresTableMixin, # Before TableMixin to override supports_create_table_like + PostgresCommentMixin, # Before TableMixin to override format_comment_statement TableMixin, ConstraintMixin, PostgresPartitionMixin, @@ -399,7 +400,6 @@ class PostgresDialect( PostgresAddressStandardizerMixin, # DDL feature mixins PostgresTriggerMixin, - PostgresCommentMixin, PostgresTypeMixin, PostgresConstraintMixin, PostgresPolicyMixin, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py index 5e7b2d57..9e1627b8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py @@ -1,20 +1,34 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py """PostgreSQL COMMENT ON DDL implementation.""" -from typing import Tuple, TYPE_CHECKING - -if TYPE_CHECKING: - from ...expression.ddl import PostgresCommentExpression +from typing import Tuple class PostgresCommentMixin: """PostgreSQL COMMENT ON implementation.""" - def format_comment_statement(self, expr: "PostgresCommentExpression") -> Tuple[str, tuple]: + def supports_comment_on(self) -> bool: + """Whether standalone ``COMMENT ON`` statements are supported. + + PostgreSQL has no inline ``COMMENT`` syntax; comments are annotated + through the standalone ``COMMENT ON`` statement (available since + 7.2). Always ``True``. + """ + return True + + def format_comment_statement(self, expr) -> Tuple[str, tuple]: """Format COMMENT ON statement (PostgreSQL-specific). + Accepts any comment expression carrying ``object_type``, + ``object_name`` and ``comment``. The comment text is bound + through a parameter placeholder (``IS ?`` / params); ``comment=None`` + renders ``IS NULL`` (clears the comment). Dotted names + (``schema.table`` / ``table.column``) are quoted segment-by-segment + so the rendered target stays a valid qualified reference. + Args: - expr: PostgresCommentExpression containing all options + expr: PostgresCommentExpression or CommentOnExpression carrying + ``object_type``, ``object_name`` and ``comment``. Returns: Tuple of (SQL string, parameters) @@ -24,7 +38,10 @@ def format_comment_statement(self, expr: "PostgresCommentExpression") -> Tuple[s else: comment_value = self.get_parameter_placeholder() - parts = ["COMMENT ON", expr.object_type, self.format_identifier(expr.object_name)] + object_sql = ".".join( + self.format_identifier(part) for part in str(expr.object_name).split(".") + ) + parts = ["COMMENT ON", expr.object_type, object_sql] parts.append("IS") parts.append(comment_value) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py index 79642056..66f833f7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py @@ -164,7 +164,6 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: ``PostgresColumnDefinition``; the latter's PostgreSQL-only attributes (``compression`` / ``storage`` / ``statistics``) are rendered here. """ - from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.impl.postgres.expression.ddl.column import ( PostgresColumnDefinition, ) @@ -190,8 +189,15 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: col_sql += suffix all_params.extend(params) if col_def.comment: - escaped_comment = SQLDialectBase._escape_sql_string(col_def.comment) - col_sql += f" COMMENT '{escaped_comment}'" + # PostgreSQL has no inline COMMENT syntax (the de-facto + # vendor form is the standalone COMMENT ON statement); a comment + # on a column definition is never silently dropped. + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, "COLUMN COMMENT", + "PostgreSQL has no inline column comment; use a standalone " + "COMMENT ON COLUMN statement.", + ) if col_def.generated_expression is not None: gen_sql, gen_params = col_def.generated_expression.to_sql() col_sql += gen_sql diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py index e3a9d854..18175421 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py @@ -33,6 +33,17 @@ class PostgresCommentSupport(Protocol): - All versions """ + def supports_comment_on(self) -> bool: + """Whether standalone ``COMMENT ON`` statements are supported. + + PostgreSQL has no inline ``COMMENT`` syntax; comments are annotated + through the standalone ``COMMENT ON`` statement (available since 7.2). + + Returns: + True + """ + ... + def format_comment_statement(self, expr: "PostgresCommentExpression") -> Tuple[str, tuple]: """Format COMMENT ON statement. diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py index fc6e3a34..a5076b84 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py @@ -151,7 +151,7 @@ def test_comment_on_table(self, dialect): assert params == ("User accounts table",) def test_comment_on_column(self, dialect): - """Test COMMENT ON COLUMN.""" + """Test COMMENT ON COLUMN (dotted target quoted segment-by-segment).""" expr = PostgresCommentExpression( dialect=dialect, object_type="COLUMN", @@ -160,7 +160,7 @@ def test_comment_on_column(self, dialect): ) sql, params = expr.to_sql() assert "COMMENT ON COLUMN" in sql - assert "users.email" in sql + assert '"users"."email"' in sql assert params == ("User email address",) def test_comment_on_index(self, dialect): @@ -221,7 +221,7 @@ def test_remove_comment(self, dialect): assert params == () def test_comment_with_schema(self, dialect): - """Test comment on object with schema.""" + """Test comment on object with schema (dotted target quoted per segment).""" expr = PostgresCommentExpression( dialect=dialect, object_type="TABLE", @@ -230,7 +230,7 @@ def test_comment_with_schema(self, dialect): schema="public", ) sql, params = expr.to_sql() - assert "public.users" in sql + assert '"public"."users"' in sql assert params == ("Public users table",) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py index 6cc7ab8b..1eb8b2e9 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py @@ -51,10 +51,14 @@ def test_combined(dialect): assert sql == '"body" TEXT COMPRESSION pglz STORAGE EXTERNAL STATISTICS 100' -def test_generic_column_still_renders_on_postgres(dialect): +def test_generic_column_comment_raises_on_postgres(dialect): + # §5.19: PostgreSQL has no inline COMMENT syntax; a comment on a column + # definition raises instead of rendering SQL PostgreSQL would reject. + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + generic = ColumnDefinition(dialect, "body", TextType(dialect), comment="c") - sql, _ = generic.to_sql() - assert sql == '"body" TEXT COMMENT \'c\'' + with pytest.raises(UnsupportedFeatureError, match="COLUMN COMMENT"): + generic.to_sql() def test_invalid_storage_type(dialect): From fef8bc39de15401621e44d551dee895f8a084987 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 23 Sep 2026 21:54:38 +0800 Subject: [PATCH 22/42] refactor(ddl): use core typed comment clause nodes Adapt the inline column-comment guard to the ColumnCommentClause node and update the corresponding test. --- .../activerecord/backend/impl/postgres/mixins/ddl_table.py | 2 +- .../backend/postgres/test_postgres_column_definition.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py index 66f833f7..430ad7fb 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py @@ -188,7 +188,7 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: suffix, params = self.format_column_constraint(constraint) col_sql += suffix all_params.extend(params) - if col_def.comment: + if col_def.comment is not None: # PostgreSQL has no inline COMMENT syntax (the de-facto # vendor form is the standalone COMMENT ON statement); a comment # on a column definition is never silently dropped. diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py index 1eb8b2e9..8cd07bf0 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py @@ -3,7 +3,7 @@ import pytest -from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression import ColumnCommentClause, ColumnDefinition from rhosocial.activerecord.backend.expression.types import TextType from rhosocial.activerecord.backend.impl.postgres import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression import ( @@ -56,7 +56,9 @@ def test_generic_column_comment_raises_on_postgres(dialect): # definition raises instead of rendering SQL PostgreSQL would reject. from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - generic = ColumnDefinition(dialect, "body", TextType(dialect), comment="c") + generic = ColumnDefinition( + dialect, "body", TextType(dialect), comment=ColumnCommentClause(dialect, "c") + ) with pytest.raises(UnsupportedFeatureError, match="COLUMN COMMENT"): generic.to_sql() From 8a758845afefff1e473f976185a356eff0da8acd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 23 Sep 2026 22:32:14 +0800 Subject: [PATCH 23/42] test(ddl): classify CommentSupport for PostgreSQL conformance --- .../feature/backend/test_postgres_protocol_conformance.py | 4 ++++ 1 file changed, 4 insertions(+) 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 d3154de1..79e30f5e 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 @@ -86,6 +86,7 @@ def get_postgres_protocols(): dialect_protocols.CollationSupport, dialect_protocols.CTESupport, dialect_protocols.ColumnAttributeSupport, + dialect_protocols.CommentSupport, dialect_protocols.FilterClauseSupport, dialect_protocols.WindowFunctionSupport, dialect_protocols.JSONSupport, @@ -331,6 +332,9 @@ def test_no_interface_overlap_between_protocols(self): # Generic generated-column capability also declared by PG features protocol ('GeneratedColumnSupport', 'PostgresFeaturesSupport'), ('PostgresFeaturesSupport', 'GeneratedColumnSupport'), + # Generic COMMENT ON capability restated by the PG comment protocol + ('CommentSupport', 'PostgresCommentSupport'), + ('PostgresCommentSupport', 'CommentSupport'), } violations = [] From ba93b90ba0ce438879f7880aa9ca505c3a8e9cd7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 00:27:09 +0800 Subject: [PATCH 24/42] test: drop redundant __primary_key__ = "id" from fixtures Model base already defaults to "id"; fixtures should exercise the unspecified-PK default path. --- tests/providers/partition.py | 2 -- .../postgres/test_qualified_reference_context.py | 2 -- .../feature/basic/fixtures/models.py | 2 -- .../feature/basic/fixtures/models_py310.py | 14 -------------- .../feature/basic/fixtures/models_py311.py | 14 -------------- .../feature/basic/fixtures/models_py312.py | 14 -------------- 6 files changed, 48 deletions(-) diff --git a/tests/providers/partition.py b/tests/providers/partition.py index c3815601..6c622de4 100644 --- a/tests/providers/partition.py +++ b/tests/providers/partition.py @@ -321,7 +321,6 @@ def _event_model(self, backend): class PartitionEvent(ActiveRecord): __table_name__ = table_name - __primary_key__ = "id" __backend__ = backend c: ClassVar[FieldProxy] = FieldProxy() @@ -338,7 +337,6 @@ def _async_event_model(self, backend): class AsyncPartitionEvent(AsyncActiveRecord): __table_name__ = table_name - __primary_key__ = "id" __backend__ = backend c: ClassVar[FieldProxy] = FieldProxy() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py index ae7ae482..e2632a58 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py @@ -40,7 +40,6 @@ class Author(ActiveRecord): __table_name__ = "authors" __schema_name__ = RISK_SCHEMA - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -51,7 +50,6 @@ class Author(ActiveRecord): class Book(ActiveRecord): __table_name__ = "books" __schema_name__ = RISK_SCHEMA - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py index 5c05e000..34d7f6f9 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py @@ -53,7 +53,6 @@ class PostgresMixedAnnotationModel(ActiveRecord): - metadata: Uses JSONB (adapter handles dict <-> str) """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[Optional[int], UseColumn("id")] = None @@ -68,7 +67,6 @@ class PostgresMixedAnnotationModel(ActiveRecord): class AsyncPostgresMixedAnnotationModel(AsyncActiveRecord): """Async version of PostgresMixedAnnotationModel.""" __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py310.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py310.py index a6c6df92..900034bb 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py310.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py310.py @@ -338,7 +338,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -360,7 +359,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -383,7 +381,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "users" - __primary_key__ = "id" # Python field: user_id, Database column: id user_id: Annotated[int | None, UseColumn("id")] = None @@ -404,7 +401,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: user_id, Database column: id @@ -426,7 +422,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "posts" - __primary_key__ = "id" # Python field: post_id, Database column: id post_id: Annotated[int | None, UseColumn("id")] = None @@ -453,7 +448,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: post_id, Database column: id @@ -481,7 +475,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "comments" - __primary_key__ = "id" # Python field: comment_id, Database column: id comment_id: Annotated[int | None, UseColumn("id")] = None @@ -508,7 +501,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: comment_id, Database column: id @@ -551,7 +543,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -567,7 +558,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. @@ -647,7 +637,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" # 1. Standard Python type name: str @@ -674,7 +663,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # 1. Standard Python type @@ -750,7 +738,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -760,7 +747,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py311.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py311.py index f912576e..c348093f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py311.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py311.py @@ -385,7 +385,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -404,7 +403,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -424,7 +422,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -443,7 +440,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -458,7 +454,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -474,7 +469,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -491,7 +485,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -507,7 +500,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -538,7 +530,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -552,7 +543,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -631,7 +621,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[int, UseColumn("id")] @@ -656,7 +645,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -721,7 +709,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -731,7 +718,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py312.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py312.py index c0178057..d0e8ce20 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py312.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py312.py @@ -372,7 +372,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -391,7 +390,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -411,7 +409,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -430,7 +427,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -445,7 +441,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -461,7 +456,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -478,7 +472,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -494,7 +487,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -525,7 +517,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -539,7 +530,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -618,7 +608,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[int, UseColumn("id")] @@ -640,7 +629,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -755,7 +743,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -765,7 +752,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None From 19af5e544f6184f8eed1b10a822032b0258923f7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 09:41:54 +0800 Subject: [PATCH 25/42] feat(postgres): complete type and domain DDL support --- .../backend/impl/postgres/__init__.py | 56 +- .../backend/impl/postgres/dialect.py | 29 +- .../impl/postgres/expression/__init__.py | 56 + .../impl/postgres/expression/ddl/__init__.py | 66 +- .../impl/postgres/expression/ddl/domain.py | 367 +++++-- .../postgres/expression/ddl/multirange.py | 61 +- .../impl/postgres/expression/ddl/type.py | 969 ++++++++++-------- .../impl/postgres/expression/registry.py | 105 ++ .../backend/impl/postgres/mixins/data_type.py | 5 + .../impl/postgres/mixins/ddl/domain.py | 446 ++++++-- .../backend/impl/postgres/mixins/ddl/type.py | 583 +++++++++-- .../impl/postgres/mixins/types/data_type.py | 42 +- .../mixins/types/data_type_formatting.py | 42 +- .../impl/postgres/mixins/types/enum.py | 416 +++----- .../impl/postgres/mixins/types/multirange.py | 144 +-- .../impl/postgres/protocols/ddl/domain.py | 83 +- .../impl/postgres/protocols/ddl/type.py | 93 +- .../postgres/protocols/types/multirange.py | 81 +- .../backend/impl/postgres/types/enum.py | 60 +- .../feature/backend/conftest.py | 2 +- .../expression/test_expression_signatures.py | 56 +- .../backend/postgres/test_ddl_alter_config.py | 7 +- .../backend/postgres/test_ddl_objects.py | 514 +++++++++- .../backend/postgres/test_ddl_protocols.py | 106 ++ .../backend/postgres/test_enum_types.py | 94 +- .../test_postgres_protocol_conformance.py | 23 +- 26 files changed, 3032 insertions(+), 1474 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py index 18b53f69..515b9421 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py @@ -76,11 +76,37 @@ ) from .explain import PostgresExplainResult, PostgresExplainPlanLine from .expression.ddl import ( - PostgresCreateEnumTypeExpression, - PostgresDropEnumTypeExpression, + PostgresAddDomainCheckAction, + PostgresAddEnumValueAction, + PostgresAddTypeAttributeAction, + PostgresAlterDomainExpression, PostgresAlterEnumTypeAddValueExpression, PostgresAlterEnumTypeRenameValueExpression, + PostgresAlterTypeAttributeAction, + PostgresBaseTypeDefinition, + PostgresChangeDomainOwnerAction, + PostgresChangeTypeOwnerAction, + PostgresCompositeTypeAttribute, + PostgresCompositeTypeDefinition, + PostgresCreateDomainExpression, + PostgresCreateEnumTypeExpression, PostgresCreateRangeTypeExpression, + PostgresDropDomainCheckAction, + PostgresDropDomainExpression, + PostgresDropEnumTypeExpression, + PostgresDropTypeAttributeAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresRenameDomainConstraintAction, + PostgresRenameEnumValueAction, + PostgresRenameTypeAction, + PostgresRenameTypeAttributeAction, + PostgresSetDomainSchemaAction, + PostgresSetTypePropertiesAction, + PostgresSetTypeSchemaAction, + PostgresShellTypeDefinition, + PostgresValidateDomainConstraintAction, ) from .adapters import ( PostgresEnumAdapter, @@ -330,11 +356,37 @@ "PostgresExplainResult", "PostgresExplainPlanLine", # PostgreSQL DDL Statements + "PostgresCompositeTypeAttribute", + "PostgresCompositeTypeDefinition", + "PostgresEnumTypeDefinition", + "PostgresRangeTypeDefinition", + "PostgresBaseTypeDefinition", + "PostgresShellTypeDefinition", + "PostgresRenameTypeAction", + "PostgresSetTypeSchemaAction", + "PostgresChangeTypeOwnerAction", + "PostgresRenameTypeAttributeAction", + "PostgresAddTypeAttributeAction", + "PostgresDropTypeAttributeAction", + "PostgresAlterTypeAttributeAction", + "PostgresAddEnumValueAction", + "PostgresRenameEnumValueAction", + "PostgresSetTypePropertiesAction", + "PostgresDropTypeExpression", "PostgresCreateEnumTypeExpression", "PostgresDropEnumTypeExpression", "PostgresAlterEnumTypeAddValueExpression", "PostgresAlterEnumTypeRenameValueExpression", "PostgresCreateRangeTypeExpression", + "PostgresAddDomainCheckAction", + "PostgresDropDomainCheckAction", + "PostgresRenameDomainConstraintAction", + "PostgresValidateDomainConstraintAction", + "PostgresChangeDomainOwnerAction", + "PostgresSetDomainSchemaAction", + "PostgresCreateDomainExpression", + "PostgresAlterDomainExpression", + "PostgresDropDomainExpression", # Range Types "PostgresRange", "PostgresRangeAdapter", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index e04849ae..e560dd9f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -6,11 +6,10 @@ based on the PostgreSQL version provided at initialization. """ -from typing import Tuple, Optional, TYPE_CHECKING +from typing import Tuple, Optional, cast, TYPE_CHECKING if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.collation import CollateExpression - from .function_versions import FunctionSupportInfo, FunctionVersionRequirement + from rhosocial.activerecord.backend.schema.differ import SchemaDiffer from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.dialect.mixins import ( @@ -50,6 +49,8 @@ DMLMixin, DDLColumnMixin, TransactionControlMixin, + UserDefinedTypeMixin, + DomainMixin, ) from rhosocial.activerecord.backend.dialect.protocols import ( SQLXMLSupport, @@ -87,6 +88,8 @@ TransactionControlSupport, SQLFunctionSupport, DDLTypeSupport, + UserDefinedTypeSupport, + DomainSupport, ) from .mixins import ( PostgresExtensionMixin, @@ -194,6 +197,8 @@ PostgresFunctionMixin, ) +from .protocols.ddl.type import PostgresTypeSupport +from .protocols.ddl.domain import PostgresDomainSupport from .reserved_words import POSTGRESQL_RESERVED_WORDS # PostgreSQL-specific imports @@ -263,13 +268,11 @@ # DDL feature protocols PostgresTriggerSupport, PostgresCommentSupport, - PostgresTypeSupport, PostgresConstraintSupport, PostgresPolicySupport, PostgresRlsConfigSupport, PostgresAlterTableSettingsSupport, PostgresClusterSupport, - PostgresDomainSupport, PostgresCollationDDLSupport, PostgresForeignTableDDLSupport, PostgresRoutineDDLSupport, @@ -291,6 +294,14 @@ class PostgresDialect( + PostgresTypeMixin, + PostgresDomainMixin, + PostgresTypeSupport, + PostgresDomainSupport, + UserDefinedTypeMixin, + DomainMixin, + UserDefinedTypeSupport, + DomainSupport, SQLDialectBase, # PG-specific mixins (before global mixins to override) PostgresDateTimeMixin, @@ -400,13 +411,11 @@ class PostgresDialect( PostgresAddressStandardizerMixin, # DDL feature mixins PostgresTriggerMixin, - PostgresTypeMixin, PostgresConstraintMixin, PostgresPolicyMixin, PostgresRlsConfigMixin, PostgresAlterTableSettingsMixin, PostgresClusterMixin, - PostgresDomainMixin, PostgresCollationDDLMixin, PostgresForeignTableMixin, PostgresRoutineMixin, @@ -533,13 +542,11 @@ class PostgresDialect( # DDL feature protocols PostgresTriggerSupport, PostgresCommentSupport, - PostgresTypeSupport, PostgresConstraintSupport, PostgresPolicySupport, PostgresRlsConfigSupport, PostgresAlterTableSettingsSupport, PostgresClusterSupport, - PostgresDomainSupport, PostgresCollationDDLSupport, PostgresForeignTableDDLSupport, PostgresRoutineDDLSupport, @@ -634,10 +641,10 @@ def get_server_version(self) -> Tuple[int, int, int]: """Return the PostgreSQL version this dialect is configured for.""" return self.version - def create_schema_differ(self): + def create_schema_differ(self) -> "SchemaDiffer": """Return the PostgreSQL schema differ for this dialect.""" from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( PostgresSchemaDiffer, ) - return PostgresSchemaDiffer() + return cast("SchemaDiffer", PostgresSchemaDiffer()) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index a47f0a83..2bb80764 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -42,12 +42,38 @@ PostgresDropStatisticsExpression, PostgresCommentExpression, PostgresRefreshMaterializedViewExpression, + PostgresCompositeTypeAttribute, + PostgresCompositeTypeDefinition, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresBaseTypeDefinition, + PostgresShellTypeDefinition, + PostgresRenameTypeAction, + PostgresSetTypeSchemaAction, + PostgresChangeTypeOwnerAction, + PostgresRenameTypeAttributeAction, + PostgresAddTypeAttributeAction, + PostgresDropTypeAttributeAction, + PostgresAlterTypeAttributeAction, + PostgresAddEnumValueAction, + PostgresRenameEnumValueAction, + PostgresSetTypePropertiesAction, + PostgresDropTypeExpression, PostgresCreateEnumTypeExpression, PostgresDropEnumTypeExpression, PostgresAlterEnumAddValueExpression, PostgresAlterEnumTypeAddValueExpression, PostgresAlterEnumTypeRenameValueExpression, PostgresCreateRangeTypeExpression, + PostgresAddDomainCheckAction, + PostgresDropDomainCheckAction, + PostgresRenameDomainConstraintAction, + PostgresValidateDomainConstraintAction, + PostgresChangeDomainOwnerAction, + PostgresSetDomainSchemaAction, + PostgresCreateDomainExpression, + PostgresAlterDomainExpression, + PostgresDropDomainExpression, PostgresCreateExtensionExpression, PostgresDropExtensionExpression, PostgresPgPartmanCreateParentExpression, @@ -109,6 +135,9 @@ PostgresXMLType, PostgresCidrType, ) +from .registry import register_postgres_ddl_expressions + +register_postgres_ddl_expressions() __all__ = [ # advisory @@ -143,12 +172,38 @@ "PostgresDropStatisticsExpression", "PostgresCommentExpression", "PostgresRefreshMaterializedViewExpression", + "PostgresCompositeTypeAttribute", + "PostgresCompositeTypeDefinition", + "PostgresEnumTypeDefinition", + "PostgresRangeTypeDefinition", + "PostgresBaseTypeDefinition", + "PostgresShellTypeDefinition", + "PostgresRenameTypeAction", + "PostgresSetTypeSchemaAction", + "PostgresChangeTypeOwnerAction", + "PostgresRenameTypeAttributeAction", + "PostgresAddTypeAttributeAction", + "PostgresDropTypeAttributeAction", + "PostgresAlterTypeAttributeAction", + "PostgresAddEnumValueAction", + "PostgresRenameEnumValueAction", + "PostgresSetTypePropertiesAction", + "PostgresDropTypeExpression", "PostgresCreateEnumTypeExpression", "PostgresDropEnumTypeExpression", "PostgresAlterEnumAddValueExpression", "PostgresAlterEnumTypeAddValueExpression", "PostgresAlterEnumTypeRenameValueExpression", "PostgresCreateRangeTypeExpression", + "PostgresAddDomainCheckAction", + "PostgresDropDomainCheckAction", + "PostgresRenameDomainConstraintAction", + "PostgresValidateDomainConstraintAction", + "PostgresChangeDomainOwnerAction", + "PostgresSetDomainSchemaAction", + "PostgresCreateDomainExpression", + "PostgresAlterDomainExpression", + "PostgresDropDomainExpression", "PostgresCreateExtensionExpression", "PostgresDropExtensionExpression", "PostgresPgPartmanCreateParentExpression", @@ -206,4 +261,5 @@ "PostgresXIDType", "PostgresXMLType", "PostgresCidrType", + "register_postgres_ddl_expressions", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index 23440819..b5d0b8bd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -92,17 +92,34 @@ from .comment import PostgresCommentExpression from .mv import PostgresRefreshMaterializedViewExpression from .type import ( - PostgresCreateEnumTypeExpression, - PostgresDropEnumTypeExpression, + PostgresAddEnumValueAction, + PostgresAddTypeAttributeAction, PostgresAlterEnumAddValueExpression, PostgresAlterEnumTypeAddValueExpression, PostgresAlterEnumTypeRenameValueExpression, + PostgresAlterTypeAttributeAction, + PostgresBaseTypeDefinition, + PostgresChangeTypeOwnerAction, + PostgresCompositeTypeAttribute, + PostgresCompositeTypeDefinition, + PostgresCreateEnumTypeExpression, PostgresCreateRangeTypeExpression, - EnumTypeNameExpression, - EnumValuesExpression, + PostgresDropEnumTypeExpression, + PostgresDropTypeAttributeAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresRenameEnumValueAction, + PostgresRenameTypeAction, + PostgresRenameTypeAttributeAction, + PostgresSetTypePropertiesAction, + PostgresSetTypeSchemaAction, + PostgresShellTypeDefinition, + AlterEnumAddValueExpression, CreateEnumTypeExpression, DropEnumTypeExpression, - AlterEnumAddValueExpression, + EnumTypeNameExpression, + EnumValuesExpression, ) from .extension import PostgresCreateExtensionExpression, PostgresDropExtensionExpression from .multirange import ( @@ -135,9 +152,18 @@ from .cluster import PostgresClusterExpression from .domain import ( AlterDomainActionType, - PostgresCreateDomainExpression, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + PostgresAddDomainCheckAction, PostgresAlterDomainExpression, + PostgresChangeDomainOwnerAction, + PostgresCreateDomainExpression, + PostgresDropDomainCheckAction, PostgresDropDomainExpression, + PostgresRenameDomainConstraintAction, + PostgresSetDomainSchemaAction, + PostgresValidateDomainConstraintAction, ) from .collation import ( PostgresCreateCollationExpression, @@ -193,7 +219,24 @@ "PostgresCommentExpression", # mv "PostgresRefreshMaterializedViewExpression", - # type (enum/range) + # type + "PostgresCompositeTypeAttribute", + "PostgresCompositeTypeDefinition", + "PostgresEnumTypeDefinition", + "PostgresRangeTypeDefinition", + "PostgresBaseTypeDefinition", + "PostgresShellTypeDefinition", + "PostgresRenameTypeAction", + "PostgresSetTypeSchemaAction", + "PostgresChangeTypeOwnerAction", + "PostgresRenameTypeAttributeAction", + "PostgresAddTypeAttributeAction", + "PostgresDropTypeAttributeAction", + "PostgresAlterTypeAttributeAction", + "PostgresAddEnumValueAction", + "PostgresRenameEnumValueAction", + "PostgresSetTypePropertiesAction", + "PostgresDropTypeExpression", "PostgresCreateEnumTypeExpression", "PostgresDropEnumTypeExpression", "PostgresAlterEnumAddValueExpression", @@ -233,6 +276,15 @@ "PostgresClusterExpression", # domain "AlterDomainActionType", + "DomainNullability", + "DomainValueExpression", + "DomainCheckConstraint", + "PostgresAddDomainCheckAction", + "PostgresDropDomainCheckAction", + "PostgresRenameDomainConstraintAction", + "PostgresValidateDomainConstraintAction", + "PostgresChangeDomainOwnerAction", + "PostgresSetDomainSchemaAction", "PostgresCreateDomainExpression", "PostgresAlterDomainExpression", "PostgresDropDomainExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py index cb7d02f3..841fe26d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py @@ -1,20 +1,30 @@ # src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py -""" -PostgreSQL DDL expressions: DOMAIN operations. +"""PostgreSQL DOMAIN DDL expressions.""" -PostgreSQL Documentation: -- CREATE DOMAIN: https://www.postgresql.org/docs/current/sql-createdomain.html -- ALTER DOMAIN: https://www.postgresql.org/docs/current/sql-alterdomain.html -- DROP DOMAIN: https://www.postgresql.org/docs/current/sql-dropdomain.html - -Version Requirements: -- CREATE/ALTER/DROP DOMAIN: PostgreSQL 9.6+ (all supported versions) -""" +from __future__ import annotations +import warnings from enum import Enum -from typing import Any, List, Optional, TYPE_CHECKING +from typing import Any, Dict, List, Optional, Sequence, Union, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLPredicate +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AddDomainCheckAction, + AlterDomainExpression, + CreateDomainExpression, + DomainAlterAction, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + DropDomainCheckAction, + DropDomainDefaultAction, + DropDomainExpression, + DropDomainNotNullAction, + RenameDomainAction, + SetDomainDefaultAction, + SetDomainNotNullAction, +) +from rhosocial.activerecord.backend.expression.types import DataType if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -22,112 +32,301 @@ __all__ = [ "AlterDomainActionType", + "DomainNullability", + "DomainValueExpression", + "DomainCheckConstraint", + "SetDomainDefaultAction", + "DropDomainDefaultAction", + "SetDomainNotNullAction", + "DropDomainNotNullAction", + "PostgresAddDomainCheckAction", + "PostgresDropDomainCheckAction", + "PostgresRenameDomainConstraintAction", + "PostgresValidateDomainConstraintAction", + "PostgresChangeDomainOwnerAction", + "PostgresSetDomainSchemaAction", "PostgresCreateDomainExpression", "PostgresAlterDomainExpression", "PostgresDropDomainExpression", ] -class AlterDomainActionType(Enum): - """Actions supported by ALTER DOMAIN (minimal subset).""" +def _validate_name(value: str, field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +def _coerce_legacy_data_type( + dialect: "SQLDialectBase", + value: Union[DataType, str], +) -> DataType: + if isinstance(value, DataType): + return value + if not isinstance(value, str) or not value.strip(): + raise TypeError("data_type must be a DataType instance") + warnings.warn( + "Raw DOMAIN data_type strings are deprecated; pass a DataType instance", + DeprecationWarning, + stacklevel=3, + ) + return DataType.parse_data_type_str(dialect, value) + + +class _LegacySqlExpression(BaseExpression): + def __init__( + self, + dialect: "SQLDialectBase", + sql: str, + field_name: str, + ) -> None: + super().__init__(dialect) + if not isinstance(sql, str) or not sql.strip(): + raise ValueError(f"{field_name} must be a non-empty SQL string") + self.sql = sql + self.field_name = field_name + + @property + def format_method(self) -> str: + return "_format_postgres_legacy_sql_expression" + + +class _UnsupportedDomainAction(DomainAlterAction): + action_kind = "unsupported" + + def __init__(self, dialect: "SQLDialectBase", action: Any) -> None: + super().__init__(dialect) + self.action = action + @property + def format_method(self) -> str: + return "_format_postgres_unsupported_domain_action" + + +class AlterDomainActionType(Enum): SET_DEFAULT = "SET DEFAULT" DROP_DEFAULT = "DROP DEFAULT" RENAME_TO = "RENAME TO" -class PostgresCreateDomainExpression(BaseExpression): - """PostgreSQL CREATE DOMAIN statement expression. +class PostgresAddDomainCheckAction(AddDomainCheckAction): + def __init__( + self, + dialect: "SQLDialectBase", + check: Union[DomainCheckConstraint, SQLPredicate], + *, + not_valid: bool = False, + ) -> None: + if isinstance(check, SQLPredicate): + check = DomainCheckConstraint(dialect, check) + super().__init__(dialect, check) + self.not_valid = not_valid + - Creates a new domain — a user-defined data type — over an underlying - PostgreSQL type, often reusing types and default/constraint logic. +class PostgresDropDomainCheckAction(DropDomainCheckAction): + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + *, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__(dialect, name=name) + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict - Attributes: - name: Name of the domain. - data_type: Underlying PostgreSQL type name (e.g. ``NUMERIC(10, 2)``). - schema: Optional schema for the domain. - collation: Optional collation name. - default: Optional literal default value expression. - constraints: Optional list of constraint clauses (e.g. ``CHECK (...)``). - """ +class PostgresRenameDomainConstraintAction(DomainAlterAction): + action_kind = "rename_constraint" def __init__( self, dialect: "SQLDialectBase", name: str, - data_type: str, - schema: Optional[str] = None, - collation: Optional[str] = None, - default: Optional[str] = None, - constraints: Optional[List[str]] = None, - ): + new_name: str, + ) -> None: super().__init__(dialect) + _validate_name(name, "name") + _validate_name(new_name, "new_name") self.name = name - self.data_type = data_type - self.schema = schema - self.collation = collation - self.default = default - self.constraints = constraints or [] + self.new_name = new_name - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_create_domain_statement" + +class PostgresValidateDomainConstraintAction(DomainAlterAction): + action_kind = "validate_constraint" + + def __init__(self, dialect: "SQLDialectBase", name: str) -> None: + super().__init__(dialect) + _validate_name(name, "name") + self.name = name -class PostgresAlterDomainExpression(BaseExpression): - """PostgreSQL ALTER DOMAIN statement expression (minimal subset). +class PostgresChangeDomainOwnerAction(DomainAlterAction): + action_kind = "change_owner" - Attributes: - name: Name of the domain to alter. - schema: Optional schema for the domain. - action: The action to perform (:class:`AlterDomainActionType`). - new_value: For ``SET DEFAULT``, the new literal default. Otherwise - unused. - new_name: For ``RENAME TO``, the new domain name. + def __init__(self, dialect: "SQLDialectBase", new_owner: str) -> None: + super().__init__(dialect) + _validate_name(new_owner, "new_owner") + self.new_owner = new_owner - Returns: - Formatting is delegated to - ``dialect.format_alter_domain_statement``. - """ +class PostgresSetDomainSchemaAction(DomainAlterAction): + action_kind = "set_schema" + + def __init__(self, dialect: "SQLDialectBase", new_schema: str) -> None: + super().__init__(dialect) + _validate_name(new_schema, "new_schema") + self.new_schema = new_schema + +class PostgresCreateDomainExpression(CreateDomainExpression): def __init__( self, dialect: "SQLDialectBase", name: str, - action: AlterDomainActionType, + data_type: Union[DataType, str], + schema: Optional[str] = None, + collation: Optional[str] = None, + default: Optional[Any] = None, + constraints: Optional[Sequence[Union[str, DomainCheckConstraint, SQLPredicate]]] = None, + *, + nullability: DomainNullability = DomainNullability.UNSPECIFIED, + checks: Optional[Sequence[Union[DomainCheckConstraint, SQLPredicate]]] = None, + schema_name: Optional[str] = None, + ) -> None: + normalized_type = _coerce_legacy_data_type(dialect, data_type) + resolved_schema = schema if schema is not None else schema_name + if schema is not None and schema_name is not None and schema != schema_name: + raise ValueError("schema and schema_name must match when both are provided") + constraint_items: List[Any] + if constraints is not None and checks is not None: + raise ValueError("constraints and checks are mutually exclusive") + if checks is not None: + check_parameter = "checks" + constraint_items = list(checks) + elif constraints is not None: + check_parameter = "constraints" + constraint_items = list(constraints) + else: + check_parameter = None + constraint_items = [] + clauses: List[BaseExpression] = [] + normalized_checks: List[DomainCheckConstraint] = [] + for item in constraint_items: + if isinstance(item, str): + warnings.warn( + "Raw DOMAIN constraint strings are deprecated; pass SQLPredicate " + "or DomainCheckConstraint instances", + DeprecationWarning, + stacklevel=2, + ) + clauses.append(_LegacySqlExpression(dialect, item, "constraints")) + elif isinstance(item, DomainCheckConstraint): + clauses.append(item) + normalized_checks.append(item) + elif isinstance(item, SQLPredicate): + check = DomainCheckConstraint(dialect, item) + clauses.append(check) + normalized_checks.append(check) + else: + raise TypeError( + "constraints must contain DomainCheckConstraint, SQLPredicate, or " + "legacy string instances" + ) + normalized_default = default + if isinstance(default, str): + warnings.warn( + "Raw DOMAIN DEFAULT strings are deprecated; pass a scalar or expression", + DeprecationWarning, + stacklevel=2, + ) + normalized_default = _LegacySqlExpression(dialect, default, "default") + super().__init__( + dialect, + name, + normalized_type, + default=normalized_default, + nullability=nullability, + checks=normalized_checks, + collation=collation, + ) + self.schema = resolved_schema + self.schema_name = resolved_schema + self.constraints = list(constraint_items) + self._constraint_clauses = clauses + self._check_parameter = check_parameter + + def get_params(self) -> Dict[str, Any]: + params = super().get_params() + params.pop("constraints", None) + params.pop("checks", None) + if self._check_parameter == "constraints": + params["constraints"] = list(self.constraints) + elif self._check_parameter == "checks": + params["checks"] = list(self.checks) + return params + + @property + def name(self) -> str: + return self.domain_name + + +class PostgresAlterDomainExpression(AlterDomainExpression): + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + action: Union[AlterDomainActionType, DomainAlterAction, Any], schema: Optional[str] = None, new_value: Any = None, new_name: Optional[str] = None, - ): - super().__init__(dialect) - self.name = name - self.schema = schema + *, + schema_name: Optional[str] = None, + ) -> None: + resolved_schema = schema if schema is not None else schema_name + if schema is not None and schema_name is not None and schema != schema_name: + raise ValueError("schema and schema_name must match when both are provided") + if isinstance(action, DomainAlterAction): + actions = [action] + elif isinstance(action, AlterDomainActionType): + if action is AlterDomainActionType.SET_DEFAULT: + normalized_default: Any = new_value + if isinstance(new_value, str): + warnings.warn( + "Raw ALTER DOMAIN DEFAULT strings are deprecated; pass a scalar " + "or expression", + DeprecationWarning, + stacklevel=2, + ) + normalized_default = _LegacySqlExpression( + dialect, + new_value, + "new_value", + ) + actions = [SetDomainDefaultAction(dialect, normalized_default)] + elif action is AlterDomainActionType.DROP_DEFAULT: + actions = [DropDomainDefaultAction(dialect)] + else: + if new_name is None: + raise ValueError("new_name is required for RENAME TO") + actions = [RenameDomainAction(dialect, new_name)] + else: + actions = [_UnsupportedDomainAction(dialect, action)] + super().__init__(dialect, name, actions) + self.schema = resolved_schema + self.schema_name = resolved_schema self.action = action self.new_value = new_value self.new_name = new_name @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_postgres_alter_domain_statement" - - -class PostgresDropDomainExpression(BaseExpression): - """PostgreSQL DROP DOMAIN statement expression. + def name(self) -> str: + return self.domain_name - Attributes: - name: Name of the domain to drop. - schema: Optional schema for the domain. - if_exists: When True, add ``IF EXISTS``. - cascade: When True, add ``CASCADE``. - restrict: When True, add ``RESTRICT``. Mutually exclusive with - ``cascade``. - - """ +class PostgresDropDomainExpression(DropDomainExpression): def __init__( self, dialect: "SQLDialectBase", @@ -136,15 +335,19 @@ def __init__( if_exists: bool = False, cascade: bool = False, restrict: bool = False, - ): - super().__init__(dialect) - self.name = name - self.schema = schema + *, + schema_name: Optional[str] = None, + ) -> None: + resolved_schema = schema if schema is not None else schema_name + if schema is not None and schema_name is not None and schema != schema_name: + raise ValueError("schema and schema_name must match when both are provided") + super().__init__(dialect, name) + self.schema = resolved_schema + self.schema_name = resolved_schema self.if_exists = if_exists self.cascade = cascade self.restrict = restrict @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_drop_domain_statement" \ No newline at end of file + def name(self) -> str: + return self.domain_name diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py index b8257c30..9fb9205b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py @@ -1,10 +1,5 @@ # src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py -"""PostgreSQL multirange type DDL expressions. - -Version Requirements: -- CREATE TYPE ... AS MULTIRANGE: PostgreSQL 14+ -- multirange_agg(): PostgreSQL 14+ -""" +"""PostgreSQL multirange expressions.""" from typing import Optional, TYPE_CHECKING @@ -14,28 +9,14 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class CreateMultirangeTypeExpression(BaseExpression): - """Expression for CREATE TYPE ... AS MULTIRANGE statement. - - PostgreSQL automatically creates multirange types when you create - a range type. This expression is for explicit creation if needed. +__all__ = [ + "CreateMultirangeTypeExpression", + "MultirangeAggFunctionExpression", +] - Attributes: - name: Multirange type name. - range_type: Associated range type. - schema: Optional schema name. - Example: - >>> expr = CreateMultirangeTypeExpression( - ... dialect, name="my_multirange", range_type="my_range", - ... ) - >>> sql, params = expr.to_sql() - >>> sql - 'CREATE TYPE my_multirange AS MULTIRANGE (my_range)' - - Note: - Requires PostgreSQL 14+. - """ +class CreateMultirangeTypeExpression(BaseExpression): + """Compatibility expression for the removed explicit multirange DDL form.""" def __init__( self, @@ -43,7 +24,7 @@ def __init__( name: str, range_type: str, schema: Optional[str] = None, - ): + ) -> None: super().__init__(dialect) self.name = name self.range_type = range_type @@ -51,33 +32,10 @@ def __init__( @property def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" return "format_create_multirange_type_statement_expression" class MultirangeAggFunctionExpression(BaseExpression): - """Expression for multirange_agg aggregate function call. - - Aggregates multiple ranges into a multirange. - - Attributes: - range_column: Range column to aggregate. - table_name: Table name. - where_clause: Optional WHERE clause. - schema: Optional schema name. - - Example: - >>> expr = MultirangeAggFunctionExpression( - ... dialect, range_column="period", table_name="events", - ... ) - >>> sql, params = expr.to_sql() - >>> sql - 'SELECT multirange_agg(period) FROM events' - - Note: - Requires PostgreSQL 14+. - """ - def __init__( self, dialect: "SQLDialectBase", @@ -85,7 +43,7 @@ def __init__( table_name: str, where_clause: Optional[str] = None, schema: Optional[str] = None, - ): + ) -> None: super().__init__(dialect) self.range_column = range_column self.table_name = table_name @@ -94,5 +52,4 @@ def __init__( @property def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" return "format_multirange_agg_function_expression" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py index 05d47443..25709b1f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py @@ -1,38 +1,51 @@ # src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py -""" -PostgreSQL DDL expressions: Enum and Range Type operations. +"""PostgreSQL user-defined TYPE DDL expressions.""" -PostgreSQL Documentation: -- CREATE TYPE: https://www.postgresql.org/docs/current/sql-createtype.html -- DROP TYPE: https://www.postgresql.org/docs/current/sql-droptype.html -- ALTER TYPE: https://www.postgresql.org/docs/current/sql-altertype.html +from __future__ import annotations -Version Requirements: -- ENUM type: PostgreSQL 8.3+ -- ADD VALUE: PostgreSQL 9.1+ -- RENAME VALUE: PostgreSQL 9.1+ -- RANGE type: PostgreSQL 9.2+ -""" - -from typing import List, Optional, TYPE_CHECKING +import warnings +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, Mapping, Optional, Sequence, Union, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) +from rhosocial.activerecord.backend.expression.types import DataType if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase __all__ = [ - # Enum types + "PostgresCompositeTypeAttribute", + "PostgresCompositeTypeDefinition", + "PostgresEnumTypeDefinition", + "PostgresRangeTypeDefinition", + "PostgresBaseTypeDefinition", + "PostgresShellTypeDefinition", + "PostgresRenameTypeAction", + "PostgresSetTypeSchemaAction", + "PostgresChangeTypeOwnerAction", + "PostgresRenameTypeAttributeAction", + "PostgresAddTypeAttributeAction", + "PostgresDropTypeAttributeAction", + "PostgresAlterTypeAttributeAction", + "PostgresAddEnumValueAction", + "PostgresRenameEnumValueAction", + "PostgresSetTypePropertiesAction", + "PostgresDropTypeExpression", "PostgresCreateEnumTypeExpression", "PostgresDropEnumTypeExpression", "PostgresAlterEnumAddValueExpression", - # Additional "PostgresAlterEnumTypeAddValueExpression", "PostgresAlterEnumTypeRenameValueExpression", - # Range type "PostgresCreateRangeTypeExpression", - # Format-signature-compliant enum expressions "EnumTypeNameExpression", "EnumValuesExpression", "CreateEnumTypeExpression", @@ -41,192 +54,506 @@ ] -# ============================================================================= -# Enum Type Operations -# ============================================================================= - -class PostgresCreateEnumTypeExpression(BaseExpression): - """PostgreSQL CREATE TYPE ... AS ENUM statement expression. - - Creates a new enumerated type consisting of a set of static string values. - ENUM types are useful for finite sets of values like status codes. - - Attributes: - name: Name of the enum type. - values: List of allowed values (must be unique). - schema: Schema name for the type. - if_not_exists: Add IF NOT EXISTS clause. - - Example: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> create_enum = PostgresCreateEnumTypeExpression( - ... dialect=dialect, - ... name="order_status", - ... values=["pending", "processing", "shipped", "delivered", "cancelled"], - ... ) - >>> sql, params = create_enum.to_sql() - >>> sql - "CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled')" - - >>> # With schema and IF NOT EXISTS - >>> create_enum = PostgresCreateEnumTypeExpression( - ... dialect=dialect, - ... name="priority", - ... values=["low", "medium", "high"], - ... schema="app", - ... if_not_exists=True, - ... ) - - """ +def _validate_name(value: str, field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +def _validate_qualified_name(value: str, field_name: str) -> None: + _validate_name(value, field_name) + if any(not part.strip() for part in value.split(".")): + raise ValueError(f"{field_name} must contain non-empty identifier segments") + + +def _validate_label(value: str, field_name: str) -> None: + if not isinstance(value, str): + raise TypeError(f"{field_name} must be a string") + + +def _require_data_type(value: DataType, field_name: str) -> None: + if not isinstance(value, DataType): + raise TypeError( + f"{field_name} must be a DataType instance, got {type(value).__name__}" + ) + + +def _coerce_legacy_data_type( + dialect: "SQLDialectBase", + value: Union[DataType, str], + field_name: str, +) -> DataType: + if isinstance(value, DataType): + return value + if not isinstance(value, str) or not value.strip(): + raise TypeError(f"{field_name} must be a DataType instance") + warnings.warn( + f"Raw {field_name} strings are deprecated; pass a DataType instance", + DeprecationWarning, + stacklevel=3, + ) + return DataType.parse_data_type_str(dialect, value) + + +@dataclass +class PostgresCompositeTypeAttribute: + """One attribute in a PostgreSQL composite TYPE definition.""" + + name: str + data_type: DataType + collation: Optional[str] = None + + def __post_init__(self) -> None: + _validate_name(self.name, "name") + _require_data_type(self.data_type, "data_type") + if self.collation is not None: + _validate_qualified_name(self.collation, "collation") + + +class PostgresCompositeTypeDefinition(TypeDefinition): + """Composite TYPE definition.""" + + definition_kind = "composite" def __init__( self, dialect: "SQLDialectBase", - name: str, - values: List[str], - schema: Optional[str] = None, - if_not_exists: bool = False, - ): + attributes: Sequence[PostgresCompositeTypeAttribute], + ) -> None: super().__init__(dialect) - self.name = name - self.values = values - self.schema = schema - self.if_not_exists = if_not_exists + items = list(attributes or []) + if any(not isinstance(item, PostgresCompositeTypeAttribute) for item in items): + raise TypeError("attributes must contain PostgresCompositeTypeAttribute instances") + self.attributes = items + + +class PostgresEnumTypeDefinition(TypeDefinition): + """ENUM TYPE definition.""" + + definition_kind = "enum" + + def __init__( + self, + dialect: "SQLDialectBase", + labels: Sequence[str], + ) -> None: + super().__init__(dialect) + items = list(labels or []) + for label in items: + _validate_label(label, "labels") + if len(items) != len(set(items)): + raise ValueError("ENUM labels must be unique") + self.labels = items + + +class PostgresRangeTypeDefinition(TypeDefinition): + """RANGE TYPE definition.""" + + definition_kind = "range" + + def __init__( + self, + dialect: "SQLDialectBase", + subtype: DataType, + *, + subtype_operator_class: Optional[str] = None, + collation: Optional[str] = None, + canonical_function: Optional[str] = None, + subtype_diff_function: Optional[str] = None, + multirange_type_name: Optional[str] = None, + ) -> None: + super().__init__(dialect) + _require_data_type(subtype, "subtype") + optional_names = { + "subtype_operator_class": subtype_operator_class, + "collation": collation, + "canonical_function": canonical_function, + "subtype_diff_function": subtype_diff_function, + "multirange_type_name": multirange_type_name, + } + for field_name, value in optional_names.items(): + if value is not None: + _validate_qualified_name(value, field_name) + self.subtype = subtype + self.subtype_operator_class = subtype_operator_class + self.collation = collation + self.canonical_function = canonical_function + self.subtype_diff_function = subtype_diff_function + self.multirange_type_name = multirange_type_name + + +class _UnsetValue(Enum): + TOKEN = "UNSET" + + +_UNSET = _UnsetValue.TOKEN + + +class PostgresBaseTypeDefinition(TypeDefinition): + """Base TYPE definition.""" + + definition_kind = "base" + + _ALLOWED_ALIGNMENTS = frozenset({"char", "int2", "int4", "double"}) + _ALLOWED_STORAGE = frozenset({"plain", "external", "extended", "main"}) + + def __init__( + self, + dialect: "SQLDialectBase", + *, + input_function: Optional[str] = None, + output_function: Optional[str] = None, + receive_function: Optional[str] = None, + send_function: Optional[str] = None, + type_modifier_input_function: Optional[str] = None, + type_modifier_output_function: Optional[str] = None, + analyze_function: Optional[str] = None, + subscript_function: Optional[str] = None, + internallength: Optional[Union[int, str]] = None, + passedbyvalue: bool = False, + alignment: Optional[str] = None, + storage: Optional[str] = None, + like_type: Optional[DataType] = None, + category: Optional[str] = None, + preferred: Optional[bool] = None, + default: Any = _UNSET, + element: Optional[DataType] = None, + delimiter: Optional[str] = None, + collatable: Optional[bool] = None, + ) -> None: + super().__init__(dialect) + function_names = { + "input_function": input_function, + "output_function": output_function, + "receive_function": receive_function, + "send_function": send_function, + "type_modifier_input_function": type_modifier_input_function, + "type_modifier_output_function": type_modifier_output_function, + "analyze_function": analyze_function, + "subscript_function": subscript_function, + } + for field_name, value in function_names.items(): + if value is not None: + _validate_qualified_name(value, field_name) + if input_function is None or output_function is None: + raise ValueError("input_function and output_function are required") + if like_type is not None: + _require_data_type(like_type, "like_type") + if internallength is not None: + if isinstance(internallength, bool): + raise TypeError("internallength must be a positive integer or VARIABLE") + if isinstance(internallength, int) and internallength <= 0: + raise ValueError("internallength must be a positive integer or VARIABLE") + if isinstance(internallength, str) and internallength.upper() != "VARIABLE": + raise ValueError("internallength must be a positive integer or VARIABLE") + if alignment is not None and alignment.lower() not in self._ALLOWED_ALIGNMENTS: + raise ValueError("alignment must be char, int2, int4, or double") + if storage is not None and storage.lower() not in self._ALLOWED_STORAGE: + raise ValueError("storage must be plain, external, extended, or main") + if category is not None and (len(category) != 1 or not category.isascii()): + raise ValueError("category must be a single ASCII character") + if element is not None: + _require_data_type(element, "element") + if delimiter is not None and not isinstance(delimiter, str): + raise TypeError("delimiter must be a string") + self.input_function = input_function + self.output_function = output_function + self.receive_function = receive_function + self.send_function = send_function + self.type_modifier_input_function = type_modifier_input_function + self.type_modifier_output_function = type_modifier_output_function + self.analyze_function = analyze_function + self.subscript_function = subscript_function + self.internallength = internallength + self.passedbyvalue = passedbyvalue + self.alignment = alignment + self.storage = storage + self.like_type = like_type + self.category = category + self.preferred = preferred + self.default = default + self.element = element + self.delimiter = delimiter + self.collatable = collatable @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_create_enum_type" - - -class PostgresDropEnumTypeExpression(BaseExpression): - """PostgreSQL DROP TYPE statement expression for enum types. - - Drops an existing enum type from the database. - - Attributes: - name: Name of the enum type to drop. - schema: Schema name for the type. - if_exists: Add IF EXISTS clause (prevent error if not exists). - cascade: Drop dependent objects (CASCADE). - - Example: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> drop_enum = PostgresDropEnumTypeExpression( - ... dialect=dialect, - ... name="order_status", - ... ) - >>> sql, params = drop_enum.to_sql() - >>> sql - "DROP TYPE order_status" - - >>> # With IF EXISTS and CASCADE - >>> drop_enum = PostgresDropEnumTypeExpression( - ... dialect=dialect, - ... name="priority", - ... schema="app", - ... if_exists=True, - ... cascade=True, - ... ) - - """ + def has_default(self) -> bool: + return self.default is not _UNSET + + +class PostgresShellTypeDefinition(TypeDefinition): + """Shell TYPE definition.""" + + definition_kind = "shell" + + +class PostgresRenameTypeAction(TypeAlterAction): + action_kind = "rename" + + def __init__(self, dialect: "SQLDialectBase", new_name: str) -> None: + super().__init__(dialect) + _validate_name(new_name, "new_name") + self.new_name = new_name + + +class PostgresSetTypeSchemaAction(TypeAlterAction): + action_kind = "set_schema" + + def __init__(self, dialect: "SQLDialectBase", new_schema: str) -> None: + super().__init__(dialect) + _validate_name(new_schema, "new_schema") + self.new_schema = new_schema + + +class PostgresChangeTypeOwnerAction(TypeAlterAction): + action_kind = "change_owner" + + def __init__(self, dialect: "SQLDialectBase", new_owner: str) -> None: + super().__init__(dialect) + _validate_name(new_owner, "new_owner") + self.new_owner = new_owner + + +class PostgresRenameTypeAttributeAction(TypeAlterAction): + action_kind = "rename_attribute" def __init__( self, dialect: "SQLDialectBase", - name: str, - schema: Optional[str] = None, + attribute_name: str, + new_name: str, + *, + cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__(dialect) + _validate_name(attribute_name, "attribute_name") + _validate_name(new_name, "new_name") + self.attribute_name = attribute_name + self.new_name = new_name + self.cascade = cascade + self.restrict = restrict + + +class PostgresAddTypeAttributeAction(TypeAlterAction): + action_kind = "add_attribute" + + def __init__( + self, + dialect: "SQLDialectBase", + attribute_name: str, + data_type: DataType, + *, + collation: Optional[str] = None, + cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__(dialect) + _validate_name(attribute_name, "attribute_name") + _require_data_type(data_type, "data_type") + if collation is not None: + _validate_qualified_name(collation, "collation") + self.attribute_name = attribute_name + self.data_type = data_type + self.collation = collation + self.cascade = cascade + self.restrict = restrict + + +class PostgresDropTypeAttributeAction(TypeAlterAction): + action_kind = "drop_attribute" + + def __init__( + self, + dialect: "SQLDialectBase", + attribute_name: str, + *, if_exists: bool = False, cascade: bool = False, - ): + restrict: bool = False, + ) -> None: super().__init__(dialect) - self.name = name - self.schema = schema + _validate_name(attribute_name, "attribute_name") + self.attribute_name = attribute_name self.if_exists = if_exists self.cascade = cascade + self.restrict = restrict - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_drop_enum_type" - - -class PostgresAlterEnumAddValueExpression(BaseExpression): - """PostgreSQL ALTER TYPE ... ADD VALUE statement expression. - - Adds a new value to an existing enum type. - The new value can be inserted before or after an existing value. - - Attributes: - type_name: Name of the enum type to alter. - new_value: New value to add. - schema: Schema name for the type. - before: Add new value before this existing value. - after: Add new value after this existing value. - - Example: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> add_value = PostgresAlterEnumAddValueExpression( - ... dialect=dialect, - ... type_name="order_status", - ... new_value="returned", - ... ) - >>> sql, params = add_value.to_sql() - >>> sql - "ALTER TYPE order_status ADD VALUE 'returned'" - - >>> # Add in order - >>> add_value = PostgresAlterEnumAddValueExpression( - ... dialect=dialect, - ... type_name="order_status", - ... new_value="refunded", - ... after="cancelled", - ... ) - - """ + +class PostgresAlterTypeAttributeAction(TypeAlterAction): + action_kind = "alter_attribute" + + def __init__( + self, + dialect: "SQLDialectBase", + attribute_name: str, + data_type: DataType, + *, + collation: Optional[str] = None, + set_data: bool = False, + cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__(dialect) + _validate_name(attribute_name, "attribute_name") + _require_data_type(data_type, "data_type") + if collation is not None: + _validate_qualified_name(collation, "collation") + self.attribute_name = attribute_name + self.data_type = data_type + self.collation = collation + self.set_data = set_data + self.cascade = cascade + self.restrict = restrict + + +class PostgresAddEnumValueAction(TypeAlterAction): + action_kind = "add_enum_value" def __init__( self, dialect: "SQLDialectBase", - type_name: str, new_value: str, - schema: Optional[str] = None, + *, before: Optional[str] = None, after: Optional[str] = None, - ): + if_not_exists: bool = False, + ) -> None: super().__init__(dialect) - self.type_name = type_name + _validate_label(new_value, "new_value") + if before is not None and after is not None: + raise ValueError("before and after are mutually exclusive") + if before is not None: + _validate_label(before, "before") + if after is not None: + _validate_label(after, "after") self.new_value = new_value - self.schema = schema self.before = before self.after = after + self.if_not_exists = if_not_exists - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_alter_enum_add_value" + +class PostgresRenameEnumValueAction(TypeAlterAction): + action_kind = "rename_enum_value" + + def __init__( + self, + dialect: "SQLDialectBase", + old_value: str, + new_value: str, + ) -> None: + super().__init__(dialect) + _validate_label(old_value, "old_value") + _validate_label(new_value, "new_value") + self.old_value = old_value + self.new_value = new_value -class PostgresAlterEnumTypeAddValueExpression(BaseExpression): - """PostgreSQL ALTER TYPE ... ADD VALUE statement. +class PostgresSetTypePropertiesAction(TypeAlterAction): + action_kind = "set_properties" - Adds a new value to an existing enum type with validation. + _FUNCTION_PROPERTIES = frozenset( + { + "RECEIVE", + "SEND", + "TYPMOD_IN", + "TYPMOD_OUT", + "ANALYZE", + "SUBSCRIPT", + } + ) + _ALLOWED_PROPERTIES = _FUNCTION_PROPERTIES | {"STORAGE"} - Attributes: - type_name: Name of the enum type to alter. - new_value: New value to add. - schema: Schema name for the type. - before: Add new value before this existing value. - after: Add new value after this existing value. + def __init__( + self, + dialect: "SQLDialectBase", + properties: Mapping[str, Optional[str]], + ) -> None: + super().__init__(dialect) + normalized: Dict[str, Optional[str]] = {} + for raw_name, value in dict(properties or {}).items(): + if not isinstance(raw_name, str) or not raw_name.strip(): + raise ValueError("property names must be non-empty strings") + name = raw_name.upper() + if name not in self._ALLOWED_PROPERTIES: + raise ValueError(f"Unsupported ALTER TYPE property: {name}") + if value is not None and not isinstance(value, str): + raise TypeError("property values must be strings or None") + if value is not None: + _validate_qualified_name(value, name) + if name == "STORAGE" and value is None: + raise ValueError("STORAGE property cannot be None") + normalized[name] = value + if not normalized: + raise ValueError("properties must contain at least one property") + self.properties = normalized + + +class PostgresDropTypeExpression(DropTypeExpression): + def __init__( + self, + dialect: "SQLDialectBase", + type_name: str, + *, + schema_name: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__( + dialect, + type_name, + schema_name=schema_name, + if_exists=if_exists, + ) + self.cascade = cascade + self.restrict = restrict - Raises: - ValueError: If type_name or new_value is empty, or both before and after specified. - """ +class PostgresCreateEnumTypeExpression(CreateTypeExpression): + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + values: Sequence[str], + schema: Optional[str] = None, + if_not_exists: bool = False, + ) -> None: + definition = PostgresEnumTypeDefinition(dialect, values) + super().__init__( + dialect, + name, + definition, + schema_name=schema, + if_not_exists=if_not_exists, + ) + self.name = name + self.values = list(values) + self.schema = schema + + +class PostgresDropEnumTypeExpression(PostgresDropTypeExpression): + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__( + dialect, + name, + schema_name=schema, + if_exists=if_exists, + cascade=cascade, + restrict=restrict, + ) + self.name = name + self.schema = schema + +class PostgresAlterEnumAddValueExpression(AlterTypeExpression): def __init__( self, dialect: "SQLDialectBase", @@ -235,43 +562,37 @@ def __init__( schema: Optional[str] = None, before: Optional[str] = None, after: Optional[str] = None, - ): - super().__init__(dialect) - if not type_name: + if_not_exists: bool = False, + ) -> None: + if not isinstance(type_name, str) or not type_name.strip(): raise ValueError("Enum type name cannot be empty") - if not new_value: + if not isinstance(new_value, str): + raise TypeError("New value must be a string") + if new_value == "": raise ValueError("New value cannot be empty") - if before and after: + if before is not None and after is not None: raise ValueError("Cannot specify both 'before' and 'after'") - + action = PostgresAddEnumValueAction( + dialect, + new_value, + before=before, + after=after, + if_not_exists=if_not_exists, + ) + super().__init__(dialect, type_name, [action], schema_name=schema) self.type_name = type_name self.new_value = new_value self.schema = schema self.before = before self.after = after + self.if_not_exists = if_not_exists - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_alter_enum_type_add_value" - - -class PostgresAlterEnumTypeRenameValueExpression(BaseExpression): - """PostgreSQL ALTER TYPE ... RENAME VALUE statement. - - Renames an existing value in an enum type. - - Attributes: - type_name: Name of the enum type to alter. - old_value: Current value to rename. - new_value: New name for the value. - schema: Schema name for the type. - Raises: - ValueError: If type_name, old_value, or new_value is empty. +class PostgresAlterEnumTypeAddValueExpression(PostgresAlterEnumAddValueExpression): + pass - """ +class PostgresAlterEnumTypeRenameValueExpression(AlterTypeExpression): def __init__( self, dialect: "SQLDialectBase", @@ -279,85 +600,60 @@ def __init__( old_value: str, new_value: str, schema: Optional[str] = None, - ): - super().__init__(dialect) - if not type_name: + ) -> None: + if not isinstance(type_name, str) or not type_name.strip(): raise ValueError("Enum type name cannot be empty") - if not old_value: + if not isinstance(old_value, str): + raise TypeError("Old value must be a string") + if not isinstance(new_value, str): + raise TypeError("New value must be a string") + if old_value == "": raise ValueError("Old value cannot be empty") - if not new_value: + if new_value == "": raise ValueError("New value cannot be empty") - + super().__init__( + dialect, + type_name, + [PostgresRenameEnumValueAction(dialect, old_value, new_value)], + schema_name=schema, + ) self.type_name = type_name self.old_value = old_value self.new_value = new_value self.schema = schema - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_alter_enum_type_rename_value" - - -class PostgresCreateRangeTypeExpression(BaseExpression): - """PostgreSQL CREATE TYPE ... AS RANGE statement expression. - - Creates a new range type for representing ranges of values. - Useful for date ranges, numeric ranges, etc. - - Attributes: - name: Name of the range type. - subtype: Base type for the range (e.g., 'int4', 'int8', 'date', 'timestamp'). - schema: Schema name for the type. - subtype_opclass: Operator class for the subtype. - collation: Collation for the subtype. - canonical: Canonical function for the range. - subtype_diff: Subtype difference function. - if_not_exists: Add IF NOT EXISTS clause. - - Raises: - ValueError: If name or subtype is empty. - - Example: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> create_range = PostgresCreateRangeTypeExpression( - ... dialect=dialect, - ... name="int4range", - ... subtype="int4", - ... ) - >>> sql, params = create_range.to_sql() - >>> sql - "CREATE TYPE int4range AS RANGE (subtype=int4)" - - >>> # Date range with custom functions (PG 9.2+) - >>> create_range = PostgresCreateRangeTypeExpression( - ... dialect=dialect, - ... name="date_range", - ... subtype="date", - ... collation="en_US", - ... ) - - """ +class PostgresCreateRangeTypeExpression(CreateTypeExpression): def __init__( self, dialect: "SQLDialectBase", name: str, - subtype: str, + subtype: Union[DataType, str], schema: Optional[str] = None, subtype_opclass: Optional[str] = None, collation: Optional[str] = None, canonical: Optional[str] = None, subtype_diff: Optional[str] = None, if_not_exists: bool = False, - ): - super().__init__(dialect) - if not name: - raise ValueError("Range type name cannot be empty") - if not subtype: - raise ValueError("Subtype cannot be empty") - + multirange_type_name: Optional[str] = None, + ) -> None: + normalized_subtype = _coerce_legacy_data_type(dialect, subtype, "subtype") + definition = PostgresRangeTypeDefinition( + dialect, + normalized_subtype, + subtype_operator_class=subtype_opclass, + collation=collation, + canonical_function=canonical, + subtype_diff_function=subtype_diff, + multirange_type_name=multirange_type_name, + ) + super().__init__( + dialect, + name, + definition, + schema_name=schema, + if_not_exists=if_not_exists, + ) self.name = name self.subtype = subtype self.schema = schema @@ -365,191 +661,46 @@ def __init__( self.collation = collation self.canonical = canonical self.subtype_diff = subtype_diff - self.if_not_exists = if_not_exists - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_create_range_type" - - -# ============================================================================= -# Format-signature-compliant enum expressions -# ============================================================================= + self.multirange_type_name = multirange_type_name class EnumTypeNameExpression(BaseExpression): - """Expression for formatting an enum type name with optional schema. - - Attributes: - name: Enum type name. - schema: Optional schema name. - - Example: - >>> expr = EnumTypeNameExpression(dialect, name="status", schema="app") - >>> sql, params = expr.to_sql() - >>> sql - 'app.status' - - """ - def __init__( self, dialect: "SQLDialectBase", name: str, schema: Optional[str] = None, - ): + ) -> None: super().__init__(dialect) self.name = name self.schema = schema @property def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" return "format_enum_type_name_expression" class EnumValuesExpression(BaseExpression): - """Expression for formatting a list of enum values for SQL. - - Attributes: - values: List of enum value strings. - - Example: - >>> expr = EnumValuesExpression(dialect, values=["a", "b", "c"]) - >>> sql, params = expr.to_sql() - >>> sql - "'a', 'b', 'c'" - - """ - def __init__( self, dialect: "SQLDialectBase", - values: List[str], - ): + values: Sequence[str], + ) -> None: super().__init__(dialect) - self.values = values + self.values = list(values) @property def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" return "format_enum_values_expression" -class CreateEnumTypeExpression(BaseExpression): - """Expression for CREATE TYPE ... AS ENUM statement. +class CreateEnumTypeExpression(PostgresCreateEnumTypeExpression): + pass - Attributes: - name: Enum type name. - values: List of allowed values. - schema: Optional schema name. - if_not_exists: Add IF NOT EXISTS clause. - Example: - >>> expr = CreateEnumTypeExpression( - ... dialect, name="status", values=["active", "inactive"], - ... ) - >>> sql, params = expr.to_sql() - >>> sql - "CREATE TYPE status AS ENUM ('active', 'inactive')" +class DropEnumTypeExpression(PostgresDropEnumTypeExpression): + pass - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - values: List[str], - schema: Optional[str] = None, - if_not_exists: bool = False, - ): - super().__init__(dialect) - self.name = name - self.values = values - self.schema = schema - self.if_not_exists = if_not_exists - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_create_enum_type" - - -class DropEnumTypeExpression(BaseExpression): - """Expression for DROP TYPE statement. - - Attributes: - name: Enum type name. - schema: Optional schema name. - if_exists: Add IF EXISTS clause. - cascade: Add CASCADE clause. - - Example: - >>> expr = DropEnumTypeExpression(dialect, name="status") - >>> sql, params = expr.to_sql() - >>> sql - "DROP TYPE status" - - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - schema: Optional[str] = None, - if_exists: bool = False, - cascade: bool = False, - ): - super().__init__(dialect) - self.name = name - self.schema = schema - self.if_exists = if_exists - self.cascade = cascade - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_drop_enum_type" - - -class AlterEnumAddValueExpression(BaseExpression): - """Expression for ALTER TYPE ... ADD VALUE statement. - - Attributes: - type_name: Enum type name. - new_value: New value to add. - schema: Optional schema name. - before: Add before this existing value. - after: Add after this existing value. - - Example: - >>> expr = AlterEnumAddValueExpression( - ... dialect, type_name="status", new_value="archived", - ... ) - >>> sql, params = expr.to_sql() - >>> sql - "ALTER TYPE status ADD VALUE 'archived'" - - """ - - def __init__( - self, - dialect: "SQLDialectBase", - type_name: str, - new_value: str, - schema: Optional[str] = None, - before: Optional[str] = None, - after: Optional[str] = None, - ): - super().__init__(dialect) - self.type_name = type_name - self.new_value = new_value - self.schema = schema - self.before = before - self.after = after - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_alter_enum_add_value" +class AlterEnumAddValueExpression(PostgresAlterEnumAddValueExpression): + pass diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py new file mode 100644 index 00000000..8855d672 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py @@ -0,0 +1,105 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py +"""PostgreSQL DDL expression registration.""" + +from typing import Tuple, Type + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry +from .ddl.domain import ( + _LegacySqlExpression, + _UnsupportedDomainAction, + PostgresAddDomainCheckAction, + PostgresAlterDomainExpression, + PostgresChangeDomainOwnerAction, + PostgresCreateDomainExpression, + PostgresDropDomainCheckAction, + PostgresDropDomainExpression, + PostgresRenameDomainConstraintAction, + PostgresSetDomainSchemaAction, + PostgresValidateDomainConstraintAction, +) +from .ddl.multirange import ( + CreateMultirangeTypeExpression, + MultirangeAggFunctionExpression, +) +from .ddl.type import ( + AlterEnumAddValueExpression, + CreateEnumTypeExpression, + DropEnumTypeExpression, + EnumTypeNameExpression, + EnumValuesExpression, + PostgresAddEnumValueAction, + PostgresAddTypeAttributeAction, + PostgresAlterEnumAddValueExpression, + PostgresAlterEnumTypeAddValueExpression, + PostgresAlterEnumTypeRenameValueExpression, + PostgresAlterTypeAttributeAction, + PostgresBaseTypeDefinition, + PostgresChangeTypeOwnerAction, + PostgresCompositeTypeDefinition, + PostgresCreateEnumTypeExpression, + PostgresCreateRangeTypeExpression, + PostgresDropEnumTypeExpression, + PostgresDropTypeAttributeAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresRenameEnumValueAction, + PostgresRenameTypeAction, + PostgresRenameTypeAttributeAction, + PostgresSetTypePropertiesAction, + PostgresSetTypeSchemaAction, + PostgresShellTypeDefinition, +) + + +_POSTGRES_DDL_EXPRESSION_CLASSES: Tuple[Type[BaseExpression], ...] = ( + PostgresCompositeTypeDefinition, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresBaseTypeDefinition, + PostgresShellTypeDefinition, + PostgresRenameTypeAction, + PostgresSetTypeSchemaAction, + PostgresChangeTypeOwnerAction, + PostgresRenameTypeAttributeAction, + PostgresAddTypeAttributeAction, + PostgresDropTypeAttributeAction, + PostgresAlterTypeAttributeAction, + PostgresAddEnumValueAction, + PostgresRenameEnumValueAction, + PostgresSetTypePropertiesAction, + PostgresDropTypeExpression, + PostgresCreateEnumTypeExpression, + PostgresDropEnumTypeExpression, + PostgresAlterEnumAddValueExpression, + PostgresAlterEnumTypeAddValueExpression, + PostgresAlterEnumTypeRenameValueExpression, + PostgresCreateRangeTypeExpression, + EnumTypeNameExpression, + EnumValuesExpression, + CreateEnumTypeExpression, + DropEnumTypeExpression, + AlterEnumAddValueExpression, + _LegacySqlExpression, + _UnsupportedDomainAction, + PostgresAddDomainCheckAction, + PostgresDropDomainCheckAction, + PostgresRenameDomainConstraintAction, + PostgresValidateDomainConstraintAction, + PostgresChangeDomainOwnerAction, + PostgresSetDomainSchemaAction, + PostgresCreateDomainExpression, + PostgresAlterDomainExpression, + PostgresDropDomainExpression, + CreateMultirangeTypeExpression, + MultirangeAggFunctionExpression, +) + + +def register_postgres_ddl_expressions() -> None: + for expression_class in _POSTGRES_DDL_EXPRESSION_CLASSES: + ExpressionRegistry.register(expression_class) + + +__all__ = ["register_postgres_ddl_expressions"] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py index c7bfb019..7e1c388d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py @@ -6,6 +6,8 @@ based on the server version. """ +from typing import Tuple, TYPE_CHECKING + class PostgresDataTypeMixin: """PostgreSQL data type enhancements implementation. @@ -13,6 +15,9 @@ class PostgresDataTypeMixin: All features are native, using version number for detection. """ + if TYPE_CHECKING: + version: Tuple[int, int, int] + def supports_multirange_type(self) -> bool: """Multirange is native feature, PG 14+.""" return self.version >= (14, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py index aa5b32e1..4de201ef 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py @@ -1,156 +1,376 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py -"""PostgreSQL DOMAIN DDL implementation. +"""PostgreSQL DOMAIN DDL capabilities and formatting.""" -Implements CREATE/ALTER/DROP DOMAIN formatting for the postgres dialect. +from __future__ import annotations -Version Requirements: -- CREATE/ALTER/DROP DOMAIN: PostgreSQL 9.6+ -""" - -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Any, List, Optional, Tuple, Type, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from ...expression.ddl.domain import AlterDomainActionType +from rhosocial.activerecord.backend.dialect.mixins.ddl_domain import DomainMixin +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AddDomainCheckAction, + AlterDomainExpression, + CreateDomainExpression, + DomainAlterAction, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + DropDomainCheckAction, + DropDomainDefaultAction, + DropDomainExpression, + DropDomainNotNullAction, + RenameDomainAction, + SetDomainDefaultAction, + SetDomainNotNullAction, +) +from ...expression.ddl.domain import ( + _UnsupportedDomainAction, + PostgresAddDomainCheckAction, + PostgresChangeDomainOwnerAction, + PostgresCreateDomainExpression, + PostgresDropDomainCheckAction, + PostgresRenameDomainConstraintAction, + PostgresSetDomainSchemaAction, + PostgresValidateDomainConstraintAction, +) if TYPE_CHECKING: - from ...expression.ddl.domain import ( - PostgresAlterDomainExpression, - PostgresCreateDomainExpression, - PostgresDropDomainExpression, + from ...expression.ddl.domain import _LegacySqlExpression + + +__all__ = ["PostgresDomainMixin"] + + +class PostgresDomainMixin(DomainMixin): + """PostgreSQL DOMAIN DDL support.""" + + if TYPE_CHECKING: + name: str + version: Tuple[int, int, int] + + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: ... + + _POSTGRES_DOMAIN_ACTIONS = ( + SetDomainDefaultAction, + DropDomainDefaultAction, + SetDomainNotNullAction, + DropDomainNotNullAction, + AddDomainCheckAction, + DropDomainCheckAction, + RenameDomainAction, + PostgresAddDomainCheckAction, + PostgresDropDomainCheckAction, + PostgresRenameDomainConstraintAction, + PostgresValidateDomainConstraintAction, + PostgresChangeDomainOwnerAction, + PostgresSetDomainSchemaAction, ) + def _format_domain_identifier(self, name: str, schema: Optional[str] = None) -> str: + value = f"{schema}.{name}" if schema is not None else name + parts = value.split(".") + if not parts or any(not part.strip() for part in parts): + raise ValueError("PostgreSQL identifiers must contain non-empty segments") + return ".".join(self.format_identifier(part) for part in parts) -class PostgresDomainMixin: - """PostgreSQL DOMAIN implementation.""" + def _format_domain_behavior(self, cascade: bool, restrict: bool) -> str: + if cascade and restrict: + raise ValueError("CASCADE and RESTRICT are mutually exclusive") + if cascade: + return "CASCADE" + if restrict: + return "RESTRICT" + return "" - # ------------------------------------------------------------------ # - # Capability switches - # ------------------------------------------------------------------ # - def supports_create_domain(self) -> bool: - """CREATE/ALTER/DROP DOMAIN require PostgreSQL 9.6+.""" + def supports_domains(self) -> bool: return self.version >= (9, 6, 0) - # ------------------------------------------------------------------ # - # Helpers - # ------------------------------------------------------------------ # - def _format_domain_ref(self, schema: Optional[str], name: str) -> str: - """Format ``name`` (optionally schema-qualified) as identifier(s).""" - if schema: - return f"{self.format_identifier(schema)}.{self.format_identifier(name)}" - return self.format_identifier(name) - - # ------------------------------------------------------------------ # - # CREATE DOMAIN - # ------------------------------------------------------------------ # - def format_create_domain_statement( - self, expr: "PostgresCreateDomainExpression" - ) -> Tuple[str, tuple]: - """Format a CREATE DOMAIN statement (PostgreSQL-specific). + def supports_create_domain(self) -> bool: + return self.supports_domains() + + def supports_alter_domain(self) -> bool: + return self.supports_domains() + + def supports_drop_domain(self) -> bool: + return self.supports_domains() + + def supports_domain_default(self) -> bool: + return self.supports_domains() - Args: - expr: :class:`PostgresCreateDomainExpression`. + def supports_domain_nullability(self, nullability: DomainNullability) -> bool: + return self.supports_domains() and nullability in set(DomainNullability) - Returns: - Tuple of (SQL string, params tuple). + def supports_domain_checks(self) -> bool: + return self.supports_domains() - Raises: - UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + def supports_named_domain_checks(self) -> bool: + return self.supports_domains() - """ - if not self.supports_create_domain(): + def supports_multiple_domain_checks(self) -> bool: + return self.supports_domains() + + def supports_domain_collation(self) -> bool: + return self.supports_domains() + + def supports_alter_domain_action( + self, + action_type: Type[DomainAlterAction], + ) -> bool: + if not self.supports_domains(): + return False + try: + return issubclass(action_type, self._POSTGRES_DOMAIN_ACTIONS) + except TypeError: + return False + + def supports_multiple_domain_alter_actions(self) -> bool: + return False + + def supports_drop_domain_if_exists(self) -> bool: + return self.supports_domains() + + def supports_drop_domain_cascade(self) -> bool: + return self.supports_domains() + + def supports_drop_domain_restrict(self) -> bool: + return self.supports_domains() + + def supports_unnamed_domain_check_drop(self) -> bool: + return False + + def format_create_domain_statement( + self, + expr: CreateDomainExpression, + ) -> Tuple[str, tuple]: + if not self.supports_domains() or not self.supports_create_domain(): raise UnsupportedFeatureError( self.name, "CREATE DOMAIN", suggestion="requires PostgreSQL 9.6+", ) - - parts: List[str] = [ + type_sql, type_params = expr.data_type.to_sql() + if type_params: + raise ValueError("DOMAIN data types must render without bind parameters") + parts = [ "CREATE DOMAIN", - self._format_domain_ref(expr.schema, expr.name), + self._format_domain_identifier( + expr.domain_name, + getattr(expr, "schema_name", None), + ), "AS", - expr.data_type, + type_sql, ] - if expr.collation: - parts.append(f"COLLATE {expr.collation}") + params = list(type_params) + if expr.collation is not None: + if not self.supports_domain_collation(): + raise UnsupportedFeatureError(self.name, "DOMAIN COLLATE") + parts.append(f"COLLATE {self._format_domain_identifier(expr.collation)}") if expr.default is not None: - parts.append(f"DEFAULT {expr.default}") - for constraint in expr.constraints: - parts.append(constraint) - return " ".join(parts), () + if not self.supports_domain_default(): + raise UnsupportedFeatureError(self.name, "DOMAIN DEFAULT") + default_sql, default_params = expr.default.to_sql() + if default_params: + raise ValueError("DOMAIN DEFAULT must render without bind parameters") + parts.append(f"DEFAULT {default_sql}") + if isinstance(expr, PostgresCreateDomainExpression): + clauses: List[Any] = list(expr._constraint_clauses) + else: + clauses = list(expr.checks) + if clauses and not self.supports_domain_checks(): + raise UnsupportedFeatureError(self.name, "DOMAIN CHECK") + if any( + isinstance(clause, DomainCheckConstraint) and clause.name is not None + for clause in clauses + ) and not self.supports_named_domain_checks(): + raise UnsupportedFeatureError(self.name, "named DOMAIN CHECK") + if len(clauses) > 1 and not self.supports_multiple_domain_checks(): + raise UnsupportedFeatureError(self.name, "multiple domain CHECK constraints") + for clause in clauses: + clause_sql, clause_params = clause.to_sql() + parts.append(clause_sql) + params.extend(clause_params) + if expr.nullability is not DomainNullability.UNSPECIFIED: + if not self.supports_domain_nullability(expr.nullability): + raise UnsupportedFeatureError( + self.name, + f"DOMAIN {expr.nullability.value}", + ) + parts.append(expr.nullability.value) + return " ".join(parts), tuple(params) - # ------------------------------------------------------------------ # - # ALTER DOMAIN - # ------------------------------------------------------------------ # - def format_postgres_alter_domain_statement( - self, expr: "PostgresAlterDomainExpression" + def format_alter_domain_statement( + self, + expr: AlterDomainExpression, ) -> Tuple[str, tuple]: - """Format an ALTER DOMAIN statement (PostgreSQL-specific). - - Args: - expr: :class:`PostgresAlterDomainExpression`. - - Returns: - Tuple of (SQL string, params tuple). - - Raises: - UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. - - """ - if not self.supports_create_domain(): + if not self.supports_domains() or not self.supports_alter_domain(): raise UnsupportedFeatureError( self.name, "ALTER DOMAIN", suggestion="requires PostgreSQL 9.6+", ) + if len(expr.actions) > 1 and not self.supports_multiple_domain_alter_actions(): + raise UnsupportedFeatureError(self.name, "multiple ALTER DOMAIN actions") + action_parts: List[str] = [] + action_params: List[Any] = [] + for action in expr.actions: + if not isinstance(action, _UnsupportedDomainAction) and not self.supports_alter_domain_action( + type(action) + ): + raise UnsupportedFeatureError( + self.name, + f"ALTER DOMAIN action {action.action_kind}", + ) + action_sql, params = action.to_sql() + action_parts.append(action_sql) + action_params.extend(params) + domain_ref = self._format_domain_identifier( + expr.domain_name, + getattr(expr, "schema_name", None), + ) + return ( + f"ALTER DOMAIN {domain_ref} {', '.join(action_parts)}", + tuple(action_params), + ) - base = f"ALTER DOMAIN {self._format_domain_ref(expr.schema, expr.name)}" - action = expr.action - if action is AlterDomainActionType.SET_DEFAULT: - return f"{base} SET DEFAULT {expr.new_value}", () - if action is AlterDomainActionType.DROP_DEFAULT: - return f"{base} DROP DEFAULT", () - if action is AlterDomainActionType.RENAME_TO: - return ( - f"{base} RENAME TO {self.format_identifier(expr.new_name)}", - (), - ) - raise ValueError(f"Unsupported ALTER DOMAIN action: {action}") + def format_postgres_alter_domain_statement( + self, + expr: AlterDomainExpression, + ) -> Tuple[str, tuple]: + return self.format_alter_domain_statement(expr) - # ------------------------------------------------------------------ # - # DROP DOMAIN - # ------------------------------------------------------------------ # def format_drop_domain_statement( - self, expr: "PostgresDropDomainExpression" + self, + expr: DropDomainExpression, ) -> Tuple[str, tuple]: - """Format a DROP DOMAIN statement (PostgreSQL-specific). - - Args: - expr: :class:`PostgresDropDomainExpression`. - - Returns: - Tuple of (SQL string, empty params tuple). - - Raises: - UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. - ValueError: if both ``cascade`` and ``restrict`` are True. - - """ - if not self.supports_create_domain(): + if not self.supports_domains() or not self.supports_drop_domain(): raise UnsupportedFeatureError( self.name, "DROP DOMAIN", suggestion="requires PostgreSQL 9.6+", ) - if expr.cascade and expr.restrict: - raise ValueError( - "DROP DOMAIN: CASCADE and RESTRICT are mutually exclusive" - ) - parts: List[str] = ["DROP DOMAIN"] - if expr.if_exists: + if_exists = getattr(expr, "if_exists", False) + cascade = getattr(expr, "cascade", False) + restrict = getattr(expr, "restrict", False) + if if_exists and not self.supports_drop_domain_if_exists(): + raise UnsupportedFeatureError(self.name, "DROP DOMAIN IF EXISTS") + if cascade and not self.supports_drop_domain_cascade(): + raise UnsupportedFeatureError(self.name, "DROP DOMAIN CASCADE") + if restrict and not self.supports_drop_domain_restrict(): + raise UnsupportedFeatureError(self.name, "DROP DOMAIN RESTRICT") + behavior = self._format_domain_behavior(cascade, restrict) + parts = ["DROP DOMAIN"] + if if_exists: parts.append("IF EXISTS") - parts.append(self._format_domain_ref(expr.schema, expr.name)) - if expr.cascade: - parts.append("CASCADE") - elif expr.restrict: - parts.append("RESTRICT") - return " ".join(parts), () \ No newline at end of file + parts.append( + self._format_domain_identifier( + expr.domain_name, + getattr(expr, "schema_name", None), + ) + ) + if behavior: + parts.append(behavior) + return " ".join(parts), () + + def format_domain_value_expression( + self, + expr: DomainValueExpression, + ) -> Tuple[str, tuple]: + if not self.supports_domains(): + raise UnsupportedFeatureError(self.name, "DOMAIN VALUE") + return "VALUE", () + + def format_domain_check_constraint( + self, + expr: DomainCheckConstraint, + ) -> Tuple[str, tuple]: + return DomainMixin.format_domain_check_constraint(self, expr) + + def format_domain_alter_action( + self, + expr: DomainAlterAction, + ) -> Tuple[str, tuple]: + if not self.supports_domains() or not self.supports_alter_domain(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN action") + if not self.supports_alter_domain_action(type(expr)): + raise UnsupportedFeatureError( + self.name, + f"ALTER DOMAIN action {expr.action_kind}", + ) + if isinstance(expr, SetDomainDefaultAction): + if not self.supports_domain_default(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN SET DEFAULT") + value_sql, params = expr.default.to_sql() + if params: + raise ValueError("ALTER DOMAIN DEFAULT must render without bind parameters") + return f"SET DEFAULT {value_sql}", () + if isinstance(expr, DropDomainDefaultAction): + if not self.supports_domain_default(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN DROP DEFAULT") + return "DROP DEFAULT", () + if isinstance(expr, SetDomainNotNullAction): + if not self.supports_domain_nullability(expr.nullability): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN SET NOT NULL") + return "SET NOT NULL", () + if isinstance(expr, DropDomainNotNullAction): + if not self.supports_domain_nullability(expr.nullability): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN DROP NOT NULL") + return "DROP NOT NULL", () + if isinstance(expr, PostgresAddDomainCheckAction): + check_sql, params = expr.check.to_sql() + suffix = " NOT VALID" if expr.not_valid else "" + return f"ADD {check_sql}{suffix}", tuple(params) + if isinstance(expr, AddDomainCheckAction): + check_sql, params = expr.check.to_sql() + return f"ADD {check_sql}", tuple(params) + if isinstance(expr, PostgresDropDomainCheckAction): + if expr.if_exists and not self.supports_drop_domain_if_exists(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN DROP CONSTRAINT IF EXISTS") + if expr.cascade and not self.supports_drop_domain_cascade(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN DROP CONSTRAINT CASCADE") + if expr.restrict and not self.supports_drop_domain_restrict(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN DROP CONSTRAINT RESTRICT") + parts = ["DROP CONSTRAINT"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.name or "")) + behavior = self._format_domain_behavior(expr.cascade, expr.restrict) + if behavior: + parts.append(behavior) + return " ".join(parts), () + if isinstance(expr, DropDomainCheckAction): + if expr.name is None: + if not self.supports_unnamed_domain_check_drop(): + raise UnsupportedFeatureError(self.name, "unnamed DOMAIN CHECK drop") + return "DROP CONSTRAINT", () + if not self.supports_named_domain_checks(): + raise UnsupportedFeatureError(self.name, "named DOMAIN CHECK drop") + return f"DROP CONSTRAINT {self.format_identifier(expr.name)}", () + if isinstance(expr, PostgresRenameDomainConstraintAction): + return ( + f"RENAME CONSTRAINT {self.format_identifier(expr.name)} " + f"TO {self.format_identifier(expr.new_name)}" + ), () + if isinstance(expr, PostgresValidateDomainConstraintAction): + return f"VALIDATE CONSTRAINT {self.format_identifier(expr.name)}", () + if isinstance(expr, PostgresChangeDomainOwnerAction): + return f"OWNER TO {self.format_identifier(expr.new_owner)}", () + if isinstance(expr, PostgresSetDomainSchemaAction): + return f"SET SCHEMA {self.format_identifier(expr.new_schema)}", () + if isinstance(expr, RenameDomainAction): + return f"RENAME TO {self.format_identifier(expr.new_name)}", () + raise UnsupportedFeatureError( + self.name, + f"ALTER DOMAIN action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) + + def _format_postgres_legacy_sql_expression( + self, + expr: "_LegacySqlExpression", + ) -> Tuple[str, tuple]: + return expr.sql, () + + def _format_postgres_unsupported_domain_action( + self, + expr: "_UnsupportedDomainAction", + ) -> Tuple[str, tuple]: + raise ValueError(f"Unsupported ALTER DOMAIN action: {expr.action}") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/type.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/type.py index 8d8c756c..79a3c2ba 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/type.py @@ -1,90 +1,551 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/type.py -from typing import Optional, Tuple, List +"""PostgreSQL user-defined TYPE DDL capabilities and formatting.""" +from __future__ import annotations -class PostgresTypeMixin: - """PostgreSQL type DDL implementation. +from typing import List, Optional, Sequence, Tuple, Type, Union, cast, TYPE_CHECKING - PostgreSQL supports user-defined types, particularly ENUM types. - """ +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins.user_defined_type import UserDefinedTypeMixin +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) +from rhosocial.activerecord.backend.expression.types import DataType +from ...expression.ddl.type import ( + PostgresAddEnumValueAction, + PostgresAddTypeAttributeAction, + PostgresAlterTypeAttributeAction, + PostgresBaseTypeDefinition, + PostgresChangeTypeOwnerAction, + PostgresCompositeTypeDefinition, + PostgresDropTypeAttributeAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresRenameEnumValueAction, + PostgresRenameTypeAction, + PostgresRenameTypeAttributeAction, + PostgresSetTypePropertiesAction, + PostgresSetTypeSchemaAction, + PostgresShellTypeDefinition, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + from ...expression.ddl.type import PostgresCompositeTypeAttribute + + +__all__ = ["PostgresTypeMixin"] + + +class PostgresTypeMixin(UserDefinedTypeMixin): + """PostgreSQL user-defined TYPE DDL support.""" + + if TYPE_CHECKING: + name: str + version: Tuple[int, int, int] + + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: ... + + def inline_sql_literal(self, value: object) -> str: ... + + def _as_dialect(self) -> "SQLDialectBase": + return cast("SQLDialectBase", self) + + _POSTGRES_TYPE_DEFINITIONS = ( + PostgresCompositeTypeDefinition, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresBaseTypeDefinition, + PostgresShellTypeDefinition, + ) + _POSTGRES_TYPE_ACTIONS = ( + PostgresRenameTypeAction, + PostgresSetTypeSchemaAction, + PostgresChangeTypeOwnerAction, + PostgresRenameTypeAttributeAction, + PostgresAddTypeAttributeAction, + PostgresDropTypeAttributeAction, + PostgresAlterTypeAttributeAction, + PostgresAddEnumValueAction, + PostgresRenameEnumValueAction, + PostgresSetTypePropertiesAction, + ) + _COMBINABLE_TYPE_ACTIONS = ( + PostgresAddTypeAttributeAction, + PostgresDropTypeAttributeAction, + PostgresAlterTypeAttributeAction, + ) + + def _format_qualified_identifier(self, name: str, schema: Optional[str] = None) -> str: + value = f"{schema}.{name}" if schema is not None else name + parts = value.split(".") + if not parts or any(not part.strip() for part in parts): + raise ValueError("PostgreSQL identifiers must contain non-empty segments") + return ".".join(self.format_identifier(part) for part in parts) + + def _format_type_data_type(self, data_type: DataType) -> str: + if not isinstance(data_type, DataType): + raise TypeError( + f"data_type must be a DataType instance, got {type(data_type).__name__}" + ) + sql, params = data_type.to_sql() + if params: + raise ValueError("TYPE data types must render without bind parameters") + return sql + + def _format_type_behavior(self, cascade: bool, restrict: bool) -> str: + if cascade and restrict: + raise ValueError("CASCADE and RESTRICT are mutually exclusive") + if cascade: + return "CASCADE" + if restrict: + return "RESTRICT" + return "" + + def _format_postgres_composite_attribute( + self, + expr: "PostgresCompositeTypeAttribute", + ) -> str: + parts = [ + self.format_identifier(expr.name), + self._format_type_data_type(expr.data_type), + ] + if expr.collation is not None: + parts.append(f"COLLATE {self._format_qualified_identifier(expr.collation)}") + return " ".join(parts) + + def _format_postgres_enum_value(self, value: str) -> str: + if not isinstance(value, str): + raise TypeError("ENUM labels must be strings") + return self.inline_sql_literal(value) + + def _format_postgres_base_type_definition( + self, + expr: PostgresBaseTypeDefinition, + ) -> str: + if expr.subscript_function is not None and self.version < (14, 0, 0): + raise UnsupportedFeatureError( + self.name, + "CREATE TYPE base SUBSCRIPT property", + suggestion="requires PostgreSQL 14+", + ) + clauses: List[str] = [] + function_values = ( + ("INPUT", expr.input_function), + ("OUTPUT", expr.output_function), + ("RECEIVE", expr.receive_function), + ("SEND", expr.send_function), + ("TYPMOD_IN", expr.type_modifier_input_function), + ("TYPMOD_OUT", expr.type_modifier_output_function), + ("ANALYZE", expr.analyze_function), + ("SUBSCRIPT", expr.subscript_function), + ) + for keyword, function_name in function_values: + if function_name is not None: + clauses.append(f"{keyword} = {self._format_qualified_identifier(function_name)}") + if expr.internallength is not None: + value = "VARIABLE" if isinstance(expr.internallength, str) else str(expr.internallength) + clauses.append(f"INTERNALLENGTH = {value}") + if expr.passedbyvalue: + clauses.append("PASSEDBYVALUE") + if expr.alignment is not None: + clauses.append(f"ALIGNMENT = {self.format_identifier(expr.alignment)}") + if expr.storage is not None: + clauses.append(f"STORAGE = {self.format_identifier(expr.storage)}") + if expr.like_type is not None: + clauses.append(f"LIKE = {self._format_type_data_type(expr.like_type)}") + if expr.category is not None: + clauses.append(f"CATEGORY = {self.format_identifier(expr.category)}") + if expr.preferred is not None: + clauses.append(f"PREFERRED = {'TRUE' if expr.preferred else 'FALSE'}") + if expr.has_default: + clauses.append(f"DEFAULT = {self.inline_sql_literal(expr.default)}") + if expr.element is not None: + clauses.append(f"ELEMENT = {self._format_type_data_type(expr.element)}") + if expr.delimiter is not None: + clauses.append(f"DELIMITER = {self.inline_sql_literal(expr.delimiter)}") + if expr.collatable is not None: + clauses.append(f"COLLATABLE = {'TRUE' if expr.collatable else 'FALSE'}") + return f"({', '.join(clauses)})" + + def supports_type_objects(self) -> bool: + return self.version >= (9, 6, 0) def supports_create_type(self) -> bool: - """CREATE TYPE is supported.""" - return True + return self.supports_type_objects() + + def supports_alter_type(self) -> bool: + return self.supports_type_objects() def supports_drop_type(self) -> bool: - """DROP TYPE is supported.""" + return self.supports_type_objects() + + def supported_type_definitions(self) -> Tuple[Type[TypeDefinition], ...]: + if not self.supports_type_objects(): + return () + return self._POSTGRES_TYPE_DEFINITIONS + + def supports_type_definition( + self, + definition_type: Type[TypeDefinition], + ) -> bool: + if not self.supports_type_objects(): + return False + try: + return any( + issubclass(definition_type, supported) + for supported in self.supported_type_definitions() + ) + except TypeError: + return False + + def supports_type_alter_action( + self, + action_type: Type[TypeAlterAction], + ) -> bool: + if not self.supports_type_objects(): + return False + try: + if not issubclass(action_type, self._POSTGRES_TYPE_ACTIONS): + return False + except TypeError: + return False + if issubclass(action_type, PostgresRenameEnumValueAction): + return self.version >= (10, 0, 0) + if issubclass(action_type, PostgresSetTypePropertiesAction): + return self.version >= (13, 0, 0) return True - def supports_type_if_not_exists(self) -> bool: - """PostgreSQL does NOT support IF NOT EXISTS for CREATE TYPE.""" + def supports_create_type_if_not_exists(self) -> bool: return False - def supports_type_if_exists(self) -> bool: - """DROP TYPE IF EXISTS is supported.""" - return True + def supports_create_type_or_replace(self) -> bool: + return False - def supports_type_cascade(self) -> bool: - """DROP TYPE CASCADE is supported.""" - return True + def supports_alter_type_if_exists(self) -> bool: + return False - def format_create_type_enum_statement( - self, name: str, values: List[str], schema: Optional[str] = None - ) -> Tuple[str, tuple]: - """Format CREATE TYPE ... AS ENUM statement. + def supports_drop_type_if_exists(self) -> bool: + return self.supports_type_objects() - PostgreSQL syntax: - CREATE TYPE [schema.]name AS ENUM ('value1', 'value2', ...); + def supports_multiple_type_alter_actions(self) -> bool: + return self.supports_type_objects() - Args: - name: Type name - values: List of enum values - schema: Optional schema name + def supports_drop_type_cascade(self) -> bool: + return self.supports_type_objects() - Returns: - Tuple of (SQL string, parameters tuple) - """ - if not values: - raise ValueError("ENUM type must have at least one value") + def supports_drop_type_restrict(self) -> bool: + return self.supports_type_objects() - type_name = self.format_identifier(name) - if schema: - type_name = f"{self.format_identifier(schema)}.{type_name}" + def supports_type_if_not_exists(self) -> bool: + return False - escaped_values = [f"'{self._escape_sql_string(str(v))}'" for v in values] - values_str = ", ".join(escaped_values) + def supports_type_if_exists(self) -> bool: + return self.supports_drop_type_if_exists() - sql = f"CREATE TYPE {type_name} AS ENUM ({values_str})" - return sql, () + def supports_type_cascade(self) -> bool: + return self.supports_drop_type_cascade() - def format_drop_type_statement( - self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False + def format_create_type_enum_statement( + self, + name: str, + values: Sequence[str], + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format DROP TYPE statement. - - PostgreSQL syntax: - DROP TYPE [IF EXISTS] [schema.]name [CASCADE]; + dialect = self._as_dialect() + expr = CreateTypeExpression( + dialect, + name, + PostgresEnumTypeDefinition(dialect, values), + schema_name=schema, + ) + return self.format_create_type_statement(expr) - Args: - name: Type name - schema: Optional schema name - if_exists: Whether to add IF EXISTS - cascade: Whether to add CASCADE + def format_create_type_statement( + self, + expr: CreateTypeExpression, + ) -> Tuple[str, tuple]: + if not self.supports_type_objects() or not self.supports_create_type(): + raise UnsupportedFeatureError(self.name, "CREATE TYPE") + if expr.if_not_exists and not self.supports_create_type_if_not_exists(): + raise UnsupportedFeatureError(self.name, "CREATE TYPE IF NOT EXISTS") + if expr.or_replace and not self.supports_create_type_or_replace(): + raise UnsupportedFeatureError(self.name, "CREATE OR REPLACE TYPE") + if not self.supports_type_definition(type(expr.definition)): + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {expr.definition.definition_kind}", + ) + definition_sql, definition_params = expr.definition.to_sql() + parts = ["CREATE"] + if expr.or_replace: + parts.append("OR REPLACE") + parts.append("TYPE") + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self._format_qualified_identifier(expr.type_name, expr.schema_name)) + if definition_sql: + parts.append(definition_sql) + return " ".join(parts), tuple(definition_params) - Returns: - Tuple of (SQL string, parameters tuple) - """ - parts = ["DROP TYPE"] + def _validate_type_action_version(self, expr: TypeAlterAction) -> None: + if isinstance(expr, PostgresRenameEnumValueAction) and self.version < (10, 0, 0): + raise UnsupportedFeatureError( + self.name, + "ALTER TYPE RENAME VALUE", + suggestion="requires PostgreSQL 10+", + ) + if isinstance(expr, PostgresSetTypePropertiesAction) and self.version < (13, 0, 0): + raise UnsupportedFeatureError( + self.name, + "ALTER TYPE SET properties", + suggestion="requires PostgreSQL 13+", + ) - if if_exists: + def format_alter_type_statement( + self, + expr: AlterTypeExpression, + ) -> Tuple[str, tuple]: + if not self.supports_type_objects() or not self.supports_alter_type(): + raise UnsupportedFeatureError(self.name, "ALTER TYPE") + if expr.if_exists and not self.supports_alter_type_if_exists(): + raise UnsupportedFeatureError(self.name, "ALTER TYPE IF EXISTS") + if len(expr.actions) > 1 and not self.supports_multiple_type_alter_actions(): + raise UnsupportedFeatureError(self.name, "multiple ALTER TYPE actions") + if len(expr.actions) > 1 and any( + not isinstance(action, self._COMBINABLE_TYPE_ACTIONS) + for action in expr.actions + ): + raise UnsupportedFeatureError( + self.name, + "multiple ALTER TYPE actions", + suggestion="PostgreSQL combines only composite attribute actions", + ) + action_parts: List[str] = [] + action_params: List[object] = [] + for action in expr.actions: + self._validate_type_action_version(action) + if not self.supports_type_alter_action(type(action)): + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {action.action_kind}", + ) + action_sql, params = action.to_sql() + action_parts.append(action_sql) + action_params.extend(params) + parts = ["ALTER TYPE"] + if expr.if_exists: parts.append("IF EXISTS") + parts.append(self._format_qualified_identifier(expr.type_name, expr.schema_name)) + parts.append(", ".join(action_parts)) + return " ".join(parts), tuple(action_params) - type_name = self.format_identifier(name) - if schema: - type_name = f"{self.format_identifier(schema)}.{type_name}" - parts.append(type_name) + def format_drop_type_statement( + self, + expr: Optional[Union[DropTypeExpression, str]] = None, + schema_name: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + *, + schema: Optional[str] = None, + name: Optional[str] = None, + ) -> Tuple[str, tuple]: + if expr is not None and name is not None: + raise ValueError("expr and name are mutually exclusive") + if expr is None: + if name is None: + raise TypeError("expr or name is required") + expr = name + if isinstance(expr, str): + if schema is not None and schema_name is not None and schema != schema_name: + raise ValueError("schema and schema_name must match when both are provided") + resolved_schema = schema if schema is not None else schema_name + expr = PostgresDropTypeExpression( + self._as_dialect(), + expr, + schema_name=resolved_schema, + if_exists=if_exists, + cascade=cascade, + restrict=restrict, + ) + if not self.supports_type_objects() or not self.supports_drop_type(): + raise UnsupportedFeatureError(self.name, "DROP TYPE") + if expr.if_exists and not self.supports_drop_type_if_exists(): + raise UnsupportedFeatureError(self.name, "DROP TYPE IF EXISTS") + behavior = self._format_type_behavior( + getattr(expr, "cascade", False), + getattr(expr, "restrict", False), + ) + if getattr(expr, "cascade", False) and not self.supports_drop_type_cascade(): + raise UnsupportedFeatureError(self.name, "DROP TYPE CASCADE") + if getattr(expr, "restrict", False) and not self.supports_drop_type_restrict(): + raise UnsupportedFeatureError(self.name, "DROP TYPE RESTRICT") + parts = ["DROP TYPE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_qualified_identifier(expr.type_name, expr.schema_name)) + if behavior: + parts.append(behavior) + return " ".join(parts), () - if cascade: - parts.append("CASCADE") + def format_type_definition( + self, + expr: TypeDefinition, + ) -> Tuple[str, tuple]: + if not self.supports_type_definition(type(expr)): + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + ) + if isinstance(expr, PostgresCompositeTypeDefinition): + attributes = ", ".join( + self._format_postgres_composite_attribute(attribute) + for attribute in expr.attributes + ) + return f"AS ({attributes})", () + if isinstance(expr, PostgresEnumTypeDefinition): + labels = ", ".join( + self._format_postgres_enum_value(label) for label in expr.labels + ) + return f"AS ENUM ({labels})", () + if isinstance(expr, PostgresRangeTypeDefinition): + if expr.multirange_type_name is not None and self.version < (14, 0, 0): + raise UnsupportedFeatureError( + self.name, + "CREATE TYPE MULTIRANGE_TYPE_NAME", + suggestion="requires PostgreSQL 14+", + ) + clauses = [f"SUBTYPE = {self._format_type_data_type(expr.subtype)}"] + optional_values = ( + ("SUBTYPE_OPCLASS", expr.subtype_operator_class), + ("COLLATION", expr.collation), + ("CANONICAL", expr.canonical_function), + ("SUBTYPE_DIFF", expr.subtype_diff_function), + ("MULTIRANGE_TYPE_NAME", expr.multirange_type_name), + ) + for keyword, value in optional_values: + if value is not None: + clauses.append(f"{keyword} = {self._format_qualified_identifier(value)}") + return f"AS RANGE ({', '.join(clauses)})", () + if isinstance(expr, PostgresBaseTypeDefinition): + return self._format_postgres_base_type_definition(expr), () + if isinstance(expr, PostgresShellTypeDefinition): + return "", () + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + ) - return " ".join(parts), () + def format_type_alter_action( + self, + expr: TypeAlterAction, + ) -> Tuple[str, tuple]: + self._validate_type_action_version(expr) + if not self.supports_type_alter_action(type(expr)): + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) + if isinstance(expr, PostgresRenameTypeAction): + return f"RENAME TO {self.format_identifier(expr.new_name)}", () + if isinstance(expr, PostgresSetTypeSchemaAction): + return f"SET SCHEMA {self.format_identifier(expr.new_schema)}", () + if isinstance(expr, PostgresChangeTypeOwnerAction): + return f"OWNER TO {self.format_identifier(expr.new_owner)}", () + if isinstance(expr, PostgresRenameTypeAttributeAction): + behavior = self._format_type_behavior(expr.cascade, expr.restrict) + sql = ( + f"RENAME ATTRIBUTE {self.format_identifier(expr.attribute_name)} " + f"TO {self.format_identifier(expr.new_name)}" + ) + return (f"{sql} {behavior}" if behavior else sql), () + if isinstance(expr, PostgresAddTypeAttributeAction): + parts = [ + "ADD ATTRIBUTE", + self.format_identifier(expr.attribute_name), + self._format_type_data_type(expr.data_type), + ] + if expr.collation is not None: + parts.append(f"COLLATE {self._format_qualified_identifier(expr.collation)}") + behavior = self._format_type_behavior(expr.cascade, expr.restrict) + if behavior: + parts.append(behavior) + return " ".join(parts), () + if isinstance(expr, PostgresDropTypeAttributeAction): + parts = ["DROP ATTRIBUTE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.attribute_name)) + behavior = self._format_type_behavior(expr.cascade, expr.restrict) + if behavior: + parts.append(behavior) + return " ".join(parts), () + if isinstance(expr, PostgresAlterTypeAttributeAction): + type_keyword = "SET DATA TYPE" if expr.set_data else "TYPE" + parts = [ + "ALTER ATTRIBUTE", + self.format_identifier(expr.attribute_name), + type_keyword, + self._format_type_data_type(expr.data_type), + ] + if expr.collation is not None: + parts.append(f"COLLATE {self._format_qualified_identifier(expr.collation)}") + behavior = self._format_type_behavior(expr.cascade, expr.restrict) + if behavior: + parts.append(behavior) + return " ".join(parts), () + if isinstance(expr, PostgresAddEnumValueAction): + if expr.if_not_exists and self.version < (9, 3, 0): + raise UnsupportedFeatureError( + self.name, + "ALTER TYPE ADD VALUE IF NOT EXISTS", + suggestion="requires PostgreSQL 9.3+", + ) + parts = ["ADD VALUE"] + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self._format_postgres_enum_value(expr.new_value)) + if expr.before is not None: + parts.extend(("BEFORE", self._format_postgres_enum_value(expr.before))) + elif expr.after is not None: + parts.extend(("AFTER", self._format_postgres_enum_value(expr.after))) + return " ".join(parts), () + if isinstance(expr, PostgresRenameEnumValueAction): + return ( + "RENAME VALUE " + f"{self._format_postgres_enum_value(expr.old_value)} " + f"TO {self._format_postgres_enum_value(expr.new_value)}" + ), () + if isinstance(expr, PostgresSetTypePropertiesAction): + if self.version < (13, 0, 0): + raise UnsupportedFeatureError( + self.name, + "ALTER TYPE SET properties", + suggestion="requires PostgreSQL 13+", + ) + if "SUBSCRIPT" in expr.properties and self.version < (14, 0, 0): + raise UnsupportedFeatureError( + self.name, + "ALTER TYPE SET SUBSCRIPT", + suggestion="requires PostgreSQL 14+", + ) + clauses: List[str] = [] + for name, value in expr.properties.items(): + if name == "STORAGE": + if value is None: + raise ValueError("STORAGE property cannot be None") + rendered_value = self.format_identifier(value) + else: + rendered_value = "NONE" if value is None else self._format_qualified_identifier(value) + clauses.append(f"{name} = {rendered_value}") + return f"SET ({', '.join(clauses)})", () + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py index 03442f05..2d956717 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py @@ -13,8 +13,9 @@ - format_create_range_type: DDL statement """ -from typing import Tuple +from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.expression.statements.ddl_type import CreateTypeExpression class PostgresDataTypeMixin: """Mixin providing PostgreSQL data type support methods. @@ -23,6 +24,14 @@ class PostgresDataTypeMixin: Designed for multiple inheritance with SQLDialectBase. """ + if TYPE_CHECKING: + version: Tuple[int, int, int] + + def format_create_type_statement( + self, + expr: CreateTypeExpression, + ) -> Tuple[str, tuple]: ... + def supports_multirange_type(self) -> bool: """Whether multirange data types are supported. @@ -72,29 +81,8 @@ def supports_xid8_type(self) -> bool: """ return self.version >= (13, 0, 0) - def format_create_range_type(self, expr) -> Tuple[str, tuple]: - """Format CREATE TYPE AS RANGE statement from expression object. - - Args: - expr: PostgresCreateRangeTypeExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) - """ - exists_clause = "IF NOT EXISTS " if expr.if_not_exists else "" - type_name = f"{expr.schema}.{expr.name}" if expr.schema else expr.name - - options = [f"subtype={expr.subtype}"] - - if expr.subtype_opclass: - options.append(f"subtype_opclass={expr.subtype_opclass}") - if expr.collation: - options.append(f"collation={expr.collation}") - if expr.canonical: - options.append(f"canonical={expr.canonical}") - if expr.subtype_diff: - options.append(f"subtype_diff={expr.subtype_diff}") - - options_str = ", ".join(options) - sql = f"CREATE TYPE {exists_clause}{type_name} AS RANGE ({options_str})" - return (sql, ()) + def format_create_range_type( + self, + expr: CreateTypeExpression, + ) -> Tuple[str, tuple]: + return self.format_create_type_statement(expr) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py index aafe9250..8969dbd6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py @@ -6,6 +6,7 @@ import re from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin from rhosocial.activerecord.backend.dialect.protocols import DDLTypeSupport from rhosocial.activerecord.backend.expression.types import ( @@ -40,10 +41,6 @@ PostgresCharacterVaryingType, ) -if TYPE_CHECKING: - pass - - class PostgresTypeFormatSupportMixin(DDLTypeMixin, DDLTypeSupport): """PostgreSQL DataType formatting and parsing. @@ -52,6 +49,10 @@ class PostgresTypeFormatSupportMixin(DDLTypeMixin, DDLTypeSupport): ``DataType`` instances. """ + if TYPE_CHECKING: + name: str + version: Tuple[int, int, int] + # ------------------------------------------------------------------ # DDLTypeSupport — formatting # ------------------------------------------------------------------ @@ -162,23 +163,32 @@ def format_data_type_postgres_tstzrange(self, data_type) -> Tuple[str, tuple]: def format_data_type_postgres_daterange(self, data_type) -> Tuple[str, tuple]: return "DATERANGE", () + def _format_postgres_multirange_data_type(self, sql: str) -> Tuple[str, tuple]: + if self.version < (14, 0, 0): + raise UnsupportedFeatureError( + self.name, + sql, + suggestion="requires PostgreSQL 14+", + ) + return sql, () + def format_data_type_postgres_int4multirange(self, data_type) -> Tuple[str, tuple]: - return "INT4MULTIRANGE", () + return self._format_postgres_multirange_data_type("INT4MULTIRANGE") def format_data_type_postgres_int8multirange(self, data_type) -> Tuple[str, tuple]: - return "INT8MULTIRANGE", () + return self._format_postgres_multirange_data_type("INT8MULTIRANGE") def format_data_type_postgres_nummultirange(self, data_type) -> Tuple[str, tuple]: - return "NUMMULTIRANGE", () + return self._format_postgres_multirange_data_type("NUMMULTIRANGE") def format_data_type_postgres_tsmultirange(self, data_type) -> Tuple[str, tuple]: - return "TSMULTIRANGE", () + return self._format_postgres_multirange_data_type("TSMULTIRANGE") def format_data_type_postgres_tstzmultirange(self, data_type) -> Tuple[str, tuple]: - return "TSTZMULTIRANGE", () + return self._format_postgres_multirange_data_type("TSTZMULTIRANGE") def format_data_type_postgres_datemultirange(self, data_type) -> Tuple[str, tuple]: - return "DATEMULTIRANGE", () + return self._format_postgres_multirange_data_type("DATEMULTIRANGE") def format_data_type_postgres_oid(self, data_type) -> Tuple[str, tuple]: return "OID", () @@ -464,22 +474,22 @@ def supports_data_type_postgres_daterange(self) -> bool: return True def supports_data_type_postgres_int4multirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_int8multirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_nummultirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_tsmultirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_tstzmultirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_datemultirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_oid(self) -> bool: return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py index b9f2826e..b679093f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py @@ -1,112 +1,87 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py -"""PostgreSQL ENUM type support mixin. +"""PostgreSQL ENUM compatibility formatting.""" -This module provides the EnumTypeMixin class for handling PostgreSQL -enumerated type operations. -""" +from __future__ import annotations -from typing import Optional, List, Tuple, TYPE_CHECKING +from typing import Any, List, Optional, Sequence, Tuple, cast, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, +) +from ...expression.ddl.type import ( + PostgresAddEnumValueAction, + PostgresDropEnumTypeExpression, + PostgresEnumTypeDefinition, + PostgresRenameEnumValueAction, +) if TYPE_CHECKING: - from rhosocial.activerecord.backend.impl.postgres.expression.ddl.type import ( - PostgresCreateEnumTypeExpression, - PostgresDropEnumTypeExpression, - PostgresAlterEnumAddValueExpression, - EnumTypeNameExpression, - EnumValuesExpression, - CreateEnumTypeExpression, - DropEnumTypeExpression, - AlterEnumAddValueExpression, - ) + from rhosocial.activerecord.backend.dialect import SQLDialectBase +__all__ = ["EnumTypeMixin"] -class EnumTypeMixin: - """Mixin providing PostgreSQL ENUM type formatting methods. - This mixin implements the EnumTypeSupport protocol. - """ +class EnumTypeMixin: + if TYPE_CHECKING: + name: str + version: Tuple[int, int, int] - def format_enum_type_name(self, name: str, schema: Optional[str] = None) -> Tuple[str, tuple]: - """Format enum type name with optional schema. + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: ... - Args: - name: Type name - schema: Optional schema name + def inline_sql_literal(self, value: object) -> str: ... - Returns: - Tuple of (formatted type name, empty params tuple) + def format_create_type_statement(self, expr: CreateTypeExpression) -> Tuple[str, tuple]: ... - """ - if schema: - return (f"{schema}.{name}", ()) - return (name, ()) + def format_drop_type_statement(self, expr: DropTypeExpression) -> Tuple[str, tuple]: ... - def format_enum_type_name_expression(self, expr: "EnumTypeNameExpression") -> Tuple[str, tuple]: - """Format enum type name from expression object. + def format_alter_type_statement(self, expr: AlterTypeExpression) -> Tuple[str, tuple]: ... - Args: - expr: :class:`EnumTypeNameExpression` instance. + def _as_dialect(self) -> "SQLDialectBase": + return cast("SQLDialectBase", self) - Returns: - Tuple of (SQL string, empty params tuple). + def format_enum_type_name( + self, + name: str, + schema: Optional[str] = None, + ) -> Tuple[str, tuple]: + value = f"{schema}.{name}" if schema is not None else name + parts = value.split(".") + if not parts or any(not part.strip() for part in parts): + raise ValueError("ENUM type identifiers must contain non-empty segments") + return ".".join(self.format_identifier(part) for part in parts), () - """ + def format_enum_type_name_expression(self, expr: Any) -> Tuple[str, tuple]: return self.format_enum_type_name(expr.name, expr.schema) - def format_enum_values(self, values: List[str]) -> Tuple[str, tuple]: - """Format enum values list for SQL. - - Args: - values: List of enum values - - Returns: - Tuple of (SQL-formatted values string, empty params tuple) - - """ - return (", ".join(f"'{v}'" for v in values), ()) - - def format_enum_values_expression(self, expr: "EnumValuesExpression") -> Tuple[str, tuple]: - """Format enum values list from expression object. - - Args: - expr: :class:`EnumValuesExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). + def format_enum_values(self, values: Sequence[str]) -> Tuple[str, tuple]: + labels = list(values or []) + if any(not isinstance(label, str) for label in labels): + raise TypeError("ENUM values must be strings") + return ", ".join(self.inline_sql_literal(label) for label in labels), () - """ + def format_enum_values_expression(self, expr: Any) -> Tuple[str, tuple]: return self.format_enum_values(expr.values) def format_create_enum_type_raw( - self, name: str, values: List[str], schema: Optional[str] = None, if_not_exists: bool = False + self, + name: str, + values: List[str], + schema: Optional[str] = None, + if_not_exists: bool = False, ) -> Tuple[str, tuple]: - """Format CREATE TYPE statement for enum. - - Args: - name: Type name - values: Allowed values - schema: Optional schema - if_not_exists: Add IF NOT EXISTS - - Returns: - Tuple of (SQL statement string, empty params tuple) - - """ - full_name, _ = self.format_enum_type_name(name, schema) - values_str, _ = self.format_enum_values(values) - exists_clause = "IF NOT EXISTS " if if_not_exists else "" - return (f"CREATE TYPE {exists_clause}{full_name} AS ENUM ({values_str})", ()) - - def format_create_enum_type_raw_expression(self, expr: "CreateEnumTypeExpression") -> Tuple[str, tuple]: - """Format CREATE TYPE ... AS ENUM from expression object. - - Args: - expr: :class:`CreateEnumTypeExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). + dialect = self._as_dialect() + expr = CreateTypeExpression( + dialect, + name, + PostgresEnumTypeDefinition(dialect, values), + schema_name=schema, + if_not_exists=if_not_exists, + ) + return self.format_create_type_statement(expr) - """ + def format_create_enum_type_raw_expression(self, expr: Any) -> Tuple[str, tuple]: return self.format_create_enum_type_raw( expr.name, expr.values, @@ -115,35 +90,22 @@ def format_create_enum_type_raw_expression(self, expr: "CreateEnumTypeExpression ) def format_drop_enum_type_raw( - self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False + self, + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, ) -> Tuple[str, tuple]: - """Format DROP TYPE statement. - - Args: - name: Type name - schema: Optional schema - if_exists: Add IF EXISTS - cascade: Add CASCADE - - Returns: - Tuple of (SQL statement string, empty params tuple) - - """ - full_name, _ = self.format_enum_type_name(name, schema) - exists_clause = "IF EXISTS " if if_exists else "" - cascade_clause = " CASCADE" if cascade else "" - return (f"DROP TYPE {exists_clause}{full_name}{cascade_clause}", ()) - - def format_drop_enum_type_raw_expression(self, expr: "DropEnumTypeExpression") -> Tuple[str, tuple]: - """Format DROP TYPE from expression object. - - Args: - expr: :class:`DropEnumTypeExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). + expr = PostgresDropEnumTypeExpression( + self._as_dialect(), + name, + schema=schema, + if_exists=if_exists, + cascade=cascade, + ) + return self.format_drop_type_statement(expr) - """ + def format_drop_enum_type_raw_expression(self, expr: Any) -> Tuple[str, tuple]: return self.format_drop_enum_type_raw( expr.name, expr.schema, @@ -152,40 +114,24 @@ def format_drop_enum_type_raw_expression(self, expr: "DropEnumTypeExpression") - ) def format_alter_enum_add_value_raw( - self, type_name: str, new_value: str, schema: Optional[str] = None, - before: Optional[str] = None, after: Optional[str] = None - ) -> Tuple[str, tuple]: - """Format ALTER TYPE ADD VALUE statement. - - Args: - type_name: Type name - new_value: New value to add - schema: Optional schema - before: Add before this value - after: Add after this value - - Returns: - Tuple of (SQL statement string, empty params tuple) - - """ - full_name, _ = self.format_enum_type_name(type_name, schema) - sql = f"ALTER TYPE {full_name} ADD VALUE '{new_value}'" - if before: - sql += f" BEFORE '{before}'" - elif after: - sql += f" AFTER '{after}'" - return (sql, ()) - - def format_alter_enum_add_value_raw_expression(self, expr: "AlterEnumAddValueExpression") -> Tuple[str, tuple]: - """Format ALTER TYPE ADD VALUE from expression object. - - Args: - expr: :class:`AlterEnumAddValueExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ + self, + type_name: str, + new_value: str, + schema: Optional[str] = None, + before: Optional[str] = None, + after: Optional[str] = None, + ) -> Tuple[str, tuple]: + dialect = self._as_dialect() + action = PostgresAddEnumValueAction( + dialect, + new_value, + before=before, + after=after, + ) + expr = AlterTypeExpression(dialect, type_name, [action], schema_name=schema) + return self.format_alter_type_statement(expr) + + def format_alter_enum_add_value_raw_expression(self, expr: Any) -> Tuple[str, tuple]: return self.format_alter_enum_add_value_raw( expr.type_name, expr.new_value, @@ -194,47 +140,28 @@ def format_alter_enum_add_value_raw_expression(self, expr: "AlterEnumAddValueExp expr.after, ) - # ========================================================================= - # Convenience methods for EnumTypeSupport protocol - # ========================================================================= - def create_enum_type( - self, name: str, values: List[str], schema: Optional[str] = None, if_not_exists: bool = False + self, + name: str, + values: List[str], + schema: Optional[str] = None, + if_not_exists: bool = False, ) -> str: - """Generate CREATE TYPE statement for enum. - - Implements EnumTypeSupport protocol. - - Args: - name: Enum type name - values: List of allowed values - schema: Optional schema name - if_not_exists: Add IF NOT EXISTS clause (PG 9.1+) - - Returns: - SQL statement string - - """ - sql, _ = self.format_create_enum_type_raw(name, values, schema, if_not_exists) + sql, _ = self.format_create_enum_type_raw( + name, + values, + schema, + if_not_exists, + ) return sql def drop_enum_type( - self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False + self, + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, ) -> str: - """Generate DROP TYPE statement for enum. - - Implements EnumTypeSupport protocol. - - Args: - name: Enum type name - schema: Optional schema name - if_exists: Add IF EXISTS clause - cascade: Add CASCADE clause - - Returns: - SQL statement string - - """ sql, _ = self.format_drop_enum_type_raw(name, schema, if_exists, cascade) return sql @@ -246,43 +173,16 @@ def alter_enum_add_value( before: Optional[str] = None, after: Optional[str] = None, ) -> str: - """Generate ALTER TYPE ADD VALUE statement. - - Implements EnumTypeSupport protocol. - - Note: Requires PostgreSQL 9.1+ - - Args: - type_name: Enum type name - new_value: New value to add - schema: Optional schema name - before: Add before this value - after: Add after this value - - Returns: - SQL statement string - - """ - sql, _ = self.format_alter_enum_add_value_raw(type_name, new_value, schema, before, after) + sql, _ = self.format_alter_enum_add_value_raw( + type_name, + new_value, + schema, + before, + after, + ) return sql - # ========================================================================= - # Expression-based methods (for expression-dialect architecture) - # ========================================================================= - - def format_create_enum_type( - self, - expr: "PostgresCreateEnumTypeExpression", - ) -> Tuple[str, tuple]: - """Format CREATE TYPE ... AS ENUM from its expression node. - - Args: - expr: :class:`PostgresCreateEnumTypeExpression` instance. - - Returns: - Tuple of (SQL string, params tuple). - - """ + def format_create_enum_type(self, expr: Any) -> Tuple[str, tuple]: return self.format_create_enum_type_raw( expr.name, expr.values, @@ -290,19 +190,7 @@ def format_create_enum_type( expr.if_not_exists, ) - def format_drop_enum_type( - self, - expr: "PostgresDropEnumTypeExpression", - ) -> Tuple[str, tuple]: - """Format DROP TYPE from its expression node. - - Args: - expr: :class:`PostgresDropEnumTypeExpression` instance. - - Returns: - Tuple of (SQL string, params tuple). - - """ + def format_drop_enum_type(self, expr: Any) -> Tuple[str, tuple]: return self.format_drop_enum_type_raw( expr.name, expr.schema, @@ -310,19 +198,7 @@ def format_drop_enum_type( expr.cascade, ) - def format_alter_enum_add_value( - self, - expr: "PostgresAlterEnumAddValueExpression", - ) -> Tuple[str, tuple]: - """Format ALTER TYPE ADD VALUE from its expression node. - - Args: - expr: :class:`PostgresAlterEnumAddValueExpression` instance. - - Returns: - Tuple of (SQL string, params tuple). - - """ + def format_alter_enum_add_value(self, expr: Any) -> Tuple[str, tuple]: return self.format_alter_enum_add_value_raw( expr.type_name, expr.new_value, @@ -331,33 +207,10 @@ def format_alter_enum_add_value( expr.after, ) - def format_enum_type_expression(self, expr) -> Tuple[str, tuple]: - """Format a :class:`PostgresEnumType` type reference expression. - - The reference is schema-qualified when the expression carries a - schema: ``"schema"."name"`` / ``"name"``. - - Args: - expr: :class:`PostgresEnumType` instance. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - if expr.schema: - return (f"{expr.schema}.{expr.name}", ()) - return (expr.name, ()) - - def format_alter_enum_type_add_value(self, expr) -> Tuple[str, tuple]: - """Format ALTER TYPE ADD VALUE statement from expression object. - - Args: - expr: PostgresAlterEnumTypeAddValueExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) + def format_enum_type_expression(self, expr: Any) -> Tuple[str, tuple]: + return self.format_enum_type_name(expr.name, expr.schema) - """ + def format_alter_enum_type_add_value(self, expr: Any) -> Tuple[str, tuple]: return self.format_alter_enum_add_value_raw( expr.type_name, expr.new_value, @@ -366,16 +219,17 @@ def format_alter_enum_type_add_value(self, expr) -> Tuple[str, tuple]: expr.after, ) - def format_alter_enum_type_rename_value(self, expr) -> Tuple[str, tuple]: - """Format ALTER TYPE RENAME VALUE statement from expression object. - - Args: - expr: PostgresAlterEnumTypeRenameValueExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) - - """ - full_name, _ = self.format_enum_type_name(expr.type_name, expr.schema) - sql = f"ALTER TYPE {full_name} RENAME VALUE '{expr.old_value}' TO '{expr.new_value}'" - return (sql, ()) + def format_alter_enum_type_rename_value(self, expr: Any) -> Tuple[str, tuple]: + dialect = self._as_dialect() + action = PostgresRenameEnumValueAction( + dialect, + expr.old_value, + expr.new_value, + ) + statement = AlterTypeExpression( + dialect, + expr.type_name, + [action], + schema_name=expr.schema, + ) + return self.format_alter_type_statement(statement) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py index 73fac075..098ea29a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py @@ -1,21 +1,10 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py -"""PostgreSQL multirange type support mixin implementation. - -This module provides the MultirangeMixin class for handling PostgreSQL -multirange type operations. - -For SQL expression generation of multirange operators and functions, -use the function factories in ``functions/range.py`` instead of the -removed format_* methods. - -Methods retained in this mixin: -- supports_*: Capability detection -- format_create_multirange_type_statement: DDL statement -- format_multirange_agg_function: Complete SELECT query template -""" +"""PostgreSQL multirange support.""" from typing import Optional, Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + if TYPE_CHECKING: from rhosocial.activerecord.backend.impl.postgres.expression.ddl.multirange import ( CreateMultirangeTypeExpression, @@ -23,112 +12,75 @@ ) +__all__ = ["MultirangeMixin"] + + class MultirangeMixin: - """Mixin providing PostgreSQL multirange type support methods. + if TYPE_CHECKING: + name: str + version: Tuple[int, int, int] - This mixin implements the PostgresMultirangeSupport protocol. - Designed for multiple inheritance with SQLDialectBase. - """ + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: ... def supports_multirange(self) -> bool: - """Check if multirange types are supported. - - Returns: - True if PostgreSQL version >= 14.0 - """ return self.version >= (14, 0, 0) def supports_multirange_constructor(self) -> bool: - """Check if multirange constructor function is supported. + return self.version >= (14, 0, 0) - Returns: - True if PostgreSQL version >= 14.0 - """ + def supports_multirange_agg(self) -> bool: return self.version >= (14, 0, 0) def format_create_multirange_type_statement( - self, name: str, range_type: str, schema: Optional[str] = None + self, + name: str, + range_type: str, + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format CREATE TYPE statement for a custom multirange type. - - Note: PostgreSQL automatically creates multirange types when you create - a range type. This method is for documentation and explicit creation - if needed. - - Args: - name: The multirange type name - range_type: The associated range type - schema: Optional schema name - - Returns: - Tuple of (SQL statement, parameters) - - Example: - >>> format_create_multirange_type_statement('my_multirange', 'my_range') - ('CREATE TYPE my_multirange AS MULTIRANGE (my_range)', ()) - """ - full_name = f"{schema}.{name}" if schema else name - sql = f"CREATE TYPE {full_name} AS MULTIRANGE ({range_type})" - return (sql, ()) + raise UnsupportedFeatureError( + self.name, + "CREATE TYPE AS MULTIRANGE", + suggestion="set MULTIRANGE_TYPE_NAME on a RANGE definition", + ) def format_multirange_agg_function( - self, range_column: str, table_name: str, where_clause: Optional[str] = None, schema: Optional[str] = None + self, + range_column: str, + table_name: str, + where_clause: Optional[str] = None, + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format multirange_agg aggregate function call. - - The multirange_agg function aggregates multiple ranges into a multirange. - - Args: - range_column: The range column to aggregate - table_name: Table name - where_clause: Optional WHERE clause - schema: Optional schema name - - Returns: - Tuple of (SQL statement, parameters) - - Example: - >>> format_multirange_agg_function('period', 'events') - ('SELECT multirange_agg(period) FROM events', ()) - """ - full_table = f"{schema}.{table_name}" if schema else table_name - sql = f"SELECT multirange_agg({range_column}) FROM {full_table}" + if not self.supports_multirange_agg(): + raise UnsupportedFeatureError( + self.name, + "multirange_agg", + suggestion="requires PostgreSQL 14+", + ) + full_name = f"{schema}.{table_name}" if schema is not None else table_name + table_sql = ".".join(self.format_identifier(part) for part in full_name.split(".")) + column_sql = self.format_identifier(range_column) + sql = f"SELECT multirange_agg({column_sql}) FROM {table_sql}" if where_clause: sql += f" WHERE {where_clause}" - return (sql, ()) - - # ========================================================================= - # Expression-based format methods - # ========================================================================= + return sql, () def format_create_multirange_type_statement_expression( - self, expr: "CreateMultirangeTypeExpression" + self, + expr: "CreateMultirangeTypeExpression", ) -> Tuple[str, tuple]: - """Format CREATE TYPE ... AS MULTIRANGE from expression object. - - Args: - expr: :class:`CreateMultirangeTypeExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ return self.format_create_multirange_type_statement( - expr.name, expr.range_type, expr.schema + expr.name, + expr.range_type, + expr.schema, ) def format_multirange_agg_function_expression( - self, expr: "MultirangeAggFunctionExpression" + self, + expr: "MultirangeAggFunctionExpression", ) -> Tuple[str, tuple]: - """Format multirange_agg aggregate function call from expression object. - - Args: - expr: :class:`MultirangeAggFunctionExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ return self.format_multirange_agg_function( - expr.range_column, expr.table_name, expr.where_clause, expr.schema + expr.range_column, + expr.table_name, + expr.where_clause, + expr.schema, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py index 7a0a64f0..87c382af 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py @@ -1,84 +1,19 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py -"""PostgreSQL DOMAIN protocol definition. +"""PostgreSQL DOMAIN DDL protocol.""" -This module contains the :class:`PostgresDomainSupport` protocol which -defines the interface for PostgreSQL's native CREATE/ALTER/DROP DOMAIN DDL. -""" +from typing import Protocol, Tuple, runtime_checkable -from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.protocols import DomainSupport +from rhosocial.activerecord.backend.expression.statements.ddl_domain import AlterDomainExpression -if TYPE_CHECKING: - from ...expression.ddl.domain import ( - PostgresAlterDomainExpression, - PostgresCreateDomainExpression, - PostgresDropDomainExpression, - ) +__all__ = ["PostgresDomainSupport"] -@runtime_checkable -class PostgresDomainSupport(Protocol): - """PostgreSQL DOMAIN protocol. - - Feature Source: Native support (no extension required) - - Official Documentation: - - CREATE DOMAIN: https://www.postgresql.org/docs/current/sql-createdomain.html - - ALTER DOMAIN: https://www.postgresql.org/docs/current/sql-alterdomain.html - - DROP DOMAIN: https://www.postgresql.org/docs/current/sql-dropdomain.html - - Version Requirements: - - CREATE/ALTER/DROP DOMAIN: PostgreSQL 9.6+ - """ - - def supports_create_domain(self) -> bool: - """Whether CREATE/ALTER/DROP DOMAIN is supported (9.6+).""" - ... - - def format_create_domain_statement( - self, expr: "PostgresCreateDomainExpression" - ) -> Tuple[str, tuple]: - """Format a ``CREATE DOMAIN`` statement. - - Args: - expr: ``PostgresCreateDomainExpression``. - - Returns: - Tuple of (SQL string, params tuple). - - Raises: - UnsupportedFeatureError: dialect predates PostgreSQL 9.6. - """ - ... +@runtime_checkable +class PostgresDomainSupport(DomainSupport, Protocol): def format_postgres_alter_domain_statement( - self, expr: "PostgresAlterDomainExpression" + self, + expr: AlterDomainExpression, ) -> Tuple[str, tuple]: - """Format an ``ALTER DOMAIN`` statement. - - Args: - expr: ``PostgresAlterDomainExpression``. - - Returns: - Tuple of (SQL string, params tuple). - - Raises: - UnsupportedFeatureError: dialect predates PostgreSQL 9.6. - """ ... - - def format_drop_domain_statement( - self, expr: "PostgresDropDomainExpression" - ) -> Tuple[str, tuple]: - """Format a ``DROP DOMAIN`` statement. - - Args: - expr: ``PostgresDropDomainExpression``. - - Returns: - Tuple of (SQL string, empty params tuple). - - Raises: - UnsupportedFeatureError: dialect predates PostgreSQL 9.6. - ValueError: both ``cascade`` and ``restrict``. - """ - ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/type.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/type.py index 821915d9..69142060 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/type.py @@ -1,98 +1,49 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/type.py -"""PostgreSQL type DDL protocol. +"""PostgreSQL user-defined TYPE DDL protocol.""" -This module defines the protocol for PostgreSQL-specific type DDL features -that extend beyond standard SQL. -""" +from typing import List, Optional, Protocol, Tuple, Union, runtime_checkable -from typing import Protocol, runtime_checkable, Optional, Tuple, List +from rhosocial.activerecord.backend.dialect.protocols import UserDefinedTypeSupport +from rhosocial.activerecord.backend.expression.statements.ddl_type import DropTypeExpression -@runtime_checkable -class PostgresTypeSupport(Protocol): - """PostgreSQL type DDL protocol. - - Feature Source: Native support (no extension required) - - PostgreSQL supports user-defined types: - - ENUM types (CREATE TYPE ... AS ENUM) - - Composite types (CREATE TYPE ... AS) - - Range types - - Base types (with C functions) - - This protocol focuses on ENUM type DDL support. +__all__ = ["PostgresTypeSupport"] - Official Documentation: - - CREATE TYPE: https://www.postgresql.org/docs/current/sql-createtype.html - - DROP TYPE: https://www.postgresql.org/docs/current/sql-droptype.html - Version Requirements: - - All versions support ENUM types - """ - - def supports_create_type(self) -> bool: - """Whether CREATE TYPE is supported. - - Native feature, all versions. - """ +@runtime_checkable +class PostgresTypeSupport(UserDefinedTypeSupport, Protocol): + def supports_drop_type_cascade(self) -> bool: ... - def supports_drop_type(self) -> bool: - """Whether DROP TYPE is supported. - - Native feature, all versions. - """ + def supports_drop_type_restrict(self) -> bool: ... def supports_type_if_not_exists(self) -> bool: - """Whether CREATE TYPE IF NOT EXISTS is supported. - - PostgreSQL does NOT support IF NOT EXISTS for CREATE TYPE. - Always returns False. - """ ... def supports_type_if_exists(self) -> bool: - """Whether DROP TYPE IF EXISTS is supported. - - Native feature, all versions. - """ ... def supports_type_cascade(self) -> bool: - """Whether DROP TYPE CASCADE is supported. - - Native feature, all versions. - """ ... def format_create_type_enum_statement( - self, name: str, values: List[str], schema: Optional[str] = None + self, + name: str, + values: List[str], + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format CREATE TYPE ... AS ENUM statement. - - Args: - name: Type name - values: List of enum values - schema: Optional schema name - - Returns: - Tuple of (SQL string, parameters tuple) - """ ... def format_drop_type_statement( - self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False + self, + expr: Optional[Union[DropTypeExpression, str]] = None, + schema_name: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + *, + schema: Optional[str] = None, + name: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format DROP TYPE statement. - - Args: - name: Type name - schema: Optional schema name - if_exists: Whether to add IF EXISTS - cascade: Whether to add CASCADE - - Returns: - Tuple of (SQL string, parameters tuple) - """ ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py index 467eb100..f3530318 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py @@ -1,87 +1,42 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py -"""PostgreSQL multirange type support protocol.""" +"""PostgreSQL multirange protocol.""" -from typing import Protocol, runtime_checkable, Optional, Tuple +from typing import Optional, Protocol, Tuple, runtime_checkable -@runtime_checkable -class PostgresMultirangeSupport(Protocol): - """PostgreSQL multirange type support protocol. - - PostgreSQL 14 introduced multirange types, which are arrays of - non-overlapping ranges. Each range type has a corresponding multirange type. - - Multirange types: - - int4multirange: Multiple int4range values - - int8multirange: Multiple int8range values - - nummultirange: Multiple numrange values - - tsmultirange: Multiple tsrange values - - tstzmultirange: Multiple tstzrange values - - datemultirange: Multiple daterange values +__all__ = ["PostgresMultirangeSupport"] - Version requirement: PostgreSQL 14+ - """ +@runtime_checkable +class PostgresMultirangeSupport(Protocol): def supports_multirange(self) -> bool: - """Check if multirange types are supported. - - Returns: - True if PostgreSQL version >= 14.0 - """ ... def supports_multirange_constructor(self) -> bool: - """Check if multirange constructor function is supported. - - The multirange constructor function (e.g., int4multirange(range1, range2)) - was added in PostgreSQL 14. + ... - Returns: - True if PostgreSQL version >= 14.0 - """ + def supports_multirange_agg(self) -> bool: ... def format_create_multirange_type_statement( - self, name: str, range_type: str, schema: Optional[str] = None + self, + name: str, + range_type: str, + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format CREATE TYPE statement for a custom multirange type. - - Note: PostgreSQL automatically creates multirange types when you create - a range type. This method is for documentation and explicit creation - if needed. - - Args: - name: The multirange type name - range_type: The associated range type - schema: Optional schema name - - Returns: - Tuple of (SQL statement, parameters) - """ ... def format_multirange_agg_function( - self, range_column: str, table_name: str, where_clause: Optional[str] = None, schema: Optional[str] = None + self, + range_column: str, + table_name: str, + where_clause: Optional[str] = None, + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format multirange_agg aggregate function call. - - The multirange_agg function aggregates multiple ranges into a multirange. - - Args: - range_column: The range column to aggregate - table_name: Table name - where_clause: Optional WHERE clause - schema: Optional schema name - - Returns: - Tuple of (SQL statement, parameters) - """ ... - def format_create_multirange_type_statement_expression(self, expr) -> Tuple[str, tuple]: - """Format CREATE TYPE for multirange from expression object.""" + def format_create_multirange_type_statement_expression(self, expr: object) -> Tuple[str, tuple]: ... - def format_multirange_agg_function_expression(self, expr) -> Tuple[str, tuple]: - """Format multirange_agg from expression object.""" + def format_multirange_agg_function_expression(self, expr: object) -> Tuple[str, tuple]: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py b/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py index 843a1321..e27cc15f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py @@ -16,18 +16,23 @@ - Adding values: PostgreSQL 9.1+ """ -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, +) + from ..expression.ddl import ( - PostgresCreateEnumTypeExpression, - PostgresDropEnumTypeExpression, - PostgresAlterEnumTypeAddValueExpression, - PostgresAlterEnumTypeRenameValueExpression, + PostgresAddEnumValueAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRenameEnumValueAction, ) @@ -180,7 +185,7 @@ class EnumTypeManager: and deletion of PostgreSQL enum types. """ - def __init__(self, backend): + def __init__(self, backend: Any) -> None: """Initialize with a database backend. Args: @@ -205,11 +210,14 @@ def create_type(self, enum_type: PostgresEnumType, if_not_exists: bool = False) enum_type: PostgresEnumType instance if_not_exists: Use IF NOT EXISTS clause """ - expr = PostgresCreateEnumTypeExpression( + expr = CreateTypeExpression( dialect=self._backend.dialect, - name=enum_type.name, - values=enum_type.values, - schema=enum_type.schema, + type_name=enum_type.name, + definition=PostgresEnumTypeDefinition( + self._backend.dialect, + enum_type.values, + ), + schema_name=enum_type.schema, if_not_exists=if_not_exists, ) sql, params = expr.to_sql() @@ -224,10 +232,10 @@ def drop_type(self, enum_type: PostgresEnumType, if_exists: bool = False, cascad if_exists: Use IF EXISTS clause cascade: Use CASCADE clause """ - expr = PostgresDropEnumTypeExpression( + expr = PostgresDropTypeExpression( dialect=self._backend.dialect, - name=enum_type.name, - schema=enum_type.schema, + type_name=enum_type.name, + schema_name=enum_type.schema, if_exists=if_exists, cascade=cascade, ) @@ -253,14 +261,18 @@ def add_value( if new_value in enum_type.values: raise ValueError(f"Value '{new_value}' already exists in enum") - expr = PostgresAlterEnumTypeAddValueExpression( - dialect=self._backend.dialect, - type_name=enum_type.name, - new_value=new_value, - schema=enum_type.schema, + action = PostgresAddEnumValueAction( + self._backend.dialect, + new_value, before=before, after=after, ) + expr = AlterTypeExpression( + dialect=self._backend.dialect, + type_name=enum_type.name, + actions=[action], + schema_name=enum_type.schema, + ) sql, params = expr.to_sql() self._backend.execute(sql, params) enum_type._values.append(new_value) @@ -278,12 +290,16 @@ def rename_value(self, enum_type: PostgresEnumType, old_value: str, new_value: s if old_value not in enum_type.values: raise ValueError(f"Value '{old_value}' not found in enum") - expr = PostgresAlterEnumTypeRenameValueExpression( + action = PostgresRenameEnumValueAction( + self._backend.dialect, + old_value, + new_value, + ) + expr = AlterTypeExpression( dialect=self._backend.dialect, type_name=enum_type.name, - old_value=old_value, - new_value=new_value, - schema=enum_type.schema, + actions=[action], + schema_name=enum_type.schema, ) sql, params = expr.to_sql() self._backend.execute(sql, params) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py index ba63d63b..4d13f625 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py @@ -266,7 +266,7 @@ async def async_postgres_control_backend(): def postgres_dialect(): """Fixture providing PostgresDialect instance for testing transaction expressions.""" from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect - return PostgresDialect() + return PostgresDialect(version=(15, 0, 0)) # --- Extension Availability Utilities --- diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py index 05aa0421..285070aa 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py @@ -4,6 +4,9 @@ These tests build expressions with a bare ``PostgresDialect`` (no DB connection) and assert on the generated SQL, so they run without a server. """ +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.impl.postgres.expression.ilike import ( ILIKEExpression, @@ -74,13 +77,13 @@ class TestEnumTypeNameExpression: def test_name_without_schema(self, postgres_dialect): expr = EnumTypeNameExpression(postgres_dialect, name="status") sql, params = expr.to_sql() - assert sql == "status" + assert sql == '"status"' assert params == () def test_name_with_schema(self, postgres_dialect): expr = EnumTypeNameExpression(postgres_dialect, name="status", schema="app") sql, params = expr.to_sql() - assert sql == "app.status" + assert sql == '"app"."status"' assert params == () @@ -108,17 +111,19 @@ def test_basic_create(self, postgres_dialect): postgres_dialect, name="status", values=["active", "inactive"], ) sql, params = expr.to_sql() - assert sql == "CREATE TYPE status AS ENUM ('active', 'inactive')" + assert sql == "CREATE TYPE \"status\" AS ENUM ('active', 'inactive')" assert params == () - def test_create_with_schema_and_if_not_exists(self, postgres_dialect): + def test_create_if_not_exists_fails_fast(self, postgres_dialect): expr = CreateEnumTypeExpression( - postgres_dialect, name="priority", values=["low", "high"], - schema="app", if_not_exists=True, + postgres_dialect, + name="priority", + values=["low", "high"], + schema="app", + if_not_exists=True, ) - sql, params = expr.to_sql() - assert sql == "CREATE TYPE IF NOT EXISTS app.priority AS ENUM ('low', 'high')" - assert params == () + with pytest.raises(UnsupportedFeatureError, match="CREATE TYPE IF NOT EXISTS"): + expr.to_sql() class TestDropEnumTypeExpression: @@ -127,7 +132,7 @@ class TestDropEnumTypeExpression: def test_basic_drop(self, postgres_dialect): expr = DropEnumTypeExpression(postgres_dialect, name="status") sql, params = expr.to_sql() - assert sql == "DROP TYPE status" + assert sql == "DROP TYPE \"status\"" assert params == () def test_drop_with_if_exists_and_cascade(self, postgres_dialect): @@ -136,7 +141,7 @@ def test_drop_with_if_exists_and_cascade(self, postgres_dialect): if_exists=True, cascade=True, ) sql, params = expr.to_sql() - assert sql == "DROP TYPE IF EXISTS app.status CASCADE" + assert sql == "DROP TYPE IF EXISTS \"app\".\"status\" CASCADE" assert params == () @@ -148,7 +153,7 @@ def test_basic_add_value(self, postgres_dialect): postgres_dialect, type_name="status", new_value="archived", ) sql, params = expr.to_sql() - assert sql == "ALTER TYPE status ADD VALUE 'archived'" + assert sql == "ALTER TYPE \"status\" ADD VALUE 'archived'" assert params == () def test_add_value_with_before_after(self, postgres_dialect): @@ -157,28 +162,19 @@ def test_add_value_with_before_after(self, postgres_dialect): schema="app", before="active", ) sql, params = expr.to_sql() - assert sql == "ALTER TYPE app.status ADD VALUE 'pending' BEFORE 'active'" + assert sql == "ALTER TYPE \"app\".\"status\" ADD VALUE 'pending' BEFORE 'active'" assert params == () class TestCreateMultirangeTypeExpression: - """Tests for CreateMultirangeTypeExpression.""" - - def test_basic_create(self, postgres_dialect): - expr = CreateMultirangeTypeExpression( - postgres_dialect, name="my_multirange", range_type="my_range", - ) - sql, params = expr.to_sql() - assert sql == "CREATE TYPE my_multirange AS MULTIRANGE (my_range)" - assert params == () - - def test_create_with_schema(self, postgres_dialect): + def test_explicit_multirange_ddl_fails_fast(self, postgres_dialect): expr = CreateMultirangeTypeExpression( - postgres_dialect, name="mr", range_type="int4range", schema="app", + postgres_dialect, + name="my_multirange", + range_type="my_range", ) - sql, params = expr.to_sql() - assert sql == "CREATE TYPE app.mr AS MULTIRANGE (int4range)" - assert params == () + with pytest.raises(UnsupportedFeatureError, match="CREATE TYPE AS MULTIRANGE"): + expr.to_sql() class TestMultirangeAggFunctionExpression: @@ -189,7 +185,7 @@ def test_basic_agg(self, postgres_dialect): postgres_dialect, range_column="period", table_name="events", ) sql, params = expr.to_sql() - assert sql == "SELECT multirange_agg(period) FROM events" + assert sql == 'SELECT multirange_agg("period") FROM "events"' assert params == () def test_agg_with_where_and_schema(self, postgres_dialect): @@ -198,5 +194,5 @@ def test_agg_with_where_and_schema(self, postgres_dialect): where_clause="status = 'active'", schema="public", ) sql, params = expr.to_sql() - assert sql == "SELECT multirange_agg(period) FROM public.events WHERE status = 'active'" + assert sql == "SELECT multirange_agg(\"period\") FROM \"public\".\"events\" WHERE status = 'active'" assert params == () diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py index f544d63a..b7d113d2 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py @@ -346,9 +346,10 @@ def test_create_enum_with_schema(self, dialect): ) assert sql == 'CREATE TYPE "app"."color" AS ENUM (\'red\')' - def test_create_enum_empty_values_raises(self, dialect): - with pytest.raises(ValueError, match="at least one value"): - dialect.format_create_type_enum_statement("color", []) + def test_create_enum_empty_values(self, dialect): + sql, params = dialect.format_create_type_enum_statement("color", []) + assert sql == 'CREATE TYPE "color" AS ENUM ()' + assert params == () def test_drop_type(self, dialect): sql, _ = dialect.format_drop_type_statement("color") diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py index b8032b8e..948b028b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py @@ -11,11 +11,63 @@ from rhosocial.activerecord.backend.dialect.exceptions import ( UnsupportedFeatureError, ) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.serialization import ( + deserialize, + deserialize_json, + deserialize_xml, + serialize, + serialize_json, + serialize_xml, +) +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AlterDomainExpression, + CreateDomainExpression, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + DropDomainDefaultAction, + DropDomainNotNullAction, + RenameDomainAction, + SetDomainDefaultAction, + SetDomainNotNullAction, +) +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, +) +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PostgresVacuumExpression, # noqa: F401 PostgresAnalyzeExpression, # noqa: F401 AlterDomainActionType, + PostgresAddDomainCheckAction, + PostgresAddEnumValueAction, + PostgresAddTypeAttributeAction, + PostgresAlterEnumAddValueExpression, + PostgresCreateEnumTypeExpression, + PostgresDropEnumTypeExpression, + PostgresAlterTypeAttributeAction, + PostgresBaseTypeDefinition, + PostgresChangeDomainOwnerAction, + PostgresChangeTypeOwnerAction, + PostgresCompositeTypeAttribute, + PostgresCompositeTypeDefinition, PostgresCreateDomainExpression, + PostgresDropDomainCheckAction, + PostgresDropTypeAttributeAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresRenameDomainConstraintAction, + PostgresRenameEnumValueAction, + PostgresRenameTypeAction, + PostgresRenameTypeAttributeAction, + PostgresSetDomainSchemaAction, + PostgresSetTypePropertiesAction, + PostgresSetTypeSchemaAction, + PostgresShellTypeDefinition, + PostgresValidateDomainConstraintAction, PostgresAlterDomainExpression, PostgresDropDomainExpression, PostgresCreateCollationExpression, @@ -72,7 +124,7 @@ def test_create_with_schema_and_collation(self, dialect): dialect, "posint", "NUMERIC", schema="app", collation="C", ).to_sql() - assert sql == 'CREATE DOMAIN "app"."posint" AS NUMERIC COLLATE C' + assert sql == 'CREATE DOMAIN "app"."posint" AS NUMERIC COLLATE "C"' def test_create_without_default(self, dialect): sql, _ = PostgresCreateDomainExpression( @@ -123,6 +175,466 @@ def test_version_gate_96(self): PostgresDropDomainExpression(d, "posint").to_sql() + def test_typed_create_with_schema_and_all_clauses(self, dialect): + value = DomainValueExpression(dialect) + condition = value > Literal(dialect, 0, inline_literals=True) + check = DomainCheckConstraint(dialect, condition, name="positive") + sql, params = PostgresCreateDomainExpression( + dialect, + "amount", + IntegerType(dialect), + schema="app", + collation="public.catalog", + default=0, + checks=[check], + nullability=DomainNullability.NOT_NULL, + ).to_sql() + assert sql == ( + 'CREATE DOMAIN "app"."amount" AS INTEGER COLLATE "public"."catalog" ' + 'DEFAULT 0 CONSTRAINT "positive" CHECK (VALUE > 0) NOT NULL' + ) + assert params == () + + def test_create_domain_params_emit_one_constraint_key(self, dialect): + value = DomainValueExpression(dialect) + condition = value > Literal(dialect, 0, inline_literals=True) + legacy = PostgresCreateDomainExpression( + dialect, + "legacy", + IntegerType(dialect), + constraints=["CHECK (VALUE > 0)"], + ) + typed = PostgresCreateDomainExpression( + dialect, + "typed", + IntegerType(dialect), + checks=[condition], + ) + legacy_params = legacy.get_params() + typed_params = typed.get_params() + assert "constraints" in legacy_params + assert "checks" not in legacy_params + assert "checks" in typed_params + assert "constraints" not in typed_params + + def test_new_core_expression_rejects_raw_data_type(self, dialect): + with pytest.raises(TypeError, match="DataType"): + CreateDomainExpression(dialect, "raw_domain", "INTEGER") + + def test_all_domain_actions(self, dialect): + value = DomainValueExpression(dialect) + check = DomainCheckConstraint( + dialect, + value > Literal(dialect, 0, inline_literals=True), + name="positive", + ) + cases = [ + (SetDomainDefaultAction(dialect, 1), "SET DEFAULT 1"), + (DropDomainDefaultAction(dialect), "DROP DEFAULT"), + (SetDomainNotNullAction(dialect), "SET NOT NULL"), + (DropDomainNotNullAction(dialect), "DROP NOT NULL"), + ( + PostgresAddDomainCheckAction(dialect, check, not_valid=True), + 'ADD CONSTRAINT "positive" CHECK (VALUE > 0) NOT VALID', + ), + ( + PostgresDropDomainCheckAction( + dialect, + "positive", + if_exists=True, + cascade=True, + ), + 'DROP CONSTRAINT IF EXISTS "positive" CASCADE', + ), + ( + PostgresRenameDomainConstraintAction( + dialect, + "positive", + "nonnegative", + ), + 'RENAME CONSTRAINT "positive" TO "nonnegative"', + ), + ( + PostgresValidateDomainConstraintAction(dialect, "nonnegative"), + 'VALIDATE CONSTRAINT "nonnegative"', + ), + (PostgresChangeDomainOwnerAction(dialect, "app_owner"), 'OWNER TO "app_owner"'), + (RenameDomainAction(dialect, "amount_v2"), 'RENAME TO "amount_v2"'), + (PostgresSetDomainSchemaAction(dialect, "archive"), 'SET SCHEMA "archive"'), + ] + for action, expected in cases: + sql, params = PostgresAlterDomainExpression( + dialect, + "amount", + action, + schema="app", + ).to_sql() + assert sql == f'ALTER DOMAIN "app"."amount" {expected}' + assert params == () + + def test_domain_actions_are_not_combinable(self, dialect): + expression = AlterDomainExpression( + dialect, + "amount", + [DropDomainDefaultAction(dialect), SetDomainNotNullAction(dialect)], + ) + with pytest.raises(UnsupportedFeatureError, match="multiple ALTER DOMAIN actions"): + expression.to_sql() + + def test_drop_domain_flags(self, dialect): + sql, _ = PostgresDropDomainExpression( + dialect, + "amount", + schema="app", + if_exists=True, + restrict=True, + ).to_sql() + assert sql == 'DROP DOMAIN IF EXISTS "app"."amount" RESTRICT' + expression = PostgresDropDomainExpression( + dialect, + "amount", + cascade=True, + restrict=True, + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expression.to_sql() + + +class TestPostgresTypeDDLExpressions: + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_composite_definition_round_trip(self, dialect): + definition = PostgresCompositeTypeDefinition( + dialect, + [PostgresCompositeTypeAttribute("id", IntegerType(dialect))], + ) + restored = ( + deserialize(serialize(definition), dialect), + deserialize_json(serialize_json(definition), dialect), + deserialize_xml(serialize_xml(definition), dialect), + ) + assert all(item.get_params() == definition.get_params() for item in restored) + + def test_base_unset_round_trip(self, dialect): + definition = PostgresBaseTypeDefinition( + dialect, + input_function="box_in", + output_function="box_out", + ) + restored = ( + deserialize(serialize(definition), dialect), + deserialize_json(serialize_json(definition), dialect), + deserialize_xml(serialize_xml(definition), dialect), + ) + assert all(item.get_params() == definition.get_params() for item in restored) + + def test_compatibility_wrapper_params(self, dialect): + create = PostgresCreateEnumTypeExpression( + dialect, + "status", + ["active"], + if_not_exists=True, + ) + alter = PostgresAlterEnumAddValueExpression( + dialect, + "status", + "pending", + if_not_exists=True, + ) + drop = PostgresDropEnumTypeExpression( + dialect, + "status", + restrict=True, + ) + assert create.get_params()["if_not_exists"] is True + assert alter.get_params()["if_not_exists"] is True + assert drop.get_params()["restrict"] is True + + def test_composite_definition(self, dialect): + definition = PostgresCompositeTypeDefinition( + dialect, + [ + PostgresCompositeTypeAttribute("id", IntegerType(dialect)), + PostgresCompositeTypeAttribute( + "label", + TextType(dialect), + collation="public.catalog", + ), + ], + ) + sql, params = CreateTypeExpression( + dialect, + "app.address", + definition, + ).to_sql() + assert sql == ( + 'CREATE TYPE "app"."address" AS ("id" INTEGER, "label" TEXT ' + 'COLLATE "public"."catalog")' + ) + assert params == () + + def test_enum_definition_escapes_labels(self, dialect): + definition = PostgresEnumTypeDefinition( + dialect, + ["ready", "O'Reilly", ""], + ) + sql, params = CreateTypeExpression(dialect, "status", definition).to_sql() + assert sql == "CREATE TYPE \"status\" AS ENUM ('ready', 'O''Reilly', '')" + assert params == () + + def test_range_definition_with_multirange_name(self, dialect): + definition = PostgresRangeTypeDefinition( + dialect, + IntegerType(dialect), + subtype_operator_class="public.int_ops", + canonical_function="public.canonical", + multirange_type_name="app.span_multirange", + ) + sql, params = CreateTypeExpression(dialect, "app.span", definition).to_sql() + assert sql == ( + 'CREATE TYPE "app"."span" AS RANGE (SUBTYPE = INTEGER, ' + 'SUBTYPE_OPCLASS = "public"."int_ops", CANONICAL = "public"."canonical", ' + 'MULTIRANGE_TYPE_NAME = "app"."span_multirange")' + ) + assert params == () + + def test_range_multirange_name_version_gate(self): + dialect = PostgresDialect(version=(13, 0, 0)) + definition = PostgresRangeTypeDefinition( + dialect, + IntegerType(dialect), + multirange_type_name="span_multirange", + ) + with pytest.raises( + UnsupportedFeatureError, + match="CREATE TYPE MULTIRANGE_TYPE_NAME", + ): + CreateTypeExpression(dialect, "span", definition).to_sql() + + def test_base_definition(self, dialect): + definition = PostgresBaseTypeDefinition( + dialect, + input_function="public.box_in", + output_function="public.box_out", + internallength=16, + passedbyvalue=True, + storage="plain", + default=0, + element=IntegerType(dialect), + ) + sql, params = CreateTypeExpression(dialect, "box", definition).to_sql() + assert sql == ( + 'CREATE TYPE "box" (INPUT = "public"."box_in", ' + 'OUTPUT = "public"."box_out", INTERNALLENGTH = 16, PASSEDBYVALUE, ' + 'STORAGE = "plain", DEFAULT = 0, ELEMENT = INTEGER)' + ) + assert params == () + + def test_base_like_does_not_bypass_io_functions(self, dialect): + with pytest.raises(ValueError, match="input_function and output_function"): + PostgresBaseTypeDefinition( + dialect, + like_type=IntegerType(dialect), + ) + + def test_shell_definition(self, dialect): + sql, params = CreateTypeExpression( + dialect, + "public.pending_box", + PostgresShellTypeDefinition(dialect), + ).to_sql() + assert sql == 'CREATE TYPE "public"."pending_box"' + assert params == () + + def test_create_type_unsupported_clauses_fail_fast(self, dialect): + enum = PostgresEnumTypeDefinition(dialect, ["active"]) + with pytest.raises(UnsupportedFeatureError, match="CREATE TYPE IF NOT EXISTS"): + CreateTypeExpression( + dialect, + "status", + enum, + if_not_exists=True, + ).to_sql() + with pytest.raises(UnsupportedFeatureError, match="CREATE OR REPLACE TYPE"): + CreateTypeExpression( + dialect, + "status", + enum, + or_replace=True, + ).to_sql() + + def test_drop_type_flags(self, dialect): + sql, params = PostgresDropTypeExpression( + dialect, + "status", + schema_name="app", + if_exists=True, + cascade=True, + ).to_sql() + assert sql == 'DROP TYPE IF EXISTS "app"."status" CASCADE' + assert params == () + expression = PostgresDropTypeExpression( + dialect, + "status", + cascade=True, + restrict=True, + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expression.to_sql() + + def test_legacy_drop_type_name_keyword(self, dialect): + sql, params = dialect.format_drop_type_statement( + name="status", + schema="app", + if_exists=True, + ) + assert sql == 'DROP TYPE IF EXISTS "app"."status"' + assert params == () + + def test_all_type_actions(self, dialect): + cases = [ + (PostgresRenameTypeAction(dialect, "state"), 'RENAME TO "state"'), + (PostgresSetTypeSchemaAction(dialect, "archive"), 'SET SCHEMA "archive"'), + (PostgresChangeTypeOwnerAction(dialect, "type_owner"), 'OWNER TO "type_owner"'), + ( + PostgresRenameTypeAttributeAction( + dialect, + "label", + "name", + restrict=True, + ), + 'RENAME ATTRIBUTE "label" TO "name" RESTRICT', + ), + ( + PostgresAddTypeAttributeAction( + dialect, + "active", + IntegerType(dialect), + collation="public.catalog", + ), + 'ADD ATTRIBUTE "active" INTEGER COLLATE "public"."catalog"', + ), + ( + PostgresDropTypeAttributeAction( + dialect, + "active", + if_exists=True, + cascade=True, + ), + 'DROP ATTRIBUTE IF EXISTS "active" CASCADE', + ), + ( + PostgresAlterTypeAttributeAction( + dialect, + "active", + IntegerType(dialect), + set_data=True, + ), + 'ALTER ATTRIBUTE "active" SET DATA TYPE INTEGER', + ), + ( + PostgresAddEnumValueAction( + dialect, + "O'Reilly", + if_not_exists=True, + after="ready", + ), + "ADD VALUE IF NOT EXISTS 'O''Reilly' AFTER 'ready'", + ), + ( + PostgresRenameEnumValueAction(dialect, "old", "new"), + "RENAME VALUE 'old' TO 'new'", + ), + ( + PostgresSetTypePropertiesAction( + dialect, + {"SEND": "public.box_send", "STORAGE": "plain"}, + ), + 'SET (SEND = "public"."box_send", STORAGE = "plain")', + ), + ] + for action, expected in cases: + sql, params = AlterTypeExpression( + dialect, + "app.status", + [action], + ).to_sql() + assert sql == f'ALTER TYPE "app"."status" {expected}' + assert params == () + + def test_composite_actions_can_be_combined(self, dialect): + actions = [ + PostgresAddTypeAttributeAction(dialect, "active", IntegerType(dialect)), + PostgresDropTypeAttributeAction(dialect, "legacy"), + ] + sql, params = AlterTypeExpression(dialect, "app.address", actions).to_sql() + assert sql == ( + 'ALTER TYPE "app"."address" ADD ATTRIBUTE "active" INTEGER, ' + 'DROP ATTRIBUTE "legacy"' + ) + assert params == () + + def test_mixed_type_actions_cannot_be_combined(self, dialect): + actions = [ + PostgresAddTypeAttributeAction(dialect, "active", IntegerType(dialect)), + PostgresRenameTypeAction(dialect, "new_address"), + ] + with pytest.raises(UnsupportedFeatureError, match="multiple ALTER TYPE actions"): + AlterTypeExpression(dialect, "app.address", actions).to_sql() + + def test_type_version_boundaries(self): + enum = PostgresEnumTypeDefinition(PostgresDialect(version=(9, 6, 0)), ["a"]) + rename = PostgresRenameEnumValueAction( + PostgresDialect(version=(9, 6, 0)), + "a", + "b", + ) + with pytest.raises(UnsupportedFeatureError, match="RENAME VALUE"): + AlterTypeExpression( + PostgresDialect(version=(9, 6, 0)), + "status", + [rename], + ).to_sql() + sql, _ = AlterTypeExpression( + PostgresDialect(version=(10, 0, 0)), + "status", + [PostgresRenameEnumValueAction( + PostgresDialect(version=(10, 0, 0)), + "a", + "b", + )], + ).to_sql() + assert sql == 'ALTER TYPE "status" RENAME VALUE \'a\' TO \'b\'' + with pytest.raises(UnsupportedFeatureError, match="SET properties"): + AlterTypeExpression( + PostgresDialect(version=(12, 0, 0)), + "box", + [PostgresSetTypePropertiesAction( + PostgresDialect(version=(12, 0, 0)), + {"STORAGE": "plain"}, + )], + ).to_sql() + assert enum.labels == ["a"] + + def test_base_subscript_version_boundary(self): + for version in ((13, 0, 0), (14, 0, 0)): + dialect = PostgresDialect(version=version) + definition = PostgresBaseTypeDefinition( + dialect, + input_function="box_in", + output_function="box_out", + subscript_function="box_subscript", + ) + if version < (14, 0, 0): + with pytest.raises(UnsupportedFeatureError, match="SUBSCRIPT"): + CreateTypeExpression(dialect, "box", definition).to_sql() + else: + sql, _ = CreateTypeExpression(dialect, "box", definition).to_sql() + assert 'SUBSCRIPT = "box_subscript"' in sql + + class TestPostgresCollationDDLExpression: """CREATE / DROP COLLATION DDL.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py index d0e75ccc..0891fd5a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py @@ -10,8 +10,30 @@ """ import pytest # noqa: F401 +from rhosocial.activerecord.backend.dialect import DomainSupport, UserDefinedTypeSupport +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins import DomainMixin, UserDefinedTypeMixin +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + DomainCheckConstraint, + DomainValueExpression, +) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAddDomainCheckAction, + PostgresCompositeTypeDefinition, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresBaseTypeDefinition, + PostgresRenameEnumValueAction, + PostgresSetTypePropertiesAction, + PostgresShellTypeDefinition, +) +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.domain import PostgresDomainMixin from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.partition import PostgresPartitionMixin +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.type import PostgresTypeMixin +from rhosocial.activerecord.backend.impl.postgres.protocols.ddl.domain import PostgresDomainSupport +from rhosocial.activerecord.backend.impl.postgres.protocols.ddl.type import PostgresTypeSupport class TestPostgresPartitionSupportFeatureDetection: @@ -632,6 +654,90 @@ def test_baseline_19(self): assert d.supports_collation_ddl() is True assert d.supports_publication() is True + +class TestPostgresTypeDomainProtocols: + def test_protocol_derivation_and_runtime_support(self): + assert issubclass(PostgresTypeSupport, UserDefinedTypeSupport) + assert issubclass(PostgresDomainSupport, DomainSupport) + dialect = PostgresDialect(version=(14, 0, 0)) + assert isinstance(dialect, PostgresTypeSupport) + assert isinstance(dialect, UserDefinedTypeSupport) + assert isinstance(dialect, PostgresDomainSupport) + assert isinstance(dialect, DomainSupport) + + def test_postgres_mixins_and_protocols_precede_core_mro(self): + mro = PostgresDialect.__mro__ + assert mro.index(PostgresTypeMixin) < mro.index(UserDefinedTypeMixin) + assert mro.index(PostgresDomainMixin) < mro.index(DomainMixin) + assert mro.index(PostgresTypeSupport) < mro.index(UserDefinedTypeSupport) + assert mro.index(PostgresDomainSupport) < mro.index(DomainSupport) + + def test_type_definitions_are_explicitly_registered(self): + dialect = PostgresDialect(version=(14, 0, 0)) + definitions = ( + PostgresCompositeTypeDefinition, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresBaseTypeDefinition, + PostgresShellTypeDefinition, + ) + assert dialect.supported_type_definitions() == definitions + assert all(dialect.supports_type_definition(item) for item in definitions) + + def test_type_capability_version_gates(self): + old = PostgresDialect(version=(9, 5, 0)) + baseline = PostgresDialect(version=(9, 6, 0)) + assert old.supports_type_objects() is False + assert baseline.supports_type_objects() is True + assert baseline.supports_create_type_if_not_exists() is False + assert baseline.supports_create_type_or_replace() is False + assert baseline.supports_alter_type_if_exists() is False + assert baseline.supports_drop_type_if_exists() is True + assert baseline.supports_drop_type_cascade() is True + assert baseline.supports_drop_type_restrict() is True + + def test_multirange_data_type_gates(self): + pg13 = PostgresDialect(version=(13, 0, 0)) + pg14 = PostgresDialect(version=(14, 0, 0)) + assert pg13.supports_multirange_type() is False + assert pg13.supports_multirange() is False + assert pg13.supports_multirange_constructor() is False + assert pg13.supports_data_type_postgres_int4multirange() is False + with pytest.raises(UnsupportedFeatureError, match="INT4MULTIRANGE"): + pg13.format_data_type_postgres_int4multirange(None) + assert pg14.supports_multirange_type() is True + assert pg14.supports_multirange() is True + assert pg14.supports_multirange_constructor() is True + assert pg14.supports_data_type_postgres_int4multirange() is True + + def test_type_action_version_gates(self): + pg96 = PostgresDialect(version=(9, 6, 0)) + pg100 = PostgresDialect(version=(10, 0, 0)) + pg12 = PostgresDialect(version=(12, 0, 0)) + pg13 = PostgresDialect(version=(13, 0, 0)) + assert pg96.supports_type_alter_action(PostgresRenameEnumValueAction) is False + assert pg100.supports_type_alter_action(PostgresRenameEnumValueAction) is True + assert pg12.supports_type_alter_action(PostgresSetTypePropertiesAction) is False + assert pg13.supports_type_alter_action(PostgresSetTypePropertiesAction) is True + + def test_domain_capability_gates_and_actions(self): + old = PostgresDialect(version=(9, 5, 0)) + baseline = PostgresDialect(version=(9, 6, 0)) + assert old.supports_domains() is False + assert baseline.supports_create_domain() is True + assert baseline.supports_alter_domain() is True + assert baseline.supports_drop_domain() is True + assert baseline.supports_multiple_domain_alter_actions() is False + assert baseline.supports_drop_domain_if_exists() is True + assert baseline.supports_drop_domain_cascade() is True + assert baseline.supports_drop_domain_restrict() is True + check = DomainCheckConstraint( + baseline, + DomainValueExpression(baseline) > Literal(baseline, 0, inline_literals=True), + ) + assert baseline.supports_alter_domain_action(PostgresAddDomainCheckAction) is True + assert isinstance(check, DomainCheckConstraint) + def test_unsupported_94_rejects_unlogged(self): d = PostgresDialect(version=(9, 4, 0)) assert d.supports_unlogged_table() is False \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py index 413024f0..34acd9a0 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py @@ -10,6 +10,7 @@ from enum import Enum from typing import Tuple # noqa: F401 +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.postgres import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.types import PostgresEnumType from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresEnumAdapter @@ -38,7 +39,7 @@ class TestPostgresEnumType: def setup_method(self): """Set up test fixtures.""" - self.dialect = PostgresDialect() + self.dialect = PostgresDialect(version=(14, 0, 0)) def test_create_enum_type(self): """Test creating an enum type.""" @@ -86,7 +87,7 @@ def test_to_sql(self): result = enum_type.to_sql() assert isinstance(result, tuple) assert len(result) == 2 - assert result[0] == 'status' + assert result[0] == '"status"' assert result[1] == () def test_to_sql_with_schema(self): @@ -98,7 +99,7 @@ def test_to_sql_with_schema(self): schema='app' ) result = enum_type.to_sql() - assert result == ('app.status', ()) + assert result == ('"app"."status"', ()) def test_create_type_sql(self): """Test CREATE TYPE SQL generation using PostgresCreateEnumTypeExpression.""" @@ -110,20 +111,18 @@ def test_create_type_sql(self): result = expr.to_sql() assert isinstance(result, tuple) sql, params = result - assert sql == "CREATE TYPE status AS ENUM ('pending', 'ready')" + assert sql == "CREATE TYPE \"status\" AS ENUM ('pending', 'ready')" assert params == () - def test_create_type_sql_if_not_exists(self): - """Test CREATE TYPE IF NOT EXISTS SQL generation.""" + def test_create_type_sql_if_not_exists_fails_fast(self): expr = PostgresCreateEnumTypeExpression( dialect=self.dialect, name='status', values=['a', 'b'], - if_not_exists=True + if_not_exists=True, ) - sql, params = expr.to_sql() - assert 'IF NOT EXISTS' in sql - assert params == () + with pytest.raises(UnsupportedFeatureError, match="CREATE TYPE IF NOT EXISTS"): + expr.to_sql() def test_drop_type_sql(self): """Test DROP TYPE SQL generation using PostgresDropEnumTypeExpression.""" @@ -132,7 +131,7 @@ def test_drop_type_sql(self): name='status' ) sql, params = expr.to_sql() - assert sql == "DROP TYPE status" + assert sql == "DROP TYPE \"status\"" assert params == () def test_drop_type_sql_if_exists(self): @@ -143,7 +142,7 @@ def test_drop_type_sql_if_exists(self): if_exists=True ) sql, params = expr.to_sql() - assert sql == "DROP TYPE IF EXISTS status" + assert sql == "DROP TYPE IF EXISTS \"status\"" assert params == () def test_drop_type_sql_cascade(self): @@ -165,7 +164,7 @@ def test_add_value_sql(self): new_value='failed' ) sql, params = expr.to_sql() - assert sql == "ALTER TYPE status ADD VALUE 'failed'" + assert sql == "ALTER TYPE \"status\" ADD VALUE 'failed'" assert params == () def test_add_value_sql_before(self): @@ -212,7 +211,7 @@ def test_rename_value_sql(self): new_value='draft' ) sql, params = expr.to_sql() - assert sql == "ALTER TYPE status RENAME VALUE 'pending' TO 'draft'" + assert sql == "ALTER TYPE \"status\" RENAME VALUE 'pending' TO 'draft'" assert params == () def test_rename_value_empty_raises_error(self): @@ -307,7 +306,7 @@ def test_to_database_invalid_type(self): def test_to_database_with_validation(self): """Test converting with enum type validation.""" adapter = PostgresEnumAdapter() - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) enum_type = PostgresEnumType( dialect=dialect, name='status', @@ -319,7 +318,7 @@ def test_to_database_with_validation(self): def test_to_database_validation_fails(self): """Test validation fails for invalid value.""" adapter = PostgresEnumAdapter() - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) enum_type = PostgresEnumType( dialect=dialect, name='status', @@ -376,62 +375,65 @@ class TestPostgresEnumTypeSupport: def test_dialect_implements_postgres_enum_type_support(self): """Test that PostgresDialect implements PostgresEnumTypeSupport.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) # Check via MRO from rhosocial.activerecord.backend.impl.postgres.protocols import PostgresEnumTypeSupport # noqa: F811 assert PostgresEnumTypeSupport in dialect.__class__.__mro__ def test_create_enum_type_method(self): """Test create_enum_type method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.create_enum_type('status', ['pending', 'ready']) - assert sql == "CREATE TYPE status AS ENUM ('pending', 'ready')" + assert sql == "CREATE TYPE \"status\" AS ENUM ('pending', 'ready')" def test_create_enum_type_with_schema(self): """Test create_enum_type with schema.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.create_enum_type('status', ['pending', 'ready'], schema='app') - assert sql == "CREATE TYPE app.status AS ENUM ('pending', 'ready')" - - def test_create_enum_type_if_not_exists(self): - """Test create_enum_type with IF NOT EXISTS.""" - dialect = PostgresDialect() - sql = dialect.create_enum_type('status', ['pending', 'ready'], if_not_exists=True) - assert 'IF NOT EXISTS' in sql + assert sql == "CREATE TYPE \"app\".\"status\" AS ENUM ('pending', 'ready')" + + def test_create_enum_type_if_not_exists_fails_fast(self): + dialect = PostgresDialect(version=(14, 0, 0)) + with pytest.raises(UnsupportedFeatureError, match="CREATE TYPE IF NOT EXISTS"): + dialect.create_enum_type( + 'status', + ['pending', 'ready'], + if_not_exists=True, + ) def test_drop_enum_type_method(self): """Test drop_enum_type method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.drop_enum_type('status') - assert sql == "DROP TYPE status" + assert sql == "DROP TYPE \"status\"" def test_drop_enum_type_if_exists(self): """Test drop_enum_type with IF EXISTS.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.drop_enum_type('status', if_exists=True) - assert sql == "DROP TYPE IF EXISTS status" + assert sql == "DROP TYPE IF EXISTS \"status\"" def test_drop_enum_type_cascade(self): """Test drop_enum_type with CASCADE.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.drop_enum_type('status', cascade=True) assert 'CASCADE' in sql def test_alter_enum_add_value_method(self): """Test alter_enum_add_value method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.alter_enum_add_value('status', 'failed') - assert sql == "ALTER TYPE status ADD VALUE 'failed'" + assert sql == "ALTER TYPE \"status\" ADD VALUE 'failed'" def test_alter_enum_add_value_before(self): """Test alter_enum_add_value with BEFORE.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.alter_enum_add_value('status', 'processing', before='ready') assert "BEFORE 'ready'" in sql def test_alter_enum_add_value_after(self): """Test alter_enum_add_value with AFTER.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.alter_enum_add_value('status', 'processing', after='pending') assert "AFTER 'pending'" in sql @@ -441,19 +443,19 @@ class TestEnumTypeMixin: def test_format_enum_type_name(self): """Test format_enum_type_name via EnumTypeNameExpression.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql, params = EnumTypeNameExpression(dialect, name='status').to_sql() - assert sql == 'status' + assert sql == '"status"' assert params == () sql, params = EnumTypeNameExpression( dialect, name='status', schema='app' ).to_sql() - assert sql == 'app.status' + assert sql == '"app"."status"' assert params == () def test_format_enum_values(self): """Test format_enum_values via EnumValuesExpression.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql, params = EnumValuesExpression( dialect, values=['a', 'b', 'c'] ).to_sql() @@ -462,28 +464,28 @@ def test_format_enum_values(self): def test_format_create_enum_type(self): """Test format_create_enum_type method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) expr = PostgresCreateEnumTypeExpression( dialect=dialect, name='status', values=['pending', 'ready'] ) sql, params = expr.to_sql() - assert sql == "CREATE TYPE status AS ENUM ('pending', 'ready')" + assert sql == "CREATE TYPE \"status\" AS ENUM ('pending', 'ready')" assert params == () def test_format_drop_enum_type(self): """Test format_drop_enum_type method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) expr = PostgresDropEnumTypeExpression(dialect=dialect, name='status') sql, params = expr.to_sql() - assert sql == "DROP TYPE status" + assert sql == "DROP TYPE \"status\"" assert params == () def test_format_alter_enum_add_value(self): """Test format_alter_enum_add_value method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) expr = PostgresAlterEnumAddValueExpression( dialect=dialect, type_name='status', new_value='failed' ) sql, params = expr.to_sql() - assert sql == "ALTER TYPE status ADD VALUE 'failed'" + assert sql == "ALTER TYPE \"status\" ADD VALUE 'failed'" assert params == () 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 79e30f5e..16062023 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 @@ -119,6 +119,8 @@ def get_postgres_protocols(): # Generic protocols Postgres also satisfies (previously omitted). dialect_protocols.AlterTableModifierSupport, dialect_protocols.DDLTypeSupport, + dialect_protocols.UserDefinedTypeSupport, + dialect_protocols.DomainSupport, dialect_protocols.GraphTableSupport, dialect_protocols.ILIKESupport, dialect_protocols.TriggerSupport, @@ -203,6 +205,8 @@ def test_does_not_implement_protocol(self, dialect, protocol): def test_positive_and_negative_lists_partition_all_protocols(self): """Every generic protocol must be classified for Postgres.""" all_protos = set(get_all_generic_protocols()) + if dialect_protocols.DDLTypeSupport is dialect_protocols.DataTypeSupport: + all_protos.discard("DDLTypeSupport") positive = {p.__name__ for p in POSTGRES_PROTOCOLS if p.__module__ == dialect_protocols.__name__} negative = {p.__name__ for p in POSTGRES_NOT_IMPLEMENTED} @@ -243,6 +247,10 @@ def test_no_interface_overlap_between_protocols(self): ('PostgresLockingSupport', 'LockingSupport'), ('TriggerSupport', 'PostgresTriggerSupport'), ('PostgresTriggerSupport', 'TriggerSupport'), + ('UserDefinedTypeSupport', 'PostgresTypeSupport'), + ('PostgresTypeSupport', 'UserDefinedTypeSupport'), + ('DomainSupport', 'PostgresDomainSupport'), + ('PostgresDomainSupport', 'DomainSupport'), # SQLXMLSupport aggregates standard SQL/XML capability-family protocols ('SQLXMLSupport', 'SQLXMLParsingSupport'), ('SQLXMLParsingSupport', 'SQLXMLSupport'), @@ -365,6 +373,8 @@ class TestPostgresProtocolDerivation: ("PostgresLockingSupport", "LockingSupport"), ("PostgresTriggerSupport", "TriggerSupport"), ("PostgresConstraintSupport", "ConstraintSupport"), + ("PostgresTypeSupport", "UserDefinedTypeSupport"), + ("PostgresDomainSupport", "DomainSupport"), ] @pytest.mark.parametrize("pg_name,generic_name", PROTOCOL_DERIVATIONS) @@ -399,12 +409,12 @@ class TestPostgresExpressionDialectSeparation: EXPRESSION_DIALECT_PAIRS = [ ("PostgresRefreshMaterializedViewExpression", "format_refresh_materialized_view_pg_statement"), - ("PostgresCreateEnumTypeExpression", "format_create_enum_type"), - ("PostgresDropEnumTypeExpression", "format_drop_enum_type"), - ("PostgresAlterEnumAddValueExpression", "format_alter_enum_add_value"), - ("PostgresAlterEnumTypeAddValueExpression", "format_alter_enum_type_add_value"), - ("PostgresAlterEnumTypeRenameValueExpression", "format_alter_enum_type_rename_value"), - ("PostgresCreateRangeTypeExpression", "format_create_range_type"), + ("PostgresCreateEnumTypeExpression", "format_create_type_statement"), + ("PostgresDropEnumTypeExpression", "format_drop_type_statement"), + ("PostgresAlterEnumAddValueExpression", "format_alter_type_statement"), + ("PostgresAlterEnumTypeAddValueExpression", "format_alter_type_statement"), + ("PostgresAlterEnumTypeRenameValueExpression", "format_alter_type_statement"), + ("PostgresCreateRangeTypeExpression", "format_create_type_statement"), ("PostgresVacuumExpression", "format_vacuum_statement"), ("PostgresAnalyzeExpression", "format_analyze_statement"), ("PostgresReindexExpression", "format_reindex_statement"), @@ -467,6 +477,7 @@ def test_expression_delegates_to_dialect(self, expr_name, format_method): (postgres_protocols.PostgresTriggerSupport, postgres_mixins.PostgresTriggerMixin), (postgres_protocols.PostgresCommentSupport, postgres_mixins.PostgresCommentMixin), (postgres_protocols.PostgresTypeSupport, postgres_mixins.PostgresTypeMixin), + (postgres_protocols.PostgresDomainSupport, postgres_mixins.PostgresDomainMixin), (postgres_protocols.PostgresConstraintSupport, postgres_mixins.PostgresConstraintMixin), # DML protocols (postgres_protocols.PostgresVacuumSupport, postgres_mixins.PostgresVacuumMixin), From 8902cb0c5ccbc2b08568a0efda5feeef21223378 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 10:23:02 +0800 Subject: [PATCH 26/42] test(ddl): cover table inheritance and tablespace --- .../feature/backend/postgres/test_table.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py index dcced9f6..ee0249df 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py @@ -1,5 +1,32 @@ +from unittest.mock import patch + +import pytest + +from rhosocial.activerecord.base.ddl import TableDDLDeriver +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.mixins.ddl_table import PostgresTableMixin +from rhosocial.activerecord.model import ActiveRecord + + +class Inheriting(ActiveRecord): + __table_name__ = "child" + + id: int + + @classmethod + def table_inherits(cls): + return ["parent_a", "parent_b"] + + +class Tablespaced(ActiveRecord): + __table_name__ = "spaced" + + id: int + + @classmethod + def table_tablespace(cls): + return "ts_data" class TestTableSupport: @@ -56,3 +83,41 @@ def test_supports_table_inheritance_direct(self): def test_supports_table_tablespace_direct(self): assert self._TableMixin().supports_table_tablespace() is True + + +class TestPostgresTableDDLDeclarations: + def test_table_declaration_defaults_are_absent(self): + class Plain(ActiveRecord): + __table_name__ = "plain_table_defaults" + + id: int + + expression = TableDDLDeriver(Plain, PostgresDialect()).create_table() + assert expression.inherits == [] + assert expression.tablespace is None + + def test_table_inherits_is_carried_and_rendered(self): + dialect = PostgresDialect(version=(16, 0, 0)) + expression = TableDDLDeriver(Inheriting, dialect).create_table() + assert expression.inherits == ["parent_a", "parent_b"] + sql, _ = expression.to_sql() + assert 'INHERITS ("parent_a", "parent_b")' in sql + + def test_table_tablespace_is_carried_and_rendered(self): + dialect = PostgresDialect(version=(16, 0, 0)) + expression = TableDDLDeriver(Tablespaced, dialect).create_table() + assert expression.tablespace == "ts_data" + sql, _ = expression.to_sql() + assert 'TABLESPACE "ts_data"' in sql + + def test_table_inherits_fails_fast_when_capability_disabled(self): + dialect = PostgresDialect(version=(16, 0, 0)) + with patch.object(dialect, "supports_table_inheritance", return_value=False): + with pytest.raises(UnsupportedFeatureError, match="INHERITS"): + TableDDLDeriver(Inheriting, dialect).create_table().to_sql() + + def test_table_tablespace_fails_fast_when_capability_disabled(self): + dialect = PostgresDialect(version=(16, 0, 0)) + with patch.object(dialect, "supports_table_tablespace", return_value=False): + with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): + TableDDLDeriver(Tablespaced, dialect).create_table().to_sql() From ae04f1902f63763d9b00a29c6173f62147b0227a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 11:17:10 +0800 Subject: [PATCH 27/42] fix(postgres): restore dialect base initialization --- src/rhosocial/activerecord/backend/impl/postgres/dialect.py | 2 +- .../feature/backend/postgres/test_ddl_protocols.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index e560dd9f..777a7296 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -294,6 +294,7 @@ class PostgresDialect( + SQLDialectBase, PostgresTypeMixin, PostgresDomainMixin, PostgresTypeSupport, @@ -302,7 +303,6 @@ class PostgresDialect( DomainMixin, UserDefinedTypeSupport, DomainSupport, - SQLDialectBase, # PG-specific mixins (before global mixins to override) PostgresDateTimeMixin, PostgresDQLMixin, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py index 0891fd5a..179865cd 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py @@ -672,6 +672,10 @@ def test_postgres_mixins_and_protocols_precede_core_mro(self): assert mro.index(PostgresTypeSupport) < mro.index(UserDefinedTypeSupport) assert mro.index(PostgresDomainSupport) < mro.index(DomainSupport) + def test_dialect_initializes_core_validation_state(self) -> None: + dialect = PostgresDialect(version=(14, 0, 0)) + assert dialect.strict_validation is True + def test_type_definitions_are_explicitly_registered(self): dialect = PostgresDialect(version=(14, 0, 0)) definitions = ( From 47111bd9b5bc31cf32db3ab989a86c869dc7fda0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 11:47:40 +0800 Subject: [PATCH 28/42] fix(postgres): align derived DDL contracts --- .../activerecord/backend/impl/postgres/mixins/ddl_table.py | 4 ++++ .../feature/backend/postgres/test_ddl_objects.py | 6 +++--- .../feature/backend/postgres/test_table.py | 2 +- .../feature/backend/types/test_postgres_type_protocol.py | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py index 430ad7fb..2950ba46 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py @@ -36,6 +36,10 @@ def supports_table_tablespace(self) -> bool: """TABLESPACE specification is supported in all versions.""" return True + def supports_alter_column_properties(self) -> bool: + """PostgreSQL supports ALTER COLUMN property changes.""" + return True + def supports_create_table_like(self) -> bool: """PostgreSQL supports CREATE TABLE (LIKE ...) with INCLUDING/EXCLUDING options.""" return True diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py index 948b028b..fa735898 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py @@ -99,7 +99,7 @@ def test_create(self, dialect): dialect, "posint", "NUMERIC", default="0", constraints=["CHECK (VALUE > 0)"], ).to_sql() - assert sql == "CREATE DOMAIN \"posint\" AS NUMERIC DEFAULT 0 CHECK (VALUE > 0)" + assert sql == "CREATE DOMAIN \"posint\" AS DECIMAL DEFAULT 0 CHECK (VALUE > 0)" def test_alter_set_default(self, dialect): sql, _ = PostgresAlterDomainExpression( @@ -124,13 +124,13 @@ def test_create_with_schema_and_collation(self, dialect): dialect, "posint", "NUMERIC", schema="app", collation="C", ).to_sql() - assert sql == 'CREATE DOMAIN "app"."posint" AS NUMERIC COLLATE "C"' + assert sql == 'CREATE DOMAIN "app"."posint" AS DECIMAL COLLATE "C"' def test_create_without_default(self, dialect): sql, _ = PostgresCreateDomainExpression( dialect, "posint", "NUMERIC", constraints=["CHECK (VALUE > 0)"] ).to_sql() - assert sql == 'CREATE DOMAIN "posint" AS NUMERIC CHECK (VALUE > 0)' + assert sql == 'CREATE DOMAIN "posint" AS DECIMAL CHECK (VALUE > 0)' def test_alter_drop_default(self, dialect): sql, _ = PostgresAlterDomainExpression( diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py index ee0249df..527b9566 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py @@ -92,7 +92,7 @@ class Plain(ActiveRecord): id: int - expression = TableDDLDeriver(Plain, PostgresDialect()).create_table() + expression = TableDDLDeriver(Plain, PostgresDialect(version=(16, 0, 0))).create_table() assert expression.inherits == [] assert expression.tablespace is None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py index e9f63af2..d3b9c99b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py @@ -75,7 +75,7 @@ def test_postgres_enum_expression_renders_via_format_enum_type_expression(dialec values=["pending", "processing", "ready"], ) sql, params = dialect.format_enum_type_expression(enum_ref) - assert sql == "video_status" + assert sql == '"video_status"' assert params == () @@ -89,7 +89,7 @@ def test_postgres_enum_expression_with_schema_renders_via_dialect(dialect): schema="app", ) sql, params = dialect.format_enum_type_expression(enum_ref) - assert sql == "app.video_status" + assert sql == '"app"."video_status"' assert params == () From 7c59cf2651eb2b59016af592bb01d5922571a628 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 25 Sep 2026 09:21:19 +0800 Subject: [PATCH 29/42] feat(postgres): add partition lifecycle provider --- .../impl/postgres/expression/ddl/__init__.py | 33 + .../impl/postgres/expression/ddl/partition.py | 99 ++- .../impl/postgres/mixins/ddl/__init__.py | 2 + .../impl/postgres/mixins/ddl/partition.py | 723 +++++++++++++----- .../impl/postgres/protocols/ddl/__init__.py | 2 + .../impl/postgres/protocols/ddl/partition.py | 21 +- tests/providers/partition.py | 2 +- .../backend/test_partition_operations.py | 107 +++ 8 files changed, 774 insertions(+), 215 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index b5d0b8bd..1d26d003 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -58,6 +58,13 @@ sql, _ = expr.to_sql() # → CREATE UNIQUE INDEX … NULLS NOT DISTINCT """ +from ..copy import ( + PostgresCopyFormat, + PostgresCopyFromExpression, + PostgresCopyLogVerbosity, + PostgresCopyOnError, + PostgresCopyToExpression, +) from .vacuum import PostgresVacuumExpression, PostgresAnalyzeExpression from .table_options import PostgresCreateTableOptions from .column import ( @@ -85,6 +92,16 @@ PostgresDropIndexExpression, ) from .exclude_constraint import PostgresExcludeConstraint +from .constraint import ( + PostgresAlterConstraint, + PostgresValidateConstraint, + PostgresAlterConstraintAction, + PostgresValidateConstraintAction, + PostgresAlterConstraintExpression, + PostgresValidateConstraintExpression, + PostgresAlterTableConstraint, + PostgresValidateTableConstraint, +) from .statistics import ( PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, @@ -150,6 +167,7 @@ PostgresAlterTableSettingsExpression, ) from .cluster import PostgresClusterExpression +from .repack import PostgresRepackExpression from .domain import ( AlterDomainActionType, DomainCheckConstraint, @@ -187,6 +205,12 @@ ) __all__ = [ + # copy + "PostgresCopyFormat", + "PostgresCopyFromExpression", + "PostgresCopyLogVerbosity", + "PostgresCopyOnError", + "PostgresCopyToExpression", # vacuum "PostgresVacuumExpression", "PostgresAnalyzeExpression", @@ -212,6 +236,14 @@ "PostgresIndexDefinition", "PostgresDropIndexExpression", "PostgresExcludeConstraint", + "PostgresAlterConstraint", + "PostgresValidateConstraint", + "PostgresAlterConstraintAction", + "PostgresValidateConstraintAction", + "PostgresAlterConstraintExpression", + "PostgresValidateConstraintExpression", + "PostgresAlterTableConstraint", + "PostgresValidateTableConstraint", # statistics "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", @@ -274,6 +306,7 @@ "PostgresAlterTableSettingsExpression", # cluster "PostgresClusterExpression", + "PostgresRepackExpression", # domain "AlterDomainActionType", "DomainNullability", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py index aedf302f..e087a908 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py @@ -11,7 +11,7 @@ - Partitioning: PostgreSQL 10+ - HASH partitioning, DEFAULT partition: PostgreSQL 11+ - Partition bounds expression: PostgreSQL 12+ -- ATTACH PARTITION with CONCURRENTLY: PostgreSQL 14+ +- Concurrent ATTACH: unsupported by this implementation """ from datetime import date, datetime @@ -20,6 +20,8 @@ from typing import Any, Dict, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal +from rhosocial.activerecord.backend.expression.operators import RawSQLExpression from rhosocial.activerecord.backend.expression.statements import PartitionClause if TYPE_CHECKING: @@ -58,15 +60,18 @@ class PartitionValue(BaseExpression): FOR VALUES clauses of PARTITION OF and ATTACH PARTITION statements. Value handling: - - None → NULL - - String 'MAXVALUE', 'MINVALUE', or 'DEFAULT' (case-insensitive) → as-is - - String/date/datetime/Decimal/numeric values → whitelist-formatted SQL literal - - Arbitrary objects are rejected to prevent unsafe ``str(value)`` fallback + - ``None`` renders as ``NULL``. + - RANGE ``MINVALUE`` and ``MAXVALUE`` render as keywords. + - LIST renders those strings as quoted values. + - Same-dialect ``FunctionCall``, ``Literal``, and ``RawSQLExpression`` values + delegate through their own formatters and retain their parameters. + - Other scalar values use a whitelist; arbitrary objects are rejected. Delegates to dialect.format_partition_value() for SQL generation. Attributes: - value: The partition bound value. + value: The partition bound value or expression. + partition_type: Optional RANGE, LIST, or HASH context. Example: >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect @@ -85,20 +90,59 @@ def __init__( self, dialect: "SQLDialectBase", value: Any, + partition_type: Optional[str] = None, ): super().__init__(dialect) - if isinstance(value, bool): - raise TypeError("partition value must not be bool") - if isinstance(value, float) and not isfinite(value): - raise ValueError("partition value float must be finite") - if isinstance(value, Decimal) and not value.is_finite(): - raise ValueError("partition value Decimal must be finite") - if not isinstance(value, (str, int, float, Decimal, date, datetime, type(None))): - raise TypeError( - "partition value must be str, int, float, Decimal, " - f"date, datetime, or None, got {type(value).__name__}" - ) + if partition_type is None: + normalized_type = None + elif not isinstance(partition_type, str): + raise TypeError("partition_type must be a string") + else: + normalized_type = partition_type.upper() + if normalized_type not in {"RANGE", "LIST", "HASH"}: + raise ValueError("partition_type must be RANGE, LIST, or HASH") + + if isinstance(value, BaseExpression): + if normalized_type == "HASH": + raise TypeError("HASH partition bounds require modulus and remainder") + pending = [value] + seen = set() + while pending: + current = pending.pop() + identity = id(current) + if identity in seen: + continue + seen.add(identity) + if current.dialect is not dialect: + raise ValueError( + "partition value expressions must use the same dialect as PartitionValue" + ) + if isinstance(current, FunctionCall): + if current.alias is not None: + raise ValueError("partition value expressions must not have aliases") + pending.extend(current.args) + elif isinstance(current, (Literal, RawSQLExpression)): + if getattr(current, "alias", None) is not None: + raise ValueError("partition value expressions must not have aliases") + else: + raise TypeError( + "partition value expressions must be FunctionCall, Literal, " + "or RawSQLExpression" + ) + else: + if isinstance(value, bool): + raise TypeError("partition value must not be bool") + if isinstance(value, float) and not isfinite(value): + raise ValueError("partition value float must be finite") + if isinstance(value, Decimal) and not value.is_finite(): + raise ValueError("partition value Decimal must be finite") + if not isinstance(value, (str, int, float, Decimal, date, datetime, type(None))): + raise TypeError( + "partition value must be a supported expression, str, int, float, " + f"Decimal, date, datetime, or None, got {type(value).__name__}" + ) self.value = value + self.partition_type = normalized_type @property def format_method(self) -> str: @@ -119,6 +163,8 @@ class PostgresCreatePartitionExpression(BaseExpression): partition_values: Partition bounds values (dict with 'from', 'to' for RANGE, list of values for LIST, or modulus/remainder for HASH). schema: Schema name for the partition. + parent_schema: Optional schema for the parent table. + partition_clause: Optional child-level PARTITION BY clause. tablespace: Tablespace for the partition. if_not_exists: Add IF NOT EXISTS clause. @@ -158,13 +204,22 @@ def __init__( schema: Optional[str] = None, tablespace: Optional[str] = None, if_not_exists: bool = False, + parent_schema: Optional[str] = None, + partition_clause: Optional[PartitionClause] = None, ): super().__init__(dialect) + if partition_clause is not None: + if not isinstance(partition_clause, PartitionClause): + raise TypeError("partition_clause must be a PartitionClause") + if partition_clause.dialect is not dialect: + raise ValueError("partition_clause must use the same dialect") self.partition_name = partition_name self.parent_table = parent_table self.partition_type = partition_type self.partition_values = partition_values self.schema = schema + self.parent_schema = parent_schema + self.partition_clause = partition_clause self.tablespace = tablespace self.if_not_exists = if_not_exists @@ -210,6 +265,7 @@ def __init__( schema: Optional[str] = None, concurrently: bool = False, finalize: bool = False, + parent_schema: Optional[str] = None, ): super().__init__(dialect) self.partition_name = partition_name @@ -217,6 +273,7 @@ def __init__( self.schema = schema self.concurrently = concurrently self.finalize = finalize + self.parent_schema = parent_schema @property def format_method(self) -> str: @@ -228,7 +285,7 @@ class PostgresAttachPartitionExpression(BaseExpression): """PostgreSQL ALTER TABLE ... ATTACH PARTITION statement expression. Attaches an existing table as a partition of a partitioned table. - Supports CONCURRENTLY mode on PostgreSQL 14+. + Concurrent attachment is rejected for every PostgreSQL version. Attributes: partition_name: Name of the table to attach. @@ -236,10 +293,10 @@ class PostgresAttachPartitionExpression(BaseExpression): partition_type: Partition type: 'RANGE', 'LIST', or 'HASH'. partition_values: Partition bounds values. schema: Schema name for the partition. - concurrently: Use non-blocking ATTACH mode (PG 14+). + concurrently: Requests unsupported concurrent attachment. Raises: - ValueError: If concurrent_attach is used on PostgreSQL < 14. + UnsupportedFeatureError: If ``concurrently=True``. Example: >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect @@ -266,6 +323,7 @@ def __init__( partition_values: Dict[str, Any], schema: Optional[str] = None, concurrently: bool = False, + parent_schema: Optional[str] = None, ): super().__init__(dialect) self.partition_name = partition_name @@ -274,6 +332,7 @@ def __init__( self.partition_values = partition_values self.schema = schema self.concurrently = concurrently + self.parent_schema = parent_schema @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py index 27d7e25d..874fda4d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py @@ -12,6 +12,7 @@ from .rls_config import PostgresRlsConfigMixin from .table_settings import PostgresAlterTableSettingsMixin from .cluster import PostgresClusterMixin +from .repack import PostgresRepackMixin from .domain import PostgresDomainMixin from .collation import PostgresCollationDDLMixin from .foreign_table import PostgresForeignTableMixin @@ -30,6 +31,7 @@ "PostgresRlsConfigMixin", "PostgresAlterTableSettingsMixin", "PostgresClusterMixin", + "PostgresRepackMixin", "PostgresDomainMixin", "PostgresCollationDDLMixin", "PostgresForeignTableMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py index 0dfa40d8..25c70fbe 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py @@ -6,9 +6,25 @@ partitioning with support for various PostgreSQL versions. """ -from typing import Any, List, Tuple, TYPE_CHECKING # noqa: F401 +from copy import copy +from typing import Any, List, Optional, Tuple, TYPE_CHECKING # noqa: F401 from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import PartitionClause +from rhosocial.activerecord.ddl.partition import ( + AttachPartitionRequest, + CreatePartitionRequest, + DetachPartitionRequest, + DropPartitionRequest, + PartitionCapabilities, + PartitionLifecycleContractError, + PartitionOperation, + PartitionOperationNotSupportedError, + PartitionRequest, + TruncatePartitionRequest, +) if TYPE_CHECKING: from ...expression.ddl import ( @@ -77,14 +93,12 @@ def supports_hash_table_partitioning(self) -> bool: return self.supports_hash_partitioning() def supports_subpartitioning(self) -> bool: - """Check if nested (sub)partitioning is exposed through this API. - - PostgreSQL supports sub-partitioning but this API does not expose it yet. + """Check if nested partitioning is supported. Returns: - Always False. + True if PostgreSQL version >= 10. """ - return False + return self.supports_table_partitioning() def supports_partition_metadata_introspection(self) -> bool: """Check if partition metadata introspection is available. @@ -156,6 +170,9 @@ def supports_detach_partition(self) -> bool: """ return self.supports_table_partitioning() + def get_partition_lifecycle_provider(self) -> "PostgresPartitionLifecycleProvider": + return PostgresPartitionLifecycleProvider(self) + def format_partition_clause(self, expr) -> Tuple[str, tuple]: """Format a PostgreSQL PARTITION BY clause from PartitionClause.""" if not self.supports_partitioned_table_creation(): @@ -165,6 +182,8 @@ def format_partition_clause(self, expr) -> Tuple[str, tuple]: "Declarative table partitioning requires PostgreSQL 10+.", ) + if expr.dialect is not self: + raise ValueError("partition clause must use the PostgreSQL dialect") method = expr.method.upper() if method == "RANGE": if not self.supports_range_table_partitioning(): @@ -187,13 +206,21 @@ def format_partition_clause(self, expr) -> Tuple[str, tuple]: ) else: raise ValueError(f"Invalid PostgreSQL partition method: {expr.method}") + if method == "LIST" and len(expr.keys) != 1: + raise ValueError( + "PostgreSQL LIST partitioning accepts exactly one key expression" + ) key_parts: List[str] = [] params: List[Any] = [] for key in expr.keys: - key_sql, key_params = key.to_sql() + if key.dialect is not self: + raise ValueError("partition key expressions must use the PostgreSQL dialect") + bound_key = self._inline_partition_expression(key) + key_sql, key_params = bound_key.to_sql() + if key_params: + raise ValueError("partition key expressions must not contain bind parameters") key_parts.append(key_sql) - params.extend(key_params) return f" PARTITION BY {method} ({', '.join(key_parts)})", tuple(params) def supports_hash_partitioning(self) -> bool: @@ -240,15 +267,12 @@ def supports_concurrent_detach(self) -> bool: return self.version >= (14, 0, 0) def supports_concurrent_attach(self) -> bool: - """Check if CONCURRENTLY ATTACH is supported. - - Native feature, PostgreSQL 14+. Enables non-blocking partition - attachment without blocking concurrent queries. + """Check if CONCURRENTLY ATTACH is exposed by this implementation. Returns: - True if PostgreSQL version >= 14. + Always False. """ - return self.version >= (14, 0, 0) + return False def supports_partition_bounds_expression(self) -> bool: """Check if partition bounds expressions are supported. @@ -283,38 +307,95 @@ def supports_partitionwise_aggregate(self) -> bool: """ return self.version >= (11, 0, 0) - def format_partition_value(self, expr: "PartitionValue") -> Tuple[str, tuple]: - """Format a partition bound value from expression. - - Public method implementing the PartitionValue expression's - SQL generation, following the Expression-Dialect-Protocol pattern. - - Value handling: - - None → 'NULL' - - String 'MAXVALUE', 'MINVALUE', or 'DEFAULT' (case-insensitive) → as-is - - String/date/datetime/Decimal/numeric values → whitelist-formatted SQL literal - - Arbitrary objects are rejected by PartitionValue before formatting + def _inline_partition_expression( + self, + value: Any, + memo: Optional[dict] = None, + ) -> Any: + if memo is None: + memo = {} + identity = id(value) + if identity in memo: + return memo[identity] + if isinstance(value, Literal): + bound = copy(value) + bound.dialect = self + bound.inline_literals = True + memo[identity] = bound + return bound + if isinstance(value, BaseExpression): + bound = copy(value) + bound.dialect = self + memo[identity] = bound + for name, child in vars(value).items(): + if name == "_dialect": + continue + setattr(bound, name, self._inline_partition_expression(child, memo)) + return bound + if isinstance(value, list): + bound_list = [self._inline_partition_expression(child, memo) for child in value] + memo[identity] = bound_list + return bound_list + if isinstance(value, tuple): + bound_tuple = tuple(self._inline_partition_expression(child, memo) for child in value) + memo[identity] = bound_tuple + return bound_tuple + if isinstance(value, dict): + bound_dict = { + key: self._inline_partition_expression(child, memo) + for key, child in value.items() + } + memo[identity] = bound_dict + return bound_dict + return value - Args: - expr: PartitionValue with the bound value. + def format_partition_value(self, expr: "PartitionValue") -> Tuple[str, tuple]: + """Format a PostgreSQL partition bound value.""" + if not self.supports_table_partitioning(): + raise UnsupportedFeatureError( + self.name, + "partition boundary value", + "Declarative table partitioning requires PostgreSQL 10+.", + ) - Returns: - Tuple of (SQL string, empty params tuple). - """ from datetime import date, datetime from decimal import Decimal from math import isfinite value = expr.value + if isinstance(value, BaseExpression): + if not self.supports_partition_bounds_expression(): + raise UnsupportedFeatureError( + self.name, + "partition bound expressions", + "Partition bound expressions require PostgreSQL 12+.", + ) + bound_value = self._inline_partition_expression(value) + value_sql, value_params = bound_value.to_sql() + if value_params: + raise ValueError("partition bound expressions must not contain bind parameters") + return value_sql, () if value is None: return "NULL", () if isinstance(value, bool): raise TypeError("partition value must not be bool") if isinstance(value, str): - upper_val = value.upper() - if upper_val in {"MAXVALUE", "MINVALUE", "DEFAULT"}: - return upper_val, () - return f"'{value.replace(chr(39), chr(39)+chr(39))}'", () + upper_value = value.upper() + if expr.partition_type in {None, "RANGE"} and upper_value in { + "MINVALUE", + "MAXVALUE", + }: + return upper_value, () + if expr.partition_type is None and upper_value == "DEFAULT": + if not self.supports_default_partition(): + raise UnsupportedFeatureError( + self.name, + "DEFAULT partition boundary", + "DEFAULT partitions require PostgreSQL 11+.", + ) + return "DEFAULT", () + escaped = value.replace("'", "''") + return f"'{escaped}'", () if isinstance(value, int): return str(value), () if isinstance(value, float): @@ -330,107 +411,166 @@ def format_partition_value(self, expr: "PartitionValue") -> Tuple[str, tuple]: if isinstance(value, date): return f"'{value.isoformat()}'", () raise TypeError( - "partition value must be str, int, float, Decimal, " - f"date, datetime, or None, got {type(value).__name__}" + "partition value must be a supported expression, str, int, float, " + f"Decimal, date, datetime, or None, got {type(value).__name__}" ) - def format_create_partition_statement(self, expr: "PostgresCreatePartitionExpression") -> Tuple[str, tuple]: - """Format CREATE TABLE ... PARTITION OF statement from expression. - - Supported partition types are ``RANGE``, ``LIST``, and ``HASH``. - - - ``expr.partition_type`` — partition method (``RANGE`` / ``LIST`` / ``HASH``). - - ``expr.if_not_exists`` — add ``IF NOT EXISTS``. - - ``expr.schema`` — optional schema qualifier. - - ``expr.partition_name`` — new partition name. - - ``expr.parent_table`` — parent partitioned table. - - ``expr.partition_values`` — bound values (see below). - - ``expr.tablespace`` — optional tablespace. - - ``partition_values`` dict format: - - - RANGE: ``{"from": ..., "to": ...}`` or ``{"default": True}``. - - LIST: ``{"values": [..., ...]}`` or ``{"default": True}``. - - HASH: ``{"modulus": N, "remainder": M}``. - - Args: - expr: PostgresCreatePartitionExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) + def _format_list_partition_values(self, values: Any) -> Tuple[List[str], List[Any]]: + if not isinstance(values, (list, tuple)) or not values: + raise ValueError("LIST partition requires a non-empty 'values' list") + if any(isinstance(value, (list, tuple)) for value in values): + raise ValueError( + "PostgreSQL LIST partitioning accepts exactly one bound expression per value; " + "nested rows are not supported" + ) - Raises: - ValueError: If partition_type is invalid or required bound values are missing. + from ...expression.ddl import PartitionValue - """ + value_sql_parts = [] + params: List[Any] = [] + for value in values: + value_sql, value_params = PartitionValue( + self, + value, + partition_type="LIST", + ).to_sql() + value_sql_parts.append(value_sql) + params.extend(value_params) + return value_sql_parts, params + + def format_create_partition_statement( + self, + expr: "PostgresCreatePartitionExpression", + ) -> Tuple[str, tuple]: + """Format CREATE TABLE ... PARTITION OF for PostgreSQL.""" + if not self.supports_table_partitioning(): + raise UnsupportedFeatureError( + self.name, + "CREATE TABLE ... PARTITION OF", + "Declarative table partitioning requires PostgreSQL 10+.", + ) + if not isinstance(expr.partition_type, str): + raise TypeError("partition_type must be a string") partition_type = expr.partition_type.upper() - if partition_type not in ("RANGE", "LIST", "HASH"): + if partition_type not in {"RANGE", "LIST", "HASH"}: raise ValueError(f"Invalid partition_type: {partition_type}") + default_marker = expr.partition_values.get("default", False) + if not isinstance(default_marker, bool): + raise TypeError("partition_values['default'] must be a bool") + if default_marker: + if partition_type == "HASH": + raise ValueError("HASH partitions cannot use DEFAULT") + if not self.supports_default_partition(): + raise UnsupportedFeatureError( + self.name, + "DEFAULT partition", + "DEFAULT partitions require PostgreSQL 11+.", + ) + parts = ["CREATE TABLE"] if expr.if_not_exists: parts.append("IF NOT EXISTS") - - # Partition name with optional schema if expr.schema: - parts.append(f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.partition_name)}") + child_name = ( + f"{self.format_identifier(expr.schema)}." + f"{self.format_identifier(expr.partition_name)}" + ) else: - parts.append(self.format_identifier(expr.partition_name)) - - # PARTITION OF parent - if expr.schema: - parts.append( - f"PARTITION OF {self.format_identifier(expr.schema)}.{self.format_identifier(expr.parent_table)}" + child_name = self.format_identifier(expr.partition_name) + parts.append(child_name) + + parent_schema = expr.parent_schema if expr.parent_schema is not None else expr.schema + if parent_schema: + parent_name = ( + f"{self.format_identifier(parent_schema)}." + f"{self.format_identifier(expr.parent_table)}" ) else: - parts.append(f"PARTITION OF {self.format_identifier(expr.parent_table)}") - - if partition_type == "RANGE": - if "default" in expr.partition_values and expr.partition_values["default"]: - parts.append("DEFAULT") - else: - parts.append("FOR VALUES") - from_val = expr.partition_values.get("from") - to_val = expr.partition_values.get("to") - if from_val is None or to_val is None: - raise ValueError("RANGE partition requires 'from' and 'to' values") - from ...expression.ddl import PartitionValue - from_sql, _ = PartitionValue(dialect=self, value=from_val).to_sql() - to_sql, _ = PartitionValue(dialect=self, value=to_val).to_sql() - parts.append(f"FROM ({from_sql}) TO ({to_sql})") + parent_name = self.format_identifier(expr.parent_table) + parts.append(f"PARTITION OF {parent_name}") + params: List[Any] = [] + from ...expression.ddl import PartitionValue + + if default_marker: + parts.append("DEFAULT") + elif partition_type == "RANGE": + from_value = expr.partition_values.get("from") + to_value = expr.partition_values.get("to") + if from_value is None or to_value is None: + raise ValueError("RANGE partition requires 'from' and 'to' values") + from_values = list(from_value) if isinstance(from_value, (list, tuple)) else [from_value] + to_values = list(to_value) if isinstance(to_value, (list, tuple)) else [to_value] + if not from_values or not to_values: + raise ValueError("RANGE partition boundaries must not be empty") + if len(from_values) != len(to_values): + raise ValueError("RANGE partition boundaries must have the same column count") + from_sql_parts = [] + to_sql_parts = [] + for value in from_values: + value_sql, value_params = PartitionValue( + self, + value, + partition_type="RANGE", + ).to_sql() + from_sql_parts.append(value_sql) + params.extend(value_params) + for value in to_values: + value_sql, value_params = PartitionValue( + self, + value, + partition_type="RANGE", + ).to_sql() + to_sql_parts.append(value_sql) + params.extend(value_params) + parts.append( + f"FOR VALUES FROM ({', '.join(from_sql_parts)}) " + f"TO ({', '.join(to_sql_parts)})" + ) elif partition_type == "LIST": - if "default" in expr.partition_values and expr.partition_values["default"]: - parts.append("DEFAULT") - else: - parts.append("FOR VALUES") - values = expr.partition_values.get("values", []) - if not values: - raise ValueError("LIST partition requires 'values' list") - from ...expression.ddl import PartitionValue - vals_str = ", ".join( - PartitionValue(dialect=self, value=v).to_sql()[0] - for v in values + value_sql_parts, value_params = self._format_list_partition_values( + expr.partition_values.get("values") + ) + params.extend(value_params) + parts.append(f"FOR VALUES IN ({', '.join(value_sql_parts)})") + else: + if not self.supports_hash_partitioning(): + raise UnsupportedFeatureError( + self.name, + "HASH partitioning", + "HASH partitioning requires PostgreSQL 11+.", ) - parts.append(f"IN ({vals_str})") - - elif partition_type == "HASH": - parts.append("FOR VALUES") modulus = expr.partition_values.get("modulus") remainder = expr.partition_values.get("remainder") if modulus is None or remainder is None: raise ValueError("HASH partition requires 'modulus' and 'remainder'") - if not self.supports_hash_partitioning(): - raise ValueError("HASH partitioning requires PostgreSQL 11+") - parts.append(f"WITH (MODULUS {modulus}, REMAINDER {remainder})") - - # TABLESPACE + if isinstance(modulus, bool) or not isinstance(modulus, int): + raise TypeError("HASH modulus must be an int") + if modulus <= 0: + raise ValueError("HASH modulus must be a positive integer") + if isinstance(remainder, bool) or not isinstance(remainder, int): + raise TypeError("HASH remainder must be an int") + if not 0 <= remainder < modulus: + raise ValueError("HASH remainder must satisfy 0 <= remainder < modulus") + parts.append(f"FOR VALUES WITH (MODULUS {modulus}, REMAINDER {remainder})") + + if expr.partition_clause is not None: + if not isinstance(expr.partition_clause, PartitionClause): + raise TypeError("partition_clause must be a PartitionClause") + clause_sql, clause_params = expr.partition_clause.to_sql() + if clause_params: + raise ValueError("partition clause must not contain bind parameters") + parts.append(clause_sql.strip()) if expr.tablespace: parts.append(f"TABLESPACE {self.format_identifier(expr.tablespace)}") - return (" ".join(parts), ()) + return " ".join(parts), tuple(params) - def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpression") -> Tuple[str, tuple]: + def format_detach_partition_statement( + self, + expr: "PostgresDetachPartitionExpression", + ) -> Tuple[str, tuple]: """Format ALTER TABLE ... DETACH PARTITION statement from expression. - ``expr.parent_table`` — partitioned table name. @@ -446,10 +586,21 @@ def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpres Tuple of (SQL string, empty params tuple) """ + if not self.supports_table_partitioning(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE ... DETACH PARTITION", + "Declarative table partitioning requires PostgreSQL 10+.", + ) + parts = ["ALTER TABLE"] - if expr.schema: - parts.append(f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.parent_table)}") + parent_schema = expr.parent_schema if expr.parent_schema is not None else expr.schema + if parent_schema: + parts.append( + f"{self.format_identifier(parent_schema)}." + f"{self.format_identifier(expr.parent_table)}" + ) else: parts.append(self.format_identifier(expr.parent_table)) @@ -472,88 +623,127 @@ def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpres return (" ".join(parts), ()) - def format_attach_partition_statement(self, expr: "PostgresAttachPartitionExpression") -> Tuple[str, tuple]: - """Format ALTER TABLE ... ATTACH PARTITION statement from expression. - - - ``expr.parent_table`` — partitioned table name. - - ``expr.schema`` — optional schema qualifier. - - ``expr.partition_name`` — partition to attach. - - ``expr.partition_type`` — partition method (``RANGE`` / ``LIST`` / ``HASH``). - - ``expr.partition_values`` — bound values (see ``format_create_partition_statement``). - - ``expr.concurrently`` — add ``CONCURRENTLY`` for non-blocking attach (PG 14+). - - Args: - expr: PostgresAttachPartitionExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) + def format_attach_partition_statement( + self, + expr: "PostgresAttachPartitionExpression", + ) -> Tuple[str, tuple]: + """Format ALTER TABLE ... ATTACH PARTITION for PostgreSQL.""" + if not self.supports_table_partitioning(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE ... ATTACH PARTITION", + "Declarative table partitioning requires PostgreSQL 10+.", + ) + if expr.concurrently: + raise UnsupportedFeatureError( + self.name, + "ATTACH PARTITION CONCURRENTLY", + "Use non-concurrent ATTACH PARTITION.", + ) + if not isinstance(expr.partition_type, str): + raise TypeError("partition_type must be a string") + partition_type = expr.partition_type.upper() + if partition_type not in {"RANGE", "LIST", "HASH"}: + raise ValueError(f"Invalid partition_type: {partition_type}") - Raises: - ValueError: If partition_type is invalid or required bound values are missing. - ValueError: If concurrently is used on PostgreSQL < 14. + default_marker = expr.partition_values.get("default", False) + if not isinstance(default_marker, bool): + raise TypeError("partition_values['default'] must be a bool") + if default_marker: + if partition_type == "HASH": + raise ValueError("HASH partitions cannot use DEFAULT") + if not self.supports_default_partition(): + raise UnsupportedFeatureError( + self.name, + "DEFAULT partition", + "DEFAULT partitions require PostgreSQL 11+.", + ) - """ parts = ["ALTER TABLE"] - - if expr.schema: - parts.append(f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.parent_table)}") + parent_schema = expr.parent_schema if expr.parent_schema is not None else expr.schema + if parent_schema: + parent_name = ( + f"{self.format_identifier(parent_schema)}." + f"{self.format_identifier(expr.parent_table)}" + ) else: - parts.append(self.format_identifier(expr.parent_table)) - - parts.append("ATTACH PARTITION") - + parent_name = self.format_identifier(expr.parent_table) if expr.schema: - parts.append(f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.partition_name)}") + partition_name = ( + f"{self.format_identifier(expr.schema)}." + f"{self.format_identifier(expr.partition_name)}" + ) else: - parts.append(self.format_identifier(expr.partition_name)) + partition_name = self.format_identifier(expr.partition_name) + parts.extend((parent_name, "ATTACH PARTITION", partition_name)) - partition_type = expr.partition_type.upper() - if partition_type not in ("RANGE", "LIST", "HASH"): - raise ValueError(f"Invalid partition_type: {partition_type}") + params: List[Any] = [] + from ...expression.ddl import PartitionValue - # FOR VALUES / DEFAULT clause (same as create partition) - if "default" in expr.partition_values and expr.partition_values["default"]: + if default_marker: parts.append("DEFAULT") + elif partition_type == "RANGE": + from_value = expr.partition_values.get("from") + to_value = expr.partition_values.get("to") + if from_value is None or to_value is None: + raise ValueError("RANGE partition requires 'from' and 'to' values") + from_values = list(from_value) if isinstance(from_value, (list, tuple)) else [from_value] + to_values = list(to_value) if isinstance(to_value, (list, tuple)) else [to_value] + if not from_values or not to_values: + raise ValueError("RANGE partition boundaries must not be empty") + if len(from_values) != len(to_values): + raise ValueError("RANGE partition boundaries must have the same column count") + from_sql_parts = [] + to_sql_parts = [] + for value in from_values: + value_sql, value_params = PartitionValue( + self, + value, + partition_type="RANGE", + ).to_sql() + from_sql_parts.append(value_sql) + params.extend(value_params) + for value in to_values: + value_sql, value_params = PartitionValue( + self, + value, + partition_type="RANGE", + ).to_sql() + to_sql_parts.append(value_sql) + params.extend(value_params) + parts.append( + f"FOR VALUES FROM ({', '.join(from_sql_parts)}) " + f"TO ({', '.join(to_sql_parts)})" + ) + elif partition_type == "LIST": + value_sql_parts, value_params = self._format_list_partition_values( + expr.partition_values.get("values") + ) + params.extend(value_params) + parts.append(f"FOR VALUES IN ({', '.join(value_sql_parts)})") else: - parts.append("FOR VALUES") - - if partition_type == "RANGE": - from_val = expr.partition_values.get("from") - to_val = expr.partition_values.get("to") - if from_val is None or to_val is None: - raise ValueError("RANGE partition requires 'from' and 'to' values") - from ...expression.ddl import PartitionValue - from_sql, _ = PartitionValue(dialect=self, value=from_val).to_sql() - to_sql, _ = PartitionValue(dialect=self, value=to_val).to_sql() - parts.append(f"FROM ({from_sql}) TO ({to_sql})") - - elif partition_type == "LIST": - values = expr.partition_values.get("values", []) - if not values: - raise ValueError("LIST partition requires 'values' list") - from ...expression.ddl import PartitionValue - vals_str = ", ".join( - PartitionValue(dialect=self, value=v).to_sql()[0] - for v in values + if not self.supports_hash_partitioning(): + raise UnsupportedFeatureError( + self.name, + "HASH partitioning", + "HASH partitioning requires PostgreSQL 11+.", ) - parts.append(f"IN ({vals_str})") - - elif partition_type == "HASH": - modulus = expr.partition_values.get("modulus") - remainder = expr.partition_values.get("remainder") - if modulus is None or remainder is None: - raise ValueError("HASH partition requires 'modulus' and 'remainder'") - if not self.supports_hash_partitioning(): - raise ValueError("HASH partitioning requires PostgreSQL 11+") - parts.append(f"WITH (MODULUS {modulus}, REMAINDER {remainder})") - - # CONCURRENTLY (PG 14+) - if expr.concurrently: - if not self.supports_concurrent_attach(): - raise ValueError("ATTACH CONCURRENTLY requires PostgreSQL 14+") - parts.append("CONCURRENTLY") + modulus = expr.partition_values.get("modulus") + remainder = expr.partition_values.get("remainder") + if modulus is None or remainder is None: + raise ValueError("HASH partition requires 'modulus' and 'remainder'") + if isinstance(modulus, bool) or not isinstance(modulus, int): + raise TypeError("HASH modulus must be an int") + if modulus <= 0: + raise ValueError("HASH modulus must be a positive integer") + if isinstance(remainder, bool) or not isinstance(remainder, int): + raise TypeError("HASH remainder must be an int") + if not 0 <= remainder < modulus: + raise ValueError("HASH remainder must satisfy 0 <= remainder < modulus") + parts.append(f"FOR VALUES WITH (MODULUS {modulus}, REMAINDER {remainder})") + + return " ".join(parts), tuple(params) - return (" ".join(parts), ()) def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpression") -> Tuple[str, tuple]: """Format pg_catalog query for partition metadata introspection. @@ -579,9 +769,7 @@ def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpres Raises: UnsupportedFeatureError: If PostgreSQL version < 10. """ - from typing import List - - if not self.supports_partition_metadata_introspection(): + if not self.supports_table_partitioning(): raise UnsupportedFeatureError( self.name, "partition metadata introspection", @@ -601,9 +789,15 @@ def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpres pg_get_expr(child.relpartbound, child.oid) AS bound FROM pg_class parent JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace + LEFT JOIN pg_partitioned_table partitioned_parent + ON partitioned_parent.partrelid = parent.oid LEFT JOIN pg_inherits i ON i.inhparent = parent.oid LEFT JOIN pg_class child ON child.oid = i.inhrelid WHERE parent.relname = {self.p()}{schema_filter} + AND ( + parent.relkind = 'p' + OR partitioned_parent.partrelid IS NOT NULL + ) ORDER BY child.relname """ else: @@ -613,6 +807,165 @@ def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpres NULL::text AS bound FROM pg_class parent JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace + LEFT JOIN pg_partitioned_table partitioned_parent + ON partitioned_parent.partrelid = parent.oid WHERE parent.relname = {self.p()}{schema_filter} + AND ( + parent.relkind = 'p' + OR partitioned_parent.partrelid IS NOT NULL + ) """ return sql, tuple(params) + + +class PostgresPartitionLifecycleProvider: + """Construct PostgreSQL partition lifecycle expressions.""" + + _SUPPORT_METHODS = { + PartitionOperation.CREATE: "supports_add_partition", + PartitionOperation.DROP: "supports_drop_partition", + PartitionOperation.TRUNCATE: "supports_truncate_partition", + PartitionOperation.ATTACH: "supports_attach_partition", + PartitionOperation.DETACH: "supports_detach_partition", + } + + def __init__(self, dialect: Any): + self.dialect = dialect + + def capabilities(self) -> PartitionCapabilities: + operations = frozenset( + operation + for operation, method_name in self._SUPPORT_METHODS.items() + if getattr(self.dialect, method_name)() + ) + strategies = tuple( + strategy + for strategy, supported in ( + ("RANGE", self.dialect.supports_range_table_partitioning()), + ("LIST", self.dialect.supports_list_table_partitioning()), + ("HASH", self.dialect.supports_hash_table_partitioning()), + ) + if supported + ) + return PartitionCapabilities(operations, strategies) + + def supports(self, operation: PartitionOperation) -> bool: + return operation in self._SUPPORT_METHODS and self.capabilities().supports(operation) + + def _require(self, request: PartitionRequest) -> None: + if not self.supports(request.operation): + raise PartitionOperationNotSupportedError(self.dialect.name, request.operation) + + def _require_type(self, request: PartitionRequest, request_type: type) -> None: + if not isinstance(request, request_type): + raise PartitionLifecycleContractError( + f"PostgreSQL partition provider received {type(request).__name__} for " + f"{request.operation.value}" + ) + + def build(self, request: PartitionRequest) -> BaseExpression: + self._require(request) + from rhosocial.activerecord.backend.expression.statements.ddl_table import DropTableExpression + from rhosocial.activerecord.backend.expression.statements.ddl_truncate import TruncateExpression + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( + PostgresAttachPartitionExpression, + PostgresCreatePartitionExpression, + PostgresDetachPartitionExpression, + ) + + if request.operation is PartitionOperation.CREATE: + self._require_type(request, CreatePartitionRequest) + parent_schema = ( + request.parent_schema + if request.parent_schema is not None + else request.table.schema_name + ) + partition_schema = ( + request.partition_schema + if request.partition_schema is not None + else request.table.schema_name + ) + return PostgresCreatePartitionExpression( + self.dialect, + partition_name=request.partition_name, + parent_table=request.table.name, + partition_type=request.partition_type, + partition_values=dict(request.partition_values), + schema=partition_schema, + parent_schema=parent_schema, + partition_clause=request.partition_clause, + tablespace=request.tablespace, + if_not_exists=request.if_not_exists, + ) + if request.operation is PartitionOperation.DROP: + self._require_type(request, DropPartitionRequest) + from rhosocial.activerecord.backend.expression.core import TableExpression + partition_schema = ( + request.partition_schema + if request.partition_schema is not None + else request.table.schema_name + ) + return DropTableExpression( + self.dialect, + TableExpression( + self.dialect, + request.partition_name, + schema_name=partition_schema, + ), + ) + if request.operation is PartitionOperation.TRUNCATE: + self._require_type(request, TruncatePartitionRequest) + partition_schema = ( + request.partition_schema + if request.partition_schema is not None + else request.table.schema_name + ) + return TruncateExpression( + self.dialect, + request.partition_name, + schema=partition_schema, + ) + if request.operation is PartitionOperation.ATTACH: + self._require_type(request, AttachPartitionRequest) + parent_schema = ( + request.parent_schema + if request.parent_schema is not None + else request.table.schema_name + ) + partition_schema = ( + request.partition_schema + if request.partition_schema is not None + else request.table.schema_name + ) + return PostgresAttachPartitionExpression( + self.dialect, + partition_name=request.partition_name, + parent_table=request.table.name, + partition_type=request.partition_type, + partition_values=dict(request.partition_values), + schema=partition_schema, + parent_schema=parent_schema, + concurrently=request.concurrently, + ) + if request.operation is PartitionOperation.DETACH: + self._require_type(request, DetachPartitionRequest) + parent_schema = ( + request.parent_schema + if request.parent_schema is not None + else request.table.schema_name + ) + partition_schema = ( + request.partition_schema + if request.partition_schema is not None + else request.table.schema_name + ) + return PostgresDetachPartitionExpression( + self.dialect, + partition_name=request.partition_name, + parent_table=request.table.name, + schema=partition_schema, + parent_schema=parent_schema, + concurrently=request.concurrently, + finalize=request.finalize, + ) + raise PartitionOperationNotSupportedError(self.dialect.name, request.operation) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py index 26f778a5..31555ee9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py @@ -11,6 +11,7 @@ from .rls_config import PostgresRlsConfigSupport from .table_settings import PostgresAlterTableSettingsSupport from .cluster import PostgresClusterSupport +from .repack import PostgresRepackSupport from .domain import PostgresDomainSupport from .collation import PostgresCollationDDLSupport from .foreign_table import PostgresForeignTableDDLSupport @@ -28,6 +29,7 @@ "PostgresRlsConfigSupport", "PostgresAlterTableSettingsSupport", "PostgresClusterSupport", + "PostgresRepackSupport", "PostgresDomainSupport", "PostgresCollationDDLSupport", "PostgresForeignTableDDLSupport", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py index f856c361..f01b8095 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py @@ -10,6 +10,8 @@ from rhosocial.activerecord.backend.dialect.protocols import PartitionSupport if TYPE_CHECKING: + from rhosocial.activerecord.ddl import PartitionLifecycleProvider + from ...expression.ddl import ( PartitionValue, PostgresCreatePartitionExpression, @@ -48,6 +50,10 @@ class PostgresPartitionSupport(PartitionSupport, Protocol): - Partitionwise aggregate: PostgreSQL 11+ """ + def get_partition_lifecycle_provider(self) -> "PartitionLifecycleProvider": + """Return the PostgreSQL partition lifecycle provider.""" + ... + def supports_hash_partitioning(self) -> bool: """Whether HASH partitioning is supported. @@ -81,10 +87,9 @@ def supports_concurrent_detach(self) -> bool: ... def supports_concurrent_attach(self) -> bool: - """Whether CONCURRENTLY ATTACH PARTITION is supported. + """Whether CONCURRENTLY ATTACH PARTITION is exposed. - Native feature, PostgreSQL 14+. - Enables non-blocking partition attachment. + This implementation always reports False. """ ... @@ -148,10 +153,8 @@ def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpres def format_attach_partition_statement(self, expr: "PostgresAttachPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... ATTACH PARTITION statement from expression. - The expression supports: - - RANGE / LIST / HASH partition types with corresponding bound values. - - DEFAULT partition (PG 11+) via ``partition_values={"default": True}``. - - CONCURRENTLY mode (PG 14+) via ``concurrently=True``. + The expression supports RANGE, LIST, and HASH bounds. DEFAULT requires + PostgreSQL 11+. Concurrent attachment is rejected for every version. Args: expr: PostgresAttachPartitionExpression with attach details. @@ -161,8 +164,8 @@ def format_attach_partition_statement(self, expr: "PostgresAttachPartitionExpres Tuple of (SQL string, parameters tuple) Raises: - ValueError: If partition_type is invalid or required bounds missing. - ValueError: If concurrently is used on PostgreSQL < 14. + ValueError: If partition_type is invalid or required bounds are missing. + UnsupportedFeatureError: If ``concurrently=True``. """ ... diff --git a/tests/providers/partition.py b/tests/providers/partition.py index 6c622de4..c6d082f6 100644 --- a/tests/providers/partition.py +++ b/tests/providers/partition.py @@ -6,7 +6,7 @@ from typing import Any, ClassVar, Dict, List, Optional, Tuple, Type from rhosocial.activerecord.base.field_proxy import FieldProxy -from rhosocial.activerecord.base.ddl.attributes import IdentityAttribute +from rhosocial.activerecord.base import IdentityAttribute from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py index ba245139..9e63a404 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py @@ -36,6 +36,7 @@ TableConstraintType, TruncateExpression, ) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression import ( PostgresAttachPartitionExpression, PostgresCreatePartitionExpression, @@ -57,6 +58,17 @@ from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, ColumnConstraintType, ) +from rhosocial.activerecord.ddl import PartitionLifecycle, PartitionOperation + + +class _LifecycleSource: + @classmethod + def table_name(cls): + return "ar_partition_events" + + @classmethod + def schema_name(cls): + return "public" PARTITION_TABLES = ( @@ -73,6 +85,101 @@ ) +def test_partition_lifecycle_provider_constructs_postgres_expressions(): + dialect = PostgresDialect() + dialect.version = (15, 0, 0) + lifecycle = PartitionLifecycle(_LifecycleSource, dialect) + assert lifecycle.capabilities().supports(PartitionOperation.CREATE) + assert not lifecycle.supports(PartitionOperation.MERGE) + assert not lifecycle.supports(PartitionOperation.SPLIT) + clause = PartitionClause( + dialect, + PartitionStrategy.LIST, + [Column(dialect, "region")], + ) + expression = lifecycle.create_partition( + "ar_partition_events_p2027", + "RANGE", + {"from": "2027-01-01", "to": "2028-01-01"}, + partition_clause=clause, + partition_schema="child_schema", + parent_schema="parent_schema", + ) + sql, params = expression.to_sql() + assert '"child_schema"."ar_partition_events_p2027"' in sql + assert 'PARTITION OF "parent_schema"."ar_partition_events"' in sql + assert sql.index("FOR VALUES") < sql.index("PARTITION BY LIST") + assert params == () + + inherited_child = lifecycle.create_partition( + "ar_partition_events_p2028", + "RANGE", + {"from": "2028-01-01", "to": "2029-01-01"}, + parent_schema="other_parent_schema", + ) + inherited_child_sql, _ = inherited_child.to_sql() + assert '"public"."ar_partition_events_p2028"' in inherited_child_sql + assert 'PARTITION OF "other_parent_schema"."ar_partition_events"' in inherited_child_sql + + +def test_partition_lifecycle_preserves_parent_and_child_schemas(): + dialect = PostgresDialect(version=(15, 0, 0)) + lifecycle = PartitionLifecycle(_LifecycleSource, dialect) + + create_sql, create_params = lifecycle.create_partition( + "ar_partition_events_p2029", + "RANGE", + {"from": "2029-01-01", "to": "2030-01-01"}, + partition_schema="child_schema", + parent_schema="parent_schema", + ).to_sql() + assert create_sql == ( + 'CREATE TABLE "child_schema"."ar_partition_events_p2029" ' + 'PARTITION OF "parent_schema"."ar_partition_events" ' + "FOR VALUES FROM ('2029-01-01') TO ('2030-01-01')" + ) + assert create_params == () + + drop_sql, drop_params = lifecycle.drop_partition( + "ar_partition_events_p2029", + partition_schema="child_schema", + ).to_sql() + assert drop_sql == 'DROP TABLE "child_schema"."ar_partition_events_p2029"' + assert drop_params == () + + truncate_sql, truncate_params = lifecycle.truncate_partition( + "ar_partition_events_p2029", + partition_schema="child_schema", + ).to_sql() + assert truncate_sql == 'TRUNCATE TABLE "child_schema"."ar_partition_events_p2029"' + assert truncate_params == () + + attach_sql, attach_params = lifecycle.attach_partition( + "ar_partition_events_p2029", + "RANGE", + {"from": "2029-01-01", "to": "2030-01-01"}, + partition_schema="child_schema", + parent_schema="parent_schema", + ).to_sql() + assert attach_sql == ( + 'ALTER TABLE "parent_schema"."ar_partition_events" ' + 'ATTACH PARTITION "child_schema"."ar_partition_events_p2029" ' + "FOR VALUES FROM ('2029-01-01') TO ('2030-01-01')" + ) + assert attach_params == () + + detach_sql, detach_params = lifecycle.detach_partition( + "ar_partition_events_p2029", + partition_schema="child_schema", + parent_schema="parent_schema", + ).to_sql() + assert detach_sql == ( + 'ALTER TABLE "parent_schema"."ar_partition_events" ' + 'DETACH PARTITION "child_schema"."ar_partition_events_p2029"' + ) + assert detach_params == () + + def _qualified(table_name: str) -> str: return f"public.{table_name}" From 1410721b5ee4f1ca273566cf0415a7323737ac9d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 25 Sep 2026 09:39:10 +0800 Subject: [PATCH 30/42] fix(postgres): remove unrelated COPY and REPACK imports --- .../impl/postgres/expression/ddl/__init__.py | 33 ------------------- .../impl/postgres/expression/ddl/column.py | 2 +- .../impl/postgres/mixins/ddl/__init__.py | 2 -- .../impl/postgres/protocols/ddl/__init__.py | 2 -- .../feature/backend/postgres/test_table.py | 2 +- 5 files changed, 2 insertions(+), 39 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index 1d26d003..b5d0b8bd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -58,13 +58,6 @@ sql, _ = expr.to_sql() # → CREATE UNIQUE INDEX … NULLS NOT DISTINCT """ -from ..copy import ( - PostgresCopyFormat, - PostgresCopyFromExpression, - PostgresCopyLogVerbosity, - PostgresCopyOnError, - PostgresCopyToExpression, -) from .vacuum import PostgresVacuumExpression, PostgresAnalyzeExpression from .table_options import PostgresCreateTableOptions from .column import ( @@ -92,16 +85,6 @@ PostgresDropIndexExpression, ) from .exclude_constraint import PostgresExcludeConstraint -from .constraint import ( - PostgresAlterConstraint, - PostgresValidateConstraint, - PostgresAlterConstraintAction, - PostgresValidateConstraintAction, - PostgresAlterConstraintExpression, - PostgresValidateConstraintExpression, - PostgresAlterTableConstraint, - PostgresValidateTableConstraint, -) from .statistics import ( PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, @@ -167,7 +150,6 @@ PostgresAlterTableSettingsExpression, ) from .cluster import PostgresClusterExpression -from .repack import PostgresRepackExpression from .domain import ( AlterDomainActionType, DomainCheckConstraint, @@ -205,12 +187,6 @@ ) __all__ = [ - # copy - "PostgresCopyFormat", - "PostgresCopyFromExpression", - "PostgresCopyLogVerbosity", - "PostgresCopyOnError", - "PostgresCopyToExpression", # vacuum "PostgresVacuumExpression", "PostgresAnalyzeExpression", @@ -236,14 +212,6 @@ "PostgresIndexDefinition", "PostgresDropIndexExpression", "PostgresExcludeConstraint", - "PostgresAlterConstraint", - "PostgresValidateConstraint", - "PostgresAlterConstraintAction", - "PostgresValidateConstraintAction", - "PostgresAlterConstraintExpression", - "PostgresValidateConstraintExpression", - "PostgresAlterTableConstraint", - "PostgresValidateTableConstraint", # statistics "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", @@ -306,7 +274,6 @@ "PostgresAlterTableSettingsExpression", # cluster "PostgresClusterExpression", - "PostgresRepackExpression", # domain "AlterDomainActionType", "DomainNullability", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py index fa3a29b5..63ccbc07 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py @@ -18,7 +18,7 @@ from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.statements import ColumnDefinition -from rhosocial.activerecord.base.ddl.options import ColumnOptions +from rhosocial.activerecord.base import ColumnOptions if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py index 874fda4d..27d7e25d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py @@ -12,7 +12,6 @@ from .rls_config import PostgresRlsConfigMixin from .table_settings import PostgresAlterTableSettingsMixin from .cluster import PostgresClusterMixin -from .repack import PostgresRepackMixin from .domain import PostgresDomainMixin from .collation import PostgresCollationDDLMixin from .foreign_table import PostgresForeignTableMixin @@ -31,7 +30,6 @@ "PostgresRlsConfigMixin", "PostgresAlterTableSettingsMixin", "PostgresClusterMixin", - "PostgresRepackMixin", "PostgresDomainMixin", "PostgresCollationDDLMixin", "PostgresForeignTableMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py index 31555ee9..26f778a5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py @@ -11,7 +11,6 @@ from .rls_config import PostgresRlsConfigSupport from .table_settings import PostgresAlterTableSettingsSupport from .cluster import PostgresClusterSupport -from .repack import PostgresRepackSupport from .domain import PostgresDomainSupport from .collation import PostgresCollationDDLSupport from .foreign_table import PostgresForeignTableDDLSupport @@ -29,7 +28,6 @@ "PostgresRlsConfigSupport", "PostgresAlterTableSettingsSupport", "PostgresClusterSupport", - "PostgresRepackSupport", "PostgresDomainSupport", "PostgresCollationDDLSupport", "PostgresForeignTableDDLSupport", diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py index 527b9566..4f7bc1b0 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py @@ -2,7 +2,7 @@ import pytest -from rhosocial.activerecord.base.ddl import TableDDLDeriver +from rhosocial.activerecord.ddl import TableDDLDeriver from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.mixins.ddl_table import PostgresTableMixin From c7019b3ad895faa50ddf6b3be40f0464f4982957 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 25 Sep 2026 10:50:09 +0800 Subject: [PATCH 31/42] feat(postgres): complete DDL and PG19 expression support --- .claude/architecture.md | 4 +- .claude/testing.md | 2 +- .github/workflows/test.yml | 5 +- CHANGELOG.md | 15 +- CLAUDE.md | 8 +- README.md | 2 +- docs/en_US/ddl/README.md | 4 +- .../installation.md | 4 +- docs/en_US/introduction/supported_versions.md | 6 +- .../postgres_specific_features/README.md | 2 +- .../postgres_specific_features/partition.md | 3 + .../property_graph_query.md | 68 +- .../protocol_support.md | 4 +- .../chapter_02_connection_pool/README.md | 2 +- .../parallel_workers/README.md | 2 +- docs/zh_CN/ddl/README.md | 4 +- .../installation.md | 4 +- docs/zh_CN/introduction/supported_versions.md | 6 +- .../postgres_specific_features/dialect.md | 25 +- .../postgres_specific_features/partition.md | 3 + .../property_graph_query.md | 83 +- .../protocol_support.md | 4 +- pyproject.toml | 2 +- requirements-3.8.txt | 2 +- requirements.txt | 2 +- .../backend/impl/postgres/__init__.py | 14 + .../impl/postgres/backend/async_backend.py | 127 ++- .../backend/impl/postgres/backend/sync.py | 105 ++- .../backend/impl/postgres/dialect.py | 31 +- .../pgq/recursive_cte_graph_traversal.py | 12 +- .../impl/postgres/expression/__init__.py | 30 + .../backend/impl/postgres/expression/copy.py | 182 +++++ .../impl/postgres/expression/ddl/__init__.py | 33 + .../postgres/expression/ddl/constraint.py | 35 + .../expression/ddl/exclude_constraint.py | 2 + .../impl/postgres/expression/ddl/repack.py | 50 ++ .../impl/postgres/expression/registry.py | 8 + .../backend/impl/postgres/mixins/__init__.py | 4 + .../impl/postgres/mixins/ddl/__init__.py | 2 + .../impl/postgres/mixins/ddl/constraint.py | 284 ++++--- .../impl/postgres/mixins/ddl/repack.py | 125 +++ .../impl/postgres/mixins/dml/__init__.py | 2 + .../backend/impl/postgres/mixins/dml/copy.py | 603 +++++++++++++++ .../postgres/mixins/property_graph_query.py | 134 +++- .../backend/impl/postgres/mixins/truncate.py | 8 +- .../impl/postgres/protocols/__init__.py | 4 + .../impl/postgres/protocols/ddl/__init__.py | 2 + .../impl/postgres/protocols/ddl/constraint.py | 46 +- .../impl/postgres/protocols/ddl/repack.py | 39 + .../impl/postgres/protocols/dml/__init__.py | 2 + .../impl/postgres/protocols/dml/copy.py | 86 +++ .../test_property_graph_query_format.py | 372 +++++---- .../backend/postgres/test_ddl_alter_config.py | 259 +++++++ .../backend/postgres/test_ddl_expressions.py | 422 +++++++++- .../backend/postgres/test_ddl_protocols.py | 27 +- .../backend/postgres/test_expressions.py | 3 +- .../backend/postgres/test_model_truncate.py | 65 +- .../backend/postgres/test_pg19_copy_repack.py | 730 ++++++++++++++++++ .../test_pg19_copy_repack_integration.py | 124 +++ .../test_property_graph_query_scenarios.py | 104 ++- .../backend/test_backend_initialization.py | 54 ++ .../backend/test_postgres_dialect_security.py | 54 +- .../test_postgres_protocol_conformance.py | 14 +- 63 files changed, 3924 insertions(+), 540 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/copy.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/constraint.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/repack.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/repack.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/copy.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/repack.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/copy.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py diff --git a/.claude/architecture.md b/.claude/architecture.md index b2ca7488..32b4ebd3 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -601,7 +601,7 @@ dependencies = [ ```python extras_require = { "mysql": ["mysql-connector-python>=8.0.0"], # MySQL backend - "postgresql": ["psycopg2>=2.9.0"], # PostgreSQL backend + "postgresql": ["psycopg[binary]>=3.2.13"], # PostgreSQL backend "dev": ["pytest", "black", "mypy"], # Development tools } ``` @@ -623,7 +623,7 @@ def discover_backends(): try: from rhosocial.activerecord.backend.impl.postgresql import PostgreSQLBackend - backends['postgresql'] = PostgreSQLBackend # Uses psycopg2 directly + backends['postgresql'] = PostgreSQLBackend # Uses psycopg 3 directly except ImportError: pass diff --git a/.claude/testing.md b/.claude/testing.md index 7acfc565..3f16238b 100644 --- a/.claude/testing.md +++ b/.claude/testing.md @@ -20,7 +20,7 @@ This backend CANNOT be used with free-threaded Python builds. Free-threaded test ```toml dependencies = [ - "psycopg[binary]", + "psycopg[binary]>=3.2.13", "rhosocial-activerecord" ] ``` diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44f826f7..10538cf2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -455,13 +455,12 @@ jobs: fail_ci_if_error: false test-postgres-19: - name: Python 3.14 / postgres 19beta3 (PGQ) + name: Python 3.14 / postgres 19beta4 runs-on: ubuntu-latest - continue-on-error: true services: postgres: - image: postgres:19beta3 + image: postgres:19beta4 env: POSTGRES_DB: test_db POSTGRES_USER: root diff --git a/CHANGELOG.md b/CHANGELOG.md index a53e881d..d7932239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [v1.0.0.dev17] - 2026-09-25 + +### Changed + +- Updated PostgreSQL 19 SQL/PGQ status: PostgreSQL 19 Beta 4 withdrew SQL/PGQ, and graph capabilities are now fail-closed by default with explicit opt-in overrides. + +### Fixed + +- Corrected PostgreSQL partition documentation: `ATTACH PARTITION CONCURRENTLY` is unsupported; concurrent `DETACH PARTITION` with `FINALIZE` is supported on PostgreSQL 14+. + + ## [v1.0.0.dev16] - 2026-08-15 ### Added @@ -17,8 +28,8 @@ - Added PostgreSQL expression-level `COLLATE` support with schema-qualified collation validation. ([#40](https://github.com/rhosocial/python-activerecord-postgres/issues/40)) - Added PostgreSQL support for core SQL/XML standard expressions, including XML construction, serialization, aggregation, and query/table expression capability declarations. ([#41](https://github.com/rhosocial/python-activerecord-postgres/issues/41)) - Added PostgreSQL dialect support for datetime interval expressions (`EXTRACT`, `DATE_PART`, `DATE_TRUNC`, `INTERVAL` add/subtract, date diff) with EXPLAIN-backed index usage verification tests. ([#42](https://github.com/rhosocial/python-activerecord-postgres/issues/42)) -- Added comprehensive PostgreSQL partitioning support including ATTACH CONCURRENTLY/DEFAULT partition, partition auto-management via named expressions (monthly/quarterly), pg_partman create_parent parameter completion, enhanced partition metadata queries, and full test coverage ([#43](https://github.com/rhosocial/python-activerecord-postgres/issues/43)) -- Add SQL/PGQ property graph query support and PostgreSQL 19 compatibility enhancements ([#44](https://github.com/rhosocial/python-activerecord-postgres/issues/44)) +- Added comprehensive PostgreSQL partitioning support including standard ATTACH PARTITION, concurrent DETACH PARTITION with FINALIZE (PG 14+), DEFAULT partition, partition auto-management via named expressions (monthly/quarterly), pg_partman create_parent parameter completion, enhanced partition metadata queries, and full test coverage ([#43](https://github.com/rhosocial/python-activerecord-postgres/issues/43)) +- Added capability-gated SQL/PGQ property graph query expressions and PostgreSQL 19 compatibility gates ([#44](https://github.com/rhosocial/python-activerecord-postgres/issues/44)) diff --git a/CLAUDE.md b/CLAUDE.md index a97384be..45533eeb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,4 +33,10 @@ This project is under active development. Key features implemented: ## Version Control and Changelog -This project adheres to the same version control, branching, commit message, and changelog management standards as the main `python-activerecord` project. \ No newline at end of file +This project adheres to the same version control, branching, commit message, and changelog management standards as the main `python-activerecord` project. + +## Search Scope + +- Use `rg` for repository searches; never use `grep`. +- Limit searches to this `python-activerecord` repository and its related testsuite/backend directories. +- Exclude `.git/`, `.venv*/`, `__pycache__/`, `build/`, `dist/`, and other cache directories. diff --git a/README.md b/README.md index 4b7d15cf..5fa4d1e2 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ print(user.id) # Populated automatically via RETURNING - **Python**: 3.8+ (including 3.13t/3.14t free-threaded builds) - **Core**: `rhosocial-activerecord>=1.0.0` -- **Driver**: `psycopg>=3.2.12` +- **Driver**: `psycopg[binary]>=3.2.13` ## PostgreSQL Version Compatibility diff --git a/docs/en_US/ddl/README.md b/docs/en_US/ddl/README.md index 61cac59e..c99dce0e 100644 --- a/docs/en_US/ddl/README.md +++ b/docs/en_US/ddl/README.md @@ -14,8 +14,8 @@ The PostgreSQL backend supports the same type-safe DDL expressions as the core l | `CreateViewExpression` | ✅ Full | Materialized views support | | `DropViewExpression` | ✅ Full | | | `CreatePartitionExpression` | ✅ Full | RANGE, LIST, HASH partitioning (PG 10+) | -| `AttachPartitionExpression` | ✅ Full | Includes CONCURRENTLY (PG 14+) | -| `DetachPartitionExpression` | ✅ Full | Includes CONCURRENTLY (PG 14+) | +| `AttachPartitionExpression` | ✅ Full | Standard ATTACH PARTITION; `CONCURRENTLY` is unsupported | +| `DetachPartitionExpression` | ✅ Full | Includes `CONCURRENTLY` and `FINALIZE` (PG 14+) | | `AlterIndexExpression` | ✅ Full | RENAME TO, SET TABLESPACE, SET/RESET storage params | | `ReindexExpression` | ✅ Full | INDEX/TABLE/SCHEMA/DATABASE level, with CONCURRENTLY | diff --git a/docs/en_US/installation_and_configuration/installation.md b/docs/en_US/installation_and_configuration/installation.md index 210f6c5b..2e4cd4d9 100644 --- a/docs/en_US/installation_and_configuration/installation.md +++ b/docs/en_US/installation_and_configuration/installation.md @@ -4,7 +4,7 @@ - Python 3.8 or higher - PostgreSQL 8.0 or higher (12+ recommended) -- psycopg 3.2.12 or higher +- `psycopg[binary]>=3.2.13` ## Installation @@ -37,7 +37,7 @@ print("PostgreSQL backend installed successfully!") The package uses `psycopg` (psycopg3) as the PostgreSQL adapter. You can optionally install `psycopg-binary` for pre-compiled binaries: ```bash -pip install psycopg-binary +pip install "psycopg-binary>=3.2.13" ``` **Note**: `psycopg-binary` is platform-specific. If unavailable for your platform, psycopg will compile from source automatically. diff --git a/docs/en_US/introduction/supported_versions.md b/docs/en_US/introduction/supported_versions.md index 60fe87e8..ee120930 100644 --- a/docs/en_US/introduction/supported_versions.md +++ b/docs/en_US/introduction/supported_versions.md @@ -107,16 +107,16 @@ | Package | Version | Python Version | Notes | |---------|---------|----------------|-------| -| psycopg | 3.2.x | 3.8+ | Supported | +| psycopg | 3.2.13+ | 3.8+ | Supported | | psycopg | 3.3.x | 3.10+ | Recommended | -| psycopg-binary | 3.2.x | 3.8+ | Pre-compiled binary | +| psycopg-binary | 3.2.13+ | 3.8+ | Pre-compiled binary | | psycopg-binary | 3.3.x | 3.10+ | Recommended | ### psycopg Version Compatibility | psycopg Version | Minimum Python | Maximum Python | |-----------------|----------------|----------------| -| 3.2.x | 3.8 | All current | +| 3.2.13+ | 3.8 | All current | | 3.3.x | 3.10 | All current | ## Core Library Dependencies diff --git a/docs/en_US/postgres_specific_features/README.md b/docs/en_US/postgres_specific_features/README.md index d26a8ff4..cd5f243f 100644 --- a/docs/en_US/postgres_specific_features/README.md +++ b/docs/en_US/postgres_specific_features/README.md @@ -12,7 +12,7 @@ PostgreSQL offers many advanced features beyond standard SQL. This section cover - **[Database Introspection](./introspection.md)**: Query metadata using pg_catalog - **[EXPLAIN Support](./explain.md)**: Query execution plan analysis and performance diagnostics - **[Table Partitioning](partition.md)**: RANGE / LIST / HASH partitioning and pg_partman management -- **[Property Graph Query](property_graph_query.md)**: SQL/PGQ property graph query (PG 19+) +- **[SQL/PGQ Availability](property_graph_query.md)**: Fail-closed gates after PostgreSQL 19 Beta 4 withdrawal ## Feature Highlights diff --git a/docs/en_US/postgres_specific_features/partition.md b/docs/en_US/postgres_specific_features/partition.md index 64bfdb27..2b528e39 100644 --- a/docs/en_US/postgres_specific_features/partition.md +++ b/docs/en_US/postgres_specific_features/partition.md @@ -25,6 +25,9 @@ partition = PostgresCreatePartitionExpression( ## Partition Management +PostgreSQL does not support `ATTACH PARTITION CONCURRENTLY`. PostgreSQL 14+ supports +`DETACH PARTITION CONCURRENTLY`, with `FINALIZE` for completing a pending detach. + ```python from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( PostgresAttachPartitionExpression, PostgresDetachPartitionExpression, diff --git a/docs/en_US/postgres_specific_features/property_graph_query.md b/docs/en_US/postgres_specific_features/property_graph_query.md index e75f060d..a5524465 100644 --- a/docs/en_US/postgres_specific_features/property_graph_query.md +++ b/docs/en_US/postgres_specific_features/property_graph_query.md @@ -1,57 +1,35 @@ -# Property Graph Query +# SQL/PGQ Availability -PostgreSQL 19+ supports the SQL/PGQ (Property Graph Query) standard for property graph queries. +PostgreSQL 19 Beta 4 withdrew SQL/PGQ. The PostgreSQL backend therefore keeps +`supports_graph_match()` and `supports_graph_table()` disabled for every server +version, including PostgreSQL 19 and later. -## MATCH Clause +The expression and formatter plumbing remains available for controlled compatibility +testing, but graph formatters fail with `UnsupportedFeatureError` by default. -```python -from rhosocial.activerecord.backend.expression.graph import ( - GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, - GraphTableExpression, GraphColumn, ColumnsClause, -) - -person = GraphVertex(dialect, variable="p", table="persons") -product = GraphVertex(dialect, variable="pr", table="products") -purchased = GraphEdge(dialect, variable="pu", table="purchases", - direction=GraphEdgeDirection.RIGHT) - -match = MatchClause(dialect, person, purchased, product) -# sql: 'MATCH (p) - [pu] -> (pr)' - -graph_table = GraphTableExpression( - dialect, - match_clause=match, - columns=ColumnsClause(dialect, columns=[ - GraphColumn(dialect, name="person_name", type="VARCHAR"), - GraphColumn(dialect, name="product_name", type="VARCHAR"), - ]), -) -``` - -## Property Graph DDL +## Capability Detection ```python -from rhosocial.activerecord.backend.expression.graph import ( - CreatePropertyGraphExpression, DropPropertyGraphExpression, - VertexTable, EdgeTable, -) +from rhosocial.activerecord.backend.impl.postgres import PostgresDialect -create_graph = CreatePropertyGraphExpression( - dialect, - graph_name="social_graph", - vertices=[VertexTable(dialect, table_name="persons", graph_label="Person")], - edges=[EdgeTable(dialect, table_name="knows", - source_vertex="Person", dest_vertex="Person")], -) +dialect = PostgresDialect(version=(19, 0, 4)) +assert dialect.supports_graph_match() is False +assert dialect.supports_graph_table() is False ``` -## Dialect Feature Detection +## Explicit Override + +A future implementation can opt in explicitly without relying on a version check: ```python -if dialect.supports_graph_match(): - # PG 19+: MATCH clause -if dialect.supports_graph_table(): - # PG 19+: GRAPH_TABLE expression +dialect = PostgresDialect( + version=(19, 0, 4), + graph_feature_overrides={ + "graph_match": True, + "graph_table": True, + }, +) ``` -> **Note**: SQL/PGQ support requires PostgreSQL 19+. +Only use these overrides when the target server or a dedicated compatibility layer +actually provides the corresponding feature. diff --git a/docs/en_US/postgres_specific_features/protocol_support.md b/docs/en_US/postgres_specific_features/protocol_support.md index a32c4c89..45cb6ea1 100644 --- a/docs/en_US/postgres_specific_features/protocol_support.md +++ b/docs/en_US/postgres_specific_features/protocol_support.md @@ -24,7 +24,7 @@ The PostgreSQL dialect implements numerous protocols from the `rhosocial.activer | **ArraySupport** | ✅ Full | All versions | Native array types and operations | | **JSONSupport** | ✅ Full | ≥ 9.2 | JSON type; JSONB ≥ 9.4; JSON_TABLE ≥ 12 | | **ExplainSupport** | ✅ Full | All versions | EXPLAIN and EXPLAIN ANALYZE with multiple formats | -| **GraphSupport** | ✅ Full | PG 19+ | SQL/PGQ property graph query MATCH and GRAPH_TABLE | +| **GraphSupport** | Disabled | Explicit override | SQL/PGQ was withdrawn in PostgreSQL 19 Beta 4 | | **LockingSupport** | ✅ Partial | ≥ 9.5 | FOR UPDATE; SKIP LOCKED ≥ 9.5 | | **MergeSupport** | ✅ Full | ≥ 15.0 | MERGE statement | | **OrderedSetAggregationSupport** | ✅ Full | ≥ 9.4 | Ordered-set aggregate functions | @@ -210,7 +210,7 @@ In addition to standard protocols, PostgreSQL provides database-specific protoco | Protocol | Description | Min Version | Documentation | |----------|-------------|-------------|---------------| | **PostgresPartitionSupport** | Advanced partitioning features | PG 10+ | [Partitioning Docs](https://www.postgresql.org/docs/current/ddl-partitioning.html) | -| **PostgresPropertyGraphQueryMixin** | SQL/PGQ property graph | PG 19+ | [Property Graph Query](property_graph_query.md) | +| **PostgresPropertyGraphQueryMixin** | SQL/PGQ compatibility gates | Explicit override | [SQL/PGQ Availability](property_graph_query.md) | | **PostgresPgPartmanSupport** | pg_partman auto-partitioning | PG 10+ | [Partitioning](partition.md) | | **PostgresXMLMixin** | SQL/XML standard expressions | PG 8.3+ | [Dialect](dialect.md) | | **PostgresCollationMixin** | Expression-level COLLATE | All | [Dialect](dialect.md) | diff --git a/docs/examples/chapter_02_connection_pool/README.md b/docs/examples/chapter_02_connection_pool/README.md index 35a29e7a..aeea028f 100644 --- a/docs/examples/chapter_02_connection_pool/README.md +++ b/docs/examples/chapter_02_connection_pool/README.md @@ -16,7 +16,7 @@ Run in PostgreSQL virtual environment: .venv_postgres\Scripts\activate # Install dependencies -pip install psycopg +pip install "psycopg[binary]>=3.2.13" # Run sync stress test python docs\examples\chapter_02_connection_pool\connection_pool_stress_test_sync.py diff --git a/docs/examples/chapter_08_scenarios/parallel_workers/README.md b/docs/examples/chapter_08_scenarios/parallel_workers/README.md index e886a43a..418edac1 100644 --- a/docs/examples/chapter_08_scenarios/parallel_workers/README.md +++ b/docs/examples/chapter_08_scenarios/parallel_workers/README.md @@ -41,7 +41,7 @@ User <--belongs_to-- Comment (评论作者) 本实验依赖 `psycopg` 版本 3: ```bash -pip install psycopg[binary] +pip install "psycopg[binary]>=3.2.13" ``` > **注意**:`psycopg` 不支持 Python 3.13t/3.14t 的 free-threaded 构建。如果需要 free-threaded Python,请使用 MySQL 或 SQLite 后端。 diff --git a/docs/zh_CN/ddl/README.md b/docs/zh_CN/ddl/README.md index 5fde20e7..c2dbe366 100644 --- a/docs/zh_CN/ddl/README.md +++ b/docs/zh_CN/ddl/README.md @@ -14,8 +14,8 @@ PostgreSQL 后端支持与核心库相同的类型安全 DDL 表达式,并具 | `CreateViewExpression` | ✅ 完整 | 物化视图支持 | | `DropViewExpression` | ✅ 完整 | | | `CreatePartitionExpression` | ✅ 完整 | RANGE, LIST, HASH 分区(PG 10+) | -| `AttachPartitionExpression` | ✅ 完整 | 含 CONCURRENTLY(PG 14+) | -| `DetachPartitionExpression` | ✅ 完整 | 含 CONCURRENTLY(PG 14+) | +| `AttachPartitionExpression` | ✅ 完整 | 支持标准 ATTACH PARTITION;不支持 `CONCURRENTLY` | +| `DetachPartitionExpression` | ✅ 完整 | 含 `CONCURRENTLY` 和 `FINALIZE`(PG 14+) | | `AlterIndexExpression` | ✅ 完整 | RENAME TO, SET TABLESPACE, SET/RESET 存储参数 | | `ReindexExpression` | ✅ 完整 | 索引/表/模式/数据库级重建,含 CONCURRENTLY | diff --git a/docs/zh_CN/installation_and_configuration/installation.md b/docs/zh_CN/installation_and_configuration/installation.md index 70a0d73d..ee416540 100644 --- a/docs/zh_CN/installation_and_configuration/installation.md +++ b/docs/zh_CN/installation_and_configuration/installation.md @@ -4,7 +4,7 @@ - Python 3.8 或更高版本 - PostgreSQL 8.0 或更高版本(推荐 12+) -- psycopg 3.2.12 或更高版本 +- `psycopg[binary]>=3.2.13` ## 安装 @@ -37,7 +37,7 @@ print("PostgreSQL 后端安装成功!") 本包使用 `psycopg`(psycopg3)作为 PostgreSQL 适配器。您可以选择安装 `psycopg-binary` 以获取预编译二进制文件: ```bash -pip install psycopg-binary +pip install "psycopg-binary>=3.2.13" ``` **注意**:`psycopg-binary` 是平台特定的。如果您的平台没有预编译版本,psycopg 将自动从源码编译。 diff --git a/docs/zh_CN/introduction/supported_versions.md b/docs/zh_CN/introduction/supported_versions.md index 8efcb936..ee64a3ba 100644 --- a/docs/zh_CN/introduction/supported_versions.md +++ b/docs/zh_CN/introduction/supported_versions.md @@ -107,16 +107,16 @@ | 包 | 版本 | Python 版本 | 说明 | |---|------|------------|------| -| psycopg | 3.2.x | 3.8+ | 支持 | +| psycopg | 3.2.13+ | 3.8+ | 支持 | | psycopg | 3.3.x | 3.10+ | 推荐 | -| psycopg-binary | 3.2.x | 3.8+ | 预编译二进制 | +| psycopg-binary | 3.2.13+ | 3.8+ | 预编译二进制 | | psycopg-binary | 3.3.x | 3.10+ | 推荐 | ### psycopg 版本兼容性 | psycopg 版本 | 最低 Python | 最高 Python | |-------------|------------|------------| -| 3.2.x | 3.8 | 所有当前版本 | +| 3.2.13+ | 3.8 | 所有当前版本 | | 3.3.x | 3.10 | 所有当前版本 | ## 核心库依赖 diff --git a/docs/zh_CN/postgres_specific_features/dialect.md b/docs/zh_CN/postgres_specific_features/dialect.md index b3547c44..cadc1d1b 100644 --- a/docs/zh_CN/postgres_specific_features/dialect.md +++ b/docs/zh_CN/postgres_specific_features/dialect.md @@ -363,25 +363,8 @@ table = xmltable(dialect, columns=[("id", "INTEGER", "id"), ("name", "VARCHAR(100)", "name")]) ``` -## 属性图查询 (PG 19+) +## SQL/PGQ 可用性 -PostgreSQL 19+ 支持 SQL/PGQ 属性图查询。详见 [属性图查询](property_graph_query.md)。 - -```python -from rhosocial.activerecord.backend.expression.graph import ( - GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, -) - -person = GraphVertex(dialect, variable="p", table="persons") -product = GraphVertex(dialect, variable="pr", table="products") -purchased = GraphEdge(dialect, variable="pu", table="purchases", - direction=GraphEdgeDirection.RIGHT) - -match = MatchClause(dialect, person, purchased, product) -# sql: 'MATCH (p) - [pu] -> (pr)' - -# 检查支持 -if dialect.supports_graph_match(): - # PG 19+ - pass -``` +PostgreSQL 19 Beta 4 已撤回 SQL/PGQ。`supports_graph_match()` 和 +`supports_graph_table()` 默认关闭,且不依据服务器版本自动启用。详见 +[SQL/PGQ 可用性](property_graph_query.md)。 diff --git a/docs/zh_CN/postgres_specific_features/partition.md b/docs/zh_CN/postgres_specific_features/partition.md index dd8d3e9e..4fb0bbf4 100644 --- a/docs/zh_CN/postgres_specific_features/partition.md +++ b/docs/zh_CN/postgres_specific_features/partition.md @@ -30,6 +30,9 @@ partition = PostgresCreatePartitionExpression( ## 分区管理 +PostgreSQL 不支持 `ATTACH PARTITION CONCURRENTLY`。PostgreSQL 14+ 支持 +`DETACH PARTITION CONCURRENTLY`,并可使用 `FINALIZE` 完成待处理的分离。 + ### ATTACH / DETACH PARTITION ```python diff --git a/docs/zh_CN/postgres_specific_features/property_graph_query.md b/docs/zh_CN/postgres_specific_features/property_graph_query.md index c2c4b7e1..0c576e4b 100644 --- a/docs/zh_CN/postgres_specific_features/property_graph_query.md +++ b/docs/zh_CN/postgres_specific_features/property_graph_query.md @@ -1,74 +1,33 @@ -# 属性图查询 (Property Graph Query) +# SQL/PGQ 可用性 -PostgreSQL 19+ 支持 SQL/PGQ(Property Graph Query)属性图查询标准。 +PostgreSQL 19 Beta 4 已撤回 SQL/PGQ。因此,PostgreSQL 后端对所有服务器版本都默认关闭 +`supports_graph_match()` 和 `supports_graph_table()`,包括 PostgreSQL 19 及更高版本。 -## MATCH 子句 +表达式和格式化器管线仍保留用于受控兼容性测试,但默认情况下,属性图格式化器会抛出 +`UnsupportedFeatureError`。 -```python -from rhosocial.activerecord.backend.expression.graph import ( - GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, GraphTableExpression, - GraphColumn, ColumnsClause, -) - -# 定义顶点 -person = GraphVertex(dialect, variable="p", table="persons") -product = GraphVertex(dialect, variable="pr", table="products") - -# 定义边 -purchased = GraphEdge(dialect, variable="pu", table="purchases", - direction=GraphEdgeDirection.RIGHT) - -# MATCH 子句 -match = MatchClause(dialect, person, purchased, product) -# sql: 'MATCH (p) - [pu] -> (pr)' - -# GRAPH_TABLE 表达式 -graph_table = GraphTableExpression( - dialect, - match_clause=match, - columns=ColumnsClause(dialect, columns=[ - GraphColumn(dialect, name="person_name", type="VARCHAR"), - GraphColumn(dialect, name="product_name", type="VARCHAR"), - ]), -) -``` - -## 属性图 DDL +## 能力检测 ```python -from rhosocial.activerecord.backend.expression.graph import ( - CreatePropertyGraphExpression, DropPropertyGraphExpression, - VertexTable, EdgeTable, TablePropertiesClause, -) - -# 创建属性图 -create_graph = CreatePropertyGraphExpression( - dialect, - graph_name="social_graph", - vertices=[ - VertexTable(dialect, table_name="persons", graph_label="Person"), - ], - edges=[ - EdgeTable(dialect, table_name="knows", - source_vertex="Person", dest_vertex="Person"), - ], -) -# sql: 'CREATE PROPERTY GRAPH "social_graph" ...' +from rhosocial.activerecord.backend.impl.postgres import PostgresDialect -# 删除属性图 -drop_graph = DropPropertyGraphExpression( - dialect, graph_name="social_graph", if_exists=True, -) +dialect = PostgresDialect(version=(19, 0, 4)) +assert dialect.supports_graph_match() is False +assert dialect.supports_graph_table() is False ``` -## 方言检查 +## 显式覆盖 -```python -if dialect.supports_graph_match(): - # PG 19+: 支持 MATCH 子句 +未来实现可以显式启用这些能力,而不依赖版本判断: -if dialect.supports_graph_table(): - # PG 19+: 支持 GRAPH_TABLE 表达式 +```python +dialect = PostgresDialect( + version=(19, 0, 4), + graph_feature_overrides={ + "graph_match": True, + "graph_table": True, + }, +) ``` -> **注意**:SQL/PGQ 支持需要 PostgreSQL 19+。 +仅当目标服务器或专用兼容层实际提供相应功能时,才应使用这些覆盖项。 diff --git a/docs/zh_CN/postgres_specific_features/protocol_support.md b/docs/zh_CN/postgres_specific_features/protocol_support.md index f9a8909a..f74ee528 100644 --- a/docs/zh_CN/postgres_specific_features/protocol_support.md +++ b/docs/zh_CN/postgres_specific_features/protocol_support.md @@ -24,7 +24,7 @@ PostgreSQL 方言实现了 `rhosocial.activerecord.backend.dialect.protocols` | **ArraySupport** | ✅ 完整 | 所有版本 | 原生数组类型和操作 | | **JSONSupport** | ✅ 完整 | ≥ 9.2 | JSON 类型;JSONB ≥ 9.4;JSON_TABLE ≥ 12 | | **ExplainSupport** | ✅ 完整 | 所有版本 | EXPLAIN 和 EXPLAIN ANALYZE 支持多种格式 | -| **GraphSupport** | ✅ 完整 | PG 19+ | SQL/PGQ 属性图查询 MATCH 子句和 GRAPH_TABLE | +| **GraphSupport** | 已禁用 | 显式覆盖 | PostgreSQL 19 Beta 4 已撤回 SQL/PGQ | | **LockingSupport** | ✅ 部分 | ≥ 9.5 | FOR UPDATE;SKIP LOCKED ≥ 9.5 | | **MergeSupport** | ✅ 完整 | ≥ 15.0 | MERGE 语句 | | **OrderedSetAggregationSupport** | ✅ 完整 | ≥ 9.4 | 有序集聚合函数 | @@ -66,7 +66,7 @@ PostgreSQL 方言实现了 `rhosocial.activerecord.backend.dialect.protocols` | 协议 | 描述 | 最低版本 | 文档 | |------|------|----------|------| | **PostgresPartitionSupport** | 高级分区特性 | PG 10+ | [分区文档](https://www.postgresql.org/docs/current/ddl-partitioning.html) | -| **PostgresPropertyGraphQueryMixin** | SQL/PGQ 属性图 | PG 19+ | [属性图查询](property_graph_query.md) | +| **PostgresPropertyGraphQueryMixin** | SQL/PGQ 兼容性门控 | 显式覆盖 | [SQL/PGQ 可用性](property_graph_query.md) | | **PostgresPgPartmanSupport** | pg_partman 自动分区 | PG 10+ | [分区文档](partition.md) | | **PostgresXMLMixin** | SQL/XML 标准表达式 | PG 8.3+ | [Dialect 表达式](dialect.md) | | **PostgresCollationMixin** | 表达式级 COLLATE | 所有版本 | [Dialect 表达式](dialect.md) | diff --git a/pyproject.toml b/pyproject.toml index ea713912..33e8ad0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "psycopg[binary]", + "psycopg[binary]>=3.2.13", "rhosocial-activerecord>=1.0.0.dev0,<2.0.0" ] diff --git a/requirements-3.8.txt b/requirements-3.8.txt index b9c2fd97..8510f370 100644 --- a/requirements-3.8.txt +++ b/requirements-3.8.txt @@ -1,2 +1,2 @@ -psycopg[binary] +psycopg[binary]>=3.2.13 rhosocial-activerecord diff --git a/requirements.txt b/requirements.txt index 6aeecb94..6f427b7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ # Core dependency (rhosocial-activerecord) is installed via CI workflow from git # PostgreSQL driver -psycopg[binary] \ No newline at end of file +psycopg[binary]>=3.2.13 \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py index 515b9421..514f26c4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py @@ -75,6 +75,14 @@ PostgresCidrType, ) from .explain import PostgresExplainResult, PostgresExplainPlanLine +from .expression import ( + PostgresCopyFormat, + PostgresCopyFromExpression, + PostgresCopyLogVerbosity, + PostgresCopyOnError, + PostgresCopyToExpression, + PostgresRepackExpression, +) from .expression.ddl import ( PostgresAddDomainCheckAction, PostgresAddEnumValueAction, @@ -355,6 +363,12 @@ # PostgreSQL EXPLAIN Result Types "PostgresExplainResult", "PostgresExplainPlanLine", + "PostgresCopyFormat", + "PostgresCopyFromExpression", + "PostgresCopyLogVerbosity", + "PostgresCopyOnError", + "PostgresCopyToExpression", + "PostgresRepackExpression", # PostgreSQL DDL Statements "PostgresCompositeTypeAttribute", "PostgresCompositeTypeDefinition", 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 cd99f36d..1541c237 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py @@ -16,7 +16,17 @@ import datetime import logging -from typing import Any, Dict, List, Optional, Tuple +from typing import ( + Any, + AsyncIterable, + Dict, + Iterable, + List, + Optional, + Tuple, + TYPE_CHECKING, + Union, +) import psycopg from psycopg import AsyncConnection @@ -47,6 +57,17 @@ from ..protocols import PostgresExtensionInfo from ..transaction import AsyncPostgresTransactionManager +if TYPE_CHECKING: + from ..expression import PostgresCopyFromExpression, PostgresCopyToExpression + + +CopyChunk = Union[str, bytes, bytearray, memoryview] +AsyncCopyInput = Union[ + CopyChunk, + Iterable[CopyChunk], + AsyncIterable[CopyChunk], +] + class AsyncPostgresBackend( IntrospectorBackendMixin, @@ -65,6 +86,8 @@ def __init__(self, **kwargs: Any) -> None: are enabled. Call introspect_and_adapt() after connecting to detect the actual server version and installed extensions. """ + graph_feature_overrides = kwargs.pop("graph_feature_overrides", None) + # Ensure we have proper PostgreSQL configuration connection_config = kwargs.get("connection_config") @@ -140,7 +163,9 @@ def __init__(self, **kwargs: Any) -> None: # Initialize PostgreSQL-specific components # Version is None until introspect_and_adapt() is called - self._dialect = PostgresDialect() + self._dialect = PostgresDialect( + graph_feature_overrides=graph_feature_overrides, + ) # Register PostgreSQL-specific type adapters (same as sync backend) # Note: XML adapter is NOT registered by default due to potential conflicts. @@ -286,6 +311,97 @@ async def _get_cursor(self) -> Any: return self._connection.cursor() + @staticmethod + def _copy_output_chunk(chunk: Any) -> bytes: + if isinstance(chunk, bytes): + return chunk + if isinstance(chunk, str): + return chunk.encode("utf-8") + if isinstance(chunk, (bytearray, memoryview)): + return bytes(chunk) + raise TypeError("PostgreSQL COPY stream yielded a non-buffer chunk") + + @staticmethod + def _validate_copy_input_chunk( + chunk: Any, + binary: bool, + encoding: Optional[str] = None, + ) -> CopyChunk: + if not isinstance(chunk, (str, bytes, bytearray, memoryview)): + raise TypeError("COPY input chunks must be str or bytes-like objects") + if binary and isinstance(chunk, str): + raise TypeError("COPY FROM BINARY requires bytes-like input chunks") + if encoding is not None and isinstance(chunk, str): + raise TypeError("COPY FROM with ENCODING requires bytes-like input chunks") + return chunk + + async def copy_to(self, expression: "PostgresCopyToExpression") -> bytes: + """Execute COPY TO STDOUT and return the complete byte stream.""" + from ..expression import PostgresCopyToExpression + + if not isinstance(expression, PostgresCopyToExpression): + raise TypeError("expression must be a PostgresCopyToExpression") + sql, params = expression.to_sql() + chunks = [] + try: + async with await self._get_cursor() as cursor: + async with cursor.copy(sql, params) as copy: + async for chunk in copy: + chunks.append(self._copy_output_chunk(chunk)) + except BaseException as error: + if isinstance(error, PsycopgError): + await self._handle_error(error) + else: + await self._try_rollback_transaction() + raise + return b"".join(chunks) + + async def copy_from( + self, + expression: "PostgresCopyFromExpression", + data: AsyncCopyInput, + ) -> int: + """Stream COPY FROM STDIN data and return the server-reported row count.""" + from ..expression import PostgresCopyFromExpression + + if not isinstance(expression, PostgresCopyFromExpression): + raise TypeError("expression must be a PostgresCopyFromExpression") + sql, params = expression.to_sql() + binary = str(expression.format).lower() == "binary" + try: + async with await self._get_cursor() as cursor: + async with cursor.copy(sql, params) as copy: + if isinstance(data, (str, bytes, bytearray, memoryview)): + await copy.write( + self._validate_copy_input_chunk(data, binary, expression.encoding) + ) + elif hasattr(data, "__aiter__"): + async for chunk in data: + await copy.write( + self._validate_copy_input_chunk(chunk, binary, expression.encoding) + ) + else: + try: + chunks = iter(data) + except TypeError as error: + raise TypeError( + "COPY input must be bytes, str, or a sync/async iterable of chunks" + ) from error + for chunk in chunks: + await copy.write( + self._validate_copy_input_chunk(chunk, binary, expression.encoding) + ) + rowcount = int(cursor.rowcount) + if getattr(self, "_transaction_manager", None) is not None: + await self._handle_auto_commit_if_needed() + return rowcount + except BaseException as error: + if isinstance(error, PsycopgError): + await self._handle_error(error) + else: + await self._try_rollback_transaction() + raise + async def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult: """Execute the same SQL statement multiple times with different parameters asynchronously.""" if not self._connection: @@ -403,7 +519,12 @@ async def introspect_and_adapt(self) -> None: if version_changed: self._server_version_cache = actual_version - self._dialect = PostgresDialect(actual_version) + self._dialect = PostgresDialect( + actual_version, + graph_feature_overrides=getattr( + self._dialect, "_graph_feature_overrides", None + ), + ) # Detect installed extensions extensions = await self._detect_extensions() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py index e1ee833a..bcba3f5f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py @@ -16,7 +16,7 @@ import datetime import logging -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING, Union import psycopg from psycopg.errors import Error as PsycopgError @@ -47,6 +47,13 @@ from ..transaction import PostgresTransactionManager from ..introspection import SyncPostgreSQLIntrospector +if TYPE_CHECKING: + from ..expression import PostgresCopyFromExpression, PostgresCopyToExpression + + +CopyChunk = Union[str, bytes, bytearray, memoryview] +CopyInput = Union[CopyChunk, Iterable[CopyChunk]] + class PostgresBackend( IntrospectorBackendMixin, @@ -65,6 +72,8 @@ def __init__(self, **kwargs: Any) -> None: are enabled. Call introspect_and_adapt() after connecting to detect the actual server version and installed extensions. """ + graph_feature_overrides = kwargs.pop("graph_feature_overrides", None) + # Ensure we have proper PostgreSQL configuration connection_config = kwargs.get("connection_config") @@ -140,7 +149,9 @@ def __init__(self, **kwargs: Any) -> None: # Initialize PostgreSQL-specific components # Version is None until introspect_and_adapt() is called - self._dialect = PostgresDialect() + self._dialect = PostgresDialect( + graph_feature_overrides=graph_feature_overrides, + ) # Register PostgreSQL-specific type adapters (same as async backend) # Note: XML adapter is NOT registered by default due to potential conflicts. @@ -287,6 +298,89 @@ def _get_cursor(self) -> Any: return self._connection.cursor() + @staticmethod + def _copy_output_chunk(chunk: Any) -> bytes: + if isinstance(chunk, bytes): + return chunk + if isinstance(chunk, str): + return chunk.encode("utf-8") + if isinstance(chunk, (bytearray, memoryview)): + return bytes(chunk) + raise TypeError("PostgreSQL COPY stream yielded a non-buffer chunk") + + @staticmethod + def _validate_copy_input_chunk( + chunk: Any, + binary: bool, + encoding: Optional[str] = None, + ) -> CopyChunk: + if not isinstance(chunk, (str, bytes, bytearray, memoryview)): + raise TypeError("COPY input chunks must be str or bytes-like objects") + if binary and isinstance(chunk, str): + raise TypeError("COPY FROM BINARY requires bytes-like input chunks") + if encoding is not None and isinstance(chunk, str): + raise TypeError("COPY FROM with ENCODING requires bytes-like input chunks") + return chunk + + def copy_to(self, expression: "PostgresCopyToExpression") -> bytes: + """Execute COPY TO STDOUT and return the complete byte stream.""" + from ..expression import PostgresCopyToExpression + + if not isinstance(expression, PostgresCopyToExpression): + raise TypeError("expression must be a PostgresCopyToExpression") + sql, params = expression.to_sql() + chunks = [] + try: + with self._get_cursor() as cursor: + with cursor.copy(sql, params) as copy: + for chunk in copy: + chunks.append(self._copy_output_chunk(chunk)) + except BaseException as error: + if isinstance(error, PsycopgError): + self._handle_error(error) + else: + self._try_rollback_transaction() + raise + return b"".join(chunks) + + def copy_from( + self, + expression: "PostgresCopyFromExpression", + data: CopyInput, + ) -> int: + """Stream COPY FROM STDIN data and return the server-reported row count.""" + from ..expression import PostgresCopyFromExpression + + if not isinstance(expression, PostgresCopyFromExpression): + raise TypeError("expression must be a PostgresCopyFromExpression") + sql, params = expression.to_sql() + binary = str(expression.format).lower() == "binary" + chunks: Iterable[CopyChunk] + if isinstance(data, (str, bytes, bytearray, memoryview)): + chunks = (data,) + else: + try: + chunks = iter(data) + except TypeError as error: + raise TypeError("COPY input must be bytes, str, or an iterable of chunks") from error + try: + with self._get_cursor() as cursor: + with cursor.copy(sql, params) as copy: + for chunk in chunks: + copy.write( + self._validate_copy_input_chunk(chunk, binary, expression.encoding) + ) + rowcount = int(cursor.rowcount) + if getattr(self, "_transaction_manager", None) is not None: + self._handle_auto_commit_if_needed() + return rowcount + except BaseException as error: + if isinstance(error, PsycopgError): + self._handle_error(error) + else: + self._try_rollback_transaction() + raise + def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult: """Execute the same SQL statement multiple times with different parameters.""" if not self._connection: @@ -404,7 +498,12 @@ def introspect_and_adapt(self) -> None: if version_changed: self._server_version_cache = actual_version - self._dialect = PostgresDialect(actual_version) + self._dialect = PostgresDialect( + actual_version, + graph_feature_overrides=getattr( + self._dialect, "_graph_feature_overrides", None + ), + ) # Detect installed extensions extensions = self._detect_extensions() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 777a7296..b55fcb3f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -6,7 +6,7 @@ based on the PostgreSQL version provided at initialization. """ -from typing import Tuple, Optional, cast, TYPE_CHECKING +from typing import Mapping, Tuple, Optional, cast, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.schema.differ import SchemaDiffer @@ -106,6 +106,7 @@ PostgresPropertyGraphQueryMixin, PostgresIndexMixin, PostgresVacuumMixin, + PostgresCopyMixin, PostgresQueryOptimizationMixin, PostgresDataTypeMixin, PostgresLogicalReplicationMixin, @@ -167,6 +168,7 @@ PostgresAlterTableSettingsMixin, PostgresClusterMixin, PostgresDomainMixin, + PostgresRepackMixin, PostgresCollationDDLMixin, PostgresForeignTableMixin, PostgresRoutineMixin, @@ -216,6 +218,7 @@ PostgresPartitionSupport, PostgresIndexSupport, PostgresVacuumSupport, + PostgresCopySupport, PostgresQueryOptimizationSupport, PostgresDataTypeSupport, PostgresLogicalReplicationSupport, @@ -273,6 +276,7 @@ PostgresRlsConfigSupport, PostgresAlterTableSettingsSupport, PostgresClusterSupport, + PostgresRepackSupport, PostgresCollationDDLSupport, PostgresForeignTableDDLSupport, PostgresRoutineDDLSupport, @@ -369,6 +373,7 @@ class PostgresDialect( PostgresTableMixin, # Before TableMixin to override supports_create_table_like PostgresCommentMixin, # Before TableMixin to override format_comment_statement TableMixin, + PostgresConstraintMixin, ConstraintMixin, PostgresPartitionMixin, PartitionMixin, @@ -381,6 +386,7 @@ class PostgresDialect( PostgresHstoreMixin, # Native feature mixins PostgresVacuumMixin, + PostgresCopyMixin, PostgresQueryOptimizationMixin, PostgresDataTypeMixin, PostgresLogicalReplicationMixin, @@ -411,11 +417,11 @@ class PostgresDialect( PostgresAddressStandardizerMixin, # DDL feature mixins PostgresTriggerMixin, - PostgresConstraintMixin, PostgresPolicyMixin, PostgresRlsConfigMixin, PostgresAlterTableSettingsMixin, PostgresClusterMixin, + PostgresRepackMixin, PostgresCollationDDLMixin, PostgresForeignTableMixin, PostgresRoutineMixin, @@ -490,6 +496,7 @@ class PostgresDialect( PostgresPartitionSupport, PostgresIndexSupport, PostgresVacuumSupport, + PostgresCopySupport, PostgresQueryOptimizationSupport, PostgresDataTypeSupport, PostgresLogicalReplicationSupport, @@ -547,6 +554,7 @@ class PostgresDialect( PostgresRlsConfigSupport, PostgresAlterTableSettingsSupport, PostgresClusterSupport, + PostgresRepackSupport, PostgresCollationDDLSupport, PostgresForeignTableDDLSupport, PostgresRoutineDDLSupport, @@ -603,7 +611,12 @@ class PostgresDialect( from .function_versions import POSTGRES_FUNCTION_VERSIONS as _FV _POSTGRES_FUNCTION_VERSIONS = _FV - def __init__(self, version: Optional[Tuple[int, int, int]] = None): + def __init__( + self, + version: Optional[Tuple[int, int, int]] = None, + *, + graph_feature_overrides: Optional[Mapping[str, bool]] = None, + ): """ Initialize PostgreSQL dialect with specific version. @@ -612,6 +625,8 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): If None, the dialect must be adapted via backend.introspect_and_adapt() before version-dependent features can be used. + graph_feature_overrides: Explicit opt-ins for withdrawn or future + property graph features. """ super().__init__() @@ -619,6 +634,16 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): if version is not None: self.version = version + overrides = dict(graph_feature_overrides or {}) + if any(not isinstance(name, str) for name in overrides): + raise TypeError("Graph feature override names must be strings") + unknown = overrides.keys() - self.GRAPH_FEATURE_NAMES + if unknown: + raise ValueError(f"Unknown graph feature overrides: {', '.join(sorted(unknown))}") + if any(type(enabled) is not bool for enabled in overrides.values()): + raise TypeError("Graph feature override values must be booleans") + self._graph_feature_overrides = overrides + @staticmethod def _validate_data_type(data_type: str) -> bool: """Validate data type for safe embedding in SQL. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py index 14f4b8b9..dccd0fb3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py @@ -1,13 +1,9 @@ """ -Recursive CTE graph traversal — PGQ quantified path alternative -=============================================================== +Recursive CTE graph traversal +============================= -PostgreSQL 19 natively supports SQL/PGQ (MATCH / GRAPH_TABLE), with limitations: - - supports_quantified_path() → False (no variable-length paths +/*/{n,m}) - - supports_comma_separated_patterns() → False (no comma-separated multi-pattern) - -This example demonstrates recursive CTE + SetOperationExpression + UNION ALL -as an equivalent alternative for graph traversal patterns. +PostgreSQL 19 Beta 4 withdrew SQL/PGQ. This example demonstrates recursive CTE ++ SetOperationExpression + UNION ALL as an alternative for graph traversal. Common use cases: 1. Social network N-degree friend recommendations (replaces MATCH (a)-[e]->+(b)) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 2bb80764..985f4b64 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -19,6 +19,13 @@ AdvisoryLockType, ) from .ilike import ILIKEExpression +from .copy import ( + PostgresCopyFormat, + PostgresCopyOnError, + PostgresCopyLogVerbosity, + PostgresCopyToExpression, + PostgresCopyFromExpression, +) from .ddl import ( PostgresColumnStorage, PostgresColumnDefinition, @@ -26,6 +33,7 @@ PostgresAlterColumn, PostgresVacuumExpression, PostgresAnalyzeExpression, + PostgresRepackExpression, PostgresPartitionClause, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, @@ -38,6 +46,14 @@ PostgresIndexDefinition, PostgresDropIndexExpression, PostgresExcludeConstraint, + PostgresAlterConstraint, + PostgresValidateConstraint, + PostgresAlterConstraintAction, + PostgresValidateConstraintAction, + PostgresAlterConstraintExpression, + PostgresValidateConstraintExpression, + PostgresAlterTableConstraint, + PostgresValidateTableConstraint, PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, PostgresCommentExpression, @@ -149,9 +165,15 @@ # locking # ilike "ILIKEExpression", + "PostgresCopyFormat", + "PostgresCopyOnError", + "PostgresCopyLogVerbosity", + "PostgresCopyToExpression", + "PostgresCopyFromExpression", # ddl "PostgresVacuumExpression", "PostgresAnalyzeExpression", + "PostgresRepackExpression", "PostgresColumnStorage", "PostgresColumnDefinition", "PostgresColumnOptions", @@ -168,6 +190,14 @@ "PostgresIndexDefinition", "PostgresDropIndexExpression", "PostgresExcludeConstraint", + "PostgresAlterConstraint", + "PostgresValidateConstraint", + "PostgresAlterConstraintAction", + "PostgresValidateConstraintAction", + "PostgresAlterConstraintExpression", + "PostgresValidateConstraintExpression", + "PostgresAlterTableConstraint", + "PostgresValidateTableConstraint", "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", "PostgresCommentExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/copy.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/copy.py new file mode 100644 index 00000000..3b610964 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/copy.py @@ -0,0 +1,182 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/copy.py +"""Typed PostgreSQL COPY expressions.""" + +from enum import Enum +from typing import List, Optional, Sequence, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresCopyFormat", + "PostgresCopyOnError", + "PostgresCopyLogVerbosity", + "PostgresCopyToExpression", + "PostgresCopyFromExpression", +] + + +class PostgresCopyFormat(str, Enum): + TEXT = "text" + CSV = "csv" + JSON = "json" + BINARY = "binary" + + +class PostgresCopyOnError(str, Enum): + STOP = "stop" + IGNORE = "ignore" + SET_NULL = "set_null" + + +class PostgresCopyLogVerbosity(str, Enum): + DEFAULT = "default" + VERBOSE = "verbose" + SILENT = "silent" + + +CopyHeader = Union[bool, int, str] +CopyColumnSelection = Union[str, Sequence[str]] + + +def _enum_value(value: Union[str, Enum]) -> str: + return str(value.value if isinstance(value, Enum) else value) + + +def _copy_columns(value: Optional[Sequence[str]], name: str) -> Optional[List[str]]: + if value is None: + return None + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise TypeError(f"{name} must be a sequence of identifiers") + columns = list(value) + if any(not isinstance(column, str) for column in columns): + raise TypeError(f"{name} must contain only string identifiers") + return columns + + +def _copy_force_columns( + value: Optional[CopyColumnSelection], +) -> Optional[Union[str, List[str]]]: + if value is None: + return None + if isinstance(value, str): + return "*" if value == "*" else [value] + if not isinstance(value, Sequence): + raise TypeError("FORCE column selection must be '*' or a sequence of identifiers") + columns = list(value) + if not columns: + raise ValueError("FORCE column selection must not be empty") + if any(not isinstance(column, str) for column in columns): + raise TypeError("FORCE column selection must contain only string identifiers") + return columns + + +class PostgresCopyToExpression(BaseExpression): + """A PostgreSQL 19-safe ``COPY ... TO STDOUT`` expression.""" + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: Optional[str] = None, + query: Optional[BaseExpression] = None, + *, + schema: Optional[str] = None, + columns: Optional[Sequence[str]] = None, + format: Union[PostgresCopyFormat, str] = PostgresCopyFormat.TEXT, + header: Optional[CopyHeader] = None, + null: Optional[str] = None, + delimiter: Optional[str] = None, + quote: Optional[str] = None, + escape: Optional[str] = None, + force_quote: Optional[CopyColumnSelection] = None, + force_array: Optional[bool] = None, + partitioned: bool = False, + is_partitioned: Optional[bool] = None, + ): + super().__init__(dialect) + if table_name is None and query is None: + raise ValueError("COPY TO requires exactly one table_name or query source") + if table_name is not None and query is not None: + raise ValueError("COPY TO accepts either table_name or query, not both") + if query is not None and not isinstance(query, BaseExpression): + raise TypeError("COPY TO query must be a BaseExpression") + if is_partitioned is not None: + if not isinstance(is_partitioned, bool): + raise TypeError("is_partitioned must be a bool") + if partitioned and not is_partitioned: + raise ValueError("partitioned and is_partitioned disagree") + partitioned = is_partitioned + self.table_name = table_name + self.query = query + self.schema = schema + self.columns = _copy_columns(columns, "columns") + self.format = _enum_value(format) + self.header = header + self.null = null + self.delimiter = delimiter + self.quote = quote + self.escape = escape + self.force_quote = _copy_force_columns(force_quote) + self.force_array = force_array + self.partitioned = partitioned + self.is_partitioned = partitioned + + @property + def format_method(self) -> str: + return "format_copy_to_statement" + + +class PostgresCopyFromExpression(BaseExpression): + """A PostgreSQL ``COPY ... FROM STDIN`` expression.""" + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + *, + schema: Optional[str] = None, + columns: Optional[Sequence[str]] = None, + format: Union[PostgresCopyFormat, str] = PostgresCopyFormat.TEXT, + header: Optional[CopyHeader] = None, + null: Optional[str] = None, + default: Optional[str] = None, + delimiter: Optional[str] = None, + quote: Optional[str] = None, + escape: Optional[str] = None, + force_not_null: Optional[CopyColumnSelection] = None, + force_null: Optional[CopyColumnSelection] = None, + freeze: bool = False, + where: Optional[BaseExpression] = None, + on_error: Optional[Union[PostgresCopyOnError, str]] = None, + reject_limit: Optional[int] = None, + encoding: Optional[str] = None, + log_verbosity: Optional[Union[PostgresCopyLogVerbosity, str]] = None, + ): + super().__init__(dialect) + if where is not None and not isinstance(where, BaseExpression): + raise TypeError("COPY FROM WHERE must be a BaseExpression") + self.table_name = table_name + self.schema = schema + self.columns = _copy_columns(columns, "columns") + self.format = _enum_value(format) + self.header = header + self.null = null + self.default = default + self.delimiter = delimiter + self.quote = quote + self.escape = escape + self.force_not_null = _copy_force_columns(force_not_null) + self.force_null = _copy_force_columns(force_null) + self.freeze = freeze + self.where = where + self.on_error = None if on_error is None else _enum_value(on_error) + self.reject_limit = reject_limit + self.encoding = encoding + self.log_verbosity = None if log_verbosity is None else _enum_value(log_verbosity) + + @property + def format_method(self) -> str: + return "format_copy_from_statement" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index b5d0b8bd..1d26d003 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -58,6 +58,13 @@ sql, _ = expr.to_sql() # → CREATE UNIQUE INDEX … NULLS NOT DISTINCT """ +from ..copy import ( + PostgresCopyFormat, + PostgresCopyFromExpression, + PostgresCopyLogVerbosity, + PostgresCopyOnError, + PostgresCopyToExpression, +) from .vacuum import PostgresVacuumExpression, PostgresAnalyzeExpression from .table_options import PostgresCreateTableOptions from .column import ( @@ -85,6 +92,16 @@ PostgresDropIndexExpression, ) from .exclude_constraint import PostgresExcludeConstraint +from .constraint import ( + PostgresAlterConstraint, + PostgresValidateConstraint, + PostgresAlterConstraintAction, + PostgresValidateConstraintAction, + PostgresAlterConstraintExpression, + PostgresValidateConstraintExpression, + PostgresAlterTableConstraint, + PostgresValidateTableConstraint, +) from .statistics import ( PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, @@ -150,6 +167,7 @@ PostgresAlterTableSettingsExpression, ) from .cluster import PostgresClusterExpression +from .repack import PostgresRepackExpression from .domain import ( AlterDomainActionType, DomainCheckConstraint, @@ -187,6 +205,12 @@ ) __all__ = [ + # copy + "PostgresCopyFormat", + "PostgresCopyFromExpression", + "PostgresCopyLogVerbosity", + "PostgresCopyOnError", + "PostgresCopyToExpression", # vacuum "PostgresVacuumExpression", "PostgresAnalyzeExpression", @@ -212,6 +236,14 @@ "PostgresIndexDefinition", "PostgresDropIndexExpression", "PostgresExcludeConstraint", + "PostgresAlterConstraint", + "PostgresValidateConstraint", + "PostgresAlterConstraintAction", + "PostgresValidateConstraintAction", + "PostgresAlterConstraintExpression", + "PostgresValidateConstraintExpression", + "PostgresAlterTableConstraint", + "PostgresValidateTableConstraint", # statistics "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", @@ -274,6 +306,7 @@ "PostgresAlterTableSettingsExpression", # cluster "PostgresClusterExpression", + "PostgresRepackExpression", # domain "AlterDomainActionType", "DomainNullability", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/constraint.py new file mode 100644 index 00000000..36f4490c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/constraint.py @@ -0,0 +1,35 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/constraint.py +"""PostgreSQL typed ALTER TABLE constraint actions.""" + +from rhosocial.activerecord.backend.expression.statements import ( + AlterConstraint, + ValidateConstraint, +) + + +class PostgresAlterConstraint(AlterConstraint): + """PostgreSQL ``ALTER CONSTRAINT`` enforcement action.""" + + +class PostgresValidateConstraint(ValidateConstraint): + """PostgreSQL ``VALIDATE CONSTRAINT`` action.""" + + +PostgresAlterConstraintAction = PostgresAlterConstraint +PostgresValidateConstraintAction = PostgresValidateConstraint +PostgresAlterConstraintExpression = PostgresAlterConstraint +PostgresValidateConstraintExpression = PostgresValidateConstraint +PostgresAlterTableConstraint = PostgresAlterConstraint +PostgresValidateTableConstraint = PostgresValidateConstraint + + +__all__ = [ + "PostgresAlterConstraint", + "PostgresValidateConstraint", + "PostgresAlterConstraintAction", + "PostgresValidateConstraintAction", + "PostgresAlterConstraintExpression", + "PostgresValidateConstraintExpression", + "PostgresAlterTableConstraint", + "PostgresValidateTableConstraint", +] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py index 5f4cbf9b..b5ef079a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py @@ -40,6 +40,7 @@ def __init__( deferrable: Optional[bool] = None, initially_deferred: Optional[bool] = None, validation: Any = None, + enforced: Optional[bool] = None, ): super().__init__( dialect, @@ -48,6 +49,7 @@ def __init__( deferrable=deferrable, initially_deferred=initially_deferred, validation=validation, + enforced=enforced, ) self.elements = elements or [] self.using = using diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/repack.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/repack.py new file mode 100644 index 00000000..0546b310 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/repack.py @@ -0,0 +1,50 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/repack.py +"""Typed PostgreSQL 19 REPACK expression.""" + +from typing import List, Optional, Sequence, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["PostgresRepackExpression"] + + +class PostgresRepackExpression(BaseExpression): + """A native PostgreSQL 19 ``REPACK`` statement expression.""" + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: Optional[str] = None, + *, + schema: Optional[str] = None, + columns: Optional[Sequence[str]] = None, + using_index: Optional[Union[bool, str]] = None, + all_using_index: bool = False, + verbose: bool = False, + analyze: bool = False, + concurrently: bool = False, + ): + super().__init__(dialect) + if isinstance(columns, (str, bytes)) or (columns is not None and not isinstance(columns, Sequence)): + raise TypeError("columns must be a sequence of identifiers") + normalized_columns = None if columns is None else list(columns) + if normalized_columns is not None and any(not isinstance(column, str) for column in normalized_columns): + raise TypeError("columns must contain only string identifiers") + if not isinstance(all_using_index, bool): + raise TypeError("all_using_index must be a bool") + self.table_name = table_name + self.schema = schema + self.columns: Optional[List[str]] = normalized_columns + self.using_index = using_index + self.all_using_index = all_using_index + self.verbose = verbose + self.analyze = analyze + self.concurrently = concurrently + + @property + def format_method(self) -> str: + return "format_repack_statement" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py index 8855d672..bb383750 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py @@ -18,10 +18,13 @@ PostgresSetDomainSchemaAction, PostgresValidateDomainConstraintAction, ) +from .copy import PostgresCopyFromExpression, PostgresCopyToExpression from .ddl.multirange import ( CreateMultirangeTypeExpression, MultirangeAggFunctionExpression, ) +from .ddl.repack import PostgresRepackExpression +from .ddl.constraint import PostgresAlterConstraint, PostgresValidateConstraint from .ddl.type import ( AlterEnumAddValueExpression, CreateEnumTypeExpression, @@ -54,6 +57,11 @@ _POSTGRES_DDL_EXPRESSION_CLASSES: Tuple[Type[BaseExpression], ...] = ( + PostgresCopyFromExpression, + PostgresCopyToExpression, + PostgresRepackExpression, + PostgresAlterConstraint, + PostgresValidateConstraint, PostgresCompositeTypeDefinition, PostgresEnumTypeDefinition, PostgresRangeTypeDefinition, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index 37342822..4a9fac6b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -33,6 +33,7 @@ from .ddl.rls_config import PostgresRlsConfigMixin from .ddl.table_settings import PostgresAlterTableSettingsMixin from .ddl.cluster import PostgresClusterMixin +from .ddl.repack import PostgresRepackMixin from .ddl.domain import PostgresDomainMixin from .ddl.collation import PostgresCollationDDLMixin from .ddl.foreign_table import PostgresForeignTableMixin @@ -41,6 +42,7 @@ # DML mixins from .dml.vacuum import PostgresVacuumMixin +from .dml.copy import PostgresCopyMixin from .dml.stored_procedure import PostgresStoredProcedureMixin from .dml.extended_statistics import PostgresExtendedStatisticsMixin from .dml.advisory_lock import PostgresAdvisoryLockMixin @@ -144,6 +146,7 @@ "PostgresRlsConfigMixin", "PostgresAlterTableSettingsMixin", "PostgresClusterMixin", + "PostgresRepackMixin", "PostgresDomainMixin", "PostgresCollationDDLMixin", "PostgresForeignTableMixin", @@ -151,6 +154,7 @@ "PostgresPublicationMixin", # DML mixins "PostgresVacuumMixin", + "PostgresCopyMixin", "PostgresStoredProcedureMixin", "PostgresExtendedStatisticsMixin", "PostgresAdvisoryLockMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py index 27d7e25d..874fda4d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py @@ -12,6 +12,7 @@ from .rls_config import PostgresRlsConfigMixin from .table_settings import PostgresAlterTableSettingsMixin from .cluster import PostgresClusterMixin +from .repack import PostgresRepackMixin from .domain import PostgresDomainMixin from .collation import PostgresCollationDDLMixin from .foreign_table import PostgresForeignTableMixin @@ -30,6 +31,7 @@ "PostgresRlsConfigMixin", "PostgresAlterTableSettingsMixin", "PostgresClusterMixin", + "PostgresRepackMixin", "PostgresDomainMixin", "PostgresCollationDDLMixin", "PostgresForeignTableMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py index 8ba42acc..eb026be5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py @@ -8,16 +8,36 @@ ConstraintMixin (rhosocial.activerecord.backend.dialect.mixins). """ -from typing import Tuple, TYPE_CHECKING +import re +from typing import Any, List, Tuple, TYPE_CHECKING, cast + +from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ( + normalize_table_constraint_type, +) if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ConstraintMixin from rhosocial.activerecord.backend.expression.statements import ( AddTableConstraint, + AlterConstraint, TableConstraint, + ValidateConstraint, ) + class _ConstraintMixinBase(ConstraintMixin): + version: Tuple[int, int, int] + name: str + + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: + return identifier + +else: + + class _ConstraintMixinBase: + pass -class PostgresConstraintMixin: + +class PostgresConstraintMixin(_ConstraintMixinBase): """PostgreSQL-proprietary constraint capability defaults. These features are PostgreSQL-specific and not part of the SQL standard. @@ -27,6 +47,51 @@ class PostgresConstraintMixin: SQL standard constraint capability defaults are in ConstraintMixin. """ + def supports_constraint_enforced(self, constraint_type: Any = None) -> bool: + if constraint_type is None: + return self.version >= (18, 0, 0) + from rhosocial.activerecord.backend.expression.statements import TableConstraintType + + normalized = normalize_table_constraint_type(constraint_type) + if normalized not in {TableConstraintType.CHECK, TableConstraintType.FOREIGN_KEY}: + return False + return self.version >= (18, 0, 0) + + def supports_alter_constraint_enforced(self, constraint_type: Any = None) -> bool: + if constraint_type is None: + return False + from rhosocial.activerecord.backend.expression.statements import TableConstraintType + + normalized = normalize_table_constraint_type(constraint_type) + if normalized == TableConstraintType.FOREIGN_KEY: + return self.version >= (18, 0, 0) + if normalized == TableConstraintType.CHECK: + return self.version >= (19, 0, 0) + return False + + def supports_validate_constraint(self) -> bool: + return True + + def _require_constraint_enforced(self, constraint_type: Any) -> None: + if self.supports_constraint_enforced(constraint_type): + return + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + raise UnsupportedFeatureError( + self.name, + "ENFORCED/NOT ENFORCED constraint", + ) + + def _require_enforceable_constraint(self, constraint_type: Any) -> None: + if self.supports_alter_constraint_enforced(constraint_type): + return + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + raise UnsupportedFeatureError( + self.name, + "ALTER CONSTRAINT ENFORCED/NOT ENFORCED", + ) + def supports_constraint_novalidate(self) -> bool: """NOT VALID constraint option (PostgreSQL-proprietary). @@ -52,116 +117,155 @@ def supports_drop_constraint_if_exists(self) -> bool: """ return True + def format_table_constraint(self, expr: "TableConstraint") -> Tuple[str, tuple]: + from rhosocial.activerecord.backend.expression.statements import TableConstraintType + + constraint_type = normalize_table_constraint_type(expr.constraint_type) + if getattr(expr, "enforced", None) is not None: + self._require_constraint_enforced(constraint_type) + if constraint_type == TableConstraintType.EXCLUDE: + return self._format_exclude_constraint(expr) + return cast(Tuple[str, tuple], super().format_table_constraint(expr)) + def format_add_table_constraint_action( self, action: "AddTableConstraint", ) -> Tuple[str, tuple]: - """Format ADD CONSTRAINT action with PostgreSQL-specific extensions. - - Extends the base class implementation with: - - EXCLUDE constraint support (PG-specific constraint type) - - NOT VALID suffix (PG-specific: skip validation of existing rows) - """ - from rhosocial.activerecord.backend.expression.statements import ( - TableConstraintType, ConstraintValidation, - ) + from rhosocial.activerecord.backend.expression.statements import TableConstraintType - # Handle EXCLUDE constraint (PG-specific, not in base class) - if action.constraint.constraint_type == TableConstraintType.EXCLUDE: - parts = [] + constraint_type = normalize_table_constraint_type(action.constraint.constraint_type) + if constraint_type == TableConstraintType.EXCLUDE: + if getattr(action.constraint, "enforced", None) is not None: + self._require_constraint_enforced(constraint_type) + validation_sql = self._format_constraint_validation(action.constraint) exclude_sql, params = self._format_exclude_constraint(action.constraint) - parts.append(exclude_sql) - - # NOT VALID suffix - if getattr(action.constraint, 'validation', None) == ConstraintValidation.NOVALIDATE: - parts.append("NOT VALID") - - return f"ADD {' '.join(parts)}", tuple(params) - - # Use base class for standard formatting (includes DEFERRABLE) - sql, params = super().format_add_table_constraint_action(action) - - # PostgreSQL NOT VALID suffix - if getattr(action.constraint, 'validation', None) == ConstraintValidation.NOVALIDATE: - sql += " NOT VALID" - - return sql, params + return f"ADD {exclude_sql}{validation_sql}", tuple(params) + return cast(Tuple[str, tuple], super().format_add_table_constraint_action(action)) + + def format_alter_constraint_action(self, action: "AlterConstraint") -> Tuple[str, tuple]: + constraint_type = normalize_table_constraint_type(action.constraint_type) + self._require_enforceable_constraint(constraint_type) + return cast(Tuple[str, tuple], super().format_alter_constraint_action(action)) + + def format_validate_constraint_action(self, action: "ValidateConstraint") -> Tuple[str, tuple]: + return cast(Tuple[str, tuple], super().format_validate_constraint_action(action)) + + @staticmethod + def _validate_exclude_expression_sql(expression_sql: str, context: str) -> None: + if not isinstance(expression_sql, str) or not expression_sql.strip(): + raise ValueError(f"{context} must render a non-empty SQL expression") + depth = 0 + quote: Any = None + index = 0 + while index < len(expression_sql): + character = expression_sql[index] + if quote is not None: + if character == quote: + if index + 1 < len(expression_sql) and expression_sql[index + 1] == quote: + index += 2 + continue + quote = None + elif character == "\\" and quote == "'": + raise ValueError(f"{context} contains an unsupported string escape") + index += 1 + continue + if character in {"'", '"'}: + quote = character + elif character == "(": + depth += 1 + elif character == ")": + depth -= 1 + if depth < 0: + raise ValueError(f"{context} has unbalanced parentheses") + elif character == ";": + raise ValueError(f"{context} must not contain statement separators") + elif character == "-" and expression_sql[index : index + 2] == "--": + raise ValueError(f"{context} must not contain SQL comments") + elif character == "/" and expression_sql[index : index + 2] == "/*": + raise ValueError(f"{context} must not contain SQL comments") + index += 1 + if quote is not None or depth != 0: + raise ValueError(f"{context} has unbalanced quotes or parentheses") def _format_exclude_constraint( self, constraint: "TableConstraint", ) -> Tuple[str, tuple]: - """Format EXCLUDE constraint (PostgreSQL-specific). - - EXCLUDE constraints are built as ``PostgresExcludeConstraint``: - - ``elements``: List of (expression, operator) tuples - e.g., [('range', '&&')] for EXCLUDE USING gist (range WITH &&) - - ``using``: The index access method (default 'gist') - e.g., 'gist', 'btree', 'spgist' - - ``where``: Optional predicate for partial exclusion constraints - - Example: - PostgresExcludeConstraint( - dialect, - name='exclude_range_overlap', - elements=[('range', '&&')], - using='gist', - ) - # Generates: EXCLUDE USING gist (range WITH &&) - - """ - params: list = [] - - if constraint.name: - parts = ["CONSTRAINT", self.format_identifier(constraint.name)] - else: - parts = [] - - # USING clause - validate index access method. - valid_using = frozenset({"gist", "btree", "spgist", "hash", "gin", "brin"}) - using = getattr(constraint, "using", "gist") or "gist" + valid_using = frozenset({"gist", "btree", "spgist", "hash", "brin"}) + using = getattr(constraint, "using", "gist") + if using is None: + using = "gist" + if not isinstance(using, str): + raise ValueError("EXCLUDE index access method must be a string") + using = using.strip().lower() if using not in valid_using: raise ValueError( - f"Invalid index access method '{using}': must be one of {valid_using}" + f"Invalid index access method '{using}': must be one of {sorted(valid_using)}" ) - parts.append(f"EXCLUDE USING {using}") - - # Elements: (expression, operator) pairs - validate operators. - valid_ops = frozenset({ - "=", "<", "<=", ">", ">=", "<>", - "&&", "@>", "<@", "<<", ">>", "&<", "&>", - "~=", "@@", "?|", "?&", "is", "is not", - }) - exclude_elements = [] - for expr, op in getattr(constraint, "elements", None) or []: - if op not in valid_ops: - raise ValueError( - f"Invalid exclude operator '{op}': must be one of {valid_ops}" - ) - if isinstance(expr, str): - exclude_elements.append(f"{self.format_identifier(expr)} WITH {op}") - else: - expr_sql, expr_params = expr.to_sql() - params.extend(expr_params) - exclude_elements.append(f"{expr_sql} WITH {op}") - if exclude_elements: - parts.append(f"({', '.join(exclude_elements)})") + elements = list(getattr(constraint, "elements", None) or []) + if not elements: + raise ValueError("EXCLUDE constraint requires at least one element") + + params: List[Any] = [] + rendered_elements: List[str] = [] + for element in elements: + if not isinstance(element, (tuple, list)) or len(element) != 2: + raise ValueError("each EXCLUDE element must be an expression/operator pair") + expression, operator = element + if not isinstance(operator, str): + raise ValueError("EXCLUDE operator must be a string") + operator = "".join(operator.strip().lower().split()) + if operator in {"is", "isnot"} or re.fullmatch( + r"[a-z0-9_+\-*/<>=~!@#%^&|?$]+", operator + ) is None: + raise ValueError(f"Invalid exclude operator '{operator}'") + if isinstance(expression, str): + if not expression.strip(): + raise ValueError("EXCLUDE element expression must not be empty") + if "(" in expression or ")" in expression: + raise ValueError( + "parenthesized EXCLUDE elements must be supplied as SQL expressions" + ) + expression_sql = self.format_identifier(expression) + else: + to_sql = getattr(expression, "to_sql", None) + if not callable(to_sql): + raise TypeError("EXCLUDE element expression must be an identifier or SQL expression") + if getattr(expression, "dialect", None) is not self: + raise ValueError("EXCLUDE element expression must use the PostgreSQL dialect") + expression_sql, expression_params = to_sql() + if expression_params: + raise ValueError("EXCLUDE expression must not contain bind parameters") + self._validate_exclude_expression_sql(expression_sql, "EXCLUDE element expression") + expression_sql = f"({expression_sql})" + rendered_elements.append(f"{expression_sql} WITH {operator}") + + parts: List[str] = [] + if constraint.name: + parts.extend(("CONSTRAINT", self.format_identifier(constraint.name))) + parts.extend(("EXCLUDE", "USING", using, f"({', '.join(rendered_elements)})")) - # WHERE clause for partial exclusion constraint where_expr = getattr(constraint, "where", None) if where_expr is not None: - where_sql, where_params = where_expr.to_sql() + where_to_sql = getattr(where_expr, "to_sql", None) + if not callable(where_to_sql): + raise TypeError("EXCLUDE where must be a SQL expression") + if getattr(where_expr, "dialect", None) is not self: + raise ValueError("EXCLUDE where expression must use the PostgreSQL dialect") + where_sql, where_params = where_to_sql() + if where_params: + raise ValueError("EXCLUDE where expression must not contain bind parameters") + self._validate_exclude_expression_sql(where_sql, "EXCLUDE where expression") params.extend(where_params) parts.append(f"WHERE ({where_sql})") - # DEFERRABLE / NOT DEFERRABLE - if constraint.deferrable is True: - if constraint.initially_deferred is True: + if getattr(constraint, "deferrable", None) is True: + if getattr(constraint, "initially_deferred", None) is True: parts.append("DEFERRABLE INITIALLY DEFERRED") - elif constraint.initially_deferred is False: + elif getattr(constraint, "initially_deferred", None) is False: parts.append("DEFERRABLE INITIALLY IMMEDIATE") else: parts.append("DEFERRABLE") - elif constraint.deferrable is False: + elif getattr(constraint, "deferrable", None) is False: parts.append("NOT DEFERRABLE") - return ' '.join(parts), tuple(params) + return " ".join(parts), tuple(params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/repack.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/repack.py new file mode 100644 index 00000000..e10c7ffa --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/repack.py @@ -0,0 +1,125 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/repack.py +"""PostgreSQL 19 native REPACK formatting and validation.""" + +from typing import List, Optional, Sequence, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.repack import PostgresRepackExpression + + +class PostgresRepackMixin: + """PostgreSQL native REPACK support, separate from the pg_repack extension.""" + + def supports_repack(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_repack_all_tables(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_repack_using_index(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_repack_concurrently(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_repack_analyze(self) -> bool: + return self.version >= (19, 0, 0) + + @staticmethod + def _repack_identifier(value: str, name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + if not value or "\x00" in value: + raise ValueError(f"{name} must be a non-empty identifier without NUL") + return value + + def _format_repack_identifier(self, value: str, name: str) -> str: + return self.format_identifier(self._repack_identifier(value, name)) + + @staticmethod + def _repack_columns(value: Optional[Sequence[str]]) -> Optional[List[str]]: + if value is None: + return None + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise TypeError("REPACK columns must be a sequence of identifiers") + columns = list(value) + if not columns: + raise ValueError("REPACK columns must not be empty") + if any(not isinstance(column, str) or not column or "\x00" in column for column in columns): + raise ValueError("REPACK columns contain an invalid identifier") + if len(columns) != len(set(columns)): + raise ValueError("REPACK columns must not contain duplicates") + return columns + + def format_repack_statement( + self, + expr: "PostgresRepackExpression", + ) -> Tuple[str, tuple]: + """Format a native PostgreSQL 19 REPACK statement.""" + if not self.supports_repack(): + raise UnsupportedFeatureError( + self.name, + "REPACK", + suggestion="requires PostgreSQL 19+", + ) + for name, value in ( + ("verbose", expr.verbose), + ("analyze", expr.analyze), + ("concurrently", expr.concurrently), + ): + if not isinstance(value, bool): + raise TypeError(f"{name} must be a bool") + columns = self._repack_columns(expr.columns) + if columns is not None and not expr.analyze: + raise ValueError("REPACK columns require ANALYZE") + if expr.analyze and expr.table_name is None: + raise ValueError("REPACK ANALYZE requires one table") + if expr.schema is not None and expr.table_name is None: + raise ValueError("REPACK schema requires one table") + if not isinstance(expr.all_using_index, bool): + raise TypeError("all_using_index must be a bool") + using_index = expr.using_index + if using_index is not None and not isinstance(using_index, (bool, str)): + raise TypeError("using_index must be a bool, string, or None") + if isinstance(using_index, str): + if not using_index: + raise ValueError("using_index must not be empty") + if "\x00" in using_index: + raise ValueError("using_index contains NUL") + if expr.table_name is None: + raise ValueError("a named REPACK index requires one table") + if expr.all_using_index and expr.table_name is not None: + raise ValueError("all_using_index cannot be combined with a table") + if expr.all_using_index and using_index not in {None, False, True}: + raise ValueError("all_using_index conflicts with using_index") + if expr.table_name is None and expr.concurrently: + raise ValueError("REPACK CONCURRENTLY requires one table") + parts = ["REPACK"] + options = [] + if expr.verbose: + options.append("VERBOSE") + if expr.analyze: + options.append("ANALYZE") + if expr.concurrently: + options.append("CONCURRENTLY") + if options: + parts.append("({})".format(", ".join(options))) + use_index = expr.all_using_index or using_index is True + if expr.table_name is not None: + table = self._format_repack_identifier(expr.table_name, "table_name") + if expr.schema is not None: + table = f"{self._format_repack_identifier(expr.schema, 'schema')}.{table}" + parts.append(table) + if columns is not None: + parts.append( + "({})".format(", ".join(self._format_repack_identifier(column, "column") for column in columns)) + ) + if use_index or isinstance(using_index, str): + parts.append("USING INDEX") + elif use_index: + parts.append("USING INDEX") + if isinstance(using_index, str): + parts.append(self._format_repack_identifier(using_index, "using_index")) + return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py index 9cd264f7..317221ce 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py @@ -2,12 +2,14 @@ """DML-related PostgreSQL mixins.""" from .vacuum import PostgresVacuumMixin +from .copy import PostgresCopyMixin from .stored_procedure import PostgresStoredProcedureMixin from .extended_statistics import PostgresExtendedStatisticsMixin from .locking import PostgresLockingMixin __all__ = [ "PostgresVacuumMixin", + "PostgresCopyMixin", "PostgresStoredProcedureMixin", "PostgresExtendedStatisticsMixin", "PostgresLockingMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/copy.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/copy.py new file mode 100644 index 00000000..d07f4aea --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/copy.py @@ -0,0 +1,603 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/copy.py +"""PostgreSQL COPY statement formatting and version gates.""" + +import re +from typing import Any, List, Optional, Sequence, Tuple, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from ...expression.copy import PostgresCopyFromExpression, PostgresCopyToExpression + + +class PostgresCopyMixin: + """PostgreSQL COPY support restricted to client-side streams.""" + + def supports_copy_to(self) -> bool: + return self.version >= (9, 6, 0) + + def supports_copy_from(self) -> bool: + return self.version >= (9, 6, 0) + + def supports_copy_json(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_copy_from_json(self) -> bool: + return False + + def supports_copy_force_array(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_copy_force_all(self) -> bool: + return self.version >= (17, 0, 0) + + def supports_copy_partitioned_table_to(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_copy_text_header(self) -> bool: + return self.version >= (15, 0, 0) + + def supports_copy_header_match(self) -> bool: + return self.version >= (15, 0, 0) + + def supports_copy_header_line_count(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_copy_on_error(self) -> bool: + return self.version >= (17, 0, 0) + + def supports_copy_reject_limit(self) -> bool: + return self.version >= (18, 0, 0) + + def supports_copy_set_null(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_copy_log_verbosity(self) -> bool: + return self.version >= (17, 0, 0) + + def supports_copy_silent_log(self) -> bool: + return self.version >= (18, 0, 0) + + def _require_copy_version( + self, + supported: bool, + feature: str, + minimum: Tuple[int, int, int], + ) -> None: + if not supported: + major, minor, _ = minimum + raise UnsupportedFeatureError( + self.name, + feature, + suggestion=f"requires PostgreSQL {major}.{minor}+", + ) + + @staticmethod + def _scan_copy_sql(sql: str) -> Tuple[List[str], bool]: + words: List[str] = [] + has_separator = False + index = 0 + length = len(sql) + while index < length: + if sql.startswith("--", index): + newline = sql.find("\n", index + 2) + index = length if newline < 0 else newline + 1 + continue + if sql.startswith("/*", index): + end = sql.find("*/", index + 2) + if end < 0: + raise ValueError("COPY SQL contains an unterminated block comment") + index = end + 2 + continue + if sql[index] == "'": + index += 1 + while index < length: + if sql[index] == "\\" and index + 1 < length: + index += 2 + continue + if sql[index] != "'": + index += 1 + continue + if index + 1 < length and sql[index + 1] == "'": + index += 2 + continue + index += 1 + break + else: + raise ValueError("COPY SQL contains an unterminated string literal") + continue + if sql[index] == '"': + index += 1 + while index < length: + if sql[index] == '"': + if index + 1 < length and sql[index + 1] == '"': + index += 2 + continue + index += 1 + break + index += 1 + else: + raise ValueError("COPY SQL contains an unterminated quoted identifier") + continue + if sql[index] == "$": + match = re.match(r"\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$", sql[index:]) + if match: + tag = match.group(0) + end = sql.find(tag, index + len(tag)) + if end < 0: + raise ValueError("COPY SQL contains an unterminated dollar string") + index = end + len(tag) + continue + if sql[index] == ";": + has_separator = True + index += 1 + continue + if sql[index].isalpha() or sql[index] == "_": + end = index + 1 + while end < length and (sql[end].isalnum() or sql[end] in "_$"): + end += 1 + words.append(sql[index:end].upper()) + index = end + continue + index += 1 + return words, has_separator + + def _validate_copy_query(self, sql: str) -> None: + if not isinstance(sql, str) or not sql.strip(): + raise ValueError("COPY TO query rendered an empty SQL value") + if "\x00" in sql: + raise ValueError("COPY TO query contains a NUL character") + words, has_separator = self._scan_copy_sql(sql) + if has_separator: + raise ValueError("COPY TO query must contain exactly one read-only statement") + if not words or words[0] not in {"SELECT", "VALUES", "WITH", "TABLE"}: + raise ValueError("COPY TO query must start with SELECT, VALUES, WITH, or TABLE") + blocked = { + "ALTER", + "ANALYZE", + "COPY", + "CREATE", + "DELETE", + "DROP", + "GRANT", + "INSERT", + "INTO", + "MERGE", + "REPACK", + "REVOKE", + "TRUNCATE", + "UPDATE", + "VACUUM", + } + if blocked.intersection(words[1:]): + raise ValueError("COPY TO query must be read-only") + + def _validate_copy_predicate(self, sql: str) -> None: + if not isinstance(sql, str) or not sql.strip(): + raise ValueError("COPY FROM WHERE rendered an empty SQL value") + if "\x00" in sql: + raise ValueError("COPY FROM WHERE contains a NUL character") + _, has_separator = self._scan_copy_sql(sql) + if has_separator: + raise ValueError("COPY FROM WHERE must contain one SQL expression") + + def _validate_copy_child_dialect(self, expression: BaseExpression, name: str) -> None: + try: + child_dialect = expression.dialect + except ValueError as error: + raise TypeError(f"{name} must have a bound dialect") from error + if child_dialect is not self: + raise ValueError(f"{name} must use the PostgreSQL dialect") + + @staticmethod + def _copy_identifier(value: str, name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + if not value or "\x00" in value: + raise ValueError(f"{name} must be a non-empty identifier without NUL") + return value + + def _format_copy_identifier(self, value: str, name: str) -> str: + return self.format_identifier(self._copy_identifier(value, name)) + + @staticmethod + def _copy_column_list(values: Optional[Sequence[str]], name: str) -> Optional[List[str]]: + if values is None: + return None + if isinstance(values, (str, bytes)) or not isinstance(values, Sequence): + raise TypeError(f"{name} must be a sequence of identifiers") + columns = list(values) + if not columns: + raise ValueError(f"{name} must not be empty") + for column in columns: + if not isinstance(column, str): + raise TypeError(f"{name} must contain only string identifiers") + if not column or "\x00" in column: + raise ValueError(f"{name} contains an invalid identifier") + if len(columns) != len(set(columns)): + raise ValueError(f"{name} must not contain duplicates") + return columns + + @staticmethod + def _copy_string(value: Any, name: str, allow_empty: bool = True) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + if "\x00" in value: + raise ValueError(f"{name} must not contain NUL") + if not allow_empty and not value: + raise ValueError(f"{name} must not be empty") + return value + + @staticmethod + def _copy_character(value: Any, name: str) -> str: + character = PostgresCopyMixin._copy_string(value, name, allow_empty=False) + if len(character) != 1 or ord(character) > 127: + raise ValueError(f"{name} must be one single-byte character") + return character + + def _format_copy_string(self, value: Any, name: str) -> str: + string = self._copy_string(value, name) + escaped = string.replace("\\", "\\\\").replace("'", "''") + prefix = "E" if "\\" in string else "" + return f"{prefix}'{escaped}'" + + def _normalize_copy_format(self, value: Any, direction: str) -> str: + copy_format = str(getattr(value, "value", value)).lower() + if copy_format not in {"text", "csv", "json", "binary"}: + raise ValueError("COPY format must be text, csv, json, or binary") + if direction == "from" and copy_format == "json": + raise ValueError("PostgreSQL COPY FROM does not support JSON format") + return copy_format + + def _normalize_copy_header( + self, + value: Any, + direction: str, + copy_format: Optional[str] = None, + ) -> Optional[Union[bool, int, str]]: + if value is None: + return None + if isinstance(value, bool): + normalized: Union[bool, int, str] = value + elif isinstance(value, int): + if direction == "to" and value not in {0, 1}: + raise ValueError("COPY TO HEADER integer must be 0 or 1") + if value < 0: + raise ValueError("COPY HEADER integer must be non-negative") + normalized = value + elif isinstance(value, str): + if direction != "from" or value.strip().upper() != "MATCH": + raise ValueError("COPY TO does not support HEADER MATCH") + normalized = "MATCH" + else: + raise TypeError("COPY HEADER must be a bool, non-negative integer, or MATCH") + if copy_format == "text" and normalized not in (False, 0): + self._require_copy_version( + self.supports_copy_text_header(), + f"COPY {direction.upper()} text HEADER", + (15, 0, 0), + ) + if isinstance(normalized, int) and normalized > 1: + self._require_copy_version( + self.supports_copy_header_line_count(), + f"COPY {direction.upper()} HEADER line count", + (19, 0, 0), + ) + if normalized == "MATCH": + self._require_copy_version( + self.supports_copy_header_match(), + "COPY FROM HEADER MATCH", + (15, 0, 0), + ) + return normalized + + @staticmethod + def _format_copy_header(value: object) -> str: + if value is True: + return "HEADER" + if value is False: + return "HEADER FALSE" + if isinstance(value, int): + return f"HEADER {value}" + return "HEADER MATCH" + + def _normalize_force_columns( + self, + value: Any, + name: str, + target_columns: Optional[Sequence[str]], + ) -> Optional[Union[str, List[str]]]: + if value is None: + return None + if isinstance(value, str) and value == "*": + return "*" + if isinstance(value, str): + normalized_columns = [value] + else: + copied_columns = self._copy_column_list(value, name) + if copied_columns is None: + return None + normalized_columns = copied_columns + if target_columns is not None: + missing = set(normalized_columns) - set(target_columns) + if missing: + raise ValueError(f"{name} names columns absent from the COPY column list") + return normalized_columns + + def _format_copy_to_source( + self, + expr: "PostgresCopyToExpression", + ) -> Tuple[str, Tuple[Any, ...]]: + if (expr.table_name is None) == (expr.query is None): + raise ValueError("COPY TO requires exactly one table_name or query source") + if expr.query is not None: + if expr.schema is not None or expr.columns is not None or expr.partitioned: + raise ValueError("schema, columns, and partitioned are valid only for table COPY TO") + if not isinstance(expr.query, BaseExpression): + raise TypeError("COPY TO query must be a BaseExpression") + self._validate_copy_child_dialect(expr.query, "COPY TO query") + query_sql, params = expr.query.to_sql() + self._validate_copy_query(query_sql) + return f"({query_sql})", tuple(params) + if not isinstance(expr.table_name, str): + raise TypeError("COPY TO table_name must be a string") + table = self._format_copy_identifier(expr.table_name, "table_name") + if expr.schema is not None: + table = f"{self._format_copy_identifier(expr.schema, 'schema')}.{table}" + columns = self._copy_column_list(expr.columns, "columns") + if columns is not None: + table = "{} ({})".format( + table, + ", ".join(self._format_copy_identifier(column, "column") for column in columns), + ) + return table, () + + def format_copy_to_statement( + self, + expr: "PostgresCopyToExpression", + ) -> Tuple[str, Tuple[Any, ...]]: + """Format a client-stream-only ``COPY ... TO STDOUT`` statement.""" + self._require_copy_version(self.supports_copy_to(), "COPY TO", (9, 6, 0)) + copy_format = self._normalize_copy_format(expr.format, "to") + if copy_format == "json": + self._require_copy_version( + self.supports_copy_json(), + "COPY TO JSON format", + (19, 0, 0), + ) + if not isinstance(expr.partitioned, bool): + raise TypeError("partitioned must be a bool") + if expr.partitioned: + self._require_copy_version( + self.supports_copy_partitioned_table_to(), + "COPY TO partitioned table", + (19, 0, 0), + ) + source, params = self._format_copy_to_source(expr) + columns = self._copy_column_list(expr.columns, "columns") + options = [f"FORMAT {copy_format.upper()}"] + header = self._normalize_copy_header(expr.header, "to", copy_format) + if header is not None: + if copy_format in {"binary", "json"}: + raise ValueError(f"HEADER is not allowed with COPY TO {copy_format.upper()}") + options.append(self._format_copy_header(header)) + if expr.null is not None: + if copy_format in {"binary", "json"}: + raise ValueError(f"NULL is not allowed with COPY TO {copy_format.upper()}") + options.append(f"NULL {self._format_copy_string(expr.null, 'null')}") + if expr.delimiter is not None: + if copy_format not in {"text", "csv"}: + raise ValueError(f"DELIMITER is not allowed with COPY TO {copy_format.upper()}") + delimiter = self._copy_character(expr.delimiter, "delimiter") + options.append(f"DELIMITER {self._format_copy_string(delimiter, 'delimiter')}") + if expr.quote is not None or expr.escape is not None: + if copy_format != "csv": + raise ValueError("QUOTE and ESCAPE require COPY TO CSV format") + if expr.quote is not None: + quote = self._copy_character(expr.quote, "quote") + options.append(f"QUOTE {self._format_copy_string(quote, 'quote')}") + if expr.escape is not None: + escape = self._copy_character(expr.escape, "escape") + options.append(f"ESCAPE {self._format_copy_string(escape, 'escape')}") + if expr.force_quote is not None: + if copy_format != "csv": + raise ValueError("FORCE_QUOTE requires COPY TO CSV format") + force_quote = self._normalize_force_columns( + expr.force_quote, + "force_quote", + columns, + ) + if expr.query is not None and force_quote != "*": + raise ValueError("query COPY TO FORCE_QUOTE requires '*'") + if force_quote == "*": + options.append("FORCE_QUOTE *") + elif force_quote: + options.append( + "FORCE_QUOTE ({})".format( + ", ".join( + self._format_copy_identifier(column, "force_quote column") + for column in force_quote + ) + ) + ) + if expr.force_array is not None: + if not isinstance(expr.force_array, bool): + raise TypeError("force_array must be a bool") + if copy_format != "json": + raise ValueError("FORCE_ARRAY requires COPY TO JSON format") + self._require_copy_version( + self.supports_copy_force_array(), + "COPY TO FORCE_ARRAY", + (19, 0, 0), + ) + value = "" if expr.force_array else " FALSE" + options.append(f"FORCE_ARRAY{value}") + return f"COPY {source} TO STDOUT ({', '.join(options)})", params + + def _format_copy_from_where( + self, + expression: BaseExpression, + ) -> Tuple[str, Tuple[Any, ...]]: + self._validate_copy_child_dialect(expression, "COPY FROM WHERE") + where_sql, params = expression.to_sql() + self._validate_copy_predicate(where_sql) + return where_sql, tuple(params) + + def format_copy_from_statement( + self, + expr: "PostgresCopyFromExpression", + ) -> Tuple[str, Tuple[Any, ...]]: + """Format a client-stream-only ``COPY ... FROM STDIN`` statement.""" + self._require_copy_version(self.supports_copy_from(), "COPY FROM", (9, 6, 0)) + copy_format = self._normalize_copy_format(expr.format, "from") + if not isinstance(expr.table_name, str): + raise TypeError("COPY FROM table_name must be a string") + table = self._format_copy_identifier(expr.table_name, "table_name") + if expr.schema is not None: + table = f"{self._format_copy_identifier(expr.schema, 'schema')}.{table}" + columns = self._copy_column_list(expr.columns, "columns") + if columns is not None: + table = "{} ({})".format( + table, + ", ".join(self._format_copy_identifier(column, "column") for column in columns), + ) + options = [f"FORMAT {copy_format.upper()}"] + header = self._normalize_copy_header(expr.header, "from", copy_format) + if header is not None: + if copy_format == "binary": + raise ValueError("HEADER is not allowed with COPY FROM BINARY") + options.append(self._format_copy_header(header)) + if expr.null is not None: + if copy_format == "binary": + raise ValueError("NULL is not allowed with COPY FROM BINARY") + options.append(f"NULL {self._format_copy_string(expr.null, 'null')}") + if expr.default is not None: + if copy_format == "binary": + raise ValueError("DEFAULT is not allowed with COPY FROM BINARY") + options.append(f"DEFAULT {self._format_copy_string(expr.default, 'default')}") + if expr.delimiter is not None: + if copy_format not in {"text", "csv"}: + raise ValueError(f"DELIMITER is not allowed with COPY FROM {copy_format.upper()}") + delimiter = self._copy_character(expr.delimiter, "delimiter") + options.append(f"DELIMITER {self._format_copy_string(delimiter, 'delimiter')}") + if expr.quote is not None or expr.escape is not None: + if copy_format != "csv": + raise ValueError("QUOTE and ESCAPE require COPY FROM CSV format") + if expr.quote is not None: + quote = self._copy_character(expr.quote, "quote") + options.append(f"QUOTE {self._format_copy_string(quote, 'quote')}") + if expr.escape is not None: + escape = self._copy_character(expr.escape, "escape") + options.append(f"ESCAPE {self._format_copy_string(escape, 'escape')}") + force_not_null = self._normalize_force_columns( + expr.force_not_null, + "force_not_null", + columns, + ) + if force_not_null is not None: + if copy_format != "csv": + raise ValueError("FORCE_NOT_NULL requires COPY FROM CSV format") + if force_not_null == "*": + self._require_copy_version( + self.supports_copy_force_all(), + "COPY FROM FORCE_NOT_NULL *", + (17, 0, 0), + ) + target = ( + "*" + if force_not_null == "*" + else "({})".format( + ", ".join( + self._format_copy_identifier(column, "force_not_null column") for column in force_not_null + ) + ) + ) + options.append(f"FORCE_NOT_NULL {target}") + force_null = self._normalize_force_columns( + expr.force_null, + "force_null", + columns, + ) + if force_null is not None: + if copy_format != "csv": + raise ValueError("FORCE_NULL requires COPY FROM CSV format") + if force_null == "*": + self._require_copy_version( + self.supports_copy_force_all(), + "COPY FROM FORCE_NULL *", + (17, 0, 0), + ) + target = ( + "*" + if force_null == "*" + else "({})".format( + ", ".join(self._format_copy_identifier(column, "force_null column") for column in force_null) + ) + ) + options.append(f"FORCE_NULL {target}") + if not isinstance(expr.freeze, bool): + raise TypeError("freeze must be a bool") + if expr.freeze: + options.append("FREEZE") + on_error = None + if expr.on_error is not None: + on_error = str(getattr(expr.on_error, "value", expr.on_error)).lower() + if on_error not in {"stop", "ignore", "set_null"}: + raise ValueError("ON_ERROR must be stop, ignore, or set_null") + self._require_copy_version( + self.supports_copy_on_error(), + "COPY FROM ON_ERROR", + (17, 0, 0), + ) + if on_error in {"ignore", "set_null"} and copy_format not in {"text", "csv"}: + raise ValueError("ON_ERROR ignore/set_null require text or csv format") + if on_error == "set_null": + self._require_copy_version( + self.supports_copy_set_null(), + "COPY FROM ON_ERROR SET_NULL", + (19, 0, 0), + ) + options.append(f"ON_ERROR {on_error.upper()}") + if expr.reject_limit is not None: + if isinstance(expr.reject_limit, bool) or not isinstance(expr.reject_limit, int): + raise TypeError("reject_limit must be an integer") + if expr.reject_limit <= 0: + raise ValueError("reject_limit must be positive") + if on_error != "ignore": + raise ValueError("REJECT_LIMIT requires ON_ERROR ignore") + self._require_copy_version( + self.supports_copy_reject_limit(), + "COPY FROM REJECT_LIMIT", + (18, 0, 0), + ) + options.append(f"REJECT_LIMIT {expr.reject_limit}") + if expr.encoding is not None: + encoding = self._copy_string(expr.encoding, "encoding", allow_empty=False) + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", encoding) is None: + raise ValueError("encoding must be a PostgreSQL encoding name") + options.append(f"ENCODING {self._format_copy_string(encoding, 'encoding')}") + if expr.log_verbosity is not None: + verbosity = str(getattr(expr.log_verbosity, "value", expr.log_verbosity)).lower() + if verbosity not in {"default", "verbose", "silent"}: + raise ValueError("LOG_VERBOSITY must be default, verbose, or silent") + self._require_copy_version( + self.supports_copy_log_verbosity(), + "COPY FROM LOG_VERBOSITY", + (17, 0, 0), + ) + if verbosity == "silent": + self._require_copy_version( + self.supports_copy_silent_log(), + "COPY FROM LOG_VERBOSITY SILENT", + (18, 0, 0), + ) + options.append(f"LOG_VERBOSITY {verbosity.upper()}") + sql = f"COPY {table} FROM STDIN ({', '.join(options)})" + params: Tuple[Any, ...] = () + if expr.where is not None: + if not isinstance(expr.where, BaseExpression): + raise TypeError("COPY FROM WHERE must be a BaseExpression") + where_sql, params = self._format_copy_from_where(expr.where) + sql = f"{sql} WHERE {where_sql}" + return sql, params 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 index 806bd957..bd29b21b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py @@ -1,38 +1,130 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py -"""PostgreSQL Property Graph Query (SQL/PGQ) support mixin. +"""PostgreSQL property graph feature gates and formatter safeguards. -PostgreSQL 19+ natively supports SQL/PGQ: -- GRAPH_TABLE (MATCH ... COLUMNS ...) -- CREATE / DROP / ALTER PROPERTY GRAPH -- Vertex/edge pattern matching with WHERE - -Known PG 19 PGQ limitations (not supported): -- Variable-length (quantified) path patterns (``+``, ``*``, ``{n,m}``) -- Comma-separated multiple patterns in a single MATCH clause -- Heterogeneous graphs require explicit edge label specification +PostgreSQL 19 Beta 4 withdrew SQL/PGQ. The formatter remains available only +through explicit feature overrides so a future implementation can opt in +without treating a server version as proof of support. """ +from typing import Callable, Mapping, Tuple, TYPE_CHECKING, cast + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.graph import ( + AlterPropertyGraphExpression, + ColumnsClause, + GraphEdge, + MatchClause, + ) + class PostgresPropertyGraphQueryMixin: - """PostgreSQL Property Graph Query support implementation. + """Fail-closed PostgreSQL property graph capabilities and validation.""" - SQL/PGQ is a native feature since PostgreSQL 19. - """ + GRAPH_FEATURE_NAMES = frozenset(("graph_match", "graph_table")) + ALTER_ACTIONS = { + "add": "ADD", + "drop": "DROP", + } + ALTER_TARGETS = { + "edge tables": "EDGE TABLES", + "tables": "TABLES", + "vertex tables": "VERTEX TABLES", + } def supports_graph_match(self) -> bool: - """MATCH clause is supported since PostgreSQL 19.""" - return self.version >= (19, 0, 0) + """Return the explicit graph MATCH capability override.""" + overrides = getattr(self, "_graph_feature_overrides", {}) or {} + return overrides.get("graph_match") is True def supports_quantified_path(self) -> bool: - """Quantified (variable-length) path patterns are **not** supported - in PostgreSQL 19.""" + """Return whether quantified graph paths are explicitly enabled.""" return False def supports_comma_separated_patterns(self) -> bool: - """Comma-separated multi-pattern MATCH is **not** supported - in PostgreSQL 19.""" + """Return whether comma-separated graph patterns are explicitly enabled.""" return False def supports_graph_table(self) -> bool: - """GRAPH_TABLE expression is supported since PostgreSQL 19.""" - return self.version >= (19, 0, 0) + """Return the explicit GRAPH_TABLE capability override.""" + overrides = getattr(self, "_graph_feature_overrides", {}) or {} + return ( + overrides.get("graph_match") is True + and overrides.get("graph_table") is True + ) + + def _runtime_attribute(self, name: str) -> object: + return getattr(self, name) + + def _require_graph_match(self) -> None: + if not self.supports_graph_match(): + dialect_name = cast(str, self._runtime_attribute("name")) + raise UnsupportedFeatureError(dialect_name, "graph MATCH clause") + + def _require_graph_table(self, feature_name: str) -> None: + if not self.supports_graph_table(): + dialect_name = cast(str, self._runtime_attribute("name")) + raise UnsupportedFeatureError(dialect_name, feature_name) + + def _delegate_graph_formatter( + self, method_name: str, expression: object + ) -> Tuple[str, tuple]: + formatter = cast( + Callable[[object], Tuple[str, tuple]], + getattr(super(), method_name), + ) + return formatter(expression) + + def format_graph_edge(self, edge: "GraphEdge") -> Tuple[str, tuple]: + """Validate and format a graph edge.""" + self._require_graph_match() + + from rhosocial.activerecord.backend.expression.graph import GraphEdge, GraphEdgeDirection + + if not isinstance(edge, GraphEdge): + raise TypeError("GraphEdge must be a GraphEdge expression") + if edge.table is not None and edge.variable is None: + raise ValueError("GraphEdge table requires a variable") + if not isinstance(edge.direction, GraphEdgeDirection): + raise ValueError(f"Invalid graph edge direction: {edge.direction!r}") + + return self._delegate_graph_formatter("format_graph_edge", edge) + + def format_match_clause(self, clause: "MatchClause") -> Tuple[str, tuple]: + """Validate and format a MATCH clause.""" + self._require_graph_match() + + return self._delegate_graph_formatter("format_match_clause", clause) + + def format_graph_columns_clause(self, columns: "ColumnsClause") -> Tuple[str, tuple]: + """Format a GRAPH_TABLE COLUMNS clause after checking capability.""" + self._require_graph_table("GRAPH_TABLE COLUMNS clause") + return self._delegate_graph_formatter("format_graph_columns_clause", columns) + + @staticmethod + def _normalize_alter_keyword(value: object, allowed: Mapping[str, str], label: str) -> str: + if isinstance(value, str): + normalized = allowed.get(value.casefold()) + if normalized is not None: + return normalized + raise ValueError(f"Invalid ALTER PROPERTY GRAPH {label}: {value!r}") + + def format_alter_property_graph_statement( + self, expr: "AlterPropertyGraphExpression" + ) -> Tuple[str, tuple]: + """Validate and format an ALTER PROPERTY GRAPH statement.""" + self._require_graph_table("ALTER PROPERTY GRAPH") + + from rhosocial.activerecord.backend.expression.graph import AlterPropertyGraphExpression + + if not isinstance(expr, AlterPropertyGraphExpression): + raise TypeError( + "ALTER PROPERTY GRAPH expression must be an " + "AlterPropertyGraphExpression" + ) + self._normalize_alter_keyword(expr.action, self.ALTER_ACTIONS, "action") + self._normalize_alter_keyword(expr.target, self.ALTER_TARGETS, "target") + return self._delegate_graph_formatter( + "format_alter_property_graph_statement", expr + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py index 49db620f..cdcf0ca1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py @@ -29,7 +29,13 @@ def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tu - ``expr.cascade`` — add ``CASCADE``. """ parts = ["TRUNCATE TABLE"] - parts.append(self.format_identifier(expr.table_name)) + table_name = self.format_identifier(expr.table_name) + if expr.schema: + table_name = ( + f"{self.format_identifier(expr.schema)}." + f"{table_name}" + ) + parts.append(table_name) if expr.restart_identity: if not self.supports_truncate_restart_identity(): diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py index d844bcdf..6f5eeb91 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py @@ -25,6 +25,7 @@ from .ddl.rls_config import PostgresRlsConfigSupport from .ddl.table_settings import PostgresAlterTableSettingsSupport from .ddl.cluster import PostgresClusterSupport +from .ddl.repack import PostgresRepackSupport from .ddl.domain import PostgresDomainSupport from .ddl.collation import PostgresCollationDDLSupport from .ddl.foreign_table import PostgresForeignTableDDLSupport @@ -33,6 +34,7 @@ # DML protocols from .dml.vacuum import PostgresVacuumSupport +from .dml.copy import PostgresCopySupport from .dml.stored_procedure import PostgresStoredProcedureSupport from .dml.extended_statistics import PostgresExtendedStatisticsSupport from .dml.advisory_lock import PostgresAdvisoryLockSupport @@ -124,6 +126,7 @@ "PostgresRlsConfigSupport", "PostgresAlterTableSettingsSupport", "PostgresClusterSupport", + "PostgresRepackSupport", "PostgresDomainSupport", "PostgresCollationDDLSupport", "PostgresForeignTableDDLSupport", @@ -131,6 +134,7 @@ "PostgresPublicationSupport", # DML protocols "PostgresVacuumSupport", + "PostgresCopySupport", "PostgresStoredProcedureSupport", "PostgresExtendedStatisticsSupport", "PostgresAdvisoryLockSupport", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py index 26f778a5..31555ee9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py @@ -11,6 +11,7 @@ from .rls_config import PostgresRlsConfigSupport from .table_settings import PostgresAlterTableSettingsSupport from .cluster import PostgresClusterSupport +from .repack import PostgresRepackSupport from .domain import PostgresDomainSupport from .collation import PostgresCollationDDLSupport from .foreign_table import PostgresForeignTableDDLSupport @@ -28,6 +29,7 @@ "PostgresRlsConfigSupport", "PostgresAlterTableSettingsSupport", "PostgresClusterSupport", + "PostgresRepackSupport", "PostgresDomainSupport", "PostgresCollationDDLSupport", "PostgresForeignTableDDLSupport", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py index c799b56d..5be435ae 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py @@ -13,12 +13,18 @@ - EXCLUDE constraints """ -from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING +from typing import Optional, Protocol, runtime_checkable, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.protocols import ConstraintSupport if TYPE_CHECKING: # pragma: no cover - from rhosocial.activerecord.backend.expression.statements import AddTableConstraint + from rhosocial.activerecord.backend.expression.statements import ( + AddTableConstraint, + AlterConstraint, + TableConstraint, + TableConstraintType, + ValidateConstraint, + ) @runtime_checkable @@ -36,6 +42,24 @@ class PostgresConstraintSupport(ConstraintSupport, Protocol): - EXCLUDE: https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-EXCLUSION """ + def supports_constraint_enforced( + self, + constraint_type: Optional["TableConstraintType"] = None, + ) -> bool: + """Whether CREATE/ADD constraint enforcement control is supported.""" + ... + + def supports_alter_constraint_enforced( + self, + constraint_type: Optional["TableConstraintType"] = None, + ) -> bool: + """Whether ALTER CONSTRAINT enforcement control is supported.""" + ... + + def supports_validate_constraint(self) -> bool: + """Whether VALIDATE CONSTRAINT is supported.""" + ... + def supports_constraint_novalidate(self) -> bool: """Whether NOT VALID constraint option is supported. @@ -70,15 +94,17 @@ def supports_drop_constraint_if_exists(self) -> bool: def format_add_table_constraint_action( self, action: "AddTableConstraint" ) -> Tuple[str, tuple]: - """Format ALTER TABLE ADD CONSTRAINT with PostgreSQL extensions. + """Format ALTER TABLE ADD CONSTRAINT with PostgreSQL extensions.""" + ... - Adds EXCLUDE constraint support and the PostgreSQL-proprietary - ``NOT VALID`` suffix to the standard formatting. + def format_table_constraint(self, expr: "TableConstraint") -> Tuple[str, tuple]: + """Format CREATE TABLE constraints with PostgreSQL extensions.""" + ... - Args: - action: AddTableConstraint action to format. + def format_alter_constraint_action(self, action: "AlterConstraint") -> Tuple[str, tuple]: + """Format ALTER CONSTRAINT enforcement.""" + ... - Returns: - Tuple of (SQL string, parameters tuple) - """ + def format_validate_constraint_action(self, action: "ValidateConstraint") -> Tuple[str, tuple]: + """Format VALIDATE CONSTRAINT.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/repack.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/repack.py new file mode 100644 index 00000000..a762e362 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/repack.py @@ -0,0 +1,39 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/repack.py +"""Runtime protocol for native PostgreSQL REPACK support.""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.repack import PostgresRepackExpression + + +@runtime_checkable +class PostgresRepackSupport(Protocol): + """PostgreSQL 19 native REPACK protocol.""" + + def supports_repack(self) -> bool: + """Whether native REPACK is supported.""" + ... + + def supports_repack_all_tables(self) -> bool: + """Whether whole-database REPACK is supported.""" + ... + + def supports_repack_using_index(self) -> bool: + """Whether REPACK USING INDEX is supported.""" + ... + + def supports_repack_concurrently(self) -> bool: + """Whether REPACK CONCURRENTLY is supported.""" + ... + + def supports_repack_analyze(self) -> bool: + """Whether REPACK ANALYZE is supported.""" + ... + + def format_repack_statement( + self, + expr: "PostgresRepackExpression", + ) -> Tuple[str, tuple]: + """Format a native REPACK statement.""" + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py index 7e25d59c..66a84b84 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py @@ -2,12 +2,14 @@ """DML-related PostgreSQL protocols.""" from .vacuum import PostgresVacuumSupport +from .copy import PostgresCopySupport from .stored_procedure import PostgresStoredProcedureSupport from .extended_statistics import PostgresExtendedStatisticsSupport from .locking import PostgresLockingSupport __all__ = [ "PostgresVacuumSupport", + "PostgresCopySupport", "PostgresStoredProcedureSupport", "PostgresExtendedStatisticsSupport", "PostgresLockingSupport", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/copy.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/copy.py new file mode 100644 index 00000000..17980d97 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/copy.py @@ -0,0 +1,86 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/copy.py +"""Runtime protocol for PostgreSQL COPY expression support.""" + +from typing import Any, Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.copy import PostgresCopyFromExpression, PostgresCopyToExpression + + +@runtime_checkable +class PostgresCopySupport(Protocol): + """PostgreSQL client-stream COPY protocol.""" + + def supports_copy_to(self) -> bool: + """Whether COPY TO STDOUT is supported.""" + ... + + def supports_copy_from(self) -> bool: + """Whether COPY FROM STDIN is supported.""" + ... + + def supports_copy_json(self) -> bool: + """Whether COPY TO JSON is supported.""" + ... + + def supports_copy_from_json(self) -> bool: + """Whether COPY FROM JSON is supported; PostgreSQL never supports it.""" + ... + + def supports_copy_force_array(self) -> bool: + """Whether COPY TO FORCE_ARRAY is supported.""" + ... + + def supports_copy_force_all(self) -> bool: + """Whether COPY FROM FORCE_NOT_NULL/FORCE_NULL accept '*'.""" + ... + + def supports_copy_partitioned_table_to(self) -> bool: + """Whether direct COPY TO supports partitioned tables.""" + ... + + def supports_copy_text_header(self) -> bool: + """Whether COPY text HEADER is supported.""" + ... + + def supports_copy_header_match(self) -> bool: + """Whether COPY FROM HEADER MATCH is supported.""" + ... + + def supports_copy_header_line_count(self) -> bool: + """Whether numeric COPY HEADER line counts are supported.""" + ... + + def supports_copy_on_error(self) -> bool: + """Whether COPY FROM ON_ERROR is supported.""" + ... + + def supports_copy_reject_limit(self) -> bool: + """Whether COPY FROM REJECT_LIMIT is supported.""" + ... + + def supports_copy_set_null(self) -> bool: + """Whether COPY FROM ON_ERROR SET_NULL is supported.""" + ... + + def supports_copy_log_verbosity(self) -> bool: + """Whether COPY FROM LOG_VERBOSITY is supported.""" + ... + + def supports_copy_silent_log(self) -> bool: + """Whether COPY FROM LOG_VERBOSITY SILENT is supported.""" + ... + + def format_copy_to_statement( + self, + expr: "PostgresCopyToExpression", + ) -> Tuple[str, Tuple[Any, ...]]: + """Format COPY TO STDOUT.""" + ... + + def format_copy_from_statement( + self, + expr: "PostgresCopyFromExpression", + ) -> Tuple[str, Tuple[Any, ...]]: + """Format COPY FROM STDIN.""" + ... 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 2318f5cf..6204af62 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 @@ -1,88 +1,120 @@ # tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_pgq_format.py -""" -Tests for PostgreSQL PGQ dialect version gating and SQL formatting. +"""Tests for fail-closed PostgreSQL graph capability and formatter safeguards.""" -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.dialect.protocols import GraphSupport, GraphTableSupport 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, + ColumnsClause, + CreatePropertyGraphExpression, + DropPropertyGraphExpression, + EdgeTable, + GraphColumn, + GraphEdge, + GraphEdgeDirection, + GraphTableExpression, + GraphVertex, + MatchClause, + TablePropertiesClause, + VertexTable, ) -from rhosocial.activerecord.backend.expression.query_parts import WhereClause from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.query_parts import WhereClause 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 + """Capability and protocol tests for PostgreSQL graph features.""" - def test_supports_graph_match_pg15_false(self): - d = PostgresDialect((15, 0, 0)) - assert d.supports_graph_match() is False + @pytest.mark.parametrize("version", [(15, 0, 0), (19, 0, 4), (20, 0, 0)]) + def test_graph_capabilities_fail_closed(self, version): + dialect = PostgresDialect(version) - @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 + assert dialect.supports_graph_match() is False + assert dialect.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.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_graph_support_protocols_are_implemented(self): + dialect = PostgresDialect((19, 0, 4)) + + assert isinstance(dialect, GraphSupport) + assert isinstance(dialect, GraphTableSupport) + + def test_explicit_graph_feature_override(self): + dialect = PostgresDialect( + (19, 0, 4), + graph_feature_overrides={ + "graph_match": True, + "graph_table": True, + }, + ) + + assert dialect.supports_graph_match() is True + assert dialect.supports_graph_table() is True + + def test_match_only_override_does_not_enable_graph_table(self): + dialect = PostgresDialect( + (19, 0, 4), + graph_feature_overrides={"graph_match": True}, + ) + + assert dialect.supports_graph_match() is True + assert dialect.supports_graph_table() is False + + def test_graph_table_override_without_match_fails_closed(self): + dialect = PostgresDialect( + (19, 0, 4), + graph_feature_overrides={"graph_table": True}, + ) + vertex = GraphVertex(dialect, "p", "person") + match = MatchClause(dialect, vertex) + columns = ColumnsClause(dialect, GraphColumn("p", "name")) + + assert dialect.supports_graph_match() is False + assert dialect.supports_graph_table() is False + with pytest.raises(UnsupportedFeatureError, match="GRAPH_TABLE"): + GraphTableExpression(dialect, "g", match, columns).to_sql() + + def test_unknown_graph_feature_override_is_rejected(self): + with pytest.raises(ValueError, match="Unknown graph feature override"): + PostgresDialect((19, 0, 4), graph_feature_overrides={"graph_sql": True}) + + def test_non_boolean_graph_feature_override_is_rejected(self): + with pytest.raises(TypeError, match="override values must be booleans"): + PostgresDialect((19, 0, 4), graph_feature_overrides={"graph_match": 1}) class TestPGQLimitationGating: - """Tests that PG 19 correctly reports unsupported features.""" - - def test_quantified_path_unsupported_pg19(self): - d = PostgresDialect((19, 0, 0)) - assert d.supports_quantified_path() is False + """Tests that withdrawn and unimplemented graph features stay disabled.""" - def test_quantified_path_unsupported_pg15(self): - d = PostgresDialect((15, 0, 0)) - assert d.supports_quantified_path() is False + def test_quantified_path_unsupported_by_default(self): + dialect = PostgresDialect((19, 0, 4)) + assert dialect.supports_quantified_path() is False - def test_comma_separated_patterns_unsupported_pg19(self): - d = PostgresDialect((19, 0, 0)) - assert d.supports_comma_separated_patterns() is False + def test_comma_separated_patterns_unsupported_by_default(self): + dialect = PostgresDialect((19, 0, 4)) + assert dialect.supports_comma_separated_patterns() is False - def test_comma_separated_patterns_unsupported_pg15(self): - d = PostgresDialect((15, 0, 0)) - assert d.supports_comma_separated_patterns() is False - - def test_quantified_path_raises_on_format(self): - d = PostgresDialect((19, 0, 0)) + def test_quantified_path_raises_on_format(self, pg19_dialect: PostgresDialect): from rhosocial.activerecord.backend.expression import QuantifiedPath - edge = GraphEdge(d, "e", "knows", GraphEdgeDirection.RIGHT) - qp = QuantifiedPath(d, edge) + + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + quantified_path = QuantifiedPath(pg19_dialect, edge) with pytest.raises(UnsupportedFeatureError): - qp.to_sql() + quantified_path.to_sql() @pytest.fixture def pg19_dialect(): - return PostgresDialect((19, 0, 0)) + return PostgresDialect( + (19, 0, 4), + graph_feature_overrides={ + "graph_match": True, + "graph_table": True, + }, + ) class TestPGQGraphVertexFormat: @@ -101,92 +133,101 @@ def test_anonymous(self, pg19_dialect: PostgresDialect): 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() + where = WhereClause( + pg19_dialect, + condition=Column(pg19_dialect, "age") > Literal(pg19_dialect, 18), + ) + vertex = GraphVertex(pg19_dialect, "p", "person", where=where) + sql, params = vertex.to_sql() assert "(p IS" in sql assert "WHERE" in sql assert params == (18,) def test_invalid_variable_name(self, pg19_dialect: PostgresDialect): - v = GraphVertex(pg19_dialect, "bad var", "person") + vertex = GraphVertex(pg19_dialect, "bad var", "person") with pytest.raises(ValueError, match="Invalid variable name"): - v.to_sql() - - + vertex.to_sql() class TestPGQGraphEdgeFormat: - """SQL formatting tests for GraphEdge with PG19 dialect.""" + """SQL formatting tests for GraphEdge with explicit capability overrides.""" def test_right(self, pg19_dialect: PostgresDialect): - e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) - sql, params = e.to_sql() + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + sql, params = edge.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"]-' + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.LEFT) + assert edge.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"]->' + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.ANY) + assert edge.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] == '-[]->' + edge = GraphEdge(pg19_dialect, direction=GraphEdgeDirection.RIGHT) + assert edge.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"]-' + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.NONE) + assert edge.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_table_without_variable_is_rejected(self, pg19_dialect: PostgresDialect): + edge = GraphEdge(pg19_dialect, table="knows", direction=GraphEdgeDirection.RIGHT) + with pytest.raises(ValueError, match="table requires a variable"): + edge.to_sql() + + def test_invalid_direction_is_rejected(self, pg19_dialect: PostgresDialect): + edge = GraphEdge(pg19_dialect, "e", "knows", direction="right") + with pytest.raises(ValueError, match="Invalid graph edge direction"): + edge.to_sql() def test_variable_only(self, pg19_dialect: PostgresDialect): - e = GraphEdge(pg19_dialect, variable="e", direction=GraphEdgeDirection.RIGHT) - assert e.to_sql()[0] == '-[e]->' + edge = GraphEdge(pg19_dialect, variable="e", direction=GraphEdgeDirection.RIGHT) + assert edge.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) + edge = GraphEdge( + pg19_dialect, + variable="bad var", + table="knows", + direction=GraphEdgeDirection.RIGHT, + ) with pytest.raises(ValueError, match="Invalid variable name"): - e.to_sql() + edge.to_sql() class TestPGQMatchClauseFormat: - """SQL formatting tests for MatchClause with PG19 dialect.""" + """SQL formatting tests for MatchClause with explicit capability overrides.""" def test_single_vertex(self, pg19_dialect: PostgresDialect): - v = GraphVertex(pg19_dialect, "p", "person") - m = MatchClause(pg19_dialect, v) - sql, params = m.to_sql() + vertex = GraphVertex(pg19_dialect, "p", "person") + match = MatchClause(pg19_dialect, vertex) + sql, params = match.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() + first = GraphVertex(pg19_dialect, "a", "person") + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + second = GraphVertex(pg19_dialect, "b", "person") + match = MatchClause(pg19_dialect, first, edge, second) + sql, params = match.to_sql() assert "MATCH" in sql assert "(a IS" in sql 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_empty_path_is_rejected(self, pg19_dialect: PostgresDialect): + match = MatchClause(pg19_dialect) + with pytest.raises(ValueError, match="at least one path element"): + match.to_sql() 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() + vertex = GraphVertex(pg19_dialect, variable=None, table="person") + match = MatchClause(pg19_dialect, vertex) + sql, params = match.to_sql() assert sql == 'MATCH ("person")' assert params == () @@ -357,71 +398,102 @@ def test_alter_with_both_tables(self, pg19_dialect: PostgresDialect): assert '"person"' in sql assert '"knows"' in sql + @pytest.mark.parametrize( + ("action", "target", "message"), + [ + ("ADD; DROP TABLE people", "VERTEX TABLES", "action"), + ("ADD", "VERTEX TABLES; DROP TABLE people", "target"), + ("ALTER", "TABLES", "action"), + ("ADD", "ALL TABLES", "target"), + ], + ) + def test_alter_rejects_non_whitelisted_keywords( + self, pg19_dialect, action, target, message + ): + expr = AlterPropertyGraphExpression(pg19_dialect, "g", action, target) + + with pytest.raises(ValueError, match=f"Invalid ALTER PROPERTY GRAPH {message}"): + expr.to_sql() + class TestPGQUnsupportedFormat: - """Tests that PG15 raises errors for PGQ formatting.""" + """Tests that graph formatters fail closed without explicit opt-in.""" @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") + def withdrawn_dialect(self): + return PostgresDialect((19, 0, 4)) + + def test_graph_vertex_unsupported(self, withdrawn_dialect: PostgresDialect): + vertex = GraphVertex(withdrawn_dialect, "p", "person") with pytest.raises(UnsupportedFeatureError): - vt.to_sql() + vertex.to_sql() - def test_edge_table_unsupported(self, pg15_dialect: PostgresDialect): - et = EdgeTable(pg15_dialect, "knows", ["pid"], ["fid"]) + def test_graph_edge_unsupported(self, withdrawn_dialect: PostgresDialect): + edge = GraphEdge(withdrawn_dialect, "e", "knows", GraphEdgeDirection.RIGHT) with pytest.raises(UnsupportedFeatureError): - et.to_sql() + edge.to_sql() - def test_create_property_graph_unsupported(self, pg15_dialect: PostgresDialect): - vt = VertexTable(pg15_dialect, "person") - expr = CreatePropertyGraphExpression(pg15_dialect, "g", [vt]) + def test_match_clause_unsupported(self, withdrawn_dialect: PostgresDialect): + vertex = GraphVertex(withdrawn_dialect, "p", "person") + match = MatchClause(withdrawn_dialect, vertex) with pytest.raises(UnsupportedFeatureError): - expr.to_sql() + match.to_sql() - def test_drop_property_graph_unsupported(self, pg15_dialect: PostgresDialect): - expr = DropPropertyGraphExpression(pg15_dialect, "g") + def test_graph_columns_clause_unsupported(self, withdrawn_dialect: PostgresDialect): + columns = ColumnsClause(withdrawn_dialect, GraphColumn("p", "name")) with pytest.raises(UnsupportedFeatureError): - expr.to_sql() + columns.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]) + def test_graph_table_unsupported(self, withdrawn_dialect: PostgresDialect): + vertex = GraphVertex(withdrawn_dialect, "p", "person") + columns = ColumnsClause(withdrawn_dialect, GraphColumn("p", "name")) + match = MatchClause(withdrawn_dialect, vertex) + graph_table = GraphTableExpression(withdrawn_dialect, "g", match, columns) with pytest.raises(UnsupportedFeatureError): - expr.to_sql() + graph_table.to_sql() + + def test_vertex_table_unsupported(self, withdrawn_dialect: PostgresDialect): + vertex_table = VertexTable(withdrawn_dialect, "person") + with pytest.raises(UnsupportedFeatureError): + vertex_table.to_sql() + + def test_edge_table_unsupported(self, withdrawn_dialect: PostgresDialect): + edge_table = EdgeTable(withdrawn_dialect, "knows", ["pid"], ["fid"]) + with pytest.raises(UnsupportedFeatureError): + edge_table.to_sql() + + def test_create_property_graph_unsupported(self, withdrawn_dialect: PostgresDialect): + vertex_table = VertexTable(withdrawn_dialect, "person") + expression = CreatePropertyGraphExpression(withdrawn_dialect, "g", [vertex_table]) + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() + + def test_drop_property_graph_unsupported(self, withdrawn_dialect: PostgresDialect): + expression = DropPropertyGraphExpression(withdrawn_dialect, "g") + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() + + def test_alter_property_graph_unsupported(self, withdrawn_dialect: PostgresDialect): + expression = AlterPropertyGraphExpression( + withdrawn_dialect, + "g", + "ADD", + "VERTEX TABLES", + ) + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() - def test_table_properties_clause_unsupported(self, pg15_dialect: PostgresDialect): - p = TablePropertiesClause(pg15_dialect, columns=["id"]) + def test_table_properties_clause_unsupported(self, withdrawn_dialect: PostgresDialect): + properties = TablePropertiesClause(withdrawn_dialect, columns=["id"]) with pytest.raises(UnsupportedFeatureError): - p.to_sql() + properties.to_sql() - def test_table_properties_all_columns_unsupported(self, pg15_dialect: PostgresDialect): - p = TablePropertiesClause(pg15_dialect, columns=None) + def test_table_properties_all_columns_unsupported(self, withdrawn_dialect: PostgresDialect): + properties = TablePropertiesClause(withdrawn_dialect, columns=None) with pytest.raises(UnsupportedFeatureError): - p.to_sql() + properties.to_sql() - def test_table_properties_none_unsupported(self, pg15_dialect: PostgresDialect): - p = TablePropertiesClause(pg15_dialect, columns=[]) + def test_table_properties_none_unsupported(self, withdrawn_dialect: PostgresDialect): + properties = TablePropertiesClause(withdrawn_dialect, columns=[]) with pytest.raises(UnsupportedFeatureError): - p.to_sql() + properties.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py index b7d113d2..81080188 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py @@ -361,3 +361,262 @@ def test_drop_type_full(self, dialect): ) assert sql == 'DROP TYPE IF EXISTS "app"."color" CASCADE' + +class TestPostgresConstraintEnforcement: + def test_capability_versions(self): + from rhosocial.activerecord.backend.expression import TableConstraintType + + assert PostgresDialect((17, 0, 0)).supports_constraint_enforced() is False + assert PostgresDialect((18, 0, 0)).supports_constraint_enforced() is True + pg18 = PostgresDialect((18, 0, 0)) + assert pg18.supports_alter_constraint_enforced() is False + assert pg18.supports_alter_constraint_enforced(TableConstraintType.FOREIGN_KEY) is True + assert pg18.supports_alter_constraint_enforced(TableConstraintType.CHECK) is False + pg19 = PostgresDialect((19, 0, 0)) + assert pg19.supports_alter_constraint_enforced(TableConstraintType.FOREIGN_KEY) is True + assert pg19.supports_alter_constraint_enforced(TableConstraintType.CHECK) is True + assert PostgresDialect((14, 0, 0)).supports_validate_constraint() is True + + def test_create_and_add_check_fk_enforcement(self): + from rhosocial.activerecord.backend.expression import ( + AddTableConstraint, + Column, + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, + ForeignKeyConstraint, + Literal, + TableConstraint, + TableConstraintType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + dialect = PostgresDialect((18, 0, 0)) + condition = Column(dialect, "age") > Literal(dialect, 0, inline_literals=True) + check = TableConstraint( + dialect, + TableConstraintType.CHECK, + name="age_check", + check_condition=condition, + enforced=False, + ) + fk = ForeignKeyConstraint( + dialect, + columns=["parent_id"], + foreign_key_table="people", + foreign_key_columns=["id"], + enforced=True, + ) + column_check = ColumnConstraint( + dialect, + ColumnConstraintType.CHECK, + check_condition=condition, + enforced=False, + ) + + assert check.to_sql()[0] == 'CONSTRAINT "age_check" CHECK ("age" > 0) NOT ENFORCED' + assert fk.to_sql()[0] == ( + 'FOREIGN KEY ("parent_id") REFERENCES "people"("id") ENFORCED' + ) + assert ColumnDefinition( + dialect, "age", IntegerType(dialect), [column_check] + ).to_sql()[0] == '"age" INTEGER CHECK ("age" > 0) NOT ENFORCED' + assert AddTableConstraint(dialect, check).to_sql()[0] == ( + 'ADD CONSTRAINT "age_check" CHECK ("age" > 0) NOT ENFORCED' + ) + + def test_not_valid_is_limited_to_check_and_fk(self): + from rhosocial.activerecord.backend.expression import AddTableConstraint + from rhosocial.activerecord.backend.expression.statements import ( + ConstraintValidation, + ForeignKeyConstraint, + TableConstraint, + TableConstraintType, + ) + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import PostgresExcludeConstraint + + dialect = PostgresDialect((18, 0, 0)) + constraint = ForeignKeyConstraint( + dialect, + columns=["parent_id"], + foreign_key_table="people", + foreign_key_columns=["id"], + validation=ConstraintValidation.NOVALIDATE, + ) + assert AddTableConstraint(dialect, constraint).to_sql()[0].endswith("NOT VALID") + string_constraint = TableConstraint( + dialect, + " check ", + check_condition=Column(dialect, "age") > Literal(dialect, 0, inline_literals=True), + validation=" not valid ", + ) + assert AddTableConstraint(dialect, string_constraint).to_sql()[0].endswith("NOT VALID") + assert "NOT VALID" not in string_constraint.to_sql()[0] + for constraint_type in (TableConstraintType.PRIMARY_KEY, TableConstraintType.UNIQUE): + table_constraint = TableConstraint( + dialect, + constraint_type, + columns=["id"], + validation=ConstraintValidation.NOVALIDATE, + ) + with pytest.raises(ValueError, match="NOT VALID"): + AddTableConstraint(dialect, table_constraint).to_sql() + exclude = PostgresExcludeConstraint( + dialect, + elements=[("range", "&&")], + validation=ConstraintValidation.NOVALIDATE, + ) + with pytest.raises(ValueError, match="NOT VALID"): + AddTableConstraint(dialect, exclude).to_sql() + + def test_alter_and_validate_actions(self): + from rhosocial.activerecord.backend.expression import ( + ColumnConstraintType, + TableConstraintType, + ) + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterConstraint, + PostgresValidateConstraint, + ) + + pg18 = PostgresDialect((18, 0, 0)) + fk_action = PostgresAlterConstraint( + pg18, + "parent_fk", + False, + constraint_type=ColumnConstraintType.FOREIGN_KEY, + ) + assert fk_action.constraint_type is TableConstraintType.FOREIGN_KEY + assert fk_action.to_sql()[0] == ( + 'ALTER CONSTRAINT "parent_fk" NOT ENFORCED' + ) + with pytest.raises(UnsupportedFeatureError): + PostgresAlterConstraint( + pg18, + "age_check", + False, + constraint_type=TableConstraintType.CHECK, + ).to_sql() + assert PostgresValidateConstraint(pg18, "age_check").to_sql()[0] == ( + 'VALIDATE CONSTRAINT "age_check"' + ) + + pg19 = PostgresDialect((19, 0, 0)) + assert PostgresAlterConstraint( + pg19, + "age_check", + False, + constraint_type=TableConstraintType.CHECK, + ).to_sql()[0] == 'ALTER CONSTRAINT "age_check" NOT ENFORCED' + with pytest.raises(TypeError): + PostgresAlterConstraint(pg19, "age_check", False) + + def test_exclude_create_and_add(self): + from rhosocial.activerecord.backend.expression import ( + AddTableConstraint, + Column, + ColumnDefinition, + CreateTableExpression, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import PostgresExcludeConstraint + + dialect = PostgresDialect((18, 0, 0)) + with pytest.raises(ValueError, match="at least one element"): + PostgresExcludeConstraint(dialect).to_sql() + exclude = PostgresExcludeConstraint( + dialect, + name="range_ex", + elements=[("range", "&&")], + ) + create = CreateTableExpression( + dialect, + "ranges", + [ColumnDefinition(dialect, "range", IntegerType(dialect))], + table_constraints=[exclude], + ) + assert create.to_sql()[0] == ( + 'CREATE TABLE "ranges" ("range" INTEGER, CONSTRAINT "range_ex" ' + 'EXCLUDE USING gist ("range" WITH &&))' + ) + assert AddTableConstraint(dialect, exclude).to_sql()[0] == ( + 'ADD CONSTRAINT "range_ex" EXCLUDE USING gist ("range" WITH &&)' + ) + + from rhosocial.activerecord.backend.expression.statements import ( + ConstraintValidation, + TableConstraint, + TableConstraintType, + ) + + not_valid_check = TableConstraint( + dialect, + TableConstraintType.CHECK, + check_condition=Column(dialect, "id") > 0, + validation=ConstraintValidation.NOVALIDATE, + ) + invalid_create = CreateTableExpression( + dialect, + "invalid_checks", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + table_constraints=[not_valid_check], + ) + with pytest.raises(ValueError, match="only valid when adding"): + invalid_create.to_sql() + + def test_exclude_expression_parentheses_and_recursive_binding(self): + from rhosocial.activerecord.backend.expression import ( + Column, + FunctionCall, + Literal, + RawSQLExpression, + RawSQLPredicate, + ) + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresExcludeConstraint, + ) + from rhosocial.activerecord.ddl import DialectBinder + + dialect = PostgresDialect((18, 0, 0)) + declared = PostgresExcludeConstraint( + None, + elements=[(FunctionCall(None, "LOWER", Column(None, "name")), "=")], + where=Column(None, "active") == Literal(None, True, inline_literals=True), + ) + bound = DialectBinder(dialect).bind(declared) + + assert bound.to_sql() == ( + 'EXCLUDE USING gist ((LOWER("name")) WITH =) ' + 'WHERE ("active" = TRUE)', + (), + ) + with pytest.raises(ValueError): + declared.elements[0][0].to_sql() + + with pytest.raises(ValueError, match="Invalid exclude operator"): + PostgresExcludeConstraint( + dialect, + elements=[("name", "is not")], + ).to_sql() + with pytest.raises(ValueError, match="parenthesized"): + PostgresExcludeConstraint( + dialect, + elements=[("(lower(name))", "=")], + ).to_sql() + with pytest.raises(ValueError, match="unbalanced"): + PostgresExcludeConstraint( + dialect, + elements=[(RawSQLExpression(dialect, "lower(name"), "=")], + ).to_sql() + with pytest.raises(ValueError, match="must not contain bind parameters"): + PostgresExcludeConstraint( + dialect, + elements=[(RawSQLExpression(dialect, "lower(name)", ("x",)), "=")], + ).to_sql() + with pytest.raises(ValueError, match="must not contain bind parameters"): + PostgresExcludeConstraint( + dialect, + elements=[("name", "=")], + where=RawSQLPredicate(dialect, "active = %s", (True,)), + ).to_sql() + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py index a5076b84..c5139cbd 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py @@ -9,13 +9,20 @@ import pytest -from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import ( + Column, + FunctionCall, + Literal, + RawSQLExpression, +) from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, CreateTableExpression, PartitionClause, PartitionStrategy, ) +from rhosocial.activerecord.backend.impl.dummy import DummyDialect from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PartitionValue, @@ -306,7 +313,7 @@ def test_create_hash_partitioned_parent_table_pg10(self): ), ) - with pytest.raises(Exception, match="HASH partitioning requires PostgreSQL 11"): + with pytest.raises(UnsupportedFeatureError, match="HASH partitioning requires PostgreSQL 11"): expr.to_sql() def test_create_hash_partitioned_parent_table_pg11(self): @@ -353,7 +360,7 @@ def test_create_partitioned_parent_table_pg9(self): ), ) - with pytest.raises(Exception, match="Declarative table partitioning requires PostgreSQL 10"): + with pytest.raises(UnsupportedFeatureError, match="Declarative table partitioning requires PostgreSQL 10"): expr.to_sql() def test_key_partitioning_is_rejected(self, dialect): @@ -406,6 +413,31 @@ def test_postgres_partition_clause_in_create_table(self, dialect): sql, _ = expr.to_sql() assert "PARTITION BY RANGE" in sql + def test_partition_clause_rejects_cross_dialect_key(self, dialect): + clause = PartitionClause( + dialect, + PartitionStrategy.RANGE, + [Column(DummyDialect(), "created_at")], + ) + with pytest.raises(ValueError, match="same dialect|PostgreSQL dialect"): + clause.to_sql() + + def test_create_partition_rejects_cross_dialect_clause(self, dialect): + clause = PartitionClause( + DummyDialect(), + PartitionStrategy.HASH, + [Column(DummyDialect(), "bucket")], + ) + with pytest.raises(ValueError, match="same dialect"): + PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": "2026-01-01", "to": "2027-01-01"}, + partition_clause=clause, + ) + def test_multi_column_range_partitioned_parent_table(self, dialect): """Test creating a RANGE-partitioned parent table with multiple partition keys.""" expr = CreateTableExpression( @@ -436,6 +468,34 @@ def test_multi_column_range_partitioned_parent_table(self, dialect): assert 'PARTITION BY RANGE ("tenant_id", "created_at")' in sql assert params == () + def test_multi_column_list_partitioned_parent_table_rejected(self, dialect): + clause = PartitionClause( + dialect=dialect, + method=PartitionStrategy.LIST, + keys=[Column(dialect, "region"), Column(dialect, "status")], + ) + + with pytest.raises(ValueError, match="exactly one key expression"): + clause.to_sql() + + def test_child_multi_column_list_partition_clause_rejected(self, dialect): + clause = PartitionClause( + dialect=dialect, + method=PartitionStrategy.LIST, + keys=[Column(dialect, "region"), Column(dialect, "status")], + ) + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": "2026-01-01", "to": "2027-01-01"}, + partition_clause=clause, + ) + + with pytest.raises(ValueError, match="exactly one key expression"): + expr.to_sql() + class TestPostgresPartitionValue: """Test safe PostgreSQL partition bound value formatting.""" @@ -474,6 +534,49 @@ def test_partition_value_rejects_nonfinite_numbers(self, dialect, value): with pytest.raises(ValueError): PartitionValue(dialect=dialect, value=value) + @pytest.mark.parametrize("value", ["MINVALUE", "MAXVALUE", "DEFAULT"]) + def test_list_partition_value_quotes_special_range_strings(self, dialect, value): + expr = PartitionValue(dialect, value, partition_type="LIST") + assert expr.to_sql() == (f"'{value}'", ()) + + def test_partition_value_renders_function_call_with_inline_literals(self, dialect): + expr = PartitionValue( + dialect, + FunctionCall(dialect, "DATE_TRUNC", Literal(dialect, "month")), + ) + assert expr.to_sql() == ("DATE_TRUNC('month')", ()) + + def test_partition_value_rejects_parameterized_raw_sql(self, dialect): + expr = PartitionValue( + dialect, + RawSQLExpression(dialect, "DATE %s", ("2024-01-01",)), + ) + with pytest.raises(ValueError, match="must not contain bind parameters"): + expr.to_sql() + + def test_partition_value_rejects_cross_dialect_expression(self, dialect): + expression = FunctionCall(DummyDialect(), "DATE", niladic=True) + with pytest.raises(ValueError, match="same dialect"): + PartitionValue(dialect, expression) + + @pytest.mark.parametrize("expression_factory", [ + lambda dialect: Column(dialect, "created_at"), + lambda dialect: FunctionCall(dialect, "DATE", Column(dialect, "created_at")), + ]) + def test_partition_value_rejects_unsafe_expression(self, dialect, expression_factory): + with pytest.raises(TypeError, match="FunctionCall, Literal, or RawSQLExpression"): + PartitionValue(dialect, expression_factory(dialect)) + + def test_default_partition_value_requires_pg11(self): + dialect = PostgresDialect(version=(10, 0, 0)) + with pytest.raises(UnsupportedFeatureError, match="DEFAULT partitions require PostgreSQL 11"): + PartitionValue(dialect, "DEFAULT").to_sql() + + def test_partition_value_requires_pg10(self): + dialect = PostgresDialect(version=(9, 6, 0)) + with pytest.raises(UnsupportedFeatureError, match="requires PostgreSQL 10"): + PartitionValue(dialect, 1).to_sql() + class TestPostgresCreatePartitionExpression: """Test PostgresCreatePartitionExpression.""" @@ -539,7 +642,7 @@ def test_create_hash_partition_pg10(self): partition_type="HASH", partition_values={"modulus": 4, "remainder": 0}, ) - with pytest.raises(Exception, match="HASH partitioning requires PostgreSQL 11"): + with pytest.raises(UnsupportedFeatureError, match="HASH partitioning requires PostgreSQL 11"): expr.to_sql() def test_create_hash_partition_pg11(self, dialect): @@ -624,6 +727,203 @@ def test_create_partition_value_types(self, dialect, partition_type, partition_v assert expected_clause in sql assert params == () + def test_create_multi_column_range_partition(self, dialect): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + { + "from": [1, date(2026, 1, 1)], + "to": [2, date(2027, 1, 1)], + }, + ) + sql, params = expr.to_sql() + assert "FROM (1, '2026-01-01') TO (2, '2027-01-01')" in sql + assert params == () + + def test_create_multi_column_list_partition_rejected(self, dialect): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "LIST", + {"values": [["us", 1], ["eu", 2]]}, + ) + + with pytest.raises(ValueError, match="nested rows"): + expr.to_sql() + + def test_list_special_range_strings_remain_quoted(self, dialect): + expr = PostgresCreatePartitionExpression( + dialect, + "events_special", + "events", + "LIST", + {"values": ["MINVALUE", "MAXVALUE", "DEFAULT"]}, + ) + sql, _ = expr.to_sql() + assert "IN ('MINVALUE', 'MAXVALUE', 'DEFAULT')" in sql + + def test_child_partition_clause_order_and_schemas(self, dialect): + clause = PartitionClause( + dialect, + PartitionStrategy.HASH, + [Column(dialect, "bucket")], + ) + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": "2026-01-01", "to": "2027-01-01"}, + schema="child_schema", + parent_schema="parent_schema", + tablespace="fastspace", + partition_clause=clause, + ) + sql, params = expr.to_sql() + assert sql == ( + 'CREATE TABLE "child_schema"."events_p1" ' + 'PARTITION OF "parent_schema"."events" ' + "FOR VALUES FROM ('2026-01-01') TO ('2027-01-01') " + 'PARTITION BY HASH ("bucket") TABLESPACE "fastspace"' + ) + assert sql.index("FOR VALUES") < sql.index("PARTITION BY") < sql.index("TABLESPACE") + assert params == () + + def test_create_partition_rejects_nested_parameters(self, dialect): + clause = PartitionClause( + dialect, + PartitionStrategy.HASH, + [RawSQLExpression(dialect, "bucket %s", (1,))], + ) + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + { + "from": RawSQLExpression(dialect, "DATE %s", ("2026-01-01",)), + "to": "MAXVALUE", + }, + partition_clause=clause, + ) + with pytest.raises(ValueError, match="must not contain bind parameters"): + expr.to_sql() + + def test_create_partition_literals_are_inlined(self, dialect): + clause = PartitionClause( + dialect, + PartitionStrategy.HASH, + [FunctionCall(dialect, "LOWER", Literal(dialect, "bucket"))], + ) + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + { + "from": Literal(dialect, "2026-01-01"), + "to": Literal(dialect, "2027-01-01"), + }, + partition_clause=clause, + ) + + sql, params = expr.to_sql() + + assert params == () + assert "%s" not in sql + assert "LOWER('bucket')" in sql + assert "FROM ('2026-01-01')" in sql + + def test_create_list_partition_literals_are_inlined(self, dialect): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "LIST", + {"values": [FunctionCall(dialect, "LOWER", Literal(dialect, "ACTIVE"))]}, + ) + + sql, params = expr.to_sql() + + assert params == () + assert "%s" not in sql + assert "IN (LOWER('ACTIVE'))" in sql + + def test_create_default_partition_requires_pg11(self): + dialect = PostgresDialect(version=(10, 0, 0)) + expr = PostgresCreatePartitionExpression( + dialect, + "events_default", + "events", + "RANGE", + {"default": True}, + ) + with pytest.raises(UnsupportedFeatureError, match="DEFAULT partitions require PostgreSQL 11"): + expr.to_sql() + + @pytest.mark.parametrize("modulus", [True, "4", 4.0]) + def test_create_hash_rejects_non_integer_modulus(self, dialect, modulus): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "HASH", + {"modulus": modulus, "remainder": 0}, + ) + with pytest.raises(TypeError, match="modulus must be an int"): + expr.to_sql() + + @pytest.mark.parametrize("modulus", [0, -1]) + def test_create_hash_rejects_non_positive_modulus(self, dialect, modulus): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "HASH", + {"modulus": modulus, "remainder": 0}, + ) + with pytest.raises(ValueError, match="modulus must be a positive integer"): + expr.to_sql() + + @pytest.mark.parametrize("remainder", [True, "0", 0.0]) + def test_create_hash_rejects_non_integer_remainder(self, dialect, remainder): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "HASH", + {"modulus": 4, "remainder": remainder}, + ) + with pytest.raises(TypeError, match="remainder must be an int"): + expr.to_sql() + + @pytest.mark.parametrize("remainder", [-1, 4]) + def test_create_hash_rejects_out_of_range_remainder(self, dialect, remainder): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "HASH", + {"modulus": 4, "remainder": remainder}, + ) + with pytest.raises(ValueError, match=r"0 <= remainder < modulus"): + expr.to_sql() + + def test_create_partition_requires_pg10(self): + dialect = PostgresDialect(version=(9, 6, 0)) + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": "2026-01-01", "to": "2027-01-01"}, + ) + with pytest.raises(UnsupportedFeatureError, match="requires PostgreSQL 10"): + expr.to_sql() + class TestPostgresDetachPartitionExpression: """Test PostgresDetachPartitionExpression.""" @@ -706,6 +1006,16 @@ def test_detach_with_schema(self, dialect): sql, params = expr.to_sql() assert '"sales".' in sql + def test_detach_partition_requires_pg10(self): + dialect = PostgresDialect(version=(9, 6, 0)) + expr = PostgresDetachPartitionExpression( + dialect, + "events_p1", + "events", + ) + with pytest.raises(UnsupportedFeatureError, match="requires PostgreSQL 10"): + expr.to_sql() + class TestPostgresAttachPartitionExpression: """Test PostgresAttachPartitionExpression.""" @@ -781,6 +1091,37 @@ def test_attach_list_partition_requires_values(self, dialect): with pytest.raises(ValueError, match="LIST partition requires"): expr.to_sql() + def test_attach_multi_column_list_partition_rejected(self, dialect): + expr = PostgresAttachPartitionExpression( + dialect, + "events_p1", + "events", + "LIST", + {"values": [["us", 1], ["eu", 2]]}, + ) + + with pytest.raises(ValueError, match="nested rows"): + expr.to_sql() + + def test_attach_partition_literals_are_inlined(self, dialect): + expr = PostgresAttachPartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + { + "from": Literal(dialect, "2026-01-01"), + "to": FunctionCall(dialect, "DATE_TRUNC", Literal(dialect, "year")), + }, + ) + + sql, params = expr.to_sql() + + assert params == () + assert "%s" not in sql + assert "FROM ('2026-01-01')" in sql + assert "TO (DATE_TRUNC('year'))" in sql + def test_attach_hash_partition_requires_pg11(self): """HASH attach requires PostgreSQL 11+.""" dialect = PostgresDialect(version=(10, 0, 0)) @@ -791,11 +1132,11 @@ def test_attach_hash_partition_requires_pg11(self): partition_type="HASH", partition_values={"modulus": 4, "remainder": 0}, ) - with pytest.raises(ValueError, match="HASH partitioning requires PostgreSQL 11"): + with pytest.raises(UnsupportedFeatureError, match="HASH partitioning requires PostgreSQL 11"): expr.to_sql() def test_attach_concurrently_pg13(self): - """ATTACH CONCURRENTLY requires PG 14+.""" + """ATTACH CONCURRENTLY is rejected on PG 13.""" dialect_pg13 = PostgresDialect(version=(13, 0, 0)) expr = PostgresAttachPartitionExpression( dialect=dialect_pg13, @@ -805,11 +1146,11 @@ def test_attach_concurrently_pg13(self): partition_values={"from": "2024-01-01", "to": "2024-04-01"}, concurrently=True, ) - with pytest.raises(ValueError, match="ATTACH CONCURRENTLY requires PostgreSQL 14"): + with pytest.raises(UnsupportedFeatureError, match="ATTACH PARTITION CONCURRENTLY"): expr.to_sql() def test_attach_concurrently_pg14(self, dialect): - """ATTACH CONCURRENTLY with PG 14+ includes CONCURRENTLY keyword.""" + """ATTACH CONCURRENTLY is rejected on PG 14+.""" expr = PostgresAttachPartitionExpression( dialect=dialect, partition_name="orders_2024_q1", @@ -818,10 +1159,8 @@ def test_attach_concurrently_pg14(self, dialect): partition_values={"from": "2024-01-01", "to": "2024-04-01"}, concurrently=True, ) - sql, params = expr.to_sql() - assert "CONCURRENTLY" in sql - assert "FOR VALUES" in sql - assert params == () + with pytest.raises(UnsupportedFeatureError, match="ATTACH PARTITION CONCURRENTLY"): + expr.to_sql() def test_attach_default_range_partition(self, dialect): """ATTACH DEFAULT partition for RANGE should emit DEFAULT keyword.""" @@ -851,6 +1190,58 @@ def test_attach_default_list_partition(self, dialect): assert "FOR VALUES" not in sql assert params == () + def test_attach_multi_column_range_partition(self, dialect): + expr = PostgresAttachPartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": [1, "2026-01-01"], "to": [2, "2027-01-01"]}, + ) + sql, params = expr.to_sql() + assert "FROM (1, '2026-01-01') TO (2, '2027-01-01')" in sql + assert params == () + + def test_attach_default_partition_requires_pg11(self): + dialect = PostgresDialect(version=(10, 0, 0)) + expr = PostgresAttachPartitionExpression( + dialect, + "events_default", + "events", + "LIST", + {"default": True}, + ) + with pytest.raises(UnsupportedFeatureError, match="DEFAULT partitions require PostgreSQL 11"): + expr.to_sql() + + @pytest.mark.parametrize("modulus,remainder", [ + ("4); DROP TABLE events; --", 0), + (4, "0); DROP TABLE events; --"), + (4, 4), + ]) + def test_attach_hash_rejects_invalid_integers(self, dialect, modulus, remainder): + expr = PostgresAttachPartitionExpression( + dialect, + "events_p1", + "events", + "HASH", + {"modulus": modulus, "remainder": remainder}, + ) + with pytest.raises((TypeError, ValueError)): + expr.to_sql() + + def test_attach_partition_requires_pg10(self): + dialect = PostgresDialect(version=(9, 6, 0)) + expr = PostgresAttachPartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": "2026-01-01", "to": "2027-01-01"}, + ) + with pytest.raises(UnsupportedFeatureError, match="requires PostgreSQL 10"): + expr.to_sql() + class TestPostgresPartitionMetadataExpression: """Test PostgreSQL partition metadata query expression.""" @@ -865,6 +1256,9 @@ def test_metadata_query_for_parent(self): sql, params = expr.to_sql() assert "pg_get_partkeydef" in sql assert "pg_inherits" in sql + assert "pg_partitioned_table" in sql + assert "parent.relkind = 'p'" in sql + assert "partitioned_parent.partrelid IS NOT NULL" in sql assert params == ("orders",) def test_metadata_query_with_schema(self): @@ -890,6 +1284,8 @@ def test_metadata_query_without_partitions(self): ) sql, params = expr.to_sql() assert "pg_inherits" not in sql + assert "pg_partitioned_table" in sql + assert "parent.relkind = 'p'" in sql assert "NULL::text AS name" in sql assert params == ("orders",) @@ -900,7 +1296,7 @@ def test_metadata_query_requires_pg10(self): dialect=dialect, parent_table="orders", ) - with pytest.raises(Exception, match="partition metadata introspection"): + with pytest.raises(UnsupportedFeatureError, match="partition metadata introspection"): expr.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py index 179865cd..53e191fa 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py @@ -75,10 +75,10 @@ def test_supports_range_and_list_partitioning_pg10(self): assert dialect.supports_range_table_partitioning() is True assert dialect.supports_list_table_partitioning() is True - def test_subpartitioning_not_exposed_yet(self): - """Nested partitioning is not exposed by this API yet.""" - dialect = PostgresDialect(version=(14, 0, 0)) - assert dialect.supports_subpartitioning() is False + def test_subpartitioning_pg10(self): + """Nested partitioning is available with PostgreSQL 10+.""" + assert PostgresDialect(version=(9, 6, 0)).supports_subpartitioning() is False + assert PostgresDialect(version=(10, 0, 0)).supports_subpartitioning() is True def test_partition_introspection_pg9(self): """Partition metadata introspection follows declarative partitioning support.""" @@ -145,9 +145,9 @@ def test_supports_concurrent_attach_pg13(self): assert dialect.supports_concurrent_attach() is False def test_supports_concurrent_attach_pg14(self): - """PostgreSQL 14 supports CONCURRENTLY ATTACH.""" + """CONCURRENTLY ATTACH remains disabled on PostgreSQL 14+.""" dialect = PostgresDialect(version=(14, 0, 0)) - assert dialect.supports_concurrent_attach() is True + assert dialect.supports_concurrent_attach() is False def test_supports_partition_bounds_expression_pg11(self): """Partition bounds expression requires PG 12+.""" @@ -225,9 +225,11 @@ def test_supports_hash_table_partitioning_direct(self): assert self._PartitionMixin().supports_hash_table_partitioning() is True assert self._PartitionMixinPg10().supports_hash_table_partitioning() is False - def test_subpartitioning_not_exposed_direct(self): - """Nested partitioning is not exposed by this API yet.""" - assert self._PartitionMixin().supports_subpartitioning() is False + def test_subpartitioning_direct(self): + """Nested partitioning follows declarative partitioning support.""" + assert self._PartitionMixin().supports_subpartitioning() is True + assert self._PartitionMixinPg10().supports_subpartitioning() is True + assert self._PartitionMixinLow().supports_subpartitioning() is False def test_partition_introspection_direct(self): """Partition metadata introspection follows table partitioning support.""" @@ -245,8 +247,8 @@ def test_partition_maintenance_capabilities_direct(self): assert mixin.supports_reorganize_partition() is False def test_supports_concurrent_attach_direct(self): - """CONCURRENTLY ATTACH follows PG version.""" - assert self._PartitionMixin().supports_concurrent_attach() is True + """CONCURRENTLY ATTACH is always disabled.""" + assert self._PartitionMixin().supports_concurrent_attach() is False assert self._PartitionMixinPg10().supports_concurrent_attach() is False @@ -390,8 +392,11 @@ def test_postgres_partition_support_is_runtime_checkable(self): from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( PostgresPartitionSupport, ) + from rhosocial.activerecord.ddl import PartitionLifecycleProvider + dialect = PostgresDialect(version=(14, 0, 0)) assert isinstance(dialect, PostgresPartitionSupport) + assert isinstance(dialect.get_partition_lifecycle_provider(), PartitionLifecycleProvider) def test_postgres_comment_support_is_runtime_checkable(self): """PostgresCommentSupport should be runtime checkable.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py index dfa112a3..59477dd8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py @@ -6,6 +6,7 @@ """ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PostgresVacuumExpression, @@ -229,7 +230,7 @@ def test_hash_partition_requires_pg11(self): partition_values={"modulus": 4, "remainder": 0} ) - with pytest.raises(ValueError, match="HASH partitioning requires PostgreSQL 11"): + with pytest.raises(UnsupportedFeatureError, match="HASH partitioning requires PostgreSQL 11"): expr.to_sql() def test_partition_with_tablespace(self, dialect): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py index 3b0b7499..763aac43 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py @@ -1,9 +1,9 @@ # tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py -"""Live execution tests for the model-level ``truncate()`` operation.""" - -import pytest -import pytest_asyncio +"""PostgreSQL rendering tests for model-bound TRUNCATE expressions.""" +from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( + TruncateExpression, +) from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord @@ -11,17 +11,7 @@ class TestSyncModelTruncate: - @pytest.fixture - def table(self, postgres_backend): - postgres_backend.execute(f"DROP TABLE IF EXISTS {TABLE}") - postgres_backend.execute( - f"CREATE TABLE {TABLE} (id SERIAL PRIMARY KEY, name VARCHAR(50))" - ) - postgres_backend.execute(f"INSERT INTO {TABLE} (name) VALUES ('a'), ('b'), ('c')") - yield - postgres_backend.execute(f"DROP TABLE IF EXISTS {TABLE}") - - def test_truncate_removes_all_rows(self, postgres_backend, table): + def test_truncate_expression_renders_postgres_options(self, postgres_backend): class Event(ActiveRecord): __table_name__ = TABLE @@ -29,27 +19,23 @@ class Event(ActiveRecord): name: str Event.__backend__ = postgres_backend - Event.truncate() - assert postgres_backend.fetch_one( - f"SELECT COUNT(*) AS cnt FROM {TABLE}" - )["cnt"] == 0 + expression = Event.ddl().truncate(restart_identity=True, cascade=True) + + assert isinstance(expression, TruncateExpression) + assert expression.table_name == TABLE + assert expression.restart_identity is True + assert expression.cascade is True + assert expression.to_sql() == ( + f'TRUNCATE TABLE "{TABLE}" RESTART IDENTITY CASCADE', + (), + ) class TestAsyncModelTruncate: - @pytest_asyncio.fixture - async def table(self, async_postgres_backend): - await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {TABLE}") - await async_postgres_backend.execute( - f"CREATE TABLE {TABLE} (id SERIAL PRIMARY KEY, name VARCHAR(50))" - ) - await async_postgres_backend.execute( - f"INSERT INTO {TABLE} (name) VALUES ('a'), ('b'), ('c')" - ) - yield - await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {TABLE}") - - @pytest.mark.asyncio - async def test_truncate_removes_all_rows(self, async_postgres_backend, table): + async def test_truncate_expression_renders_postgres_options( + self, + async_postgres_backend, + ): class Event(AsyncActiveRecord): __table_name__ = TABLE @@ -57,8 +43,13 @@ class Event(AsyncActiveRecord): name: str Event.__backend__ = async_postgres_backend - await Event.truncate() - count = await async_postgres_backend.fetch_one( - f"SELECT COUNT(*) AS cnt FROM {TABLE}" + expression = Event.ddl().truncate(restart_identity=True, cascade=True) + + assert isinstance(expression, TruncateExpression) + assert expression.table_name == TABLE + assert expression.restart_identity is True + assert expression.cascade is True + assert expression.to_sql() == ( + f'TRUNCATE TABLE "{TABLE}" RESTART IDENTITY CASCADE', + (), ) - assert count["cnt"] == 0 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py new file mode 100644 index 00000000..5b781c50 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py @@ -0,0 +1,730 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py +"""Tests for PostgreSQL COPY and native REPACK expressions and APIs.""" + +from contextlib import contextmanager +from unittest.mock import AsyncMock, Mock + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.errors import QueryError +from rhosocial.activerecord.backend.expression import ( + Column, + Literal, + QueryExpression, + RawSQLExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.serialization import deserialize, serialize +from rhosocial.activerecord.backend.impl.postgres import ( + AsyncPostgresBackend, + PostgresBackend, + PostgresCopyFromExpression, + PostgresCopyLogVerbosity, + PostgresCopyOnError, + PostgresCopyToExpression, + PostgresDialect, + PostgresRepackExpression, +) +from rhosocial.activerecord.backend.impl.postgres.mixins import ( + PostgresCopyMixin, + PostgresRepackMixin, +) +from rhosocial.activerecord.backend.impl.postgres.protocols import ( + PostgresCopySupport, + PostgresRepackSupport, +) + + +class SyncCopy: + def __init__(self, chunks=(), error=None): + self.chunks = list(chunks) + self.error = error + self.writes = [] + + def __iter__(self): + yield from self.chunks + if self.error is not None: + raise self.error + + def write(self, chunk): + self.writes.append(chunk) + + +class SyncCursor: + def __init__(self, copy, rowcount=0): + self.copy_object = copy + self.rowcount = rowcount + self.statements = [] + self.closed = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.closed = True + + @contextmanager + def copy(self, statement, params): + self.statements.append((statement, params)) + yield self.copy_object + + +class AsyncCopy: + def __init__(self, chunks=(), error=None): + self.chunks = list(chunks) + self.error = error + self.writes = [] + + async def __aiter__(self): + for chunk in self.chunks: + yield chunk + if self.error is not None: + raise self.error + + async def write(self, chunk): + self.writes.append(chunk) + + +class AsyncCopyContext: + def __init__(self, copy): + self.copy = copy + + async def __aenter__(self): + return self.copy + + async def __aexit__(self, exc_type, exc_value, traceback): + return None + + +class AsyncCursor: + def __init__(self, copy, rowcount=0): + self.copy_object = copy + self.rowcount = rowcount + self.statements = [] + self.closed = False + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + self.closed = True + + def copy(self, statement, params): + self.statements.append((statement, params)) + return AsyncCopyContext(self.copy_object) + + +def make_sync_backend(cursor): + backend = object.__new__(PostgresBackend) + backend._get_cursor = Mock(return_value=cursor) + backend._transaction_manager = None + backend._connection = None + return backend + + +def make_async_backend(cursor): + backend = object.__new__(AsyncPostgresBackend) + backend._get_cursor = AsyncMock(return_value=cursor) + backend._transaction_manager = None + backend._connection = None + return backend + + +def select_query(dialect): + return QueryExpression( + dialect, + select=[Column(dialect, "id")], + from_=TableExpression(dialect, "users"), + where=Column(dialect, "id") > Literal(dialect, 3), + ) + + +def test_copy_to_table_formatter_and_safe_literals(): + dialect = PostgresDialect((19, 0, 0)) + expression = PostgresCopyToExpression( + dialect, + table_name='users"; DROP TABLE users; --', + schema="app data", + columns=["id", "display'name"], + format="csv", + header=True, + null="NULL", + delimiter="|", + quote="'", + escape="\\", + ) + + sql, params = expression.to_sql() + + assert params == () + assert 'COPY "app data"."users""; DROP TABLE users; --"' in sql + assert '("id", "display\'name")' in sql + assert "FORMAT CSV" in sql + assert "HEADER" in sql + assert "NULL 'NULL'" in sql + assert "DELIMITER '|'" in sql + assert "QUOTE ''''" in sql + assert "ESCAPE E'\\\\'" in sql + assert "PROGRAM" not in sql + assert "STDOUT" in sql + + +def test_copy_to_requires_table_or_base_expression_query(): + dialect = PostgresDialect((19, 0, 0)) + with pytest.raises(ValueError, match="exactly one"): + PostgresCopyToExpression(dialect) + with pytest.raises(TypeError, match="BaseExpression"): + PostgresCopyToExpression(dialect, query="SELECT 1") + with pytest.raises(ValueError, match="not both"): + PostgresCopyToExpression(dialect, table_name="users", query=RawSQLExpression(dialect, "SELECT 1")) + with pytest.raises(TypeError): + PostgresCopyToExpression(dialect, table_name="users", program="cat input") + + +def test_copy_to_query_returns_parameters_and_rejects_mutations(): + dialect = PostgresDialect((19, 0, 0)) + expression = PostgresCopyToExpression(dialect, query=select_query(dialect)) + + sql, params = expression.to_sql() + + assert sql == 'COPY (SELECT "id" FROM "users" WHERE "id" > %s) TO STDOUT (FORMAT TEXT)' + assert params == (3,) + with pytest.raises(ValueError, match="read-only"): + PostgresCopyToExpression( + dialect, + query=RawSQLExpression(dialect, "SELECT 1; DELETE FROM users"), + ).to_sql() + with pytest.raises(ValueError, match="read-only"): + PostgresCopyToExpression( + dialect, + query=RawSQLExpression(dialect, "WITH gone AS (DELETE FROM users RETURNING id) SELECT * FROM gone"), + ).to_sql() + + +def test_copy_to_pg19_json_force_array_and_partitioned_gates(): + expression = PostgresCopyToExpression( + PostgresDialect((18, 0, 0)), + table_name="events", + format="json", + force_array=True, + partitioned=True, + ) + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 19"): + expression.to_sql() + + sql, params = PostgresCopyToExpression( + PostgresDialect((19, 0, 0)), + table_name="events", + format="json", + force_array=True, + partitioned=True, + ).to_sql() + assert sql == 'COPY "events" TO STDOUT (FORMAT JSON, FORCE_ARRAY)' + assert params == () + + +def test_copy_to_rejects_format_option_combinations(): + dialect = PostgresDialect((19, 0, 0)) + with pytest.raises(ValueError, match="JSON"): + PostgresCopyToExpression(dialect, table_name="events", format="json", header=True).to_sql() + with pytest.raises(ValueError, match="CSV"): + PostgresCopyToExpression(dialect, table_name="events", quote='"').to_sql() + with pytest.raises(ValueError, match="FORCE_ARRAY"): + PostgresCopyToExpression(dialect, table_name="events", force_array=True).to_sql() + with pytest.raises(ValueError, match="single-byte"): + PostgresCopyToExpression(dialect, table_name="events", delimiter="ab").to_sql() + + +def test_copy_from_csv_formatter_with_all_supported_options(): + dialect = PostgresDialect((19, 0, 0)) + expression = PostgresCopyFromExpression( + dialect, + 'users"; DROP TABLE users; --', + schema="app data", + columns=["id", "name"], + format="csv", + header="match", + null="NULL", + default="DEFAULT", + delimiter="|", + quote="'", + escape="\\", + force_not_null=["name"], + force_null="*", + on_error=PostgresCopyOnError.IGNORE, + reject_limit=5, + encoding="UTF8", + log_verbosity=PostgresCopyLogVerbosity.SILENT, + ) + + sql, params = expression.to_sql() + + assert params == () + assert 'COPY "app data"."users""; DROP TABLE users; --" ("id", "name") FROM STDIN' in sql + assert "FORMAT CSV" in sql + assert "HEADER MATCH" in sql + assert "NULL 'NULL'" in sql + assert "DEFAULT 'DEFAULT'" in sql + assert 'FORCE_NOT_NULL ("name")' in sql + assert "FORCE_NULL *" in sql + assert "ON_ERROR IGNORE" in sql + assert "REJECT_LIMIT 5" in sql + assert "ENCODING 'UTF8'" in sql + assert "LOG_VERBOSITY SILENT" in sql + assert "PROGRAM" not in sql + + +def test_copy_from_rejects_json_and_direction_options(): + dialect = PostgresDialect((19, 0, 0)) + with pytest.raises(ValueError, match="COPY FROM does not support JSON"): + PostgresCopyFromExpression(dialect, "users", format="json").to_sql() + with pytest.raises(TypeError): + PostgresCopyFromExpression(dialect, "users", stdout=True) + with pytest.raises(TypeError): + PostgresCopyFromExpression(dialect, "users", query=RawSQLExpression(dialect, "SELECT 1")) + + +def test_copy_header_version_boundaries(): + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 15"): + PostgresCopyToExpression( + PostgresDialect((14, 0, 0)), + table_name="users", + header=True, + ).to_sql() + assert ( + "HEADER" + in PostgresCopyToExpression( + PostgresDialect((15, 0, 0)), + table_name="users", + header=True, + ).to_sql()[0] + ) + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 15"): + PostgresCopyFromExpression( + PostgresDialect((14, 0, 0)), + "users", + header="match", + ).to_sql() + assert ( + "HEADER MATCH" + in PostgresCopyFromExpression( + PostgresDialect((15, 0, 0)), + "users", + header="match", + ).to_sql()[0] + ) + assert ( + "HEADER FALSE" + in PostgresCopyToExpression( + PostgresDialect((14, 0, 0)), + table_name="users", + header=False, + ).to_sql()[0] + ) + assert ( + "HEADER" + in PostgresCopyFromExpression( + PostgresDialect((14, 0, 0)), + "users", + format="csv", + header=True, + ).to_sql()[0] + ) + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 19"): + PostgresCopyFromExpression( + PostgresDialect((18, 0, 0)), + "users", + format="csv", + header=3, + ).to_sql() + assert ( + "HEADER 1" + in PostgresCopyFromExpression( + PostgresDialect((18, 0, 0)), + "users", + format="csv", + header=1, + ).to_sql()[0] + ) + assert ( + "HEADER 3" + in PostgresCopyFromExpression( + PostgresDialect((19, 0, 0)), + "users", + format="csv", + header=3, + ).to_sql()[0] + ) + with pytest.raises(ValueError, match="0 or 1"): + PostgresCopyToExpression( + PostgresDialect((19, 0, 0)), + table_name="users", + header=2, + ).to_sql() + + +def test_copy_error_option_version_boundaries(): + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 17"): + PostgresCopyFromExpression( + PostgresDialect((16, 0, 0)), + "users", + on_error="ignore", + ).to_sql() + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 18"): + PostgresCopyFromExpression( + PostgresDialect((17, 0, 0)), + "users", + on_error="ignore", + reject_limit=2, + ).to_sql() + assert ( + "REJECT_LIMIT 2" + in PostgresCopyFromExpression( + PostgresDialect((18, 0, 0)), + "users", + on_error="ignore", + reject_limit=2, + ).to_sql()[0] + ) + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 19"): + PostgresCopyFromExpression( + PostgresDialect((18, 0, 0)), + "users", + on_error="set_null", + ).to_sql() + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 17"): + PostgresCopyFromExpression( + PostgresDialect((16, 0, 0)), + "users", + log_verbosity="verbose", + ).to_sql() + assert ( + "LOG_VERBOSITY VERBOSE" + in PostgresCopyFromExpression( + PostgresDialect((17, 0, 0)), + "users", + log_verbosity="verbose", + ).to_sql()[0] + ) + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 18"): + PostgresCopyFromExpression( + PostgresDialect((17, 0, 0)), + "users", + log_verbosity="silent", + ).to_sql() + assert ( + "LOG_VERBOSITY SILENT" + in PostgresCopyFromExpression( + PostgresDialect((18, 0, 0)), + "users", + on_error="ignore", + log_verbosity="silent", + ).to_sql()[0] + ) + + +def test_copy_force_all_star_version_boundaries(): + for option in ("force_not_null", "force_null"): + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 17"): + PostgresCopyFromExpression( + PostgresDialect((16, 0, 0)), + "users", + format="csv", + **{option: "*"}, + ).to_sql() + assert ( + f"{option.upper()} *" + in PostgresCopyFromExpression( + PostgresDialect((17, 0, 0)), + "users", + format="csv", + **{option: "*"}, + ).to_sql()[0] + ) + assert ( + "FORCE_QUOTE *" + in PostgresCopyToExpression( + PostgresDialect((16, 0, 0)), + table_name="users", + format="csv", + force_quote="*", + ).to_sql()[0] + ) + + +def test_copy_from_rejects_invalid_option_relationships(): + dialect = PostgresDialect((19, 0, 0)) + with pytest.raises(ValueError, match="REJECT_LIMIT requires"): + PostgresCopyFromExpression(dialect, "users", reject_limit=1).to_sql() + with pytest.raises(ValueError, match="require text or csv"): + PostgresCopyFromExpression( + dialect, + "users", + format="binary", + on_error="ignore", + ).to_sql() + with pytest.raises(ValueError, match="QUOTE and ESCAPE"): + PostgresCopyFromExpression(dialect, "users", quote='"').to_sql() + with pytest.raises(ValueError, match="absent"): + PostgresCopyFromExpression( + dialect, + "users", + columns=["id"], + force_null=["missing"], + format="csv", + ).to_sql() + with pytest.raises(TypeError, match="BaseExpression"): + PostgresCopyFromExpression(dialect, "users", where="id > 0") + + +def test_copy_from_where_returns_parameters(): + dialect = PostgresDialect((19, 0, 0)) + expression = PostgresCopyFromExpression( + dialect, + "users", + where=Column(dialect, "id") > Literal(dialect, 7), + ) + + sql, params = expression.to_sql() + + assert sql.endswith('WHERE "id" > %s') + assert params == (7,) + + +def test_repack_version_gate_and_all_forms(): + expression = PostgresRepackExpression(PostgresDialect((18, 0, 0)), table_name="users") + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 19"): + expression.to_sql() + + dialect = PostgresDialect((19, 0, 0)) + assert PostgresRepackExpression(dialect).to_sql() == ("REPACK", ()) + assert PostgresRepackExpression( + dialect, + verbose=True, + all_using_index=True, + ).to_sql() == ("REPACK (VERBOSE) USING INDEX", ()) + assert PostgresRepackExpression( + dialect, + table_name="users", + concurrently=True, + using_index=True, + ).to_sql() == ('REPACK (CONCURRENTLY) "users" USING INDEX', ()) + assert PostgresRepackExpression( + dialect, + table_name="users", + schema="app data", + columns=["id", "name"], + analyze=True, + using_index='users"; DROP TABLE users; --', + ).to_sql() == ( + 'REPACK (ANALYZE) "app data"."users" ("id", "name") USING INDEX "users""; DROP TABLE users; --"', + (), + ) + + +def test_repack_strict_relationships(): + dialect = PostgresDialect((19, 0, 0)) + with pytest.raises(ValueError, match="columns require ANALYZE"): + PostgresRepackExpression(dialect, table_name="users", columns=["id"]).to_sql() + with pytest.raises(ValueError, match="ANALYZE requires one table"): + PostgresRepackExpression(dialect, analyze=True).to_sql() + with pytest.raises(ValueError, match="CONCURRENTLY requires one table"): + PostgresRepackExpression(dialect, concurrently=True).to_sql() + with pytest.raises(ValueError, match="named REPACK index"): + PostgresRepackExpression(dialect, using_index="users_idx").to_sql() + with pytest.raises(ValueError, match="cannot be combined"): + PostgresRepackExpression( + dialect, + table_name="users", + all_using_index=True, + ).to_sql() + + +def test_protocol_mro_and_serialization(): + dialect = PostgresDialect((19, 0, 0)) + assert isinstance(dialect, PostgresCopySupport) + assert isinstance(dialect, PostgresRepackSupport) + assert PostgresDialect.__mro__.index(PostgresCopyMixin) < PostgresDialect.__mro__.index(PostgresCopySupport) + assert PostgresDialect.__mro__.index(PostgresRepackMixin) < PostgresDialect.__mro__.index(PostgresRepackSupport) + + original = PostgresCopyToExpression(dialect, query=select_query(dialect)) + restored = deserialize(serialize(original), dialect) + assert isinstance(restored, PostgresCopyToExpression) + assert restored.to_sql() == original.to_sql() + + +def test_sync_copy_to_consumes_complete_stream_and_passes_parameters(): + dialect = PostgresDialect((19, 0, 0)) + copy = SyncCopy([b"first", memoryview(b"-second"), "third"]) + cursor = SyncCursor(copy) + backend = make_sync_backend(cursor) + + result = backend.copy_to(PostgresCopyToExpression(dialect, query=select_query(dialect))) + + assert result == b"first-secondthird" + assert cursor.statements[0][1] == (3,) + assert cursor.closed is True + + +def test_sync_copy_from_streams_chunks_and_returns_row_count(): + dialect = PostgresDialect((19, 0, 0)) + copy = SyncCopy() + cursor = SyncCursor(copy, rowcount=3) + backend = make_sync_backend(cursor) + + result = backend.copy_from( + PostgresCopyFromExpression(dialect, "users", format="csv"), + (b"1,one\n", memoryview(b"2,two\n"), "3,three\n"), + ) + + assert result == 3 + assert copy.writes == [b"1,one\n", memoryview(b"2,two\n"), "3,three\n"] + assert cursor.closed is True + + +def test_sync_copy_to_client_error_rolls_back_explicit_transaction(): + dialect = PostgresDialect((19, 0, 0)) + copy = SyncCopy([b"partial"], RuntimeError("client copy failure")) + cursor = SyncCursor(copy) + backend = make_sync_backend(cursor) + manager = Mock(is_active=True) + backend._transaction_manager = manager + backend._connection = Mock(closed=False) + + with pytest.raises(RuntimeError, match="client copy failure"): + backend.copy_to(PostgresCopyToExpression(dialect, table_name="users")) + + manager.rollback.assert_called_once_with() + + +def test_sync_copy_from_rejects_str_with_explicit_encoding(): + dialect = PostgresDialect((19, 0, 0)) + copy = SyncCopy() + cursor = SyncCursor(copy) + backend = make_sync_backend(cursor) + + with pytest.raises(TypeError, match="ENCODING"): + backend.copy_from( + PostgresCopyFromExpression(dialect, "users", encoding="LATIN1"), + "one\n", + ) + + assert copy.writes == [] + + +def test_sync_copy_client_error_rolls_back_explicit_transaction(): + dialect = PostgresDialect((19, 0, 0)) + copy = SyncCopy() + cursor = SyncCursor(copy) + backend = make_sync_backend(cursor) + manager = Mock(is_active=True) + backend._transaction_manager = manager + backend._connection = Mock(closed=False) + + def input_chunks(): + yield b"one\n" + raise RuntimeError("client copy failure") + + with pytest.raises(RuntimeError, match="client copy failure"): + backend.copy_from(PostgresCopyFromExpression(dialect, "users"), input_chunks()) + + manager.rollback.assert_called_once_with() + assert copy.writes == [b"one\n"] + + +@pytest.mark.asyncio +async def test_async_copy_to_client_error_rolls_back_explicit_transaction(): + dialect = PostgresDialect((19, 0, 0)) + copy = AsyncCopy([b"partial"], RuntimeError("client copy failure")) + cursor = AsyncCursor(copy) + backend = make_async_backend(cursor) + manager = Mock(is_active=True) + manager.rollback = AsyncMock() + backend._transaction_manager = manager + backend._connection = Mock(closed=False) + backend._connection.rollback = AsyncMock() + + with pytest.raises(RuntimeError, match="client copy failure"): + await backend.copy_to(PostgresCopyToExpression(dialect, table_name="users")) + + manager.rollback.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_async_copy_from_rejects_str_with_explicit_encoding(): + dialect = PostgresDialect((19, 0, 0)) + copy = AsyncCopy() + cursor = AsyncCursor(copy) + backend = make_async_backend(cursor) + + with pytest.raises(TypeError, match="ENCODING"): + await backend.copy_from( + PostgresCopyFromExpression(dialect, "users", encoding="LATIN1"), + "one\n", + ) + + assert copy.writes == [] + + +@pytest.mark.asyncio +async def test_async_copy_client_error_rolls_back_explicit_transaction(): + dialect = PostgresDialect((19, 0, 0)) + copy = AsyncCopy() + cursor = AsyncCursor(copy) + backend = make_async_backend(cursor) + manager = Mock(is_active=True) + manager.rollback = AsyncMock() + backend._transaction_manager = manager + backend._connection = Mock(closed=False) + backend._connection.rollback = AsyncMock() + + async def input_chunks(): + yield b"one\n" + raise RuntimeError("client copy failure") + + with pytest.raises(RuntimeError, match="client copy failure"): + await backend.copy_from(PostgresCopyFromExpression(dialect, "users"), input_chunks()) + + manager.rollback.assert_awaited_once_with() + assert copy.writes == [b"one\n"] + + +def test_sync_copy_converts_psycopg_errors(): + from psycopg.errors import ProgrammingError + + class ErrorCursor(SyncCursor): + @contextmanager + def copy(self, statement, params): + raise ProgrammingError("invalid COPY") + yield + + dialect = PostgresDialect((19, 0, 0)) + cursor = ErrorCursor(SyncCopy()) + backend = make_sync_backend(cursor) + backend._handle_error = Mock(side_effect=QueryError("converted")) + + with pytest.raises(QueryError, match="converted"): + backend.copy_to(PostgresCopyToExpression(dialect, table_name="users")) + + +@pytest.mark.asyncio +async def test_async_copy_apis_are_symmetric_and_consume_streams(): + dialect = PostgresDialect((19, 0, 0)) + output_cursor = AsyncCursor(AsyncCopy([b"one", b"two"])) + output_backend = make_async_backend(output_cursor) + output = await output_backend.copy_to(PostgresCopyToExpression(dialect, query=select_query(dialect))) + assert output == b"onetwo" + assert output_cursor.statements[0][1] == (3,) + assert output_cursor.closed is True + + input_copy = AsyncCopy() + input_cursor = AsyncCursor(input_copy, rowcount=2) + input_backend = make_async_backend(input_cursor) + count = await input_backend.copy_from( + PostgresCopyFromExpression(dialect, "users"), + [b"one\n", b"two\n"], + ) + assert count == 2 + assert input_copy.writes == [b"one\n", b"two\n"] + assert input_cursor.closed is True diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py new file mode 100644 index 00000000..ab599fb5 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py @@ -0,0 +1,124 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py +"""Live PostgreSQL COPY and REPACK integration tests.""" + +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal, QueryExpression, TableExpression +from rhosocial.activerecord.backend.impl.postgres import ( + PostgresCopyFromExpression, + PostgresCopyToExpression, + PostgresRepackExpression, +) + + +TABLE_NAME = "rhosocial_copy_repack_test" +PARTITIONED_TABLE_NAME = "rhosocial_copy_partitioned_test" +PARTITION_NAME = "rhosocial_copy_partitioned_test_0_10" + + +def prepare_table(backend): + backend.execute(f'DROP TABLE IF EXISTS "{TABLE_NAME}"') + backend.execute(f'CREATE TABLE "{TABLE_NAME}" ("id" integer, "name" text)') + + +async def async_prepare_table(backend): + await backend.execute(f'DROP TABLE IF EXISTS "{TABLE_NAME}"') + await backend.execute(f'CREATE TABLE "{TABLE_NAME}" ("id" integer, "name" text)') + + +def test_live_copy_round_trip_and_native_repack(postgres_backend): + dialect = postgres_backend.dialect + prepare_table(postgres_backend) + try: + copied = postgres_backend.copy_from( + PostgresCopyFromExpression(dialect, TABLE_NAME), + [b"1\tAlice\n", b"2\tBob\n"], + ) + assert copied == 2 + + query = QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "name")], + from_=TableExpression(dialect, TABLE_NAME), + where=Column(dialect, "id") > Literal(dialect, 1), + ) + assert postgres_backend.copy_to(PostgresCopyToExpression(dialect, query=query)) == b"2\tBob\n" + + if dialect.version >= (19, 0, 0): + json_data = postgres_backend.copy_to( + PostgresCopyToExpression( + dialect, + table_name=TABLE_NAME, + format="json", + force_array=True, + ) + ) + assert b'"name":"Alice"' in json_data + assert b'"name":"Bob"' in json_data + postgres_backend.execute(*PostgresRepackExpression(dialect, table_name=TABLE_NAME).to_sql()) + finally: + postgres_backend.execute(f'DROP TABLE IF EXISTS "{TABLE_NAME}"') + + +def test_live_pg19_partitioned_copy_to(postgres_backend): + dialect = postgres_backend.dialect + if dialect.version < (19, 0, 0): + pytest.skip("Direct partitioned COPY TO requires PostgreSQL 19+") + postgres_backend.execute(f'DROP TABLE IF EXISTS "{PARTITIONED_TABLE_NAME}"') + postgres_backend.execute( + f'CREATE TABLE "{PARTITIONED_TABLE_NAME}" ("id" integer, "name" text) PARTITION BY RANGE ("id")' + ) + postgres_backend.execute( + f'CREATE TABLE "{PARTITION_NAME}" PARTITION OF "{PARTITIONED_TABLE_NAME}" FOR VALUES FROM (0) TO (10)' + ) + try: + copied = postgres_backend.copy_from( + PostgresCopyFromExpression(dialect, PARTITION_NAME), + b"1\tPartitioned\n", + ) + assert copied == 1 + data = postgres_backend.copy_to( + PostgresCopyToExpression( + dialect, + table_name=PARTITIONED_TABLE_NAME, + partitioned=True, + ) + ) + assert data == b"1\tPartitioned\n" + finally: + postgres_backend.execute(f'DROP TABLE IF EXISTS "{PARTITIONED_TABLE_NAME}"') + + +@pytest.mark.asyncio +async def test_live_async_copy_round_trip_and_native_repack(async_postgres_backend): + dialect = async_postgres_backend.dialect + await async_prepare_table(async_postgres_backend) + try: + copied = await async_postgres_backend.copy_from( + PostgresCopyFromExpression(dialect, TABLE_NAME), + [b"1\tAlice\n", b"2\tBob\n"], + ) + assert copied == 2 + + query = QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "name")], + from_=TableExpression(dialect, TABLE_NAME), + where=Column(dialect, "id") > Literal(dialect, 1), + ) + copied_query = await async_postgres_backend.copy_to(PostgresCopyToExpression(dialect, query=query)) + assert copied_query == b"2\tBob\n" + + if dialect.version >= (19, 0, 0): + json_data = await async_postgres_backend.copy_to( + PostgresCopyToExpression( + dialect, + table_name=TABLE_NAME, + format="json", + force_array=True, + ) + ) + assert b'"name":"Alice"' in json_data + await async_postgres_backend.execute(*PostgresRepackExpression(dialect, table_name=TABLE_NAME).to_sql()) + finally: + await async_postgres_backend.execute(f'DROP TABLE IF EXISTS "{TABLE_NAME}"') 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 b54de82d..b2e75176 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 @@ -1,8 +1,8 @@ # 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. +"""Capability-gated SQL/PGQ integration scenarios. -All SQL is constructed via expression classes. No raw SQL strings are used. +The synchronous and asynchronous database tests skip unless the backend explicitly +reports GRAPH_TABLE support. All SQL is constructed through expression classes. """ import pytest import pytest_asyncio @@ -90,11 +90,36 @@ def social_data(postgres_backend): 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")], + [ + 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()) @@ -167,8 +192,15 @@ class TestSocialGraph: 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"))) + 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) @@ -188,8 +220,15 @@ def test_single_hop_followers(self, postgres_backend, social_data): 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"))) + 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) @@ -211,13 +250,20 @@ def test_two_hop_recommendation(self, postgres_backend, social_data): 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"))) + 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) + like_edge = GraphEdge(dialect, "l", "likes", GraphEdgeDirection.LEFT) liker = GraphVertex(dialect, "liker", "person") - match = MatchClause(dialect, a, p, post, l, liker) + match = MatchClause(dialect, a, p, post, like_edge, liker) cols = ColumnsClause(dialect, GraphColumn("liker", "name", "liker_name")) gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") @@ -236,9 +282,9 @@ def test_graph_table_with_group_by(self, postgres_backend, social_data): a = GraphVertex(dialect, "a", "person") p = GraphEdge(dialect, "p", "authored", GraphEdgeDirection.RIGHT) post = GraphVertex(dialect, "post", "post") - l = GraphEdge(dialect, "l", "likes", GraphEdgeDirection.LEFT) + like_edge = GraphEdge(dialect, "l", "likes", GraphEdgeDirection.LEFT) liker = GraphVertex(dialect, "liker", "person") - match = MatchClause(dialect, a, p, post, l, liker) + match = MatchClause(dialect, a, p, post, like_edge, liker) cols = ColumnsClause(dialect, GraphColumn("a", "name", "author"), GraphColumn("liker", "name", "liker_name")) @@ -294,8 +340,15 @@ def test_graph_table_combined_with_regular_sql(self, postgres_backend, social_da 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"))) + 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) @@ -374,8 +427,15 @@ async def async_social_data(self, async_postgres_backend): @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"))) + 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) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py index 39dc4279..a9e8b113 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py @@ -1,3 +1,5 @@ +import pytest + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend, PostgresBackend from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect @@ -47,3 +49,55 @@ def test_async_backend_preserves_explicit_connection_config(self): assert backend.config is config assert isinstance(backend.dialect, PostgresDialect) + + def test_sync_backend_preserves_graph_feature_overrides(self): + backend = PostgresBackend( + graph_feature_overrides={"graph_match": True, "graph_table": True} + ) + + assert backend.dialect.supports_graph_match() is True + assert backend.dialect.supports_graph_table() is True + + def test_async_backend_preserves_graph_feature_overrides(self): + backend = AsyncPostgresBackend( + graph_feature_overrides={"graph_match": True, "graph_table": True} + ) + + assert backend.dialect.supports_graph_match() is True + assert backend.dialect.supports_graph_table() is True + + def test_sync_backend_preserves_overrides_during_version_replacement(self, monkeypatch): + backend = PostgresBackend( + graph_feature_overrides={"graph_match": True, "graph_table": True} + ) + backend._connection = object() + monkeypatch.setattr(backend, "get_server_version", lambda: (19, 0, 4)) + monkeypatch.setattr(backend, "_detect_extensions", lambda: {}) + + backend.introspect_and_adapt() + + assert backend.dialect.version == (19, 0, 4) + assert backend.dialect.supports_graph_match() is True + assert backend.dialect.supports_graph_table() is True + + @pytest.mark.asyncio + async def test_async_backend_preserves_overrides_during_version_replacement(self, monkeypatch): + backend = AsyncPostgresBackend( + graph_feature_overrides={"graph_match": True, "graph_table": True} + ) + backend._connection = object() + + async def get_server_version(): + return (19, 0, 4) + + async def detect_extensions(): + return {} + + monkeypatch.setattr(backend, "get_server_version", get_server_version) + monkeypatch.setattr(backend, "_detect_extensions", detect_extensions) + + await backend.introspect_and_adapt() + + assert backend.dialect.version == (19, 0, 4) + assert backend.dialect.supports_graph_match() is True + assert backend.dialect.supports_graph_table() is True diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py index 6b34ee4b..3ffd1890 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py @@ -14,8 +14,6 @@ ColumnDefinition, ColumnConstraint, ColumnConstraintType, - TableConstraint, - TableConstraintType, ) from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PostgresExcludeConstraint, @@ -134,6 +132,26 @@ def test_exclude_constraint_valid_operators(dialect): assert f"WITH {op}" in sql +def test_exclude_constraint_rejects_gin(dialect): + constraint = PostgresExcludeConstraint( + dialect, + elements=[("range", "&&")], + using="gin", + ) + with pytest.raises(ValueError, match="Invalid index access method"): + dialect._format_exclude_constraint(constraint) + + +def test_exclude_constraint_accepts_symbol_operator(dialect): + constraint = PostgresExcludeConstraint( + dialect, + elements=[("box", "|>>")], + ) + sql, params = dialect._format_exclude_constraint(constraint) + assert 'WITH |>>' in sql + assert params == () + + def test_exclude_constraint_rejects_invalid_using(dialect): """Test that invalid index access method is rejected.""" constraint = PostgresExcludeConstraint( @@ -175,6 +193,38 @@ def test_exclude_constraint_sql_injection_prevention(dialect): assert "/*" not in sql +def test_exclude_constraint_rejects_empty_elements(dialect): + constraint = PostgresExcludeConstraint(dialect, name="empty_exclude") + with pytest.raises(ValueError, match="at least one element"): + dialect._format_exclude_constraint(constraint) + + +def test_exclude_constraint_formats_expression_and_where(dialect): + from rhosocial.activerecord.backend.expression import Column, Literal + + where = Column(dialect, "active") == Literal(dialect, True, inline_literals=True) + constraint = PostgresExcludeConstraint( + dialect, + elements=[("range", "&&")], + where=where, + ) + + sql, params = dialect._format_exclude_constraint(constraint) + + assert sql == 'EXCLUDE USING gist ("range" WITH &&) WHERE ("active" = TRUE)' + assert params == () + + +def test_exclude_constraint_rejects_raw_where(dialect): + constraint = PostgresExcludeConstraint( + dialect, + elements=[("range", "&&")], + where="active = TRUE; DROP TABLE people", + ) + with pytest.raises(TypeError, match="where must be a SQL expression"): + dialect._format_exclude_constraint(constraint) + + class TestPostgresEnumSecurity: """Tests for CREATE TYPE ENUM value escaping (fix for SQL injection risk).""" 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 16062023..379709ed 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 @@ -431,7 +431,11 @@ class TestPostgresExpressionDialectSeparation: ("PostgresPartitionMetadataExpression", "format_partition_metadata_query"), ("PostgresCreateStatisticsExpression", "format_create_statistics_statement"), ("PostgresDropStatisticsExpression", "format_drop_statistics_statement"), - ("PostgresAlterIndexExpression", "format_alter_index_statement"), + ("PostgresAlterIndexExpression", "format_alter_index_statement"), + ("PostgresCopyToExpression", "format_copy_to_statement"), + ("PostgresCopyFromExpression", "format_copy_from_statement"), + ("PostgresRepackExpression", "format_repack_statement"), + ] @pytest.mark.parametrize("expr_name,format_method", EXPRESSION_DIALECT_PAIRS) @@ -478,9 +482,13 @@ def test_expression_delegates_to_dialect(self, expr_name, format_method): (postgres_protocols.PostgresCommentSupport, postgres_mixins.PostgresCommentMixin), (postgres_protocols.PostgresTypeSupport, postgres_mixins.PostgresTypeMixin), (postgres_protocols.PostgresDomainSupport, postgres_mixins.PostgresDomainMixin), - (postgres_protocols.PostgresConstraintSupport, postgres_mixins.PostgresConstraintMixin), + (postgres_protocols.PostgresConstraintSupport, postgres_mixins.PostgresConstraintMixin), + (postgres_protocols.PostgresRepackSupport, postgres_mixins.PostgresRepackMixin), + # DML protocols - (postgres_protocols.PostgresVacuumSupport, postgres_mixins.PostgresVacuumMixin), + (postgres_protocols.PostgresVacuumSupport, postgres_mixins.PostgresVacuumMixin), + (postgres_protocols.PostgresCopySupport, postgres_mixins.PostgresCopyMixin), + (postgres_protocols.PostgresStoredProcedureSupport, postgres_mixins.PostgresStoredProcedureMixin), (postgres_protocols.PostgresExtendedStatisticsSupport, postgres_mixins.PostgresExtendedStatisticsMixin), (postgres_protocols.PostgresAdvisoryLockSupport, postgres_mixins.PostgresAdvisoryLockMixin), From debe75b6771dc9b4baf3d266de72a57cd8fddf84 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 25 Sep 2026 18:08:04 +0800 Subject: [PATCH 32/42] refactor(postgres): remove generated DDL lifecycle --- .../impl/postgres/mixins/ddl/partition.py | 168 ----------------- .../impl/postgres/protocols/ddl/partition.py | 6 - .../backend/postgres/test_ddl_alter_config.py | 19 +- .../backend/postgres/test_ddl_protocols.py | 2 - .../backend/postgres/test_model_truncate.py | 29 ++- .../feature/backend/postgres/test_table.py | 56 +++--- .../backend/test_partition_operations.py | 170 ++++++------------ 7 files changed, 99 insertions(+), 351 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py index 25c70fbe..2d2e9edf 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py @@ -13,18 +13,6 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.expression.statements import PartitionClause -from rhosocial.activerecord.ddl.partition import ( - AttachPartitionRequest, - CreatePartitionRequest, - DetachPartitionRequest, - DropPartitionRequest, - PartitionCapabilities, - PartitionLifecycleContractError, - PartitionOperation, - PartitionOperationNotSupportedError, - PartitionRequest, - TruncatePartitionRequest, -) if TYPE_CHECKING: from ...expression.ddl import ( @@ -170,9 +158,6 @@ def supports_detach_partition(self) -> bool: """ return self.supports_table_partitioning() - def get_partition_lifecycle_provider(self) -> "PostgresPartitionLifecycleProvider": - return PostgresPartitionLifecycleProvider(self) - def format_partition_clause(self, expr) -> Tuple[str, tuple]: """Format a PostgreSQL PARTITION BY clause from PartitionClause.""" if not self.supports_partitioned_table_creation(): @@ -816,156 +801,3 @@ def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpres ) """ return sql, tuple(params) - - -class PostgresPartitionLifecycleProvider: - """Construct PostgreSQL partition lifecycle expressions.""" - - _SUPPORT_METHODS = { - PartitionOperation.CREATE: "supports_add_partition", - PartitionOperation.DROP: "supports_drop_partition", - PartitionOperation.TRUNCATE: "supports_truncate_partition", - PartitionOperation.ATTACH: "supports_attach_partition", - PartitionOperation.DETACH: "supports_detach_partition", - } - - def __init__(self, dialect: Any): - self.dialect = dialect - - def capabilities(self) -> PartitionCapabilities: - operations = frozenset( - operation - for operation, method_name in self._SUPPORT_METHODS.items() - if getattr(self.dialect, method_name)() - ) - strategies = tuple( - strategy - for strategy, supported in ( - ("RANGE", self.dialect.supports_range_table_partitioning()), - ("LIST", self.dialect.supports_list_table_partitioning()), - ("HASH", self.dialect.supports_hash_table_partitioning()), - ) - if supported - ) - return PartitionCapabilities(operations, strategies) - - def supports(self, operation: PartitionOperation) -> bool: - return operation in self._SUPPORT_METHODS and self.capabilities().supports(operation) - - def _require(self, request: PartitionRequest) -> None: - if not self.supports(request.operation): - raise PartitionOperationNotSupportedError(self.dialect.name, request.operation) - - def _require_type(self, request: PartitionRequest, request_type: type) -> None: - if not isinstance(request, request_type): - raise PartitionLifecycleContractError( - f"PostgreSQL partition provider received {type(request).__name__} for " - f"{request.operation.value}" - ) - - def build(self, request: PartitionRequest) -> BaseExpression: - self._require(request) - from rhosocial.activerecord.backend.expression.statements.ddl_table import DropTableExpression - from rhosocial.activerecord.backend.expression.statements.ddl_truncate import TruncateExpression - from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( - PostgresAttachPartitionExpression, - PostgresCreatePartitionExpression, - PostgresDetachPartitionExpression, - ) - - if request.operation is PartitionOperation.CREATE: - self._require_type(request, CreatePartitionRequest) - parent_schema = ( - request.parent_schema - if request.parent_schema is not None - else request.table.schema_name - ) - partition_schema = ( - request.partition_schema - if request.partition_schema is not None - else request.table.schema_name - ) - return PostgresCreatePartitionExpression( - self.dialect, - partition_name=request.partition_name, - parent_table=request.table.name, - partition_type=request.partition_type, - partition_values=dict(request.partition_values), - schema=partition_schema, - parent_schema=parent_schema, - partition_clause=request.partition_clause, - tablespace=request.tablespace, - if_not_exists=request.if_not_exists, - ) - if request.operation is PartitionOperation.DROP: - self._require_type(request, DropPartitionRequest) - from rhosocial.activerecord.backend.expression.core import TableExpression - partition_schema = ( - request.partition_schema - if request.partition_schema is not None - else request.table.schema_name - ) - return DropTableExpression( - self.dialect, - TableExpression( - self.dialect, - request.partition_name, - schema_name=partition_schema, - ), - ) - if request.operation is PartitionOperation.TRUNCATE: - self._require_type(request, TruncatePartitionRequest) - partition_schema = ( - request.partition_schema - if request.partition_schema is not None - else request.table.schema_name - ) - return TruncateExpression( - self.dialect, - request.partition_name, - schema=partition_schema, - ) - if request.operation is PartitionOperation.ATTACH: - self._require_type(request, AttachPartitionRequest) - parent_schema = ( - request.parent_schema - if request.parent_schema is not None - else request.table.schema_name - ) - partition_schema = ( - request.partition_schema - if request.partition_schema is not None - else request.table.schema_name - ) - return PostgresAttachPartitionExpression( - self.dialect, - partition_name=request.partition_name, - parent_table=request.table.name, - partition_type=request.partition_type, - partition_values=dict(request.partition_values), - schema=partition_schema, - parent_schema=parent_schema, - concurrently=request.concurrently, - ) - if request.operation is PartitionOperation.DETACH: - self._require_type(request, DetachPartitionRequest) - parent_schema = ( - request.parent_schema - if request.parent_schema is not None - else request.table.schema_name - ) - partition_schema = ( - request.partition_schema - if request.partition_schema is not None - else request.table.schema_name - ) - return PostgresDetachPartitionExpression( - self.dialect, - partition_name=request.partition_name, - parent_table=request.table.name, - schema=partition_schema, - parent_schema=parent_schema, - concurrently=request.concurrently, - finalize=request.finalize, - ) - raise PartitionOperationNotSupportedError(self.dialect.name, request.operation) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py index f01b8095..564c322b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py @@ -10,8 +10,6 @@ from rhosocial.activerecord.backend.dialect.protocols import PartitionSupport if TYPE_CHECKING: - from rhosocial.activerecord.ddl import PartitionLifecycleProvider - from ...expression.ddl import ( PartitionValue, PostgresCreatePartitionExpression, @@ -50,10 +48,6 @@ class PostgresPartitionSupport(PartitionSupport, Protocol): - Partitionwise aggregate: PostgreSQL 11+ """ - def get_partition_lifecycle_provider(self) -> "PartitionLifecycleProvider": - """Return the PostgreSQL partition lifecycle provider.""" - ... - def supports_hash_partitioning(self) -> bool: """Whether HASH partitioning is supported. diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py index 81080188..4bf23423 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py @@ -575,23 +575,24 @@ def test_exclude_expression_parentheses_and_recursive_binding(self): from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PostgresExcludeConstraint, ) - from rhosocial.activerecord.ddl import DialectBinder - dialect = PostgresDialect((18, 0, 0)) declared = PostgresExcludeConstraint( - None, - elements=[(FunctionCall(None, "LOWER", Column(None, "name")), "=")], - where=Column(None, "active") == Literal(None, True, inline_literals=True), + dialect, + elements=[ + ( + FunctionCall(dialect, "LOWER", Column(dialect, "name")), + "=", + ) + ], + where=Column(dialect, "active") + == Literal(dialect, True, inline_literals=True), ) - bound = DialectBinder(dialect).bind(declared) - assert bound.to_sql() == ( + assert declared.to_sql() == ( 'EXCLUDE USING gist ((LOWER("name")) WITH =) ' 'WHERE ("active" = TRUE)', (), ) - with pytest.raises(ValueError): - declared.elements[0][0].to_sql() with pytest.raises(ValueError, match="Invalid exclude operator"): PostgresExcludeConstraint( diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py index 53e191fa..47c23821 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py @@ -392,11 +392,9 @@ def test_postgres_partition_support_is_runtime_checkable(self): from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( PostgresPartitionSupport, ) - from rhosocial.activerecord.ddl import PartitionLifecycleProvider dialect = PostgresDialect(version=(14, 0, 0)) assert isinstance(dialect, PostgresPartitionSupport) - assert isinstance(dialect.get_partition_lifecycle_provider(), PartitionLifecycleProvider) def test_postgres_comment_support_is_runtime_checkable(self): """PostgresCommentSupport should be runtime checkable.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py index 763aac43..2a91b48c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py @@ -4,7 +4,6 @@ from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( TruncateExpression, ) -from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord TABLE = "model_truncate_events" @@ -12,14 +11,12 @@ class TestSyncModelTruncate: def test_truncate_expression_renders_postgres_options(self, postgres_backend): - class Event(ActiveRecord): - __table_name__ = TABLE - - id: int - name: str - - Event.__backend__ = postgres_backend - expression = Event.ddl().truncate(restart_identity=True, cascade=True) + expression = TruncateExpression( + postgres_backend.dialect, + TABLE, + restart_identity=True, + cascade=True, + ) assert isinstance(expression, TruncateExpression) assert expression.table_name == TABLE @@ -36,14 +33,12 @@ async def test_truncate_expression_renders_postgres_options( self, async_postgres_backend, ): - class Event(AsyncActiveRecord): - __table_name__ = TABLE - - id: int - name: str - - Event.__backend__ = async_postgres_backend - expression = Event.ddl().truncate(restart_identity=True, cascade=True) + expression = TruncateExpression( + async_postgres_backend.dialect, + TABLE, + restart_identity=True, + cascade=True, + ) assert isinstance(expression, TruncateExpression) assert expression.table_name == TABLE diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py index 4f7bc1b0..409aaf45 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py @@ -2,31 +2,14 @@ import pytest -from rhosocial.activerecord.ddl import TableDDLDeriver from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import ( + ColumnDefinition, + CreateTableExpression, +) +from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.mixins.ddl_table import PostgresTableMixin -from rhosocial.activerecord.model import ActiveRecord - - -class Inheriting(ActiveRecord): - __table_name__ = "child" - - id: int - - @classmethod - def table_inherits(cls): - return ["parent_a", "parent_b"] - - -class Tablespaced(ActiveRecord): - __table_name__ = "spaced" - - id: int - - @classmethod - def table_tablespace(cls): - return "ts_data" class TestTableSupport: @@ -86,26 +69,33 @@ def test_supports_table_tablespace_direct(self): class TestPostgresTableDDLDeclarations: - def test_table_declaration_defaults_are_absent(self): - class Plain(ActiveRecord): - __table_name__ = "plain_table_defaults" + @staticmethod + def _expression(dialect, table="child", *, inherits=None, tablespace=None): + return CreateTableExpression( + dialect, + table, + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + inherits=inherits, + tablespace=tablespace, + ) - id: int - - expression = TableDDLDeriver(Plain, PostgresDialect(version=(16, 0, 0))).create_table() + def test_table_declaration_defaults_are_absent(self): + expression = self._expression(PostgresDialect(version=(16, 0, 0))) assert expression.inherits == [] assert expression.tablespace is None def test_table_inherits_is_carried_and_rendered(self): dialect = PostgresDialect(version=(16, 0, 0)) - expression = TableDDLDeriver(Inheriting, dialect).create_table() + expression = self._expression( + dialect, inherits=["parent_a", "parent_b"] + ) assert expression.inherits == ["parent_a", "parent_b"] sql, _ = expression.to_sql() assert 'INHERITS ("parent_a", "parent_b")' in sql def test_table_tablespace_is_carried_and_rendered(self): dialect = PostgresDialect(version=(16, 0, 0)) - expression = TableDDLDeriver(Tablespaced, dialect).create_table() + expression = self._expression(dialect, tablespace="ts_data") assert expression.tablespace == "ts_data" sql, _ = expression.to_sql() assert 'TABLESPACE "ts_data"' in sql @@ -114,10 +104,12 @@ def test_table_inherits_fails_fast_when_capability_disabled(self): dialect = PostgresDialect(version=(16, 0, 0)) with patch.object(dialect, "supports_table_inheritance", return_value=False): with pytest.raises(UnsupportedFeatureError, match="INHERITS"): - TableDDLDeriver(Inheriting, dialect).create_table().to_sql() + self._expression( + dialect, inherits=["parent_a", "parent_b"] + ).to_sql() def test_table_tablespace_fails_fast_when_capability_disabled(self): dialect = PostgresDialect(version=(16, 0, 0)) with patch.object(dialect, "supports_table_tablespace", return_value=False): with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): - TableDDLDeriver(Tablespaced, dialect).create_table().to_sql() + self._expression(dialect, tablespace="ts_data").to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py index 9e63a404..f6fb2d27 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py @@ -11,6 +11,7 @@ import pytest import pytest_asyncio +from rhosocial.activerecord.backend.errors import IntegrityError from rhosocial.activerecord.backend.expression import ( Column, CreateIndexExpression, @@ -36,7 +37,6 @@ TableConstraintType, TruncateExpression, ) -from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression import ( PostgresAttachPartitionExpression, PostgresCreatePartitionExpression, @@ -58,17 +58,6 @@ from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, ColumnConstraintType, ) -from rhosocial.activerecord.ddl import PartitionLifecycle, PartitionOperation - - -class _LifecycleSource: - @classmethod - def table_name(cls): - return "ar_partition_events" - - @classmethod - def schema_name(cls): - return "public" PARTITION_TABLES = ( @@ -85,101 +74,6 @@ def schema_name(cls): ) -def test_partition_lifecycle_provider_constructs_postgres_expressions(): - dialect = PostgresDialect() - dialect.version = (15, 0, 0) - lifecycle = PartitionLifecycle(_LifecycleSource, dialect) - assert lifecycle.capabilities().supports(PartitionOperation.CREATE) - assert not lifecycle.supports(PartitionOperation.MERGE) - assert not lifecycle.supports(PartitionOperation.SPLIT) - clause = PartitionClause( - dialect, - PartitionStrategy.LIST, - [Column(dialect, "region")], - ) - expression = lifecycle.create_partition( - "ar_partition_events_p2027", - "RANGE", - {"from": "2027-01-01", "to": "2028-01-01"}, - partition_clause=clause, - partition_schema="child_schema", - parent_schema="parent_schema", - ) - sql, params = expression.to_sql() - assert '"child_schema"."ar_partition_events_p2027"' in sql - assert 'PARTITION OF "parent_schema"."ar_partition_events"' in sql - assert sql.index("FOR VALUES") < sql.index("PARTITION BY LIST") - assert params == () - - inherited_child = lifecycle.create_partition( - "ar_partition_events_p2028", - "RANGE", - {"from": "2028-01-01", "to": "2029-01-01"}, - parent_schema="other_parent_schema", - ) - inherited_child_sql, _ = inherited_child.to_sql() - assert '"public"."ar_partition_events_p2028"' in inherited_child_sql - assert 'PARTITION OF "other_parent_schema"."ar_partition_events"' in inherited_child_sql - - -def test_partition_lifecycle_preserves_parent_and_child_schemas(): - dialect = PostgresDialect(version=(15, 0, 0)) - lifecycle = PartitionLifecycle(_LifecycleSource, dialect) - - create_sql, create_params = lifecycle.create_partition( - "ar_partition_events_p2029", - "RANGE", - {"from": "2029-01-01", "to": "2030-01-01"}, - partition_schema="child_schema", - parent_schema="parent_schema", - ).to_sql() - assert create_sql == ( - 'CREATE TABLE "child_schema"."ar_partition_events_p2029" ' - 'PARTITION OF "parent_schema"."ar_partition_events" ' - "FOR VALUES FROM ('2029-01-01') TO ('2030-01-01')" - ) - assert create_params == () - - drop_sql, drop_params = lifecycle.drop_partition( - "ar_partition_events_p2029", - partition_schema="child_schema", - ).to_sql() - assert drop_sql == 'DROP TABLE "child_schema"."ar_partition_events_p2029"' - assert drop_params == () - - truncate_sql, truncate_params = lifecycle.truncate_partition( - "ar_partition_events_p2029", - partition_schema="child_schema", - ).to_sql() - assert truncate_sql == 'TRUNCATE TABLE "child_schema"."ar_partition_events_p2029"' - assert truncate_params == () - - attach_sql, attach_params = lifecycle.attach_partition( - "ar_partition_events_p2029", - "RANGE", - {"from": "2029-01-01", "to": "2030-01-01"}, - partition_schema="child_schema", - parent_schema="parent_schema", - ).to_sql() - assert attach_sql == ( - 'ALTER TABLE "parent_schema"."ar_partition_events" ' - 'ATTACH PARTITION "child_schema"."ar_partition_events_p2029" ' - "FOR VALUES FROM ('2029-01-01') TO ('2030-01-01')" - ) - assert attach_params == () - - detach_sql, detach_params = lifecycle.detach_partition( - "ar_partition_events_p2029", - partition_schema="child_schema", - parent_schema="parent_schema", - ).to_sql() - assert detach_sql == ( - 'ALTER TABLE "parent_schema"."ar_partition_events" ' - 'DETACH PARTITION "child_schema"."ar_partition_events_p2029"' - ) - assert detach_params == () - - def _qualified(table_name: str) -> str: return f"public.{table_name}" @@ -189,8 +83,22 @@ def _create_partitioned_parent_sql(dialect, table_name: str): dialect=dialect, table=table_name, columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition( + dialect, + "id", + BigIntType(dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), + ColumnDefinition( + dialect, + "created_at", + TimestampType(dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), ColumnDefinition(dialect, "payload", TextType(dialect=dialect)), ], partition=PartitionClause( @@ -313,10 +221,38 @@ def _create_production_parent_sql(dialect): dialect=dialect, table=PRODUCTION_PARTITION_TABLE, columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", TimestampType(precision=6, dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "tenant_id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "payload", TextType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition( + dialect, + "id", + BigIntType(dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), + ColumnDefinition( + dialect, + "created_at", + TimestampType(precision=6, dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), + ColumnDefinition( + dialect, + "tenant_id", + BigIntType(dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), + ColumnDefinition( + dialect, + "payload", + TextType(dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), ], table_constraints=[ TableConstraint( @@ -850,7 +786,7 @@ def test_default_partition_catches_overflow_and_blocks_conflicting_range( assert row["partition_name"] == "ar_partition_events_default" assert row["payload"] == "default-overflow" - with pytest.raises(Exception): + with pytest.raises(IntegrityError): postgres_backend.execute( *_create_range_partition_sql( dialect, @@ -1068,7 +1004,7 @@ async def test_default_partition_catches_overflow_and_blocks_conflicting_range( assert row["partition_name"] == "ar_partition_events_default" assert row["payload"] == "default-overflow" - with pytest.raises(Exception): + with pytest.raises(IntegrityError): await async_postgres_backend.execute( *_create_range_partition_sql( dialect, @@ -1228,7 +1164,7 @@ def test_initial_year_partition_uses_microsecond_boundaries( ) assert [row["payload"] for row in rows] == ["year-start", "year-end"] - with pytest.raises(Exception): + with pytest.raises(IntegrityError): postgres_backend.execute( *_insert_production_events_expression( postgres_backend.dialect, @@ -1464,7 +1400,7 @@ async def test_initial_year_partition_uses_microsecond_boundaries( ) assert [row["payload"] for row in rows] == ["year-start", "year-end"] - with pytest.raises(Exception): + with pytest.raises(IntegrityError): await async_postgres_backend.execute( *_insert_production_events_expression( async_postgres_backend.dialect, From d32196e0326d71ebfa2920b56b4796834196b14d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 25 Sep 2026 18:55:43 +0800 Subject: [PATCH 33/42] fix(protocol): declare partition support capabilities --- .../impl/postgres/protocols/ddl/partition.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py index 564c322b..a724f765 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py @@ -111,6 +111,30 @@ def supports_partitionwise_aggregate(self) -> bool: """ ... + def supports_add_partition(self) -> bool: + """Whether ADD PARTITION is supported.""" + ... + + def supports_drop_partition(self) -> bool: + """Whether DROP PARTITION is supported.""" + ... + + def supports_truncate_partition(self) -> bool: + """Whether TRUNCATE PARTITION is supported.""" + ... + + def supports_reorganize_partition(self) -> bool: + """Whether REORGANIZE PARTITION is supported.""" + ... + + def supports_attach_partition(self) -> bool: + """Whether ATTACH PARTITION is supported.""" + ... + + def supports_detach_partition(self) -> bool: + """Whether DETACH PARTITION is supported.""" + ... + def format_create_partition_statement(self, expr: "PostgresCreatePartitionExpression") -> Tuple[str, tuple]: """Format CREATE TABLE ... PARTITION OF statement from expression. From 4e4abc5e435802a76f56cefa1dc37104e7cfe2af Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 00:32:58 +0800 Subject: [PATCH 34/42] test(ddl): cover DDLSource collection Verify PostgreSQL DDLSource declarations, backend types, column options, and dialect rendering. --- .../postgres/test_ddl_source_collection.py | 466 ++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py new file mode 100644 index 00000000..c3bbf98b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py @@ -0,0 +1,466 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py + +try: + from typing import Annotated, Optional +except ImportError: + from typing_extensions import Annotated, Optional + +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraintType, + ColumnDefinition, + CreateTableExpression, + GeneratedColumnExpression, + GeneratedColumnType, + IndexDefinition, + PartitionStrategy, + StorageOptionsExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.expression.types import TextType +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression import ( + PostgresColumnOptions, + PostgresColumnStorage, + PostgresPartitionClause, + PostgresUUIDType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateTableOptions, +) +from rhosocial.activerecord.base import ( + CharacterSetAttribute, + CollationAttribute, + DDLAnnotation, + DDLAnnotationHandler, + DDLSource, + IdentityAttribute, + UseColumn, + UseColumnAttributes, + UseComment, + UseConstraint, + UseGeneratedColumn, + UseIndex, + UseSqlType, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + + +DIALECT = PostgresDialect((16, 2, 0)) +SQL_TYPE = PostgresUUIDType() +FALLBACK_SQL_TYPE = TextType() +SQL_TYPE_MARKER = UseSqlType(SQL_TYPE, FALLBACK_SQL_TYPE) +DEFAULT_VALUE = Literal(DIALECT, "new", inline_literals=True) +DEFAULT_CONSTRAINT = UseConstraint( + ColumnConstraintType.DEFAULT, + name="df_status_default", + default_value=DEFAULT_VALUE, +) +UNIQUE_CONSTRAINT = UseConstraint( + ColumnConstraintType.UNIQUE, + name="uq_status", +) +COLLATION = CollationAttribute(name="C.UTF-8") +CHARACTER_SET = CharacterSetAttribute(name="UTF8") +IDENTITY = IdentityAttribute(generation="ALWAYS", start=10, increment=2) +ATTRIBUTES = UseColumnAttributes(COLLATION, CHARACTER_SET, IDENTITY) +COMMENT = UseComment("current order status") +FIRST_INDEX = UseIndex( + "idx_display_label", + unique=True, + type="BTREE", + include_columns=["tenant_id"], + if_not_exists=True, + tablespace="fastspace", +) +SECOND_INDEX = UseIndex("idx_display_label_suffix", type="BTREE") +GENERATED = GeneratedColumnExpression( + DIALECT, + Column(DIALECT, "order_id"), + GeneratedColumnType.STORED, +) +COLUMN_OPTION = PostgresColumnOptions( + compression="lz4", + storage=PostgresColumnStorage.EXTERNAL, + statistics=500, +) +SECOND_COLUMN_OPTION = PostgresColumnOptions( + compression="pglz", + storage=PostgresColumnStorage.MAIN, + statistics=1000, +) +TABLE_CONSTRAINTS = [ + TableConstraint( + DIALECT, + TableConstraintType.FOREIGN_KEY, + name="fk_order_tenant", + columns=["tenant_id"], + foreign_key_table="tenant", + foreign_key_columns=["id"], + ), + TableConstraint( + DIALECT, + TableConstraintType.UNIQUE, + name="uq_order_pair", + columns=["tenant_id", "order_id"], + ), +] +TABLE_INDEXES = [ + IndexDefinition( + DIALECT, + name="idx_table_status", + columns=["status"], + unique=True, + ), +] +TABLE_OPTIONS = [ + PostgresCreateTableOptions( + DIALECT, + unlogged=True, + ), +] +STORAGE_OPTIONS = [ + StorageOptionsExpression(DIALECT, {"fillfactor": 70}), +] +TABLE_PARTITION = PostgresPartitionClause( + DIALECT, + PartitionStrategy.RANGE, + [Column(DIALECT, "display_label")], +) +TABLE_INHERITS = ["order_audit_base"] +TABLE_TABLESPACE = "fastspace" +MODEL_FIELDS = ( + "tenant_id", + "order_id", + "status", + "label", + "computed", + "option_one", + "option_many", + "optional_note", +) +BATCH_METHODS = ( + "columns_name", + "columns_type", + "columns_constraints", + "columns_attributes", + "columns_indexes", + "columns_comment", + "columns_generated", + "columns_options", +) + + +class BackendColumnOptionsAnnotation(DDLAnnotation): + def __init__(self, *options): + self.options = options + + +class BackendColumnOptionsHandler(DDLAnnotationHandler): + annotation_types = (BackendColumnOptionsAnnotation,) + + @classmethod + def apply(cls, new_class, field_name, annotation, metadata): + metadata.add_column_options(*annotation.options) + + +class DDLDeclarations: + __table_name__ = "ddl_source_orders" + __primary_key__ = ("tenant_key", "order_key") + __table_constraints__ = TABLE_CONSTRAINTS + __table_indexes__ = TABLE_INDEXES + _feature_handlers = [BackendColumnOptionsHandler] + + tenant_id: Annotated[ + str, + UseColumn("tenant_key"), + SQL_TYPE_MARKER, + ] + order_id: Annotated[str, UseColumn("order_key")] + status: Annotated[ + str, + DEFAULT_CONSTRAINT, + UNIQUE_CONSTRAINT, + ATTRIBUTES, + COMMENT, + ] + label: Annotated[ + str, + UseColumn("display_label"), + FIRST_INDEX, + SECOND_INDEX, + ] + computed: Annotated[str, UseGeneratedColumn(GENERATED)] + option_one: Annotated[str, BackendColumnOptionsAnnotation(COLUMN_OPTION)] + option_many: Annotated[ + str, + BackendColumnOptionsAnnotation(COLUMN_OPTION, SECOND_COLUMN_OPTION), + ] + optional_note: Optional[str] = None + + @classmethod + def table_options(cls): + return TABLE_OPTIONS + + @classmethod + def table_storage_options(cls): + return STORAGE_OPTIONS + + @classmethod + def table_partition(cls): + return TABLE_PARTITION + + @classmethod + def table_inherits(cls): + return TABLE_INHERITS + + @classmethod + def table_tablespace(cls): + return TABLE_TABLESPACE + + +class SyncDDLSource(DDLDeclarations, ActiveRecord): + pass + + +class AsyncDDLSource(DDLDeclarations, AsyncActiveRecord): + pass + + +SINGLE_SQL_TYPE = PostgresUUIDType() +SINGLE_SQL_TYPE_MARKER = UseSqlType(SINGLE_SQL_TYPE) + + +class SingleKeyDeclarations: + __table_name__ = "ddl_source_single_key" + __primary_key__ = "record_pk" + + record_id: Annotated[ + str, + UseColumn("record_pk"), + SINGLE_SQL_TYPE_MARKER, + ] + + +class SyncSingleKey(SingleKeyDeclarations, ActiveRecord): + pass + + +class AsyncSingleKey(SingleKeyDeclarations, AsyncActiveRecord): + pass + + +def test_models_satisfy_ddl_source_and_preserve_field_order(): + for model in (SyncDDLSource, AsyncDDLSource): + assert isinstance(model, DDLSource) + assert model.ddl_field_names() == MODEL_FIELDS + assert model.field_python_type("tenant_id") is str + assert model.field_is_optional("optional_note") is True + assert model.field_is_optional("status") is False + + +def test_field_markers_preserve_declared_objects_and_order(): + assert SyncDDLSource.column_type("tenant_id") is SQL_TYPE_MARKER + assert SyncDDLSource.column_type("tenant_id").data_types == ( + SQL_TYPE, + FALLBACK_SQL_TYPE, + ) + assert SyncDDLSource.column_type("tenant_id").data_types[0] is SQL_TYPE + + constraints = SyncDDLSource.column_constraints("status") + assert constraints[0] is DEFAULT_CONSTRAINT.constraint + assert constraints[1] is UNIQUE_CONSTRAINT.constraint + assert constraints[0].default_value is DEFAULT_VALUE + assert [item.constraint_type for item in constraints] == [ + ColumnConstraintType.DEFAULT, + ColumnConstraintType.UNIQUE, + ColumnConstraintType.NOT_NULL, + ] + + attributes = SyncDDLSource.column_attributes("status") + assert attributes == [COLLATION, CHARACTER_SET, IDENTITY] + assert attributes[0] is COLLATION + assert attributes[1] is CHARACTER_SET + assert attributes[2] is IDENTITY + assert SyncDDLSource.column_comment("status") == COMMENT.comment + assert SyncDDLSource.generated_column("computed") is GENERATED + + indexes = SyncDDLSource.column_indexes("label") + assert [index.name for index in indexes] == [FIRST_INDEX.name, SECOND_INDEX.name] + assert all(index.columns == ["display_label"] for index in indexes) + assert indexes[0].unique is True + assert indexes[0].include_columns is FIRST_INDEX.include_columns + assert indexes[0].if_not_exists is True + assert indexes[0].tablespace == FIRST_INDEX.tablespace + + assert SyncDDLSource.column_options("option_one") is COLUMN_OPTION + options = SyncDDLSource.column_options("option_many") + assert options == [COLUMN_OPTION, SECOND_COLUMN_OPTION] + assert options[0] is COLUMN_OPTION + assert options[1] is SECOND_COLUMN_OPTION + + +def test_backend_types_and_column_options_are_collected_without_dialect_selection(): + sql_type = SyncDDLSource.column_type("tenant_id") + assert isinstance(sql_type, UseSqlType) + assert isinstance(sql_type.data_type, PostgresUUIDType) + assert sql_type.data_types[1] is FALLBACK_SQL_TYPE + + option = SyncDDLSource.column_options("option_one") + assert isinstance(option, PostgresColumnOptions) + assert option.compression == "lz4" + assert option.storage is PostgresColumnStorage.EXTERNAL + assert option.statistics == 500 + + +def test_primary_keys_use_physical_column_names_and_declaration_order(): + assert SyncDDLSource.columns_name() == { + "tenant_id": "tenant_key", + "order_id": "order_key", + "status": "status", + "label": "display_label", + "computed": "computed", + "option_one": "option_one", + "option_many": "option_many", + "optional_note": "optional_note", + } + + for field in ("tenant_id", "order_id"): + constraint_types = [ + item.constraint_type for item in SyncDDLSource.column_constraints(field) + ] + assert ColumnConstraintType.PRIMARY_KEY not in constraint_types + assert ColumnConstraintType.NOT_NULL in constraint_types + + single_types = [ + item.constraint_type for item in SyncSingleKey.column_constraints("record_id") + ] + assert single_types == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert SyncSingleKey.columns_name() == {"record_id": "record_pk"} + assert SyncSingleKey.column_type("record_id") is SINGLE_SQL_TYPE_MARKER + assert SyncSingleKey.table_constraints() == [] + + +def test_table_declarations_preserve_identity_order_and_composite_primary_key(): + constraints = SyncDDLSource.table_constraints() + assert constraints is not TABLE_CONSTRAINTS + assert constraints[0] is TABLE_CONSTRAINTS[0] + assert constraints[1] is TABLE_CONSTRAINTS[1] + assert [item.constraint_type for item in constraints] == [ + TableConstraintType.FOREIGN_KEY, + TableConstraintType.UNIQUE, + TableConstraintType.PRIMARY_KEY, + ] + assert constraints[2].columns == ["tenant_key", "order_key"] + assert len(TABLE_CONSTRAINTS) == 2 + + indexes = SyncDDLSource.table_indexes() + assert indexes is not TABLE_INDEXES + assert indexes[0] is TABLE_INDEXES[0] + assert [index.name for index in indexes] == ["idx_table_status"] + + assert SyncDDLSource.table_options() is TABLE_OPTIONS + assert SyncDDLSource.table_options()[0] is TABLE_OPTIONS[0] + assert isinstance(TABLE_OPTIONS[0], PostgresCreateTableOptions) + assert TABLE_OPTIONS[0].unlogged is True + assert SyncDDLSource.table_storage_options() is STORAGE_OPTIONS + assert SyncDDLSource.table_storage_options()[0] is STORAGE_OPTIONS[0] + assert SyncDDLSource.table_partition() is TABLE_PARTITION + assert SyncDDLSource.table_inherits() is TABLE_INHERITS + assert SyncDDLSource.table_tablespace() == TABLE_TABLESPACE + + +def test_collected_table_options_render_with_postgres_dialect(): + table_options = SyncDDLSource.table_options() + expression = CreateTableExpression( + DIALECT, + "ddl_source_orders", + [ColumnDefinition(DIALECT, "id", IntegerType(DIALECT))], + table_options=table_options[0], + ) + sql, params = expression.to_sql() + assert sql.startswith('CREATE UNLOGGED TABLE "ddl_source_orders"') + assert '"id" INTEGER' in sql + assert params == () + + +def test_batch_interfaces_cover_all_fields_and_preserve_selected_order(): + selected = ["label", "status", "tenant_id", "optional_note"] + for model in (SyncDDLSource, AsyncDDLSource): + for method_name in BATCH_METHODS: + method = getattr(model, method_name) + assert tuple(method()) == MODEL_FIELDS + assert tuple(method(selected)) == tuple(selected) + + assert model.columns_name(selected) == { + "label": "display_label", + "status": "status", + "tenant_id": "tenant_key", + "optional_note": "optional_note", + } + assert model.columns_type(selected)["tenant_id"] is SQL_TYPE_MARKER + assert model.columns_constraints(selected)["status"][0] is DEFAULT_CONSTRAINT.constraint + assert model.columns_attributes(selected)["status"][0] is COLLATION + assert model.columns_comment(selected)["status"] == COMMENT.comment + assert model.columns_generated(selected)["optional_note"] is None + assert model.columns_options(selected)["optional_note"] is None + + +def test_sync_and_async_sources_collect_the_same_declarations(): + assert AsyncDDLSource.column_type("tenant_id") is SyncDDLSource.column_type("tenant_id") + assert AsyncDDLSource.column_comment("status") == SyncDDLSource.column_comment("status") + assert AsyncDDLSource.generated_column("computed") is GENERATED + assert AsyncDDLSource.column_options("option_one") is COLUMN_OPTION + assert SyncDDLSource.column_options("option_many") == [ + COLUMN_OPTION, + SECOND_COLUMN_OPTION, + ] + assert AsyncDDLSource.column_options("option_many") == [ + COLUMN_OPTION, + SECOND_COLUMN_OPTION, + ] + + sync_attributes = SyncDDLSource.column_attributes("status") + async_attributes = AsyncDDLSource.column_attributes("status") + assert all(left is right for left, right in zip(sync_attributes, async_attributes)) + + sync_constraints = SyncDDLSource.column_constraints("status") + async_constraints = AsyncDDLSource.column_constraints("status") + assert sync_constraints[0] is async_constraints[0] + assert sync_constraints[1] is async_constraints[1] + assert [item.constraint_type for item in async_constraints] == [ + item.constraint_type for item in sync_constraints + ] + + sync_indexes = SyncDDLSource.column_indexes("label") + async_indexes = AsyncDDLSource.column_indexes("label") + assert [index.name for index in async_indexes] == [index.name for index in sync_indexes] + assert [index.columns for index in async_indexes] == [ + index.columns for index in sync_indexes + ] + + assert AsyncDDLSource.table_options() is TABLE_OPTIONS + assert AsyncDDLSource.table_storage_options() is STORAGE_OPTIONS + assert AsyncDDLSource.table_partition() is TABLE_PARTITION + assert AsyncDDLSource.table_inherits() is TABLE_INHERITS + assert AsyncDDLSource.table_tablespace() == TABLE_TABLESPACE + assert AsyncDDLSource.table_indexes()[0] is TABLE_INDEXES[0] + + sync_table_constraints = SyncDDLSource.table_constraints() + async_table_constraints = AsyncDDLSource.table_constraints() + assert sync_table_constraints[0] is async_table_constraints[0] + assert sync_table_constraints[1] is async_table_constraints[1] + assert async_table_constraints[2].columns == ["tenant_key", "order_key"] + + for model in (SyncSingleKey, AsyncSingleKey): + assert [item.constraint_type for item in model.column_constraints("record_id")] == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert model.column_name("record_id") == "record_pk" + assert model.column_type("record_id") is SINGLE_SQL_TYPE_MARKER From 56ee162a4a7a90c05d0dea992ecede49310a032a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 01:57:42 +0800 Subject: [PATCH 35/42] fix(postgres): restore MV storage_options and complete MV DDL coverage PostgresMaterializedViewMixin was previously ordered after the core ViewMixin, so its format_create_materialized_view_statement never took effect and storage_options was silently dropped. Landed the failing tests first and watched them fail (T-01/T-02/T-05, 7 red), then reordered the bases in dialect.py to turn them green. Fixed: - MRO order: PostgresMaterializedViewMixin moved ahead of ViewMixin, with PostgresMaterializedViewMixin(ViewMixin) declaring the dependency explicitly - PG syntax order: WITH (storage_parameter) must precede TABLESPACE - The CONCURRENTLY version gate moved down into format_refresh_materialized_view_statement, which also gates the core RefreshMaterializedViewExpression; the raised exception is now uniformly UnsupportedFeatureError instead of ValueError - Schema qualification now goes through format_qualified_identifier, fixing mixed-case/reserved identifiers broken by bare concatenation - Fixed the unescaped percent sign in LIKE 'pg_%' in the new introspection query - MV capability probes moved off PostgresViewMixin/PostgresViewSupport into a materialized-view-specific mixin/protocol pair, now returning by version (PG 9.3/9.4 thresholds) Added: - PostgresStorageParameter enum (the server's RELOPT_KIND_HEAP standard relation options, per the fillRelOptions mapping for RELKIND_MATVIEW) with value_type, min_version and enum_values; storage_options keys are validated strictly against the enum rather than by regex, and table access method / toast.* namespaced parameters require an explicit allow_unlisted_storage_parameters=True - PostgresCreate/Drop/AlterMaterializedViewExpression plus 5 ALTER actions (RENAME TO / SET SCHEMA / SET / RESET / OWNER TO); PG does not support ALTER MATERIALIZED VIEW ... SET TABLESPACE - list_materialized_views() / get_materialized_view_info() / materialized_view_exists(), with symmetric sync/async implementations, reporting is_populated and has_unique_index Tests: L1 SQL-generation unit tests, L2 doc/example consistency, L3 live-database integration (sync + async), plus a real run of the extended examples/ddl/materialized_view.py against PG 19. --- changelog.d/58.added.md | 1 + changelog.d/58.fixed.md | 1 + docs/en_US/ddl/README.md | 6 +- docs/en_US/ddl/materialized_view.md | 247 ++++++ .../protocol_support.md | 2 +- docs/zh_CN/ddl/README.md | 6 +- docs/zh_CN/ddl/materialized_view.md | 240 ++++++ .../protocol_support.md | 2 +- .../backend/impl/postgres/__init__.py | 7 + .../backend/impl/postgres/dialect.py | 2 +- .../examples/ddl/materialized_view.py | 195 ++++- .../impl/postgres/expression/__init__.py | 18 + .../impl/postgres/expression/ddl/__init__.py | 22 +- .../impl/postgres/expression/ddl/mv.py | 312 ++++++- .../impl/postgres/expression/introspection.py | 73 ++ .../postgres/introspection/introspector.py | 192 ++++- .../backend/impl/postgres/mixins/ddl_view.py | 12 - .../impl/postgres/mixins/introspection.py | 87 ++ .../impl/postgres/mixins/materialized_view.py | 282 +++++- .../postgres/protocols/materialized_view.py | 97 ++- .../backend/impl/postgres/protocols/view.py | 4 - .../impl/postgres/storage_parameters.py | 235 +++++ .../backend/dialect/test_materialized_view.py | 812 ++++++++++++++++++ .../dialect/test_materialized_view_docs.py | 134 +++ .../test_materialized_view_introspection.py | 157 ++++ .../feature/backend/postgres/conftest.py | 145 ++++ .../test_materialized_view_integration.py | 338 ++++++++ 27 files changed, 3548 insertions(+), 81 deletions(-) create mode 100644 changelog.d/58.added.md create mode 100644 changelog.d/58.fixed.md create mode 100644 docs/en_US/ddl/materialized_view.md create mode 100644 docs/zh_CN/ddl/materialized_view.md create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/storage_parameters.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_docs.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_introspection.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/conftest.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_materialized_view_integration.py diff --git a/changelog.d/58.added.md b/changelog.d/58.added.md new file mode 100644 index 00000000..b9652d30 --- /dev/null +++ b/changelog.d/58.added.md @@ -0,0 +1 @@ +Add PostgreSQL materialized view coverage: `PostgresCreateMaterializedViewExpression` (schema qualification, `IF NOT EXISTS`), `PostgresDropMaterializedViewExpression` (schema qualification), `PostgresAlterMaterializedViewExpression` with RENAME TO / SET SCHEMA / SET / RESET / OWNER TO actions, the enumerated `PostgresStorageParameter` reloption set with enum-based validation, and `list_materialized_views()` / `get_materialized_view_info()` / `materialized_view_exists()` introspection reporting `is_populated` and `has_unique_index`. diff --git a/changelog.d/58.fixed.md b/changelog.d/58.fixed.md new file mode 100644 index 00000000..af150794 --- /dev/null +++ b/changelog.d/58.fixed.md @@ -0,0 +1 @@ +Fixed materialized view support in the PostgreSQL dialect: `PostgresMaterializedViewMixin` was listed after the core `ViewMixin` in `PostgresDialect`, so its CREATE formatter was dead code and `storage_options` was silently dropped from the generated SQL. Storage parameters are also emitted in the correct PostgreSQL grammar order (`WITH (...)` before `TABLESPACE`), and `REFRESH MATERIALIZED VIEW CONCURRENTLY` is now version-gated (PostgreSQL 9.4+) on both the generic and the PostgreSQL expression, raising `UnsupportedFeatureError` instead of `ValueError`. diff --git a/docs/en_US/ddl/README.md b/docs/en_US/ddl/README.md index c99dce0e..bc3e44f5 100644 --- a/docs/en_US/ddl/README.md +++ b/docs/en_US/ddl/README.md @@ -11,8 +11,12 @@ The PostgreSQL backend supports the same type-safe DDL expressions as the core l | `AlterTableExpression` | ✅ Full | ADD/DROP COLUMN, ALTER COLUMN | | `CreateIndexExpression` | ✅ Full | Index types (BTREE, HASH, GIN, GiST, BRIN) | | `DropIndexExpression` | ✅ Full | | -| `CreateViewExpression` | ✅ Full | Materialized views support | +| `CreateViewExpression` | ✅ Full | | | `DropViewExpression` | ✅ Full | | +| `PostgresCreateMaterializedViewExpression` | ✅ Full | IF NOT EXISTS, TABLESPACE, storage parameters (see [materialized views](./materialized_view.md)) | +| `PostgresRefreshMaterializedViewExpression` | ✅ Full | CONCURRENTLY (PG 9.4+, requires a UNIQUE index) | +| `PostgresAlterMaterializedViewExpression` | ✅ Full | RENAME TO, SET SCHEMA, SET/RESET storage parameters, OWNER TO | +| `PostgresDropMaterializedViewExpression` | ✅ Full | IF EXISTS, CASCADE | | `CreatePartitionExpression` | ✅ Full | RANGE, LIST, HASH partitioning (PG 10+) | | `AttachPartitionExpression` | ✅ Full | Standard ATTACH PARTITION; `CONCURRENTLY` is unsupported | | `DetachPartitionExpression` | ✅ Full | Includes `CONCURRENTLY` and `FINALIZE` (PG 14+) | diff --git a/docs/en_US/ddl/materialized_view.md b/docs/en_US/ddl/materialized_view.md new file mode 100644 index 00000000..4da9e612 --- /dev/null +++ b/docs/en_US/ddl/materialized_view.md @@ -0,0 +1,247 @@ +# PostgreSQL Materialized Views + +> Materialized views require PostgreSQL 9.3+; `CONCURRENTLY` refresh and +> `IF NOT EXISTS` require 9.4+. +> +> Official docs: +> [CREATE MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-creatematerializedview.html) · +> [REFRESH MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html) · +> [ALTER MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-altermaterializedview.html) + +## Expression matrix + +| Expression | Purpose | PostgreSQL-specific options | +|---|---|---| +| `PostgresCreateMaterializedViewExpression` | `CREATE MATERIALIZED VIEW` | `schema`, `if_not_exists`, `storage_options`, `tablespace`, `column_aliases`, `with_data` | +| `PostgresRefreshMaterializedViewExpression` | `REFRESH MATERIALIZED VIEW` | `schema`, `concurrently`, `with_data` | +| `PostgresAlterMaterializedViewExpression` | `ALTER MATERIALIZED VIEW` | `schema`, action list | +| `PostgresDropMaterializedViewExpression` | `DROP MATERIALIZED VIEW` | `schema`, `if_exists`, `cascade` | + +The generic core expressions (`CreateMaterializedViewExpression`, +`RefreshMaterializedViewExpression`, `DropMaterializedViewExpression`) remain +usable; they simply do not carry `schema` / `if_not_exists`. + +## Creating a materialized view + +```python +from rhosocial.activerecord.backend.expression import Column, FunctionCall, QueryExpression, TableExpression +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateMaterializedViewExpression, +) + +summary = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "product_id"), + FunctionCall(dialect, "COUNT", Column(dialect, "id")), + FunctionCall(dialect, "SUM", Column(dialect, "amount")), + ], + from_=TableExpression(dialect, "sales"), + group_by_having=GroupByHavingClause(dialect, group_by=[Column(dialect, "product_id")]), +) + +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + query=summary, + schema="reporting", + if_not_exists=True, + column_aliases=["product_id", "sale_count", "total_amount"], + storage_options={ + PostgresStorageParameter.FILLFACTOR: 70, + PostgresStorageParameter.AUTOVACUUM_ENABLED: "false", + }, +) +sql, params = create.to_sql() +``` + +Emitted SQL (note the PostgreSQL grammar order — +column aliases, then `WITH (...)`, then `TABLESPACE`): + +```sql +CREATE MATERIALIZED VIEW IF NOT EXISTS "reporting"."sales_summary" + ("product_id", "sale_count", "total_amount") + WITH (fillfactor = 70, autovacuum_enabled = false) AS +SELECT ... WITH DATA +``` + +### `WITH NO DATA` + +```python +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="sales_daily", query=daily, with_data=False +) +``` + +The view is registered but unscannable until it is refreshed +(`REFRESH MATERIALIZED VIEW ... WITH DATA`). + +## Storage parameters + +A materialized view is stored as a regular heap relation — the server maps +`RELKIND_MATVIEW` onto `RELOPT_KIND_HEAP` in `fillRelOptions()` +(`src/backend/access/common/reloptions.c`). The accepted set is therefore the +standard heap relation options, enumerated by +[`PostgresStorageParameter`](../../api/storage_parameters.md): + +```python +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter + +PostgresStorageParameter.FILLFACTOR.value # 'fillfactor' +PostgresStorageParameter.FILLFACTOR.value_type # PostgresStorageParameterValueType.INT +PostgresStorageParameter.VACUUM_INDEX_CLEANUP.enum_values # ('auto', 'enabled', 'disabled') +PostgresStorageParameter.PARALLEL_WORKERS.min_version # (11, 0, 0) +``` + +Keys are validated against that enum — a name outside the enum raises +`ValueError`. Two families are intentionally outside the enum and need the +explicit `allow_unlisted_storage_parameters=True` opt-in: + +* namespaced options (`toast.autovacuum_enabled`, …); +* options registered by a table access method (`USING method`, e.g. a columnar + AM adding `compresslevel`), since `add_reloption_kind()` / + `add_*_reloption()` are exported server APIs. + +```python +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="columnar_summary", + query=summary, + storage_options={"compresslevel": 4}, + allow_unlisted_storage_parameters=True, +) +``` + +## Refreshing + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresRefreshMaterializedViewExpression, +) + +# standard refresh +PostgresRefreshMaterializedViewExpression(dialect=dialect, name="sales_summary") + +# concurrent refresh — requires a UNIQUE index, rejected below PG 9.4 +PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_summary", schema="reporting", concurrently=True +) + +# populate a WITH NO DATA view +PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_daily", with_data=True +) +``` + +`concurrently=True` raises `UnsupportedFeatureError` on PostgreSQL < 9.4 — on +both the generic and the PostgreSQL expression. + +## Altering a materialized view + +PostgreSQL supports exactly five actions; `SET TABLESPACE` is **not** among +them (a materialized view cannot be relocated to another tablespace after +creation). + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) + +alter = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + schema="reporting", + actions=[ + PostgresRenameMaterializedViewAction(dialect, "sales_summary_v2"), + PostgresSetMaterializedViewSchemaAction(dialect, "archive"), + PostgresSetMaterializedViewPropertiesAction(dialect, {"fillfactor": 90}), + PostgresResetMaterializedViewPropertiesAction(dialect, ["autovacuum_enabled"]), + PostgresChangeMaterializedViewOwnerAction(dialect, "CURRENT_USER"), + ], +) +sql, params = alter.to_sql() +``` + +Multiple actions render as separate statements joined with `";\n"`: + +```sql +ALTER MATERIALIZED VIEW "reporting"."sales_summary" RENAME TO "sales_summary_v2"; +ALTER MATERIALIZED VIEW "reporting"."sales_summary" SET SCHEMA "archive"; +ALTER MATERIALIZED VIEW "reporting"."sales_summary" SET (fillfactor = 90); +ALTER MATERIALIZED VIEW "reporting"."sales_summary" RESET (autovacuum_enabled); +ALTER MATERIALIZED VIEW "reporting"."sales_summary" OWNER TO CURRENT_USER +``` + +`CURRENT_ROLE` / `CURRENT_USER` / `SESSION_USER` are emitted verbatim; any other +owner is quoted as an identifier. + +## Dropping + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresDropMaterializedViewExpression, +) + +PostgresDropMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", schema="reporting", + if_exists=True, cascade=True, +) +# DROP MATERIALIZED VIEW IF EXISTS "reporting"."sales_summary" CASCADE +``` + +## Introspection + +Materialized views are **not** part of `list_views()` (which stays `relkind='v'`). +Use the dedicated methods, available on both the sync and async introspector: + +```python +views = backend.introspector.list_materialized_views() +for view in views: + print(view.name, view.schema, view.extra) + +info = backend.introspector.get_materialized_view_info("sales_summary") +exists = backend.introspector.materialized_view_exists("sales_summary") +``` + +Each `ViewInfo` carries: + +| Field | Meaning | +|---|---| +| `definition` | defining query, from `pg_matviews` | +| `is_updatable` / `is_insertable` | always `False` | +| `extra['is_materialized']` | always `True` (distinguishes MV rows) | +| `extra['is_populated']` | `False` for a `WITH NO DATA` view never refreshed | +| `extra['has_unique_index']` | prerequisite for `REFRESH ... CONCURRENTLY` | + +`list_tables(table_type="MATERIALIZED VIEW")` also filters `relkind = 'm'`. + +## Capability probes + +```python +dialect.supports_materialized_view() # PG >= 9.3 +dialect.supports_refresh_materialized_view() # PG >= 9.3 +dialect.supports_materialized_view_concurrent_refresh() # PG >= 9.4 +dialect.supports_materialized_view_if_not_exists() # PG >= 9.4 +dialect.supports_materialized_view_tablespace() # PG >= 9.3 +dialect.supports_materialized_view_storage_options() # PG >= 9.3 +dialect.supports_alter_materialized_view() # PG >= 9.3 +``` + +All of them are declared by `PostgresMaterializedViewSupport`. Every +materialized-view formatter lives in `PostgresMaterializedViewMixin`, which sits +**before** the core `ViewMixin` in `PostgresDialect`'s MRO — a regression test +asserts that ordering, because a mixin placed after the core one silently +disables its formatter. + +## See also + +* [DDL operations](./README.md) +* [PostgreSQL-specific features](../postgres_specific_features/protocol_support.md) +* Runnable example: + `src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py` diff --git a/docs/en_US/postgres_specific_features/protocol_support.md b/docs/en_US/postgres_specific_features/protocol_support.md index 45cb6ea1..d1286a35 100644 --- a/docs/en_US/postgres_specific_features/protocol_support.md +++ b/docs/en_US/postgres_specific_features/protocol_support.md @@ -220,7 +220,7 @@ In addition to standard protocols, PostgreSQL provides database-specific protoco | **PostgresDataTypeSupport** | Data type enhancements | PG 11+ | [Data Types Docs](https://www.postgresql.org/docs/current/datatype.html) | | **PostgresSQLSyntaxSupport** | SQL syntax enhancements | PG 12+ | [SQL Syntax Docs](https://www.postgresql.org/docs/current/sql-syntax.html) | | **PostgresLogicalReplicationSupport** | Logical replication features | PG 10+ | [Replication Docs](https://www.postgresql.org/docs/current/logical-replication.html) | -| **PostgresMaterializedViewSupport** | Materialized views | PG 9.3+ | [MV Docs](https://www.postgresql.org/docs/current/rules-materializedviews.html) | +| **PostgresMaterializedViewSupport** | Materialized views (CREATE/REFRESH/ALTER/DROP, IF NOT EXISTS, CONCURRENTLY, TABLESPACE, storage parameters) | PG 9.3+ (CONCURRENTLY / IF NOT EXISTS: 9.4+) | [MV DDL Docs](https://www.postgresql.org/docs/current/sql-creatematerializedview.html) · [guide](../../ddl/materialized_view.md) | | **PostgresTableSupport** | Table-specific features | All | [Table Docs](https://www.postgresql.org/docs/current/ddl.html) | ## Detailed Native Feature Support diff --git a/docs/zh_CN/ddl/README.md b/docs/zh_CN/ddl/README.md index c2dbe366..b43cbe3d 100644 --- a/docs/zh_CN/ddl/README.md +++ b/docs/zh_CN/ddl/README.md @@ -11,8 +11,12 @@ PostgreSQL 后端支持与核心库相同的类型安全 DDL 表达式,并具 | `AlterTableExpression` | ✅ 完整 | ADD/DROP COLUMN, ALTER COLUMN | | `CreateIndexExpression` | ✅ 完整 | 索引类型 (BTREE, HASH, GIN, GiST, BRIN) | | `DropIndexExpression` | ✅ 完整 | | -| `CreateViewExpression` | ✅ 完整 | 物化视图支持 | +| `CreateViewExpression` | ✅ 完整 | | | `DropViewExpression` | ✅ 完整 | | +| `PostgresCreateMaterializedViewExpression` | ✅ 完整 | IF NOT EXISTS、TABLESPACE、存储参数(见[物化视图](./materialized_view.md)) | +| `PostgresRefreshMaterializedViewExpression` | ✅ 完整 | CONCURRENTLY(PG 9.4+,要求存在 UNIQUE 索引) | +| `PostgresAlterMaterializedViewExpression` | ✅ 完整 | RENAME TO、SET SCHEMA、SET/RESET 存储参数、OWNER TO | +| `PostgresDropMaterializedViewExpression` | ✅ 完整 | IF EXISTS、CASCADE | | `CreatePartitionExpression` | ✅ 完整 | RANGE, LIST, HASH 分区(PG 10+) | | `AttachPartitionExpression` | ✅ 完整 | 支持标准 ATTACH PARTITION;不支持 `CONCURRENTLY` | | `DetachPartitionExpression` | ✅ 完整 | 含 `CONCURRENTLY` 和 `FINALIZE`(PG 14+) | diff --git a/docs/zh_CN/ddl/materialized_view.md b/docs/zh_CN/ddl/materialized_view.md new file mode 100644 index 00000000..a8ef20ce --- /dev/null +++ b/docs/zh_CN/ddl/materialized_view.md @@ -0,0 +1,240 @@ +# PostgreSQL 物化视图 + +> 物化视图需要 PostgreSQL 9.3+;`CONCURRENTLY` 刷新与 `IF NOT EXISTS` 需要 9.4+。 +> +> 官方文档: +> [CREATE MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-creatematerializedview.html) · +> [REFRESH MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html) · +> [ALTER MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-altermaterializedview.html) + +## 表达式一览 + +| 表达式 | 用途 | PostgreSQL 专有选项 | +|---|---|---| +| `PostgresCreateMaterializedViewExpression` | `CREATE MATERIALIZED VIEW` | `schema`、`if_not_exists`、`storage_options`、`tablespace`、`column_aliases`、`with_data` | +| `PostgresRefreshMaterializedViewExpression` | `REFRESH MATERIALIZED VIEW` | `schema`、`concurrently`、`with_data` | +| `PostgresAlterMaterializedViewExpression` | `ALTER MATERIALIZED VIEW` | `schema`、动作列表 | +| `PostgresDropMaterializedViewExpression` | `DROP MATERIALIZED VIEW` | `schema`、`if_exists`、`cascade` | + +核心库通用表达式(`CreateMaterializedViewExpression`、 +`RefreshMaterializedViewExpression`、`DropMaterializedViewExpression`)仍可使用, +只是不携带 `schema` / `if_not_exists`。 + +## 创建物化视图 + +```python +from rhosocial.activerecord.backend.expression import Column, FunctionCall, QueryExpression, TableExpression +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateMaterializedViewExpression, +) + +summary = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "product_id"), + FunctionCall(dialect, "COUNT", Column(dialect, "id")), + FunctionCall(dialect, "SUM", Column(dialect, "amount")), + ], + from_=TableExpression(dialect, "sales"), + group_by_having=GroupByHavingClause(dialect, group_by=[Column(dialect, "product_id")]), +) + +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + query=summary, + schema="reporting", + if_not_exists=True, + column_aliases=["product_id", "sale_count", "total_amount"], + storage_options={ + PostgresStorageParameter.FILLFACTOR: 70, + PostgresStorageParameter.AUTOVACUUM_ENABLED: "false", + }, +) +sql, params = create.to_sql() +``` + +生成的 SQL(注意 PostgreSQL 语法顺序:列别名 → `WITH (...)` → `TABLESPACE`): + +```sql +CREATE MATERIALIZED VIEW IF NOT EXISTS "reporting"."sales_summary" + ("product_id", "sale_count", "total_amount") + WITH (fillfactor = 70, autovacuum_enabled = false) AS +SELECT ... WITH DATA +``` + +### `WITH NO DATA` + +```python +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="sales_daily", query=daily, with_data=False +) +``` + +视图会被注册,但在刷新前不可查询(需 +`REFRESH MATERIALIZED VIEW ... WITH DATA`)。 + +## 存储参数 + +物化视图以普通堆表形式存储——服务端在 `fillRelOptions()` 中把 +`RELKIND_MATVIEW` 映射到 `RELOPT_KIND_HEAP` +(`src/backend/access/common/reloptions.c`)。因此可用参数集合就是标准堆表 +关系选项,已由 [`PostgresStorageParameter`](../../api/storage_parameters.md) 枚举: + +```python +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter + +PostgresStorageParameter.FILLFACTOR.value # 'fillfactor' +PostgresStorageParameter.FILLFACTOR.value_type # PostgresStorageParameterValueType.INT +PostgresStorageParameter.VACUUM_INDEX_CLEANUP.enum_values # ('auto', 'enabled', 'disabled') +PostgresStorageParameter.PARALLEL_WORKERS.min_version # (11, 0, 0) +``` + +键名按该枚举校验——枚举外的名字会抛 `ValueError`。以下两类参数有意不纳入 +枚举,需要显式传 `allow_unlisted_storage_parameters=True`: + +* 带命名空间的参数(`toast.autovacuum_enabled` 等); +* 表访问方法(`USING method`)注册的参数(例如列式 AM 的 `compresslevel`), + 因为 `add_reloption_kind()` / `add_*_reloption()` 是服务端导出的扩展点。 + +```python +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="columnar_summary", + query=summary, + storage_options={"compresslevel": 4}, + allow_unlisted_storage_parameters=True, +) +``` + +## 刷新 + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresRefreshMaterializedViewExpression, +) + +# 普通刷新 +PostgresRefreshMaterializedViewExpression(dialect=dialect, name="sales_summary") + +# 并发刷新——要求存在 UNIQUE 索引;PG < 9.4 会被拒绝 +PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_summary", schema="reporting", concurrently=True +) + +# 填充 WITH NO DATA 的视图 +PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_daily", with_data=True +) +``` + +`concurrently=True` 在 PostgreSQL < 9.4 上抛 `UnsupportedFeatureError`—— +通用表达式与 PostgreSQL 专有表达式两条路径都会拦截。 + +## 修改物化视图 + +PostgreSQL 恰好支持 5 种动作,**不含** `SET TABLESPACE` +(物化视图创建后无法迁移到其他表空间)。 + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) + +alter = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + schema="reporting", + actions=[ + PostgresRenameMaterializedViewAction(dialect, "sales_summary_v2"), + PostgresSetMaterializedViewSchemaAction(dialect, "archive"), + PostgresSetMaterializedViewPropertiesAction(dialect, {"fillfactor": 90}), + PostgresResetMaterializedViewPropertiesAction(dialect, ["autovacuum_enabled"]), + PostgresChangeMaterializedViewOwnerAction(dialect, "CURRENT_USER"), + ], +) +sql, params = alter.to_sql() +``` + +多动作会渲染为多条语句,以 `";\n"` 连接: + +```sql +ALTER MATERIALIZED VIEW "reporting"."sales_summary" RENAME TO "sales_summary_v2"; +ALTER MATERIALIZED VIEW "reporting"."sales_summary" SET SCHEMA "archive"; +ALTER MATERIALIZED VIEW "reporting"."sales_summary" SET (fillfactor = 90); +ALTER MATERIALIZED VIEW "reporting"."sales_summary" RESET (autovacuum_enabled); +ALTER MATERIALIZED VIEW "reporting"."sales_summary" OWNER TO CURRENT_USER +``` + +`CURRENT_ROLE` / `CURRENT_USER` / `SESSION_USER` 原样输出;其余 owner +按标识符加引号。 + +## 删除 + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresDropMaterializedViewExpression, +) + +PostgresDropMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", schema="reporting", + if_exists=True, cascade=True, +) +# DROP MATERIALIZED VIEW IF EXISTS "reporting"."sales_summary" CASCADE +``` + +## 内省 + +物化视图**不属于** `list_views()`(后者固定为 `relkind='v'`)。 +请使用专用方法,同步与异步内省器都提供: + +```python +views = backend.introspector.list_materialized_views() +for view in views: + print(view.name, view.schema, view.extra) + +info = backend.introspector.get_materialized_view_info("sales_summary") +exists = backend.introspector.materialized_view_exists("sales_summary") +``` + +每个 `ViewInfo` 携带: + +| 字段 | 含义 | +|---|---| +| `definition` | 定义查询,来自 `pg_matviews` | +| `is_updatable` / `is_insertable` | 恒为 `False` | +| `extra['is_materialized']` | 恒为 `True`(用于区分物化视图行) | +| `extra['is_populated']` | `WITH NO DATA` 且从未刷新时为 `False` | +| `extra['has_unique_index']` | `REFRESH ... CONCURRENTLY` 的前置条件 | + +`list_tables(table_type="MATERIALIZED VIEW")` 也会按 `relkind = 'm'` 过滤。 + +## 能力探测 + +```python +dialect.supports_materialized_view() # PG >= 9.3 +dialect.supports_refresh_materialized_view() # PG >= 9.3 +dialect.supports_materialized_view_concurrent_refresh() # PG >= 9.4 +dialect.supports_materialized_view_if_not_exists() # PG >= 9.4 +dialect.supports_materialized_view_tablespace() # PG >= 9.3 +dialect.supports_materialized_view_storage_options() # PG >= 9.3 +dialect.supports_alter_materialized_view() # PG >= 9.3 +``` + +这些探针全部由 `PostgresMaterializedViewSupport` 声明。所有物化视图格式化器 +都位于 `PostgresMaterializedViewMixin`,它在 `PostgresDialect` 的 MRO 中 +**排在**核心 `ViewMixin` **之前**——回归测试会断言该顺序,因为一旦排到核心 +mixin 之后,其格式化器会静默失效。 + +## 相关文档 + +* [DDL 操作](./README.md) +* [PostgreSQL 专有特性](../postgres_specific_features/protocol_support.md) +* 可运行示例: + `src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py` diff --git a/docs/zh_CN/postgres_specific_features/protocol_support.md b/docs/zh_CN/postgres_specific_features/protocol_support.md index f74ee528..2aa95804 100644 --- a/docs/zh_CN/postgres_specific_features/protocol_support.md +++ b/docs/zh_CN/postgres_specific_features/protocol_support.md @@ -76,7 +76,7 @@ PostgreSQL 方言实现了 `rhosocial.activerecord.backend.dialect.protocols` | **PostgresDataTypeSupport** | 数据类型增强 | PG 11+ | [数据类型文档](https://www.postgresql.org/docs/current/datatype.html) | | **PostgresSQLSyntaxSupport** | SQL 语法增强 | PG 12+ | [SQL 语法文档](https://www.postgresql.org/docs/current/sql-syntax.html) | | **PostgresLogicalReplicationSupport** | 逻辑复制特性 | PG 10+ | [复制文档](https://www.postgresql.org/docs/current/logical-replication.html) | -| **PostgresMaterializedViewSupport** | 物化视图 | PG 9.3+ | [物化视图文档](https://www.postgresql.org/docs/current/rules-materializedviews.html) | +| **PostgresMaterializedViewSupport** | 物化视图(CREATE/REFRESH/ALTER/DROP、IF NOT EXISTS、CONCURRENTLY、TABLESPACE、存储参数) | PG 9.3+(CONCURRENTLY / IF NOT EXISTS:9.4+) | [物化视图 DDL 文档](https://www.postgresql.org/docs/current/sql-creatematerializedview.html) · [指南](../../ddl/materialized_view.md) | | **PostgresTableSupport** | 表特定特性 | 所有版本 | [表文档](https://www.postgresql.org/docs/current/ddl.html) | ## 详细原生特性支持 diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py index 514f26c4..1bd9a409 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py @@ -21,6 +21,10 @@ from .collation import PostgresCollation # noqa: F401 from .config import PostgresConnectionConfig from .dialect import PostgresDialect +from .storage_parameters import ( # noqa: F401 + PostgresStorageParameter, + PostgresStorageParameterValueType, +) from .transaction import PostgresTransactionManager, AsyncPostgresTransactionManager from .types import PostgresEnumType from .expression.types import ( @@ -354,6 +358,9 @@ "PostgresConnectionConfig", # Dialect related "PostgresDialect", + # PostgreSQL Relation Storage Parameters + "PostgresStorageParameter", + "PostgresStorageParameterValueType", # Transaction - Sync and Async "PostgresTransactionManager", "AsyncPostgresTransactionManager", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index b55fcb3f..37499848 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -361,6 +361,7 @@ class PostgresDialect( UpsertMixin, LateralJoinMixin, JoinMixin, + PostgresMaterializedViewMixin, # Before ViewMixin to override format_*_materialized_view_* ViewMixin, SchemaMixin, PostgresIndexMixin, @@ -368,7 +369,6 @@ class PostgresDialect( SequenceMixin, # PostgreSQL-specific mixins PostgresExtensionMixin, - PostgresMaterializedViewMixin, PostgresAlterColumnModifierMixin, # Before TableMixin/ConstraintMixin to override format_*_action PostgresTableMixin, # Before TableMixin to override supports_create_table_like PostgresCommentMixin, # Before TableMixin to override format_comment_statement diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py index 2aec6dd9..6057a364 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py @@ -12,6 +12,10 @@ 8. REFRESH MATERIALIZED VIEW WITH DATA (after NO DATA) 9. Schema-qualified refresh (PostgresRefreshMaterializedViewExpression) 10. DROP MATERIALIZED VIEW with CASCADE and IF EXISTS +11. IF NOT EXISTS (PostgreSQL 9.4+) via PostgresCreateMaterializedViewExpression +12. Schema-qualified create/drop +13. ALTER MATERIALIZED VIEW: RENAME TO / SET SCHEMA / SET () / RESET () / OWNER TO +14. Introspection: list_materialized_views() with is_populated / has_unique_index """ # ============================================================ @@ -41,6 +45,18 @@ ColumnConstraint, ColumnConstraintType, ) +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresCreateMaterializedViewExpression, + PostgresDropMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -84,21 +100,22 @@ table='sales', columns=[ ColumnDefinition( + dialect, 'id', - 'SERIAL', + dialect.parse_type('SERIAL'), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ], ), - ColumnDefinition('product_id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnDefinition(dialect, 'product_id', dialect.parse_type('INT'), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ]), - ColumnDefinition('amount', 'DECIMAL(10,2)', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnDefinition(dialect, 'amount', dialect.parse_type('DECIMAL(10,2)'), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ]), - ColumnDefinition('sale_date', 'DATE', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnDefinition(dialect, 'sale_date', dialect.parse_type('DATE'), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ]), ], if_not_exists=True, @@ -145,14 +162,17 @@ ), ) -create_mv = CreateMaterializedViewExpression( +create_mv = PostgresCreateMaterializedViewExpression( dialect=dialect, view_name='sales_summary', query=summary_query, column_aliases=['product_id', 'total_sales', 'total_amount', 'avg_amount'], + # Storage parameter names are validated against PostgresStorageParameter; + # unlisted names (toast.* / table access method options) need + # allow_unlisted_storage_parameters=True. storage_options={ - 'fillfactor': 70, - 'autovacuum_enabled': 'true', + PostgresStorageParameter.FILLFACTOR: 70, + PostgresStorageParameter.AUTOVACUUM_ENABLED: 'true', }, ) sql, params = create_mv.to_sql() @@ -347,10 +367,7 @@ # ============================================================ # The PostgreSQL backend provides an extended refresh expression that # supports specifying the schema for the materialized view. - -from rhosocial.activerecord.backend.impl.postgres.expression.ddl.mv import ( - PostgresRefreshMaterializedViewExpression, -) +# (PostgresRefreshMaterializedViewExpression is imported at the top of this file.) # Refresh with schema (public is PG's default schema) pg_refresh = PostgresRefreshMaterializedViewExpression( @@ -408,9 +425,150 @@ print(f"DROP without IF EXISTS SQL (not executed): {sql}") print(" Note: This would fail at runtime if the MV does not exist.") +# ============================================================ +# SECTION: 10. IF NOT EXISTS (PostgreSQL 9.4+) +# ============================================================ +# IF NOT EXISTS makes creation idempotent: a second run emits a notice +# instead of failing. Requires PostgreSQL 9.4+; the dialect raises +# UnsupportedFeatureError below that version. + +if_not_exists_mv = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', + query=summary_query, + column_aliases=['product_id', 'total_sales', 'total_amount', 'avg_amount'], + if_not_exists=True, +) +sql, params = if_not_exists_mv.to_sql() +print(f"\nIF NOT EXISTS SQL: {sql}") +backend.execute(sql, params) + +# ============================================================ +# SECTION: 11. Schema-Qualified Create and Drop +# ============================================================ +# Every MV statement accepts a schema; names are quoted per part, so mixed +# case and reserved words are safe. + +backend.execute('CREATE SCHEMA IF NOT EXISTS mv_reporting', options=ddl_options) + +schema_mv = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name='Order Summary', + query=summary_query, + schema='mv_reporting', + column_aliases=['product_id', 'total_sales', 'total_amount', 'avg_amount'], +) +sql, params = schema_mv.to_sql() +print(f"\nSchema-qualified CREATE SQL: {sql}") +backend.execute(sql, params) + +schema_verify = QueryExpression( + dialect=dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression( + dialect, 'Order Summary', schema_name='mv_reporting' + ), +) +sql, params = schema_verify.to_sql() +result = backend.execute(sql, params, options=dql_options) +print("Schema-qualified MV result:") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: 12. ALTER MATERIALIZED VIEW +# ============================================================ +# PostgreSQL supports exactly five ALTER MATERIALIZED VIEW actions. +# SET TABLESPACE is NOT one of them: a materialized view cannot be +# relocated to another tablespace after creation. +# Multiple actions render as separate statements joined with ";\n". + +alter_mv = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', + actions=[ + PostgresSetMaterializedViewPropertiesAction( + dialect, {PostgresStorageParameter.FILLFACTOR: 85} + ), + PostgresResetMaterializedViewPropertiesAction( + dialect, [PostgresStorageParameter.AUTOVACUUM_ENABLED] + ), + ], +) +sql, params = alter_mv.to_sql() +print(f"\nALTER SET/RESET SQL:\n{sql}") +backend.execute(sql, params, options=ddl_options) + +rename_mv = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', + actions=[PostgresRenameMaterializedViewAction(dialect, 'sales_summary_v2')], +) +sql, params = rename_mv.to_sql() +print(f"\nALTER RENAME SQL: {sql}") +backend.execute(sql, params, options=ddl_options) + +# Move the schema-qualified view into the public schema. +set_schema_mv = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name='Order Summary', + schema='mv_reporting', + actions=[PostgresSetMaterializedViewSchemaAction(dialect, 'public')], +) +sql, params = set_schema_mv.to_sql() +print(f"\nALTER SET SCHEMA SQL: {sql}") +backend.execute(sql, params, options=ddl_options) + +# CURRENT_USER / CURRENT_ROLE / SESSION_USER are emitted verbatim. +owner_mv = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary_v2', + actions=[PostgresChangeMaterializedViewOwnerAction(dialect, 'CURRENT_USER')], +) +sql, params = owner_mv.to_sql() +print(f"\nALTER OWNER TO SQL: {sql}") +backend.execute(sql, params, options=ddl_options) + +# ============================================================ +# SECTION: 13. Introspection +# ============================================================ +# list_materialized_views() is a dedicated entry point: list_views() stays +# limited to relkind='v'. Each entry reports whether the view is populated +# and whether it carries the UNIQUE index CONCURRENTLY refresh needs. + +backend.introspector.clear_cache() +materialized_views = backend.introspector.list_materialized_views() +print("\nMaterialized views:") +for view in materialized_views: + print(f" {view.schema}.{view.name} populated={view.extra.get('is_populated')} " + f"unique_index={view.extra.get('has_unique_index')}") + +info = backend.introspector.get_materialized_view_info('sales_summary_v2') +print(f"\nMV info: {info.name if info else None} definition={info.definition[:60] if info and info.definition else None}") + +# ============================================================ +# SECTION: 14. Schema-Qualified Drop +# ============================================================ + +drop_schema_mv = PostgresDropMaterializedViewExpression( + dialect=dialect, + view_name='Order Summary', + schema='mv_reporting', + if_exists=True, + cascade=True, +) +sql, params = drop_schema_mv.to_sql() +print(f"\nSchema-qualified DROP SQL: {sql}") +backend.execute(sql, params, options=ddl_options) + # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ +drop_mv = PostgresDropMaterializedViewExpression( + dialect=dialect, view_name='sales_summary_v2', if_exists=True, cascade=True, +) +backend.execute(*drop_mv.to_sql()) +backend.execute('DROP SCHEMA IF EXISTS mv_reporting CASCADE', options=ddl_options) drop_table = DropTableExpression( dialect=dialect, table='sales', @@ -437,4 +595,7 @@ # 10. Use DropMaterializedViewExpression with cascade=True to drop dependent objects # 11. Use DropMaterializedViewExpression with if_exists=True for safe drop # 12. Requires PostgreSQL 9.3+ for basic MV support -# 13. Requires PostgreSQL 9.4+ for CONCURRENTLY refresh \ No newline at end of file +# 13. Requires PostgreSQL 9.4+ for CONCURRENTLY refresh and IF NOT EXISTS +# 14. storage_options keys are validated against PostgresStorageParameter +# 15. ALTER MATERIALIZED VIEW supports RENAME/SET SCHEMA/SET()/RESET()/OWNER TO +# 16. list_materialized_views() reports is_populated and has_unique_index \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 985f4b64..9206de32 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -57,7 +57,16 @@ PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, PostgresCommentExpression, + MaterializedViewAlterAction, + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresCreateMaterializedViewExpression, + PostgresDropMaterializedViewExpression, PostgresRefreshMaterializedViewExpression, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, PostgresCompositeTypeAttribute, PostgresCompositeTypeDefinition, PostgresEnumTypeDefinition, @@ -201,7 +210,16 @@ "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", "PostgresCommentExpression", + "MaterializedViewAlterAction", + "PostgresAlterMaterializedViewExpression", + "PostgresChangeMaterializedViewOwnerAction", + "PostgresCreateMaterializedViewExpression", + "PostgresDropMaterializedViewExpression", "PostgresRefreshMaterializedViewExpression", + "PostgresRenameMaterializedViewAction", + "PostgresResetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewSchemaAction", "PostgresCompositeTypeAttribute", "PostgresCompositeTypeDefinition", "PostgresEnumTypeDefinition", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index 1d26d003..e5de03c5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -107,7 +107,18 @@ PostgresDropStatisticsExpression, ) from .comment import PostgresCommentExpression -from .mv import PostgresRefreshMaterializedViewExpression +from .mv import ( + MaterializedViewAlterAction, + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresCreateMaterializedViewExpression, + PostgresDropMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) from .type import ( PostgresAddEnumValueAction, PostgresAddTypeAttributeAction, @@ -250,7 +261,16 @@ # comment "PostgresCommentExpression", # mv + "MaterializedViewAlterAction", + "PostgresAlterMaterializedViewExpression", + "PostgresChangeMaterializedViewOwnerAction", + "PostgresCreateMaterializedViewExpression", + "PostgresDropMaterializedViewExpression", "PostgresRefreshMaterializedViewExpression", + "PostgresRenameMaterializedViewAction", + "PostgresResetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewSchemaAction", # type "PostgresCompositeTypeAttribute", "PostgresCompositeTypeDefinition", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py index 123072fa..7145123d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py @@ -3,23 +3,152 @@ PostgreSQL DDL expressions: Materialized View operations. PostgreSQL Documentation: +- CREATE MATERIALIZED VIEW: https://www.postgresql.org/docs/current/sql-creatematerializedview.html - REFRESH MATERIALIZED VIEW: https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html +- ALTER MATERIALIZED VIEW: https://www.postgresql.org/docs/current/sql-altermaterializedview.html Version Requirements: -- REFRESH MATERIALIZED VIEW: PostgreSQL 9.3+ -- CONCURRENTLY option: PostgreSQL 9.4+ +- Materialized views: PostgreSQL 9.3+ +- CONCURRENTLY refresh / IF NOT EXISTS: PostgreSQL 9.4+ - WITH NO DATA: PostgreSQL 9.4+ + +PostgreSQL's ``ALTER MATERIALIZED VIEW`` does not support ``SET TABLESPACE``: +a materialized view cannot be relocated to another tablespace after creation. """ -from typing import Optional, TYPE_CHECKING +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewExpression, + DropMaterializedViewExpression, + RefreshMaterializedViewExpression, +) -from rhosocial.activerecord.backend.expression.statements.ddl_view import RefreshMaterializedViewExpression +from ...storage_parameters import validate_storage_parameters if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase -__all__ = ["PostgresRefreshMaterializedViewExpression"] +__all__ = [ + "MaterializedViewAlterAction", + "PostgresAlterMaterializedViewExpression", + "PostgresChangeMaterializedViewOwnerAction", + "PostgresCreateMaterializedViewExpression", + "PostgresDropMaterializedViewExpression", + "PostgresRefreshMaterializedViewExpression", + "PostgresRenameMaterializedViewAction", + "PostgresResetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewSchemaAction", +] + +_CURRENT_ROLE_KEYWORDS = frozenset({"CURRENT_ROLE", "CURRENT_USER", "SESSION_USER"}) + + +def _validate_name(value: str, field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +class PostgresCreateMaterializedViewExpression(CreateMaterializedViewExpression): + """PostgreSQL CREATE MATERIALIZED VIEW with schema and IF NOT EXISTS support. + + Extends the generic expression with the two PostgreSQL-specific options: + ``IF NOT EXISTS`` (PG 9.4+) and schema qualification. + + ``storage_options`` keys are validated against + :class:`~....storage_parameters.PostgresStorageParameter`; pass + ``allow_unlisted_storage_parameters=True`` to forward options registered by + a table access method (``USING method``) or namespaced under ``toast.``. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import ( + ... PostgresDialect, + ... PostgresStorageParameter, + ... ) + >>> dialect = PostgresDialect(version=(15, 0, 0)) + >>> create = PostgresCreateMaterializedViewExpression( + ... dialect=dialect, + ... view_name="monthly_sales_summary", + ... query=sales_query, + ... schema="reporting", + ... if_not_exists=True, + ... storage_options={PostgresStorageParameter.FILLFACTOR: 70}, + ... ) + >>> sql, params = create.to_sql() + >>> sql + 'CREATE MATERIALIZED VIEW IF NOT EXISTS "reporting"."monthly_sales_summary" \\ +WITH (FILLFACTOR = 70) AS SELECT ... WITH DATA' + """ + + def __init__( + self, + dialect: "SQLDialectBase", + view_name: str, + query: Any, + column_aliases: Optional[List[str]] = None, + tablespace: Optional[str] = None, + with_data: bool = True, + storage_options: Optional[Dict[Any, Any]] = None, + schema: Optional[str] = None, + if_not_exists: bool = False, + allow_unlisted_storage_parameters: bool = False, + ): + super().__init__( + dialect, + view_name=view_name, + query=query, + column_aliases=column_aliases, + tablespace=tablespace, + with_data=with_data, + storage_options=storage_options, + ) + _validate_name(view_name, "view_name") + if schema is not None: + _validate_name(schema, "schema") + if storage_options: + validate_storage_parameters( + storage_options.keys(), + "storage_options", + allow_unlisted=allow_unlisted_storage_parameters, + ) + self.schema = schema + self.if_not_exists = if_not_exists + self.allow_unlisted_storage_parameters = allow_unlisted_storage_parameters + + +class PostgresDropMaterializedViewExpression(DropMaterializedViewExpression): + """PostgreSQL DROP MATERIALIZED VIEW with schema qualification. + + Example: + >>> drop = PostgresDropMaterializedViewExpression( + ... dialect=dialect, view_name="monthly_sales_summary", schema="reporting", + ... ) + >>> drop.to_sql()[0] + 'DROP MATERIALIZED VIEW "reporting"."monthly_sales_summary"' + """ + + def __init__( + self, + dialect: "SQLDialectBase", + view_name: str, + if_exists: bool = False, + cascade: bool = False, + schema: Optional[str] = None, + ): + super().__init__( + dialect, + view_name=view_name, + if_exists=if_exists, + cascade=cascade, + ) + _validate_name(view_name, "view_name") + if schema is not None: + _validate_name(schema, "schema") + self.schema = schema class PostgresRefreshMaterializedViewExpression(RefreshMaterializedViewExpression): @@ -81,19 +210,186 @@ def __init__( concurrent=concurrently, with_data=with_data, ) + _validate_name(name, "name") + if schema is not None: + _validate_name(schema, "schema") self.schema = schema @property - def name(self): + def name(self) -> str: """Alias for view_name (backward compatibility).""" return self.view_name @property - def concurrently(self): + def concurrently(self) -> bool: """Alias for concurrent (backward compatibility).""" return self.concurrent @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_refresh_materialized_view_pg_statement" \ No newline at end of file + return "format_refresh_materialized_view_pg_statement" + + +class MaterializedViewAlterAction(BaseExpression, ABC): + """Base class for one ALTER MATERIALIZED VIEW action. + + Supported actions (PostgreSQL): + ``RENAME TO`` / ``SET SCHEMA`` / ``SET (...)`` / ``RESET (...)`` / ``OWNER TO``. + """ + + @property + @abstractmethod + def action_kind(self) -> str: + """Stable kind name used by dialect capability dispatch.""" + + @property + def format_method(self) -> str: + return "format_materialized_view_alter_action" + + +class PostgresRenameMaterializedViewAction(MaterializedViewAlterAction): + """``ALTER MATERIALIZED VIEW name RENAME TO new_name``.""" + + action_kind = "rename" + + def __init__(self, dialect: "SQLDialectBase", new_name: str) -> None: + super().__init__(dialect) + _validate_name(new_name, "new_name") + self.new_name = new_name + + +class PostgresSetMaterializedViewSchemaAction(MaterializedViewAlterAction): + """``ALTER MATERIALIZED VIEW name SET SCHEMA new_schema``.""" + + action_kind = "set_schema" + + def __init__(self, dialect: "SQLDialectBase", new_schema: str) -> None: + super().__init__(dialect) + _validate_name(new_schema, "new_schema") + self.new_schema = new_schema + + +class PostgresSetMaterializedViewPropertiesAction(MaterializedViewAlterAction): + """``ALTER MATERIALIZED VIEW name SET (storage_parameter [= value] [, ...])``. + + Parameter names are validated against + :class:`~....storage_parameters.PostgresStorageParameter`. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter + >>> action = PostgresSetMaterializedViewPropertiesAction( + ... dialect, + ... {PostgresStorageParameter.FILLFACTOR: 90, "autovacuum_enabled": "true"}, + ... ) + """ + + action_kind = "set_properties" + + def __init__( + self, + dialect: "SQLDialectBase", + properties: Dict[Any, Any], + allow_unlisted_storage_parameters: bool = False, + ) -> None: + super().__init__(dialect) + if not isinstance(properties, dict) or not properties: + raise ValueError("properties must be a non-empty dict") + validate_storage_parameters( + properties.keys(), + "properties", + allow_unlisted=allow_unlisted_storage_parameters, + ) + self.properties = dict(properties) + self.allow_unlisted_storage_parameters = allow_unlisted_storage_parameters + + +class PostgresResetMaterializedViewPropertiesAction(MaterializedViewAlterAction): + """``ALTER MATERIALIZED VIEW name RESET (storage_parameter [, ...])``.""" + + action_kind = "reset_properties" + + def __init__( + self, + dialect: "SQLDialectBase", + parameters: Sequence[Any], + allow_unlisted_storage_parameters: bool = False, + ) -> None: + super().__init__(dialect) + validate_storage_parameters( + parameters, + "parameters", + allow_unlisted=allow_unlisted_storage_parameters, + ) + if not parameters: + raise ValueError("parameters must contain at least one storage parameter") + self.parameters = list(parameters) + self.allow_unlisted_storage_parameters = allow_unlisted_storage_parameters + + +class PostgresChangeMaterializedViewOwnerAction(MaterializedViewAlterAction): + """``ALTER MATERIALIZED VIEW name OWNER TO {new_owner | CURRENT_ROLE | ...}``. + + The ``CURRENT_ROLE`` / ``CURRENT_USER`` / ``SESSION_USER`` keywords are kept + verbatim; any other value is treated as a role name and quoted. + """ + + action_kind = "change_owner" + + def __init__(self, dialect: "SQLDialectBase", new_owner: str) -> None: + super().__init__(dialect) + _validate_name(new_owner, "new_owner") + self.new_owner = new_owner + + +class PostgresAlterMaterializedViewExpression(BaseExpression): + """Expression for ``ALTER MATERIALIZED VIEW``. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + ... PostgresAlterMaterializedViewExpression, + ... PostgresRenameMaterializedViewAction, + ... PostgresSetMaterializedViewPropertiesAction, + ... ) + >>> dialect = PostgresDialect(version=(15, 0, 0)) + >>> alter = PostgresAlterMaterializedViewExpression( + ... dialect=dialect, + ... view_name="sales_summary", + ... actions=[ + ... PostgresRenameMaterializedViewAction(dialect, "sales_summary_v2"), + ... PostgresSetMaterializedViewPropertiesAction(dialect, {"fillfactor": 90}), + ... ], + ... ) + >>> alter.to_sql()[0] + 'ALTER MATERIALIZED VIEW "sales_summary" RENAME TO "sales_summary_v2";\\n\\ +ALTER MATERIALIZED VIEW "sales_summary" SET (FILLFACTOR = 90)' + """ + + def __init__( + self, + dialect: "SQLDialectBase", + view_name: str, + actions: Sequence[MaterializedViewAlterAction], + schema: Optional[str] = None, + ) -> None: + super().__init__(dialect) + _validate_name(view_name, "view_name") + if schema is not None: + _validate_name(schema, "schema") + action_list = list(actions or []) + if not action_list: + raise ValueError("actions must contain at least one MaterializedViewAlterAction") + for action in action_list: + if not isinstance(action, MaterializedViewAlterAction): + raise TypeError( + "actions must contain MaterializedViewAlterAction instances, " + f"got {type(action).__name__}" + ) + self.view_name = view_name + self.schema = schema + self.actions = action_list + + @property + def format_method(self) -> str: + return "format_alter_materialized_view_statement" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py new file mode 100644 index 00000000..b5fe67d7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py @@ -0,0 +1,73 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py +"""PostgreSQL-specific introspection expressions. + +These expressions reuse the core parameter container contract +(``IntrospectionExpression.get_params()``) and only redirect SQL generation to +PostgreSQL-specific formatters, so the core introspection layer stays untouched. +""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.introspection import ( + ViewInfoExpression, + ViewListExpression, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresMaterializedViewInfoExpression", + "PostgresMaterializedViewListExpression", +] + + +class PostgresMaterializedViewListExpression(ViewListExpression): + """List materialized views (``pg_class.relkind = 'm'``). + + Example: + >>> expr = PostgresMaterializedViewListExpression(dialect, schema="public") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + schema: Optional[str] = None, + include_system: bool = False, + ): + super().__init__(dialect, schema=schema, include_system=include_system) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_materialized_view_list_query" + + +class PostgresMaterializedViewInfoExpression(ViewInfoExpression): + """Fetch a single materialized view by name. + + Example: + >>> expr = PostgresMaterializedViewInfoExpression(dialect, "sales_summary") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + view_name: str, + schema: Optional[str] = None, + include_columns: bool = True, + ): + super().__init__( + dialect, + view_name=view_name, + schema=schema, + include_columns=include_columns, + ) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_materialized_view_info_query" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py index ea4371bb..76b68178 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py @@ -25,7 +25,7 @@ """ import re -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from rhosocial.activerecord.backend.introspection.base import ( IntrospectorMixin, @@ -44,6 +44,7 @@ IndexColumnInfo, IndexInfo, IndexType, + IntrospectionScope, ReferentialAction, TableInfo, TableType, @@ -253,6 +254,83 @@ def _parse_view_info(self, rows: List[Dict[str, Any]], view_name: str, schema: s comment=row.get("comment"), ) + @staticmethod + def _parse_materialized_view(row: Dict[str, Any], default_schema: str, default_name: str = "") -> ViewInfo: + """Build a ViewInfo for a materialized view. + + Materialized views are never updatable/insertable; ``extra`` carries the + PostgreSQL specific state: ``is_materialized``, ``is_populated`` and + ``has_unique_index`` (the prerequisite for a CONCURRENTLY refresh). + """ + return ViewInfo( + name=row.get("view_name") or default_name, + schema=row.get("schema_name") or default_schema, + definition=row.get("definition"), + is_updatable=False, + is_insertable=False, + comment=row.get("comment"), + extra={ + "is_materialized": True, + "is_populated": bool(row.get("is_populated")), + "has_unique_index": bool(row.get("has_unique_index")), + }, + ) + + @staticmethod + def _parse_materialized_views( + rows: List[Dict[str, Any]], schema: str + ) -> List[ViewInfo]: + return [ + PostgreSQLIntrospectorMixin._parse_materialized_view(row, schema) for row in rows + ] + + @staticmethod + def _parse_materialized_view_info( + rows: List[Dict[str, Any]], view_name: str, schema: str + ) -> Optional[ViewInfo]: + if not rows: + return None + return PostgreSQLIntrospectorMixin._parse_materialized_view( + rows[0], schema, view_name + ) + + def _build_materialized_view_list_sql( + self, schema: Optional[str], include_system: bool + ) -> Tuple[str, tuple]: + from ..expression.introspection import PostgresMaterializedViewListExpression + + target_schema = schema if schema is not None else self._get_default_schema() + expr = PostgresMaterializedViewListExpression(self.dialect).schema( + target_schema + ).include_system(include_system) + return expr.to_sql() + + def _build_materialized_view_info_sql( + self, view_name: str, schema: Optional[str] + ) -> Tuple[str, tuple]: + from ..expression.introspection import PostgresMaterializedViewInfoExpression + + target_schema = schema if schema is not None else self._get_default_schema() + expr = PostgresMaterializedViewInfoExpression(self.dialect, view_name).schema( + target_schema + ) + return expr.to_sql() + + def _materialized_view_cache_key( + self, schema: Optional[str], view_name: Optional[str] = None, include_system: bool = False + ) -> str: + """Build the cache key for materialized view introspection results. + + Materialized views share ``IntrospectionScope.VIEW`` with regular views, + so the key is namespaced to avoid cross-contamination. + """ + return self._make_cache_key( + IntrospectionScope.VIEW, + view_name or "*", + schema=schema, + extra=f"matviews:{include_system}", + ) + def _parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[TriggerInfo]: triggers = [] for row in rows: @@ -344,6 +422,63 @@ def status(self) -> SyncPostgreSQLStatusIntrospector: self._status_instance = SyncPostgreSQLStatusIntrospector(self._backend) return self._status_instance + def list_materialized_views( + self, + schema: Optional[str] = None, + include_system: bool = False, + ) -> List[ViewInfo]: + """List materialized views in the given schema. + + Each entry carries ``extra['is_populated']`` (false for a view created + WITH NO DATA that was never refreshed) and ``extra['has_unique_index']`` + (the prerequisite for REFRESH ... CONCURRENTLY). + + Args: + schema: Schema to inspect. Defaults to the connection default schema. + include_system: Whether to include system objects. + + Returns: + List of ViewInfo describing each materialized view. + """ + target = schema if schema is not None else self._get_default_schema() + key = self._materialized_view_cache_key(schema, include_system=include_system) + cached = self._get_cached(key) + if cached is not None: + return cached + sql, params = self._build_materialized_view_list_sql(schema, include_system) + result = self._parse_materialized_views(self._executor.execute(sql, params), target) + self._set_cached(key, result) + return result + + def get_materialized_view_info( + self, view_name: str, schema: Optional[str] = None + ) -> Optional[ViewInfo]: + """Return information for a single materialized view, or None if absent. + + Args: + view_name: Materialized view name. + schema: Schema to inspect. Defaults to the connection default schema. + + Returns: + ViewInfo, or None when the materialized view does not exist. + """ + target = schema if schema is not None else self._get_default_schema() + key = self._materialized_view_cache_key(schema, view_name=view_name) + cached = self._get_cached(key) + if cached is not None: + return cached + sql, params = self._build_materialized_view_info_sql(view_name, schema) + result = self._parse_materialized_view_info( + self._executor.execute(sql, params), view_name, target + ) + if result is not None: + self._set_cached(key, result) + return result + + def materialized_view_exists(self, view_name: str, schema: Optional[str] = None) -> bool: + """Return True when the named materialized view exists.""" + return self.get_materialized_view_info(view_name, schema) is not None + class AsyncPostgreSQLIntrospector(PostgreSQLIntrospectorMixin, AsyncAbstractIntrospector): """Asynchronous introspector for PostgreSQL backends. @@ -371,3 +506,58 @@ def status(self) -> AsyncPostgreSQLStatusIntrospector: if self._status_instance is None: self._status_instance = AsyncPostgreSQLStatusIntrospector(self._backend) return self._status_instance + + async def list_materialized_views( + self, + schema: Optional[str] = None, + include_system: bool = False, + ) -> List[ViewInfo]: + """List materialized views in the given schema (async counterpart). + + Args: + schema: Schema to inspect. Defaults to the connection default schema. + include_system: Whether to include system objects. + + Returns: + List of ViewInfo describing each materialized view. + """ + target = schema if schema is not None else self._get_default_schema() + key = self._materialized_view_cache_key(schema, include_system=include_system) + cached = self._get_cached(key) + if cached is not None: + return cached + sql, params = self._build_materialized_view_list_sql(schema, include_system) + rows = await self._executor.execute(sql, params) + result = self._parse_materialized_views(rows, target) + self._set_cached(key, result) + return result + + async def get_materialized_view_info( + self, view_name: str, schema: Optional[str] = None + ) -> Optional[ViewInfo]: + """Return information for a single materialized view, or None (async). + + Args: + view_name: Materialized view name. + schema: Schema to inspect. Defaults to the connection default schema. + + Returns: + ViewInfo, or None when the materialized view does not exist. + """ + target = schema if schema is not None else self._get_default_schema() + key = self._materialized_view_cache_key(schema, view_name=view_name) + cached = self._get_cached(key) + if cached is not None: + return cached + sql, params = self._build_materialized_view_info_sql(view_name, schema) + rows = await self._executor.execute(sql, params) + result = self._parse_materialized_view_info(rows, view_name, target) + if result is not None: + self._set_cached(key, result) + return result + + async def materialized_view_exists( + self, view_name: str, schema: Optional[str] = None + ) -> bool: + """Return True when the named materialized view exists (async).""" + return await self.get_materialized_view_info(view_name, schema) is not None diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py index 256b4b68..8ee14c64 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py @@ -32,18 +32,6 @@ def supports_view_check_option(self) -> bool: def supports_cascade_view(self) -> bool: return True - def supports_materialized_view(self) -> bool: - return True - - def supports_refresh_materialized_view(self) -> bool: - return True - - def supports_materialized_view_tablespace(self) -> bool: - return True - - def supports_materialized_view_storage_options(self) -> bool: - return True - def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: """Format CREATE VIEW statement for PostgreSQL. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py index 47040fef..7e19abf8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py @@ -40,6 +40,10 @@ TriggerListExpression, TriggerInfoExpression, ) + from ..expression.introspection import ( + PostgresMaterializedViewInfoExpression, + PostgresMaterializedViewListExpression, + ) class PostgresIntrospectionCapabilityMixin(IntrospectionMixin): @@ -446,6 +450,89 @@ def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple return (sql, (schema, view_name)) + def format_materialized_view_list_query( + self, expr: "PostgresMaterializedViewListExpression" + ) -> Tuple[str, tuple]: + """Format materialized view list query. + + Uses ``pg_matviews`` (a view over ``pg_class``/``pg_rewrite``) joined with + ``pg_index`` so callers can tell whether a materialized view is populated + and whether it carries the UNIQUE index that ``REFRESH ... CONCURRENTLY`` + requires. + + Args: + expr: Materialized view list expression with parameters. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + params = expr.get_params() + schema = params.get("schema") or self._get_default_schema() + include_system = params.get("include_system", False) + + sql = f""" + SELECT m.schemaname as schema_name, + m.matviewname as view_name, + m.definition as definition, + m.ispopulated as is_populated, + obj_description(c.oid) as comment, + EXISTS ( + SELECT 1 + FROM pg_index i + WHERE i.indrelid = c.oid AND i.indisunique + ) as has_unique_index, + pg_get_viewdef(c.oid, true) as view_definition + FROM pg_matviews m + JOIN pg_class c ON c.relname = m.matviewname + JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = m.schemaname + WHERE m.schemaname = {self.p()} + """ + + params_list = [schema] + + if not include_system: + # NOTE: doubled percent — psycopg treats a bare '%' as a placeholder start. + sql += " AND m.matviewname NOT LIKE 'pg_%%'" + + sql += " ORDER BY m.matviewname" + + return (sql, tuple(params_list)) + + def format_materialized_view_info_query( + self, expr: "PostgresMaterializedViewInfoExpression" + ) -> Tuple[str, tuple]: + """Format single materialized view information query. + + Args: + expr: Materialized view info expression with parameters. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + params = expr.get_params() + view_name = params.get("view_name", "") + schema = params.get("schema") or self._get_default_schema() + + sql = f""" + SELECT m.schemaname as schema_name, + m.matviewname as view_name, + m.definition as definition, + m.ispopulated as is_populated, + obj_description(c.oid) as comment, + EXISTS ( + SELECT 1 + FROM pg_index i + WHERE i.indrelid = c.oid AND i.indisunique + ) as has_unique_index, + pg_get_viewdef(c.oid, true) as view_definition + FROM pg_matviews m + JOIN pg_class c ON c.relname = m.matviewname + JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = m.schemaname + WHERE m.schemaname = {self.p()} AND m.matviewname = {self.p()} + """ + + return (sql, (schema, view_name)) + def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, tuple]: """Format trigger list query using pg_trigger. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py index 972964c0..8a5e026c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py @@ -1,25 +1,151 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py -from typing import Tuple, TYPE_CHECKING +"""PostgreSQL materialized view feature support implementation. + +PostgreSQL grammar (https://www.postgresql.org/docs/current/sql-creatematerializedview.html): + + CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] name [ (aliases) ] + [ WITH ( storage_parameter [= value] [, ...] ) ] + [ TABLESPACE tablespace_name ] AS query [ WITH [ NO ] DATA ] + + REFRESH MATERIALIZED VIEW [ CONCURRENTLY ] name [ WITH [ NO ] DATA ] + + ALTER MATERIALIZED VIEW name RENAME TO new_name + ALTER MATERIALIZED VIEW name SET SCHEMA new_schema + ALTER MATERIALIZED VIEW name SET ( storage_parameter [= value] [, ...] ) + ALTER MATERIALIZED VIEW name RESET ( storage_parameter [, ...] ) + ALTER MATERIALIZED VIEW name OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } + + DROP MATERIALIZED VIEW [ IF EXISTS ] name [ CASCADE ] + +Note: ``ALTER MATERIALIZED VIEW`` does **not** support ``SET TABLESPACE`` — a +materialized view cannot be relocated to another tablespace after creation. + +This mixin must precede the core ``ViewMixin`` in ``PostgresDialect`` so the +formatters below take precedence over the generic ones. +""" +from typing import Any, Dict, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins import ViewMixin +from rhosocial.activerecord.backend.expression.core import QualifiedIdentifierExpression + +from ..expression.ddl.mv import ( + _CURRENT_ROLE_KEYWORDS, + PostgresChangeMaterializedViewOwnerAction, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) +from ..storage_parameters import resolve_storage_parameter if TYPE_CHECKING: - from ...expression.ddl import PostgresRefreshMaterializedViewExpression - from ...expression.statements.ddl_view import CreateMaterializedViewExpression + from ....expression.statements.ddl_view import CreateMaterializedViewExpression + from ..expression.ddl.mv import ( + PostgresAlterMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + ) -class PostgresMaterializedViewMixin: - """PostgreSQL materialized view extended features implementation.""" +class PostgresMaterializedViewMixin(ViewMixin): + """PostgreSQL materialized view extended features implementation. + + Extends the core :class:`ViewMixin` so the generic REFRESH/DROP paths are + reachable through ``super()``; this class must stay *before* ``ViewMixin`` + in ``PostgresDialect``'s base list so its formatters win. + """ + + def supports_materialized_view(self) -> bool: + """Materialized views require PostgreSQL 9.3+.""" + return self.version >= (9, 3, 0) + + def supports_refresh_materialized_view(self) -> bool: + """REFRESH MATERIALIZED VIEW requires PostgreSQL 9.3+.""" + return self.version >= (9, 3, 0) def supports_materialized_view_concurrent_refresh(self) -> bool: """CONCURRENTLY is supported since PostgreSQL 9.4.""" return self.version >= (9, 4, 0) + def supports_materialized_view_if_not_exists(self) -> bool: + """CREATE MATERIALIZED VIEW IF NOT EXISTS is supported since PostgreSQL 9.4.""" + return self.version >= (9, 4, 0) + + def supports_materialized_view_tablespace(self) -> bool: + """TABLESPACE on CREATE MATERIALIZED VIEW is supported since PostgreSQL 9.3.""" + return self.version >= (9, 3, 0) + + def supports_materialized_view_storage_options(self) -> bool: + """WITH (storage_parameter) is supported since PostgreSQL 9.3.""" + return self.version >= (9, 3, 0) + + def supports_alter_materialized_view(self) -> bool: + """ALTER MATERIALIZED VIEW is supported since PostgreSQL 9.3.""" + return self.version >= (9, 3, 0) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _format_materialized_view_name(self, expr: Any) -> str: + """Render a materialized view name, schema-qualified when available. + + Args: + expr: Any MV expression exposing ``view_name`` and optionally ``schema``. + + Returns: + The quoted (and possibly schema-qualified) identifier. + """ + name_sql, _ = self.format_qualified_identifier( + QualifiedIdentifierExpression( + self, + schema=getattr(expr, "schema", None), + name=expr.view_name, + ) + ) + return name_sql + + def _format_storage_parameters(self, properties: Dict[Any, Any]) -> str: + """Render a ``KEY = value, ...`` storage parameter list. + + Names come from :class:`PostgresStorageParameter` (already lower case) and + are emitted bare: PostgreSQL folds unquoted identifiers to lower case, so + quoting is unnecessary and values are inserted verbatim because the + server accepts bare literals as well as quoted strings. + """ + rendered = [] + for key, value in properties.items(): + parameter = resolve_storage_parameter(key) + name = parameter.value if parameter is not None else str(key) + rendered.append(f"{name} = {value}") + return ", ".join(rendered) + + def _format_storage_parameter_names(self, parameters: Any) -> str: + """Render a ``key, ...`` storage parameter name list (for ``RESET``).""" + rendered = [] + for key in parameters: + parameter = resolve_storage_parameter(key) + rendered.append(parameter.value if parameter is not None else str(key)) + return ", ".join(rendered) + + # ------------------------------------------------------------------ + # Formatters + # ------------------------------------------------------------------ + def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewExpression") -> Tuple[str, tuple]: """Format CREATE MATERIALIZED VIEW statement for PostgreSQL. + Follows the PostgreSQL grammar order: + ``CREATE MATERIALIZED VIEW [IF NOT EXISTS] name [(aliases)] + [WITH (storage_parameter = value, ...)] [TABLESPACE name] AS query + [WITH [NO] DATA]``. + - ``expr.view_name`` — view name (identifier). + - ``expr.schema`` — optional schema (PostgreSQL extension expression). - ``expr.column_aliases`` — optional list of column aliases. - - ``expr.tablespace`` — optional tablespace. + - ``expr.if_not_exists`` — ``IF NOT EXISTS`` (PostgreSQL 9.4+). - ``expr.storage_options`` — optional dict of storage parameters (``WITH (… )``). + - ``expr.tablespace`` — optional tablespace. - ``expr.query`` — source SELECT expression. - ``expr.with_data`` — ``WITH DATA`` / ``WITH NO DATA``. @@ -29,23 +155,36 @@ def format_create_materialized_view_statement(self, expr: "CreateMaterializedVie Returns: Tuple of (SQL string, params tuple) + Raises: + UnsupportedFeatureError: If the dialect or the target server version + does not support the requested materialized view feature. """ + if not self.supports_materialized_view(): + raise UnsupportedFeatureError(self.name, "CREATE MATERIALIZED VIEW") + parts = ["CREATE MATERIALIZED VIEW"] - parts.append(self.format_identifier(expr.view_name)) + + if getattr(expr, "if_not_exists", False): + if not self.supports_materialized_view_if_not_exists(): + raise UnsupportedFeatureError( + self.name, + "CREATE MATERIALIZED VIEW IF NOT EXISTS", + f"Requires PostgreSQL 9.4+, current version is {self.version}.", + ) + parts.append("IF NOT EXISTS") + + parts.append(self._format_materialized_view_name(expr)) if expr.column_aliases: cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) parts.append(f"({cols})") + if expr.storage_options and self.supports_materialized_view_storage_options(): + parts.append(f"WITH ({self._format_storage_parameters(expr.storage_options)})") + if expr.tablespace and self.supports_materialized_view_tablespace(): parts.append(f"TABLESPACE {self.format_identifier(expr.tablespace)}") - if expr.storage_options and self.supports_materialized_view_storage_options(): - storage_parts = [] - for key, value in expr.storage_options.items(): - storage_parts.append(f"{key.upper()} = {value}") - parts.append(f"WITH ({', '.join(storage_parts)})") - query_sql, query_params = expr.query.to_sql() parts.append(f"AS {query_sql}") @@ -56,6 +195,32 @@ def format_create_materialized_view_statement(self, expr: "CreateMaterializedVie return " ".join(parts), query_params + def format_drop_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format DROP MATERIALIZED VIEW statement for PostgreSQL. + + Extends the core formatter with schema qualification. + """ + if not self.supports_materialized_view(): + raise UnsupportedFeatureError(self.name, "DROP MATERIALIZED VIEW") + + parts = ["DROP MATERIALIZED VIEW"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_materialized_view_name(expr)) + if expr.cascade: + parts.append("CASCADE") + return " ".join(parts), () + + def format_refresh_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format REFRESH MATERIALIZED VIEW, gating CONCURRENTLY on PG 9.4+. + + The generic ``RefreshMaterializedViewExpression`` routes here, so the + version gate also protects callers that do not use the PostgreSQL + specific expression. + """ + self._check_concurrent_refresh_support(expr) + return super().format_refresh_materialized_view_statement(expr) + def format_refresh_materialized_view_pg_statement( self, expr: "PostgresRefreshMaterializedViewExpression" ) -> Tuple[str, tuple]: @@ -66,22 +231,87 @@ def format_refresh_materialized_view_pg_statement( Returns: Tuple of (SQL statement, parameters tuple) - """ - full_name = f"{expr.schema}.{expr.view_name}" if expr.schema else expr.view_name - sql = "REFRESH MATERIALIZED VIEW" + Raises: + UnsupportedFeatureError: If CONCURRENTLY is requested on PG < 9.4. + """ + self._check_concurrent_refresh_support(expr) + parts = ["REFRESH MATERIALIZED VIEW"] if expr.concurrent: - if not self.supports_materialized_view_concurrent_refresh(): - raise ValueError("CONCURRENTLY requires PostgreSQL 9.4+") - sql += " CONCURRENTLY" + parts.append("CONCURRENTLY") + parts.append(self._format_materialized_view_name(expr)) + if expr.with_data is not None: + parts.append("WITH DATA" if expr.with_data else "WITH NO DATA") + return " ".join(parts), () - sql += f" {full_name}" + def format_alter_materialized_view_statement( + self, expr: "PostgresAlterMaterializedViewExpression" + ) -> Tuple[str, tuple]: + """Format ALTER MATERIALIZED VIEW statement. - if expr.with_data is not None: - if expr.with_data: - sql += " WITH DATA" - else: - sql += " WITH NO DATA" + Each action renders as its own ``ALTER MATERIALIZED VIEW `` + statement; multiple actions are joined with ``";\\n"``. + + Args: + expr: PostgresAlterMaterializedViewExpression with the actions to apply + + Returns: + Tuple of (SQL statement, parameters tuple); ``params`` is always empty. + + Raises: + UnsupportedFeatureError: If the dialect does not support + ALTER MATERIALIZED VIEW. + """ + if not self.supports_alter_materialized_view(): + raise UnsupportedFeatureError(self.name, "ALTER MATERIALIZED VIEW") + + target = self._format_materialized_view_name(expr) + rendered = [ + f"ALTER MATERIALIZED VIEW {target} {self.format_materialized_view_alter_action(action)}" + for action in expr.actions + ] + return ";\n".join(rendered), () + + def format_materialized_view_alter_action(self, expr: Any) -> str: + """Render one ALTER MATERIALIZED VIEW action body. + + Args: + expr: A ``MaterializedViewAlterAction`` subclass instance. + + Returns: + The action SQL without the leading ``ALTER MATERIALIZED VIEW ``. + + Raises: + UnsupportedFeatureError: If the action is not a known PostgreSQL + materialized view action. + """ + if isinstance(expr, PostgresRenameMaterializedViewAction): + return f"RENAME TO {self.format_identifier(expr.new_name)}" + if isinstance(expr, PostgresSetMaterializedViewSchemaAction): + return f"SET SCHEMA {self.format_identifier(expr.new_schema)}" + if isinstance(expr, PostgresSetMaterializedViewPropertiesAction): + return f"SET ({self._format_storage_parameters(expr.properties)})" + if isinstance(expr, PostgresResetMaterializedViewPropertiesAction): + return f"RESET ({self._format_storage_parameter_names(expr.parameters)})" + if isinstance(expr, PostgresChangeMaterializedViewOwnerAction): + if expr.new_owner.upper() in _CURRENT_ROLE_KEYWORDS: + return f"OWNER TO {expr.new_owner.upper()}" + return f"OWNER TO {self.format_identifier(expr.new_owner)}" + raise UnsupportedFeatureError( + self.name, + f"ALTER MATERIALIZED VIEW action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) + + # ------------------------------------------------------------------ + # Internal checks + # ------------------------------------------------------------------ - return sql, () + def _check_concurrent_refresh_support(self, expr: Any) -> None: + """Raise when CONCURRENTLY is requested but unsupported by the server version.""" + if getattr(expr, "concurrent", False) and not self.supports_materialized_view_concurrent_refresh(): + raise UnsupportedFeatureError( + self.name, + "REFRESH MATERIALIZED VIEW CONCURRENTLY", + f"Requires PostgreSQL 9.4+, current version is {self.version}.", + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py index fb3162a7..3b30a056 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py @@ -8,7 +8,10 @@ from typing import Any, Protocol, runtime_checkable, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expression.ddl import PostgresRefreshMaterializedViewExpression + from ...expression.ddl import ( + PostgresAlterMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + ) @runtime_checkable @@ -17,12 +20,14 @@ class PostgresMaterializedViewSupport(Protocol): PostgreSQL's materialized view support extends beyond SQL standard, including: - CONCURRENTLY refresh (requires unique index) + - IF NOT EXISTS on CREATE - TABLESPACE storage - WITH (storage_options) storage parameters + - ALTER MATERIALIZED VIEW actions Version requirements: - Basic materialized view: PostgreSQL 9.3+ - - CONCURRENTLY refresh: PostgreSQL 9.4+ + - CONCURRENTLY refresh / IF NOT EXISTS: PostgreSQL 9.4+ - TABLESPACE: PostgreSQL 9.3+ Note: These features don't require additional plugins, they're part of @@ -31,6 +36,14 @@ class PostgresMaterializedViewSupport(Protocol): Documentation: https://www.postgresql.org/docs/current/sql-creatematerializedview.html """ + def supports_materialized_view(self) -> bool: + """Whether materialized views are supported (PostgreSQL 9.3+).""" + ... + + def supports_refresh_materialized_view(self) -> bool: + """Whether REFRESH MATERIALIZED VIEW is supported (PostgreSQL 9.3+).""" + ... + def supports_materialized_view_concurrent_refresh(self) -> bool: """Whether CONCURRENTLY refresh for materialized views is supported. @@ -39,6 +52,60 @@ def supports_materialized_view_concurrent_refresh(self) -> bool: """ ... + def supports_materialized_view_if_not_exists(self) -> bool: + """Whether CREATE MATERIALIZED VIEW IF NOT EXISTS is supported (PG 9.4+).""" + ... + + def supports_materialized_view_tablespace(self) -> bool: + """Whether TABLESPACE can be specified for materialized views (PG 9.3+).""" + ... + + def supports_materialized_view_storage_options(self) -> bool: + """Whether WITH (storage_parameter) is supported for materialized views (PG 9.3+).""" + ... + + def supports_alter_materialized_view(self) -> bool: + """Whether ALTER MATERIALIZED VIEW is supported (PG 9.3+).""" + ... + + def format_create_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format CREATE MATERIALIZED VIEW statement for PostgreSQL. + + Supports column aliases, schema qualification, IF NOT EXISTS, + WITH (storage_options), TABLESPACE, and WITH DATA / WITH NO DATA. + + Args: + expr: CreateMaterializedViewExpression instance + + Returns: + Tuple of (SQL statement, parameters tuple) + """ + ... + + def format_drop_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format DROP MATERIALIZED VIEW statement for PostgreSQL. + + Supports schema qualification, IF EXISTS and CASCADE. + + Args: + expr: DropMaterializedViewExpression instance + + Returns: + Tuple of (SQL statement, parameters tuple) + """ + ... + + def format_refresh_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format REFRESH MATERIALIZED VIEW statement, gating CONCURRENTLY on PG 9.4+. + + Args: + expr: RefreshMaterializedViewExpression instance + + Returns: + Tuple of (SQL statement, parameters tuple) + """ + ... + def format_refresh_materialized_view_pg_statement( self, expr: "PostgresRefreshMaterializedViewExpression" ) -> Tuple[str, tuple]: @@ -52,16 +119,32 @@ def format_refresh_materialized_view_pg_statement( """ ... - def format_create_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: - """Format CREATE MATERIALIZED VIEW statement for PostgreSQL. + def format_alter_materialized_view_statement( + self, expr: "PostgresAlterMaterializedViewExpression" + ) -> Tuple[str, tuple]: + """Format ALTER MATERIALIZED VIEW statement. - Supports column aliases, TABLESPACE, WITH (storage_options), - and WITH DATA / WITH NO DATA. + Supports RENAME TO, SET SCHEMA, SET (), RESET () and OWNER TO actions. + Multiple actions are joined with ``";\\n"``. Args: - expr: CreateMaterializedViewExpression instance + expr: PostgresAlterMaterializedViewExpression with the actions to apply Returns: Tuple of (SQL statement, parameters tuple) """ ... + + def format_materialized_view_alter_action(self, expr: Any) -> str: + """Render one ALTER MATERIALIZED VIEW action body. + + Storage parameter names must come from + :class:`~....storage_parameters.PostgresStorageParameter`. + + Args: + expr: A ``MaterializedViewAlterAction`` subclass instance. + + Returns: + The action SQL without the leading ``ALTER MATERIALIZED VIEW ``. + """ + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py index ca2419ba..f1c4a7ca 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py @@ -15,10 +15,6 @@ def supports_temporary_view(self) -> bool: ... def supports_if_exists_view(self) -> bool: ... def supports_view_check_option(self) -> bool: ... def supports_cascade_view(self) -> bool: ... - def supports_materialized_view(self) -> bool: ... - def supports_refresh_materialized_view(self) -> bool: ... - def supports_materialized_view_tablespace(self) -> bool: ... - def supports_materialized_view_storage_options(self) -> bool: ... def format_create_view_statement(self, expr: Any) -> Tuple[str, tuple]: """Format CREATE VIEW statement for PostgreSQL. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/storage_parameters.py b/src/rhosocial/activerecord/backend/impl/postgres/storage_parameters.py new file mode 100644 index 00000000..3a76351a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/storage_parameters.py @@ -0,0 +1,235 @@ +# src/rhosocial/activerecord/backend/impl/postgres/storage_parameters.py +"""PostgreSQL relation storage parameters (reloptions). + +A materialized view is stored as a regular heap relation: the server maps +``RELKIND_MATVIEW`` onto ``RELOPT_KIND_HEAP`` in ``fillRelOptions()`` +(``src/backend/access/common/reloptions.c``). Therefore the parameters accepted +by ``CREATE MATERIALIZED VIEW ... WITH (...)`` / ``ALTER MATERIALIZED VIEW ... +SET (...)`` are exactly the *standard heap relation options* listed in +``stdRdOptionsTab`` in the same file. + +That set is enumerated by :class:`PostgresStorageParameter`, and every MV +storage-parameter expression validates against it — no pattern matching on +user input. + +Scope notes +----------- +* Namespaced options (``toast.autovacuum_enabled`` and friends) are not part of + this enum; pass them with ``allow_unlisted_storage_parameters=True``. +* ``USING method`` table access methods may register additional options + (``add_reloption_kind()`` / ``add_*_reloption()`` are exported server APIs, + e.g. Citus columnar adds ``compresslevel``). Those also require + ``allow_unlisted_storage_parameters=True``. +* ``min_version`` is only populated where PostgreSQL documents a specific + introduction version; ``None`` means "no client-side gate, the server + validates the value". +""" +from enum import Enum +from typing import Any, Dict, Optional, Tuple + + +__all__ = [ + "PostgresStorageParameter", + "PostgresStorageParameterValueType", + "resolve_storage_parameter", + "validate_storage_parameters", +] + + +class PostgresStorageParameterValueType(Enum): + """Value type of a storage parameter, mirroring PostgreSQL ``relopt_type``.""" + + INT = "int" + REAL = "real" + BOOL = "bool" + TERNARY = "ternary" + ENUM = "enum" + STRING = "string" + + +class PostgresStorageParameter(str, Enum): + """Standard heap relation options accepted by materialized views. + + The string value is the exact PostgreSQL option name (lower case, as stored + in ``pg_class.reloptions``). + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import ( + ... PostgresStorageParameter, + ... ) + >>> PostgresStorageParameter.FILLFACTOR.value + 'fillfactor' + >>> PostgresStorageParameter("autovacuum_enabled") is \\ + ... PostgresStorageParameter.AUTOVACUUM_ENABLED + True + """ + + FILLFACTOR = "fillfactor" + TOAST_TUPLE_TARGET = "toast_tuple_target" + PARALLEL_WORKERS = "parallel_workers" + USER_CATALOG_TABLE = "user_catalog_table" + VACUUM_INDEX_CLEANUP = "vacuum_index_cleanup" + VACUUM_TRUNCATE = "vacuum_truncate" + VACUUM_MAX_EAGER_FREEZE_FAILURE_RATE = "vacuum_max_eager_freeze_failure_rate" + + AUTOVACUUM_ENABLED = "autovacuum_enabled" + AUTOVACUUM_PARALLEL_WORKERS = "autovacuum_parallel_workers" + AUTOVACUUM_VACUUM_THRESHOLD = "autovacuum_vacuum_threshold" + AUTOVACUUM_VACUUM_MAX_THRESHOLD = "autovacuum_vacuum_max_threshold" + AUTOVACUUM_VACUUM_INSERT_THRESHOLD = "autovacuum_vacuum_insert_threshold" + AUTOVACUUM_ANALYZE_THRESHOLD = "autovacuum_analyze_threshold" + AUTOVACUUM_VACUUM_COST_LIMIT = "autovacuum_vacuum_cost_limit" + AUTOVACUUM_VACUUM_COST_DELAY = "autovacuum_vacuum_cost_delay" + AUTOVACUUM_VACUUM_SCALE_FACTOR = "autovacuum_vacuum_scale_factor" + AUTOVACUUM_VACUUM_INSERT_SCALE_FACTOR = "autovacuum_vacuum_insert_scale_factor" + AUTOVACUUM_ANALYZE_SCALE_FACTOR = "autovacuum_analyze_scale_factor" + AUTOVACUUM_FREEZE_MIN_AGE = "autovacuum_freeze_min_age" + AUTOVACUUM_FREEZE_MAX_AGE = "autovacuum_freeze_max_age" + AUTOVACUUM_FREEZE_TABLE_AGE = "autovacuum_freeze_table_age" + AUTOVACUUM_MULTIXACT_FREEZE_MIN_AGE = "autovacuum_multixact_freeze_min_age" + AUTOVACUUM_MULTIXACT_FREEZE_MAX_AGE = "autovacuum_multixact_freeze_max_age" + AUTOVACUUM_MULTIXACT_FREEZE_TABLE_AGE = "autovacuum_multixact_freeze_table_age" + LOG_AUTOVACUUM_MIN_DURATION = "log_autovacuum_min_duration" + LOG_AUTOANALYZE_MIN_DURATION = "log_autoanalyze_min_duration" + + @property + def value_type(self) -> PostgresStorageParameterValueType: + """The value type PostgreSQL expects for this parameter.""" + return _VALUE_TYPES[self] + + @property + def min_version(self) -> Optional[Tuple[int, int, int]]: + """First PostgreSQL version accepting this parameter, when documented. + + ``None`` means the parameter has been accepted since materialized views + exist, or that no client-side version gate is applied. + """ + return _MIN_VERSIONS.get(self) + + @property + def enum_values(self) -> Optional[Tuple[str, ...]]: + """Accepted values for ``ENUM`` parameters, ``None`` for other types.""" + return _ENUM_VALUES.get(self) + + +_VALUE_TYPES: Dict[PostgresStorageParameter, PostgresStorageParameterValueType] = { + PostgresStorageParameter.FILLFACTOR: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.TOAST_TUPLE_TARGET: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.PARALLEL_WORKERS: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.USER_CATALOG_TABLE: PostgresStorageParameterValueType.BOOL, + PostgresStorageParameter.VACUUM_INDEX_CLEANUP: PostgresStorageParameterValueType.ENUM, + PostgresStorageParameter.VACUUM_TRUNCATE: PostgresStorageParameterValueType.TERNARY, + PostgresStorageParameter.VACUUM_MAX_EAGER_FREEZE_FAILURE_RATE: ( + PostgresStorageParameterValueType.REAL + ), + PostgresStorageParameter.AUTOVACUUM_ENABLED: PostgresStorageParameterValueType.TERNARY, + PostgresStorageParameter.AUTOVACUUM_PARALLEL_WORKERS: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_VACUUM_THRESHOLD: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_VACUUM_MAX_THRESHOLD: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_VACUUM_INSERT_THRESHOLD: ( + PostgresStorageParameterValueType.INT + ), + PostgresStorageParameter.AUTOVACUUM_ANALYZE_THRESHOLD: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_VACUUM_COST_LIMIT: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_VACUUM_COST_DELAY: PostgresStorageParameterValueType.REAL, + PostgresStorageParameter.AUTOVACUUM_VACUUM_SCALE_FACTOR: ( + PostgresStorageParameterValueType.REAL + ), + PostgresStorageParameter.AUTOVACUUM_VACUUM_INSERT_SCALE_FACTOR: ( + PostgresStorageParameterValueType.REAL + ), + PostgresStorageParameter.AUTOVACUUM_ANALYZE_SCALE_FACTOR: ( + PostgresStorageParameterValueType.REAL + ), + PostgresStorageParameter.AUTOVACUUM_FREEZE_MIN_AGE: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_FREEZE_MAX_AGE: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_FREEZE_TABLE_AGE: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_MULTIXACT_FREEZE_MIN_AGE: ( + PostgresStorageParameterValueType.INT + ), + PostgresStorageParameter.AUTOVACUUM_MULTIXACT_FREEZE_MAX_AGE: ( + PostgresStorageParameterValueType.INT + ), + PostgresStorageParameter.AUTOVACUUM_MULTIXACT_FREEZE_TABLE_AGE: ( + PostgresStorageParameterValueType.INT + ), + PostgresStorageParameter.LOG_AUTOVACUUM_MIN_DURATION: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.LOG_AUTOANALYZE_MIN_DURATION: PostgresStorageParameterValueType.INT, +} + +# Only versions PostgreSQL documents explicitly; None elsewhere. +_MIN_VERSIONS: Dict[PostgresStorageParameter, Optional[Tuple[int, int, int]]] = { + PostgresStorageParameter.PARALLEL_WORKERS: (11, 0, 0), + PostgresStorageParameter.VACUUM_TRUNCATE: (12, 0, 0), + PostgresStorageParameter.AUTOVACUUM_VACUUM_MAX_THRESHOLD: (12, 0, 0), + PostgresStorageParameter.AUTOVACUUM_VACUUM_INSERT_THRESHOLD: (13, 0, 0), + PostgresStorageParameter.AUTOVACUUM_VACUUM_INSERT_SCALE_FACTOR: (13, 0, 0), + PostgresStorageParameter.LOG_AUTOANALYZE_MIN_DURATION: (15, 0, 0), + PostgresStorageParameter.AUTOVACUUM_PARALLEL_WORKERS: (16, 0, 0), + PostgresStorageParameter.VACUUM_MAX_EAGER_FREEZE_FAILURE_RATE: (18, 0, 0), +} + +_ENUM_VALUES: Dict[PostgresStorageParameter, Tuple[str, ...]] = { + PostgresStorageParameter.VACUUM_INDEX_CLEANUP: ("auto", "enabled", "disabled"), +} + + +def resolve_storage_parameter(name: Any) -> Optional[PostgresStorageParameter]: + """Resolve a storage parameter from an enum member or its PostgreSQL name. + + Args: + name: A :class:`PostgresStorageParameter` member or its string value. + + Returns: + The matching member, or ``None`` when the name is not a standard heap + relation option. + """ + if isinstance(name, PostgresStorageParameter): + return name + if isinstance(name, str): + try: + return PostgresStorageParameter(name) + except ValueError: + return None + return None + + +def validate_storage_parameters( + names: Any, + field_name: str, + allow_unlisted: bool = False, +) -> Tuple[PostgresStorageParameter, ...]: + """Validate storage parameter names against :class:`PostgresStorageParameter`. + + Args: + names: Iterable of enum members and/or PostgreSQL option names. + field_name: Field name used in error messages. + allow_unlisted: Accept names outside the standard heap relation option + set (namespaced ``toast.*`` options, or options registered by a + table access method). The server remains the authority. + + Returns: + The resolved members, in input order. When ``allow_unlisted`` is set, + unlisted names are dropped from the result. + + Raises: + ValueError: If a name is not a standard storage parameter and + ``allow_unlisted`` is False. + TypeError: If ``names`` is not an iterable of names. + """ + if isinstance(names, (str, bytes)) or not hasattr(names, "__iter__"): + raise TypeError(f"{field_name} must be an iterable of storage parameter names") + + resolved = [] + for name in names: + parameter = resolve_storage_parameter(name) + if parameter is None: + if allow_unlisted: + continue + raise ValueError( + f"{field_name} contains an unknown PostgreSQL storage parameter: {name!r}. " + f"Pass allow_unlisted=True to forward names registered by a table " + f"access method or namespaced under 'toast.'." + ) + resolved.append(parameter) + return tuple(resolved) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view.py new file mode 100644 index 00000000..957e192f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view.py @@ -0,0 +1,812 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view.py +"""PostgreSQL materialized view DDL tests (SQL generation only, no database). + +Covers the regression matrix documented in +``.claude/plan/2026-09-26/materialized-view-coverage.md``: + +- G1: ``PostgresMaterializedViewMixin`` must precede the core ``ViewMixin`` in + the MRO, otherwise the PostgreSQL CREATE formatter is dead code and + ``storage_options`` is silently dropped. +- G2/G3: version gates and exception contract for ``REFRESH ... CONCURRENTLY``. +- G4: schema qualification and identifier quoting for all MV statements. +- G5: ``CREATE MATERIALIZED VIEW IF NOT EXISTS`` (PG 9.4+). +- G6: ``ALTER MATERIALIZED VIEW`` (RENAME / SET SCHEMA / SET() / RESET() / OWNER). +- G8: protocol and package surface. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins import ViewMixin +from rhosocial.activerecord.backend.expression import ( + Column, + CreateMaterializedViewExpression, + DropMaterializedViewExpression, + QueryExpression, + RefreshMaterializedViewExpression, + TableExpression, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + MaterializedViewAlterAction, + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresCreateMaterializedViewExpression, + PostgresDropMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) +from rhosocial.activerecord.backend.impl.postgres.mixins.materialized_view import ( + PostgresMaterializedViewMixin, +) +from rhosocial.activerecord.backend.impl.postgres.protocols.materialized_view import ( + PostgresMaterializedViewSupport, +) +from rhosocial.activerecord.backend.impl.postgres.storage_parameters import ( + PostgresStorageParameter, + PostgresStorageParameterValueType, + resolve_storage_parameter, + validate_storage_parameters, +) + +PG_93 = (9, 3, 0) +PG_94 = (9, 4, 0) +PG_15 = (15, 0, 0) + + +def _dialect(version=PG_15): + return PostgresDialect(version=version) + + +def _source_query(dialect): + return QueryExpression( + dialect=dialect, + select=[Column(dialect, "product_id")], + from_=TableExpression(dialect, "sales"), + ) + + +def _create(dialect, **kwargs): + kwargs.setdefault("view_name", "sales_summary") + kwargs.setdefault("query", _source_query(dialect)) + return CreateMaterializedViewExpression(dialect=dialect, **kwargs) + + +class TestMaterializedViewMixinResolution: + """Lock down mixin resolution so PG-specific MV formatting cannot go dead again.""" + + def test_pg_mv_mixin_precedes_core_view_mixin(self): + """T-01: PG MV mixin must come before the core ViewMixin.""" + mro = PostgresDialect.__mro__ + assert mro.index(PostgresMaterializedViewMixin) < mro.index(ViewMixin) + + def test_create_mv_formatter_owned_by_pg_mixin(self): + """T-02: CREATE MATERIALIZED VIEW must be formatted by the PG mixin.""" + owner = _dialect().format_create_materialized_view_statement.__qualname__ + assert owner.startswith("PostgresMaterializedViewMixin.") + + def test_drop_mv_formatter_owned_by_pg_mixin(self): + """T-03: the DROP path is PG-owned (schema aware) and keeps the core SQL shape.""" + dialect = _dialect() + owner = dialect.format_drop_materialized_view_statement.__qualname__ + assert owner.startswith("PostgresMaterializedViewMixin.") + sql, _ = DropMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", if_exists=True, cascade=True + ).to_sql() + assert sql == 'DROP MATERIALIZED VIEW IF EXISTS "sales_summary" CASCADE' + + @pytest.mark.parametrize( + "capability,formatter", + [ + ("supports_materialized_view_tablespace", "format_create_materialized_view_statement"), + ("supports_materialized_view_storage_options", "format_create_materialized_view_statement"), + ], + ) + def test_pg_only_capability_has_pg_owned_formatter(self, capability, formatter): + """T-04: a declared PG-only capability must have a PG-owned formatter behind it. + + This is the generic guard against "capability probe says True but the + formatter never runs" (the exact shape of the G1 defect). + """ + dialect = _dialect() + assert getattr(dialect, capability)() is True + owner = getattr(dialect, formatter).__qualname__ + assert owner.startswith("PostgresMaterializedViewMixin.") + + +class TestCreateMaterializedViewFormatting: + """G1: PostgreSQL CREATE MATERIALIZED VIEW specifics.""" + + def test_storage_options_rendered(self): + """T-05: storage_options must appear in the generated SQL.""" + dialect = _dialect() + sql, _ = _create(dialect, storage_options={"fillfactor": 70}).to_sql() + assert sql == ( + 'CREATE MATERIALIZED VIEW "sales_summary" WITH (fillfactor = 70) ' + 'AS SELECT "product_id" FROM "sales" WITH DATA' + ) + + def test_multiple_storage_options_rendered(self): + """T-06: every storage option is rendered, keys upper-cased.""" + dialect = _dialect() + sql, _ = _create( + dialect, + storage_options={"fillfactor": 70, "autovacuum_enabled": "true"}, + ).to_sql() + assert sql == ( + 'CREATE MATERIALIZED VIEW "sales_summary" ' + 'WITH (fillfactor = 70, autovacuum_enabled = true) ' + 'AS SELECT "product_id" FROM "sales" WITH DATA' + ) + + def test_tablespace_column_aliases_with_data(self): + """T-07: PG grammar order is column aliases -> WITH (...) -> TABLESPACE -> AS.""" + dialect = _dialect() + sql, _ = _create( + dialect, + column_aliases=["product_id", "total_sales"], + storage_options={"fillfactor": 90}, + tablespace="fast_ssd", + ).to_sql() + assert sql == ( + 'CREATE MATERIALIZED VIEW "sales_summary" ("product_id", "total_sales") ' + 'WITH (fillfactor = 90) TABLESPACE "fast_ssd" ' + 'AS SELECT "product_id" FROM "sales" WITH DATA' + ) + + def test_with_no_data(self): + """T-08: WITH NO DATA tail.""" + dialect = _dialect() + sql, _ = _create(dialect, with_data=False).to_sql() + assert sql.endswith("WITH NO DATA") + + def test_empty_storage_options_omitted(self): + """T-09: no empty ``WITH ()`` is emitted when storage_options is empty.""" + dialect = _dialect() + sql, _ = _create(dialect).to_sql() + assert "WITH (" not in sql + assert sql == ( + 'CREATE MATERIALIZED VIEW "sales_summary" ' + 'AS SELECT "product_id" FROM "sales" WITH DATA' + ) + + +class TestDropMaterializedViewFormatting: + """G1: DROP MATERIALIZED VIEW keeps IF EXISTS / CASCADE.""" + + def test_drop_plain(self): + dialect = _dialect() + sql, params = DropMaterializedViewExpression( + dialect=dialect, view_name="sales_summary" + ).to_sql() + assert sql == 'DROP MATERIALIZED VIEW "sales_summary"' + assert params == () + + def test_drop_if_exists_cascade(self): + dialect = _dialect() + sql, _ = DropMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", if_exists=True, cascade=True + ).to_sql() + assert sql == 'DROP MATERIALIZED VIEW IF EXISTS "sales_summary" CASCADE' + + +class TestRefreshMaterializedViewFormatting: + """G2: CONCURRENTLY must be gated on PostgreSQL 9.4+ on every code path.""" + + def test_refresh_plain(self): + dialect = _dialect() + sql, params = RefreshMaterializedViewExpression( + dialect=dialect, view_name="sales_summary" + ).to_sql() + assert sql == 'REFRESH MATERIALIZED VIEW "sales_summary"' + assert params == () + + @pytest.mark.parametrize("with_data,expected", [(True, " WITH DATA"), (False, " WITH NO DATA")]) + def test_refresh_with_data_variants(self, with_data, expected): + dialect = _dialect() + sql, _ = RefreshMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", with_data=with_data + ).to_sql() + assert sql == f'REFRESH MATERIALIZED VIEW "sales_summary"{expected}' + + def test_refresh_without_with_data_omits_clause(self): + dialect = _dialect() + sql, _ = RefreshMaterializedViewExpression( + dialect=dialect, view_name="sales_summary" + ).to_sql() + assert "WITH DATA" not in sql + + @pytest.mark.parametrize("version", [PG_94, (10, 0, 0), PG_15, (17, 0, 0)]) + def test_refresh_concurrently_allowed_since_94(self, version): + """T-12: CONCURRENTLY is emitted from PG 9.4 onwards.""" + dialect = _dialect(version) + sql, _ = RefreshMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", concurrent=True + ).to_sql() + assert sql == 'REFRESH MATERIALIZED VIEW CONCURRENTLY "sales_summary"' + + def test_refresh_concurrently_rejected_before_94(self): + """T-13: the generic expression must refuse CONCURRENTLY before PG 9.4.""" + dialect = _dialect(PG_93) + with pytest.raises(UnsupportedFeatureError) as exc: + RefreshMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", concurrent=True + ).to_sql() + assert "CONCURRENTLY" in str(exc.value) + + def test_pg_expression_shares_same_gate(self): + """T-15: the PostgreSQL expression is gated identically.""" + dialect = _dialect(PG_93) + with pytest.raises(UnsupportedFeatureError): + PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_summary", concurrently=True + ).to_sql() + + def test_pg_expression_renders_concurrently_since_94(self): + dialect = _dialect() + sql, _ = PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_summary", concurrently=True, with_data=True + ).to_sql() + assert sql == 'REFRESH MATERIALIZED VIEW CONCURRENTLY "sales_summary" WITH DATA' + + @pytest.mark.parametrize( + "version,feature,should_raise", + [ + (PG_93, "concurrent", True), + (PG_93, "if_not_exists", True), + (PG_93, "tablespace", False), + (PG_93, "storage_options", False), + (PG_94, "concurrent", False), + (PG_94, "if_not_exists", False), + ((9, 2, 0), "concurrent", True), + ((9, 2, 0), "if_not_exists", True), + ((9, 2, 0), "tablespace", True), + ((9, 2, 0), "storage_options", True), + ], + ) + def test_version_gate_matrix(self, version, feature, should_raise): + """T-16: version gate matrix across MV features.""" + dialect = _dialect(version) + build = { + "concurrent": lambda: RefreshMaterializedViewExpression( + dialect=dialect, view_name="mv", concurrent=True + ), + "if_not_exists": lambda: PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_source_query(dialect), + if_not_exists=True, + ), + "tablespace": lambda: PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_source_query(dialect), + tablespace="fast_ssd", + ), + "storage_options": lambda: PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_source_query(dialect), + storage_options={"fillfactor": 70}, + ), + }[feature] + if should_raise: + with pytest.raises(UnsupportedFeatureError): + build().to_sql() + else: + sql, _ = build().to_sql() + assert sql.startswith("CREATE MATERIALIZED VIEW") or sql.startswith( + "REFRESH MATERIALIZED VIEW" + ) + + +class TestMaterializedViewSchemaQualification: + """G4: MV names must be schema-qualified and quoted.""" + + def test_refresh_schema_qualified_and_quoted(self): + """T-17.""" + dialect = _dialect() + sql, _ = PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="mv_sales", schema="reporting" + ).to_sql() + assert sql == 'REFRESH MATERIALIZED VIEW "reporting"."mv_sales"' + + def test_mixed_case_and_reserved_word_quoted(self): + """T-18: the previous bare f-string concatenation broke here.""" + dialect = _dialect() + sql, _ = PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="Order Summary", schema="Reporting" + ).to_sql() + assert sql == 'REFRESH MATERIALIZED VIEW "Reporting"."Order Summary"' + + def test_create_mv_schema_qualified(self): + """T-19.""" + dialect = _dialect() + sql, _ = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv_sales", + query=_source_query(dialect), + schema="reporting", + ).to_sql() + assert sql.startswith('CREATE MATERIALIZED VIEW "reporting"."mv_sales" ') + + def test_drop_mv_schema_qualified(self): + """T-20.""" + dialect = _dialect() + sql, _ = PostgresDropMaterializedViewExpression( + dialect=dialect, view_name="mv_sales", schema="reporting", if_exists=True + ).to_sql() + assert sql == 'DROP MATERIALIZED VIEW IF EXISTS "reporting"."mv_sales"' + + def test_no_schema_emits_bare_identifier(self): + """T-21: no stray separator when schema is absent.""" + dialect = _dialect() + sql, _ = PostgresDropMaterializedViewExpression( + dialect=dialect, view_name="mv_sales" + ).to_sql() + assert sql == 'DROP MATERIALIZED VIEW "mv_sales"' + assert "." not in sql + + def test_rejects_empty_names(self): + dialect = _dialect() + with pytest.raises(ValueError): + PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name=" ", query=_source_query(dialect) + ) + with pytest.raises(ValueError): + PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=_source_query(dialect), schema="" + ) + + +class TestCreateMaterializedViewIfNotExists: + """G5: CREATE MATERIALIZED VIEW IF NOT EXISTS (PG 9.4+).""" + + def test_if_not_exists_rendered(self): + """T-22.""" + dialect = _dialect() + sql, _ = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + query=_source_query(dialect), + if_not_exists=True, + ).to_sql() + assert sql == ( + 'CREATE MATERIALIZED VIEW IF NOT EXISTS "sales_summary" ' + 'AS SELECT "product_id" FROM "sales" WITH DATA' + ) + + def test_if_not_exists_false_omitted(self): + """T-23.""" + dialect = _dialect() + sql, _ = PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", query=_source_query(dialect) + ).to_sql() + assert "IF NOT EXISTS" not in sql + + def test_if_not_exists_rejected_before_94(self): + """T-24.""" + dialect = _dialect(PG_93) + with pytest.raises(UnsupportedFeatureError): + PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + query=_source_query(dialect), + if_not_exists=True, + ).to_sql() + + +class TestMaterializedViewExceptionContract: + """G3: MV feature gates raise UnsupportedFeatureError, never bare ValueError.""" + + @pytest.mark.parametrize( + "build", + [ + lambda dialect: RefreshMaterializedViewExpression( + dialect=dialect, view_name="mv", concurrent=True + ), + lambda dialect: PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="mv", concurrently=True + ), + lambda dialect: PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=_source_query(dialect), if_not_exists=True + ), + ], + ) + def test_all_mv_gates_raise_unsupported_feature_error(self, build): + """T-25.""" + dialect = _dialect(PG_93) + with pytest.raises(UnsupportedFeatureError) as exc: + build(dialect).to_sql() + assert exc.value.dialect_name == dialect.name + assert exc.value.feature_name + + def test_error_message_mentions_dialect_and_feature(self): + """T-26.""" + dialect = _dialect(PG_93) + with pytest.raises(UnsupportedFeatureError) as exc: + RefreshMaterializedViewExpression( + dialect=dialect, view_name="mv", concurrent=True + ).to_sql() + message = str(exc.value) + assert dialect.name in message + assert "CONCURRENTLY" in message + + def test_unsupported_feature_error_is_not_value_error(self): + """T-27: guards the core invariant so MV gates cannot regress to ValueError.""" + assert not issubclass(UnsupportedFeatureError, ValueError) + + +class TestAlterMaterializedView: + """G6: ALTER MATERIALIZED VIEW actions.""" + + def _alter(self, dialect, *actions, **kwargs): + return PostgresAlterMaterializedViewExpression( + dialect=dialect, view_name=kwargs.pop("view_name", "sales_summary"), + actions=list(actions), **kwargs + ) + + def test_rename_to(self): + """T-28.""" + dialect = _dialect() + sql, params = self._alter( + dialect, PostgresRenameMaterializedViewAction(dialect, "sales_summary_v2") + ).to_sql() + assert sql == 'ALTER MATERIALIZED VIEW "sales_summary" RENAME TO "sales_summary_v2"' + assert params == () + + def test_set_schema(self): + """T-29.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, PostgresSetMaterializedViewSchemaAction(dialect, "archive") + ).to_sql() + assert sql == 'ALTER MATERIALIZED VIEW "sales_summary" SET SCHEMA "archive"' + + def test_set_properties(self): + """T-30.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, + PostgresSetMaterializedViewPropertiesAction( + dialect, {"fillfactor": 90, "autovacuum_enabled": "true"} + ), + ).to_sql() + assert sql == ( + 'ALTER MATERIALIZED VIEW "sales_summary" ' + "SET (fillfactor = 90, autovacuum_enabled = true)" + ) + + def test_reset_parameters(self): + """T-31.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, + PostgresResetMaterializedViewPropertiesAction( + dialect, ["fillfactor", "autovacuum_enabled"] + ), + ).to_sql() + assert sql == ( + 'ALTER MATERIALIZED VIEW "sales_summary" RESET (fillfactor, autovacuum_enabled)' + ) + + def test_owner_to(self): + """T-32: role names are quoted, CURRENT_* keywords are not.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, PostgresChangeMaterializedViewOwnerAction(dialect, "app_owner") + ).to_sql() + assert sql == 'ALTER MATERIALIZED VIEW "sales_summary" OWNER TO "app_owner"' + + for keyword in ("CURRENT_USER", "CURRENT_ROLE", "SESSION_USER", "current_user"): + sql, _ = self._alter( + dialect, PostgresChangeMaterializedViewOwnerAction(dialect, keyword) + ).to_sql() + assert sql.endswith(f"OWNER TO {keyword.upper()}") + + def test_multiple_actions(self): + """T-33.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, + PostgresRenameMaterializedViewAction(dialect, "sales_summary_v2"), + PostgresSetMaterializedViewPropertiesAction(dialect, {"fillfactor": 90}), + ).to_sql() + assert sql == ( + 'ALTER MATERIALIZED VIEW "sales_summary" RENAME TO "sales_summary_v2";\n' + 'ALTER MATERIALIZED VIEW "sales_summary" SET (fillfactor = 90)' + ) + + def test_alter_target_schema_qualified(self): + """T-34.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, + PostgresSetMaterializedViewSchemaAction(dialect, "archive"), + view_name="mv_sales", + schema="reporting", + ).to_sql() + assert sql == ( + 'ALTER MATERIALIZED VIEW "reporting"."mv_sales" SET SCHEMA "archive"' + ) + + def test_set_tablespace_not_offered(self): + """T-35: PostgreSQL cannot relocate a MV to another tablespace.""" + import rhosocial.activerecord.backend.impl.postgres.expression.ddl.mv as mv_module + + assert not hasattr(mv_module, "PostgresSetMaterializedViewTablespaceAction") + assert not any( + "TABLESPACE" in (action.__doc__ or "") + for action in ( + PostgresRenameMaterializedViewAction, + PostgresSetMaterializedViewSchemaAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresChangeMaterializedViewOwnerAction, + ) + ) + + def test_requires_at_least_one_action(self): + dialect = _dialect() + with pytest.raises(ValueError): + PostgresAlterMaterializedViewExpression( + dialect=dialect, view_name="mv", actions=[] + ) + + def test_rejects_non_action_objects(self): + dialect = _dialect() + with pytest.raises(TypeError): + PostgresAlterMaterializedViewExpression( + dialect=dialect, view_name="mv", actions=[object()] + ) + + def test_rejects_unlisted_storage_parameters(self): + """Names must exist in PostgresStorageParameter; no pattern matching.""" + dialect = _dialect() + for bogus in ("nonexistent_opt", "fillfactor = 70) --", "toast.autovacuum_enabled"): + with pytest.raises(ValueError) as exc: + PostgresSetMaterializedViewPropertiesAction(dialect, {bogus: 1}) + assert "unknown PostgreSQL storage parameter" in str(exc.value) + with pytest.raises(ValueError): + PostgresResetMaterializedViewPropertiesAction(dialect, [bogus]) + with pytest.raises(ValueError): + PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_source_query(dialect), + storage_options={bogus: 1}, + ) + + def test_allows_unlisted_storage_parameters_explicitly(self): + """Escape hatch for table access method options and toast.* names.""" + dialect = _dialect() + expression = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_source_query(dialect), + storage_options={"compresslevel": 4}, + allow_unlisted_storage_parameters=True, + ) + sql, _ = expression.to_sql() + assert "WITH (compresslevel = 4)" in sql + + def test_rejects_unknown_action(self): + dialect = _dialect() + + class RogueAction(MaterializedViewAlterAction): + action_kind = "rogue" + + with pytest.raises(UnsupportedFeatureError): + dialect.format_materialized_view_alter_action(RogueAction(dialect)) + + +class TestPostgresStorageParameterEnum: + """Storage parameter names are enumerated, never pattern matched.""" + + def test_enum_covers_heap_relation_options(self): + """The enum is the authoritative allow-list for MV reloptions.""" + values = {member.value for member in PostgresStorageParameter} + assert len(values) == len(list(PostgresStorageParameter)) + for expected in ( + "fillfactor", + "toast_tuple_target", + "parallel_workers", + "user_catalog_table", + "vacuum_index_cleanup", + "vacuum_truncate", + "autovacuum_enabled", + "autovacuum_vacuum_threshold", + "autovacuum_analyze_threshold", + "autovacuum_freeze_min_age", + "autovacuum_multixact_freeze_table_age", + "log_autovacuum_min_duration", + ): + assert expected in values + + def test_lookup_by_name_and_member(self): + assert resolve_storage_parameter("fillfactor") is PostgresStorageParameter.FILLFACTOR + assert ( + resolve_storage_parameter(PostgresStorageParameter.FILLFACTOR) + is PostgresStorageParameter.FILLFACTOR + ) + assert resolve_storage_parameter("toast_tuple_target") is ( + PostgresStorageParameter.TOAST_TUPLE_TARGET + ) + + def test_lookup_rejects_unknown(self): + assert resolve_storage_parameter("fillfactor ") is None + assert resolve_storage_parameter(None) is None + assert resolve_storage_parameter(70) is None + + def test_validate_accepts_mixed_forms(self): + resolved = validate_storage_parameters( + ["fillfactor", PostgresStorageParameter.AUTOVACUUM_ENABLED], + "properties", + ) + assert resolved == ( + PostgresStorageParameter.FILLFACTOR, + PostgresStorageParameter.AUTOVACUUM_ENABLED, + ) + + def test_validate_rejects_unknown_by_default(self): + with pytest.raises(ValueError): + validate_storage_parameters(["fillfactor", "compresslevel"], "properties") + + def test_validate_can_allow_unlisted(self): + assert validate_storage_parameters( + ["compresslevel"], "properties", allow_unlisted=True + ) == () + + def test_validate_rejects_non_iterable(self): + with pytest.raises(TypeError): + validate_storage_parameters("fillfactor", "properties") + + def test_value_types_are_declared(self): + assert ( + PostgresStorageParameter.FILLFACTOR.value_type + is PostgresStorageParameterValueType.INT + ) + assert ( + PostgresStorageParameter.AUTOVACUUM_VACUUM_SCALE_FACTOR.value_type + is PostgresStorageParameterValueType.REAL + ) + assert ( + PostgresStorageParameter.AUTOVACUUM_ENABLED.value_type + is PostgresStorageParameterValueType.TERNARY + ) + assert ( + PostgresStorageParameter.VACUUM_INDEX_CLEANUP.enum_values + == ("auto", "enabled", "disabled") + ) + assert PostgresStorageParameter.FILLFACTOR.enum_values is None + + def test_min_version_metadata(self): + assert PostgresStorageParameter.PARALLEL_WORKERS.min_version == (11, 0, 0) + assert PostgresStorageParameter.AUTOVACUUM_VACUUM_INSERT_THRESHOLD.min_version == (13, 0, 0) + assert PostgresStorageParameter.LOG_AUTOANALYZE_MIN_DURATION.min_version == (15, 0, 0) + assert PostgresStorageParameter.AUTOVACUUM_PARALLEL_WORKERS.min_version == (16, 0, 0) + assert PostgresStorageParameter.FILLFACTOR.min_version is None + + def test_enum_exported_from_package_root(self): + from rhosocial.activerecord.backend.impl import postgres as pg + + assert pg.PostgresStorageParameter is PostgresStorageParameter + assert "PostgresStorageParameter" in pg.__all__ + + def test_expressions_render_enum_members(self): + """Enum members and their string values render identically.""" + dialect = _dialect() + by_member = PostgresSetMaterializedViewPropertiesAction( + dialect, {PostgresStorageParameter.FILLFACTOR: 70} + ) + by_name = PostgresSetMaterializedViewPropertiesAction(dialect, {"fillfactor": 70}) + assert by_member.to_sql() == by_name.to_sql() + assert "fillfactor = 70" in by_member.to_sql() + + +class TestMaterializedViewProtocolAndSurface: + """G8: protocol and package surface stay in sync.""" + + def test_protocol_isinstance(self): + """T-36.""" + assert isinstance(_dialect(), PostgresMaterializedViewSupport) + + def test_protocol_declares_all_formatters(self): + """T-37: the protocol must cover every MV formatter the mixin exposes.""" + for name in ( + "supports_materialized_view", + "supports_materialized_view_concurrent_refresh", + "supports_materialized_view_if_not_exists", + "supports_materialized_view_tablespace", + "supports_materialized_view_storage_options", + "supports_alter_materialized_view", + "format_create_materialized_view_statement", + "format_drop_materialized_view_statement", + "format_refresh_materialized_view_statement", + "format_refresh_materialized_view_pg_statement", + "format_alter_materialized_view_statement", + "format_materialized_view_alter_action", + ): + assert hasattr(_dialect(), name), name + assert hasattr(PostgresMaterializedViewSupport, name), name + + def test_new_expressions_exported(self): + """T-38.""" + from rhosocial.activerecord.backend.impl.postgres import expression as pg_expression + + for name in ( + "PostgresCreateMaterializedViewExpression", + "PostgresDropMaterializedViewExpression", + "PostgresRefreshMaterializedViewExpression", + "PostgresAlterMaterializedViewExpression", + "PostgresRenameMaterializedViewAction", + "PostgresSetMaterializedViewSchemaAction", + "PostgresSetMaterializedViewPropertiesAction", + "PostgresResetMaterializedViewPropertiesAction", + "PostgresChangeMaterializedViewOwnerAction", + "MaterializedViewAlterAction", + ): + assert name in pg_expression.__all__, name + assert hasattr(pg_expression, name), name + + def test_format_method_targets_exist(self): + """T-39: every MV expression routes to a formatter the dialect implements.""" + dialect = _dialect() + query = _source_query(dialect) + expressions = [ + _create(dialect), + PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=query + ), + DropMaterializedViewExpression(dialect=dialect, view_name="mv"), + PostgresDropMaterializedViewExpression(dialect=dialect, view_name="mv"), + RefreshMaterializedViewExpression(dialect=dialect, view_name="mv"), + PostgresRefreshMaterializedViewExpression(dialect=dialect, name="mv"), + PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name="mv", + actions=[PostgresRenameMaterializedViewAction(dialect, "mv2")], + ), + ] + for expression in expressions: + formatter = getattr(dialect, expression.format_method, None) + assert callable(formatter), f"{type(expression).__name__} -> {expression.format_method}" + + def test_legacy_aliases_preserved(self): + """T-40: examples rely on the name=/concurrently= aliases.""" + dialect = _dialect() + expression = PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_summary", concurrently=True + ) + assert expression.name == expression.view_name == "sales_summary" + assert expression.concurrently is True + assert expression.concurrent is True + + @pytest.mark.parametrize( + "version,expected", + [ + ((9, 2, 0), False), + (PG_93, True), + (PG_15, True), + ], + ) + def test_base_capability_probe_respects_version(self, version, expected): + """T-41a: materialized views themselves require PostgreSQL 9.3+.""" + assert _dialect(version).supports_materialized_view() is expected + + @pytest.mark.parametrize( + "version,expected", [(PG_93, False), (PG_94, True), (PG_15, True)] + ) + def test_concurrent_refresh_probe_matches_version(self, version, expected): + """T-41b.""" + assert _dialect(version).supports_materialized_view_concurrent_refresh() is expected + + def test_pg_only_capability_has_pg_owned_formatter_for_refresh(self): + """T-04 (refresh half): CONCURRENTLY gating lives in the PG mixin.""" + dialect = _dialect() + assert dialect.supports_materialized_view_concurrent_refresh() is True + owner = dialect.format_refresh_materialized_view_statement.__qualname__ + assert owner.startswith("PostgresMaterializedViewMixin.") diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_docs.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_docs.py new file mode 100644 index 00000000..eb441407 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_docs.py @@ -0,0 +1,134 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_docs.py +"""Documentation and example consistency for materialized views. + +Docs that drift from the implementation are a defect: the previous MV +documentation promised ``storage_options`` support that the MRO bug silently +disabled. These tests keep the docs, the example and the capability surface +aligned with the code. +""" + +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[6] +MV_DOC_PAGES = [ + PROJECT_ROOT / "docs" / "en_US" / "ddl" / "materialized_view.md", + PROJECT_ROOT / "docs" / "zh_CN" / "ddl" / "materialized_view.md", +] +DDL_INDEX_PAGES = [ + PROJECT_ROOT / "docs" / "en_US" / "ddl" / "README.md", + PROJECT_ROOT / "docs" / "zh_CN" / "ddl" / "README.md", +] +PROTOCOL_DOC_PAGES = [ + PROJECT_ROOT / "docs" / "en_US" / "postgres_specific_features" / "protocol_support.md", + PROJECT_ROOT / "docs" / "zh_CN" / "postgres_specific_features" / "protocol_support.md", +] +EXAMPLE = ( + PROJECT_ROOT + / "src" / "rhosocial" / "activerecord" / "backend" / "impl" / "postgres" + / "examples" / "ddl" / "materialized_view.py" +) +EXAMPLES_META = ( + PROJECT_ROOT + / "src" / "rhosocial" / "activerecord" / "backend" / "impl" / "postgres" + / "examples" / "conftest.py" +) + + +class TestMaterializedViewDocs: + """T-54: a dedicated page exists and documents every supported feature.""" + + @pytest.mark.parametrize("page", MV_DOC_PAGES, ids=lambda p: p.parent.parent.name) + def test_page_exists(self, page): + assert page.is_file(), f"missing documentation page: {page}" + + @pytest.mark.parametrize("page", MV_DOC_PAGES, ids=lambda p: p.parent.parent.name) + def test_page_documents_supported_features(self, page): + text = page.read_text(encoding="utf-8") + for token in ( + "PostgresCreateMaterializedViewExpression", + "PostgresRefreshMaterializedViewExpression", + "PostgresAlterMaterializedViewExpression", + "PostgresDropMaterializedViewExpression", + "if_not_exists", + "concurrently", + "TABLESPACE", + "storage", + "PostgresStorageParameter", + "list_materialized_views", + "is_populated", + "has_unique_index", + ): + assert token in text, f"{page.name} does not mention {token}" + + @pytest.mark.parametrize( + "page,marker", + [ + (MV_DOC_PAGES[0], "cannot be relocated"), + (MV_DOC_PAGES[1], "无法迁移到其他表空间"), + ], + ids=["en_US", "zh_CN"], + ) + def test_page_documents_alter_boundary(self, page, marker): + """SET TABLESPACE must be documented as unsupported for MVs. + + The code-side boundary is locked by + ``TestAlterMaterializedView::test_set_tablespace_not_offered``. + """ + text = page.read_text(encoding="utf-8") + assert "SET TABLESPACE" in text + assert marker in text + + @pytest.mark.parametrize("page", MV_DOC_PAGES, ids=lambda p: p.parent.parent.name) + def test_page_documents_version_requirements(self, page): + text = page.read_text(encoding="utf-8") + assert "9.3" in text + assert "9.4" in text + + @pytest.mark.parametrize("page", DDL_INDEX_PAGES, ids=lambda p: p.parent.parent.name) + def test_ddl_index_lists_mv_expressions(self, page): + text = page.read_text(encoding="utf-8") + assert "PostgresCreateMaterializedViewExpression" in text + assert "PostgresAlterMaterializedViewExpression" in text + assert "materialized_view.md" in text + + @pytest.mark.parametrize("page", PROTOCOL_DOC_PAGES, ids=lambda p: p.parent.parent.name) + def test_protocol_doc_lists_mv_support(self, page): + text = page.read_text(encoding="utf-8") + assert "PostgresMaterializedViewSupport" in text + assert "materialized_view.md" in text + + +class TestMaterializedViewExample: + """T-55 / T-56: the runnable example exercises the documented features.""" + + def test_example_exists(self): + assert EXAMPLE.is_file() + + def test_example_exercises_new_features(self): + text = EXAMPLE.read_text(encoding="utf-8") + for token in ( + "PostgresCreateMaterializedViewExpression", + "if_not_exists=True", + "PostgresStorageParameter", + "PostgresAlterMaterializedViewExpression", + "PostgresRenameMaterializedViewAction", + "PostgresSetMaterializedViewSchemaAction", + "PostgresSetMaterializedViewPropertiesAction", + "PostgresResetMaterializedViewPropertiesAction", + "PostgresChangeMaterializedViewOwnerAction", + "PostgresDropMaterializedViewExpression", + "list_materialized_views", + ): + assert token in text, f"example does not exercise {token}" + + def test_example_documents_sections(self): + text = EXAMPLE.read_text(encoding="utf-8") + assert "IF NOT EXISTS" in text + assert "ALTER MATERIALIZED VIEW" in text + assert "Introspection" in text + + def test_example_registered_in_metadata(self): + assert EXAMPLE.is_file() + assert "'ddl/materialized_view.py'" in EXAMPLES_META.read_text(encoding="utf-8") diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_introspection.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_introspection.py new file mode 100644 index 00000000..d2ceccc2 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_introspection.py @@ -0,0 +1,157 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_introspection.py +"""PostgreSQL materialized view introspection tests (SQL generation only). + +Covers the introspection half of the regression matrix documented in +``.claude/plan/2026-09-26/materialized-view-coverage.md``: + +- G7: materialized views are discoverable (``pg_matviews``), report whether they + are populated, and expose the UNIQUE index prerequisite for + ``REFRESH ... CONCURRENTLY``. +- G8: sync/async parity for the new introspector methods. +""" + +import pytest + +from rhosocial.activerecord.backend.introspection.types import IntrospectionScope +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.introspection import ( + PostgresMaterializedViewInfoExpression, + PostgresMaterializedViewListExpression, +) +from rhosocial.activerecord.backend.impl.postgres.introspection.introspector import ( + AsyncPostgreSQLIntrospector, + PostgreSQLIntrospectorMixin, + SyncPostgreSQLIntrospector, +) + + +def _dialect(): + return PostgresDialect(version=(15, 0, 0)) + + +class TestMaterializedViewIntrospectionSQL: + """The generated catalog queries must target relkind 'm' via pg_matviews.""" + + def test_list_query_uses_pg_matviews(self): + sql, params = PostgresMaterializedViewListExpression( + _dialect(), schema="reporting" + ).to_sql() + assert "pg_matviews" in sql + assert "ispopulated" in sql + assert "indisunique" in sql + assert params == ("reporting",) + + def test_list_query_reports_unique_index_and_populated_flag(self): + sql, _ = PostgresMaterializedViewListExpression(_dialect()).to_sql() + for column in ( + "is_populated", + "has_unique_index", + "definition", + "comment", + ): + assert f"as {column}" in sql + + def test_list_query_excludes_system_objects_by_default(self): + sql, _ = PostgresMaterializedViewListExpression(_dialect()).to_sql() + # doubled percent: psycopg would treat a bare '%' as a placeholder start + assert "NOT LIKE 'pg_%%'" in sql + + def test_list_query_can_include_system_objects(self): + sql, _ = PostgresMaterializedViewListExpression( + _dialect() + ).include_system(True).to_sql() + assert "NOT LIKE 'pg_%'" not in sql + + def test_info_query_filters_by_name(self): + sql, params = PostgresMaterializedViewInfoExpression( + _dialect(), "sales_summary", schema="reporting" + ).to_sql() + assert "pg_matviews" in sql + assert "m.matviewname = " in sql + assert params == ("reporting", "sales_summary") + + def test_info_query_defaults_to_connection_schema(self): + _, params = PostgresMaterializedViewInfoExpression(_dialect(), "sales_summary").to_sql() + assert params[0] == "public" + + +class TestMaterializedViewParsing: + """Row parsing must expose PostgreSQL specific MV state.""" + + @pytest.fixture + def parser(self): + return PostgreSQLIntrospectorMixin + + def _row(self, **overrides): + row = { + "schema_name": "reporting", + "view_name": "sales_summary", + "definition": " SELECT 1;", + "is_populated": True, + "has_unique_index": True, + "comment": "monthly rollup", + } + row.update(overrides) + return row + + def test_parse_exposes_materialized_state(self, parser): + info = parser._parse_materialized_view(self._row(), "public") + assert info.name == "sales_summary" + assert info.schema == "reporting" + assert info.definition == " SELECT 1;" + assert info.comment == "monthly rollup" + assert info.is_updatable is False + assert info.is_insertable is False + assert info.extra["is_materialized"] is True + assert info.extra["is_populated"] is True + assert info.extra["has_unique_index"] is True + + def test_parse_unpopulated_view(self, parser): + info = parser._parse_materialized_view( + self._row(is_populated=False, has_unique_index=False), "public" + ) + assert info.extra["is_populated"] is False + assert info.extra["has_unique_index"] is False + + def test_parse_falls_back_to_defaults(self, parser): + info = parser._parse_materialized_view({}, "public", "fallback_mv") + assert info.name == "fallback_mv" + assert info.schema == "public" + assert info.extra["is_populated"] is False + assert info.extra["has_unique_index"] is False + + def test_parse_list(self, parser): + infos = parser._parse_materialized_views( + [self._row(), self._row(view_name="sales_daily", is_populated=False)], + "public", + ) + assert [info.name for info in infos] == ["sales_summary", "sales_daily"] + assert [info.extra["is_populated"] for info in infos] == [True, False] + + def test_parse_info_returns_none_when_missing(self, parser): + assert parser._parse_materialized_view_info([], "missing_mv", "public") is None + + +class TestMaterializedViewIntrospectionSurface: + """Sync/async parity and cache-key isolation.""" + + @pytest.mark.parametrize( + "method", ["list_materialized_views", "get_materialized_view_info", "materialized_view_exists"] + ) + def test_sync_and_async_expose_same_methods(self, method): + assert hasattr(SyncPostgreSQLIntrospector, method) + assert hasattr(AsyncPostgreSQLIntrospector, method) + + def test_cache_keys_are_namespaced(self): + """MV lookups must not collide with regular view lookups.""" + introspector = SyncPostgreSQLIntrospector.__new__(SyncPostgreSQLIntrospector) + list_key = introspector._materialized_view_cache_key("public", include_system=False) + single_key = introspector._materialized_view_cache_key( + "public", view_name="sales_summary" + ) + system_key = introspector._materialized_view_cache_key("public", include_system=True) + view_key = introspector._make_cache_key(IntrospectionScope.VIEW, schema="public") + assert list_key != single_key + assert list_key != system_key + assert list_key != view_key + assert "matviews" in list_key diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/conftest.py new file mode 100644 index 00000000..1a24b64b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/conftest.py @@ -0,0 +1,145 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/conftest.py +"""Fixtures for PostgreSQL materialized view integration tests.""" + +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.expression import ( + Column, + FunctionCall, + GroupByHavingClause, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateMaterializedViewExpression, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +DDL = ExecutionOptions(stmt_type=StatementType.DDL) +DQL = ExecutionOptions(stmt_type=StatementType.DQL) + +_SETUP_SQL = [ + "DROP MATERIALIZED VIEW IF EXISTS mv_sales_summary CASCADE", + "DROP MATERIALIZED VIEW IF EXISTS mv_sales_daily CASCADE", + "DROP MATERIALIZED VIEW IF EXISTS mv_reporting.sales_summary CASCADE", + "DROP SCHEMA IF EXISTS mv_reporting CASCADE", + "DROP TABLE IF EXISTS mv_sales CASCADE", + """ + CREATE TABLE mv_sales ( + id SERIAL PRIMARY KEY, + product_id INTEGER NOT NULL, + amount DECIMAL(10,2) NOT NULL, + sale_date DATE NOT NULL + ) + """, + """ + INSERT INTO mv_sales (product_id, amount, sale_date) VALUES + (1, 100.00, '2024-01-01'), + (1, 150.00, '2024-01-02'), + (2, 200.00, '2024-01-01'), + (2, 250.00, '2024-01-03') + """, +] + +_TEARDOWN_SQL = [ + "DROP MATERIALIZED VIEW IF EXISTS mv_sales_summary CASCADE", + "DROP MATERIALIZED VIEW IF EXISTS mv_sales_daily CASCADE", + "DROP MATERIALIZED VIEW IF EXISTS mv_reporting.sales_summary CASCADE", + "DROP SCHEMA IF EXISTS mv_reporting CASCADE", + "DROP TABLE IF EXISTS mv_sales CASCADE", +] + + +@pytest.fixture +def summary_query(mv_backend): + """Aggregation over the mv_sales fixture table.""" + def _build(): + dialect = mv_backend.dialect + return QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "product_id"), + FunctionCall(dialect, "COUNT", Column(dialect, "id")), + FunctionCall(dialect, "SUM", Column(dialect, "amount")), + ], + from_=TableExpression(dialect, "mv_sales"), + group_by_having=GroupByHavingClause( + dialect=dialect, group_by=[Column(dialect, "product_id")] + ), + ) + + return _build + + +@pytest.fixture +def daily_query(mv_backend): + """Per-day aggregation over the mv_sales fixture table.""" + def _build(): + dialect = mv_backend.dialect + return QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "sale_date"), + FunctionCall(dialect, "SUM", Column(dialect, "amount")), + ], + from_=TableExpression(dialect, "mv_sales"), + group_by_having=GroupByHavingClause( + dialect=dialect, group_by=[Column(dialect, "sale_date")] + ), + ) + + return _build + + +@pytest.fixture +def mv_backend(postgres_backend_single): + """Backend with the mv_sales base table populated.""" + backend = postgres_backend_single + for statement in _SETUP_SQL: + backend.execute(statement) + backend.introspector.clear_cache() + yield backend + try: + backend.introspector.clear_cache() + for statement in _TEARDOWN_SQL: + backend.execute(statement) + except Exception: + pass + + +@pytest_asyncio.fixture +async def async_mv_backend(async_postgres_backend_single): + """Async backend with the mv_sales base table populated.""" + backend = async_postgres_backend_single + for statement in _SETUP_SQL: + await backend.execute(statement) + backend.introspector.clear_cache() + yield backend + try: + backend.introspector.clear_cache() + for statement in _TEARDOWN_SQL: + await backend.execute(statement) + except Exception: + pass + + +@pytest.fixture +def create_summary_mv(mv_backend, summary_query): + """Factory that creates a summary materialized view on the live server.""" + + def _create(view_name="mv_sales_summary", query=None, **kwargs): + expression = PostgresCreateMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name=view_name, + query=query if query is not None else summary_query(), + **kwargs, + ) + sql, params = expression.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + return expression + + return _create + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_materialized_view_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_materialized_view_integration.py new file mode 100644 index 00000000..c4ae17bb --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_materialized_view_integration.py @@ -0,0 +1,338 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_materialized_view_integration.py +"""PostgreSQL materialized view integration tests (require a live server). + +Covers the server-behaviour half of the regression matrix documented in +``.claude/plan/2026-09-26/materialized-view-coverage.md``: the SQL-generation unit +tests prove the emitted text, these prove PostgreSQL accepts it and that the +object behaves as documented. +""" + +import pytest + +from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresCreateMaterializedViewExpression, + PostgresDropMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + PostgresRenameMaterializedViewAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) + +from conftest import DDL, DQL + +pytestmark = pytest.mark.usefixtures("mv_backend") + + +def _query(backend, sql): + return backend.execute(sql, options=DQL) + + +def _reloptions(backend, view_name, schema="public"): + """Read pg_class.reloptions straight from the catalog.""" + result = backend.execute( + "SELECT reloptions FROM pg_class c " + "JOIN pg_namespace n ON n.oid = c.relnamespace " + "WHERE c.relname = %s AND n.nspname = %s", + (view_name, schema), + options=DQL, + ) + rows = result.data or [] + return rows[0]["reloptions"] if rows else None + + +class TestCreateMaterializedView: + def test_create_and_query(self, mv_backend, create_summary_mv): + create_summary_mv() + result = _query(mv_backend, "SELECT product_id FROM mv_sales_summary ORDER BY product_id") + assert [row["product_id"] for row in result.data or []] == [1, 2] + + def test_storage_options_applied_on_server(self, mv_backend, create_summary_mv): + """T-42: the server must accept and persist the storage parameters.""" + create_summary_mv( + storage_options={ + PostgresStorageParameter.FILLFACTOR: 70, + PostgresStorageParameter.AUTOVACUUM_ENABLED: "false", + } + ) + options = _reloptions(mv_backend, "mv_sales_summary") + assert options is not None + joined = " ".join(options) + assert "fillfactor=70" in joined + assert "autovacuum_enabled=false" in joined + + def test_if_not_exists_is_idempotent(self, mv_backend, create_summary_mv): + """T-45.""" + create_summary_mv(if_not_exists=True) + create_summary_mv(if_not_exists=True) + result = _query(mv_backend, "SELECT count(*) FROM mv_sales_summary") + assert result.data[0]["count"] == 2 + + def test_with_no_data_is_unqueryable_until_refresh(self, mv_backend, daily_query): + """T-44.""" + expression = PostgresCreateMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="mv_sales_daily", + query=daily_query(), + with_data=False, + ) + sql, params = expression.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + + with pytest.raises(DatabaseError) as exc: + _query(mv_backend, "SELECT count(*) FROM mv_sales_daily") + assert "has not been populated" in str(exc.value) + + refresh = PostgresRefreshMaterializedViewExpression( + dialect=mv_backend.dialect, name="mv_sales_daily", with_data=True + ) + sql, params = refresh.to_sql() + mv_backend.execute(sql, params) + mv_backend.introspector.clear_cache() + result = _query(mv_backend, "SELECT count(*) FROM mv_sales_daily") + assert result.data[0]["count"] == 3 + + +class TestRefreshMaterializedView: + def test_refresh_picks_up_new_rows(self, mv_backend, create_summary_mv): + create_summary_mv() + mv_backend.execute( + "INSERT INTO mv_sales (product_id, amount, sale_date) VALUES (3, 10.00, '2024-02-01')" + ) + refresh = PostgresRefreshMaterializedViewExpression( + dialect=mv_backend.dialect, name="mv_sales_summary" + ) + sql, params = refresh.to_sql() + mv_backend.execute(sql, params) + result = _query(mv_backend, "SELECT count(*) FROM mv_sales_summary") + assert result.data[0]["count"] == 3 + + def test_concurrent_refresh_requires_unique_index(self, mv_backend, create_summary_mv): + """T-43: without a UNIQUE index the server must reject CONCURRENTLY.""" + create_summary_mv() + refresh = PostgresRefreshMaterializedViewExpression( + dialect=mv_backend.dialect, name="mv_sales_summary", concurrently=True + ) + sql, params = refresh.to_sql() + with pytest.raises(DatabaseError) as exc: + mv_backend.execute(sql, params) + assert "unique" in str(exc.value).lower() + + mv_backend.execute( + "CREATE UNIQUE INDEX mv_sales_summary_pk ON mv_sales_summary (product_id)", + options=DDL, + ) + mv_backend.execute(sql, params) + result = _query(mv_backend, "SELECT count(*) FROM mv_sales_summary") + assert result.data[0]["count"] == 2 + + +class TestSchemaQualifiedMaterializedView: + def test_lifecycle_in_non_public_schema(self, mv_backend, create_summary_mv): + """T-46.""" + mv_backend.execute("CREATE SCHEMA IF NOT EXISTS mv_reporting", options=DDL) + create_summary_mv( + view_name="sales_summary", + schema="mv_reporting", + column_aliases=["product_id", "sale_count", "total_amount"], + ) + result = _query(mv_backend, "SELECT product_id FROM mv_reporting.sales_summary ORDER BY product_id") + assert [row["product_id"] for row in result.data or []] == [1, 2] + + alter = PostgresAlterMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="sales_summary", + schema="mv_reporting", + actions=[PostgresSetMaterializedViewSchemaAction(mv_backend.dialect, "public")], + ) + sql, params = alter.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + result = _query(mv_backend, "SELECT product_id FROM public.sales_summary") + assert len(result.data or []) == 2 + + +class TestAlterMaterializedView: + def test_rename(self, mv_backend, create_summary_mv): + """T-47.""" + create_summary_mv() + alter = PostgresAlterMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="mv_sales_summary", + actions=[PostgresRenameMaterializedViewAction(mv_backend.dialect, "mv_sales_renamed")], + ) + sql, params = alter.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + assert mv_backend.introspector.materialized_view_exists("mv_sales_renamed") + assert not mv_backend.introspector.materialized_view_exists("mv_sales_summary") + + def test_set_storage_properties(self, mv_backend, create_summary_mv): + create_summary_mv() + alter = PostgresAlterMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="mv_sales_summary", + actions=[ + PostgresSetMaterializedViewPropertiesAction( + mv_backend.dialect, {PostgresStorageParameter.FILLFACTOR: 85} + ) + ], + ) + sql, params = alter.to_sql() + mv_backend.execute(sql, params, options=DDL) + assert "fillfactor=85" in " ".join(_reloptions(mv_backend, "mv_sales_summary") or []) + + def test_owner_to_is_accepted(self, mv_backend, create_summary_mv): + create_summary_mv() + alter = PostgresAlterMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="mv_sales_summary", + actions=[PostgresChangeMaterializedViewOwnerAction(mv_backend.dialect, "CURRENT_USER")], + ) + sql, params = alter.to_sql() + mv_backend.execute(sql, params, options=DDL) + + +class TestMaterializedViewIntrospection: + def test_list_reports_populated_flag(self, mv_backend, create_summary_mv, daily_query): + """T-48.""" + create_summary_mv() + empty = PostgresCreateMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="mv_sales_daily", + query=daily_query(), + with_data=False, + ) + sql, params = empty.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + + views = {view.name: view for view in mv_backend.introspector.list_materialized_views()} + assert "mv_sales_summary" in views + assert "mv_sales_daily" in views + assert views["mv_sales_summary"].extra["is_populated"] is True + assert views["mv_sales_daily"].extra["is_populated"] is False + assert all(view.extra["is_materialized"] for view in views.values()) + assert all(view.is_updatable is False for view in views.values()) + + def test_list_reports_unique_index(self, mv_backend, create_summary_mv): + """T-49.""" + create_summary_mv() + mv_backend.introspector.clear_cache() + views = {view.name: view for view in mv_backend.introspector.list_materialized_views()} + assert views["mv_sales_summary"].extra["has_unique_index"] is False + + mv_backend.execute( + "CREATE UNIQUE INDEX mv_sales_summary_uidx ON mv_sales_summary (product_id)", + options=DDL, + ) + mv_backend.introspector.clear_cache() + views = {view.name: view for view in mv_backend.introspector.list_materialized_views()} + assert views["mv_sales_summary"].extra["has_unique_index"] is True + + def test_get_info_and_exists(self, mv_backend, create_summary_mv): + create_summary_mv() + mv_backend.introspector.clear_cache() + info = mv_backend.introspector.get_materialized_view_info("mv_sales_summary") + assert info is not None + assert info.schema == "public" + assert info.definition + assert mv_backend.introspector.materialized_view_exists("mv_sales_summary") + assert not mv_backend.introspector.materialized_view_exists("mv_missing") + + def test_regular_views_are_not_listed(self, mv_backend, create_summary_mv): + """MV enumeration must not leak into the regular view listing.""" + create_summary_mv() + mv_backend.execute("CREATE OR REPLACE VIEW mv_plain_view AS SELECT 1 AS one", options=DDL) + mv_backend.introspector.clear_cache() + view_names = {view.name for view in mv_backend.introspector.list_views()} + assert "mv_plain_view" in view_names + assert "mv_sales_summary" not in view_names + + def test_table_list_filters_materialized_view(self, mv_backend, create_summary_mv): + """T-50: relkind 'm' rows are reported and filterable.""" + create_summary_mv() + tables = mv_backend.introspector.list_tables(table_type="MATERIALIZED VIEW") + assert "mv_sales_summary" in {table.name for table in tables} + + def test_drop_cascade(self, mv_backend, create_summary_mv): + create_summary_mv() + drop = PostgresDropMaterializedViewExpression( + dialect=mv_backend.dialect, view_name="mv_sales_summary", if_exists=True, cascade=True + ) + sql, params = drop.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + assert not mv_backend.introspector.materialized_view_exists("mv_sales_summary") + + +class TestMaterializedViewAsyncParity: + """T-52 / T-53: the async backend must behave identically.""" + + async def test_create_refresh_drop(self, async_mv_backend, summary_query): + dialect = async_mv_backend.dialect + create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv_sales_summary", + query=summary_query(), + column_aliases=["product_id", "sale_count", "total_amount"], + ) + sql, params = create.to_sql() + await async_mv_backend.execute(sql, params, options=DDL) + + result = await async_mv_backend.execute( + "SELECT product_id FROM mv_sales_summary ORDER BY product_id", options=DQL + ) + assert [row["product_id"] for row in result.data or []] == [1, 2] + + await async_mv_backend.execute( + "INSERT INTO mv_sales (product_id, amount, sale_date) VALUES (3, 10.00, '2024-02-01')" + ) + refresh = PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="mv_sales_summary" + ) + sql, params = refresh.to_sql() + await async_mv_backend.execute(sql, params) + + result = await async_mv_backend.execute( + "SELECT count(*) FROM mv_sales_summary", options=DQL + ) + assert result.data[0]["count"] == 3 + + drop = PostgresDropMaterializedViewExpression( + dialect=dialect, view_name="mv_sales_summary", if_exists=True, cascade=True + ) + sql, params = drop.to_sql() + await async_mv_backend.execute(sql, params, options=DDL) + + async def test_list_materialized_views(self, async_mv_backend, summary_query): + dialect = async_mv_backend.dialect + create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv_sales_summary", + query=summary_query(), + ) + sql, params = create.to_sql() + await async_mv_backend.execute(sql, params, options=DDL) + async_mv_backend.introspector.clear_cache() + + views = await async_mv_backend.introspector.list_materialized_views() + names = {view.name for view in views} + assert "mv_sales_summary" in names + summary = next(view for view in views if view.name == "mv_sales_summary") + assert summary.extra["is_populated"] is True + assert await async_mv_backend.introspector.materialized_view_exists("mv_sales_summary") + + async def test_teardown_leaves_no_residue(self, async_mv_backend, daily_query): + """T-51: fixture teardown must remove every object it created.""" + dialect = async_mv_backend.dialect + create = PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="mv_sales_daily", query=daily_query(), with_data=False + ) + sql, params = create.to_sql() + await async_mv_backend.execute(sql, params, options=DDL) + assert await async_mv_backend.introspector.materialized_view_exists("mv_sales_daily") From 1bef1e689f7a9397edbf25df63040d866b944b42 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 02:11:51 +0800 Subject: [PATCH 36/42] test(postgres): align materialized view tests with corrected contracts CI (run 36170586225) exposed 4 cases that had encoded the old, incorrect behaviour: - REFRESH MATERIALIZED VIEW identifiers are now quoted per repository convention (the old PG formatter used bare f-string concatenation, the only case that did not quote) - Schema qualification is now quoted per segment: "analytics"."monthly_sales_summary" - The CONCURRENTLY 9.4 version gate now raises UnsupportedFeatureError instead of ValueError - supports_materialized_view() is now version-aware (PG 9.3+); an unadapted dialect cannot answer version questions. The contract matches the existing supports_table_partitioning(), so the cases now pass version explicitly Assertions for the 9.2/9.3/15 version points were added at the same time. --- .../feature/backend/postgres/test_ddl_expressions.py | 8 +++++--- .../feature/backend/test_postgres_ddl_improvements.py | 11 ++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py index c5139cbd..9b285096 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py @@ -63,7 +63,7 @@ def test_basic_refresh(self, dialect): name="monthly_sales_summary", ) sql, params = expr.to_sql() - assert sql == "REFRESH MATERIALIZED VIEW monthly_sales_summary" + assert sql == 'REFRESH MATERIALIZED VIEW "monthly_sales_summary"' assert params == () def test_refresh_with_schema(self, dialect): @@ -74,7 +74,7 @@ def test_refresh_with_schema(self, dialect): schema="analytics", ) sql, params = expr.to_sql() - assert "analytics.monthly_sales_summary" in sql + assert sql == 'REFRESH MATERIALIZED VIEW "analytics"."monthly_sales_summary"' assert params == () def test_refresh_concurrently_pg13(self): @@ -86,8 +86,10 @@ def test_refresh_concurrently_pg13(self): concurrently=True, ) - with pytest.raises(ValueError, match="CONCURRENTLY requires PostgreSQL 9"): + with pytest.raises(UnsupportedFeatureError) as exc: expr.to_sql() + assert "CONCURRENTLY" in str(exc.value) + assert "9.4" in str(exc.value) def test_refresh_concurrently_pg94(self, dialect): """Test CONCURRENTLY refresh with PG 9.4+.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_ddl_improvements.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_ddl_improvements.py index fda7eeb0..f06ed4d7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_ddl_improvements.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_ddl_improvements.py @@ -33,9 +33,14 @@ def test_drop_view_cascade_supported(self): assert dialect.supports_cascade_view() is True def test_materialized_view_supported(self): - """PostgreSQL supports materialized views.""" - dialect = PostgresDialect() - assert dialect.supports_materialized_view() is True + """PostgreSQL supports materialized views (PG 9.3+). + + The probe is version aware, so the dialect must carry a version — + same contract as supports_table_partitioning(). + """ + assert PostgresDialect(version=(9, 3, 0)).supports_materialized_view() is True + assert PostgresDialect(version=(15, 0, 0)).supports_materialized_view() is True + assert PostgresDialect(version=(9, 2, 0)).supports_materialized_view() is False class TestPostgresColumnCapabilityGating: From 72f1c4673dc57159c5062db44a7cedc0783fd4ed Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 12:08:08 +0800 Subject: [PATCH 37/42] docs(postgres): record why SQL/PGQ stays disabled after 19 Beta 4 PostgreSQL 19 Beta 4 withdrew SQL/PGQ before the 19.0 release, and no earlier release shipped it either. Every graph grammar production was pulled from the parser, so CREATE PROPERTY GRAPH, CREATE VERTEX TABLE, CREATE EDGE TABLE, MATCH, GRAPH_TABLE and PROPERTIES are now syntax errors. Verified against the servers in tests/config/postgres_scenarios.yaml. All of the above are rejected with SQLSTATE 42601 on 19beta4, and the supporting catalog objects are gone as well (pg_proc graph/vertex/edge functions 1 -> 0, pg_type graph/agtype types 14 -> 0, pg_class property-graph relations 2 -> 0, and the GRAPH_TABLE/VERTEX/EDGE/PROPERTY parser keywords 4 -> 0 when compared against 19beta3). The same holds for 9.6 through 18, which never had the feature. The generated statements were also executed against a 19beta3 container, which did have it: 8 of 9 were still rejected, so the formatters were never validated against a real server. The capability is therefore resolved from explicit opt-ins rather than from self.version, which is how PostgresPropertyGraphQueryMixin already behaved; this change only documents the reasoning. No expression, formatter or capability behaviour is altered. - Expand the mixin module/class/method docstrings with the catalog evidence and per-probe rationale - Document graph_feature_overrides in PostgresDialect.__init__, and why the mixin must precede GraphMixin/GraphTableMixin in the MRO - Fix the stale path in the formatter test module header, and record in both test modules why the formatter tests drive the dialect through the explicit opt-in instead of relying on the default - Add the beta3/beta4 catalog comparison to the availability doc (en_US, zh_CN) --- .../property_graph_query.md | 31 +++++++++- .../property_graph_query.md | 27 +++++++- .../backend/impl/postgres/dialect.py | 14 ++++- .../postgres/mixins/property_graph_query.py | 62 ++++++++++++++++--- .../test_property_graph_query_format.py | 11 +++- .../test_property_graph_query_scenarios.py | 12 +++- 6 files changed, 139 insertions(+), 18 deletions(-) diff --git a/docs/en_US/postgres_specific_features/property_graph_query.md b/docs/en_US/postgres_specific_features/property_graph_query.md index a5524465..4d46e690 100644 --- a/docs/en_US/postgres_specific_features/property_graph_query.md +++ b/docs/en_US/postgres_specific_features/property_graph_query.md @@ -1,12 +1,37 @@ # SQL/PGQ Availability -PostgreSQL 19 Beta 4 withdrew SQL/PGQ. The PostgreSQL backend therefore keeps +PostgreSQL 19 Beta 4 withdrew SQL/PGQ before the 19.0 release, and no earlier +release shipped it either. The PostgreSQL backend therefore keeps `supports_graph_match()` and `supports_graph_table()` disabled for every server version, including PostgreSQL 19 and later. The expression and formatter plumbing remains available for controlled compatibility testing, but graph formatters fail with `UnsupportedFeatureError` by default. +## Why the capability is version-independent + +Every graph grammar production was pulled from the parser, so `CREATE PROPERTY +GRAPH`, `CREATE VERTEX TABLE`, `CREATE EDGE TABLE`, `MATCH`, `GRAPH_TABLE` and +`PROPERTIES` are now syntax errors. The supporting catalog objects went with +them: + +| Catalog probe | 19beta3 | 19beta4 | +| --- | --- | --- | +| `pg_proc` graph/vertex/edge functions | 1 | 0 | +| `pg_type` graph/agtype types | 14 | 0 | +| `pg_class` property-graph relations | 2 | 0 | +| `GRAPH_TABLE`/`VERTEX`/`EDGE`/`PROPERTY` keywords | 4 | 0 | + +Because no release has ever shipped the feature, support is resolved from +explicit opt-ins rather than from `self.version` — a version number is not +evidence of support, and inferring it would re-advertise a feature that does not +exist. + +Note that these formatters were never validated against a real server: even +against 19beta3, which did have the feature, 8 of the 9 generated statements +were rejected. Treat the opt-in below as a compatibility hook, not as a working +feature. + ## Capability Detection ```python @@ -32,4 +57,6 @@ dialect = PostgresDialect( ``` Only use these overrides when the target server or a dedicated compatibility layer -actually provides the corresponding feature. +actually provides the corresponding feature. `supports_graph_table()` requires +both keys, since `GRAPH_TABLE` wraps a `MATCH` clause. Quantified paths and +comma-separated patterns have no override path and stay off unconditionally. diff --git a/docs/zh_CN/postgres_specific_features/property_graph_query.md b/docs/zh_CN/postgres_specific_features/property_graph_query.md index 0c576e4b..d88c5e8a 100644 --- a/docs/zh_CN/postgres_specific_features/property_graph_query.md +++ b/docs/zh_CN/postgres_specific_features/property_graph_query.md @@ -1,11 +1,32 @@ # SQL/PGQ 可用性 -PostgreSQL 19 Beta 4 已撤回 SQL/PGQ。因此,PostgreSQL 后端对所有服务器版本都默认关闭 -`supports_graph_match()` 和 `supports_graph_table()`,包括 PostgreSQL 19 及更高版本。 +PostgreSQL 19 Beta 4 在 19.0 正式发布前撤回了 SQL/PGQ,更早的版本也从未发布过该特性。 +因此,PostgreSQL 后端对所有服务器版本都默认关闭 `supports_graph_match()` 和 +`supports_graph_table()`,包括 PostgreSQL 19 及更高版本。 表达式和格式化器管线仍保留用于受控兼容性测试,但默认情况下,属性图格式化器会抛出 `UnsupportedFeatureError`。 +## 为何该能力与版本无关 + +所有图相关的语法产生式都已从解析器中移除,`CREATE PROPERTY GRAPH`、 +`CREATE VERTEX TABLE`、`CREATE EDGE TABLE`、`MATCH`、`GRAPH_TABLE` 和 +`PROPERTIES` 现在都是语法错误。相关的系统目录对象也随之消失: + +| 目录探针 | 19beta3 | 19beta4 | +| --- | --- | --- | +| `pg_proc` 中 graph/vertex/edge 函数 | 1 | 0 | +| `pg_type` 中 graph/agtype 类型 | 14 | 0 | +| `pg_class` 中属性图关系 | 2 | 0 | +| `GRAPH_TABLE`/`VERTEX`/`EDGE`/`PROPERTY` 关键字 | 4 | 0 | + +由于从来没有任何版本发布过该特性,支持与否取决于显式 opt-in,而非 `self.version` +—— 版本号不能作为支持与否的证据,据此推断等于重新宣传一个并不存在的特性。 + +请注意,这些格式化器从未针对真实服务器验证过:即使在确实具备该特性的 19beta3 上, +生成的 9 条语句中也有 8 条被拒绝。因此下面的覆盖项应视为兼容性钩子, +而不是可用特性。 + ## 能力检测 ```python @@ -31,3 +52,5 @@ dialect = PostgresDialect( ``` 仅当目标服务器或专用兼容层实际提供相应功能时,才应使用这些覆盖项。 +`supports_graph_table()` 需要同时提供两个键,因为 `GRAPH_TABLE` 包裹着 `MATCH` +子句。量化路径和逗号分隔模式没有覆盖入口,始终保持关闭。 diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 37499848..92d049e8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -350,6 +350,9 @@ class PostgresDialect( ArrayMixin, ExplainMixin, + # Must precede GraphMixin/GraphTableMixin so that the explicit-override + # probes here win the MRO lookup; the core mixins would otherwise answer + # from their own always-False defaults and ignore graph_feature_overrides. PostgresPropertyGraphQueryMixin, GraphMixin, GraphTableMixin, @@ -625,8 +628,15 @@ def __init__( If None, the dialect must be adapted via backend.introspect_and_adapt() before version-dependent features can be used. - graph_feature_overrides: Explicit opt-ins for withdrawn or future - property graph features. + graph_feature_overrides: Explicit opt-ins for SQL/PGQ property graph + features, keyed by ``"graph_match"`` / ``"graph_table"``. + + Left unset, property graph support stays off for every version. + This is deliberate: SQL/PGQ was withdrawn in PostgreSQL 19 Beta 4 + and shipped in no earlier release, so a version check can never + be evidence of support. Pass these only when the target server + (or a compatibility layer in front of it) genuinely provides the + feature. """ super().__init__() 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 index bd29b21b..270863a6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py @@ -1,9 +1,28 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py """PostgreSQL property graph feature gates and formatter safeguards. -PostgreSQL 19 Beta 4 withdrew SQL/PGQ. The formatter remains available only -through explicit feature overrides so a future implementation can opt in -without treating a server version as proof of support. +Why SQL/PGQ is gated off +------------------------ +PostgreSQL 19 Beta 4 withdrew the whole SQL/PGQ (SQL:2023 property graph) +feature before the 19.0 release. Every graph-specific grammar production was +pulled from the parser, so ``CREATE PROPERTY GRAPH``, ``CREATE VERTEX TABLE``, +``CREATE EDGE TABLE``, ``MATCH``, ``GRAPH_TABLE`` and ``PROPERTIES`` are now +plain syntax errors. + +Verified against a live PostgreSQL 19beta4 server: all of the above are rejected +with SQLSTATE 42601, and the supporting catalog objects are gone as well +(``pg_proc`` graph/vertex/edge functions: 0, ``pg_type`` graph/agtype types: 0, +``pg_class`` property-graph relations: 0, and the GRAPH_TABLE/VERTEX/EDGE/ +PROPERTY parser keywords: 0). The same probes against 19beta3 return 1 / 14 / 2 +/ 4 respectively, so the withdrawal is what removed them. + +Because no PostgreSQL release has ever shipped SQL/PGQ, the capability is +resolved from explicit opt-ins rather than from ``self.version``: a version +number is not evidence of support, and inferring support from it would +re-advertise a feature that does not exist. The expression classes and +formatters are intentionally left in place and reachable through +``graph_feature_overrides`` so the plumbing can be exercised and a future +implementation (or a compatibility shim) can opt in without a rewrite. """ from typing import Callable, Mapping, Tuple, TYPE_CHECKING, cast @@ -20,8 +39,17 @@ class PostgresPropertyGraphQueryMixin: - """Fail-closed PostgreSQL property graph capabilities and validation.""" + """Fail-closed PostgreSQL property graph capabilities and validation. + Every probe here answers ``False``. SQL/PGQ was withdrawn in PostgreSQL + 19 Beta 4 and shipped in no earlier release, so there is no server version + for which these capabilities may be reported as supported. See the module + docstring for the catalog evidence behind that decision. + """ + + #: Explicit opt-in keys. SQL/PGQ was withdrawn in PostgreSQL 19 Beta 4, so + #: nothing enables these by default -- they exist only for compatibility + #: testing and for a future opt-in implementation. GRAPH_FEATURE_NAMES = frozenset(("graph_match", "graph_table")) ALTER_ACTIONS = { "add": "ADD", @@ -34,20 +62,38 @@ class PostgresPropertyGraphQueryMixin: } def supports_graph_match(self) -> bool: - """Return the explicit graph MATCH capability override.""" + """Return the explicit graph MATCH capability override. + + Off unless a caller passes ``graph_feature_overrides={"graph_match": + True}``. Deliberately independent of ``self.version``: the MATCH clause + does not exist in any released PostgreSQL, including 19beta4. + """ overrides = getattr(self, "_graph_feature_overrides", {}) or {} return overrides.get("graph_match") is True def supports_quantified_path(self) -> bool: - """Return whether quantified graph paths are explicitly enabled.""" + """Return whether quantified graph paths are explicitly enabled. + + Hard-coded off with no override path. Quantified paths (``+``, ``*``, + ``{n,m}``) were part of the withdrawn SQL/PGQ grammar, and no + PostgreSQL release implements them. + """ return False def supports_comma_separated_patterns(self) -> bool: - """Return whether comma-separated graph patterns are explicitly enabled.""" + """Return whether comma-separated graph patterns are explicitly enabled. + + Hard-coded off with no override path, for the same reason as + :meth:`supports_quantified_path`. + """ return False def supports_graph_table(self) -> bool: - """Return the explicit GRAPH_TABLE capability override.""" + """Return the explicit GRAPH_TABLE capability override. + + Requires both overrides, because ``GRAPH_TABLE`` wraps a MATCH clause + and is meaningless without it. + """ overrides = getattr(self, "_graph_feature_overrides", {}) or {} return ( overrides.get("graph_match") is True 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 6204af62..2d183b96 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 @@ -1,5 +1,12 @@ -# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_pgq_format.py -"""Tests for fail-closed PostgreSQL graph capability and formatter safeguards.""" +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py +"""Tests for fail-closed PostgreSQL graph capability and formatter safeguards. + +SQL/PGQ was withdrawn in PostgreSQL 19 Beta 4 and shipped in no earlier +release, so ``supports_graph_match()`` / ``supports_graph_table()`` must report +False for every version. The formatter tests therefore drive the dialect through +the explicit ``graph_feature_overrides`` opt-in instead of relying on the +default, which keeps them meaningful while the capability stays off. +""" import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError 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 b2e75176..3ea2bb80 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 @@ -1,8 +1,16 @@ # tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py """Capability-gated SQL/PGQ integration scenarios. -The synchronous and asynchronous database tests skip unless the backend explicitly -reports GRAPH_TABLE support. All SQL is constructed through expression classes. +These scenarios need a live server that actually implements SQL/PGQ. That was +true of PostgreSQL 19 Beta 3 and is false from Beta 4 onwards, where the feature +was withdrawn before the 19.0 release, and it was never true before PostgreSQL +19 at all. With the capability off by default the fixtures below skip, so the +whole file is inert against a stock server; it stays here to exercise the +opt-in path once a server (or compatibility layer) provides the feature. + +The synchronous and asynchronous database tests skip unless the backend +explicitly reports GRAPH_TABLE support. All SQL is constructed through +expression classes. """ import pytest import pytest_asyncio From d0040c19154766a417502e6c6fa699577d9a2efd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 10:58:03 +0800 Subject: [PATCH 38/42] fix(backend): repair unresolvable TYPE_CHECKING imports Expression classes moved under backend.expression/ and several were renamed during the derived-DDL refactor, leaving relative-import depths and forward references in TYPE_CHECKING blocks pointing at modules and classes that no longer exist. Type checkers and any runtime evaluation of those blocks fail to resolve them. Verified statically: every TYPE_CHECKING import in the touched files now resolves to an existing module and an existing top-level symbol, and no string annotation references a name absent from the workspace. --- .../activerecord/backend/impl/postgres/mixins/ddl_view.py | 2 +- .../activerecord/backend/impl/postgres/mixins/explain.py | 2 +- .../activerecord/backend/impl/postgres/mixins/truncate.py | 2 +- .../activerecord/backend/impl/postgres/mixins/upsert.py | 2 +- .../activerecord/backend/impl/postgres/protocols/extension.py | 2 +- .../backend/impl/postgres/protocols/materialized_view.py | 2 +- src/rhosocial/activerecord/backend/impl/postgres/transaction.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py index 8ee14c64..3428f2bd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py @@ -4,7 +4,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expression.statements.ddl_view import CreateViewExpression + from ....expression.statements.ddl_view import CreateViewExpression class PostgresViewMixin: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py index d0605175..09e53351 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py @@ -4,7 +4,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expression.statements.explain import ExplainExpression + from ....expression.statements.explain import ExplainExpression class PostgresExplainMixin: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py index cdcf0ca1..99603159 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py @@ -6,7 +6,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if TYPE_CHECKING: - from ...expression.statements.ddl_truncate import TruncateExpression + from ....expression.statements.ddl_truncate import TruncateExpression class PostgresTruncateMixin: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py index 51a1b905..bd73c444 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py @@ -4,7 +4,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expression.statements.dml import OnConflictClause + from ....expression.statements.dml import OnConflictClause class PostgresUpsertMixin: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/extension.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/extension.py index f63f22a1..c46137d3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/extension.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/extension.py @@ -10,7 +10,7 @@ from .base import PostgresExtensionInfo if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements import SQLQueryAndParams + from rhosocial.activerecord.backend.expression.bases import SQLQueryAndParams from ..expression.ddl.extension import PostgresCreateExtensionExpression, PostgresDropExtensionExpression diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py index 3b30a056..ed82eae3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py @@ -8,7 +8,7 @@ from typing import Any, Protocol, runtime_checkable, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expression.ddl import ( + from ..expression.ddl import ( PostgresAlterMaterializedViewExpression, PostgresRefreshMaterializedViewExpression, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py index e0dbf7ae..23a5b41b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from .backend import PostgresBackend - from .async_backend import AsyncPostgresBackend + from .backend.async_backend import AsyncPostgresBackend class PostgresTransactionMixin: From 9f4ff33c21b39b435ef8e3c335ec6bdbb256ae50 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 12:36:01 +0800 Subject: [PATCH 39/42] chore(test): register the pytest markers the suite already uses The suite applies markers that were never declared in [tool.pytest.ini_options] markers. pytest only warns for an unknown marker, so the warning scrolls past unnoticed and a dropped or misspelt registration is silent. Registers the ones in use, with descriptions that say what they do: collection grouping, not ordering. `feature` labels feature-area tests and `benchmark_relation` marks relation benchmarks; neither affects scheduling, so neither substitutes for the xdist_group marker that actually serialises a suite. usefixtures needs no entry and is not added: pytest provides it, so it was a false positive in the isolation audit rather than a missing registration. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 33e8ad0c..c96b3de0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,9 @@ markers = [ "benchmark_sync: marks sync benchmark tests", "benchmark_write: marks write benchmark tests", "benchmark_read: marks read benchmark tests", + "feature: category label for feature-area tests; changes collection grouping only, not ordering", + "benchmark_relation: marks relation benchmarks; changes collection grouping only, not ordering", + ] [tool.coverage.run] From cc825f6b482c508be7c0b6fd121aa9eec9cfad7b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 15:13:55 +0800 Subject: [PATCH 40/42] fix(scripts): stop the pre-commit hook from blocking commits on older revisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installed hook exec'd scripts/preflight.sh unconditionally. That hook is a local, untracked artifact, so it survives a checkout of any commit that predates the script, and on such a commit exec failed with a bare "No such file or directory" — blocking the commit with nothing to act on. In this repository scripts/preflight.sh first appears at ef1a19d, so every earlier commit is affected. The failure is also silent in a dangerous way: it swallowed a `git commit --amend`, so a commit-message rewrite reported as applied when it had not been, and had to be redone. The hook now checks the script is executable and exits 0 otherwise, so a revision without the pre-flight simply skips it. Normal operation is unchanged. Also honours core.hooksPath. git ignores .git/hooks entirely once that is set, so the hook was being written to a location that would never run while this script still reported success. No repository sets it today, so that part is latent. --- scripts/install-git-hooks.sh | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index e06f0cbd..81b3dda5 100644 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -4,21 +4,42 @@ # Installs an optional pre-commit hook that runs scripts/preflight.sh so a # broken import/lint is caught before the commit (and before a CI round-trip). # Bypass a single commit with: git commit --no-verify +# +# Re-run this after pulling, and re-run it from the repository root. To remove +# the hook, delete .git/hooks/pre-commit (or the core.hooksPath directory). set -euo pipefail cd "$(dirname "$0")/.." -HOOK=".git/hooks/pre-commit" -if [ ! -d .git/hooks ]; then - echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 - exit 2 +# git ignores .git/hooks entirely once core.hooksPath is set, so a hook written +# there would never run while this script still reported success. +HOOKS_PATH="$(git config core.hooksPath || true)" +if [ -n "$HOOKS_PATH" ]; then + echo "install-git-hooks: core.hooksPath is set to '$HOOKS_PATH'." >&2 + echo "install-git-hooks: .git/hooks is ignored by git; writing the hook to" >&2 + echo " '$HOOKS_PATH/pre-commit' instead." >&2 + HOOK="$HOOKS_PATH/pre-commit" + mkdir -p "$HOOKS_PATH" +else + HOOK=".git/hooks/pre-commit" + if [ ! -d .git/hooks ]; then + echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 + exit 2 + fi fi cat > "$HOOK" <<'HOOK_BODY' #!/usr/bin/env bash # Installed by scripts/install-git-hooks.sh — runs the local pre-flight. # Skip with: git commit --no-verify -exec "$(git rev-parse --show-toplevel)/scripts/preflight.sh" +# +# The guard below matters on older revisions: this hook is a local, untracked +# artifact, so it survives a checkout of a commit that predates +# scripts/preflight.sh. Without the guard, exec fails with a bare +# "No such file or directory" and blocks the commit with nothing to act on. +PREFLIGHT="$(git rev-parse --show-toplevel)/scripts/preflight.sh" +[ -x "$PREFLIGHT" ] || exit 0 +exec "$PREFLIGHT" HOOK_BODY chmod +x "$HOOK" From 4e9d1f459289d6bcb947f5077129bed5cbafd258 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 15:44:18 +0800 Subject: [PATCH 41/42] ci: install core and testsuite from release branches Both dependencies were pinned to feature/activerecord-derived-ddl, a branch that exists only to carry the derived-DDL work. Now that the core change has landed in release/v1.0.0.dev30 and the matching testsuite change in release/v1.0.0.dev17, pinning to the feature branch means CI keeps resolving a branch that will be deleted once the backend PRs merge. This is a prerequisite for merging: leaving the old ref in place means the first run after that branch disappears fails to resolve, not the code. --- .github/workflows/test.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 10538cf2..b6818502 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,8 +68,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create postgres scenario config run: | @@ -279,8 +279,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create postgres scenario config run: | @@ -391,8 +391,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create postgres scenario config run: | @@ -489,8 +489,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create postgres scenario config run: | From f7dbc3940c72e499dc1a4473ac53e21c4215342b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 17:49:02 +0800 Subject: [PATCH 42/42] chore: add changelog fragment for PR #58 --- changelog.d/58.changed.md | 1 + changelog.d/58.internal.md | 1 + changelog.d/58.removed.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 changelog.d/58.changed.md create mode 100644 changelog.d/58.internal.md create mode 100644 changelog.d/58.removed.md diff --git a/changelog.d/58.changed.md b/changelog.d/58.changed.md new file mode 100644 index 00000000..d98d3d1b --- /dev/null +++ b/changelog.d/58.changed.md @@ -0,0 +1 @@ +Replace the reserved `dialect_options` bag with typed declarations: `PostgresColumnDefinition` and typed column options, `PostgresCreateTableOptions` with `UNLOGGED`, `PostgresPartitionClause` with a partition lifecycle provider, and typed `ALTER ... USING`, `EXCLUDE` constraints and vector indexes. Correct capability reporting for `supports_table_inherits`, `supports_comment_on` and `CommentSupport`, wire `PostgresDatabaseMixin` into the dialect, and fix index-drop rendering for a bare index name without `ON
`. diff --git a/changelog.d/58.internal.md b/changelog.d/58.internal.md new file mode 100644 index 00000000..08961541 --- /dev/null +++ b/changelog.d/58.internal.md @@ -0,0 +1 @@ +Install core and testsuite from release branches in CI instead of the derived-DDL feature branch, repair TYPE_CHECKING imports broken by the expression namespace split, register the pytest markers the suite already uses, and stop the pre-commit hook from blocking commits on revisions that predate `scripts/preflight.sh`. diff --git a/changelog.d/58.removed.md b/changelog.d/58.removed.md new file mode 100644 index 00000000..6ec22551 --- /dev/null +++ b/changelog.d/58.removed.md @@ -0,0 +1 @@ +Remove the reserved `dialect_options` bag from data-type value objects, the `ILIKE` expression, DDL expressions and index expressions, the legacy identity fields on column classes, and the generated DDL lifecycle.