Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/27.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add PostgreSQL constraint support protocol with EXCLUDE and NOT VALID options
130 changes: 130 additions & 0 deletions src/rhosocial/activerecord/backend/impl/postgres/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
SetOperationMixin,
TruncateMixin,
ILIKEMixin,
ConstraintMixin,
)
from rhosocial.activerecord.backend.dialect.protocols import (
CTESupport,
Expand Down Expand Up @@ -65,6 +66,7 @@
ILIKESupport,
IntrospectionSupport,
TransactionControlSupport,
ConstraintSupport,
)
from .mixins import (
PostgresExtensionMixin,
Expand Down Expand Up @@ -92,6 +94,7 @@
PostgresTriggerMixin,
PostgresCommentMixin,
PostgresTypeMixin,
PostgresConstraintMixin,
# Type mixins
EnumTypeMixin,
TypesDataTypeMixin,
Expand Down Expand Up @@ -131,6 +134,7 @@
PostgresTriggerSupport,
PostgresCommentSupport,
PostgresTypeSupport,
PostgresConstraintSupport,
# Type feature protocols
MultirangeSupport,
EnumTypeSupport,
Expand Down Expand Up @@ -189,6 +193,7 @@ class PostgresDialect(
IndexMixin,
SequenceMixin,
TableMixin,
ConstraintMixin,
# Introspection capability
PostgresIntrospectionCapabilityMixin,
# PostgreSQL-specific mixins
Expand Down Expand Up @@ -217,6 +222,7 @@ class PostgresDialect(
PostgresTriggerMixin,
PostgresCommentMixin,
PostgresTypeMixin,
PostgresConstraintMixin,
# Type mixins
EnumTypeMixin,
TypesDataTypeMixin,
Expand Down Expand Up @@ -252,6 +258,7 @@ class PostgresDialect(
IndexSupport,
SequenceSupport,
TableSupport,
ConstraintSupport,
# Introspection protocol
IntrospectionSupport,
# Transaction control protocol
Expand Down Expand Up @@ -282,6 +289,7 @@ class PostgresDialect(
PostgresTriggerSupport,
PostgresCommentSupport,
PostgresTypeSupport,
PostgresConstraintSupport,
# Type feature protocols
MultirangeSupport,
EnumTypeSupport,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
from .trigger import PostgresTriggerMixin
from .comment import PostgresCommentMixin
from .type import PostgresTypeMixin
from .constraint import PostgresConstraintMixin

__all__ = [
"PostgresPartitionMixin",
"PostgresIndexMixin",
"PostgresTriggerMixin",
"PostgresCommentMixin",
"PostgresTypeMixin",
"PostgresConstraintMixin",
]
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,6 +63,7 @@
"PostgresTriggerSupport",
"PostgresCommentSupport",
"PostgresTypeSupport",
"PostgresConstraintSupport",
# DML protocols
"PostgresVacuumSupport",
"PostgresStoredProcedureSupport",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
from .trigger import PostgresTriggerSupport
from .comment import PostgresCommentSupport
from .type import PostgresTypeSupport
from .constraint import PostgresConstraintSupport

__all__ = [
"PostgresPartitionSupport",
"PostgresIndexSupport",
"PostgresTriggerSupport",
"PostgresCommentSupport",
"PostgresTypeSupport",
"PostgresConstraintSupport",
]
Original file line number Diff line number Diff line change
@@ -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).
"""
...
Loading