diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7a2b0170..32d571af 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -220,7 +220,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" test-experimental-python-315: - # Temporarily disabled until Python 3.15 beta/toolchain compatibility stabilizes. + # Temporarily disabled until Python 3.15 toolchain compatibility stabilizes. if: ${{ false }} name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} (experimental) runs-on: ubuntu-latest @@ -231,8 +231,8 @@ jobs: fail-fast: false matrix: include: - - { python-version: '3.15.0-beta.2', postgres-version: '18', freethreaded: false } - - { python-version: '3.15.0-beta.2', postgres-version: '18', freethreaded: true } + - { python-version: '3.15.0-rc.1', postgres-version: '18', freethreaded: false } + - { python-version: '3.15.0-rc.1', postgres-version: '18', freethreaded: true } services: postgres: diff --git a/changelog.d/51.added.md b/changelog.d/51.added.md new file mode 100644 index 00000000..30375662 --- /dev/null +++ b/changelog.d/51.added.md @@ -0,0 +1 @@ +Added `PostgresAlterColumnModifierMixin` so PostgreSQL renders the vendor `IF [NOT] EXISTS` qualifiers on ALTER TABLE actions: `ADD COLUMN IF NOT EXISTS`, `DROP COLUMN IF EXISTS`, and `DROP CONSTRAINT IF EXISTS` (all supported since PostgreSQL 9.6). The qualifiers are opt-in via `if_not_exists` / `if_exists` on `AddColumn` / `DropColumn` / `DropTableConstraint`; `None` (default) renders the plain SQL-standard form. Also added the `supports_drop_constraint_if_exists` capability to `PostgresConstraintSupport` / `PostgresConstraintMixin`. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index c2127666..2508c5d5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -178,6 +178,7 @@ PostgresLockingMixin, # Introspection capability mixin PostgresIntrospectionCapabilityMixin, + PostgresAlterColumnModifierMixin, ) from .collation import validate_postgres_collation_name @@ -348,6 +349,7 @@ class PostgresDialect( # PostgreSQL-specific mixins PostgresExtensionMixin, PostgresMaterializedViewMixin, + PostgresAlterColumnModifierMixin, # Before TableMixin/ConstraintMixin to override format_*_action PostgresTableMixin, # Before TableMixin to override supports_table_like_syntax TableMixin, ConstraintMixin, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index 77981ee9..7ff74432 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -22,6 +22,7 @@ from .ddl.comment import PostgresCommentMixin from .ddl.type import PostgresTypeMixin from .ddl.constraint import PostgresConstraintMixin +from .ddl.column import PostgresAlterColumnModifierMixin # DML mixins from .dml.vacuum import PostgresVacuumMixin @@ -116,6 +117,7 @@ "PostgresCommentMixin", "PostgresTypeMixin", "PostgresConstraintMixin", + "PostgresAlterColumnModifierMixin", # 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 e4ed4e7a..2822601d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py @@ -7,6 +7,7 @@ from .comment import PostgresCommentMixin from .type import PostgresTypeMixin from .constraint import PostgresConstraintMixin +from .column import PostgresAlterColumnModifierMixin __all__ = [ "PostgresPartitionMixin", @@ -15,4 +16,5 @@ "PostgresCommentMixin", "PostgresTypeMixin", "PostgresConstraintMixin", + "PostgresAlterColumnModifierMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py new file mode 100644 index 00000000..b94cd11b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py @@ -0,0 +1,52 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py +"""PostgreSQL ALTER TABLE IF [NOT] EXISTS qualifier formatting.""" + +from typing import Tuple + + +class PostgresAlterColumnModifierMixin: + """PostgreSQL IF [NOT] EXISTS qualifiers for ALTER TABLE actions. + + PostgreSQL has supported these qualifiers since 9.6: + - ADD [COLUMN] [IF NOT EXISTS] + - DROP [COLUMN] [IF EXISTS] + - DROP CONSTRAINT [IF EXISTS] + + The qualifiers are vendor extensions (not in ISO/IEC 9075-2 §11.10); + applications opt in by passing ``if_not_exists`` / ``if_exists`` on + the corresponding ``AddColumn`` / ``DropColumn`` / + ``DropTableConstraint`` actions. ``None`` (default) means "no + qualifier", matching the SQL standard form. + """ + + def supports_add_column_if_not_exists(self) -> bool: + """``ADD COLUMN IF NOT EXISTS`` is supported since PostgreSQL 9.6.""" + return True + + def supports_drop_column_if_exists(self) -> bool: + """``DROP COLUMN IF EXISTS`` is supported since PostgreSQL 9.6.""" + return True + + def supports_drop_constraint_if_exists(self) -> bool: + """``DROP CONSTRAINT IF EXISTS`` is supported since PostgreSQL 9.6.""" + return True + + def format_add_column_action(self, action) -> Tuple[str, tuple]: + column_sql, column_params = self.format_column_definition(action.column) + if getattr(action, "if_not_exists", None) is True: + return f"ADD COLUMN IF NOT EXISTS {column_sql}", column_params + return f"ADD COLUMN {column_sql}", column_params + + def format_drop_column_action(self, action) -> Tuple[str, tuple]: + if getattr(action, "if_exists", None) is True: + return f"DROP COLUMN IF EXISTS {self.format_identifier(action.column_name)}", () + return f"DROP COLUMN {self.format_identifier(action.column_name)}", () + + def format_drop_table_constraint_action(self, action) -> Tuple[str, tuple]: + result = "DROP CONSTRAINT" + if getattr(action, "if_exists", None) is True: + result = f"{result} IF EXISTS" + result = f"{result} {self.format_identifier(action.constraint_name)}" + if getattr(action, "cascade", None): + result = f"{result} CASCADE" + return result, () diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py index a936f54e..213e74f5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py @@ -41,3 +41,11 @@ def supports_exclude_constraint(self) -> bool: Supported by all PostgreSQL versions. """ return True + + def supports_drop_constraint_if_exists(self) -> bool: + """``DROP CONSTRAINT IF EXISTS`` (vendor extension). + + PostgreSQL has supported the ``IF EXISTS`` qualifier on + ``DROP CONSTRAINT`` since 9.6. + """ + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py index 9e2d2109..7f324ac0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py @@ -55,3 +55,11 @@ def supports_exclude_constraint(self) -> bool: Requires appropriate operator class (e.g., btree_gist for gist). """ ... + + def supports_drop_constraint_if_exists(self) -> bool: + """Whether ``DROP CONSTRAINT IF EXISTS`` is supported. + + Vendor extension (not in ISO/IEC 9075-2 §11.10). PostgreSQL has + supported it since 9.6. + """ + ... diff --git a/tests/providers/basic.py b/tests/providers/basic.py index 0889f99a..8347b96e 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -195,6 +195,17 @@ def get_test_scenarios(self) -> List[str]: def get_yes_no_adapter(self) -> 'BaseSQLTypeAdapter': return YesOrNoBooleanAdapter() + def get_dialect(self, scenario_name: str = "default"): + """Return a bare, fully-constructed PostgreSQL dialect instance. + + Used by the ``feature/basic/ddl`` subtopic (expression/dialect + contract). ``(16, 0, 0)`` advertises support for all three + ``IF [NOT] EXISTS`` modifiers (available since PG 9.6). + """ + from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + return PostgresDialect(version=(16, 0, 0)) + def _track_backend(self, backend_instance, collection) -> None: if backend_instance not in collection: collection.append(backend_instance) @@ -374,6 +385,10 @@ def __init__(self): super().__init__() self._active_async_backends = [] + async def get_dialect(self, scenario_name: str = "default"): + """Async mirror of ``BasicProviderBase.get_dialect``.""" + return super().get_dialect(scenario_name) + async def _setup_async_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend _, config = get_scenario(scenario_name) 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 new file mode 100644 index 00000000..a22f4eb7 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py @@ -0,0 +1,109 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py +"""Tests for ALTER TABLE IF [NOT] EXISTS qualifier rendering on PostgreSQL. + +PostgreSQL supports the vendor extensions since 9.6: + - ADD COLUMN IF NOT EXISTS + - DROP COLUMN IF EXISTS + - DROP CONSTRAINT IF EXISTS + +The qualifiers are opt-in via ``if_not_exists`` / ``if_exists`` on the +``AddColumn`` / ``DropColumn`` / ``DropTableConstraint`` actions; ``None`` +(the default) renders the plain SQL-standard form. +""" + +import pytest + +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddColumn, + AlterTableExpression, + DropColumn, + DropTableConstraint, +) +from rhosocial.activerecord.backend.expression.types import TextType +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +@pytest.fixture +def dialect(): + return PostgresDialect(version=(15, 0, 0)) + + +class TestPostgresAlterTableModifierCapabilities: + def test_supports_switches(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 + + +class TestPostgresAddColumnIfNotExists: + def test_if_not_exists_renders_qualifier(self, dialect): + action = AddColumn( + dialect, + ColumnDefinition("content", TextType()), + if_not_exists=True, + ) + sql, params = action.to_sql() + assert 'ADD COLUMN IF NOT EXISTS "content" TEXT' == sql + assert params == () + + def test_none_renders_plain_form(self, dialect): + action = AddColumn(dialect, ColumnDefinition("content", TextType())) + sql, params = action.to_sql() + assert 'ADD COLUMN "content" TEXT' == sql + assert "IF NOT EXISTS" not in sql + assert params == () + + def test_inside_alter_table(self, dialect): + action = AddColumn( + dialect, + ColumnDefinition("content", TextType()), + if_not_exists=True, + ) + expr = AlterTableExpression( + dialect, table_name="users", actions=[action] + ) + sql, params = expr.to_sql() + assert 'ALTER TABLE "users"' in sql + assert 'ADD COLUMN IF NOT EXISTS "content" TEXT' in sql + assert params == () + + +class TestPostgresDropColumnIfExists: + def test_if_exists_renders_qualifier(self, dialect): + action = DropColumn(dialect, column_name="x", if_exists=True) + sql, params = action.to_sql() + assert 'DROP COLUMN IF EXISTS "x"' == sql + assert params == () + + def test_none_renders_plain_form(self, dialect): + action = DropColumn(dialect, column_name="x") + sql, params = action.to_sql() + assert 'DROP COLUMN "x"' == sql + assert "IF EXISTS" not in sql + assert params == () + + +class TestPostgresDropConstraintIfExists: + def test_if_exists_renders_qualifier(self, dialect): + action = DropTableConstraint( + dialect, constraint_name="fkey", if_exists=True + ) + sql, params = action.to_sql() + assert 'DROP CONSTRAINT IF EXISTS "fkey"' == sql + assert params == () + + def test_if_exists_with_cascade(self, dialect): + action = DropTableConstraint( + dialect, constraint_name="fkey", if_exists=True, cascade=True + ) + sql, params = action.to_sql() + assert 'DROP CONSTRAINT IF EXISTS "fkey" CASCADE' == sql + assert params == () + + def test_none_renders_plain_form(self, dialect): + action = DropTableConstraint(dialect, constraint_name="fkey") + sql, params = action.to_sql() + assert 'DROP CONSTRAINT "fkey"' == sql + assert "IF EXISTS" not in sql + assert params == () diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/conftest.py new file mode 100644 index 00000000..7cc74555 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/conftest.py @@ -0,0 +1,10 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/conftest.py +""" +Pytest configuration for the basic/ddl ALTER TABLE tests. + +This file imports fixtures from the corresponding testsuite subtopic, making +them (and the ``requires_protocol`` protocol-check autouse fixture) available +to the tests in this directory. +""" + +from rhosocial.activerecord.testsuite.feature.basic.ddl.conftest import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic.py new file mode 100644 index 00000000..6602a2f9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic.py @@ -0,0 +1,9 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists.py +""" +ALTER TABLE IF [NOT] EXISTS tests (sync) for the PostgreSQL backend. + +Thin bridge that runs the shared testsuite contract against the PostgreSQL +dialect, which supports all three modifiers (PG >= 9.6). +""" + +from rhosocial.activerecord.testsuite.feature.basic.ddl.test_alter_table_if_exists import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic_async.py new file mode 100644 index 00000000..b23bfe37 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic_async.py @@ -0,0 +1,8 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists_async.py +""" +ALTER TABLE IF [NOT] EXISTS tests (async) for the PostgreSQL backend. + +Async mirror of ``test_alter_table_if_exists.py``. +""" + +from rhosocial.activerecord.testsuite.feature.basic.ddl.test_alter_table_if_exists_async import * # noqa: E501, F401, F403 \ No newline at end of file