diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32d571af..ca80f78a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -455,13 +455,13 @@ jobs: fail_ci_if_error: false test-postgres-19: - name: Python 3.14 / postgres 19beta1 (PGQ) + name: Python 3.14 / postgres 19beta2 (PGQ) runs-on: ubuntu-latest continue-on-error: true services: postgres: - image: postgres:19beta1 + image: postgres:19beta2 env: POSTGRES_DB: test_db POSTGRES_USER: root diff --git a/changelog.d/52.added.md b/changelog.d/52.added.md new file mode 100644 index 00000000..31a02214 --- /dev/null +++ b/changelog.d/52.added.md @@ -0,0 +1 @@ +Added PostgreSQL Row-Level Security POLICY DDL expressions and dialect support. New `PostgresCreatePolicyExpression`, `PostgresAlterPolicyExpression`, `PostgresDropPolicyExpression` (plus `PolicyType`, `PolicyCommand`, `AlterPolicyMode` enums) in `expression/ddl/policy.py` allow RLS policy administration via the Expression-Dialect pipeline; the new `PostgresPolicyMixin` provides version-gated `format_*` methods (PG 9.5+ for basic CREATE/ALTER/DROP, 10+ for `AS RESTRICTIVE` and explicit `AS PERMISSIVE`, 15+ for the `CURRENT_ROLE` keyword in `TO`) and capability switches; `PostgresPolicySupport` is the corresponding `@runtime_checkable` Protocol. Per the layering decision POLICY is a PostgreSQL extension (not SQL standard) and is therefore implemented in the postgres backend package only — the core library `backend/expression/statements/` is untouched. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 2508c5d5..660f59cc 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -162,6 +162,7 @@ PostgresCommentMixin, PostgresTypeMixin, PostgresConstraintMixin, + PostgresPolicyMixin, # Type mixins EnumTypeMixin, TypesDataTypeMixin, @@ -252,6 +253,7 @@ PostgresCommentSupport, PostgresTypeSupport, PostgresConstraintSupport, + PostgresPolicySupport, # Type feature protocols PostgresMultirangeSupport, PostgresEnumTypeSupport, @@ -397,6 +399,7 @@ class PostgresDialect( PostgresCommentMixin, PostgresTypeMixin, PostgresConstraintMixin, + PostgresPolicyMixin, # Type mixins EnumTypeMixin, TypesDataTypeMixin, @@ -522,6 +525,7 @@ class PostgresDialect( PostgresCommentSupport, PostgresTypeSupport, PostgresConstraintSupport, + PostgresPolicySupport, # Type feature protocols PostgresMultirangeSupport, PostgresEnumTypeSupport, 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 9fa46a23..3127e7a9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -11,6 +11,7 @@ - mv.py - Materialized view expressions - type.py - Enum/Range type expressions - extension.py - Extension DDL expressions +- policy.py - Row-Level Security POLICY expressions (CREATE/ALTER/DROP) Missing expressions — why? ============================ @@ -84,6 +85,14 @@ PostgresPgPartmanUpdateConfigExpression, PostgresPgPartmanDeleteConfigExpression, ) +from .policy import ( + AlterPolicyMode, + PolicyCommand, + PolicyType, + PostgresCreatePolicyExpression, + PostgresAlterPolicyExpression, + PostgresDropPolicyExpression, +) __all__ = [ # vacuum @@ -120,4 +129,11 @@ "PostgresPgPartmanRunMaintenanceExpression", "PostgresPgPartmanUpdateConfigExpression", "PostgresPgPartmanDeleteConfigExpression", + # policy + "PolicyType", + "PolicyCommand", + "AlterPolicyMode", + "PostgresCreatePolicyExpression", + "PostgresAlterPolicyExpression", + "PostgresDropPolicyExpression", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py new file mode 100644 index 00000000..42b907e2 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py @@ -0,0 +1,249 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py +""" +PostgreSQL DDL expressions: Row-Level Security POLICY operations. + +PostgreSQL Documentation: +- CREATE POLICY: https://www.postgresql.org/docs/current/sql-createpolicy.html +- ALTER POLICY: https://www.postgresql.org/docs/current/sql-alterpolicy.html +- DROP POLICY: https://www.postgresql.org/docs/current/sql-droppolicy.html + +Version Requirements: +- CREATE/ALTER/DROP POLICY (basic): PostgreSQL 9.5+ +- AS RESTRICTIVE / explicit AS PERMISSIVE: PostgreSQL 10+ +- CURRENT_ROLE keyword in TO role list: PostgreSQL 15+ + +Note: These statements are PostgreSQL extensions (non-SQL-standard). They live in +the postgres backend package, not in the core backend/expression/statements/. +""" + +from enum import Enum +from typing import List, Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import ( + BaseExpression, + SQLPredicate, + SQLQueryAndParams, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PolicyType", + "PolicyCommand", + "AlterPolicyMode", + "PostgresCreatePolicyExpression", + "PostgresAlterPolicyExpression", + "PostgresDropPolicyExpression", +] + + +class PolicyType(Enum): + """Row-Level Security policy type.""" + + PERMISSIVE = "PERMISSIVE" + RESTRICTIVE = "RESTRICTIVE" + + +class PolicyCommand(Enum): + """SQL command a policy applies to.""" + + ALL = "ALL" + SELECT = "SELECT" + INSERT = "INSERT" + UPDATE = "UPDATE" + DELETE = "DELETE" + + +class AlterPolicyMode(Enum): + """The two syntactic forms of ALTER POLICY. + + REPLACE: alter roles / USING / WITH CHECK (several may be omitted). + RENAME: ALTER POLICY name ON table RENAME TO new_name. + """ + + REPLACE = "REPLACE" + RENAME = "RENAME" + + +class PostgresCreatePolicyExpression(BaseExpression): + """PostgreSQL CREATE POLICY statement expression. + + Defines a new row-level security policy for a table. + + Attributes: + name: Name of the policy (distinct per table). + table_name: Name of the table the policy applies to. + schema: Optional schema for the table. + policy_type: Optional ``AS PERMISSIVE``/``AS RESTRICTIVE`` clause. + RESTRICTIVE requires PostgreSQL 10+; PERMISSIVE keyword is only + emitted on PostgreSQL 10+ (silently omitted on 9.5). + command: Optional ``FOR {ALL|SELECT|INSERT|UPDATE|DELETE}`` clause. + If None, the clause is omitted (PostgreSQL default = ALL). + roles: Optional ``TO role [, ...]`` clause list. Special literals + ``PUBLIC`` / ``CURRENT_ROLE`` / ``CURRENT_USER`` / ``SESSION_USER`` + are emitted verbatim (no quoting); any other value is treated as + an identifier. CURRENT_ROLE requires PostgreSQL 15+. + using: Optional ``USING (predicate)`` expression. Forbidden when + ``command == INSERT``. + with_check: Optional ``WITH CHECK (predicate)`` expression. Forbidden + when ``command`` is SELECT or DELETE. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> expr = PostgresCreatePolicyExpression( + ... dialect=dialect, + ... name="user_select_own", + ... table_name="orders", + ... command=PolicyCommand.SELECT, + ... using=Column(dialect, "user_id") == 1, + ... ) + >>> sql, params = expr.to_sql() # doctest: +SKIP + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + table_name: str, + schema: Optional[str] = None, + policy_type: Optional[PolicyType] = None, + command: Optional[PolicyCommand] = None, + roles: Optional[List[str]] = None, + using: Optional[SQLPredicate] = None, + with_check: Optional[SQLPredicate] = None, + ): + super().__init__(dialect) + self.name = name + self.table_name = table_name + self.schema = schema + self.policy_type = policy_type + self.command = command + self.roles = roles + self.using = using + self.with_check = with_check + + def to_sql(self) -> "SQLQueryAndParams": + """Generate CREATE POLICY SQL statement. + + Returns: + Tuple of (SQL string, params tuple). + + """ + return self.dialect.format_create_policy_statement(self) + + +class PostgresAlterPolicyExpression(BaseExpression): + """PostgreSQL ALTER POLICY statement expression. + + PostgreSQL supports two syntactic forms: + + 1. ``ALTER POLICY name ON table RENAME TO new_name`` + 2. ``ALTER POLICY name ON table [TO roles] [USING (...)] [WITH CHECK (...)]`` + + Each clause of form 2 may be independently specified or omitted; clauses not + given are unchanged on the existing policy. + + Attributes: + name: Name of the existing policy to alter. + table_name: Name of the table the policy is on. + schema: Optional schema for the table. + new_name: If given, switches the expression to form 1 (RENAME TO). + Mutually exclusive with roles/using/with_check. + roles: Optional new ``TO role [, ...]`` clause (form 2 only). + using: Optional new ``USING (...)`` expression (form 2 only). + with_check: Optional new ``WITH CHECK (...)`` expression (form 2 only). + + Raises: + ValueError: if ``new_name`` is set together with any of roles/using/ + with_check (the two forms are mutually exclusive). + UnsupportedFeatureError: when the dialect version predates + PostgreSQL 9.5. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + table_name: str, + schema: Optional[str] = None, + new_name: Optional[str] = None, + roles: Optional[List[str]] = None, + using: Optional[SQLPredicate] = None, + with_check: Optional[SQLPredicate] = None, + ): + super().__init__(dialect) + self.name = name + self.table_name = table_name + self.schema = schema + self.new_name = new_name + self.roles = roles + self.using = using + self.with_check = with_check + + @property + def mode(self) -> AlterPolicyMode: + """Determine the ALTER POLICY form implied by the parameters.""" + if self.new_name is not None: + return AlterPolicyMode.RENAME + return AlterPolicyMode.REPLACE + + def to_sql(self) -> "SQLQueryAndParams": + """Generate ALTER POLICY SQL statement. + + Returns: + Tuple of (SQL string, params tuple). + + """ + return self.dialect.format_alter_policy_statement(self) + + +class PostgresDropPolicyExpression(BaseExpression): + """PostgreSQL DROP POLICY statement expression. + + Removes a row-level security policy from a table. + + Attributes: + name: Name of the policy to drop. + table_name: Name of the table the policy is on. + schema: Optional schema for the table. + if_exists: When True, add ``IF EXISTS`` (PostgreSQL 9.5+, always + supported). A notice — not an error — is issued if missing. + cascade: When True, append ``CASCADE``. Note: per PostgreSQL + documentation, CASCADE/RESTRICT have no effect for DROP POLICY + (no dependencies) but are accepted syntactically. + restrict: When True, append ``RESTRICT``. Mutually exclusive with + ``cascade``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + table_name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.table_name = table_name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "SQLQueryAndParams": + """Generate DROP POLICY SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_policy_statement(self) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index 7ff74432..7c1c4f3c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -23,6 +23,7 @@ from .ddl.type import PostgresTypeMixin from .ddl.constraint import PostgresConstraintMixin from .ddl.column import PostgresAlterColumnModifierMixin +from .ddl.policy import PostgresPolicyMixin # DML mixins from .dml.vacuum import PostgresVacuumMixin @@ -118,6 +119,7 @@ "PostgresTypeMixin", "PostgresConstraintMixin", "PostgresAlterColumnModifierMixin", + "PostgresPolicyMixin", # DML mixins "PostgresVacuumMixin", "PostgresStoredProcedureMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py index 2822601d..b7e90639 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py @@ -8,6 +8,7 @@ from .type import PostgresTypeMixin from .constraint import PostgresConstraintMixin from .column import PostgresAlterColumnModifierMixin +from .policy import PostgresPolicyMixin __all__ = [ "PostgresPartitionMixin", @@ -17,4 +18,5 @@ "PostgresTypeMixin", "PostgresConstraintMixin", "PostgresAlterColumnModifierMixin", + "PostgresPolicyMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/policy.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/policy.py new file mode 100644 index 00000000..efedabf2 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/policy.py @@ -0,0 +1,398 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/policy.py +"""PostgreSQL Row-Level Security POLICY DDL implementation. + +Implements CREATE/ALTER/DROP POLICY formatting for the postgres dialect. +All three statements are PostgreSQL extensions (non-SQL-standard). + +Version Requirements: +- CREATE/ALTER/DROP POLICY (basic): PostgreSQL 9.5+ +- AS RESTRICTIVE / explicit AS PERMISSIVE keyword: PostgreSQL 10+ +- DROP POLICY IF EXISTS: PostgreSQL 9.5+ (always supported here) +- CURRENT_ROLE keyword in TO role list: PostgreSQL 15+ +- CURRENT_USER / SESSION_USER / PUBLIC: all versions +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import SQLPredicate + +from ...expression.ddl.policy import ( + AlterPolicyMode, + PolicyCommand, + PolicyType, +) + +if TYPE_CHECKING: + from ...expression.ddl.policy import ( + PostgresAlterPolicyExpression, + PostgresCreatePolicyExpression, + PostgresDropPolicyExpression, + ) + + +# Role tokens that MUST NOT be wrapped by format_identifier — they are +# PostgreSQL keywords / reserved role references, not identifiers. +_ROLE_KEYWORDS = frozenset( + {"PUBLIC", "CURRENT_ROLE", "CURRENT_USER", "SESSION_USER"} +) + +# CURRENT_ROLE was introduced in PostgreSQL 15; the other three role keywords +# exist in all supported 9.5+ versions. +_ROLE_KEYWORD_MIN_VERSION = { + "CURRENT_ROLE": (15, 0, 0), + "CURRENT_USER": (9, 5, 0), + "SESSION_USER": (9, 5, 0), + "PUBLIC": (9, 5, 0), +} + + +class PostgresPolicyMixin: + """PostgreSQL Row-Level Security POLICY implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_create_policy(self) -> bool: + """CREATE/ALTER/DROP POLICY basic syntax requires PostgreSQL 9.5+.""" + return self.version >= (9, 5, 0) + + def supports_policy_restrictive(self) -> bool: + """AS RESTRICTIVE clause requires PostgreSQL 10+.""" + return self.version >= (10, 0, 0) + + def supports_explicit_permissive_keyword(self) -> bool: + """The literal ``AS PERMISSIVE`` keyword requires PostgreSQL 10+. + + On PostgreSQL 9.5, permissive is the implicit default and the + keyword itself is not accepted by the parser; it must be omitted. + """ + return self.version >= (10, 0, 0) + + def supports_drop_policy_if_exists(self) -> bool: + """DROP POLICY IF EXISTS is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_alter_policy_rename(self) -> bool: + """ALTER POLICY ... RENAME TO ... is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_alter_policy_replace(self) -> bool: + """ALTER POLICY (roles/USING/WITH CHECK) is supported since 9.5.""" + return self.version >= (9, 5, 0) + + def supports_current_role_keyword(self) -> bool: + """CURRENT_ROLE keyword in TO role list requires PostgreSQL 15+.""" + return self.version >= (15, 0, 0) + + # ------------------------------------------------------------------ # + # Shared helpers + # ------------------------------------------------------------------ # + def _format_table_ref(self, schema: Optional[str], table_name: str) -> str: + """Format ``table_name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return f"{self.format_identifier(schema)}.{self.format_identifier(table_name)}" + return self.format_identifier(table_name) + + def _format_role_list(self, roles: List[str]) -> Tuple[str, tuple]: + """Format the ``TO role [, ...]`` clause. + + Returns ``(formatted_role_list, params)`` — params are always empty + for the role list itself (roles are identifiers or keywords, never + parameterised). + """ + parts: List[str] = [] + for r in roles: + upper = r.upper() + if upper in _ROLE_KEYWORDS: + min_ver = _ROLE_KEYWORD_MIN_VERSION[upper] + if self.version < min_ver: + raise UnsupportedFeatureError( + self.name, + f"role keyword {upper}", + suggestion=f"requires PostgreSQL " + f"{min_ver[0]}.{min_ver[1]}+", + ) + parts.append(upper) + else: + parts.append(self.format_identifier(r)) + return ", ".join(parts), () + + def _format_predicate( + self, expr: SQLPredicate, clause_keyword: str + ) -> Tuple[str, tuple]: + """Format ````USING``/``WITH CHECK`` ( predicate )`` clause.""" + sql, params = expr.to_sql() + return f"{clause_keyword} ({sql})", params + + # ------------------------------------------------------------------ # + # CREATE POLICY + # ------------------------------------------------------------------ # + def format_create_policy_statement( + self, expr: "PostgresCreatePolicyExpression" + ) -> Tuple[str, tuple]: + """Format CREATE POLICY statement (PostgreSQL-specific). + + Performs version gates and per-command validation in line with + PostgreSQL's policy semantics, then assembles the SQL. + + Args: + expr: :class:`PostgresCreatePolicyExpression` with the policy + definition. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: when the dialect predates PostgreSQL 9.5 + or when ``AS RESTRICTIVE`` is used on a version below 10. + ValueError: when a command/expression combination is invalid + (e.g. INSERT policy with a USING expression). + + """ + if not self.supports_create_policy(): + raise UnsupportedFeatureError( + self.name, + "CREATE POLICY", + suggestion="requires PostgreSQL 9.5+", + ) + + # AS clause handling + if expr.policy_type is PolicyType.RESTRICTIVE: + if not self.supports_policy_restrictive(): + raise UnsupportedFeatureError( + self.name, + "AS RESTRICTIVE policy", + suggestion="requires PostgreSQL 10+", + ) + as_clause = "AS RESTRICTIVE" + elif expr.policy_type is PolicyType.PERMISSIVE: + # On 9.5 the AS PERMISSIVE keyword is not accepted; silently omit. + if self.supports_explicit_permissive_keyword(): + as_clause = "AS PERMISSIVE" + else: + as_clause = "" + else: + as_clause = "" + + # FOR command clause + command_clause = "" + if expr.command is not None: + cmd = expr.command + cmd_combined_clause_errors(cmd, expr.using, expr.with_check) + command_clause = f"FOR {cmd.value}" + + # TO role list + to_clause = "" + if expr.roles is not None: + role_list, _ = self._format_role_list(list(expr.roles)) + to_clause = f"TO {role_list}" + + # USING / WITH CHECK + all_params: List[object] = [] + using_clause = "" + if expr.using is not None: + using_clause, params = self._format_predicate(expr.using, "USING") + all_params.extend(params) + with_check_clause = "" + if expr.with_check is not None: + with_check_clause, params = self._format_predicate( + expr.with_check, "WITH CHECK" + ) + all_params.extend(params) + + parts: List[str] = ["CREATE POLICY"] + parts.append(self.format_identifier(expr.name)) + parts.append("ON") + parts.append(self._format_table_ref(expr.schema, expr.table_name)) + if as_clause: + parts.append(as_clause) + if command_clause: + parts.append(command_clause) + if to_clause: + parts.append(to_clause) + if using_clause: + parts.append(using_clause) + if with_check_clause: + parts.append(with_check_clause) + + return " ".join(parts), tuple(all_params) + + # ------------------------------------------------------------------ # + # ALTER POLICY + # ------------------------------------------------------------------ # + def format_alter_policy_statement( + self, expr: "PostgresAlterPolicyExpression" + ) -> Tuple[str, tuple]: + """Format ALTER POLICY statement (PostgreSQL-specific). + + Handles two syntactic forms via :attr:`expr.mode`: + + - ``RENAME``: ``ALTER POLICY name ON table RENAME TO new_name`` + - ``REPLACE``: ``ALTER POLICY name ON table [TO ...] + [USING (...)] [WITH CHECK (...)]`` + + For REPLACE, each clause is emitted independently if specified; + omitted clauses leave the existing policy unchanged (no SQL token). + + Args: + expr: :class:`PostgresAlterPolicyExpression`. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.5 or + when a role keyword needs a version not satisfied. + ValueError: if RENAME and REPLACE clauses are both specified, or + if no REPLACE clause is specified at all. + + """ + if not self.supports_create_policy(): + raise UnsupportedFeatureError( + self.name, + "ALTER POLICY", + suggestion="requires PostgreSQL 9.5+", + ) + + mode = expr.mode + if mode is AlterPolicyMode.RENAME: + if expr.roles is not None or expr.using is not None \ + or expr.with_check is not None: + raise ValueError( + "ALTER POLICY RENAME TO is mutually exclusive with " + "TO/USING/WITH CHECK clauses" + ) + if not self.supports_alter_policy_rename(): + raise UnsupportedFeatureError( + self.name, + "ALTER POLICY RENAME", + suggestion="requires PostgreSQL 9.5+", + ) + parts = [ + "ALTER POLICY", + self.format_identifier(expr.name), + "ON", + self._format_table_ref(expr.schema, expr.table_name), + "RENAME TO", + self.format_identifier(expr.new_name), + ] + return " ".join(parts), () + + # REPLACE form + if not self.supports_alter_policy_replace(): + raise UnsupportedFeatureError( + self.name, + "ALTER POLICY ... TO/USING/WITH CHECK", + suggestion="requires PostgreSQL 9.5+", + ) + if expr.roles is None and expr.using is None \ + and expr.with_check is None: + raise ValueError( + "ALTER POLICY (REPLACE form) requires at least one of " + "TO / USING / WITH CHECK" + ) + + all_params: List[object] = [] + parts: List[str] = [ + "ALTER POLICY", + self.format_identifier(expr.name), + "ON", + self._format_table_ref(expr.schema, expr.table_name), + ] + if expr.roles is not None: + role_list, _ = self._format_role_list(list(expr.roles)) + parts.append(f"TO {role_list}") + if expr.using is not None: + using_clause, params = self._format_predicate(expr.using, "USING") + parts.append(using_clause) + all_params.extend(params) + if expr.with_check is not None: + wc_clause, params = self._format_predicate( + expr.with_check, "WITH CHECK" + ) + parts.append(wc_clause) + all_params.extend(params) + + return " ".join(parts), tuple(all_params) + + # ------------------------------------------------------------------ # + # DROP POLICY + # ------------------------------------------------------------------ # + def format_drop_policy_statement( + self, expr: "PostgresDropPolicyExpression" + ) -> Tuple[str, tuple]: + """Format DROP POLICY statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropPolicyExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.5. + ValueError: if both ``cascade`` and ``restrict`` are True. + + Note: + Per PostgreSQL documentation, ``CASCADE``/``RESTRICT`` have no + effect on DROP POLICY (the policy has no dependents), but are + accepted syntactically — we honour the user's choice and emit + the token, leaving semantics to the server. + + """ + if not self.supports_create_policy(): + raise UnsupportedFeatureError( + self.name, + "DROP POLICY", + suggestion="requires PostgreSQL 9.5+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP POLICY: CASCADE and RESTRICT are mutually exclusive" + ) + + parts: List[str] = ["DROP POLICY"] + if expr.if_exists and self.supports_drop_policy_if_exists(): + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.name)) + parts.append("ON") + parts.append(self._format_table_ref(expr.schema, expr.table_name)) + if expr.cascade: + parts.append("CASCADE") + elif expr.restrict: + parts.append("RESTRICT") + return " ".join(parts), () + + +# ------------------------------------------------------------------ # +# Module-level helper (no class binding so it can be reused and unit +# tested in isolation; intentionally avoids any dialect reference). +# ------------------------------------------------------------------ # +def cmd_combined_clause_errors( + cmd: PolicyCommand, + using: Optional[SQLPredicate], + with_check: Optional[SQLPredicate], +) -> None: + """Validate USING/WITH CHECK against the chosen FOR command type. + + PostgreSQL semantics: + - INSERT policy: must NOT have USING, may have WITH CHECK. + - SELECT / DELETE policy: must NOT have WITH CHECK; USING allowed. + - ALL / UPDATE: both allowed. + + Raises: + ValueError: describing the mismatch. + + """ + if cmd is PolicyCommand.INSERT and using is not None: + raise ValueError( + "INSERT policy cannot have a USING expression " + "(use WITH CHECK for new-row validation)" + ) + if cmd in (PolicyCommand.SELECT, PolicyCommand.DELETE) \ + and with_check is not None: + target = "SELECT" if cmd is PolicyCommand.SELECT else "DELETE" + raise ValueError( + f"{target} policy cannot have a WITH CHECK expression" + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py index 90c64355..5ad8c906 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py @@ -21,6 +21,7 @@ from .ddl.comment import PostgresCommentSupport from .ddl.type import PostgresTypeSupport from .ddl.constraint import PostgresConstraintSupport +from .ddl.policy import PostgresPolicySupport # DML protocols from .dml.vacuum import PostgresVacuumSupport @@ -111,6 +112,7 @@ "PostgresCommentSupport", "PostgresTypeSupport", "PostgresConstraintSupport", + "PostgresPolicySupport", # 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 b35ad690..e1140341 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py @@ -7,6 +7,7 @@ from .comment import PostgresCommentSupport from .type import PostgresTypeSupport from .constraint import PostgresConstraintSupport +from .policy import PostgresPolicySupport __all__ = [ "PostgresPartitionSupport", @@ -15,4 +16,5 @@ "PostgresCommentSupport", "PostgresTypeSupport", "PostgresConstraintSupport", + "PostgresPolicySupport", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/policy.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/policy.py new file mode 100644 index 00000000..f7f85e0d --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/policy.py @@ -0,0 +1,137 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/policy.py +"""PostgreSQL Row-Level Security POLICY protocol definition. + +This module contains the :class:`PostgresPolicySupport` protocol which +defines the interface for PostgreSQL's native POLICY DDL features. + +POLICY (CREATE/ALTER/DROP) is a PostgreSQL extension — not SQL standard. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.policy import ( + PostgresAlterPolicyExpression, + PostgresCreatePolicyExpression, + PostgresDropPolicyExpression, + ) + + +@runtime_checkable +class PostgresPolicySupport(Protocol): + """PostgreSQL Row-Level Security POLICY protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CREATE POLICY: https://www.postgresql.org/docs/current/sql-createpolicy.html + - ALTER POLICY: https://www.postgresql.org/docs/current/sql-alterpolicy.html + - DROP POLICY: https://www.postgresql.org/docs/current/sql-droppolicy.html + + Version Requirements: + - CREATE/ALTER/DROP POLICY (basic): PostgreSQL 9.5+ + - AS RESTRICTIVE / explicit AS PERMISSIVE keyword: PostgreSQL 10+ + - DROP POLICY IF EXISTS: PostgreSQL 9.5+ (always supported here) + - ALTER POLICY ... RENAME TO: PostgreSQL 9.5+ + - CURRENT_ROLE keyword in TO role list: PostgreSQL 15+ + - CURRENT_USER / SESSION_USER / PUBLIC: all versions + """ + + def supports_create_policy(self) -> bool: + """Whether CREATE/ALTER/DROP POLICY basic syntax is supported (9.5+).""" + ... + + def supports_policy_restrictive(self) -> bool: + """Whether ``AS RESTRICTIVE`` clause is supported (10+).""" + ... + + def supports_explicit_permissive_keyword(self) -> bool: + """Whether the literal ``AS PERMISSIVE`` keyword is accepted (10+). + + On 9.5 the permissive mode is implicit (the parser rejects the + keyword itself); it must be omitted silently. + """ + ... + + def supports_drop_policy_if_exists(self) -> bool: + """Whether ``DROP POLICY IF EXISTS`` is supported (9.5+).""" + ... + + def supports_alter_policy_rename(self) -> bool: + """Whether ``ALTER POLICY ... RENAME TO`` is supported (9.5+).""" + ... + + def supports_alter_policy_replace(self) -> bool: + """Whether ``ALTER POLICY ... TO/USING/WITH CHECK`` is supported (9.5+).""" + ... + + def supports_current_role_keyword(self) -> bool: + """Whether ``CURRENT_ROLE`` may appear in the ``TO`` role list (15+).""" + ... + + def format_create_policy_statement( + self, expr: "PostgresCreatePolicyExpression" + ) -> Tuple[str, tuple]: + """Format ``CREATE POLICY`` statement (PostgreSQL-specific syntax). + + Args: + expr: ``PostgresCreatePolicyExpression`` defining the policy. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.5, or + ``AS RESTRICTIVE`` on a version below 10, or an unsupported + role keyword for the active version. + ValueError: an incompatible ``command`` / USING / WITH CHECK + combination (e.g. INSERT+USING, SELECT+WITH CHECK). + """ + ... + + def format_alter_policy_statement( + self, expr: "PostgresAlterPolicyExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER POLICY`` statement (PostgreSQL-specific syntax). + + Handles two forms via :attr:`expr.mode`: + + - ``RENAME``: ``ALTER POLICY name ON table RENAME TO new_name`` + - ``REPLACE``: ``ALTER POLICY name ON table [TO ...] + [USING (...)] [WITH CHECK (...)]`` + + Args: + expr: ``PostgresAlterPolicyExpression``. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.5 or + role keyword needs a newer version. + ValueError: both RENAME and REPLACE clauses present, or no + REPLACE clause present at all. + """ + ... + + def format_drop_policy_statement( + self, expr: "PostgresDropPolicyExpression" + ) -> Tuple[str, tuple]: + """Format ``DROP POLICY`` statement (PostgreSQL-specific syntax). + + Args: + expr: ``PostgresDropPolicyExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.5. + ValueError: both ``cascade`` and ``restrict`` are True. + + Note: + Per PostgreSQL documentation, ``CASCADE``/``RESTRICT`` have no + effect on DROP POLICY (no dependencies), but are accepted + syntactically. + """ + ... diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py index 6be8da1d..6ed571df 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py @@ -6,6 +6,7 @@ - PostgresCommentSupport - PostgresIndexSupport - PostgresMaterializedViewSupport +- PostgresPolicySupport """ import pytest # noqa: F401 @@ -392,4 +393,26 @@ def test_postgres_materialized_view_support_is_runtime_checkable(self): PostgresMaterializedViewSupport, ) dialect = PostgresDialect(version=(14, 0, 0)) - assert isinstance(dialect, PostgresMaterializedViewSupport) \ No newline at end of file + assert isinstance(dialect, PostgresMaterializedViewSupport) + + def test_postgres_policy_support_is_runtime_checkable(self): + """PostgresPolicySupport should be runtime checkable.""" + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresPolicySupport, + ) + # Versions >= 9.5 should satisfy the Protocol + dialect = PostgresDialect(version=(14, 0, 0)) + assert isinstance(dialect, PostgresPolicySupport) + + def test_postgres_policy_support_rejected_on_unsupported_version(self): + """Pre-9.5 dialects still satisfy Protocol methods (no attributes + differ structurally), but capability switches report False.""" + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresPolicySupport, + ) + dialect = PostgresDialect(version=(9, 4, 0)) + # Structural isinstance still passes (Protocol only checks method + # presence, not version gates), but capability report is False: + assert dialect.supports_create_policy() is False + # The Protocol *presence* is still satisfied (the methods exist): + assert isinstance(dialect, PostgresPolicySupport) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_policy.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_policy.py new file mode 100644 index 00000000..d464e4dd --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_policy.py @@ -0,0 +1,491 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_policy.py +"""Unit tests for PostgreSQL Row-Level Security POLICY DDL. + +Tests cover: +- ``PostgresPolicyMixin`` capability switches across PG versions +- ``format_create_policy_statement`` for all FOR commands, AS clause + variants, role lists (identifiers + reserved role keywords), USING / + WITH CHECK expression parameter passing, command/expression + incompatibilities, and version gates. +- ``format_alter_policy_statement`` for the RENAME form and the REPLACE + form (independent clauses, mutual-exclusivity, REPLACE-no-clause error). +- ``format_drop_policy_statement`` for ``IF EXISTS`` / ``CASCADE`` / + ``RESTRICT``, their mutual exclusion, and version gates. + +These tests are pure SQL-string / params assertions — no active DB. +""" +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.impl.postgres import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.policy import ( + AlterPolicyMode, + PostgresAlterPolicyExpression, + PostgresCreatePolicyExpression, + PostgresDropPolicyExpression, + PolicyCommand, + PolicyType, +) +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.policy import ( + PostgresPolicyMixin, +) +from rhosocial.activerecord.backend.impl.postgres.protocols import ( + PostgresPolicySupport, +) + + +# --------------------------------------------------------------------------- # +# Capability switches +# --------------------------------------------------------------------------- # +class TestPolicyFeatureDetection: + """Test capability switches across PG versions.""" + + @pytest.mark.parametrize("ver,expected", [ + ((9, 4, 0), False), + ((9, 5, 0), True), + ((10, 0, 0), True), + ((14, 0, 0), True), + ]) + def test_supports_create_policy(self, ver, expected): + assert PostgresDialect(ver).supports_create_policy() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 5, 0), False), + ((9, 6, 0), False), + ((10, 0, 0), True), + ((14, 0, 0), True), + ]) + def test_supports_policy_restrictive(self, ver, expected): + assert PostgresDialect(ver).supports_policy_restrictive() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 5, 0), False), + ((10, 0, 0), True), + ]) + def test_supports_explicit_permissive_keyword(self, ver, expected): + assert PostgresDialect(ver).supports_explicit_permissive_keyword() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 4, 0), False), + ((9, 5, 0), True), + ((14, 0, 0), True), + ]) + def test_supports_drop_policy_if_exists(self, ver, expected): + assert PostgresDialect(ver).supports_drop_policy_if_exists() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 4, 0), False), + ((9, 5, 0), True), + ]) + def test_supports_alter_policy_rename(self, ver, expected): + assert PostgresDialect(ver).supports_alter_policy_rename() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 4, 0), False), + ((9, 5, 0), True), + ]) + def test_supports_alter_policy_replace(self, ver, expected): + assert PostgresDialect(ver).supports_alter_policy_replace() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 5, 0), False), + ((14, 0, 0), False), + ((15, 0, 0), True), + ((18, 0, 0), True), + ]) + def test_supports_current_role_keyword(self, ver, expected): + assert PostgresDialect(ver).supports_current_role_keyword() is expected + + +# --------------------------------------------------------------------------- # +# Protocol conformance +# --------------------------------------------------------------------------- # +class TestPolicyProtocolConformance: + """PostgresDialect must satisfy PostgresPolicySupport Protocol.""" + + @pytest.mark.parametrize("ver", [(9, 5, 0), (10, 0, 0), (14, 0, 0), (18, 0, 0)]) + def test_isinstance_protocol(self, ver): + assert isinstance(PostgresDialect(ver), PostgresPolicySupport) + + def test_protocol_runtime_checkable(self): + # Protocol must be runtime_checkable (decorated) + assert hasattr(PostgresPolicySupport, "_is_protocol") + # PyPy / CPython alternative: explicit isinstance check + assert isinstance(PostgresDialect((14, 0, 0)), PostgresPolicySupport) + + +# --------------------------------------------------------------------------- # +# CREATE POLICY +# --------------------------------------------------------------------------- # +class TestFormatCreatePolicyStatement: + """Test CREATE POLICY statement formatting.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect((14, 0, 0)) + + def test_basic_create(self, dialect): + """Bare statement — only name + table required.""" + expr = PostgresCreatePolicyExpression( + dialect, name="p1", table_name="orders" + ) + sql, params = expr.to_sql() + assert sql == 'CREATE POLICY "p1" ON "orders"' + assert params == () + + def test_create_with_schema(self, dialect): + """Schema-qualified table reference.""" + expr = PostgresCreatePolicyExpression( + dialect, name="p1", table_name="orders", schema="analytics" + ) + sql, _ = expr.to_sql() + assert sql == 'CREATE POLICY "p1" ON "analytics"."orders"' + + def test_create_permissive_implicit_omitted_on_95(self): + """AS PERMISSIVE keyword must be silently omitted on PG 9.5.""" + d = PostgresDialect((9, 5, 0)) + expr = PostgresCreatePolicyExpression( + d, name="p1", table_name="t1", policy_type=PolicyType.PERMISSIVE + ) + sql, _ = expr.to_sql() + assert "AS" not in sql + assert sql == 'CREATE POLICY "p1" ON "t1"' + + def test_create_permissive_keyword_emitted_on_10(self): + """AS PERMISSIVE keyword emitted on PG 10+.""" + d = PostgresDialect((10, 0, 0)) + expr = PostgresCreatePolicyExpression( + d, name="p1", table_name="t1", policy_type=PolicyType.PERMISSIVE + ) + sql, _ = expr.to_sql() + assert "AS PERMISSIVE" in sql + + def test_create_restrictive_requires_10(self): + """RESTRICTIVE rejects on PG 9.5.""" + d = PostgresDialect((9, 5, 0)) + expr = PostgresCreatePolicyExpression( + d, name="p1", table_name="t1", policy_type=PolicyType.RESTRICTIVE + ) + with pytest.raises(UnsupportedFeatureError) as ei: + expr.to_sql() + assert "RESTRICTIVE" in str(ei.value) + assert "10" in str(ei.value) + + def test_create_restrictive_ok_10(self, dialect): + """RESTRICTIVE allowed on PG 10+.""" + expr = PostgresCreatePolicyExpression( + dialect, name="p1", table_name="t1", policy_type=PolicyType.RESTRICTIVE + ) + sql, _ = expr.to_sql() + assert "AS RESTRICTIVE" in sql + + @pytest.mark.parametrize("cmd,expected_kw", [ + (PolicyCommand.ALL, "FOR ALL"), + (PolicyCommand.SELECT, "FOR SELECT"), + (PolicyCommand.INSERT, "FOR INSERT"), + (PolicyCommand.UPDATE, "FOR UPDATE"), + (PolicyCommand.DELETE, "FOR DELETE"), + ]) + def test_create_all_commands(self, dialect, cmd, expected_kw): + expr = PostgresCreatePolicyExpression( + dialect, name="p1", table_name="t1", command=cmd + ) + sql, _ = expr.to_sql() + assert expected_kw in sql + + def test_create_roles_identifiers_quoted(self, dialect): + """Plain role names are identifiers — wrapped by format_identifier.""" + expr = PostgresCreatePolicyExpression( + dialect, name="p1", table_name="t1", roles=["analyst", "reporter"] + ) + sql, _ = expr.to_sql() + assert 'TO "analyst", "reporter"' in sql + + def test_create_roles_reserved_keywords_verbatim(self, dialect): + """PUBLIC / CURRENT_USER / SESSION_USER emitted verbatim (no quoting).""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + roles=["PUBLIC", "CURRENT_USER", "SESSION_USER"], + ) + sql, _ = expr.to_sql() + # Reserved role keywords must NOT be double-quoted + assert "TO PUBLIC, CURRENT_USER, SESSION_USER" in sql + assert '"PUBLIC"' not in sql + assert '"CURRENT_USER"' not in sql + + def test_create_roles_current_role_requires_15(self): + """CURRENT_ROLE keyword requires PG 15; rejected on 14.""" + d = PostgresDialect((14, 0, 0)) + expr = PostgresCreatePolicyExpression( + d, name="p1", table_name="t1", roles=["CURRENT_ROLE"] + ) + with pytest.raises(UnsupportedFeatureError) as ei: + expr.to_sql() + assert "CURRENT_ROLE" in str(ei.value) + assert "15" in str(ei.value) + + def test_create_using_with_param_passing(self, dialect): + """USING expression parameters are passed through to_sql().""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + command=PolicyCommand.SELECT, + using=Column(dialect, "user_id") == Literal(dialect, 1), + ) + sql, params = expr.to_sql() + assert "USING (" in sql + assert "%s" in sql + assert params == (1,) + + def test_create_with_check(self, dialect): + """WITH CHECK expression for UPDATE.""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + command=PolicyCommand.UPDATE, + with_check=Column(dialect, "status") == Literal(dialect, "active"), + ) + sql, params = expr.to_sql() + assert "WITH CHECK (" in sql + assert params == ("active",) + + def test_create_insert_with_using_rejected(self, dialect): + """INSERT command + USING is invalid (INSERT has no USING semantics).""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + command=PolicyCommand.INSERT, + using=Column(dialect, "user_id") == Literal(dialect, 1), + ) + with pytest.raises(ValueError, match="INSERT.*USING"): + expr.to_sql() + + def test_create_select_with_check_rejected(self, dialect): + """SELECT command + WITH CHECK is invalid (SELECT has no new rows).""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + command=PolicyCommand.SELECT, + with_check=Column(dialect, "user_id") == Literal(dialect, 1), + ) + with pytest.raises(ValueError, match="SELECT.*WITH CHECK"): + expr.to_sql() + + def test_create_delete_with_check_rejected(self, dialect): + """DELETE command + WITH CHECK is invalid (DELETE has no new rows).""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + command=PolicyCommand.DELETE, + with_check=Column(dialect, "user_id") == Literal(dialect, 1), + ) + with pytest.raises(ValueError, match="DELETE.*WITH CHECK"): + expr.to_sql() + + def test_create_full_combination(self, dialect): + """Full statement with all clauses.""" + expr = PostgresCreatePolicyExpression( + dialect, + name="user_select_own", + table_name="orders", + schema="public", + policy_type=PolicyType.RESTRICTIVE, + command=PolicyCommand.UPDATE, + roles=["app_user", "PUBLIC"], + using=Column(dialect, "owner") == Literal(dialect, "alice"), + with_check=Column(dialect, "owner").is_not_null(), + ) + sql, params = expr.to_sql() + # All clauses present in canonical order + assert sql.startswith('CREATE POLICY "user_select_own"') + assert 'ON "public"."orders"' in sql + assert "AS RESTRICTIVE" in sql + assert "FOR UPDATE" in sql + assert 'TO "app_user", PUBLIC' in sql + assert "USING (" in sql + assert "WITH CHECK (" in sql + # USING uses the literal "alice"; WITH CHECK IS NOT NULL yields no params + assert params == ("alice",) + + def test_create_rejected_on_94(self): + """Whole CREATE POLICY statement rejected before PG 9.5.""" + d = PostgresDialect((9, 4, 0)) + expr = PostgresCreatePolicyExpression(d, name="p1", table_name="t1") + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + +# --------------------------------------------------------------------------- # +# ALTER POLICY +# --------------------------------------------------------------------------- # +class TestFormatAlterPolicyStatement: + """Test ALTER POLICY statement formatting.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect((14, 0, 0)) + + def test_alter_rename(self, dialect): + """Form 1: RENAME TO.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", new_name="p1_v2" + ) + assert expr.mode is AlterPolicyMode.RENAME + sql, params = expr.to_sql() + assert sql == 'ALTER POLICY "p1" ON "t1" RENAME TO "p1_v2"' + assert params == () + + def test_alter_rename_with_schema(self, dialect): + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", + schema="my_schema", new_name="p1_v2", + ) + sql, _ = expr.to_sql() + assert 'ON "my_schema"."t1" RENAME TO "p1_v2"' in sql + + def test_alter_rename_rejects_replace_clauses(self, dialect): + """new_name is mutually exclusive with roles/using/with_check.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", new_name="p1_v2", + roles=["app_user"], + ) + with pytest.raises(ValueError, match="RENAME.*mutually exclusive"): + expr.to_sql() + + def test_alter_replace_roles_only(self, dialect): + """Form 2: only TO clause replaced.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", roles=["app_user", "PUBLIC"] + ) + assert expr.mode is AlterPolicyMode.REPLACE + sql, _ = expr.to_sql() + assert sql == 'ALTER POLICY "p1" ON "t1" TO "app_user", PUBLIC' + + def test_alter_replace_using_only(self, dialect): + """Form 2: only USING clause replaced.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", + using=Column(dialect, "user_id") == Literal(dialect, 42), + ) + sql, params = expr.to_sql() + assert "USING (" in sql + assert params == (42,) + + def test_alter_replace_with_check_only(self, dialect): + """Form 2: only WITH CHECK clause replaced.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", + with_check=Column(dialect, "status") == Literal(dialect, "active"), + ) + sql, params = expr.to_sql() + assert "WITH CHECK (" in sql + assert params == ("active",) + + def test_alter_replace_full(self, dialect): + """Form 2: all three clauses (TO/USING/WITH CHECK) replaced.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", + roles=["app_user"], + using=Column(dialect, "a") == Literal(dialect, 1), + with_check=Column(dialect, "b") == Literal(dialect, "x"), + ) + sql, params = expr.to_sql() + assert 'TO "app_user"' in sql + assert "USING (" in sql + assert "WITH CHECK (" in sql + assert params == (1, "x") + + def test_alter_replace_no_clause_rejected(self, dialect): + """Form 2 requires at least one of TO/USING/WITH CHECK.""" + expr = PostgresAlterPolicyExpression(dialect, name="p1", table_name="t1") + assert expr.mode is AlterPolicyMode.REPLACE + with pytest.raises(ValueError, match="requires at least one"): + expr.to_sql() + + def test_alter_rejected_on_94(self): + """ALTER POLICY totally rejected before PG 9.5.""" + d = PostgresDialect((9, 4, 0)) + expr = PostgresAlterPolicyExpression( + d, name="p1", table_name="t1", new_name="p2" + ) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + +# --------------------------------------------------------------------------- # +# DROP POLICY +# --------------------------------------------------------------------------- # +class TestFormatDropPolicyStatement: + """Test DROP POLICY statement formatting.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect((14, 0, 0)) + + def test_drop_basic(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1" + ) + sql, params = expr.to_sql() + assert sql == 'DROP POLICY "p1" ON "t1"' + assert params == () + + def test_drop_with_schema(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1", schema="rhosocial" + ) + sql, _ = expr.to_sql() + assert sql == 'DROP POLICY "p1" ON "rhosocial"."t1"' + + def test_drop_if_exists(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1", if_exists=True + ) + sql, _ = expr.to_sql() + assert "DROP POLICY IF EXISTS" in sql + + def test_drop_cascade(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1", cascade=True + ) + sql, _ = expr.to_sql() + assert sql.endswith("CASCADE") + + def test_drop_restrict(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1", restrict=True + ) + sql, _ = expr.to_sql() + assert sql.endswith("RESTRICT") + + def test_drop_cascade_and_restrict_mutually_exclusive(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1", + cascade=True, restrict=True, + ) + with pytest.raises(ValueError, match="CASC.*RESTRICT"): + expr.to_sql() + + def test_drop_rejected_on_94(self): + d = PostgresDialect((9, 4, 0)) + expr = PostgresDropPolicyExpression(d, name="p1", table_name="t1") + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + +# --------------------------------------------------------------------------- # +# Mixin import sanity (defensive) +# --------------------------------------------------------------------------- # +class TestPolicyMixinImport: + """Mixin class exists and is re-exported through the expected package path.""" + + def test_mixin_importable(self): + from rhosocial.activerecord.backend.impl.postgres.mixins import ( + PostgresPolicyMixin as M1, + ) + from rhosocial.activerecord.backend.impl.postgres.mixins.ddl import ( + PostgresPolicyMixin as M2, + ) + assert M1 is M2 is PostgresPolicyMixin