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 == ()