From f931ed25671bd8c5f656f242c6f3412377ffc760 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 16 May 2026 19:53:10 +0800 Subject: [PATCH 01/20] chore: bump version to 1.0.0.dev14 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f26a0a17..118b3d99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rhosocial-activerecord-postgres" -version = "1.0.0.dev13" +version = "1.0.0.dev14" description = "postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support." readme = "README.md" license = { file = "LICENSE" } From 9f117b52082dbbf5ec987c2a82e6643bd90cdfef Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 17 May 2026 02:33:10 +0800 Subject: [PATCH 02/20] feat: split returning support into DML-specific flags for PostgreSQL dialect --- .github/workflows/test.yml | 4 ++-- .../activerecord/backend/impl/postgres/dialect.py | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 358e7e4e..0c2a420f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,9 +77,9 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt - - name: Install activerecord and testsuite from PyPI + - name: Install activerecord and testsuite run: | - pip install rhosocial-activerecord==1.0.0.dev26 + pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev27 pip install rhosocial-activerecord-testsuite==1.0.0.dev13 - name: Create postgres scenario config diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 487b4b12..2cd1804f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -472,9 +472,17 @@ def supports_materialized_cte(self) -> bool: """MATERIALIZED hint is supported since PostgreSQL 12.""" return self.version >= (12, 0, 0) - def supports_returning_clause(self) -> bool: - """RETURNING clause is supported since PostgreSQL 8.2.""" - return True # Supported in all modern versions + def supports_returning_insert(self) -> bool: + """RETURNING clause is supported for INSERT since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) + + def supports_returning_update(self) -> bool: + """RETURNING clause is supported for UPDATE since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) + + def supports_returning_delete(self) -> bool: + """RETURNING clause is supported for DELETE since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) def supports_window_functions(self) -> bool: """Window functions are supported since PostgreSQL 8.4.""" From 5b05ec7bf28d83ee767aedd11dadb252140d2e2f Mon Sep 17 00:00:00 2001 From: zhao vistart <864657+vistart@users.noreply.github.com> Date: Sun, 17 May 2026 21:30:11 +0800 Subject: [PATCH 03/20] feat(dialect): require version adaptation before using Postgres dialect (#37) * feat(dialect): require version adaptation before using Postgres dialect PostgresDialect now defaults to version=None, requiring introspect_and_adapt() or explicit version before version-dependent features can be used. This prevents silent SQL errors from incorrect version assumptions. - Change PostgresDialect.__init__ default version to None - Remove hardcoded (9, 6, 0) from backend dialect construction - Update tests to use explicit version (13, 0, 0) Co-Authored-By: Claude Opus 4.7 * chore: add changelog fragment for PR #37 --------- Co-authored-by: Claude Opus 4.7 --- changelog.d/37.changed.md | 1 + .../backend/impl/postgres/backend/async_backend.py | 4 ++-- .../backend/impl/postgres/backend/sync.py | 4 ++-- .../activerecord/backend/impl/postgres/dialect.py | 10 +++++++--- .../feature/backend/test_dialect_function_support.py | 12 ++++++------ .../backend/test_postgres_dialect_security.py | 2 +- 6 files changed, 19 insertions(+), 14 deletions(-) create mode 100644 changelog.d/37.changed.md diff --git a/changelog.d/37.changed.md b/changelog.d/37.changed.md new file mode 100644 index 00000000..357f3fd9 --- /dev/null +++ b/changelog.d/37.changed.md @@ -0,0 +1 @@ +Changed PostgresDialect to default to version=None instead of fixed version, requiring introspect_and_adapt() or explicit version before version-dependent features can be used. \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py index a3e8cc38..beca6fbc 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py @@ -138,8 +138,8 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # Initialize PostgreSQL-specific components - # Use default version (9.6.0) - actual version detected via introspect_and_adapt() - self._dialect = PostgresDialect((9, 6, 0)) + # Version is None until introspect_and_adapt() is called + self._dialect = PostgresDialect() # Register PostgreSQL-specific type adapters (same as sync backend) # Note: XML adapter is NOT registered by default due to potential conflicts. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py index cf9b6d61..1c8eae2d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py @@ -138,8 +138,8 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # Initialize PostgreSQL-specific components - # Use default version (9.6.0) - actual version detected via introspect_and_adapt() - self._dialect = PostgresDialect((9, 6, 0)) + # Version is None until introspect_and_adapt() is called + self._dialect = PostgresDialect() # Register PostgreSQL-specific type adapters (same as async backend) # Note: XML adapter is NOT registered by default due to potential conflicts. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 2cd1804f..2e5a24f5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -427,15 +427,19 @@ class PostgresDialect( Use introspect_and_adapt() to detect installed extensions automatically. """ - def __init__(self, version: Tuple[int, int, int] = (13, 0, 0)): + def __init__(self, version: Optional[Tuple[int, int, int]] = None): """ Initialize PostgreSQL dialect with specific version. Args: - version: PostgreSQL version tuple (major, minor, patch) + version: PostgreSQL version tuple (major, minor, patch). + If None, the dialect must be adapted via + backend.introspect_and_adapt() before version-dependent + features can be used. """ - self.version = version super().__init__() + if version is not None: + self.version = version @staticmethod def _validate_data_type(data_type: str) -> bool: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py index da6d08ea..84813be1 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py @@ -15,21 +15,21 @@ class TestPostgreSQLFunctionSupportBasic: def test_supports_functions_returns_dict(self): """Test that supports_functions returns a dictionary.""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect.supports_functions() assert isinstance(result, dict) assert len(result) > 0 def test_supports_functions_all_values_are_support_info(self): """Test that all values in the returned dict are FunctionSupportInfo.""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect.supports_functions() for func_name, info in result.items(): assert isinstance(info, FunctionSupportInfo), f"Value for {func_name} is not FunctionSupportInfo" def test_core_functions_always_supported(self): """Test that core functions are marked as supported.""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect.supports_functions() core_functions = ["count", "sum_", "avg", "min_", "max_", "coalesce", "nullif"] for func in core_functions: @@ -231,7 +231,7 @@ def test_macaddr8_set7bit_requires_pg_10(self): def test_always_available_functions(self): """Test functions that are available in all PostgreSQL versions.""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect.supports_functions() always_available = [ @@ -249,7 +249,7 @@ class TestPostgreSQLFunctionSupportPrivateMethod: def test_unknown_function_returns_true(self): """Test that unknown functions return True (no restriction).""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect._is_postgres_function_supported("unknown_function_xyz") assert result is True @@ -277,7 +277,7 @@ class TestPostgreSQLFunctionSupportIntegration: def test_function_dict_contains_both_core_and_backend_functions(self): """Test that the result contains both core and PostgreSQL-specific functions.""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect.supports_functions() assert any(func in result for func in ["count", "sum", "avg"]) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py index d4d6fab9..a5e7e836 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py @@ -23,7 +23,7 @@ @pytest.fixture def dialect(): """Create a PostgreSQL test dialect.""" - return PostgresDialect() + return PostgresDialect((13, 0, 0)) def test_postgres_escape_sql_string(dialect): From d6d796bcd4b4e47e4558fa23ecc61eb5958b9717 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 22 May 2026 20:43:45 +0800 Subject: [PATCH 04/20] refactor(cli): rename named-query to named-expression, add procedure-graph Rename the CLI subcommand and underlying module from "named-query" to "named-expression" to reflect broader expression evaluation capability beyond just query execution. Add new "named-procedure-graph" subcommand for visualizing procedure relationship graphs. Update named_connection to pass output provider. --- .../diagram_demo.py | 2 +- .../order_workflow.py | 4 +- .../backend/impl/postgres/cli/__init__.py | 6 +- .../backend/impl/postgres/cli/connection.py | 2 +- .../impl/postgres/cli/named_connection.py | 7 +- .../{named_query.py => named_expression.py} | 65 ++-- .../impl/postgres/cli/named_procedure.py | 4 +- .../postgres/cli/named_procedure_graph.py | 92 ++++++ .../examples/cli/bash/cli_commands.sh | 299 ++++++++++++++++++ .../{named_query.sh => named_expression.sh} | 6 +- .../feature/backend/test_cli.py | 54 ++-- 11 files changed, 469 insertions(+), 72 deletions(-) rename src/rhosocial/activerecord/backend/impl/postgres/cli/{named_query.py => named_expression.py} (61%) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure_graph.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/cli_commands.sh rename src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/{named_query.sh => named_expression.sh} (67%) diff --git a/docs/examples/chapter_12_named_procedure/diagram_demo.py b/docs/examples/chapter_12_named_procedure/diagram_demo.py index d301bdb1..e223777a 100644 --- a/docs/examples/chapter_12_named_procedure/diagram_demo.py +++ b/docs/examples/chapter_12_named_procedure/diagram_demo.py @@ -7,7 +7,7 @@ 2. 执行过程后生成实例图 3. 将图嵌入文档 """ -from rhosocial.activerecord.backend.named_query import ( +from rhosocial.activerecord.backend.named_expression import ( Procedure, ProcedureContext, ProcedureRunner, diff --git a/docs/examples/chapter_12_named_procedure/order_workflow.py b/docs/examples/chapter_12_named_procedure/order_workflow.py index 6a8bdf38..b6b511a3 100644 --- a/docs/examples/chapter_12_named_procedure/order_workflow.py +++ b/docs/examples/chapter_12_named_procedure/order_workflow.py @@ -9,7 +9,7 @@ - 静态图和实例图生成 - PostgreSQL 特有的 NOWAIT, RETURNING 等特性 """ -from rhosocial.activerecord.backend.named_query import ( +from rhosocial.activerecord.backend.named_expression import ( Procedure, ProcedureContext, ParallelStep, @@ -192,7 +192,7 @@ async def run(self, ctx: ProcedureContext) -> None: if __name__ == "__main__": - from rhosocial.activerecord.backend.named_query import ProcedureDiagram + from rhosocial.activerecord.backend.named_expression import ProcedureDiagram print("=== 静态流程图 ===") print(OrderProcessingProcedure.static_diagram("flowchart")) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py index 999b3d97..54045e8f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py @@ -5,7 +5,7 @@ COMMAND_NAMES = [ 'info', 'query', 'introspect', 'status', - 'named-query', 'named-procedure', 'named-connection', + 'named-expression', 'named-procedure', 'named-procedure-graph', 'named-connection', ] @@ -15,8 +15,9 @@ def register_commands(subparsers): from .query import create_parser as query_parser from .introspect import create_parser as introspect_parser from .status import create_parser as status_parser - from .named_query import create_parser as nq_parser + from .named_expression import create_parser as nq_parser from .named_procedure import create_parser as np_parser + from .named_procedure_graph import create_parser as npg_parser from .named_connection import create_parser as nc_parser info_parser(subparsers) query_parser(subparsers) @@ -24,6 +25,7 @@ def register_commands(subparsers): status_parser(subparsers) nq_parser(subparsers) np_parser(subparsers) + npg_parser(subparsers) nc_parser(subparsers) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py index 2f400421..06dada96 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py @@ -70,7 +70,7 @@ def add_version_arg(parser): def create_connection_parent_parser(): """Create a parent parser with connection and output arguments. - Used by shared CLI helpers (named-query, named-procedure) that + Used by shared CLI helpers (named-expression, named-procedure) that require a parent_parser containing connection parameters. """ import argparse diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_connection.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_connection.py index 9a40e897..f010b88e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_connection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_connection.py @@ -26,7 +26,12 @@ def handle(args): """Handle the named-connection subcommand.""" from rhosocial.activerecord.backend.named_connection.cli import handle_named_connection as handle_nc + from .output import create_provider + output_fmt = getattr(args, "output", "table") + ascii_borders = getattr(args, "rich_ascii", False) + provider = create_provider(output_fmt, ascii_borders=ascii_borders) + def named_connection_resolver_factory(name): return NamedConnectionResolver(name) - handle_nc(args, named_connection_resolver_factory) + handle_nc(args, named_connection_resolver_factory, provider) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_query.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_expression.py similarity index 61% rename from src/rhosocial/activerecord/backend/impl/postgres/cli/named_query.py rename to src/rhosocial/activerecord/backend/impl/postgres/cli/named_expression.py index 966b4aeb..93f3d166 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_query.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_expression.py @@ -1,7 +1,7 @@ -# src/rhosocial/activerecord/backend/impl/postgres/cli/named_query.py -"""named-query subcommand - Adapter for shared CLI helper. +# src/rhosocial/activerecord/backend/impl/postgres/cli/named_expression.py +"""named-expression subcommand - Adapter for shared CLI helper. -named-query requires connection arguments and output arguments. +named-expression requires connection arguments and output arguments. """ from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, AsyncPostgresBackend @@ -12,44 +12,38 @@ def create_parser(subparsers): - """Create the named-query subcommand parser. + """Create the named-expression subcommand parser. - Reuses the shared create_named_query_parser, passing a parent parser + Reuses the shared create_named_expression_parser, passing a parent parser containing only connection and output arguments. """ - from rhosocial.activerecord.backend.named_query.cli import create_named_query_parser + from rhosocial.activerecord.backend.named_expression.cli import create_named_expression_parser local_parent = create_connection_parent_parser() + parser = create_named_expression_parser(subparsers, local_parent) + parser.add_argument( + "--dialect-version", + default=None, + help="PostgreSQL dialect version for capability probing (e.g., 15.0.0, 14.0.0).", + ) + return parser - nq_epilog = """Examples: - # Execute named query with connection parameters - %(prog)s myapp.queries.orders.high_value_pending --host localhost --database mydb --user postgres --password secret - - # Override parameters - %(prog)s myapp.queries.orders.high_value_pending --host localhost --database mydb \\ - --param threshold=5000 --param days=7 - - # Show signature without executing - %(prog)s myapp.queries.orders.high_value_pending --describe - - # Preview SQL without executing - %(prog)s myapp.queries.orders.orders_by_status \\ - --database mydb --param status=pending --dry-run - # List all named queries in a module - %(prog)s myapp.queries.orders --list +def handle(args): + """Handle the named-expression subcommand.""" + from rhosocial.activerecord.backend.named_expression.cli import handle_named_expression as handle_ne - # Using environment variables - export POSTGRES_HOST=localhost POSTGRES_DATABASE=mydb POSTGRES_USER=postgres POSTGRES_PASSWORD=secret - %(prog)s myapp.queries.orders --list -""" - return create_named_query_parser(subparsers, local_parent, epilog=nq_epilog) + provider = create_provider(args.output, ascii_borders=args.rich_ascii) + from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -def handle(args): - """Handle the named-query subcommand.""" - from rhosocial.activerecord.backend.named_query.cli import handle_named_query as handle_nq + def _get_requested_version(): + ver = getattr(args, "dialect_version", None) + if ver: + return tuple(int(p) for p in ver.split(".")) + return None - provider = create_provider(args.output, ascii_borders=args.rich_ascii) + def create_dialect(): + return PostgresDialect(version=_get_requested_version()) backend = None @@ -59,6 +53,9 @@ def backend_factory(): backend = PostgresBackend(connection_config=config) backend.connect() backend.introspect_and_adapt() + req_ver = _get_requested_version() + if req_ver: + backend._dialect.version = req_ver return backend def get_dialect(b): @@ -91,7 +88,7 @@ async def disconnect_async(): if async_backend and async_backend._connection: await async_backend.disconnect() - handle_nq( + handle_ne( args, provider, backend_factory=backend_factory, @@ -102,14 +99,16 @@ async def disconnect_async(): get_dialect_async=get_dialect_async, execute_query_async=execute_query_async, disconnect_async=disconnect_async, + create_dialect=create_dialect, ) return - handle_nq( + handle_ne( args, provider, backend_factory=backend_factory, get_dialect=get_dialect, execute_query=execute_query, disconnect=disconnect, + create_dialect=create_dialect, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure.py index 877be6d1..2df72470 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure.py @@ -16,7 +16,7 @@ def create_parser(subparsers): Reuses the shared create_named_procedure_parser, passing a parent parser containing only connection and output arguments. """ - from rhosocial.activerecord.backend.named_query.cli_procedure import create_named_procedure_parser + from rhosocial.activerecord.backend.named_expression.cli_procedure import create_named_procedure_parser local_parent = create_connection_parent_parser() np_epilog = """Examples: @@ -43,7 +43,7 @@ def create_parser(subparsers): def handle(args): """Handle the named-procedure subcommand.""" - from rhosocial.activerecord.backend.named_query.cli_procedure import handle_named_procedure as handle_np + from rhosocial.activerecord.backend.named_expression.cli_procedure import handle_named_procedure as handle_np provider = create_provider(args.output, ascii_borders=args.rich_ascii) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure_graph.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure_graph.py new file mode 100644 index 00000000..c3686f7e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure_graph.py @@ -0,0 +1,92 @@ +# src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure_graph.py +"""named-procedure-graph subcommand - Adapter for shared CLI helper. + +named-procedure-graph requires connection arguments, output arguments, and --rich-ascii. + +Usage: + $ python -m rhosocial.activerecord.backend.impl.postgres named-procedure-graph run \ + myapp.npg.monthly_report \ + --params '{"month": "2026-04"}' + + $ python -m rhosocial.activerecord.backend.impl.postgres named-procedure-graph list \ + myapp.npg + + $ python -m rhosocial.activerecord.backend.impl.postgres named-procedure-graph validate \ + myapp.npg.monthly_report +""" + +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend + +from .connection import create_connection_parent_parser, resolve_connection_config_from_args +from .output import create_provider + + +def create_parser(subparsers): + """Create the named-procedure-graph subcommand parser. + + Reuses the shared create_named_procedure_graph_parser, passing a parent parser + containing only connection and output arguments. + """ + from rhosocial.activerecord.backend.named_expression.cli_procedure_graph import ( + create_named_procedure_graph_parser, + ) + + local_parent = create_connection_parent_parser() + return create_named_procedure_graph_parser(subparsers, local_parent) + + +def handle(args): + """Handle the named-procedure-graph subcommand.""" + from rhosocial.activerecord.backend.named_expression.cli_procedure_graph import ( + handle_named_procedure_graph as handle_npg, + ) + + provider = create_provider(args.output, ascii_borders=args.rich_ascii) + + backend = None + + def backend_factory(): + nonlocal backend + config = resolve_connection_config_from_args(args) + backend = PostgresBackend(connection_config=config) + backend.connect() + backend.introspect_and_adapt() + return backend + + def disconnect(): + if backend and backend._connection: + backend.disconnect() + + is_async = getattr(args, "is_async", False) + + if is_async: + async_backend = None + + def backend_async_factory(): + nonlocal async_backend + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + config = resolve_connection_config_from_args(args) + async_backend = AsyncPostgresBackend(connection_config=config) + return async_backend + + async def disconnect_async(): + if async_backend and async_backend._connection: + await async_backend.disconnect() + + handle_npg( + args, + provider, + backend_factory=backend_factory, + disconnect=disconnect, + backend_async_factory=backend_async_factory, + disconnect_async=disconnect_async, + ) + return + + handle_npg( + args, + provider, + backend_factory=backend_factory, + disconnect=disconnect, + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/cli_commands.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/cli_commands.sh new file mode 100644 index 00000000..e9f2d368 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/cli_commands.sh @@ -0,0 +1,299 @@ +#!/bin/bash +# cli_commands.sh - Complete PostgreSQL CLI command examples +# +# This script demonstrates all available CLI commands in the PostgreSQL backend. +# Run this script to see examples of each command category. +# +# Usage: +# ./cli_commands.sh [COMMAND] [OPTIONS] +# +# Commands: +# info - Display PostgreSQL environment information +# query - Execute SQL queries +# introspect - Database introspection +# status - Display server status +# named-expression - Execute named expressions +# named-procedure - Execute named procedures +# named-procedure-graph - Execute procedure graphs +# named-connection - Manage named connections +# all - Run all command examples (default) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +export PYTHONSAFEPATH=1 + +# Default connection parameters (override via environment) +POSTGRES_HOST="${POSTGRES_HOST:-localhost}" +POSTGRES_PORT="${POSTGRES_PORT:-5432}" +POSTGRES_DATABASE="${POSTGRES_DATABASE:-test}" +POSTGRES_USER="${POSTGRES_USER:-postgres}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" + +export POSTGRES_HOST POSTGRES_PORT POSTGRES_DATABASE POSTGRES_USER POSTGRES_PASSWORD + +PYTHON_CMD="python -m rhosocial.activerecord.backend.impl.postgres" +CONN_ARGS="--host $POSTGRES_HOST --port $POSTGRES_PORT --database $POSTGRES_DATABASE --user $POSTGRES_USER --password $POSTGRES_PASSWORD" + +# Command: info +run_info() { + echo "" + echo "==========================================" + echo "Command: info" + echo "==========================================" + echo "" + + echo "--- Basic info ---" + $PYTHON_CMD info + + echo "" + echo "--- Verbose info (protocol families) ---" + $PYTHON_CMD info -v + + echo "" + echo "--- Detailed verbose (all details) ---" + $PYTHON_CMD info -vv + + echo "" + echo "--- JSON output ---" + $PYTHON_CMD info -o json +} + +# Command: query +run_query() { + echo "" + echo "==========================================" + echo "Command: query" + echo "==========================================" + echo "" + + echo "--- Simple SELECT ---" + $PYTHON_CMD query $CONN_ARGS "SELECT 1 as test, 'hello' as greeting" 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- SELECT with WHERE ---" + $PYTHON_CMD query $CONN_ARGS "SELECT * FROM users WHERE id = 1" 2>/dev/null || echo "(Requires running PostgreSQL server with users table)" + + echo "" + echo "--- JSON output ---" + $PYTHON_CMD query $CONN_ARGS "SELECT 1 as test" -o json 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- CSV output ---" + $PYTHON_CMD query $CONN_ARGS "SELECT 1 as test" -o csv 2>/dev/null || echo "(Requires running PostgreSQL server)" +} + +# Command: introspect +run_introspect() { + echo "" + echo "==========================================" + echo "Command: introspect" + echo "==========================================" + echo "" + + echo "--- List all tables ---" + $PYTHON_CMD introspect $CONN_ARGS tables 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- List all views ---" + $PYTHON_CMD introspect $CONN_ARGS views 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get table details ---" + $PYTHON_CMD introspect $CONN_ARGS table users 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get column details ---" + $PYTHON_CMD introspect $CONN_ARGS columns users 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get indexes ---" + $PYTHON_CMD introspect $CONN_ARGS indexes users 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get foreign keys ---" + $PYTHON_CMD introspect $CONN_ARGS foreign-keys users 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get triggers ---" + $PYTHON_CMD introspect $CONN_ARGS triggers users 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get database info ---" + $PYTHON_CMD introspect $CONN_ARGS database 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- JSON output ---" + $PYTHON_CMD introspect $CONN_ARGS tables -o json 2>/dev/null || echo "(Requires running PostgreSQL server)" +} + +# Command: status +run_status() { + echo "" + echo "==========================================" + echo "Command: status" + echo "==========================================" + echo "" + + echo "--- All status ---" + $PYTHON_CMD status $CONN_ARGS all 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Config status ---" + $PYTHON_CMD status $CONN_ARGS config 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Performance status ---" + $PYTHON_CMD status $CONN_ARGS performance 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Storage status ---" + $PYTHON_CMD status $CONN_ARGS storage 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Databases status ---" + $PYTHON_CMD status $CONN_ARGS databases 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Verbose output ---" + $PYTHON_CMD status $CONN_ARGS all -v 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- JSON output ---" + $PYTHON_CMD status $CONN_ARGS all -o json 2>/dev/null || echo "(Requires running PostgreSQL server)" +} + +# Command: named-expression +run_named_expression() { + echo "" + echo "==========================================" + echo "Command: named-expression" + echo "==========================================" + echo "" + + echo "--- List named expressions (module may not exist) ---" + $PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions 2>/dev/null || echo "(No named expression examples found)" + + echo "" + echo "--- List with dialect version ---" + $PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions --dialect-version 15.0.0 2>/dev/null || echo "(No named expression examples found)" + + echo "" + echo "--- Describe expression ---" + $PYTHON_CMD named-expression --describe rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.some_expression 2>/dev/null || echo "(No named expression examples found)" +} + +# Command: named-procedure +run_named_procedure() { + echo "" + echo "==========================================" + echo "Command: named-procedure" + echo "==========================================" + echo "" + + echo "--- List named procedures (module may not exist) ---" + $PYTHON_CMD named-procedure --list rhosocial.activerecord.backend.impl.postgres.examples.named_procedures 2>/dev/null || echo "(No named procedure examples found)" + + echo "" + echo "--- Describe procedure ---" + $PYTHON_CMD named-procedure --describe rhosocial.activerecord.backend.impl.postgres.examples.named_procedures.SomeProcedure 2>/dev/null || echo "(No named procedure examples found)" + + echo "" + echo "--- Dry run procedure ---" + $PYTHON_CMD named-procedure rhosocial.activerecord.backend.impl.postgres.examples.named_procedures.SomeProcedure --dry-run 2>/dev/null || echo "(No named procedure examples found)" +} + +# Command: named-procedure-graph +run_named_procedure_graph() { + echo "" + echo "==========================================" + echo "Command: named-procedure-graph" + echo "==========================================" + echo "" + + echo "--- List procedure graphs (module may not exist) ---" + $PYTHON_CMD named-procedure-graph --list rhosocial.activerecord.backend.impl.postgres.examples.named_procedure_graph 2>/dev/null || echo "(No named procedure graph examples found)" + + echo "" + echo "--- Validate graph ---" + $PYTHON_CMD named-procedure-graph rhosocial.activerecord.backend.impl.postgres.examples.named_procedure_graph.some_graph --validate 2>/dev/null || echo "(No named procedure graph examples found)" + + echo "" + echo "--- Show wave decomposition ---" + $PYTHON_CMD named-procedure-graph rhosocial.activerecord.backend.impl.postgres.examples.named_procedure_graph.some_graph --waves 2>/dev/null || echo "(No named procedure graph examples found)" +} + +# Command: named-connection +run_named_connection() { + echo "" + echo "==========================================" + echo "Command: named-connection" + echo "==========================================" + echo "" + + MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_connections" + + echo "--- List connections in module ---" + $PYTHON_CMD named-connection --list "$MODULE" + + echo "" + echo "--- Show connection ---" + $PYTHON_CMD named-connection --show "$MODULE.local_dev" || true + + echo "" + echo "--- Describe connection ---" + $PYTHON_CMD named-connection --describe "$MODULE.local_dev" || true +} + +# Main +COMMAND="${1:-all}" + +case "$COMMAND" in + info) + run_info + ;; + query) + run_query + ;; + introspect) + run_introspect + ;; + status) + run_status + ;; + named-expression) + run_named_expression + ;; + named-procedure) + run_named_procedure + ;; + named-procedure-graph) + run_named_procedure_graph + ;; + named-connection) + run_named_connection + ;; + all) + run_info + run_query + run_introspect + run_status + run_named_expression + run_named_procedure + run_named_procedure_graph + run_named_connection + echo "" + echo "==========================================" + echo "All examples completed!" + echo "==========================================" + ;; + *) + echo "Unknown command: $COMMAND" + echo "Available commands: info, query, introspect, status, named-expression, named-procedure, named-procedure-graph, named-connection, all" + exit 1 + ;; +esac + +echo "" +echo "Done." diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_query.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_expression.sh similarity index 67% rename from src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_query.sh rename to src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_expression.sh index 833f1226..ff246bd9 100755 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_query.sh +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_expression.sh @@ -1,5 +1,5 @@ #!/bin/bash -# named_query.sh - PostgreSQL CLI named-query command example +# named_expression.sh - PostgreSQL CLI named-expression command example set -e @@ -14,8 +14,8 @@ export POSTGRES_HOST POSTGRES_PORT POSTGRES_DATABASE POSTGRES_USER POSTGRES_PASS PYTHON_CMD="python -m rhosocial.activerecord.backend.impl.postgres" echo "==========================================" -echo "PostgreSQL CLI - named-query command examples" +echo "PostgreSQL CLI - named-expression command examples" echo "==========================================" -$PYTHON_CMD named-query --list rhosocial.activerecord.backend.impl.postgres.examples.named_queries 2>/dev/null || echo "(No named queries found)" +$PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions 2>/dev/null || echo "(No named expressions found)" $PYTHON_CMD named-connection --list rhosocial.activerecord.backend.impl.postgres.examples.named_connections \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py index 9e6e2b65..4b3bff63 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py @@ -2,7 +2,7 @@ """ Tests for PostgreSQL backend CLI. -Tests argument parsing for named-query and named-procedure subcommands. +Tests argument parsing for named-expression and named-procedure subcommands. """ import pytest @@ -10,71 +10,71 @@ from unittest.mock import patch -class TestCLINamedQueryArgs: - """Tests for named-query subcommand argument parsing.""" +class TestCLINamedExpressionArgs: + """Tests for named-expression subcommand argument parsing.""" - def test_parse_args_named_query_basic(self): - """Test basic named-query parsing.""" + def test_parse_args_named_expression_basic(self): + """Test basic named-expression parsing.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-query', 'myapp.queries.test']): + with patch.object(sys, 'argv', ['postgres', 'named-expression', 'myapp.queries.test']): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.qualified_name == 'myapp.queries.test' - def test_parse_args_named_query_with_params(self): - """Test named-query with parameters.""" + def test_parse_args_named_expression_with_params(self): + """Test named-expression with parameters.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args with patch.object(sys, 'argv', [ - 'postgres', 'named-query', 'myapp.queries.test', + 'postgres', 'named-expression', 'myapp.queries.test', '--param', 'limit=50', '--param', 'status=active', ]): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.params == ['limit=50', 'status=active'] - def test_parse_args_named_query_dry_run(self): - """Test named-query with --dry-run.""" + def test_parse_args_named_expression_dry_run(self): + """Test named-expression with --dry-run.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-query', 'myapp.queries.test', '--dry-run']): + with patch.object(sys, 'argv', ['postgres', 'named-expression', 'myapp.queries.test', '--dry-run']): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.dry_run is True - def test_parse_args_named_query_describe(self): - """Test named-query with --describe.""" + def test_parse_args_named_expression_describe(self): + """Test named-expression with --describe.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-query', 'myapp.queries.test', '--describe']): + with patch.object(sys, 'argv', ['postgres', 'named-expression', 'myapp.queries.test', '--describe']): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.describe is True - def test_parse_args_named_query_list(self): - """Test named-query with --list.""" + def test_parse_args_named_expression_list(self): + """Test named-expression with --list.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-query', 'myapp.queries', '--list']): + with patch.object(sys, 'argv', ['postgres', 'named-expression', 'myapp.queries', '--list']): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.list_queries is True - def test_parse_args_named_query_async(self): - """Test named-query with --async.""" + def test_parse_args_named_expression_async(self): + """Test named-expression with --async.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-query', 'myapp.queries.test', '--async']): + with patch.object(sys, 'argv', ['postgres', 'named-expression', 'myapp.queries.test', '--async']): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.is_async is True From fdb7fb33be37aa4a045d384a85e30e49d37c4107 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 23 May 2026 13:49:40 +0800 Subject: [PATCH 05/20] fix(postgres): fix test file indentation and add format_identifier security tests - Fixed broken indentation: test_drop_statistics_name_quoted was nested inside test_partition_value_integer instead of the class - Added format_identifier naive-vs-proper equivalence tests for safe input - Added format_identifier naive-vs-proper injection immunity tests showing proper escaping prevents breakout that naive quoting allows - Added tests for normal, embedded-quote, injection-payload, and empty cases --- .../backend/test_postgres_dialect_security.py | 873 ++++++++++-------- 1 file changed, 490 insertions(+), 383 deletions(-) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py index a5e7e836..6bbb5b05 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py @@ -1,383 +1,490 @@ -# tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py -""" -Tests for PostgreSQL dialect SQL injection security fixes. - -This test module verifies that string escaping and validation -methods properly sanitize user input to prevent SQL injection. -Tests are run against the actual PostgreSQL dialect. -""" -import pytest - -from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.expression.bases import BaseExpression -from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - TableConstraint, - TableConstraintType, -) -from typing import Tuple, Any - - -@pytest.fixture -def dialect(): - """Create a PostgreSQL test dialect.""" - return PostgresDialect((13, 0, 0)) - - -def test_postgres_escape_sql_string(dialect): - """Test PostgreSQL inherits _escape_sql_string.""" - result = dialect._escape_sql_string("test's value") - assert result == "test''s value" - - -def test_postgres_validate_data_type(dialect): - """Test PostgreSQL inherits _validate_data_type.""" - assert dialect._validate_data_type("INTEGER") - assert dialect._validate_data_type("VARCHAR(255)") - assert dialect._validate_data_type("TEXT") - assert not dialect._validate_data_type("TEXT; DROP TABLE users--") - - -def test_postgres_format_column_definition_data_type_validation(dialect): - """Test column definition validates data_type.""" - col_def = ColumnDefinition( - name="test_col", - data_type="VARCHAR(255)", - ) - - sql, params = dialect.format_column_definition(col_def) - assert "VARCHAR(255)" in sql - - -def test_postgres_format_column_definition_data_type_rejects_injection(dialect): - """Test that malicious data_type is rejected.""" - col_def = ColumnDefinition( - name="test_col", - data_type="VARCHAR(255); DROP TABLE users--", - ) - - with pytest.raises(ValueError, match="Invalid data type"): - dialect.format_column_definition(col_def) - - -def test_postgres_format_default_constraint_string_escaping(dialect): - """Test DEFAULT constraint string is escaped.""" - constraint = ColumnConstraint( - constraint_type=ColumnConstraintType.DEFAULT, - default_value="test's value", - ) - - sql, params = dialect._format_default_constraint(constraint) - assert "test''s value" in sql - assert "'; DROP" not in sql - - -def test_postgres_format_storage_options_string_escaping(dialect): - """Test storage options string values are escaped.""" - storage_opts = {"key": "value's"} - sql, params = dialect._format_storage_options(storage_opts) - assert "value''s" in sql - assert "'; DROP" not in sql - - -def test_postgres_format_cast_expression_valid(dialect): - """Test that CAST expression validates target_type.""" - sql, params = dialect.format_cast_expression("column", "INTEGER", (), None) - assert "INTEGER" in sql - - -def test_exclude_constraint_valid_using_methods(dialect): - """Test valid index access methods are accepted.""" - constraint = TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "using": "gist", - "exclude_elements": [("int4range", "&&")], - }, - ) - - sql, params = dialect._format_exclude_constraint(constraint) - assert "EXCLUDE USING gist" in sql - - -def test_exclude_constraint_valid_operators(dialect): - """Test valid exclude operators are accepted.""" - valid_ops = [ - ("int4range", "&&"), - ("float8range", "&&"), - ("name", "="), - ("box", "~="), - ] - - for expr, op in valid_ops: - constraint = TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "exclude_elements": [(expr, op)], - }, - ) - - sql, params = dialect._format_exclude_constraint(constraint) - assert f"WITH {op}" in sql - - -def test_exclude_constraint_rejects_invalid_using(dialect): - """Test that invalid index access method is rejected.""" - constraint = TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "using": "invalid_method", - "exclude_elements": [("range", "&&")], - }, - ) - - with pytest.raises(ValueError, match="Invalid index access method"): - dialect._format_exclude_constraint(constraint) - - -def test_exclude_constraint_rejects_invalid_operator(dialect): - """Test that invalid exclude operator is rejected.""" - constraint = TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "exclude_elements": [("range", "'; DROP TABLE users--")], - }, - ) - - with pytest.raises(ValueError, match="Invalid exclude operator"): - dialect._format_exclude_constraint(constraint) - - -def test_exclude_constraint_sql_injection_prevention(dialect): - """Test that SQL injection attempts are blocked.""" - constraint = TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "using": "gist", - "exclude_elements": [("range", "&&")], - }, - ) - - sql, params = dialect._format_exclude_constraint(constraint) - - assert "; DROP" not in sql - assert "--" not in sql - assert "/*" not in sql - - -class TestPostgresEnumSecurity: - """Tests for CREATE TYPE ENUM value escaping (fix for SQL injection risk).""" - - def test_create_type_enum_values_escaped(self, dialect): - """Test ENUM values are properly escaped with single quote doubling.""" - sql, params = dialect.format_create_type_enum_statement( - name="test_enum", - values=["it's", "normal", "test's value"], - ) - - assert "it''s" in sql - assert "test''s value" in sql - assert "'; DROP" not in sql - - def test_create_type_enum_sql_injection_blocked(self, dialect): - """Test SQL injection in ENUM values is properly escaped (not executed).""" - sql, params = dialect.format_create_type_enum_statement( - name="test_enum", - values=["normal", "'; DROP TABLE users--"], - ) - - assert "DROP TABLE users--" in sql - assert "CREATE TYPE" in sql - assert "AS ENUM" in sql - - -class TestPostgresStoredProcedureNameSecurity: - """Tests for stored procedure names using format_identifier.""" - - def test_create_procedure_name_quoted(self, dialect): - """Test CREATE PROCEDURE name is properly quoted.""" - sql, params = dialect.format_create_procedure_statement( - name="my_procedure", - body="BEGIN END", - language="PL/pgSQL", - ) - - assert '"my_procedure"' in sql - - def test_drop_procedure_name_quoted(self, dialect): - """Test DROP PROCEDURE name is properly quoted.""" - sql, params = dialect.format_drop_procedure_statement( - name="my_procedure", - ) - - assert '"my_procedure"' in sql - - def test_call_statement_name_quoted(self, dialect): - """Test CALL statement procedure name is properly quoted.""" - sql, params = dialect.format_call_statement( - name="my_procedure", - ) - - assert '"my_procedure"' in sql - - def test_procedure_with_schema_quoted(self, dialect): - """Test procedure with schema is properly quoted.""" - sql, params = dialect.format_create_procedure_statement( - schema="my_schema", - name="my_procedure", - body="BEGIN END", - language="PL/pgSQL", - ) - - assert '"my_schema"' in sql - assert '"my_procedure"' in sql - - -class TestPostgresPartialIndexWhereClauseSecurity: - """Tests for partial index WHERE clause (ToSQLProtocol support).""" - - def test_create_index_with_string_where_clause(self, dialect): - """Test CREATE INDEX with string WHERE clause (backward compatible).""" - sql, params = dialect.format_create_index_pg_statement( - index_name="idx_test", - table_name="users", - columns=["email"], - where_clause="active = true", - ) - - assert "WHERE active = true" in sql - assert params == () - - def test_create_index_with_to_sql_protocol(self, dialect): - """Test CREATE INDEX with ToSQLProtocol expression (parameterized).""" - from rhosocial.activerecord.backend.expression.bases import BaseExpression - - class MockWhereExpr(BaseExpression): - def __init__(self): - self._sql = "age >= 18" - self._params = (18,) - - def to_sql(self): - return self._sql, self._params - - sql, params = dialect.format_create_index_pg_statement( - index_name="idx_active", - table_name="users", - columns=["email"], - where_clause=MockWhereExpr(), - ) - - assert "WHERE age >= 18" in sql - assert params == (18,) - - def test_create_index_where_clause_params_collected(self, dialect): - """Test WHERE clause parameters are properly collected and returned.""" - from rhosocial.activerecord.backend.expression.bases import BaseExpression - - class MockWhereExpr(BaseExpression): - def __init__(self): - self._sql = "status = %s" - self._params = ("active",) - - def to_sql(self): - return self._sql, self._params - - sql, params = dialect.format_create_index_pg_statement( - index_name="idx_test", - table_name="users", - columns=["status"], - where_clause=MockWhereExpr(), - ) - - assert "WHERE status = %s" in sql - assert params == ("active",) - - -class TestPostgresTriggerFunctionNameSecurity: - """Tests for trigger function name using format_identifier.""" - - def test_trigger_function_name_quoted(self, dialect): - """Test trigger function name is properly quoted with double quotes.""" - from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - TriggerTiming, - TriggerEvent, - ) - - expr = CreateTriggerExpression( - dialect=dialect, - trigger_name="my_trigger", - table_name="users", - timing=TriggerTiming.BEFORE, - events=[TriggerEvent.INSERT], - function_name="my_function", - ) - - sql, params = dialect.format_create_trigger_statement(expr) - - assert '"my_function"' in sql - - def test_trigger_function_name_special_chars_quoted(self, dialect): - """Test trigger function name with special characters is quoted.""" - from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - TriggerTiming, - TriggerEvent, - ) - - expr = CreateTriggerExpression( - dialect=dialect, - trigger_name="trigger", - table_name="users", - timing=TriggerTiming.BEFORE, - events=[TriggerEvent.INSERT], - function_name="Function With Spaces", - ) - - sql, params = dialect.format_create_trigger_statement(expr) - - assert '"Function With Spaces"' in sql - - -class TestPostgresExtendedStatisticsNameSecurity: - """Tests for extended statistics names using format_identifier.""" - - def test_create_statistics_name_quoted(self, dialect): - """Test CREATE STATISTICS name is properly quoted.""" - from rhosocial.activerecord.backend.impl.postgres.expression.ddl.statistics import ( - PostgresCreateStatisticsExpression, - ) - - expr = PostgresCreateStatisticsExpression( - dialect=dialect, - name="my_stats", - table_name="users", - columns=["email"], - statistics_type="ndistinct", - ) - - sql, params = dialect.format_create_statistics_statement(expr) - - assert '"my_stats"' in sql - - def test_drop_statistics_name_quoted(self, dialect): - """Test DROP STATISTICS name is properly quoted.""" - from rhosocial.activerecord.backend.impl.postgres.expression.ddl.statistics import ( - PostgresDropStatisticsExpression, - ) - - expr = PostgresDropStatisticsExpression( - dialect=dialect, - name="my_stats", - ) - - sql, params = dialect.format_drop_statistics_statement(expr) - - assert '"my_stats"' in sql \ No newline at end of file +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +""" +Tests for PostgreSQL dialect SQL injection security fixes. + +This test module verifies that string escaping and validation +methods properly sanitize user input to prevent SQL injection. +Tests are run against the actual PostgreSQL dialect. +""" +import pytest + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, + TableConstraint, + TableConstraintType, +) +from typing import Tuple, Any + + +@pytest.fixture +def dialect(): + """Create a PostgreSQL test dialect.""" + return PostgresDialect((13, 0, 0)) + + +def test_postgres_escape_sql_string(dialect): + """Test PostgreSQL inherits _escape_sql_string.""" + result = dialect._escape_sql_string("test's value") + assert result == "test''s value" + + +def test_postgres_validate_data_type(dialect): + """Test PostgreSQL inherits _validate_data_type.""" + assert dialect._validate_data_type("INTEGER") + assert dialect._validate_data_type("VARCHAR(255)") + assert dialect._validate_data_type("TEXT") + assert not dialect._validate_data_type("TEXT; DROP TABLE users--") + + +def test_postgres_format_column_definition_data_type_validation(dialect): + """Test column definition validates data_type.""" + col_def = ColumnDefinition( + name="test_col", + data_type="VARCHAR(255)", + ) + + sql, params = dialect.format_column_definition(col_def) + assert "VARCHAR(255)" in sql + + +def test_postgres_format_column_definition_data_type_rejects_injection(dialect): + """Test that malicious data_type is rejected.""" + col_def = ColumnDefinition( + name="test_col", + data_type="VARCHAR(255); DROP TABLE users--", + ) + + with pytest.raises(ValueError, match="Invalid data type"): + dialect.format_column_definition(col_def) + + +def test_postgres_format_default_constraint_string_escaping(dialect): + """Test DEFAULT constraint string is escaped.""" + constraint = ColumnConstraint( + constraint_type=ColumnConstraintType.DEFAULT, + default_value="test's value", + ) + + sql, params = dialect._format_default_constraint(constraint) + assert "test''s value" in sql + assert "'; DROP" not in sql + + +def test_postgres_format_storage_options_string_escaping(dialect): + """Test storage options string values are escaped.""" + storage_opts = {"key": "value's"} + sql, params = dialect._format_storage_options(storage_opts) + assert "value''s" in sql + assert "'; DROP" not in sql + + +def test_postgres_format_cast_expression_valid(dialect): + """Test that CAST expression validates target_type.""" + sql, params = dialect.format_cast_expression("column", "INTEGER", (), None) + assert "INTEGER" in sql + + +def test_exclude_constraint_valid_using_methods(dialect): + """Test valid index access methods are accepted.""" + constraint = TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name="test_exclude", + dialect_options={ + "using": "gist", + "exclude_elements": [("int4range", "&&")], + }, + ) + + sql, params = dialect._format_exclude_constraint(constraint) + assert "EXCLUDE USING gist" in sql + + +def test_exclude_constraint_valid_operators(dialect): + """Test valid exclude operators are accepted.""" + valid_ops = [ + ("int4range", "&&"), + ("float8range", "&&"), + ("name", "="), + ("box", "~="), + ] + + for expr, op in valid_ops: + constraint = TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name="test_exclude", + dialect_options={ + "exclude_elements": [(expr, op)], + }, + ) + + sql, params = dialect._format_exclude_constraint(constraint) + assert f"WITH {op}" in sql + + +def test_exclude_constraint_rejects_invalid_using(dialect): + """Test that invalid index access method is rejected.""" + constraint = TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name="test_exclude", + dialect_options={ + "using": "invalid_method", + "exclude_elements": [("range", "&&")], + }, + ) + + with pytest.raises(ValueError, match="Invalid index access method"): + dialect._format_exclude_constraint(constraint) + + +def test_exclude_constraint_rejects_invalid_operator(dialect): + """Test that invalid exclude operator is rejected.""" + constraint = TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name="test_exclude", + dialect_options={ + "exclude_elements": [("range", "'; DROP TABLE users--")], + }, + ) + + with pytest.raises(ValueError, match="Invalid exclude operator"): + dialect._format_exclude_constraint(constraint) + + +def test_exclude_constraint_sql_injection_prevention(dialect): + """Test that SQL injection attempts are blocked.""" + constraint = TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name="test_exclude", + dialect_options={ + "using": "gist", + "exclude_elements": [("range", "&&")], + }, + ) + + sql, params = dialect._format_exclude_constraint(constraint) + + assert "; DROP" not in sql + assert "--" not in sql + assert "/*" not in sql + + +class TestPostgresEnumSecurity: + """Tests for CREATE TYPE ENUM value escaping (fix for SQL injection risk).""" + + def test_create_type_enum_values_escaped(self, dialect): + """Test ENUM values are properly escaped with single quote doubling.""" + sql, params = dialect.format_create_type_enum_statement( + name="test_enum", + values=["it's", "normal", "test's value"], + ) + + assert "it''s" in sql + assert "test''s value" in sql + assert "'; DROP" not in sql + + def test_create_type_enum_sql_injection_blocked(self, dialect): + """Test SQL injection in ENUM values is properly escaped (not executed).""" + sql, params = dialect.format_create_type_enum_statement( + name="test_enum", + values=["normal", "'; DROP TABLE users--"], + ) + + assert "DROP TABLE users--" in sql + assert "CREATE TYPE" in sql + assert "AS ENUM" in sql + + +class TestPostgresStoredProcedureNameSecurity: + """Tests for stored procedure names using format_identifier.""" + + def test_create_procedure_name_quoted(self, dialect): + """Test CREATE PROCEDURE name is properly quoted.""" + sql, params = dialect.format_create_procedure_statement( + name="my_procedure", + body="BEGIN END", + language="PL/pgSQL", + ) + + assert '"my_procedure"' in sql + + def test_drop_procedure_name_quoted(self, dialect): + """Test DROP PROCEDURE name is properly quoted.""" + sql, params = dialect.format_drop_procedure_statement( + name="my_procedure", + ) + + assert '"my_procedure"' in sql + + def test_call_statement_name_quoted(self, dialect): + """Test CALL statement procedure name is properly quoted.""" + sql, params = dialect.format_call_statement( + name="my_procedure", + ) + + assert '"my_procedure"' in sql + + def test_procedure_with_schema_quoted(self, dialect): + """Test procedure with schema is properly quoted.""" + sql, params = dialect.format_create_procedure_statement( + schema="my_schema", + name="my_procedure", + body="BEGIN END", + language="PL/pgSQL", + ) + + assert '"my_schema"' in sql + assert '"my_procedure"' in sql + + +class TestPostgresPartialIndexWhereClauseSecurity: + """Tests for partial index WHERE clause (ToSQLProtocol support).""" + + def test_create_index_with_string_where_clause(self, dialect): + """Test CREATE INDEX with string WHERE clause (backward compatible).""" + sql, params = dialect.format_create_index_pg_statement( + index_name="idx_test", + table_name="users", + columns=["email"], + where_clause="active = true", + ) + + assert "WHERE active = true" in sql + assert params == () + + def test_create_index_with_to_sql_protocol(self, dialect): + """Test CREATE INDEX with ToSQLProtocol expression (parameterized).""" + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + class MockWhereExpr(BaseExpression): + def __init__(self): + self._sql = "age >= 18" + self._params = (18,) + + def to_sql(self): + return self._sql, self._params + + sql, params = dialect.format_create_index_pg_statement( + index_name="idx_active", + table_name="users", + columns=["email"], + where_clause=MockWhereExpr(), + ) + + assert "WHERE age >= 18" in sql + assert params == (18,) + + def test_create_index_where_clause_params_collected(self, dialect): + """Test WHERE clause parameters are properly collected and returned.""" + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + class MockWhereExpr(BaseExpression): + def __init__(self): + self._sql = "status = %s" + self._params = ("active",) + + def to_sql(self): + return self._sql, self._params + + sql, params = dialect.format_create_index_pg_statement( + index_name="idx_test", + table_name="users", + columns=["status"], + where_clause=MockWhereExpr(), + ) + + assert "WHERE status = %s" in sql + assert params == ("active",) + + +class TestPostgresTriggerFunctionNameSecurity: + """Tests for trigger function name using format_identifier.""" + + def test_trigger_function_name_quoted(self, dialect): + """Test trigger function name is properly quoted with double quotes.""" + from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( + CreateTriggerExpression, + TriggerTiming, + TriggerEvent, + ) + + expr = CreateTriggerExpression( + dialect=dialect, + trigger_name="my_trigger", + table_name="users", + timing=TriggerTiming.BEFORE, + events=[TriggerEvent.INSERT], + function_name="my_function", + ) + + sql, params = dialect.format_create_trigger_statement(expr) + + assert '"my_function"' in sql + + def test_trigger_function_name_special_chars_quoted(self, dialect): + """Test trigger function name with special characters is quoted.""" + from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( + CreateTriggerExpression, + TriggerTiming, + TriggerEvent, + ) + + expr = CreateTriggerExpression( + dialect=dialect, + trigger_name="trigger", + table_name="users", + timing=TriggerTiming.BEFORE, + events=[TriggerEvent.INSERT], + function_name="Function With Spaces", + ) + + sql, params = dialect.format_create_trigger_statement(expr) + + assert '"Function With Spaces"' in sql + + +class TestPostgresExtendedStatisticsNameSecurity: + """Tests for extended statistics names using format_identifier.""" + + def test_create_statistics_name_quoted(self, dialect): + """Test CREATE STATISTICS name is properly quoted.""" + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.statistics import ( + PostgresCreateStatisticsExpression, + ) + + expr = PostgresCreateStatisticsExpression( + dialect=dialect, + name="my_stats", + table_name="users", + columns=["email"], + statistics_type="ndistinct", + ) + + sql, params = dialect.format_create_statistics_statement(expr) + + assert '"my_stats"' in sql + + def test_drop_statistics_name_quoted(self, dialect): + """Test DROP STATISTICS name is properly quoted.""" + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.statistics import ( + PostgresDropStatisticsExpression, + ) + + expr = PostgresDropStatisticsExpression( + dialect=dialect, + name="my_stats", + ) + + sql, params = dialect.format_drop_statistics_statement(expr) + + assert '"my_stats"' in sql + + +# ============================================================ +# _format_partition_value — single-quote escaping +# ============================================================ + +def test_partition_value_none(dialect): + """None partition value returns NULL.""" + result = dialect._format_partition_value(None) + assert result == "NULL" + + +def test_partition_value_maxvalue(dialect): + """MAXVALUE is returned as-is (case-insensitive).""" + result = dialect._format_partition_value("MAXVALUE") + assert result == "MAXVALUE" + + +def test_partition_value_minvalue(dialect): + """MINVALUE is returned as-is (case-insensitive).""" + result = dialect._format_partition_value("minvalue") + assert result == "MINVALUE" + + +def test_partition_value_normal_string(dialect): + """Normal string value is single-quoted.""" + result = dialect._format_partition_value("2024-01-01") + assert result == "'2024-01-01'" + + +def test_partition_value_escaped_single_quote(dialect): + """String value with single quote is properly escaped.""" + result = dialect._format_partition_value("it's") + assert result == "'it''s'" + assert "'; DROP" not in result + + +def test_partition_value_injection_blocked(dialect): + """SQL injection in partition value is safely escaped (inside quotes).""" + result = dialect._format_partition_value("x'; DROP TABLE users--") + assert result.count("'") % 2 == 0 + assert result.startswith("'") + assert result.endswith("'") + + +def test_partition_value_integer(dialect): + """Integer partition value is returned as str().""" + result = dialect._format_partition_value(42) + assert result == "42" + + +# ============================================================ +# format_identifier — identifier quoting equivalence and injection immunity +# ============================================================ + +def test_format_identifier_normal(dialect): + """Normal identifier is double-quoted.""" + result = dialect.format_identifier("users") + assert result == '"users"' + + +def test_format_identifier_with_quote(dialect): + """Identifier with embedded double-quote is properly escaped.""" + result = dialect.format_identifier('table"name') + assert result == '"table""name"' + + +def test_format_identifier_injection_payload(dialect): + """Identifier with injection payload is safely contained (balanced quotes).""" + payload = 'users"; DROP TABLE users--' + result = dialect.format_identifier(payload) + assert result.count('"') % 2 == 0, f"Unbalanced quotes: {result}" + assert result == '"users""; DROP TABLE users--"' + + +def test_format_identifier_naive_vs_proper_safe(dialect): + """For safe input, naive and proper quoting produce same structure.""" + names = ["users", "orders", "products", "table_1", "camelCase"] + for name in names: + naive = f'"{name}"' + proper = dialect.format_identifier(name) + assert naive == proper, f"Mismatch for '{name}': naive={naive}, proper={proper}" + + +def test_format_identifier_naive_vs_proper_malicious(dialect): + """For malicious input, proper quoting prevents breakout that naive allows.""" + payloads = [ + 'x"; DROP TABLE users--', + 'y"; DELETE FROM t--', + 'z"; UPDATE t SET a=1--', + ] + for payload in payloads: + naive = f'"{payload}"' + proper = dialect.format_identifier(payload) + + # Naive produces odd quote count => breakout + assert naive.count('"') % 2 != 0, \ + f"Naive quoting should unbalance quotes for '{payload}': {naive}" + + # Proper produces even quote count => contained + assert proper.count('"') % 2 == 0, \ + f"Proper quoting should balance quotes for '{payload}': {proper}" + + +def test_format_identifier_empty_string(dialect): + """Empty identifier produces empty double quotes.""" + assert dialect.format_identifier("") == '""' \ No newline at end of file From 835c4681f129b6f2a65fb6736b4784e4a5796a20 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 23 May 2026 13:53:04 +0800 Subject: [PATCH 06/20] fix(postgres): remove dead _quote_identifier, fix _format_partition_value injection - Remove _quote_identifier dead code (delegate to format_identifier) - Fix _format_partition_value to escape single quotes in string values, preventing SQL injection via partition value breakout --- .../backend/impl/postgres/mixins/ddl/partition.py | 4 ++-- .../backend/impl/postgres/mixins/introspection.py | 14 -------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py index 3557ad04..1f322e55 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py @@ -135,8 +135,8 @@ def _format_partition_value(self, value: Any) -> str: upper_val = value.upper() if upper_val == "MAXVALUE" or upper_val == "MINVALUE": return upper_val - # Add quotes around string values - return f"'{value}'" + # Add quotes around string values, escaping internal single quotes + return f"'{value.replace(chr(39), chr(39)+chr(39))}'" # escape ' to '' else: return str(value) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py index fcd09e4e..1df9610e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py @@ -104,20 +104,6 @@ def get_supported_introspection_scopes(self) -> List[IntrospectionScope]: # ========== Helper Methods ========== - def _quote_identifier(self, name: str) -> str: - """Quote an identifier for PostgreSQL. - - PostgreSQL uses double quotes for identifiers. - - Args: - name: The identifier name to quote. - - Returns: - The quoted identifier. - """ - escaped = name.replace('"', '""') - return f'"{escaped}"' - def _get_default_schema(self) -> str: """Get default schema name. From 4b19847f7d480d35a340e2fe911df785f88c78f0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 23 May 2026 13:53:43 +0800 Subject: [PATCH 07/20] fix(postgres): apply security fixes and dialect improvements - Remove _quote_identifier dead code, delegate to format_identifier - Fix format_drop_virtual_table, format_create_trigger_statement - Apply named_expression feature enhancements --- .../backend/impl/postgres/dialect.py | 3442 ++++++++--------- 1 file changed, 1721 insertions(+), 1721 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 2e5a24f5..2db9e7a5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -1,1721 +1,1721 @@ -# src/rhosocial/activerecord/backend/impl/postgres/dialect.py -""" -PostgreSQL backend SQL dialect implementation. - -This dialect implements protocols for features that PostgreSQL actually supports, -based on the PostgreSQL version provided at initialization. -""" - -from typing import Any, Dict, Tuple, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from .function_versions import FunctionSupportInfo, FunctionVersionRequirement - -from rhosocial.activerecord.backend.dialect.base import SQLDialectBase -from rhosocial.activerecord.backend.dialect.mixins import ( - CTEMixin, - FilterClauseMixin, - WindowFunctionMixin, - JSONMixin, - ReturningMixin, - AdvancedGroupingMixin, - ArrayMixin, - ExplainMixin, - GraphMixin, - LockingMixin, - MergeMixin, - OrderedSetAggregationMixin, - QualifyClauseMixin, - TemporalTableMixin, - UpsertMixin, - LateralJoinMixin, - JoinMixin, - ViewMixin, - SchemaMixin, - IndexMixin, - SequenceMixin, - TableMixin, - SetOperationMixin, - TruncateMixin, - ILIKEMixin, - ConstraintMixin, -) -from rhosocial.activerecord.backend.dialect.protocols import ( - CTESupport, - FilterClauseSupport, - WindowFunctionSupport, - JSONSupport, - ReturningSupport, - AdvancedGroupingSupport, - ArraySupport, - ExplainSupport, - GraphSupport, - MergeSupport, - OrderedSetAggregationSupport, - QualifyClauseSupport, - TemporalTableSupport, - UpsertSupport, - LateralJoinSupport, - WildcardSupport, - JoinSupport, - ViewSupport, - SchemaSupport, - SequenceSupport, - SetOperationSupport, - TruncateSupport, - ILIKESupport, - IntrospectionSupport, - TransactionControlSupport, - SQLFunctionSupport, -) -from .mixins import ( - PostgresExtensionMixin, - PostgresMaterializedViewMixin, - PostgresTableMixin, - PostgresPgvectorMixin, - PostgresPostGISMixin, - PostgresPostgisRasterMixin, - PostgresPgroutingMixin, - PostgresPgTrgmMixin, - PostgresHstoreMixin, - # Native feature mixins - PostgresPartitionMixin, - PostgresIndexMixin, - PostgresVacuumMixin, - PostgresQueryOptimizationMixin, - PostgresDataTypeMixin, - PostgresSQLSyntaxMixin, - PostgresLogicalReplicationMixin, - PostgresParallelQueryMixin, - # Extension feature mixins - PostgresLtreeMixin, - PostgresIntarrayMixin, - PostgresEarthdistanceMixin, - PostgresTablefuncMixin, - PostgresPgStatStatementsMixin, - PostgresCitextMixin, - PostgresPgcryptoMixin, - PostgresFuzzystrmatchMixin, - PostgresCubeMixin, - PostgresUuidOssMixin, - PostgresBloomMixin, - PostgresBtreeGinMixin, - PostgresBtreeGistMixin, - PostgresPgCronMixin, - PostgresPgPartmanMixin, - PostgresPgSurgeryMixin, - PostgresPgWalinspectMixin, - PostgresPgLogicalMixin, - PostgresPgauditMixin, - PostgresPgRepackMixin, - PostgresHypoPgMixin, - PostgresOrafceMixin, - PostgresAddressStandardizerMixin, - # DDL feature mixins - PostgresTriggerMixin, - PostgresCommentMixin, - PostgresTypeMixin, - PostgresConstraintMixin, - # Type mixins - EnumTypeMixin, - TypesDataTypeMixin, - MultirangeMixin, - PostgresFullTextSearchMixin, - PostgresRangeTypeMixin, - PostgresJSONBEnhancedMixin, - PostgresArrayEnhancedMixin, - # DDL/DML operation mixins (new) - PostgresExtendedStatisticsMixin, - PostgresStoredProcedureMixin, - PostgresAdvisoryLockMixin, - PostgresLockingMixin, - # Introspection capability mixin - PostgresIntrospectionCapabilityMixin, -) - -# PostgreSQL-specific imports -from .protocols import ( - PostgresExtensionSupport, - PostgresMaterializedViewSupport, - PostgresTableSupport, - PostgresPgvectorSupport, - PostgresPostGISSupport, - PostgresPostgisRasterSupport, - PostgresPgroutingSupport, - PostgresPgTrgmSupport, - PostgresHstoreSupport, - # Native feature protocols - PostgresPartitionSupport, - PostgresIndexSupport, - PostgresVacuumSupport, - PostgresQueryOptimizationSupport, - PostgresDataTypeSupport, - PostgresSQLSyntaxSupport, - PostgresLogicalReplicationSupport, - # Extension feature protocols - PostgresLtreeSupport, - PostgresIntarraySupport, - PostgresEarthdistanceSupport, - PostgresTablefuncSupport, - PostgresPgStatStatementsSupport, - PostgresCitextSupport, - PostgresPgcryptoSupport, - PostgresFuzzystrmatchSupport, - PostgresCubeSupport, - PostgresUuidOssSupport, - PostgresBloomSupport, - PostgresBtreeGinSupport, - PostgresBtreeGistSupport, - PostgresPgCronSupport, - PostgresPgPartmanSupport, - PostgresPgSurgerySupport, - PostgresPgWalinspectSupport, - PostgresPgLogicalSupport, - PostgresPgauditSupport, - PostgresPgRepackSupport, - PostgresHypoPgSupport, - PostgresOrafceSupport, - PostgresAddressStandardizerSupport, - # DDL feature protocols - PostgresTriggerSupport, - PostgresCommentSupport, - PostgresTypeSupport, - PostgresConstraintSupport, - # Type feature protocols - PostgresMultirangeSupport, - PostgresEnumTypeSupport, - PostgresFullTextSearchSupport, - PostgresRangeTypeSupport, - PostgresJSONBEnhancedSupport, - PostgresArrayEnhancedSupport, - # New feature protocols - PostgresParallelQuerySupport, - PostgresStoredProcedureSupport, - PostgresExtendedStatisticsSupport, - PostgresAdvisoryLockSupport, - PostgresLockingSupport, -) - -if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, - CreateViewExpression, - DropViewExpression, - TruncateExpression, - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, - ExplainExpression, - AddTableConstraint, - TableConstraint, - ) - from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - CommitTransactionExpression, - RollbackTransactionExpression, - SavepointExpression, - ReleaseSavepointExpression, - SetTransactionExpression, - ) - - -class PostgresDialect( - SQLDialectBase, - SetOperationMixin, - TruncateMixin, - ILIKEMixin, - CTEMixin, - FilterClauseMixin, - WindowFunctionMixin, - JSONMixin, - ReturningMixin, - AdvancedGroupingMixin, - ArrayMixin, - ExplainMixin, - GraphMixin, - PostgresLockingMixin, - LockingMixin, - MergeMixin, - OrderedSetAggregationMixin, - QualifyClauseMixin, - TemporalTableMixin, - UpsertMixin, - LateralJoinMixin, - JoinMixin, - ViewMixin, - SchemaMixin, - PostgresIndexMixin, - IndexMixin, - SequenceMixin, - TableMixin, - ConstraintMixin, - PostgresIntrospectionCapabilityMixin, - # PostgreSQL-specific mixins - PostgresExtensionMixin, - PostgresMaterializedViewMixin, - PostgresTableMixin, - PostgresPgvectorMixin, - PostgresPostGISMixin, - PostgresPostgisRasterMixin, - PostgresPgroutingMixin, - PostgresPgTrgmMixin, - PostgresHstoreMixin, - # Native feature mixins - PostgresPartitionMixin, - PostgresVacuumMixin, - PostgresQueryOptimizationMixin, - PostgresDataTypeMixin, - PostgresSQLSyntaxMixin, - PostgresLogicalReplicationMixin, - PostgresParallelQueryMixin, - # Extension feature mixins - PostgresLtreeMixin, - PostgresIntarrayMixin, - PostgresEarthdistanceMixin, - PostgresTablefuncMixin, - PostgresPgStatStatementsMixin, - PostgresCitextMixin, - PostgresPgcryptoMixin, - PostgresFuzzystrmatchMixin, - PostgresCubeMixin, - PostgresUuidOssMixin, - PostgresBloomMixin, - PostgresBtreeGinMixin, - PostgresBtreeGistMixin, - PostgresPgCronMixin, - PostgresPgPartmanMixin, - PostgresPgSurgeryMixin, - PostgresPgWalinspectMixin, - PostgresPgLogicalMixin, - PostgresPgauditMixin, - PostgresPgRepackMixin, - PostgresHypoPgMixin, - PostgresOrafceMixin, - PostgresAddressStandardizerMixin, - # DDL feature mixins - PostgresTriggerMixin, - PostgresCommentMixin, - PostgresTypeMixin, - PostgresConstraintMixin, - # Type mixins - EnumTypeMixin, - TypesDataTypeMixin, - MultirangeMixin, - PostgresFullTextSearchMixin, - PostgresRangeTypeMixin, - PostgresJSONBEnhancedMixin, - PostgresArrayEnhancedMixin, - # DDL/DML operation mixins (new) - PostgresExtendedStatisticsMixin, - PostgresStoredProcedureMixin, - PostgresAdvisoryLockMixin, - # Protocol supports - SetOperationSupport, - TruncateSupport, - ILIKESupport, - CTESupport, - FilterClauseSupport, - WindowFunctionSupport, - JSONSupport, - ReturningSupport, - AdvancedGroupingSupport, - ArraySupport, - ExplainSupport, - GraphSupport, - MergeSupport, - OrderedSetAggregationSupport, - QualifyClauseSupport, - TemporalTableSupport, - UpsertSupport, - LateralJoinSupport, - WildcardSupport, - JoinSupport, - ViewSupport, - SchemaSupport, - SequenceSupport, - # Introspection protocol - IntrospectionSupport, - # Transaction control protocol - TransactionControlSupport, - # PostgreSQL-specific protocols - PostgresExtensionSupport, - PostgresMaterializedViewSupport, - PostgresTableSupport, - PostgresPgvectorSupport, - PostgresPostGISSupport, - PostgresPostgisRasterSupport, - PostgresPgroutingSupport, - PostgresPgTrgmSupport, - PostgresHstoreSupport, - # Native feature protocols - PostgresPartitionSupport, - PostgresIndexSupport, - PostgresVacuumSupport, - PostgresQueryOptimizationSupport, - PostgresDataTypeSupport, - PostgresSQLSyntaxSupport, - PostgresLogicalReplicationSupport, - # Extension feature protocols - PostgresLtreeSupport, - PostgresIntarraySupport, - PostgresEarthdistanceSupport, - PostgresTablefuncSupport, - PostgresPgStatStatementsSupport, - PostgresCitextSupport, - PostgresPgcryptoSupport, - PostgresFuzzystrmatchSupport, - PostgresCubeSupport, - PostgresUuidOssSupport, - PostgresBloomSupport, - PostgresBtreeGinSupport, - PostgresBtreeGistSupport, - PostgresPgCronSupport, - PostgresPgPartmanSupport, - PostgresPgSurgerySupport, - PostgresPgWalinspectSupport, - PostgresPgLogicalSupport, - PostgresPgauditSupport, - PostgresPgRepackSupport, - PostgresHypoPgSupport, - PostgresOrafceSupport, - PostgresAddressStandardizerSupport, - # DDL feature protocols - PostgresTriggerSupport, - PostgresCommentSupport, - PostgresTypeSupport, - PostgresConstraintSupport, - # Type feature protocols - PostgresMultirangeSupport, - PostgresEnumTypeSupport, - PostgresFullTextSearchSupport, - PostgresRangeTypeSupport, - PostgresJSONBEnhancedSupport, - PostgresArrayEnhancedSupport, - # New feature protocols - PostgresParallelQuerySupport, - PostgresStoredProcedureSupport, - PostgresExtendedStatisticsSupport, - PostgresAdvisoryLockSupport, - PostgresLockingSupport, - # Function support protocol - SQLFunctionSupport, -): - """ - PostgreSQL dialect implementation that adapts to the PostgreSQL version. - - PostgreSQL features and support based on version: - - Basic and recursive CTEs (since 8.4) - - Window functions (since 8.4) - - RETURNING clause (since 8.2) - - JSON operations (since 9.2, JSONB since 9.4) - - FILTER clause (since 9.4) - - UPSERT (ON CONFLICT) (since 9.5) - - MERGE statement (since 15) - - Advanced grouping (CUBE, ROLLUP, GROUPING SETS) (since 9.5) - - Array types (since early versions) - - LATERAL joins (since 9.3) - - Parallel query execution (since 9.6) - - Stored procedures with CALL (since 11) - - Extended statistics (since 10) - - PostgreSQL-specific features: - - Table inheritance (INHERITS) - - CONCURRENTLY refresh for materialized views (since 9.4) - - Extension detection (PostGIS, pgvector, pg_trgm, hstore, etc.) - - Note: Extension features require the extension to be installed in the database. - Use introspect_and_adapt() to detect installed extensions automatically. - """ - - def __init__(self, version: Optional[Tuple[int, int, int]] = None): - """ - Initialize PostgreSQL dialect with specific version. - - Args: - version: PostgreSQL version tuple (major, minor, patch). - If None, the dialect must be adapted via - backend.introspect_and_adapt() before version-dependent - features can be used. - """ - super().__init__() - if version is not None: - self.version = version - - @staticmethod - def _validate_data_type(data_type: str) -> bool: - """Validate data type for safe embedding in SQL. - - PostgreSQL supports array types like TEXT[], INTEGER[]. - - Note: rhosocial-activerecord base dialect will include this change in - a future release. This override can be removed after upgrading to that - version (expected: include brackets [] in the allowlist pattern). - """ - import re - - return bool(re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", data_type)) - - def get_parameter_placeholder(self, position: int = 0) -> str: - """psycopg uses '%s' for placeholders.""" - return "%s" - - def get_server_version(self) -> Tuple[int, int, int]: - """Return the PostgreSQL version this dialect is configured for.""" - return self.version - - # region Protocol Support Checks based on version - def supports_basic_cte(self) -> bool: - """Basic CTEs are supported since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_recursive_cte(self) -> bool: - """Recursive CTEs are supported since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_materialized_cte(self) -> bool: - """MATERIALIZED hint is supported since PostgreSQL 12.""" - return self.version >= (12, 0, 0) - - def supports_returning_insert(self) -> bool: - """RETURNING clause is supported for INSERT since PostgreSQL 8.2.""" - return self.version >= (8, 2, 0) - - def supports_returning_update(self) -> bool: - """RETURNING clause is supported for UPDATE since PostgreSQL 8.2.""" - return self.version >= (8, 2, 0) - - def supports_returning_delete(self) -> bool: - """RETURNING clause is supported for DELETE since PostgreSQL 8.2.""" - return self.version >= (8, 2, 0) - - def supports_window_functions(self) -> bool: - """Window functions are supported since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_window_frame_clause(self) -> bool: - """Whether window frame clauses (ROWS/RANGE) are supported, since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_filter_clause(self) -> bool: - """FILTER clause for aggregate functions is supported since PostgreSQL 9.4.""" - return self.version >= (9, 4, 0) - - def get_json_access_operator(self) -> str: - """PostgreSQL uses '->' for JSON access.""" - return "->" - - def supports_rollup(self) -> bool: - """ROLLUP is supported since PostgreSQL 9.5.""" - return self.version >= (9, 5, 0) - - def supports_cube(self) -> bool: - """CUBE is supported since PostgreSQL 9.5.""" - return self.version >= (9, 5, 0) - - def supports_grouping_sets(self) -> bool: - """GROUPING SETS is supported since PostgreSQL 9.5.""" - return self.version >= (9, 5, 0) - - def supports_explain_analyze(self) -> bool: - """Whether EXPLAIN ANALYZE is supported.""" - return True # Supported in all modern versions - - def supports_explain_format(self, format_type: str) -> bool: - """Check if specific EXPLAIN format is supported.""" - format_type_upper = format_type.upper() - # PostgreSQL supports TEXT, XML, JSON, and YAML formats - supported_formats = ["TEXT", "XML", "JSON", "YAML"] - return format_type_upper in supported_formats - - def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: - """Build the PostgreSQL EXPLAIN SQL string and return (sql, params). - - PostgreSQL syntax: ``EXPLAIN [ ( option [, ...] ) ] statement`` - - Supported options assembled here: - - ``ANALYZE`` - - ``FORMAT { TEXT | XML | JSON | YAML }`` - - ``VERBOSE`` (passed through ``ExplainOptions.verbose`` if present) - """ - from rhosocial.activerecord.backend.expression.statements import ExplainType - - statement_sql, statement_params = explain_expr.statement.to_sql() - options = explain_expr.options - if options is None: - return f"EXPLAIN {statement_sql}", statement_params - - opts: list = [] - - if options.analyze: - opts.append("ANALYZE") - - if options.format is not None: - fmt_name = options.format.name if hasattr(options.format, "name") else str(options.format) - opts.append(f"FORMAT {fmt_name.upper()}") - elif options.type is not None and options.type == ExplainType.QUERY_PLAN: - # PostgreSQL has no QUERY PLAN keyword; plain EXPLAIN is equivalent - pass - - if opts: - return "EXPLAIN (" + ", ".join(opts) + ") " + statement_sql, statement_params - return f"EXPLAIN {statement_sql}", statement_params - - def supports_graph_match(self) -> bool: - """Whether graph query MATCH clause is supported.""" - # PostgreSQL doesn't have native MATCH clause like some other systems - # Though graph querying can be done with extensions like Apache AGE - return False - - def supports_for_update(self) -> bool: - """Whether FOR UPDATE clause is supported in SELECT statements. - - PostgreSQL supports FOR UPDATE since early versions. The clause locks - selected rows preventing other transactions from modifying them. - PostgreSQL also supports FOR UPDATE OF, FOR UPDATE NOWAIT, and - FOR UPDATE SKIP LOCKED (since 9.5). - """ - return True - - def supports_lock_strength(self, strength) -> bool: - """ - Check if a specific lock strength is supported. - - PostgreSQL lock strength support by version: - - FOR UPDATE: All versions - - FOR NO KEY UPDATE: PostgreSQL 9.0+ - - FOR SHARE: PostgreSQL 9.0+ - - FOR KEY SHARE: PostgreSQL 9.3+ - - Args: - strength: The LockStrength enum value to check - - Returns: - True if the lock strength is supported, False otherwise - """ - from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength - - if strength == LockStrength.UPDATE: - return True # All PostgreSQL versions support FOR UPDATE - elif strength == LockStrength.NO_KEY_UPDATE: - return self.version >= (9, 0, 0) - elif strength == LockStrength.SHARE: - return self.version >= (9, 0, 0) - elif strength == LockStrength.KEY_SHARE: - return self.version >= (9, 3, 0) - return False - - def supports_merge_statement(self) -> bool: - """Whether MERGE statement is supported.""" - return self.version >= (15, 0, 0) # MERGE added in PostgreSQL 15 - - def supports_temporal_tables(self) -> bool: - """Whether temporal tables are supported.""" - # PostgreSQL doesn't have built-in temporal tables - return False - - def supports_qualify_clause(self) -> bool: - """Whether QUALIFY clause is supported.""" - # PostgreSQL doesn't have QUALIFY clause (though can be simulated with subqueries) - return False - - def supports_upsert(self) -> bool: - """Whether UPSERT (ON CONFLICT) is supported.""" - return self.version >= (9, 5, 0) # ON CONFLICT added in 9.5 - - def get_upsert_syntax_type(self) -> str: - """ - Get UPSERT syntax type. - - Returns: - 'ON CONFLICT' (PostgreSQL) or 'ON DUPLICATE KEY' (MySQL) - """ - return "ON CONFLICT" - - def supports_lateral_join(self) -> bool: - """Whether LATERAL joins are supported.""" - return self.version >= (9, 3, 0) # LATERAL joins added in 9.3 - - def supports_ordered_set_aggregation(self) -> bool: - """Whether ordered-set aggregate functions are supported.""" - return self.version >= (9, 4, 0) # Supported since 9.4 - - def supports_multirange(self) -> bool: - """Whether multirange types are supported (PostgreSQL 14+).""" - return self.version >= (14, 0, 0) - - def supports_multirange_constructor(self) -> bool: - """Whether multirange constructor functions are supported (PostgreSQL 14+).""" - return self.version >= (14, 0, 0) - - def supports_inner_join(self) -> bool: - """INNER JOIN is supported.""" - return True - - def supports_left_join(self) -> bool: - """LEFT JOIN is supported.""" - return True - - def supports_right_join(self) -> bool: - """RIGHT JOIN is supported.""" - return True - - def supports_full_join(self) -> bool: - """FULL JOIN is supported.""" - return True - - def supports_cross_join(self) -> bool: - """CROSS JOIN is supported.""" - return True - - def supports_natural_join(self) -> bool: - """NATURAL JOIN is supported.""" - return True - - def supports_wildcard(self) -> bool: - """Wildcard (*) is supported.""" - return True - - # region ILIKE Support - def supports_ilike(self) -> bool: - """ILIKE is supported.""" - return True - - def format_ilike_expression(self, column: Any, pattern: str, negate: bool = False) -> Tuple[str, Tuple]: - """Format ILIKE expression for PostgreSQL.""" - if isinstance(column, str): - col_sql = self.format_identifier(column) - else: - col_sql, col_params = column.to_sql() if hasattr(column, "to_sql") else (str(column), ()) - - if negate: - sql = f"{col_sql} NOT ILIKE %s" - else: - sql = f"{col_sql} ILIKE %s" - - return sql, (pattern,) - - # endregion - - # region Set Operation Support - def supports_union(self) -> bool: - """UNION is supported.""" - return True - - def supports_union_all(self) -> bool: - """UNION ALL is supported.""" - return True - - def supports_intersect(self) -> bool: - """INTERSECT is supported.""" - return True - - def supports_except(self) -> bool: - """EXCEPT is supported.""" - return True - - def supports_set_operation_order_by(self) -> bool: - """ORDER BY is supported for set operations.""" - return True - - def supports_set_operation_limit_offset(self) -> bool: - """LIMIT/OFFSET is supported for set operations.""" - return True - - def supports_set_operation_for_update(self) -> bool: - """FOR UPDATE is supported for set operations.""" - return True - - # endregion - - # region Truncate Support - def supports_truncate(self) -> bool: - """TRUNCATE is supported.""" - return True - - def supports_truncate_table_keyword(self) -> bool: - """TABLE keyword is supported in TRUNCATE.""" - return True - - def supports_truncate_restart_identity(self) -> bool: - """RESTART IDENTITY is supported since PostgreSQL 8.4.""" - return self.version >= (8, 4, 0) - - def supports_truncate_cascade(self) -> bool: - """CASCADE is supported in TRUNCATE.""" - return True - - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: - """Format TRUNCATE statement for PostgreSQL.""" - parts = ["TRUNCATE TABLE"] - parts.append(self.format_identifier(expr.table_name)) - - if expr.restart_identity and self.supports_truncate_restart_identity(): - parts.append("RESTART IDENTITY") - - if expr.cascade: - parts.append("CASCADE") - - return " ".join(parts), () - - # endregion - - # region Custom Implementations for PostgreSQL-specific behavior - def format_identifier(self, identifier: str) -> str: - """ - Format identifier using PostgreSQL's double quote quoting mechanism. - - Args: - identifier: Raw identifier string - - Returns: - Quoted identifier with escaped internal quotes - """ - # Escape any internal double quotes by doubling them - escaped = identifier.replace('"', '""') - return f'"{escaped}"' - - def format_column(self, name: str, table: Optional[str] = None, - alias: Optional[str] = None, - schema_name: Optional[str] = None) -> Tuple[str, Tuple]: - """Format column reference for PostgreSQL. - - PostgreSQL rules for column references: - - When the table has an alias (used in FROM/JOIN), column references - must use the alias, not the schema-qualified name. For example, - when FROM clause has ``public.users AS "users"``, the column - reference must be ``"users"."id"``, not ``"public"."users"."id"``. - - When no alias is present but schema is specified, use the - full three-segment form: ``"schema"."table"."column"``. - - Otherwise use the standard two-segment or single-segment form. - """ - if table: - # In PostgreSQL, when a table has an alias, column references - # must use the alias — schema_name is irrelevant in this context. - if schema_name and not alias: - col_sql = f"{self.format_identifier(schema_name)}.{self.format_identifier(table)}.{self.format_identifier(name)}" - else: - col_sql = f"{self.format_identifier(table)}.{self.format_identifier(name)}" - else: - col_sql = self.format_identifier(name) - - if alias: - col_sql = f"{col_sql} AS {self.format_identifier(alias)}" - - return col_sql, () - - def format_on_conflict_clause(self, expr) -> Tuple[str, tuple]: - """Format ON CONFLICT clause for PostgreSQL. - - Overrides the base implementation to handle EXCLUDED pseudo-table - references without quoting, as EXCLUDED is a special PostgreSQL - keyword in ON CONFLICT context and must not be double-quoted. - """ - from rhosocial.activerecord.backend.expression import bases - from rhosocial.activerecord.backend.expression.core import Column - - all_params = [] - parts = ["ON CONFLICT"] - - # Add conflict target if specified - if expr.conflict_target: - target_parts = [] - for target in expr.conflict_target: - if isinstance(target, str): - target_parts.append(self.format_identifier(target)) - elif hasattr(target, 'to_sql'): - target_sql, target_params = target.to_sql() - target_parts.append(target_sql) - all_params.extend(target_params) - else: - target_parts.append(self.format_identifier(str(target))) - if target_parts: - parts.append(f"({', '.join(target_parts)})") - - # Add DO NOTHING or DO UPDATE - if expr.do_nothing: - parts.append("DO NOTHING") - elif expr.update_assignments: - update_parts = [] - for col, expr_val in expr.update_assignments.items(): - if isinstance(expr_val, Column) and getattr(expr_val, 'table', None) == 'EXCLUDED': - # EXCLUDED is a special pseudo-table in PostgreSQL ON CONFLICT. - # It must NOT be double-quoted, only the column name should be quoted. - val_sql = f'EXCLUDED.{self.format_identifier(expr_val.name)}' - update_parts.append(f"{self.format_identifier(col)} = {val_sql}") - elif isinstance(expr_val, bases.BaseExpression): - val_sql, val_params = expr_val.to_sql() - update_parts.append(f"{self.format_identifier(col)} = {val_sql}") - all_params.extend(val_params) - else: - update_parts.append(f"{self.format_identifier(col)} = {self.get_parameter_placeholder()}") - all_params.append(expr_val) - - parts.append(f"DO UPDATE SET {', '.join(update_parts)}") - - # Add WHERE clause if specified - if expr.update_where: - where_sql, where_params = expr.update_where.to_sql() - parts.append(f"WHERE {where_sql}") - all_params.extend(where_params) - else: - parts.append("DO NOTHING") - - return " ".join(parts), tuple(all_params) - - # region View Support - def supports_or_replace_view(self) -> bool: - """Whether CREATE OR REPLACE VIEW is supported.""" - return True # PostgreSQL supports OR REPLACE - - def supports_temporary_view(self) -> bool: - """Whether TEMPORARY views are supported.""" - return True # PostgreSQL supports TEMPORARY views - - def supports_materialized_view(self) -> bool: - """Whether materialized views are supported.""" - return True # PostgreSQL supports materialized views since 9.3 - - def supports_refresh_materialized_view(self) -> bool: - """Whether REFRESH MATERIALIZED VIEW is supported.""" - return True # PostgreSQL supports REFRESH MATERIALIZED VIEW - - def supports_materialized_view_concurrent_refresh(self) -> bool: - """Whether concurrent refresh for materialized views is supported.""" - return self.version >= (9, 4, 0) # CONCURRENTLY added in 9.4 - - def supports_materialized_view_tablespace(self) -> bool: - """Whether tablespace specification for materialized views is supported.""" - return True - - def supports_materialized_view_storage_options(self) -> bool: - """Whether storage options for materialized views are supported.""" - return True - - def supports_if_exists_view(self) -> bool: - """Whether DROP VIEW IF EXISTS is supported.""" - return True # PostgreSQL supports IF EXISTS - - def supports_view_check_option(self) -> bool: - """Whether WITH CHECK OPTION is supported.""" - return True # PostgreSQL supports WITH CHECK OPTION - - def supports_cascade_view(self) -> bool: - """Whether DROP VIEW CASCADE is supported.""" - return True # PostgreSQL supports CASCADE - - def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: - """Format CREATE VIEW statement for PostgreSQL.""" - parts = ["CREATE"] - - if expr.temporary: - parts.append("TEMPORARY") - - if expr.replace: - parts.append("OR REPLACE") - - parts.append("VIEW") - parts.append(self.format_identifier(expr.view_name)) - - if expr.column_aliases: - cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) - parts.append(f"({cols})") - - query_sql, query_params = expr.query.to_sql() - parts.append(f"AS {query_sql}") - - if expr.options and expr.options.check_option: - check_option = expr.options.check_option.value - parts.append(f"WITH {check_option} CHECK OPTION") - - return " ".join(parts), query_params - - def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, tuple]: - """Format DROP VIEW statement for PostgreSQL.""" - parts = ["DROP VIEW"] - if expr.if_exists: - parts.append("IF EXISTS") - parts.append(self.format_identifier(expr.view_name)) - if expr.cascade: - parts.append("CASCADE") - return " ".join(parts), () - - def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewExpression") -> Tuple[str, tuple]: - """Format CREATE MATERIALIZED VIEW statement for PostgreSQL.""" - parts = ["CREATE MATERIALIZED VIEW"] - parts.append(self.format_identifier(expr.view_name)) - - if expr.column_aliases: - cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) - parts.append(f"({cols})") - - if expr.tablespace and self.supports_materialized_view_tablespace(): - parts.append(f"TABLESPACE {self.format_identifier(expr.tablespace)}") - - if expr.storage_options and self.supports_materialized_view_storage_options(): - storage_parts = [] - for key, value in expr.storage_options.items(): - storage_parts.append(f"{key.upper()} = {value}") - parts.append(f"WITH ({', '.join(storage_parts)})") - - query_sql, query_params = expr.query.to_sql() - parts.append(f"AS {query_sql}") - - if expr.with_data: - parts.append("WITH DATA") - else: - parts.append("WITH NO DATA") - - return " ".join(parts), query_params - - def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExpression") -> Tuple[str, tuple]: - """Format DROP MATERIALIZED VIEW statement for PostgreSQL.""" - parts = ["DROP MATERIALIZED VIEW"] - if expr.if_exists: - parts.append("IF EXISTS") - parts.append(self.format_identifier(expr.view_name)) - if expr.cascade: - parts.append("CASCADE") - return " ".join(parts), () - - def format_refresh_materialized_view_statement( - self, expr: "RefreshMaterializedViewExpression" - ) -> Tuple[str, tuple]: - """Format REFRESH MATERIALIZED VIEW statement for PostgreSQL.""" - parts = ["REFRESH MATERIALIZED VIEW"] - if expr.concurrent and self.supports_materialized_view_concurrent_refresh(): - parts.append("CONCURRENTLY") - parts.append(self.format_identifier(expr.view_name)) - if expr.with_data is not None: - parts.append("WITH DATA" if expr.with_data else "WITH NO DATA") - return " ".join(parts), () - - # endregion - - # region Schema Support - def supports_create_schema(self) -> bool: - """Whether CREATE SCHEMA is supported.""" - return True - - def supports_drop_schema(self) -> bool: - """Whether DROP SCHEMA is supported.""" - return True - - def supports_schema_if_not_exists(self) -> bool: - """Whether CREATE SCHEMA IF NOT EXISTS is supported.""" - return True # PostgreSQL 9.3+ supports IF NOT EXISTS - - def supports_schema_if_exists(self) -> bool: - """Whether DROP SCHEMA IF EXISTS is supported.""" - return True - - def supports_schema_cascade(self) -> bool: - """Whether DROP SCHEMA CASCADE is supported.""" - return True - - # endregion - - # region Index Support - def supports_create_index(self) -> bool: - """Whether CREATE INDEX is supported.""" - return True - - def supports_drop_index(self) -> bool: - """Whether DROP INDEX is supported.""" - return True - - def supports_unique_index(self) -> bool: - """Whether UNIQUE indexes are supported.""" - return True - - 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 - - def supports_index_if_exists(self) -> bool: - """Whether DROP INDEX IF EXISTS is supported.""" - return True - - # endregion - - # region Sequence Support - def supports_create_sequence(self) -> bool: - """Whether CREATE SEQUENCE is supported.""" - return True - - def supports_drop_sequence(self) -> bool: - """Whether DROP SEQUENCE is supported.""" - return True - - # endregion - - # region Table Support - def supports_if_not_exists_table(self) -> bool: - """Whether CREATE TABLE IF NOT EXISTS is supported.""" - return True - - def supports_if_exists_table(self) -> bool: - """Whether DROP TABLE IF EXISTS is supported.""" - return True - - def supports_temporary_table(self) -> bool: - """Whether TEMPORARY tables are supported.""" - return True - - def supports_table_inheritance(self) -> bool: - """Whether table inheritance is supported.""" - return True # PostgreSQL supports INHERITS - - def supports_table_partitioning(self) -> bool: - """Whether table partitioning is supported.""" - return True # PostgreSQL supports partitioning - - def supports_table_tablespace(self) -> bool: - """Whether tablespace specification is supported.""" - return True - - # Constraint capability overrides - - def supports_constraint_enforced(self) -> bool: - """PostgreSQL does not support ENFORCED/NOT ENFORCED (SQL:2016). - - PostgreSQL uses NOT VALID as a proprietary alternative for - skipping validation of existing data when adding constraints. - """ - return False - - def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: - """ - Format CREATE TABLE statement for PostgreSQL, including LIKE syntax support. - - PostgreSQL LIKE syntax supports INCLUDING/EXCLUDING options to control what - gets copied: DEFAULTS, CONSTRAINTS, INDEXES, IDENTITY, GENERATED, ALL, - COMMENTS, STORAGE, COMPRESSION. - - The behavior is controlled by the `dialect_options` parameter in - CreateTableExpression: - - 1. When `dialect_options` contains 'like_table' key: - - LIKE syntax takes highest priority - - All other parameters (columns, indexes, constraints, etc.) are IGNORED - - Only temporary and if_not_exists flags are considered - - Additional 'like_options' key controls INCLUDING/EXCLUDING behavior - - 2. When `dialect_options` does NOT contain 'like_table': - - Falls back to base class implementation - - Standard CREATE TABLE with column definitions is generated - - The 'like_options' key supports two formats: - - a) Dictionary format (recommended): - { - 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], - 'excluding': ['COMMENTS'] - } - - b) List format (for backwards compatibility): - ['DEFAULTS', 'CONSTRAINTS'] # Defaults to INCLUDING - or - [('INCLUDING', 'DEFAULTS'), ('EXCLUDING', 'INDEXES')] - - Usage Examples: - # Basic LIKE syntax - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[], # Ignored when like_table is present - dialect_options={'like_table': 'users'} - ) - # Generates: CREATE TABLE "users_copy" (LIKE "users") - - # LIKE with INCLUDING options (dictionary format - recommended) - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[...], # Will be ignored - dialect_options={ - 'like_table': 'users', - 'like_options': { - 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], - 'excluding': ['COMMENTS'] - } - } - ) - # Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, - # INCLUDING CONSTRAINTS, INCLUDING INDEXES, EXCLUDING COMMENTS) - - # LIKE with schema-qualified source table - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[], - dialect_options={'like_table': ('public', 'users')} - ) - # Generates: CREATE TABLE "users_copy" (LIKE "public"."users") - - # LIKE with TEMPORARY and IF NOT EXISTS - CreateTableExpression( - dialect=postgres_dialect, - table_name="temp_users", - columns=[], - temporary=True, - if_not_exists=True, - dialect_options={'like_table': 'users'} - ) - # Generates: CREATE TEMPORARY TABLE IF NOT EXISTS "temp_users" (LIKE "users") - - # LIKE with INCLUDING ALL - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': {'including': ['ALL']} - } - ) - # Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING ALL) - - Args: - expr: CreateTableExpression instance - - Returns: - Tuple of (SQL string, parameters tuple) - """ - # Check for LIKE syntax in dialect_options (highest priority) - if "like_table" in expr.dialect_options: - like_table = expr.dialect_options["like_table"] - like_options = expr.dialect_options.get("like_options", []) - - parts = ["CREATE"] - - if expr.temporary: - parts.append("TEMPORARY") - - parts.append("TABLE") - - if expr.if_not_exists: - parts.append("IF NOT EXISTS") - - parts.append(self.format_identifier(expr.table_name)) - - # Build LIKE clause with options - like_parts = [] - - # Handle schema-qualified table name: ('schema', 'table') - if isinstance(like_table, tuple): - schema, table = like_table - like_table_str = f"{self.format_identifier(schema)}.{self.format_identifier(table)}" - else: - like_table_str = self.format_identifier(like_table) - - like_parts.append(f"LIKE {like_table_str}") - - # Add INCLUDING/EXCLUDING options - # Format: dictionary with 'including' and 'excluding' keys - # Example: {'including': ['DEFAULTS', 'CONSTRAINTS'], 'excluding': ['INDEXES']} - if isinstance(like_options, dict): - # Handle dictionary format - including = like_options.get("including", []) - excluding = like_options.get("excluding", []) - - for option in including: - like_parts.append(f"INCLUDING {option.upper()}") - - for option in excluding: - like_parts.append(f"EXCLUDING {option.upper()}") - elif isinstance(like_options, list): - # Handle list format for backwards compatibility - for option in like_options: - if isinstance(option, tuple): - action, feature = option - like_parts.append(f"{action.upper()} {feature.upper()}") - else: - # Default to INCLUDING if just feature name provided - like_parts.append(f"INCLUDING {option.upper()}") - - parts.append(f"({', '.join(like_parts)})") - - return " ".join(parts), () - - # Otherwise, delegate to base implementation - return super().format_create_table_statement(expr) - - # endregion - - # region Type Casting Support (PostgreSQL-specific) - def format_cast_expression( - self, expr_sql: str, target_type: str, expr_params: tuple, alias: Optional[str] = None - ) -> Tuple[str, Tuple]: - """Format type cast expression using PostgreSQL :: syntax. - - PostgreSQL supports both standard CAST(expr AS type) syntax and the - PostgreSQL-specific expr::type syntax. This method uses the more - concise :: syntax which is idiomatic in PostgreSQL. - - Args: - expr_sql: SQL expression string to be cast - target_type: Target PostgreSQL type name (e.g., 'integer', 'varchar(100)') - expr_params: Parameters tuple for the expression - alias: Optional alias for the result - - Returns: - Tuple of (SQL string, parameters) - - Example: - >>> dialect.format_cast_expression('price', 'numeric', ()) - # Returns: ('price::numeric', ()) - >>> dialect.format_cast_expression('amount', 'money', ()) - # Returns: ('amount::money', ()) - >>> dialect.format_cast_expression('value', 'integer', (), 'int_val') - # Returns: ('value::integer AS "int_val"', ()) - - Note: - For chained type conversions, each ::type is appended: - >>> col.cast('money').cast('numeric').cast('float8') - # Generates: col::money::numeric::float8 - """ - sql = f"{expr_sql}::{target_type}" - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" - return sql, expr_params - - # endregion - - # region Operator Formatting (PostgreSQL-specific) - - def format_binary_operator( - self, op: str, left_sql: str, right_sql: str, left_params: tuple, right_params: tuple - ) -> Tuple[str, Tuple]: - """Format binary operator with psycopg placeholder escaping. - - psycopg uses %s as parameter placeholder. When the SQL operator itself - contains % (e.g., pg_trgm similarity operator), it must be escaped as %% - to prevent psycopg from interpreting it as a placeholder prefix. - """ - # Escape % in operators for psycopg compatibility - escaped_op = op.replace('%', '%%') if '%' in op else op - sql = f"{left_sql} {escaped_op} {right_sql}" - return sql, left_params + right_params - - # endregion - - # region Constraint DDL Support (PostgreSQL-specific) - - def format_add_table_constraint_action( - self, action: "AddTableConstraint", - ) -> Tuple[str, tuple]: - """Format ADD CONSTRAINT action with PostgreSQL-specific extensions. - - Extends the base class implementation with: - - EXCLUDE constraint support (PG-specific constraint type) - - NOT VALID suffix (PG-specific: skip validation of existing rows) - """ - from rhosocial.activerecord.backend.expression.statements import ( - TableConstraintType, ConstraintValidation, - ) - - # Handle EXCLUDE constraint (PG-specific, not in base class) - if action.constraint.constraint_type == TableConstraintType.EXCLUDE: - parts = [] - exclude_sql, params = self._format_exclude_constraint(action.constraint) - parts.append(exclude_sql) - - # NOT VALID suffix - if action.constraint.dialect_options: - validation = action.constraint.dialect_options.get('validation') - if validation == ConstraintValidation.NOVALIDATE: - parts.append("NOT VALID") - - return f"ADD {' '.join(parts)}", tuple(params) - - # Use base class for standard formatting (includes DEFERRABLE) - sql, params = super().format_add_table_constraint_action(action) - - # PostgreSQL NOT VALID suffix - if action.constraint.dialect_options: - validation = action.constraint.dialect_options.get('validation') - if validation == ConstraintValidation.NOVALIDATE: - sql += " NOT VALID" - - return sql, params - - def _format_exclude_constraint( - self, constraint: "TableConstraint", - ) -> Tuple[str, tuple]: - """Format EXCLUDE constraint (PostgreSQL-specific). - - EXCLUDE constraints use the dialect_options dict to specify: - - 'exclude_elements': List of (expression, operator) tuples - e.g., [('range', '&&')] for EXCLUDE USING gist (range WITH &&) - - 'using': The index access method (default 'gist') - e.g., 'gist', 'btree', 'spgist' - - 'where': Optional predicate for partial exclusion constraints - - Example: - TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name='exclude_range_overlap', - dialect_options={ - 'exclude_elements': [('range', '&&')], - 'using': 'gist', - } - ) - # Generates: EXCLUDE USING gist (range WITH &&) - """ - params: list = [] - - if constraint.name: - parts = ["CONSTRAINT", self.format_identifier(constraint.name)] - else: - parts = [] - - # USING clause - validate index access method. - valid_using = frozenset({"gist", "btree", "spgist", "hash", "gin", "brin"}) - using = constraint.dialect_options.get("using", "gist") if constraint.dialect_options else "gist" - if using not in valid_using: - raise ValueError( - f"Invalid index access method '{using}': must be one of {valid_using}" - ) - parts.append(f"EXCLUDE USING {using}") - - # Elements: (expression, operator) pairs - validate operators. - valid_ops = frozenset({ - "=", "<", "<=", ">", ">=", "<>", - "&&", "@>", "<@", "<<", ">>", "&<", "&>", - "~=", "@@", "?|", "?&", "is", "is not", - }) - exclude_elements = [] - if constraint.dialect_options and "exclude_elements" in constraint.dialect_options: - for expr, op in constraint.dialect_options["exclude_elements"]: - if op not in valid_ops: - raise ValueError( - f"Invalid exclude operator '{op}': must be one of {valid_ops}" - ) - if isinstance(expr, str): - exclude_elements.append(f"{self.format_identifier(expr)} WITH {op}") - else: - expr_sql, expr_params = expr.to_sql() - params.extend(expr_params) - exclude_elements.append(f"{expr_sql} WITH {op}") - - if exclude_elements: - parts.append(f"({', '.join(exclude_elements)})") - - # WHERE clause for partial exclusion constraint - if constraint.dialect_options and 'where' in constraint.dialect_options: - where_expr = constraint.dialect_options['where'] - where_sql, where_params = where_expr.to_sql() - params.extend(where_params) - parts.append(f"WHERE ({where_sql})") - - # DEFERRABLE / NOT DEFERRABLE - if constraint.deferrable is True: - if constraint.initially_deferred is True: - parts.append("DEFERRABLE INITIALLY DEFERRED") - elif constraint.initially_deferred is False: - parts.append("DEFERRABLE INITIALLY IMMEDIATE") - else: - parts.append("DEFERRABLE") - elif constraint.deferrable is False: - parts.append("NOT DEFERRABLE") - - return ' '.join(parts), tuple(params) - - # endregion - - # region Trigger Support (PostgreSQL-specific) - def supports_trigger(self) -> bool: - return True - - def supports_create_trigger(self) -> bool: - return True - - def supports_drop_trigger(self) -> bool: - return True - - # endregion - - # region Transaction Control Support - - # PostgreSQL function version support: function_name -> (min_version, max_version) - # min_version: minimum supported version (inclusive), None = all versions - # Function version requirements are defined in function_versions.py, - # categorized by topic (JSON Path, Range, hstore, pgvector, PostGIS, etc.) - # and assembled into POSTGRES_FUNCTION_VERSIONS. - from .function_versions import POSTGRES_FUNCTION_VERSIONS as _FV - _POSTGRES_FUNCTION_VERSIONS = _FV - - def supports_functions(self) -> Dict[str, "FunctionSupportInfo"]: - """Return supported SQL functions with detailed support information. - - This method combines: - 1. Core functions from rhosocial.activerecord.backend.expression.functions - 2. PostgreSQL-specific functions from rhosocial.activerecord.backend.impl.postgres.functions - - Each function is mapped to a FunctionSupportInfo indicating: - - Whether the function is supported - - If not, the reason why (PG version, extension status, etc.) - - Returns: - Dict mapping function names to FunctionSupportInfo. - """ - from .function_versions import FunctionSupportInfo - from rhosocial.activerecord.backend.expression.functions import ( - __all__ as core_functions, - ) - from rhosocial.activerecord.backend.impl.postgres import functions as postgres_functions - - result: Dict[str, FunctionSupportInfo] = {} - for func_name in core_functions: - result[func_name] = self._check_function_support(func_name) - - postgres_funcs = getattr(postgres_functions, "__all__", []) - for func_name in postgres_funcs: - if func_name not in result: - result[func_name] = self._check_function_support(func_name) - - return result - - def _check_function_support(self, func_name: str) -> "FunctionSupportInfo": - """Check function support status and return detailed information. - - Args: - func_name: Name of the function to check - - Returns: - FunctionSupportInfo with support status and reason if unsupported - """ - from .function_versions import FunctionSupportInfo - - requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) - if requirement is None: - return FunctionSupportInfo(supported=True) - - # Check PostgreSQL server version - if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: - return FunctionSupportInfo(supported=False, reason="pg_version_too_low") - if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: - return FunctionSupportInfo(supported=False, reason="pg_version_too_high") - - # Check extension requirements - if requirement.extension is not None: - if not hasattr(self, "_extensions"): - return FunctionSupportInfo(supported=False, reason="extension_not_probed") - - if requirement.ext_feature is not None: - if not self.check_extension_feature(requirement.extension, requirement.ext_feature): - if not self.is_extension_installed(requirement.extension): - return FunctionSupportInfo(supported=False, reason="extension_not_installed") - return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") - else: - if not self.is_extension_installed(requirement.extension): - return FunctionSupportInfo(supported=False, reason="extension_not_installed") - if requirement.min_ext_version is not None: - installed = self.get_extension_version(requirement.extension) - if installed is None or self._compare_versions(installed, requirement.min_ext_version) < 0: - return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") - - return FunctionSupportInfo(supported=True) - - def _is_postgres_function_supported(self, func_name: str) -> bool: - """Check if a PostgreSQL function is supported based on version and extensions. - - Checks: - 1. PostgreSQL server version (for built-in and version-gated functions) - 2. Extension installation and version (for extension-provided functions) - - For extension functions, requires _extensions to have been populated - via introspect_and_adapt(). If _extensions is not available, - extension functions return False (cannot confirm availability). - - Args: - func_name: Name of the PostgreSQL function - - Returns: - True if supported, False otherwise - """ - requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) - if requirement is None: - return True # Unregistered functions default to supported - - # Step 1: Check PostgreSQL server version - if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: - return False - if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: - return False - - # Step 2: Check extension requirements - if requirement.extension is not None: - return self._check_extension_requirement(requirement) - - return True - - def _check_extension_requirement( - self, requirement: "FunctionVersionRequirement" - ) -> bool: - """Check extension requirements based on introspect_and_adapt() results. - - Requires _extensions to have been populated via introspect_and_adapt(). - If _extensions is not available, returns False (cannot confirm - extension availability without probing the database). - """ - ext_name = requirement.extension - - # _extensions is populated by introspect_and_adapt() - # If the attribute doesn't exist, introspect_and_adapt() hasn't been called - if not hasattr(self, "_extensions"): - return False - - # Use feature-level check if ext_feature is specified - if requirement.ext_feature is not None: - return self.check_extension_feature(ext_name, requirement.ext_feature) - - # Check extension installed - if not self.is_extension_installed(ext_name): - return False - - # Check extension version - if requirement.min_ext_version is not None: - installed = self.get_extension_version(ext_name) - if installed is None: - return False - return self._compare_versions(installed, requirement.min_ext_version) >= 0 - - return True - - def supports_transaction_mode(self) -> bool: - """PostgreSQL supports READ ONLY and READ WRITE transaction modes.""" - return True - - def supports_isolation_level_in_begin(self) -> bool: - """PostgreSQL supports isolation level specification in BEGIN statement.""" - return True - - def supports_read_only_transaction(self) -> bool: - """PostgreSQL supports READ ONLY transactions.""" - return True - - def supports_deferrable_transaction(self) -> bool: - """PostgreSQL supports DEFERRABLE transactions (for SERIALIZABLE isolation).""" - return True - - def supports_savepoint(self) -> bool: - """PostgreSQL supports savepoints.""" - return True - - def format_begin_transaction( - self, expr: "BeginTransactionExpression" - ) -> Tuple[str, tuple]: - """Format BEGIN TRANSACTION statement for PostgreSQL. - - PostgreSQL syntax: - BEGIN [ ISOLATION LEVEL { READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE } ] - [ { READ WRITE | READ ONLY } ] - [ { NOT DEFERRABLE | DEFERRABLE } ] - - DEFERRABLE is only meaningful for SERIALIZABLE isolation level. - """ - params = expr.get_params() - parts = ["BEGIN"] - - isolation = params.get("isolation_level") - if isolation: - level_str = self.get_isolation_level_name(isolation) - parts.append(f"ISOLATION LEVEL {level_str}") - - mode = params.get("mode") - if mode: - mode_name = mode.name if hasattr(mode, "name") else str(mode) - if mode_name == "READ_ONLY": - parts.append("READ ONLY") - elif mode_name == "READ_WRITE": - parts.append("READ WRITE") - - deferrable = params.get("deferrable") - if deferrable is not None and isolation: - isolation_name = isolation.name if hasattr(isolation, "name") else str(isolation) - if isolation_name == "SERIALIZABLE": - parts.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") - - return " ".join(parts), () - - def format_commit_transaction( - self, expr: "CommitTransactionExpression" - ) -> Tuple[str, tuple]: - """Format COMMIT TRANSACTION statement for PostgreSQL.""" - return "COMMIT", () - - def format_rollback_transaction( - self, expr: "RollbackTransactionExpression" - ) -> Tuple[str, tuple]: - """Format ROLLBACK TRANSACTION statement for PostgreSQL. - - Supports ROLLBACK [ TO SAVEPOINT savepoint_name ]. - """ - params = expr.get_params() - savepoint = params.get("savepoint") - if savepoint: - return f"ROLLBACK TO SAVEPOINT {self.format_identifier(savepoint)}", () - return "ROLLBACK", () - - def format_savepoint( - self, expr: "SavepointExpression" - ) -> Tuple[str, tuple]: - """Format SAVEPOINT statement for PostgreSQL.""" - params = expr.get_params() - name = params.get("name", "") - return f"SAVEPOINT {self.format_identifier(name)}", () - - def format_release_savepoint( - self, expr: "ReleaseSavepointExpression" - ) -> Tuple[str, tuple]: - """Format RELEASE SAVEPOINT statement for PostgreSQL.""" - params = expr.get_params() - name = params.get("name", "") - return f"RELEASE SAVEPOINT {self.format_identifier(name)}", () - - def format_set_transaction( - self, expr: "SetTransactionExpression" - ) -> Tuple[str, tuple]: - """Format SET TRANSACTION statement for PostgreSQL. - - PostgreSQL supports setting transaction characteristics for the current - transaction or for subsequent transactions. - - Syntax: - SET TRANSACTION { ISOLATION LEVEL { ... } | { READ WRITE | READ ONLY } | [ NOT ] DEFERRABLE } [, ...] - SET SESSION CHARACTERISTICS AS TRANSACTION { ... } - """ - params = expr.get_params() - parts = [] - - if params.get("session"): - parts.append("SET SESSION CHARACTERISTICS AS TRANSACTION") - else: - parts.append("SET TRANSACTION") - - options = [] - - isolation = params.get("isolation_level") - if isolation: - level_str = self.get_isolation_level_name(isolation) - options.append(f"ISOLATION LEVEL {level_str}") - - mode = params.get("mode") - if mode: - mode_name = mode.name if hasattr(mode, "name") else str(mode) - if mode_name == "READ_ONLY": - options.append("READ ONLY") - elif mode_name == "READ_WRITE": - options.append("READ WRITE") - - deferrable = params.get("deferrable") - if deferrable is not None: - options.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") - - if options: - parts.append(" ".join(options)) - - return " ".join(parts), () - - # endregion +# src/rhosocial/activerecord/backend/impl/postgres/dialect.py +""" +PostgreSQL backend SQL dialect implementation. + +This dialect implements protocols for features that PostgreSQL actually supports, +based on the PostgreSQL version provided at initialization. +""" + +from typing import Any, Dict, Tuple, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .function_versions import FunctionSupportInfo, FunctionVersionRequirement + +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.dialect.mixins import ( + CTEMixin, + FilterClauseMixin, + WindowFunctionMixin, + JSONMixin, + ReturningMixin, + AdvancedGroupingMixin, + ArrayMixin, + ExplainMixin, + GraphMixin, + LockingMixin, + MergeMixin, + OrderedSetAggregationMixin, + QualifyClauseMixin, + TemporalTableMixin, + UpsertMixin, + LateralJoinMixin, + JoinMixin, + ViewMixin, + SchemaMixin, + IndexMixin, + SequenceMixin, + TableMixin, + SetOperationMixin, + TruncateMixin, + ILIKEMixin, + ConstraintMixin, +) +from rhosocial.activerecord.backend.dialect.protocols import ( + CTESupport, + FilterClauseSupport, + WindowFunctionSupport, + JSONSupport, + ReturningSupport, + AdvancedGroupingSupport, + ArraySupport, + ExplainSupport, + GraphSupport, + MergeSupport, + OrderedSetAggregationSupport, + QualifyClauseSupport, + TemporalTableSupport, + UpsertSupport, + LateralJoinSupport, + WildcardSupport, + JoinSupport, + ViewSupport, + SchemaSupport, + SequenceSupport, + SetOperationSupport, + TruncateSupport, + ILIKESupport, + IntrospectionSupport, + TransactionControlSupport, + SQLFunctionSupport, +) +from .mixins import ( + PostgresExtensionMixin, + PostgresMaterializedViewMixin, + PostgresTableMixin, + PostgresPgvectorMixin, + PostgresPostGISMixin, + PostgresPostgisRasterMixin, + PostgresPgroutingMixin, + PostgresPgTrgmMixin, + PostgresHstoreMixin, + # Native feature mixins + PostgresPartitionMixin, + PostgresIndexMixin, + PostgresVacuumMixin, + PostgresQueryOptimizationMixin, + PostgresDataTypeMixin, + PostgresSQLSyntaxMixin, + PostgresLogicalReplicationMixin, + PostgresParallelQueryMixin, + # Extension feature mixins + PostgresLtreeMixin, + PostgresIntarrayMixin, + PostgresEarthdistanceMixin, + PostgresTablefuncMixin, + PostgresPgStatStatementsMixin, + PostgresCitextMixin, + PostgresPgcryptoMixin, + PostgresFuzzystrmatchMixin, + PostgresCubeMixin, + PostgresUuidOssMixin, + PostgresBloomMixin, + PostgresBtreeGinMixin, + PostgresBtreeGistMixin, + PostgresPgCronMixin, + PostgresPgPartmanMixin, + PostgresPgSurgeryMixin, + PostgresPgWalinspectMixin, + PostgresPgLogicalMixin, + PostgresPgauditMixin, + PostgresPgRepackMixin, + PostgresHypoPgMixin, + PostgresOrafceMixin, + PostgresAddressStandardizerMixin, + # DDL feature mixins + PostgresTriggerMixin, + PostgresCommentMixin, + PostgresTypeMixin, + PostgresConstraintMixin, + # Type mixins + EnumTypeMixin, + TypesDataTypeMixin, + MultirangeMixin, + PostgresFullTextSearchMixin, + PostgresRangeTypeMixin, + PostgresJSONBEnhancedMixin, + PostgresArrayEnhancedMixin, + # DDL/DML operation mixins (new) + PostgresExtendedStatisticsMixin, + PostgresStoredProcedureMixin, + PostgresAdvisoryLockMixin, + PostgresLockingMixin, + # Introspection capability mixin + PostgresIntrospectionCapabilityMixin, +) + +# PostgreSQL-specific imports +from .protocols import ( + PostgresExtensionSupport, + PostgresMaterializedViewSupport, + PostgresTableSupport, + PostgresPgvectorSupport, + PostgresPostGISSupport, + PostgresPostgisRasterSupport, + PostgresPgroutingSupport, + PostgresPgTrgmSupport, + PostgresHstoreSupport, + # Native feature protocols + PostgresPartitionSupport, + PostgresIndexSupport, + PostgresVacuumSupport, + PostgresQueryOptimizationSupport, + PostgresDataTypeSupport, + PostgresSQLSyntaxSupport, + PostgresLogicalReplicationSupport, + # Extension feature protocols + PostgresLtreeSupport, + PostgresIntarraySupport, + PostgresEarthdistanceSupport, + PostgresTablefuncSupport, + PostgresPgStatStatementsSupport, + PostgresCitextSupport, + PostgresPgcryptoSupport, + PostgresFuzzystrmatchSupport, + PostgresCubeSupport, + PostgresUuidOssSupport, + PostgresBloomSupport, + PostgresBtreeGinSupport, + PostgresBtreeGistSupport, + PostgresPgCronSupport, + PostgresPgPartmanSupport, + PostgresPgSurgerySupport, + PostgresPgWalinspectSupport, + PostgresPgLogicalSupport, + PostgresPgauditSupport, + PostgresPgRepackSupport, + PostgresHypoPgSupport, + PostgresOrafceSupport, + PostgresAddressStandardizerSupport, + # DDL feature protocols + PostgresTriggerSupport, + PostgresCommentSupport, + PostgresTypeSupport, + PostgresConstraintSupport, + # Type feature protocols + PostgresMultirangeSupport, + PostgresEnumTypeSupport, + PostgresFullTextSearchSupport, + PostgresRangeTypeSupport, + PostgresJSONBEnhancedSupport, + PostgresArrayEnhancedSupport, + # New feature protocols + PostgresParallelQuerySupport, + PostgresStoredProcedureSupport, + PostgresExtendedStatisticsSupport, + PostgresAdvisoryLockSupport, + PostgresLockingSupport, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements import ( + CreateTableExpression, + CreateViewExpression, + DropViewExpression, + TruncateExpression, + CreateMaterializedViewExpression, + DropMaterializedViewExpression, + RefreshMaterializedViewExpression, + ExplainExpression, + AddTableConstraint, + TableConstraint, + ) + from rhosocial.activerecord.backend.expression.transaction import ( + BeginTransactionExpression, + CommitTransactionExpression, + RollbackTransactionExpression, + SavepointExpression, + ReleaseSavepointExpression, + SetTransactionExpression, + ) + + +class PostgresDialect( + SQLDialectBase, + SetOperationMixin, + TruncateMixin, + ILIKEMixin, + CTEMixin, + FilterClauseMixin, + WindowFunctionMixin, + JSONMixin, + ReturningMixin, + AdvancedGroupingMixin, + ArrayMixin, + ExplainMixin, + GraphMixin, + PostgresLockingMixin, + LockingMixin, + MergeMixin, + OrderedSetAggregationMixin, + QualifyClauseMixin, + TemporalTableMixin, + UpsertMixin, + LateralJoinMixin, + JoinMixin, + ViewMixin, + SchemaMixin, + PostgresIndexMixin, + IndexMixin, + SequenceMixin, + TableMixin, + ConstraintMixin, + PostgresIntrospectionCapabilityMixin, + # PostgreSQL-specific mixins + PostgresExtensionMixin, + PostgresMaterializedViewMixin, + PostgresTableMixin, + PostgresPgvectorMixin, + PostgresPostGISMixin, + PostgresPostgisRasterMixin, + PostgresPgroutingMixin, + PostgresPgTrgmMixin, + PostgresHstoreMixin, + # Native feature mixins + PostgresPartitionMixin, + PostgresVacuumMixin, + PostgresQueryOptimizationMixin, + PostgresDataTypeMixin, + PostgresSQLSyntaxMixin, + PostgresLogicalReplicationMixin, + PostgresParallelQueryMixin, + # Extension feature mixins + PostgresLtreeMixin, + PostgresIntarrayMixin, + PostgresEarthdistanceMixin, + PostgresTablefuncMixin, + PostgresPgStatStatementsMixin, + PostgresCitextMixin, + PostgresPgcryptoMixin, + PostgresFuzzystrmatchMixin, + PostgresCubeMixin, + PostgresUuidOssMixin, + PostgresBloomMixin, + PostgresBtreeGinMixin, + PostgresBtreeGistMixin, + PostgresPgCronMixin, + PostgresPgPartmanMixin, + PostgresPgSurgeryMixin, + PostgresPgWalinspectMixin, + PostgresPgLogicalMixin, + PostgresPgauditMixin, + PostgresPgRepackMixin, + PostgresHypoPgMixin, + PostgresOrafceMixin, + PostgresAddressStandardizerMixin, + # DDL feature mixins + PostgresTriggerMixin, + PostgresCommentMixin, + PostgresTypeMixin, + PostgresConstraintMixin, + # Type mixins + EnumTypeMixin, + TypesDataTypeMixin, + MultirangeMixin, + PostgresFullTextSearchMixin, + PostgresRangeTypeMixin, + PostgresJSONBEnhancedMixin, + PostgresArrayEnhancedMixin, + # DDL/DML operation mixins (new) + PostgresExtendedStatisticsMixin, + PostgresStoredProcedureMixin, + PostgresAdvisoryLockMixin, + # Protocol supports + SetOperationSupport, + TruncateSupport, + ILIKESupport, + CTESupport, + FilterClauseSupport, + WindowFunctionSupport, + JSONSupport, + ReturningSupport, + AdvancedGroupingSupport, + ArraySupport, + ExplainSupport, + GraphSupport, + MergeSupport, + OrderedSetAggregationSupport, + QualifyClauseSupport, + TemporalTableSupport, + UpsertSupport, + LateralJoinSupport, + WildcardSupport, + JoinSupport, + ViewSupport, + SchemaSupport, + SequenceSupport, + # Introspection protocol + IntrospectionSupport, + # Transaction control protocol + TransactionControlSupport, + # PostgreSQL-specific protocols + PostgresExtensionSupport, + PostgresMaterializedViewSupport, + PostgresTableSupport, + PostgresPgvectorSupport, + PostgresPostGISSupport, + PostgresPostgisRasterSupport, + PostgresPgroutingSupport, + PostgresPgTrgmSupport, + PostgresHstoreSupport, + # Native feature protocols + PostgresPartitionSupport, + PostgresIndexSupport, + PostgresVacuumSupport, + PostgresQueryOptimizationSupport, + PostgresDataTypeSupport, + PostgresSQLSyntaxSupport, + PostgresLogicalReplicationSupport, + # Extension feature protocols + PostgresLtreeSupport, + PostgresIntarraySupport, + PostgresEarthdistanceSupport, + PostgresTablefuncSupport, + PostgresPgStatStatementsSupport, + PostgresCitextSupport, + PostgresPgcryptoSupport, + PostgresFuzzystrmatchSupport, + PostgresCubeSupport, + PostgresUuidOssSupport, + PostgresBloomSupport, + PostgresBtreeGinSupport, + PostgresBtreeGistSupport, + PostgresPgCronSupport, + PostgresPgPartmanSupport, + PostgresPgSurgerySupport, + PostgresPgWalinspectSupport, + PostgresPgLogicalSupport, + PostgresPgauditSupport, + PostgresPgRepackSupport, + PostgresHypoPgSupport, + PostgresOrafceSupport, + PostgresAddressStandardizerSupport, + # DDL feature protocols + PostgresTriggerSupport, + PostgresCommentSupport, + PostgresTypeSupport, + PostgresConstraintSupport, + # Type feature protocols + PostgresMultirangeSupport, + PostgresEnumTypeSupport, + PostgresFullTextSearchSupport, + PostgresRangeTypeSupport, + PostgresJSONBEnhancedSupport, + PostgresArrayEnhancedSupport, + # New feature protocols + PostgresParallelQuerySupport, + PostgresStoredProcedureSupport, + PostgresExtendedStatisticsSupport, + PostgresAdvisoryLockSupport, + PostgresLockingSupport, + # Function support protocol + SQLFunctionSupport, +): + """ + PostgreSQL dialect implementation that adapts to the PostgreSQL version. + + PostgreSQL features and support based on version: + - Basic and recursive CTEs (since 8.4) + - Window functions (since 8.4) + - RETURNING clause (since 8.2) + - JSON operations (since 9.2, JSONB since 9.4) + - FILTER clause (since 9.4) + - UPSERT (ON CONFLICT) (since 9.5) + - MERGE statement (since 15) + - Advanced grouping (CUBE, ROLLUP, GROUPING SETS) (since 9.5) + - Array types (since early versions) + - LATERAL joins (since 9.3) + - Parallel query execution (since 9.6) + - Stored procedures with CALL (since 11) + - Extended statistics (since 10) + + PostgreSQL-specific features: + - Table inheritance (INHERITS) + - CONCURRENTLY refresh for materialized views (since 9.4) + - Extension detection (PostGIS, pgvector, pg_trgm, hstore, etc.) + + Note: Extension features require the extension to be installed in the database. + Use introspect_and_adapt() to detect installed extensions automatically. + """ + + def __init__(self, version: Optional[Tuple[int, int, int]] = None): + """ + Initialize PostgreSQL dialect with specific version. + + Args: + version: PostgreSQL version tuple (major, minor, patch). + If None, the dialect must be adapted via + backend.introspect_and_adapt() before version-dependent + features can be used. + """ + super().__init__() + if version is not None: + self.version = version + + @staticmethod + def _validate_data_type(data_type: str) -> bool: + """Validate data type for safe embedding in SQL. + + PostgreSQL supports array types like TEXT[], INTEGER[]. + + Note: rhosocial-activerecord base dialect will include this change in + a future release. This override can be removed after upgrading to that + version (expected: include brackets [] in the allowlist pattern). + """ + import re + + return bool(re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", data_type)) + + def get_parameter_placeholder(self, position: int = 0) -> str: + """psycopg uses '%s' for placeholders.""" + return "%s" + + def get_server_version(self) -> Tuple[int, int, int]: + """Return the PostgreSQL version this dialect is configured for.""" + return self.version + + # region Protocol Support Checks based on version + def supports_basic_cte(self) -> bool: + """Basic CTEs are supported since PostgreSQL 8.4.""" + return True # Supported in all modern versions + + def supports_recursive_cte(self) -> bool: + """Recursive CTEs are supported since PostgreSQL 8.4.""" + return True # Supported in all modern versions + + def supports_materialized_cte(self) -> bool: + """MATERIALIZED hint is supported since PostgreSQL 12.""" + return self.version >= (12, 0, 0) + + def supports_returning_insert(self) -> bool: + """RETURNING clause is supported for INSERT since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) + + def supports_returning_update(self) -> bool: + """RETURNING clause is supported for UPDATE since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) + + def supports_returning_delete(self) -> bool: + """RETURNING clause is supported for DELETE since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) + + def supports_window_functions(self) -> bool: + """Window functions are supported since PostgreSQL 8.4.""" + return True # Supported in all modern versions + + def supports_window_frame_clause(self) -> bool: + """Whether window frame clauses (ROWS/RANGE) are supported, since PostgreSQL 8.4.""" + return True # Supported in all modern versions + + def supports_filter_clause(self) -> bool: + """FILTER clause for aggregate functions is supported since PostgreSQL 9.4.""" + return self.version >= (9, 4, 0) + + def get_json_access_operator(self) -> str: + """PostgreSQL uses '->' for JSON access.""" + return "->" + + def supports_rollup(self) -> bool: + """ROLLUP is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_cube(self) -> bool: + """CUBE is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_grouping_sets(self) -> bool: + """GROUPING SETS is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_explain_analyze(self) -> bool: + """Whether EXPLAIN ANALYZE is supported.""" + return True # Supported in all modern versions + + def supports_explain_format(self, format_type: str) -> bool: + """Check if specific EXPLAIN format is supported.""" + format_type_upper = format_type.upper() + # PostgreSQL supports TEXT, XML, JSON, and YAML formats + supported_formats = ["TEXT", "XML", "JSON", "YAML"] + return format_type_upper in supported_formats + + def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: + """Build the PostgreSQL EXPLAIN SQL string and return (sql, params). + + PostgreSQL syntax: ``EXPLAIN [ ( option [, ...] ) ] statement`` + + Supported options assembled here: + - ``ANALYZE`` + - ``FORMAT { TEXT | XML | JSON | YAML }`` + - ``VERBOSE`` (passed through ``ExplainOptions.verbose`` if present) + """ + from rhosocial.activerecord.backend.expression.statements import ExplainType + + statement_sql, statement_params = explain_expr.statement.to_sql() + options = explain_expr.options + if options is None: + return f"EXPLAIN {statement_sql}", statement_params + + opts: list = [] + + if options.analyze: + opts.append("ANALYZE") + + if options.format is not None: + fmt_name = options.format.name if hasattr(options.format, "name") else str(options.format) + opts.append(f"FORMAT {fmt_name.upper()}") + elif options.type is not None and options.type == ExplainType.QUERY_PLAN: + # PostgreSQL has no QUERY PLAN keyword; plain EXPLAIN is equivalent + pass + + if opts: + return "EXPLAIN (" + ", ".join(opts) + ") " + statement_sql, statement_params + return f"EXPLAIN {statement_sql}", statement_params + + def supports_graph_match(self) -> bool: + """Whether graph query MATCH clause is supported.""" + # PostgreSQL doesn't have native MATCH clause like some other systems + # Though graph querying can be done with extensions like Apache AGE + return False + + def supports_for_update(self) -> bool: + """Whether FOR UPDATE clause is supported in SELECT statements. + + PostgreSQL supports FOR UPDATE since early versions. The clause locks + selected rows preventing other transactions from modifying them. + PostgreSQL also supports FOR UPDATE OF, FOR UPDATE NOWAIT, and + FOR UPDATE SKIP LOCKED (since 9.5). + """ + return True + + def supports_lock_strength(self, strength) -> bool: + """ + Check if a specific lock strength is supported. + + PostgreSQL lock strength support by version: + - FOR UPDATE: All versions + - FOR NO KEY UPDATE: PostgreSQL 9.0+ + - FOR SHARE: PostgreSQL 9.0+ + - FOR KEY SHARE: PostgreSQL 9.3+ + + Args: + strength: The LockStrength enum value to check + + Returns: + True if the lock strength is supported, False otherwise + """ + from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength + + if strength == LockStrength.UPDATE: + return True # All PostgreSQL versions support FOR UPDATE + elif strength == LockStrength.NO_KEY_UPDATE: + return self.version >= (9, 0, 0) + elif strength == LockStrength.SHARE: + return self.version >= (9, 0, 0) + elif strength == LockStrength.KEY_SHARE: + return self.version >= (9, 3, 0) + return False + + def supports_merge_statement(self) -> bool: + """Whether MERGE statement is supported.""" + return self.version >= (15, 0, 0) # MERGE added in PostgreSQL 15 + + def supports_temporal_tables(self) -> bool: + """Whether temporal tables are supported.""" + # PostgreSQL doesn't have built-in temporal tables + return False + + def supports_qualify_clause(self) -> bool: + """Whether QUALIFY clause is supported.""" + # PostgreSQL doesn't have QUALIFY clause (though can be simulated with subqueries) + return False + + def supports_upsert(self) -> bool: + """Whether UPSERT (ON CONFLICT) is supported.""" + return self.version >= (9, 5, 0) # ON CONFLICT added in 9.5 + + def get_upsert_syntax_type(self) -> str: + """ + Get UPSERT syntax type. + + Returns: + 'ON CONFLICT' (PostgreSQL) or 'ON DUPLICATE KEY' (MySQL) + """ + return "ON CONFLICT" + + def supports_lateral_join(self) -> bool: + """Whether LATERAL joins are supported.""" + return self.version >= (9, 3, 0) # LATERAL joins added in 9.3 + + def supports_ordered_set_aggregation(self) -> bool: + """Whether ordered-set aggregate functions are supported.""" + return self.version >= (9, 4, 0) # Supported since 9.4 + + def supports_multirange(self) -> bool: + """Whether multirange types are supported (PostgreSQL 14+).""" + return self.version >= (14, 0, 0) + + def supports_multirange_constructor(self) -> bool: + """Whether multirange constructor functions are supported (PostgreSQL 14+).""" + return self.version >= (14, 0, 0) + + def supports_inner_join(self) -> bool: + """INNER JOIN is supported.""" + return True + + def supports_left_join(self) -> bool: + """LEFT JOIN is supported.""" + return True + + def supports_right_join(self) -> bool: + """RIGHT JOIN is supported.""" + return True + + def supports_full_join(self) -> bool: + """FULL JOIN is supported.""" + return True + + def supports_cross_join(self) -> bool: + """CROSS JOIN is supported.""" + return True + + def supports_natural_join(self) -> bool: + """NATURAL JOIN is supported.""" + return True + + def supports_wildcard(self) -> bool: + """Wildcard (*) is supported.""" + return True + + # region ILIKE Support + def supports_ilike(self) -> bool: + """ILIKE is supported.""" + return True + + def format_ilike_expression(self, column: Any, pattern: str, negate: bool = False) -> Tuple[str, Tuple]: + """Format ILIKE expression for PostgreSQL.""" + if isinstance(column, str): + col_sql = self.format_identifier(column) + else: + col_sql, col_params = column.to_sql() if hasattr(column, "to_sql") else (str(column), ()) + + if negate: + sql = f"{col_sql} NOT ILIKE %s" + else: + sql = f"{col_sql} ILIKE %s" + + return sql, (pattern,) + + # endregion + + # region Set Operation Support + def supports_union(self) -> bool: + """UNION is supported.""" + return True + + def supports_union_all(self) -> bool: + """UNION ALL is supported.""" + return True + + def supports_intersect(self) -> bool: + """INTERSECT is supported.""" + return True + + def supports_except(self) -> bool: + """EXCEPT is supported.""" + return True + + def supports_set_operation_order_by(self) -> bool: + """ORDER BY is supported for set operations.""" + return True + + def supports_set_operation_limit_offset(self) -> bool: + """LIMIT/OFFSET is supported for set operations.""" + return True + + def supports_set_operation_for_update(self) -> bool: + """FOR UPDATE is supported for set operations.""" + return True + + # endregion + + # region Truncate Support + def supports_truncate(self) -> bool: + """TRUNCATE is supported.""" + return True + + def supports_truncate_table_keyword(self) -> bool: + """TABLE keyword is supported in TRUNCATE.""" + return True + + def supports_truncate_restart_identity(self) -> bool: + """RESTART IDENTITY is supported since PostgreSQL 8.4.""" + return self.version >= (8, 4, 0) + + def supports_truncate_cascade(self) -> bool: + """CASCADE is supported in TRUNCATE.""" + return True + + def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: + """Format TRUNCATE statement for PostgreSQL.""" + parts = ["TRUNCATE TABLE"] + parts.append(self.format_identifier(expr.table_name)) + + if expr.restart_identity and self.supports_truncate_restart_identity(): + parts.append("RESTART IDENTITY") + + if expr.cascade: + parts.append("CASCADE") + + return " ".join(parts), () + + # endregion + + # region Custom Implementations for PostgreSQL-specific behavior + def format_identifier(self, identifier: str) -> str: + """ + Format identifier using PostgreSQL's double quote quoting mechanism. + + Args: + identifier: Raw identifier string + + Returns: + Quoted identifier with escaped internal quotes + """ + # Escape any internal double quotes by doubling them + escaped = identifier.replace('"', '""') + return f'"{escaped}"' + + def format_column(self, name: str, table: Optional[str] = None, + alias: Optional[str] = None, + schema_name: Optional[str] = None) -> Tuple[str, Tuple]: + """Format column reference for PostgreSQL. + + PostgreSQL rules for column references: + - When the table has an alias (used in FROM/JOIN), column references + must use the alias, not the schema-qualified name. For example, + when FROM clause has ``public.users AS "users"``, the column + reference must be ``"users"."id"``, not ``"public"."users"."id"``. + - When no alias is present but schema is specified, use the + full three-segment form: ``"schema"."table"."column"``. + - Otherwise use the standard two-segment or single-segment form. + """ + if table: + # In PostgreSQL, when a table has an alias, column references + # must use the alias — schema_name is irrelevant in this context. + if schema_name and not alias: + col_sql = f"{self.format_identifier(schema_name)}.{self.format_identifier(table)}.{self.format_identifier(name)}" + else: + col_sql = f"{self.format_identifier(table)}.{self.format_identifier(name)}" + else: + col_sql = self.format_identifier(name) + + if alias: + col_sql = f"{col_sql} AS {self.format_identifier(alias)}" + + return col_sql, () + + def format_on_conflict_clause(self, expr) -> Tuple[str, tuple]: + """Format ON CONFLICT clause for PostgreSQL. + + Overrides the base implementation to handle EXCLUDED pseudo-table + references without quoting, as EXCLUDED is a special PostgreSQL + keyword in ON CONFLICT context and must not be double-quoted. + """ + from rhosocial.activerecord.backend.expression import bases + from rhosocial.activerecord.backend.expression.core import Column + + all_params = [] + parts = ["ON CONFLICT"] + + # Add conflict target if specified + if expr.conflict_target: + target_parts = [] + for target in expr.conflict_target: + if isinstance(target, str): + target_parts.append(self.format_identifier(target)) + elif hasattr(target, 'to_sql'): + target_sql, target_params = target.to_sql() + target_parts.append(target_sql) + all_params.extend(target_params) + else: + target_parts.append(self.format_identifier(str(target))) + if target_parts: + parts.append(f"({', '.join(target_parts)})") + + # Add DO NOTHING or DO UPDATE + if expr.do_nothing: + parts.append("DO NOTHING") + elif expr.update_assignments: + update_parts = [] + for col, expr_val in expr.update_assignments.items(): + if isinstance(expr_val, Column) and getattr(expr_val, 'table', None) == 'EXCLUDED': + # EXCLUDED is a special pseudo-table in PostgreSQL ON CONFLICT. + # It must NOT be double-quoted, only the column name should be quoted. + val_sql = f'EXCLUDED.{self.format_identifier(expr_val.name)}' + update_parts.append(f"{self.format_identifier(col)} = {val_sql}") + elif isinstance(expr_val, bases.BaseExpression): + val_sql, val_params = expr_val.to_sql() + update_parts.append(f"{self.format_identifier(col)} = {val_sql}") + all_params.extend(val_params) + else: + update_parts.append(f"{self.format_identifier(col)} = {self.get_parameter_placeholder()}") + all_params.append(expr_val) + + parts.append(f"DO UPDATE SET {', '.join(update_parts)}") + + # Add WHERE clause if specified + if expr.update_where: + where_sql, where_params = expr.update_where.to_sql() + parts.append(f"WHERE {where_sql}") + all_params.extend(where_params) + else: + parts.append("DO NOTHING") + + return " ".join(parts), tuple(all_params) + + # region View Support + def supports_or_replace_view(self) -> bool: + """Whether CREATE OR REPLACE VIEW is supported.""" + return True # PostgreSQL supports OR REPLACE + + def supports_temporary_view(self) -> bool: + """Whether TEMPORARY views are supported.""" + return True # PostgreSQL supports TEMPORARY views + + def supports_materialized_view(self) -> bool: + """Whether materialized views are supported.""" + return True # PostgreSQL supports materialized views since 9.3 + + def supports_refresh_materialized_view(self) -> bool: + """Whether REFRESH MATERIALIZED VIEW is supported.""" + return True # PostgreSQL supports REFRESH MATERIALIZED VIEW + + def supports_materialized_view_concurrent_refresh(self) -> bool: + """Whether concurrent refresh for materialized views is supported.""" + return self.version >= (9, 4, 0) # CONCURRENTLY added in 9.4 + + def supports_materialized_view_tablespace(self) -> bool: + """Whether tablespace specification for materialized views is supported.""" + return True + + def supports_materialized_view_storage_options(self) -> bool: + """Whether storage options for materialized views are supported.""" + return True + + def supports_if_exists_view(self) -> bool: + """Whether DROP VIEW IF EXISTS is supported.""" + return True # PostgreSQL supports IF EXISTS + + def supports_view_check_option(self) -> bool: + """Whether WITH CHECK OPTION is supported.""" + return True # PostgreSQL supports WITH CHECK OPTION + + def supports_cascade_view(self) -> bool: + """Whether DROP VIEW CASCADE is supported.""" + return True # PostgreSQL supports CASCADE + + def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: + """Format CREATE VIEW statement for PostgreSQL.""" + parts = ["CREATE"] + + if expr.temporary: + parts.append("TEMPORARY") + + if expr.replace: + parts.append("OR REPLACE") + + parts.append("VIEW") + parts.append(self.format_identifier(expr.view_name)) + + if expr.column_aliases: + cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) + parts.append(f"({cols})") + + query_sql, query_params = expr.query.to_sql() + parts.append(f"AS {query_sql}") + + if expr.options and expr.options.check_option: + check_option = expr.options.check_option.value + parts.append(f"WITH {check_option} CHECK OPTION") + + return " ".join(parts), query_params + + def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, tuple]: + """Format DROP VIEW statement for PostgreSQL.""" + parts = ["DROP VIEW"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.view_name)) + if expr.cascade: + parts.append("CASCADE") + return " ".join(parts), () + + def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewExpression") -> Tuple[str, tuple]: + """Format CREATE MATERIALIZED VIEW statement for PostgreSQL.""" + parts = ["CREATE MATERIALIZED VIEW"] + parts.append(self.format_identifier(expr.view_name)) + + if expr.column_aliases: + cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) + parts.append(f"({cols})") + + if expr.tablespace and self.supports_materialized_view_tablespace(): + parts.append(f"TABLESPACE {self.format_identifier(expr.tablespace)}") + + if expr.storage_options and self.supports_materialized_view_storage_options(): + storage_parts = [] + for key, value in expr.storage_options.items(): + storage_parts.append(f"{key.upper()} = {value}") + parts.append(f"WITH ({', '.join(storage_parts)})") + + query_sql, query_params = expr.query.to_sql() + parts.append(f"AS {query_sql}") + + if expr.with_data: + parts.append("WITH DATA") + else: + parts.append("WITH NO DATA") + + return " ".join(parts), query_params + + def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExpression") -> Tuple[str, tuple]: + """Format DROP MATERIALIZED VIEW statement for PostgreSQL.""" + parts = ["DROP MATERIALIZED VIEW"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.view_name)) + if expr.cascade: + parts.append("CASCADE") + return " ".join(parts), () + + def format_refresh_materialized_view_statement( + self, expr: "RefreshMaterializedViewExpression" + ) -> Tuple[str, tuple]: + """Format REFRESH MATERIALIZED VIEW statement for PostgreSQL.""" + parts = ["REFRESH MATERIALIZED VIEW"] + if expr.concurrent and self.supports_materialized_view_concurrent_refresh(): + parts.append("CONCURRENTLY") + parts.append(self.format_identifier(expr.view_name)) + if expr.with_data is not None: + parts.append("WITH DATA" if expr.with_data else "WITH NO DATA") + return " ".join(parts), () + + # endregion + + # region Schema Support + def supports_create_schema(self) -> bool: + """Whether CREATE SCHEMA is supported.""" + return True + + def supports_drop_schema(self) -> bool: + """Whether DROP SCHEMA is supported.""" + return True + + def supports_schema_if_not_exists(self) -> bool: + """Whether CREATE SCHEMA IF NOT EXISTS is supported.""" + return True # PostgreSQL 9.3+ supports IF NOT EXISTS + + def supports_schema_if_exists(self) -> bool: + """Whether DROP SCHEMA IF EXISTS is supported.""" + return True + + def supports_schema_cascade(self) -> bool: + """Whether DROP SCHEMA CASCADE is supported.""" + return True + + # endregion + + # region Index Support + def supports_create_index(self) -> bool: + """Whether CREATE INDEX is supported.""" + return True + + def supports_drop_index(self) -> bool: + """Whether DROP INDEX is supported.""" + return True + + def supports_unique_index(self) -> bool: + """Whether UNIQUE indexes are supported.""" + return True + + 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 + + def supports_index_if_exists(self) -> bool: + """Whether DROP INDEX IF EXISTS is supported.""" + return True + + # endregion + + # region Sequence Support + def supports_create_sequence(self) -> bool: + """Whether CREATE SEQUENCE is supported.""" + return True + + def supports_drop_sequence(self) -> bool: + """Whether DROP SEQUENCE is supported.""" + return True + + # endregion + + # region Table Support + def supports_if_not_exists_table(self) -> bool: + """Whether CREATE TABLE IF NOT EXISTS is supported.""" + return True + + def supports_if_exists_table(self) -> bool: + """Whether DROP TABLE IF EXISTS is supported.""" + return True + + def supports_temporary_table(self) -> bool: + """Whether TEMPORARY tables are supported.""" + return True + + def supports_table_inheritance(self) -> bool: + """Whether table inheritance is supported.""" + return True # PostgreSQL supports INHERITS + + def supports_table_partitioning(self) -> bool: + """Whether table partitioning is supported.""" + return True # PostgreSQL supports partitioning + + def supports_table_tablespace(self) -> bool: + """Whether tablespace specification is supported.""" + return True + + # Constraint capability overrides + + def supports_constraint_enforced(self) -> bool: + """PostgreSQL does not support ENFORCED/NOT ENFORCED (SQL:2016). + + PostgreSQL uses NOT VALID as a proprietary alternative for + skipping validation of existing data when adding constraints. + """ + return False + + def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: + """ + Format CREATE TABLE statement for PostgreSQL, including LIKE syntax support. + + PostgreSQL LIKE syntax supports INCLUDING/EXCLUDING options to control what + gets copied: DEFAULTS, CONSTRAINTS, INDEXES, IDENTITY, GENERATED, ALL, + COMMENTS, STORAGE, COMPRESSION. + + The behavior is controlled by the `dialect_options` parameter in + CreateTableExpression: + + 1. When `dialect_options` contains 'like_table' key: + - LIKE syntax takes highest priority + - All other parameters (columns, indexes, constraints, etc.) are IGNORED + - Only temporary and if_not_exists flags are considered + - Additional 'like_options' key controls INCLUDING/EXCLUDING behavior + + 2. When `dialect_options` does NOT contain 'like_table': + - Falls back to base class implementation + - Standard CREATE TABLE with column definitions is generated + + The 'like_options' key supports two formats: + + a) Dictionary format (recommended): + { + 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], + 'excluding': ['COMMENTS'] + } + + b) List format (for backwards compatibility): + ['DEFAULTS', 'CONSTRAINTS'] # Defaults to INCLUDING + or + [('INCLUDING', 'DEFAULTS'), ('EXCLUDING', 'INDEXES')] + + Usage Examples: + # Basic LIKE syntax + CreateTableExpression( + dialect=postgres_dialect, + table_name="users_copy", + columns=[], # Ignored when like_table is present + dialect_options={'like_table': 'users'} + ) + # Generates: CREATE TABLE "users_copy" (LIKE "users") + + # LIKE with INCLUDING options (dictionary format - recommended) + CreateTableExpression( + dialect=postgres_dialect, + table_name="users_copy", + columns=[...], # Will be ignored + dialect_options={ + 'like_table': 'users', + 'like_options': { + 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], + 'excluding': ['COMMENTS'] + } + } + ) + # Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, + # INCLUDING CONSTRAINTS, INCLUDING INDEXES, EXCLUDING COMMENTS) + + # LIKE with schema-qualified source table + CreateTableExpression( + dialect=postgres_dialect, + table_name="users_copy", + columns=[], + dialect_options={'like_table': ('public', 'users')} + ) + # Generates: CREATE TABLE "users_copy" (LIKE "public"."users") + + # LIKE with TEMPORARY and IF NOT EXISTS + CreateTableExpression( + dialect=postgres_dialect, + table_name="temp_users", + columns=[], + temporary=True, + if_not_exists=True, + dialect_options={'like_table': 'users'} + ) + # Generates: CREATE TEMPORARY TABLE IF NOT EXISTS "temp_users" (LIKE "users") + + # LIKE with INCLUDING ALL + CreateTableExpression( + dialect=postgres_dialect, + table_name="users_copy", + columns=[], + dialect_options={ + 'like_table': 'users', + 'like_options': {'including': ['ALL']} + } + ) + # Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING ALL) + + Args: + expr: CreateTableExpression instance + + Returns: + Tuple of (SQL string, parameters tuple) + """ + # Check for LIKE syntax in dialect_options (highest priority) + if "like_table" in expr.dialect_options: + like_table = expr.dialect_options["like_table"] + like_options = expr.dialect_options.get("like_options", []) + + parts = ["CREATE"] + + if expr.temporary: + parts.append("TEMPORARY") + + parts.append("TABLE") + + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + + parts.append(self.format_identifier(expr.table_name)) + + # Build LIKE clause with options + like_parts = [] + + # Handle schema-qualified table name: ('schema', 'table') + if isinstance(like_table, tuple): + schema, table = like_table + like_table_str = f"{self.format_identifier(schema)}.{self.format_identifier(table)}" + else: + like_table_str = self.format_identifier(like_table) + + like_parts.append(f"LIKE {like_table_str}") + + # Add INCLUDING/EXCLUDING options + # Format: dictionary with 'including' and 'excluding' keys + # Example: {'including': ['DEFAULTS', 'CONSTRAINTS'], 'excluding': ['INDEXES']} + if isinstance(like_options, dict): + # Handle dictionary format + including = like_options.get("including", []) + excluding = like_options.get("excluding", []) + + for option in including: + like_parts.append(f"INCLUDING {option.upper()}") + + for option in excluding: + like_parts.append(f"EXCLUDING {option.upper()}") + elif isinstance(like_options, list): + # Handle list format for backwards compatibility + for option in like_options: + if isinstance(option, tuple): + action, feature = option + like_parts.append(f"{action.upper()} {feature.upper()}") + else: + # Default to INCLUDING if just feature name provided + like_parts.append(f"INCLUDING {option.upper()}") + + parts.append(f"({', '.join(like_parts)})") + + return " ".join(parts), () + + # Otherwise, delegate to base implementation + return super().format_create_table_statement(expr) + + # endregion + + # region Type Casting Support (PostgreSQL-specific) + def format_cast_expression( + self, expr_sql: str, target_type: str, expr_params: tuple, alias: Optional[str] = None + ) -> Tuple[str, Tuple]: + """Format type cast expression using PostgreSQL :: syntax. + + PostgreSQL supports both standard CAST(expr AS type) syntax and the + PostgreSQL-specific expr::type syntax. This method uses the more + concise :: syntax which is idiomatic in PostgreSQL. + + Args: + expr_sql: SQL expression string to be cast + target_type: Target PostgreSQL type name (e.g., 'integer', 'varchar(100)') + expr_params: Parameters tuple for the expression + alias: Optional alias for the result + + Returns: + Tuple of (SQL string, parameters) + + Example: + >>> dialect.format_cast_expression('price', 'numeric', ()) + # Returns: ('price::numeric', ()) + >>> dialect.format_cast_expression('amount', 'money', ()) + # Returns: ('amount::money', ()) + >>> dialect.format_cast_expression('value', 'integer', (), 'int_val') + # Returns: ('value::integer AS "int_val"', ()) + + Note: + For chained type conversions, each ::type is appended: + >>> col.cast('money').cast('numeric').cast('float8') + # Generates: col::money::numeric::float8 + """ + sql = f"{expr_sql}::{target_type}" + if alias: + sql = f"{sql} AS {self.format_identifier(alias)}" + return sql, expr_params + + # endregion + + # region Operator Formatting (PostgreSQL-specific) + + def format_binary_operator( + self, op: str, left_sql: str, right_sql: str, left_params: tuple, right_params: tuple + ) -> Tuple[str, Tuple]: + """Format binary operator with psycopg placeholder escaping. + + psycopg uses %s as parameter placeholder. When the SQL operator itself + contains % (e.g., pg_trgm similarity operator), it must be escaped as %% + to prevent psycopg from interpreting it as a placeholder prefix. + """ + # Escape % in operators for psycopg compatibility + escaped_op = op.replace('%', '%%') if '%' in op else op + sql = f"{left_sql} {escaped_op} {right_sql}" + return sql, left_params + right_params + + # endregion + + # region Constraint DDL Support (PostgreSQL-specific) + + def format_add_table_constraint_action( + self, action: "AddTableConstraint", + ) -> Tuple[str, tuple]: + """Format ADD CONSTRAINT action with PostgreSQL-specific extensions. + + Extends the base class implementation with: + - EXCLUDE constraint support (PG-specific constraint type) + - NOT VALID suffix (PG-specific: skip validation of existing rows) + """ + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraintType, ConstraintValidation, + ) + + # Handle EXCLUDE constraint (PG-specific, not in base class) + if action.constraint.constraint_type == TableConstraintType.EXCLUDE: + parts = [] + exclude_sql, params = self._format_exclude_constraint(action.constraint) + parts.append(exclude_sql) + + # NOT VALID suffix + if action.constraint.dialect_options: + validation = action.constraint.dialect_options.get('validation') + if validation == ConstraintValidation.NOVALIDATE: + parts.append("NOT VALID") + + return f"ADD {' '.join(parts)}", tuple(params) + + # Use base class for standard formatting (includes DEFERRABLE) + sql, params = super().format_add_table_constraint_action(action) + + # PostgreSQL NOT VALID suffix + if action.constraint.dialect_options: + validation = action.constraint.dialect_options.get('validation') + if validation == ConstraintValidation.NOVALIDATE: + sql += " NOT VALID" + + return sql, params + + def _format_exclude_constraint( + self, constraint: "TableConstraint", + ) -> Tuple[str, tuple]: + """Format EXCLUDE constraint (PostgreSQL-specific). + + EXCLUDE constraints use the dialect_options dict to specify: + - 'exclude_elements': List of (expression, operator) tuples + e.g., [('range', '&&')] for EXCLUDE USING gist (range WITH &&) + - 'using': The index access method (default 'gist') + e.g., 'gist', 'btree', 'spgist' + - 'where': Optional predicate for partial exclusion constraints + + Example: + TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name='exclude_range_overlap', + dialect_options={ + 'exclude_elements': [('range', '&&')], + 'using': 'gist', + } + ) + # Generates: EXCLUDE USING gist (range WITH &&) + """ + params: list = [] + + if constraint.name: + parts = ["CONSTRAINT", self.format_identifier(constraint.name)] + else: + parts = [] + + # USING clause - validate index access method. + valid_using = frozenset({"gist", "btree", "spgist", "hash", "gin", "brin"}) + using = constraint.dialect_options.get("using", "gist") if constraint.dialect_options else "gist" + if using not in valid_using: + raise ValueError( + f"Invalid index access method '{using}': must be one of {valid_using}" + ) + parts.append(f"EXCLUDE USING {using}") + + # Elements: (expression, operator) pairs - validate operators. + valid_ops = frozenset({ + "=", "<", "<=", ">", ">=", "<>", + "&&", "@>", "<@", "<<", ">>", "&<", "&>", + "~=", "@@", "?|", "?&", "is", "is not", + }) + exclude_elements = [] + if constraint.dialect_options and "exclude_elements" in constraint.dialect_options: + for expr, op in constraint.dialect_options["exclude_elements"]: + if op not in valid_ops: + raise ValueError( + f"Invalid exclude operator '{op}': must be one of {valid_ops}" + ) + if isinstance(expr, str): + exclude_elements.append(f"{self.format_identifier(expr)} WITH {op}") + else: + expr_sql, expr_params = expr.to_sql() + params.extend(expr_params) + exclude_elements.append(f"{expr_sql} WITH {op}") + + if exclude_elements: + parts.append(f"({', '.join(exclude_elements)})") + + # WHERE clause for partial exclusion constraint + if constraint.dialect_options and 'where' in constraint.dialect_options: + where_expr = constraint.dialect_options['where'] + where_sql, where_params = where_expr.to_sql() + params.extend(where_params) + parts.append(f"WHERE ({where_sql})") + + # DEFERRABLE / NOT DEFERRABLE + if constraint.deferrable is True: + if constraint.initially_deferred is True: + parts.append("DEFERRABLE INITIALLY DEFERRED") + elif constraint.initially_deferred is False: + parts.append("DEFERRABLE INITIALLY IMMEDIATE") + else: + parts.append("DEFERRABLE") + elif constraint.deferrable is False: + parts.append("NOT DEFERRABLE") + + return ' '.join(parts), tuple(params) + + # endregion + + # region Trigger Support (PostgreSQL-specific) + def supports_trigger(self) -> bool: + return True + + def supports_create_trigger(self) -> bool: + return True + + def supports_drop_trigger(self) -> bool: + return True + + # endregion + + # region Transaction Control Support + + # PostgreSQL function version support: function_name -> (min_version, max_version) + # min_version: minimum supported version (inclusive), None = all versions + # Function version requirements are defined in function_versions.py, + # categorized by topic (JSON Path, Range, hstore, pgvector, PostGIS, etc.) + # and assembled into POSTGRES_FUNCTION_VERSIONS. + from .function_versions import POSTGRES_FUNCTION_VERSIONS as _FV + _POSTGRES_FUNCTION_VERSIONS = _FV + + def supports_functions(self) -> Dict[str, "FunctionSupportInfo"]: + """Return supported SQL functions with detailed support information. + + This method combines: + 1. Core functions from rhosocial.activerecord.backend.expression.functions + 2. PostgreSQL-specific functions from rhosocial.activerecord.backend.impl.postgres.functions + + Each function is mapped to a FunctionSupportInfo indicating: + - Whether the function is supported + - If not, the reason why (PG version, extension status, etc.) + + Returns: + Dict mapping function names to FunctionSupportInfo. + """ + from .function_versions import FunctionSupportInfo + from rhosocial.activerecord.backend.expression.functions import ( + __all__ as core_functions, + ) + from rhosocial.activerecord.backend.impl.postgres import functions as postgres_functions + + result: Dict[str, FunctionSupportInfo] = {} + for func_name in core_functions: + result[func_name] = self._check_function_support(func_name) + + postgres_funcs = getattr(postgres_functions, "__all__", []) + for func_name in postgres_funcs: + if func_name not in result: + result[func_name] = self._check_function_support(func_name) + + return result + + def _check_function_support(self, func_name: str) -> "FunctionSupportInfo": + """Check function support status and return detailed information. + + Args: + func_name: Name of the function to check + + Returns: + FunctionSupportInfo with support status and reason if unsupported + """ + from .function_versions import FunctionSupportInfo + + requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) + if requirement is None: + return FunctionSupportInfo(supported=True) + + # Check PostgreSQL server version + if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: + return FunctionSupportInfo(supported=False, reason="pg_version_too_low") + if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: + return FunctionSupportInfo(supported=False, reason="pg_version_too_high") + + # Check extension requirements + if requirement.extension is not None: + if not hasattr(self, "_extensions"): + return FunctionSupportInfo(supported=False, reason="extension_not_probed") + + if requirement.ext_feature is not None: + if not self.check_extension_feature(requirement.extension, requirement.ext_feature): + if not self.is_extension_installed(requirement.extension): + return FunctionSupportInfo(supported=False, reason="extension_not_installed") + return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") + else: + if not self.is_extension_installed(requirement.extension): + return FunctionSupportInfo(supported=False, reason="extension_not_installed") + if requirement.min_ext_version is not None: + installed = self.get_extension_version(requirement.extension) + if installed is None or self._compare_versions(installed, requirement.min_ext_version) < 0: + return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") + + return FunctionSupportInfo(supported=True) + + def _is_postgres_function_supported(self, func_name: str) -> bool: + """Check if a PostgreSQL function is supported based on version and extensions. + + Checks: + 1. PostgreSQL server version (for built-in and version-gated functions) + 2. Extension installation and version (for extension-provided functions) + + For extension functions, requires _extensions to have been populated + via introspect_and_adapt(). If _extensions is not available, + extension functions return False (cannot confirm availability). + + Args: + func_name: Name of the PostgreSQL function + + Returns: + True if supported, False otherwise + """ + requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) + if requirement is None: + return True # Unregistered functions default to supported + + # Step 1: Check PostgreSQL server version + if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: + return False + if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: + return False + + # Step 2: Check extension requirements + if requirement.extension is not None: + return self._check_extension_requirement(requirement) + + return True + + def _check_extension_requirement( + self, requirement: "FunctionVersionRequirement" + ) -> bool: + """Check extension requirements based on introspect_and_adapt() results. + + Requires _extensions to have been populated via introspect_and_adapt(). + If _extensions is not available, returns False (cannot confirm + extension availability without probing the database). + """ + ext_name = requirement.extension + + # _extensions is populated by introspect_and_adapt() + # If the attribute doesn't exist, introspect_and_adapt() hasn't been called + if not hasattr(self, "_extensions"): + return False + + # Use feature-level check if ext_feature is specified + if requirement.ext_feature is not None: + return self.check_extension_feature(ext_name, requirement.ext_feature) + + # Check extension installed + if not self.is_extension_installed(ext_name): + return False + + # Check extension version + if requirement.min_ext_version is not None: + installed = self.get_extension_version(ext_name) + if installed is None: + return False + return self._compare_versions(installed, requirement.min_ext_version) >= 0 + + return True + + def supports_transaction_mode(self) -> bool: + """PostgreSQL supports READ ONLY and READ WRITE transaction modes.""" + return True + + def supports_isolation_level_in_begin(self) -> bool: + """PostgreSQL supports isolation level specification in BEGIN statement.""" + return True + + def supports_read_only_transaction(self) -> bool: + """PostgreSQL supports READ ONLY transactions.""" + return True + + def supports_deferrable_transaction(self) -> bool: + """PostgreSQL supports DEFERRABLE transactions (for SERIALIZABLE isolation).""" + return True + + def supports_savepoint(self) -> bool: + """PostgreSQL supports savepoints.""" + return True + + def format_begin_transaction( + self, expr: "BeginTransactionExpression" + ) -> Tuple[str, tuple]: + """Format BEGIN TRANSACTION statement for PostgreSQL. + + PostgreSQL syntax: + BEGIN [ ISOLATION LEVEL { READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE } ] + [ { READ WRITE | READ ONLY } ] + [ { NOT DEFERRABLE | DEFERRABLE } ] + + DEFERRABLE is only meaningful for SERIALIZABLE isolation level. + """ + params = expr.get_params() + parts = ["BEGIN"] + + isolation = params.get("isolation_level") + if isolation: + level_str = self.get_isolation_level_name(isolation) + parts.append(f"ISOLATION LEVEL {level_str}") + + mode = params.get("mode") + if mode: + mode_name = mode.name if hasattr(mode, "name") else str(mode) + if mode_name == "READ_ONLY": + parts.append("READ ONLY") + elif mode_name == "READ_WRITE": + parts.append("READ WRITE") + + deferrable = params.get("deferrable") + if deferrable is not None and isolation: + isolation_name = isolation.name if hasattr(isolation, "name") else str(isolation) + if isolation_name == "SERIALIZABLE": + parts.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") + + return " ".join(parts), () + + def format_commit_transaction( + self, expr: "CommitTransactionExpression" + ) -> Tuple[str, tuple]: + """Format COMMIT TRANSACTION statement for PostgreSQL.""" + return "COMMIT", () + + def format_rollback_transaction( + self, expr: "RollbackTransactionExpression" + ) -> Tuple[str, tuple]: + """Format ROLLBACK TRANSACTION statement for PostgreSQL. + + Supports ROLLBACK [ TO SAVEPOINT savepoint_name ]. + """ + params = expr.get_params() + savepoint = params.get("savepoint") + if savepoint: + return f"ROLLBACK TO SAVEPOINT {self.format_identifier(savepoint)}", () + return "ROLLBACK", () + + def format_savepoint( + self, expr: "SavepointExpression" + ) -> Tuple[str, tuple]: + """Format SAVEPOINT statement for PostgreSQL.""" + params = expr.get_params() + name = params.get("name", "") + return f"SAVEPOINT {self.format_identifier(name)}", () + + def format_release_savepoint( + self, expr: "ReleaseSavepointExpression" + ) -> Tuple[str, tuple]: + """Format RELEASE SAVEPOINT statement for PostgreSQL.""" + params = expr.get_params() + name = params.get("name", "") + return f"RELEASE SAVEPOINT {self.format_identifier(name)}", () + + def format_set_transaction( + self, expr: "SetTransactionExpression" + ) -> Tuple[str, tuple]: + """Format SET TRANSACTION statement for PostgreSQL. + + PostgreSQL supports setting transaction characteristics for the current + transaction or for subsequent transactions. + + Syntax: + SET TRANSACTION { ISOLATION LEVEL { ... } | { READ WRITE | READ ONLY } | [ NOT ] DEFERRABLE } [, ...] + SET SESSION CHARACTERISTICS AS TRANSACTION { ... } + """ + params = expr.get_params() + parts = [] + + if params.get("session"): + parts.append("SET SESSION CHARACTERISTICS AS TRANSACTION") + else: + parts.append("SET TRANSACTION") + + options = [] + + isolation = params.get("isolation_level") + if isolation: + level_str = self.get_isolation_level_name(isolation) + options.append(f"ISOLATION LEVEL {level_str}") + + mode = params.get("mode") + if mode: + mode_name = mode.name if hasattr(mode, "name") else str(mode) + if mode_name == "READ_ONLY": + options.append("READ ONLY") + elif mode_name == "READ_WRITE": + options.append("READ WRITE") + + deferrable = params.get("deferrable") + if deferrable is not None: + options.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") + + if options: + parts.append(" ".join(options)) + + return " ".join(parts), () + + # endregion From e338aa9176c1939b98ac1f4fa7ed123160587ede Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 23 May 2026 15:32:08 +0800 Subject: [PATCH 08/20] ci: upgrade actions/checkout from v4 to v6 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c2a420f..b47eb2e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,7 +65,7 @@ jobs: steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 From d871e853146ce4eca4a5ef82e57587d433595ea8 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 23 May 2026 15:54:30 +0800 Subject: [PATCH 09/20] chore: add .gitattributes to enforce LF line endings --- .gitattributes | 11 + .github/workflows/changelog-check.yml | 2 +- .github/workflows/publish.yml | 8 +- .github/workflows/test.yml | 2 +- .../backend/impl/postgres/dialect.py | 3442 ++++++++--------- .../backend/test_postgres_dialect_security.py | 978 ++--- 6 files changed, 2227 insertions(+), 2216 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..bb5f4901 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf +*.py text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +*.cfg text eol=lf +*.ini text eol=lf +*.sh text eol=lf +*.bat text eol=crlf +*.ps1 text eol=lf diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 8913cc44..b0b920fc 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -24,7 +24,7 @@ jobs: check-fragment: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c2d370ab..2e723bf0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,10 +43,10 @@ jobs: steps: - name: Check out the repository - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.13" @@ -78,10 +78,10 @@ jobs: steps: - name: Check out the repository - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.13" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b47eb2e3..dac3048c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,7 +68,7 @@ jobs: uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 2db9e7a5..2e5a24f5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -1,1721 +1,1721 @@ -# src/rhosocial/activerecord/backend/impl/postgres/dialect.py -""" -PostgreSQL backend SQL dialect implementation. - -This dialect implements protocols for features that PostgreSQL actually supports, -based on the PostgreSQL version provided at initialization. -""" - -from typing import Any, Dict, Tuple, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from .function_versions import FunctionSupportInfo, FunctionVersionRequirement - -from rhosocial.activerecord.backend.dialect.base import SQLDialectBase -from rhosocial.activerecord.backend.dialect.mixins import ( - CTEMixin, - FilterClauseMixin, - WindowFunctionMixin, - JSONMixin, - ReturningMixin, - AdvancedGroupingMixin, - ArrayMixin, - ExplainMixin, - GraphMixin, - LockingMixin, - MergeMixin, - OrderedSetAggregationMixin, - QualifyClauseMixin, - TemporalTableMixin, - UpsertMixin, - LateralJoinMixin, - JoinMixin, - ViewMixin, - SchemaMixin, - IndexMixin, - SequenceMixin, - TableMixin, - SetOperationMixin, - TruncateMixin, - ILIKEMixin, - ConstraintMixin, -) -from rhosocial.activerecord.backend.dialect.protocols import ( - CTESupport, - FilterClauseSupport, - WindowFunctionSupport, - JSONSupport, - ReturningSupport, - AdvancedGroupingSupport, - ArraySupport, - ExplainSupport, - GraphSupport, - MergeSupport, - OrderedSetAggregationSupport, - QualifyClauseSupport, - TemporalTableSupport, - UpsertSupport, - LateralJoinSupport, - WildcardSupport, - JoinSupport, - ViewSupport, - SchemaSupport, - SequenceSupport, - SetOperationSupport, - TruncateSupport, - ILIKESupport, - IntrospectionSupport, - TransactionControlSupport, - SQLFunctionSupport, -) -from .mixins import ( - PostgresExtensionMixin, - PostgresMaterializedViewMixin, - PostgresTableMixin, - PostgresPgvectorMixin, - PostgresPostGISMixin, - PostgresPostgisRasterMixin, - PostgresPgroutingMixin, - PostgresPgTrgmMixin, - PostgresHstoreMixin, - # Native feature mixins - PostgresPartitionMixin, - PostgresIndexMixin, - PostgresVacuumMixin, - PostgresQueryOptimizationMixin, - PostgresDataTypeMixin, - PostgresSQLSyntaxMixin, - PostgresLogicalReplicationMixin, - PostgresParallelQueryMixin, - # Extension feature mixins - PostgresLtreeMixin, - PostgresIntarrayMixin, - PostgresEarthdistanceMixin, - PostgresTablefuncMixin, - PostgresPgStatStatementsMixin, - PostgresCitextMixin, - PostgresPgcryptoMixin, - PostgresFuzzystrmatchMixin, - PostgresCubeMixin, - PostgresUuidOssMixin, - PostgresBloomMixin, - PostgresBtreeGinMixin, - PostgresBtreeGistMixin, - PostgresPgCronMixin, - PostgresPgPartmanMixin, - PostgresPgSurgeryMixin, - PostgresPgWalinspectMixin, - PostgresPgLogicalMixin, - PostgresPgauditMixin, - PostgresPgRepackMixin, - PostgresHypoPgMixin, - PostgresOrafceMixin, - PostgresAddressStandardizerMixin, - # DDL feature mixins - PostgresTriggerMixin, - PostgresCommentMixin, - PostgresTypeMixin, - PostgresConstraintMixin, - # Type mixins - EnumTypeMixin, - TypesDataTypeMixin, - MultirangeMixin, - PostgresFullTextSearchMixin, - PostgresRangeTypeMixin, - PostgresJSONBEnhancedMixin, - PostgresArrayEnhancedMixin, - # DDL/DML operation mixins (new) - PostgresExtendedStatisticsMixin, - PostgresStoredProcedureMixin, - PostgresAdvisoryLockMixin, - PostgresLockingMixin, - # Introspection capability mixin - PostgresIntrospectionCapabilityMixin, -) - -# PostgreSQL-specific imports -from .protocols import ( - PostgresExtensionSupport, - PostgresMaterializedViewSupport, - PostgresTableSupport, - PostgresPgvectorSupport, - PostgresPostGISSupport, - PostgresPostgisRasterSupport, - PostgresPgroutingSupport, - PostgresPgTrgmSupport, - PostgresHstoreSupport, - # Native feature protocols - PostgresPartitionSupport, - PostgresIndexSupport, - PostgresVacuumSupport, - PostgresQueryOptimizationSupport, - PostgresDataTypeSupport, - PostgresSQLSyntaxSupport, - PostgresLogicalReplicationSupport, - # Extension feature protocols - PostgresLtreeSupport, - PostgresIntarraySupport, - PostgresEarthdistanceSupport, - PostgresTablefuncSupport, - PostgresPgStatStatementsSupport, - PostgresCitextSupport, - PostgresPgcryptoSupport, - PostgresFuzzystrmatchSupport, - PostgresCubeSupport, - PostgresUuidOssSupport, - PostgresBloomSupport, - PostgresBtreeGinSupport, - PostgresBtreeGistSupport, - PostgresPgCronSupport, - PostgresPgPartmanSupport, - PostgresPgSurgerySupport, - PostgresPgWalinspectSupport, - PostgresPgLogicalSupport, - PostgresPgauditSupport, - PostgresPgRepackSupport, - PostgresHypoPgSupport, - PostgresOrafceSupport, - PostgresAddressStandardizerSupport, - # DDL feature protocols - PostgresTriggerSupport, - PostgresCommentSupport, - PostgresTypeSupport, - PostgresConstraintSupport, - # Type feature protocols - PostgresMultirangeSupport, - PostgresEnumTypeSupport, - PostgresFullTextSearchSupport, - PostgresRangeTypeSupport, - PostgresJSONBEnhancedSupport, - PostgresArrayEnhancedSupport, - # New feature protocols - PostgresParallelQuerySupport, - PostgresStoredProcedureSupport, - PostgresExtendedStatisticsSupport, - PostgresAdvisoryLockSupport, - PostgresLockingSupport, -) - -if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, - CreateViewExpression, - DropViewExpression, - TruncateExpression, - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, - ExplainExpression, - AddTableConstraint, - TableConstraint, - ) - from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - CommitTransactionExpression, - RollbackTransactionExpression, - SavepointExpression, - ReleaseSavepointExpression, - SetTransactionExpression, - ) - - -class PostgresDialect( - SQLDialectBase, - SetOperationMixin, - TruncateMixin, - ILIKEMixin, - CTEMixin, - FilterClauseMixin, - WindowFunctionMixin, - JSONMixin, - ReturningMixin, - AdvancedGroupingMixin, - ArrayMixin, - ExplainMixin, - GraphMixin, - PostgresLockingMixin, - LockingMixin, - MergeMixin, - OrderedSetAggregationMixin, - QualifyClauseMixin, - TemporalTableMixin, - UpsertMixin, - LateralJoinMixin, - JoinMixin, - ViewMixin, - SchemaMixin, - PostgresIndexMixin, - IndexMixin, - SequenceMixin, - TableMixin, - ConstraintMixin, - PostgresIntrospectionCapabilityMixin, - # PostgreSQL-specific mixins - PostgresExtensionMixin, - PostgresMaterializedViewMixin, - PostgresTableMixin, - PostgresPgvectorMixin, - PostgresPostGISMixin, - PostgresPostgisRasterMixin, - PostgresPgroutingMixin, - PostgresPgTrgmMixin, - PostgresHstoreMixin, - # Native feature mixins - PostgresPartitionMixin, - PostgresVacuumMixin, - PostgresQueryOptimizationMixin, - PostgresDataTypeMixin, - PostgresSQLSyntaxMixin, - PostgresLogicalReplicationMixin, - PostgresParallelQueryMixin, - # Extension feature mixins - PostgresLtreeMixin, - PostgresIntarrayMixin, - PostgresEarthdistanceMixin, - PostgresTablefuncMixin, - PostgresPgStatStatementsMixin, - PostgresCitextMixin, - PostgresPgcryptoMixin, - PostgresFuzzystrmatchMixin, - PostgresCubeMixin, - PostgresUuidOssMixin, - PostgresBloomMixin, - PostgresBtreeGinMixin, - PostgresBtreeGistMixin, - PostgresPgCronMixin, - PostgresPgPartmanMixin, - PostgresPgSurgeryMixin, - PostgresPgWalinspectMixin, - PostgresPgLogicalMixin, - PostgresPgauditMixin, - PostgresPgRepackMixin, - PostgresHypoPgMixin, - PostgresOrafceMixin, - PostgresAddressStandardizerMixin, - # DDL feature mixins - PostgresTriggerMixin, - PostgresCommentMixin, - PostgresTypeMixin, - PostgresConstraintMixin, - # Type mixins - EnumTypeMixin, - TypesDataTypeMixin, - MultirangeMixin, - PostgresFullTextSearchMixin, - PostgresRangeTypeMixin, - PostgresJSONBEnhancedMixin, - PostgresArrayEnhancedMixin, - # DDL/DML operation mixins (new) - PostgresExtendedStatisticsMixin, - PostgresStoredProcedureMixin, - PostgresAdvisoryLockMixin, - # Protocol supports - SetOperationSupport, - TruncateSupport, - ILIKESupport, - CTESupport, - FilterClauseSupport, - WindowFunctionSupport, - JSONSupport, - ReturningSupport, - AdvancedGroupingSupport, - ArraySupport, - ExplainSupport, - GraphSupport, - MergeSupport, - OrderedSetAggregationSupport, - QualifyClauseSupport, - TemporalTableSupport, - UpsertSupport, - LateralJoinSupport, - WildcardSupport, - JoinSupport, - ViewSupport, - SchemaSupport, - SequenceSupport, - # Introspection protocol - IntrospectionSupport, - # Transaction control protocol - TransactionControlSupport, - # PostgreSQL-specific protocols - PostgresExtensionSupport, - PostgresMaterializedViewSupport, - PostgresTableSupport, - PostgresPgvectorSupport, - PostgresPostGISSupport, - PostgresPostgisRasterSupport, - PostgresPgroutingSupport, - PostgresPgTrgmSupport, - PostgresHstoreSupport, - # Native feature protocols - PostgresPartitionSupport, - PostgresIndexSupport, - PostgresVacuumSupport, - PostgresQueryOptimizationSupport, - PostgresDataTypeSupport, - PostgresSQLSyntaxSupport, - PostgresLogicalReplicationSupport, - # Extension feature protocols - PostgresLtreeSupport, - PostgresIntarraySupport, - PostgresEarthdistanceSupport, - PostgresTablefuncSupport, - PostgresPgStatStatementsSupport, - PostgresCitextSupport, - PostgresPgcryptoSupport, - PostgresFuzzystrmatchSupport, - PostgresCubeSupport, - PostgresUuidOssSupport, - PostgresBloomSupport, - PostgresBtreeGinSupport, - PostgresBtreeGistSupport, - PostgresPgCronSupport, - PostgresPgPartmanSupport, - PostgresPgSurgerySupport, - PostgresPgWalinspectSupport, - PostgresPgLogicalSupport, - PostgresPgauditSupport, - PostgresPgRepackSupport, - PostgresHypoPgSupport, - PostgresOrafceSupport, - PostgresAddressStandardizerSupport, - # DDL feature protocols - PostgresTriggerSupport, - PostgresCommentSupport, - PostgresTypeSupport, - PostgresConstraintSupport, - # Type feature protocols - PostgresMultirangeSupport, - PostgresEnumTypeSupport, - PostgresFullTextSearchSupport, - PostgresRangeTypeSupport, - PostgresJSONBEnhancedSupport, - PostgresArrayEnhancedSupport, - # New feature protocols - PostgresParallelQuerySupport, - PostgresStoredProcedureSupport, - PostgresExtendedStatisticsSupport, - PostgresAdvisoryLockSupport, - PostgresLockingSupport, - # Function support protocol - SQLFunctionSupport, -): - """ - PostgreSQL dialect implementation that adapts to the PostgreSQL version. - - PostgreSQL features and support based on version: - - Basic and recursive CTEs (since 8.4) - - Window functions (since 8.4) - - RETURNING clause (since 8.2) - - JSON operations (since 9.2, JSONB since 9.4) - - FILTER clause (since 9.4) - - UPSERT (ON CONFLICT) (since 9.5) - - MERGE statement (since 15) - - Advanced grouping (CUBE, ROLLUP, GROUPING SETS) (since 9.5) - - Array types (since early versions) - - LATERAL joins (since 9.3) - - Parallel query execution (since 9.6) - - Stored procedures with CALL (since 11) - - Extended statistics (since 10) - - PostgreSQL-specific features: - - Table inheritance (INHERITS) - - CONCURRENTLY refresh for materialized views (since 9.4) - - Extension detection (PostGIS, pgvector, pg_trgm, hstore, etc.) - - Note: Extension features require the extension to be installed in the database. - Use introspect_and_adapt() to detect installed extensions automatically. - """ - - def __init__(self, version: Optional[Tuple[int, int, int]] = None): - """ - Initialize PostgreSQL dialect with specific version. - - Args: - version: PostgreSQL version tuple (major, minor, patch). - If None, the dialect must be adapted via - backend.introspect_and_adapt() before version-dependent - features can be used. - """ - super().__init__() - if version is not None: - self.version = version - - @staticmethod - def _validate_data_type(data_type: str) -> bool: - """Validate data type for safe embedding in SQL. - - PostgreSQL supports array types like TEXT[], INTEGER[]. - - Note: rhosocial-activerecord base dialect will include this change in - a future release. This override can be removed after upgrading to that - version (expected: include brackets [] in the allowlist pattern). - """ - import re - - return bool(re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", data_type)) - - def get_parameter_placeholder(self, position: int = 0) -> str: - """psycopg uses '%s' for placeholders.""" - return "%s" - - def get_server_version(self) -> Tuple[int, int, int]: - """Return the PostgreSQL version this dialect is configured for.""" - return self.version - - # region Protocol Support Checks based on version - def supports_basic_cte(self) -> bool: - """Basic CTEs are supported since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_recursive_cte(self) -> bool: - """Recursive CTEs are supported since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_materialized_cte(self) -> bool: - """MATERIALIZED hint is supported since PostgreSQL 12.""" - return self.version >= (12, 0, 0) - - def supports_returning_insert(self) -> bool: - """RETURNING clause is supported for INSERT since PostgreSQL 8.2.""" - return self.version >= (8, 2, 0) - - def supports_returning_update(self) -> bool: - """RETURNING clause is supported for UPDATE since PostgreSQL 8.2.""" - return self.version >= (8, 2, 0) - - def supports_returning_delete(self) -> bool: - """RETURNING clause is supported for DELETE since PostgreSQL 8.2.""" - return self.version >= (8, 2, 0) - - def supports_window_functions(self) -> bool: - """Window functions are supported since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_window_frame_clause(self) -> bool: - """Whether window frame clauses (ROWS/RANGE) are supported, since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_filter_clause(self) -> bool: - """FILTER clause for aggregate functions is supported since PostgreSQL 9.4.""" - return self.version >= (9, 4, 0) - - def get_json_access_operator(self) -> str: - """PostgreSQL uses '->' for JSON access.""" - return "->" - - def supports_rollup(self) -> bool: - """ROLLUP is supported since PostgreSQL 9.5.""" - return self.version >= (9, 5, 0) - - def supports_cube(self) -> bool: - """CUBE is supported since PostgreSQL 9.5.""" - return self.version >= (9, 5, 0) - - def supports_grouping_sets(self) -> bool: - """GROUPING SETS is supported since PostgreSQL 9.5.""" - return self.version >= (9, 5, 0) - - def supports_explain_analyze(self) -> bool: - """Whether EXPLAIN ANALYZE is supported.""" - return True # Supported in all modern versions - - def supports_explain_format(self, format_type: str) -> bool: - """Check if specific EXPLAIN format is supported.""" - format_type_upper = format_type.upper() - # PostgreSQL supports TEXT, XML, JSON, and YAML formats - supported_formats = ["TEXT", "XML", "JSON", "YAML"] - return format_type_upper in supported_formats - - def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: - """Build the PostgreSQL EXPLAIN SQL string and return (sql, params). - - PostgreSQL syntax: ``EXPLAIN [ ( option [, ...] ) ] statement`` - - Supported options assembled here: - - ``ANALYZE`` - - ``FORMAT { TEXT | XML | JSON | YAML }`` - - ``VERBOSE`` (passed through ``ExplainOptions.verbose`` if present) - """ - from rhosocial.activerecord.backend.expression.statements import ExplainType - - statement_sql, statement_params = explain_expr.statement.to_sql() - options = explain_expr.options - if options is None: - return f"EXPLAIN {statement_sql}", statement_params - - opts: list = [] - - if options.analyze: - opts.append("ANALYZE") - - if options.format is not None: - fmt_name = options.format.name if hasattr(options.format, "name") else str(options.format) - opts.append(f"FORMAT {fmt_name.upper()}") - elif options.type is not None and options.type == ExplainType.QUERY_PLAN: - # PostgreSQL has no QUERY PLAN keyword; plain EXPLAIN is equivalent - pass - - if opts: - return "EXPLAIN (" + ", ".join(opts) + ") " + statement_sql, statement_params - return f"EXPLAIN {statement_sql}", statement_params - - def supports_graph_match(self) -> bool: - """Whether graph query MATCH clause is supported.""" - # PostgreSQL doesn't have native MATCH clause like some other systems - # Though graph querying can be done with extensions like Apache AGE - return False - - def supports_for_update(self) -> bool: - """Whether FOR UPDATE clause is supported in SELECT statements. - - PostgreSQL supports FOR UPDATE since early versions. The clause locks - selected rows preventing other transactions from modifying them. - PostgreSQL also supports FOR UPDATE OF, FOR UPDATE NOWAIT, and - FOR UPDATE SKIP LOCKED (since 9.5). - """ - return True - - def supports_lock_strength(self, strength) -> bool: - """ - Check if a specific lock strength is supported. - - PostgreSQL lock strength support by version: - - FOR UPDATE: All versions - - FOR NO KEY UPDATE: PostgreSQL 9.0+ - - FOR SHARE: PostgreSQL 9.0+ - - FOR KEY SHARE: PostgreSQL 9.3+ - - Args: - strength: The LockStrength enum value to check - - Returns: - True if the lock strength is supported, False otherwise - """ - from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength - - if strength == LockStrength.UPDATE: - return True # All PostgreSQL versions support FOR UPDATE - elif strength == LockStrength.NO_KEY_UPDATE: - return self.version >= (9, 0, 0) - elif strength == LockStrength.SHARE: - return self.version >= (9, 0, 0) - elif strength == LockStrength.KEY_SHARE: - return self.version >= (9, 3, 0) - return False - - def supports_merge_statement(self) -> bool: - """Whether MERGE statement is supported.""" - return self.version >= (15, 0, 0) # MERGE added in PostgreSQL 15 - - def supports_temporal_tables(self) -> bool: - """Whether temporal tables are supported.""" - # PostgreSQL doesn't have built-in temporal tables - return False - - def supports_qualify_clause(self) -> bool: - """Whether QUALIFY clause is supported.""" - # PostgreSQL doesn't have QUALIFY clause (though can be simulated with subqueries) - return False - - def supports_upsert(self) -> bool: - """Whether UPSERT (ON CONFLICT) is supported.""" - return self.version >= (9, 5, 0) # ON CONFLICT added in 9.5 - - def get_upsert_syntax_type(self) -> str: - """ - Get UPSERT syntax type. - - Returns: - 'ON CONFLICT' (PostgreSQL) or 'ON DUPLICATE KEY' (MySQL) - """ - return "ON CONFLICT" - - def supports_lateral_join(self) -> bool: - """Whether LATERAL joins are supported.""" - return self.version >= (9, 3, 0) # LATERAL joins added in 9.3 - - def supports_ordered_set_aggregation(self) -> bool: - """Whether ordered-set aggregate functions are supported.""" - return self.version >= (9, 4, 0) # Supported since 9.4 - - def supports_multirange(self) -> bool: - """Whether multirange types are supported (PostgreSQL 14+).""" - return self.version >= (14, 0, 0) - - def supports_multirange_constructor(self) -> bool: - """Whether multirange constructor functions are supported (PostgreSQL 14+).""" - return self.version >= (14, 0, 0) - - def supports_inner_join(self) -> bool: - """INNER JOIN is supported.""" - return True - - def supports_left_join(self) -> bool: - """LEFT JOIN is supported.""" - return True - - def supports_right_join(self) -> bool: - """RIGHT JOIN is supported.""" - return True - - def supports_full_join(self) -> bool: - """FULL JOIN is supported.""" - return True - - def supports_cross_join(self) -> bool: - """CROSS JOIN is supported.""" - return True - - def supports_natural_join(self) -> bool: - """NATURAL JOIN is supported.""" - return True - - def supports_wildcard(self) -> bool: - """Wildcard (*) is supported.""" - return True - - # region ILIKE Support - def supports_ilike(self) -> bool: - """ILIKE is supported.""" - return True - - def format_ilike_expression(self, column: Any, pattern: str, negate: bool = False) -> Tuple[str, Tuple]: - """Format ILIKE expression for PostgreSQL.""" - if isinstance(column, str): - col_sql = self.format_identifier(column) - else: - col_sql, col_params = column.to_sql() if hasattr(column, "to_sql") else (str(column), ()) - - if negate: - sql = f"{col_sql} NOT ILIKE %s" - else: - sql = f"{col_sql} ILIKE %s" - - return sql, (pattern,) - - # endregion - - # region Set Operation Support - def supports_union(self) -> bool: - """UNION is supported.""" - return True - - def supports_union_all(self) -> bool: - """UNION ALL is supported.""" - return True - - def supports_intersect(self) -> bool: - """INTERSECT is supported.""" - return True - - def supports_except(self) -> bool: - """EXCEPT is supported.""" - return True - - def supports_set_operation_order_by(self) -> bool: - """ORDER BY is supported for set operations.""" - return True - - def supports_set_operation_limit_offset(self) -> bool: - """LIMIT/OFFSET is supported for set operations.""" - return True - - def supports_set_operation_for_update(self) -> bool: - """FOR UPDATE is supported for set operations.""" - return True - - # endregion - - # region Truncate Support - def supports_truncate(self) -> bool: - """TRUNCATE is supported.""" - return True - - def supports_truncate_table_keyword(self) -> bool: - """TABLE keyword is supported in TRUNCATE.""" - return True - - def supports_truncate_restart_identity(self) -> bool: - """RESTART IDENTITY is supported since PostgreSQL 8.4.""" - return self.version >= (8, 4, 0) - - def supports_truncate_cascade(self) -> bool: - """CASCADE is supported in TRUNCATE.""" - return True - - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: - """Format TRUNCATE statement for PostgreSQL.""" - parts = ["TRUNCATE TABLE"] - parts.append(self.format_identifier(expr.table_name)) - - if expr.restart_identity and self.supports_truncate_restart_identity(): - parts.append("RESTART IDENTITY") - - if expr.cascade: - parts.append("CASCADE") - - return " ".join(parts), () - - # endregion - - # region Custom Implementations for PostgreSQL-specific behavior - def format_identifier(self, identifier: str) -> str: - """ - Format identifier using PostgreSQL's double quote quoting mechanism. - - Args: - identifier: Raw identifier string - - Returns: - Quoted identifier with escaped internal quotes - """ - # Escape any internal double quotes by doubling them - escaped = identifier.replace('"', '""') - return f'"{escaped}"' - - def format_column(self, name: str, table: Optional[str] = None, - alias: Optional[str] = None, - schema_name: Optional[str] = None) -> Tuple[str, Tuple]: - """Format column reference for PostgreSQL. - - PostgreSQL rules for column references: - - When the table has an alias (used in FROM/JOIN), column references - must use the alias, not the schema-qualified name. For example, - when FROM clause has ``public.users AS "users"``, the column - reference must be ``"users"."id"``, not ``"public"."users"."id"``. - - When no alias is present but schema is specified, use the - full three-segment form: ``"schema"."table"."column"``. - - Otherwise use the standard two-segment or single-segment form. - """ - if table: - # In PostgreSQL, when a table has an alias, column references - # must use the alias — schema_name is irrelevant in this context. - if schema_name and not alias: - col_sql = f"{self.format_identifier(schema_name)}.{self.format_identifier(table)}.{self.format_identifier(name)}" - else: - col_sql = f"{self.format_identifier(table)}.{self.format_identifier(name)}" - else: - col_sql = self.format_identifier(name) - - if alias: - col_sql = f"{col_sql} AS {self.format_identifier(alias)}" - - return col_sql, () - - def format_on_conflict_clause(self, expr) -> Tuple[str, tuple]: - """Format ON CONFLICT clause for PostgreSQL. - - Overrides the base implementation to handle EXCLUDED pseudo-table - references without quoting, as EXCLUDED is a special PostgreSQL - keyword in ON CONFLICT context and must not be double-quoted. - """ - from rhosocial.activerecord.backend.expression import bases - from rhosocial.activerecord.backend.expression.core import Column - - all_params = [] - parts = ["ON CONFLICT"] - - # Add conflict target if specified - if expr.conflict_target: - target_parts = [] - for target in expr.conflict_target: - if isinstance(target, str): - target_parts.append(self.format_identifier(target)) - elif hasattr(target, 'to_sql'): - target_sql, target_params = target.to_sql() - target_parts.append(target_sql) - all_params.extend(target_params) - else: - target_parts.append(self.format_identifier(str(target))) - if target_parts: - parts.append(f"({', '.join(target_parts)})") - - # Add DO NOTHING or DO UPDATE - if expr.do_nothing: - parts.append("DO NOTHING") - elif expr.update_assignments: - update_parts = [] - for col, expr_val in expr.update_assignments.items(): - if isinstance(expr_val, Column) and getattr(expr_val, 'table', None) == 'EXCLUDED': - # EXCLUDED is a special pseudo-table in PostgreSQL ON CONFLICT. - # It must NOT be double-quoted, only the column name should be quoted. - val_sql = f'EXCLUDED.{self.format_identifier(expr_val.name)}' - update_parts.append(f"{self.format_identifier(col)} = {val_sql}") - elif isinstance(expr_val, bases.BaseExpression): - val_sql, val_params = expr_val.to_sql() - update_parts.append(f"{self.format_identifier(col)} = {val_sql}") - all_params.extend(val_params) - else: - update_parts.append(f"{self.format_identifier(col)} = {self.get_parameter_placeholder()}") - all_params.append(expr_val) - - parts.append(f"DO UPDATE SET {', '.join(update_parts)}") - - # Add WHERE clause if specified - if expr.update_where: - where_sql, where_params = expr.update_where.to_sql() - parts.append(f"WHERE {where_sql}") - all_params.extend(where_params) - else: - parts.append("DO NOTHING") - - return " ".join(parts), tuple(all_params) - - # region View Support - def supports_or_replace_view(self) -> bool: - """Whether CREATE OR REPLACE VIEW is supported.""" - return True # PostgreSQL supports OR REPLACE - - def supports_temporary_view(self) -> bool: - """Whether TEMPORARY views are supported.""" - return True # PostgreSQL supports TEMPORARY views - - def supports_materialized_view(self) -> bool: - """Whether materialized views are supported.""" - return True # PostgreSQL supports materialized views since 9.3 - - def supports_refresh_materialized_view(self) -> bool: - """Whether REFRESH MATERIALIZED VIEW is supported.""" - return True # PostgreSQL supports REFRESH MATERIALIZED VIEW - - def supports_materialized_view_concurrent_refresh(self) -> bool: - """Whether concurrent refresh for materialized views is supported.""" - return self.version >= (9, 4, 0) # CONCURRENTLY added in 9.4 - - def supports_materialized_view_tablespace(self) -> bool: - """Whether tablespace specification for materialized views is supported.""" - return True - - def supports_materialized_view_storage_options(self) -> bool: - """Whether storage options for materialized views are supported.""" - return True - - def supports_if_exists_view(self) -> bool: - """Whether DROP VIEW IF EXISTS is supported.""" - return True # PostgreSQL supports IF EXISTS - - def supports_view_check_option(self) -> bool: - """Whether WITH CHECK OPTION is supported.""" - return True # PostgreSQL supports WITH CHECK OPTION - - def supports_cascade_view(self) -> bool: - """Whether DROP VIEW CASCADE is supported.""" - return True # PostgreSQL supports CASCADE - - def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: - """Format CREATE VIEW statement for PostgreSQL.""" - parts = ["CREATE"] - - if expr.temporary: - parts.append("TEMPORARY") - - if expr.replace: - parts.append("OR REPLACE") - - parts.append("VIEW") - parts.append(self.format_identifier(expr.view_name)) - - if expr.column_aliases: - cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) - parts.append(f"({cols})") - - query_sql, query_params = expr.query.to_sql() - parts.append(f"AS {query_sql}") - - if expr.options and expr.options.check_option: - check_option = expr.options.check_option.value - parts.append(f"WITH {check_option} CHECK OPTION") - - return " ".join(parts), query_params - - def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, tuple]: - """Format DROP VIEW statement for PostgreSQL.""" - parts = ["DROP VIEW"] - if expr.if_exists: - parts.append("IF EXISTS") - parts.append(self.format_identifier(expr.view_name)) - if expr.cascade: - parts.append("CASCADE") - return " ".join(parts), () - - def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewExpression") -> Tuple[str, tuple]: - """Format CREATE MATERIALIZED VIEW statement for PostgreSQL.""" - parts = ["CREATE MATERIALIZED VIEW"] - parts.append(self.format_identifier(expr.view_name)) - - if expr.column_aliases: - cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) - parts.append(f"({cols})") - - if expr.tablespace and self.supports_materialized_view_tablespace(): - parts.append(f"TABLESPACE {self.format_identifier(expr.tablespace)}") - - if expr.storage_options and self.supports_materialized_view_storage_options(): - storage_parts = [] - for key, value in expr.storage_options.items(): - storage_parts.append(f"{key.upper()} = {value}") - parts.append(f"WITH ({', '.join(storage_parts)})") - - query_sql, query_params = expr.query.to_sql() - parts.append(f"AS {query_sql}") - - if expr.with_data: - parts.append("WITH DATA") - else: - parts.append("WITH NO DATA") - - return " ".join(parts), query_params - - def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExpression") -> Tuple[str, tuple]: - """Format DROP MATERIALIZED VIEW statement for PostgreSQL.""" - parts = ["DROP MATERIALIZED VIEW"] - if expr.if_exists: - parts.append("IF EXISTS") - parts.append(self.format_identifier(expr.view_name)) - if expr.cascade: - parts.append("CASCADE") - return " ".join(parts), () - - def format_refresh_materialized_view_statement( - self, expr: "RefreshMaterializedViewExpression" - ) -> Tuple[str, tuple]: - """Format REFRESH MATERIALIZED VIEW statement for PostgreSQL.""" - parts = ["REFRESH MATERIALIZED VIEW"] - if expr.concurrent and self.supports_materialized_view_concurrent_refresh(): - parts.append("CONCURRENTLY") - parts.append(self.format_identifier(expr.view_name)) - if expr.with_data is not None: - parts.append("WITH DATA" if expr.with_data else "WITH NO DATA") - return " ".join(parts), () - - # endregion - - # region Schema Support - def supports_create_schema(self) -> bool: - """Whether CREATE SCHEMA is supported.""" - return True - - def supports_drop_schema(self) -> bool: - """Whether DROP SCHEMA is supported.""" - return True - - def supports_schema_if_not_exists(self) -> bool: - """Whether CREATE SCHEMA IF NOT EXISTS is supported.""" - return True # PostgreSQL 9.3+ supports IF NOT EXISTS - - def supports_schema_if_exists(self) -> bool: - """Whether DROP SCHEMA IF EXISTS is supported.""" - return True - - def supports_schema_cascade(self) -> bool: - """Whether DROP SCHEMA CASCADE is supported.""" - return True - - # endregion - - # region Index Support - def supports_create_index(self) -> bool: - """Whether CREATE INDEX is supported.""" - return True - - def supports_drop_index(self) -> bool: - """Whether DROP INDEX is supported.""" - return True - - def supports_unique_index(self) -> bool: - """Whether UNIQUE indexes are supported.""" - return True - - 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 - - def supports_index_if_exists(self) -> bool: - """Whether DROP INDEX IF EXISTS is supported.""" - return True - - # endregion - - # region Sequence Support - def supports_create_sequence(self) -> bool: - """Whether CREATE SEQUENCE is supported.""" - return True - - def supports_drop_sequence(self) -> bool: - """Whether DROP SEQUENCE is supported.""" - return True - - # endregion - - # region Table Support - def supports_if_not_exists_table(self) -> bool: - """Whether CREATE TABLE IF NOT EXISTS is supported.""" - return True - - def supports_if_exists_table(self) -> bool: - """Whether DROP TABLE IF EXISTS is supported.""" - return True - - def supports_temporary_table(self) -> bool: - """Whether TEMPORARY tables are supported.""" - return True - - def supports_table_inheritance(self) -> bool: - """Whether table inheritance is supported.""" - return True # PostgreSQL supports INHERITS - - def supports_table_partitioning(self) -> bool: - """Whether table partitioning is supported.""" - return True # PostgreSQL supports partitioning - - def supports_table_tablespace(self) -> bool: - """Whether tablespace specification is supported.""" - return True - - # Constraint capability overrides - - def supports_constraint_enforced(self) -> bool: - """PostgreSQL does not support ENFORCED/NOT ENFORCED (SQL:2016). - - PostgreSQL uses NOT VALID as a proprietary alternative for - skipping validation of existing data when adding constraints. - """ - return False - - def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: - """ - Format CREATE TABLE statement for PostgreSQL, including LIKE syntax support. - - PostgreSQL LIKE syntax supports INCLUDING/EXCLUDING options to control what - gets copied: DEFAULTS, CONSTRAINTS, INDEXES, IDENTITY, GENERATED, ALL, - COMMENTS, STORAGE, COMPRESSION. - - The behavior is controlled by the `dialect_options` parameter in - CreateTableExpression: - - 1. When `dialect_options` contains 'like_table' key: - - LIKE syntax takes highest priority - - All other parameters (columns, indexes, constraints, etc.) are IGNORED - - Only temporary and if_not_exists flags are considered - - Additional 'like_options' key controls INCLUDING/EXCLUDING behavior - - 2. When `dialect_options` does NOT contain 'like_table': - - Falls back to base class implementation - - Standard CREATE TABLE with column definitions is generated - - The 'like_options' key supports two formats: - - a) Dictionary format (recommended): - { - 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], - 'excluding': ['COMMENTS'] - } - - b) List format (for backwards compatibility): - ['DEFAULTS', 'CONSTRAINTS'] # Defaults to INCLUDING - or - [('INCLUDING', 'DEFAULTS'), ('EXCLUDING', 'INDEXES')] - - Usage Examples: - # Basic LIKE syntax - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[], # Ignored when like_table is present - dialect_options={'like_table': 'users'} - ) - # Generates: CREATE TABLE "users_copy" (LIKE "users") - - # LIKE with INCLUDING options (dictionary format - recommended) - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[...], # Will be ignored - dialect_options={ - 'like_table': 'users', - 'like_options': { - 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], - 'excluding': ['COMMENTS'] - } - } - ) - # Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, - # INCLUDING CONSTRAINTS, INCLUDING INDEXES, EXCLUDING COMMENTS) - - # LIKE with schema-qualified source table - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[], - dialect_options={'like_table': ('public', 'users')} - ) - # Generates: CREATE TABLE "users_copy" (LIKE "public"."users") - - # LIKE with TEMPORARY and IF NOT EXISTS - CreateTableExpression( - dialect=postgres_dialect, - table_name="temp_users", - columns=[], - temporary=True, - if_not_exists=True, - dialect_options={'like_table': 'users'} - ) - # Generates: CREATE TEMPORARY TABLE IF NOT EXISTS "temp_users" (LIKE "users") - - # LIKE with INCLUDING ALL - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': {'including': ['ALL']} - } - ) - # Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING ALL) - - Args: - expr: CreateTableExpression instance - - Returns: - Tuple of (SQL string, parameters tuple) - """ - # Check for LIKE syntax in dialect_options (highest priority) - if "like_table" in expr.dialect_options: - like_table = expr.dialect_options["like_table"] - like_options = expr.dialect_options.get("like_options", []) - - parts = ["CREATE"] - - if expr.temporary: - parts.append("TEMPORARY") - - parts.append("TABLE") - - if expr.if_not_exists: - parts.append("IF NOT EXISTS") - - parts.append(self.format_identifier(expr.table_name)) - - # Build LIKE clause with options - like_parts = [] - - # Handle schema-qualified table name: ('schema', 'table') - if isinstance(like_table, tuple): - schema, table = like_table - like_table_str = f"{self.format_identifier(schema)}.{self.format_identifier(table)}" - else: - like_table_str = self.format_identifier(like_table) - - like_parts.append(f"LIKE {like_table_str}") - - # Add INCLUDING/EXCLUDING options - # Format: dictionary with 'including' and 'excluding' keys - # Example: {'including': ['DEFAULTS', 'CONSTRAINTS'], 'excluding': ['INDEXES']} - if isinstance(like_options, dict): - # Handle dictionary format - including = like_options.get("including", []) - excluding = like_options.get("excluding", []) - - for option in including: - like_parts.append(f"INCLUDING {option.upper()}") - - for option in excluding: - like_parts.append(f"EXCLUDING {option.upper()}") - elif isinstance(like_options, list): - # Handle list format for backwards compatibility - for option in like_options: - if isinstance(option, tuple): - action, feature = option - like_parts.append(f"{action.upper()} {feature.upper()}") - else: - # Default to INCLUDING if just feature name provided - like_parts.append(f"INCLUDING {option.upper()}") - - parts.append(f"({', '.join(like_parts)})") - - return " ".join(parts), () - - # Otherwise, delegate to base implementation - return super().format_create_table_statement(expr) - - # endregion - - # region Type Casting Support (PostgreSQL-specific) - def format_cast_expression( - self, expr_sql: str, target_type: str, expr_params: tuple, alias: Optional[str] = None - ) -> Tuple[str, Tuple]: - """Format type cast expression using PostgreSQL :: syntax. - - PostgreSQL supports both standard CAST(expr AS type) syntax and the - PostgreSQL-specific expr::type syntax. This method uses the more - concise :: syntax which is idiomatic in PostgreSQL. - - Args: - expr_sql: SQL expression string to be cast - target_type: Target PostgreSQL type name (e.g., 'integer', 'varchar(100)') - expr_params: Parameters tuple for the expression - alias: Optional alias for the result - - Returns: - Tuple of (SQL string, parameters) - - Example: - >>> dialect.format_cast_expression('price', 'numeric', ()) - # Returns: ('price::numeric', ()) - >>> dialect.format_cast_expression('amount', 'money', ()) - # Returns: ('amount::money', ()) - >>> dialect.format_cast_expression('value', 'integer', (), 'int_val') - # Returns: ('value::integer AS "int_val"', ()) - - Note: - For chained type conversions, each ::type is appended: - >>> col.cast('money').cast('numeric').cast('float8') - # Generates: col::money::numeric::float8 - """ - sql = f"{expr_sql}::{target_type}" - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" - return sql, expr_params - - # endregion - - # region Operator Formatting (PostgreSQL-specific) - - def format_binary_operator( - self, op: str, left_sql: str, right_sql: str, left_params: tuple, right_params: tuple - ) -> Tuple[str, Tuple]: - """Format binary operator with psycopg placeholder escaping. - - psycopg uses %s as parameter placeholder. When the SQL operator itself - contains % (e.g., pg_trgm similarity operator), it must be escaped as %% - to prevent psycopg from interpreting it as a placeholder prefix. - """ - # Escape % in operators for psycopg compatibility - escaped_op = op.replace('%', '%%') if '%' in op else op - sql = f"{left_sql} {escaped_op} {right_sql}" - return sql, left_params + right_params - - # endregion - - # region Constraint DDL Support (PostgreSQL-specific) - - def format_add_table_constraint_action( - self, action: "AddTableConstraint", - ) -> Tuple[str, tuple]: - """Format ADD CONSTRAINT action with PostgreSQL-specific extensions. - - Extends the base class implementation with: - - EXCLUDE constraint support (PG-specific constraint type) - - NOT VALID suffix (PG-specific: skip validation of existing rows) - """ - from rhosocial.activerecord.backend.expression.statements import ( - TableConstraintType, ConstraintValidation, - ) - - # Handle EXCLUDE constraint (PG-specific, not in base class) - if action.constraint.constraint_type == TableConstraintType.EXCLUDE: - parts = [] - exclude_sql, params = self._format_exclude_constraint(action.constraint) - parts.append(exclude_sql) - - # NOT VALID suffix - if action.constraint.dialect_options: - validation = action.constraint.dialect_options.get('validation') - if validation == ConstraintValidation.NOVALIDATE: - parts.append("NOT VALID") - - return f"ADD {' '.join(parts)}", tuple(params) - - # Use base class for standard formatting (includes DEFERRABLE) - sql, params = super().format_add_table_constraint_action(action) - - # PostgreSQL NOT VALID suffix - if action.constraint.dialect_options: - validation = action.constraint.dialect_options.get('validation') - if validation == ConstraintValidation.NOVALIDATE: - sql += " NOT VALID" - - return sql, params - - def _format_exclude_constraint( - self, constraint: "TableConstraint", - ) -> Tuple[str, tuple]: - """Format EXCLUDE constraint (PostgreSQL-specific). - - EXCLUDE constraints use the dialect_options dict to specify: - - 'exclude_elements': List of (expression, operator) tuples - e.g., [('range', '&&')] for EXCLUDE USING gist (range WITH &&) - - 'using': The index access method (default 'gist') - e.g., 'gist', 'btree', 'spgist' - - 'where': Optional predicate for partial exclusion constraints - - Example: - TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name='exclude_range_overlap', - dialect_options={ - 'exclude_elements': [('range', '&&')], - 'using': 'gist', - } - ) - # Generates: EXCLUDE USING gist (range WITH &&) - """ - params: list = [] - - if constraint.name: - parts = ["CONSTRAINT", self.format_identifier(constraint.name)] - else: - parts = [] - - # USING clause - validate index access method. - valid_using = frozenset({"gist", "btree", "spgist", "hash", "gin", "brin"}) - using = constraint.dialect_options.get("using", "gist") if constraint.dialect_options else "gist" - if using not in valid_using: - raise ValueError( - f"Invalid index access method '{using}': must be one of {valid_using}" - ) - parts.append(f"EXCLUDE USING {using}") - - # Elements: (expression, operator) pairs - validate operators. - valid_ops = frozenset({ - "=", "<", "<=", ">", ">=", "<>", - "&&", "@>", "<@", "<<", ">>", "&<", "&>", - "~=", "@@", "?|", "?&", "is", "is not", - }) - exclude_elements = [] - if constraint.dialect_options and "exclude_elements" in constraint.dialect_options: - for expr, op in constraint.dialect_options["exclude_elements"]: - if op not in valid_ops: - raise ValueError( - f"Invalid exclude operator '{op}': must be one of {valid_ops}" - ) - if isinstance(expr, str): - exclude_elements.append(f"{self.format_identifier(expr)} WITH {op}") - else: - expr_sql, expr_params = expr.to_sql() - params.extend(expr_params) - exclude_elements.append(f"{expr_sql} WITH {op}") - - if exclude_elements: - parts.append(f"({', '.join(exclude_elements)})") - - # WHERE clause for partial exclusion constraint - if constraint.dialect_options and 'where' in constraint.dialect_options: - where_expr = constraint.dialect_options['where'] - where_sql, where_params = where_expr.to_sql() - params.extend(where_params) - parts.append(f"WHERE ({where_sql})") - - # DEFERRABLE / NOT DEFERRABLE - if constraint.deferrable is True: - if constraint.initially_deferred is True: - parts.append("DEFERRABLE INITIALLY DEFERRED") - elif constraint.initially_deferred is False: - parts.append("DEFERRABLE INITIALLY IMMEDIATE") - else: - parts.append("DEFERRABLE") - elif constraint.deferrable is False: - parts.append("NOT DEFERRABLE") - - return ' '.join(parts), tuple(params) - - # endregion - - # region Trigger Support (PostgreSQL-specific) - def supports_trigger(self) -> bool: - return True - - def supports_create_trigger(self) -> bool: - return True - - def supports_drop_trigger(self) -> bool: - return True - - # endregion - - # region Transaction Control Support - - # PostgreSQL function version support: function_name -> (min_version, max_version) - # min_version: minimum supported version (inclusive), None = all versions - # Function version requirements are defined in function_versions.py, - # categorized by topic (JSON Path, Range, hstore, pgvector, PostGIS, etc.) - # and assembled into POSTGRES_FUNCTION_VERSIONS. - from .function_versions import POSTGRES_FUNCTION_VERSIONS as _FV - _POSTGRES_FUNCTION_VERSIONS = _FV - - def supports_functions(self) -> Dict[str, "FunctionSupportInfo"]: - """Return supported SQL functions with detailed support information. - - This method combines: - 1. Core functions from rhosocial.activerecord.backend.expression.functions - 2. PostgreSQL-specific functions from rhosocial.activerecord.backend.impl.postgres.functions - - Each function is mapped to a FunctionSupportInfo indicating: - - Whether the function is supported - - If not, the reason why (PG version, extension status, etc.) - - Returns: - Dict mapping function names to FunctionSupportInfo. - """ - from .function_versions import FunctionSupportInfo - from rhosocial.activerecord.backend.expression.functions import ( - __all__ as core_functions, - ) - from rhosocial.activerecord.backend.impl.postgres import functions as postgres_functions - - result: Dict[str, FunctionSupportInfo] = {} - for func_name in core_functions: - result[func_name] = self._check_function_support(func_name) - - postgres_funcs = getattr(postgres_functions, "__all__", []) - for func_name in postgres_funcs: - if func_name not in result: - result[func_name] = self._check_function_support(func_name) - - return result - - def _check_function_support(self, func_name: str) -> "FunctionSupportInfo": - """Check function support status and return detailed information. - - Args: - func_name: Name of the function to check - - Returns: - FunctionSupportInfo with support status and reason if unsupported - """ - from .function_versions import FunctionSupportInfo - - requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) - if requirement is None: - return FunctionSupportInfo(supported=True) - - # Check PostgreSQL server version - if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: - return FunctionSupportInfo(supported=False, reason="pg_version_too_low") - if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: - return FunctionSupportInfo(supported=False, reason="pg_version_too_high") - - # Check extension requirements - if requirement.extension is not None: - if not hasattr(self, "_extensions"): - return FunctionSupportInfo(supported=False, reason="extension_not_probed") - - if requirement.ext_feature is not None: - if not self.check_extension_feature(requirement.extension, requirement.ext_feature): - if not self.is_extension_installed(requirement.extension): - return FunctionSupportInfo(supported=False, reason="extension_not_installed") - return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") - else: - if not self.is_extension_installed(requirement.extension): - return FunctionSupportInfo(supported=False, reason="extension_not_installed") - if requirement.min_ext_version is not None: - installed = self.get_extension_version(requirement.extension) - if installed is None or self._compare_versions(installed, requirement.min_ext_version) < 0: - return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") - - return FunctionSupportInfo(supported=True) - - def _is_postgres_function_supported(self, func_name: str) -> bool: - """Check if a PostgreSQL function is supported based on version and extensions. - - Checks: - 1. PostgreSQL server version (for built-in and version-gated functions) - 2. Extension installation and version (for extension-provided functions) - - For extension functions, requires _extensions to have been populated - via introspect_and_adapt(). If _extensions is not available, - extension functions return False (cannot confirm availability). - - Args: - func_name: Name of the PostgreSQL function - - Returns: - True if supported, False otherwise - """ - requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) - if requirement is None: - return True # Unregistered functions default to supported - - # Step 1: Check PostgreSQL server version - if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: - return False - if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: - return False - - # Step 2: Check extension requirements - if requirement.extension is not None: - return self._check_extension_requirement(requirement) - - return True - - def _check_extension_requirement( - self, requirement: "FunctionVersionRequirement" - ) -> bool: - """Check extension requirements based on introspect_and_adapt() results. - - Requires _extensions to have been populated via introspect_and_adapt(). - If _extensions is not available, returns False (cannot confirm - extension availability without probing the database). - """ - ext_name = requirement.extension - - # _extensions is populated by introspect_and_adapt() - # If the attribute doesn't exist, introspect_and_adapt() hasn't been called - if not hasattr(self, "_extensions"): - return False - - # Use feature-level check if ext_feature is specified - if requirement.ext_feature is not None: - return self.check_extension_feature(ext_name, requirement.ext_feature) - - # Check extension installed - if not self.is_extension_installed(ext_name): - return False - - # Check extension version - if requirement.min_ext_version is not None: - installed = self.get_extension_version(ext_name) - if installed is None: - return False - return self._compare_versions(installed, requirement.min_ext_version) >= 0 - - return True - - def supports_transaction_mode(self) -> bool: - """PostgreSQL supports READ ONLY and READ WRITE transaction modes.""" - return True - - def supports_isolation_level_in_begin(self) -> bool: - """PostgreSQL supports isolation level specification in BEGIN statement.""" - return True - - def supports_read_only_transaction(self) -> bool: - """PostgreSQL supports READ ONLY transactions.""" - return True - - def supports_deferrable_transaction(self) -> bool: - """PostgreSQL supports DEFERRABLE transactions (for SERIALIZABLE isolation).""" - return True - - def supports_savepoint(self) -> bool: - """PostgreSQL supports savepoints.""" - return True - - def format_begin_transaction( - self, expr: "BeginTransactionExpression" - ) -> Tuple[str, tuple]: - """Format BEGIN TRANSACTION statement for PostgreSQL. - - PostgreSQL syntax: - BEGIN [ ISOLATION LEVEL { READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE } ] - [ { READ WRITE | READ ONLY } ] - [ { NOT DEFERRABLE | DEFERRABLE } ] - - DEFERRABLE is only meaningful for SERIALIZABLE isolation level. - """ - params = expr.get_params() - parts = ["BEGIN"] - - isolation = params.get("isolation_level") - if isolation: - level_str = self.get_isolation_level_name(isolation) - parts.append(f"ISOLATION LEVEL {level_str}") - - mode = params.get("mode") - if mode: - mode_name = mode.name if hasattr(mode, "name") else str(mode) - if mode_name == "READ_ONLY": - parts.append("READ ONLY") - elif mode_name == "READ_WRITE": - parts.append("READ WRITE") - - deferrable = params.get("deferrable") - if deferrable is not None and isolation: - isolation_name = isolation.name if hasattr(isolation, "name") else str(isolation) - if isolation_name == "SERIALIZABLE": - parts.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") - - return " ".join(parts), () - - def format_commit_transaction( - self, expr: "CommitTransactionExpression" - ) -> Tuple[str, tuple]: - """Format COMMIT TRANSACTION statement for PostgreSQL.""" - return "COMMIT", () - - def format_rollback_transaction( - self, expr: "RollbackTransactionExpression" - ) -> Tuple[str, tuple]: - """Format ROLLBACK TRANSACTION statement for PostgreSQL. - - Supports ROLLBACK [ TO SAVEPOINT savepoint_name ]. - """ - params = expr.get_params() - savepoint = params.get("savepoint") - if savepoint: - return f"ROLLBACK TO SAVEPOINT {self.format_identifier(savepoint)}", () - return "ROLLBACK", () - - def format_savepoint( - self, expr: "SavepointExpression" - ) -> Tuple[str, tuple]: - """Format SAVEPOINT statement for PostgreSQL.""" - params = expr.get_params() - name = params.get("name", "") - return f"SAVEPOINT {self.format_identifier(name)}", () - - def format_release_savepoint( - self, expr: "ReleaseSavepointExpression" - ) -> Tuple[str, tuple]: - """Format RELEASE SAVEPOINT statement for PostgreSQL.""" - params = expr.get_params() - name = params.get("name", "") - return f"RELEASE SAVEPOINT {self.format_identifier(name)}", () - - def format_set_transaction( - self, expr: "SetTransactionExpression" - ) -> Tuple[str, tuple]: - """Format SET TRANSACTION statement for PostgreSQL. - - PostgreSQL supports setting transaction characteristics for the current - transaction or for subsequent transactions. - - Syntax: - SET TRANSACTION { ISOLATION LEVEL { ... } | { READ WRITE | READ ONLY } | [ NOT ] DEFERRABLE } [, ...] - SET SESSION CHARACTERISTICS AS TRANSACTION { ... } - """ - params = expr.get_params() - parts = [] - - if params.get("session"): - parts.append("SET SESSION CHARACTERISTICS AS TRANSACTION") - else: - parts.append("SET TRANSACTION") - - options = [] - - isolation = params.get("isolation_level") - if isolation: - level_str = self.get_isolation_level_name(isolation) - options.append(f"ISOLATION LEVEL {level_str}") - - mode = params.get("mode") - if mode: - mode_name = mode.name if hasattr(mode, "name") else str(mode) - if mode_name == "READ_ONLY": - options.append("READ ONLY") - elif mode_name == "READ_WRITE": - options.append("READ WRITE") - - deferrable = params.get("deferrable") - if deferrable is not None: - options.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") - - if options: - parts.append(" ".join(options)) - - return " ".join(parts), () - - # endregion +# src/rhosocial/activerecord/backend/impl/postgres/dialect.py +""" +PostgreSQL backend SQL dialect implementation. + +This dialect implements protocols for features that PostgreSQL actually supports, +based on the PostgreSQL version provided at initialization. +""" + +from typing import Any, Dict, Tuple, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .function_versions import FunctionSupportInfo, FunctionVersionRequirement + +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.dialect.mixins import ( + CTEMixin, + FilterClauseMixin, + WindowFunctionMixin, + JSONMixin, + ReturningMixin, + AdvancedGroupingMixin, + ArrayMixin, + ExplainMixin, + GraphMixin, + LockingMixin, + MergeMixin, + OrderedSetAggregationMixin, + QualifyClauseMixin, + TemporalTableMixin, + UpsertMixin, + LateralJoinMixin, + JoinMixin, + ViewMixin, + SchemaMixin, + IndexMixin, + SequenceMixin, + TableMixin, + SetOperationMixin, + TruncateMixin, + ILIKEMixin, + ConstraintMixin, +) +from rhosocial.activerecord.backend.dialect.protocols import ( + CTESupport, + FilterClauseSupport, + WindowFunctionSupport, + JSONSupport, + ReturningSupport, + AdvancedGroupingSupport, + ArraySupport, + ExplainSupport, + GraphSupport, + MergeSupport, + OrderedSetAggregationSupport, + QualifyClauseSupport, + TemporalTableSupport, + UpsertSupport, + LateralJoinSupport, + WildcardSupport, + JoinSupport, + ViewSupport, + SchemaSupport, + SequenceSupport, + SetOperationSupport, + TruncateSupport, + ILIKESupport, + IntrospectionSupport, + TransactionControlSupport, + SQLFunctionSupport, +) +from .mixins import ( + PostgresExtensionMixin, + PostgresMaterializedViewMixin, + PostgresTableMixin, + PostgresPgvectorMixin, + PostgresPostGISMixin, + PostgresPostgisRasterMixin, + PostgresPgroutingMixin, + PostgresPgTrgmMixin, + PostgresHstoreMixin, + # Native feature mixins + PostgresPartitionMixin, + PostgresIndexMixin, + PostgresVacuumMixin, + PostgresQueryOptimizationMixin, + PostgresDataTypeMixin, + PostgresSQLSyntaxMixin, + PostgresLogicalReplicationMixin, + PostgresParallelQueryMixin, + # Extension feature mixins + PostgresLtreeMixin, + PostgresIntarrayMixin, + PostgresEarthdistanceMixin, + PostgresTablefuncMixin, + PostgresPgStatStatementsMixin, + PostgresCitextMixin, + PostgresPgcryptoMixin, + PostgresFuzzystrmatchMixin, + PostgresCubeMixin, + PostgresUuidOssMixin, + PostgresBloomMixin, + PostgresBtreeGinMixin, + PostgresBtreeGistMixin, + PostgresPgCronMixin, + PostgresPgPartmanMixin, + PostgresPgSurgeryMixin, + PostgresPgWalinspectMixin, + PostgresPgLogicalMixin, + PostgresPgauditMixin, + PostgresPgRepackMixin, + PostgresHypoPgMixin, + PostgresOrafceMixin, + PostgresAddressStandardizerMixin, + # DDL feature mixins + PostgresTriggerMixin, + PostgresCommentMixin, + PostgresTypeMixin, + PostgresConstraintMixin, + # Type mixins + EnumTypeMixin, + TypesDataTypeMixin, + MultirangeMixin, + PostgresFullTextSearchMixin, + PostgresRangeTypeMixin, + PostgresJSONBEnhancedMixin, + PostgresArrayEnhancedMixin, + # DDL/DML operation mixins (new) + PostgresExtendedStatisticsMixin, + PostgresStoredProcedureMixin, + PostgresAdvisoryLockMixin, + PostgresLockingMixin, + # Introspection capability mixin + PostgresIntrospectionCapabilityMixin, +) + +# PostgreSQL-specific imports +from .protocols import ( + PostgresExtensionSupport, + PostgresMaterializedViewSupport, + PostgresTableSupport, + PostgresPgvectorSupport, + PostgresPostGISSupport, + PostgresPostgisRasterSupport, + PostgresPgroutingSupport, + PostgresPgTrgmSupport, + PostgresHstoreSupport, + # Native feature protocols + PostgresPartitionSupport, + PostgresIndexSupport, + PostgresVacuumSupport, + PostgresQueryOptimizationSupport, + PostgresDataTypeSupport, + PostgresSQLSyntaxSupport, + PostgresLogicalReplicationSupport, + # Extension feature protocols + PostgresLtreeSupport, + PostgresIntarraySupport, + PostgresEarthdistanceSupport, + PostgresTablefuncSupport, + PostgresPgStatStatementsSupport, + PostgresCitextSupport, + PostgresPgcryptoSupport, + PostgresFuzzystrmatchSupport, + PostgresCubeSupport, + PostgresUuidOssSupport, + PostgresBloomSupport, + PostgresBtreeGinSupport, + PostgresBtreeGistSupport, + PostgresPgCronSupport, + PostgresPgPartmanSupport, + PostgresPgSurgerySupport, + PostgresPgWalinspectSupport, + PostgresPgLogicalSupport, + PostgresPgauditSupport, + PostgresPgRepackSupport, + PostgresHypoPgSupport, + PostgresOrafceSupport, + PostgresAddressStandardizerSupport, + # DDL feature protocols + PostgresTriggerSupport, + PostgresCommentSupport, + PostgresTypeSupport, + PostgresConstraintSupport, + # Type feature protocols + PostgresMultirangeSupport, + PostgresEnumTypeSupport, + PostgresFullTextSearchSupport, + PostgresRangeTypeSupport, + PostgresJSONBEnhancedSupport, + PostgresArrayEnhancedSupport, + # New feature protocols + PostgresParallelQuerySupport, + PostgresStoredProcedureSupport, + PostgresExtendedStatisticsSupport, + PostgresAdvisoryLockSupport, + PostgresLockingSupport, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements import ( + CreateTableExpression, + CreateViewExpression, + DropViewExpression, + TruncateExpression, + CreateMaterializedViewExpression, + DropMaterializedViewExpression, + RefreshMaterializedViewExpression, + ExplainExpression, + AddTableConstraint, + TableConstraint, + ) + from rhosocial.activerecord.backend.expression.transaction import ( + BeginTransactionExpression, + CommitTransactionExpression, + RollbackTransactionExpression, + SavepointExpression, + ReleaseSavepointExpression, + SetTransactionExpression, + ) + + +class PostgresDialect( + SQLDialectBase, + SetOperationMixin, + TruncateMixin, + ILIKEMixin, + CTEMixin, + FilterClauseMixin, + WindowFunctionMixin, + JSONMixin, + ReturningMixin, + AdvancedGroupingMixin, + ArrayMixin, + ExplainMixin, + GraphMixin, + PostgresLockingMixin, + LockingMixin, + MergeMixin, + OrderedSetAggregationMixin, + QualifyClauseMixin, + TemporalTableMixin, + UpsertMixin, + LateralJoinMixin, + JoinMixin, + ViewMixin, + SchemaMixin, + PostgresIndexMixin, + IndexMixin, + SequenceMixin, + TableMixin, + ConstraintMixin, + PostgresIntrospectionCapabilityMixin, + # PostgreSQL-specific mixins + PostgresExtensionMixin, + PostgresMaterializedViewMixin, + PostgresTableMixin, + PostgresPgvectorMixin, + PostgresPostGISMixin, + PostgresPostgisRasterMixin, + PostgresPgroutingMixin, + PostgresPgTrgmMixin, + PostgresHstoreMixin, + # Native feature mixins + PostgresPartitionMixin, + PostgresVacuumMixin, + PostgresQueryOptimizationMixin, + PostgresDataTypeMixin, + PostgresSQLSyntaxMixin, + PostgresLogicalReplicationMixin, + PostgresParallelQueryMixin, + # Extension feature mixins + PostgresLtreeMixin, + PostgresIntarrayMixin, + PostgresEarthdistanceMixin, + PostgresTablefuncMixin, + PostgresPgStatStatementsMixin, + PostgresCitextMixin, + PostgresPgcryptoMixin, + PostgresFuzzystrmatchMixin, + PostgresCubeMixin, + PostgresUuidOssMixin, + PostgresBloomMixin, + PostgresBtreeGinMixin, + PostgresBtreeGistMixin, + PostgresPgCronMixin, + PostgresPgPartmanMixin, + PostgresPgSurgeryMixin, + PostgresPgWalinspectMixin, + PostgresPgLogicalMixin, + PostgresPgauditMixin, + PostgresPgRepackMixin, + PostgresHypoPgMixin, + PostgresOrafceMixin, + PostgresAddressStandardizerMixin, + # DDL feature mixins + PostgresTriggerMixin, + PostgresCommentMixin, + PostgresTypeMixin, + PostgresConstraintMixin, + # Type mixins + EnumTypeMixin, + TypesDataTypeMixin, + MultirangeMixin, + PostgresFullTextSearchMixin, + PostgresRangeTypeMixin, + PostgresJSONBEnhancedMixin, + PostgresArrayEnhancedMixin, + # DDL/DML operation mixins (new) + PostgresExtendedStatisticsMixin, + PostgresStoredProcedureMixin, + PostgresAdvisoryLockMixin, + # Protocol supports + SetOperationSupport, + TruncateSupport, + ILIKESupport, + CTESupport, + FilterClauseSupport, + WindowFunctionSupport, + JSONSupport, + ReturningSupport, + AdvancedGroupingSupport, + ArraySupport, + ExplainSupport, + GraphSupport, + MergeSupport, + OrderedSetAggregationSupport, + QualifyClauseSupport, + TemporalTableSupport, + UpsertSupport, + LateralJoinSupport, + WildcardSupport, + JoinSupport, + ViewSupport, + SchemaSupport, + SequenceSupport, + # Introspection protocol + IntrospectionSupport, + # Transaction control protocol + TransactionControlSupport, + # PostgreSQL-specific protocols + PostgresExtensionSupport, + PostgresMaterializedViewSupport, + PostgresTableSupport, + PostgresPgvectorSupport, + PostgresPostGISSupport, + PostgresPostgisRasterSupport, + PostgresPgroutingSupport, + PostgresPgTrgmSupport, + PostgresHstoreSupport, + # Native feature protocols + PostgresPartitionSupport, + PostgresIndexSupport, + PostgresVacuumSupport, + PostgresQueryOptimizationSupport, + PostgresDataTypeSupport, + PostgresSQLSyntaxSupport, + PostgresLogicalReplicationSupport, + # Extension feature protocols + PostgresLtreeSupport, + PostgresIntarraySupport, + PostgresEarthdistanceSupport, + PostgresTablefuncSupport, + PostgresPgStatStatementsSupport, + PostgresCitextSupport, + PostgresPgcryptoSupport, + PostgresFuzzystrmatchSupport, + PostgresCubeSupport, + PostgresUuidOssSupport, + PostgresBloomSupport, + PostgresBtreeGinSupport, + PostgresBtreeGistSupport, + PostgresPgCronSupport, + PostgresPgPartmanSupport, + PostgresPgSurgerySupport, + PostgresPgWalinspectSupport, + PostgresPgLogicalSupport, + PostgresPgauditSupport, + PostgresPgRepackSupport, + PostgresHypoPgSupport, + PostgresOrafceSupport, + PostgresAddressStandardizerSupport, + # DDL feature protocols + PostgresTriggerSupport, + PostgresCommentSupport, + PostgresTypeSupport, + PostgresConstraintSupport, + # Type feature protocols + PostgresMultirangeSupport, + PostgresEnumTypeSupport, + PostgresFullTextSearchSupport, + PostgresRangeTypeSupport, + PostgresJSONBEnhancedSupport, + PostgresArrayEnhancedSupport, + # New feature protocols + PostgresParallelQuerySupport, + PostgresStoredProcedureSupport, + PostgresExtendedStatisticsSupport, + PostgresAdvisoryLockSupport, + PostgresLockingSupport, + # Function support protocol + SQLFunctionSupport, +): + """ + PostgreSQL dialect implementation that adapts to the PostgreSQL version. + + PostgreSQL features and support based on version: + - Basic and recursive CTEs (since 8.4) + - Window functions (since 8.4) + - RETURNING clause (since 8.2) + - JSON operations (since 9.2, JSONB since 9.4) + - FILTER clause (since 9.4) + - UPSERT (ON CONFLICT) (since 9.5) + - MERGE statement (since 15) + - Advanced grouping (CUBE, ROLLUP, GROUPING SETS) (since 9.5) + - Array types (since early versions) + - LATERAL joins (since 9.3) + - Parallel query execution (since 9.6) + - Stored procedures with CALL (since 11) + - Extended statistics (since 10) + + PostgreSQL-specific features: + - Table inheritance (INHERITS) + - CONCURRENTLY refresh for materialized views (since 9.4) + - Extension detection (PostGIS, pgvector, pg_trgm, hstore, etc.) + + Note: Extension features require the extension to be installed in the database. + Use introspect_and_adapt() to detect installed extensions automatically. + """ + + def __init__(self, version: Optional[Tuple[int, int, int]] = None): + """ + Initialize PostgreSQL dialect with specific version. + + Args: + version: PostgreSQL version tuple (major, minor, patch). + If None, the dialect must be adapted via + backend.introspect_and_adapt() before version-dependent + features can be used. + """ + super().__init__() + if version is not None: + self.version = version + + @staticmethod + def _validate_data_type(data_type: str) -> bool: + """Validate data type for safe embedding in SQL. + + PostgreSQL supports array types like TEXT[], INTEGER[]. + + Note: rhosocial-activerecord base dialect will include this change in + a future release. This override can be removed after upgrading to that + version (expected: include brackets [] in the allowlist pattern). + """ + import re + + return bool(re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", data_type)) + + def get_parameter_placeholder(self, position: int = 0) -> str: + """psycopg uses '%s' for placeholders.""" + return "%s" + + def get_server_version(self) -> Tuple[int, int, int]: + """Return the PostgreSQL version this dialect is configured for.""" + return self.version + + # region Protocol Support Checks based on version + def supports_basic_cte(self) -> bool: + """Basic CTEs are supported since PostgreSQL 8.4.""" + return True # Supported in all modern versions + + def supports_recursive_cte(self) -> bool: + """Recursive CTEs are supported since PostgreSQL 8.4.""" + return True # Supported in all modern versions + + def supports_materialized_cte(self) -> bool: + """MATERIALIZED hint is supported since PostgreSQL 12.""" + return self.version >= (12, 0, 0) + + def supports_returning_insert(self) -> bool: + """RETURNING clause is supported for INSERT since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) + + def supports_returning_update(self) -> bool: + """RETURNING clause is supported for UPDATE since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) + + def supports_returning_delete(self) -> bool: + """RETURNING clause is supported for DELETE since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) + + def supports_window_functions(self) -> bool: + """Window functions are supported since PostgreSQL 8.4.""" + return True # Supported in all modern versions + + def supports_window_frame_clause(self) -> bool: + """Whether window frame clauses (ROWS/RANGE) are supported, since PostgreSQL 8.4.""" + return True # Supported in all modern versions + + def supports_filter_clause(self) -> bool: + """FILTER clause for aggregate functions is supported since PostgreSQL 9.4.""" + return self.version >= (9, 4, 0) + + def get_json_access_operator(self) -> str: + """PostgreSQL uses '->' for JSON access.""" + return "->" + + def supports_rollup(self) -> bool: + """ROLLUP is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_cube(self) -> bool: + """CUBE is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_grouping_sets(self) -> bool: + """GROUPING SETS is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_explain_analyze(self) -> bool: + """Whether EXPLAIN ANALYZE is supported.""" + return True # Supported in all modern versions + + def supports_explain_format(self, format_type: str) -> bool: + """Check if specific EXPLAIN format is supported.""" + format_type_upper = format_type.upper() + # PostgreSQL supports TEXT, XML, JSON, and YAML formats + supported_formats = ["TEXT", "XML", "JSON", "YAML"] + return format_type_upper in supported_formats + + def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: + """Build the PostgreSQL EXPLAIN SQL string and return (sql, params). + + PostgreSQL syntax: ``EXPLAIN [ ( option [, ...] ) ] statement`` + + Supported options assembled here: + - ``ANALYZE`` + - ``FORMAT { TEXT | XML | JSON | YAML }`` + - ``VERBOSE`` (passed through ``ExplainOptions.verbose`` if present) + """ + from rhosocial.activerecord.backend.expression.statements import ExplainType + + statement_sql, statement_params = explain_expr.statement.to_sql() + options = explain_expr.options + if options is None: + return f"EXPLAIN {statement_sql}", statement_params + + opts: list = [] + + if options.analyze: + opts.append("ANALYZE") + + if options.format is not None: + fmt_name = options.format.name if hasattr(options.format, "name") else str(options.format) + opts.append(f"FORMAT {fmt_name.upper()}") + elif options.type is not None and options.type == ExplainType.QUERY_PLAN: + # PostgreSQL has no QUERY PLAN keyword; plain EXPLAIN is equivalent + pass + + if opts: + return "EXPLAIN (" + ", ".join(opts) + ") " + statement_sql, statement_params + return f"EXPLAIN {statement_sql}", statement_params + + def supports_graph_match(self) -> bool: + """Whether graph query MATCH clause is supported.""" + # PostgreSQL doesn't have native MATCH clause like some other systems + # Though graph querying can be done with extensions like Apache AGE + return False + + def supports_for_update(self) -> bool: + """Whether FOR UPDATE clause is supported in SELECT statements. + + PostgreSQL supports FOR UPDATE since early versions. The clause locks + selected rows preventing other transactions from modifying them. + PostgreSQL also supports FOR UPDATE OF, FOR UPDATE NOWAIT, and + FOR UPDATE SKIP LOCKED (since 9.5). + """ + return True + + def supports_lock_strength(self, strength) -> bool: + """ + Check if a specific lock strength is supported. + + PostgreSQL lock strength support by version: + - FOR UPDATE: All versions + - FOR NO KEY UPDATE: PostgreSQL 9.0+ + - FOR SHARE: PostgreSQL 9.0+ + - FOR KEY SHARE: PostgreSQL 9.3+ + + Args: + strength: The LockStrength enum value to check + + Returns: + True if the lock strength is supported, False otherwise + """ + from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength + + if strength == LockStrength.UPDATE: + return True # All PostgreSQL versions support FOR UPDATE + elif strength == LockStrength.NO_KEY_UPDATE: + return self.version >= (9, 0, 0) + elif strength == LockStrength.SHARE: + return self.version >= (9, 0, 0) + elif strength == LockStrength.KEY_SHARE: + return self.version >= (9, 3, 0) + return False + + def supports_merge_statement(self) -> bool: + """Whether MERGE statement is supported.""" + return self.version >= (15, 0, 0) # MERGE added in PostgreSQL 15 + + def supports_temporal_tables(self) -> bool: + """Whether temporal tables are supported.""" + # PostgreSQL doesn't have built-in temporal tables + return False + + def supports_qualify_clause(self) -> bool: + """Whether QUALIFY clause is supported.""" + # PostgreSQL doesn't have QUALIFY clause (though can be simulated with subqueries) + return False + + def supports_upsert(self) -> bool: + """Whether UPSERT (ON CONFLICT) is supported.""" + return self.version >= (9, 5, 0) # ON CONFLICT added in 9.5 + + def get_upsert_syntax_type(self) -> str: + """ + Get UPSERT syntax type. + + Returns: + 'ON CONFLICT' (PostgreSQL) or 'ON DUPLICATE KEY' (MySQL) + """ + return "ON CONFLICT" + + def supports_lateral_join(self) -> bool: + """Whether LATERAL joins are supported.""" + return self.version >= (9, 3, 0) # LATERAL joins added in 9.3 + + def supports_ordered_set_aggregation(self) -> bool: + """Whether ordered-set aggregate functions are supported.""" + return self.version >= (9, 4, 0) # Supported since 9.4 + + def supports_multirange(self) -> bool: + """Whether multirange types are supported (PostgreSQL 14+).""" + return self.version >= (14, 0, 0) + + def supports_multirange_constructor(self) -> bool: + """Whether multirange constructor functions are supported (PostgreSQL 14+).""" + return self.version >= (14, 0, 0) + + def supports_inner_join(self) -> bool: + """INNER JOIN is supported.""" + return True + + def supports_left_join(self) -> bool: + """LEFT JOIN is supported.""" + return True + + def supports_right_join(self) -> bool: + """RIGHT JOIN is supported.""" + return True + + def supports_full_join(self) -> bool: + """FULL JOIN is supported.""" + return True + + def supports_cross_join(self) -> bool: + """CROSS JOIN is supported.""" + return True + + def supports_natural_join(self) -> bool: + """NATURAL JOIN is supported.""" + return True + + def supports_wildcard(self) -> bool: + """Wildcard (*) is supported.""" + return True + + # region ILIKE Support + def supports_ilike(self) -> bool: + """ILIKE is supported.""" + return True + + def format_ilike_expression(self, column: Any, pattern: str, negate: bool = False) -> Tuple[str, Tuple]: + """Format ILIKE expression for PostgreSQL.""" + if isinstance(column, str): + col_sql = self.format_identifier(column) + else: + col_sql, col_params = column.to_sql() if hasattr(column, "to_sql") else (str(column), ()) + + if negate: + sql = f"{col_sql} NOT ILIKE %s" + else: + sql = f"{col_sql} ILIKE %s" + + return sql, (pattern,) + + # endregion + + # region Set Operation Support + def supports_union(self) -> bool: + """UNION is supported.""" + return True + + def supports_union_all(self) -> bool: + """UNION ALL is supported.""" + return True + + def supports_intersect(self) -> bool: + """INTERSECT is supported.""" + return True + + def supports_except(self) -> bool: + """EXCEPT is supported.""" + return True + + def supports_set_operation_order_by(self) -> bool: + """ORDER BY is supported for set operations.""" + return True + + def supports_set_operation_limit_offset(self) -> bool: + """LIMIT/OFFSET is supported for set operations.""" + return True + + def supports_set_operation_for_update(self) -> bool: + """FOR UPDATE is supported for set operations.""" + return True + + # endregion + + # region Truncate Support + def supports_truncate(self) -> bool: + """TRUNCATE is supported.""" + return True + + def supports_truncate_table_keyword(self) -> bool: + """TABLE keyword is supported in TRUNCATE.""" + return True + + def supports_truncate_restart_identity(self) -> bool: + """RESTART IDENTITY is supported since PostgreSQL 8.4.""" + return self.version >= (8, 4, 0) + + def supports_truncate_cascade(self) -> bool: + """CASCADE is supported in TRUNCATE.""" + return True + + def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: + """Format TRUNCATE statement for PostgreSQL.""" + parts = ["TRUNCATE TABLE"] + parts.append(self.format_identifier(expr.table_name)) + + if expr.restart_identity and self.supports_truncate_restart_identity(): + parts.append("RESTART IDENTITY") + + if expr.cascade: + parts.append("CASCADE") + + return " ".join(parts), () + + # endregion + + # region Custom Implementations for PostgreSQL-specific behavior + def format_identifier(self, identifier: str) -> str: + """ + Format identifier using PostgreSQL's double quote quoting mechanism. + + Args: + identifier: Raw identifier string + + Returns: + Quoted identifier with escaped internal quotes + """ + # Escape any internal double quotes by doubling them + escaped = identifier.replace('"', '""') + return f'"{escaped}"' + + def format_column(self, name: str, table: Optional[str] = None, + alias: Optional[str] = None, + schema_name: Optional[str] = None) -> Tuple[str, Tuple]: + """Format column reference for PostgreSQL. + + PostgreSQL rules for column references: + - When the table has an alias (used in FROM/JOIN), column references + must use the alias, not the schema-qualified name. For example, + when FROM clause has ``public.users AS "users"``, the column + reference must be ``"users"."id"``, not ``"public"."users"."id"``. + - When no alias is present but schema is specified, use the + full three-segment form: ``"schema"."table"."column"``. + - Otherwise use the standard two-segment or single-segment form. + """ + if table: + # In PostgreSQL, when a table has an alias, column references + # must use the alias — schema_name is irrelevant in this context. + if schema_name and not alias: + col_sql = f"{self.format_identifier(schema_name)}.{self.format_identifier(table)}.{self.format_identifier(name)}" + else: + col_sql = f"{self.format_identifier(table)}.{self.format_identifier(name)}" + else: + col_sql = self.format_identifier(name) + + if alias: + col_sql = f"{col_sql} AS {self.format_identifier(alias)}" + + return col_sql, () + + def format_on_conflict_clause(self, expr) -> Tuple[str, tuple]: + """Format ON CONFLICT clause for PostgreSQL. + + Overrides the base implementation to handle EXCLUDED pseudo-table + references without quoting, as EXCLUDED is a special PostgreSQL + keyword in ON CONFLICT context and must not be double-quoted. + """ + from rhosocial.activerecord.backend.expression import bases + from rhosocial.activerecord.backend.expression.core import Column + + all_params = [] + parts = ["ON CONFLICT"] + + # Add conflict target if specified + if expr.conflict_target: + target_parts = [] + for target in expr.conflict_target: + if isinstance(target, str): + target_parts.append(self.format_identifier(target)) + elif hasattr(target, 'to_sql'): + target_sql, target_params = target.to_sql() + target_parts.append(target_sql) + all_params.extend(target_params) + else: + target_parts.append(self.format_identifier(str(target))) + if target_parts: + parts.append(f"({', '.join(target_parts)})") + + # Add DO NOTHING or DO UPDATE + if expr.do_nothing: + parts.append("DO NOTHING") + elif expr.update_assignments: + update_parts = [] + for col, expr_val in expr.update_assignments.items(): + if isinstance(expr_val, Column) and getattr(expr_val, 'table', None) == 'EXCLUDED': + # EXCLUDED is a special pseudo-table in PostgreSQL ON CONFLICT. + # It must NOT be double-quoted, only the column name should be quoted. + val_sql = f'EXCLUDED.{self.format_identifier(expr_val.name)}' + update_parts.append(f"{self.format_identifier(col)} = {val_sql}") + elif isinstance(expr_val, bases.BaseExpression): + val_sql, val_params = expr_val.to_sql() + update_parts.append(f"{self.format_identifier(col)} = {val_sql}") + all_params.extend(val_params) + else: + update_parts.append(f"{self.format_identifier(col)} = {self.get_parameter_placeholder()}") + all_params.append(expr_val) + + parts.append(f"DO UPDATE SET {', '.join(update_parts)}") + + # Add WHERE clause if specified + if expr.update_where: + where_sql, where_params = expr.update_where.to_sql() + parts.append(f"WHERE {where_sql}") + all_params.extend(where_params) + else: + parts.append("DO NOTHING") + + return " ".join(parts), tuple(all_params) + + # region View Support + def supports_or_replace_view(self) -> bool: + """Whether CREATE OR REPLACE VIEW is supported.""" + return True # PostgreSQL supports OR REPLACE + + def supports_temporary_view(self) -> bool: + """Whether TEMPORARY views are supported.""" + return True # PostgreSQL supports TEMPORARY views + + def supports_materialized_view(self) -> bool: + """Whether materialized views are supported.""" + return True # PostgreSQL supports materialized views since 9.3 + + def supports_refresh_materialized_view(self) -> bool: + """Whether REFRESH MATERIALIZED VIEW is supported.""" + return True # PostgreSQL supports REFRESH MATERIALIZED VIEW + + def supports_materialized_view_concurrent_refresh(self) -> bool: + """Whether concurrent refresh for materialized views is supported.""" + return self.version >= (9, 4, 0) # CONCURRENTLY added in 9.4 + + def supports_materialized_view_tablespace(self) -> bool: + """Whether tablespace specification for materialized views is supported.""" + return True + + def supports_materialized_view_storage_options(self) -> bool: + """Whether storage options for materialized views are supported.""" + return True + + def supports_if_exists_view(self) -> bool: + """Whether DROP VIEW IF EXISTS is supported.""" + return True # PostgreSQL supports IF EXISTS + + def supports_view_check_option(self) -> bool: + """Whether WITH CHECK OPTION is supported.""" + return True # PostgreSQL supports WITH CHECK OPTION + + def supports_cascade_view(self) -> bool: + """Whether DROP VIEW CASCADE is supported.""" + return True # PostgreSQL supports CASCADE + + def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: + """Format CREATE VIEW statement for PostgreSQL.""" + parts = ["CREATE"] + + if expr.temporary: + parts.append("TEMPORARY") + + if expr.replace: + parts.append("OR REPLACE") + + parts.append("VIEW") + parts.append(self.format_identifier(expr.view_name)) + + if expr.column_aliases: + cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) + parts.append(f"({cols})") + + query_sql, query_params = expr.query.to_sql() + parts.append(f"AS {query_sql}") + + if expr.options and expr.options.check_option: + check_option = expr.options.check_option.value + parts.append(f"WITH {check_option} CHECK OPTION") + + return " ".join(parts), query_params + + def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, tuple]: + """Format DROP VIEW statement for PostgreSQL.""" + parts = ["DROP VIEW"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.view_name)) + if expr.cascade: + parts.append("CASCADE") + return " ".join(parts), () + + def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewExpression") -> Tuple[str, tuple]: + """Format CREATE MATERIALIZED VIEW statement for PostgreSQL.""" + parts = ["CREATE MATERIALIZED VIEW"] + parts.append(self.format_identifier(expr.view_name)) + + if expr.column_aliases: + cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) + parts.append(f"({cols})") + + if expr.tablespace and self.supports_materialized_view_tablespace(): + parts.append(f"TABLESPACE {self.format_identifier(expr.tablespace)}") + + if expr.storage_options and self.supports_materialized_view_storage_options(): + storage_parts = [] + for key, value in expr.storage_options.items(): + storage_parts.append(f"{key.upper()} = {value}") + parts.append(f"WITH ({', '.join(storage_parts)})") + + query_sql, query_params = expr.query.to_sql() + parts.append(f"AS {query_sql}") + + if expr.with_data: + parts.append("WITH DATA") + else: + parts.append("WITH NO DATA") + + return " ".join(parts), query_params + + def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExpression") -> Tuple[str, tuple]: + """Format DROP MATERIALIZED VIEW statement for PostgreSQL.""" + parts = ["DROP MATERIALIZED VIEW"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.view_name)) + if expr.cascade: + parts.append("CASCADE") + return " ".join(parts), () + + def format_refresh_materialized_view_statement( + self, expr: "RefreshMaterializedViewExpression" + ) -> Tuple[str, tuple]: + """Format REFRESH MATERIALIZED VIEW statement for PostgreSQL.""" + parts = ["REFRESH MATERIALIZED VIEW"] + if expr.concurrent and self.supports_materialized_view_concurrent_refresh(): + parts.append("CONCURRENTLY") + parts.append(self.format_identifier(expr.view_name)) + if expr.with_data is not None: + parts.append("WITH DATA" if expr.with_data else "WITH NO DATA") + return " ".join(parts), () + + # endregion + + # region Schema Support + def supports_create_schema(self) -> bool: + """Whether CREATE SCHEMA is supported.""" + return True + + def supports_drop_schema(self) -> bool: + """Whether DROP SCHEMA is supported.""" + return True + + def supports_schema_if_not_exists(self) -> bool: + """Whether CREATE SCHEMA IF NOT EXISTS is supported.""" + return True # PostgreSQL 9.3+ supports IF NOT EXISTS + + def supports_schema_if_exists(self) -> bool: + """Whether DROP SCHEMA IF EXISTS is supported.""" + return True + + def supports_schema_cascade(self) -> bool: + """Whether DROP SCHEMA CASCADE is supported.""" + return True + + # endregion + + # region Index Support + def supports_create_index(self) -> bool: + """Whether CREATE INDEX is supported.""" + return True + + def supports_drop_index(self) -> bool: + """Whether DROP INDEX is supported.""" + return True + + def supports_unique_index(self) -> bool: + """Whether UNIQUE indexes are supported.""" + return True + + 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 + + def supports_index_if_exists(self) -> bool: + """Whether DROP INDEX IF EXISTS is supported.""" + return True + + # endregion + + # region Sequence Support + def supports_create_sequence(self) -> bool: + """Whether CREATE SEQUENCE is supported.""" + return True + + def supports_drop_sequence(self) -> bool: + """Whether DROP SEQUENCE is supported.""" + return True + + # endregion + + # region Table Support + def supports_if_not_exists_table(self) -> bool: + """Whether CREATE TABLE IF NOT EXISTS is supported.""" + return True + + def supports_if_exists_table(self) -> bool: + """Whether DROP TABLE IF EXISTS is supported.""" + return True + + def supports_temporary_table(self) -> bool: + """Whether TEMPORARY tables are supported.""" + return True + + def supports_table_inheritance(self) -> bool: + """Whether table inheritance is supported.""" + return True # PostgreSQL supports INHERITS + + def supports_table_partitioning(self) -> bool: + """Whether table partitioning is supported.""" + return True # PostgreSQL supports partitioning + + def supports_table_tablespace(self) -> bool: + """Whether tablespace specification is supported.""" + return True + + # Constraint capability overrides + + def supports_constraint_enforced(self) -> bool: + """PostgreSQL does not support ENFORCED/NOT ENFORCED (SQL:2016). + + PostgreSQL uses NOT VALID as a proprietary alternative for + skipping validation of existing data when adding constraints. + """ + return False + + def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: + """ + Format CREATE TABLE statement for PostgreSQL, including LIKE syntax support. + + PostgreSQL LIKE syntax supports INCLUDING/EXCLUDING options to control what + gets copied: DEFAULTS, CONSTRAINTS, INDEXES, IDENTITY, GENERATED, ALL, + COMMENTS, STORAGE, COMPRESSION. + + The behavior is controlled by the `dialect_options` parameter in + CreateTableExpression: + + 1. When `dialect_options` contains 'like_table' key: + - LIKE syntax takes highest priority + - All other parameters (columns, indexes, constraints, etc.) are IGNORED + - Only temporary and if_not_exists flags are considered + - Additional 'like_options' key controls INCLUDING/EXCLUDING behavior + + 2. When `dialect_options` does NOT contain 'like_table': + - Falls back to base class implementation + - Standard CREATE TABLE with column definitions is generated + + The 'like_options' key supports two formats: + + a) Dictionary format (recommended): + { + 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], + 'excluding': ['COMMENTS'] + } + + b) List format (for backwards compatibility): + ['DEFAULTS', 'CONSTRAINTS'] # Defaults to INCLUDING + or + [('INCLUDING', 'DEFAULTS'), ('EXCLUDING', 'INDEXES')] + + Usage Examples: + # Basic LIKE syntax + CreateTableExpression( + dialect=postgres_dialect, + table_name="users_copy", + columns=[], # Ignored when like_table is present + dialect_options={'like_table': 'users'} + ) + # Generates: CREATE TABLE "users_copy" (LIKE "users") + + # LIKE with INCLUDING options (dictionary format - recommended) + CreateTableExpression( + dialect=postgres_dialect, + table_name="users_copy", + columns=[...], # Will be ignored + dialect_options={ + 'like_table': 'users', + 'like_options': { + 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], + 'excluding': ['COMMENTS'] + } + } + ) + # Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, + # INCLUDING CONSTRAINTS, INCLUDING INDEXES, EXCLUDING COMMENTS) + + # LIKE with schema-qualified source table + CreateTableExpression( + dialect=postgres_dialect, + table_name="users_copy", + columns=[], + dialect_options={'like_table': ('public', 'users')} + ) + # Generates: CREATE TABLE "users_copy" (LIKE "public"."users") + + # LIKE with TEMPORARY and IF NOT EXISTS + CreateTableExpression( + dialect=postgres_dialect, + table_name="temp_users", + columns=[], + temporary=True, + if_not_exists=True, + dialect_options={'like_table': 'users'} + ) + # Generates: CREATE TEMPORARY TABLE IF NOT EXISTS "temp_users" (LIKE "users") + + # LIKE with INCLUDING ALL + CreateTableExpression( + dialect=postgres_dialect, + table_name="users_copy", + columns=[], + dialect_options={ + 'like_table': 'users', + 'like_options': {'including': ['ALL']} + } + ) + # Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING ALL) + + Args: + expr: CreateTableExpression instance + + Returns: + Tuple of (SQL string, parameters tuple) + """ + # Check for LIKE syntax in dialect_options (highest priority) + if "like_table" in expr.dialect_options: + like_table = expr.dialect_options["like_table"] + like_options = expr.dialect_options.get("like_options", []) + + parts = ["CREATE"] + + if expr.temporary: + parts.append("TEMPORARY") + + parts.append("TABLE") + + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + + parts.append(self.format_identifier(expr.table_name)) + + # Build LIKE clause with options + like_parts = [] + + # Handle schema-qualified table name: ('schema', 'table') + if isinstance(like_table, tuple): + schema, table = like_table + like_table_str = f"{self.format_identifier(schema)}.{self.format_identifier(table)}" + else: + like_table_str = self.format_identifier(like_table) + + like_parts.append(f"LIKE {like_table_str}") + + # Add INCLUDING/EXCLUDING options + # Format: dictionary with 'including' and 'excluding' keys + # Example: {'including': ['DEFAULTS', 'CONSTRAINTS'], 'excluding': ['INDEXES']} + if isinstance(like_options, dict): + # Handle dictionary format + including = like_options.get("including", []) + excluding = like_options.get("excluding", []) + + for option in including: + like_parts.append(f"INCLUDING {option.upper()}") + + for option in excluding: + like_parts.append(f"EXCLUDING {option.upper()}") + elif isinstance(like_options, list): + # Handle list format for backwards compatibility + for option in like_options: + if isinstance(option, tuple): + action, feature = option + like_parts.append(f"{action.upper()} {feature.upper()}") + else: + # Default to INCLUDING if just feature name provided + like_parts.append(f"INCLUDING {option.upper()}") + + parts.append(f"({', '.join(like_parts)})") + + return " ".join(parts), () + + # Otherwise, delegate to base implementation + return super().format_create_table_statement(expr) + + # endregion + + # region Type Casting Support (PostgreSQL-specific) + def format_cast_expression( + self, expr_sql: str, target_type: str, expr_params: tuple, alias: Optional[str] = None + ) -> Tuple[str, Tuple]: + """Format type cast expression using PostgreSQL :: syntax. + + PostgreSQL supports both standard CAST(expr AS type) syntax and the + PostgreSQL-specific expr::type syntax. This method uses the more + concise :: syntax which is idiomatic in PostgreSQL. + + Args: + expr_sql: SQL expression string to be cast + target_type: Target PostgreSQL type name (e.g., 'integer', 'varchar(100)') + expr_params: Parameters tuple for the expression + alias: Optional alias for the result + + Returns: + Tuple of (SQL string, parameters) + + Example: + >>> dialect.format_cast_expression('price', 'numeric', ()) + # Returns: ('price::numeric', ()) + >>> dialect.format_cast_expression('amount', 'money', ()) + # Returns: ('amount::money', ()) + >>> dialect.format_cast_expression('value', 'integer', (), 'int_val') + # Returns: ('value::integer AS "int_val"', ()) + + Note: + For chained type conversions, each ::type is appended: + >>> col.cast('money').cast('numeric').cast('float8') + # Generates: col::money::numeric::float8 + """ + sql = f"{expr_sql}::{target_type}" + if alias: + sql = f"{sql} AS {self.format_identifier(alias)}" + return sql, expr_params + + # endregion + + # region Operator Formatting (PostgreSQL-specific) + + def format_binary_operator( + self, op: str, left_sql: str, right_sql: str, left_params: tuple, right_params: tuple + ) -> Tuple[str, Tuple]: + """Format binary operator with psycopg placeholder escaping. + + psycopg uses %s as parameter placeholder. When the SQL operator itself + contains % (e.g., pg_trgm similarity operator), it must be escaped as %% + to prevent psycopg from interpreting it as a placeholder prefix. + """ + # Escape % in operators for psycopg compatibility + escaped_op = op.replace('%', '%%') if '%' in op else op + sql = f"{left_sql} {escaped_op} {right_sql}" + return sql, left_params + right_params + + # endregion + + # region Constraint DDL Support (PostgreSQL-specific) + + def format_add_table_constraint_action( + self, action: "AddTableConstraint", + ) -> Tuple[str, tuple]: + """Format ADD CONSTRAINT action with PostgreSQL-specific extensions. + + Extends the base class implementation with: + - EXCLUDE constraint support (PG-specific constraint type) + - NOT VALID suffix (PG-specific: skip validation of existing rows) + """ + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraintType, ConstraintValidation, + ) + + # Handle EXCLUDE constraint (PG-specific, not in base class) + if action.constraint.constraint_type == TableConstraintType.EXCLUDE: + parts = [] + exclude_sql, params = self._format_exclude_constraint(action.constraint) + parts.append(exclude_sql) + + # NOT VALID suffix + if action.constraint.dialect_options: + validation = action.constraint.dialect_options.get('validation') + if validation == ConstraintValidation.NOVALIDATE: + parts.append("NOT VALID") + + return f"ADD {' '.join(parts)}", tuple(params) + + # Use base class for standard formatting (includes DEFERRABLE) + sql, params = super().format_add_table_constraint_action(action) + + # PostgreSQL NOT VALID suffix + if action.constraint.dialect_options: + validation = action.constraint.dialect_options.get('validation') + if validation == ConstraintValidation.NOVALIDATE: + sql += " NOT VALID" + + return sql, params + + def _format_exclude_constraint( + self, constraint: "TableConstraint", + ) -> Tuple[str, tuple]: + """Format EXCLUDE constraint (PostgreSQL-specific). + + EXCLUDE constraints use the dialect_options dict to specify: + - 'exclude_elements': List of (expression, operator) tuples + e.g., [('range', '&&')] for EXCLUDE USING gist (range WITH &&) + - 'using': The index access method (default 'gist') + e.g., 'gist', 'btree', 'spgist' + - 'where': Optional predicate for partial exclusion constraints + + Example: + TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name='exclude_range_overlap', + dialect_options={ + 'exclude_elements': [('range', '&&')], + 'using': 'gist', + } + ) + # Generates: EXCLUDE USING gist (range WITH &&) + """ + params: list = [] + + if constraint.name: + parts = ["CONSTRAINT", self.format_identifier(constraint.name)] + else: + parts = [] + + # USING clause - validate index access method. + valid_using = frozenset({"gist", "btree", "spgist", "hash", "gin", "brin"}) + using = constraint.dialect_options.get("using", "gist") if constraint.dialect_options else "gist" + if using not in valid_using: + raise ValueError( + f"Invalid index access method '{using}': must be one of {valid_using}" + ) + parts.append(f"EXCLUDE USING {using}") + + # Elements: (expression, operator) pairs - validate operators. + valid_ops = frozenset({ + "=", "<", "<=", ">", ">=", "<>", + "&&", "@>", "<@", "<<", ">>", "&<", "&>", + "~=", "@@", "?|", "?&", "is", "is not", + }) + exclude_elements = [] + if constraint.dialect_options and "exclude_elements" in constraint.dialect_options: + for expr, op in constraint.dialect_options["exclude_elements"]: + if op not in valid_ops: + raise ValueError( + f"Invalid exclude operator '{op}': must be one of {valid_ops}" + ) + if isinstance(expr, str): + exclude_elements.append(f"{self.format_identifier(expr)} WITH {op}") + else: + expr_sql, expr_params = expr.to_sql() + params.extend(expr_params) + exclude_elements.append(f"{expr_sql} WITH {op}") + + if exclude_elements: + parts.append(f"({', '.join(exclude_elements)})") + + # WHERE clause for partial exclusion constraint + if constraint.dialect_options and 'where' in constraint.dialect_options: + where_expr = constraint.dialect_options['where'] + where_sql, where_params = where_expr.to_sql() + params.extend(where_params) + parts.append(f"WHERE ({where_sql})") + + # DEFERRABLE / NOT DEFERRABLE + if constraint.deferrable is True: + if constraint.initially_deferred is True: + parts.append("DEFERRABLE INITIALLY DEFERRED") + elif constraint.initially_deferred is False: + parts.append("DEFERRABLE INITIALLY IMMEDIATE") + else: + parts.append("DEFERRABLE") + elif constraint.deferrable is False: + parts.append("NOT DEFERRABLE") + + return ' '.join(parts), tuple(params) + + # endregion + + # region Trigger Support (PostgreSQL-specific) + def supports_trigger(self) -> bool: + return True + + def supports_create_trigger(self) -> bool: + return True + + def supports_drop_trigger(self) -> bool: + return True + + # endregion + + # region Transaction Control Support + + # PostgreSQL function version support: function_name -> (min_version, max_version) + # min_version: minimum supported version (inclusive), None = all versions + # Function version requirements are defined in function_versions.py, + # categorized by topic (JSON Path, Range, hstore, pgvector, PostGIS, etc.) + # and assembled into POSTGRES_FUNCTION_VERSIONS. + from .function_versions import POSTGRES_FUNCTION_VERSIONS as _FV + _POSTGRES_FUNCTION_VERSIONS = _FV + + def supports_functions(self) -> Dict[str, "FunctionSupportInfo"]: + """Return supported SQL functions with detailed support information. + + This method combines: + 1. Core functions from rhosocial.activerecord.backend.expression.functions + 2. PostgreSQL-specific functions from rhosocial.activerecord.backend.impl.postgres.functions + + Each function is mapped to a FunctionSupportInfo indicating: + - Whether the function is supported + - If not, the reason why (PG version, extension status, etc.) + + Returns: + Dict mapping function names to FunctionSupportInfo. + """ + from .function_versions import FunctionSupportInfo + from rhosocial.activerecord.backend.expression.functions import ( + __all__ as core_functions, + ) + from rhosocial.activerecord.backend.impl.postgres import functions as postgres_functions + + result: Dict[str, FunctionSupportInfo] = {} + for func_name in core_functions: + result[func_name] = self._check_function_support(func_name) + + postgres_funcs = getattr(postgres_functions, "__all__", []) + for func_name in postgres_funcs: + if func_name not in result: + result[func_name] = self._check_function_support(func_name) + + return result + + def _check_function_support(self, func_name: str) -> "FunctionSupportInfo": + """Check function support status and return detailed information. + + Args: + func_name: Name of the function to check + + Returns: + FunctionSupportInfo with support status and reason if unsupported + """ + from .function_versions import FunctionSupportInfo + + requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) + if requirement is None: + return FunctionSupportInfo(supported=True) + + # Check PostgreSQL server version + if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: + return FunctionSupportInfo(supported=False, reason="pg_version_too_low") + if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: + return FunctionSupportInfo(supported=False, reason="pg_version_too_high") + + # Check extension requirements + if requirement.extension is not None: + if not hasattr(self, "_extensions"): + return FunctionSupportInfo(supported=False, reason="extension_not_probed") + + if requirement.ext_feature is not None: + if not self.check_extension_feature(requirement.extension, requirement.ext_feature): + if not self.is_extension_installed(requirement.extension): + return FunctionSupportInfo(supported=False, reason="extension_not_installed") + return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") + else: + if not self.is_extension_installed(requirement.extension): + return FunctionSupportInfo(supported=False, reason="extension_not_installed") + if requirement.min_ext_version is not None: + installed = self.get_extension_version(requirement.extension) + if installed is None or self._compare_versions(installed, requirement.min_ext_version) < 0: + return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") + + return FunctionSupportInfo(supported=True) + + def _is_postgres_function_supported(self, func_name: str) -> bool: + """Check if a PostgreSQL function is supported based on version and extensions. + + Checks: + 1. PostgreSQL server version (for built-in and version-gated functions) + 2. Extension installation and version (for extension-provided functions) + + For extension functions, requires _extensions to have been populated + via introspect_and_adapt(). If _extensions is not available, + extension functions return False (cannot confirm availability). + + Args: + func_name: Name of the PostgreSQL function + + Returns: + True if supported, False otherwise + """ + requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) + if requirement is None: + return True # Unregistered functions default to supported + + # Step 1: Check PostgreSQL server version + if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: + return False + if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: + return False + + # Step 2: Check extension requirements + if requirement.extension is not None: + return self._check_extension_requirement(requirement) + + return True + + def _check_extension_requirement( + self, requirement: "FunctionVersionRequirement" + ) -> bool: + """Check extension requirements based on introspect_and_adapt() results. + + Requires _extensions to have been populated via introspect_and_adapt(). + If _extensions is not available, returns False (cannot confirm + extension availability without probing the database). + """ + ext_name = requirement.extension + + # _extensions is populated by introspect_and_adapt() + # If the attribute doesn't exist, introspect_and_adapt() hasn't been called + if not hasattr(self, "_extensions"): + return False + + # Use feature-level check if ext_feature is specified + if requirement.ext_feature is not None: + return self.check_extension_feature(ext_name, requirement.ext_feature) + + # Check extension installed + if not self.is_extension_installed(ext_name): + return False + + # Check extension version + if requirement.min_ext_version is not None: + installed = self.get_extension_version(ext_name) + if installed is None: + return False + return self._compare_versions(installed, requirement.min_ext_version) >= 0 + + return True + + def supports_transaction_mode(self) -> bool: + """PostgreSQL supports READ ONLY and READ WRITE transaction modes.""" + return True + + def supports_isolation_level_in_begin(self) -> bool: + """PostgreSQL supports isolation level specification in BEGIN statement.""" + return True + + def supports_read_only_transaction(self) -> bool: + """PostgreSQL supports READ ONLY transactions.""" + return True + + def supports_deferrable_transaction(self) -> bool: + """PostgreSQL supports DEFERRABLE transactions (for SERIALIZABLE isolation).""" + return True + + def supports_savepoint(self) -> bool: + """PostgreSQL supports savepoints.""" + return True + + def format_begin_transaction( + self, expr: "BeginTransactionExpression" + ) -> Tuple[str, tuple]: + """Format BEGIN TRANSACTION statement for PostgreSQL. + + PostgreSQL syntax: + BEGIN [ ISOLATION LEVEL { READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE } ] + [ { READ WRITE | READ ONLY } ] + [ { NOT DEFERRABLE | DEFERRABLE } ] + + DEFERRABLE is only meaningful for SERIALIZABLE isolation level. + """ + params = expr.get_params() + parts = ["BEGIN"] + + isolation = params.get("isolation_level") + if isolation: + level_str = self.get_isolation_level_name(isolation) + parts.append(f"ISOLATION LEVEL {level_str}") + + mode = params.get("mode") + if mode: + mode_name = mode.name if hasattr(mode, "name") else str(mode) + if mode_name == "READ_ONLY": + parts.append("READ ONLY") + elif mode_name == "READ_WRITE": + parts.append("READ WRITE") + + deferrable = params.get("deferrable") + if deferrable is not None and isolation: + isolation_name = isolation.name if hasattr(isolation, "name") else str(isolation) + if isolation_name == "SERIALIZABLE": + parts.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") + + return " ".join(parts), () + + def format_commit_transaction( + self, expr: "CommitTransactionExpression" + ) -> Tuple[str, tuple]: + """Format COMMIT TRANSACTION statement for PostgreSQL.""" + return "COMMIT", () + + def format_rollback_transaction( + self, expr: "RollbackTransactionExpression" + ) -> Tuple[str, tuple]: + """Format ROLLBACK TRANSACTION statement for PostgreSQL. + + Supports ROLLBACK [ TO SAVEPOINT savepoint_name ]. + """ + params = expr.get_params() + savepoint = params.get("savepoint") + if savepoint: + return f"ROLLBACK TO SAVEPOINT {self.format_identifier(savepoint)}", () + return "ROLLBACK", () + + def format_savepoint( + self, expr: "SavepointExpression" + ) -> Tuple[str, tuple]: + """Format SAVEPOINT statement for PostgreSQL.""" + params = expr.get_params() + name = params.get("name", "") + return f"SAVEPOINT {self.format_identifier(name)}", () + + def format_release_savepoint( + self, expr: "ReleaseSavepointExpression" + ) -> Tuple[str, tuple]: + """Format RELEASE SAVEPOINT statement for PostgreSQL.""" + params = expr.get_params() + name = params.get("name", "") + return f"RELEASE SAVEPOINT {self.format_identifier(name)}", () + + def format_set_transaction( + self, expr: "SetTransactionExpression" + ) -> Tuple[str, tuple]: + """Format SET TRANSACTION statement for PostgreSQL. + + PostgreSQL supports setting transaction characteristics for the current + transaction or for subsequent transactions. + + Syntax: + SET TRANSACTION { ISOLATION LEVEL { ... } | { READ WRITE | READ ONLY } | [ NOT ] DEFERRABLE } [, ...] + SET SESSION CHARACTERISTICS AS TRANSACTION { ... } + """ + params = expr.get_params() + parts = [] + + if params.get("session"): + parts.append("SET SESSION CHARACTERISTICS AS TRANSACTION") + else: + parts.append("SET TRANSACTION") + + options = [] + + isolation = params.get("isolation_level") + if isolation: + level_str = self.get_isolation_level_name(isolation) + options.append(f"ISOLATION LEVEL {level_str}") + + mode = params.get("mode") + if mode: + mode_name = mode.name if hasattr(mode, "name") else str(mode) + if mode_name == "READ_ONLY": + options.append("READ ONLY") + elif mode_name == "READ_WRITE": + options.append("READ WRITE") + + deferrable = params.get("deferrable") + if deferrable is not None: + options.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") + + if options: + parts.append(" ".join(options)) + + return " ".join(parts), () + + # endregion diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py index 6bbb5b05..bf683660 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py @@ -1,490 +1,490 @@ -# tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py -""" -Tests for PostgreSQL dialect SQL injection security fixes. - -This test module verifies that string escaping and validation -methods properly sanitize user input to prevent SQL injection. -Tests are run against the actual PostgreSQL dialect. -""" -import pytest - -from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.expression.bases import BaseExpression -from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - TableConstraint, - TableConstraintType, -) -from typing import Tuple, Any - - -@pytest.fixture -def dialect(): - """Create a PostgreSQL test dialect.""" - return PostgresDialect((13, 0, 0)) - - -def test_postgres_escape_sql_string(dialect): - """Test PostgreSQL inherits _escape_sql_string.""" - result = dialect._escape_sql_string("test's value") - assert result == "test''s value" - - -def test_postgres_validate_data_type(dialect): - """Test PostgreSQL inherits _validate_data_type.""" - assert dialect._validate_data_type("INTEGER") - assert dialect._validate_data_type("VARCHAR(255)") - assert dialect._validate_data_type("TEXT") - assert not dialect._validate_data_type("TEXT; DROP TABLE users--") - - -def test_postgres_format_column_definition_data_type_validation(dialect): - """Test column definition validates data_type.""" - col_def = ColumnDefinition( - name="test_col", - data_type="VARCHAR(255)", - ) - - sql, params = dialect.format_column_definition(col_def) - assert "VARCHAR(255)" in sql - - -def test_postgres_format_column_definition_data_type_rejects_injection(dialect): - """Test that malicious data_type is rejected.""" - col_def = ColumnDefinition( - name="test_col", - data_type="VARCHAR(255); DROP TABLE users--", - ) - - with pytest.raises(ValueError, match="Invalid data type"): - dialect.format_column_definition(col_def) - - -def test_postgres_format_default_constraint_string_escaping(dialect): - """Test DEFAULT constraint string is escaped.""" - constraint = ColumnConstraint( - constraint_type=ColumnConstraintType.DEFAULT, - default_value="test's value", - ) - - sql, params = dialect._format_default_constraint(constraint) - assert "test''s value" in sql - assert "'; DROP" not in sql - - -def test_postgres_format_storage_options_string_escaping(dialect): - """Test storage options string values are escaped.""" - storage_opts = {"key": "value's"} - sql, params = dialect._format_storage_options(storage_opts) - assert "value''s" in sql - assert "'; DROP" not in sql - - -def test_postgres_format_cast_expression_valid(dialect): - """Test that CAST expression validates target_type.""" - sql, params = dialect.format_cast_expression("column", "INTEGER", (), None) - assert "INTEGER" in sql - - -def test_exclude_constraint_valid_using_methods(dialect): - """Test valid index access methods are accepted.""" - constraint = TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "using": "gist", - "exclude_elements": [("int4range", "&&")], - }, - ) - - sql, params = dialect._format_exclude_constraint(constraint) - assert "EXCLUDE USING gist" in sql - - -def test_exclude_constraint_valid_operators(dialect): - """Test valid exclude operators are accepted.""" - valid_ops = [ - ("int4range", "&&"), - ("float8range", "&&"), - ("name", "="), - ("box", "~="), - ] - - for expr, op in valid_ops: - constraint = TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "exclude_elements": [(expr, op)], - }, - ) - - sql, params = dialect._format_exclude_constraint(constraint) - assert f"WITH {op}" in sql - - -def test_exclude_constraint_rejects_invalid_using(dialect): - """Test that invalid index access method is rejected.""" - constraint = TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "using": "invalid_method", - "exclude_elements": [("range", "&&")], - }, - ) - - with pytest.raises(ValueError, match="Invalid index access method"): - dialect._format_exclude_constraint(constraint) - - -def test_exclude_constraint_rejects_invalid_operator(dialect): - """Test that invalid exclude operator is rejected.""" - constraint = TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "exclude_elements": [("range", "'; DROP TABLE users--")], - }, - ) - - with pytest.raises(ValueError, match="Invalid exclude operator"): - dialect._format_exclude_constraint(constraint) - - -def test_exclude_constraint_sql_injection_prevention(dialect): - """Test that SQL injection attempts are blocked.""" - constraint = TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "using": "gist", - "exclude_elements": [("range", "&&")], - }, - ) - - sql, params = dialect._format_exclude_constraint(constraint) - - assert "; DROP" not in sql - assert "--" not in sql - assert "/*" not in sql - - -class TestPostgresEnumSecurity: - """Tests for CREATE TYPE ENUM value escaping (fix for SQL injection risk).""" - - def test_create_type_enum_values_escaped(self, dialect): - """Test ENUM values are properly escaped with single quote doubling.""" - sql, params = dialect.format_create_type_enum_statement( - name="test_enum", - values=["it's", "normal", "test's value"], - ) - - assert "it''s" in sql - assert "test''s value" in sql - assert "'; DROP" not in sql - - def test_create_type_enum_sql_injection_blocked(self, dialect): - """Test SQL injection in ENUM values is properly escaped (not executed).""" - sql, params = dialect.format_create_type_enum_statement( - name="test_enum", - values=["normal", "'; DROP TABLE users--"], - ) - - assert "DROP TABLE users--" in sql - assert "CREATE TYPE" in sql - assert "AS ENUM" in sql - - -class TestPostgresStoredProcedureNameSecurity: - """Tests for stored procedure names using format_identifier.""" - - def test_create_procedure_name_quoted(self, dialect): - """Test CREATE PROCEDURE name is properly quoted.""" - sql, params = dialect.format_create_procedure_statement( - name="my_procedure", - body="BEGIN END", - language="PL/pgSQL", - ) - - assert '"my_procedure"' in sql - - def test_drop_procedure_name_quoted(self, dialect): - """Test DROP PROCEDURE name is properly quoted.""" - sql, params = dialect.format_drop_procedure_statement( - name="my_procedure", - ) - - assert '"my_procedure"' in sql - - def test_call_statement_name_quoted(self, dialect): - """Test CALL statement procedure name is properly quoted.""" - sql, params = dialect.format_call_statement( - name="my_procedure", - ) - - assert '"my_procedure"' in sql - - def test_procedure_with_schema_quoted(self, dialect): - """Test procedure with schema is properly quoted.""" - sql, params = dialect.format_create_procedure_statement( - schema="my_schema", - name="my_procedure", - body="BEGIN END", - language="PL/pgSQL", - ) - - assert '"my_schema"' in sql - assert '"my_procedure"' in sql - - -class TestPostgresPartialIndexWhereClauseSecurity: - """Tests for partial index WHERE clause (ToSQLProtocol support).""" - - def test_create_index_with_string_where_clause(self, dialect): - """Test CREATE INDEX with string WHERE clause (backward compatible).""" - sql, params = dialect.format_create_index_pg_statement( - index_name="idx_test", - table_name="users", - columns=["email"], - where_clause="active = true", - ) - - assert "WHERE active = true" in sql - assert params == () - - def test_create_index_with_to_sql_protocol(self, dialect): - """Test CREATE INDEX with ToSQLProtocol expression (parameterized).""" - from rhosocial.activerecord.backend.expression.bases import BaseExpression - - class MockWhereExpr(BaseExpression): - def __init__(self): - self._sql = "age >= 18" - self._params = (18,) - - def to_sql(self): - return self._sql, self._params - - sql, params = dialect.format_create_index_pg_statement( - index_name="idx_active", - table_name="users", - columns=["email"], - where_clause=MockWhereExpr(), - ) - - assert "WHERE age >= 18" in sql - assert params == (18,) - - def test_create_index_where_clause_params_collected(self, dialect): - """Test WHERE clause parameters are properly collected and returned.""" - from rhosocial.activerecord.backend.expression.bases import BaseExpression - - class MockWhereExpr(BaseExpression): - def __init__(self): - self._sql = "status = %s" - self._params = ("active",) - - def to_sql(self): - return self._sql, self._params - - sql, params = dialect.format_create_index_pg_statement( - index_name="idx_test", - table_name="users", - columns=["status"], - where_clause=MockWhereExpr(), - ) - - assert "WHERE status = %s" in sql - assert params == ("active",) - - -class TestPostgresTriggerFunctionNameSecurity: - """Tests for trigger function name using format_identifier.""" - - def test_trigger_function_name_quoted(self, dialect): - """Test trigger function name is properly quoted with double quotes.""" - from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - TriggerTiming, - TriggerEvent, - ) - - expr = CreateTriggerExpression( - dialect=dialect, - trigger_name="my_trigger", - table_name="users", - timing=TriggerTiming.BEFORE, - events=[TriggerEvent.INSERT], - function_name="my_function", - ) - - sql, params = dialect.format_create_trigger_statement(expr) - - assert '"my_function"' in sql - - def test_trigger_function_name_special_chars_quoted(self, dialect): - """Test trigger function name with special characters is quoted.""" - from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - TriggerTiming, - TriggerEvent, - ) - - expr = CreateTriggerExpression( - dialect=dialect, - trigger_name="trigger", - table_name="users", - timing=TriggerTiming.BEFORE, - events=[TriggerEvent.INSERT], - function_name="Function With Spaces", - ) - - sql, params = dialect.format_create_trigger_statement(expr) - - assert '"Function With Spaces"' in sql - - -class TestPostgresExtendedStatisticsNameSecurity: - """Tests for extended statistics names using format_identifier.""" - - def test_create_statistics_name_quoted(self, dialect): - """Test CREATE STATISTICS name is properly quoted.""" - from rhosocial.activerecord.backend.impl.postgres.expression.ddl.statistics import ( - PostgresCreateStatisticsExpression, - ) - - expr = PostgresCreateStatisticsExpression( - dialect=dialect, - name="my_stats", - table_name="users", - columns=["email"], - statistics_type="ndistinct", - ) - - sql, params = dialect.format_create_statistics_statement(expr) - - assert '"my_stats"' in sql - - def test_drop_statistics_name_quoted(self, dialect): - """Test DROP STATISTICS name is properly quoted.""" - from rhosocial.activerecord.backend.impl.postgres.expression.ddl.statistics import ( - PostgresDropStatisticsExpression, - ) - - expr = PostgresDropStatisticsExpression( - dialect=dialect, - name="my_stats", - ) - - sql, params = dialect.format_drop_statistics_statement(expr) - - assert '"my_stats"' in sql - - -# ============================================================ -# _format_partition_value — single-quote escaping -# ============================================================ - -def test_partition_value_none(dialect): - """None partition value returns NULL.""" - result = dialect._format_partition_value(None) - assert result == "NULL" - - -def test_partition_value_maxvalue(dialect): - """MAXVALUE is returned as-is (case-insensitive).""" - result = dialect._format_partition_value("MAXVALUE") - assert result == "MAXVALUE" - - -def test_partition_value_minvalue(dialect): - """MINVALUE is returned as-is (case-insensitive).""" - result = dialect._format_partition_value("minvalue") - assert result == "MINVALUE" - - -def test_partition_value_normal_string(dialect): - """Normal string value is single-quoted.""" - result = dialect._format_partition_value("2024-01-01") - assert result == "'2024-01-01'" - - -def test_partition_value_escaped_single_quote(dialect): - """String value with single quote is properly escaped.""" - result = dialect._format_partition_value("it's") - assert result == "'it''s'" - assert "'; DROP" not in result - - -def test_partition_value_injection_blocked(dialect): - """SQL injection in partition value is safely escaped (inside quotes).""" - result = dialect._format_partition_value("x'; DROP TABLE users--") - assert result.count("'") % 2 == 0 - assert result.startswith("'") - assert result.endswith("'") - - -def test_partition_value_integer(dialect): - """Integer partition value is returned as str().""" - result = dialect._format_partition_value(42) - assert result == "42" - - -# ============================================================ -# format_identifier — identifier quoting equivalence and injection immunity -# ============================================================ - -def test_format_identifier_normal(dialect): - """Normal identifier is double-quoted.""" - result = dialect.format_identifier("users") - assert result == '"users"' - - -def test_format_identifier_with_quote(dialect): - """Identifier with embedded double-quote is properly escaped.""" - result = dialect.format_identifier('table"name') - assert result == '"table""name"' - - -def test_format_identifier_injection_payload(dialect): - """Identifier with injection payload is safely contained (balanced quotes).""" - payload = 'users"; DROP TABLE users--' - result = dialect.format_identifier(payload) - assert result.count('"') % 2 == 0, f"Unbalanced quotes: {result}" - assert result == '"users""; DROP TABLE users--"' - - -def test_format_identifier_naive_vs_proper_safe(dialect): - """For safe input, naive and proper quoting produce same structure.""" - names = ["users", "orders", "products", "table_1", "camelCase"] - for name in names: - naive = f'"{name}"' - proper = dialect.format_identifier(name) - assert naive == proper, f"Mismatch for '{name}': naive={naive}, proper={proper}" - - -def test_format_identifier_naive_vs_proper_malicious(dialect): - """For malicious input, proper quoting prevents breakout that naive allows.""" - payloads = [ - 'x"; DROP TABLE users--', - 'y"; DELETE FROM t--', - 'z"; UPDATE t SET a=1--', - ] - for payload in payloads: - naive = f'"{payload}"' - proper = dialect.format_identifier(payload) - - # Naive produces odd quote count => breakout - assert naive.count('"') % 2 != 0, \ - f"Naive quoting should unbalance quotes for '{payload}': {naive}" - - # Proper produces even quote count => contained - assert proper.count('"') % 2 == 0, \ - f"Proper quoting should balance quotes for '{payload}': {proper}" - - -def test_format_identifier_empty_string(dialect): - """Empty identifier produces empty double quotes.""" +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +""" +Tests for PostgreSQL dialect SQL injection security fixes. + +This test module verifies that string escaping and validation +methods properly sanitize user input to prevent SQL injection. +Tests are run against the actual PostgreSQL dialect. +""" +import pytest + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, + TableConstraint, + TableConstraintType, +) +from typing import Tuple, Any + + +@pytest.fixture +def dialect(): + """Create a PostgreSQL test dialect.""" + return PostgresDialect((13, 0, 0)) + + +def test_postgres_escape_sql_string(dialect): + """Test PostgreSQL inherits _escape_sql_string.""" + result = dialect._escape_sql_string("test's value") + assert result == "test''s value" + + +def test_postgres_validate_data_type(dialect): + """Test PostgreSQL inherits _validate_data_type.""" + assert dialect._validate_data_type("INTEGER") + assert dialect._validate_data_type("VARCHAR(255)") + assert dialect._validate_data_type("TEXT") + assert not dialect._validate_data_type("TEXT; DROP TABLE users--") + + +def test_postgres_format_column_definition_data_type_validation(dialect): + """Test column definition validates data_type.""" + col_def = ColumnDefinition( + name="test_col", + data_type="VARCHAR(255)", + ) + + sql, params = dialect.format_column_definition(col_def) + assert "VARCHAR(255)" in sql + + +def test_postgres_format_column_definition_data_type_rejects_injection(dialect): + """Test that malicious data_type is rejected.""" + col_def = ColumnDefinition( + name="test_col", + data_type="VARCHAR(255); DROP TABLE users--", + ) + + with pytest.raises(ValueError, match="Invalid data type"): + dialect.format_column_definition(col_def) + + +def test_postgres_format_default_constraint_string_escaping(dialect): + """Test DEFAULT constraint string is escaped.""" + constraint = ColumnConstraint( + constraint_type=ColumnConstraintType.DEFAULT, + default_value="test's value", + ) + + sql, params = dialect._format_default_constraint(constraint) + assert "test''s value" in sql + assert "'; DROP" not in sql + + +def test_postgres_format_storage_options_string_escaping(dialect): + """Test storage options string values are escaped.""" + storage_opts = {"key": "value's"} + sql, params = dialect._format_storage_options(storage_opts) + assert "value''s" in sql + assert "'; DROP" not in sql + + +def test_postgres_format_cast_expression_valid(dialect): + """Test that CAST expression validates target_type.""" + sql, params = dialect.format_cast_expression("column", "INTEGER", (), None) + assert "INTEGER" in sql + + +def test_exclude_constraint_valid_using_methods(dialect): + """Test valid index access methods are accepted.""" + constraint = TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name="test_exclude", + dialect_options={ + "using": "gist", + "exclude_elements": [("int4range", "&&")], + }, + ) + + sql, params = dialect._format_exclude_constraint(constraint) + assert "EXCLUDE USING gist" in sql + + +def test_exclude_constraint_valid_operators(dialect): + """Test valid exclude operators are accepted.""" + valid_ops = [ + ("int4range", "&&"), + ("float8range", "&&"), + ("name", "="), + ("box", "~="), + ] + + for expr, op in valid_ops: + constraint = TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name="test_exclude", + dialect_options={ + "exclude_elements": [(expr, op)], + }, + ) + + sql, params = dialect._format_exclude_constraint(constraint) + assert f"WITH {op}" in sql + + +def test_exclude_constraint_rejects_invalid_using(dialect): + """Test that invalid index access method is rejected.""" + constraint = TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name="test_exclude", + dialect_options={ + "using": "invalid_method", + "exclude_elements": [("range", "&&")], + }, + ) + + with pytest.raises(ValueError, match="Invalid index access method"): + dialect._format_exclude_constraint(constraint) + + +def test_exclude_constraint_rejects_invalid_operator(dialect): + """Test that invalid exclude operator is rejected.""" + constraint = TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name="test_exclude", + dialect_options={ + "exclude_elements": [("range", "'; DROP TABLE users--")], + }, + ) + + with pytest.raises(ValueError, match="Invalid exclude operator"): + dialect._format_exclude_constraint(constraint) + + +def test_exclude_constraint_sql_injection_prevention(dialect): + """Test that SQL injection attempts are blocked.""" + constraint = TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name="test_exclude", + dialect_options={ + "using": "gist", + "exclude_elements": [("range", "&&")], + }, + ) + + sql, params = dialect._format_exclude_constraint(constraint) + + assert "; DROP" not in sql + assert "--" not in sql + assert "/*" not in sql + + +class TestPostgresEnumSecurity: + """Tests for CREATE TYPE ENUM value escaping (fix for SQL injection risk).""" + + def test_create_type_enum_values_escaped(self, dialect): + """Test ENUM values are properly escaped with single quote doubling.""" + sql, params = dialect.format_create_type_enum_statement( + name="test_enum", + values=["it's", "normal", "test's value"], + ) + + assert "it''s" in sql + assert "test''s value" in sql + assert "'; DROP" not in sql + + def test_create_type_enum_sql_injection_blocked(self, dialect): + """Test SQL injection in ENUM values is properly escaped (not executed).""" + sql, params = dialect.format_create_type_enum_statement( + name="test_enum", + values=["normal", "'; DROP TABLE users--"], + ) + + assert "DROP TABLE users--" in sql + assert "CREATE TYPE" in sql + assert "AS ENUM" in sql + + +class TestPostgresStoredProcedureNameSecurity: + """Tests for stored procedure names using format_identifier.""" + + def test_create_procedure_name_quoted(self, dialect): + """Test CREATE PROCEDURE name is properly quoted.""" + sql, params = dialect.format_create_procedure_statement( + name="my_procedure", + body="BEGIN END", + language="PL/pgSQL", + ) + + assert '"my_procedure"' in sql + + def test_drop_procedure_name_quoted(self, dialect): + """Test DROP PROCEDURE name is properly quoted.""" + sql, params = dialect.format_drop_procedure_statement( + name="my_procedure", + ) + + assert '"my_procedure"' in sql + + def test_call_statement_name_quoted(self, dialect): + """Test CALL statement procedure name is properly quoted.""" + sql, params = dialect.format_call_statement( + name="my_procedure", + ) + + assert '"my_procedure"' in sql + + def test_procedure_with_schema_quoted(self, dialect): + """Test procedure with schema is properly quoted.""" + sql, params = dialect.format_create_procedure_statement( + schema="my_schema", + name="my_procedure", + body="BEGIN END", + language="PL/pgSQL", + ) + + assert '"my_schema"' in sql + assert '"my_procedure"' in sql + + +class TestPostgresPartialIndexWhereClauseSecurity: + """Tests for partial index WHERE clause (ToSQLProtocol support).""" + + def test_create_index_with_string_where_clause(self, dialect): + """Test CREATE INDEX with string WHERE clause (backward compatible).""" + sql, params = dialect.format_create_index_pg_statement( + index_name="idx_test", + table_name="users", + columns=["email"], + where_clause="active = true", + ) + + assert "WHERE active = true" in sql + assert params == () + + def test_create_index_with_to_sql_protocol(self, dialect): + """Test CREATE INDEX with ToSQLProtocol expression (parameterized).""" + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + class MockWhereExpr(BaseExpression): + def __init__(self): + self._sql = "age >= 18" + self._params = (18,) + + def to_sql(self): + return self._sql, self._params + + sql, params = dialect.format_create_index_pg_statement( + index_name="idx_active", + table_name="users", + columns=["email"], + where_clause=MockWhereExpr(), + ) + + assert "WHERE age >= 18" in sql + assert params == (18,) + + def test_create_index_where_clause_params_collected(self, dialect): + """Test WHERE clause parameters are properly collected and returned.""" + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + class MockWhereExpr(BaseExpression): + def __init__(self): + self._sql = "status = %s" + self._params = ("active",) + + def to_sql(self): + return self._sql, self._params + + sql, params = dialect.format_create_index_pg_statement( + index_name="idx_test", + table_name="users", + columns=["status"], + where_clause=MockWhereExpr(), + ) + + assert "WHERE status = %s" in sql + assert params == ("active",) + + +class TestPostgresTriggerFunctionNameSecurity: + """Tests for trigger function name using format_identifier.""" + + def test_trigger_function_name_quoted(self, dialect): + """Test trigger function name is properly quoted with double quotes.""" + from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( + CreateTriggerExpression, + TriggerTiming, + TriggerEvent, + ) + + expr = CreateTriggerExpression( + dialect=dialect, + trigger_name="my_trigger", + table_name="users", + timing=TriggerTiming.BEFORE, + events=[TriggerEvent.INSERT], + function_name="my_function", + ) + + sql, params = dialect.format_create_trigger_statement(expr) + + assert '"my_function"' in sql + + def test_trigger_function_name_special_chars_quoted(self, dialect): + """Test trigger function name with special characters is quoted.""" + from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( + CreateTriggerExpression, + TriggerTiming, + TriggerEvent, + ) + + expr = CreateTriggerExpression( + dialect=dialect, + trigger_name="trigger", + table_name="users", + timing=TriggerTiming.BEFORE, + events=[TriggerEvent.INSERT], + function_name="Function With Spaces", + ) + + sql, params = dialect.format_create_trigger_statement(expr) + + assert '"Function With Spaces"' in sql + + +class TestPostgresExtendedStatisticsNameSecurity: + """Tests for extended statistics names using format_identifier.""" + + def test_create_statistics_name_quoted(self, dialect): + """Test CREATE STATISTICS name is properly quoted.""" + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.statistics import ( + PostgresCreateStatisticsExpression, + ) + + expr = PostgresCreateStatisticsExpression( + dialect=dialect, + name="my_stats", + table_name="users", + columns=["email"], + statistics_type="ndistinct", + ) + + sql, params = dialect.format_create_statistics_statement(expr) + + assert '"my_stats"' in sql + + def test_drop_statistics_name_quoted(self, dialect): + """Test DROP STATISTICS name is properly quoted.""" + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.statistics import ( + PostgresDropStatisticsExpression, + ) + + expr = PostgresDropStatisticsExpression( + dialect=dialect, + name="my_stats", + ) + + sql, params = dialect.format_drop_statistics_statement(expr) + + assert '"my_stats"' in sql + + +# ============================================================ +# _format_partition_value — single-quote escaping +# ============================================================ + +def test_partition_value_none(dialect): + """None partition value returns NULL.""" + result = dialect._format_partition_value(None) + assert result == "NULL" + + +def test_partition_value_maxvalue(dialect): + """MAXVALUE is returned as-is (case-insensitive).""" + result = dialect._format_partition_value("MAXVALUE") + assert result == "MAXVALUE" + + +def test_partition_value_minvalue(dialect): + """MINVALUE is returned as-is (case-insensitive).""" + result = dialect._format_partition_value("minvalue") + assert result == "MINVALUE" + + +def test_partition_value_normal_string(dialect): + """Normal string value is single-quoted.""" + result = dialect._format_partition_value("2024-01-01") + assert result == "'2024-01-01'" + + +def test_partition_value_escaped_single_quote(dialect): + """String value with single quote is properly escaped.""" + result = dialect._format_partition_value("it's") + assert result == "'it''s'" + assert "'; DROP" not in result + + +def test_partition_value_injection_blocked(dialect): + """SQL injection in partition value is safely escaped (inside quotes).""" + result = dialect._format_partition_value("x'; DROP TABLE users--") + assert result.count("'") % 2 == 0 + assert result.startswith("'") + assert result.endswith("'") + + +def test_partition_value_integer(dialect): + """Integer partition value is returned as str().""" + result = dialect._format_partition_value(42) + assert result == "42" + + +# ============================================================ +# format_identifier — identifier quoting equivalence and injection immunity +# ============================================================ + +def test_format_identifier_normal(dialect): + """Normal identifier is double-quoted.""" + result = dialect.format_identifier("users") + assert result == '"users"' + + +def test_format_identifier_with_quote(dialect): + """Identifier with embedded double-quote is properly escaped.""" + result = dialect.format_identifier('table"name') + assert result == '"table""name"' + + +def test_format_identifier_injection_payload(dialect): + """Identifier with injection payload is safely contained (balanced quotes).""" + payload = 'users"; DROP TABLE users--' + result = dialect.format_identifier(payload) + assert result.count('"') % 2 == 0, f"Unbalanced quotes: {result}" + assert result == '"users""; DROP TABLE users--"' + + +def test_format_identifier_naive_vs_proper_safe(dialect): + """For safe input, naive and proper quoting produce same structure.""" + names = ["users", "orders", "products", "table_1", "camelCase"] + for name in names: + naive = f'"{name}"' + proper = dialect.format_identifier(name) + assert naive == proper, f"Mismatch for '{name}': naive={naive}, proper={proper}" + + +def test_format_identifier_naive_vs_proper_malicious(dialect): + """For malicious input, proper quoting prevents breakout that naive allows.""" + payloads = [ + 'x"; DROP TABLE users--', + 'y"; DELETE FROM t--', + 'z"; UPDATE t SET a=1--', + ] + for payload in payloads: + naive = f'"{payload}"' + proper = dialect.format_identifier(payload) + + # Naive produces odd quote count => breakout + assert naive.count('"') % 2 != 0, \ + f"Naive quoting should unbalance quotes for '{payload}': {naive}" + + # Proper produces even quote count => contained + assert proper.count('"') % 2 == 0, \ + f"Proper quoting should balance quotes for '{payload}': {proper}" + + +def test_format_identifier_empty_string(dialect): + """Empty identifier produces empty double quotes.""" assert dialect.format_identifier("") == '""' \ No newline at end of file From 13bbe7015760ecb302cae1d744aba68e6a497c92 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 24 May 2026 19:15:50 +0800 Subject: [PATCH 10/20] test(query): add bridge for unicode/emoji/injection round-trip tests Add bridge file importing the new multilingual Unicode, emoji burst, SQL injection payload, and injection immunity tests from testsuite. --- .../feature/query/test_logging_data_summarization.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py new file mode 100644 index 00000000..f3ac80e4 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py @@ -0,0 +1,8 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py +""" +Bridge file for logging data summarization tests from the testsuite. + +This file imports the generic tests from the testsuite package and makes them +discoverable by pytest in this project's test run. +""" +from rhosocial.activerecord.testsuite.feature.query.test_logging_data_summarization import * From caa363fded98b993402dd42964997aaf8f0bfb08 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 24 May 2026 23:59:47 +0800 Subject: [PATCH 11/20] ci: upgrade testsuite source to git+https at release/v1.0.0.dev14 PyPI-hosted testsuite dev13 does not include the test_logging_data_summarization module, causing collection errors. Switch to git source at dev14 which has it. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dac3048c..32553b50 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,7 +80,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev27 - pip install rhosocial-activerecord-testsuite==1.0.0.dev13 + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev14 - name: Create postgres scenario config run: | From 0fe59cb64e50c4f9497b98c899a26108ccbd6235 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 25 May 2026 00:03:47 +0800 Subject: [PATCH 12/20] fix(backend): preserve exception chain and add credential warning - Replace `raise ... from None` with `raise ... from e` in both sync and async backends to preserve the original traceback for debugging - Add password-in-plaintext warning to PostgresConnectionConfig.to_connection_string() Co-Authored-By: Claude Opus 4.7 --- .../impl/postgres/backend/async_backend.py | 26 +++++++++---------- .../backend/impl/postgres/backend/sync.py | 26 +++++++++---------- .../backend/impl/postgres/config.py | 4 +++ 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py index beca6fbc..b4008f99 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py @@ -250,7 +250,7 @@ async def connect(self): ) except PsycopgError as e: self.log(logging.ERROR, f"Failed to connect to PostgreSQL database: {str(e)}") - raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}") from None + raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}") from e async def disconnect(self): """Close async connection to PostgreSQL database.""" @@ -265,7 +265,7 @@ async def disconnect(self): self.log(logging.INFO, "Disconnected from PostgreSQL database") except PsycopgError as e: self.log(logging.ERROR, f"Error during disconnection: {str(e)}") - raise OperationalError(f"Error during PostgreSQL disconnection: {str(e)}") from None + raise OperationalError(f"Error during PostgreSQL disconnection: {str(e)}") from e async def _get_cursor(self): """Get a database cursor, ensuring connection is active. @@ -319,13 +319,13 @@ async def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult: except PsycopgIntegrityError as e: self.log(logging.ERROR, f"Integrity error in batch: {str(e)}") - raise IntegrityError(str(e)) from None + raise IntegrityError(str(e)) from e except PsycopgError as e: self.log(logging.ERROR, f"PostgreSQL error in batch: {str(e)}") - raise DatabaseError(str(e)) from None + raise DatabaseError(str(e)) from e except Exception as e: self.log(logging.ERROR, f"Unexpected error during batch execution: {str(e)}") - raise QueryError(str(e)) from None + raise QueryError(str(e)) from e finally: if cursor: await cursor.close() @@ -576,27 +576,27 @@ async def _handle_error(self, error: Exception) -> None: if isinstance(error, PsycopgIntegrityError): if "duplicate key value violates unique constraint" in error_msg.lower(): self.log(logging.ERROR, f"Unique constraint violation: {error_msg}") - raise IntegrityError(f"Unique constraint violation: {error_msg}") + raise IntegrityError(f"Unique constraint violation: {error_msg}") from error elif "violates foreign key constraint" in error_msg.lower(): self.log(logging.ERROR, f"Foreign key constraint violation: {error_msg}") - raise IntegrityError(f"Foreign key constraint violation: {error_msg}") + raise IntegrityError(f"Foreign key constraint violation: {error_msg}") from error self.log(logging.ERROR, f"Integrity error: {error_msg}") - raise IntegrityError(error_msg) + raise IntegrityError(error_msg) from error elif isinstance(error, PsycopgOperationalError): if "deadlock detected" in error_msg.lower(): self.log(logging.ERROR, f"Deadlock error: {error_msg}") - raise DeadlockError(error_msg) + raise DeadlockError(error_msg) from error self.log(logging.ERROR, f"Operational error: {error_msg}") - raise OperationalError(error_msg) + raise OperationalError(error_msg) from error elif isinstance(error, PsycopgProgrammingError): self.log(logging.ERROR, f"Programming error: {error_msg}") - raise QueryError(error_msg) + raise QueryError(error_msg) from error elif isinstance(error, PsycopgDeadlockError): self.log(logging.ERROR, f"Deadlock error: {error_msg}") - raise DeadlockError(error_msg) + raise DeadlockError(error_msg) from error elif isinstance(error, PsycopgError): self.log(logging.ERROR, f"PostgreSQL error: {error_msg}") - raise DatabaseError(error_msg) + raise DatabaseError(error_msg) from error else: self.log(logging.ERROR, f"Unexpected error: {error_msg}") raise error diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py index 1c8eae2d..7071a2e4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py @@ -251,7 +251,7 @@ def connect(self): ) except PsycopgError as e: self.log(logging.ERROR, f"Failed to connect to PostgreSQL database: {str(e)}") - raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}") from None + raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}") from e def disconnect(self): """Close connection to PostgreSQL database.""" @@ -266,7 +266,7 @@ def disconnect(self): self.log(logging.INFO, "Disconnected from PostgreSQL database") except PsycopgError as e: self.log(logging.ERROR, f"Error during disconnection: {str(e)}") - raise OperationalError(f"Error during PostgreSQL disconnection: {str(e)}") from None + raise OperationalError(f"Error during PostgreSQL disconnection: {str(e)}") from e def _get_cursor(self): """Get a database cursor, ensuring connection is active. @@ -320,13 +320,13 @@ def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult: except PsycopgIntegrityError as e: self.log(logging.ERROR, f"Integrity error in batch: {str(e)}") - raise IntegrityError(str(e)) from None + raise IntegrityError(str(e)) from e except PsycopgError as e: self.log(logging.ERROR, f"PostgreSQL error in batch: {str(e)}") - raise DatabaseError(str(e)) from None + raise DatabaseError(str(e)) from e except Exception as e: self.log(logging.ERROR, f"Unexpected error during batch execution: {str(e)}") - raise QueryError(str(e)) from None + raise QueryError(str(e)) from e finally: if cursor: cursor.close() @@ -577,27 +577,27 @@ def _handle_error(self, error: Exception) -> None: if isinstance(error, PsycopgIntegrityError): if "duplicate key value violates unique constraint" in error_msg.lower(): self.log(logging.ERROR, f"Unique constraint violation: {error_msg}") - raise IntegrityError(f"Unique constraint violation: {error_msg}") + raise IntegrityError(f"Unique constraint violation: {error_msg}") from error elif "violates foreign key constraint" in error_msg.lower(): self.log(logging.ERROR, f"Foreign key constraint violation: {error_msg}") - raise IntegrityError(f"Foreign key constraint violation: {error_msg}") + raise IntegrityError(f"Foreign key constraint violation: {error_msg}") from error self.log(logging.ERROR, f"Integrity error: {error_msg}") - raise IntegrityError(error_msg) + raise IntegrityError(error_msg) from error elif isinstance(error, PsycopgOperationalError): if "deadlock detected" in error_msg.lower(): self.log(logging.ERROR, f"Deadlock error: {error_msg}") - raise DeadlockError(error_msg) + raise DeadlockError(error_msg) from error self.log(logging.ERROR, f"Operational error: {error_msg}") - raise OperationalError(error_msg) + raise OperationalError(error_msg) from error elif isinstance(error, PsycopgProgrammingError): self.log(logging.ERROR, f"Programming error: {error_msg}") - raise QueryError(error_msg) + raise QueryError(error_msg) from error elif isinstance(error, PsycopgDeadlockError): self.log(logging.ERROR, f"Deadlock error: {error_msg}") - raise DeadlockError(error_msg) + raise DeadlockError(error_msg) from error elif isinstance(error, PsycopgError): self.log(logging.ERROR, f"PostgreSQL error: {error_msg}") - raise DatabaseError(error_msg) + raise DatabaseError(error_msg) from error else: self.log(logging.ERROR, f"Unexpected error: {error_msg}") raise error diff --git a/src/rhosocial/activerecord/backend/impl/postgres/config.py b/src/rhosocial/activerecord/backend/impl/postgres/config.py index e43ee8e8..251b3306 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/config.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/config.py @@ -124,6 +124,10 @@ def to_dict(self) -> Dict[str, Any]: def to_connection_string(self) -> str: """Convert configuration to postgres connection URI. + Warning: + The returned connection string includes the password in plaintext. + Avoid logging or storing this string. + Returns: postgres connection URI string From acbf0677031107270cc3387fa993f2f56a3af5ec Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 25 May 2026 00:38:47 +0800 Subject: [PATCH 13/20] fix(test): register PostgresJSONBAdapter for JsonUser JSON fields psycopg3 automatically deserializes JSONB columns to Python dict/list, but JsonUser's JSON fields are defined as Optional[str] in the testsuite. Without a type adapter, Pydantic validation fails when receiving dict instead of str from the database. Register PostgresJSONBAdapter for all JSON fields in setup_json_user_fixtures and setup_async_json_user_fixtures. The adapter's from_database() method converts dict/list back to JSON string when target_type is str. Co-Authored-By: Claude Opus 4.7 --- tests/providers/query.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/providers/query.py b/tests/providers/query.py index 77687471..8d93134a 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -220,7 +220,20 @@ def setup_blog_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], T return self._setup_multiple_models(models_and_tables, scenario_name) def setup_json_user_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for the JSON user model.""" + """Sets up the database for the JSON user model. + + Registers PostgresJSONBAdapter for JSON fields so that psycopg3's + automatic JSONB deserialization (dict/list) is converted back to str + before Pydantic validation, matching the model's Optional[str] type. + """ + from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresJSONBAdapter + + jsonb_adapter = PostgresJSONBAdapter() + json_fields = ["settings", "tags", "profile", "roles", "scores", "subscription", "preferences"] + for field_name in json_fields: + if field_name not in JsonUser.__field_adapters__: + JsonUser.__field_adapters__[field_name] = (jsonb_adapter, str) + models_and_tables = [ (JsonUser, "json_users"), ] @@ -289,8 +302,21 @@ async def setup_async_blog_fixtures(self, scenario_name: str) -> Tuple[Type[Acti ], scenario_name) async def setup_async_json_user_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for the async JSON user model.""" + """Sets up the database for the async JSON user model. + + Registers PostgresJSONBAdapter for JSON fields so that psycopg3's + automatic JSONB deserialization (dict/list) is converted back to str + before Pydantic validation, matching the model's Optional[str] type. + """ from rhosocial.activerecord.testsuite.feature.query.fixtures.async_json_models import AsyncJsonUser + from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresJSONBAdapter + + jsonb_adapter = PostgresJSONBAdapter() + json_fields = ["settings", "tags", "profile", "roles", "scores", "subscription", "preferences"] + for field_name in json_fields: + if field_name not in AsyncJsonUser.__field_adapters__: + AsyncJsonUser.__field_adapters__[field_name] = (jsonb_adapter, str) + return await self._setup_multiple_models_async([ (AsyncJsonUser, "json_users"), ], scenario_name) From 08e7e9faa6af4fb4a52dddf5c570abcc3c342610 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 25 May 2026 01:39:47 +0800 Subject: [PATCH 14/20] fix(adapters): inherit BaseSQLTypeAdapter to properly handle Optional types All Postgres adapters (PostgresJSONBAdapter, PostgresListAdapter, PostgresNetworkAddressAdapter, PostgresEnumAdapter, PostgresHstoreAdapter) previously implemented SQLTypeAdapter Protocol directly and overrode from_database() / to_database() directly, bypassing BaseSQLTypeAdapter's Optional type unwrapping logic. This caused Pydantic ValidationError when psycopg3 auto-deserializes JSONB columns to dict/list, because the adapter received Optional[str] as target_type but checked `target_type is str` which never matched. By inheriting BaseSQLTypeAdapter and implementing _do_from_database() / _do_to_database() instead, Optional unwrapping is handled by the base class before reaching the adapter's type-specific logic. Also removes mock-based tests for ipaddress unavailability, as ipaddress is a standard library module guaranteed to exist in all supported Python versions. Co-Authored-By: Claude Opus 4.7 --- .../backend/impl/postgres/adapters/base.py | 141 +++++------------- .../backend/impl/postgres/adapters/hstore.py | 59 ++------ .../backend/impl/postgres/adapters/json.py | 26 ++-- .../feature/backend/test_adapters.py | 28 ++-- 4 files changed, 71 insertions(+), 183 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py b/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py index 03517f70..c63d7180 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py @@ -7,14 +7,14 @@ import json from enum import Enum as PythonEnum -from typing import Any, Dict, List, Type, Union, Optional, Set +from typing import Any, Dict, List, Type, Union, Optional, Set, get_args, get_origin from psycopg.types.json import Jsonb -from rhosocial.activerecord.backend.type_adapter import SQLTypeAdapter +from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter -class PostgresListAdapter(SQLTypeAdapter): +class PostgresListAdapter(BaseSQLTypeAdapter): """ Adapts Python list to PostgreSQL array types. @@ -22,19 +22,15 @@ class PostgresListAdapter(SQLTypeAdapter): Python lists natively for PostgreSQL array types. """ - @property - def supported_types(self) -> Dict[Type, List[Any]]: - return {list: [list]} + def __init__(self): + super().__init__() + self._register_type(list, list) - def to_database(self, value: list, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: - if value is None: - return None + def _do_to_database(self, value: list, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: # psycopg handles list natively for PostgreSQL arrays return value - def from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> list: - if value is None: - return None + def _do_from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> list: if isinstance(value, list): return value raise TypeError(f"Cannot convert {type(value).__name__} to list") @@ -73,7 +69,7 @@ def from_database_batch( return values -class PostgresJSONBAdapter(SQLTypeAdapter): +class PostgresJSONBAdapter(BaseSQLTypeAdapter): """ Adapts Python dict to PostgreSQL JSONB and vice-versa. @@ -82,26 +78,22 @@ class PostgresJSONBAdapter(SQLTypeAdapter): we need to serialize back to JSON string. """ - @property - def supported_types(self) -> Dict[Type, List[Any]]: - return {dict: [Jsonb]} + def __init__(self): + super().__init__() + self._register_type(dict, Jsonb) - def to_database(self, value: Union[dict, list], target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: - if value is None: - return None + def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: return Jsonb(value) - def from_database( + def _do_from_database( self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None - ) -> Union[dict, list]: - if value is None: - return None + ) -> Union[dict, list, str]: # For string target type, serialize dict/list back to JSON string # This is needed because psycopg auto-deserializes JSON to dict, - # but model fields may be defined as str type + # but model fields may be defined as Optional[str] type if target_type is str: if isinstance(value, (dict, list)): - return json.dumps(value) + return json.dumps(value, ensure_ascii=False) return value # For dict/list target types, return as-is if isinstance(value, dict): @@ -114,19 +106,7 @@ def from_database( def to_database_batch( self, values: List[Any], target_type: Type, options: Optional[Dict[str, Any]] = None ) -> List[Any]: - """Optimized batch JSON conversion to database format. - - Creates Jsonb wrappers for all values in a single pass, - reducing function call overhead compared to individual calls. - - Args: - values: List of values to convert - target_type: Target type (Jsonb) - options: Optional conversion options - - Returns: - List of Jsonb-wrapped values - """ + """Optimized batch JSON conversion to database format.""" result = [] for value in values: if value is None: @@ -138,67 +118,47 @@ def to_database_batch( def from_database_batch( self, values: List[Any], target_type: Type, options: Optional[Dict[str, Any]] = None ) -> List[Any]: - """Optimized batch JSON deserialization from database. - - Args: - values: List of values from database - target_type: Target Python type (dict, list, or str) - options: Optional conversion options - - Returns: - List of converted values - """ + """Optimized batch JSON deserialization from database.""" result = [] for value in values: if value is None: result.append(None) elif target_type is str: if isinstance(value, (dict, list)): - result.append(json.dumps(value)) + result.append(json.dumps(value, ensure_ascii=False)) else: result.append(value) else: - # For dict/list target types, return as-is result.append(value) return result -class PostgresNetworkAddressAdapter(SQLTypeAdapter): +class PostgresNetworkAddressAdapter(BaseSQLTypeAdapter): """ Adapts Python ipaddress objects to PostgreSQL network types. """ - @property - def supported_types(self) -> Dict[Type, List[Any]]: + def __init__(self): + super().__init__() try: import ipaddress - - return { - ipaddress.IPv4Address: [str], - ipaddress.IPv6Address: [str], - ipaddress.IPv4Network: [str], - ipaddress.IPv6Network: [str], - } + self._register_type(ipaddress.IPv4Address, str) + self._register_type(ipaddress.IPv6Address, str) + self._register_type(ipaddress.IPv4Network, str) + self._register_type(ipaddress.IPv6Network, str) except ImportError: - return {} + pass - def to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: - if value is None: - return None + def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: return str(value) - def from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: - if value is None: - return None + def _do_from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: try: import ipaddress - return ipaddress.ip_address(value) except (ImportError, ValueError): try: - # try network import ipaddress - return ipaddress.ip_network(value) except (ImportError, ValueError): return value @@ -256,7 +216,7 @@ def from_database_batch( return result -class PostgresEnumAdapter(SQLTypeAdapter): +class PostgresEnumAdapter(BaseSQLTypeAdapter): """PostgreSQL ENUM type adapter. This adapter handles conversion between Python values and PostgreSQL enum values. @@ -267,26 +227,11 @@ class PostgresEnumAdapter(SQLTypeAdapter): - None (NULL) """ - @property - def supported_types(self) -> Dict[Type, Set[Type]]: - """Return supported type mappings.""" - return {str: {str}} - - def to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: - """Convert Python value to PostgreSQL enum value. - - Args: - value: String, Python Enum, or None - target_type: Target type (not used for enums) - options: Optional conversion options - - 'enum_type': PostgresEnumType for validation - - Returns: - Enum value string, or None - """ - if value is None: - return None + def __init__(self): + super().__init__() + self._register_type(str, str) + def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: # Handle Python Enum if isinstance(value, PythonEnum): result = value.name @@ -306,21 +251,7 @@ def to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, return result - def from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: - """Convert PostgreSQL enum value to Python string. - - Args: - value: Enum value from database - target_type: Target Python type - options: Optional conversion options - - 'enum_class': Python Enum class to convert to - - Returns: - String value, Python Enum instance, or None - """ - if value is None: - return None - + def _do_from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: # If already a string, check if we should convert to Python Enum if isinstance(value, str): if options and "enum_class" in options: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/adapters/hstore.py b/src/rhosocial/activerecord/backend/impl/postgres/adapters/hstore.py index a33b5c3b..670b8d8f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/adapters/hstore.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/adapters/hstore.py @@ -9,13 +9,13 @@ PostgreSQL Documentation: https://www.postgresql.org/docs/current/hstore.html """ -from typing import Dict, Optional, Type, Union, List +from typing import Any, Dict, Optional, Type, Union, List -from rhosocial.activerecord.backend.type_adapter import SQLTypeAdapter +from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter from rhosocial.activerecord.backend.impl.postgres.types.hstore import PostgresHstore -class PostgresHstoreAdapter(SQLTypeAdapter): +class PostgresHstoreAdapter(BaseSQLTypeAdapter): """Adapter for converting between PostgresHstore and database values. Supports conversion from: @@ -30,34 +30,17 @@ class PostgresHstoreAdapter(SQLTypeAdapter): - None → None """ - @property - def supported_types(self) -> Dict[Type, Type]: - """Return mapping of Python types to their database representation types.""" - return { - PostgresHstore: str, - dict: str, - } + def __init__(self): + super().__init__() + self._register_type(PostgresHstore, str) + self._register_type(dict, str) - def to_database( + def _do_to_database( self, - value: Union[PostgresHstore, Dict[str, Optional[str]], str, None], + value: Union[PostgresHstore, Dict[str, Optional[str]], str], target_type: Type, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: - """Convert a Python value to a database-compatible format. - - Args: - value: The Python value to convert - target_type: The target database type - - Returns: - hstore literal string or None - - Raises: - TypeError: If value type is not supported - """ - if value is None: - return None - if isinstance(value, PostgresHstore): return value.to_postgres_string() @@ -73,26 +56,12 @@ def to_database( f"Expected PostgresHstore, dict, str, or None." ) - def from_database( + def _do_from_database( self, - value: Union[str, PostgresHstore, Dict, None], - source_type: Type, + value: Union[str, PostgresHstore, Dict], + target_type: Type, + options: Optional[Dict[str, Any]] = None, ) -> Optional[PostgresHstore]: - """Convert a database value to a Python PostgresHstore object. - - Args: - value: The database value to convert - source_type: The source database type - - Returns: - PostgresHstore instance or None - - Raises: - TypeError: If value type is not supported - """ - if value is None: - return None - if isinstance(value, PostgresHstore): return value diff --git a/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py b/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py index ac4be88c..c92f154f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py @@ -16,16 +16,16 @@ """ import json -from typing import Any, Dict, List, Optional, Set, Type, Union +from typing import Any, Dict, List, Optional, Set, Type, Union, get_args, get_origin from psycopg.types.json import Jsonb -from rhosocial.activerecord.backend.type_adapter import SQLTypeAdapter +from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter from ..types.json import PostgresJsonPath -class PostgresJSONBAdapter(SQLTypeAdapter): +class PostgresJSONBAdapter(BaseSQLTypeAdapter): """ Adapts Python dict to PostgreSQL JSONB and vice-versa. @@ -34,26 +34,22 @@ class PostgresJSONBAdapter(SQLTypeAdapter): we need to serialize back to JSON string. """ - @property - def supported_types(self) -> Dict[Type, List[Any]]: - return {dict: [Jsonb]} + def __init__(self): + super().__init__() + self._register_type(dict, Jsonb) - def to_database(self, value: Union[dict, list], target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: - if value is None: - return None + def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: return Jsonb(value) - def from_database( + def _do_from_database( self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None - ) -> Union[dict, list]: - if value is None: - return None + ) -> Union[dict, list, str]: # For string target type, serialize dict/list back to JSON string # This is needed because psycopg auto-deserializes JSON to dict, - # but model fields may be defined as str type + # but model fields may be defined as Optional[str] type if target_type is str: if isinstance(value, (dict, list)): - return json.dumps(value) + return json.dumps(value, ensure_ascii=False) return value # For dict/list target types, return as-is if isinstance(value, dict): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py index 6dc8ac6f..34ce6b4b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py @@ -12,7 +12,8 @@ def test_jsonb_adapter_supported_types(): adapter = PostgresJSONBAdapter() supported = adapter.supported_types - assert supported[dict] == [Jsonb] + assert dict in supported + assert Jsonb in supported[dict] def test_jsonb_to_database(): @@ -57,18 +58,16 @@ def test_jsonb_from_database(): def test_network_adapter_supported_types(): adapter = PostgresNetworkAddressAdapter() supported = adapter.supported_types - assert supported[ipaddress.IPv4Address] == [str] - assert supported[ipaddress.IPv6Address] == [str] - assert supported[ipaddress.IPv4Network] == [str] - assert supported[ipaddress.IPv6Network] == [str] + assert ipaddress.IPv4Address in supported + assert str in supported[ipaddress.IPv4Address] + assert ipaddress.IPv6Address in supported + assert str in supported[ipaddress.IPv6Address] + assert ipaddress.IPv4Network in supported + assert str in supported[ipaddress.IPv4Network] + assert ipaddress.IPv6Network in supported + assert str in supported[ipaddress.IPv6Network] -def test_network_adapter_supported_types_no_ipaddress(mocker): - """Test that it returns empty dict if ipaddress module is missing.""" - mocker.patch('builtins.__import__', side_effect=ImportError) - adapter = PostgresNetworkAddressAdapter() - assert adapter.supported_types == {} - @pytest.mark.parametrize("ip_obj, expected_str", [ (ipaddress.ip_address("192.168.1.1"), "192.168.1.1"), @@ -112,13 +111,6 @@ def test_network_from_database_none(): adapter = PostgresNetworkAddressAdapter() assert adapter.from_database(None, ipaddress.IPv4Address) is None -def test_network_from_database_no_ipaddress(mocker): - """Test that it returns the original value if ipaddress module is missing.""" - mocker.patch('builtins.__import__', side_effect=ImportError) - adapter = PostgresNetworkAddressAdapter() - ip_str = "192.168.1.1" - assert adapter.from_database(ip_str, str) == ip_str - # ============================================================================= # Batch Operation Tests From 5668112b42c614407701ca676526052a0f4f8054 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 28 May 2026 15:09:15 +0800 Subject: [PATCH 15/20] test: add Pydantic native validation coverage Bridge the shared testsuite Pydantic native validation contract into the PostgreSQL provider, align dev test dependencies, and add a Python 3.14 / PostgreSQL 18 benchmark job for CI parity. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/test.yml | 127 ++++++++++++++++++ requirements-dev.txt | 54 +++++++- tests/providers/basic.py | 42 ++++-- .../schema/pydantic_validated_models.sql | 13 ++ .../basic/test_pydantic_native_validation.py | 16 +++ 5 files changed, 233 insertions(+), 19 deletions(-) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/basic/schema/pydantic_validated_models.sql create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32553b50..6b59df50 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,133 @@ permissions: contents: read jobs: + benchmark-python-314-postgres-18: + name: Run benchmarks with Python 3.14 / postgres 18 + runs-on: ubuntu-latest + permissions: + contents: read + + services: + postgres: + image: postgres:18 + env: + POSTGRES_DB: test_db + POSTGRES_USER: root + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres-exts: + image: vistart/postgres-exts:18 + env: + POSTGRES_DB: test_db + POSTGRES_USER: root + POSTGRES_PASSWORD: password + ports: + - 5433:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Check out the repository + uses: actions/checkout@v6 + + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Install activerecord and testsuite + run: | + pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev27 + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev14 + + - name: Create postgres scenario config + run: | + cat > postgres_scenarios.yaml << EOF + scenarios: + postgres_default: + host: 127.0.0.1 + port: 5432 + database: test_db + username: root + password: password + postgres_exts: + host: 127.0.0.1 + port: 5433 + database: test_db + username: root + password: password + EOF + echo "POSTGRES_SCENARIOS_CONFIG_PATH=$(pwd)/postgres_scenarios.yaml" >> $GITHUB_ENV + + - name: Install postgres client for health check + run: | + sudo apt-get update + sudo apt-get install --yes postgresql-client + + - name: Wait for postgres service + run: | + until pg_isready -h 127.0.0.1 -p 5432 -U root; do + echo "Waiting for postgres..." + sleep 2 + done + echo "postgres is ready!" + until pg_isready -h 127.0.0.1 -p 5433 -U root; do + echo "Waiting for postgres-exts..." + sleep 2 + done + echo "postgres-exts is ready!" + + - name: Run benchmark suite + run: | + set -o pipefail + export PYTHONPATH=src:tests + TESTSUITE_DIR=$(python - <<'PY' + import pathlib + import rhosocial.activerecord.testsuite as testsuite + + print(pathlib.Path(testsuite.__file__).parent) + PY + ) + python -m pytest \ + --rootdir=. \ + --confcutdir=. \ + "$TESTSUITE_DIR/benchmark" \ + -m benchmark \ + --benchmark-size=small \ + --benchmark-sort=mean \ + --tb=short | tee benchmark-output.txt + + - name: Write benchmark conclusion + if: always() + run: | + { + echo "## Python 3.14 / postgres 18 Benchmark Conclusion" + echo + echo "- Python: 3.14" + echo "- postgres: 18" + echo "- Scope: testsuite benchmark suite" + echo "- Size: small" + echo "- Result: benchmark job fails only when benchmark execution or correctness checks fail." + echo + echo '```text' + grep -A 120 "benchmark:" benchmark-output.txt || cat benchmark-output.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + test: name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} runs-on: ubuntu-latest diff --git a/requirements-dev.txt b/requirements-dev.txt index 0773a3c8..582de444 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,9 +1,51 @@ -pytest -pyyaml -rhosocial-activerecord-testsuite +# requirements-dev.txt +# Testing framework for unit and integration tests +# Core framework for running test suites and fixtures +# Version ~=8.4.2 provides stable testing capabilities +pytest~=8.4.2 pytest-asyncio + +# Pytest plugin for performance benchmarking +# Provides benchmark fixtures and benchmark command-line options +pytest-benchmark + +# Pytest plugin for mocking +# Provides the 'mocker' fixture for patching and mocking objects in tests +pytest-mock + +# Async SQLite support (test only) +aiosqlite>=0.19.0 + +# Async file operations for tests +aiofiles>=23.0.0 + +# Parallel test execution plugin for pytest +# Enables faster test execution through parallel processing +# Version ~=3.8.0 provides stable parallel test execution +pytest-xdist~=3.8.0 + +# Code coverage measurement tool +# Measures test coverage and generates coverage reports +# Version ~=7.10.7 provides comprehensive coverage analysis +coverage~=7.10.7 + +# Coverage integration with pytest +# Combines pytest and coverage for seamless test coverage reporting +pytest-cov>=4.0.0 + +# Pydantic with email validation support +# Adds email field validation capabilities to the core framework +# Required for testing email field validation in user models pydantic[email] + +# YAML file processing library +# Used for configuration file handling and test data +# Version ~=6.0.3 provides safe YAML parsing and generation +pyyaml~=6.0.3 + +# Rich terminal output library +# Enhanced output formatting for test results and debugging +rich + +# Test timeout plugin for long-running database integration tests pytest-timeout -pytest-mock -pytest-xdist -pytest-cov \ No newline at end of file diff --git a/tests/providers/basic.py b/tests/providers/basic.py index ee1eb06b..52ecf7db 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -30,6 +30,7 @@ User as UserBase, TypeCase as TypeCaseBase, ValidatedFieldUser as ValidatedFieldUserBase, TypeTestModel as TypeTestModelBase, ValidatedUser as ValidatedUserBase, TypeAdapterTest as TypeAdapterTestBase, YesOrNoBooleanAdapter, + PydanticValidatedModel as PydanticValidatedModelBase, MappedUser as MappedUserBase, MappedPost as MappedPostBase, MappedComment as MappedCommentBase, ColumnMappingModel as ColumnMappingModelBase, MixedAnnotationModel as MixedAnnotationModelBase ) @@ -38,6 +39,7 @@ AsyncUser as AsyncUserBase, AsyncTypeCase as AsyncTypeCaseBase, AsyncValidatedUser as AsyncValidatedUserBase, AsyncValidatedFieldUser as AsyncValidatedFieldUserBase, AsyncTypeTestModel as AsyncTypeTestModelBase, AsyncTypeAdapterTest as AsyncTypeAdapterTestBase, + AsyncPydanticValidatedModel as AsyncPydanticValidatedModelBase, AsyncMappedUser as AsyncMappedUserBase, AsyncMappedPost as AsyncMappedPostBase, AsyncMappedComment as AsyncMappedCommentBase, AsyncColumnMappingModel as AsyncColumnMappingModelBase, AsyncMixedAnnotationModel as AsyncMixedAnnotationModelBase @@ -45,10 +47,10 @@ # Conditionally import Python 3.10+ models User310 = TypeCase310 = ValidatedFieldUser310 = TypeTestModel310 = ValidatedUser310 = None -TypeAdapterTest310 = MappedUser310 = MappedPost310 = MappedComment310 = None +TypeAdapterTest310 = PydanticValidatedModel310 = MappedUser310 = MappedPost310 = MappedComment310 = None ColumnMappingModel310 = MixedAnnotationModel310 = None AsyncUser310 = AsyncTypeCase310 = AsyncValidatedFieldUser310 = AsyncTypeTestModel310 = None -AsyncValidatedUser310 = AsyncTypeAdapterTest310 = AsyncMappedUser310 = AsyncMappedPost310 = None +AsyncValidatedUser310 = AsyncTypeAdapterTest310 = AsyncPydanticValidatedModel310 = AsyncMappedUser310 = AsyncMappedPost310 = None AsyncMappedComment310 = AsyncColumnMappingModel310 = AsyncMixedAnnotationModel310 = None if sys.version_info >= (3, 10): @@ -56,7 +58,7 @@ from rhosocial.activerecord.testsuite.feature.basic.fixtures.models_py310 import ( User as User310, TypeCase as TypeCase310, ValidatedFieldUser as ValidatedFieldUser310, TypeTestModel as TypeTestModel310, ValidatedUser as ValidatedUser310, - TypeAdapterTest as TypeAdapterTest310, + TypeAdapterTest as TypeAdapterTest310, PydanticValidatedModel as PydanticValidatedModel310, MappedUser as MappedUser310, MappedPost as MappedPost310, MappedComment as MappedComment310, ColumnMappingModel as ColumnMappingModel310, MixedAnnotationModel as MixedAnnotationModel310 ) @@ -64,6 +66,7 @@ AsyncUser as AsyncUser310, AsyncTypeCase as AsyncTypeCase310, AsyncValidatedUser as AsyncValidatedUser310, AsyncValidatedFieldUser as AsyncValidatedFieldUser310, AsyncTypeTestModel as AsyncTypeTestModel310, AsyncTypeAdapterTest as AsyncTypeAdapterTest310, + AsyncPydanticValidatedModel as AsyncPydanticValidatedModel310, AsyncMappedUser as AsyncMappedUser310, AsyncMappedPost as AsyncMappedPost310, AsyncMappedComment as AsyncMappedComment310, AsyncColumnMappingModel as AsyncColumnMappingModel310, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel310 @@ -73,10 +76,10 @@ # Conditionally import Python 3.11+ models User311 = TypeCase311 = ValidatedFieldUser311 = TypeTestModel311 = ValidatedUser311 = None -TypeAdapterTest311 = MappedUser311 = MappedPost311 = MappedComment311 = None +TypeAdapterTest311 = PydanticValidatedModel311 = MappedUser311 = MappedPost311 = MappedComment311 = None ColumnMappingModel311 = MixedAnnotationModel311 = None AsyncUser311 = AsyncTypeCase311 = AsyncValidatedFieldUser311 = AsyncTypeTestModel311 = None -AsyncValidatedUser311 = AsyncTypeAdapterTest311 = AsyncMappedUser311 = AsyncMappedPost311 = None +AsyncValidatedUser311 = AsyncTypeAdapterTest311 = AsyncPydanticValidatedModel311 = AsyncMappedUser311 = AsyncMappedPost311 = None AsyncMappedComment311 = AsyncColumnMappingModel311 = AsyncMixedAnnotationModel311 = None if sys.version_info >= (3, 11): @@ -84,7 +87,7 @@ from rhosocial.activerecord.testsuite.feature.basic.fixtures.models_py311 import ( User as User311, TypeCase as TypeCase311, ValidatedFieldUser as ValidatedFieldUser311, TypeTestModel as TypeTestModel311, ValidatedUser as ValidatedUser311, - TypeAdapterTest as TypeAdapterTest311, + TypeAdapterTest as TypeAdapterTest311, PydanticValidatedModel as PydanticValidatedModel311, MappedUser as MappedUser311, MappedPost as MappedPost311, MappedComment as MappedComment311, ColumnMappingModel as ColumnMappingModel311, MixedAnnotationModel as MixedAnnotationModel311 ) @@ -92,6 +95,7 @@ AsyncUser as AsyncUser311, AsyncTypeCase as AsyncTypeCase311, AsyncValidatedUser as AsyncValidatedUser311, AsyncValidatedFieldUser as AsyncValidatedFieldUser311, AsyncTypeTestModel as AsyncTypeTestModel311, AsyncTypeAdapterTest as AsyncTypeAdapterTest311, + AsyncPydanticValidatedModel as AsyncPydanticValidatedModel311, AsyncMappedUser as AsyncMappedUser311, AsyncMappedPost as AsyncMappedPost311, AsyncMappedComment as AsyncMappedComment311, AsyncColumnMappingModel as AsyncColumnMappingModel311, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel311 @@ -101,10 +105,10 @@ # Conditionally import Python 3.12+ models User312 = TypeCase312 = ValidatedFieldUser312 = TypeTestModel312 = ValidatedUser312 = None -TypeAdapterTest312 = MappedUser312 = MappedPost312 = MappedComment312 = None +TypeAdapterTest312 = PydanticValidatedModel312 = MappedUser312 = MappedPost312 = MappedComment312 = None ColumnMappingModel312 = MixedAnnotationModel312 = None AsyncUser312 = AsyncTypeCase312 = AsyncValidatedFieldUser312 = AsyncTypeTestModel312 = None -AsyncValidatedUser312 = AsyncTypeAdapterTest312 = AsyncMappedUser312 = AsyncMappedPost312 = None +AsyncValidatedUser312 = AsyncTypeAdapterTest312 = AsyncPydanticValidatedModel312 = AsyncMappedUser312 = AsyncMappedPost312 = None AsyncMappedComment312 = AsyncColumnMappingModel312 = AsyncMixedAnnotationModel312 = None if sys.version_info >= (3, 12): @@ -112,7 +116,7 @@ from rhosocial.activerecord.testsuite.feature.basic.fixtures.models_py312 import ( User as User312, TypeCase as TypeCase312, ValidatedFieldUser as ValidatedFieldUser312, TypeTestModel as TypeTestModel312, ValidatedUser as ValidatedUser312, - TypeAdapterTest as TypeAdapterTest312, + TypeAdapterTest as TypeAdapterTest312, PydanticValidatedModel as PydanticValidatedModel312, MappedUser as MappedUser312, MappedPost as MappedPost312, MappedComment as MappedComment312, ColumnMappingModel as ColumnMappingModel312, MixedAnnotationModel as MixedAnnotationModel312 ) @@ -120,6 +124,7 @@ AsyncUser as AsyncUser312, AsyncTypeCase as AsyncTypeCase312, AsyncValidatedUser as AsyncValidatedUser312, AsyncValidatedFieldUser as AsyncValidatedFieldUser312, AsyncTypeTestModel as AsyncTypeTestModel312, AsyncTypeAdapterTest as AsyncTypeAdapterTest312, + AsyncPydanticValidatedModel as AsyncPydanticValidatedModel312, AsyncMappedUser as AsyncMappedUser312, AsyncMappedPost as AsyncMappedPost312, AsyncMappedComment as AsyncMappedComment312, AsyncColumnMappingModel as AsyncColumnMappingModel312, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel312 @@ -144,6 +149,7 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s TypeTestModel = _select_model_class(TypeTestModelBase, TypeTestModel312, TypeTestModel311, TypeTestModel310, "TypeTestModel") ValidatedUser = _select_model_class(ValidatedUserBase, ValidatedUser312, ValidatedUser311, ValidatedUser310, "ValidatedUser") TypeAdapterTest = _select_model_class(TypeAdapterTestBase, TypeAdapterTest312, TypeAdapterTest311, TypeAdapterTest310, "TypeAdapterTest") +PydanticValidatedModel = _select_model_class(PydanticValidatedModelBase, PydanticValidatedModel312, PydanticValidatedModel311, PydanticValidatedModel310, "PydanticValidatedModel") MappedUser = _select_model_class(MappedUserBase, MappedUser312, MappedUser311, MappedUser310, "MappedUser") MappedPost = _select_model_class(MappedPostBase, MappedPost312, MappedPost311, MappedPost310, "MappedPost") MappedComment = _select_model_class(MappedCommentBase, MappedComment312, MappedComment311, MappedComment310, "MappedComment") @@ -157,6 +163,7 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s AsyncTypeTestModel = _select_model_class(AsyncTypeTestModelBase, AsyncTypeTestModel312, AsyncTypeTestModel311, AsyncTypeTestModel310, "AsyncTypeTestModel") AsyncValidatedUser = _select_model_class(AsyncValidatedUserBase, AsyncValidatedUser312, AsyncValidatedUser311, AsyncValidatedUser310, "AsyncValidatedUser") AsyncTypeAdapterTest = _select_model_class(AsyncTypeAdapterTestBase, AsyncTypeAdapterTest312, AsyncTypeAdapterTest311, AsyncTypeAdapterTest310, "AsyncTypeAdapterTest") +AsyncPydanticValidatedModel = _select_model_class(AsyncPydanticValidatedModelBase, AsyncPydanticValidatedModel312, AsyncPydanticValidatedModel311, AsyncPydanticValidatedModel310, "AsyncPydanticValidatedModel") AsyncMappedUser = _select_model_class(AsyncMappedUserBase, AsyncMappedUser312, AsyncMappedUser311, AsyncMappedUser310, "AsyncMappedUser") AsyncMappedPost = _select_model_class(AsyncMappedPostBase, AsyncMappedPost312, AsyncMappedPost311, AsyncMappedPost310, "AsyncMappedPost") AsyncMappedComment = _select_model_class(AsyncMappedCommentBase, AsyncMappedComment312, AsyncMappedComment311, AsyncMappedComment310, "AsyncMappedComment") @@ -299,6 +306,10 @@ def setup_validated_user_model(self, scenario_name: str) -> Type[ActiveRecord]: """Sets up the database for validated user model tests.""" return self._setup_model(ValidatedUser, scenario_name, "validated_users") + def setup_pydantic_validated_model(self, scenario_name: str) -> Type[ActiveRecord]: + """Sets up the database for Pydantic native validation tests.""" + return self._setup_model(PydanticValidatedModel, scenario_name, "pydantic_validated_models") + def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: """Sets up the database for MappedUser, MappedPost, and MappedComment models.""" return self._setup_multiple_models([ @@ -353,6 +364,10 @@ async def setup_async_validated_user_model(self, scenario_name: str) -> Type[Act """Sets up the database for async validated user model tests.""" return await self._setup_async_model(AsyncValidatedUser, scenario_name, "validated_users") + async def setup_async_pydantic_validated_model(self, scenario_name: str) -> Type[ActiveRecord]: + """Sets up the database for async Pydantic native validation tests.""" + return await self._setup_async_model(AsyncPydanticValidatedModel, scenario_name, "pydantic_validated_models") + async def setup_async_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: """Sets up the database for AsyncMappedUser, AsyncMappedPost, and AsyncMappedComment models.""" return await self._setup_multiple_models_async([ @@ -375,8 +390,8 @@ async def cleanup_after_test_async(self, scenario_name: str): """ tables_to_drop = [ 'users', 'type_cases', 'type_tests', 'validated_field_users', - 'validated_users', 'type_adapter_tests', 'posts', 'comments', - 'column_mapping_items', 'mixed_annotation_items' + 'validated_users', 'pydantic_validated_models', 'type_adapter_tests', + 'posts', 'comments', 'column_mapping_items', 'mixed_annotation_items' ] for backend_instance in self._active_async_backends: try: @@ -409,8 +424,8 @@ def cleanup_after_test(self, scenario_name: str): """ tables_to_drop = [ 'users', 'type_cases', 'type_tests', 'validated_field_users', - 'validated_users', 'type_adapter_tests', 'posts', 'comments', - 'column_mapping_items', 'mixed_annotation_items' + 'validated_users', 'pydantic_validated_models', 'type_adapter_tests', + 'posts', 'comments', 'column_mapping_items', 'mixed_annotation_items' ] for backend_instance in self._active_backends: try: @@ -466,6 +481,7 @@ def get_worker_connection_params(self, scenario_name: str, fixture_type: str = N 'type_test': 'type_tests', 'validated_field_user': 'validated_field_users', 'validated_user': 'validated_users', + 'pydantic_validated_model': 'pydantic_validated_models', 'type_adapter_test': 'type_adapter_tests', } table_name = table_map.get(base_type, 'users') diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/pydantic_validated_models.sql b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/pydantic_validated_models.sql new file mode 100644 index 00000000..53e07498 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/pydantic_validated_models.sql @@ -0,0 +1,13 @@ +-- tests/rhosocial/activerecord_postgres_test/feature/basic/schema/pydantic_validated_models.sql +CREATE TABLE pydantic_validated_models ( + id SERIAL PRIMARY KEY, + code VARCHAR(32), + quantity INTEGER, + step_count INTEGER, + price NUMERIC(10, 2), + start_at TIMESTAMP, + end_at TIMESTAMP, + status VARCHAR(32), + normalized_name VARCHAR(50), + created_token VARCHAR(255) +); diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py new file mode 100644 index 00000000..a1581ecb --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py @@ -0,0 +1,16 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py +""" +Bridge file for Pydantic native validation tests from the shared testsuite. + +This file imports the reusable testsuite tests so pytest discovers and runs them +against the PostgreSQL providers. +""" + +# IMPORTANT: These imports are required for pytest discovery of testsuite +# fixtures and shared test classes. Do not remove them as unused imports. +from rhosocial.activerecord.testsuite.feature.basic.conftest import ( + async_pydantic_validated_model, + pydantic_validated_model, +) + +from rhosocial.activerecord.testsuite.feature.basic.test_pydantic_native_validation import * From 3c51b2cb05a921ee4ac77d09ff3ad5cba5c2eaac Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 28 May 2026 15:38:05 +0800 Subject: [PATCH 16/20] ci: fix PostgreSQL benchmark coverage Register benchmark providers so the testsuite benchmark job runs PostgreSQL scenarios instead of skipping, and restore Python 3.8-specific dependency constraints for the CI matrix. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/test.yml | 9 +- requirements-3.8.txt | 2 + requirements-dev-3.8.txt | 31 ++++ tests/providers/backend_benchmark.py | 160 ++++++++++++++++++++ tests/providers/crud_benchmark.py | 179 +++++++++++++++++++++++ tests/providers/mixin_benchmark.py | 15 ++ tests/providers/query_benchmark.py | 15 ++ tests/providers/registry.py | 25 +++- tests/providers/transaction_benchmark.py | 17 +++ 9 files changed, 449 insertions(+), 4 deletions(-) create mode 100644 requirements-3.8.txt create mode 100644 requirements-dev-3.8.txt create mode 100644 tests/providers/backend_benchmark.py create mode 100644 tests/providers/crud_benchmark.py create mode 100644 tests/providers/mixin_benchmark.py create mode 100644 tests/providers/query_benchmark.py create mode 100644 tests/providers/transaction_benchmark.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b59df50..4b95c3da 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -201,8 +201,13 @@ jobs: - name: Install dependencies run: | - pip install -r requirements.txt - pip install -r requirements-dev.txt + if [ "${{ matrix.python-version }}" == "3.8" ]; then + pip install -r requirements-3.8.txt + pip install -r requirements-dev-3.8.txt + else + pip install -r requirements.txt + pip install -r requirements-dev.txt + fi - name: Install activerecord and testsuite run: | diff --git a/requirements-3.8.txt b/requirements-3.8.txt new file mode 100644 index 00000000..b9c2fd97 --- /dev/null +++ b/requirements-3.8.txt @@ -0,0 +1,2 @@ +psycopg[binary] +rhosocial-activerecord diff --git a/requirements-dev-3.8.txt b/requirements-dev-3.8.txt new file mode 100644 index 00000000..06cfdc9f --- /dev/null +++ b/requirements-dev-3.8.txt @@ -0,0 +1,31 @@ +# requirements-dev-3.8.txt +# Testing framework for unit and integration tests +# Version ~=8.3.5 is the latest pytest version compatible with Python 3.8 +pytest~=8.3.5 +pytest-asyncio + +# Pytest plugin for mocking +pytest-mock + +# Async SQLite support and async file operations for tests +aiosqlite>=0.19.0 +aiofiles>=23.0.0 + +# Parallel test execution plugin for pytest +pytest-xdist + +# Code coverage measurement tool and pytest integration +coverage +pytest-cov>=4.0.0 + +# Pydantic with email validation support +pydantic[email] + +# YAML file processing library +pyyaml + +# Rich terminal output library +rich + +# Test timeout plugin for long-running database integration tests +pytest-timeout diff --git a/tests/providers/backend_benchmark.py b/tests/providers/backend_benchmark.py new file mode 100644 index 00000000..16ad4f81 --- /dev/null +++ b/tests/providers/backend_benchmark.py @@ -0,0 +1,160 @@ +"""PostgreSQL provider for backend direct benchmark tests.""" + +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.testsuite.benchmark.backend.interfaces import ( + BackendBenchmarkContext, +) +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import ( + make_user_payloads, + payload_count_for_size, +) + +from .scenarios import get_enabled_scenarios, get_scenario + + +class BackendBenchmarkProvider: + def __init__(self): + self._active_backends = [] + self._active_async_backends = [] + + def get_benchmark_scenarios(self): + return list(get_enabled_scenarios().keys()) + + def setup_benchmark_sync(self, scenario: str, size: str): + from rhosocial.activerecord.backend.impl.postgres import PostgresBackend + + _, config = get_scenario(scenario) + backend = PostgresBackend(connection_config=config) + self._initialize_schema(backend) + payloads = make_user_payloads(payload_count_for_size(size)) + record_ids = self._seed_sync(backend, payloads) + self._active_backends.append(backend) + return BackendBenchmarkContext( + scenario=scenario, + size=size, + backend=backend, + payloads=payloads, + record_ids=record_ids, + sql=self._sql_templates(), + params_factory=self._params_factory, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + ) + + def teardown_benchmark_sync(self, scenario: str, context: BackendBenchmarkContext) -> None: + self._cleanup_sync() + + async def setup_benchmark_async(self, scenario: str, size: str): + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + _, config = get_scenario(scenario) + backend = AsyncPostgresBackend(connection_config=config) + await self._initialize_schema_async(backend) + payloads = make_user_payloads(payload_count_for_size(size)) + record_ids = await self._seed_async(backend, payloads) + self._active_async_backends.append(backend) + return BackendBenchmarkContext( + scenario=scenario, + size=size, + backend=backend, + payloads=payloads, + record_ids=record_ids, + sql=self._sql_templates(), + params_factory=self._params_factory, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + ) + + async def teardown_benchmark_async(self, scenario: str, context: BackendBenchmarkContext) -> None: + await self._cleanup_async() + + def _initialize_schema(self, backend): + options = ExecutionOptions(stmt_type=StatementType.DDL) + backend.execute("DROP TABLE IF EXISTS benchmark_users", options=options) + backend.execute(self._schema_sql(), options=options) + + async def _initialize_schema_async(self, backend): + options = ExecutionOptions(stmt_type=StatementType.DDL) + await backend.execute("DROP TABLE IF EXISTS benchmark_users", options=options) + await backend.execute(self._schema_sql(), options=options) + + def _seed_sync(self, backend, payloads): + record_ids = [] + for payload in payloads: + result = backend.execute( + self._sql_templates()["insert"], + self._params_factory("insert", payload), + ) + if result.affected_rows != 1 or not result.data: + raise AssertionError("failed to seed sync backend benchmark row") + record_ids.append(result.data[0]["id"]) + return record_ids + + async def _seed_async(self, backend, payloads): + record_ids = [] + for payload in payloads: + result = await backend.execute( + self._sql_templates()["insert"], + self._params_factory("insert", payload), + ) + if result.affected_rows != 1 or not result.data: + raise AssertionError("failed to seed async backend benchmark row") + record_ids.append(result.data[0]["id"]) + return record_ids + + def _sql_templates(self): + return { + "insert": """ +INSERT INTO benchmark_users ( + username, email, age, balance, notes, is_active, created_at, updated_at +) VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +RETURNING id +""", + "find_one": "SELECT * FROM benchmark_users WHERE id = %s", + "update": "UPDATE benchmark_users SET username = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", + "delete": "DELETE FROM benchmark_users WHERE id = %s", + } + + def _params_factory(self, operation, payload): + if operation != "insert": + raise ValueError(f"unsupported backend benchmark operation: {operation}") + return ( + payload["username"], + payload["email"], + payload["age"], + payload["balance"], + payload["notes"], + payload["is_active"], + ) + + def _schema_sql(self): + return """ +CREATE TABLE benchmark_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + age INTEGER, + balance DOUBLE PRECISION NOT NULL DEFAULT 0.0, + notes TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP +) +""" + + def _cleanup_sync(self): + for backend in self._active_backends: + try: + backend.disconnect() + except Exception: + pass + self._active_backends.clear() + + async def _cleanup_async(self): + for backend in self._active_async_backends: + try: + await backend.disconnect() + except Exception: + pass + self._active_async_backends.clear() diff --git a/tests/providers/crud_benchmark.py b/tests/providers/crud_benchmark.py new file mode 100644 index 00000000..1c35af46 --- /dev/null +++ b/tests/providers/crud_benchmark.py @@ -0,0 +1,179 @@ +"""PostgreSQL provider for CRUD benchmark tests.""" + +import sys +from typing import Type + +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import ( + make_user_payloads, + payload_count_for_size, +) +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models import ( + AsyncBenchmarkUser as AsyncBenchmarkUserBase, + BenchmarkUser as BenchmarkUserBase, +) +from rhosocial.activerecord.testsuite.benchmark.crud.interfaces import CrudBenchmarkContext +from rhosocial.activerecord.testsuite.utils import select_fixture + +from .scenarios import get_enabled_scenarios, get_scenario + +BenchmarkUser310 = AsyncBenchmarkUser310 = None +BenchmarkUser311 = AsyncBenchmarkUser311 = None +BenchmarkUser312 = AsyncBenchmarkUser312 = None + +if sys.version_info >= (3, 10): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py310 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser310, + BenchmarkUser as BenchmarkUser310, + ) + +if sys.version_info >= (3, 11): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py311 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser311, + BenchmarkUser as BenchmarkUser311, + ) + +if sys.version_info >= (3, 12): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py312 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser312, + BenchmarkUser as BenchmarkUser312, + ) + + +BenchmarkUser = select_fixture( + *[ + candidate + for candidate in (BenchmarkUser312, BenchmarkUser311, BenchmarkUser310, BenchmarkUserBase) + if candidate + ] +) +AsyncBenchmarkUser = select_fixture( + *[ + candidate + for candidate in ( + AsyncBenchmarkUser312, + AsyncBenchmarkUser311, + AsyncBenchmarkUser310, + AsyncBenchmarkUserBase, + ) + if candidate + ] +) + + +class CrudBenchmarkProvider: + def __init__(self): + self._active_backends = [] + self._active_async_backends = [] + + def get_benchmark_scenarios(self): + return list(get_enabled_scenarios().keys()) + + def setup_benchmark_sync(self, scenario: str, size: str): + model_class = self._setup_model(BenchmarkUser, scenario) + payloads = make_user_payloads(payload_count_for_size(size)) + record_ids = [] + for payload in payloads: + instance = model_class(**payload) + rows = instance.save() + if rows != 1 or instance.id is None: + raise AssertionError("failed to seed sync CRUD benchmark row") + record_ids.append(instance.id) + return CrudBenchmarkContext( + scenario=scenario, + size=size, + model_class=model_class, + payloads=payloads, + record_ids=record_ids, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + ) + + def teardown_benchmark_sync(self, scenario: str, context: CrudBenchmarkContext) -> None: + self._cleanup_sync() + + async def setup_benchmark_async(self, scenario: str, size: str): + model_class = await self._setup_async_model(AsyncBenchmarkUser, scenario) + payloads = make_user_payloads(payload_count_for_size(size)) + record_ids = [] + for payload in payloads: + instance = model_class(**payload) + rows = await instance.save() + if rows != 1 or instance.id is None: + raise AssertionError("failed to seed async CRUD benchmark row") + record_ids.append(instance.id) + return CrudBenchmarkContext( + scenario=scenario, + size=size, + model_class=model_class, + payloads=payloads, + record_ids=record_ids, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + ) + + async def teardown_benchmark_async(self, scenario: str, context: CrudBenchmarkContext) -> None: + await self._cleanup_async() + + def _setup_model(self, model_class: Type[ActiveRecord], scenario: str) -> Type[ActiveRecord]: + backend_class, config = get_scenario(scenario) + model_class.configure(config, backend_class) + self._initialize_schema(model_class.__backend__) + if model_class.__backend__ not in self._active_backends: + self._active_backends.append(model_class.__backend__) + return model_class + + async def _setup_async_model( + self, model_class: Type[ActiveRecord], scenario: str + ) -> Type[ActiveRecord]: + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + _, config = get_scenario(scenario) + await model_class.configure(config, AsyncPostgresBackend) + await self._initialize_schema_async(model_class.__backend__) + if model_class.__backend__ not in self._active_async_backends: + self._active_async_backends.append(model_class.__backend__) + return model_class + + def _initialize_schema(self, backend): + options = ExecutionOptions(stmt_type=StatementType.DDL) + backend.execute("DROP TABLE IF EXISTS benchmark_users", options=options) + backend.execute(self._schema_sql(), options=options) + + async def _initialize_schema_async(self, backend): + options = ExecutionOptions(stmt_type=StatementType.DDL) + await backend.execute("DROP TABLE IF EXISTS benchmark_users", options=options) + await backend.execute(self._schema_sql(), options=options) + + def _schema_sql(self): + return """ +CREATE TABLE benchmark_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + age INTEGER, + balance DOUBLE PRECISION NOT NULL DEFAULT 0.0, + notes TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP +) +""" + + def _cleanup_sync(self): + for backend in self._active_backends: + try: + backend.disconnect() + except Exception: + pass + self._active_backends.clear() + + async def _cleanup_async(self): + for backend in self._active_async_backends: + try: + await backend.disconnect() + except Exception: + pass + self._active_async_backends.clear() diff --git a/tests/providers/mixin_benchmark.py b/tests/providers/mixin_benchmark.py new file mode 100644 index 00000000..0e47555e --- /dev/null +++ b/tests/providers/mixin_benchmark.py @@ -0,0 +1,15 @@ +"""PostgreSQL provider for mixin benchmark tests.""" + +from rhosocial.activerecord.testsuite.benchmark.mixin.interfaces import MixinBenchmarkContext + +from .crud_benchmark import CrudBenchmarkProvider + + +class MixinBenchmarkProvider(CrudBenchmarkProvider): + def setup_benchmark_sync(self, scenario: str, size: str): + context = super().setup_benchmark_sync(scenario, size) + return MixinBenchmarkContext(**context.__dict__) + + async def setup_benchmark_async(self, scenario: str, size: str): + context = await super().setup_benchmark_async(scenario, size) + return MixinBenchmarkContext(**context.__dict__) diff --git a/tests/providers/query_benchmark.py b/tests/providers/query_benchmark.py new file mode 100644 index 00000000..cca4f5c6 --- /dev/null +++ b/tests/providers/query_benchmark.py @@ -0,0 +1,15 @@ +"""PostgreSQL provider for query benchmark tests.""" + +from rhosocial.activerecord.testsuite.benchmark.query.interfaces import QueryBenchmarkContext + +from .crud_benchmark import CrudBenchmarkProvider + + +class QueryBenchmarkProvider(CrudBenchmarkProvider): + def setup_benchmark_sync(self, scenario: str, size: str): + context = super().setup_benchmark_sync(scenario, size) + return QueryBenchmarkContext(**context.__dict__) + + async def setup_benchmark_async(self, scenario: str, size: str): + context = await super().setup_benchmark_async(scenario, size) + return QueryBenchmarkContext(**context.__dict__) diff --git a/tests/providers/registry.py b/tests/providers/registry.py index 69a1c98f..03263a6b 100644 --- a/tests/providers/registry.py +++ b/tests/providers/registry.py @@ -12,6 +12,11 @@ from .query import QueryProvider from .basic_connection import BasicConnectionProvider from .query_connection import QueryConnectionProvider +from .backend_benchmark import BackendBenchmarkProvider +from .crud_benchmark import CrudBenchmarkProvider +from .mixin_benchmark import MixinBenchmarkProvider +from .query_benchmark import QueryBenchmarkProvider +from .transaction_benchmark import TransactionBenchmarkProvider # Create a single, global instance of the ProviderRegistry for this backend. provider_registry = ProviderRegistry() @@ -24,5 +29,21 @@ provider_registry.register("feature.query.IQueryProvider", QueryProvider) # Register connection pool context awareness providers -provider_registry.register("feature.basic.connection.IBasicConnectionProvider", BasicConnectionProvider) -provider_registry.register("feature.query.connection.IQueryConnectionProvider", QueryConnectionProvider) +provider_registry.register( + "feature.basic.connection.IBasicConnectionProvider", + BasicConnectionProvider, +) +provider_registry.register( + "feature.query.connection.IQueryConnectionProvider", + QueryConnectionProvider, +) + +# Register benchmark providers. +provider_registry.register("benchmark.backend.IBackendBenchmarkProvider", BackendBenchmarkProvider) +provider_registry.register("benchmark.crud.ICrudBenchmarkProvider", CrudBenchmarkProvider) +provider_registry.register("benchmark.query.IQueryBenchmarkProvider", QueryBenchmarkProvider) +provider_registry.register( + "benchmark.transaction.ITransactionBenchmarkProvider", + TransactionBenchmarkProvider, +) +provider_registry.register("benchmark.mixin.IMixinBenchmarkProvider", MixinBenchmarkProvider) diff --git a/tests/providers/transaction_benchmark.py b/tests/providers/transaction_benchmark.py new file mode 100644 index 00000000..3330de2e --- /dev/null +++ b/tests/providers/transaction_benchmark.py @@ -0,0 +1,17 @@ +"""PostgreSQL provider for transaction benchmark tests.""" + +from rhosocial.activerecord.testsuite.benchmark.transaction.interfaces import ( + TransactionBenchmarkContext, +) + +from .crud_benchmark import CrudBenchmarkProvider + + +class TransactionBenchmarkProvider(CrudBenchmarkProvider): + def setup_benchmark_sync(self, scenario: str, size: str): + context = super().setup_benchmark_sync(scenario, size) + return TransactionBenchmarkContext(**context.__dict__) + + async def setup_benchmark_async(self, scenario: str, size: str): + context = await super().setup_benchmark_async(scenario, size) + return TransactionBenchmarkContext(**context.__dict__) From a314c491b111afc6b3f91bcd5239c8d85e0ab1f9 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 29 May 2026 21:55:44 +0800 Subject: [PATCH 17/20] test: update PostgreSQL benchmarks and testsuite dependency Consolidate PostgreSQL backend benchmark relocation, FastAPI benchmark coverage, benchmark insert id fixes, and the CI testsuite dependency switch to the published PyPI package. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/test.yml | 56 ++++- pyproject.toml | 3 + requirements-dev-3.8.txt | 4 + requirements-dev.txt | 4 + tests/benchmark/__init__.py | 0 tests/benchmark/backend/__init__.py | 0 tests/benchmark/backend/conftest.py | 212 ++++++++++++++++ .../backend/test_execute_crud_async.py | 89 +++++++ .../backend/test_execute_crud_sync.py | 83 ++++++ .../backend/test_execute_many_async.py | 19 ++ .../backend/test_execute_many_sync.py | 18 ++ tests/benchmark/backend/workloads.py | 94 +++++++ tests/providers/backend_benchmark.py | 160 ------------ tests/providers/fastapi_benchmark.py | 236 ++++++++++++++++++ tests/providers/registry.py | 4 +- 15 files changed, 816 insertions(+), 166 deletions(-) create mode 100644 tests/benchmark/__init__.py create mode 100644 tests/benchmark/backend/__init__.py create mode 100644 tests/benchmark/backend/conftest.py create mode 100644 tests/benchmark/backend/test_execute_crud_async.py create mode 100644 tests/benchmark/backend/test_execute_crud_sync.py create mode 100644 tests/benchmark/backend/test_execute_many_async.py create mode 100644 tests/benchmark/backend/test_execute_many_sync.py create mode 100644 tests/benchmark/backend/workloads.py delete mode 100644 tests/providers/backend_benchmark.py create mode 100644 tests/providers/fastapi_benchmark.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b95c3da..266692fb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,7 +68,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev27 - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev14 + pip install rhosocial-activerecord-testsuite==1.0.0.dev14 - name: Create postgres scenario config run: | @@ -121,11 +121,42 @@ jobs: python -m pytest \ --rootdir=. \ --confcutdir=. \ - "$TESTSUITE_DIR/benchmark" \ + "$TESTSUITE_DIR/benchmark/crud" \ + "$TESTSUITE_DIR/benchmark/query" \ + "$TESTSUITE_DIR/benchmark/transaction" \ + "$TESTSUITE_DIR/benchmark/mixin" \ -m benchmark \ --benchmark-size=small \ --benchmark-sort=mean \ --tb=short | tee benchmark-output.txt + python -m pytest \ + --rootdir=. \ + --confcutdir=. \ + tests/benchmark/backend \ + -m benchmark \ + --benchmark-size=small \ + --benchmark-sort=mean \ + --tb=short | tee -a benchmark-output.txt + + - name: Run FastAPI benchmark suite + run: | + set -o pipefail + export PYTHONPATH=src:tests + TESTSUITE_DIR=$(python - <<'PY' + import pathlib + import rhosocial.activerecord.testsuite as testsuite + + print(pathlib.Path(testsuite.__file__).parent) + PY + ) + python -m pytest \ + --rootdir=. \ + --confcutdir=. \ + "$TESTSUITE_DIR/benchmark/fastapi" \ + -m benchmark_fastapi \ + --benchmark-size=small \ + --benchmark-sort=mean \ + --tb=short | tee fastapi-benchmark-output.txt - name: Write benchmark conclusion if: always() @@ -135,7 +166,7 @@ jobs: echo echo "- Python: 3.14" echo "- postgres: 18" - echo "- Scope: testsuite benchmark suite" + echo "- Scope: testsuite generic benchmarks + PostgreSQL backend direct benchmarks" echo "- Size: small" echo "- Result: benchmark job fails only when benchmark execution or correctness checks fail." echo @@ -144,6 +175,23 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" + - name: Write FastAPI benchmark conclusion + if: always() + run: | + { + echo "## Python 3.14 / postgres 18 FastAPI Benchmark Conclusion" + echo + echo "- Python: 3.14" + echo "- postgres: 18" + echo "- Scope: testsuite FastAPI database benchmarks" + echo "- Size: small" + echo "- Result: benchmark job fails only when FastAPI benchmark execution or correctness checks fail." + echo + echo '```text' + grep -A 120 "benchmark:" fastapi-benchmark-output.txt || cat fastapi-benchmark-output.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + test: name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} runs-on: ubuntu-latest @@ -212,7 +260,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev27 - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev14 + pip install rhosocial-activerecord-testsuite==1.0.0.dev14 - name: Create postgres scenario config run: | diff --git a/pyproject.toml b/pyproject.toml index 118b3d99..337f192c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,8 @@ test = [ "pytest>=7.0.0", "coverage>=7.0.0", "pytest-mock>=3.0.0", + "fastapi>=0.110", + "httpx>=0.27", ] dev = [ "black>=23.0.0", @@ -86,6 +88,7 @@ markers = [ "requires_protocol: marks tests requiring specific backend protocol support", "requires_functions: marks tests requiring specific database functions", "requires_inner_join: marks tests requiring INNER JOIN support", + "benchmark_fastapi: marks FastAPI benchmark tests", ] [tool.coverage.run] diff --git a/requirements-dev-3.8.txt b/requirements-dev-3.8.txt index 06cfdc9f..c6ed77b9 100644 --- a/requirements-dev-3.8.txt +++ b/requirements-dev-3.8.txt @@ -4,6 +4,10 @@ pytest~=8.3.5 pytest-asyncio +# Pytest plugin for performance benchmarking +# Provides benchmark fixtures and marker registration used during collection +pytest-benchmark + # Pytest plugin for mocking pytest-mock diff --git a/requirements-dev.txt b/requirements-dev.txt index 582de444..0966b148 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -38,6 +38,10 @@ pytest-cov>=4.0.0 # Required for testing email field validation in user models pydantic[email] +# FastAPI benchmark dependencies +fastapi>=0.110 +httpx>=0.27 + # YAML file processing library # Used for configuration file handling and test data # Version ~=6.0.3 provides safe YAML parsing and generation diff --git a/tests/benchmark/__init__.py b/tests/benchmark/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/benchmark/backend/__init__.py b/tests/benchmark/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/benchmark/backend/conftest.py b/tests/benchmark/backend/conftest.py new file mode 100644 index 00000000..543e4860 --- /dev/null +++ b/tests/benchmark/backend/conftest.py @@ -0,0 +1,212 @@ +"""PostgreSQL direct backend benchmark fixtures.""" + +import asyncio +from dataclasses import dataclass +from typing import Any, Callable, List + +import pytest + +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import ( + make_user_payloads, + payload_count_for_size, +) + +from providers.scenarios import get_enabled_scenarios, get_scenario + + +@dataclass +class BackendBenchmarkContext: + scenario: str + size: str + backend: Any + payloads: List[dict] + record_ids: List[Any] + sql: dict + params_factory: Callable[..., Any] + backend_namespace: str + backend_name: str + + +DQL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DQL, process_result_set=True) +DDL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DDL) +RETURNING_ID_OPTIONS = ExecutionOptions(stmt_type=StatementType.DML, process_result_set=True) + +SCENARIO_PARAMS = list(get_enabled_scenarios().keys()) or [ + pytest.param( + "default", + marks=pytest.mark.skip(reason="No PostgreSQL benchmark scenarios found"), + ) +] + + +def pytest_addoption(parser): + parser.addoption( + "--benchmark-size", + action="store", + default="small", + choices=("small", "medium", "large"), + help="Data size for PostgreSQL backend benchmark scenarios.", + ) + + +@pytest.fixture(scope="function") +def benchmark_size(request): + return request.config.getoption("--benchmark-size") + + +def pytest_configure(config): + config.addinivalue_line("markers", "benchmark: Mark tests as performance benchmarks") + config.addinivalue_line("markers", "benchmark_sync: Mark synchronous benchmark tests") + config.addinivalue_line("markers", "benchmark_async: Mark asynchronous benchmark tests") + config.addinivalue_line("markers", "benchmark_read: Mark read-oriented benchmark tests") + config.addinivalue_line("markers", "benchmark_write: Mark write-oriented benchmark tests") + config.addinivalue_line("markers", "benchmark_backend: Mark backend direct benchmark tests") + + +@pytest.fixture(scope="function", params=SCENARIO_PARAMS) +def postgres_backend_sync_context(request, benchmark_size): + from rhosocial.activerecord.backend.impl.postgres import PostgresBackend + + scenario = request.param + _, config = get_scenario(scenario) + backend = PostgresBackend(connection_config=config) + _initialize_schema(backend) + payloads = make_user_payloads(payload_count_for_size(benchmark_size)) + record_ids = _seed_sync(backend, payloads) + try: + yield BackendBenchmarkContext( + scenario=scenario, + size=benchmark_size, + backend=backend, + payloads=payloads, + record_ids=record_ids, + sql=_sql_templates(), + params_factory=_params_factory, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + ) + finally: + backend.disconnect() + + +@pytest.fixture(scope="function", params=SCENARIO_PARAMS) +def postgres_backend_async_context(request, benchmark_size): + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + scenario = request.param + _, config = get_scenario(scenario) + loop = asyncio.new_event_loop() + backend = AsyncPostgresBackend(connection_config=config) + try: + loop.run_until_complete(_initialize_schema_async(backend)) + payloads = make_user_payloads(payload_count_for_size(benchmark_size)) + record_ids = loop.run_until_complete(_seed_async(backend, payloads)) + yield ( + BackendBenchmarkContext( + scenario=scenario, + size=benchmark_size, + backend=backend, + payloads=payloads, + record_ids=record_ids, + sql=_sql_templates(), + params_factory=_params_factory, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + ), + loop.run_until_complete, + ) + finally: + loop.run_until_complete(backend.disconnect()) + loop.close() + + +def _initialize_schema(backend): + backend.execute("DROP TABLE IF EXISTS benchmark_users", options=DDL_OPTIONS) + backend.execute(_schema_sql(), options=DDL_OPTIONS) + + +async def _initialize_schema_async(backend): + await backend.execute("DROP TABLE IF EXISTS benchmark_users", options=DDL_OPTIONS) + await backend.execute(_schema_sql(), options=DDL_OPTIONS) + + +def _seed_sync(backend, payloads): + record_ids = [] + for payload in payloads: + result = backend.execute( + _sql_templates()["insert"], + _params_factory("insert", payload), + options=RETURNING_ID_OPTIONS, + ) + if result.affected_rows != 1 or not result.data: + raise AssertionError("failed to seed sync PostgreSQL backend benchmark row") + record_ids.append(result.data[0]["id"]) + return record_ids + + +async def _seed_async(backend, payloads): + record_ids = [] + for payload in payloads: + result = await backend.execute( + _sql_templates()["insert"], + _params_factory("insert", payload), + options=RETURNING_ID_OPTIONS, + ) + if result.affected_rows != 1 or not result.data: + raise AssertionError("failed to seed async PostgreSQL backend benchmark row") + record_ids.append(result.data[0]["id"]) + return record_ids + + +def _sql_templates(): + return { + "insert": """ +INSERT INTO benchmark_users ( + username, email, age, balance, notes, is_active, created_at, updated_at +) VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +RETURNING id +""", + "insert_many": """ +INSERT INTO benchmark_users ( + username, email, age, balance, notes, is_active, created_at, updated_at +) VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +""", + "find_one": "SELECT * FROM benchmark_users WHERE id = %s", + "update": ( + "UPDATE benchmark_users " + "SET username = %s, updated_at = CURRENT_TIMESTAMP " + "WHERE id = %s" + ), + "delete": "DELETE FROM benchmark_users WHERE id = %s", + } + + +def _params_factory(operation, payload): + if operation not in {"insert", "insert_many"}: + raise ValueError(f"unsupported backend benchmark operation: {operation}") + return ( + payload["username"], + payload["email"], + payload["age"], + payload["balance"], + payload["notes"], + payload["is_active"], + ) + + +def _schema_sql(): + return """ +CREATE TABLE benchmark_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + age INTEGER, + balance DOUBLE PRECISION NOT NULL DEFAULT 0.0, + notes TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP +) +""" diff --git a/tests/benchmark/backend/test_execute_crud_async.py b/tests/benchmark/backend/test_execute_crud_async.py new file mode 100644 index 00000000..5165c33c --- /dev/null +++ b/tests/benchmark/backend/test_execute_crud_async.py @@ -0,0 +1,89 @@ +"""Asynchronous PostgreSQL direct backend CRUD benchmarks.""" + +import pytest + +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import make_user_payload + +from .workloads import ( + DQL_OPTIONS, + execute_delete_one_async, + execute_find_one_async, + execute_insert_one_async, + execute_update_one_async, +) + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_async +@pytest.mark.benchmark_write +def test_postgres_backend_execute_insert_one_async(benchmark, postgres_backend_async_context): + context, run = postgres_backend_async_context + counter = {"value": 0} + + async def insert_row(): + counter["value"] += 1 + payload = make_user_payload(len(context.payloads) + counter["value"]) + return await execute_insert_one_async(context, payload) + + inserted_id = benchmark(lambda: run(insert_row())) + row = run(execute_find_one_async(context, inserted_id)) + assert row["username"] == f"bench_user_{len(context.payloads) + counter['value']}" + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_async +@pytest.mark.benchmark_read +def test_postgres_backend_execute_find_one_async(benchmark, postgres_backend_async_context): + context, run = postgres_backend_async_context + record_id = context.record_ids[len(context.record_ids) // 2] + row = benchmark(lambda: run(execute_find_one_async(context, record_id))) + assert row["id"] == record_id + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_async +@pytest.mark.benchmark_write +def test_postgres_backend_execute_update_one_async(benchmark, postgres_backend_async_context): + context, run = postgres_backend_async_context + counter = {"value": 0} + + async def update_row(): + counter["value"] += 1 + payload = make_user_payload(len(context.payloads) + counter["value"]) + record_id = await execute_insert_one_async(context, payload) + username = f"backend_updated_{counter['value']}" + await execute_update_one_async(context, record_id, username) + return record_id, username + + record_id, username = benchmark(lambda: run(update_row())) + row = run(execute_find_one_async(context, record_id)) + assert row["username"] == username + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_async +@pytest.mark.benchmark_write +def test_postgres_backend_execute_delete_one_async(benchmark, postgres_backend_async_context): + context, run = postgres_backend_async_context + counter = {"value": 0} + + async def delete_row(): + counter["value"] += 1 + payload = make_user_payload(len(context.payloads) + counter["value"]) + record_id = await execute_insert_one_async(context, payload) + await execute_delete_one_async(context, record_id) + return record_id + + record_id = benchmark(lambda: run(delete_row())) + result = run( + context.backend.execute( + context.sql["find_one"], + (record_id,), + options=DQL_OPTIONS, + ) + ) + assert not result.data diff --git a/tests/benchmark/backend/test_execute_crud_sync.py b/tests/benchmark/backend/test_execute_crud_sync.py new file mode 100644 index 00000000..0c8f5714 --- /dev/null +++ b/tests/benchmark/backend/test_execute_crud_sync.py @@ -0,0 +1,83 @@ +"""Synchronous PostgreSQL direct backend CRUD benchmarks.""" + +import pytest + +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import make_user_payload + +from .workloads import ( + DQL_OPTIONS, + execute_delete_one, + execute_find_one, + execute_insert_one, + execute_update_one, +) + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_sync +@pytest.mark.benchmark_write +def test_postgres_backend_execute_insert_one_sync(benchmark, postgres_backend_sync_context): + counter = {"value": 0} + + def insert_row(): + counter["value"] += 1 + payload = make_user_payload(len(postgres_backend_sync_context.payloads) + counter["value"]) + return execute_insert_one(postgres_backend_sync_context, payload) + + inserted_id = benchmark(insert_row) + row = execute_find_one(postgres_backend_sync_context, inserted_id) + assert row["username"] == f"bench_user_{len(postgres_backend_sync_context.payloads) + counter['value']}" + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_sync +@pytest.mark.benchmark_read +def test_postgres_backend_execute_find_one_sync(benchmark, postgres_backend_sync_context): + record_id = postgres_backend_sync_context.record_ids[len(postgres_backend_sync_context.record_ids) // 2] + row = benchmark(execute_find_one, postgres_backend_sync_context, record_id) + assert row["id"] == record_id + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_sync +@pytest.mark.benchmark_write +def test_postgres_backend_execute_update_one_sync(benchmark, postgres_backend_sync_context): + counter = {"value": 0} + + def update_row(): + counter["value"] += 1 + payload = make_user_payload(len(postgres_backend_sync_context.payloads) + counter["value"]) + record_id = execute_insert_one(postgres_backend_sync_context, payload) + username = f"backend_updated_{counter['value']}" + execute_update_one(postgres_backend_sync_context, record_id, username) + return record_id, username + + record_id, username = benchmark(update_row) + row = execute_find_one(postgres_backend_sync_context, record_id) + assert row["username"] == username + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_sync +@pytest.mark.benchmark_write +def test_postgres_backend_execute_delete_one_sync(benchmark, postgres_backend_sync_context): + counter = {"value": 0} + + def delete_row(): + counter["value"] += 1 + payload = make_user_payload(len(postgres_backend_sync_context.payloads) + counter["value"]) + record_id = execute_insert_one(postgres_backend_sync_context, payload) + execute_delete_one(postgres_backend_sync_context, record_id) + return record_id + + record_id = benchmark(delete_row) + result = postgres_backend_sync_context.backend.execute( + postgres_backend_sync_context.sql["find_one"], + (record_id,), + options=DQL_OPTIONS, + ) + assert not result.data diff --git a/tests/benchmark/backend/test_execute_many_async.py b/tests/benchmark/backend/test_execute_many_async.py new file mode 100644 index 00000000..746787a8 --- /dev/null +++ b/tests/benchmark/backend/test_execute_many_async.py @@ -0,0 +1,19 @@ +"""Asynchronous PostgreSQL direct backend batch benchmarks.""" + +import pytest + +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import make_user_payload + +from .workloads import execute_many_insert_async + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_async +@pytest.mark.benchmark_write +def test_postgres_backend_execute_many_insert_async(benchmark, postgres_backend_async_context): + context, run = postgres_backend_async_context + start = len(context.payloads) + 1 + payloads = [make_user_payload(start + index) for index in range(100)] + affected_rows = benchmark(lambda: run(execute_many_insert_async(context, payloads))) + assert affected_rows == len(payloads) diff --git a/tests/benchmark/backend/test_execute_many_sync.py b/tests/benchmark/backend/test_execute_many_sync.py new file mode 100644 index 00000000..d62a7290 --- /dev/null +++ b/tests/benchmark/backend/test_execute_many_sync.py @@ -0,0 +1,18 @@ +"""Synchronous PostgreSQL direct backend batch benchmarks.""" + +import pytest + +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import make_user_payload + +from .workloads import execute_many_insert + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_sync +@pytest.mark.benchmark_write +def test_postgres_backend_execute_many_insert_sync(benchmark, postgres_backend_sync_context): + start = len(postgres_backend_sync_context.payloads) + 1 + payloads = [make_user_payload(start + index) for index in range(100)] + affected_rows = benchmark(execute_many_insert, postgres_backend_sync_context, payloads) + assert affected_rows == len(payloads) diff --git a/tests/benchmark/backend/workloads.py b/tests/benchmark/backend/workloads.py new file mode 100644 index 00000000..9f841bb5 --- /dev/null +++ b/tests/benchmark/backend/workloads.py @@ -0,0 +1,94 @@ +"""PostgreSQL direct backend benchmark workloads.""" + +from typing import Any, Dict, List + +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +DQL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DQL, process_result_set=True) +DML_OPTIONS = ExecutionOptions(stmt_type=StatementType.DML, process_result_set=False) +RETURNING_ID_OPTIONS = ExecutionOptions(stmt_type=StatementType.DML, process_result_set=True) + + +def execute_insert_one(context: Any, payload: Dict[str, object]) -> Any: + result = context.backend.execute( + context.sql["insert"], + context.params_factory("insert", payload), + options=RETURNING_ID_OPTIONS, + ) + if result.affected_rows != 1 or not result.data: + raise AssertionError("PostgreSQL insert benchmark did not return inserted id") + return result.data[0]["id"] + + +async def execute_insert_one_async(context: Any, payload: Dict[str, object]) -> Any: + result = await context.backend.execute( + context.sql["insert"], + context.params_factory("insert", payload), + options=RETURNING_ID_OPTIONS, + ) + if result.affected_rows != 1 or not result.data: + raise AssertionError("async PostgreSQL insert benchmark did not return inserted id") + return result.data[0]["id"] + + +def execute_find_one(context: Any, record_id: Any) -> Dict[str, object]: + result = context.backend.execute(context.sql["find_one"], (record_id,), options=DQL_OPTIONS) + if len(result.data) != 1 or result.data[0]["id"] != record_id: + raise AssertionError("PostgreSQL find benchmark returned an unexpected row") + return result.data[0] + + +async def execute_find_one_async(context: Any, record_id: Any) -> Dict[str, object]: + result = await context.backend.execute(context.sql["find_one"], (record_id,), options=DQL_OPTIONS) + if len(result.data) != 1 or result.data[0]["id"] != record_id: + raise AssertionError("async PostgreSQL find benchmark returned an unexpected row") + return result.data[0] + + +def execute_update_one(context: Any, record_id: Any, username: str) -> None: + result = context.backend.execute( + context.sql["update"], + (username, record_id), + options=DML_OPTIONS, + ) + if result.affected_rows != 1: + raise AssertionError("PostgreSQL update benchmark did not affect exactly one row") + + +async def execute_update_one_async(context: Any, record_id: Any, username: str) -> None: + result = await context.backend.execute( + context.sql["update"], + (username, record_id), + options=DML_OPTIONS, + ) + if result.affected_rows != 1: + raise AssertionError("async PostgreSQL update benchmark did not affect exactly one row") + + +def execute_delete_one(context: Any, record_id: Any) -> None: + result = context.backend.execute(context.sql["delete"], (record_id,), options=DML_OPTIONS) + if result.affected_rows != 1: + raise AssertionError("PostgreSQL delete benchmark did not affect exactly one row") + + +async def execute_delete_one_async(context: Any, record_id: Any) -> None: + result = await context.backend.execute(context.sql["delete"], (record_id,), options=DML_OPTIONS) + if result.affected_rows != 1: + raise AssertionError("async PostgreSQL delete benchmark did not affect exactly one row") + + +def execute_many_insert(context: Any, payloads: List[Dict[str, object]]) -> int: + params_list = [context.params_factory("insert_many", payload) for payload in payloads] + result = context.backend.execute_many(context.sql["insert_many"], params_list) + if result.affected_rows != len(payloads): + raise AssertionError("PostgreSQL execute_many insert affected unexpected row count") + return result.affected_rows + + +async def execute_many_insert_async(context: Any, payloads: List[Dict[str, object]]) -> int: + params_list = [context.params_factory("insert_many", payload) for payload in payloads] + result = await context.backend.execute_many(context.sql["insert_many"], params_list) + if result.affected_rows != len(payloads): + raise AssertionError("async PostgreSQL execute_many insert affected unexpected row count") + return result.affected_rows diff --git a/tests/providers/backend_benchmark.py b/tests/providers/backend_benchmark.py deleted file mode 100644 index 16ad4f81..00000000 --- a/tests/providers/backend_benchmark.py +++ /dev/null @@ -1,160 +0,0 @@ -"""PostgreSQL provider for backend direct benchmark tests.""" - -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType -from rhosocial.activerecord.testsuite.benchmark.backend.interfaces import ( - BackendBenchmarkContext, -) -from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import ( - make_user_payloads, - payload_count_for_size, -) - -from .scenarios import get_enabled_scenarios, get_scenario - - -class BackendBenchmarkProvider: - def __init__(self): - self._active_backends = [] - self._active_async_backends = [] - - def get_benchmark_scenarios(self): - return list(get_enabled_scenarios().keys()) - - def setup_benchmark_sync(self, scenario: str, size: str): - from rhosocial.activerecord.backend.impl.postgres import PostgresBackend - - _, config = get_scenario(scenario) - backend = PostgresBackend(connection_config=config) - self._initialize_schema(backend) - payloads = make_user_payloads(payload_count_for_size(size)) - record_ids = self._seed_sync(backend, payloads) - self._active_backends.append(backend) - return BackendBenchmarkContext( - scenario=scenario, - size=size, - backend=backend, - payloads=payloads, - record_ids=record_ids, - sql=self._sql_templates(), - params_factory=self._params_factory, - backend_namespace="rhosocial.activerecord.backend.impl.postgres", - backend_name="postgres", - ) - - def teardown_benchmark_sync(self, scenario: str, context: BackendBenchmarkContext) -> None: - self._cleanup_sync() - - async def setup_benchmark_async(self, scenario: str, size: str): - from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend - - _, config = get_scenario(scenario) - backend = AsyncPostgresBackend(connection_config=config) - await self._initialize_schema_async(backend) - payloads = make_user_payloads(payload_count_for_size(size)) - record_ids = await self._seed_async(backend, payloads) - self._active_async_backends.append(backend) - return BackendBenchmarkContext( - scenario=scenario, - size=size, - backend=backend, - payloads=payloads, - record_ids=record_ids, - sql=self._sql_templates(), - params_factory=self._params_factory, - backend_namespace="rhosocial.activerecord.backend.impl.postgres", - backend_name="postgres", - ) - - async def teardown_benchmark_async(self, scenario: str, context: BackendBenchmarkContext) -> None: - await self._cleanup_async() - - def _initialize_schema(self, backend): - options = ExecutionOptions(stmt_type=StatementType.DDL) - backend.execute("DROP TABLE IF EXISTS benchmark_users", options=options) - backend.execute(self._schema_sql(), options=options) - - async def _initialize_schema_async(self, backend): - options = ExecutionOptions(stmt_type=StatementType.DDL) - await backend.execute("DROP TABLE IF EXISTS benchmark_users", options=options) - await backend.execute(self._schema_sql(), options=options) - - def _seed_sync(self, backend, payloads): - record_ids = [] - for payload in payloads: - result = backend.execute( - self._sql_templates()["insert"], - self._params_factory("insert", payload), - ) - if result.affected_rows != 1 or not result.data: - raise AssertionError("failed to seed sync backend benchmark row") - record_ids.append(result.data[0]["id"]) - return record_ids - - async def _seed_async(self, backend, payloads): - record_ids = [] - for payload in payloads: - result = await backend.execute( - self._sql_templates()["insert"], - self._params_factory("insert", payload), - ) - if result.affected_rows != 1 or not result.data: - raise AssertionError("failed to seed async backend benchmark row") - record_ids.append(result.data[0]["id"]) - return record_ids - - def _sql_templates(self): - return { - "insert": """ -INSERT INTO benchmark_users ( - username, email, age, balance, notes, is_active, created_at, updated_at -) VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -RETURNING id -""", - "find_one": "SELECT * FROM benchmark_users WHERE id = %s", - "update": "UPDATE benchmark_users SET username = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", - "delete": "DELETE FROM benchmark_users WHERE id = %s", - } - - def _params_factory(self, operation, payload): - if operation != "insert": - raise ValueError(f"unsupported backend benchmark operation: {operation}") - return ( - payload["username"], - payload["email"], - payload["age"], - payload["balance"], - payload["notes"], - payload["is_active"], - ) - - def _schema_sql(self): - return """ -CREATE TABLE benchmark_users ( - id SERIAL PRIMARY KEY, - username VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL, - age INTEGER, - balance DOUBLE PRECISION NOT NULL DEFAULT 0.0, - notes TEXT, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMP, - updated_at TIMESTAMP -) -""" - - def _cleanup_sync(self): - for backend in self._active_backends: - try: - backend.disconnect() - except Exception: - pass - self._active_backends.clear() - - async def _cleanup_async(self): - for backend in self._active_async_backends: - try: - await backend.disconnect() - except Exception: - pass - self._active_async_backends.clear() diff --git a/tests/providers/fastapi_benchmark.py b/tests/providers/fastapi_benchmark.py new file mode 100644 index 00000000..db0ae072 --- /dev/null +++ b/tests/providers/fastapi_benchmark.py @@ -0,0 +1,236 @@ +"""PostgreSQL provider for FastAPI benchmark tests.""" + +import sys +from contextlib import asynccontextmanager +from typing import Type + +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import ( + make_user_payloads, + payload_count_for_size, +) +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models import ( + AsyncBenchmarkUser as AsyncBenchmarkUserBase, +) +from rhosocial.activerecord.testsuite.benchmark.fastapi.interfaces import ( + FASTAPI_CONNECTION_STRATEGIES, + FASTAPI_CONTEXT_STRATEGY, + FASTAPI_RUNTIME_CONFIGS, + FastAPIBenchmarkContext, +) +from rhosocial.activerecord.testsuite.utils import select_fixture + +from .scenarios import get_enabled_scenarios, get_scenario + +AsyncBenchmarkUser310 = None +AsyncBenchmarkUser311 = None +AsyncBenchmarkUser312 = None + +if sys.version_info >= (3, 10): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py310 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser310, + ) + +if sys.version_info >= (3, 11): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py311 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser311, + ) + +if sys.version_info >= (3, 12): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py312 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser312, + ) + +AsyncBenchmarkUser = select_fixture( + *[ + candidate + for candidate in ( + AsyncBenchmarkUser312, + AsyncBenchmarkUser311, + AsyncBenchmarkUser310, + AsyncBenchmarkUserBase, + ) + if candidate + ] +) + + +class FastAPIBenchmarkProvider: + def __init__(self): + self._active_async_backends = [] + self._active_async_pools = [] + self._active_clients = [] + + def get_benchmark_scenarios(self): + return list(get_enabled_scenarios().keys()) + + def get_connection_strategies(self): + return FASTAPI_CONNECTION_STRATEGIES + + async def setup_benchmark_async( + self, + scenario: str, + size: str, + connection_strategy: str = "context", + ): + import httpx + from rhosocial.activerecord.testsuite.benchmark.fastapi.app import ( + create_fastapi_benchmark_app, + ) + + model_class, config = await self._setup_async_model(AsyncBenchmarkUser, scenario) + payloads = make_user_payloads(payload_count_for_size(size)) + record_ids = [] + async with model_class.__backend__.context(): + for payload in payloads: + instance = model_class(**payload) + rows = await instance.save() + if rows != 1 or instance.id is None: + raise AssertionError("failed to seed async FastAPI benchmark row") + record_ids.append(instance.id) + + runtime_config = self._fastapi_runtime_config(connection_strategy) + pool = None + pool_config = None + backend_context_factory = self._create_async_context_factory(config) + if connection_strategy != FASTAPI_CONTEXT_STRATEGY: + pool, pool_config = await self._create_async_pool(config, runtime_config) + backend_context_factory = pool.connection + + app = create_fastapi_benchmark_app( + model_class=model_class, + backend_context_factory=backend_context_factory, + backend_name="postgres", + scenario=scenario, + ) + transport = httpx.ASGITransport(app=app) + client = httpx.AsyncClient(transport=transport, base_url="http://testserver") + self._active_clients.append(client) + + return FastAPIBenchmarkContext( + scenario=scenario, + size=size, + app=app, + client=client, + model_class=model_class, + payloads=payloads, + record_ids=record_ids, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + connection_strategy=connection_strategy, + concurrency=runtime_config["concurrency"], + repeat=runtime_config["repeat"], + pool_config={ + "min_size": pool_config.min_size, + "max_size": pool_config.max_size, + "connection_mode": pool_config.connection_mode, + } + if pool_config + else None, + pool_connection_mode=pool.connection_mode if pool else None, + pool_stats=pool.get_stats if pool else None, + ) + + async def teardown_benchmark_async( + self, + scenario: str, + context: FastAPIBenchmarkContext, + ) -> None: + await self._cleanup_async() + + async def _setup_async_model( + self, model_class: Type[ActiveRecord], scenario: str + ) -> Type[ActiveRecord]: + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + _, config = get_scenario(scenario) + await model_class.configure(config, AsyncPostgresBackend) + await self._initialize_schema_async(model_class.__backend__) + if model_class.__backend__ not in self._active_async_backends: + self._active_async_backends.append(model_class.__backend__) + return model_class, config + + async def _initialize_schema_async(self, backend): + options = ExecutionOptions(stmt_type=StatementType.DDL) + await self._drop_benchmark_table(backend, options) + await backend.execute(self._schema_sql(), options=options) + + def _create_async_context_factory(self, config): + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + @asynccontextmanager + async def context_factory(): + backend = AsyncPostgresBackend(connection_config=config) + async with backend.context(): + yield backend + + return context_factory + + def _fastapi_runtime_config(self, connection_strategy): + try: + return FASTAPI_RUNTIME_CONFIGS[connection_strategy] + except KeyError as exc: + raise ValueError( + f"unsupported FastAPI benchmark connection strategy: {connection_strategy}" + ) from exc + + async def _create_async_pool(self, config, runtime_config): + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + from rhosocial.activerecord.connection.pool import AsyncBackendPool, PoolConfig + + pool_config = PoolConfig( + min_size=runtime_config["pool_min_size"], + max_size=runtime_config["pool_max_size"], + connection_mode="auto", + backend_factory=lambda: AsyncPostgresBackend(connection_config=config), + ) + pool = await AsyncBackendPool.create(pool_config) + self._active_async_pools.append(pool) + return pool, pool_config + + def _schema_sql(self): + return """ +CREATE TABLE benchmark_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + age INTEGER, + balance DOUBLE PRECISION NOT NULL DEFAULT 0.0, + notes TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP +) +""" + + async def _cleanup_async(self): + for client in self._active_clients: + try: + await client.aclose() + except Exception: + pass + self._active_clients.clear() + + for pool in self._active_async_pools: + try: + await pool.close(force=True) + except Exception: + pass + self._active_async_pools.clear() + + for backend in self._active_async_backends: + try: + options = ExecutionOptions(stmt_type=StatementType.DDL) + await self._drop_benchmark_table(backend, options) + except Exception: + pass + try: + await backend.disconnect() + except Exception: + pass + self._active_async_backends.clear() + + async def _drop_benchmark_table(self, backend, options): + await backend.execute('DROP TABLE IF EXISTS "benchmark_users" CASCADE', options=options) diff --git a/tests/providers/registry.py b/tests/providers/registry.py index 03263a6b..b6ab3719 100644 --- a/tests/providers/registry.py +++ b/tests/providers/registry.py @@ -12,8 +12,8 @@ from .query import QueryProvider from .basic_connection import BasicConnectionProvider from .query_connection import QueryConnectionProvider -from .backend_benchmark import BackendBenchmarkProvider from .crud_benchmark import CrudBenchmarkProvider +from .fastapi_benchmark import FastAPIBenchmarkProvider from .mixin_benchmark import MixinBenchmarkProvider from .query_benchmark import QueryBenchmarkProvider from .transaction_benchmark import TransactionBenchmarkProvider @@ -39,7 +39,6 @@ ) # Register benchmark providers. -provider_registry.register("benchmark.backend.IBackendBenchmarkProvider", BackendBenchmarkProvider) provider_registry.register("benchmark.crud.ICrudBenchmarkProvider", CrudBenchmarkProvider) provider_registry.register("benchmark.query.IQueryBenchmarkProvider", QueryBenchmarkProvider) provider_registry.register( @@ -47,3 +46,4 @@ TransactionBenchmarkProvider, ) provider_registry.register("benchmark.mixin.IMixinBenchmarkProvider", MixinBenchmarkProvider) +provider_registry.register("benchmark.fastapi.IFastAPIBenchmarkProvider", FastAPIBenchmarkProvider) From f000cf75cec298fb30f8b9a65888998a01d63940 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 29 May 2026 22:58:22 +0800 Subject: [PATCH 18/20] ci: install core package from PyPI Pin the CI core dependency to the published 1.0.0.dev27 package so workflows no longer depend on the release branch source checkout. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 266692fb..ad4f0351 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,7 +67,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev27 + pip install rhosocial-activerecord==1.0.0.dev27 pip install rhosocial-activerecord-testsuite==1.0.0.dev14 - name: Create postgres scenario config @@ -259,7 +259,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev27 + pip install rhosocial-activerecord==1.0.0.dev27 pip install rhosocial-activerecord-testsuite==1.0.0.dev14 - name: Create postgres scenario config From e7029d2f3e452600539b0705f5355bb095174309 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 30 May 2026 00:35:10 +0800 Subject: [PATCH 19/20] test: improve CLI and backend initialization coverage Add focused tests for PostgreSQL CLI adapter wiring and backend initialization paths to improve release PR coverage without requiring live database connections. Co-Authored-By: Claude Opus 4.7 --- .../backend/test_backend_initialization.py | 49 ++++ .../feature/backend/test_cli.py | 272 +++++++++++++++++- 2 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py new file mode 100644 index 00000000..39dc4279 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py @@ -0,0 +1,49 @@ +from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend, PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.transaction import ( + AsyncPostgresTransactionManager, + PostgresTransactionManager, +) + + +class TestPostgresBackendInitialization: + def test_sync_backend_builds_default_config_from_kwargs(self): + backend = PostgresBackend(database="app_db", username="app_user", application_name="tests") + + assert backend.config.host == "localhost" + assert backend.config.port == 5432 + assert backend.config.database == "app_db" + assert backend.config.username == "app_user" + assert backend.config.application_name == "tests" + assert isinstance(backend.dialect, PostgresDialect) + assert isinstance(backend.transaction_manager, PostgresTransactionManager) + + def test_sync_backend_preserves_explicit_connection_config(self): + config = PostgresConnectionConfig(host="db.local", port=15432, database="app_db") + backend = PostgresBackend(connection_config=config) + + assert backend.config is config + assert isinstance(backend.dialect, PostgresDialect) + + def test_async_backend_builds_default_config_from_kwargs(self): + backend = AsyncPostgresBackend( + database="app_db", + username="app_user", + application_name="tests", + ) + + assert backend.config.host == "localhost" + assert backend.config.port == 5432 + assert backend.config.database == "app_db" + assert backend.config.username == "app_user" + assert backend.config.application_name == "tests" + assert isinstance(backend.dialect, PostgresDialect) + assert isinstance(backend.transaction_manager, AsyncPostgresTransactionManager) + + def test_async_backend_preserves_explicit_connection_config(self): + config = PostgresConnectionConfig(host="db.local", port=15432, database="app_db") + backend = AsyncPostgresBackend(connection_config=config) + + assert backend.config is config + assert isinstance(backend.dialect, PostgresDialect) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py index 4b3bff63..e0530a7a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py @@ -5,9 +5,10 @@ Tests argument parsing for named-expression and named-procedure subcommands. """ -import pytest +import asyncio import sys -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch class TestCLINamedExpressionArgs: @@ -77,6 +78,141 @@ def test_parse_args_named_expression_async(self): assert args.command == 'named-expression' assert args.is_async is True + def test_parse_args_named_expression_dialect_version(self): + from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args + + with patch.object(sys, 'argv', [ + 'postgres', 'named-expression', 'myapp.queries.test', + '--dialect-version', '15.2.1', + ]): + args = parse_args() + + assert args.command == 'named-expression' + assert args.dialect_version == '15.2.1' + + +class TestCLINamedExpressionAdapter: + def _args(self, **overrides): + values = { + 'output': 'table', + 'rich_ascii': False, + 'is_async': False, + 'dialect_version': None, + 'host': 'localhost', + 'port': 5432, + 'database': 'test_db', + 'user': 'postgres', + 'password': '', + 'named_connection': None, + 'connection_params': [], + } + values.update(overrides) + return SimpleNamespace(**values) + + def test_handle_sync_wires_backend_provider_and_dialect_version(self): + from rhosocial.activerecord.backend.impl.postgres.cli import named_expression + + captured = {} + backend = MagicMock() + backend._connection = object() + backend.dialect = object() + + def fake_handle(args, provider, **kwargs): + captured['args'] = args + captured['provider'] = provider + captured.update(kwargs) + + args = self._args(dialect_version='15.2.1') + + with patch.object( + named_expression, 'create_provider', return_value='provider' + ) as create_provider, patch.object( + named_expression, + 'resolve_connection_config_from_args', + return_value='config', + ) as resolve_config, patch.object( + named_expression, 'PostgresBackend', return_value=backend + ) as backend_cls, patch( + 'rhosocial.activerecord.backend.named_expression.cli.handle_named_expression', + side_effect=fake_handle, + ): + named_expression.handle(args) + create_provider.assert_called_once_with('table', ascii_borders=False) + resolved_backend = captured['backend_factory']() + assert resolved_backend is backend + resolve_config.assert_called_once_with(args) + backend_cls.assert_called_once_with(connection_config='config') + backend.connect.assert_called_once_with() + backend.introspect_and_adapt.assert_called_once_with() + assert backend._dialect.version == (15, 2, 1) + assert captured['get_dialect'](backend) is backend.dialect + + captured['execute_query']('SELECT 1', (), 'dql') + backend.execute.assert_called_once() + + captured['disconnect']() + backend.disconnect.assert_called_once_with() + + def test_handle_sync_creates_standalone_dialect_for_dry_run_paths(self): + from rhosocial.activerecord.backend.impl.postgres.cli import named_expression + + captured = {} + + def fake_handle(args, provider, **kwargs): + captured.update(kwargs) + + args = self._args(dialect_version='14.0.0') + + with patch.object( + named_expression, 'create_provider', return_value='provider' + ), patch( + 'rhosocial.activerecord.backend.named_expression.cli.handle_named_expression', + side_effect=fake_handle, + ): + named_expression.handle(args) + + assert captured['create_dialect']().version == (14, 0, 0) + + def test_handle_async_wires_async_callbacks(self): + from rhosocial.activerecord.backend.impl.postgres.cli import named_expression + + captured = {} + async_backend = MagicMock() + async_backend._connection = object() + async_backend.dialect = object() + async_backend.execute = AsyncMock(return_value='result') + async_backend.disconnect = AsyncMock() + + def fake_handle(args, provider, **kwargs): + captured.update(kwargs) + + args = self._args(is_async=True) + + with patch.object( + named_expression, 'create_provider', return_value='provider' + ), patch.object( + named_expression, + 'resolve_connection_config_from_args', + return_value='config', + ), patch.object( + named_expression, 'AsyncPostgresBackend', return_value=async_backend + ) as backend_cls, patch( + 'rhosocial.activerecord.backend.named_expression.cli.handle_named_expression', + side_effect=fake_handle, + ): + named_expression.handle(args) + resolved_backend = captured['backend_async_factory']() + assert resolved_backend is async_backend + backend_cls.assert_called_once_with(connection_config='config') + dialect = asyncio.run(captured['get_dialect_async'](async_backend)) + assert dialect is async_backend.dialect + result = asyncio.run( + captured['execute_query_async']('SELECT 1', (), 'dql') + ) + assert result == 'result' + asyncio.run(captured['disconnect_async']()) + async_backend.disconnect.assert_awaited_once_with() + class TestCLINamedProcedureArgs: """Tests for named-procedure subcommand argument parsing.""" @@ -173,3 +309,135 @@ def test_parse_args_named_procedure_async(self): assert args.command == 'named-procedure' assert args.is_async is True + + +class TestCLINamedProcedureGraphArgs: + def test_parse_args_named_procedure_graph_run(self): + from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args + + with patch.object(sys, 'argv', [ + 'postgres', 'named-procedure-graph', 'myapp.graphs.monthly_report', + '--params', '{"month":"2026-04"}', + ]): + args = parse_args() + + assert args.command == 'named-procedure-graph' + assert args.qualified_name == 'myapp.graphs.monthly_report' + assert args.params_json == '{"month":"2026-04"}' + + def test_parse_args_named_procedure_graph_list(self): + from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args + + with patch.object(sys, 'argv', [ + 'postgres', 'named-procedure-graph', 'myapp.graphs', '--list', + ]): + args = parse_args() + + assert args.command == 'named-procedure-graph' + assert args.qualified_name == 'myapp.graphs' + assert args.list_procedure_graphs is True + + def test_parse_args_named_procedure_graph_validate_async(self): + from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args + + with patch.object(sys, 'argv', [ + 'postgres', 'named-procedure-graph', 'myapp.graphs.monthly_report', + '--validate', '--async', + ]): + args = parse_args() + + assert args.command == 'named-procedure-graph' + assert args.qualified_name == 'myapp.graphs.monthly_report' + assert args.validate is True + assert args.is_async is True + + +class TestCLINamedProcedureGraphAdapter: + def _args(self, **overrides): + values = { + 'output': 'table', + 'rich_ascii': True, + 'is_async': False, + 'host': 'localhost', + 'port': 5432, + 'database': 'test_db', + 'user': 'postgres', + 'password': '', + 'named_connection': None, + 'connection_params': [], + } + values.update(overrides) + return SimpleNamespace(**values) + + def test_handle_sync_wires_backend_and_disconnect(self): + from rhosocial.activerecord.backend.impl.postgres.cli import named_procedure_graph + + captured = {} + backend = MagicMock() + backend._connection = object() + + def fake_handle(args, provider, **kwargs): + captured['args'] = args + captured['provider'] = provider + captured.update(kwargs) + + args = self._args() + + with patch.object( + named_procedure_graph, 'create_provider', return_value='provider' + ) as create_provider, patch.object( + named_procedure_graph, + 'resolve_connection_config_from_args', + return_value='config', + ) as resolve_config, patch.object( + named_procedure_graph, 'PostgresBackend', return_value=backend + ) as backend_cls, patch( + 'rhosocial.activerecord.backend.named_expression.cli_procedure_graph.' + 'handle_named_procedure_graph', + side_effect=fake_handle, + ): + named_procedure_graph.handle(args) + create_provider.assert_called_once_with('table', ascii_borders=True) + resolved_backend = captured['backend_factory']() + assert resolved_backend is backend + resolve_config.assert_called_once_with(args) + backend_cls.assert_called_once_with(connection_config='config') + backend.connect.assert_called_once_with() + backend.introspect_and_adapt.assert_called_once_with() + + captured['disconnect']() + backend.disconnect.assert_called_once_with() + + def test_handle_async_wires_async_backend_and_disconnect(self): + from rhosocial.activerecord.backend.impl.postgres.cli import named_procedure_graph + + captured = {} + async_backend = MagicMock() + async_backend._connection = object() + async_backend.disconnect = AsyncMock() + + def fake_handle(args, provider, **kwargs): + captured.update(kwargs) + + args = self._args(is_async=True) + + with patch.object( + named_procedure_graph, 'create_provider', return_value='provider' + ), patch.object( + named_procedure_graph, + 'resolve_connection_config_from_args', + return_value='config', + ), patch( + 'rhosocial.activerecord.backend.impl.postgres.AsyncPostgresBackend', + return_value=async_backend, + ) as backend_cls, patch( + 'rhosocial.activerecord.backend.named_expression.cli_procedure_graph.' + 'handle_named_procedure_graph', + side_effect=fake_handle, + ): + named_procedure_graph.handle(args) + resolved_backend = captured['backend_async_factory']() + assert resolved_backend is async_backend + backend_cls.assert_called_once_with(connection_config='config') + asyncio.run(captured['disconnect_async']()) + async_backend.disconnect.assert_awaited_once_with() From a824b41198ee78d0dad8e56c1f60fd441a5eb745 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 30 May 2026 00:58:05 +0800 Subject: [PATCH 20/20] docs: update CHANGELOG for v1.0.0.dev14 Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 7 +++++++ changelog.d/37.changed.md | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) delete mode 100644 changelog.d/37.changed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f88dfd9e..1ed0595c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [v1.0.0.dev14] - 2026-05-30 + +### Changed + +- Changed PostgresDialect to default to version=None instead of fixed version, requiring introspect_and_adapt() or explicit version before version-dependent features can be used. ([#37](https://github.com/rhosocial/python-activerecord-postgres/issues/37)) + + ## [v1.0.0.dev13] - 2026-05-15 ### Security diff --git a/changelog.d/37.changed.md b/changelog.d/37.changed.md deleted file mode 100644 index 357f3fd9..00000000 --- a/changelog.d/37.changed.md +++ /dev/null @@ -1 +0,0 @@ -Changed PostgresDialect to default to version=None instead of fixed version, requiring introspect_and_adapt() or explicit version before version-dependent features can be used. \ No newline at end of file