diff --git a/changelog.d/27.added.md b/changelog.d/27.added.md new file mode 100644 index 00000000..7be3a867 --- /dev/null +++ b/changelog.d/27.added.md @@ -0,0 +1 @@ +Add PostgreSQL constraint support protocol with EXCLUDE and NOT VALID options \ 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 47289d70..31bcd428 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -35,6 +35,7 @@ SetOperationMixin, TruncateMixin, ILIKEMixin, + ConstraintMixin, ) from rhosocial.activerecord.backend.dialect.protocols import ( CTESupport, @@ -65,6 +66,7 @@ ILIKESupport, IntrospectionSupport, TransactionControlSupport, + ConstraintSupport, ) from .mixins import ( PostgresExtensionMixin, @@ -92,6 +94,7 @@ PostgresTriggerMixin, PostgresCommentMixin, PostgresTypeMixin, + PostgresConstraintMixin, # Type mixins EnumTypeMixin, TypesDataTypeMixin, @@ -131,6 +134,7 @@ PostgresTriggerSupport, PostgresCommentSupport, PostgresTypeSupport, + PostgresConstraintSupport, # Type feature protocols MultirangeSupport, EnumTypeSupport, @@ -189,6 +193,7 @@ class PostgresDialect( IndexMixin, SequenceMixin, TableMixin, + ConstraintMixin, # Introspection capability PostgresIntrospectionCapabilityMixin, # PostgreSQL-specific mixins @@ -217,6 +222,7 @@ class PostgresDialect( PostgresTriggerMixin, PostgresCommentMixin, PostgresTypeMixin, + PostgresConstraintMixin, # Type mixins EnumTypeMixin, TypesDataTypeMixin, @@ -252,6 +258,7 @@ class PostgresDialect( IndexSupport, SequenceSupport, TableSupport, + ConstraintSupport, # Introspection protocol IntrospectionSupport, # Transaction control protocol @@ -282,6 +289,7 @@ class PostgresDialect( PostgresTriggerSupport, PostgresCommentSupport, PostgresTypeSupport, + PostgresConstraintSupport, # Type feature protocols MultirangeSupport, EnumTypeSupport, @@ -890,6 +898,16 @@ def supports_table_tablespace(self) -> bool: """Whether tablespace specification is supported.""" return True + # Constraint capability overrides + + def supports_constraint_enforced(self) -> bool: + """PostgreSQL does not support ENFORCED/NOT ENFORCED (SQL:2016). + + PostgreSQL uses NOT VALID as a proprietary alternative for + skipping validation of existing data when adding constraints. + """ + return False + def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: """ Format CREATE TABLE statement for PostgreSQL, including LIKE syntax support. @@ -1088,6 +1106,118 @@ def format_cast_expression( # endregion + # region Constraint DDL Support (PostgreSQL-specific) + + def format_add_table_constraint_action( + self, action: "AddTableConstraint", + ) -> Tuple[str, tuple]: + """Format ADD CONSTRAINT action with PostgreSQL-specific extensions. + + Extends the base class implementation with: + - EXCLUDE constraint support (PG-specific constraint type) + - NOT VALID suffix (PG-specific: skip validation of existing rows) + """ + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraintType, ConstraintValidation, + ) + + # Handle EXCLUDE constraint (PG-specific, not in base class) + if action.constraint.constraint_type == TableConstraintType.EXCLUDE: + parts = [] + exclude_sql, params = self._format_exclude_constraint(action.constraint) + parts.append(exclude_sql) + + # NOT VALID suffix + if action.constraint.dialect_options: + validation = action.constraint.dialect_options.get('validation') + if validation == ConstraintValidation.NOVALIDATE: + parts.append("NOT VALID") + + return f"ADD {' '.join(parts)}", tuple(params) + + # Use base class for standard formatting (includes DEFERRABLE) + sql, params = super().format_add_table_constraint_action(action) + + # PostgreSQL NOT VALID suffix + if action.constraint.dialect_options: + validation = action.constraint.dialect_options.get('validation') + if validation == ConstraintValidation.NOVALIDATE: + sql += " NOT VALID" + + return sql, params + + def _format_exclude_constraint( + self, constraint: "TableConstraint", + ) -> Tuple[str, tuple]: + """Format EXCLUDE constraint (PostgreSQL-specific). + + EXCLUDE constraints use the dialect_options dict to specify: + - 'exclude_elements': List of (expression, operator) tuples + e.g., [('range', '&&')] for EXCLUDE USING gist (range WITH &&) + - 'using': The index access method (default 'gist') + e.g., 'gist', 'btree', 'spgist' + - 'where': Optional predicate for partial exclusion constraints + + Example: + TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name='exclude_range_overlap', + dialect_options={ + 'exclude_elements': [('range', '&&')], + 'using': 'gist', + } + ) + # Generates: EXCLUDE USING gist (range WITH &&) + """ + parts = [] + params: list = [] + + if constraint.name: + parts.append(f"CONSTRAINT {self.format_identifier(constraint.name)}") + + # USING clause + using = 'gist' # default + if constraint.dialect_options and 'using' in constraint.dialect_options: + using = constraint.dialect_options['using'] + parts.append(f"EXCLUDE USING {using}") + + # Elements: (expression, operator) pairs + exclude_elements = [] + if constraint.dialect_options and 'exclude_elements' in constraint.dialect_options: + for expr, op in constraint.dialect_options['exclude_elements']: + if isinstance(expr, str): + exclude_elements.append(f"{self.format_identifier(expr)} WITH {op}") + else: + # expr is a BaseExpression + expr_sql, expr_params = expr.to_sql() + params.extend(expr_params) + exclude_elements.append(f"{expr_sql} WITH {op}") + + if exclude_elements: + parts.append(f"({', '.join(exclude_elements)})") + + # WHERE clause for partial exclusion constraint + if constraint.dialect_options and 'where' in constraint.dialect_options: + where_expr = constraint.dialect_options['where'] + where_sql, where_params = where_expr.to_sql() + params.extend(where_params) + parts.append(f"WHERE ({where_sql})") + + # DEFERRABLE / NOT DEFERRABLE + if constraint.deferrable is True: + if constraint.initially_deferred is True: + parts.append("DEFERRABLE INITIALLY DEFERRED") + elif constraint.initially_deferred is False: + parts.append("DEFERRABLE INITIALLY IMMEDIATE") + else: + parts.append("DEFERRABLE") + elif constraint.deferrable is False: + parts.append("NOT DEFERRABLE") + + return ' '.join(parts), tuple(params) + + # endregion + # region Trigger Support (PostgreSQL-specific) def supports_trigger(self) -> bool: return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index fe2be7ba..07c80018 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -21,6 +21,7 @@ from .ddl.trigger import PostgresTriggerMixin from .ddl.comment import PostgresCommentMixin from .ddl.type import PostgresTypeMixin +from .ddl.constraint import PostgresConstraintMixin # DML mixins from .dml.vacuum import PostgresVacuumMixin 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 59910b89..e4ed4e7a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py @@ -6,6 +6,7 @@ from .trigger import PostgresTriggerMixin from .comment import PostgresCommentMixin from .type import PostgresTypeMixin +from .constraint import PostgresConstraintMixin __all__ = [ "PostgresPartitionMixin", @@ -13,4 +14,5 @@ "PostgresTriggerMixin", "PostgresCommentMixin", "PostgresTypeMixin", + "PostgresConstraintMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py new file mode 100644 index 00000000..a936f54e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py @@ -0,0 +1,43 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py +"""PostgreSQL-specific constraint capability defaults. + +Provides default implementations for PostgreSQL-proprietary constraint +features that are not part of the SQL standard. + +SQL standard constraint capability defaults are defined in the core +ConstraintMixin (rhosocial.activerecord.backend.dialect.mixins). + +Note: The format_add_table_constraint_action method is defined directly +in PostgresDialect rather than in this mixin, because Python's C3 MRO +places SQLDialectBase before mixins in the inheritance chain, which +prevents mixin methods from overriding base class methods of the same name. +This mixin provides capability detection defaults only. +""" + + +class PostgresConstraintMixin: + """PostgreSQL-proprietary constraint capability defaults. + + These features are PostgreSQL-specific and not part of the SQL standard. + Both NOT VALID and EXCLUDE constraints are supported by all PostgreSQL + versions, so they default to True. + + SQL standard constraint capability defaults are in ConstraintMixin. + """ + + def supports_constraint_novalidate(self) -> bool: + """NOT VALID constraint option (PostgreSQL-proprietary). + + Allows adding a constraint without validating existing data. + Supported by all PostgreSQL versions. + """ + return True + + def supports_exclude_constraint(self) -> bool: + """EXCLUDE constraints (PostgreSQL-proprietary). + + Exclusion constraints prevent overlapping rows on specified + columns using specified operators. + Supported by all PostgreSQL versions. + """ + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py index fa6d1adf..56ab0f7c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py @@ -20,6 +20,7 @@ from .ddl.trigger import PostgresTriggerSupport from .ddl.comment import PostgresCommentSupport from .ddl.type import PostgresTypeSupport +from .ddl.constraint import PostgresConstraintSupport # DML protocols from .dml.vacuum import PostgresVacuumSupport @@ -62,6 +63,7 @@ "PostgresTriggerSupport", "PostgresCommentSupport", "PostgresTypeSupport", + "PostgresConstraintSupport", # 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 b32febca..b35ad690 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py @@ -6,6 +6,7 @@ from .trigger import PostgresTriggerSupport from .comment import PostgresCommentSupport from .type import PostgresTypeSupport +from .constraint import PostgresConstraintSupport __all__ = [ "PostgresPartitionSupport", @@ -13,4 +14,5 @@ "PostgresTriggerSupport", "PostgresCommentSupport", "PostgresTypeSupport", + "PostgresConstraintSupport", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py new file mode 100644 index 00000000..b18f8a5e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py @@ -0,0 +1,55 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py +"""PostgreSQL-proprietary constraint capability protocol definitions. + +This module contains the PostgresConstraintSupport protocol which defines +the interface for PostgreSQL's proprietary constraint features that are +not part of the SQL standard. + +SQL standard constraint capabilities are defined in the core +ConstraintSupport protocol (rhosocial.activerecord.backend.dialect.protocols). + +PostgreSQL-proprietary features: +- NOT VALID (add constraint without validating existing data) +- EXCLUDE constraints +""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresConstraintSupport(Protocol): + """PostgreSQL-proprietary constraint capabilities protocol. + + These features are PostgreSQL-specific and not part of the SQL standard. + SQL standard constraint features (PRIMARY KEY, UNIQUE, CHECK, FK, etc.) + are defined in the core ConstraintSupport protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - NOT VALID: https://www.postgresql.org/docs/current/sql-altertable.html + - EXCLUDE: https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-EXCLUSION + """ + + def supports_constraint_novalidate(self) -> bool: + """Whether NOT VALID constraint option is supported. + + PostgreSQL-proprietary feature. Allows adding a constraint + without validating existing data, then validating later + with VALIDATE CONSTRAINT. + + Native feature, all PostgreSQL versions. + """ + ... + + def supports_exclude_constraint(self) -> bool: + """Whether EXCLUDE constraints are supported. + + PostgreSQL-proprietary feature. Exclusion constraints ensure + that no two rows overlap on specified columns using specified + operators (e.g., && for range overlap). + + Native feature, all PostgreSQL versions. + Requires appropriate operator class (e.g., btree_gist for gist). + """ + ...