diff --git a/changelog.d/39.fixed.md b/changelog.d/39.fixed.md new file mode 100644 index 00000000..45e2b046 --- /dev/null +++ b/changelog.d/39.fixed.md @@ -0,0 +1 @@ +Fill all PostgreSQL index, trigger, table, and extension protocol feature coverage gaps diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 2e5a24f5..a92c3823 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -1029,12 +1029,42 @@ def supports_unique_index(self) -> bool: def supports_index_if_not_exists(self) -> bool: """Whether CREATE INDEX IF NOT EXISTS is supported.""" - return True # PostgreSQL 9.5+ supports IF NOT EXISTS + return self.version >= (9, 5, 0) # PostgreSQL 9.5+ supports IF NOT EXISTS def supports_index_if_exists(self) -> bool: """Whether DROP INDEX IF EXISTS is supported.""" return True + def format_add_index_action(self, action) -> Tuple[str, tuple]: + """PostgreSQL does not support ALTER TABLE ADD INDEX. + + Raises UnsupportedFeatureError — use CREATE INDEX instead. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE ADD INDEX", + suggestion="Use CREATE INDEX to create an index on the table.", + ) + + def format_drop_index_action(self, action) -> Tuple[str, tuple]: + """PostgreSQL does not support ALTER TABLE DROP INDEX. + + Raises UnsupportedFeatureError — use DROP INDEX statement instead. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE DROP INDEX", + suggestion="Use DROP INDEX statement to remove an index.", + ) + # endregion # region Sequence Support @@ -1051,7 +1081,7 @@ def supports_drop_sequence(self) -> bool: # region Table Support def supports_if_not_exists_table(self) -> bool: """Whether CREATE TABLE IF NOT EXISTS is supported.""" - return True + return self.version >= (9, 5, 0) def supports_if_exists_table(self) -> bool: """Whether DROP TABLE IF EXISTS is supported.""" @@ -1067,7 +1097,7 @@ def supports_table_inheritance(self) -> bool: def supports_table_partitioning(self) -> bool: """Whether table partitioning is supported.""" - return True # PostgreSQL supports partitioning + return self.version >= (10, 0, 0) def supports_table_tablespace(self) -> bool: """Whether tablespace specification is supported.""" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 3f1ec5f9..19cf265d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -25,6 +25,10 @@ PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, + PostgresCreateIndexExpression, + PostgresDropIndexExpression, + PostgresAlterIndexExpression, + PostgresAlterIndexActionType, PostgresReindexExpression, PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, @@ -56,6 +60,10 @@ "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", "PostgresAttachPartitionExpression", + "PostgresCreateIndexExpression", + "PostgresDropIndexExpression", + "PostgresAlterIndexExpression", + "PostgresAlterIndexActionType", "PostgresReindexExpression", "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index c5047369..0a5589a7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -19,7 +19,13 @@ PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, ) -from .index import PostgresReindexExpression +from .index import ( + PostgresCreateIndexExpression, + PostgresDropIndexExpression, + PostgresAlterIndexExpression, + PostgresAlterIndexActionType, + PostgresReindexExpression, +) from .statistics import ( PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, @@ -45,6 +51,10 @@ "PostgresDetachPartitionExpression", "PostgresAttachPartitionExpression", # index + "PostgresCreateIndexExpression", + "PostgresDropIndexExpression", + "PostgresAlterIndexExpression", + "PostgresAlterIndexActionType", "PostgresReindexExpression", # statistics "PostgresCreateStatisticsExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py index a46060e6..e9271067 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py @@ -1,25 +1,224 @@ # src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py """ -PostgreSQL DDL expressions: Index operations (REINDEX). +PostgreSQL DDL expressions: Index operations (CREATE/DROP/ALTER INDEX, REINDEX). PostgreSQL Documentation: +- CREATE INDEX: https://www.postgresql.org/docs/current/sql-createindex.html +- DROP INDEX: https://www.postgresql.org/docs/current/sql-dropindex.html +- ALTER INDEX: https://www.postgresql.org/docs/current/sql-alterindex.html - REINDEX: https://www.postgresql.org/docs/current/sql-reindex.html Version Requirements: - REINDEX: PostgreSQL 8.0+ +- DROP INDEX CONCURRENTLY: PostgreSQL 18+ - CONCURRENTLY option: PostgreSQL 12+ - TABLESPACE option: PostgreSQL 14+ +- NULLS NOT DISTINCT: PostgreSQL 15+ +- CONCURRENTLY + NULLS NOT DISTINCT: PostgreSQL 16+ """ -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple, Union, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements.ddl_index import ( + CreateIndexExpression, + DropIndexExpression, +) -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression import SQLPredicate -__all__ = ["PostgresReindexExpression"] +__all__ = [ + "PostgresCreateIndexExpression", + "PostgresDropIndexExpression", + "PostgresAlterIndexExpression", + "PostgresAlterIndexActionType", + "PostgresReindexExpression", +] + + +class PostgresCreateIndexExpression(CreateIndexExpression): + """PostgreSQL-specific CREATE INDEX expression. + + Extends the standard CreateIndexExpression with PostgreSQL-specific + features such as NULLS NOT DISTINCT. + + The option is stored in dialect_options under the key ``"nulls_not_distinct"`` + and is consumed by ``PostgresIndexMixin.format_create_index_statement``. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((15, 0, 0)) + >>> idx = PostgresCreateIndexExpression( + ... dialect=dialect, + ... index_name="idx_uniq_abc", + ... table_name="t", + ... columns=["a", "b", "c"], + ... unique=True, + ... nulls_not_distinct_unique=True, + ... ) + >>> sql, params = idx.to_sql() + >>> sql + 'CREATE UNIQUE INDEX "idx_uniq_abc" ON "t" ("a", "b", "c") NULLS NOT DISTINCT' + """ + + def __init__( + self, + dialect: "SQLDialectBase", + index_name: str, + table_name: str, + columns: List[Union[str, "BaseExpression"]], + unique: bool = False, + if_not_exists: bool = False, + index_type: Optional[str] = None, + where: Optional["SQLPredicate"] = None, + include: Optional[List[str]] = None, + tablespace: Optional[str] = None, + concurrent: bool = False, + nulls_not_distinct_unique: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + merged_options = dict(dialect_options or {}) + if nulls_not_distinct_unique: + merged_options["nulls_not_distinct"] = True + super().__init__( + dialect=dialect, + index_name=index_name, + table_name=table_name, + columns=columns, + unique=unique, + if_not_exists=if_not_exists, + index_type=index_type, + where=where, + include=include, + tablespace=tablespace, + concurrent=concurrent, + dialect_options=merged_options, + ) + + def to_sql(self) -> Tuple[str, tuple]: + return self.dialect.format_create_index_statement(self) + + +class PostgresDropIndexExpression(DropIndexExpression): + """PostgreSQL-specific DROP INDEX expression. + + Extends the standard DropIndexExpression with PostgreSQL-specific + features such as CONCURRENTLY (PG 18+). + + ``concurrent`` is stored in ``dialect_options["concurrent"]`` and + consumed by ``PostgresIndexMixin.format_drop_index_statement``. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((18, 0, 0)) + >>> expr = PostgresDropIndexExpression( + ... dialect=dialect, + ... index_name="idx_old", + ... concurrent=True, + ... ) + >>> sql, params = expr.to_sql() + >>> sql + 'DROP INDEX CONCURRENTLY "idx_old"' + """ + + def __init__( + self, + dialect: "SQLDialectBase", + index_name: str, + table_name: Optional[str] = None, + if_exists: bool = False, + concurrent: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + merged_options = dict(dialect_options or {}) + if concurrent: + merged_options["concurrent"] = True + super().__init__( + dialect=dialect, + index_name=index_name, + table_name=table_name, + if_exists=if_exists, + dialect_options=merged_options, + ) + + def to_sql(self) -> Tuple[str, tuple]: + return self.dialect.format_drop_index_statement(self) + + +class PostgresAlterIndexActionType(Enum): + """Action types for ALTER INDEX statement.""" + + RENAME_TO = "RENAME TO" + SET_TABLESPACE = "SET TABLESPACE" + SET_STORAGE_PARAMETERS = "SET" + RESET_STORAGE_PARAMETERS = "RESET" + ALTER_COLUMN_STATISTICS = "ALTER COLUMN SET STATISTICS" + ALL_IN_TABLESPACE = "ALL IN TABLESPACE" + + +class PostgresAlterIndexExpression(BaseExpression): + """PostgreSQL ALTER INDEX statement expression. + + Supports all ALTER INDEX operations: + - Rename index + - Move index to a different tablespace + - Set/reset storage parameters + - Alter per-column statistics target + - Bulk tablespace move with ALL IN TABLESPACE + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> # Rename index + >>> expr = PostgresAlterIndexExpression( + ... dialect, "idx_old", + ... action_type=PostgresAlterIndexActionType.RENAME_TO, + ... new_name="idx_new", + ... ) + >>> sql, params = expr.to_sql() + >>> sql + 'ALTER INDEX "idx_old" RENAME TO "idx_new"' + """ + + def __init__( + self, + dialect: "SQLDialectBase", + index_name: str, + action_type: PostgresAlterIndexActionType, + *, + if_exists: bool = False, + new_name: Optional[str] = None, + tablespace: Optional[str] = None, + storage_parameters: Optional[Dict[str, Any]] = None, + column_number: Optional[int] = None, + statistics_target: Optional[int] = None, + source_tablespace: Optional[str] = None, + target_tablespace: Optional[str] = None, + nowait: bool = False, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.index_name = index_name + self.action_type = action_type + self.if_exists = if_exists + self.new_name = new_name + self.tablespace = tablespace + self.storage_parameters = storage_parameters + self.column_number = column_number + self.statistics_target = statistics_target + self.source_tablespace = source_tablespace + self.target_tablespace = target_tablespace + self.nowait = nowait + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + return self.dialect.format_alter_index_statement(self) class PostgresReindexExpression(BaseExpression): diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py index 97ff0d2d..f7e67fb9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py @@ -9,10 +9,14 @@ from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol -if TYPE_CHECKING: - from ...expression.ddl import PostgresReindexExpression +if TYPE_CHECKING: # pragma: no cover + from ...expression.ddl import ( + PostgresAlterIndexExpression, + PostgresReindexExpression, + ) from rhosocial.activerecord.backend.expression.statements.ddl_index import ( CreateIndexExpression, + DropIndexExpression, ) @@ -127,6 +131,109 @@ def supports_streaming_btree_index_build(self) -> bool: """ return self.version >= (18, 0, 0) + # ========================================================================= + # IndexSupport Protocol Overrides (all supported by PostgreSQL) + # ========================================================================= + + def supports_create_index(self) -> bool: + """CREATE INDEX is supported in all versions.""" + return True + + def supports_drop_index(self) -> bool: + """DROP INDEX is supported in all versions.""" + return True + + def supports_unique_index(self) -> bool: + """UNIQUE indexes are supported in all versions.""" + return True + + def supports_index_if_not_exists(self) -> bool: + """IF NOT EXISTS is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_index_if_exists(self) -> bool: + """DROP INDEX IF EXISTS is supported in all versions.""" + return True + + def supports_index_type(self) -> bool: + """PostgreSQL supports USING BTREE | HASH | GIST | GIN | SPGIST | BRIN.""" + return True + + def supports_partial_index(self) -> bool: + """PostgreSQL supports partial indexes with WHERE clause.""" + return True + + def supports_functional_index(self) -> bool: + """PostgreSQL supports expression/functional indexes.""" + return True + + def supports_index_include(self) -> bool: + """PostgreSQL supports INCLUDE clause (PG 11+ for btree, PG 12+ for GiST, PG 14+ for SP-GiST).""" + return self.version >= (11, 0, 0) + + def supports_index_tablespace(self) -> bool: + """PostgreSQL supports TABLESPACE for indexes.""" + return True + + def supports_concurrent_index(self) -> bool: + """CREATE INDEX CONCURRENTLY is supported since PostgreSQL 11. + + Note: CONCURRENTLY-only features may have higher version requirements + (e.g., CONCURRENTLY + NULLS NOT DISTINCT requires PG 16+). + """ + return self.version >= (11, 0, 0) + + def get_supported_index_types(self) -> List[str]: + """Return all index access methods supported by PostgreSQL.""" + return ["BTREE", "HASH", "GIST", "GIN", "SPGIST", "BRIN"] + + # ========================================================================= + # Fulltext methods — PostgreSQL does NOT use MySQL-style FULLTEXT indexes + # ========================================================================= + + def supports_fulltext_index(self) -> bool: + """PostgreSQL uses GIN index + tsvector/tsquery, not MySQL-style FULLTEXT.""" + return False + + def supports_fulltext_parser(self) -> bool: + """PostgreSQL does not have MySQL-style FULLTEXT parsers.""" + return False + + def supports_fulltext_boolean_mode(self) -> bool: + """PostgreSQL does not support MySQL-style FULLTEXT boolean mode.""" + return False + + def supports_fulltext_query_expansion(self) -> bool: + """PostgreSQL does not support MySQL-style FULLTEXT query expansion.""" + return False + + def format_fulltext_match(self, columns, search_term, mode=None) -> Tuple[str, Tuple]: + """Not supported — PostgreSQL uses tsvector @@ tsquery instead.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + "PostgreSQL", + "FULLTEXT MATCH", + suggestion="Use tsvector @@ tsquery for full-text search.", + ) + + def format_create_fulltext_index_statement(self, expr) -> Tuple[str, tuple]: + """Not supported — use CREATE INDEX with GIN and tsvector expression.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + "PostgreSQL", + "CREATE FULLTEXT INDEX", + suggestion="Use CREATE INDEX ... ON ... USING GIN (to_tsvector(...))", + ) + + def format_drop_fulltext_index_statement(self, expr) -> Tuple[str, tuple]: + """Not supported — use DROP INDEX instead.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + "PostgreSQL", + "DROP FULLTEXT INDEX", + suggestion="Use DROP INDEX to remove a GIN index.", + ) + def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[str, tuple]: """Format CREATE INDEX statement with PostgreSQL-specific options. @@ -134,6 +241,7 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ - Operator classes via dialect_options["opclasses"] - CONCURRENTLY clause - INCLUDE clause with version checks + - NULLS NOT DISTINCT via dialect_options["nulls_not_distinct"] """ all_params = [] parts = ["CREATE"] @@ -185,6 +293,18 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ include_cols = ", ".join(self.format_identifier(c) for c in expr.include) parts.append(f"INCLUDE ({include_cols})") + # NULLS NOT DISTINCT (PG 15+, requires UNIQUE) + if expr.dialect_options.get("nulls_not_distinct"): + if not expr.unique: + raise ValueError("NULLS NOT DISTINCT is only valid for UNIQUE indexes") + if expr.concurrent and not self.supports_concurrent_unique_nulls_not_distinct(): + raise ValueError( + "CONCURRENTLY + NULLS NOT DISTINCT requires PostgreSQL 16+" + ) + if not self.supports_nulls_not_distinct_unique(): + raise ValueError("NULLS NOT DISTINCT requires PostgreSQL 15+") + parts.append("NULLS NOT DISTINCT") + # WITH options via dialect_options with_options = expr.dialect_options.get("with") if with_options: @@ -201,6 +321,96 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ return " ".join(parts), tuple(all_params) + def format_drop_index_statement(self, expr: "DropIndexExpression") -> Tuple[str, tuple]: + """Format DROP INDEX statement with PostgreSQL-specific options. + + Extends the generic format_drop_index_statement to support: + - CONCURRENTLY clause (PG 18+) via dialect_options["concurrent"] + """ + parts = ["DROP INDEX"] + + if expr.dialect_options.get("concurrent"): + if not self.supports_streaming_btree_index_build(): + raise ValueError("DROP INDEX CONCURRENTLY requires PostgreSQL 18+") + parts.append("CONCURRENTLY") + + if expr.if_exists: + parts.append("IF EXISTS") + + parts.append(self.format_identifier(expr.index_name)) + + # table_name is ignored for PostgreSQL (unlike MySQL) + return " ".join(parts), () + + def format_alter_index_statement( + self, expr: "PostgresAlterIndexExpression" + ) -> Tuple[str, tuple]: + """Format ALTER INDEX statement with PostgreSQL-specific operations.""" + from ...expression.ddl import PostgresAlterIndexActionType + + parts = ["ALTER INDEX"] + action = expr.action_type + + # ALL IN TABLESPACE uses "ALL" instead of an index name + if action == PostgresAlterIndexActionType.ALL_IN_TABLESPACE: + if not expr.source_tablespace or not expr.target_tablespace: + raise ValueError( + "source_tablespace and target_tablespace are required " + "for ALL IN TABLESPACE action" + ) + parts.append("ALL IN TABLESPACE") + parts.append(self.format_identifier(expr.source_tablespace)) + parts.append("SET TABLESPACE") + parts.append(self.format_identifier(expr.target_tablespace)) + if expr.nowait: + parts.append("NOWAIT") + return " ".join(parts), () + + if expr.if_exists: + parts.append("IF EXISTS") + + parts.append(self.format_identifier(expr.index_name)) + + if action == PostgresAlterIndexActionType.RENAME_TO: + if not expr.new_name: + raise ValueError("new_name is required for RENAME TO action") + parts.append("RENAME TO") + parts.append(self.format_identifier(expr.new_name)) + + elif action == PostgresAlterIndexActionType.SET_TABLESPACE: + if not expr.tablespace: + raise ValueError("tablespace is required for SET TABLESPACE action") + parts.append("SET TABLESPACE") + parts.append(self.format_identifier(expr.tablespace)) + + elif action == PostgresAlterIndexActionType.SET_STORAGE_PARAMETERS: + if not expr.storage_parameters: + raise ValueError("storage_parameters is required for SET action") + opts = ", ".join( + f"{k} = {v}" for k, v in expr.storage_parameters.items() + ) + parts.append(f"SET ({opts})") + + elif action == PostgresAlterIndexActionType.RESET_STORAGE_PARAMETERS: + if not expr.storage_parameters: + raise ValueError("storage_parameters is required for RESET action") + opts = ", ".join(expr.storage_parameters.keys()) + parts.append(f"RESET ({opts})") + + elif action == PostgresAlterIndexActionType.ALTER_COLUMN_STATISTICS: + if expr.column_number is None or expr.statistics_target is None: + raise ValueError( + "column_number and statistics_target are required " + "for ALTER COLUMN SET STATISTICS action" + ) + parts.append(f"ALTER COLUMN {expr.column_number}") + parts.append(f"SET STATISTICS {expr.statistics_target}") + + else: + raise ValueError(f"Unsupported ALTER INDEX action: {action}") + + return " ".join(parts), () + def format_reindex_statement(self, expr: "PostgresReindexExpression") -> Tuple[str, tuple]: """Format REINDEX statement with PostgreSQL-specific options.""" target_type = expr.target_type.upper() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/trigger.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/trigger.py index e7d16d32..bea54c16 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/trigger.py @@ -9,6 +9,18 @@ class PostgresTriggerMixin: the SQL:1999 standard 'EXECUTE func_name'. """ + def supports_trigger(self) -> bool: + """Triggers are supported in all versions.""" + return True + + def supports_create_trigger(self) -> bool: + """CREATE TRIGGER is supported in all versions.""" + return True + + def supports_drop_trigger(self) -> bool: + """DROP TRIGGER is supported in all versions.""" + return True + def supports_trigger_referencing(self) -> bool: """REFERENCING clause is supported since PostgreSQL 10.""" return self.version >= (10, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py index b8f67f30..66c91fe9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py @@ -2,6 +2,26 @@ class PostgresTableMixin: """PostgreSQL table extended features implementation.""" + def supports_if_not_exists_table(self) -> bool: + """CREATE TABLE IF NOT EXISTS is supported since PostgreSQL 9.5+.""" + return self.version >= (9, 5, 0) + + def supports_if_exists_table(self) -> bool: + """DROP TABLE IF EXISTS is supported in all versions.""" + return True + + def supports_temporary_table(self) -> bool: + """TEMPORARY tables are supported in all versions.""" + return True + def supports_table_inheritance(self) -> bool: """PostgreSQL supports table inheritance.""" return True + + def supports_table_partitioning(self) -> bool: + """Table partitioning is supported since PostgreSQL 10.""" + return self.version >= (10, 0, 0) + + def supports_table_tablespace(self) -> bool: + """TABLESPACE specification is supported in all versions.""" + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py index 1d311f6f..da26564d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py @@ -5,12 +5,17 @@ that extend beyond standard SQL. """ +from enum import Enum from typing import Protocol, runtime_checkable, Optional, Tuple, List, Dict, Any, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.protocols import IndexSupport -if TYPE_CHECKING: - from ...expression.ddl import PostgresReindexExpression +if TYPE_CHECKING: # pragma: no cover + from ...expression.ddl import ( + PostgresAlterIndexExpression, + PostgresAlterIndexActionType, + PostgresReindexExpression, + ) @runtime_checkable @@ -53,7 +58,7 @@ def supports_safe_hash_index(self) -> bool: Native feature, PostgreSQL 10+. Hash indexes are now fully WAL-logged and crash-safe. """ - ... + ... # pragma: no cover def supports_parallel_create_index(self) -> bool: """Whether parallel B-tree index build is supported. @@ -61,7 +66,7 @@ def supports_parallel_create_index(self) -> bool: Native feature, PostgreSQL 11+. Enables parallel workers for B-tree index creation. """ - ... + ... # pragma: no cover def supports_gist_include(self) -> bool: """Whether GiST indexes support INCLUDE clause. @@ -69,7 +74,7 @@ def supports_gist_include(self) -> bool: Native feature, PostgreSQL 12+. Allows including non-key columns in GiST indexes. """ - ... + ... # pragma: no cover def supports_reindex_concurrently(self) -> bool: """Whether REINDEX CONCURRENTLY is supported. @@ -77,7 +82,7 @@ def supports_reindex_concurrently(self) -> bool: Native feature, PostgreSQL 12+. Enables non-blocking index rebuilding. """ - ... + ... # pragma: no cover def supports_btree_deduplication(self) -> bool: """Whether B-tree deduplication is supported. @@ -85,7 +90,7 @@ def supports_btree_deduplication(self) -> bool: Native feature, PostgreSQL 13+. Reduces index size for columns with many duplicate values. """ - ... + ... # pragma: no cover def supports_brin_multivalue(self) -> bool: """Whether BRIN indexes support multiple min/max values. @@ -93,7 +98,7 @@ def supports_brin_multivalue(self) -> bool: Native feature, PostgreSQL 14+. Allows BRIN indexes to store multiple value ranges per block. """ - ... + ... # pragma: no cover def supports_brin_bloom(self) -> bool: """Whether BRIN bloom filter indexes are supported. @@ -101,7 +106,7 @@ def supports_brin_bloom(self) -> bool: Native feature, PostgreSQL 14+. Enables bloom filter ops for BRIN indexes. """ - ... + ... # pragma: no cover def supports_spgist_include(self) -> bool: """Whether SP-GiST indexes support INCLUDE clause. @@ -109,7 +114,7 @@ def supports_spgist_include(self) -> bool: Native feature, PostgreSQL 14+. Allows including non-key columns in SP-GiST indexes. """ - ... + ... # pragma: no cover def supports_streaming_btree_index_build(self) -> bool: """Whether streaming B-tree index build is supported. @@ -117,7 +122,7 @@ def supports_streaming_btree_index_build(self) -> bool: Native feature, PostgreSQL 18+. Allows concurrent index builds with less memory usage. """ - ... + ... # pragma: no cover def supports_create_statistics(self) -> bool: """Whether CREATE STATISTICS for extended statistics is supported. @@ -126,7 +131,7 @@ def supports_create_statistics(self) -> bool: Allows creating extended statistics objects for better query planning on column correlations. """ - ... + ... # pragma: no cover def supports_statistics_mcv(self) -> bool: """Whether MCV (Most Common Values) statistics are supported. @@ -135,14 +140,14 @@ def supports_statistics_mcv(self) -> bool: Extended statistics can include MCV lists for better estimation of combined column values. """ - ... + ... # pragma: no cover def supports_concurrent_unique_nulls_not_distinct(self) -> bool: """Whether CONCURRENT unique index with NULLS NOT DISTINCT is supported. Native feature, PostgreSQL 16+. """ - ... + ... # pragma: no cover def supports_statistics_dependencies(self) -> bool: """Whether statistics dependencies are supported. @@ -151,7 +156,7 @@ def supports_statistics_dependencies(self) -> bool: Functional dependencies between columns can be tracked for better row estimation. """ - ... + ... # pragma: no cover def supports_statistics_ndistinct(self) -> bool: """Whether NDistinct statistics are supported. @@ -159,7 +164,23 @@ def supports_statistics_ndistinct(self) -> bool: Native feature, PostgreSQL 10+. Tracks number of distinct values for column groups. """ - ... + ... # pragma: no cover + + def format_alter_index_statement( + self, expr: "PostgresAlterIndexExpression" + ) -> Tuple[str, tuple]: + """Format ALTER INDEX statement with PostgreSQL-specific operations. + + Supports RENAME TO, SET TABLESPACE, SET/RESET storage parameters, + ALTER COLUMN SET STATISTICS, and ALL IN TABLESPACE operations. + + Args: + expr: PostgresAlterIndexExpression containing action details + + Returns: + Tuple of (SQL string, parameters tuple) + """ + ... # pragma: no cover def format_reindex_statement(self, expr: "PostgresReindexExpression") -> Tuple[str, tuple]: """Format REINDEX statement with PostgreSQL-specific options. @@ -170,7 +191,7 @@ def format_reindex_statement(self, expr: "PostgresReindexExpression") -> Tuple[s Returns: Tuple of (SQL string, parameters tuple) """ - ... + ... # pragma: no cover def format_create_index_pg_statement( self, @@ -209,4 +230,4 @@ def format_create_index_pg_statement( Returns: Tuple of (SQL string, parameters tuple) """ - ... + ... # pragma: no cover diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py index 3d236f1a..3f9dcc46 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py @@ -5,11 +5,29 @@ - PostgresIndexMixin feature detection - Format REINDEX statement - Format CREATE INDEX with PostgreSQL-specific options +- Format DROP INDEX with CONCURRENTLY +- Format ALTER INDEX +- PostgreSQL-specific IndexSupport protocol methods +- PostgresCreateIndexExpression / PostgresDropIndexExpression / PostgresAlterIndexExpression """ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddIndex, + DropIndex as DropIndexAction, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import IndexDefinition + from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.impl.postgres.expression.ddl import PostgresReindexExpression +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.index import PostgresIndexMixin +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateIndexExpression, + PostgresDropIndexExpression, + PostgresAlterIndexExpression, + PostgresAlterIndexActionType, + PostgresReindexExpression, +) class TestIndexFeatureDetection: @@ -396,3 +414,536 @@ def test_create_index_multiple_columns(self): columns=["email", "name"] ) assert '("email", "name")' in sql + + +class TestIndexSupportProtocol: + """Test IndexSupport protocol methods.""" + + def test_supports_create_index(self): + assert PostgresDialect().supports_create_index() is True + + def test_supports_drop_index(self): + assert PostgresDialect().supports_drop_index() is True + + def test_supports_unique_index(self): + assert PostgresDialect().supports_unique_index() is True + + def test_supports_index_if_not_exists(self): + assert PostgresDialect((9, 5, 0)).supports_index_if_not_exists() is True + + def test_supports_index_if_not_exists_pg94(self): + assert PostgresDialect((9, 4, 0)).supports_index_if_not_exists() is False + + def test_supports_index_if_exists(self): + assert PostgresDialect().supports_index_if_exists() is True + + def test_supports_index_type(self): + d = PostgresDialect() + assert d.supports_index_type() is True + + def test_supports_partial_index(self): + d = PostgresDialect() + assert d.supports_partial_index() is True + + def test_supports_functional_index(self): + d = PostgresDialect() + assert d.supports_functional_index() is True + + def test_supports_index_tablespace(self): + d = PostgresDialect() + assert d.supports_index_tablespace() is True + + def test_supports_index_include_pg10(self): + assert PostgresDialect((10, 0, 0)).supports_index_include() is False + + def test_supports_index_include_pg11(self): + assert PostgresDialect((11, 0, 0)).supports_index_include() is True + + def test_supports_concurrent_index_pg10(self): + assert PostgresDialect((10, 0, 0)).supports_concurrent_index() is False + + def test_supports_concurrent_index_pg11(self): + assert PostgresDialect((11, 0, 0)).supports_concurrent_index() is True + + def test_get_supported_index_types(self): + types = PostgresDialect().get_supported_index_types() + assert types == ["BTREE", "HASH", "GIST", "GIN", "SPGIST", "BRIN"] + + +class TestFulltextNotSupported: + """PostgreSQL does not support MySQL-style FULLTEXT indexes.""" + + def test_supports_fulltext_index(self): + assert PostgresDialect().supports_fulltext_index() is False + + def test_supports_fulltext_parser(self): + assert PostgresDialect().supports_fulltext_parser() is False + + def test_supports_fulltext_boolean_mode(self): + assert PostgresDialect().supports_fulltext_boolean_mode() is False + + def test_supports_fulltext_query_expansion(self): + assert PostgresDialect().supports_fulltext_query_expansion() is False + + def test_format_fulltext_match_raises(self): + with pytest.raises(UnsupportedFeatureError): + PostgresDialect().format_fulltext_match(["col"], "search") + + def test_format_create_fulltext_index_statement_raises(self): + with pytest.raises(UnsupportedFeatureError): + PostgresDialect().format_create_fulltext_index_statement(None) + + def test_format_drop_fulltext_index_statement_raises(self): + with pytest.raises(UnsupportedFeatureError): + PostgresDialect().format_drop_fulltext_index_statement(None) + + +class TestPostgresCreateIndexExpression: + """Test PostgresCreateIndexExpression with NULLS NOT DISTINCT.""" + + def test_basic_create(self): + d = PostgresDialect((15, 0, 0)) + expr = PostgresCreateIndexExpression(d, "idx_test", "t", ["a"]) + sql, _ = expr.to_sql() + assert sql == 'CREATE INDEX "idx_test" ON "t" ("a")' + + def test_unique(self): + d = PostgresDialect((15, 0, 0)) + expr = PostgresCreateIndexExpression(d, "idx_u", "t", ["a"], unique=True) + sql, _ = expr.to_sql() + assert sql == 'CREATE UNIQUE INDEX "idx_u" ON "t" ("a")' + + def test_nulls_not_distinct(self): + d = PostgresDialect((15, 0, 0)) + expr = PostgresCreateIndexExpression( + d, "idx_u", "t", ["a"], unique=True, nulls_not_distinct_unique=True + ) + sql, _ = expr.to_sql() + assert sql == 'CREATE UNIQUE INDEX "idx_u" ON "t" ("a") NULLS NOT DISTINCT' + + def test_nulls_not_distinct_pg14_raises(self): + d = PostgresDialect((14, 0, 0)) + expr = PostgresCreateIndexExpression( + d, "idx_u", "t", ["a"], unique=True, nulls_not_distinct_unique=True + ) + with pytest.raises(ValueError, match="NULLS NOT DISTINCT requires PostgreSQL 15"): + expr.to_sql() + + def test_nulls_not_distinct_non_unique_raises(self): + d = PostgresDialect((15, 0, 0)) + expr = PostgresCreateIndexExpression( + d, "idx_u", "t", ["a"], unique=False, nulls_not_distinct_unique=True + ) + with pytest.raises(ValueError, match="NULLS NOT DISTINCT is only valid for UNIQUE"): + expr.to_sql() + + def test_concurrent_and_nulls_not_distinct_pg15_raises(self): + d = PostgresDialect((15, 0, 0)) + expr = PostgresCreateIndexExpression( + d, "idx_u", "t", ["a"], + unique=True, concurrent=True, nulls_not_distinct_unique=True, + ) + with pytest.raises(ValueError, match="CONCURRENTLY.*NULLS NOT DISTINCT.*PostgreSQL 16"): + expr.to_sql() + + def test_concurrent_and_nulls_not_distinct_pg16(self): + d = PostgresDialect((16, 0, 0)) + expr = PostgresCreateIndexExpression( + d, "idx_u", "t", ["a"], + unique=True, concurrent=True, nulls_not_distinct_unique=True, + ) + sql, _ = expr.to_sql() + assert "NULLS NOT DISTINCT" in sql + assert "CONCURRENTLY" in sql + + +class TestPostgresDropIndexExpression: + """Test PostgresDropIndexExpression with CONCURRENTLY.""" + + def test_basic_drop(self): + d = PostgresDialect((18, 0, 0)) + expr = PostgresDropIndexExpression(d, "idx_test") + sql, _ = expr.to_sql() + assert sql == 'DROP INDEX "idx_test"' + + def test_drop_if_exists(self): + d = PostgresDialect((18, 0, 0)) + expr = PostgresDropIndexExpression(d, "idx_test", if_exists=True) + sql, _ = expr.to_sql() + assert sql == 'DROP INDEX IF EXISTS "idx_test"' + + def test_drop_concurrently(self): + d = PostgresDialect((18, 0, 0)) + expr = PostgresDropIndexExpression(d, "idx_test", concurrent=True) + sql, _ = expr.to_sql() + assert sql == 'DROP INDEX CONCURRENTLY "idx_test"' + + def test_drop_concurrently_pg17_raises(self): + d = PostgresDialect((17, 0, 0)) + expr = PostgresDropIndexExpression(d, "idx_test", concurrent=True) + with pytest.raises(ValueError, match="DROP INDEX CONCURRENTLY requires PostgreSQL 18"): + expr.to_sql() + + def test_drop_concurrently_if_exists(self): + d = PostgresDialect((18, 0, 0)) + expr = PostgresDropIndexExpression(d, "idx_test", if_exists=True, concurrent=True) + sql, _ = expr.to_sql() + assert sql == 'DROP INDEX CONCURRENTLY IF EXISTS "idx_test"' + + +class TestPostgresAlterIndexExpression: + """Test PostgresAlterIndexExpression for all action types.""" + + def test_rename_to(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.RENAME_TO, new_name="idx_new" + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX "idx_old" RENAME TO "idx_new"' + + def test_rename_to_if_exists(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.RENAME_TO, + new_name="idx_new", if_exists=True, + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX IF EXISTS "idx_old" RENAME TO "idx_new"' + + def test_rename_to_missing_name_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.RENAME_TO + ) + with pytest.raises(ValueError, match="new_name is required"): + expr.to_sql() + + def test_set_tablespace(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.SET_TABLESPACE, + tablespace="fast_ts", + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX "idx_old" SET TABLESPACE "fast_ts"' + + def test_set_storage_parameters(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.SET_STORAGE_PARAMETERS, + storage_parameters={"fillfactor": 70}, + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX "idx_old" SET (fillfactor = 70)' + + def test_set_storage_parameters_multiple(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.SET_STORAGE_PARAMETERS, + storage_parameters={"fillfactor": 70, "deduplicate_items": "off"}, + ) + sql, _ = expr.to_sql() + assert "fillfactor = 70" in sql + assert "deduplicate_items = off" in sql + + def test_reset_storage_parameters(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.RESET_STORAGE_PARAMETERS, + storage_parameters={"fillfactor": 70}, + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX "idx_old" RESET (fillfactor)' + + def test_alter_column_statistics(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.ALTER_COLUMN_STATISTICS, + column_number=1, statistics_target=100, + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX "idx_old" ALTER COLUMN 1 SET STATISTICS 100' + + def test_all_in_tablespace(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "", PostgresAlterIndexActionType.ALL_IN_TABLESPACE, + source_tablespace="old_ts", target_tablespace="new_ts", + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX ALL IN TABLESPACE "old_ts" SET TABLESPACE "new_ts"' + + def test_all_in_tablespace_nowait(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "", PostgresAlterIndexActionType.ALL_IN_TABLESPACE, + source_tablespace="old_ts", target_tablespace="new_ts", nowait=True, + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX ALL IN TABLESPACE "old_ts" SET TABLESPACE "new_ts" NOWAIT' + + +class TestFormatAddDropIndexAction: + """Test format_add/drop_index_action raise UnsupportedFeatureError for PostgreSQL.""" + + def test_format_add_index_action_raises(self): + d = PostgresDialect() + add = AddIndex(index=IndexDefinition(name="idx_test", columns=["a"])) + with pytest.raises(UnsupportedFeatureError, match="ALTER TABLE ADD INDEX"): + d.format_add_index_action(add) + + def test_format_drop_index_action_raises(self): + d = PostgresDialect() + drop = DropIndexAction(index_name="idx_test") + with pytest.raises(UnsupportedFeatureError, match="ALTER TABLE DROP INDEX"): + d.format_drop_index_action(drop) + + +class TestDialectIndexSupport: + """Test the 5 PostgresDialect-level index support methods (B1–B5).""" + + def test_supports_create_index(self): + assert PostgresDialect().supports_create_index() is True + + def test_supports_drop_index(self): + assert PostgresDialect().supports_drop_index() is True + + def test_supports_unique_index(self): + assert PostgresDialect().supports_unique_index() is True + + def test_supports_index_if_not_exists(self): + assert PostgresDialect((15, 0, 0)).supports_index_if_not_exists() is True + + def test_supports_index_if_exists(self): + assert PostgresDialect().supports_index_if_exists() is True + + +class TestPostgresCreateIndexExpressionAllOptions: + """Expand CREATE INDEX expression coverage — all remaining options.""" + + def test_concurrent(self): + d = PostgresDialect((11, 0, 0)) + expr = PostgresCreateIndexExpression(d, "idx_c", "t", ["a"], concurrent=True) + sql, _ = expr.to_sql() + assert "CONCURRENTLY" in sql + + def test_concurrent_pg10_raises(self): + d = PostgresDialect((10, 0, 0)) + expr = PostgresCreateIndexExpression(d, "idx_c", "t", ["a"], concurrent=True) + with pytest.raises(ValueError, match="CONCURRENTLY requires PostgreSQL 11"): + expr.to_sql() + + def test_if_not_exists(self): + d = PostgresDialect() + expr = PostgresCreateIndexExpression(d, "idx_t", "t", ["a"], if_not_exists=True) + sql, _ = expr.to_sql() + assert "IF NOT EXISTS" in sql + + def test_index_type(self): + d = PostgresDialect() + expr = PostgresCreateIndexExpression(d, "idx_h", "t", ["a"], index_type="hash") + sql, _ = expr.to_sql() + assert "USING hash" in sql + + def test_include(self): + d = PostgresDialect((12, 0, 0)) + expr = PostgresCreateIndexExpression( + d, "idx_i", "t", ["a"], include=["b", "c"] + ) + sql, _ = expr.to_sql() + assert 'INCLUDE ("b", "c")' in sql + + def test_tablespace(self): + d = PostgresDialect() + expr = PostgresCreateIndexExpression( + d, "idx_t", "t", ["a"], tablespace="fast_ts" + ) + sql, _ = expr.to_sql() + assert 'TABLESPACE "fast_ts"' in sql + + def test_where(self): + d = PostgresDialect() + from rhosocial.activerecord.backend.expression import Literal + from rhosocial.activerecord.backend.expression import Column + expr = PostgresCreateIndexExpression( + d, "idx_w", "t", ["a"], + where=Column(d, "status") == Literal(d, 1), + ) + sql, params = expr.to_sql() + assert "WHERE" in sql + assert "%s" in sql + assert params == (1,) + + def test_with_options(self): + d = PostgresDialect() + expr = PostgresCreateIndexExpression( + d, "idx_w", "t", ["a"], + dialect_options={"with": {"fillfactor": 70}}, + ) + sql, _ = expr.to_sql() + assert "WITH (fillfactor = 70)" in sql + + def test_opclasses(self): + d = PostgresDialect() + expr = PostgresCreateIndexExpression( + d, "idx_o", "t", ["a", "b"], + dialect_options={"opclasses": {"a": "text_pattern_ops"}}, + ) + sql, _ = expr.to_sql() + assert '"a" text_pattern_ops' in sql + assert '"b"' in sql + + +class TestFormatCreateIndexPgStatementWhereExpression: + """Test format_create_index_pg_statement where_clause as ToSQLProtocol.""" + + def test_where_expression(self): + d = PostgresDialect() + from rhosocial.activerecord.backend.expression import Column, Literal + sql, params = d.format_create_index_pg_statement( + index_name="idx_active", + table_name="users", + columns=["email"], + where_clause=Column(d, "status") == Literal(d, 1), + ) + assert "WHERE" in sql + assert params == (1,) + + +class TestPostgresAlterIndexExpressionErrorPaths: + """Test error paths in ALTER INDEX operations.""" + + def test_all_in_tablespace_missing_source_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "", PostgresAlterIndexActionType.ALL_IN_TABLESPACE, + target_tablespace="new_ts", + ) + with pytest.raises(ValueError, match="source_tablespace"): + expr.to_sql() + + def test_all_in_tablespace_missing_target_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "", PostgresAlterIndexActionType.ALL_IN_TABLESPACE, + source_tablespace="old_ts", + ) + with pytest.raises(ValueError, match="target_tablespace"): + expr.to_sql() + + def test_set_tablespace_missing_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx", PostgresAlterIndexActionType.SET_TABLESPACE, + ) + with pytest.raises(ValueError, match="tablespace is required"): + expr.to_sql() + + def test_set_storage_params_missing_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx", PostgresAlterIndexActionType.SET_STORAGE_PARAMETERS, + ) + with pytest.raises(ValueError, match="storage_parameters"): + expr.to_sql() + + def test_reset_storage_params_missing_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx", PostgresAlterIndexActionType.RESET_STORAGE_PARAMETERS, + ) + with pytest.raises(ValueError, match="storage_parameters"): + expr.to_sql() + + def test_alter_statistics_missing_params_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx", PostgresAlterIndexActionType.ALTER_COLUMN_STATISTICS, + ) + with pytest.raises(ValueError, match="column_number"): + expr.to_sql() + + +class TestPostgresIndexMixinDirect: + """Test PostgresIndexMixin directly (not through PostgresDialect).""" + + class _Host: + version = (9, 0, 0) + name = "PostgreSQL" + def format_identifier(self, s): return f'"{s}"' + + class _IndexMixinLow(_Host, PostgresIndexMixin): + pass + + class _HostHigh: + version = (15, 0, 0) + name = "PostgreSQL" + def format_identifier(self, s): return f'"{s}"' + + class _IndexMixinHigh(_HostHigh, PostgresIndexMixin): + pass + + def test_supports_create_statistics_low(self): + assert not self._IndexMixinLow().supports_create_statistics() + + def test_supports_statistics_mcv_low(self): + assert not self._IndexMixinLow().supports_statistics_mcv() + + def test_supports_statistics_dependencies_low(self): + assert not self._IndexMixinLow().supports_statistics_dependencies() + + def test_supports_statistics_ndistinct_low(self): + assert not self._IndexMixinLow().supports_statistics_ndistinct() + + def test_supports_create_index(self): + assert self._IndexMixinHigh().supports_create_index() is True + + def test_supports_drop_index(self): + assert self._IndexMixinHigh().supports_drop_index() is True + + def test_supports_unique_index(self): + assert self._IndexMixinHigh().supports_unique_index() is True + + def test_supports_index_if_not_exists_low(self): + assert not self._IndexMixinLow().supports_index_if_not_exists() + + def test_supports_index_if_exists(self): + assert self._IndexMixinHigh().supports_index_if_exists() is True + + def test_format_create_index_expression_column(self): + from rhosocial.activerecord.backend.expression import Column, Literal + expr = PostgresCreateIndexExpression( + PostgresDialect((15, 0, 0)), "idx_e", "t", [Column(PostgresDialect((15, 0, 0)), "a")], + ) + sql, _ = expr.to_sql() + assert '"a"' in sql + + def test_include_gist_low_raises(self): + d = PostgresDialect((11, 0, 0)) + expr = PostgresCreateIndexExpression( + d, "idx_g", "t", ["a"], include=["b"], index_type="gist", + ) + with pytest.raises(ValueError, match="INCLUDE for GiST"): + expr.to_sql() + + def test_include_spgist_low_raises(self): + d = PostgresDialect((12, 0, 0)) + expr = PostgresCreateIndexExpression( + d, "idx_s", "t", ["a"], include=["b"], index_type="spgist", + ) + with pytest.raises(ValueError, match="INCLUDE for SP-GiST"): + expr.to_sql() + + def test_unsupported_alter_index_action_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression(d, "idx", "INVALID") + with pytest.raises(ValueError, match="Unsupported ALTER INDEX action"): + expr.to_sql() + + def test_reindex_expression_to_sql(self): + d = PostgresDialect((15, 0, 0)) + expr = PostgresReindexExpression(d, "INDEX", "idx_test") + sql, _ = expr.to_sql() + assert "REINDEX INDEX" in sql + assert "idx_test" in sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py new file mode 100644 index 00000000..e1288a32 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py @@ -0,0 +1,70 @@ +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.mixins.table import PostgresTableMixin + + +class TestTableSupport: + """Test PostgresTableMixin feature detection.""" + + def test_supports_if_not_exists_table_pg95(self): + assert PostgresDialect((9, 5, 0)).supports_if_not_exists_table() is True + + def test_supports_if_not_exists_table_pg94(self): + assert PostgresDialect((9, 4, 0)).supports_if_not_exists_table() is False + + def test_supports_if_exists_table(self): + assert PostgresDialect().supports_if_exists_table() is True + + def test_supports_temporary_table(self): + assert PostgresDialect().supports_temporary_table() is True + + def test_supports_table_inheritance(self): + assert PostgresDialect().supports_table_inheritance() is True + + def test_supports_table_partitioning_pg10(self): + assert PostgresDialect((10, 0, 0)).supports_table_partitioning() is True + + def test_supports_table_partitioning_pg94(self): + assert PostgresDialect((9, 4, 0)).supports_table_partitioning() is False + + def test_supports_table_tablespace(self): + assert PostgresDialect().supports_table_tablespace() is True + + +class TestPostgresTableMixinDirect: + """Test PostgresTableMixin directly (not through PostgresDialect).""" + + class _Host: + version = (15, 0, 0) + + class _LowHost: + version = (9, 4, 0) + + class _TableMixin(_Host, PostgresTableMixin): + pass + + class _TableMixinLow(_LowHost, PostgresTableMixin): + pass + + def test_supports_if_not_exists_table_direct(self): + assert self._TableMixin().supports_if_not_exists_table() is True + + def test_supports_if_not_exists_table_low(self): + assert not self._TableMixinLow().supports_if_not_exists_table() + + def test_supports_if_exists_table_direct(self): + assert self._TableMixin().supports_if_exists_table() is True + + def test_supports_temporary_table_direct(self): + assert self._TableMixin().supports_temporary_table() is True + + def test_supports_table_inheritance_direct(self): + assert self._TableMixin().supports_table_inheritance() is True + + def test_supports_table_partitioning_direct(self): + assert self._TableMixin().supports_table_partitioning() is True + + def test_supports_table_partitioning_low(self): + assert not self._TableMixinLow().supports_table_partitioning() + + def test_supports_table_tablespace_direct(self): + assert self._TableMixin().supports_table_tablespace() is True diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py index 5eacdd2b..3475e51a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py @@ -9,6 +9,7 @@ import pytest from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.trigger import PostgresTriggerMixin from rhosocial.activerecord.backend.expression.statements import ( CreateTriggerExpression, DropTriggerExpression, @@ -21,6 +22,21 @@ class TestTriggerFeatureDetection: """Test trigger feature detection methods.""" + def test_supports_trigger(self): + """Triggers are supported in all versions.""" + dialect = PostgresDialect((9, 5, 0)) + assert dialect.supports_trigger() is True + + def test_supports_create_trigger(self): + """CREATE TRIGGER is supported in all versions.""" + dialect = PostgresDialect((9, 5, 0)) + assert dialect.supports_create_trigger() is True + + def test_supports_drop_trigger(self): + """DROP TRIGGER is supported in all versions.""" + dialect = PostgresDialect((9, 5, 0)) + assert dialect.supports_drop_trigger() is True + def test_supports_trigger_referencing_pg9(self): """PostgreSQL 9.5 does not support REFERENCING clause.""" dialect = PostgresDialect((9, 5, 0)) @@ -180,6 +196,41 @@ def test_create_trigger_with_referencing_pg10(self): sql, params = dialect.format_create_trigger_statement(expr) assert "OLD TABLE AS old NEW TABLE AS new" in sql + def test_create_trigger_no_level(self): + """Test CREATE TRIGGER without FOR EACH clause (level=None).""" + from rhosocial.activerecord.backend.expression.statements import TriggerLevel + dialect = PostgresDialect((14, 0, 0)) + expr = CreateTriggerExpression( + dialect, + trigger_name="simple_trigger", + table_name="users", + timing=TriggerTiming.BEFORE, + events=[TriggerEvent.INSERT], + function_name="simple_func", + level=None, + ) + sql, params = dialect.format_create_trigger_statement(expr) + assert "FOR EACH" not in sql + + def test_create_trigger_with_condition(self): + """Test CREATE TRIGGER with WHEN condition expression.""" + from rhosocial.activerecord.backend.expression import Column, Literal + dialect = PostgresDialect((14, 0, 0)) + condition = Column(dialect, "status") == Literal(dialect, "ACTIVE") + expr = CreateTriggerExpression( + dialect, + trigger_name="check_status", + table_name="users", + timing=TriggerTiming.BEFORE, + events=[TriggerEvent.UPDATE], + function_name="validate_status", + condition=condition, + ) + sql, params = dialect.format_create_trigger_statement(expr) + assert "WHEN" in sql + assert "%s" in sql + assert params == ("ACTIVE",) + def test_create_trigger_instead_of(self): """Test CREATE TRIGGER INSTEAD OF for views.""" dialect = PostgresDialect((14, 0, 0)) @@ -229,3 +280,31 @@ def test_drop_trigger_if_exists(self): ) sql, params = dialect.format_drop_trigger_statement(expr) assert "DROP TRIGGER IF EXISTS" in sql + + +class TestPostgresTriggerMixinDirect: + """Test PostgresTriggerMixin directly (not through PostgresDialect).""" + + class _Host: + version = (9, 5, 0) + + class _LowHost: + version = (9, 4, 0) + + class _TriggerMixin(_Host, PostgresTriggerMixin): + pass + + class _TriggerMixinLow(_LowHost, PostgresTriggerMixin): + pass + + def test_supports_trigger(self): + assert self._TriggerMixin().supports_trigger() is True + + def test_supports_create_trigger(self): + assert self._TriggerMixin().supports_create_trigger() is True + + def test_supports_drop_trigger(self): + assert self._TriggerMixin().supports_drop_trigger() is True + + def test_supports_trigger_if_not_exists_low(self): + assert self._TriggerMixinLow().supports_trigger_if_not_exists() is False diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py index 9474afa2..f9c69526 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py @@ -266,6 +266,9 @@ class TestPostgresExpressionDialectSeparation: ("PostgresAttachPartitionExpression", "format_attach_partition_statement"), ("PostgresCreateStatisticsExpression", "format_create_statistics_statement"), ("PostgresDropStatisticsExpression", "format_drop_statistics_statement"), + ("PostgresCreateIndexExpression", "format_create_index_statement"), + ("PostgresDropIndexExpression", "format_drop_index_statement"), + ("PostgresAlterIndexExpression", "format_alter_index_statement"), ] @pytest.mark.parametrize("expr_name,format_method", EXPRESSION_DIALECT_PAIRS) @@ -351,6 +354,17 @@ def test_expression_delegates_to_dialect(self, expr_name, format_method): (postgres_protocols.PostgresBloomSupport, postgres_mixins.PostgresBloomMixin), (postgres_protocols.PostgresBtreeGinSupport, postgres_mixins.PostgresBtreeGinMixin), (postgres_protocols.PostgresBtreeGistSupport, postgres_mixins.PostgresBtreeGistMixin), + # Unpaired extension protocols + (postgres_protocols.PostgresAddressStandardizerSupport, postgres_mixins.PostgresAddressStandardizerMixin), + (postgres_protocols.PostgresHypoPgSupport, postgres_mixins.PostgresHypoPgMixin), + (postgres_protocols.PostgresOrafceSupport, postgres_mixins.PostgresOrafceMixin), + (postgres_protocols.PostgresPgCronSupport, postgres_mixins.PostgresPgCronMixin), + (postgres_protocols.PostgresPgLogicalSupport, postgres_mixins.PostgresPgLogicalMixin), + (postgres_protocols.PostgresPgPartmanSupport, postgres_mixins.PostgresPgPartmanMixin), + (postgres_protocols.PostgresPgRepackSupport, postgres_mixins.PostgresPgRepackMixin), + (postgres_protocols.PostgresPgSurgerySupport, postgres_mixins.PostgresPgSurgeryMixin), + (postgres_protocols.PostgresPgWalinspectSupport, postgres_mixins.PostgresPgWalinspectMixin), + (postgres_protocols.PostgresPgauditSupport, postgres_mixins.PostgresPgauditMixin), ]