From a7639baa30389fa92e500b052818dcc2aeb3c2de Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 11 Apr 2026 14:55:21 +0800 Subject: [PATCH 1/3] feat(postgres): add constraint DDL support with EXCLUDE and NOT VALID Add PostgreSQL-specific constraint DDL formatting directly in PostgresDialect (not via mixin override, due to Python C3 MRO placing SQLDialectBase before mixins): - EXCLUDE constraint support with USING, elements, WHERE, DEFERRABLE - NOT VALID suffix for skipping validation of existing rows - format_add_table_constraint_action overrides base class for PG extensions - PostgresConstraintMixin retained as organizational placeholder Co-Authored-By: Claude Opus 4.6 --- .../backend/impl/postgres/dialect.py | 114 ++++++++++++++++++ .../backend/impl/postgres/mixins/__init__.py | 1 + .../impl/postgres/mixins/ddl/__init__.py | 2 + .../impl/postgres/mixins/ddl/constraint.py | 23 ++++ 4 files changed, 140 insertions(+) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 47289d70..7bf700c3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -92,6 +92,7 @@ PostgresTriggerMixin, PostgresCommentMixin, PostgresTypeMixin, + PostgresConstraintMixin, # Type mixins EnumTypeMixin, TypesDataTypeMixin, @@ -217,6 +218,7 @@ class PostgresDialect( PostgresTriggerMixin, PostgresCommentMixin, PostgresTypeMixin, + PostgresConstraintMixin, # Type mixins EnumTypeMixin, TypesDataTypeMixin, @@ -1088,6 +1090,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..3ee8aea3 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py @@ -0,0 +1,23 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py +"""PostgreSQL-specific constraint DDL support. + +Provides PostgreSQL-specific constraint features: +- NOT VALID (add constraint without validating existing data) +- EXCLUDE constraints + +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 is retained for organizational consistency within the ddl package. +""" + + +class PostgresConstraintMixin: + """PostgreSQL-specific constraint formatting placeholder. + + The actual constraint formatting logic resides in PostgresDialect + for MRO compatibility. See PostgresDialect.format_add_table_constraint_action + and PostgresDialect._format_exclude_constraint. + """ + pass From 0ea068eb4c55cd04cbe7037ee791660424de004a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 11 Apr 2026 18:42:29 +0800 Subject: [PATCH 2/3] feat(backend): add PostgreSQL constraint support protocol - Add PostgresConstraintSupport protocol for PostgreSQL-proprietary constraint features (NOT VALID, EXCLUDE constraints) - Implement supports_constraint_enforced() returning False (PostgreSQL uses NOT VALID instead of ENFORCED/NOT ENFORCED) - Add supports_constraint_novalidate() and supports_exclude_constraint() to PostgresConstraintMixin --- .../backend/impl/postgres/dialect.py | 16 ++++++ .../impl/postgres/mixins/ddl/constraint.py | 40 ++++++++++---- .../impl/postgres/protocols/__init__.py | 2 + .../impl/postgres/protocols/ddl/__init__.py | 2 + .../impl/postgres/protocols/ddl/constraint.py | 55 +++++++++++++++++++ 5 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 7bf700c3..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, @@ -132,6 +134,7 @@ PostgresTriggerSupport, PostgresCommentSupport, PostgresTypeSupport, + PostgresConstraintSupport, # Type feature protocols MultirangeSupport, EnumTypeSupport, @@ -190,6 +193,7 @@ class PostgresDialect( IndexMixin, SequenceMixin, TableMixin, + ConstraintMixin, # Introspection capability PostgresIntrospectionCapabilityMixin, # PostgreSQL-specific mixins @@ -254,6 +258,7 @@ class PostgresDialect( IndexSupport, SequenceSupport, TableSupport, + ConstraintSupport, # Introspection protocol IntrospectionSupport, # Transaction control protocol @@ -284,6 +289,7 @@ class PostgresDialect( PostgresTriggerSupport, PostgresCommentSupport, PostgresTypeSupport, + PostgresConstraintSupport, # Type feature protocols MultirangeSupport, EnumTypeSupport, @@ -892,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. 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 3ee8aea3..a936f54e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py @@ -1,23 +1,43 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py -"""PostgreSQL-specific constraint DDL support. +"""PostgreSQL-specific constraint capability defaults. -Provides PostgreSQL-specific constraint features: -- NOT VALID (add constraint without validating existing data) -- EXCLUDE constraints +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 is retained for organizational consistency within the ddl package. +This mixin provides capability detection defaults only. """ class PostgresConstraintMixin: - """PostgreSQL-specific constraint formatting placeholder. + """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. - The actual constraint formatting logic resides in PostgresDialect - for MRO compatibility. See PostgresDialect.format_add_table_constraint_action - and PostgresDialect._format_exclude_constraint. + SQL standard constraint capability defaults are in ConstraintMixin. """ - pass + + 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). + """ + ... From e99e3068350cd4b24e64fecea6378d2abd7b6edb Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 11 Apr 2026 20:18:37 +0800 Subject: [PATCH 3/3] chore: add changelog fragment for PR #27 --- changelog.d/27.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/27.added.md 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