From 16a6bc583c7a6de24d83bac8536979b830a8fd55 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 8 Aug 2026 10:39:40 +0800 Subject: [PATCH 1/5] feat(backend): add coverage-gap DDL expressions for PostgreSQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add expression/mixin/protocol triads covering the remaining PostgreSQL DDL coverage gaps, each version-gated and exposed through a @runtime_checkable Protocol: - RLS table configuration (ENABLE/DISABLE/ALWAYS/FORCE) — PG 9.5+ - ALTER TABLE SET LOGGED/UNLOGGED (9.6+) and SET ACCESS METHOD (15+) - CLUSTER (index re-cluster) — 9.6+ - CREATE/ALTER/DROP DOMAIN (9.6+; ALTER supports SET DEFAULT/RENAME NOT NULL) - CREATE/DROP COLLATION objects (9.6+) - CREATE/DROP FOREIGN TABLE (9.6+) - CREATE/DROP FUNCTION and CREATE/DROP AGGREGATE (9.6+) - CREATE/DROP PUBLICATION / SUBSCRIPTION (10+) PostgresAlterColumnModifierMixin.format_alter_column_action now renders the ALTER COLUMN ... SET DATA TYPE ... USING (expr) conversion (opt-in via dialect_options['using']), raising ValueError when used on non-type ops. PostgreSQL extensions only — core rhosocial-activerecord untouched. Adds pure-SQL unit tests (no DB) for capability switches, protocol conformance, and SQL generation. Closes the plan at .claude/plan/2026-08-07 postgres-coverage-gap-analysis.md. --- changelog.d/53.added.md | 1 + .../backend/impl/postgres/dialect.py | 32 ++ .../impl/postgres/expression/ddl/__init__.py | 75 +++- .../impl/postgres/expression/ddl/cluster.py | 69 ++++ .../impl/postgres/expression/ddl/collation.py | 113 ++++++ .../impl/postgres/expression/ddl/domain.py | 168 +++++++++ .../postgres/expression/ddl/foreign_table.py | 107 ++++++ .../postgres/expression/ddl/publication.py | 174 +++++++++ .../postgres/expression/ddl/rls_config.py | 130 +++++++ .../impl/postgres/expression/ddl/routine.py | 222 ++++++++++++ .../postgres/expression/ddl/table_settings.py | 90 +++++ .../backend/impl/postgres/mixins/__init__.py | 16 + .../impl/postgres/mixins/ddl/__init__.py | 16 + .../impl/postgres/mixins/ddl/cluster.py | 77 ++++ .../impl/postgres/mixins/ddl/collation.py | 130 +++++++ .../impl/postgres/mixins/ddl/column.py | 49 ++- .../impl/postgres/mixins/ddl/domain.py | 156 ++++++++ .../impl/postgres/mixins/ddl/foreign_table.py | 120 +++++++ .../impl/postgres/mixins/ddl/publication.py | 193 ++++++++++ .../impl/postgres/mixins/ddl/rls_config.py | 125 +++++++ .../impl/postgres/mixins/ddl/routine.py | 214 +++++++++++ .../postgres/mixins/ddl/table_settings.py | 109 ++++++ .../impl/postgres/protocols/__init__.py | 16 + .../impl/postgres/protocols/ddl/__init__.py | 16 + .../impl/postgres/protocols/ddl/cluster.py | 45 +++ .../impl/postgres/protocols/ddl/collation.py | 67 ++++ .../impl/postgres/protocols/ddl/domain.py | 84 +++++ .../postgres/protocols/ddl/foreign_table.py | 66 ++++ .../postgres/protocols/ddl/publication.py | 67 ++++ .../impl/postgres/protocols/ddl/rls_config.py | 79 ++++ .../impl/postgres/protocols/ddl/routine.py | 74 ++++ .../postgres/protocols/ddl/table_settings.py | 55 +++ .../backend/postgres/test_ddl_expressions.py | 340 +++++++++++++++++- .../backend/postgres/test_ddl_protocols.py | 161 ++++++++- 34 files changed, 3452 insertions(+), 4 deletions(-) create mode 100644 changelog.d/53.added.md create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/cluster.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/foreign_table.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/publication.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/rls_config.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/routine.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/table_settings.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/cluster.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/collation.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/foreign_table.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/publication.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/rls_config.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/routine.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/table_settings.py diff --git a/changelog.d/53.added.md b/changelog.d/53.added.md new file mode 100644 index 00000000..f731c7ba --- /dev/null +++ b/changelog.d/53.added.md @@ -0,0 +1 @@ +Added PostgreSQL coverage-completion DDL expressions and dialect support. New expression/mixin/protocol triads in the postgres backend package cover: `ALTER TABLE ... ENABLE/DISABLE [ALWAYS] ROW LEVEL SECURITY` and `FORCE/NO FORCE ROW LEVEL SECURITY` (`PostgresAlterTableRlsExpression`, `PostgresForceRlsExpression`; PG 9.5+); `ALTER TABLE ... SET LOGGED/UNLOGGED` (PG 9.6+) and `SET ACCESS METHOD` (PG 15+); `CLUSTER` (PG 9.6+, index re-cluster only); `CREATE/ALTER/DROP DOMAIN` (`PostgresAlterDomainExpression` supports SET DEFAULT / RENAME); `CREATE/DROP COLLATION` objects; `CREATE/DROP FOREIGN TABLE`; `CREATE/DROP FUNCTION` and `CREATE/DROP AGGREGATE` (PG 9.6+); and `CREATE/DROP PUBLICATION` / `CREATE/DROP SUBSCRIPTION` (PG 10+). Additionally `PostgresAlterColumnModifierMixin.format_alter_column_action` now renders PostgreSQL's `ALTER COLUMN ... SET DATA TYPE ... USING (expr)` conversion expression (opt-in via `dialect_options={"using": ...}`; rejected on non-type operations). Each feature has a version-gated capability switch and a matching `@runtime_checkable` Protocol (`PostgresRlsConfigSupport`, `PostgresAlterTableSettingsSupport`, `PostgresClusterSupport`, `PostgresDomainSupport`, `PostgresCollationDDLSupport`, `PostgresForeignTableDDLSupport`, `PostgresRoutineDDLSupport`, `PostgresPublicationSupport`). All are PostgreSQL extensions implemented only in the postgres backend package — the core library is untouched. \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 660f59cc..38ddabb1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -163,6 +163,14 @@ PostgresTypeMixin, PostgresConstraintMixin, PostgresPolicyMixin, + PostgresRlsConfigMixin, + PostgresAlterTableSettingsMixin, + PostgresClusterMixin, + PostgresDomainMixin, + PostgresCollationDDLMixin, + PostgresForeignTableMixin, + PostgresRoutineMixin, + PostgresPublicationMixin, # Type mixins EnumTypeMixin, TypesDataTypeMixin, @@ -254,6 +262,14 @@ PostgresTypeSupport, PostgresConstraintSupport, PostgresPolicySupport, + PostgresRlsConfigSupport, + PostgresAlterTableSettingsSupport, + PostgresClusterSupport, + PostgresDomainSupport, + PostgresCollationDDLSupport, + PostgresForeignTableDDLSupport, + PostgresRoutineDDLSupport, + PostgresPublicationSupport, # Type feature protocols PostgresMultirangeSupport, PostgresEnumTypeSupport, @@ -400,6 +416,14 @@ class PostgresDialect( PostgresTypeMixin, PostgresConstraintMixin, PostgresPolicyMixin, + PostgresRlsConfigMixin, + PostgresAlterTableSettingsMixin, + PostgresClusterMixin, + PostgresDomainMixin, + PostgresCollationDDLMixin, + PostgresForeignTableMixin, + PostgresRoutineMixin, + PostgresPublicationMixin, # Type mixins EnumTypeMixin, TypesDataTypeMixin, @@ -526,6 +550,14 @@ class PostgresDialect( PostgresTypeSupport, PostgresConstraintSupport, PostgresPolicySupport, + PostgresRlsConfigSupport, + PostgresAlterTableSettingsSupport, + PostgresClusterSupport, + PostgresDomainSupport, + PostgresCollationDDLSupport, + PostgresForeignTableDDLSupport, + PostgresRoutineDDLSupport, + PostgresPublicationSupport, # Type feature protocols PostgresMultirangeSupport, PostgresEnumTypeSupport, 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 3127e7a9..ae8a9149 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -12,6 +12,14 @@ - type.py - Enum/Range type expressions - extension.py - Extension DDL expressions - policy.py - Row-Level Security POLICY expressions (CREATE/ALTER/DROP) +- rls_config.py - Row-Level Security table configuration (ENABLE/DISABLE/FORCE) +- table_settings.py - ALTER TABLE SET LOGGED/UNLOGGED/ACCESS METHOD +- cluster.py - CLUSTER expressions +- domain.py - CREATE/ALTER/DROP DOMAIN expressions +- collation.py - CREATE/DROP COLLATION object expressions +- foreign_table.py - CREATE/DROP FOREIGN TABLE expressions +- routine.py - CREATE/DROP FUNCTION / AGGREGATE expressions +- publication.py - CREATE/DROP PUBLICATION / SUBSCRIPTION expressions Missing expressions — why? ============================ @@ -93,6 +101,42 @@ PostgresAlterPolicyExpression, PostgresDropPolicyExpression, ) +from .rls_config import ( + RlsConfigurationMode, + PostgresAlterTableRlsExpression, + PostgresForceRlsExpression, +) +from .table_settings import ( + LoggingMode, + PostgresAlterTableSettingsExpression, +) +from .cluster import PostgresClusterExpression +from .domain import ( + AlterDomainActionType, + PostgresCreateDomainExpression, + PostgresAlterDomainExpression, + PostgresDropDomainExpression, +) +from .collation import ( + PostgresCreateCollationExpression, + PostgresDropCollationExpression, +) +from .foreign_table import ( + PostgresCreateForeignTableExpression, + PostgresDropForeignTableExpression, +) +from .routine import ( + PostgresCreateFunctionExpression, + PostgresDropFunctionExpression, + PostgresCreateAggregateExpression, + PostgresDropAggregateExpression, +) +from .publication import ( + PostgresCreatePublicationExpression, + PostgresDropPublicationExpression, + PostgresCreateSubscriptionExpression, + PostgresDropSubscriptionExpression, +) __all__ = [ # vacuum @@ -133,7 +177,36 @@ "PolicyType", "PolicyCommand", "AlterPolicyMode", - "PostgresCreatePolicyExpression", "PostgresAlterPolicyExpression", "PostgresDropPolicyExpression", + # rls_config + "RlsConfigurationMode", + "PostgresAlterTableRlsExpression", + "PostgresForceRlsExpression", + # table_settings + "LoggingMode", + "PostgresAlterTableSettingsExpression", + # cluster + "PostgresClusterExpression", + # domain + "AlterDomainActionType", + "PostgresCreateDomainExpression", + "PostgresAlterDomainExpression", + "PostgresDropDomainExpression", + # collation + "PostgresCreateCollationExpression", + "PostgresDropCollationExpression", + # foreign_table + "PostgresCreateForeignTableExpression", + "PostgresDropForeignTableExpression", + # routine + "PostgresCreateFunctionExpression", + "PostgresDropFunctionExpression", + "PostgresCreateAggregateExpression", + "PostgresDropAggregateExpression", + # publication + "PostgresCreatePublicationExpression", + "PostgresDropPublicationExpression", + "PostgresCreateSubscriptionExpression", + "PostgresDropSubscriptionExpression", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py new file mode 100644 index 00000000..91b80782 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py @@ -0,0 +1,69 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py +""" +PostgreSQL DDL expressions: CLUSTER. + +PostgreSQL Documentation: +- CLUSTER: https://www.postgresql.org/docs/current/sql-cluster.html + +Version Requirements: +- CLUSTER table [ USING index ]: PostgreSQL 9.6+ (all supported versions) +- VERBOSE option: PostgreSQL 9.6+ (all supported versions) +""" + +from typing import Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["PostgresClusterExpression"] + + +class PostgresClusterExpression(BaseExpression): + """PostgreSQL CLUSTER statement expression. + + Physically reorders a table (or all tables) to match the order of the + index used for clustering. Commonly run inside a transaction after a + bulk load. + + Attributes: + table_name: Name of the table to cluster. If None, clusters all + tables that have previously been clustered (``CLUSTER`` alone). + schema: Optional schema for the table. + using_index: Optional index name for ``CLUSTER table USING index``. + verbose: When True, report the table being clustered (``VERBOSE``). + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> expr = PostgresClusterExpression( + ... dialect, table_name="orders", using_index="orders_pkey" + ... ) + >>> sql, params = expr.to_sql() # doctest: +SKIP + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: Optional[str] = None, + schema: Optional[str] = None, + using_index: Optional[str] = None, + verbose: bool = False, + ): + super().__init__(dialect) + self.table_name = table_name + self.schema = schema + self.using_index = using_index + self.verbose = verbose + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CLUSTER statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_cluster_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py new file mode 100644 index 00000000..5427436c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py @@ -0,0 +1,113 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py +""" +PostgreSQL DDL expressions: COLLATION objects. + +PostgreSQL Documentation: +- CREATE COLLATION: https://www.postgresql.org/docs/current/sql-createcollation.html +- ALTER COLLATION: https://www.postgresql.org/docs/current/sql-altercollation.html +- DROP COLLATION: https://www.postgresql.org/docs/current/sql-dropcollation.html + +Version Requirements: +- CREATE/DROP COLLATION: PostgreSQL 9.6+ (all supported versions) +""" + +from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["PostgresCreateCollationExpression", "PostgresDropCollationExpression"] + + +class PostgresCreateCollationExpression(BaseExpression): + """PostgreSQL CREATE COLLATION statement expression. + + Defines a new collation object, typically from an OS locale. + + Attributes: + name: Name of the collation. + schema: Optional schema for the collation. + locale: Optional ``LOCALE`` (provider-agnostic shorthand). + lc_collate: Optional ``LC_COLLATE``. + lc_ctype: Optional ``LC_CTYPE``. + provider: Optional ``PROVIDER`` (e.g. ``libc`` or ``icu``). + version: Optional ``VERSION``. + if_not_exists: When True, add ``IF NOT EXISTS``. + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + locale: Optional[str] = None, + lc_collate: Optional[str] = None, + lc_ctype: Optional[str] = None, + 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 + self.schema = schema + self.locale = locale + self.lc_collate = lc_collate + self.lc_ctype = lc_ctype + self.provider = provider + self.version = version + self.if_not_exists = if_not_exists + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CREATE COLLATION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_collation_ddl_statement(self) + + +class PostgresDropCollationExpression(BaseExpression): + """PostgreSQL DROP COLLATION statement expression. + + Attributes: + name: Name of the collation to drop. + schema: Optional schema for the collation. + if_exists: When True, add ``IF EXISTS``. + cascade: When True, add ``CASCADE``. + restrict: When True, add ``RESTRICT``. Mutually exclusive with + ``cascade``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP COLLATION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_collation_ddl_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py new file mode 100644 index 00000000..5c155ced --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py @@ -0,0 +1,168 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py +""" +PostgreSQL DDL expressions: DOMAIN operations. + +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 enum import Enum +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "AlterDomainActionType", + "PostgresCreateDomainExpression", + "PostgresAlterDomainExpression", + "PostgresDropDomainExpression", +] + + +class AlterDomainActionType(Enum): + """Actions supported by ALTER DOMAIN (minimal subset).""" + + SET_DEFAULT = "SET DEFAULT" + DROP_DEFAULT = "DROP DEFAULT" + RENAME_TO = "RENAME TO" + + +class PostgresCreateDomainExpression(BaseExpression): + """PostgreSQL CREATE DOMAIN statement expression. + + Creates a new domain — a user-defined data type — over an underlying + PostgreSQL type, often reusing types and default/constraint logic. + + 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 (...)``). + dialect_options: Reserved for extensions. + + """ + + 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, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.data_type = data_type + self.schema = schema + self.collation = collation + self.default = default + self.constraints = constraints or [] + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate CREATE DOMAIN SQL statement. + + Returns: + Tuple of (SQL string, params tuple). + + """ + return self.dialect.format_create_domain_statement(self) + + +class PostgresAlterDomainExpression(BaseExpression): + """PostgreSQL ALTER DOMAIN statement expression (minimal subset). + + 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. + dialect_options: Reserved. + + Returns: + Formatting is delegated to + ``dialect.format_alter_domain_statement``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + action: AlterDomainActionType, + 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 + self.schema = schema + self.action = action + self.new_value = new_value + self.new_name = new_name + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the ALTER DOMAIN SQL statement. + + Returns: + Tuple of (SQL string, params tuple). + + """ + return self.dialect.format_postgres_alter_domain_statement(self) + + +class PostgresDropDomainExpression(BaseExpression): + """PostgreSQL DROP DOMAIN statement expression. + + 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``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the DROP DOMAIN SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_domain_statement(self) \ No newline at end of file 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 new file mode 100644 index 00000000..66558f59 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py @@ -0,0 +1,107 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py +""" +PostgreSQL DDL expressions: FOREIGN TABLE. + +PostgreSQL Documentation: +- CREATE FOREIGN TABLE: https://www.postgresql.org/docs/current/sql-createforeigntable.html +- DROP FOREIGN TABLE: https://www.postgresql.org/docs/current/sql-dropforeigntable.html + +Version Requirements: +- CREATE/DROP FOREIGN TABLE: PostgreSQL 9.6+ +""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["PostgresCreateForeignTableExpression", "PostgresDropForeignTableExpression"] + + +class PostgresCreateForeignTableExpression(BaseExpression): + """PostgreSQL CREATE FOREIGN TABLE statement expression. + + Defines a foreign table backed by a foreign server (FDW). + + Attributes: + name: Name of the foreign table. + schema: Optional schema for the table. + columns: Optional list of column definition strings. + server_name: Name of the foreign server to bind. + options: Optional ``OPTIONS ( option 'value', ... )`` list of + ``"option 'value'"`` strings. + if_not_exists: When True, add ``IF NOT EXISTS``. + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + server_name: str, + schema: Optional[str] = None, + 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 + self.schema = schema + self.server_name = server_name + self.columns = columns or [] + self.options = options or [] + self.if_not_exists = if_not_exists + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CREATE FOREIGN TABLE statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_foreign_table_statement(self) + + +class PostgresDropForeignTableExpression(BaseExpression): + """PostgreSQL DROP FOREIGN TABLE statement expression. + + Attributes: + name: Name of the foreign table to drop. + schema: Optional schema for the foreign table. + if_exists: When True, add ``IF EXISTS``. + cascade: When True, add ``CASCADE``. + restrict: When True, add ``RESTRICT``. Mutually exclusive with + ``cascade``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP FOREIGN TABLE statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_foreign_table_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py new file mode 100644 index 00000000..3ce661a1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py @@ -0,0 +1,174 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py +""" +PostgreSQL DDL expressions: PUBLICATION and SUBSCRIPTION (logical +replication). + +PostgreSQL Documentation: +- CREATE PUBLICATION: https://www.postgresql.org/docs/current/sql-createpublication.html +- DROP PUBLICATION: https://www.postgresql.org/docs/current/sql-droppublication.html +- CREATE SUBSCRIPTION: https://www.postgresql.org/docs/current/sql-createsubscription.html +- DROP SUBSCRIPTION: https://www.postgresql.org/docs/current/sql-dropsubscription.html + +Version Requirements: +- CREATE/DROP PUBLICATION: PostgreSQL 10+ +- CREATE/DROP SUBSCRIPTION: PostgreSQL 10+ +""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresCreatePublicationExpression", + "PostgresDropPublicationExpression", + "PostgresCreateSubscriptionExpression", + "PostgresDropSubscriptionExpression", +] + + +class PostgresCreatePublicationExpression(BaseExpression): + """PostgreSQL CREATE PUBLICATION statement expression. + + Attributes: + name: Name of the publication. + tables: Optional list of table identifiers to publish with + ``FOR TABLE``. Exactly one of ``tables`` or ``all_tables`` + should be given. + all_tables: When True, publish all tables (``FOR ALL TABLES``). + options: Optional list of ``WITH (...)`` options (e.g. ``publish` + strings). + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + 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 {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CREATE PUBLICATION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_publication_statement(self) + + +class PostgresDropPublicationExpression(BaseExpression): + """PostgreSQL DROP PUBLICATION statement expression. + + Attributes: + name: Name of the publication to drop. + if_exists: When True, add ``IF EXISTS``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + cascade: bool = False, + if_exists: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP PUBLICATION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_publication_statement(self) + + +class PostgresCreateSubscriptionExpression(BaseExpression): + """PostgreSQL CREATE SUBSCRIPTION statement expression. + + Attributes: + name: Name of the subscription. + connection: Connection string for ``CONNECTION '...'``. + publications: Publication names to subscribe to. + options: Optional ``WITH (``...)`` creation options. + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + 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 {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CREATE SUBSCRIPTION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_subscription_statement(self) + + +class PostgresDropSubscriptionExpression(BaseExpression): + """PostgreSQL DROP SUBSCRIPTION statement expression. + + Attributes: + name: Name of the subscription to drop. + if_exists: When True, add ``IF EXISTS``. + cascade: When True, add ``CASCADE``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + if_exists: bool = False, + cascade: bool = False, + ): + super().__init__(dialect) + self.name = name + self.if_exists = if_exists + self.cascade = cascade + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP SUBSCRIPTION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_subscription_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py new file mode 100644 index 00000000..92b36c8c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py @@ -0,0 +1,130 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py +""" +PostgreSQL DDL expressions: Row-Level Security configuration. + +PostgreSQL Documentation: +- ALTER TABLE: https://www.postgresql.org/docs/current/sql-altertable.html + +Version Requirements: +- ENABLE / DISABLE ROW LEVEL SECURITY: PostgreSQL 9.5+ +- ENABLE ALWAYS / FORCE / NO FORCE ROW LEVEL SECURITY: PostgreSQL 9.5+ + +Note: These statements are PostgreSQL extensions (non-SQL-standard). They +live in this backend package, together with the POLICY statements that build +the complete Row-Level Security ecosystem. +""" + +from enum import Enum +from typing import Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "RlsConfigurationMode", + "PostgresAlterTableRlsExpression", + "PostgresForceRlsExpression", +] + + +class RlsConfigurationMode(Enum): + """The ``ENABLE``/``DISABLE`` forms of row-level security on a table. + + PostgreSQL maps these onto ``ALTER TABLE ... ENABLE ROW LEVEL SECURITY`` + / ``ALTER TABLE ... DISABLE ROW LEVEL SECURITY``. + """ + + ENABLE = "ENABLE" + DISABLE = "DISABLE" + + +class PostgresAlterTableRlsExpression(BaseExpression): + """PostgreSQL ``ALTER TABLE ... {ENABLE|DISABLE} ROW LEVEL SECURITY``. + + A single expression drives both states through the ``mode`` field. The + ``ENABLE ALWAYS`` variant is a separate keyword on the same statement and + is produced by :attr:`always` (see class :class:`PostgresForceRlsExpression` + for the related ``FORCE`` forms). + + Attributes: + table_name: Name of the target table. + schema: Optional schema for the table. + mode: ``ENABLE`` or ``DISABLE``. + always: If True (and ``mode`` is ENABLE), emit ``ENABLE ALWAYS``. + ``ALWAYS`` has no meaning with ``DISABLE`` and raises an error. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> expr = PostgresAlterTableRlsExpression( + ... dialect, table_name="orders", mode=RlsConfigurationMode.ENABLE + ... ) + >>> sql, params = expr.to_sql() # doctest: +SKIP + + Raises: + ValueError: if ``always`` is True with ``mode == DISABLE``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + mode: RlsConfigurationMode, + schema: Optional[str] = None, + always: bool = False, + ): + super().__init__(dialect) + self.table_name = table_name + self.schema = schema + self.mode = mode + self.always = always + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the ALTER TABLE ... ROW LEVEL SECURITY statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_alter_table_rls_statement(self) + + +class PostgresForceRlsExpression(BaseExpression): + """PostgreSQL ``ALTER TABLE ... {FORCE|NO FORCE} ROW LEVEL SECURITY``. + + ``FORCE ROW LEVEL SECURITY`` causes the table owner to also be subject to + row-level security policies (the default exempts the table owner unless + ``ENABLE ALWAYS``). ``NO FORCE`` reverts to the default behaviour. Both are + PostgreSQL 9.5+. + + Attributes: + table_name: Name of the target table. + schema: Optional schema for the table. + force: When True emit ``FORCE``; when False emit ``NO FORCE``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + force: bool = True, + schema: Optional[str] = None, + ): + super().__init__(dialect) + self.table_name = table_name + self.schema = schema + self.force = force + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the FORCE/NO FORCE ROW LEVEL SECURITY statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_force_rls_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py new file mode 100644 index 00000000..00d41ec1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py @@ -0,0 +1,222 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py +""" +PostgreSQL DDL expressions: FUNCTION and AGGREGATE routines. + +PostgreSQL Documentation: +- CREATE FUNCTION: https://www.postgresql.org/docs/current/sql-createfunction.html +- DROP FUNCTION: https://www.postgresql.org/docs/current/sql-dropfunction.html +- CREATE AGGREGATE: https://www.postgresql.org/docs/current/sql-createaggregate.html +- DROP AGGREGATE: https://www.postgresql.org/docs/current/sql-dropaggregate.html + +Version Requirements: +- CREATE/DROP FUNCTION: PostgreSQL 9.6+ +- CREATE/DROP AGGREGATE: PostgreSQL 9.6+ + +Note: CREATE/DROP PROCEDURE is covered by the existing +``PostgresStoredProcedureMixin``; this module covers FUNCTION and AGGREGATE. +""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresCreateFunctionExpression", + "PostgresDropFunctionExpression", + "PostgresCreateAggregateExpression", + "PostgresDropAggregateExpression", +] + + +class PostgresCreateFunctionExpression(BaseExpression): + """PostgreSQL CREATE FUNCTION statement expression. + + Attributes: + name: Name of the function. + args: Optional list of argument declarations (e.g. ``["a integer"]`` + or ``[]`` for no arguments). + returns: Return type (required for functions that return a value). + language: Procedural language (default ``plpgsql``). + body: Function body (between the ``$$`` dollar-quoting delimiters). + schema: Optional schema for the function. + or_replace: Add ``OR REPLACE``. + security: ``DEFINER`` or ``INVOKER`` (optional). + cost: Estimated execution cost (optional). + rows: Estimated rows returned (optional). + is_strict: When True, add ``STRICT``. + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + return_type: str, + body: str, + schema: Optional[str] = None, + args: Optional[List[str]] = None, + language: str = "plpgsql", + or_replace: bool = False, + security: Optional[str] = None, + cost: Optional[float] = None, + rows: Optional[int] = None, + strict: bool = False, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.return_type = return_type + self.args = args or [] + self.body = body + self.language = language + self.or_replace = or_replace + self.security = security + self.cost = cost + self.rows = rows + self.strict = strict + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CREATE FUNCTION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_function_ddl_statement(self) + + +class PostgresDropFunctionExpression(BaseExpression): + """PostgreSQL DROP FUNCTION statement expression. + + Attributes: + name: Name of the function to drop. + schema: Optional schema for the function. + args: Optional argument type list used for overload resolution + (e.g. ``["integer"]``). + if_exists: When True, add ``IF EXISTS``. + cascade: When True, add ``CASCADE``. + restrict: When True, add ``RESTRICT``. Mutually exclusive with + ``cascade``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + args: Optional[List[str]] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.args = args or [] + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP FUNCTION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_function_ddl_statement(self) + + +class PostgresCreateAggregateExpression(BaseExpression): + """PostgreSQL CREATE AGGREGATE statement expression (minimal). + + Attributes: + name: Name of the aggregate. + sfunc: State transition function name. + stype: State data type. + schema: Optional schema for the aggregate. + finalfunc: Optional final function name. + initcond: Optional initial condition value. + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + sfunc: str, + stype: str, + 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 + self.schema = schema + self.sfunc = sfunc + self.stype = stype + self.finalfunc = finalfunc + self.initcond = initcond + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the CREATE AGGREGATE statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_aggregate_ddl_statement(self) + + +class PostgresDropAggregateExpression(BaseExpression): + """PostgreSQL DROP AGGREGATE statement expression. + + Attributes: + name: Name of the aggregate to drop. + arg_type: Aggregated argument type (needed to identify the + aggregate, e.g. ``integer``). + schema: Optional schema for the aggregate. + if_exists: When True, add ``IF EXISTS``. + cascade: When True, add ``CASCADE``. + restrict: When True, add ``RESTRICT``. Mutually exclusive with + ``cascade``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + arg_type: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.arg_type = arg_type + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP AGGREGATE statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_aggregate_ddl_statement(self) \ No newline at end of file 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 new file mode 100644 index 00000000..a763afdd --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py @@ -0,0 +1,90 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py +""" +PostgreSQL DDL expressions: ALTER TABLE table-level settings. + +PostgreSQL Documentation: +- ALTER TABLE: https://www.postgresql.org/docs/current/sql-altertable.html + +Version Requirements: +- SET LOGGED / SET UNLOGGED: PostgreSQL 9.6+ (exists since 9.5) +- SET ACCESS METHOD: PostgreSQL 15+ +""" + +from enum import Enum +from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "LoggingMode", + "PostgresAlterTableSettingsExpression", +] + + +class LoggingMode(Enum): + """Whether a table is WAL-logged or not. + + Switching between these with ``ALTER TABLE ... SET LOGGED`` / + ``SET UNLOGGED`` is commonly used to prototype with fast UNLOGGED tables + and migrate to LOGGED for production. + """ + + LOGGED = "LOGGED" + UNLOGGED = "UNLOGGED" + + +class PostgresAlterTableSettingsExpression(BaseExpression): + """PostgreSQL ``ALTER TABLE ... SET`` table-level settings. + + Attributes: + table_name: Name of the target table. + schema: Optional schema for the table. + logging_mode: Optional ``LOGGED`` / ``UNLOGGED`` switch + (PostgreSQL 9.6+, exists since 9.5). + access_method: Optional new access method name for ``SET ACCESS + METHOD`` (PostgreSQL 15+). Mutually exclusive with + ``logging_mode``. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> expr = PostgresAlterTableSettingsExpression( + ... dialect, table_name="orders", mode=LoggingMode.UNLOGGED + ... ) + >>> sql, params = expr.to_sql() # doctest: +SKIP + + Raises: + ValueError: if neither ``mode`` nor ``access_method`` is given, or + if both are given. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + 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 {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the ALTER TABLE ... SET statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_alter_table_settings_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index 7c1c4f3c..a43bfe77 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -24,6 +24,14 @@ from .ddl.constraint import PostgresConstraintMixin from .ddl.column import PostgresAlterColumnModifierMixin from .ddl.policy import PostgresPolicyMixin +from .ddl.rls_config import PostgresRlsConfigMixin +from .ddl.table_settings import PostgresAlterTableSettingsMixin +from .ddl.cluster import PostgresClusterMixin +from .ddl.domain import PostgresDomainMixin +from .ddl.collation import PostgresCollationDDLMixin +from .ddl.foreign_table import PostgresForeignTableMixin +from .ddl.routine import PostgresRoutineMixin +from .ddl.publication import PostgresPublicationMixin # DML mixins from .dml.vacuum import PostgresVacuumMixin @@ -120,6 +128,14 @@ "PostgresConstraintMixin", "PostgresAlterColumnModifierMixin", "PostgresPolicyMixin", + "PostgresRlsConfigMixin", + "PostgresAlterTableSettingsMixin", + "PostgresClusterMixin", + "PostgresDomainMixin", + "PostgresCollationDDLMixin", + "PostgresForeignTableMixin", + "PostgresRoutineMixin", + "PostgresPublicationMixin", # DML mixins "PostgresVacuumMixin", "PostgresStoredProcedureMixin", 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 b7e90639..27d7e25d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py @@ -9,6 +9,14 @@ from .constraint import PostgresConstraintMixin from .column import PostgresAlterColumnModifierMixin from .policy import PostgresPolicyMixin +from .rls_config import PostgresRlsConfigMixin +from .table_settings import PostgresAlterTableSettingsMixin +from .cluster import PostgresClusterMixin +from .domain import PostgresDomainMixin +from .collation import PostgresCollationDDLMixin +from .foreign_table import PostgresForeignTableMixin +from .routine import PostgresRoutineMixin +from .publication import PostgresPublicationMixin __all__ = [ "PostgresPartitionMixin", @@ -19,4 +27,12 @@ "PostgresConstraintMixin", "PostgresAlterColumnModifierMixin", "PostgresPolicyMixin", + "PostgresRlsConfigMixin", + "PostgresAlterTableSettingsMixin", + "PostgresClusterMixin", + "PostgresDomainMixin", + "PostgresCollationDDLMixin", + "PostgresForeignTableMixin", + "PostgresRoutineMixin", + "PostgresPublicationMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/cluster.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/cluster.py new file mode 100644 index 00000000..a56e64a7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/cluster.py @@ -0,0 +1,77 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/cluster.py +"""PostgreSQL CLUSTER DDL implementation. + +Implements the CLUSTER statement for the postgres dialect. + +Version Requirements: +- CLUSTER table [ USING index ]: PostgreSQL 9.6+ +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.cluster import PostgresClusterExpression + + +class PostgresClusterMixin: + """PostgreSQL CLUSTER implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_cluster(self) -> bool: + """CLUSTER is supported on all supported PostgreSQL versions.""" + return self.version >= (9, 6, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_cluster_table_ref( + self, schema: Optional[str], table_name: str + ) -> str: + """Format ``table_name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(table_name)}" + ) + return self.format_identifier(table_name) + + # ------------------------------------------------------------------ # + # Statement + # ------------------------------------------------------------------ # + def format_cluster_statement( + self, expr: "PostgresClusterExpression" + ) -> Tuple[str, tuple]: + """Format a CLUSTER statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresClusterExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_cluster(): + raise UnsupportedFeatureError( + self.name, + "CLUSTER", + suggestion="requires PostgreSQL 9.6+", + ) + + parts: List[str] = ["CLUSTER"] + if expr.verbose: + parts.append("VERBOSE") + if expr.table_name: + parts.append( + self._format_cluster_table_ref(expr.schema, expr.table_name) + ) + if expr.using_index: + parts.append("USING") + parts.append(self.format_identifier(expr.using_index)) + return " ".join(parts), () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py new file mode 100644 index 00000000..a6f02356 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py @@ -0,0 +1,130 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py +"""PostgreSQL COLLATION object DDL implementation. + +Implements CREATE/DROP COLLATION DDL formatting for the postgres dialect. +This is distinct from ``PostgresCollationMixin`` (mixins/collation.py), +which handles the ``COLLATE`` expression and nondeterministic-collation +capability detection. + +Version Requirements: +- CREATE/DROP COLLATION: PostgreSQL 9.6+ +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.collation import ( + PostgresCreateCollationExpression, + PostgresDropCollationExpression, + ) + + +class PostgresCollationDDLMixin: + """PostgreSQL COLLATION object DDL implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_collation_ddl(self) -> bool: + """CREATE/DROP COLLATION require PostgreSQL 9.6+.""" + return self.version >= (9, 6, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_collation_ref(self, schema: Optional[str], name: str) -> str: + """Format ``name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(name)}" + ) + return self.format_identifier(name) + + # ------------------------------------------------------------------ # + # CREATE COLLATION + # ------------------------------------------------------------------ # + def format_create_collation_ddl_statement( + self, expr: "PostgresCreateCollationExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE COLLATION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreateCollationExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_collation_ddl(): + raise UnsupportedFeatureError( + self.name, + "CREATE COLLATION", + suggestion="requires PostgreSQL 9.6+", + ) + + parts: List[str] = ["CREATE COLLATION"] + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self._format_collation_ref(expr.schema, expr.name)) + params_list = [ + ("LOCALE", expr.locale), + ("LC_COLLATE", expr.lc_collate), + ("LC_CTYPE", expr.lc_ctype), + ("PROVIDER", expr.provider), + ("VERSION", expr.version), + ] + clauses = [] + for keyword, value in params_list: + if value is not None: + clauses.append(f"{keyword} = {value}") + if clauses: + parts.append("(" + ", ".join(clauses) + ")") + return " ".join(parts), () + if clauses: + parts.append("(" + ", ".join(clauses) + ")") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # DROP COLLATION + # ------------------------------------------------------------------ # + def format_drop_collation_ddl_statement( + self, expr: "PostgresDropCollationExpression" + ) -> Tuple[str, tuple]: + """Format a DROP COLLATION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropCollationExpression`. + + 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_collation_ddl(): + raise UnsupportedFeatureError( + self.name, + "DROP COLLATION", + suggestion="requires PostgreSQL 9.6+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP COLLATION: CASCADE and RESTRICT are mutually exclusive" + ) + parts: List[str] = ["DROP COLLATION"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_collation_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 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 b94cd11b..6c5923de 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py @@ -1,5 +1,10 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py -"""PostgreSQL ALTER TABLE IF [NOT] EXISTS qualifier formatting.""" +"""PostgreSQL ALTER TABLE / ALTER COLUMN formatting extensions. + +Covers PostgreSQL-specific qualifiers for ALTER TABLE actions as well as +the ``ALTER COLUMN ... SET DATA TYPE ... USING`` conversion expression, +which the core ``DDLColumnMixin`` does not emit. +""" from typing import Tuple @@ -50,3 +55,45 @@ def format_drop_table_constraint_action(self, action) -> Tuple[str, tuple]: if getattr(action, "cascade", None): result = f"{result} CASCADE" return result, () + + def format_alter_column_action(self, action) -> Tuple[str, tuple]: + """Format an ``ALTER COLUMN`` action, adding ``USING`` conversion. + + The core ``DDLColumnMixin.format_alter_column_action`` renders the + standard subclauses (SET/DROP DEFAULT, SET/DROP NOT NULL, and + ``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"]``. + + Args: + action: An ``AlterColumn`` action (core ``ddl_alter`` module). + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + ValueError: when ``using`` is supplied for any operation other + than ``SET DATA TYPE`` (the only one PostgreSQL allows it on). + + """ + sql, params = super().format_alter_column_action(action) + dialect_options = getattr(action, "dialect_options", None) or {} + using = dialect_options.get("using") + if using is None: + return sql, params + + op = getattr(action, "operation", None) + op_str = op.value if hasattr(op, "value") else str(op) + if op_str != "SET DATA TYPE": + raise ValueError( + "USING conversion is only valid for ALTER COLUMN SET DATA TYPE" + ) + + using_sql, using_params = using.to_sql() + core, sep, cascade = sql.partition(" CASCADE") + if sep: + modified = f"{core} USING ({using_sql}){sep}{cascade}" + else: + modified = f"{sql} USING ({using_sql})" + return modified, tuple(params) + tuple(using_params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py new file mode 100644 index 00000000..aa5b32e1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py @@ -0,0 +1,156 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py +"""PostgreSQL DOMAIN DDL implementation. + +Implements CREATE/ALTER/DROP DOMAIN formatting for the postgres dialect. + +Version Requirements: +- CREATE/ALTER/DROP DOMAIN: PostgreSQL 9.6+ +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from ...expression.ddl.domain import AlterDomainActionType + +if TYPE_CHECKING: + from ...expression.ddl.domain import ( + PostgresAlterDomainExpression, + PostgresCreateDomainExpression, + PostgresDropDomainExpression, + ) + + +class PostgresDomainMixin: + """PostgreSQL DOMAIN implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_create_domain(self) -> bool: + """CREATE/ALTER/DROP DOMAIN require PostgreSQL 9.6+.""" + 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). + + Args: + expr: :class:`PostgresCreateDomainExpression`. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_create_domain(): + raise UnsupportedFeatureError( + self.name, + "CREATE DOMAIN", + suggestion="requires PostgreSQL 9.6+", + ) + + parts: List[str] = [ + "CREATE DOMAIN", + self._format_domain_ref(expr.schema, expr.name), + "AS", + expr.data_type, + ] + if expr.collation: + parts.append(f"COLLATE {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), () + + # ------------------------------------------------------------------ # + # ALTER DOMAIN + # ------------------------------------------------------------------ # + def format_postgres_alter_domain_statement( + self, expr: "PostgresAlterDomainExpression" + ) -> 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(): + raise UnsupportedFeatureError( + self.name, + "ALTER DOMAIN", + suggestion="requires PostgreSQL 9.6+", + ) + + 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}") + + # ------------------------------------------------------------------ # + # DROP DOMAIN + # ------------------------------------------------------------------ # + def format_drop_domain_statement( + self, expr: "PostgresDropDomainExpression" + ) -> 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(): + 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: + 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 diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/foreign_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/foreign_table.py new file mode 100644 index 00000000..8cecf8ac --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/foreign_table.py @@ -0,0 +1,120 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/foreign_table.py +"""PostgreSQL FOREIGN TABLE DDL implementation. + +Implements CREATE/DROP FOREIGN TABLE formatting for the postgres dialect. + +Version Requirements: +- CREATE/DROP FOREIGN TABLE: PostgreSQL 9.6+ +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.foreign_table import ( + PostgresCreateForeignTableExpression, + PostgresDropForeignTableExpression, + ) + + +class PostgresForeignTableMixin: + """PostgreSQL FOREIGN TABLE implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_foreign_table_ddl(self) -> bool: + """CREATE/DROP FOREIGN TABLE require PostgreSQL 9.6+.""" + return self.version >= (9, 6, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_foreign_table_ref( + self, schema: Optional[str], name: str + ) -> str: + """Format ``name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(name)}" + ) + return self.format_identifier(name) + + # ------------------------------------------------------------------ # + # CREATE FOREIGN TABLE + # ------------------------------------------------------------------ # + def format_create_foreign_table_statement( + self, expr: "PostgresCreateForeignTableExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE FOREIGN TABLE statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreateForeignTableExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_foreign_table_ddl(): + raise UnsupportedFeatureError( + self.name, + "CREATE FOREIGN TABLE", + suggestion="requires PostgreSQL 9.6+", + ) + + parts: List[str] = ["CREATE FOREIGN TABLE"] + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self._format_foreign_table_ref(expr.schema, expr.name)) + if expr.columns: + parts.append("(" + ", ".join(expr.columns) + ")") + parts.append("SERVER") + parts.append(self.format_identifier(expr.server_name)) + if expr.options: + parts.append("OPTIONS (" + ", ".join(expr.options) + ")") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # DROP FOREIGN TABLE + # ------------------------------------------------------------------ # + def format_drop_foreign_table_statement( + self, expr: "PostgresDropForeignTableExpression" + ) -> Tuple[str, tuple]: + """Format a DROP FOREIGN TABLE statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropForeignTableExpression`. + + 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_foreign_table_ddl(): + raise UnsupportedFeatureError( + self.name, + "DROP FOREIGN TABLE", + suggestion="requires PostgreSQL 9.6+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP FOREIGN TABLE: CASCADE and RESTRICT are mutually " + "exclusive" + ) + parts: List[str] = ["DROP FOREIGN TABLE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_foreign_table_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 diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/publication.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/publication.py new file mode 100644 index 00000000..717f7628 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/publication.py @@ -0,0 +1,193 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/publication.py +"""PostgreSQL PUBLICATION / SUBSCRIPTION DDL implementation. + +Implements CREATE/DROP PUBLICATION and CREATE/DROP SUBSCRIPTION for the +postgres dialect (logical replication). + +Version Requirements: +- CREATE/DROP PUBLICATION: PostgreSQL 10+ +- CREATE/DROP SUBSCRIPTION: PostgreSQL 10+ +""" + +from typing import List, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.publication import ( + PostgresCreatePublicationExpression, + PostgresCreateSubscriptionExpression, + PostgresDropPublicationExpression, + PostgresDropSubscriptionExpression, + ) + + +class PostgresPublicationMixin: + """PostgreSQL PUBLICATION / SUBSCRIPTION implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_publication(self) -> bool: + """CREATE/DROP PUBLICATION require PostgreSQL 10+.""" + return self.version >= (10, 0, 0) + + def supports_subscription(self) -> bool: + """CREATE/DROP SUBSCRIPTION require PostgreSQL 10+.""" + return self.version >= (10, 0, 0) + + # ------------------------------------------------------------------ # + # CREATE PUBLICATION + # ------------------------------------------------------------------ # + def format_create_publication_statement( + self, expr: "PostgresCreatePublicationExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE PUBLICATION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreatePublicationExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 10. + ValueError: if both ``tables`` and ``all_tables`` are given, or + neither is. + + """ + if not self.supports_publication(): + raise UnsupportedFeatureError( + self.name, + "CREATE PUBLICATION", + suggestion="requires PostgreSQL 10+", + ) + for_target = expr.tables is not None and len(expr.tables) > 0 + if expr.all_tables and expr.tables: + raise ValueError( + "CREATE PUBLICATION: tables and all_tables are mutually " + "exclusive" + ) + if not expr.all_tables and not for_target and expr.tables is None: + # No table list at all: PostgreSQL requires either FOR TABLE or + # FOR ALL TABLES in modern versions (defaults varied historically). + raise ValueError( + "CREATE PUBLICATION requires either tables or all_tables" + ) + + parts: List[str] = ["CREATE PUBLICATION", self.format_identifier(expr.name)] + if expr.all_tables: + parts.append("FOR ALL TABLES") + elif for_target: + quoted = ", ".join(self.format_identifier(t) for t in expr.tables) + parts.append(f"FOR TABLE {quoted}") + if expr.options: + parts.append("WITH (" + ", ".join(expr.options) + ")") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # DROP PUBLICATION + # ------------------------------------------------------------------ # + def format_drop_publication_statement( + self, expr: "PostgresDropPublicationExpression" + ) -> Tuple[str, tuple]: + """Format a DROP PUBLICATION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropPublicationExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 10. + + """ + if not self.supports_publication(): + raise UnsupportedFeatureError( + self.name, + "DROP PUBLICATION", + suggestion="requires PostgreSQL 10+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP PUBLICATION: CASCADE and RESTRICT are mutually exclusive" + ) + parts: List[str] = ["DROP PUBLICATION"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.name)) + if expr.cascade: + parts.append("CASCADE") + elif expr.restrict: + parts.append("RESTRICT") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # CREATE SUBSCRIPTION + # ------------------------------------------------------------------ # + def format_create_subscription_statement( + self, expr: "PostgresCreateSubscriptionExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE SUBSCRIPTION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreateSubscriptionExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 10. + + """ + if not self.supports_subscription(): + raise UnsupportedFeatureError( + self.name, + "CREATE SUBSCRIPTION", + suggestion="requires PostgreSQL 10+", + ) + pub_list = ", ".join( + self.format_identifier(p) for p in expr.publications + ) + parts: List[str] = [ + "CREATE SUBSCRIPTION", + self.format_identifier(expr.name), + f"CONNECTION '{expr.connection}'", + f"PUBLICATION {pub_list}", + ] + if expr.options: + parts.append("WITH (" + ", ".join(expr.options) + ")") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # DROP SUBSCRIPTION + # ------------------------------------------------------------------ # + def format_drop_subscription_statement( + self, expr: "PostgresDropSubscriptionExpression" + ) -> Tuple[str, tuple]: + """Format a DROP SUBSCRIPTION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropSubscriptionExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 10. + + """ + if not self.supports_subscription(): + raise UnsupportedFeatureError( + self.name, + "DROP SUBSCRIPTION", + suggestion="requires PostgreSQL 10+", + ) + parts: List[str] = ["DROP SUBSCRIPTION"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.name)) + if expr.cascade: + parts.append("CASCADE") + return " ".join(parts), () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/rls_config.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/rls_config.py new file mode 100644 index 00000000..d606a713 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/rls_config.py @@ -0,0 +1,125 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/rls_config.py +"""PostgreSQL Row-Level Security table configuration DDL implementation. + +Implements ``ALTER TABLE ... ENABLE/DISABLE ROW LEVEL SECURITY`` and +``ALTER TABLE ... FORCE / NO FORCE ROW LEVEL SECURITY`` for the postgres +dialect. Together with the ``PostgresPolicyMixin`` (CREATE/ALTER/DROP +POLICY) this forms the complete Row-Level Security ecosystem. + +Version Requirements: +- All four forms: PostgreSQL 9.5+ +""" + +from typing import Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.rls_config import ( + PostgresAlterTableRlsExpression, + PostgresForceRlsExpression, + ) + + +class PostgresRlsConfigMixin: + """PostgreSQL Row-Level Security table configuration implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_rls_enable_disable(self) -> bool: + """``ENABLE/DISABLE ROW LEVEL SECURITY`` requires PostgreSQL 9.5+.""" + return self.version >= (9, 5, 0) + + def supports_rls_force(self) -> bool: + """``FORCE / NO FORCE ROW LEVEL SECURITY`` requires PostgreSQL 9.5+.""" + return self.version >= (9, 5, 0) + + def supports_rls_enable_always(self) -> bool: + """``ENABLE ALWAYS ROW LEVEL SECURITY`` requires PostgreSQL 9.5+.""" + return self.version >= (9, 5, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_rls_table_ref( + self, schema: Optional[str], table_name: str + ) -> str: + """Format ``table_name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(table_name)}" + ) + return self.format_identifier(table_name) + + # ------------------------------------------------------------------ # + # ENABLE / DISABLE + # ------------------------------------------------------------------ # + def format_alter_table_rls_statement( + self, expr: "PostgresAlterTableRlsExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... ENABLE/DISABLE ROW LEVEL SECURITY``. + + Args: + expr: :class:`PostgresAlterTableRlsExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.5. + ValueError: if ``always`` is combined with ``DISABLE``. + + """ + if not self.supports_rls_enable_disable(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE ... ENABLE/DISABLE ROW LEVEL SECURITY", + suggestion="requires PostgreSQL 9.5+", + ) + if expr.always and expr.mode.value == "DISABLE": + raise ValueError( + "ENABLE ALWAYS is only valid with ENABLE ROW LEVEL SECURITY" + ) + + mode = "ENABLE ALWAYS" if expr.always else expr.mode.value + parts = [ + "ALTER TABLE", + self._format_rls_table_ref(expr.schema, expr.table_name), + f"{mode} ROW LEVEL SECURITY", + ] + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # FORCE / NO FORCE + # ------------------------------------------------------------------ # + def format_force_rls_statement( + self, expr: "PostgresForceRlsExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... FORCE/NO FORCE ROW LEVEL SECURITY``. + + Args: + expr: :class:`PostgresForceRlsExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.5. + + """ + if not self.supports_rls_force(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE ... FORCE/NO FORCE ROW LEVEL SECURITY", + suggestion="requires PostgreSQL 9.5+", + ) + mode = "FORCE ROW LEVEL SECURITY" if expr.force else \ + "NO FORCE ROW LEVEL SECURITY" + parts = [ + "ALTER TABLE", + self._format_rls_table_ref(expr.schema, expr.table_name), + mode, + ] + return " ".join(parts), () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/routine.py new file mode 100644 index 00000000..a70b1df3 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/routine.py @@ -0,0 +1,214 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/routine.py +"""PostgreSQL FUNCTION / AGGREGATE DDL implementation. + +Implements CREATE/DROP FUNCTION and CREATE/DROP AGGREGATE formatting for +the postgres dialect. (CREATE/DROP PROCEDURE lives in the existing +``PostgresStoredProcedureMixin``.) + +Version Requirements: +- CREATE/DROP FUNCTION: PostgreSQL 9.6+ +- CREATE/DROP AGGREGATE: PostgreSQL 9.6+ +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.routine import ( + PostgresCreateAggregateExpression, + PostgresCreateFunctionExpression, + PostgresDropAggregateExpression, + PostgresDropFunctionExpression, + ) + + +class PostgresRoutineMixin: + """PostgreSQL FUNCTION / AGGREGATE DDL implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_function_ddl(self) -> bool: + """CREATE/DROP FUNCTION require PostgreSQL 9.6+.""" + return self.version >= (9, 6, 0) + + def supports_aggregate_ddl(self) -> bool: + """CREATE/DROP AGGREGATE require PostgreSQL 9.6+.""" + return self.version >= (9, 6, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_routine_ref(self, schema: Optional[str], name: str) -> str: + """Format ``name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(name)}" + ) + return self.format_identifier(name) + + # ------------------------------------------------------------------ # + # CREATE FUNCTION + # ------------------------------------------------------------------ # + def format_create_function_ddl_statement( + self, expr: "PostgresCreateFunctionExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE FUNCTION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreateFunctionExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_function_ddl(): + raise UnsupportedFeatureError( + self.name, + "CREATE FUNCTION", + suggestion="requires PostgreSQL 9.6+", + ) + + replace = "OR REPLACE " if expr.or_replace else "" + base = ( + f"CREATE {replace}FUNCTION " + f"{self._format_routine_ref(expr.schema, expr.name)}" + ) + args = ", ".join(expr.args) if expr.args else "" + parts: List[str] = [f"{base}({args})"] + parts.append(f"RETURNS {expr.return_type}") + if expr.strict: + parts.append("STRICT") + if expr.security is not None: + security = expr.security.upper() + if security not in ("DEFINER", "INVOKER"): + raise ValueError("security must be 'DEFINER' or 'INVOKER'") + parts.append(f"SECURITY {security}") + if expr.cost is not None: + parts.append(f"COST {expr.cost}") + if expr.rows is not None: + parts.append(f"ROWS {expr.rows}") + parts.append(f"LANGUAGE {expr.language}") + parts.append(f"AS $$ {expr.body} $$") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # DROP FUNCTION + # ------------------------------------------------------------------ # + def format_drop_function_ddl_statement( + self, expr: "PostgresDropFunctionExpression" + ) -> Tuple[str, tuple]: + """Format a DROP FUNCTION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropFunctionExpression`. + + 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_function_ddl(): + raise UnsupportedFeatureError( + self.name, + "DROP FUNCTION", + suggestion="requires PostgreSQL 9.6+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP FUNCTION: CASCADE and RESTRICT are mutually exclusive" + ) + parts: List[str] = ["DROP FUNCTION"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_routine_ref(expr.schema, expr.name)) + if expr.args: + parts.append("(" + ", ".join(expr.args) + ")") + if expr.cascade: + parts.append("CASCADE") + elif expr.restrict: + parts.append("RESTRICT") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # CREATE AGGREGATE + # ------------------------------------------------------------------ # + def format_create_aggregate_ddl_statement( + self, expr: "PostgresCreateAggregateExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE AGGREGATE statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreateAggregateExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_aggregate_ddl(): + raise UnsupportedFeatureError( + self.name, + "CREATE AGGREGATE", + suggestion="requires PostgreSQL 9.6+", + ) + base = ( + f"CREATE AGGREGATE " + f"{self._format_routine_ref(expr.schema, expr.name)}" + ) + options = [f"SFUNC={expr.sfunc}", f"STYPE={expr.stype}"] + if expr.finalfunc: + options.append(f"FINALFUNC={expr.finalfunc}") + if expr.initcond is not None: + options.append(f"INITCOND={expr.initcond}") + return f"{base} ({', '.join(options)})", () + + # ------------------------------------------------------------------ # + # DROP AGGREGATE + # ------------------------------------------------------------------ # + def format_drop_aggregate_ddl_statement( + self, expr: "PostgresDropAggregateExpression" + ) -> Tuple[str, tuple]: + """Format a DROP AGGREGATE statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropAggregateExpression`. + + Returns: + Tuple of (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_aggregate_ddl(): + raise UnsupportedFeatureError( + self.name, + "DROP AGGREGATE", + suggestion="requires PostgreSQL 9.6+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP AGGREGATE: CASCADE and RESTRICT are mutually exclusive" + ) + parts: List[str] = ["DROP AGGREGATE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_routine_ref(expr.schema, expr.name)) + parts.append(f"({expr.arg_type})") + if expr.cascade: + parts.append("CASCADE") + elif expr.restrict: + parts.append("RESTRICT") + return " ".join(parts), () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/table_settings.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/table_settings.py new file mode 100644 index 00000000..29c4277d --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/table_settings.py @@ -0,0 +1,109 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/table_settings.py +"""PostgreSQL ALTER TABLE table-level settings DDL implementation. + +Implements ``ALTER TABLE ... SET LOGGED/UNLOGGED`` and +``ALTER TABLE ... SET ACCESS METHOD ...`` for the postgres dialect. + +Version Requirements: +- SET LOGGED / SET UNLOGGED: PostgreSQL 9.6+ (exists since 9.5) +- SET ACCESS METHOD: PostgreSQL 15+ +""" + +from typing import Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from ...expression.ddl.table_settings import LoggingMode + +if TYPE_CHECKING: + from ...expression.ddl.table_settings import ( + PostgresAlterTableSettingsExpression, + ) + + +class PostgresAlterTableSettingsMixin: + """PostgreSQL ALTER TABLE ... SET table-level settings implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_table_logging_switch(self) -> bool: + """``SET LOGGED/UNLOGGED`` requires PostgreSQL 9.6+ (since 9.5).""" + return self.version >= (9, 6, 0) + + def supports_table_set_access_method(self) -> bool: + """``SET ACCESS METHOD`` requires PostgreSQL 15+.""" + return self.version >= (15, 0, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_settings_table_ref( + self, schema: Optional[str], table_name: str + ) -> str: + """Format ``table_name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(table_name)}" + ) + return self.format_identifier(table_name) + + # ------------------------------------------------------------------ # + # Statement + # ------------------------------------------------------------------ # + def format_alter_table_settings_statement( + self, expr: "PostgresAlterTableSettingsExpression" + ) -> Tuple[str, tuple]: + """Format an ALTER TABLE ... SET table-level settings statement. + + Args: + expr: :class:`PostgresAlterTableSettingsExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + ValueError: if neither ``mode`` nor ``access_method`` is given, + or if both are given. + UnsupportedFeatureError: when a version-gated setting is requested + on an insufficient PostgreSQL version. + + """ + has_logging = expr.mode is not None + has_access = expr.access_method is not None + if not has_logging and not has_access: + raise ValueError( + "ALTER TABLE settings requires exactly one of " + "mode (LOGGED/UNLOGGED) or access_method" + ) + if has_logging and has_access: + raise ValueError( + "ALTER TABLE settings: mode and access_method are mutually " + "exclusive" + ) + + table_ref = self._format_settings_table_ref(expr.schema, expr.table_name) + + if has_logging: + if not self.supports_table_logging_switch(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE SET LOGGED/UNLOGGED", + suggestion="requires PostgreSQL 9.6+", + ) + if expr.mode is LoggingMode.LOGGED: + setting = "SET LOGGED" + else: + setting = "SET UNLOGGED" + else: + if not self.supports_table_set_access_method(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE SET ACCESS METHOD", + suggestion="requires PostgreSQL 15+", + ) + setting = ( + f"SET ACCESS METHOD {self.format_identifier(expr.access_method)}" + ) + + return f"ALTER TABLE {table_ref} {setting}", () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py index 5ad8c906..d844bcdf 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py @@ -22,6 +22,14 @@ from .ddl.type import PostgresTypeSupport from .ddl.constraint import PostgresConstraintSupport from .ddl.policy import PostgresPolicySupport +from .ddl.rls_config import PostgresRlsConfigSupport +from .ddl.table_settings import PostgresAlterTableSettingsSupport +from .ddl.cluster import PostgresClusterSupport +from .ddl.domain import PostgresDomainSupport +from .ddl.collation import PostgresCollationDDLSupport +from .ddl.foreign_table import PostgresForeignTableDDLSupport +from .ddl.routine import PostgresRoutineDDLSupport +from .ddl.publication import PostgresPublicationSupport # DML protocols from .dml.vacuum import PostgresVacuumSupport @@ -113,6 +121,14 @@ "PostgresTypeSupport", "PostgresConstraintSupport", "PostgresPolicySupport", + "PostgresRlsConfigSupport", + "PostgresAlterTableSettingsSupport", + "PostgresClusterSupport", + "PostgresDomainSupport", + "PostgresCollationDDLSupport", + "PostgresForeignTableDDLSupport", + "PostgresRoutineDDLSupport", + "PostgresPublicationSupport", # DML protocols "PostgresVacuumSupport", "PostgresStoredProcedureSupport", 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 e1140341..26f778a5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py @@ -8,6 +8,14 @@ from .type import PostgresTypeSupport from .constraint import PostgresConstraintSupport from .policy import PostgresPolicySupport +from .rls_config import PostgresRlsConfigSupport +from .table_settings import PostgresAlterTableSettingsSupport +from .cluster import PostgresClusterSupport +from .domain import PostgresDomainSupport +from .collation import PostgresCollationDDLSupport +from .foreign_table import PostgresForeignTableDDLSupport +from .routine import PostgresRoutineDDLSupport +from .publication import PostgresPublicationSupport __all__ = [ "PostgresPartitionSupport", @@ -17,4 +25,12 @@ "PostgresTypeSupport", "PostgresConstraintSupport", "PostgresPolicySupport", + "PostgresRlsConfigSupport", + "PostgresAlterTableSettingsSupport", + "PostgresClusterSupport", + "PostgresDomainSupport", + "PostgresCollationDDLSupport", + "PostgresForeignTableDDLSupport", + "PostgresRoutineDDLSupport", + "PostgresPublicationSupport", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/cluster.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/cluster.py new file mode 100644 index 00000000..17e7e22c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/cluster.py @@ -0,0 +1,45 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/cluster.py +"""PostgreSQL CLUSTER protocol definition. + +This module contains the :class:`PostgresClusterSupport` protocol which +defines the interface for PostgreSQL's native CLUSTER command. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.cluster import PostgresClusterExpression + + +@runtime_checkable +class PostgresClusterSupport(Protocol): + """PostgreSQL CLUSTER protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CLUSTER: https://www.postgresql.org/docs/current/sql-cluster.html + + Version Requirements: + - CLUSTER table [ USING index ]: PostgreSQL 9.6+ + """ + + def supports_cluster(self) -> bool: + """Whether CLUSTER is supported (9.6+).""" + ... + + def format_cluster_statement( + self, expr: "PostgresClusterExpression" + ) -> Tuple[str, tuple]: + """Format a ``CLUSTER`` statement. + + Args: + expr: ``PostgresClusterExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.6. + """ + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/collation.py new file mode 100644 index 00000000..29c5efa5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/collation.py @@ -0,0 +1,67 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/collation.py +"""PostgreSQL COLLATION object DDL protocol definition. + +This module contains the :class:`PostgresCollationDDLSupport` protocol. +It is distinct from the ``COLLATE``-expression support provided by the +existing ``PostgresCollationSupport``. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.collation import ( + PostgresCreateCollationExpression, + PostgresDropCollationExpression, + ) + + +@runtime_checkable +class PostgresCollationDDLSupport(Protocol): + """PostgreSQL COLLATION object DDL protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CREATE COLLATION: https://www.postgresql.org/docs/current/sql-createcollation.html + - DROP COLLATION: https://www.postgresql.org/docs/current/sql-dropcollation.html + + Version Requirements: + - CREATE/DROP COLLATION: PostgreSQL 9.6+ + """ + + def supports_collation_ddl(self) -> bool: + """Whether CREATE/DROP COLLATION is supported (9.6+).""" + ... + + def format_create_collation_ddl_statement( + self, expr: "PostgresCreateCollationExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE COLLATION`` statement. + + Args: + expr: ``PostgresCreateCollationExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.6. + """ + ... + + def format_drop_collation_ddl_statement( + self, expr: "PostgresDropCollationExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP COLLATION`` statement. + + Args: + expr: ``PostgresDropCollationExpression``. + + 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/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py new file mode 100644 index 00000000..7a0a64f0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py @@ -0,0 +1,84 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py +"""PostgreSQL DOMAIN protocol definition. + +This module contains the :class:`PostgresDomainSupport` protocol which +defines the interface for PostgreSQL's native CREATE/ALTER/DROP DOMAIN DDL. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.domain import ( + PostgresAlterDomainExpression, + PostgresCreateDomainExpression, + PostgresDropDomainExpression, + ) + + +@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. + """ + ... + + def format_postgres_alter_domain_statement( + self, expr: "PostgresAlterDomainExpression" + ) -> 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/foreign_table.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/foreign_table.py new file mode 100644 index 00000000..d0795947 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/foreign_table.py @@ -0,0 +1,66 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/foreign_table.py +"""PostgreSQL FOREIGN TABLE DDL protocol definition. + +This module contains the :class:`PostgresForeignTableDDLSupport` protocol +which defines the interface for PostgreSQL's native CREATE/DROP FOREIGN TABLE. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.foreign_table import ( + PostgresCreateForeignTableExpression, + PostgresDropForeignTableExpression, + ) + + +@runtime_checkable +class PostgresForeignTableDDLSupport(Protocol): + """PostgreSQL FOREIGN TABLE DDL protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CREATE FOREIGN TABLE: https://www.postgresql.org/docs/current/sql-createforeigntable.html + - DROP FOREIGN TABLE: https://www.postgresql.org/docs/current/sql-dropforeigntable.html + + Version Requirements: + - CREATE/DROP FOREIGN TABLE: PostgreSQL 9.6+ + """ + + def supports_foreign_table_ddl(self) -> bool: + """Whether CREATE/DROP FOREIGN TABLE is supported (9.6+).""" + ... + + def format_create_foreign_table_statement( + self, expr: "PostgresCreateForeignTableExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE FOREIGN TABLE`` statement. + + Args: + expr: ``PostgresCreateForeignTableExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.6. + """ + ... + + def format_drop_foreign_table_statement( + self, expr: "PostgresDropForeignTableExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP FOREIGN TABLE`` statement. + + Args: + expr: ``PostgresDropForeignTableExpression``. + + 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/publication.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/publication.py new file mode 100644 index 00000000..e8925160 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/publication.py @@ -0,0 +1,67 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/publication.py +"""PostgreSQL PUBLICATION / SUBSCRIPTION protocol definition. + +This module contains the :class:`PostgresPublicationSupport` protocol which +defines the interface for PostgreSQL's logical replication PUBLICATION and +SUBSCRIPTION DDL commands. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.publication import ( + PostgresCreatePublicationExpression, + PostgresCreateSubscriptionExpression, + PostgresDropPublicationExpression, + PostgresDropSubscriptionExpression, + ) + + +@runtime_checkable +class PostgresPublicationSupport(Protocol): + """PostgreSQL PUBLICATION / SUBSCRIPTION protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CREATE PUBLICATION: https://www.postgresql.org/docs/current/sql-createpublication.html + - DROP PUBLICATION: https://www.postgresql.org/docs/current/sql-droppublication.html + - CREATE SUBSCRIPTION: https://www.postgresql.org/docs/current/sql-createsubscription.html + - DROP SUBSCRIPTION: https://www.postgresql.org/docs/current/sql-dropsubscription.html + + Version Requirements: + - CREATE/DROP PUBLICATION: PostgreSQL 10+ + - CREATE/DROP SUBSCRIPTION: PostgreSQL 10+ + """ + + def supports_publication(self) -> bool: + """Whether CREATE/DROP PUBLICATION is supported (10+).""" + ... + + def supports_subscription(self) -> bool: + """Whether CREATE/DROP SUBSCRIPTION is supported (10+).""" + ... + + def format_create_publication_statement( + self, expr: "PostgresCreatePublicationExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE PUBLICATION`` statement.""" + ... + + def format_drop_publication_statement( + self, expr: "PostgresDropPublicationExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP PUBLICATION`` statement.""" + ... + + def format_create_subscription_statement( + self, expr: "PostgresCreateSubscriptionExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE SUBSCRIPTION`` statement.""" + ... + + def format_drop_subscription_statement( + self, expr: "PostgresDropSubscriptionExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP SUBSCRIPTION`` statement.""" + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/rls_config.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/rls_config.py new file mode 100644 index 00000000..037ed901 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/rls_config.py @@ -0,0 +1,79 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/rls_config.py +"""PostgreSQL Row-Level Security table configuration protocol definition. + +This module contains the :class:`PostgresRlsConfigSupport` protocol which +defines the interface for PostgreSQL's native ALTER TABLE ... ROW LEVEL +SECURITY configuration commands. + +ENABLE/DISABLE/FORCE/NO FORCE ROW LEVEL SECURITY is a PostgreSQL extension — +not SQL standard. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.rls_config import ( + PostgresAlterTableRlsExpression, + PostgresForceRlsExpression, + ) + + +@runtime_checkable +class PostgresRlsConfigSupport(Protocol): + """PostgreSQL Row-Level Security table configuration protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - ALTER TABLE: https://www.postgresql.org/docs/current/sql-altertable.html + + Version Requirements: + - ENABLE / DISABLE ROW LEVEL SECURITY: PostgreSQL 9.5+ + - ENABLE ALWAYS ROW LEVEL SECURITY: PostgreSQL 9.5+ + - FORCE / NO FORCE ROW LEVEL SECURITY: PostgreSQL 9.5+ + """ + + def supports_rls_enable_disable(self) -> bool: + """Whether ``ENABLE/DISABLE ROW LEVEL SECURITY`` is supported (9.5+).""" + ... + + def supports_rls_force(self) -> bool: + """Whether ``FORCE/NO FORCE ROW LEVEL SECURITY`` is supported (9.5+).""" + ... + + def supports_rls_enable_always(self) -> bool: + """Whether ``ENABLE ALWAYS ROW LEVEL SECURITY`` is supported (9.5+).""" + ... + + def format_alter_table_rls_statement( + self, expr: "PostgresAlterTableRlsExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... ENABLE/DISABLE ROW LEVEL SECURITY``. + + Args: + expr: ``PostgresAlterTableRlsExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.5. + ValueError: ``always`` combined with ``DISABLE``. + """ + ... + + def format_force_rls_statement( + self, expr: "PostgresForceRlsExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... FORCE/NO FORCE ROW LEVEL SECURITY``. + + Args: + expr: ``PostgresForceRlsExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.5. + """ + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/routine.py new file mode 100644 index 00000000..b36b395b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/routine.py @@ -0,0 +1,74 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/routine.py +"""PostgreSQL FUNCTION / AGGREGATE routine DDL protocol definition. + +This module contains the :class:`PostgresRoutineDDLSupport` protocol. +CREATE/DROP PROCEDURE is covered by the existing +``PostgresStoredProcedureSupport``. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.routine import ( + PostgresCreateAggregateExpression, + PostgresCreateFunctionExpression, + PostgresDropAggregateExpression, + PostgresDropFunctionExpression, + ) + + +@runtime_checkable +class PostgresRoutineDDLSupport(Protocol): + """PostgreSQL FUNCTION / AGGREGATE routine DDL protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CREATE FUNCTION: https://www.postgresql.org/docs/current/sql-createfunction.html + - DROP FUNCTION: https://www.postgresql.org/docs/current/sql-dropfunction.html + - CREATE AGGREGATE: https://www.postgresql.org/docs/current/sql-createaggregate.html + - DROP AGGREGATE: https://www.postgresql.org/docs/current/sql-dropaggregate.html + + Version Requirements: + - CREATE/DROP FUNCTION: PostgreSQL 9.6+ + - CREATE/DROP AGGREGATE: PostgreSQL 9.6+ + """ + + def supports_function_ddl(self) -> bool: + """Whether CREATE/DROP FUNCTION is supported (9.6+).""" + ... + + def supports_aggregate_ddl(self) -> bool: + """Whether CREATE/DROP AGGREGATE is supported (9.6+).""" + ... + + def format_create_function_ddl_statement( + self, expr: "PostgresCreateFunctionExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE FUNCTION`` statement. + + Args: + expr: ``PostgresCreateFunctionExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + ... + + def format_drop_function_ddl_statement( + self, expr: "PostgresDropFunctionExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP FUNCTION`` statement.""" + ... + + def format_create_aggregate_ddl_statement( + self, expr: "PostgresCreateAggregateExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE AGGREGATE`` statement.""" + ... + + def format_drop_aggregate_ddl_statement( + self, expr: "PostgresDropAggregateExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP AGGREGATE`` statement.""" + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/table_settings.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/table_settings.py new file mode 100644 index 00000000..6114b452 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/table_settings.py @@ -0,0 +1,55 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/table_settings.py +"""PostgreSQL ALTER TABLE table-level settings protocol definition. + +This module contains the :class:`PostgresAlterTableSettingsSupport` protocol +which defines the interface for PostgreSQL's native ALTER TABLE ... SET +table-level settings (LOGGED/UNLOGGED, ACCESS METHOD). +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.table_settings import ( + PostgresAlterTableSettingsExpression, + ) + + +@runtime_checkable +class PostgresAlterTableSettingsSupport(Protocol): + """PostgreSQL ALTER TABLE ... SET table-level settings protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - ALTER TABLE: https://www.postgresql.org/docs/current/sql-altertable.html + + Version Requirements: + - SET LOGGED / SET UNLOGGED: PostgreSQL 9.6+ (exists since 9.5) + - SET ACCESS METHOD: PostgreSQL 15+ + """ + + def supports_table_logging_switch(self) -> bool: + """Whether ``SET LOGGED/UNLOGGED`` is supported (9.6+).""" + ... + + def supports_table_set_access_method(self) -> bool: + """Whether ``SET ACCESS METHOD`` is supported (15+).""" + ... + + def format_alter_table_settings_statement( + self, expr: "PostgresAlterTableSettingsExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... SET`` table-level settings. + + Args: + expr: ``PostgresAlterTableSettingsExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + ValueError: no setting or conflicting settings supplied. + UnsupportedFeatureError: version-gated setting on an + insufficient PostgreSQL version. + """ + ... \ No newline at end of file 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 e304adc8..d7cb72a3 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,14 +9,18 @@ import pytest -from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression import Column, Literal from rhosocial.activerecord.backend.expression.statements import ( + AlterColumn, ColumnDefinition, CreateTableExpression, PartitionClause, PartitionStrategy, ) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, +) from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PartitionValue, PostgresRefreshMaterializedViewExpression, @@ -31,6 +35,28 @@ PostgresPgPartmanUpdateConfigExpression, PostgresVacuumExpression, # noqa: F401 PostgresAnalyzeExpression, # noqa: F401 + LoggingMode, + RlsConfigurationMode, + AlterDomainActionType, + PostgresAlterTableRlsExpression, + PostgresForceRlsExpression, + PostgresAlterTableSettingsExpression, + PostgresClusterExpression, + PostgresCreateDomainExpression, + PostgresAlterDomainExpression, + PostgresDropDomainExpression, + PostgresCreateCollationExpression, + PostgresDropCollationExpression, + PostgresCreateForeignTableExpression, + PostgresDropForeignTableExpression, + PostgresCreateFunctionExpression, + PostgresDropFunctionExpression, + PostgresCreateAggregateExpression, + PostgresDropAggregateExpression, + PostgresCreatePublicationExpression, + PostgresDropPublicationExpression, + PostgresCreateSubscriptionExpression, + PostgresDropSubscriptionExpression, ) from rhosocial.activerecord.backend.expression.types import ( BigIntType, TextType, TimestampType, @@ -951,3 +977,315 @@ def test_run_maintenance_global_expression(self, dialect): sql, params = expr.to_sql() assert sql == 'SELECT "partman"."run_maintenance"()' assert params == () + + +class TestPostgresRlsConfigExpression: + """Test RLS enable/disable/always/force DDL expressions.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_enable(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.ENABLE + ).to_sql() + assert sql == 'ALTER TABLE "orders" ENABLE ROW LEVEL SECURITY' + + def test_disable(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.DISABLE + ).to_sql() + assert sql == 'ALTER TABLE "orders" DISABLE ROW LEVEL SECURITY' + + def test_enable_always(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.ENABLE, always=True + ).to_sql() + assert sql == 'ALTER TABLE "orders" ENABLE ALWAYS ROW LEVEL SECURITY' + + def test_schema_qualify(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.ENABLE, schema="app" + ).to_sql() + assert sql == 'ALTER TABLE "app"."orders" ENABLE ROW LEVEL SECURITY' + + def test_force(self, dialect): + sql, _ = PostgresForceRlsExpression(dialect, "orders").to_sql() + assert sql == 'ALTER TABLE "orders" FORCE ROW LEVEL SECURITY' + + def test_no_force(self, dialect): + sql, _ = PostgresForceRlsExpression(dialect, "orders", force=False).to_sql() + assert sql == 'ALTER TABLE "orders" NO FORCE ROW LEVEL SECURITY' + + def test_version_gate_94(self): + d = PostgresDialect(version=(9, 4, 0)) + expr = PostgresAlterTableRlsExpression(d, "orders", RlsConfigurationMode.ENABLE) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + +class TestPostgresAlterTableSettingsExpression: + """SET LOGGED / UNLOGGED / ACCESS METHOD DDL.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(15, 0, 0)) + + def test_unlogged(self, dialect): + sql, _ = PostgresAlterTableSettingsExpression( + dialect, "t", mode=LoggingMode.UNLOGGED + ).to_sql() + assert sql == 'ALTER TABLE "t" SET UNLOGGED' + + def test_logged_schema(self, dialect): + sql, _ = PostgresAlterTableSettingsExpression( + dialect, "t", schema="s", mode=LoggingMode.LOGGED + ).to_sql() + assert sql == 'ALTER TABLE "s"."t" SET LOGGED' + + def test_access_method(self, dialect): + sql, _ = PostgresAlterTableSettingsExpression( + dialect, "t", access_method="heap" + ).to_sql() + assert sql == 'ALTER TABLE "t" SET ACCESS METHOD "heap"' + + def test_access_method_requires_15(self): + d = PostgresDialect(version=(14, 0, 0)) + expr = PostgresAlterTableSettingsExpression(d, "t", access_method="heap") + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_no_clause_raises(self, dialect): + expr = PostgresAlterTableSettingsExpression(dialect, "t") + with pytest.raises(ValueError): + expr.to_sql() + + +class TestPostgresClusterExpression: + """CLUSTER DDL.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_full(self, dialect): + sql, _ = PostgresClusterExpression( + dialect, "orders", schema="public", + using_index="orders_pkey", verbose=True, + ).to_sql() + assert sql == 'CLUSTER VERBOSE "public"."orders" USING "orders_pkey"' + + def test_bare(self, dialect): + sql, _ = PostgresClusterExpression(dialect, verbose=True).to_sql() + assert sql == "CLUSTER VERBOSE" + + def test_version_gate_95(self): + d = PostgresDialect(version=(9, 5, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresClusterExpression(d, "t").to_sql() + + +class TestPostgresDomainExpression: + """CREATE / ALTER / DROP DOMAIN.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create(self, dialect): + sql, _ = PostgresCreateDomainExpression( + dialect, "posint", "NUMERIC", + default="0", constraints=["CHECK (VALUE > 0)"], + ).to_sql() + assert sql == "CREATE DOMAIN \"posint\" AS NUMERIC DEFAULT 0 CHECK (VALUE > 0)" + + def test_alter_set_default(self, dialect): + sql, _ = PostgresAlterDomainExpression( + dialect, "posint", AlterDomainActionType.SET_DEFAULT, new_value="0" + ).to_sql() + assert sql == 'ALTER DOMAIN "posint" SET DEFAULT 0' + + def test_alter_rename(self, dialect): + sql, _ = PostgresAlterDomainExpression( + dialect, "posint", AlterDomainActionType.RENAME_TO, new_name="posint2" + ).to_sql() + assert sql == 'ALTER DOMAIN "posint" RENAME TO "posint2"' + + def test_drop(self, dialect): + sql, _ = PostgresDropDomainExpression( + dialect, "posint", if_exists=True, cascade=True + ).to_sql() + assert sql == 'DROP DOMAIN IF EXISTS "posint" CASCADE' + + +class TestPostgresCollationDDLExpression: + """CREATE / DROP COLLATION DDL.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create(self, dialect): + sql, _ = PostgresCreateCollationExpression( + dialect, "enloc", locale="en_US.UTF-8", provider="libc" + ).to_sql() + assert sql == 'CREATE COLLATION "enloc" (LOCALE = en_US.UTF-8, PROVIDER = libc)' + + def test_create_if_not_exists(self, dialect): + sql, _ = PostgresCreateCollationExpression( + dialect, "myloc", if_not_exists=True, lc_collate="en_US", + ).to_sql() + assert sql == 'CREATE COLLATION IF NOT EXISTS "myloc" (LC_COLLATE = en_US)' + + def test_drop(self, dialect): + sql, _ = PostgresDropCollationExpression( + dialect, "enloc", if_exists=True + ).to_sql() + assert sql == 'DROP COLLATION IF EXISTS "enloc"' + + +class TestPostgresForeignTableDDLExpression: + """CREATE / DROP FOREIGN TABLE.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create(self, dialect): + sql, _ = PostgresCreateForeignTableExpression( + dialect, "ft", "srv", columns=["a integer", "b text"], + options=["host 'h'"], + ).to_sql() + assert sql == ( + 'CREATE FOREIGN TABLE "ft" (a integer, b text) ' + 'SERVER "srv" OPTIONS (host \'h\')' + ) + + def test_drop(self, dialect): + sql, _ = PostgresDropForeignTableExpression( + dialect, "ft", if_exists=True, cascade=True + ).to_sql() + assert sql == 'DROP FOREIGN TABLE IF EXISTS "ft" CASCADE' + + +class TestPostgresRoutineDDLExpression: + """CREATE / DROP FUNCTION and AGGREGATE.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create_function(self, dialect): + sql, _ = PostgresCreateFunctionExpression( + dialect, "add", "integer", "int 1;", + args=["a integer"], strict=True, security="DEFINER", + ).to_sql() + assert sql == ( + 'CREATE FUNCTION "add"(a integer) RETURNS integer ' + 'STRICT SECURITY DEFINER LANGUAGE plpgsql AS $$ int 1; $$' + ) + + def test_drop_function(self, dialect): + sql, _ = PostgresDropFunctionExpression( + dialect, "add", args=["integer"], cascade=True + ).to_sql() + assert sql == 'DROP FUNCTION "add" (integer) CASCADE' + + def test_create_aggregate(self, dialect): + sql, _ = PostgresCreateAggregateExpression( + dialect, "mysum", "sum", "integer", initcond="0" + ).to_sql() + assert sql == 'CREATE AGGREGATE "mysum" (SFUNC=sum, STYPE=integer, INITCOND=0)' + + def test_drop_aggregate(self, dialect): + sql, _ = PostgresDropAggregateExpression( + dialect, "mysum", "integer", if_exists=True + ).to_sql() + assert sql == 'DROP AGGREGATE IF EXISTS "mysum" (integer)' + + +class TestPostgresPublicationExpression: + """CREATE / DROP PUBLICATION and SUBSCRIPTION.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create_publication_tables(self, dialect): + sql, _ = PostgresCreatePublicationExpression( + dialect, "pub1", tables=["orders", "users"] + ).to_sql() + assert sql == 'CREATE PUBLICATION "pub1" FOR TABLE "orders", "users"' + + def test_create_publication_all_tables(self, dialect): + sql, _ = PostgresCreatePublicationExpression( + dialect, "pub_all", all_tables=True, + options=["publish='insert'"], + ).to_sql() + assert sql == ( + 'CREATE PUBLICATION "pub_all" FOR ALL TABLES WITH (publish=\'insert\')' + ) + + def test_drop_publication(self, dialect): + sql, _ = PostgresDropPublicationExpression( + dialect, "pub1", if_exists=True + ).to_sql() + assert sql == 'DROP PUBLICATION IF EXISTS "pub1"' + + def test_create_subscription(self, dialect): + sql, _ = PostgresCreateSubscriptionExpression( + dialect, "sub1", "host=db port=5432", ["pub1"] + ).to_sql() + assert sql == ( + 'CREATE SUBSCRIPTION "sub1" CONNECTION \'host=db port=5432\' ' + 'PUBLICATION "pub1"' + ) + + def test_drop_subscription(self, dialect): + sql, _ = PostgresDropSubscriptionExpression(dialect, "sub1", cascade=True).to_sql() + assert sql == 'DROP SUBSCRIPTION "sub1" CASCADE' + + +class TestPostgresAlterColumnUsingExpression: + """ALTER COLUMN ... SET DATA TYPE ... USING.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_using_clause(self, dialect): + action = AlterColumn( + dialect, + "price", + "SET DATA TYPE", + new_value="NUMERIC(10,2)", + dialect_options={"using": Column(dialect, "price") + Literal(dialect, 1)}, + ) + sql, serialized = dialect.format_alter_column_action(action) + assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql + assert 'USING ("price" + %s)' in sql + assert serialized == (1,) + + def test_using_rejected_for_non_set_data_type(self, dialect): + """USING is only valid on SET DATA TYPE.""" + action = AlterColumn( + dialect, + "price", + "SET DEFAULT", + new_value="0", + dialect_options={"using": Column(dialect, "price")}, + ) + with pytest.raises(ValueError, match="USING"): + dialect.format_alter_column_action(action) + + def test_without_using_unaffected(self, dialect): + """No USING -> output matches the standard form.""" + action = AlterColumn( + dialect, + "price", + "SET DATA TYPE", + new_value="NUMERIC(10,2)", + ) + sql, _ = dialect.format_alter_column_action(action) + assert sql == 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' 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 6ed571df..e659c264 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 @@ -415,4 +415,163 @@ def test_postgres_policy_support_rejected_on_unsupported_version(self): # presence, not version gates), but capability report is False: assert dialect.supports_create_policy() is False # The Protocol *presence* is still satisfied (the methods exist): - assert isinstance(dialect, PostgresPolicySupport) \ No newline at end of file + assert isinstance(dialect, PostgresPolicySupport) + + +class TestPostgresRlsConfigFeatureDetection: + """Test PostgresRlsConfigSupport feature detection methods.""" + + def test_rls_enable_disable_pg94(self): + dialect = PostgresDialect(version=(9, 4, 0)) + assert dialect.supports_rls_enable_disable() is False + + def test_rls_enable_disable_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_rls_enable_disable() is True + assert dialect.supports_rls_force() is True + assert dialect.supports_rls_enable_always() is True + + def test_rls_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresRlsConfigSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresRlsConfigSupport) + + +class TestPostgresAlterTableSettingsFeatureDetection: + """Test PostgresAlterTableSettingsSupport feature detection methods.""" + + def test_table_logging_switch_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_table_logging_switch() is False + + def test_table_logging_switch_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_table_logging_switch() is True + assert dialect.supports_table_set_access_method() is False + + def test_table_set_access_method_pg15(self): + dialect = PostgresDialect(version=(15, 0, 0)) + assert dialect.supports_table_set_access_method() is True + + def test_alter_table_settings_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresAlterTableSettingsSupport, + ) + assert isinstance( + PostgresDialect(version=(14, 0, 0)), PostgresAlterTableSettingsSupport + ) + + +class TestPostgresClusterFeatureDetection: + """Test PostgresClusterSupport feature detection methods.""" + + def test_cluster_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_cluster() is False + + def test_cluster_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_cluster() is True + + def test_cluster_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresClusterSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresClusterSupport) + + +class TestPostgresDomainFeatureDetection: + """Test PostgresDomainSupport feature detection methods.""" + + def test_domain_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_create_domain() is False + + def test_domain_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_create_domain() is True + + def test_domain_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresDomainSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresDomainSupport) + + +class TestPostgresCollationDDLFeatureDetection: + """Test PostgresCollationDDLSupport feature detection methods.""" + + def test_collation_ddl_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_collation_ddl() is False + + def test_collation_ddl_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_collation_ddl() is True + + def test_collation_ddl_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresCollationDDLSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresCollationDDLSupport) + + +class TestPostgresForeignTableDDLFeatureDetection: + """Test PostgresForeignTableDDLSupport feature detection methods.""" + + def test_foreign_table_ddl_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_foreign_table_ddl() is False + + def test_foreign_table_ddl_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_foreign_table_ddl() is True + + def test_foreign_table_ddl_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresForeignTableDDLSupport, + ) + assert isinstance( + PostgresDialect(version=(14, 0, 0)), PostgresForeignTableDDLSupport + ) + + +class TestPostgresRoutineDDLFeatureDetection: + """Test PostgresRoutineDDLSupport feature detection methods.""" + + def test_routine_ddl_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_function_ddl() is False + assert dialect.supports_aggregate_ddl() is False + + def test_routine_ddl_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_function_ddl() is True + assert dialect.supports_aggregate_ddl() is True + + def test_routine_ddl_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresRoutineDDLSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresRoutineDDLSupport) + + +class TestPostgresPublicationFeatureDetection: + """Test PostgresPublicationSupport feature detection methods.""" + + def test_publication_pg99(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_publication() is False + assert dialect.supports_subscription() is False + + def test_publication_pg100(self): + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_publication() is True + assert dialect.supports_subscription() is True + + def test_publication_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresPublicationSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresPublicationSupport) \ No newline at end of file From 8ccd35e13df5873dd9e00837ae84070f6e384eaf Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 8 Aug 2026 10:53:50 +0800 Subject: [PATCH 2/5] docs(backend): add JSONExpression type hints to enhanced JSONB mixin and protocol --- .../backend/impl/postgres/mixins/types/jsonb_enhanced.py | 7 ++++++- .../impl/postgres/protocols/types/jsonb_enhanced.py | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py index cf9706f9..08b35287 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py @@ -5,6 +5,11 @@ Implements the PostgresJSONBEnhancedSupport protocol. """ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression import JSONExpression + class PostgresJSONBEnhancedMixin: """Mixin for PostgreSQL enhanced JSONB support. @@ -13,7 +18,7 @@ class PostgresJSONBEnhancedMixin: JSON and JSONB features beyond the standard JSONSupport protocol. """ - def format_json_expression(self, expr): + def format_json_expression(self, expr: "JSONExpression"): from rhosocial.activerecord.backend.expression import bases if isinstance(expr.column, bases.BaseExpression): diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py index 1e47db69..fbd53533 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py @@ -5,7 +5,10 @@ enhancements beyond the core JSON support. """ -from typing import Protocol, runtime_checkable +from typing import Protocol, runtime_checkable, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression import JSONExpression @runtime_checkable @@ -38,7 +41,7 @@ class PostgresJSONBEnhancedSupport(Protocol): - Numeric infinity in JSONB: PostgreSQL 17+ """ - def format_json_expression(self, expr): + def format_json_expression(self, expr: "JSONExpression"): """Format a PostgreSQL JSON expression.""" ... From 147efd36541a178cd86baa359da273d063f0129d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 8 Aug 2026 12:19:22 +0800 Subject: [PATCH 3/5] feat(backend): complete DDL coverage gaps RENAME and CREATE UNLOGGED TABLE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 4.6/4.7 closure: - CREATE UNLOGGED TABLE via dialect_options={'unlogged_table': True} on CreateTableExpression, version-gated at 9.5+, rendering through PostgresTableMixin.format_create_table_statement; TEMPORARY wins over UNLOGGED. Exposes supports_unlogged_table on PostgresTableSupport. - RENAME COLUMN / RENAME TABLE verified through core actions and covered by tests (already supported by core DDL; no new mixin needed). Tests: - Extend test_alter_table_if_exists.py with ALTER COLUMN SET DATA TYPE [USING] and RENAME subclause coverage plus CREATE UNLOGGED TABLE. - Add plan §6.3 version-baseline sweep at PG 9.6/10/11/14/15/17/19 with UnsupportedFeatureError gates. --- .../backend/impl/postgres/mixins/table.py | 45 ++++- .../backend/impl/postgres/protocols/table.py | 9 + .../dialect/test_alter_table_if_exists.py | 171 ++++++++++++++++++ .../backend/postgres/test_ddl_protocols.py | 62 ++++++- 4 files changed, 285 insertions(+), 2 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py index 66290066..83bebc5d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py @@ -17,6 +17,17 @@ def supports_temporary_table(self) -> bool: """TEMPORARY tables are supported in all versions.""" return True + def supports_unlogged_table(self) -> bool: + """CREATE UNLOGGED TABLE is supported since PostgreSQL 9.5. + + UNLOGGED tables write no WAL; they are faster for staging data but are + not crash-safe and are truncated on crash recovery. PostgreSQL 14 added + the ``UNLOGGED`` / ``LOGGED`` table as a distribution. The bare + ``UNLOGGED`` qualifier on CREATE TABLE exists since 9.5, matching the + §0 baseline. + """ + return self.version >= (9, 5, 0) + def supports_table_inheritance(self) -> bool: """PostgreSQL supports table inheritance.""" return True @@ -29,7 +40,39 @@ def supports_table_like_syntax(self) -> bool: """PostgreSQL supports CREATE TABLE (LIKE ...) with INCLUDING/EXCLUDING options.""" return True - def format_create_table_like(self, expr) -> tuple: + def format_create_table_statement(self, expr) -> Tuple[str, tuple]: + """Render CREATE TABLE, injecting the UNLOGGED qualifier. + + The core ``TableMixin.format_create_table_statement`` does not know + about PostgreSQL's ``UNLOGGED`` table classes (WAL-avoiding tables). + Callers opt in via ``dialect_options={"unlogged": True}`` on the + ``CreateTableExpression``; the qualifier is version-gated at 9.5+. + ``UNLOGGED`` and ``TEMPORARY`` are mutually exclusive qualifiers in + PostgreSQL's grammar; TEMPORARY wins when both are requested. + """ + sql, params = super().format_create_table_statement(expr) + if not (getattr(expr, "dialect_options", None) or {}).get("unlogged_table"): + return sql, params + if getattr(expr, "temporary", False): + return sql, params + if not self.supports_unlogged_table(): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + raise UnsupportedFeatureError( + self.name, + "CREATE UNLOGGED TABLE", + suggestion="requires PostgreSQL 9.5+", + ) + prefix = "CREATE TABLE " + if sql.startswith(prefix): + return sql.replace(prefix, "CREATE UNLOGGED TABLE ", 1), params + temp_prefix = "CREATE TEMPORARY TABLE " + if sql.startswith(temp_prefix): + return sql.replace(temp_prefix, "CREATE UNLOGGED TABLE ", 1), params + return sql, params + + def format_create_table_like(self, expr) -> Tuple[str, tuple]: """Format CREATE TABLE (LIKE ...) statement for PostgreSQL. Delegates to format_create_table_statement which already handles diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py index a7842ac2..9ff7b6a0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py @@ -37,6 +37,15 @@ def supports_table_inheritance(self) -> bool: """ ... + 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 ``dialect_options={"unlogged_table": + True}`` on ``CreateTableExpression``. + """ + ... + def supports_table_like_syntax(self) -> bool: """Whether CREATE TABLE (LIKE ...) syntax is supported. 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 a22f4eb7..5fee2aa0 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 @@ -107,3 +107,174 @@ def test_none_renders_plain_form(self, dialect): assert 'DROP CONSTRAINT "fkey"' == sql assert "IF EXISTS" not in sql assert params == () + + +class TestPostgresAlterColumnTypeAndUsing: + """SET DATA TYPE with the PostgreSQL ``USING`` conversion expression. + + Covered here per plan §6.3: the ALTER COLUMN subclauses added by the + PostgreSQL DDL coverage-completion work must render through the same + ALTER TABLE action pipeline as the IF [NOT] EXISTS qualifiers. + """ + + @pytest.fixture + 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, + ) + + action = AlterColumn( + dialect, "price", "SET DATA TYPE", new_value="NUMERIC(10,2)" + ) + sql, params = action.to_sql() + assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' == sql + assert params == () + + 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, + ) + + action = AlterColumn( + dialect, + "price", + "SET DATA TYPE", + new_value="NUMERIC(10,2)", + dialect_options={"using": Column(dialect, "price") + Literal(dialect, 1)}, + ) + sql, serialized = action.to_sql() + assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql + assert 'USING ("price" + %s)' in sql + assert serialized == (1,) + + 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, + ) + + action = AlterColumn( + dialect, + "price", + "SET DEFAULT", + new_value="0", + dialect_options={"using": Column(dialect, "price")}, + ) + with pytest.raises(ValueError, match="USING"): + action.to_sql() + + +class TestPostgresRenameColumnAndTable: + """RENAME COLUMN / RENAME TABLE render through the core actions.""" + + def test_rename_column(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + RenameColumn, + ) + + sql, params = RenameColumn( + dialect, old_name="id", new_name="order_id" + ).to_sql() + assert 'RENAME COLUMN "id" TO "order_id"' == sql + assert params == () + + def test_rename_column_inside_alter_table(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + RenameColumn, + ) + + expr = AlterTableExpression( + dialect, + table_name="orders", + actions=[RenameColumn(dialect, old_name="id", new_name="order_id")], + ) + sql, params = expr.to_sql() + assert 'ALTER TABLE "orders"' in sql + assert 'RENAME COLUMN "id" TO "order_id"' in sql + assert params == () + + def test_rename_table(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + RenameTable, + ) + + sql, params = RenameTable( + dialect, old_name="orders", new_name="orders2" + ).to_sql() + assert 'RENAME TO "orders2"' == sql + assert params == () + + def test_capability_switches(self, dialect): + assert dialect.supports_rename_column() is True + assert dialect.supports_rename_table() is True + + +class TestPostgresCreateUnloggedTable: + """CREATE UNLOGGED TABLE via dialect_options on CreateTableExpression.""" + + def test_unlogged_renders_qualifier(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, + ) + + expr = CreateTableExpression( + dialect, + table="audit", + columns=[ColumnDefinition("id", TextType())], + dialect_options={"unlogged_table": True}, + ) + sql, params = expr.to_sql() + assert sql.startswith('CREATE UNLOGGED TABLE "audit"') + assert params == () + + def test_plain_omits_qualifier(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, + ) + + expr = CreateTableExpression( + dialect, + table="audit", + columns=[ColumnDefinition("id", TextType())], + ) + sql, params = expr.to_sql() + assert not sql.startswith("CREATE UNLOGGED") + assert params == () + + def test_temporary_wins_over_unlogged(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, + ) + + expr = CreateTableExpression( + dialect, + table="audit", + columns=[ColumnDefinition("id", TextType())], + temporary=True, + dialect_options={"unlogged_table": True}, + ) + sql, params = expr.to_sql() + assert sql.startswith("CREATE TEMPORARY TABLE") + assert params == () + + def test_version_gate_94(self): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, + ) + + low = PostgresDialect(version=(9, 4, 0)) + expr = CreateTableExpression( + low, + table="audit", + columns=[ColumnDefinition("id", TextType())], + dialect_options={"unlogged_table": True}, + ) + with pytest.raises(UnsupportedFeatureError): + 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 e659c264..d0e75ccc 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 @@ -574,4 +574,64 @@ def test_publication_support_is_runtime_checkable(self): from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( PostgresPublicationSupport, ) - assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresPublicationSupport) \ No newline at end of file + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresPublicationSupport) + + +class TestDdlVersionBaselineCoverage: + """Version-boundary sweep across the plan's supported baseline 9.6–19. + + Plan §6.3 requires capability switches be exercised at + 9.6 / 10 / 11 / 14 / 15 / 17 / 19 — not just the historical 9.4/9.5 set. + """ + + def test_baseline_96(self): + d = PostgresDialect(version=(9, 6, 0)) + assert d.supports_rls_enable_disable() is True + assert d.supports_table_logging_switch() is True + assert d.supports_cluster() is True + assert d.supports_create_domain() is True + assert d.supports_collation_ddl() is True + assert d.supports_foreign_table_ddl() is True + assert d.supports_function_ddl() is True + assert d.supports_aggregate_ddl() is True + assert d.supports_unlogged_table() is True + assert d.supports_publication() is False + assert d.supports_subscription() is False + + def test_baseline_10(self): + d = PostgresDialect(version=(10, 0, 0)) + assert d.supports_publication() is True + assert d.supports_subscription() is True + + def test_baseline_11(self): + d = PostgresDialect(version=(11, 0, 0)) + assert d.supports_publication() is True + assert d.supports_subscription() is True + assert d.supports_create_domain() is True + + def test_baseline_14(self): + d = PostgresDialect(version=(14, 0, 0)) + assert d.supports_cluster() is True + assert d.supports_function_ddl() is True + + def test_baseline_15(self): + d = PostgresDialect(version=(15, 0, 0)) + assert d.supports_table_set_access_method() is True + + def test_baseline_17(self): + d = PostgresDialect(version=(17, 0, 0)) + assert d.supports_unlogged_table() is True + assert d.supports_rls_enable_always() is True + assert d.supports_table_set_access_method() is True + + def test_baseline_19(self): + d = PostgresDialect(version=(19, 0, 0)) + assert d.supports_table_set_access_method() is True + assert d.supports_subscription() is True + assert d.supports_create_domain() is True + assert d.supports_collation_ddl() is True + assert d.supports_publication() is True + + 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 From 57408238cf7c6172e14e43edd720bd1ec6b7f765 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 8 Aug 2026 13:21:22 +0800 Subject: [PATCH 4/5] test(backend): split DDL expression tests and close DDL mixin coverage gaps --- .../impl/postgres/mixins/ddl/collation.py | 3 - .../backend/postgres/test_ddl_alter_config.py | 368 +++++++++++++ .../backend/postgres/test_ddl_expressions.py | 378 ++----------- .../backend/postgres/test_ddl_objects.py | 495 ++++++++++++++++++ 4 files changed, 895 insertions(+), 349 deletions(-) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py index a6f02356..abee6a37 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py @@ -86,9 +86,6 @@ def format_create_collation_ddl_statement( if clauses: parts.append("(" + ", ".join(clauses) + ")") return " ".join(parts), () - if clauses: - parts.append("(" + ", ".join(clauses) + ")") - return " ".join(parts), () # ------------------------------------------------------------------ # # DROP COLLATION 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 new file mode 100644 index 00000000..c44a8926 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py @@ -0,0 +1,368 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +"""Tests for PostgreSQL DDL expression classes. + +This module tests the expression-based format methods for DDL operations, +including materialized view refresh, comment, and partition expressions. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.statements import ( + AddColumn, + AlterColumn, + ColumnDefinition, + DropColumn, + DropTableConstraint, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresVacuumExpression, # noqa: F401 + PostgresAnalyzeExpression, # noqa: F401 + LoggingMode, + RlsConfigurationMode, + PostgresAlterTableRlsExpression, + PostgresForceRlsExpression, + PostgresAlterTableSettingsExpression, + PostgresClusterExpression, +) +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.impl.postgres.mixins.dml.extended_statistics import ( + PostgresExtendedStatisticsMixin, # noqa: F401 +) + +class TestPostgresRlsConfigExpression: + """Test RLS enable/disable/always/force DDL expressions.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_enable(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.ENABLE + ).to_sql() + assert sql == 'ALTER TABLE "orders" ENABLE ROW LEVEL SECURITY' + + def test_disable(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.DISABLE + ).to_sql() + assert sql == 'ALTER TABLE "orders" DISABLE ROW LEVEL SECURITY' + + def test_enable_always(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.ENABLE, always=True + ).to_sql() + assert sql == 'ALTER TABLE "orders" ENABLE ALWAYS ROW LEVEL SECURITY' + + def test_schema_qualify(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.ENABLE, schema="app" + ).to_sql() + assert sql == 'ALTER TABLE "app"."orders" ENABLE ROW LEVEL SECURITY' + + def test_force(self, dialect): + sql, _ = PostgresForceRlsExpression(dialect, "orders").to_sql() + assert sql == 'ALTER TABLE "orders" FORCE ROW LEVEL SECURITY' + + def test_no_force(self, dialect): + sql, _ = PostgresForceRlsExpression(dialect, "orders", force=False).to_sql() + assert sql == 'ALTER TABLE "orders" NO FORCE ROW LEVEL SECURITY' + + def test_version_gate_94(self): + d = PostgresDialect(version=(9, 4, 0)) + expr = PostgresAlterTableRlsExpression(d, "orders", RlsConfigurationMode.ENABLE) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_disable_with_always_raises(self, dialect): + expr = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.DISABLE, always=True + ) + with pytest.raises(ValueError, match="ALWAYS"): + expr.to_sql() + + def test_force_version_gate_94(self): + d = PostgresDialect(version=(9, 4, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresForceRlsExpression(d, "orders").to_sql() + + +class TestPostgresAlterTableSettingsExpression: + """SET LOGGED / UNLOGGED / ACCESS METHOD DDL.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(15, 0, 0)) + + def test_unlogged(self, dialect): + sql, _ = PostgresAlterTableSettingsExpression( + dialect, "t", mode=LoggingMode.UNLOGGED + ).to_sql() + assert sql == 'ALTER TABLE "t" SET UNLOGGED' + + def test_logged_schema(self, dialect): + sql, _ = PostgresAlterTableSettingsExpression( + dialect, "t", schema="s", mode=LoggingMode.LOGGED + ).to_sql() + assert sql == 'ALTER TABLE "s"."t" SET LOGGED' + + def test_access_method(self, dialect): + sql, _ = PostgresAlterTableSettingsExpression( + dialect, "t", access_method="heap" + ).to_sql() + assert sql == 'ALTER TABLE "t" SET ACCESS METHOD "heap"' + + def test_access_method_requires_15(self): + d = PostgresDialect(version=(14, 0, 0)) + expr = PostgresAlterTableSettingsExpression(d, "t", access_method="heap") + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_no_clause_raises(self, dialect): + expr = PostgresAlterTableSettingsExpression(dialect, "t") + with pytest.raises(ValueError): + expr.to_sql() + + def test_both_clauses_raises(self, dialect): + expr = PostgresAlterTableSettingsExpression( + dialect, "t", mode=LoggingMode.UNLOGGED, access_method="heap" + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_logging_version_gate_96(self): + d = PostgresDialect(version=(9, 5, 0)) + expr = PostgresAlterTableSettingsExpression( + d, "t", mode=LoggingMode.LOGGED + ) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + +class TestPostgresClusterExpression: + """CLUSTER DDL.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_full(self, dialect): + sql, _ = PostgresClusterExpression( + dialect, "orders", schema="public", + using_index="orders_pkey", verbose=True, + ).to_sql() + assert sql == 'CLUSTER VERBOSE "public"."orders" USING "orders_pkey"' + + def test_bare(self, dialect): + sql, _ = PostgresClusterExpression(dialect, verbose=True).to_sql() + assert sql == "CLUSTER VERBOSE" + + def test_version_gate_95(self): + d = PostgresDialect(version=(9, 5, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresClusterExpression(d, "t").to_sql() + + def test_without_verbose(self, dialect): + sql, _ = PostgresClusterExpression( + dialect, "orders", using_index="orders_pkey" + ).to_sql() + assert sql == 'CLUSTER "orders" USING "orders_pkey"' + + def test_without_index(self, dialect): + sql, _ = PostgresClusterExpression( + dialect, "orders", verbose=True + ).to_sql() + assert sql == 'CLUSTER VERBOSE "orders"' + + def test_verbose_false_schema(self, dialect): + sql, _ = PostgresClusterExpression( + dialect, "orders", schema="app", verbose=False + ).to_sql() + assert sql == 'CLUSTER "app"."orders"' + + +class TestPostgresAlterColumnUsingExpression: + """ALTER COLUMN ... SET DATA TYPE ... USING.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_using_clause(self, dialect): + action = AlterColumn( + dialect, + "price", + "SET DATA TYPE", + new_value="NUMERIC(10,2)", + dialect_options={"using": Column(dialect, "price") + Literal(dialect, 1)}, + ) + sql, serialized = dialect.format_alter_column_action(action) + assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql + assert 'USING ("price" + %s)' in sql + assert serialized == (1,) + + def test_using_clause_with_cascade(self, dialect): + action = AlterColumn( + dialect, + "price", + "SET DATA TYPE", + new_value="NUMERIC(10,2)", + cascade=True, + dialect_options={"using": Column(dialect, "price")}, + ) + sql, serialized = dialect.format_alter_column_action(action) + assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql + assert 'USING ("price")' in sql + assert sql.endswith(" CASCADE") + + def test_using_rejected_for_non_set_data_type(self, dialect): + """USING is only valid on SET DATA TYPE.""" + action = AlterColumn( + dialect, + "price", + "SET DEFAULT", + new_value="0", + dialect_options={"using": Column(dialect, "price")}, + ) + with pytest.raises(ValueError, match="USING"): + dialect.format_alter_column_action(action) + + def test_without_using_unaffected(self, dialect): + """No USING -> output matches the standard form.""" + action = AlterColumn( + dialect, + "price", + "SET DATA TYPE", + new_value="NUMERIC(10,2)", + ) + sql, _ = dialect.format_alter_column_action(action) + assert sql == 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' + + +class TestPostgresAlterColumnModifierMixin: + """ADD/DROP COLUMN and DROP CONSTRAINT IF [NOT] EXISTS qualifiers.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_capabilities(self, dialect): + assert dialect.supports_add_column_if_not_exists() is True + assert dialect.supports_drop_column_if_exists() is True + assert dialect.supports_drop_constraint_if_exists() is True + + def test_add_column(self, dialect): + column = ColumnDefinition( + "email", TextType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ) + sql, params = dialect.format_add_column_action( + AddColumn(dialect, column) + ) + assert sql == 'ADD COLUMN "email" TEXT NOT NULL' + assert params == () + + def test_add_column_if_not_exists(self, dialect): + column = ColumnDefinition("email", TextType()) + sql, _ = dialect.format_add_column_action( + AddColumn(dialect, column, if_not_exists=True) + ) + assert sql == 'ADD COLUMN IF NOT EXISTS "email" TEXT' + + def test_drop_column(self, dialect): + sql, _ = dialect.format_drop_column_action( + DropColumn(dialect, "email") + ) + assert sql == 'DROP COLUMN "email"' + + def test_drop_column_if_exists(self, dialect): + sql, _ = dialect.format_drop_column_action( + DropColumn(dialect, "email", if_exists=True) + ) + assert sql == 'DROP COLUMN IF EXISTS "email"' + + def test_drop_constraint(self, dialect): + sql, _ = dialect.format_drop_table_constraint_action( + DropTableConstraint(dialect, "user_email_key") + ) + assert sql == 'DROP CONSTRAINT "user_email_key"' + + def test_drop_constraint_if_exists(self, dialect): + sql, _ = dialect.format_drop_table_constraint_action( + DropTableConstraint(dialect, "user_email_key", if_exists=True) + ) + assert sql == 'DROP CONSTRAINT IF EXISTS "user_email_key"' + + def test_drop_constraint_if_exists_cascade(self, dialect): + sql, _ = dialect.format_drop_table_constraint_action( + DropTableConstraint( + dialect, "user_email_key", if_exists=True, cascade=True + ) + ) + assert sql == 'DROP CONSTRAINT IF EXISTS "user_email_key" CASCADE' + + +class TestPostgresConstraintCapabilities: + """PostgreSQL-only constraint capability switches.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_capabilities(self, dialect): + assert dialect.supports_constraint_novalidate() is True + assert dialect.supports_exclude_constraint() is True + assert dialect.supports_drop_constraint_if_exists() is True + + +class TestPostgresTypeDDL: + """CREATE/DROP TYPE capability switches and formatting.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_capabilities(self, dialect): + assert dialect.supports_create_type() is True + assert dialect.supports_drop_type() is True + assert dialect.supports_type_if_not_exists() is False + assert dialect.supports_type_if_exists() is True + assert dialect.supports_type_cascade() is True + + def test_create_enum(self, dialect): + sql, params = dialect.format_create_type_enum_statement( + "color", ["red", "blue"] + ) + assert sql == 'CREATE TYPE "color" AS ENUM (\'red\', \'blue\')' + assert params == () + + def test_create_enum_with_schema(self, dialect): + sql, _ = dialect.format_create_type_enum_statement( + "color", ["red"], schema="app" + ) + 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_drop_type(self, dialect): + sql, _ = dialect.format_drop_type_statement("color") + assert sql == 'DROP TYPE "color"' + + def test_drop_type_full(self, dialect): + sql, _ = dialect.format_drop_type_statement( + "color", schema="app", if_exists=True, cascade=True + ) + assert sql == 'DROP TYPE IF EXISTS "app"."color" CASCADE' + 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 d7cb72a3..22231b29 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,18 +9,14 @@ import pytest -from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.statements import ( - AlterColumn, ColumnDefinition, CreateTableExpression, PartitionClause, PartitionStrategy, ) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.dialect.exceptions import ( - UnsupportedFeatureError, -) from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PartitionValue, PostgresRefreshMaterializedViewExpression, @@ -35,29 +31,7 @@ PostgresPgPartmanUpdateConfigExpression, PostgresVacuumExpression, # noqa: F401 PostgresAnalyzeExpression, # noqa: F401 - LoggingMode, - RlsConfigurationMode, - AlterDomainActionType, - PostgresAlterTableRlsExpression, - PostgresForceRlsExpression, - PostgresAlterTableSettingsExpression, - PostgresClusterExpression, - PostgresCreateDomainExpression, - PostgresAlterDomainExpression, - PostgresDropDomainExpression, - PostgresCreateCollationExpression, - PostgresDropCollationExpression, - PostgresCreateForeignTableExpression, - PostgresDropForeignTableExpression, - PostgresCreateFunctionExpression, - PostgresDropFunctionExpression, - PostgresCreateAggregateExpression, - PostgresDropAggregateExpression, - PostgresCreatePublicationExpression, - PostgresDropPublicationExpression, - PostgresCreateSubscriptionExpression, - PostgresDropSubscriptionExpression, -) + ) from rhosocial.activerecord.backend.expression.types import ( BigIntType, TextType, TimestampType, ) @@ -68,7 +42,6 @@ PostgresExtendedStatisticsMixin, # noqa: F401 ) - class TestPostgresRefreshMaterializedViewExpression: """Test PostgresRefreshMaterializedViewExpression.""" @@ -275,7 +248,10 @@ def test_create_range_partitioned_parent_table(self, dialect): table="events", columns=[ ColumnDefinition("id", BigIntType()), - ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition( + "created_at", TimestampType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), ], partition=PartitionClause( dialect=dialect, @@ -315,7 +291,12 @@ def test_create_hash_partitioned_parent_table_pg10(self): expr = CreateTableExpression( dialect=dialect, table="events", - columns=[ColumnDefinition("tenant_id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)])], + columns=[ + ColumnDefinition( + "tenant_id", BigIntType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ) + ], partition=PartitionClause( dialect=dialect, method=PartitionStrategy.HASH, @@ -332,7 +313,12 @@ def test_create_hash_partitioned_parent_table_pg11(self): expr = CreateTableExpression( dialect=dialect, table="events", - columns=[ColumnDefinition("tenant_id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)])], + columns=[ + ColumnDefinition( + "tenant_id", BigIntType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ) + ], partition=PartitionClause( dialect=dialect, method=PartitionStrategy.HASH, @@ -350,7 +336,12 @@ def test_create_partitioned_parent_table_pg9(self): expr = CreateTableExpression( dialect=dialect, table="events", - columns=[ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)])], + columns=[ + ColumnDefinition( + "created_at", TimestampType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ) + ], partition=PartitionClause( dialect=dialect, method=PartitionStrategy.RANGE, @@ -376,8 +367,14 @@ def test_multi_column_range_partitioned_parent_table(self, dialect): dialect=dialect, table="tenanted_events", columns=[ - ColumnDefinition("tenant_id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition( + "tenant_id", BigIntType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), + ColumnDefinition( + "created_at", TimestampType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), ColumnDefinition("payload", TextType()), ], partition=PartitionClause( @@ -978,314 +975,3 @@ def test_run_maintenance_global_expression(self, dialect): assert sql == 'SELECT "partman"."run_maintenance"()' assert params == () - -class TestPostgresRlsConfigExpression: - """Test RLS enable/disable/always/force DDL expressions.""" - - @pytest.fixture - def dialect(self): - return PostgresDialect(version=(14, 0, 0)) - - def test_enable(self, dialect): - sql, _ = PostgresAlterTableRlsExpression( - dialect, "orders", RlsConfigurationMode.ENABLE - ).to_sql() - assert sql == 'ALTER TABLE "orders" ENABLE ROW LEVEL SECURITY' - - def test_disable(self, dialect): - sql, _ = PostgresAlterTableRlsExpression( - dialect, "orders", RlsConfigurationMode.DISABLE - ).to_sql() - assert sql == 'ALTER TABLE "orders" DISABLE ROW LEVEL SECURITY' - - def test_enable_always(self, dialect): - sql, _ = PostgresAlterTableRlsExpression( - dialect, "orders", RlsConfigurationMode.ENABLE, always=True - ).to_sql() - assert sql == 'ALTER TABLE "orders" ENABLE ALWAYS ROW LEVEL SECURITY' - - def test_schema_qualify(self, dialect): - sql, _ = PostgresAlterTableRlsExpression( - dialect, "orders", RlsConfigurationMode.ENABLE, schema="app" - ).to_sql() - assert sql == 'ALTER TABLE "app"."orders" ENABLE ROW LEVEL SECURITY' - - def test_force(self, dialect): - sql, _ = PostgresForceRlsExpression(dialect, "orders").to_sql() - assert sql == 'ALTER TABLE "orders" FORCE ROW LEVEL SECURITY' - - def test_no_force(self, dialect): - sql, _ = PostgresForceRlsExpression(dialect, "orders", force=False).to_sql() - assert sql == 'ALTER TABLE "orders" NO FORCE ROW LEVEL SECURITY' - - def test_version_gate_94(self): - d = PostgresDialect(version=(9, 4, 0)) - expr = PostgresAlterTableRlsExpression(d, "orders", RlsConfigurationMode.ENABLE) - with pytest.raises(UnsupportedFeatureError): - expr.to_sql() - - -class TestPostgresAlterTableSettingsExpression: - """SET LOGGED / UNLOGGED / ACCESS METHOD DDL.""" - - @pytest.fixture - def dialect(self): - return PostgresDialect(version=(15, 0, 0)) - - def test_unlogged(self, dialect): - sql, _ = PostgresAlterTableSettingsExpression( - dialect, "t", mode=LoggingMode.UNLOGGED - ).to_sql() - assert sql == 'ALTER TABLE "t" SET UNLOGGED' - - def test_logged_schema(self, dialect): - sql, _ = PostgresAlterTableSettingsExpression( - dialect, "t", schema="s", mode=LoggingMode.LOGGED - ).to_sql() - assert sql == 'ALTER TABLE "s"."t" SET LOGGED' - - def test_access_method(self, dialect): - sql, _ = PostgresAlterTableSettingsExpression( - dialect, "t", access_method="heap" - ).to_sql() - assert sql == 'ALTER TABLE "t" SET ACCESS METHOD "heap"' - - def test_access_method_requires_15(self): - d = PostgresDialect(version=(14, 0, 0)) - expr = PostgresAlterTableSettingsExpression(d, "t", access_method="heap") - with pytest.raises(UnsupportedFeatureError): - expr.to_sql() - - def test_no_clause_raises(self, dialect): - expr = PostgresAlterTableSettingsExpression(dialect, "t") - with pytest.raises(ValueError): - expr.to_sql() - - -class TestPostgresClusterExpression: - """CLUSTER DDL.""" - - @pytest.fixture - def dialect(self): - return PostgresDialect(version=(14, 0, 0)) - - def test_full(self, dialect): - sql, _ = PostgresClusterExpression( - dialect, "orders", schema="public", - using_index="orders_pkey", verbose=True, - ).to_sql() - assert sql == 'CLUSTER VERBOSE "public"."orders" USING "orders_pkey"' - - def test_bare(self, dialect): - sql, _ = PostgresClusterExpression(dialect, verbose=True).to_sql() - assert sql == "CLUSTER VERBOSE" - - def test_version_gate_95(self): - d = PostgresDialect(version=(9, 5, 0)) - with pytest.raises(UnsupportedFeatureError): - PostgresClusterExpression(d, "t").to_sql() - - -class TestPostgresDomainExpression: - """CREATE / ALTER / DROP DOMAIN.""" - - @pytest.fixture - def dialect(self): - return PostgresDialect(version=(14, 0, 0)) - - def test_create(self, dialect): - sql, _ = PostgresCreateDomainExpression( - dialect, "posint", "NUMERIC", - default="0", constraints=["CHECK (VALUE > 0)"], - ).to_sql() - assert sql == "CREATE DOMAIN \"posint\" AS NUMERIC DEFAULT 0 CHECK (VALUE > 0)" - - def test_alter_set_default(self, dialect): - sql, _ = PostgresAlterDomainExpression( - dialect, "posint", AlterDomainActionType.SET_DEFAULT, new_value="0" - ).to_sql() - assert sql == 'ALTER DOMAIN "posint" SET DEFAULT 0' - - def test_alter_rename(self, dialect): - sql, _ = PostgresAlterDomainExpression( - dialect, "posint", AlterDomainActionType.RENAME_TO, new_name="posint2" - ).to_sql() - assert sql == 'ALTER DOMAIN "posint" RENAME TO "posint2"' - - def test_drop(self, dialect): - sql, _ = PostgresDropDomainExpression( - dialect, "posint", if_exists=True, cascade=True - ).to_sql() - assert sql == 'DROP DOMAIN IF EXISTS "posint" CASCADE' - - -class TestPostgresCollationDDLExpression: - """CREATE / DROP COLLATION DDL.""" - - @pytest.fixture - def dialect(self): - return PostgresDialect(version=(14, 0, 0)) - - def test_create(self, dialect): - sql, _ = PostgresCreateCollationExpression( - dialect, "enloc", locale="en_US.UTF-8", provider="libc" - ).to_sql() - assert sql == 'CREATE COLLATION "enloc" (LOCALE = en_US.UTF-8, PROVIDER = libc)' - - def test_create_if_not_exists(self, dialect): - sql, _ = PostgresCreateCollationExpression( - dialect, "myloc", if_not_exists=True, lc_collate="en_US", - ).to_sql() - assert sql == 'CREATE COLLATION IF NOT EXISTS "myloc" (LC_COLLATE = en_US)' - - def test_drop(self, dialect): - sql, _ = PostgresDropCollationExpression( - dialect, "enloc", if_exists=True - ).to_sql() - assert sql == 'DROP COLLATION IF EXISTS "enloc"' - - -class TestPostgresForeignTableDDLExpression: - """CREATE / DROP FOREIGN TABLE.""" - - @pytest.fixture - def dialect(self): - return PostgresDialect(version=(14, 0, 0)) - - def test_create(self, dialect): - sql, _ = PostgresCreateForeignTableExpression( - dialect, "ft", "srv", columns=["a integer", "b text"], - options=["host 'h'"], - ).to_sql() - assert sql == ( - 'CREATE FOREIGN TABLE "ft" (a integer, b text) ' - 'SERVER "srv" OPTIONS (host \'h\')' - ) - - def test_drop(self, dialect): - sql, _ = PostgresDropForeignTableExpression( - dialect, "ft", if_exists=True, cascade=True - ).to_sql() - assert sql == 'DROP FOREIGN TABLE IF EXISTS "ft" CASCADE' - - -class TestPostgresRoutineDDLExpression: - """CREATE / DROP FUNCTION and AGGREGATE.""" - - @pytest.fixture - def dialect(self): - return PostgresDialect(version=(14, 0, 0)) - - def test_create_function(self, dialect): - sql, _ = PostgresCreateFunctionExpression( - dialect, "add", "integer", "int 1;", - args=["a integer"], strict=True, security="DEFINER", - ).to_sql() - assert sql == ( - 'CREATE FUNCTION "add"(a integer) RETURNS integer ' - 'STRICT SECURITY DEFINER LANGUAGE plpgsql AS $$ int 1; $$' - ) - - def test_drop_function(self, dialect): - sql, _ = PostgresDropFunctionExpression( - dialect, "add", args=["integer"], cascade=True - ).to_sql() - assert sql == 'DROP FUNCTION "add" (integer) CASCADE' - - def test_create_aggregate(self, dialect): - sql, _ = PostgresCreateAggregateExpression( - dialect, "mysum", "sum", "integer", initcond="0" - ).to_sql() - assert sql == 'CREATE AGGREGATE "mysum" (SFUNC=sum, STYPE=integer, INITCOND=0)' - - def test_drop_aggregate(self, dialect): - sql, _ = PostgresDropAggregateExpression( - dialect, "mysum", "integer", if_exists=True - ).to_sql() - assert sql == 'DROP AGGREGATE IF EXISTS "mysum" (integer)' - - -class TestPostgresPublicationExpression: - """CREATE / DROP PUBLICATION and SUBSCRIPTION.""" - - @pytest.fixture - def dialect(self): - return PostgresDialect(version=(14, 0, 0)) - - def test_create_publication_tables(self, dialect): - sql, _ = PostgresCreatePublicationExpression( - dialect, "pub1", tables=["orders", "users"] - ).to_sql() - assert sql == 'CREATE PUBLICATION "pub1" FOR TABLE "orders", "users"' - - def test_create_publication_all_tables(self, dialect): - sql, _ = PostgresCreatePublicationExpression( - dialect, "pub_all", all_tables=True, - options=["publish='insert'"], - ).to_sql() - assert sql == ( - 'CREATE PUBLICATION "pub_all" FOR ALL TABLES WITH (publish=\'insert\')' - ) - - def test_drop_publication(self, dialect): - sql, _ = PostgresDropPublicationExpression( - dialect, "pub1", if_exists=True - ).to_sql() - assert sql == 'DROP PUBLICATION IF EXISTS "pub1"' - - def test_create_subscription(self, dialect): - sql, _ = PostgresCreateSubscriptionExpression( - dialect, "sub1", "host=db port=5432", ["pub1"] - ).to_sql() - assert sql == ( - 'CREATE SUBSCRIPTION "sub1" CONNECTION \'host=db port=5432\' ' - 'PUBLICATION "pub1"' - ) - - def test_drop_subscription(self, dialect): - sql, _ = PostgresDropSubscriptionExpression(dialect, "sub1", cascade=True).to_sql() - assert sql == 'DROP SUBSCRIPTION "sub1" CASCADE' - - -class TestPostgresAlterColumnUsingExpression: - """ALTER COLUMN ... SET DATA TYPE ... USING.""" - - @pytest.fixture - def dialect(self): - return PostgresDialect(version=(14, 0, 0)) - - def test_using_clause(self, dialect): - action = AlterColumn( - dialect, - "price", - "SET DATA TYPE", - new_value="NUMERIC(10,2)", - dialect_options={"using": Column(dialect, "price") + Literal(dialect, 1)}, - ) - sql, serialized = dialect.format_alter_column_action(action) - assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql - assert 'USING ("price" + %s)' in sql - assert serialized == (1,) - - def test_using_rejected_for_non_set_data_type(self, dialect): - """USING is only valid on SET DATA TYPE.""" - action = AlterColumn( - dialect, - "price", - "SET DEFAULT", - new_value="0", - dialect_options={"using": Column(dialect, "price")}, - ) - with pytest.raises(ValueError, match="USING"): - dialect.format_alter_column_action(action) - - def test_without_using_unaffected(self, dialect): - """No USING -> output matches the standard form.""" - action = AlterColumn( - dialect, - "price", - "SET DATA TYPE", - new_value="NUMERIC(10,2)", - ) - sql, _ = dialect.format_alter_column_action(action) - assert sql == 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' 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 new file mode 100644 index 00000000..b8032b8e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py @@ -0,0 +1,495 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +"""Tests for PostgreSQL DDL expression classes. + +This module tests the expression-based format methods for DDL operations, +including materialized view refresh, comment, and partition expressions. +""" + +import pytest + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresVacuumExpression, # noqa: F401 + PostgresAnalyzeExpression, # noqa: F401 + AlterDomainActionType, + PostgresCreateDomainExpression, + PostgresAlterDomainExpression, + PostgresDropDomainExpression, + PostgresCreateCollationExpression, + PostgresDropCollationExpression, + PostgresCreateForeignTableExpression, + PostgresDropForeignTableExpression, + PostgresCreateFunctionExpression, + PostgresDropFunctionExpression, + PostgresCreateAggregateExpression, + PostgresDropAggregateExpression, + PostgresCreatePublicationExpression, + PostgresDropPublicationExpression, + PostgresCreateSubscriptionExpression, + PostgresDropSubscriptionExpression, +) +from rhosocial.activerecord.backend.impl.postgres.mixins.dml.extended_statistics import ( + PostgresExtendedStatisticsMixin, # noqa: F401 +) + +class TestPostgresDomainExpression: + """CREATE / ALTER / DROP DOMAIN.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create(self, dialect): + sql, _ = PostgresCreateDomainExpression( + dialect, "posint", "NUMERIC", + default="0", constraints=["CHECK (VALUE > 0)"], + ).to_sql() + assert sql == "CREATE DOMAIN \"posint\" AS NUMERIC DEFAULT 0 CHECK (VALUE > 0)" + + def test_alter_set_default(self, dialect): + sql, _ = PostgresAlterDomainExpression( + dialect, "posint", AlterDomainActionType.SET_DEFAULT, new_value="0" + ).to_sql() + assert sql == 'ALTER DOMAIN "posint" SET DEFAULT 0' + + def test_alter_rename(self, dialect): + sql, _ = PostgresAlterDomainExpression( + dialect, "posint", AlterDomainActionType.RENAME_TO, new_name="posint2" + ).to_sql() + assert sql == 'ALTER DOMAIN "posint" RENAME TO "posint2"' + + def test_drop(self, dialect): + sql, _ = PostgresDropDomainExpression( + dialect, "posint", if_exists=True, cascade=True + ).to_sql() + assert sql == 'DROP DOMAIN IF EXISTS "posint" CASCADE' + + def test_create_with_schema_and_collation(self, dialect): + sql, _ = PostgresCreateDomainExpression( + dialect, "posint", "NUMERIC", + schema="app", collation="C", + ).to_sql() + assert sql == 'CREATE DOMAIN "app"."posint" AS NUMERIC 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)' + + def test_alter_drop_default(self, dialect): + sql, _ = PostgresAlterDomainExpression( + dialect, "posint", AlterDomainActionType.DROP_DEFAULT + ).to_sql() + assert sql == 'ALTER DOMAIN "posint" DROP DEFAULT' + + def test_alter_set_default_with_schema(self, dialect): + sql, _ = PostgresAlterDomainExpression( + dialect, "posint", AlterDomainActionType.SET_DEFAULT, + schema="app", new_value="1", + ).to_sql() + assert sql == 'ALTER DOMAIN "app"."posint" SET DEFAULT 1' + + def test_alter_unsupported_action_raises(self, dialect): + expr = PostgresAlterDomainExpression(dialect, "posint", "RANDOM") + with pytest.raises(ValueError, match="Unsupported ALTER DOMAIN"): + expr.to_sql() + + def test_drop_restrict(self, dialect): + sql, _ = PostgresDropDomainExpression( + dialect, "posint", schema="app", restrict=True + ).to_sql() + assert sql == 'DROP DOMAIN "app"."posint" RESTRICT' + + def test_drop_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropDomainExpression( + dialect, "posint", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_version_gate_96(self): + d = PostgresDialect(version=(9, 5, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresCreateDomainExpression(d, "posint", "INTEGER").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresAlterDomainExpression( + d, "posint", AlterDomainActionType.SET_DEFAULT, new_value="0" + ).to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropDomainExpression(d, "posint").to_sql() + + +class TestPostgresCollationDDLExpression: + """CREATE / DROP COLLATION DDL.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create(self, dialect): + sql, _ = PostgresCreateCollationExpression( + dialect, "enloc", locale="en_US.UTF-8", provider="libc" + ).to_sql() + assert sql == 'CREATE COLLATION "enloc" (LOCALE = en_US.UTF-8, PROVIDER = libc)' + + def test_create_if_not_exists(self, dialect): + sql, _ = PostgresCreateCollationExpression( + dialect, "myloc", if_not_exists=True, lc_collate="en_US", + ).to_sql() + assert sql == 'CREATE COLLATION IF NOT EXISTS "myloc" (LC_COLLATE = en_US)' + + def test_drop(self, dialect): + sql, _ = PostgresDropCollationExpression( + dialect, "enloc", if_exists=True + ).to_sql() + assert sql == 'DROP COLLATION IF EXISTS "enloc"' + + def test_create_with_schema_and_all_params(self, dialect): + sql, _ = PostgresCreateCollationExpression( + dialect, "custom", schema="app", if_not_exists=True, + locale="en_US.UTF-8", lc_collate="en_US", lc_ctype="en_US", + provider="icu", version="153.14", + ).to_sql() + assert sql == ( + 'CREATE COLLATION IF NOT EXISTS "app"."custom" ' + '(LOCALE = en_US.UTF-8, LC_COLLATE = en_US, LC_CTYPE = en_US, ' + 'PROVIDER = icu, VERSION = 153.14)' + ) + + def test_create_without_params(self, dialect): + sql, _ = PostgresCreateCollationExpression(dialect, "bare").to_sql() + assert sql == 'CREATE COLLATION "bare"' + + def test_drop_restrict_with_schema(self, dialect): + sql, _ = PostgresDropCollationExpression( + dialect, "enloc", schema="app", restrict=True + ).to_sql() + assert sql == 'DROP COLLATION "app"."enloc" RESTRICT' + + def test_drop_cascade(self, dialect): + sql, _ = PostgresDropCollationExpression( + dialect, "enloc", cascade=True + ).to_sql() + assert sql == 'DROP COLLATION "enloc" CASCADE' + + def test_drop_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropCollationExpression( + dialect, "enloc", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_version_gate_96(self): + d = PostgresDialect(version=(9, 5, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresCreateCollationExpression(d, "c").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropCollationExpression(d, "c").to_sql() + + +class TestPostgresForeignTableDDLExpression: + """CREATE / DROP FOREIGN TABLE.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create(self, dialect): + sql, _ = PostgresCreateForeignTableExpression( + dialect, "ft", "srv", columns=["a integer", "b text"], + options=["host 'h'"], + ).to_sql() + assert sql == ( + 'CREATE FOREIGN TABLE "ft" (a integer, b text) ' + 'SERVER "srv" OPTIONS (host \'h\')' + ) + + def test_drop(self, dialect): + sql, _ = PostgresDropForeignTableExpression( + dialect, "ft", if_exists=True, cascade=True + ).to_sql() + assert sql == 'DROP FOREIGN TABLE IF EXISTS "ft" CASCADE' + + def test_create_if_not_exists_schema(self, dialect): + sql, _ = PostgresCreateForeignTableExpression( + dialect, "ft", "srv", schema="app", if_not_exists=True, + columns=["a integer"], + ).to_sql() + assert sql == ( + 'CREATE FOREIGN TABLE IF NOT EXISTS "app"."ft" (a integer) ' + 'SERVER "srv"' + ) + + def test_create_without_columns_or_options(self, dialect): + sql, _ = PostgresCreateForeignTableExpression( + dialect, "bar_ft", "fdw_bar" + ).to_sql() + assert sql == 'CREATE FOREIGN TABLE "bar_ft" SERVER "fdw_bar"' + + def test_drop_restrict_with_schema(self, dialect): + sql, _ = PostgresDropForeignTableExpression( + dialect, "ft", schema="app", restrict=True + ).to_sql() + assert sql == 'DROP FOREIGN TABLE "app"."ft" RESTRICT' + + def test_drop_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropForeignTableExpression( + dialect, "ft", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_version_gate_96(self): + d = PostgresDialect(version=(9, 5, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresCreateForeignTableExpression(d, "ft", "srv").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropForeignTableExpression(d, "ft").to_sql() + + +class TestPostgresRoutineDDLExpression: + """CREATE / DROP FUNCTION and AGGREGATE.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create_function(self, dialect): + sql, _ = PostgresCreateFunctionExpression( + dialect, "add", "integer", "int 1;", + args=["a integer"], strict=True, security="DEFINER", + ).to_sql() + assert sql == ( + 'CREATE FUNCTION "add"(a integer) RETURNS integer ' + 'STRICT SECURITY DEFINER LANGUAGE plpgsql AS $$ int 1; $$' + ) + + def test_drop_function(self, dialect): + sql, _ = PostgresDropFunctionExpression( + dialect, "add", args=["integer"], cascade=True + ).to_sql() + assert sql == 'DROP FUNCTION "add" (integer) CASCADE' + + def test_create_aggregate(self, dialect): + sql, _ = PostgresCreateAggregateExpression( + dialect, "mysum", "sum", "integer", initcond="0" + ).to_sql() + assert sql == 'CREATE AGGREGATE "mysum" (SFUNC=sum, STYPE=integer, INITCOND=0)' + + def test_drop_aggregate(self, dialect): + sql, _ = PostgresDropAggregateExpression( + dialect, "mysum", "integer", if_exists=True + ).to_sql() + assert sql == 'DROP AGGREGATE IF EXISTS "mysum" (integer)' + + def test_create_function_full_options(self, dialect): + sql, _ = PostgresCreateFunctionExpression( + dialect, "add", "integer", "int 1;", schema="app", + args=["a integer", "b integer"], or_replace=True, + security="INVOKER", cost=10.0, rows=50, + ).to_sql() + assert 'CREATE OR REPLACE FUNCTION "app"."add"' in sql + assert "(a integer, b integer)" in sql + assert "SECURITY INVOKER" in sql + assert "COST 10.0" in sql + assert "ROWS 50" in sql + + def test_create_function_no_args_no_strict(self, dialect): + sql, _ = PostgresCreateFunctionExpression( + dialect, "now", "timestamptz", "SELECT now();", language="sql" + ).to_sql() + assert sql == ( + 'CREATE FUNCTION "now"() RETURNS timestamptz LANGUAGE sql ' + 'AS $$ SELECT now(); $$' + ) + + def test_create_function_invalid_security(self, dialect): + expr = PostgresCreateFunctionExpression( + dialect, "f", "integer", "int 1;", security="OWNER" + ) + with pytest.raises(ValueError, match="DEFINER"): + expr.to_sql() + + def test_drop_function_restrict(self, dialect): + sql, _ = PostgresDropFunctionExpression( + dialect, "add", schema="app", if_exists=True, restrict=True + ).to_sql() + assert sql == 'DROP FUNCTION IF EXISTS "app"."add" RESTRICT' + + def test_drop_function_without_args(self, dialect): + sql, _ = PostgresDropFunctionExpression( + dialect, "add", schema="app" + ).to_sql() + assert sql == 'DROP FUNCTION "app"."add"' + + def test_drop_function_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropFunctionExpression( + dialect, "add", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_create_aggregate_full(self, dialect): + sql, _ = PostgresCreateAggregateExpression( + dialect, "mysum", "sum", "integer", schema="app", + finalfunc="mysum_final", + ).to_sql() + assert sql == ( + 'CREATE AGGREGATE "app"."mysum" ' + '(SFUNC=sum, STYPE=integer, FINALFUNC=mysum_final)' + ) + + def test_drop_aggregate_cascade(self, dialect): + sql, _ = PostgresDropAggregateExpression( + dialect, "mysum", "integer", schema="app", cascade=True + ).to_sql() + assert sql == 'DROP AGGREGATE "app"."mysum" (integer) CASCADE' + + def test_drop_aggregate_restrict(self, dialect): + sql, _ = PostgresDropAggregateExpression( + dialect, "mysum", "integer", if_exists=True, restrict=True + ).to_sql() + assert sql == 'DROP AGGREGATE IF EXISTS "mysum" (integer) RESTRICT' + + def test_drop_aggregate_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropAggregateExpression( + dialect, "mysum", "integer", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_routine_version_gate_96(self): + d = PostgresDialect(version=(9, 5, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresCreateFunctionExpression( + d, "f", "integer", "int 1;" + ).to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropFunctionExpression(d, "f").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresCreateAggregateExpression(d, "s", "sum", "integer").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropAggregateExpression(d, "s", "integer").to_sql() + + +class TestPostgresPublicationExpression: + """CREATE / DROP PUBLICATION and SUBSCRIPTION.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create_publication_tables(self, dialect): + sql, _ = PostgresCreatePublicationExpression( + dialect, "pub1", tables=["orders", "users"] + ).to_sql() + assert sql == 'CREATE PUBLICATION "pub1" FOR TABLE "orders", "users"' + + def test_create_publication_all_tables(self, dialect): + sql, _ = PostgresCreatePublicationExpression( + dialect, "pub_all", all_tables=True, + options=["publish='insert'"], + ).to_sql() + assert sql == ( + 'CREATE PUBLICATION "pub_all" FOR ALL TABLES WITH (publish=\'insert\')' + ) + + def test_drop_publication(self, dialect): + sql, _ = PostgresDropPublicationExpression( + dialect, "pub1", if_exists=True + ).to_sql() + assert sql == 'DROP PUBLICATION IF EXISTS "pub1"' + + def test_create_subscription(self, dialect): + sql, _ = PostgresCreateSubscriptionExpression( + dialect, "sub1", "host=db port=5432", ["pub1"] + ).to_sql() + assert sql == ( + 'CREATE SUBSCRIPTION "sub1" CONNECTION \'host=db port=5432\' ' + 'PUBLICATION "pub1"' + ) + + def test_drop_subscription(self, dialect): + sql, _ = PostgresDropSubscriptionExpression(dialect, "sub1", cascade=True).to_sql() + assert sql == 'DROP SUBSCRIPTION "sub1" CASCADE' + + def test_create_publication_tables_options(self, dialect): + sql, _ = PostgresCreatePublicationExpression( + dialect, "pub1", tables=["orders"], + options=["publish='insert'"], + ).to_sql() + assert sql == ( + 'CREATE PUBLICATION "pub1" FOR TABLE "orders" ' + "WITH (publish='insert')" + ) + + def test_create_publication_tables_and_all_raises(self, dialect): + expr = PostgresCreatePublicationExpression( + dialect, "pub1", tables=["orders"], all_tables=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_create_publication_neither_raises(self, dialect): + expr = PostgresCreatePublicationExpression(dialect, "pub1") + with pytest.raises(ValueError, match="requires either"): + expr.to_sql() + + def test_drop_publication_cascade(self, dialect): + sql, _ = PostgresDropPublicationExpression( + dialect, "pub1", cascade=True + ).to_sql() + assert sql == 'DROP PUBLICATION "pub1" CASCADE' + + def test_drop_publication_restrict(self, dialect): + sql, _ = PostgresDropPublicationExpression( + dialect, "pub1", restrict=True + ).to_sql() + assert sql == 'DROP PUBLICATION "pub1" RESTRICT' + + def test_drop_publication_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropPublicationExpression( + dialect, "pub1", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_drop_publication_if_exists(self, dialect): + sql, _ = PostgresDropPublicationExpression( + dialect, "pub1", if_exists=True + ).to_sql() + assert sql == 'DROP PUBLICATION IF EXISTS "pub1"' + + def test_create_subscription_options(self, dialect): + sql, _ = PostgresCreateSubscriptionExpression( + dialect, "sub2", "host=db port=5432", ["pub1", "pub2"], + options=["copy_data = false"], + ).to_sql() + assert sql == ( + 'CREATE SUBSCRIPTION "sub2" CONNECTION \'host=db port=5432\' ' + 'PUBLICATION "pub1", "pub2" WITH (copy_data = false)' + ) + + def test_drop_subscription_if_exists(self, dialect): + sql, _ = PostgresDropSubscriptionExpression( + dialect, "sub1", if_exists=True + ).to_sql() + assert sql == 'DROP SUBSCRIPTION IF EXISTS "sub1"' + + def test_pubsub_version_gate_10(self): + d = PostgresDialect(version=(9, 6, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresCreatePublicationExpression( + d, "pub1", tables=["orders"] + ).to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropPublicationExpression(d, "pub1").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresCreateSubscriptionExpression(d, "s", "conn", ["p"]).to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropSubscriptionExpression(d, "s").to_sql() + From 0b81bde2635e630d1e13ee5a02a00682de5522b0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 8 Aug 2026 15:17:06 +0800 Subject: [PATCH 5/5] chore(backend): clean up unused imports and long-line style in DDL code paths Static-check pass over source and tests touched by the DDL coverage work: - src: drop unused imports (types, ddl/__init__, named_expressions/partition), fix E501 in examples, restore noqa where intentional - tests: remove unused imports, add E402 noqa to late imports, drop unused backend binding var No behavioral change.; src and changed tests pass ruff. --- .../backend/impl/postgres/examples/ddl/alter_table.py | 2 +- .../backend/impl/postgres/examples/ddl/materialized_view.py | 2 +- .../impl/postgres/examples/schema_diff/index_type_diff.py | 2 +- .../examples/schema_diff/serialization_roundtrip.py | 2 +- .../backend/impl/postgres/expression/ddl/__init__.py | 1 + .../activerecord/backend/impl/postgres/expression/types.py | 3 --- .../backend/impl/postgres/named_expressions/partition.py | 5 ++--- tests/providers/basic.py | 4 ++-- tests/providers/events.py | 4 ++-- tests/providers/mixins.py | 4 ++-- tests/providers/query.py | 6 +++--- tests/providers/relation.py | 3 +-- .../feature/backend/postgres/test_core_type_mappings.py | 3 +-- .../backend/postgres/test_property_graph_query_scenarios.py | 3 +-- .../feature/backend/test_partition_advanced_operations.py | 2 -- .../feature/backend/test_partition_explain.py | 2 +- .../feature/basic/connection/test_pool_transaction_paths.py | 2 +- .../feature/basic/transaction/test_async_owner_task.py | 1 - .../feature/relation/fixtures/models.py | 2 +- .../activerecord_postgres_test/feature/test_features.py | 1 - .../feature/test_features_async.py | 1 - 21 files changed, 22 insertions(+), 33 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py index ce68ffae..117c989b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py @@ -149,7 +149,7 @@ # Verify table structure from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression -from rhosocial.activerecord.backend.expression.core import WildcardExpression, Column +from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.expression.query_parts import OrderByClause verify_expr = QueryExpression( 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 1ef87074..2aec6dd9 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 @@ -156,7 +156,7 @@ }, ) sql, params = create_mv.to_sql() -print(f"Create MV with column aliases and storage options:") +print("Create MV with column aliases and storage options:") print(f" SQL: {sql}") backend.execute(sql, params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py index ca9c7a29..eb2cc5d2 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py @@ -19,7 +19,7 @@ ColumnConstraint, ColumnConstraintType, ) from rhosocial.activerecord.backend.expression.types import ( - VarCharType, ArrayType, IntegerType, TextType, + VarCharType, ArrayType, TextType, ) from rhosocial.activerecord.backend.impl.postgres.expression.types import ( PostgresSerialType, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py index b630b4f0..fb41ddd0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py @@ -107,7 +107,7 @@ assert col_b.data_type.__class__.__name__ == col_a.data_type.__class__.__name__, ( f"type mismatch in {name}.{col_b.name}" ) -print(f"Table data roundtrip: all columns and types match") +print("Table data roundtrip: all columns and types match") print(f"Added tables: {diff.added_tables}") print(f"Removed tables: {diff.removed_tables}") print(f"Modified tables: {diff.modified_tables}") 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 ae8a9149..1c4be6a0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -177,6 +177,7 @@ "PolicyType", "PolicyCommand", "AlterPolicyMode", + "PostgresCreatePolicyExpression", "PostgresAlterPolicyExpression", "PostgresDropPolicyExpression", # rls_config diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py index 5a1508fd..53d43944 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py @@ -23,9 +23,6 @@ ArrayType, BlobType, DataType, - IntegerType, - SmallIntType, - BigIntType, VarCharType, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/partition.py index d8a2f623..2da88248 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/partition.py @@ -15,13 +15,12 @@ - PostgreSQL Partitioning: https://www.postgresql.org/docs/current/ddl-partitioning.html """ -from datetime import date, datetime, timedelta -from typing import Dict, Optional, Tuple +from datetime import date +from typing import Optional, Tuple from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( PostgresCreatePartitionExpression, - PostgresPartitionMetadataExpression, ) diff --git a/tests/providers/basic.py b/tests/providers/basic.py index 8347b96e..964849be 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -27,8 +27,8 @@ from rhosocial.activerecord.testsuite.utils import select_fixture # noqa: E402 # Expression-based DDL fixtures -from providers.fixtures._common import drop_table -from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_TABLE_EXPRESSIONS +from providers.fixtures._common import drop_table # noqa: E402 +from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_TABLE_EXPRESSIONS # noqa: E402 # Import base version models (Python 3.8+) from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ( # noqa: E402 diff --git a/tests/providers/events.py b/tests/providers/events.py index 0b11a7ca..b53f7499 100644 --- a/tests/providers/events.py +++ b/tests/providers/events.py @@ -87,8 +87,8 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s from .scenarios import get_enabled_scenarios, get_scenario # noqa: E402 # Expression-based DDL fixtures -from .fixtures._common import drop_table -from .fixtures.events import TABLE_EXPRESSIONS as EVENTS_TABLE_EXPRESSIONS +from .fixtures._common import drop_table # noqa: E402 +from .fixtures.events import TABLE_EXPRESSIONS as EVENTS_TABLE_EXPRESSIONS # noqa: E402 class EventsProviderBase: diff --git a/tests/providers/mixins.py b/tests/providers/mixins.py index 149fd6f8..72afb884 100644 --- a/tests/providers/mixins.py +++ b/tests/providers/mixins.py @@ -104,8 +104,8 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s from .scenarios import get_enabled_scenarios, get_scenario # noqa: E402 # Expression-based DDL fixtures -from .fixtures._common import drop_table -from .fixtures.mixins import TABLE_EXPRESSIONS as MIXINS_TABLE_EXPRESSIONS +from .fixtures._common import drop_table # noqa: E402 +from .fixtures.mixins import TABLE_EXPRESSIONS as MIXINS_TABLE_EXPRESSIONS # noqa: E402 class MixinsProviderBase: diff --git a/tests/providers/query.py b/tests/providers/query.py index 2bf80f61..209121c0 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -13,7 +13,7 @@ import os import sys import logging -from typing import Type, List, Tuple +from typing import Set, Type, List, Tuple from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord @@ -115,8 +115,8 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s ) # Expression-based DDL fixtures -from providers.fixtures._common import drop_table -from providers.fixtures.query import TABLE_EXPRESSIONS as QUERY_TABLE_EXPRESSIONS +from providers.fixtures._common import drop_table # noqa: E402 +from providers.fixtures.query import TABLE_EXPRESSIONS as QUERY_TABLE_EXPRESSIONS # noqa: E402 # The scenarios are defined specifically for this backend. from .scenarios import get_enabled_scenarios, get_scenario # noqa: E402 diff --git a/tests/providers/relation.py b/tests/providers/relation.py index d556166c..f58fb93d 100644 --- a/tests/providers/relation.py +++ b/tests/providers/relation.py @@ -1,8 +1,7 @@ # tests/providers/relation.py -import asyncio from typing import Dict, List, Tuple, Type, Set -from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend from rhosocial.activerecord.backend.impl.postgres.adapters.json import PostgresJSONBAdapter from rhosocial.activerecord.testsuite.feature.relation.interfaces import IRelationSyncProvider, IRelationAsyncProvider diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py index 2c8914ed..7dd8ea9e 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py @@ -2,7 +2,6 @@ import pytest -from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin @pytest.fixture @@ -133,7 +132,7 @@ def test_array_equality(self, dialect): PostgresArrayType, ) from rhosocial.activerecord.backend.expression.types import ( - ArrayType, IntegerType, VarCharType, + IntegerType, VarCharType, ) a1 = PostgresArrayType(IntegerType(), 2) a2 = PostgresArrayType(IntegerType(), 2) 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 49f5e03c..c36e307f 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 @@ -24,8 +24,7 @@ WhereClause, OrderByClause, GroupByHavingClause, LimitOffsetClause, ) from rhosocial.activerecord.backend.expression.core import Column -from rhosocial.activerecord.backend.expression.query_parts import JoinExpression, JoinType -from rhosocial.activerecord.backend.expression.literals import Identifier +from rhosocial.activerecord.backend.expression.query_parts import JoinExpression from rhosocial.activerecord.backend.expression.types import ( IntegerType, TextType, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py index fd4677d8..95649d01 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime from typing import Any, Iterable import pytest @@ -319,7 +318,6 @@ def test_default_attach_and_detach_workflow(self, postgres_backend, postgres_ran default_partition_name = "ar_partition_adv_default" # Create a table with matching structure for DEFAULT attachment - from rhosocial.activerecord.backend.expression.statements import DropTableExpression, TableConstraint create_default = CreateTableExpression( dialect=dialect, table=default_partition_name, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py index 772cfba6..e8a5bdbb 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Dict +from typing import Any import pytest import pytest_asyncio diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_pool_transaction_paths.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_pool_transaction_paths.py index ef5c2c63..95ffd60b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_pool_transaction_paths.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_pool_transaction_paths.py @@ -94,7 +94,7 @@ def test_tx_token_set_during_path2_and_cleared_after(self, sync_pool_and_model): def test_path2_commits_visible_after_exit(self, sync_pool_and_model): pool, model = sync_pool_and_model with pool.connection(): - with pool.transaction() as backend: + with pool.transaction(): instance = model(name="Path2User", email="p2@test.local") instance.save() # Connection-only context exited; transaction must already be committed. diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_async_owner_task.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_async_owner_task.py index baca4538..a27119c9 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_async_owner_task.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_async_owner_task.py @@ -24,7 +24,6 @@ """ import asyncio import warnings -from typing import Tuple import pytest diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py index 1f02c19f..3e1f957f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py @@ -15,7 +15,7 @@ from rhosocial.activerecord.base import DerivedField, FieldProxy from rhosocial.activerecord.backend.expression import Column, Literal from rhosocial.activerecord.backend.expression.functions import ( - json_extract_text, length, concat, coalesce + json_extract_text, length, coalesce ) from rhosocial.activerecord.relation.descriptors import BelongsTo, HasMany, HasOne from rhosocial.activerecord.relation.async_descriptors import AsyncBelongsTo, AsyncHasMany, AsyncHasOne diff --git a/tests/rhosocial/activerecord_postgres_test/feature/test_features.py b/tests/rhosocial/activerecord_postgres_test/feature/test_features.py index 7e775fa3..b84fe49d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/test_features.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/test_features.py @@ -3,7 +3,6 @@ import pytest from rhosocial.activerecord.testsuite.utils import ( - requires_protocol, requires_functions, requires_window_functions, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/test_features_async.py b/tests/rhosocial/activerecord_postgres_test/feature/test_features_async.py index 24f83a03..9baf541b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/test_features_async.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/test_features_async.py @@ -3,7 +3,6 @@ import pytest from rhosocial.activerecord.testsuite.utils import ( - requires_protocol, requires_functions, requires_window_functions, )