From c9b61641ae40b5f3f50888ff3195af8ad8ec9b6f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 6 Aug 2026 19:55:51 +0800 Subject: [PATCH 1/5] feat(backend): render ALTER TABLE IF [NOT] EXISTS qualifiers for PostgreSQL Add PostgresAlterColumnModifierMixin so PostgreSQL emits the vendor qualifiers supported since 9.6: ADD COLUMN IF NOT EXISTS, DROP COLUMN IF EXISTS, and DROP CONSTRAINT IF EXISTS, opt-in via if_not_exists / if_exists on AddColumn / DropColumn / DropTableConstraint. Wire the mixin into PostgresDialect ahead of TableMixin/ConstraintMixin and add the supports_drop_constraint_if_exists capability to PostgresConstraintSupport and PostgresConstraintMixin. Add unit tests covering qualifier and plain forms. --- changelog.d/50.added.md | 1 + .../backend/impl/postgres/dialect.py | 2 + .../backend/impl/postgres/mixins/__init__.py | 2 + .../impl/postgres/mixins/ddl/__init__.py | 2 + .../impl/postgres/mixins/ddl/column.py | 52 +++++++++ .../impl/postgres/mixins/ddl/constraint.py | 8 ++ .../impl/postgres/protocols/ddl/constraint.py | 8 ++ .../dialect/test_alter_table_if_exists.py | 109 ++++++++++++++++++ 8 files changed, 184 insertions(+) create mode 100644 changelog.d/50.added.md create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py diff --git a/changelog.d/50.added.md b/changelog.d/50.added.md new file mode 100644 index 00000000..30375662 --- /dev/null +++ b/changelog.d/50.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/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 == () From 6e6efc0f5dbb41e6c7234625878061a071152418 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 7 Aug 2026 03:18:52 +0800 Subject: [PATCH 2/5] chore(changelog): rename fragment 50 to new PR #51 --- changelog.d/{50.added.md => 51.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{50.added.md => 51.added.md} (100%) diff --git a/changelog.d/50.added.md b/changelog.d/51.added.md similarity index 100% rename from changelog.d/50.added.md rename to changelog.d/51.added.md From f6c9a71dfa07e37c39db93c5d2e367fff5844b98 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 7 Aug 2026 10:58:03 +0800 Subject: [PATCH 3/5] ci: update Python 3.15 beta-2 to rc.1 in test matrix --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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: From 53a6b254fe66196d4ff1ff05ce8246310c6d749f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 7 Aug 2026 12:10:20 +0800 Subject: [PATCH 4/5] test: add feature/basic/ddl mirror with provider get_dialect for postgres --- tests/providers/basic.py | 15 +++++++++++++++ .../feature/basic/ddl/conftest.py | 10 ++++++++++ .../basic/ddl/test_alter_table_if_exists.py | 9 +++++++++ .../basic/ddl/test_alter_table_if_exists_async.py | 8 ++++++++ 4 files changed, 42 insertions(+) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/conftest.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists_async.py 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/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_if_exists.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists.py new file mode 100644 index 00000000..6602a2f9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists.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_if_exists_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists_async.py new file mode 100644 index 00000000..b23bfe37 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists_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 From 02cb9243f22deaf6228919b5cfbd0228928ff12d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 7 Aug 2026 12:26:18 +0800 Subject: [PATCH 5/5] test: rename basic/ddl mirror basename to avoid pytest import collision with backend dialect test --- .../{test_alter_table_if_exists.py => test_alter_table_basic.py} | 0 ...r_table_if_exists_async.py => test_alter_table_basic_async.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/{test_alter_table_if_exists.py => test_alter_table_basic.py} (100%) rename tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/{test_alter_table_if_exists_async.py => test_alter_table_basic_async.py} (100%) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic.py similarity index 100% rename from tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists.py rename to tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic.py diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic_async.py similarity index 100% rename from tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists_async.py rename to tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic_async.py