diff --git a/.claude/architecture.md b/.claude/architecture.md index b2ca7488..32b4ebd3 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -601,7 +601,7 @@ dependencies = [ ```python extras_require = { "mysql": ["mysql-connector-python>=8.0.0"], # MySQL backend - "postgresql": ["psycopg2>=2.9.0"], # PostgreSQL backend + "postgresql": ["psycopg[binary]>=3.2.13"], # PostgreSQL backend "dev": ["pytest", "black", "mypy"], # Development tools } ``` @@ -623,7 +623,7 @@ def discover_backends(): try: from rhosocial.activerecord.backend.impl.postgresql import PostgreSQLBackend - backends['postgresql'] = PostgreSQLBackend # Uses psycopg2 directly + backends['postgresql'] = PostgreSQLBackend # Uses psycopg 3 directly except ImportError: pass diff --git a/.claude/testing.md b/.claude/testing.md index 7acfc565..3f16238b 100644 --- a/.claude/testing.md +++ b/.claude/testing.md @@ -20,7 +20,7 @@ This backend CANNOT be used with free-threaded Python builds. Free-threaded test ```toml dependencies = [ - "psycopg[binary]", + "psycopg[binary]>=3.2.13", "rhosocial-activerecord" ] ``` diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2100cb73..b6818502 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create postgres scenario config run: | @@ -280,7 +280,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create postgres scenario config run: | @@ -392,7 +392,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create postgres scenario config run: | @@ -455,13 +455,12 @@ jobs: fail_ci_if_error: false test-postgres-19: - name: Python 3.14 / postgres 19beta3 (PGQ) + name: Python 3.14 / postgres 19beta4 runs-on: ubuntu-latest - continue-on-error: true services: postgres: - image: postgres:19beta3 + image: postgres:19beta4 env: POSTGRES_DB: test_db POSTGRES_USER: root @@ -491,7 +490,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create postgres scenario config run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index a53e881d..d7932239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [v1.0.0.dev17] - 2026-09-25 + +### Changed + +- Updated PostgreSQL 19 SQL/PGQ status: PostgreSQL 19 Beta 4 withdrew SQL/PGQ, and graph capabilities are now fail-closed by default with explicit opt-in overrides. + +### Fixed + +- Corrected PostgreSQL partition documentation: `ATTACH PARTITION CONCURRENTLY` is unsupported; concurrent `DETACH PARTITION` with `FINALIZE` is supported on PostgreSQL 14+. + + ## [v1.0.0.dev16] - 2026-08-15 ### Added @@ -17,8 +28,8 @@ - Added PostgreSQL expression-level `COLLATE` support with schema-qualified collation validation. ([#40](https://github.com/rhosocial/python-activerecord-postgres/issues/40)) - Added PostgreSQL support for core SQL/XML standard expressions, including XML construction, serialization, aggregation, and query/table expression capability declarations. ([#41](https://github.com/rhosocial/python-activerecord-postgres/issues/41)) - Added PostgreSQL dialect support for datetime interval expressions (`EXTRACT`, `DATE_PART`, `DATE_TRUNC`, `INTERVAL` add/subtract, date diff) with EXPLAIN-backed index usage verification tests. ([#42](https://github.com/rhosocial/python-activerecord-postgres/issues/42)) -- Added comprehensive PostgreSQL partitioning support including ATTACH CONCURRENTLY/DEFAULT partition, partition auto-management via named expressions (monthly/quarterly), pg_partman create_parent parameter completion, enhanced partition metadata queries, and full test coverage ([#43](https://github.com/rhosocial/python-activerecord-postgres/issues/43)) -- Add SQL/PGQ property graph query support and PostgreSQL 19 compatibility enhancements ([#44](https://github.com/rhosocial/python-activerecord-postgres/issues/44)) +- Added comprehensive PostgreSQL partitioning support including standard ATTACH PARTITION, concurrent DETACH PARTITION with FINALIZE (PG 14+), DEFAULT partition, partition auto-management via named expressions (monthly/quarterly), pg_partman create_parent parameter completion, enhanced partition metadata queries, and full test coverage ([#43](https://github.com/rhosocial/python-activerecord-postgres/issues/43)) +- Added capability-gated SQL/PGQ property graph query expressions and PostgreSQL 19 compatibility gates ([#44](https://github.com/rhosocial/python-activerecord-postgres/issues/44)) diff --git a/CLAUDE.md b/CLAUDE.md index a97384be..45533eeb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,4 +33,10 @@ This project is under active development. Key features implemented: ## Version Control and Changelog -This project adheres to the same version control, branching, commit message, and changelog management standards as the main `python-activerecord` project. \ No newline at end of file +This project adheres to the same version control, branching, commit message, and changelog management standards as the main `python-activerecord` project. + +## Search Scope + +- Use `rg` for repository searches; never use `grep`. +- Limit searches to this `python-activerecord` repository and its related testsuite/backend directories. +- Exclude `.git/`, `.venv*/`, `__pycache__/`, `build/`, `dist/`, and other cache directories. diff --git a/README.md b/README.md index 4b7d15cf..5fa4d1e2 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ print(user.id) # Populated automatically via RETURNING - **Python**: 3.8+ (including 3.13t/3.14t free-threaded builds) - **Core**: `rhosocial-activerecord>=1.0.0` -- **Driver**: `psycopg>=3.2.12` +- **Driver**: `psycopg[binary]>=3.2.13` ## PostgreSQL Version Compatibility diff --git a/changelog.d/58.added.md b/changelog.d/58.added.md new file mode 100644 index 00000000..b9652d30 --- /dev/null +++ b/changelog.d/58.added.md @@ -0,0 +1 @@ +Add PostgreSQL materialized view coverage: `PostgresCreateMaterializedViewExpression` (schema qualification, `IF NOT EXISTS`), `PostgresDropMaterializedViewExpression` (schema qualification), `PostgresAlterMaterializedViewExpression` with RENAME TO / SET SCHEMA / SET / RESET / OWNER TO actions, the enumerated `PostgresStorageParameter` reloption set with enum-based validation, and `list_materialized_views()` / `get_materialized_view_info()` / `materialized_view_exists()` introspection reporting `is_populated` and `has_unique_index`. diff --git a/changelog.d/58.changed.md b/changelog.d/58.changed.md new file mode 100644 index 00000000..d98d3d1b --- /dev/null +++ b/changelog.d/58.changed.md @@ -0,0 +1 @@ +Replace the reserved `dialect_options` bag with typed declarations: `PostgresColumnDefinition` and typed column options, `PostgresCreateTableOptions` with `UNLOGGED`, `PostgresPartitionClause` with a partition lifecycle provider, and typed `ALTER ... USING`, `EXCLUDE` constraints and vector indexes. Correct capability reporting for `supports_table_inherits`, `supports_comment_on` and `CommentSupport`, wire `PostgresDatabaseMixin` into the dialect, and fix index-drop rendering for a bare index name without `ON `. diff --git a/changelog.d/58.fixed.md b/changelog.d/58.fixed.md new file mode 100644 index 00000000..af150794 --- /dev/null +++ b/changelog.d/58.fixed.md @@ -0,0 +1 @@ +Fixed materialized view support in the PostgreSQL dialect: `PostgresMaterializedViewMixin` was listed after the core `ViewMixin` in `PostgresDialect`, so its CREATE formatter was dead code and `storage_options` was silently dropped from the generated SQL. Storage parameters are also emitted in the correct PostgreSQL grammar order (`WITH (...)` before `TABLESPACE`), and `REFRESH MATERIALIZED VIEW CONCURRENTLY` is now version-gated (PostgreSQL 9.4+) on both the generic and the PostgreSQL expression, raising `UnsupportedFeatureError` instead of `ValueError`. diff --git a/changelog.d/58.internal.md b/changelog.d/58.internal.md new file mode 100644 index 00000000..08961541 --- /dev/null +++ b/changelog.d/58.internal.md @@ -0,0 +1 @@ +Install core and testsuite from release branches in CI instead of the derived-DDL feature branch, repair TYPE_CHECKING imports broken by the expression namespace split, register the pytest markers the suite already uses, and stop the pre-commit hook from blocking commits on revisions that predate `scripts/preflight.sh`. diff --git a/changelog.d/58.removed.md b/changelog.d/58.removed.md new file mode 100644 index 00000000..6ec22551 --- /dev/null +++ b/changelog.d/58.removed.md @@ -0,0 +1 @@ +Remove the reserved `dialect_options` bag from data-type value objects, the `ILIKE` expression, DDL expressions and index expressions, the legacy identity fields on column classes, and the generated DDL lifecycle. diff --git a/docs/en_US/ddl/README.md b/docs/en_US/ddl/README.md index 61cac59e..bc3e44f5 100644 --- a/docs/en_US/ddl/README.md +++ b/docs/en_US/ddl/README.md @@ -11,11 +11,15 @@ The PostgreSQL backend supports the same type-safe DDL expressions as the core l | `AlterTableExpression` | ✅ Full | ADD/DROP COLUMN, ALTER COLUMN | | `CreateIndexExpression` | ✅ Full | Index types (BTREE, HASH, GIN, GiST, BRIN) | | `DropIndexExpression` | ✅ Full | | -| `CreateViewExpression` | ✅ Full | Materialized views support | +| `CreateViewExpression` | ✅ Full | | | `DropViewExpression` | ✅ Full | | +| `PostgresCreateMaterializedViewExpression` | ✅ Full | IF NOT EXISTS, TABLESPACE, storage parameters (see [materialized views](./materialized_view.md)) | +| `PostgresRefreshMaterializedViewExpression` | ✅ Full | CONCURRENTLY (PG 9.4+, requires a UNIQUE index) | +| `PostgresAlterMaterializedViewExpression` | ✅ Full | RENAME TO, SET SCHEMA, SET/RESET storage parameters, OWNER TO | +| `PostgresDropMaterializedViewExpression` | ✅ Full | IF EXISTS, CASCADE | | `CreatePartitionExpression` | ✅ Full | RANGE, LIST, HASH partitioning (PG 10+) | -| `AttachPartitionExpression` | ✅ Full | Includes CONCURRENTLY (PG 14+) | -| `DetachPartitionExpression` | ✅ Full | Includes CONCURRENTLY (PG 14+) | +| `AttachPartitionExpression` | ✅ Full | Standard ATTACH PARTITION; `CONCURRENTLY` is unsupported | +| `DetachPartitionExpression` | ✅ Full | Includes `CONCURRENTLY` and `FINALIZE` (PG 14+) | | `AlterIndexExpression` | ✅ Full | RENAME TO, SET TABLESPACE, SET/RESET storage params | | `ReindexExpression` | ✅ Full | INDEX/TABLE/SCHEMA/DATABASE level, with CONCURRENTLY | diff --git a/docs/en_US/ddl/materialized_view.md b/docs/en_US/ddl/materialized_view.md new file mode 100644 index 00000000..4da9e612 --- /dev/null +++ b/docs/en_US/ddl/materialized_view.md @@ -0,0 +1,247 @@ +# PostgreSQL Materialized Views + +> Materialized views require PostgreSQL 9.3+; `CONCURRENTLY` refresh and +> `IF NOT EXISTS` require 9.4+. +> +> Official docs: +> [CREATE MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-creatematerializedview.html) · +> [REFRESH MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html) · +> [ALTER MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-altermaterializedview.html) + +## Expression matrix + +| Expression | Purpose | PostgreSQL-specific options | +|---|---|---| +| `PostgresCreateMaterializedViewExpression` | `CREATE MATERIALIZED VIEW` | `schema`, `if_not_exists`, `storage_options`, `tablespace`, `column_aliases`, `with_data` | +| `PostgresRefreshMaterializedViewExpression` | `REFRESH MATERIALIZED VIEW` | `schema`, `concurrently`, `with_data` | +| `PostgresAlterMaterializedViewExpression` | `ALTER MATERIALIZED VIEW` | `schema`, action list | +| `PostgresDropMaterializedViewExpression` | `DROP MATERIALIZED VIEW` | `schema`, `if_exists`, `cascade` | + +The generic core expressions (`CreateMaterializedViewExpression`, +`RefreshMaterializedViewExpression`, `DropMaterializedViewExpression`) remain +usable; they simply do not carry `schema` / `if_not_exists`. + +## Creating a materialized view + +```python +from rhosocial.activerecord.backend.expression import Column, FunctionCall, QueryExpression, TableExpression +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateMaterializedViewExpression, +) + +summary = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "product_id"), + FunctionCall(dialect, "COUNT", Column(dialect, "id")), + FunctionCall(dialect, "SUM", Column(dialect, "amount")), + ], + from_=TableExpression(dialect, "sales"), + group_by_having=GroupByHavingClause(dialect, group_by=[Column(dialect, "product_id")]), +) + +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + query=summary, + schema="reporting", + if_not_exists=True, + column_aliases=["product_id", "sale_count", "total_amount"], + storage_options={ + PostgresStorageParameter.FILLFACTOR: 70, + PostgresStorageParameter.AUTOVACUUM_ENABLED: "false", + }, +) +sql, params = create.to_sql() +``` + +Emitted SQL (note the PostgreSQL grammar order — +column aliases, then `WITH (...)`, then `TABLESPACE`): + +```sql +CREATE MATERIALIZED VIEW IF NOT EXISTS "reporting"."sales_summary" + ("product_id", "sale_count", "total_amount") + WITH (fillfactor = 70, autovacuum_enabled = false) AS +SELECT ... WITH DATA +``` + +### `WITH NO DATA` + +```python +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="sales_daily", query=daily, with_data=False +) +``` + +The view is registered but unscannable until it is refreshed +(`REFRESH MATERIALIZED VIEW ... WITH DATA`). + +## Storage parameters + +A materialized view is stored as a regular heap relation — the server maps +`RELKIND_MATVIEW` onto `RELOPT_KIND_HEAP` in `fillRelOptions()` +(`src/backend/access/common/reloptions.c`). The accepted set is therefore the +standard heap relation options, enumerated by +[`PostgresStorageParameter`](../../api/storage_parameters.md): + +```python +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter + +PostgresStorageParameter.FILLFACTOR.value # 'fillfactor' +PostgresStorageParameter.FILLFACTOR.value_type # PostgresStorageParameterValueType.INT +PostgresStorageParameter.VACUUM_INDEX_CLEANUP.enum_values # ('auto', 'enabled', 'disabled') +PostgresStorageParameter.PARALLEL_WORKERS.min_version # (11, 0, 0) +``` + +Keys are validated against that enum — a name outside the enum raises +`ValueError`. Two families are intentionally outside the enum and need the +explicit `allow_unlisted_storage_parameters=True` opt-in: + +* namespaced options (`toast.autovacuum_enabled`, …); +* options registered by a table access method (`USING method`, e.g. a columnar + AM adding `compresslevel`), since `add_reloption_kind()` / + `add_*_reloption()` are exported server APIs. + +```python +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="columnar_summary", + query=summary, + storage_options={"compresslevel": 4}, + allow_unlisted_storage_parameters=True, +) +``` + +## Refreshing + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresRefreshMaterializedViewExpression, +) + +# standard refresh +PostgresRefreshMaterializedViewExpression(dialect=dialect, name="sales_summary") + +# concurrent refresh — requires a UNIQUE index, rejected below PG 9.4 +PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_summary", schema="reporting", concurrently=True +) + +# populate a WITH NO DATA view +PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_daily", with_data=True +) +``` + +`concurrently=True` raises `UnsupportedFeatureError` on PostgreSQL < 9.4 — on +both the generic and the PostgreSQL expression. + +## Altering a materialized view + +PostgreSQL supports exactly five actions; `SET TABLESPACE` is **not** among +them (a materialized view cannot be relocated to another tablespace after +creation). + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) + +alter = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + schema="reporting", + actions=[ + PostgresRenameMaterializedViewAction(dialect, "sales_summary_v2"), + PostgresSetMaterializedViewSchemaAction(dialect, "archive"), + PostgresSetMaterializedViewPropertiesAction(dialect, {"fillfactor": 90}), + PostgresResetMaterializedViewPropertiesAction(dialect, ["autovacuum_enabled"]), + PostgresChangeMaterializedViewOwnerAction(dialect, "CURRENT_USER"), + ], +) +sql, params = alter.to_sql() +``` + +Multiple actions render as separate statements joined with `";\n"`: + +```sql +ALTER MATERIALIZED VIEW "reporting"."sales_summary" RENAME TO "sales_summary_v2"; +ALTER MATERIALIZED VIEW "reporting"."sales_summary" SET SCHEMA "archive"; +ALTER MATERIALIZED VIEW "reporting"."sales_summary" SET (fillfactor = 90); +ALTER MATERIALIZED VIEW "reporting"."sales_summary" RESET (autovacuum_enabled); +ALTER MATERIALIZED VIEW "reporting"."sales_summary" OWNER TO CURRENT_USER +``` + +`CURRENT_ROLE` / `CURRENT_USER` / `SESSION_USER` are emitted verbatim; any other +owner is quoted as an identifier. + +## Dropping + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresDropMaterializedViewExpression, +) + +PostgresDropMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", schema="reporting", + if_exists=True, cascade=True, +) +# DROP MATERIALIZED VIEW IF EXISTS "reporting"."sales_summary" CASCADE +``` + +## Introspection + +Materialized views are **not** part of `list_views()` (which stays `relkind='v'`). +Use the dedicated methods, available on both the sync and async introspector: + +```python +views = backend.introspector.list_materialized_views() +for view in views: + print(view.name, view.schema, view.extra) + +info = backend.introspector.get_materialized_view_info("sales_summary") +exists = backend.introspector.materialized_view_exists("sales_summary") +``` + +Each `ViewInfo` carries: + +| Field | Meaning | +|---|---| +| `definition` | defining query, from `pg_matviews` | +| `is_updatable` / `is_insertable` | always `False` | +| `extra['is_materialized']` | always `True` (distinguishes MV rows) | +| `extra['is_populated']` | `False` for a `WITH NO DATA` view never refreshed | +| `extra['has_unique_index']` | prerequisite for `REFRESH ... CONCURRENTLY` | + +`list_tables(table_type="MATERIALIZED VIEW")` also filters `relkind = 'm'`. + +## Capability probes + +```python +dialect.supports_materialized_view() # PG >= 9.3 +dialect.supports_refresh_materialized_view() # PG >= 9.3 +dialect.supports_materialized_view_concurrent_refresh() # PG >= 9.4 +dialect.supports_materialized_view_if_not_exists() # PG >= 9.4 +dialect.supports_materialized_view_tablespace() # PG >= 9.3 +dialect.supports_materialized_view_storage_options() # PG >= 9.3 +dialect.supports_alter_materialized_view() # PG >= 9.3 +``` + +All of them are declared by `PostgresMaterializedViewSupport`. Every +materialized-view formatter lives in `PostgresMaterializedViewMixin`, which sits +**before** the core `ViewMixin` in `PostgresDialect`'s MRO — a regression test +asserts that ordering, because a mixin placed after the core one silently +disables its formatter. + +## See also + +* [DDL operations](./README.md) +* [PostgreSQL-specific features](../postgres_specific_features/protocol_support.md) +* Runnable example: + `src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py` diff --git a/docs/en_US/installation_and_configuration/installation.md b/docs/en_US/installation_and_configuration/installation.md index 210f6c5b..2e4cd4d9 100644 --- a/docs/en_US/installation_and_configuration/installation.md +++ b/docs/en_US/installation_and_configuration/installation.md @@ -4,7 +4,7 @@ - Python 3.8 or higher - PostgreSQL 8.0 or higher (12+ recommended) -- psycopg 3.2.12 or higher +- `psycopg[binary]>=3.2.13` ## Installation @@ -37,7 +37,7 @@ print("PostgreSQL backend installed successfully!") The package uses `psycopg` (psycopg3) as the PostgreSQL adapter. You can optionally install `psycopg-binary` for pre-compiled binaries: ```bash -pip install psycopg-binary +pip install "psycopg-binary>=3.2.13" ``` **Note**: `psycopg-binary` is platform-specific. If unavailable for your platform, psycopg will compile from source automatically. diff --git a/docs/en_US/introduction/supported_versions.md b/docs/en_US/introduction/supported_versions.md index 60fe87e8..ee120930 100644 --- a/docs/en_US/introduction/supported_versions.md +++ b/docs/en_US/introduction/supported_versions.md @@ -107,16 +107,16 @@ | Package | Version | Python Version | Notes | |---------|---------|----------------|-------| -| psycopg | 3.2.x | 3.8+ | Supported | +| psycopg | 3.2.13+ | 3.8+ | Supported | | psycopg | 3.3.x | 3.10+ | Recommended | -| psycopg-binary | 3.2.x | 3.8+ | Pre-compiled binary | +| psycopg-binary | 3.2.13+ | 3.8+ | Pre-compiled binary | | psycopg-binary | 3.3.x | 3.10+ | Recommended | ### psycopg Version Compatibility | psycopg Version | Minimum Python | Maximum Python | |-----------------|----------------|----------------| -| 3.2.x | 3.8 | All current | +| 3.2.13+ | 3.8 | All current | | 3.3.x | 3.10 | All current | ## Core Library Dependencies diff --git a/docs/en_US/postgres_specific_features/README.md b/docs/en_US/postgres_specific_features/README.md index d26a8ff4..cd5f243f 100644 --- a/docs/en_US/postgres_specific_features/README.md +++ b/docs/en_US/postgres_specific_features/README.md @@ -12,7 +12,7 @@ PostgreSQL offers many advanced features beyond standard SQL. This section cover - **[Database Introspection](./introspection.md)**: Query metadata using pg_catalog - **[EXPLAIN Support](./explain.md)**: Query execution plan analysis and performance diagnostics - **[Table Partitioning](partition.md)**: RANGE / LIST / HASH partitioning and pg_partman management -- **[Property Graph Query](property_graph_query.md)**: SQL/PGQ property graph query (PG 19+) +- **[SQL/PGQ Availability](property_graph_query.md)**: Fail-closed gates after PostgreSQL 19 Beta 4 withdrawal ## Feature Highlights diff --git a/docs/en_US/postgres_specific_features/partition.md b/docs/en_US/postgres_specific_features/partition.md index 64bfdb27..2b528e39 100644 --- a/docs/en_US/postgres_specific_features/partition.md +++ b/docs/en_US/postgres_specific_features/partition.md @@ -25,6 +25,9 @@ partition = PostgresCreatePartitionExpression( ## Partition Management +PostgreSQL does not support `ATTACH PARTITION CONCURRENTLY`. PostgreSQL 14+ supports +`DETACH PARTITION CONCURRENTLY`, with `FINALIZE` for completing a pending detach. + ```python from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( PostgresAttachPartitionExpression, PostgresDetachPartitionExpression, diff --git a/docs/en_US/postgres_specific_features/property_graph_query.md b/docs/en_US/postgres_specific_features/property_graph_query.md index e75f060d..4d46e690 100644 --- a/docs/en_US/postgres_specific_features/property_graph_query.md +++ b/docs/en_US/postgres_specific_features/property_graph_query.md @@ -1,57 +1,62 @@ -# Property Graph Query +# SQL/PGQ Availability -PostgreSQL 19+ supports the SQL/PGQ (Property Graph Query) standard for property graph queries. +PostgreSQL 19 Beta 4 withdrew SQL/PGQ before the 19.0 release, and no earlier +release shipped it either. The PostgreSQL backend therefore keeps +`supports_graph_match()` and `supports_graph_table()` disabled for every server +version, including PostgreSQL 19 and later. -## MATCH Clause +The expression and formatter plumbing remains available for controlled compatibility +testing, but graph formatters fail with `UnsupportedFeatureError` by default. -```python -from rhosocial.activerecord.backend.expression.graph import ( - GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, - GraphTableExpression, GraphColumn, ColumnsClause, -) +## Why the capability is version-independent -person = GraphVertex(dialect, variable="p", table="persons") -product = GraphVertex(dialect, variable="pr", table="products") -purchased = GraphEdge(dialect, variable="pu", table="purchases", - direction=GraphEdgeDirection.RIGHT) - -match = MatchClause(dialect, person, purchased, product) -# sql: 'MATCH (p) - [pu] -> (pr)' - -graph_table = GraphTableExpression( - dialect, - match_clause=match, - columns=ColumnsClause(dialect, columns=[ - GraphColumn(dialect, name="person_name", type="VARCHAR"), - GraphColumn(dialect, name="product_name", type="VARCHAR"), - ]), -) -``` +Every graph grammar production was pulled from the parser, so `CREATE PROPERTY +GRAPH`, `CREATE VERTEX TABLE`, `CREATE EDGE TABLE`, `MATCH`, `GRAPH_TABLE` and +`PROPERTIES` are now syntax errors. The supporting catalog objects went with +them: + +| Catalog probe | 19beta3 | 19beta4 | +| --- | --- | --- | +| `pg_proc` graph/vertex/edge functions | 1 | 0 | +| `pg_type` graph/agtype types | 14 | 0 | +| `pg_class` property-graph relations | 2 | 0 | +| `GRAPH_TABLE`/`VERTEX`/`EDGE`/`PROPERTY` keywords | 4 | 0 | + +Because no release has ever shipped the feature, support is resolved from +explicit opt-ins rather than from `self.version` — a version number is not +evidence of support, and inferring it would re-advertise a feature that does not +exist. -## Property Graph DDL +Note that these formatters were never validated against a real server: even +against 19beta3, which did have the feature, 8 of the 9 generated statements +were rejected. Treat the opt-in below as a compatibility hook, not as a working +feature. + +## Capability Detection ```python -from rhosocial.activerecord.backend.expression.graph import ( - CreatePropertyGraphExpression, DropPropertyGraphExpression, - VertexTable, EdgeTable, -) +from rhosocial.activerecord.backend.impl.postgres import PostgresDialect -create_graph = CreatePropertyGraphExpression( - dialect, - graph_name="social_graph", - vertices=[VertexTable(dialect, table_name="persons", graph_label="Person")], - edges=[EdgeTable(dialect, table_name="knows", - source_vertex="Person", dest_vertex="Person")], -) +dialect = PostgresDialect(version=(19, 0, 4)) +assert dialect.supports_graph_match() is False +assert dialect.supports_graph_table() is False ``` -## Dialect Feature Detection +## Explicit Override + +A future implementation can opt in explicitly without relying on a version check: ```python -if dialect.supports_graph_match(): - # PG 19+: MATCH clause -if dialect.supports_graph_table(): - # PG 19+: GRAPH_TABLE expression +dialect = PostgresDialect( + version=(19, 0, 4), + graph_feature_overrides={ + "graph_match": True, + "graph_table": True, + }, +) ``` -> **Note**: SQL/PGQ support requires PostgreSQL 19+. +Only use these overrides when the target server or a dedicated compatibility layer +actually provides the corresponding feature. `supports_graph_table()` requires +both keys, since `GRAPH_TABLE` wraps a `MATCH` clause. Quantified paths and +comma-separated patterns have no override path and stay off unconditionally. diff --git a/docs/en_US/postgres_specific_features/protocol_support.md b/docs/en_US/postgres_specific_features/protocol_support.md index a32c4c89..d1286a35 100644 --- a/docs/en_US/postgres_specific_features/protocol_support.md +++ b/docs/en_US/postgres_specific_features/protocol_support.md @@ -24,7 +24,7 @@ The PostgreSQL dialect implements numerous protocols from the `rhosocial.activer | **ArraySupport** | ✅ Full | All versions | Native array types and operations | | **JSONSupport** | ✅ Full | ≥ 9.2 | JSON type; JSONB ≥ 9.4; JSON_TABLE ≥ 12 | | **ExplainSupport** | ✅ Full | All versions | EXPLAIN and EXPLAIN ANALYZE with multiple formats | -| **GraphSupport** | ✅ Full | PG 19+ | SQL/PGQ property graph query MATCH and GRAPH_TABLE | +| **GraphSupport** | Disabled | Explicit override | SQL/PGQ was withdrawn in PostgreSQL 19 Beta 4 | | **LockingSupport** | ✅ Partial | ≥ 9.5 | FOR UPDATE; SKIP LOCKED ≥ 9.5 | | **MergeSupport** | ✅ Full | ≥ 15.0 | MERGE statement | | **OrderedSetAggregationSupport** | ✅ Full | ≥ 9.4 | Ordered-set aggregate functions | @@ -210,7 +210,7 @@ In addition to standard protocols, PostgreSQL provides database-specific protoco | Protocol | Description | Min Version | Documentation | |----------|-------------|-------------|---------------| | **PostgresPartitionSupport** | Advanced partitioning features | PG 10+ | [Partitioning Docs](https://www.postgresql.org/docs/current/ddl-partitioning.html) | -| **PostgresPropertyGraphQueryMixin** | SQL/PGQ property graph | PG 19+ | [Property Graph Query](property_graph_query.md) | +| **PostgresPropertyGraphQueryMixin** | SQL/PGQ compatibility gates | Explicit override | [SQL/PGQ Availability](property_graph_query.md) | | **PostgresPgPartmanSupport** | pg_partman auto-partitioning | PG 10+ | [Partitioning](partition.md) | | **PostgresXMLMixin** | SQL/XML standard expressions | PG 8.3+ | [Dialect](dialect.md) | | **PostgresCollationMixin** | Expression-level COLLATE | All | [Dialect](dialect.md) | @@ -220,7 +220,7 @@ In addition to standard protocols, PostgreSQL provides database-specific protoco | **PostgresDataTypeSupport** | Data type enhancements | PG 11+ | [Data Types Docs](https://www.postgresql.org/docs/current/datatype.html) | | **PostgresSQLSyntaxSupport** | SQL syntax enhancements | PG 12+ | [SQL Syntax Docs](https://www.postgresql.org/docs/current/sql-syntax.html) | | **PostgresLogicalReplicationSupport** | Logical replication features | PG 10+ | [Replication Docs](https://www.postgresql.org/docs/current/logical-replication.html) | -| **PostgresMaterializedViewSupport** | Materialized views | PG 9.3+ | [MV Docs](https://www.postgresql.org/docs/current/rules-materializedviews.html) | +| **PostgresMaterializedViewSupport** | Materialized views (CREATE/REFRESH/ALTER/DROP, IF NOT EXISTS, CONCURRENTLY, TABLESPACE, storage parameters) | PG 9.3+ (CONCURRENTLY / IF NOT EXISTS: 9.4+) | [MV DDL Docs](https://www.postgresql.org/docs/current/sql-creatematerializedview.html) · [guide](../../ddl/materialized_view.md) | | **PostgresTableSupport** | Table-specific features | All | [Table Docs](https://www.postgresql.org/docs/current/ddl.html) | ## Detailed Native Feature Support diff --git a/docs/examples/chapter_02_connection_pool/README.md b/docs/examples/chapter_02_connection_pool/README.md index 35a29e7a..aeea028f 100644 --- a/docs/examples/chapter_02_connection_pool/README.md +++ b/docs/examples/chapter_02_connection_pool/README.md @@ -16,7 +16,7 @@ Run in PostgreSQL virtual environment: .venv_postgres\Scripts\activate # Install dependencies -pip install psycopg +pip install "psycopg[binary]>=3.2.13" # Run sync stress test python docs\examples\chapter_02_connection_pool\connection_pool_stress_test_sync.py diff --git a/docs/examples/chapter_08_scenarios/parallel_workers/README.md b/docs/examples/chapter_08_scenarios/parallel_workers/README.md index e886a43a..418edac1 100644 --- a/docs/examples/chapter_08_scenarios/parallel_workers/README.md +++ b/docs/examples/chapter_08_scenarios/parallel_workers/README.md @@ -41,7 +41,7 @@ User <--belongs_to-- Comment (评论作者) 本实验依赖 `psycopg` 版本 3: ```bash -pip install psycopg[binary] +pip install "psycopg[binary]>=3.2.13" ``` > **注意**:`psycopg` 不支持 Python 3.13t/3.14t 的 free-threaded 构建。如果需要 free-threaded Python,请使用 MySQL 或 SQLite 后端。 diff --git a/docs/zh_CN/ddl/README.md b/docs/zh_CN/ddl/README.md index 5fde20e7..b43cbe3d 100644 --- a/docs/zh_CN/ddl/README.md +++ b/docs/zh_CN/ddl/README.md @@ -11,11 +11,15 @@ PostgreSQL 后端支持与核心库相同的类型安全 DDL 表达式,并具 | `AlterTableExpression` | ✅ 完整 | ADD/DROP COLUMN, ALTER COLUMN | | `CreateIndexExpression` | ✅ 完整 | 索引类型 (BTREE, HASH, GIN, GiST, BRIN) | | `DropIndexExpression` | ✅ 完整 | | -| `CreateViewExpression` | ✅ 完整 | 物化视图支持 | +| `CreateViewExpression` | ✅ 完整 | | | `DropViewExpression` | ✅ 完整 | | +| `PostgresCreateMaterializedViewExpression` | ✅ 完整 | IF NOT EXISTS、TABLESPACE、存储参数(见[物化视图](./materialized_view.md)) | +| `PostgresRefreshMaterializedViewExpression` | ✅ 完整 | CONCURRENTLY(PG 9.4+,要求存在 UNIQUE 索引) | +| `PostgresAlterMaterializedViewExpression` | ✅ 完整 | RENAME TO、SET SCHEMA、SET/RESET 存储参数、OWNER TO | +| `PostgresDropMaterializedViewExpression` | ✅ 完整 | IF EXISTS、CASCADE | | `CreatePartitionExpression` | ✅ 完整 | RANGE, LIST, HASH 分区(PG 10+) | -| `AttachPartitionExpression` | ✅ 完整 | 含 CONCURRENTLY(PG 14+) | -| `DetachPartitionExpression` | ✅ 完整 | 含 CONCURRENTLY(PG 14+) | +| `AttachPartitionExpression` | ✅ 完整 | 支持标准 ATTACH PARTITION;不支持 `CONCURRENTLY` | +| `DetachPartitionExpression` | ✅ 完整 | 含 `CONCURRENTLY` 和 `FINALIZE`(PG 14+) | | `AlterIndexExpression` | ✅ 完整 | RENAME TO, SET TABLESPACE, SET/RESET 存储参数 | | `ReindexExpression` | ✅ 完整 | 索引/表/模式/数据库级重建,含 CONCURRENTLY | diff --git a/docs/zh_CN/ddl/materialized_view.md b/docs/zh_CN/ddl/materialized_view.md new file mode 100644 index 00000000..a8ef20ce --- /dev/null +++ b/docs/zh_CN/ddl/materialized_view.md @@ -0,0 +1,240 @@ +# PostgreSQL 物化视图 + +> 物化视图需要 PostgreSQL 9.3+;`CONCURRENTLY` 刷新与 `IF NOT EXISTS` 需要 9.4+。 +> +> 官方文档: +> [CREATE MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-creatematerializedview.html) · +> [REFRESH MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html) · +> [ALTER MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-altermaterializedview.html) + +## 表达式一览 + +| 表达式 | 用途 | PostgreSQL 专有选项 | +|---|---|---| +| `PostgresCreateMaterializedViewExpression` | `CREATE MATERIALIZED VIEW` | `schema`、`if_not_exists`、`storage_options`、`tablespace`、`column_aliases`、`with_data` | +| `PostgresRefreshMaterializedViewExpression` | `REFRESH MATERIALIZED VIEW` | `schema`、`concurrently`、`with_data` | +| `PostgresAlterMaterializedViewExpression` | `ALTER MATERIALIZED VIEW` | `schema`、动作列表 | +| `PostgresDropMaterializedViewExpression` | `DROP MATERIALIZED VIEW` | `schema`、`if_exists`、`cascade` | + +核心库通用表达式(`CreateMaterializedViewExpression`、 +`RefreshMaterializedViewExpression`、`DropMaterializedViewExpression`)仍可使用, +只是不携带 `schema` / `if_not_exists`。 + +## 创建物化视图 + +```python +from rhosocial.activerecord.backend.expression import Column, FunctionCall, QueryExpression, TableExpression +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateMaterializedViewExpression, +) + +summary = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "product_id"), + FunctionCall(dialect, "COUNT", Column(dialect, "id")), + FunctionCall(dialect, "SUM", Column(dialect, "amount")), + ], + from_=TableExpression(dialect, "sales"), + group_by_having=GroupByHavingClause(dialect, group_by=[Column(dialect, "product_id")]), +) + +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + query=summary, + schema="reporting", + if_not_exists=True, + column_aliases=["product_id", "sale_count", "total_amount"], + storage_options={ + PostgresStorageParameter.FILLFACTOR: 70, + PostgresStorageParameter.AUTOVACUUM_ENABLED: "false", + }, +) +sql, params = create.to_sql() +``` + +生成的 SQL(注意 PostgreSQL 语法顺序:列别名 → `WITH (...)` → `TABLESPACE`): + +```sql +CREATE MATERIALIZED VIEW IF NOT EXISTS "reporting"."sales_summary" + ("product_id", "sale_count", "total_amount") + WITH (fillfactor = 70, autovacuum_enabled = false) AS +SELECT ... WITH DATA +``` + +### `WITH NO DATA` + +```python +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="sales_daily", query=daily, with_data=False +) +``` + +视图会被注册,但在刷新前不可查询(需 +`REFRESH MATERIALIZED VIEW ... WITH DATA`)。 + +## 存储参数 + +物化视图以普通堆表形式存储——服务端在 `fillRelOptions()` 中把 +`RELKIND_MATVIEW` 映射到 `RELOPT_KIND_HEAP` +(`src/backend/access/common/reloptions.c`)。因此可用参数集合就是标准堆表 +关系选项,已由 [`PostgresStorageParameter`](../../api/storage_parameters.md) 枚举: + +```python +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter + +PostgresStorageParameter.FILLFACTOR.value # 'fillfactor' +PostgresStorageParameter.FILLFACTOR.value_type # PostgresStorageParameterValueType.INT +PostgresStorageParameter.VACUUM_INDEX_CLEANUP.enum_values # ('auto', 'enabled', 'disabled') +PostgresStorageParameter.PARALLEL_WORKERS.min_version # (11, 0, 0) +``` + +键名按该枚举校验——枚举外的名字会抛 `ValueError`。以下两类参数有意不纳入 +枚举,需要显式传 `allow_unlisted_storage_parameters=True`: + +* 带命名空间的参数(`toast.autovacuum_enabled` 等); +* 表访问方法(`USING method`)注册的参数(例如列式 AM 的 `compresslevel`), + 因为 `add_reloption_kind()` / `add_*_reloption()` 是服务端导出的扩展点。 + +```python +create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="columnar_summary", + query=summary, + storage_options={"compresslevel": 4}, + allow_unlisted_storage_parameters=True, +) +``` + +## 刷新 + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresRefreshMaterializedViewExpression, +) + +# 普通刷新 +PostgresRefreshMaterializedViewExpression(dialect=dialect, name="sales_summary") + +# 并发刷新——要求存在 UNIQUE 索引;PG < 9.4 会被拒绝 +PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_summary", schema="reporting", concurrently=True +) + +# 填充 WITH NO DATA 的视图 +PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_daily", with_data=True +) +``` + +`concurrently=True` 在 PostgreSQL < 9.4 上抛 `UnsupportedFeatureError`—— +通用表达式与 PostgreSQL 专有表达式两条路径都会拦截。 + +## 修改物化视图 + +PostgreSQL 恰好支持 5 种动作,**不含** `SET TABLESPACE` +(物化视图创建后无法迁移到其他表空间)。 + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) + +alter = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + schema="reporting", + actions=[ + PostgresRenameMaterializedViewAction(dialect, "sales_summary_v2"), + PostgresSetMaterializedViewSchemaAction(dialect, "archive"), + PostgresSetMaterializedViewPropertiesAction(dialect, {"fillfactor": 90}), + PostgresResetMaterializedViewPropertiesAction(dialect, ["autovacuum_enabled"]), + PostgresChangeMaterializedViewOwnerAction(dialect, "CURRENT_USER"), + ], +) +sql, params = alter.to_sql() +``` + +多动作会渲染为多条语句,以 `";\n"` 连接: + +```sql +ALTER MATERIALIZED VIEW "reporting"."sales_summary" RENAME TO "sales_summary_v2"; +ALTER MATERIALIZED VIEW "reporting"."sales_summary" SET SCHEMA "archive"; +ALTER MATERIALIZED VIEW "reporting"."sales_summary" SET (fillfactor = 90); +ALTER MATERIALIZED VIEW "reporting"."sales_summary" RESET (autovacuum_enabled); +ALTER MATERIALIZED VIEW "reporting"."sales_summary" OWNER TO CURRENT_USER +``` + +`CURRENT_ROLE` / `CURRENT_USER` / `SESSION_USER` 原样输出;其余 owner +按标识符加引号。 + +## 删除 + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresDropMaterializedViewExpression, +) + +PostgresDropMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", schema="reporting", + if_exists=True, cascade=True, +) +# DROP MATERIALIZED VIEW IF EXISTS "reporting"."sales_summary" CASCADE +``` + +## 内省 + +物化视图**不属于** `list_views()`(后者固定为 `relkind='v'`)。 +请使用专用方法,同步与异步内省器都提供: + +```python +views = backend.introspector.list_materialized_views() +for view in views: + print(view.name, view.schema, view.extra) + +info = backend.introspector.get_materialized_view_info("sales_summary") +exists = backend.introspector.materialized_view_exists("sales_summary") +``` + +每个 `ViewInfo` 携带: + +| 字段 | 含义 | +|---|---| +| `definition` | 定义查询,来自 `pg_matviews` | +| `is_updatable` / `is_insertable` | 恒为 `False` | +| `extra['is_materialized']` | 恒为 `True`(用于区分物化视图行) | +| `extra['is_populated']` | `WITH NO DATA` 且从未刷新时为 `False` | +| `extra['has_unique_index']` | `REFRESH ... CONCURRENTLY` 的前置条件 | + +`list_tables(table_type="MATERIALIZED VIEW")` 也会按 `relkind = 'm'` 过滤。 + +## 能力探测 + +```python +dialect.supports_materialized_view() # PG >= 9.3 +dialect.supports_refresh_materialized_view() # PG >= 9.3 +dialect.supports_materialized_view_concurrent_refresh() # PG >= 9.4 +dialect.supports_materialized_view_if_not_exists() # PG >= 9.4 +dialect.supports_materialized_view_tablespace() # PG >= 9.3 +dialect.supports_materialized_view_storage_options() # PG >= 9.3 +dialect.supports_alter_materialized_view() # PG >= 9.3 +``` + +这些探针全部由 `PostgresMaterializedViewSupport` 声明。所有物化视图格式化器 +都位于 `PostgresMaterializedViewMixin`,它在 `PostgresDialect` 的 MRO 中 +**排在**核心 `ViewMixin` **之前**——回归测试会断言该顺序,因为一旦排到核心 +mixin 之后,其格式化器会静默失效。 + +## 相关文档 + +* [DDL 操作](./README.md) +* [PostgreSQL 专有特性](../postgres_specific_features/protocol_support.md) +* 可运行示例: + `src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py` diff --git a/docs/zh_CN/installation_and_configuration/installation.md b/docs/zh_CN/installation_and_configuration/installation.md index 70a0d73d..ee416540 100644 --- a/docs/zh_CN/installation_and_configuration/installation.md +++ b/docs/zh_CN/installation_and_configuration/installation.md @@ -4,7 +4,7 @@ - Python 3.8 或更高版本 - PostgreSQL 8.0 或更高版本(推荐 12+) -- psycopg 3.2.12 或更高版本 +- `psycopg[binary]>=3.2.13` ## 安装 @@ -37,7 +37,7 @@ print("PostgreSQL 后端安装成功!") 本包使用 `psycopg`(psycopg3)作为 PostgreSQL 适配器。您可以选择安装 `psycopg-binary` 以获取预编译二进制文件: ```bash -pip install psycopg-binary +pip install "psycopg-binary>=3.2.13" ``` **注意**:`psycopg-binary` 是平台特定的。如果您的平台没有预编译版本,psycopg 将自动从源码编译。 diff --git a/docs/zh_CN/introduction/supported_versions.md b/docs/zh_CN/introduction/supported_versions.md index 8efcb936..ee64a3ba 100644 --- a/docs/zh_CN/introduction/supported_versions.md +++ b/docs/zh_CN/introduction/supported_versions.md @@ -107,16 +107,16 @@ | 包 | 版本 | Python 版本 | 说明 | |---|------|------------|------| -| psycopg | 3.2.x | 3.8+ | 支持 | +| psycopg | 3.2.13+ | 3.8+ | 支持 | | psycopg | 3.3.x | 3.10+ | 推荐 | -| psycopg-binary | 3.2.x | 3.8+ | 预编译二进制 | +| psycopg-binary | 3.2.13+ | 3.8+ | 预编译二进制 | | psycopg-binary | 3.3.x | 3.10+ | 推荐 | ### psycopg 版本兼容性 | psycopg 版本 | 最低 Python | 最高 Python | |-------------|------------|------------| -| 3.2.x | 3.8 | 所有当前版本 | +| 3.2.13+ | 3.8 | 所有当前版本 | | 3.3.x | 3.10 | 所有当前版本 | ## 核心库依赖 diff --git a/docs/zh_CN/postgres_specific_features/dialect.md b/docs/zh_CN/postgres_specific_features/dialect.md index b3547c44..cadc1d1b 100644 --- a/docs/zh_CN/postgres_specific_features/dialect.md +++ b/docs/zh_CN/postgres_specific_features/dialect.md @@ -363,25 +363,8 @@ table = xmltable(dialect, columns=[("id", "INTEGER", "id"), ("name", "VARCHAR(100)", "name")]) ``` -## 属性图查询 (PG 19+) +## SQL/PGQ 可用性 -PostgreSQL 19+ 支持 SQL/PGQ 属性图查询。详见 [属性图查询](property_graph_query.md)。 - -```python -from rhosocial.activerecord.backend.expression.graph import ( - GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, -) - -person = GraphVertex(dialect, variable="p", table="persons") -product = GraphVertex(dialect, variable="pr", table="products") -purchased = GraphEdge(dialect, variable="pu", table="purchases", - direction=GraphEdgeDirection.RIGHT) - -match = MatchClause(dialect, person, purchased, product) -# sql: 'MATCH (p) - [pu] -> (pr)' - -# 检查支持 -if dialect.supports_graph_match(): - # PG 19+ - pass -``` +PostgreSQL 19 Beta 4 已撤回 SQL/PGQ。`supports_graph_match()` 和 +`supports_graph_table()` 默认关闭,且不依据服务器版本自动启用。详见 +[SQL/PGQ 可用性](property_graph_query.md)。 diff --git a/docs/zh_CN/postgres_specific_features/partition.md b/docs/zh_CN/postgres_specific_features/partition.md index dd8d3e9e..4fb0bbf4 100644 --- a/docs/zh_CN/postgres_specific_features/partition.md +++ b/docs/zh_CN/postgres_specific_features/partition.md @@ -30,6 +30,9 @@ partition = PostgresCreatePartitionExpression( ## 分区管理 +PostgreSQL 不支持 `ATTACH PARTITION CONCURRENTLY`。PostgreSQL 14+ 支持 +`DETACH PARTITION CONCURRENTLY`,并可使用 `FINALIZE` 完成待处理的分离。 + ### ATTACH / DETACH PARTITION ```python diff --git a/docs/zh_CN/postgres_specific_features/property_graph_query.md b/docs/zh_CN/postgres_specific_features/property_graph_query.md index c2c4b7e1..d88c5e8a 100644 --- a/docs/zh_CN/postgres_specific_features/property_graph_query.md +++ b/docs/zh_CN/postgres_specific_features/property_graph_query.md @@ -1,74 +1,56 @@ -# 属性图查询 (Property Graph Query) +# SQL/PGQ 可用性 -PostgreSQL 19+ 支持 SQL/PGQ(Property Graph Query)属性图查询标准。 +PostgreSQL 19 Beta 4 在 19.0 正式发布前撤回了 SQL/PGQ,更早的版本也从未发布过该特性。 +因此,PostgreSQL 后端对所有服务器版本都默认关闭 `supports_graph_match()` 和 +`supports_graph_table()`,包括 PostgreSQL 19 及更高版本。 -## MATCH 子句 +表达式和格式化器管线仍保留用于受控兼容性测试,但默认情况下,属性图格式化器会抛出 +`UnsupportedFeatureError`。 -```python -from rhosocial.activerecord.backend.expression.graph import ( - GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, GraphTableExpression, - GraphColumn, ColumnsClause, -) +## 为何该能力与版本无关 -# 定义顶点 -person = GraphVertex(dialect, variable="p", table="persons") -product = GraphVertex(dialect, variable="pr", table="products") +所有图相关的语法产生式都已从解析器中移除,`CREATE PROPERTY GRAPH`、 +`CREATE VERTEX TABLE`、`CREATE EDGE TABLE`、`MATCH`、`GRAPH_TABLE` 和 +`PROPERTIES` 现在都是语法错误。相关的系统目录对象也随之消失: -# 定义边 -purchased = GraphEdge(dialect, variable="pu", table="purchases", - direction=GraphEdgeDirection.RIGHT) +| 目录探针 | 19beta3 | 19beta4 | +| --- | --- | --- | +| `pg_proc` 中 graph/vertex/edge 函数 | 1 | 0 | +| `pg_type` 中 graph/agtype 类型 | 14 | 0 | +| `pg_class` 中属性图关系 | 2 | 0 | +| `GRAPH_TABLE`/`VERTEX`/`EDGE`/`PROPERTY` 关键字 | 4 | 0 | -# MATCH 子句 -match = MatchClause(dialect, person, purchased, product) -# sql: 'MATCH (p) - [pu] -> (pr)' +由于从来没有任何版本发布过该特性,支持与否取决于显式 opt-in,而非 `self.version` +—— 版本号不能作为支持与否的证据,据此推断等于重新宣传一个并不存在的特性。 -# GRAPH_TABLE 表达式 -graph_table = GraphTableExpression( - dialect, - match_clause=match, - columns=ColumnsClause(dialect, columns=[ - GraphColumn(dialect, name="person_name", type="VARCHAR"), - GraphColumn(dialect, name="product_name", type="VARCHAR"), - ]), -) -``` +请注意,这些格式化器从未针对真实服务器验证过:即使在确实具备该特性的 19beta3 上, +生成的 9 条语句中也有 8 条被拒绝。因此下面的覆盖项应视为兼容性钩子, +而不是可用特性。 -## 属性图 DDL +## 能力检测 ```python -from rhosocial.activerecord.backend.expression.graph import ( - CreatePropertyGraphExpression, DropPropertyGraphExpression, - VertexTable, EdgeTable, TablePropertiesClause, -) +from rhosocial.activerecord.backend.impl.postgres import PostgresDialect -# 创建属性图 -create_graph = CreatePropertyGraphExpression( - dialect, - graph_name="social_graph", - vertices=[ - VertexTable(dialect, table_name="persons", graph_label="Person"), - ], - edges=[ - EdgeTable(dialect, table_name="knows", - source_vertex="Person", dest_vertex="Person"), - ], -) -# sql: 'CREATE PROPERTY GRAPH "social_graph" ...' - -# 删除属性图 -drop_graph = DropPropertyGraphExpression( - dialect, graph_name="social_graph", if_exists=True, -) +dialect = PostgresDialect(version=(19, 0, 4)) +assert dialect.supports_graph_match() is False +assert dialect.supports_graph_table() is False ``` -## 方言检查 +## 显式覆盖 -```python -if dialect.supports_graph_match(): - # PG 19+: 支持 MATCH 子句 +未来实现可以显式启用这些能力,而不依赖版本判断: -if dialect.supports_graph_table(): - # PG 19+: 支持 GRAPH_TABLE 表达式 +```python +dialect = PostgresDialect( + version=(19, 0, 4), + graph_feature_overrides={ + "graph_match": True, + "graph_table": True, + }, +) ``` -> **注意**:SQL/PGQ 支持需要 PostgreSQL 19+。 +仅当目标服务器或专用兼容层实际提供相应功能时,才应使用这些覆盖项。 +`supports_graph_table()` 需要同时提供两个键,因为 `GRAPH_TABLE` 包裹着 `MATCH` +子句。量化路径和逗号分隔模式没有覆盖入口,始终保持关闭。 diff --git a/docs/zh_CN/postgres_specific_features/protocol_support.md b/docs/zh_CN/postgres_specific_features/protocol_support.md index f9a8909a..2aa95804 100644 --- a/docs/zh_CN/postgres_specific_features/protocol_support.md +++ b/docs/zh_CN/postgres_specific_features/protocol_support.md @@ -24,7 +24,7 @@ PostgreSQL 方言实现了 `rhosocial.activerecord.backend.dialect.protocols` | **ArraySupport** | ✅ 完整 | 所有版本 | 原生数组类型和操作 | | **JSONSupport** | ✅ 完整 | ≥ 9.2 | JSON 类型;JSONB ≥ 9.4;JSON_TABLE ≥ 12 | | **ExplainSupport** | ✅ 完整 | 所有版本 | EXPLAIN 和 EXPLAIN ANALYZE 支持多种格式 | -| **GraphSupport** | ✅ 完整 | PG 19+ | SQL/PGQ 属性图查询 MATCH 子句和 GRAPH_TABLE | +| **GraphSupport** | 已禁用 | 显式覆盖 | PostgreSQL 19 Beta 4 已撤回 SQL/PGQ | | **LockingSupport** | ✅ 部分 | ≥ 9.5 | FOR UPDATE;SKIP LOCKED ≥ 9.5 | | **MergeSupport** | ✅ 完整 | ≥ 15.0 | MERGE 语句 | | **OrderedSetAggregationSupport** | ✅ 完整 | ≥ 9.4 | 有序集聚合函数 | @@ -66,7 +66,7 @@ PostgreSQL 方言实现了 `rhosocial.activerecord.backend.dialect.protocols` | 协议 | 描述 | 最低版本 | 文档 | |------|------|----------|------| | **PostgresPartitionSupport** | 高级分区特性 | PG 10+ | [分区文档](https://www.postgresql.org/docs/current/ddl-partitioning.html) | -| **PostgresPropertyGraphQueryMixin** | SQL/PGQ 属性图 | PG 19+ | [属性图查询](property_graph_query.md) | +| **PostgresPropertyGraphQueryMixin** | SQL/PGQ 兼容性门控 | 显式覆盖 | [SQL/PGQ 可用性](property_graph_query.md) | | **PostgresPgPartmanSupport** | pg_partman 自动分区 | PG 10+ | [分区文档](partition.md) | | **PostgresXMLMixin** | SQL/XML 标准表达式 | PG 8.3+ | [Dialect 表达式](dialect.md) | | **PostgresCollationMixin** | 表达式级 COLLATE | 所有版本 | [Dialect 表达式](dialect.md) | @@ -76,7 +76,7 @@ PostgreSQL 方言实现了 `rhosocial.activerecord.backend.dialect.protocols` | **PostgresDataTypeSupport** | 数据类型增强 | PG 11+ | [数据类型文档](https://www.postgresql.org/docs/current/datatype.html) | | **PostgresSQLSyntaxSupport** | SQL 语法增强 | PG 12+ | [SQL 语法文档](https://www.postgresql.org/docs/current/sql-syntax.html) | | **PostgresLogicalReplicationSupport** | 逻辑复制特性 | PG 10+ | [复制文档](https://www.postgresql.org/docs/current/logical-replication.html) | -| **PostgresMaterializedViewSupport** | 物化视图 | PG 9.3+ | [物化视图文档](https://www.postgresql.org/docs/current/rules-materializedviews.html) | +| **PostgresMaterializedViewSupport** | 物化视图(CREATE/REFRESH/ALTER/DROP、IF NOT EXISTS、CONCURRENTLY、TABLESPACE、存储参数) | PG 9.3+(CONCURRENTLY / IF NOT EXISTS:9.4+) | [物化视图 DDL 文档](https://www.postgresql.org/docs/current/sql-creatematerializedview.html) · [指南](../../ddl/materialized_view.md) | | **PostgresTableSupport** | 表特定特性 | 所有版本 | [表文档](https://www.postgresql.org/docs/current/ddl.html) | ## 详细原生特性支持 diff --git a/pyproject.toml b/pyproject.toml index ea713912..c96b3de0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "psycopg[binary]", + "psycopg[binary]>=3.2.13", "rhosocial-activerecord>=1.0.0.dev0,<2.0.0" ] @@ -96,6 +96,9 @@ markers = [ "benchmark_sync: marks sync benchmark tests", "benchmark_write: marks write benchmark tests", "benchmark_read: marks read benchmark tests", + "feature: category label for feature-area tests; changes collection grouping only, not ordering", + "benchmark_relation: marks relation benchmarks; changes collection grouping only, not ordering", + ] [tool.coverage.run] diff --git a/requirements-3.8.txt b/requirements-3.8.txt index b9c2fd97..8510f370 100644 --- a/requirements-3.8.txt +++ b/requirements-3.8.txt @@ -1,2 +1,2 @@ -psycopg[binary] +psycopg[binary]>=3.2.13 rhosocial-activerecord diff --git a/requirements.txt b/requirements.txt index 6aeecb94..6f427b7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ # Core dependency (rhosocial-activerecord) is installed via CI workflow from git # PostgreSQL driver -psycopg[binary] \ No newline at end of file +psycopg[binary]>=3.2.13 \ No newline at end of file diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index e06f0cbd..81b3dda5 100644 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -4,21 +4,42 @@ # Installs an optional pre-commit hook that runs scripts/preflight.sh so a # broken import/lint is caught before the commit (and before a CI round-trip). # Bypass a single commit with: git commit --no-verify +# +# Re-run this after pulling, and re-run it from the repository root. To remove +# the hook, delete .git/hooks/pre-commit (or the core.hooksPath directory). set -euo pipefail cd "$(dirname "$0")/.." -HOOK=".git/hooks/pre-commit" -if [ ! -d .git/hooks ]; then - echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 - exit 2 +# git ignores .git/hooks entirely once core.hooksPath is set, so a hook written +# there would never run while this script still reported success. +HOOKS_PATH="$(git config core.hooksPath || true)" +if [ -n "$HOOKS_PATH" ]; then + echo "install-git-hooks: core.hooksPath is set to '$HOOKS_PATH'." >&2 + echo "install-git-hooks: .git/hooks is ignored by git; writing the hook to" >&2 + echo " '$HOOKS_PATH/pre-commit' instead." >&2 + HOOK="$HOOKS_PATH/pre-commit" + mkdir -p "$HOOKS_PATH" +else + HOOK=".git/hooks/pre-commit" + if [ ! -d .git/hooks ]; then + echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 + exit 2 + fi fi cat > "$HOOK" <<'HOOK_BODY' #!/usr/bin/env bash # Installed by scripts/install-git-hooks.sh — runs the local pre-flight. # Skip with: git commit --no-verify -exec "$(git rev-parse --show-toplevel)/scripts/preflight.sh" +# +# The guard below matters on older revisions: this hook is a local, untracked +# artifact, so it survives a checkout of a commit that predates +# scripts/preflight.sh. Without the guard, exec fails with a bare +# "No such file or directory" and blocks the commit with nothing to act on. +PREFLIGHT="$(git rev-parse --show-toplevel)/scripts/preflight.sh" +[ -x "$PREFLIGHT" ] || exit 0 +exec "$PREFLIGHT" HOOK_BODY chmod +x "$HOOK" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py index 18b53f69..1bd9a409 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py @@ -21,6 +21,10 @@ from .collation import PostgresCollation # noqa: F401 from .config import PostgresConnectionConfig from .dialect import PostgresDialect +from .storage_parameters import ( # noqa: F401 + PostgresStorageParameter, + PostgresStorageParameterValueType, +) from .transaction import PostgresTransactionManager, AsyncPostgresTransactionManager from .types import PostgresEnumType from .expression.types import ( @@ -75,12 +79,46 @@ PostgresCidrType, ) from .explain import PostgresExplainResult, PostgresExplainPlanLine +from .expression import ( + PostgresCopyFormat, + PostgresCopyFromExpression, + PostgresCopyLogVerbosity, + PostgresCopyOnError, + PostgresCopyToExpression, + PostgresRepackExpression, +) from .expression.ddl import ( - PostgresCreateEnumTypeExpression, - PostgresDropEnumTypeExpression, + PostgresAddDomainCheckAction, + PostgresAddEnumValueAction, + PostgresAddTypeAttributeAction, + PostgresAlterDomainExpression, PostgresAlterEnumTypeAddValueExpression, PostgresAlterEnumTypeRenameValueExpression, + PostgresAlterTypeAttributeAction, + PostgresBaseTypeDefinition, + PostgresChangeDomainOwnerAction, + PostgresChangeTypeOwnerAction, + PostgresCompositeTypeAttribute, + PostgresCompositeTypeDefinition, + PostgresCreateDomainExpression, + PostgresCreateEnumTypeExpression, PostgresCreateRangeTypeExpression, + PostgresDropDomainCheckAction, + PostgresDropDomainExpression, + PostgresDropEnumTypeExpression, + PostgresDropTypeAttributeAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresRenameDomainConstraintAction, + PostgresRenameEnumValueAction, + PostgresRenameTypeAction, + PostgresRenameTypeAttributeAction, + PostgresSetDomainSchemaAction, + PostgresSetTypePropertiesAction, + PostgresSetTypeSchemaAction, + PostgresShellTypeDefinition, + PostgresValidateDomainConstraintAction, ) from .adapters import ( PostgresEnumAdapter, @@ -320,6 +358,9 @@ "PostgresConnectionConfig", # Dialect related "PostgresDialect", + # PostgreSQL Relation Storage Parameters + "PostgresStorageParameter", + "PostgresStorageParameterValueType", # Transaction - Sync and Async "PostgresTransactionManager", "AsyncPostgresTransactionManager", @@ -329,12 +370,44 @@ # PostgreSQL EXPLAIN Result Types "PostgresExplainResult", "PostgresExplainPlanLine", + "PostgresCopyFormat", + "PostgresCopyFromExpression", + "PostgresCopyLogVerbosity", + "PostgresCopyOnError", + "PostgresCopyToExpression", + "PostgresRepackExpression", # PostgreSQL DDL Statements + "PostgresCompositeTypeAttribute", + "PostgresCompositeTypeDefinition", + "PostgresEnumTypeDefinition", + "PostgresRangeTypeDefinition", + "PostgresBaseTypeDefinition", + "PostgresShellTypeDefinition", + "PostgresRenameTypeAction", + "PostgresSetTypeSchemaAction", + "PostgresChangeTypeOwnerAction", + "PostgresRenameTypeAttributeAction", + "PostgresAddTypeAttributeAction", + "PostgresDropTypeAttributeAction", + "PostgresAlterTypeAttributeAction", + "PostgresAddEnumValueAction", + "PostgresRenameEnumValueAction", + "PostgresSetTypePropertiesAction", + "PostgresDropTypeExpression", "PostgresCreateEnumTypeExpression", "PostgresDropEnumTypeExpression", "PostgresAlterEnumTypeAddValueExpression", "PostgresAlterEnumTypeRenameValueExpression", "PostgresCreateRangeTypeExpression", + "PostgresAddDomainCheckAction", + "PostgresDropDomainCheckAction", + "PostgresRenameDomainConstraintAction", + "PostgresValidateDomainConstraintAction", + "PostgresChangeDomainOwnerAction", + "PostgresSetDomainSchemaAction", + "PostgresCreateDomainExpression", + "PostgresAlterDomainExpression", + "PostgresDropDomainExpression", # Range Types "PostgresRange", "PostgresRangeAdapter", 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 cd99f36d..1541c237 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py @@ -16,7 +16,17 @@ import datetime import logging -from typing import Any, Dict, List, Optional, Tuple +from typing import ( + Any, + AsyncIterable, + Dict, + Iterable, + List, + Optional, + Tuple, + TYPE_CHECKING, + Union, +) import psycopg from psycopg import AsyncConnection @@ -47,6 +57,17 @@ from ..protocols import PostgresExtensionInfo from ..transaction import AsyncPostgresTransactionManager +if TYPE_CHECKING: + from ..expression import PostgresCopyFromExpression, PostgresCopyToExpression + + +CopyChunk = Union[str, bytes, bytearray, memoryview] +AsyncCopyInput = Union[ + CopyChunk, + Iterable[CopyChunk], + AsyncIterable[CopyChunk], +] + class AsyncPostgresBackend( IntrospectorBackendMixin, @@ -65,6 +86,8 @@ def __init__(self, **kwargs: Any) -> None: are enabled. Call introspect_and_adapt() after connecting to detect the actual server version and installed extensions. """ + graph_feature_overrides = kwargs.pop("graph_feature_overrides", None) + # Ensure we have proper PostgreSQL configuration connection_config = kwargs.get("connection_config") @@ -140,7 +163,9 @@ def __init__(self, **kwargs: Any) -> None: # Initialize PostgreSQL-specific components # Version is None until introspect_and_adapt() is called - self._dialect = PostgresDialect() + self._dialect = PostgresDialect( + graph_feature_overrides=graph_feature_overrides, + ) # Register PostgreSQL-specific type adapters (same as sync backend) # Note: XML adapter is NOT registered by default due to potential conflicts. @@ -286,6 +311,97 @@ async def _get_cursor(self) -> Any: return self._connection.cursor() + @staticmethod + def _copy_output_chunk(chunk: Any) -> bytes: + if isinstance(chunk, bytes): + return chunk + if isinstance(chunk, str): + return chunk.encode("utf-8") + if isinstance(chunk, (bytearray, memoryview)): + return bytes(chunk) + raise TypeError("PostgreSQL COPY stream yielded a non-buffer chunk") + + @staticmethod + def _validate_copy_input_chunk( + chunk: Any, + binary: bool, + encoding: Optional[str] = None, + ) -> CopyChunk: + if not isinstance(chunk, (str, bytes, bytearray, memoryview)): + raise TypeError("COPY input chunks must be str or bytes-like objects") + if binary and isinstance(chunk, str): + raise TypeError("COPY FROM BINARY requires bytes-like input chunks") + if encoding is not None and isinstance(chunk, str): + raise TypeError("COPY FROM with ENCODING requires bytes-like input chunks") + return chunk + + async def copy_to(self, expression: "PostgresCopyToExpression") -> bytes: + """Execute COPY TO STDOUT and return the complete byte stream.""" + from ..expression import PostgresCopyToExpression + + if not isinstance(expression, PostgresCopyToExpression): + raise TypeError("expression must be a PostgresCopyToExpression") + sql, params = expression.to_sql() + chunks = [] + try: + async with await self._get_cursor() as cursor: + async with cursor.copy(sql, params) as copy: + async for chunk in copy: + chunks.append(self._copy_output_chunk(chunk)) + except BaseException as error: + if isinstance(error, PsycopgError): + await self._handle_error(error) + else: + await self._try_rollback_transaction() + raise + return b"".join(chunks) + + async def copy_from( + self, + expression: "PostgresCopyFromExpression", + data: AsyncCopyInput, + ) -> int: + """Stream COPY FROM STDIN data and return the server-reported row count.""" + from ..expression import PostgresCopyFromExpression + + if not isinstance(expression, PostgresCopyFromExpression): + raise TypeError("expression must be a PostgresCopyFromExpression") + sql, params = expression.to_sql() + binary = str(expression.format).lower() == "binary" + try: + async with await self._get_cursor() as cursor: + async with cursor.copy(sql, params) as copy: + if isinstance(data, (str, bytes, bytearray, memoryview)): + await copy.write( + self._validate_copy_input_chunk(data, binary, expression.encoding) + ) + elif hasattr(data, "__aiter__"): + async for chunk in data: + await copy.write( + self._validate_copy_input_chunk(chunk, binary, expression.encoding) + ) + else: + try: + chunks = iter(data) + except TypeError as error: + raise TypeError( + "COPY input must be bytes, str, or a sync/async iterable of chunks" + ) from error + for chunk in chunks: + await copy.write( + self._validate_copy_input_chunk(chunk, binary, expression.encoding) + ) + rowcount = int(cursor.rowcount) + if getattr(self, "_transaction_manager", None) is not None: + await self._handle_auto_commit_if_needed() + return rowcount + except BaseException as error: + if isinstance(error, PsycopgError): + await self._handle_error(error) + else: + await self._try_rollback_transaction() + raise + async def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult: """Execute the same SQL statement multiple times with different parameters asynchronously.""" if not self._connection: @@ -403,7 +519,12 @@ async def introspect_and_adapt(self) -> None: if version_changed: self._server_version_cache = actual_version - self._dialect = PostgresDialect(actual_version) + self._dialect = PostgresDialect( + actual_version, + graph_feature_overrides=getattr( + self._dialect, "_graph_feature_overrides", None + ), + ) # Detect installed extensions extensions = await self._detect_extensions() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py index e1ee833a..bcba3f5f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py @@ -16,7 +16,7 @@ import datetime import logging -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING, Union import psycopg from psycopg.errors import Error as PsycopgError @@ -47,6 +47,13 @@ from ..transaction import PostgresTransactionManager from ..introspection import SyncPostgreSQLIntrospector +if TYPE_CHECKING: + from ..expression import PostgresCopyFromExpression, PostgresCopyToExpression + + +CopyChunk = Union[str, bytes, bytearray, memoryview] +CopyInput = Union[CopyChunk, Iterable[CopyChunk]] + class PostgresBackend( IntrospectorBackendMixin, @@ -65,6 +72,8 @@ def __init__(self, **kwargs: Any) -> None: are enabled. Call introspect_and_adapt() after connecting to detect the actual server version and installed extensions. """ + graph_feature_overrides = kwargs.pop("graph_feature_overrides", None) + # Ensure we have proper PostgreSQL configuration connection_config = kwargs.get("connection_config") @@ -140,7 +149,9 @@ def __init__(self, **kwargs: Any) -> None: # Initialize PostgreSQL-specific components # Version is None until introspect_and_adapt() is called - self._dialect = PostgresDialect() + self._dialect = PostgresDialect( + graph_feature_overrides=graph_feature_overrides, + ) # Register PostgreSQL-specific type adapters (same as async backend) # Note: XML adapter is NOT registered by default due to potential conflicts. @@ -287,6 +298,89 @@ def _get_cursor(self) -> Any: return self._connection.cursor() + @staticmethod + def _copy_output_chunk(chunk: Any) -> bytes: + if isinstance(chunk, bytes): + return chunk + if isinstance(chunk, str): + return chunk.encode("utf-8") + if isinstance(chunk, (bytearray, memoryview)): + return bytes(chunk) + raise TypeError("PostgreSQL COPY stream yielded a non-buffer chunk") + + @staticmethod + def _validate_copy_input_chunk( + chunk: Any, + binary: bool, + encoding: Optional[str] = None, + ) -> CopyChunk: + if not isinstance(chunk, (str, bytes, bytearray, memoryview)): + raise TypeError("COPY input chunks must be str or bytes-like objects") + if binary and isinstance(chunk, str): + raise TypeError("COPY FROM BINARY requires bytes-like input chunks") + if encoding is not None and isinstance(chunk, str): + raise TypeError("COPY FROM with ENCODING requires bytes-like input chunks") + return chunk + + def copy_to(self, expression: "PostgresCopyToExpression") -> bytes: + """Execute COPY TO STDOUT and return the complete byte stream.""" + from ..expression import PostgresCopyToExpression + + if not isinstance(expression, PostgresCopyToExpression): + raise TypeError("expression must be a PostgresCopyToExpression") + sql, params = expression.to_sql() + chunks = [] + try: + with self._get_cursor() as cursor: + with cursor.copy(sql, params) as copy: + for chunk in copy: + chunks.append(self._copy_output_chunk(chunk)) + except BaseException as error: + if isinstance(error, PsycopgError): + self._handle_error(error) + else: + self._try_rollback_transaction() + raise + return b"".join(chunks) + + def copy_from( + self, + expression: "PostgresCopyFromExpression", + data: CopyInput, + ) -> int: + """Stream COPY FROM STDIN data and return the server-reported row count.""" + from ..expression import PostgresCopyFromExpression + + if not isinstance(expression, PostgresCopyFromExpression): + raise TypeError("expression must be a PostgresCopyFromExpression") + sql, params = expression.to_sql() + binary = str(expression.format).lower() == "binary" + chunks: Iterable[CopyChunk] + if isinstance(data, (str, bytes, bytearray, memoryview)): + chunks = (data,) + else: + try: + chunks = iter(data) + except TypeError as error: + raise TypeError("COPY input must be bytes, str, or an iterable of chunks") from error + try: + with self._get_cursor() as cursor: + with cursor.copy(sql, params) as copy: + for chunk in chunks: + copy.write( + self._validate_copy_input_chunk(chunk, binary, expression.encoding) + ) + rowcount = int(cursor.rowcount) + if getattr(self, "_transaction_manager", None) is not None: + self._handle_auto_commit_if_needed() + return rowcount + except BaseException as error: + if isinstance(error, PsycopgError): + self._handle_error(error) + else: + self._try_rollback_transaction() + raise + def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult: """Execute the same SQL statement multiple times with different parameters.""" if not self._connection: @@ -404,7 +498,12 @@ def introspect_and_adapt(self) -> None: if version_changed: self._server_version_cache = actual_version - self._dialect = PostgresDialect(actual_version) + self._dialect = PostgresDialect( + actual_version, + graph_feature_overrides=getattr( + self._dialect, "_graph_feature_overrides", None + ), + ) # Detect installed extensions extensions = self._detect_extensions() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 314f4629..92d049e8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -6,11 +6,10 @@ based on the PostgreSQL version provided at initialization. """ -from typing import Tuple, Optional, TYPE_CHECKING +from typing import Mapping, Tuple, Optional, cast, TYPE_CHECKING if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.collation import CollateExpression - from .function_versions import FunctionSupportInfo, FunctionVersionRequirement + from rhosocial.activerecord.backend.schema.differ import SchemaDiffer from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.dialect.mixins import ( @@ -50,6 +49,8 @@ DMLMixin, DDLColumnMixin, TransactionControlMixin, + UserDefinedTypeMixin, + DomainMixin, ) from rhosocial.activerecord.backend.dialect.protocols import ( SQLXMLSupport, @@ -87,6 +88,8 @@ TransactionControlSupport, SQLFunctionSupport, DDLTypeSupport, + UserDefinedTypeSupport, + DomainSupport, ) from .mixins import ( PostgresExtensionMixin, @@ -103,6 +106,7 @@ PostgresPropertyGraphQueryMixin, PostgresIndexMixin, PostgresVacuumMixin, + PostgresCopyMixin, PostgresQueryOptimizationMixin, PostgresDataTypeMixin, PostgresLogicalReplicationMixin, @@ -122,6 +126,7 @@ PostgresJoinMixin, PostgresTruncateMixin, PostgresSchemaMixin, + PostgresDatabaseMixin, PostgresSequenceMixin, PostgresTransactionMixin, PostgresViewMixin, @@ -163,6 +168,7 @@ PostgresAlterTableSettingsMixin, PostgresClusterMixin, PostgresDomainMixin, + PostgresRepackMixin, PostgresCollationDDLMixin, PostgresForeignTableMixin, PostgresRoutineMixin, @@ -193,6 +199,8 @@ PostgresFunctionMixin, ) +from .protocols.ddl.type import PostgresTypeSupport +from .protocols.ddl.domain import PostgresDomainSupport from .reserved_words import POSTGRESQL_RESERVED_WORDS # PostgreSQL-specific imports @@ -210,6 +218,7 @@ PostgresPartitionSupport, PostgresIndexSupport, PostgresVacuumSupport, + PostgresCopySupport, PostgresQueryOptimizationSupport, PostgresDataTypeSupport, PostgresLogicalReplicationSupport, @@ -262,13 +271,12 @@ # DDL feature protocols PostgresTriggerSupport, PostgresCommentSupport, - PostgresTypeSupport, PostgresConstraintSupport, PostgresPolicySupport, PostgresRlsConfigSupport, PostgresAlterTableSettingsSupport, PostgresClusterSupport, - PostgresDomainSupport, + PostgresRepackSupport, PostgresCollationDDLSupport, PostgresForeignTableDDLSupport, PostgresRoutineDDLSupport, @@ -291,6 +299,14 @@ class PostgresDialect( SQLDialectBase, + PostgresTypeMixin, + PostgresDomainMixin, + PostgresTypeSupport, + PostgresDomainSupport, + UserDefinedTypeMixin, + DomainMixin, + UserDefinedTypeSupport, + DomainSupport, # PG-specific mixins (before global mixins to override) PostgresDateTimeMixin, PostgresDQLMixin, @@ -313,6 +329,7 @@ class PostgresDialect( PostgresJoinMixin, PostgresTruncateMixin, PostgresSchemaMixin, + PostgresDatabaseMixin, PostgresSequenceMixin, PostgresTransactionMixin, PostgresViewMixin, @@ -333,6 +350,9 @@ class PostgresDialect( ArrayMixin, ExplainMixin, + # Must precede GraphMixin/GraphTableMixin so that the explicit-override + # probes here win the MRO lookup; the core mixins would otherwise answer + # from their own always-False defaults and ignore graph_feature_overrides. PostgresPropertyGraphQueryMixin, GraphMixin, GraphTableMixin, @@ -344,6 +364,7 @@ class PostgresDialect( UpsertMixin, LateralJoinMixin, JoinMixin, + PostgresMaterializedViewMixin, # Before ViewMixin to override format_*_materialized_view_* ViewMixin, SchemaMixin, PostgresIndexMixin, @@ -351,10 +372,11 @@ class PostgresDialect( SequenceMixin, # PostgreSQL-specific mixins PostgresExtensionMixin, - PostgresMaterializedViewMixin, PostgresAlterColumnModifierMixin, # Before TableMixin/ConstraintMixin to override format_*_action PostgresTableMixin, # Before TableMixin to override supports_create_table_like + PostgresCommentMixin, # Before TableMixin to override format_comment_statement TableMixin, + PostgresConstraintMixin, ConstraintMixin, PostgresPartitionMixin, PartitionMixin, @@ -367,6 +389,7 @@ class PostgresDialect( PostgresHstoreMixin, # Native feature mixins PostgresVacuumMixin, + PostgresCopyMixin, PostgresQueryOptimizationMixin, PostgresDataTypeMixin, PostgresLogicalReplicationMixin, @@ -397,14 +420,11 @@ class PostgresDialect( PostgresAddressStandardizerMixin, # DDL feature mixins PostgresTriggerMixin, - PostgresCommentMixin, - PostgresTypeMixin, - PostgresConstraintMixin, PostgresPolicyMixin, PostgresRlsConfigMixin, PostgresAlterTableSettingsMixin, PostgresClusterMixin, - PostgresDomainMixin, + PostgresRepackMixin, PostgresCollationDDLMixin, PostgresForeignTableMixin, PostgresRoutineMixin, @@ -479,6 +499,7 @@ class PostgresDialect( PostgresPartitionSupport, PostgresIndexSupport, PostgresVacuumSupport, + PostgresCopySupport, PostgresQueryOptimizationSupport, PostgresDataTypeSupport, PostgresLogicalReplicationSupport, @@ -531,13 +552,12 @@ class PostgresDialect( # DDL feature protocols PostgresTriggerSupport, PostgresCommentSupport, - PostgresTypeSupport, PostgresConstraintSupport, PostgresPolicySupport, PostgresRlsConfigSupport, PostgresAlterTableSettingsSupport, PostgresClusterSupport, - PostgresDomainSupport, + PostgresRepackSupport, PostgresCollationDDLSupport, PostgresForeignTableDDLSupport, PostgresRoutineDDLSupport, @@ -594,7 +614,12 @@ class PostgresDialect( from .function_versions import POSTGRES_FUNCTION_VERSIONS as _FV _POSTGRES_FUNCTION_VERSIONS = _FV - def __init__(self, version: Optional[Tuple[int, int, int]] = None): + def __init__( + self, + version: Optional[Tuple[int, int, int]] = None, + *, + graph_feature_overrides: Optional[Mapping[str, bool]] = None, + ): """ Initialize PostgreSQL dialect with specific version. @@ -603,6 +628,15 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): If None, the dialect must be adapted via backend.introspect_and_adapt() before version-dependent features can be used. + graph_feature_overrides: Explicit opt-ins for SQL/PGQ property graph + features, keyed by ``"graph_match"`` / ``"graph_table"``. + + Left unset, property graph support stays off for every version. + This is deliberate: SQL/PGQ was withdrawn in PostgreSQL 19 Beta 4 + and shipped in no earlier release, so a version check can never + be evidence of support. Pass these only when the target server + (or a compatibility layer in front of it) genuinely provides the + feature. """ super().__init__() @@ -610,6 +644,16 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): if version is not None: self.version = version + overrides = dict(graph_feature_overrides or {}) + if any(not isinstance(name, str) for name in overrides): + raise TypeError("Graph feature override names must be strings") + unknown = overrides.keys() - self.GRAPH_FEATURE_NAMES + if unknown: + raise ValueError(f"Unknown graph feature overrides: {', '.join(sorted(unknown))}") + if any(type(enabled) is not bool for enabled in overrides.values()): + raise TypeError("Graph feature override values must be booleans") + self._graph_feature_overrides = overrides + @staticmethod def _validate_data_type(data_type: str) -> bool: """Validate data type for safe embedding in SQL. @@ -632,10 +676,10 @@ def get_server_version(self) -> Tuple[int, int, int]: """Return the PostgreSQL version this dialect is configured for.""" return self.version - def create_schema_differ(self): + def create_schema_differ(self) -> "SchemaDiffer": """Return the PostgreSQL schema differ for this dialect.""" from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( PostgresSchemaDiffer, ) - return PostgresSchemaDiffer() + return cast("SchemaDiffer", PostgresSchemaDiffer()) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py index 2aec6dd9..6057a364 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py @@ -12,6 +12,10 @@ 8. REFRESH MATERIALIZED VIEW WITH DATA (after NO DATA) 9. Schema-qualified refresh (PostgresRefreshMaterializedViewExpression) 10. DROP MATERIALIZED VIEW with CASCADE and IF EXISTS +11. IF NOT EXISTS (PostgreSQL 9.4+) via PostgresCreateMaterializedViewExpression +12. Schema-qualified create/drop +13. ALTER MATERIALIZED VIEW: RENAME TO / SET SCHEMA / SET () / RESET () / OWNER TO +14. Introspection: list_materialized_views() with is_populated / has_unique_index """ # ============================================================ @@ -41,6 +45,18 @@ ColumnConstraint, ColumnConstraintType, ) +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresCreateMaterializedViewExpression, + PostgresDropMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -84,21 +100,22 @@ table='sales', columns=[ ColumnDefinition( + dialect, 'id', - 'SERIAL', + dialect.parse_type('SERIAL'), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ], ), - ColumnDefinition('product_id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnDefinition(dialect, 'product_id', dialect.parse_type('INT'), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ]), - ColumnDefinition('amount', 'DECIMAL(10,2)', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnDefinition(dialect, 'amount', dialect.parse_type('DECIMAL(10,2)'), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ]), - ColumnDefinition('sale_date', 'DATE', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnDefinition(dialect, 'sale_date', dialect.parse_type('DATE'), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ]), ], if_not_exists=True, @@ -145,14 +162,17 @@ ), ) -create_mv = CreateMaterializedViewExpression( +create_mv = PostgresCreateMaterializedViewExpression( dialect=dialect, view_name='sales_summary', query=summary_query, column_aliases=['product_id', 'total_sales', 'total_amount', 'avg_amount'], + # Storage parameter names are validated against PostgresStorageParameter; + # unlisted names (toast.* / table access method options) need + # allow_unlisted_storage_parameters=True. storage_options={ - 'fillfactor': 70, - 'autovacuum_enabled': 'true', + PostgresStorageParameter.FILLFACTOR: 70, + PostgresStorageParameter.AUTOVACUUM_ENABLED: 'true', }, ) sql, params = create_mv.to_sql() @@ -347,10 +367,7 @@ # ============================================================ # The PostgreSQL backend provides an extended refresh expression that # supports specifying the schema for the materialized view. - -from rhosocial.activerecord.backend.impl.postgres.expression.ddl.mv import ( - PostgresRefreshMaterializedViewExpression, -) +# (PostgresRefreshMaterializedViewExpression is imported at the top of this file.) # Refresh with schema (public is PG's default schema) pg_refresh = PostgresRefreshMaterializedViewExpression( @@ -408,9 +425,150 @@ print(f"DROP without IF EXISTS SQL (not executed): {sql}") print(" Note: This would fail at runtime if the MV does not exist.") +# ============================================================ +# SECTION: 10. IF NOT EXISTS (PostgreSQL 9.4+) +# ============================================================ +# IF NOT EXISTS makes creation idempotent: a second run emits a notice +# instead of failing. Requires PostgreSQL 9.4+; the dialect raises +# UnsupportedFeatureError below that version. + +if_not_exists_mv = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', + query=summary_query, + column_aliases=['product_id', 'total_sales', 'total_amount', 'avg_amount'], + if_not_exists=True, +) +sql, params = if_not_exists_mv.to_sql() +print(f"\nIF NOT EXISTS SQL: {sql}") +backend.execute(sql, params) + +# ============================================================ +# SECTION: 11. Schema-Qualified Create and Drop +# ============================================================ +# Every MV statement accepts a schema; names are quoted per part, so mixed +# case and reserved words are safe. + +backend.execute('CREATE SCHEMA IF NOT EXISTS mv_reporting', options=ddl_options) + +schema_mv = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name='Order Summary', + query=summary_query, + schema='mv_reporting', + column_aliases=['product_id', 'total_sales', 'total_amount', 'avg_amount'], +) +sql, params = schema_mv.to_sql() +print(f"\nSchema-qualified CREATE SQL: {sql}") +backend.execute(sql, params) + +schema_verify = QueryExpression( + dialect=dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression( + dialect, 'Order Summary', schema_name='mv_reporting' + ), +) +sql, params = schema_verify.to_sql() +result = backend.execute(sql, params, options=dql_options) +print("Schema-qualified MV result:") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: 12. ALTER MATERIALIZED VIEW +# ============================================================ +# PostgreSQL supports exactly five ALTER MATERIALIZED VIEW actions. +# SET TABLESPACE is NOT one of them: a materialized view cannot be +# relocated to another tablespace after creation. +# Multiple actions render as separate statements joined with ";\n". + +alter_mv = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', + actions=[ + PostgresSetMaterializedViewPropertiesAction( + dialect, {PostgresStorageParameter.FILLFACTOR: 85} + ), + PostgresResetMaterializedViewPropertiesAction( + dialect, [PostgresStorageParameter.AUTOVACUUM_ENABLED] + ), + ], +) +sql, params = alter_mv.to_sql() +print(f"\nALTER SET/RESET SQL:\n{sql}") +backend.execute(sql, params, options=ddl_options) + +rename_mv = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', + actions=[PostgresRenameMaterializedViewAction(dialect, 'sales_summary_v2')], +) +sql, params = rename_mv.to_sql() +print(f"\nALTER RENAME SQL: {sql}") +backend.execute(sql, params, options=ddl_options) + +# Move the schema-qualified view into the public schema. +set_schema_mv = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name='Order Summary', + schema='mv_reporting', + actions=[PostgresSetMaterializedViewSchemaAction(dialect, 'public')], +) +sql, params = set_schema_mv.to_sql() +print(f"\nALTER SET SCHEMA SQL: {sql}") +backend.execute(sql, params, options=ddl_options) + +# CURRENT_USER / CURRENT_ROLE / SESSION_USER are emitted verbatim. +owner_mv = PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary_v2', + actions=[PostgresChangeMaterializedViewOwnerAction(dialect, 'CURRENT_USER')], +) +sql, params = owner_mv.to_sql() +print(f"\nALTER OWNER TO SQL: {sql}") +backend.execute(sql, params, options=ddl_options) + +# ============================================================ +# SECTION: 13. Introspection +# ============================================================ +# list_materialized_views() is a dedicated entry point: list_views() stays +# limited to relkind='v'. Each entry reports whether the view is populated +# and whether it carries the UNIQUE index CONCURRENTLY refresh needs. + +backend.introspector.clear_cache() +materialized_views = backend.introspector.list_materialized_views() +print("\nMaterialized views:") +for view in materialized_views: + print(f" {view.schema}.{view.name} populated={view.extra.get('is_populated')} " + f"unique_index={view.extra.get('has_unique_index')}") + +info = backend.introspector.get_materialized_view_info('sales_summary_v2') +print(f"\nMV info: {info.name if info else None} definition={info.definition[:60] if info and info.definition else None}") + +# ============================================================ +# SECTION: 14. Schema-Qualified Drop +# ============================================================ + +drop_schema_mv = PostgresDropMaterializedViewExpression( + dialect=dialect, + view_name='Order Summary', + schema='mv_reporting', + if_exists=True, + cascade=True, +) +sql, params = drop_schema_mv.to_sql() +print(f"\nSchema-qualified DROP SQL: {sql}") +backend.execute(sql, params, options=ddl_options) + # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ +drop_mv = PostgresDropMaterializedViewExpression( + dialect=dialect, view_name='sales_summary_v2', if_exists=True, cascade=True, +) +backend.execute(*drop_mv.to_sql()) +backend.execute('DROP SCHEMA IF EXISTS mv_reporting CASCADE', options=ddl_options) drop_table = DropTableExpression( dialect=dialect, table='sales', @@ -437,4 +595,7 @@ # 10. Use DropMaterializedViewExpression with cascade=True to drop dependent objects # 11. Use DropMaterializedViewExpression with if_exists=True for safe drop # 12. Requires PostgreSQL 9.3+ for basic MV support -# 13. Requires PostgreSQL 9.4+ for CONCURRENTLY refresh \ No newline at end of file +# 13. Requires PostgreSQL 9.4+ for CONCURRENTLY refresh and IF NOT EXISTS +# 14. storage_options keys are validated against PostgresStorageParameter +# 15. ALTER MATERIALIZED VIEW supports RENAME/SET SCHEMA/SET()/RESET()/OWNER TO +# 16. list_materialized_views() reports is_populated and has_unique_index \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py index c34770f2..896b65aa 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py @@ -97,7 +97,6 @@ Column(dialect, 'name'), Literal(dialect, 'Alice'), ), - dialect_options={}, ) sql, params = delete_expr.to_sql() @@ -121,7 +120,6 @@ Literal(dialect, 'Bob'), ), returning=ReturningClause(dialect, [Column(dialect, 'id'), Column(dialect, 'name')]), - dialect_options={}, ) sql, params = delete_returning.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/bloom.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/bloom.py index 59c3c26a..f8a8efdb 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/bloom.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/bloom.py @@ -169,7 +169,7 @@ columns=["name", "email"], index_type="BLOOM", if_not_exists=True, - dialect_options={"with": {"length": 64, "col1": 2, "col2": 2}}, + with_options={"length": 64, "col1": 2, "col2": 2}, ) sql, params = create_idx_opts.to_sql() print("\n--- CREATE BLOOM INDEX WITH OPTIONS ---") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gin.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gin.py index 643a8e23..2e1333fe 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gin.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gin.py @@ -132,7 +132,7 @@ columns=["name"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"name": "int4_ops"}}, + opclasses={"name": "int4_ops"}, ) sql, params = create_idx.to_sql() print("\n--- CREATE GIN INDEX with btree_gin operator class ---") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gist.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gist.py index 2a6c48bc..b2201ee0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gist.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/btree_gist.py @@ -133,7 +133,7 @@ columns=["created_at"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"created_at": "int4_ops"}}, + opclasses={"created_at": "int4_ops"}, ) sql, params = create_idx.to_sql() print("\n--- CREATE GIST INDEX with btree_gist operator class ---") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pg_trgm.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pg_trgm.py index bb407575..612284f6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pg_trgm.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pg_trgm.py @@ -233,7 +233,7 @@ columns=["title"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"title": "gin_trgm_ops"}}, + opclasses={"title": "gin_trgm_ops"}, ) sql, params = create_idx.to_sql() print("\n--- CREATE GIN INDEX ---") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py index 7353b070..89c056ac 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py @@ -78,7 +78,6 @@ source=ValuesSource(dialect, [[Literal(dialect, 'Alice'), Literal(dialect, 'alice@example.com')]]), columns=['name', 'email'], returning=ReturningClause(dialect, [Column(dialect, 'id')]), - dialect_options={}, ) sql, params = insert_expr.to_sql() @@ -99,7 +98,6 @@ source=ValuesSource(dialect, [[Literal(dialect, 'Bob'), Literal(dialect, 'bob@example.com')]]), columns=['name', 'email'], returning=ReturningClause(dialect, [Column(dialect, 'id'), Column(dialect, 'name')]), - dialect_options={}, ) sql, params = insert_multi.to_sql() @@ -116,7 +114,6 @@ ]), columns=['name', 'email'], returning=ReturningClause(dialect, [Column(dialect, 'id'), Column(dialect, 'name')]), - dialect_options={}, ) sql, params = batch_insert.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py index 14f4b8b9..dccd0fb3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py @@ -1,13 +1,9 @@ """ -Recursive CTE graph traversal — PGQ quantified path alternative -=============================================================== +Recursive CTE graph traversal +============================= -PostgreSQL 19 natively supports SQL/PGQ (MATCH / GRAPH_TABLE), with limitations: - - supports_quantified_path() → False (no variable-length paths +/*/{n,m}) - - supports_comma_separated_patterns() → False (no comma-separated multi-pattern) - -This example demonstrates recursive CTE + SetOperationExpression + UNION ALL -as an equivalent alternative for graph traversal patterns. +PostgreSQL 19 Beta 4 withdrew SQL/PGQ. This example demonstrates recursive CTE ++ SetOperationExpression + UNION ALL as an alternative for graph traversal. Common use cases: 1. Social network N-degree friend recommendations (replaces MATCH (a)-[e]->+(b)) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py index b1d11b9d..5e0e63de 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py @@ -19,6 +19,7 @@ import os from rhosocial.activerecord.backend.impl.postgres import PostgresBackend from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression.statements import IdentityClause from rhosocial.activerecord.backend.expression import ( CreateTableExpression, DropTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, @@ -61,14 +62,14 @@ backend.execute(sql, params) # PostgreSQL 10+: GENERATED AS IDENTITY -# Note: dialect_options={'identity': 'ALWAYS'} triggers GENERATED ALWAYS AS IDENTITY +# Note: IdentityClause(generation='ALWAYS') triggers GENERATED ALWAYS AS IDENTITY # in the PostgreSQL dialect's format_column_definition try: pg10_table = CreateTableExpression( dialect=dialect, table="pg10_style", columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)], - dialect_options={"identity": "ALWAYS"}), + identity_clause=IdentityClause(dialect, generation="ALWAYS")), ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), ] ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 98684799..9206de32 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -19,9 +19,22 @@ AdvisoryLockType, ) from .ilike import ILIKEExpression +from .copy import ( + PostgresCopyFormat, + PostgresCopyOnError, + PostgresCopyLogVerbosity, + PostgresCopyToExpression, + PostgresCopyFromExpression, +) from .ddl import ( + PostgresColumnStorage, + PostgresColumnDefinition, + PostgresColumnOptions, + PostgresAlterColumn, PostgresVacuumExpression, PostgresAnalyzeExpression, + PostgresRepackExpression, + PostgresPartitionClause, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, @@ -29,16 +42,63 @@ PostgresAlterIndexExpression, PostgresAlterIndexActionType, PostgresReindexExpression, + PostgresCreateIndexExpression, + PostgresIndexDefinition, + PostgresDropIndexExpression, + PostgresExcludeConstraint, + PostgresAlterConstraint, + PostgresValidateConstraint, + PostgresAlterConstraintAction, + PostgresValidateConstraintAction, + PostgresAlterConstraintExpression, + PostgresValidateConstraintExpression, + PostgresAlterTableConstraint, + PostgresValidateTableConstraint, PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, PostgresCommentExpression, + MaterializedViewAlterAction, + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresCreateMaterializedViewExpression, + PostgresDropMaterializedViewExpression, PostgresRefreshMaterializedViewExpression, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, + PostgresCompositeTypeAttribute, + PostgresCompositeTypeDefinition, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresBaseTypeDefinition, + PostgresShellTypeDefinition, + PostgresRenameTypeAction, + PostgresSetTypeSchemaAction, + PostgresChangeTypeOwnerAction, + PostgresRenameTypeAttributeAction, + PostgresAddTypeAttributeAction, + PostgresDropTypeAttributeAction, + PostgresAlterTypeAttributeAction, + PostgresAddEnumValueAction, + PostgresRenameEnumValueAction, + PostgresSetTypePropertiesAction, + PostgresDropTypeExpression, PostgresCreateEnumTypeExpression, PostgresDropEnumTypeExpression, PostgresAlterEnumAddValueExpression, PostgresAlterEnumTypeAddValueExpression, PostgresAlterEnumTypeRenameValueExpression, PostgresCreateRangeTypeExpression, + PostgresAddDomainCheckAction, + PostgresDropDomainCheckAction, + PostgresRenameDomainConstraintAction, + PostgresValidateDomainConstraintAction, + PostgresChangeDomainOwnerAction, + PostgresSetDomainSchemaAction, + PostgresCreateDomainExpression, + PostgresAlterDomainExpression, + PostgresDropDomainExpression, PostgresCreateExtensionExpression, PostgresDropExtensionExpression, PostgresPgPartmanCreateParentExpression, @@ -100,6 +160,9 @@ PostgresXMLType, PostgresCidrType, ) +from .registry import register_postgres_ddl_expressions + +register_postgres_ddl_expressions() __all__ = [ # advisory @@ -111,9 +174,20 @@ # locking # ilike "ILIKEExpression", + "PostgresCopyFormat", + "PostgresCopyOnError", + "PostgresCopyLogVerbosity", + "PostgresCopyToExpression", + "PostgresCopyFromExpression", # ddl "PostgresVacuumExpression", "PostgresAnalyzeExpression", + "PostgresRepackExpression", + "PostgresColumnStorage", + "PostgresColumnDefinition", + "PostgresColumnOptions", + "PostgresAlterColumn", + "PostgresPartitionClause", "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", "PostgresAttachPartitionExpression", @@ -121,16 +195,63 @@ "PostgresAlterIndexExpression", "PostgresAlterIndexActionType", "PostgresReindexExpression", + "PostgresCreateIndexExpression", + "PostgresIndexDefinition", + "PostgresDropIndexExpression", + "PostgresExcludeConstraint", + "PostgresAlterConstraint", + "PostgresValidateConstraint", + "PostgresAlterConstraintAction", + "PostgresValidateConstraintAction", + "PostgresAlterConstraintExpression", + "PostgresValidateConstraintExpression", + "PostgresAlterTableConstraint", + "PostgresValidateTableConstraint", "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", "PostgresCommentExpression", + "MaterializedViewAlterAction", + "PostgresAlterMaterializedViewExpression", + "PostgresChangeMaterializedViewOwnerAction", + "PostgresCreateMaterializedViewExpression", + "PostgresDropMaterializedViewExpression", "PostgresRefreshMaterializedViewExpression", + "PostgresRenameMaterializedViewAction", + "PostgresResetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewSchemaAction", + "PostgresCompositeTypeAttribute", + "PostgresCompositeTypeDefinition", + "PostgresEnumTypeDefinition", + "PostgresRangeTypeDefinition", + "PostgresBaseTypeDefinition", + "PostgresShellTypeDefinition", + "PostgresRenameTypeAction", + "PostgresSetTypeSchemaAction", + "PostgresChangeTypeOwnerAction", + "PostgresRenameTypeAttributeAction", + "PostgresAddTypeAttributeAction", + "PostgresDropTypeAttributeAction", + "PostgresAlterTypeAttributeAction", + "PostgresAddEnumValueAction", + "PostgresRenameEnumValueAction", + "PostgresSetTypePropertiesAction", + "PostgresDropTypeExpression", "PostgresCreateEnumTypeExpression", "PostgresDropEnumTypeExpression", "PostgresAlterEnumAddValueExpression", "PostgresAlterEnumTypeAddValueExpression", "PostgresAlterEnumTypeRenameValueExpression", "PostgresCreateRangeTypeExpression", + "PostgresAddDomainCheckAction", + "PostgresDropDomainCheckAction", + "PostgresRenameDomainConstraintAction", + "PostgresValidateDomainConstraintAction", + "PostgresChangeDomainOwnerAction", + "PostgresSetDomainSchemaAction", + "PostgresCreateDomainExpression", + "PostgresAlterDomainExpression", + "PostgresDropDomainExpression", "PostgresCreateExtensionExpression", "PostgresDropExtensionExpression", "PostgresPgPartmanCreateParentExpression", @@ -188,4 +309,5 @@ "PostgresXIDType", "PostgresXMLType", "PostgresCidrType", + "register_postgres_ddl_expressions", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/copy.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/copy.py new file mode 100644 index 00000000..3b610964 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/copy.py @@ -0,0 +1,182 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/copy.py +"""Typed PostgreSQL COPY expressions.""" + +from enum import Enum +from typing import List, Optional, Sequence, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresCopyFormat", + "PostgresCopyOnError", + "PostgresCopyLogVerbosity", + "PostgresCopyToExpression", + "PostgresCopyFromExpression", +] + + +class PostgresCopyFormat(str, Enum): + TEXT = "text" + CSV = "csv" + JSON = "json" + BINARY = "binary" + + +class PostgresCopyOnError(str, Enum): + STOP = "stop" + IGNORE = "ignore" + SET_NULL = "set_null" + + +class PostgresCopyLogVerbosity(str, Enum): + DEFAULT = "default" + VERBOSE = "verbose" + SILENT = "silent" + + +CopyHeader = Union[bool, int, str] +CopyColumnSelection = Union[str, Sequence[str]] + + +def _enum_value(value: Union[str, Enum]) -> str: + return str(value.value if isinstance(value, Enum) else value) + + +def _copy_columns(value: Optional[Sequence[str]], name: str) -> Optional[List[str]]: + if value is None: + return None + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise TypeError(f"{name} must be a sequence of identifiers") + columns = list(value) + if any(not isinstance(column, str) for column in columns): + raise TypeError(f"{name} must contain only string identifiers") + return columns + + +def _copy_force_columns( + value: Optional[CopyColumnSelection], +) -> Optional[Union[str, List[str]]]: + if value is None: + return None + if isinstance(value, str): + return "*" if value == "*" else [value] + if not isinstance(value, Sequence): + raise TypeError("FORCE column selection must be '*' or a sequence of identifiers") + columns = list(value) + if not columns: + raise ValueError("FORCE column selection must not be empty") + if any(not isinstance(column, str) for column in columns): + raise TypeError("FORCE column selection must contain only string identifiers") + return columns + + +class PostgresCopyToExpression(BaseExpression): + """A PostgreSQL 19-safe ``COPY ... TO STDOUT`` expression.""" + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: Optional[str] = None, + query: Optional[BaseExpression] = None, + *, + schema: Optional[str] = None, + columns: Optional[Sequence[str]] = None, + format: Union[PostgresCopyFormat, str] = PostgresCopyFormat.TEXT, + header: Optional[CopyHeader] = None, + null: Optional[str] = None, + delimiter: Optional[str] = None, + quote: Optional[str] = None, + escape: Optional[str] = None, + force_quote: Optional[CopyColumnSelection] = None, + force_array: Optional[bool] = None, + partitioned: bool = False, + is_partitioned: Optional[bool] = None, + ): + super().__init__(dialect) + if table_name is None and query is None: + raise ValueError("COPY TO requires exactly one table_name or query source") + if table_name is not None and query is not None: + raise ValueError("COPY TO accepts either table_name or query, not both") + if query is not None and not isinstance(query, BaseExpression): + raise TypeError("COPY TO query must be a BaseExpression") + if is_partitioned is not None: + if not isinstance(is_partitioned, bool): + raise TypeError("is_partitioned must be a bool") + if partitioned and not is_partitioned: + raise ValueError("partitioned and is_partitioned disagree") + partitioned = is_partitioned + self.table_name = table_name + self.query = query + self.schema = schema + self.columns = _copy_columns(columns, "columns") + self.format = _enum_value(format) + self.header = header + self.null = null + self.delimiter = delimiter + self.quote = quote + self.escape = escape + self.force_quote = _copy_force_columns(force_quote) + self.force_array = force_array + self.partitioned = partitioned + self.is_partitioned = partitioned + + @property + def format_method(self) -> str: + return "format_copy_to_statement" + + +class PostgresCopyFromExpression(BaseExpression): + """A PostgreSQL ``COPY ... FROM STDIN`` expression.""" + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + *, + schema: Optional[str] = None, + columns: Optional[Sequence[str]] = None, + format: Union[PostgresCopyFormat, str] = PostgresCopyFormat.TEXT, + header: Optional[CopyHeader] = None, + null: Optional[str] = None, + default: Optional[str] = None, + delimiter: Optional[str] = None, + quote: Optional[str] = None, + escape: Optional[str] = None, + force_not_null: Optional[CopyColumnSelection] = None, + force_null: Optional[CopyColumnSelection] = None, + freeze: bool = False, + where: Optional[BaseExpression] = None, + on_error: Optional[Union[PostgresCopyOnError, str]] = None, + reject_limit: Optional[int] = None, + encoding: Optional[str] = None, + log_verbosity: Optional[Union[PostgresCopyLogVerbosity, str]] = None, + ): + super().__init__(dialect) + if where is not None and not isinstance(where, BaseExpression): + raise TypeError("COPY FROM WHERE must be a BaseExpression") + self.table_name = table_name + self.schema = schema + self.columns = _copy_columns(columns, "columns") + self.format = _enum_value(format) + self.header = header + self.null = null + self.default = default + self.delimiter = delimiter + self.quote = quote + self.escape = escape + self.force_not_null = _copy_force_columns(force_not_null) + self.force_null = _copy_force_columns(force_null) + self.freeze = freeze + self.where = where + self.on_error = None if on_error is None else _enum_value(on_error) + self.reject_limit = reject_limit + self.encoding = encoding + self.log_verbosity = None if log_verbosity is None else _enum_value(log_verbosity) + + @property + def format_method(self) -> str: + return "format_copy_from_statement" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index 2f45fd40..e5de03c5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -6,6 +6,7 @@ - vacuum.py - VACUUM/ANALYZE expressions - partition.py - Partition DDL expressions - index.py - Index DDL expressions +- index_definition.py - PostgreSQL index definition / CREATE INDEX expressions - statistics.py - Statistics DDL expressions - comment.py - COMMENT expressions - mv.py - Materialized view expressions @@ -21,47 +22,60 @@ - routine.py - CREATE/DROP FUNCTION / AGGREGATE expressions - publication.py - CREATE/DROP PUBLICATION / SUBSCRIPTION expressions -Missing expressions — why? +PostgreSQL index expressions ============================ -This package does **not** provide ``PostgresCreateIndexExpression`` or -``PostgresDropIndexExpression``. Those were removed because the generic -``CreateIndexExpression`` / ``DropIndexExpression`` (from -``rhosocial.activerecord.backend.expression.statements.ddl_index``) already -accept all common parameters **and** a ``dialect_options`` dict. +The generic ``CreateIndexExpression`` / ``DropIndexExpression`` (from +``rhosocial.activerecord.backend.expression.statements.ddl_index``) carry all +common parameters. -PG‑specific features such as ``NULLS NOT DISTINCT`` or ``CONCURRENTLY`` on -DROP INDEX are passed through ``dialect_options`` and consumed by the dialect -(``PostgresIndexMixin``). See ``index.py`` for the supported keys. +PostgreSQL-only features are supplied through typed fields, not a +``dialect_options`` bag: + +* ``PostgresCreateIndexExpression`` adds ``opclasses``, + ``nulls_not_distinct`` and ``with_options`` for ``CREATE INDEX``. +* ``PostgresIndexDefinition`` adds the same options to an inline table index + definition. +* ``PostgresDropIndexExpression`` marks PostgreSQL ownership of a + ``DROP INDEX`` (the generic expression already carries ``concurrent``). + +The typed fields are consumed by ``PostgresIndexMixin`` (see ``index.py``). Example:: - from rhosocial.activerecord.backend.expression.statements.ddl_index import ( - CreateIndexExpression, - DropIndexExpression, - ) from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateIndexExpression, + ) d = PostgresDialect((15, 0, 0)) - # NULLS NOT DISTINCT via dialect_options - expr = CreateIndexExpression( + # NULLS NOT DISTINCT via a typed field + expr = PostgresCreateIndexExpression( d, "idx_uniq_abc", "t", ["a", "b"], unique=True, - dialect_options={"nulls_not_distinct": True}, + nulls_not_distinct=True, ) sql, _ = expr.to_sql() # → CREATE UNIQUE INDEX … NULLS NOT DISTINCT - - # DROP INDEX CONCURRENTLY via dialect_options (PG 18+) - expr = DropIndexExpression( - d, "idx_old", - dialect_options={"concurrent": True}, - ) - sql, _ = expr.to_sql() # → DROP INDEX CONCURRENTLY … """ +from ..copy import ( + PostgresCopyFormat, + PostgresCopyFromExpression, + PostgresCopyLogVerbosity, + PostgresCopyOnError, + PostgresCopyToExpression, +) from .vacuum import PostgresVacuumExpression, PostgresAnalyzeExpression +from .table_options import PostgresCreateTableOptions +from .column import ( + PostgresColumnStorage, + PostgresColumnDefinition, + PostgresColumnOptions, +) +from .alter_column import PostgresAlterColumn from .partition import ( PartitionValue, + PostgresPartitionClause, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, @@ -72,24 +86,68 @@ PostgresAlterIndexActionType, PostgresReindexExpression, ) +from .index_definition import ( + PostgresCreateIndexExpression, + PostgresIndexDefinition, + PostgresDropIndexExpression, +) +from .exclude_constraint import PostgresExcludeConstraint +from .constraint import ( + PostgresAlterConstraint, + PostgresValidateConstraint, + PostgresAlterConstraintAction, + PostgresValidateConstraintAction, + PostgresAlterConstraintExpression, + PostgresValidateConstraintExpression, + PostgresAlterTableConstraint, + PostgresValidateTableConstraint, +) from .statistics import ( PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, ) from .comment import PostgresCommentExpression -from .mv import PostgresRefreshMaterializedViewExpression +from .mv import ( + MaterializedViewAlterAction, + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresCreateMaterializedViewExpression, + PostgresDropMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) from .type import ( - PostgresCreateEnumTypeExpression, - PostgresDropEnumTypeExpression, + PostgresAddEnumValueAction, + PostgresAddTypeAttributeAction, PostgresAlterEnumAddValueExpression, PostgresAlterEnumTypeAddValueExpression, PostgresAlterEnumTypeRenameValueExpression, + PostgresAlterTypeAttributeAction, + PostgresBaseTypeDefinition, + PostgresChangeTypeOwnerAction, + PostgresCompositeTypeAttribute, + PostgresCompositeTypeDefinition, + PostgresCreateEnumTypeExpression, PostgresCreateRangeTypeExpression, - EnumTypeNameExpression, - EnumValuesExpression, + PostgresDropEnumTypeExpression, + PostgresDropTypeAttributeAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresRenameEnumValueAction, + PostgresRenameTypeAction, + PostgresRenameTypeAttributeAction, + PostgresSetTypePropertiesAction, + PostgresSetTypeSchemaAction, + PostgresShellTypeDefinition, + AlterEnumAddValueExpression, CreateEnumTypeExpression, DropEnumTypeExpression, - AlterEnumAddValueExpression, + EnumTypeNameExpression, + EnumValuesExpression, ) from .extension import PostgresCreateExtensionExpression, PostgresDropExtensionExpression from .multirange import ( @@ -120,11 +178,21 @@ PostgresAlterTableSettingsExpression, ) from .cluster import PostgresClusterExpression +from .repack import PostgresRepackExpression from .domain import ( AlterDomainActionType, - PostgresCreateDomainExpression, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + PostgresAddDomainCheckAction, PostgresAlterDomainExpression, + PostgresChangeDomainOwnerAction, + PostgresCreateDomainExpression, + PostgresDropDomainCheckAction, PostgresDropDomainExpression, + PostgresRenameDomainConstraintAction, + PostgresSetDomainSchemaAction, + PostgresValidateDomainConstraintAction, ) from .collation import ( PostgresCreateCollationExpression, @@ -148,11 +216,25 @@ ) __all__ = [ + # copy + "PostgresCopyFormat", + "PostgresCopyFromExpression", + "PostgresCopyLogVerbosity", + "PostgresCopyOnError", + "PostgresCopyToExpression", # vacuum "PostgresVacuumExpression", "PostgresAnalyzeExpression", + # table options + "PostgresCreateTableOptions", + # column + "PostgresColumnStorage", + "PostgresColumnDefinition", + "PostgresColumnOptions", + "PostgresAlterColumn", # partition "PartitionValue", + "PostgresPartitionClause", "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", "PostgresAttachPartitionExpression", @@ -161,14 +243,52 @@ "PostgresAlterIndexExpression", "PostgresAlterIndexActionType", "PostgresReindexExpression", + "PostgresCreateIndexExpression", + "PostgresIndexDefinition", + "PostgresDropIndexExpression", + "PostgresExcludeConstraint", + "PostgresAlterConstraint", + "PostgresValidateConstraint", + "PostgresAlterConstraintAction", + "PostgresValidateConstraintAction", + "PostgresAlterConstraintExpression", + "PostgresValidateConstraintExpression", + "PostgresAlterTableConstraint", + "PostgresValidateTableConstraint", # statistics "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", # comment "PostgresCommentExpression", # mv + "MaterializedViewAlterAction", + "PostgresAlterMaterializedViewExpression", + "PostgresChangeMaterializedViewOwnerAction", + "PostgresCreateMaterializedViewExpression", + "PostgresDropMaterializedViewExpression", "PostgresRefreshMaterializedViewExpression", - # type (enum/range) + "PostgresRenameMaterializedViewAction", + "PostgresResetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewSchemaAction", + # type + "PostgresCompositeTypeAttribute", + "PostgresCompositeTypeDefinition", + "PostgresEnumTypeDefinition", + "PostgresRangeTypeDefinition", + "PostgresBaseTypeDefinition", + "PostgresShellTypeDefinition", + "PostgresRenameTypeAction", + "PostgresSetTypeSchemaAction", + "PostgresChangeTypeOwnerAction", + "PostgresRenameTypeAttributeAction", + "PostgresAddTypeAttributeAction", + "PostgresDropTypeAttributeAction", + "PostgresAlterTypeAttributeAction", + "PostgresAddEnumValueAction", + "PostgresRenameEnumValueAction", + "PostgresSetTypePropertiesAction", + "PostgresDropTypeExpression", "PostgresCreateEnumTypeExpression", "PostgresDropEnumTypeExpression", "PostgresAlterEnumAddValueExpression", @@ -206,8 +326,18 @@ "PostgresAlterTableSettingsExpression", # cluster "PostgresClusterExpression", + "PostgresRepackExpression", # domain "AlterDomainActionType", + "DomainNullability", + "DomainValueExpression", + "DomainCheckConstraint", + "PostgresAddDomainCheckAction", + "PostgresDropDomainCheckAction", + "PostgresRenameDomainConstraintAction", + "PostgresValidateDomainConstraintAction", + "PostgresChangeDomainOwnerAction", + "PostgresSetDomainSchemaAction", "PostgresCreateDomainExpression", "PostgresAlterDomainExpression", "PostgresDropDomainExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/alter_column.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/alter_column.py new file mode 100644 index 00000000..e97ad8a0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/alter_column.py @@ -0,0 +1,54 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/alter_column.py +"""PostgreSQL-specific ALTER TABLE column actions. + +PostgreSQL's ``ALTER COLUMN ... SET DATA TYPE ... USING `` accepts a +conversion expression that has no generic equivalent: + +* ``USING ()`` — how to convert existing values to the new type. + +It lives on ``PostgresAlterColumn`` (deriving the generic ``AlterColumn``) and +is rendered by the PostgreSQL ``format_alter_column_action`` override. +PostgreSQL does **not** share it with any other backend. +""" + +from typing import Any, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import ( + AlterColumn, + ColumnAlterOperation, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresAlterColumn", +] + + +class PostgresAlterColumn(AlterColumn): + """A PostgreSQL ``ALTER COLUMN`` action extending the generic one. + + Adds the PostgreSQL-only ``using`` conversion expression for + ``SET DATA TYPE``. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + column_name: str, + operation: Union[ColumnAlterOperation, str], + *, + new_value: Any = None, + cascade: bool = False, + using: Any = None, + ): + super().__init__( + dialect, + column_name, + operation, + new_value=new_value, + cascade=cascade, + ) + self.using = using diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py index 85c2e009..3da0ef68 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py @@ -11,7 +11,7 @@ - CREATE/DROP COLLATION: PostgreSQL 9.6+ (all supported versions) """ -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -36,7 +36,6 @@ class PostgresCreateCollationExpression(BaseExpression): provider: Optional ``PROVIDER`` (e.g. ``libc`` or ``icu``). version: Optional ``VERSION``. if_not_exists: When True, add ``IF NOT EXISTS``. - dialect_options: Reserved. """ @@ -51,7 +50,6 @@ def __init__( provider: Optional[str] = None, version: Optional[str] = None, if_not_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -62,7 +60,6 @@ def __init__( self.provider = provider self.version = version self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py new file mode 100644 index 00000000..63ccbc07 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py @@ -0,0 +1,127 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/column.py +"""PostgreSQL-specific column definition expressions. + +PostgreSQL extends the standard column definition with per-column storage +attributes that have no generic equivalent: + +* ``COMPRESSION `` — column compression (PG 14+): ``pglz`` / ``lz4``. +* ``STORAGE {PLAIN|EXTERNAL|EXTENDED|MAIN|DEFAULT}`` — TOAST storage strategy. +* ``STATISTICS `` — per-column statistics target (0..10000). + +These live on ``PostgresColumnDefinition`` (deriving the generic +``ColumnDefinition``) and are rendered by the PostgreSQL +``format_column_definition`` override. They are declared through +``PostgresColumnOptions`` (deriving the generic ``ColumnOptions``). +""" + +from enum import Enum +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.base import ColumnOptions + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresColumnStorage", + "PostgresColumnDefinition", + "PostgresColumnOptions", +] + + +class PostgresColumnStorage(Enum): + """PostgreSQL TOAST ``STORAGE`` strategies.""" + + PLAIN = "PLAIN" + EXTERNAL = "EXTERNAL" + EXTENDED = "EXTENDED" + MAIN = "MAIN" + DEFAULT = "DEFAULT" + + +class PostgresColumnDefinition(ColumnDefinition): + """A PostgreSQL column definition extending the generic one. + + Adds PostgreSQL-only typed attributes: ``compression``, ``storage`` and + ``statistics``. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + data_type, + constraints=None, + comment: Optional[str] = None, + generated_expression=None, + attributes=None, + *, + compression: Optional[str] = None, + storage: Optional[PostgresColumnStorage] = None, + statistics: Optional[int] = None, + ): + super().__init__( + dialect, + name, + data_type, + constraints=constraints, + comment=comment, + generated_expression=generated_expression, + attributes=attributes, + ) + if storage is not None and not isinstance(storage, PostgresColumnStorage): + raise TypeError( + "storage must be a PostgresColumnStorage value, " + f"got {type(storage).__name__}" + ) + if statistics is not None and ( + not isinstance(statistics, int) or isinstance(statistics, bool) + or statistics < 0 or statistics > 10000 + ): + raise ValueError("statistics must be an integer in 0..10000") + self.compression = compression + self.storage = storage + self.statistics = statistics + + +class PostgresColumnOptions(ColumnOptions): + """PostgreSQL per-column options declaration.""" + + def __init__( + self, + *, + compression: Optional[str] = None, + storage: Optional[PostgresColumnStorage] = None, + statistics: Optional[int] = None, + ): + super().__init__() + if storage is not None and not isinstance(storage, PostgresColumnStorage): + raise TypeError( + "storage must be a PostgresColumnStorage value, " + f"got {type(storage).__name__}" + ) + if statistics is not None and ( + not isinstance(statistics, int) or isinstance(statistics, bool) + or statistics < 0 or statistics > 10000 + ): + raise ValueError("statistics must be an integer in 0..10000") + self.compression = compression + self.storage = storage + self.statistics = statistics + + def column_definition_class(self): + """Build a ``PostgresColumnDefinition`` for these options.""" + return PostgresColumnDefinition + + def apply_to(self, column) -> None: + """Transfer the PostgreSQL-only fields onto the column definition.""" + if not isinstance(column, PostgresColumnDefinition): + raise TypeError( + "PostgresColumnOptions.apply_to requires a PostgresColumnDefinition, " + f"got {type(column).__name__}" + ) + column.compression = self.compression + column.storage = self.storage + column.statistics = self.statistics diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py index f1025d50..59ba7d03 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py @@ -9,7 +9,7 @@ - COMMENT: PostgreSQL 7.2+ """ -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -73,15 +73,12 @@ def __init__( object_name: str, comment: Optional[str], schema: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.object_type = object_type self.object_name = object_name self.comment = comment self.schema = schema - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/constraint.py new file mode 100644 index 00000000..36f4490c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/constraint.py @@ -0,0 +1,35 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/constraint.py +"""PostgreSQL typed ALTER TABLE constraint actions.""" + +from rhosocial.activerecord.backend.expression.statements import ( + AlterConstraint, + ValidateConstraint, +) + + +class PostgresAlterConstraint(AlterConstraint): + """PostgreSQL ``ALTER CONSTRAINT`` enforcement action.""" + + +class PostgresValidateConstraint(ValidateConstraint): + """PostgreSQL ``VALIDATE CONSTRAINT`` action.""" + + +PostgresAlterConstraintAction = PostgresAlterConstraint +PostgresValidateConstraintAction = PostgresValidateConstraint +PostgresAlterConstraintExpression = PostgresAlterConstraint +PostgresValidateConstraintExpression = PostgresValidateConstraint +PostgresAlterTableConstraint = PostgresAlterConstraint +PostgresValidateTableConstraint = PostgresValidateConstraint + + +__all__ = [ + "PostgresAlterConstraint", + "PostgresValidateConstraint", + "PostgresAlterConstraintAction", + "PostgresValidateConstraintAction", + "PostgresAlterConstraintExpression", + "PostgresValidateConstraintExpression", + "PostgresAlterTableConstraint", + "PostgresValidateTableConstraint", +] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py index cf2f462b..841fe26d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py @@ -1,20 +1,30 @@ # src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py -""" -PostgreSQL DDL expressions: DOMAIN operations. +"""PostgreSQL DOMAIN DDL expressions.""" -PostgreSQL Documentation: -- CREATE DOMAIN: https://www.postgresql.org/docs/current/sql-createdomain.html -- ALTER DOMAIN: https://www.postgresql.org/docs/current/sql-alterdomain.html -- DROP DOMAIN: https://www.postgresql.org/docs/current/sql-dropdomain.html - -Version Requirements: -- CREATE/ALTER/DROP DOMAIN: PostgreSQL 9.6+ (all supported versions) -""" +from __future__ import annotations +import warnings from enum import Enum -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, Dict, List, Optional, Sequence, Union, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLPredicate +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AddDomainCheckAction, + AlterDomainExpression, + CreateDomainExpression, + DomainAlterAction, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + DropDomainCheckAction, + DropDomainDefaultAction, + DropDomainExpression, + DropDomainNotNullAction, + RenameDomainAction, + SetDomainDefaultAction, + SetDomainNotNullAction, +) +from rhosocial.activerecord.backend.expression.types import DataType if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -22,118 +32,301 @@ __all__ = [ "AlterDomainActionType", + "DomainNullability", + "DomainValueExpression", + "DomainCheckConstraint", + "SetDomainDefaultAction", + "DropDomainDefaultAction", + "SetDomainNotNullAction", + "DropDomainNotNullAction", + "PostgresAddDomainCheckAction", + "PostgresDropDomainCheckAction", + "PostgresRenameDomainConstraintAction", + "PostgresValidateDomainConstraintAction", + "PostgresChangeDomainOwnerAction", + "PostgresSetDomainSchemaAction", "PostgresCreateDomainExpression", "PostgresAlterDomainExpression", "PostgresDropDomainExpression", ] -class AlterDomainActionType(Enum): - """Actions supported by ALTER DOMAIN (minimal subset).""" +def _validate_name(value: str, field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +def _coerce_legacy_data_type( + dialect: "SQLDialectBase", + value: Union[DataType, str], +) -> DataType: + if isinstance(value, DataType): + return value + if not isinstance(value, str) or not value.strip(): + raise TypeError("data_type must be a DataType instance") + warnings.warn( + "Raw DOMAIN data_type strings are deprecated; pass a DataType instance", + DeprecationWarning, + stacklevel=3, + ) + return DataType.parse_data_type_str(dialect, value) + + +class _LegacySqlExpression(BaseExpression): + def __init__( + self, + dialect: "SQLDialectBase", + sql: str, + field_name: str, + ) -> None: + super().__init__(dialect) + if not isinstance(sql, str) or not sql.strip(): + raise ValueError(f"{field_name} must be a non-empty SQL string") + self.sql = sql + self.field_name = field_name + + @property + def format_method(self) -> str: + return "_format_postgres_legacy_sql_expression" + + +class _UnsupportedDomainAction(DomainAlterAction): + action_kind = "unsupported" + + def __init__(self, dialect: "SQLDialectBase", action: Any) -> None: + super().__init__(dialect) + self.action = action + @property + def format_method(self) -> str: + return "_format_postgres_unsupported_domain_action" + + +class AlterDomainActionType(Enum): SET_DEFAULT = "SET DEFAULT" DROP_DEFAULT = "DROP DEFAULT" RENAME_TO = "RENAME TO" -class PostgresCreateDomainExpression(BaseExpression): - """PostgreSQL CREATE DOMAIN statement expression. +class PostgresAddDomainCheckAction(AddDomainCheckAction): + def __init__( + self, + dialect: "SQLDialectBase", + check: Union[DomainCheckConstraint, SQLPredicate], + *, + not_valid: bool = False, + ) -> None: + if isinstance(check, SQLPredicate): + check = DomainCheckConstraint(dialect, check) + super().__init__(dialect, check) + self.not_valid = not_valid + - Creates a new domain — a user-defined data type — over an underlying - PostgreSQL type, often reusing types and default/constraint logic. +class PostgresDropDomainCheckAction(DropDomainCheckAction): + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + *, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__(dialect, name=name) + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict - Attributes: - name: Name of the domain. - data_type: Underlying PostgreSQL type name (e.g. ``NUMERIC(10, 2)``). - schema: Optional schema for the domain. - collation: Optional collation name. - default: Optional literal default value expression. - constraints: Optional list of constraint clauses (e.g. ``CHECK (...)``). - dialect_options: Reserved for extensions. - """ +class PostgresRenameDomainConstraintAction(DomainAlterAction): + action_kind = "rename_constraint" def __init__( self, dialect: "SQLDialectBase", name: str, - data_type: str, - schema: Optional[str] = None, - collation: Optional[str] = None, - default: Optional[str] = None, - constraints: Optional[List[str]] = None, - dialect_options: Optional[Dict[str, Any]] = None, - ): + new_name: str, + ) -> None: super().__init__(dialect) + _validate_name(name, "name") + _validate_name(new_name, "new_name") self.name = name - self.data_type = data_type - self.schema = schema - self.collation = collation - self.default = default - self.constraints = constraints or [] - self.dialect_options = dialect_options or {} + self.new_name = new_name - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_create_domain_statement" + +class PostgresValidateDomainConstraintAction(DomainAlterAction): + action_kind = "validate_constraint" + + def __init__(self, dialect: "SQLDialectBase", name: str) -> None: + super().__init__(dialect) + _validate_name(name, "name") + self.name = name -class PostgresAlterDomainExpression(BaseExpression): - """PostgreSQL ALTER DOMAIN statement expression (minimal subset). +class PostgresChangeDomainOwnerAction(DomainAlterAction): + action_kind = "change_owner" - Attributes: - name: Name of the domain to alter. - schema: Optional schema for the domain. - action: The action to perform (:class:`AlterDomainActionType`). - new_value: For ``SET DEFAULT``, the new literal default. Otherwise - unused. - new_name: For ``RENAME TO``, the new domain name. - dialect_options: Reserved. + def __init__(self, dialect: "SQLDialectBase", new_owner: str) -> None: + super().__init__(dialect) + _validate_name(new_owner, "new_owner") + self.new_owner = new_owner - Returns: - Formatting is delegated to - ``dialect.format_alter_domain_statement``. - """ +class PostgresSetDomainSchemaAction(DomainAlterAction): + action_kind = "set_schema" + + def __init__(self, dialect: "SQLDialectBase", new_schema: str) -> None: + super().__init__(dialect) + _validate_name(new_schema, "new_schema") + self.new_schema = new_schema + +class PostgresCreateDomainExpression(CreateDomainExpression): def __init__( self, dialect: "SQLDialectBase", name: str, - action: AlterDomainActionType, + data_type: Union[DataType, str], + schema: Optional[str] = None, + collation: Optional[str] = None, + default: Optional[Any] = None, + constraints: Optional[Sequence[Union[str, DomainCheckConstraint, SQLPredicate]]] = None, + *, + nullability: DomainNullability = DomainNullability.UNSPECIFIED, + checks: Optional[Sequence[Union[DomainCheckConstraint, SQLPredicate]]] = None, + schema_name: Optional[str] = None, + ) -> None: + normalized_type = _coerce_legacy_data_type(dialect, data_type) + resolved_schema = schema if schema is not None else schema_name + if schema is not None and schema_name is not None and schema != schema_name: + raise ValueError("schema and schema_name must match when both are provided") + constraint_items: List[Any] + if constraints is not None and checks is not None: + raise ValueError("constraints and checks are mutually exclusive") + if checks is not None: + check_parameter = "checks" + constraint_items = list(checks) + elif constraints is not None: + check_parameter = "constraints" + constraint_items = list(constraints) + else: + check_parameter = None + constraint_items = [] + clauses: List[BaseExpression] = [] + normalized_checks: List[DomainCheckConstraint] = [] + for item in constraint_items: + if isinstance(item, str): + warnings.warn( + "Raw DOMAIN constraint strings are deprecated; pass SQLPredicate " + "or DomainCheckConstraint instances", + DeprecationWarning, + stacklevel=2, + ) + clauses.append(_LegacySqlExpression(dialect, item, "constraints")) + elif isinstance(item, DomainCheckConstraint): + clauses.append(item) + normalized_checks.append(item) + elif isinstance(item, SQLPredicate): + check = DomainCheckConstraint(dialect, item) + clauses.append(check) + normalized_checks.append(check) + else: + raise TypeError( + "constraints must contain DomainCheckConstraint, SQLPredicate, or " + "legacy string instances" + ) + normalized_default = default + if isinstance(default, str): + warnings.warn( + "Raw DOMAIN DEFAULT strings are deprecated; pass a scalar or expression", + DeprecationWarning, + stacklevel=2, + ) + normalized_default = _LegacySqlExpression(dialect, default, "default") + super().__init__( + dialect, + name, + normalized_type, + default=normalized_default, + nullability=nullability, + checks=normalized_checks, + collation=collation, + ) + self.schema = resolved_schema + self.schema_name = resolved_schema + self.constraints = list(constraint_items) + self._constraint_clauses = clauses + self._check_parameter = check_parameter + + def get_params(self) -> Dict[str, Any]: + params = super().get_params() + params.pop("constraints", None) + params.pop("checks", None) + if self._check_parameter == "constraints": + params["constraints"] = list(self.constraints) + elif self._check_parameter == "checks": + params["checks"] = list(self.checks) + return params + + @property + def name(self) -> str: + return self.domain_name + + +class PostgresAlterDomainExpression(AlterDomainExpression): + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + action: Union[AlterDomainActionType, DomainAlterAction, Any], schema: Optional[str] = None, new_value: Any = None, new_name: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.name = name - self.schema = schema + *, + schema_name: Optional[str] = None, + ) -> None: + resolved_schema = schema if schema is not None else schema_name + if schema is not None and schema_name is not None and schema != schema_name: + raise ValueError("schema and schema_name must match when both are provided") + if isinstance(action, DomainAlterAction): + actions = [action] + elif isinstance(action, AlterDomainActionType): + if action is AlterDomainActionType.SET_DEFAULT: + normalized_default: Any = new_value + if isinstance(new_value, str): + warnings.warn( + "Raw ALTER DOMAIN DEFAULT strings are deprecated; pass a scalar " + "or expression", + DeprecationWarning, + stacklevel=2, + ) + normalized_default = _LegacySqlExpression( + dialect, + new_value, + "new_value", + ) + actions = [SetDomainDefaultAction(dialect, normalized_default)] + elif action is AlterDomainActionType.DROP_DEFAULT: + actions = [DropDomainDefaultAction(dialect)] + else: + if new_name is None: + raise ValueError("new_name is required for RENAME TO") + actions = [RenameDomainAction(dialect, new_name)] + else: + actions = [_UnsupportedDomainAction(dialect, action)] + super().__init__(dialect, name, actions) + self.schema = resolved_schema + self.schema_name = resolved_schema self.action = action self.new_value = new_value self.new_name = new_name - self.dialect_options = dialect_options or {} @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_postgres_alter_domain_statement" - - -class PostgresDropDomainExpression(BaseExpression): - """PostgreSQL DROP DOMAIN statement expression. + def name(self) -> str: + return self.domain_name - Attributes: - name: Name of the domain to drop. - schema: Optional schema for the domain. - if_exists: When True, add ``IF EXISTS``. - cascade: When True, add ``CASCADE``. - restrict: When True, add ``RESTRICT``. Mutually exclusive with - ``cascade``. - - """ +class PostgresDropDomainExpression(DropDomainExpression): def __init__( self, dialect: "SQLDialectBase", @@ -142,15 +335,19 @@ def __init__( if_exists: bool = False, cascade: bool = False, restrict: bool = False, - ): - super().__init__(dialect) - self.name = name - self.schema = schema + *, + schema_name: Optional[str] = None, + ) -> None: + resolved_schema = schema if schema is not None else schema_name + if schema is not None and schema_name is not None and schema != schema_name: + raise ValueError("schema and schema_name must match when both are provided") + super().__init__(dialect, name) + self.schema = resolved_schema + self.schema_name = resolved_schema self.if_exists = if_exists self.cascade = cascade self.restrict = restrict @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_drop_domain_statement" \ No newline at end of file + def name(self) -> str: + return self.domain_name diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py new file mode 100644 index 00000000..b5ef079a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py @@ -0,0 +1,56 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/exclude_constraint.py +"""PostgreSQL EXCLUDE constraint definition. + +PostgreSQL adds the ``EXCLUDE`` table constraint with no generic equivalent. +The excluded elements (expression/operator pairs), the index access method and +the optional partial predicate live on ``PostgresExcludeConstraint`` (deriving +the generic ``TableConstraint``) and are rendered by the PostgreSQL +``_format_exclude_constraint``. +""" + +from typing import Any, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import SQLPredicate +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + TableConstraint, + TableConstraintType, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class PostgresExcludeConstraint(TableConstraint): + """A PostgreSQL EXCLUDE constraint extending the generic one. + + ``elements`` is a list of ``(expression, operator)`` pairs where the + expression is either a column name (``str``) or a ``BaseExpression``; + ``using`` selects the index access method (default ``gist``) and ``where`` + is an optional partial-exclusion predicate. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: Optional[str] = None, + elements: Optional[List[Tuple[Any, str]]] = None, + *, + using: str = "gist", + where: Optional[SQLPredicate] = None, + deferrable: Optional[bool] = None, + initially_deferred: Optional[bool] = None, + validation: Any = None, + enforced: Optional[bool] = None, + ): + super().__init__( + dialect, + TableConstraintType.EXCLUDE, + name=name, + deferrable=deferrable, + initially_deferred=initially_deferred, + validation=validation, + enforced=enforced, + ) + self.elements = elements or [] + self.using = using + self.where = where diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py index 0639a43b..2a1b99b1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py @@ -11,7 +11,7 @@ - DROP EXTENSION: PostgreSQL 9.1+ """ -from typing import Optional, Dict, Any, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -67,8 +67,6 @@ def __init__( version: Optional[str] = None, if_not_exists: bool = True, cascade: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -76,7 +74,6 @@ def __init__( self.version = version self.if_not_exists = if_not_exists self.cascade = cascade - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -119,8 +116,6 @@ def __init__( if_exists: bool = True, cascade: bool = False, restrict: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -128,7 +123,6 @@ def __init__( self.if_exists = if_exists self.cascade = cascade self.restrict = restrict - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py index 4231b97d..43491e42 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py @@ -10,7 +10,7 @@ - CREATE/DROP FOREIGN TABLE: PostgreSQL 9.6+ """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -34,7 +34,6 @@ class PostgresCreateForeignTableExpression(BaseExpression): options: Optional ``OPTIONS ( option 'value', ... )`` list of ``"option 'value'"`` strings. if_not_exists: When True, add ``IF NOT EXISTS``. - dialect_options: Reserved. """ @@ -47,7 +46,6 @@ def __init__( columns: Optional[List[str]] = None, options: Optional[List[str]] = None, if_not_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -56,7 +54,6 @@ def __init__( self.columns = columns or [] self.options = options or [] self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py index 9a5be396..5d5eb035 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py @@ -11,54 +11,48 @@ - TABLESPACE option: PostgreSQL 14+ - CONCURRENTLY option: PostgreSQL 12+ -CREATE / DROP INDEX — use the generic expressions with ``dialect_options`` -========================================================================== -This module intentionally does **not** define ``PostgresCreateIndexExpression`` -or ``PostgresDropIndexExpression``. The generic classes from the core -package already serve this purpose: - - from rhosocial.activerecord.backend.expression.statements.ddl_index import ( - CreateIndexExpression, - DropIndexExpression, - ) - -PostgreSQL‑specific options are supplied via the ``dialect_options`` dict and -consumed by ``PostgresIndexMixin.format_create_index_statement`` / -``format_drop_index_statement``. Supported keys: - -======================= ======================================================= -Key Description -======================= ======================================================= -``nulls_not_distinct`` ``bool`` — add ``NULLS NOT DISTINCT`` (PG 15+, unique - index only). Passed to ``CreateIndexExpression``. -``opclasses`` ``dict[str, str]`` — operator classes per column, e.g. - ``{"a": "text_pattern_ops"}``. Passed to - ``CreateIndexExpression``. -``with`` ``dict[str, Any]`` — storage parameters, e.g. - ``{"fillfactor": 70}``. Passed to - ``CreateIndexExpression``. -``concurrent`` ``bool`` — add ``CONCURRENTLY`` to ``DROP INDEX`` - (PG 18+). Passed to ``DropIndexExpression``. - (For ``CREATE INDEX`` the generic class already has - a ``concurrent`` named parameter.) -======================= ======================================================= +CREATE / DROP INDEX — typed index expressions +============================================== +``PostgresCreateIndexExpression`` / ``PostgresIndexDefinition`` / +``PostgresDropIndexExpression`` live in ``index_definition.py``. The generic +core classes carry all common parameters; PostgreSQL adds typed (not +``dialect_options``) fields: + +======================== ======================================================= +Field Description +======================== ======================================================= +``opclasses`` ``dict[str, str]`` — operator classes per column, e.g. + ``{"a": "text_pattern_ops"}``. +``nulls_not_distinct`` ``bool`` — add ``NULLS NOT DISTINCT`` (PG 15+, unique + index only). +``with_options`` ``dict[str, Any]`` — storage parameters, e.g. + ``{"fillfactor": 70}``. +``concurrent`` ``bool`` — add ``CONCURRENTLY`` (CREATE INDEX PG 11+ / + DROP INDEX PG 18+); present on the generic classes. +======================== ======================================================= + +They are consumed by ``PostgresIndexMixin.format_create_index_statement`` / +``format_drop_index_statement``. Example:: from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateIndexExpression, + ) d = PostgresDialect((15, 0, 0)) - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( d, "idx_uniq_abc", "t", ["a", "b"], unique=True, - dialect_options={"nulls_not_distinct": True}, + nulls_not_distinct=True, ) sql, _ = expr.to_sql() # → CREATE UNIQUE INDEX … NULLS NOT DISTINCT """ from enum import Enum -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Any, Dict, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -124,7 +118,6 @@ def __init__( source_tablespace: Optional[str] = None, target_tablespace: Optional[str] = None, nowait: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.index_name = index_name @@ -138,7 +131,6 @@ def __init__( self.source_tablespace = source_tablespace self.target_tablespace = target_tablespace self.nowait = nowait - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -201,8 +193,6 @@ def __init__( tablespace: Optional[str] = None, concurrently: bool = False, verbose: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.target_type = target_type @@ -211,7 +201,6 @@ def __init__( self.tablespace = tablespace self.concurrently = concurrently self.verbose = verbose - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index_definition.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index_definition.py new file mode 100644 index 00000000..0bb21df3 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index_definition.py @@ -0,0 +1,115 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index_definition.py +"""PostgreSQL-specific index expressions. + +PostgreSQL adds index options with no generic equivalent: + +* ``opclasses`` — operator classes per column (e.g. ``{"a": "text_pattern_ops"}``). +* ``nulls_not_distinct`` — ``NULLS NOT DISTINCT`` on a UNIQUE index (PG 15+). +* ``with_options`` — index storage parameters, e.g. ``{"fillfactor": 70}``. + +They live on ``PostgresCreateIndexExpression`` (standalone ``CREATE INDEX``) +and ``PostgresIndexDefinition`` (inline table index definition), both deriving +their generic core counterparts, and are rendered by the PostgreSQL +``PostgresIndexMixin`` overrides. +""" + +from typing import Any, Dict, List, Optional + +from rhosocial.activerecord.backend.expression.bases import SQLPredicate +from rhosocial.activerecord.backend.expression.statements.ddl_index import ( + CreateIndexExpression, + DropIndexExpression, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import IndexDefinition + + +class PostgresCreateIndexExpression(CreateIndexExpression): + """A PostgreSQL ``CREATE INDEX`` statement extending the generic one. + + Adds the PostgreSQL-only typed options consumed by + ``PostgresIndexMixin.format_create_index_statement``: + + * ``opclasses`` — operator classes per column (e.g. ``{"a": "text_pattern_ops"}``). + * ``nulls_not_distinct`` — ``NULLS NOT DISTINCT`` on a UNIQUE index (PG 15+). + * ``with_options`` — index storage parameters, e.g. ``{"fillfactor": 70}``. + """ + + def __init__( + self, + dialect: Any, + index_name: str, + table_name: str, + columns: List[Any], + unique: bool = False, + if_not_exists: bool = False, + index_type: Optional[str] = None, + where: Optional[SQLPredicate] = None, + include: Optional[List[str]] = None, + tablespace: Optional[str] = None, + concurrent: bool = False, + *, + opclasses: Optional[Dict[str, str]] = None, + nulls_not_distinct: bool = False, + with_options: Optional[Dict[str, Any]] = None, + ): + super().__init__( + dialect, + index_name=index_name, + table_name=table_name, + columns=columns, + unique=unique, + if_not_exists=if_not_exists, + index_type=index_type, + where=where, + include=include, + tablespace=tablespace, + concurrent=concurrent, + ) + self.opclasses = opclasses or {} + self.nulls_not_distinct = nulls_not_distinct + self.with_options = with_options or {} + + +class PostgresIndexDefinition(IndexDefinition): + """A PostgreSQL index definition extending the generic one. + + Adds the PostgreSQL-only ``opclasses`` / ``nulls_not_distinct`` / ``with`` + options. + """ + + def __init__( + self, + dialect: Any, + name: str, + columns: List[Any], + *, + unique: bool = False, + type: Optional[str] = None, + partial_condition: Optional[SQLPredicate] = None, + include_columns: Optional[List[str]] = None, + opclasses: Optional[Dict[str, str]] = None, + nulls_not_distinct: bool = False, + with_options: Optional[Dict[str, Any]] = None, + ): + super().__init__( + dialect, + name=name, + columns=columns, + unique=unique, + type=type, + partial_condition=partial_condition, + include_columns=include_columns, + ) + self.opclasses = opclasses or {} + self.nulls_not_distinct = nulls_not_distinct + self.with_options = with_options or {} + + +class PostgresDropIndexExpression(DropIndexExpression): + """A PostgreSQL DROP INDEX statement extending the generic one. + + Adds the PostgreSQL-only ``concurrent`` flag (``DROP INDEX CONCURRENTLY``, + PG 18+); the generic ``DropIndexExpression`` already carries ``concurrent``, + so this subclass mainly marks PostgreSQL ownership for the DDL gates. + """ + diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py index ec0c4f4b..9fb9205b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py @@ -1,12 +1,7 @@ # src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py -"""PostgreSQL multirange type DDL expressions. +"""PostgreSQL multirange expressions.""" -Version Requirements: -- CREATE TYPE ... AS MULTIRANGE: PostgreSQL 14+ -- multirange_agg(): PostgreSQL 14+ -""" - -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -14,28 +9,14 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class CreateMultirangeTypeExpression(BaseExpression): - """Expression for CREATE TYPE ... AS MULTIRANGE statement. - - PostgreSQL automatically creates multirange types when you create - a range type. This expression is for explicit creation if needed. +__all__ = [ + "CreateMultirangeTypeExpression", + "MultirangeAggFunctionExpression", +] - Attributes: - name: Multirange type name. - range_type: Associated range type. - schema: Optional schema name. - Example: - >>> expr = CreateMultirangeTypeExpression( - ... dialect, name="my_multirange", range_type="my_range", - ... ) - >>> sql, params = expr.to_sql() - >>> sql - 'CREATE TYPE my_multirange AS MULTIRANGE (my_range)' - - Note: - Requires PostgreSQL 14+. - """ +class CreateMultirangeTypeExpression(BaseExpression): + """Compatibility expression for the removed explicit multirange DDL form.""" def __init__( self, @@ -43,44 +24,18 @@ def __init__( name: str, range_type: str, schema: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): + ) -> None: super().__init__(dialect) self.name = name self.range_type = range_type self.schema = schema - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" return "format_create_multirange_type_statement_expression" class MultirangeAggFunctionExpression(BaseExpression): - """Expression for multirange_agg aggregate function call. - - Aggregates multiple ranges into a multirange. - - Attributes: - range_column: Range column to aggregate. - table_name: Table name. - where_clause: Optional WHERE clause. - schema: Optional schema name. - - Example: - >>> expr = MultirangeAggFunctionExpression( - ... dialect, range_column="period", table_name="events", - ... ) - >>> sql, params = expr.to_sql() - >>> sql - 'SELECT multirange_agg(period) FROM events' - - Note: - Requires PostgreSQL 14+. - """ - def __init__( self, dialect: "SQLDialectBase", @@ -88,17 +43,13 @@ def __init__( table_name: str, where_clause: Optional[str] = None, schema: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): + ) -> None: super().__init__(dialect) self.range_column = range_column self.table_name = table_name self.where_clause = where_clause self.schema = schema - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" return "format_multirange_agg_function_expression" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py index f904d52d..7145123d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py @@ -3,23 +3,152 @@ PostgreSQL DDL expressions: Materialized View operations. PostgreSQL Documentation: +- CREATE MATERIALIZED VIEW: https://www.postgresql.org/docs/current/sql-creatematerializedview.html - REFRESH MATERIALIZED VIEW: https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html +- ALTER MATERIALIZED VIEW: https://www.postgresql.org/docs/current/sql-altermaterializedview.html Version Requirements: -- REFRESH MATERIALIZED VIEW: PostgreSQL 9.3+ -- CONCURRENTLY option: PostgreSQL 9.4+ +- Materialized views: PostgreSQL 9.3+ +- CONCURRENTLY refresh / IF NOT EXISTS: PostgreSQL 9.4+ - WITH NO DATA: PostgreSQL 9.4+ + +PostgreSQL's ``ALTER MATERIALIZED VIEW`` does not support ``SET TABLESPACE``: +a materialized view cannot be relocated to another tablespace after creation. """ -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewExpression, + DropMaterializedViewExpression, + RefreshMaterializedViewExpression, +) -from rhosocial.activerecord.backend.expression.statements.ddl_view import RefreshMaterializedViewExpression +from ...storage_parameters import validate_storage_parameters if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase -__all__ = ["PostgresRefreshMaterializedViewExpression"] +__all__ = [ + "MaterializedViewAlterAction", + "PostgresAlterMaterializedViewExpression", + "PostgresChangeMaterializedViewOwnerAction", + "PostgresCreateMaterializedViewExpression", + "PostgresDropMaterializedViewExpression", + "PostgresRefreshMaterializedViewExpression", + "PostgresRenameMaterializedViewAction", + "PostgresResetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewPropertiesAction", + "PostgresSetMaterializedViewSchemaAction", +] + +_CURRENT_ROLE_KEYWORDS = frozenset({"CURRENT_ROLE", "CURRENT_USER", "SESSION_USER"}) + + +def _validate_name(value: str, field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +class PostgresCreateMaterializedViewExpression(CreateMaterializedViewExpression): + """PostgreSQL CREATE MATERIALIZED VIEW with schema and IF NOT EXISTS support. + + Extends the generic expression with the two PostgreSQL-specific options: + ``IF NOT EXISTS`` (PG 9.4+) and schema qualification. + + ``storage_options`` keys are validated against + :class:`~....storage_parameters.PostgresStorageParameter`; pass + ``allow_unlisted_storage_parameters=True`` to forward options registered by + a table access method (``USING method``) or namespaced under ``toast.``. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import ( + ... PostgresDialect, + ... PostgresStorageParameter, + ... ) + >>> dialect = PostgresDialect(version=(15, 0, 0)) + >>> create = PostgresCreateMaterializedViewExpression( + ... dialect=dialect, + ... view_name="monthly_sales_summary", + ... query=sales_query, + ... schema="reporting", + ... if_not_exists=True, + ... storage_options={PostgresStorageParameter.FILLFACTOR: 70}, + ... ) + >>> sql, params = create.to_sql() + >>> sql + 'CREATE MATERIALIZED VIEW IF NOT EXISTS "reporting"."monthly_sales_summary" \\ +WITH (FILLFACTOR = 70) AS SELECT ... WITH DATA' + """ + + def __init__( + self, + dialect: "SQLDialectBase", + view_name: str, + query: Any, + column_aliases: Optional[List[str]] = None, + tablespace: Optional[str] = None, + with_data: bool = True, + storage_options: Optional[Dict[Any, Any]] = None, + schema: Optional[str] = None, + if_not_exists: bool = False, + allow_unlisted_storage_parameters: bool = False, + ): + super().__init__( + dialect, + view_name=view_name, + query=query, + column_aliases=column_aliases, + tablespace=tablespace, + with_data=with_data, + storage_options=storage_options, + ) + _validate_name(view_name, "view_name") + if schema is not None: + _validate_name(schema, "schema") + if storage_options: + validate_storage_parameters( + storage_options.keys(), + "storage_options", + allow_unlisted=allow_unlisted_storage_parameters, + ) + self.schema = schema + self.if_not_exists = if_not_exists + self.allow_unlisted_storage_parameters = allow_unlisted_storage_parameters + + +class PostgresDropMaterializedViewExpression(DropMaterializedViewExpression): + """PostgreSQL DROP MATERIALIZED VIEW with schema qualification. + + Example: + >>> drop = PostgresDropMaterializedViewExpression( + ... dialect=dialect, view_name="monthly_sales_summary", schema="reporting", + ... ) + >>> drop.to_sql()[0] + 'DROP MATERIALIZED VIEW "reporting"."monthly_sales_summary"' + """ + + def __init__( + self, + dialect: "SQLDialectBase", + view_name: str, + if_exists: bool = False, + cascade: bool = False, + schema: Optional[str] = None, + ): + super().__init__( + dialect, + view_name=view_name, + if_exists=if_exists, + cascade=cascade, + ) + _validate_name(view_name, "view_name") + if schema is not None: + _validate_name(schema, "schema") + self.schema = schema class PostgresRefreshMaterializedViewExpression(RefreshMaterializedViewExpression): @@ -74,29 +203,193 @@ def __init__( schema: Optional[str] = None, concurrently: bool = False, with_data: Optional[bool] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__( dialect, view_name=name, concurrent=concurrently, with_data=with_data, - dialect_options=dialect_options, ) + _validate_name(name, "name") + if schema is not None: + _validate_name(schema, "schema") self.schema = schema @property - def name(self): + def name(self) -> str: """Alias for view_name (backward compatibility).""" return self.view_name @property - def concurrently(self): + def concurrently(self) -> bool: """Alias for concurrent (backward compatibility).""" return self.concurrent @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_refresh_materialized_view_pg_statement" \ No newline at end of file + return "format_refresh_materialized_view_pg_statement" + + +class MaterializedViewAlterAction(BaseExpression, ABC): + """Base class for one ALTER MATERIALIZED VIEW action. + + Supported actions (PostgreSQL): + ``RENAME TO`` / ``SET SCHEMA`` / ``SET (...)`` / ``RESET (...)`` / ``OWNER TO``. + """ + + @property + @abstractmethod + def action_kind(self) -> str: + """Stable kind name used by dialect capability dispatch.""" + + @property + def format_method(self) -> str: + return "format_materialized_view_alter_action" + + +class PostgresRenameMaterializedViewAction(MaterializedViewAlterAction): + """``ALTER MATERIALIZED VIEW name RENAME TO new_name``.""" + + action_kind = "rename" + + def __init__(self, dialect: "SQLDialectBase", new_name: str) -> None: + super().__init__(dialect) + _validate_name(new_name, "new_name") + self.new_name = new_name + + +class PostgresSetMaterializedViewSchemaAction(MaterializedViewAlterAction): + """``ALTER MATERIALIZED VIEW name SET SCHEMA new_schema``.""" + + action_kind = "set_schema" + + def __init__(self, dialect: "SQLDialectBase", new_schema: str) -> None: + super().__init__(dialect) + _validate_name(new_schema, "new_schema") + self.new_schema = new_schema + + +class PostgresSetMaterializedViewPropertiesAction(MaterializedViewAlterAction): + """``ALTER MATERIALIZED VIEW name SET (storage_parameter [= value] [, ...])``. + + Parameter names are validated against + :class:`~....storage_parameters.PostgresStorageParameter`. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter + >>> action = PostgresSetMaterializedViewPropertiesAction( + ... dialect, + ... {PostgresStorageParameter.FILLFACTOR: 90, "autovacuum_enabled": "true"}, + ... ) + """ + + action_kind = "set_properties" + + def __init__( + self, + dialect: "SQLDialectBase", + properties: Dict[Any, Any], + allow_unlisted_storage_parameters: bool = False, + ) -> None: + super().__init__(dialect) + if not isinstance(properties, dict) or not properties: + raise ValueError("properties must be a non-empty dict") + validate_storage_parameters( + properties.keys(), + "properties", + allow_unlisted=allow_unlisted_storage_parameters, + ) + self.properties = dict(properties) + self.allow_unlisted_storage_parameters = allow_unlisted_storage_parameters + + +class PostgresResetMaterializedViewPropertiesAction(MaterializedViewAlterAction): + """``ALTER MATERIALIZED VIEW name RESET (storage_parameter [, ...])``.""" + + action_kind = "reset_properties" + + def __init__( + self, + dialect: "SQLDialectBase", + parameters: Sequence[Any], + allow_unlisted_storage_parameters: bool = False, + ) -> None: + super().__init__(dialect) + validate_storage_parameters( + parameters, + "parameters", + allow_unlisted=allow_unlisted_storage_parameters, + ) + if not parameters: + raise ValueError("parameters must contain at least one storage parameter") + self.parameters = list(parameters) + self.allow_unlisted_storage_parameters = allow_unlisted_storage_parameters + + +class PostgresChangeMaterializedViewOwnerAction(MaterializedViewAlterAction): + """``ALTER MATERIALIZED VIEW name OWNER TO {new_owner | CURRENT_ROLE | ...}``. + + The ``CURRENT_ROLE`` / ``CURRENT_USER`` / ``SESSION_USER`` keywords are kept + verbatim; any other value is treated as a role name and quoted. + """ + + action_kind = "change_owner" + + def __init__(self, dialect: "SQLDialectBase", new_owner: str) -> None: + super().__init__(dialect) + _validate_name(new_owner, "new_owner") + self.new_owner = new_owner + + +class PostgresAlterMaterializedViewExpression(BaseExpression): + """Expression for ``ALTER MATERIALIZED VIEW``. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + ... PostgresAlterMaterializedViewExpression, + ... PostgresRenameMaterializedViewAction, + ... PostgresSetMaterializedViewPropertiesAction, + ... ) + >>> dialect = PostgresDialect(version=(15, 0, 0)) + >>> alter = PostgresAlterMaterializedViewExpression( + ... dialect=dialect, + ... view_name="sales_summary", + ... actions=[ + ... PostgresRenameMaterializedViewAction(dialect, "sales_summary_v2"), + ... PostgresSetMaterializedViewPropertiesAction(dialect, {"fillfactor": 90}), + ... ], + ... ) + >>> alter.to_sql()[0] + 'ALTER MATERIALIZED VIEW "sales_summary" RENAME TO "sales_summary_v2";\\n\\ +ALTER MATERIALIZED VIEW "sales_summary" SET (FILLFACTOR = 90)' + """ + + def __init__( + self, + dialect: "SQLDialectBase", + view_name: str, + actions: Sequence[MaterializedViewAlterAction], + schema: Optional[str] = None, + ) -> None: + super().__init__(dialect) + _validate_name(view_name, "view_name") + if schema is not None: + _validate_name(schema, "schema") + action_list = list(actions or []) + if not action_list: + raise ValueError("actions must contain at least one MaterializedViewAlterAction") + for action in action_list: + if not isinstance(action, MaterializedViewAlterAction): + raise TypeError( + "actions must contain MaterializedViewAlterAction instances, " + f"got {type(action).__name__}" + ) + self.view_name = view_name + self.schema = schema + self.actions = action_list + + @property + def format_method(self) -> str: + return "format_alter_materialized_view_statement" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py index c71501fe..e087a908 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py @@ -11,15 +11,18 @@ - Partitioning: PostgreSQL 10+ - HASH partitioning, DEFAULT partition: PostgreSQL 11+ - Partition bounds expression: PostgreSQL 12+ -- ATTACH PARTITION with CONCURRENTLY: PostgreSQL 14+ +- Concurrent ATTACH: unsupported by this implementation """ from datetime import date, datetime from decimal import Decimal from math import isfinite -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Any, Dict, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal +from rhosocial.activerecord.backend.expression.operators import RawSQLExpression +from rhosocial.activerecord.backend.expression.statements import PartitionClause if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -27,6 +30,7 @@ __all__ = [ "PartitionValue", + "PostgresPartitionClause", "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", "PostgresAttachPartitionExpression", @@ -34,6 +38,21 @@ ] +class PostgresPartitionClause(PartitionClause): + """PostgreSQL ``PARTITION BY {RANGE|LIST|HASH} (...)`` clause. + + PostgreSQL's declarative partitioning shares the generic clause shape, so + this subclass currently adds no fields; it exists as the PostgreSQL-owned + clause type (for capability gating and future PG-only partition parameters) + and to distinguish PG-declared candidates during backend selection. + + Concrete partitions are **not** declared inline: PostgreSQL creates them + through the separate ``CREATE TABLE ... PARTITION OF`` statement + (``PostgresCreatePartitionExpression``). This class therefore remains + clause-only and never carries ``PartitionDefinition`` values. + """ + + class PartitionValue(BaseExpression): """Represents a partition bound value in PostgreSQL DDL. @@ -41,15 +60,18 @@ class PartitionValue(BaseExpression): FOR VALUES clauses of PARTITION OF and ATTACH PARTITION statements. Value handling: - - None → NULL - - String 'MAXVALUE', 'MINVALUE', or 'DEFAULT' (case-insensitive) → as-is - - String/date/datetime/Decimal/numeric values → whitelist-formatted SQL literal - - Arbitrary objects are rejected to prevent unsafe ``str(value)`` fallback + - ``None`` renders as ``NULL``. + - RANGE ``MINVALUE`` and ``MAXVALUE`` render as keywords. + - LIST renders those strings as quoted values. + - Same-dialect ``FunctionCall``, ``Literal``, and ``RawSQLExpression`` values + delegate through their own formatters and retain their parameters. + - Other scalar values use a whitelist; arbitrary objects are rejected. Delegates to dialect.format_partition_value() for SQL generation. Attributes: - value: The partition bound value. + value: The partition bound value or expression. + partition_type: Optional RANGE, LIST, or HASH context. Example: >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect @@ -68,20 +90,59 @@ def __init__( self, dialect: "SQLDialectBase", value: Any, + partition_type: Optional[str] = None, ): super().__init__(dialect) - if isinstance(value, bool): - raise TypeError("partition value must not be bool") - if isinstance(value, float) and not isfinite(value): - raise ValueError("partition value float must be finite") - if isinstance(value, Decimal) and not value.is_finite(): - raise ValueError("partition value Decimal must be finite") - if not isinstance(value, (str, int, float, Decimal, date, datetime, type(None))): - raise TypeError( - "partition value must be str, int, float, Decimal, " - f"date, datetime, or None, got {type(value).__name__}" - ) + if partition_type is None: + normalized_type = None + elif not isinstance(partition_type, str): + raise TypeError("partition_type must be a string") + else: + normalized_type = partition_type.upper() + if normalized_type not in {"RANGE", "LIST", "HASH"}: + raise ValueError("partition_type must be RANGE, LIST, or HASH") + + if isinstance(value, BaseExpression): + if normalized_type == "HASH": + raise TypeError("HASH partition bounds require modulus and remainder") + pending = [value] + seen = set() + while pending: + current = pending.pop() + identity = id(current) + if identity in seen: + continue + seen.add(identity) + if current.dialect is not dialect: + raise ValueError( + "partition value expressions must use the same dialect as PartitionValue" + ) + if isinstance(current, FunctionCall): + if current.alias is not None: + raise ValueError("partition value expressions must not have aliases") + pending.extend(current.args) + elif isinstance(current, (Literal, RawSQLExpression)): + if getattr(current, "alias", None) is not None: + raise ValueError("partition value expressions must not have aliases") + else: + raise TypeError( + "partition value expressions must be FunctionCall, Literal, " + "or RawSQLExpression" + ) + else: + if isinstance(value, bool): + raise TypeError("partition value must not be bool") + if isinstance(value, float) and not isfinite(value): + raise ValueError("partition value float must be finite") + if isinstance(value, Decimal) and not value.is_finite(): + raise ValueError("partition value Decimal must be finite") + if not isinstance(value, (str, int, float, Decimal, date, datetime, type(None))): + raise TypeError( + "partition value must be a supported expression, str, int, float, " + f"Decimal, date, datetime, or None, got {type(value).__name__}" + ) self.value = value + self.partition_type = normalized_type @property def format_method(self) -> str: @@ -102,6 +163,8 @@ class PostgresCreatePartitionExpression(BaseExpression): partition_values: Partition bounds values (dict with 'from', 'to' for RANGE, list of values for LIST, or modulus/remainder for HASH). schema: Schema name for the partition. + parent_schema: Optional schema for the parent table. + partition_clause: Optional child-level PARTITION BY clause. tablespace: Tablespace for the partition. if_not_exists: Add IF NOT EXISTS clause. @@ -141,18 +204,24 @@ def __init__( schema: Optional[str] = None, tablespace: Optional[str] = None, if_not_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, + parent_schema: Optional[str] = None, + partition_clause: Optional[PartitionClause] = None, ): super().__init__(dialect) + if partition_clause is not None: + if not isinstance(partition_clause, PartitionClause): + raise TypeError("partition_clause must be a PartitionClause") + if partition_clause.dialect is not dialect: + raise ValueError("partition_clause must use the same dialect") self.partition_name = partition_name self.parent_table = parent_table self.partition_type = partition_type self.partition_values = partition_values self.schema = schema + self.parent_schema = parent_schema + self.partition_clause = partition_clause self.tablespace = tablespace self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -196,8 +265,7 @@ def __init__( schema: Optional[str] = None, concurrently: bool = False, finalize: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, + parent_schema: Optional[str] = None, ): super().__init__(dialect) self.partition_name = partition_name @@ -205,7 +273,7 @@ def __init__( self.schema = schema self.concurrently = concurrently self.finalize = finalize - self.dialect_options = dialect_options or {} + self.parent_schema = parent_schema @property def format_method(self) -> str: @@ -217,7 +285,7 @@ class PostgresAttachPartitionExpression(BaseExpression): """PostgreSQL ALTER TABLE ... ATTACH PARTITION statement expression. Attaches an existing table as a partition of a partitioned table. - Supports CONCURRENTLY mode on PostgreSQL 14+. + Concurrent attachment is rejected for every PostgreSQL version. Attributes: partition_name: Name of the table to attach. @@ -225,10 +293,10 @@ class PostgresAttachPartitionExpression(BaseExpression): partition_type: Partition type: 'RANGE', 'LIST', or 'HASH'. partition_values: Partition bounds values. schema: Schema name for the partition. - concurrently: Use non-blocking ATTACH mode (PG 14+). + concurrently: Requests unsupported concurrent attachment. Raises: - ValueError: If concurrent_attach is used on PostgreSQL < 14. + UnsupportedFeatureError: If ``concurrently=True``. Example: >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect @@ -255,8 +323,7 @@ def __init__( partition_values: Dict[str, Any], schema: Optional[str] = None, concurrently: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, + parent_schema: Optional[str] = None, ): super().__init__(dialect) self.partition_name = partition_name @@ -265,7 +332,7 @@ def __init__( self.partition_values = partition_values self.schema = schema self.concurrently = concurrently - self.dialect_options = dialect_options or {} + self.parent_schema = parent_schema @property def format_method(self) -> str: @@ -289,7 +356,6 @@ def __init__( schema: Optional[str] = None, *, include_partitions: bool = True, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not parent_table: @@ -297,7 +363,6 @@ def __init__( self.parent_table = parent_table self.schema = schema self.include_partitions = include_partitions - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py index a6a8024f..1bed99b7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py @@ -14,7 +14,7 @@ - CREATE/DROP SUBSCRIPTION: PostgreSQL 10+ """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -41,7 +41,6 @@ class PostgresCreatePublicationExpression(BaseExpression): all_tables: When True, publish all tables (``FOR ALL TABLES``). options: Optional list of ``WITH (...)`` options (e.g. ``publish` strings). - dialect_options: Reserved. """ @@ -52,14 +51,12 @@ def __init__( tables: Optional[List[str]] = None, all_tables: bool = False, options: Optional[List[str]] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.tables = tables self.all_tables = all_tables self.options = options or [] - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -104,7 +101,6 @@ class PostgresCreateSubscriptionExpression(BaseExpression): connection: Connection string for ``CONNECTION '...'``. publications: Publication names to subscribe to. options: Optional ``WITH (``...)`` creation options. - dialect_options: Reserved. """ @@ -115,14 +111,12 @@ def __init__( connection: str, publications: List[str], options: Optional[List[str]] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.connection = connection self.publications = publications self.options = options or [] - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/repack.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/repack.py new file mode 100644 index 00000000..0546b310 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/repack.py @@ -0,0 +1,50 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/repack.py +"""Typed PostgreSQL 19 REPACK expression.""" + +from typing import List, Optional, Sequence, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["PostgresRepackExpression"] + + +class PostgresRepackExpression(BaseExpression): + """A native PostgreSQL 19 ``REPACK`` statement expression.""" + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: Optional[str] = None, + *, + schema: Optional[str] = None, + columns: Optional[Sequence[str]] = None, + using_index: Optional[Union[bool, str]] = None, + all_using_index: bool = False, + verbose: bool = False, + analyze: bool = False, + concurrently: bool = False, + ): + super().__init__(dialect) + if isinstance(columns, (str, bytes)) or (columns is not None and not isinstance(columns, Sequence)): + raise TypeError("columns must be a sequence of identifiers") + normalized_columns = None if columns is None else list(columns) + if normalized_columns is not None and any(not isinstance(column, str) for column in normalized_columns): + raise TypeError("columns must contain only string identifiers") + if not isinstance(all_using_index, bool): + raise TypeError("all_using_index must be a bool") + self.table_name = table_name + self.schema = schema + self.columns: Optional[List[str]] = normalized_columns + self.using_index = using_index + self.all_using_index = all_using_index + self.verbose = verbose + self.analyze = analyze + self.concurrently = concurrently + + @property + def format_method(self) -> str: + return "format_repack_statement" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py index ca7cf395..18c54b46 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py @@ -16,7 +16,7 @@ ``PostgresStoredProcedureMixin``; this module covers FUNCTION and AGGREGATE. """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -48,7 +48,6 @@ class PostgresCreateFunctionExpression(BaseExpression): cost: Estimated execution cost (optional). rows: Estimated rows returned (optional). is_strict: When True, add ``STRICT``. - dialect_options: Reserved. """ @@ -66,7 +65,6 @@ def __init__( cost: Optional[float] = None, rows: Optional[int] = None, strict: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -80,7 +78,6 @@ def __init__( self.cost = cost self.rows = rows self.strict = strict - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -137,7 +134,6 @@ class PostgresCreateAggregateExpression(BaseExpression): schema: Optional schema for the aggregate. finalfunc: Optional final function name. initcond: Optional initial condition value. - dialect_options: Reserved. """ @@ -150,7 +146,6 @@ def __init__( schema: Optional[str] = None, finalfunc: Optional[str] = None, initcond: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -159,7 +154,6 @@ def __init__( self.stype = stype self.finalfunc = finalfunc self.initcond = initcond - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py index afdd2cd9..37f597df 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py @@ -12,7 +12,7 @@ - MCV lists: PostgreSQL 12+ """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -73,8 +73,6 @@ def __init__( schema: Optional[str] = None, statistics_type: Optional[str] = None, if_not_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name @@ -83,7 +81,6 @@ def __init__( self.schema = schema self.statistics_type = statistics_type self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -120,14 +117,11 @@ def __init__( name: str, schema: Optional[str] = None, if_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name = name self.schema = schema self.if_exists = if_exists - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_options.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_options.py new file mode 100644 index 00000000..78f25029 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_options.py @@ -0,0 +1,38 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_options.py +"""PostgreSQL-specific CREATE TABLE options. + +PostgreSQL adds the ``UNLOGGED`` header modifier to ``CREATE TABLE``, which +has no generic equivalent. It lives on ``PostgresCreateTableOptions`` (deriving +the generic ``CreateTableOptions``) and is rendered by the PostgreSQL +``format_create_table_options`` override. +""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import CreateTableOptions + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresCreateTableOptions", +] + + +class PostgresCreateTableOptions(CreateTableOptions): + """A PostgreSQL CREATE TABLE options declaration extending the generic one. + + Adds the PostgreSQL-only ``unlogged`` header modifier. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + *, + or_replace: bool = False, + comment: Optional[str] = None, + unlogged: bool = False, + ): + super().__init__(dialect, or_replace=or_replace, comment=comment) + self.unlogged = unlogged diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py index 67142b67..e5aaeb10 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py @@ -11,7 +11,7 @@ """ from enum import Enum -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -70,15 +70,12 @@ def __init__( schema: Optional[str] = None, mode: Optional[LoggingMode] = None, access_method: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.table_name = table_name self.schema = schema self.mode = mode self.access_method = access_method - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py index af3d5317..25709b1f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py @@ -1,38 +1,51 @@ # src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py -""" -PostgreSQL DDL expressions: Enum and Range Type operations. +"""PostgreSQL user-defined TYPE DDL expressions.""" -PostgreSQL Documentation: -- CREATE TYPE: https://www.postgresql.org/docs/current/sql-createtype.html -- DROP TYPE: https://www.postgresql.org/docs/current/sql-droptype.html -- ALTER TYPE: https://www.postgresql.org/docs/current/sql-altertype.html +from __future__ import annotations -Version Requirements: -- ENUM type: PostgreSQL 8.3+ -- ADD VALUE: PostgreSQL 9.1+ -- RENAME VALUE: PostgreSQL 9.1+ -- RANGE type: PostgreSQL 9.2+ -""" - -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +import warnings +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, Mapping, Optional, Sequence, Union, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) +from rhosocial.activerecord.backend.expression.types import DataType if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase __all__ = [ - # Enum types + "PostgresCompositeTypeAttribute", + "PostgresCompositeTypeDefinition", + "PostgresEnumTypeDefinition", + "PostgresRangeTypeDefinition", + "PostgresBaseTypeDefinition", + "PostgresShellTypeDefinition", + "PostgresRenameTypeAction", + "PostgresSetTypeSchemaAction", + "PostgresChangeTypeOwnerAction", + "PostgresRenameTypeAttributeAction", + "PostgresAddTypeAttributeAction", + "PostgresDropTypeAttributeAction", + "PostgresAlterTypeAttributeAction", + "PostgresAddEnumValueAction", + "PostgresRenameEnumValueAction", + "PostgresSetTypePropertiesAction", + "PostgresDropTypeExpression", "PostgresCreateEnumTypeExpression", "PostgresDropEnumTypeExpression", "PostgresAlterEnumAddValueExpression", - # Additional "PostgresAlterEnumTypeAddValueExpression", "PostgresAlterEnumTypeRenameValueExpression", - # Range type "PostgresCreateRangeTypeExpression", - # Format-signature-compliant enum expressions "EnumTypeNameExpression", "EnumValuesExpression", "CreateEnumTypeExpression", @@ -41,201 +54,506 @@ ] -# ============================================================================= -# Enum Type Operations -# ============================================================================= - -class PostgresCreateEnumTypeExpression(BaseExpression): - """PostgreSQL CREATE TYPE ... AS ENUM statement expression. - - Creates a new enumerated type consisting of a set of static string values. - ENUM types are useful for finite sets of values like status codes. - - Attributes: - name: Name of the enum type. - values: List of allowed values (must be unique). - schema: Schema name for the type. - if_not_exists: Add IF NOT EXISTS clause. - - Example: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> create_enum = PostgresCreateEnumTypeExpression( - ... dialect=dialect, - ... name="order_status", - ... values=["pending", "processing", "shipped", "delivered", "cancelled"], - ... ) - >>> sql, params = create_enum.to_sql() - >>> sql - "CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled')" - - >>> # With schema and IF NOT EXISTS - >>> create_enum = PostgresCreateEnumTypeExpression( - ... dialect=dialect, - ... name="priority", - ... values=["low", "medium", "high"], - ... schema="app", - ... if_not_exists=True, - ... ) - - """ +def _validate_name(value: str, field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +def _validate_qualified_name(value: str, field_name: str) -> None: + _validate_name(value, field_name) + if any(not part.strip() for part in value.split(".")): + raise ValueError(f"{field_name} must contain non-empty identifier segments") + + +def _validate_label(value: str, field_name: str) -> None: + if not isinstance(value, str): + raise TypeError(f"{field_name} must be a string") + + +def _require_data_type(value: DataType, field_name: str) -> None: + if not isinstance(value, DataType): + raise TypeError( + f"{field_name} must be a DataType instance, got {type(value).__name__}" + ) + + +def _coerce_legacy_data_type( + dialect: "SQLDialectBase", + value: Union[DataType, str], + field_name: str, +) -> DataType: + if isinstance(value, DataType): + return value + if not isinstance(value, str) or not value.strip(): + raise TypeError(f"{field_name} must be a DataType instance") + warnings.warn( + f"Raw {field_name} strings are deprecated; pass a DataType instance", + DeprecationWarning, + stacklevel=3, + ) + return DataType.parse_data_type_str(dialect, value) + + +@dataclass +class PostgresCompositeTypeAttribute: + """One attribute in a PostgreSQL composite TYPE definition.""" + + name: str + data_type: DataType + collation: Optional[str] = None + + def __post_init__(self) -> None: + _validate_name(self.name, "name") + _require_data_type(self.data_type, "data_type") + if self.collation is not None: + _validate_qualified_name(self.collation, "collation") + + +class PostgresCompositeTypeDefinition(TypeDefinition): + """Composite TYPE definition.""" + + definition_kind = "composite" def __init__( self, dialect: "SQLDialectBase", - name: str, - values: List[str], - schema: Optional[str] = None, - if_not_exists: bool = False, + attributes: Sequence[PostgresCompositeTypeAttribute], + ) -> None: + super().__init__(dialect) + items = list(attributes or []) + if any(not isinstance(item, PostgresCompositeTypeAttribute) for item in items): + raise TypeError("attributes must contain PostgresCompositeTypeAttribute instances") + self.attributes = items + + +class PostgresEnumTypeDefinition(TypeDefinition): + """ENUM TYPE definition.""" + + definition_kind = "enum" + + def __init__( + self, + dialect: "SQLDialectBase", + labels: Sequence[str], + ) -> None: + super().__init__(dialect) + items = list(labels or []) + for label in items: + _validate_label(label, "labels") + if len(items) != len(set(items)): + raise ValueError("ENUM labels must be unique") + self.labels = items + + +class PostgresRangeTypeDefinition(TypeDefinition): + """RANGE TYPE definition.""" + + definition_kind = "range" + + def __init__( + self, + dialect: "SQLDialectBase", + subtype: DataType, *, - dialect_options: Optional[Dict[str, Any]] = None, - ): + subtype_operator_class: Optional[str] = None, + collation: Optional[str] = None, + canonical_function: Optional[str] = None, + subtype_diff_function: Optional[str] = None, + multirange_type_name: Optional[str] = None, + ) -> None: super().__init__(dialect) - self.name = name - self.values = values - self.schema = schema - self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} + _require_data_type(subtype, "subtype") + optional_names = { + "subtype_operator_class": subtype_operator_class, + "collation": collation, + "canonical_function": canonical_function, + "subtype_diff_function": subtype_diff_function, + "multirange_type_name": multirange_type_name, + } + for field_name, value in optional_names.items(): + if value is not None: + _validate_qualified_name(value, field_name) + self.subtype = subtype + self.subtype_operator_class = subtype_operator_class + self.collation = collation + self.canonical_function = canonical_function + self.subtype_diff_function = subtype_diff_function + self.multirange_type_name = multirange_type_name + + +class _UnsetValue(Enum): + TOKEN = "UNSET" + + +_UNSET = _UnsetValue.TOKEN + + +class PostgresBaseTypeDefinition(TypeDefinition): + """Base TYPE definition.""" + + definition_kind = "base" + + _ALLOWED_ALIGNMENTS = frozenset({"char", "int2", "int4", "double"}) + _ALLOWED_STORAGE = frozenset({"plain", "external", "extended", "main"}) + + def __init__( + self, + dialect: "SQLDialectBase", + *, + input_function: Optional[str] = None, + output_function: Optional[str] = None, + receive_function: Optional[str] = None, + send_function: Optional[str] = None, + type_modifier_input_function: Optional[str] = None, + type_modifier_output_function: Optional[str] = None, + analyze_function: Optional[str] = None, + subscript_function: Optional[str] = None, + internallength: Optional[Union[int, str]] = None, + passedbyvalue: bool = False, + alignment: Optional[str] = None, + storage: Optional[str] = None, + like_type: Optional[DataType] = None, + category: Optional[str] = None, + preferred: Optional[bool] = None, + default: Any = _UNSET, + element: Optional[DataType] = None, + delimiter: Optional[str] = None, + collatable: Optional[bool] = None, + ) -> None: + super().__init__(dialect) + function_names = { + "input_function": input_function, + "output_function": output_function, + "receive_function": receive_function, + "send_function": send_function, + "type_modifier_input_function": type_modifier_input_function, + "type_modifier_output_function": type_modifier_output_function, + "analyze_function": analyze_function, + "subscript_function": subscript_function, + } + for field_name, value in function_names.items(): + if value is not None: + _validate_qualified_name(value, field_name) + if input_function is None or output_function is None: + raise ValueError("input_function and output_function are required") + if like_type is not None: + _require_data_type(like_type, "like_type") + if internallength is not None: + if isinstance(internallength, bool): + raise TypeError("internallength must be a positive integer or VARIABLE") + if isinstance(internallength, int) and internallength <= 0: + raise ValueError("internallength must be a positive integer or VARIABLE") + if isinstance(internallength, str) and internallength.upper() != "VARIABLE": + raise ValueError("internallength must be a positive integer or VARIABLE") + if alignment is not None and alignment.lower() not in self._ALLOWED_ALIGNMENTS: + raise ValueError("alignment must be char, int2, int4, or double") + if storage is not None and storage.lower() not in self._ALLOWED_STORAGE: + raise ValueError("storage must be plain, external, extended, or main") + if category is not None and (len(category) != 1 or not category.isascii()): + raise ValueError("category must be a single ASCII character") + if element is not None: + _require_data_type(element, "element") + if delimiter is not None and not isinstance(delimiter, str): + raise TypeError("delimiter must be a string") + self.input_function = input_function + self.output_function = output_function + self.receive_function = receive_function + self.send_function = send_function + self.type_modifier_input_function = type_modifier_input_function + self.type_modifier_output_function = type_modifier_output_function + self.analyze_function = analyze_function + self.subscript_function = subscript_function + self.internallength = internallength + self.passedbyvalue = passedbyvalue + self.alignment = alignment + self.storage = storage + self.like_type = like_type + self.category = category + self.preferred = preferred + self.default = default + self.element = element + self.delimiter = delimiter + self.collatable = collatable @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_create_enum_type" - - -class PostgresDropEnumTypeExpression(BaseExpression): - """PostgreSQL DROP TYPE statement expression for enum types. - - Drops an existing enum type from the database. - - Attributes: - name: Name of the enum type to drop. - schema: Schema name for the type. - if_exists: Add IF EXISTS clause (prevent error if not exists). - cascade: Drop dependent objects (CASCADE). - - Example: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> drop_enum = PostgresDropEnumTypeExpression( - ... dialect=dialect, - ... name="order_status", - ... ) - >>> sql, params = drop_enum.to_sql() - >>> sql - "DROP TYPE order_status" - - >>> # With IF EXISTS and CASCADE - >>> drop_enum = PostgresDropEnumTypeExpression( - ... dialect=dialect, - ... name="priority", - ... schema="app", - ... if_exists=True, - ... cascade=True, - ... ) - - """ + def has_default(self) -> bool: + return self.default is not _UNSET + + +class PostgresShellTypeDefinition(TypeDefinition): + """Shell TYPE definition.""" + + definition_kind = "shell" + + +class PostgresRenameTypeAction(TypeAlterAction): + action_kind = "rename" + + def __init__(self, dialect: "SQLDialectBase", new_name: str) -> None: + super().__init__(dialect) + _validate_name(new_name, "new_name") + self.new_name = new_name + + +class PostgresSetTypeSchemaAction(TypeAlterAction): + action_kind = "set_schema" + + def __init__(self, dialect: "SQLDialectBase", new_schema: str) -> None: + super().__init__(dialect) + _validate_name(new_schema, "new_schema") + self.new_schema = new_schema + + +class PostgresChangeTypeOwnerAction(TypeAlterAction): + action_kind = "change_owner" + + def __init__(self, dialect: "SQLDialectBase", new_owner: str) -> None: + super().__init__(dialect) + _validate_name(new_owner, "new_owner") + self.new_owner = new_owner + + +class PostgresRenameTypeAttributeAction(TypeAlterAction): + action_kind = "rename_attribute" def __init__( self, dialect: "SQLDialectBase", - name: str, - schema: Optional[str] = None, - if_exists: bool = False, + attribute_name: str, + new_name: str, + *, cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__(dialect) + _validate_name(attribute_name, "attribute_name") + _validate_name(new_name, "new_name") + self.attribute_name = attribute_name + self.new_name = new_name + self.cascade = cascade + self.restrict = restrict + + +class PostgresAddTypeAttributeAction(TypeAlterAction): + action_kind = "add_attribute" + + def __init__( + self, + dialect: "SQLDialectBase", + attribute_name: str, + data_type: DataType, *, - dialect_options: Optional[Dict[str, Any]] = None, - ): + collation: Optional[str] = None, + cascade: bool = False, + restrict: bool = False, + ) -> None: super().__init__(dialect) - self.name = name - self.schema = schema + _validate_name(attribute_name, "attribute_name") + _require_data_type(data_type, "data_type") + if collation is not None: + _validate_qualified_name(collation, "collation") + self.attribute_name = attribute_name + self.data_type = data_type + self.collation = collation + self.cascade = cascade + self.restrict = restrict + + +class PostgresDropTypeAttributeAction(TypeAlterAction): + action_kind = "drop_attribute" + + def __init__( + self, + dialect: "SQLDialectBase", + attribute_name: str, + *, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__(dialect) + _validate_name(attribute_name, "attribute_name") + self.attribute_name = attribute_name self.if_exists = if_exists self.cascade = cascade - self.dialect_options = dialect_options or {} + self.restrict = restrict - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_drop_enum_type" - - -class PostgresAlterEnumAddValueExpression(BaseExpression): - """PostgreSQL ALTER TYPE ... ADD VALUE statement expression. - - Adds a new value to an existing enum type. - The new value can be inserted before or after an existing value. - - Attributes: - type_name: Name of the enum type to alter. - new_value: New value to add. - schema: Schema name for the type. - before: Add new value before this existing value. - after: Add new value after this existing value. - - Example: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> add_value = PostgresAlterEnumAddValueExpression( - ... dialect=dialect, - ... type_name="order_status", - ... new_value="returned", - ... ) - >>> sql, params = add_value.to_sql() - >>> sql - "ALTER TYPE order_status ADD VALUE 'returned'" - - >>> # Add in order - >>> add_value = PostgresAlterEnumAddValueExpression( - ... dialect=dialect, - ... type_name="order_status", - ... new_value="refunded", - ... after="cancelled", - ... ) - - """ + +class PostgresAlterTypeAttributeAction(TypeAlterAction): + action_kind = "alter_attribute" + + def __init__( + self, + dialect: "SQLDialectBase", + attribute_name: str, + data_type: DataType, + *, + collation: Optional[str] = None, + set_data: bool = False, + cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__(dialect) + _validate_name(attribute_name, "attribute_name") + _require_data_type(data_type, "data_type") + if collation is not None: + _validate_qualified_name(collation, "collation") + self.attribute_name = attribute_name + self.data_type = data_type + self.collation = collation + self.set_data = set_data + self.cascade = cascade + self.restrict = restrict + + +class PostgresAddEnumValueAction(TypeAlterAction): + action_kind = "add_enum_value" def __init__( self, dialect: "SQLDialectBase", - type_name: str, new_value: str, - schema: Optional[str] = None, + *, before: Optional[str] = None, after: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): + if_not_exists: bool = False, + ) -> None: super().__init__(dialect) - self.type_name = type_name + _validate_label(new_value, "new_value") + if before is not None and after is not None: + raise ValueError("before and after are mutually exclusive") + if before is not None: + _validate_label(before, "before") + if after is not None: + _validate_label(after, "after") self.new_value = new_value - self.schema = schema self.before = before self.after = after - self.dialect_options = dialect_options or {} + self.if_not_exists = if_not_exists - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_alter_enum_add_value" + +class PostgresRenameEnumValueAction(TypeAlterAction): + action_kind = "rename_enum_value" + + def __init__( + self, + dialect: "SQLDialectBase", + old_value: str, + new_value: str, + ) -> None: + super().__init__(dialect) + _validate_label(old_value, "old_value") + _validate_label(new_value, "new_value") + self.old_value = old_value + self.new_value = new_value -class PostgresAlterEnumTypeAddValueExpression(BaseExpression): - """PostgreSQL ALTER TYPE ... ADD VALUE statement. +class PostgresSetTypePropertiesAction(TypeAlterAction): + action_kind = "set_properties" - Adds a new value to an existing enum type with validation. + _FUNCTION_PROPERTIES = frozenset( + { + "RECEIVE", + "SEND", + "TYPMOD_IN", + "TYPMOD_OUT", + "ANALYZE", + "SUBSCRIPT", + } + ) + _ALLOWED_PROPERTIES = _FUNCTION_PROPERTIES | {"STORAGE"} + + def __init__( + self, + dialect: "SQLDialectBase", + properties: Mapping[str, Optional[str]], + ) -> None: + super().__init__(dialect) + normalized: Dict[str, Optional[str]] = {} + for raw_name, value in dict(properties or {}).items(): + if not isinstance(raw_name, str) or not raw_name.strip(): + raise ValueError("property names must be non-empty strings") + name = raw_name.upper() + if name not in self._ALLOWED_PROPERTIES: + raise ValueError(f"Unsupported ALTER TYPE property: {name}") + if value is not None and not isinstance(value, str): + raise TypeError("property values must be strings or None") + if value is not None: + _validate_qualified_name(value, name) + if name == "STORAGE" and value is None: + raise ValueError("STORAGE property cannot be None") + normalized[name] = value + if not normalized: + raise ValueError("properties must contain at least one property") + self.properties = normalized + + +class PostgresDropTypeExpression(DropTypeExpression): + def __init__( + self, + dialect: "SQLDialectBase", + type_name: str, + *, + schema_name: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__( + dialect, + type_name, + schema_name=schema_name, + if_exists=if_exists, + ) + self.cascade = cascade + self.restrict = restrict - Attributes: - type_name: Name of the enum type to alter. - new_value: New value to add. - schema: Schema name for the type. - before: Add new value before this existing value. - after: Add new value after this existing value. - Raises: - ValueError: If type_name or new_value is empty, or both before and after specified. +class PostgresCreateEnumTypeExpression(CreateTypeExpression): + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + values: Sequence[str], + schema: Optional[str] = None, + if_not_exists: bool = False, + ) -> None: + definition = PostgresEnumTypeDefinition(dialect, values) + super().__init__( + dialect, + name, + definition, + schema_name=schema, + if_not_exists=if_not_exists, + ) + self.name = name + self.values = list(values) + self.schema = schema - """ +class PostgresDropEnumTypeExpression(PostgresDropTypeExpression): + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ) -> None: + super().__init__( + dialect, + name, + schema_name=schema, + if_exists=if_exists, + cascade=cascade, + restrict=restrict, + ) + self.name = name + self.schema = schema + + +class PostgresAlterEnumAddValueExpression(AlterTypeExpression): def __init__( self, dialect: "SQLDialectBase", @@ -244,43 +562,37 @@ def __init__( schema: Optional[str] = None, before: Optional[str] = None, after: Optional[str] = None, - ): - super().__init__(dialect) - if not type_name: + if_not_exists: bool = False, + ) -> None: + if not isinstance(type_name, str) or not type_name.strip(): raise ValueError("Enum type name cannot be empty") - if not new_value: + if not isinstance(new_value, str): + raise TypeError("New value must be a string") + if new_value == "": raise ValueError("New value cannot be empty") - if before and after: + if before is not None and after is not None: raise ValueError("Cannot specify both 'before' and 'after'") - + action = PostgresAddEnumValueAction( + dialect, + new_value, + before=before, + after=after, + if_not_exists=if_not_exists, + ) + super().__init__(dialect, type_name, [action], schema_name=schema) self.type_name = type_name self.new_value = new_value self.schema = schema self.before = before self.after = after + self.if_not_exists = if_not_exists - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_alter_enum_type_add_value" - - -class PostgresAlterEnumTypeRenameValueExpression(BaseExpression): - """PostgreSQL ALTER TYPE ... RENAME VALUE statement. - - Renames an existing value in an enum type. - - Attributes: - type_name: Name of the enum type to alter. - old_value: Current value to rename. - new_value: New name for the value. - schema: Schema name for the type. - Raises: - ValueError: If type_name, old_value, or new_value is empty. +class PostgresAlterEnumTypeAddValueExpression(PostgresAlterEnumAddValueExpression): + pass - """ +class PostgresAlterEnumTypeRenameValueExpression(AlterTypeExpression): def __init__( self, dialect: "SQLDialectBase", @@ -288,85 +600,60 @@ def __init__( old_value: str, new_value: str, schema: Optional[str] = None, - ): - super().__init__(dialect) - if not type_name: + ) -> None: + if not isinstance(type_name, str) or not type_name.strip(): raise ValueError("Enum type name cannot be empty") - if not old_value: + if not isinstance(old_value, str): + raise TypeError("Old value must be a string") + if not isinstance(new_value, str): + raise TypeError("New value must be a string") + if old_value == "": raise ValueError("Old value cannot be empty") - if not new_value: + if new_value == "": raise ValueError("New value cannot be empty") - + super().__init__( + dialect, + type_name, + [PostgresRenameEnumValueAction(dialect, old_value, new_value)], + schema_name=schema, + ) self.type_name = type_name self.old_value = old_value self.new_value = new_value self.schema = schema - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_alter_enum_type_rename_value" - - -class PostgresCreateRangeTypeExpression(BaseExpression): - """PostgreSQL CREATE TYPE ... AS RANGE statement expression. - - Creates a new range type for representing ranges of values. - Useful for date ranges, numeric ranges, etc. - - Attributes: - name: Name of the range type. - subtype: Base type for the range (e.g., 'int4', 'int8', 'date', 'timestamp'). - schema: Schema name for the type. - subtype_opclass: Operator class for the subtype. - collation: Collation for the subtype. - canonical: Canonical function for the range. - subtype_diff: Subtype difference function. - if_not_exists: Add IF NOT EXISTS clause. - - Raises: - ValueError: If name or subtype is empty. - - Example: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> create_range = PostgresCreateRangeTypeExpression( - ... dialect=dialect, - ... name="int4range", - ... subtype="int4", - ... ) - >>> sql, params = create_range.to_sql() - >>> sql - "CREATE TYPE int4range AS RANGE (subtype=int4)" - - >>> # Date range with custom functions (PG 9.2+) - >>> create_range = PostgresCreateRangeTypeExpression( - ... dialect=dialect, - ... name="date_range", - ... subtype="date", - ... collation="en_US", - ... ) - - """ +class PostgresCreateRangeTypeExpression(CreateTypeExpression): def __init__( self, dialect: "SQLDialectBase", name: str, - subtype: str, + subtype: Union[DataType, str], schema: Optional[str] = None, subtype_opclass: Optional[str] = None, collation: Optional[str] = None, canonical: Optional[str] = None, subtype_diff: Optional[str] = None, if_not_exists: bool = False, - ): - super().__init__(dialect) - if not name: - raise ValueError("Range type name cannot be empty") - if not subtype: - raise ValueError("Subtype cannot be empty") - + multirange_type_name: Optional[str] = None, + ) -> None: + normalized_subtype = _coerce_legacy_data_type(dialect, subtype, "subtype") + definition = PostgresRangeTypeDefinition( + dialect, + normalized_subtype, + subtype_operator_class=subtype_opclass, + collation=collation, + canonical_function=canonical, + subtype_diff_function=subtype_diff, + multirange_type_name=multirange_type_name, + ) + super().__init__( + dialect, + name, + definition, + schema_name=schema, + if_not_exists=if_not_exists, + ) self.name = name self.subtype = subtype self.schema = schema @@ -374,206 +661,46 @@ def __init__( self.collation = collation self.canonical = canonical self.subtype_diff = subtype_diff - self.if_not_exists = if_not_exists - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_create_range_type" - - -# ============================================================================= -# Format-signature-compliant enum expressions -# ============================================================================= + self.multirange_type_name = multirange_type_name class EnumTypeNameExpression(BaseExpression): - """Expression for formatting an enum type name with optional schema. - - Attributes: - name: Enum type name. - schema: Optional schema name. - - Example: - >>> expr = EnumTypeNameExpression(dialect, name="status", schema="app") - >>> sql, params = expr.to_sql() - >>> sql - 'app.status' - - """ - def __init__( self, dialect: "SQLDialectBase", name: str, schema: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): + ) -> None: super().__init__(dialect) self.name = name self.schema = schema - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" return "format_enum_type_name_expression" class EnumValuesExpression(BaseExpression): - """Expression for formatting a list of enum values for SQL. - - Attributes: - values: List of enum value strings. - - Example: - >>> expr = EnumValuesExpression(dialect, values=["a", "b", "c"]) - >>> sql, params = expr.to_sql() - >>> sql - "'a', 'b', 'c'" - - """ - def __init__( self, dialect: "SQLDialectBase", - values: List[str], - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): + values: Sequence[str], + ) -> None: super().__init__(dialect) - self.values = values - self.dialect_options = dialect_options or {} + self.values = list(values) @property def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" return "format_enum_values_expression" -class CreateEnumTypeExpression(BaseExpression): - """Expression for CREATE TYPE ... AS ENUM statement. +class CreateEnumTypeExpression(PostgresCreateEnumTypeExpression): + pass - Attributes: - name: Enum type name. - values: List of allowed values. - schema: Optional schema name. - if_not_exists: Add IF NOT EXISTS clause. - Example: - >>> expr = CreateEnumTypeExpression( - ... dialect, name="status", values=["active", "inactive"], - ... ) - >>> sql, params = expr.to_sql() - >>> sql - "CREATE TYPE status AS ENUM ('active', 'inactive')" +class DropEnumTypeExpression(PostgresDropEnumTypeExpression): + pass - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - values: List[str], - schema: Optional[str] = None, - if_not_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.name = name - self.values = values - self.schema = schema - self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_create_enum_type" - - -class DropEnumTypeExpression(BaseExpression): - """Expression for DROP TYPE statement. - - Attributes: - name: Enum type name. - schema: Optional schema name. - if_exists: Add IF EXISTS clause. - cascade: Add CASCADE clause. - - Example: - >>> expr = DropEnumTypeExpression(dialect, name="status") - >>> sql, params = expr.to_sql() - >>> sql - "DROP TYPE status" - - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - schema: Optional[str] = None, - if_exists: bool = False, - cascade: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.name = name - self.schema = schema - self.if_exists = if_exists - self.cascade = cascade - self.dialect_options = dialect_options or {} - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_drop_enum_type" - - -class AlterEnumAddValueExpression(BaseExpression): - """Expression for ALTER TYPE ... ADD VALUE statement. - - Attributes: - type_name: Enum type name. - new_value: New value to add. - schema: Optional schema name. - before: Add before this existing value. - after: Add after this existing value. - - Example: - >>> expr = AlterEnumAddValueExpression( - ... dialect, type_name="status", new_value="archived", - ... ) - >>> sql, params = expr.to_sql() - >>> sql - "ALTER TYPE status ADD VALUE 'archived'" - - """ - - def __init__( - self, - dialect: "SQLDialectBase", - type_name: str, - new_value: str, - schema: Optional[str] = None, - before: Optional[str] = None, - after: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.type_name = type_name - self.new_value = new_value - self.schema = schema - self.before = before - self.after = after - self.dialect_options = dialect_options or {} - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_alter_enum_add_value" +class AlterEnumAddValueExpression(PostgresAlterEnumAddValueExpression): + pass diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py index 0a28b660..95c3dc7c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py @@ -14,7 +14,7 @@ - TRUNCATE option: PostgreSQL 14+ """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -83,8 +83,6 @@ def __init__( skip_locked: bool = False, truncate: bool = False, columns: Optional[List[str]] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.table_name = table_name @@ -99,7 +97,6 @@ def __init__( self.skip_locked = skip_locked self.truncate = truncate self.columns = columns - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -149,8 +146,6 @@ def __init__( verbose: bool = False, skip_locked: bool = False, columns: Optional[List[str]] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.table_name = table_name @@ -158,7 +153,6 @@ def __init__( self.verbose = verbose self.skip_locked = skip_locked self.columns = columns - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py index 753496ef..fa827ad9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py @@ -1,7 +1,7 @@ # src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py """PostgreSQL ILIKE expression class.""" -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Any, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -36,14 +36,11 @@ def __init__( column: Any, pattern: str, negate: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.column = column self.pattern = pattern self.negate = negate - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py new file mode 100644 index 00000000..b5fe67d7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py @@ -0,0 +1,73 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py +"""PostgreSQL-specific introspection expressions. + +These expressions reuse the core parameter container contract +(``IntrospectionExpression.get_params()``) and only redirect SQL generation to +PostgreSQL-specific formatters, so the core introspection layer stays untouched. +""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.introspection import ( + ViewInfoExpression, + ViewListExpression, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresMaterializedViewInfoExpression", + "PostgresMaterializedViewListExpression", +] + + +class PostgresMaterializedViewListExpression(ViewListExpression): + """List materialized views (``pg_class.relkind = 'm'``). + + Example: + >>> expr = PostgresMaterializedViewListExpression(dialect, schema="public") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + schema: Optional[str] = None, + include_system: bool = False, + ): + super().__init__(dialect, schema=schema, include_system=include_system) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_materialized_view_list_query" + + +class PostgresMaterializedViewInfoExpression(ViewInfoExpression): + """Fetch a single materialized view by name. + + Example: + >>> expr = PostgresMaterializedViewInfoExpression(dialect, "sales_summary") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + view_name: str, + schema: Optional[str] = None, + include_columns: bool = True, + ): + super().__init__( + dialect, + view_name=view_name, + schema=schema, + include_columns=include_columns, + ) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_materialized_view_info_query" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py new file mode 100644 index 00000000..bb383750 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py @@ -0,0 +1,113 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/registry.py +"""PostgreSQL DDL expression registration.""" + +from typing import Tuple, Type + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry +from .ddl.domain import ( + _LegacySqlExpression, + _UnsupportedDomainAction, + PostgresAddDomainCheckAction, + PostgresAlterDomainExpression, + PostgresChangeDomainOwnerAction, + PostgresCreateDomainExpression, + PostgresDropDomainCheckAction, + PostgresDropDomainExpression, + PostgresRenameDomainConstraintAction, + PostgresSetDomainSchemaAction, + PostgresValidateDomainConstraintAction, +) +from .copy import PostgresCopyFromExpression, PostgresCopyToExpression +from .ddl.multirange import ( + CreateMultirangeTypeExpression, + MultirangeAggFunctionExpression, +) +from .ddl.repack import PostgresRepackExpression +from .ddl.constraint import PostgresAlterConstraint, PostgresValidateConstraint +from .ddl.type import ( + AlterEnumAddValueExpression, + CreateEnumTypeExpression, + DropEnumTypeExpression, + EnumTypeNameExpression, + EnumValuesExpression, + PostgresAddEnumValueAction, + PostgresAddTypeAttributeAction, + PostgresAlterEnumAddValueExpression, + PostgresAlterEnumTypeAddValueExpression, + PostgresAlterEnumTypeRenameValueExpression, + PostgresAlterTypeAttributeAction, + PostgresBaseTypeDefinition, + PostgresChangeTypeOwnerAction, + PostgresCompositeTypeDefinition, + PostgresCreateEnumTypeExpression, + PostgresCreateRangeTypeExpression, + PostgresDropEnumTypeExpression, + PostgresDropTypeAttributeAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresRenameEnumValueAction, + PostgresRenameTypeAction, + PostgresRenameTypeAttributeAction, + PostgresSetTypePropertiesAction, + PostgresSetTypeSchemaAction, + PostgresShellTypeDefinition, +) + + +_POSTGRES_DDL_EXPRESSION_CLASSES: Tuple[Type[BaseExpression], ...] = ( + PostgresCopyFromExpression, + PostgresCopyToExpression, + PostgresRepackExpression, + PostgresAlterConstraint, + PostgresValidateConstraint, + PostgresCompositeTypeDefinition, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresBaseTypeDefinition, + PostgresShellTypeDefinition, + PostgresRenameTypeAction, + PostgresSetTypeSchemaAction, + PostgresChangeTypeOwnerAction, + PostgresRenameTypeAttributeAction, + PostgresAddTypeAttributeAction, + PostgresDropTypeAttributeAction, + PostgresAlterTypeAttributeAction, + PostgresAddEnumValueAction, + PostgresRenameEnumValueAction, + PostgresSetTypePropertiesAction, + PostgresDropTypeExpression, + PostgresCreateEnumTypeExpression, + PostgresDropEnumTypeExpression, + PostgresAlterEnumAddValueExpression, + PostgresAlterEnumTypeAddValueExpression, + PostgresAlterEnumTypeRenameValueExpression, + PostgresCreateRangeTypeExpression, + EnumTypeNameExpression, + EnumValuesExpression, + CreateEnumTypeExpression, + DropEnumTypeExpression, + AlterEnumAddValueExpression, + _LegacySqlExpression, + _UnsupportedDomainAction, + PostgresAddDomainCheckAction, + PostgresDropDomainCheckAction, + PostgresRenameDomainConstraintAction, + PostgresValidateDomainConstraintAction, + PostgresChangeDomainOwnerAction, + PostgresSetDomainSchemaAction, + PostgresCreateDomainExpression, + PostgresAlterDomainExpression, + PostgresDropDomainExpression, + CreateMultirangeTypeExpression, + MultirangeAggFunctionExpression, +) + + +def register_postgres_ddl_expressions() -> None: + for expression_class in _POSTGRES_DDL_EXPRESSION_CLASSES: + ExpressionRegistry.register(expression_class) + + +__all__ = ["register_postgres_ddl_expressions"] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py index c2b99f89..afac3ea1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py @@ -17,7 +17,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional, Set, TYPE_CHECKING +from typing import Optional, Set, TYPE_CHECKING from rhosocial.activerecord.backend.expression.types import ( ArrayType, @@ -133,9 +133,8 @@ class PostgresBitType(DataType): name = "postgres_bit" n: Optional[int] = None - def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None): + super().__init__(dialect) self.n = n def _type_params(self) -> tuple: @@ -148,9 +147,8 @@ class PostgresVarBitType(DataType): name = "postgres_varbit" n: Optional[int] = None - def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None): + super().__init__(dialect) self.n = n def _type_params(self) -> tuple: @@ -358,9 +356,8 @@ class PostgresVectorType(DataType): name = "postgres_vector" dim: int - def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None): + super().__init__(dialect) self.dim = dim def _type_params(self) -> tuple: @@ -380,9 +377,8 @@ class PostgresHalfvecType(DataType): name = "postgres_halfvec" dim: int - def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None): + super().__init__(dialect) self.dim = dim def _type_params(self) -> tuple: @@ -402,9 +398,8 @@ class PostgresSparsevecType(DataType): name = "postgres_sparsevec" dim: int - def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None): + super().__init__(dialect) self.dim = dim def _type_params(self) -> tuple: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py index 44e856b1..e46172dd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py @@ -41,6 +41,9 @@ from rhosocial.activerecord.backend.expression.statements.ddl_index import ( CreateIndexExpression, ) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.index_definition import ( + PostgresCreateIndexExpression, +) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -202,15 +205,13 @@ def create_vector_index( if lists is not None: with_options["lists"] = lists - return CreateIndexExpression( + return PostgresCreateIndexExpression( dialect=dialect, index_name=idx_name, table_name=table_name, columns=[column_name], index_type=index_type_l.upper(), if_not_exists=if_not_exists, - dialect_options={ - "opclasses": {column_name: _METRIC_OPCLASS[metric]}, - "with": with_options, - }, + opclasses={column_name: _METRIC_OPCLASS[metric]}, + with_options=with_options, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py index ea4371bb..76b68178 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py @@ -25,7 +25,7 @@ """ import re -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from rhosocial.activerecord.backend.introspection.base import ( IntrospectorMixin, @@ -44,6 +44,7 @@ IndexColumnInfo, IndexInfo, IndexType, + IntrospectionScope, ReferentialAction, TableInfo, TableType, @@ -253,6 +254,83 @@ def _parse_view_info(self, rows: List[Dict[str, Any]], view_name: str, schema: s comment=row.get("comment"), ) + @staticmethod + def _parse_materialized_view(row: Dict[str, Any], default_schema: str, default_name: str = "") -> ViewInfo: + """Build a ViewInfo for a materialized view. + + Materialized views are never updatable/insertable; ``extra`` carries the + PostgreSQL specific state: ``is_materialized``, ``is_populated`` and + ``has_unique_index`` (the prerequisite for a CONCURRENTLY refresh). + """ + return ViewInfo( + name=row.get("view_name") or default_name, + schema=row.get("schema_name") or default_schema, + definition=row.get("definition"), + is_updatable=False, + is_insertable=False, + comment=row.get("comment"), + extra={ + "is_materialized": True, + "is_populated": bool(row.get("is_populated")), + "has_unique_index": bool(row.get("has_unique_index")), + }, + ) + + @staticmethod + def _parse_materialized_views( + rows: List[Dict[str, Any]], schema: str + ) -> List[ViewInfo]: + return [ + PostgreSQLIntrospectorMixin._parse_materialized_view(row, schema) for row in rows + ] + + @staticmethod + def _parse_materialized_view_info( + rows: List[Dict[str, Any]], view_name: str, schema: str + ) -> Optional[ViewInfo]: + if not rows: + return None + return PostgreSQLIntrospectorMixin._parse_materialized_view( + rows[0], schema, view_name + ) + + def _build_materialized_view_list_sql( + self, schema: Optional[str], include_system: bool + ) -> Tuple[str, tuple]: + from ..expression.introspection import PostgresMaterializedViewListExpression + + target_schema = schema if schema is not None else self._get_default_schema() + expr = PostgresMaterializedViewListExpression(self.dialect).schema( + target_schema + ).include_system(include_system) + return expr.to_sql() + + def _build_materialized_view_info_sql( + self, view_name: str, schema: Optional[str] + ) -> Tuple[str, tuple]: + from ..expression.introspection import PostgresMaterializedViewInfoExpression + + target_schema = schema if schema is not None else self._get_default_schema() + expr = PostgresMaterializedViewInfoExpression(self.dialect, view_name).schema( + target_schema + ) + return expr.to_sql() + + def _materialized_view_cache_key( + self, schema: Optional[str], view_name: Optional[str] = None, include_system: bool = False + ) -> str: + """Build the cache key for materialized view introspection results. + + Materialized views share ``IntrospectionScope.VIEW`` with regular views, + so the key is namespaced to avoid cross-contamination. + """ + return self._make_cache_key( + IntrospectionScope.VIEW, + view_name or "*", + schema=schema, + extra=f"matviews:{include_system}", + ) + def _parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[TriggerInfo]: triggers = [] for row in rows: @@ -344,6 +422,63 @@ def status(self) -> SyncPostgreSQLStatusIntrospector: self._status_instance = SyncPostgreSQLStatusIntrospector(self._backend) return self._status_instance + def list_materialized_views( + self, + schema: Optional[str] = None, + include_system: bool = False, + ) -> List[ViewInfo]: + """List materialized views in the given schema. + + Each entry carries ``extra['is_populated']`` (false for a view created + WITH NO DATA that was never refreshed) and ``extra['has_unique_index']`` + (the prerequisite for REFRESH ... CONCURRENTLY). + + Args: + schema: Schema to inspect. Defaults to the connection default schema. + include_system: Whether to include system objects. + + Returns: + List of ViewInfo describing each materialized view. + """ + target = schema if schema is not None else self._get_default_schema() + key = self._materialized_view_cache_key(schema, include_system=include_system) + cached = self._get_cached(key) + if cached is not None: + return cached + sql, params = self._build_materialized_view_list_sql(schema, include_system) + result = self._parse_materialized_views(self._executor.execute(sql, params), target) + self._set_cached(key, result) + return result + + def get_materialized_view_info( + self, view_name: str, schema: Optional[str] = None + ) -> Optional[ViewInfo]: + """Return information for a single materialized view, or None if absent. + + Args: + view_name: Materialized view name. + schema: Schema to inspect. Defaults to the connection default schema. + + Returns: + ViewInfo, or None when the materialized view does not exist. + """ + target = schema if schema is not None else self._get_default_schema() + key = self._materialized_view_cache_key(schema, view_name=view_name) + cached = self._get_cached(key) + if cached is not None: + return cached + sql, params = self._build_materialized_view_info_sql(view_name, schema) + result = self._parse_materialized_view_info( + self._executor.execute(sql, params), view_name, target + ) + if result is not None: + self._set_cached(key, result) + return result + + def materialized_view_exists(self, view_name: str, schema: Optional[str] = None) -> bool: + """Return True when the named materialized view exists.""" + return self.get_materialized_view_info(view_name, schema) is not None + class AsyncPostgreSQLIntrospector(PostgreSQLIntrospectorMixin, AsyncAbstractIntrospector): """Asynchronous introspector for PostgreSQL backends. @@ -371,3 +506,58 @@ def status(self) -> AsyncPostgreSQLStatusIntrospector: if self._status_instance is None: self._status_instance = AsyncPostgreSQLStatusIntrospector(self._backend) return self._status_instance + + async def list_materialized_views( + self, + schema: Optional[str] = None, + include_system: bool = False, + ) -> List[ViewInfo]: + """List materialized views in the given schema (async counterpart). + + Args: + schema: Schema to inspect. Defaults to the connection default schema. + include_system: Whether to include system objects. + + Returns: + List of ViewInfo describing each materialized view. + """ + target = schema if schema is not None else self._get_default_schema() + key = self._materialized_view_cache_key(schema, include_system=include_system) + cached = self._get_cached(key) + if cached is not None: + return cached + sql, params = self._build_materialized_view_list_sql(schema, include_system) + rows = await self._executor.execute(sql, params) + result = self._parse_materialized_views(rows, target) + self._set_cached(key, result) + return result + + async def get_materialized_view_info( + self, view_name: str, schema: Optional[str] = None + ) -> Optional[ViewInfo]: + """Return information for a single materialized view, or None (async). + + Args: + view_name: Materialized view name. + schema: Schema to inspect. Defaults to the connection default schema. + + Returns: + ViewInfo, or None when the materialized view does not exist. + """ + target = schema if schema is not None else self._get_default_schema() + key = self._materialized_view_cache_key(schema, view_name=view_name) + cached = self._get_cached(key) + if cached is not None: + return cached + sql, params = self._build_materialized_view_info_sql(view_name, schema) + rows = await self._executor.execute(sql, params) + result = self._parse_materialized_view_info(rows, view_name, target) + if result is not None: + self._set_cached(key, result) + return result + + async def materialized_view_exists( + self, view_name: str, schema: Optional[str] = None + ) -> bool: + """Return True when the named materialized view exists (async).""" + return await self.get_materialized_view_info(view_name, schema) is not None diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index bae798e1..4a9fac6b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -33,6 +33,7 @@ from .ddl.rls_config import PostgresRlsConfigMixin from .ddl.table_settings import PostgresAlterTableSettingsMixin from .ddl.cluster import PostgresClusterMixin +from .ddl.repack import PostgresRepackMixin from .ddl.domain import PostgresDomainMixin from .ddl.collation import PostgresCollationDDLMixin from .ddl.foreign_table import PostgresForeignTableMixin @@ -41,6 +42,7 @@ # DML mixins from .dml.vacuum import PostgresVacuumMixin +from .dml.copy import PostgresCopyMixin from .dml.stored_procedure import PostgresStoredProcedureMixin from .dml.extended_statistics import PostgresExtendedStatisticsMixin from .dml.advisory_lock import PostgresAdvisoryLockMixin @@ -108,6 +110,7 @@ from .ilike import PostgresILIKEMixin from .join import PostgresJoinMixin from .truncate import PostgresTruncateMixin +from .ddl_database import PostgresDatabaseMixin from .schema import PostgresSchemaMixin from .sequence import PostgresSequenceMixin from .transaction import PostgresTransactionMixin @@ -143,6 +146,7 @@ "PostgresRlsConfigMixin", "PostgresAlterTableSettingsMixin", "PostgresClusterMixin", + "PostgresRepackMixin", "PostgresDomainMixin", "PostgresCollationDDLMixin", "PostgresForeignTableMixin", @@ -150,6 +154,7 @@ "PostgresPublicationMixin", # DML mixins "PostgresVacuumMixin", + "PostgresCopyMixin", "PostgresStoredProcedureMixin", "PostgresExtendedStatisticsMixin", "PostgresAdvisoryLockMixin", @@ -214,6 +219,7 @@ "PostgresJoinMixin", "PostgresTruncateMixin", "PostgresSchemaMixin", + "PostgresDatabaseMixin", "PostgresSequenceMixin", "PostgresTransactionMixin", "PostgresViewMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py index c7bfb019..7e1c388d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py @@ -6,6 +6,8 @@ based on the server version. """ +from typing import Tuple, TYPE_CHECKING + class PostgresDataTypeMixin: """PostgreSQL data type enhancements implementation. @@ -13,6 +15,9 @@ class PostgresDataTypeMixin: All features are native, using version number for detection. """ + if TYPE_CHECKING: + version: Tuple[int, int, int] + def supports_multirange_type(self) -> bool: """Multirange is native feature, PG 14+.""" return self.version >= (14, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py index 27d7e25d..874fda4d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py @@ -12,6 +12,7 @@ from .rls_config import PostgresRlsConfigMixin from .table_settings import PostgresAlterTableSettingsMixin from .cluster import PostgresClusterMixin +from .repack import PostgresRepackMixin from .domain import PostgresDomainMixin from .collation import PostgresCollationDDLMixin from .foreign_table import PostgresForeignTableMixin @@ -30,6 +31,7 @@ "PostgresRlsConfigMixin", "PostgresAlterTableSettingsMixin", "PostgresClusterMixin", + "PostgresRepackMixin", "PostgresDomainMixin", "PostgresCollationDDLMixin", "PostgresForeignTableMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py index ee2550ff..87bcb12a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py @@ -64,7 +64,7 @@ def format_alter_column_action(self, action) -> Tuple[str, tuple]: ``SET DATA TYPE``). PostgreSQL additionally supports an optional ``USING (conversion_expression)`` on ``SET DATA TYPE`` — an extension the core dialect does not emit. This override injects it - from ``action.dialect_options["using"]``. + from the PostgreSQL ``PostgresAlterColumn`` typed ``using`` field. Args: action: An ``AlterColumn`` action (core ``ddl_alter`` module). @@ -78,8 +78,7 @@ def format_alter_column_action(self, action) -> Tuple[str, tuple]: """ sql, params = super().format_alter_column_action(action) - dialect_options = getattr(action, "dialect_options", None) or {} - using = dialect_options.get("using") + using = getattr(action, "using", None) if using is None: return sql, params diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py index 5e7b2d57..9e1627b8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py @@ -1,20 +1,34 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py """PostgreSQL COMMENT ON DDL implementation.""" -from typing import Tuple, TYPE_CHECKING - -if TYPE_CHECKING: - from ...expression.ddl import PostgresCommentExpression +from typing import Tuple class PostgresCommentMixin: """PostgreSQL COMMENT ON implementation.""" - def format_comment_statement(self, expr: "PostgresCommentExpression") -> Tuple[str, tuple]: + def supports_comment_on(self) -> bool: + """Whether standalone ``COMMENT ON`` statements are supported. + + PostgreSQL has no inline ``COMMENT`` syntax; comments are annotated + through the standalone ``COMMENT ON`` statement (available since + 7.2). Always ``True``. + """ + return True + + def format_comment_statement(self, expr) -> Tuple[str, tuple]: """Format COMMENT ON statement (PostgreSQL-specific). + Accepts any comment expression carrying ``object_type``, + ``object_name`` and ``comment``. The comment text is bound + through a parameter placeholder (``IS ?`` / params); ``comment=None`` + renders ``IS NULL`` (clears the comment). Dotted names + (``schema.table`` / ``table.column``) are quoted segment-by-segment + so the rendered target stays a valid qualified reference. + Args: - expr: PostgresCommentExpression containing all options + expr: PostgresCommentExpression or CommentOnExpression carrying + ``object_type``, ``object_name`` and ``comment``. Returns: Tuple of (SQL string, parameters) @@ -24,7 +38,10 @@ def format_comment_statement(self, expr: "PostgresCommentExpression") -> Tuple[s else: comment_value = self.get_parameter_placeholder() - parts = ["COMMENT ON", expr.object_type, self.format_identifier(expr.object_name)] + object_sql = ".".join( + self.format_identifier(part) for part in str(expr.object_name).split(".") + ) + parts = ["COMMENT ON", expr.object_type, object_sql] parts.append("IS") parts.append(comment_value) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py index d71b6a99..eb026be5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py @@ -8,16 +8,36 @@ ConstraintMixin (rhosocial.activerecord.backend.dialect.mixins). """ -from typing import Tuple, TYPE_CHECKING +import re +from typing import Any, List, Tuple, TYPE_CHECKING, cast + +from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ( + normalize_table_constraint_type, +) if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ConstraintMixin from rhosocial.activerecord.backend.expression.statements import ( AddTableConstraint, + AlterConstraint, TableConstraint, + ValidateConstraint, ) + class _ConstraintMixinBase(ConstraintMixin): + version: Tuple[int, int, int] + name: str + + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: + return identifier + +else: + + class _ConstraintMixinBase: + pass -class PostgresConstraintMixin: + +class PostgresConstraintMixin(_ConstraintMixinBase): """PostgreSQL-proprietary constraint capability defaults. These features are PostgreSQL-specific and not part of the SQL standard. @@ -27,6 +47,51 @@ class PostgresConstraintMixin: SQL standard constraint capability defaults are in ConstraintMixin. """ + def supports_constraint_enforced(self, constraint_type: Any = None) -> bool: + if constraint_type is None: + return self.version >= (18, 0, 0) + from rhosocial.activerecord.backend.expression.statements import TableConstraintType + + normalized = normalize_table_constraint_type(constraint_type) + if normalized not in {TableConstraintType.CHECK, TableConstraintType.FOREIGN_KEY}: + return False + return self.version >= (18, 0, 0) + + def supports_alter_constraint_enforced(self, constraint_type: Any = None) -> bool: + if constraint_type is None: + return False + from rhosocial.activerecord.backend.expression.statements import TableConstraintType + + normalized = normalize_table_constraint_type(constraint_type) + if normalized == TableConstraintType.FOREIGN_KEY: + return self.version >= (18, 0, 0) + if normalized == TableConstraintType.CHECK: + return self.version >= (19, 0, 0) + return False + + def supports_validate_constraint(self) -> bool: + return True + + def _require_constraint_enforced(self, constraint_type: Any) -> None: + if self.supports_constraint_enforced(constraint_type): + return + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + raise UnsupportedFeatureError( + self.name, + "ENFORCED/NOT ENFORCED constraint", + ) + + def _require_enforceable_constraint(self, constraint_type: Any) -> None: + if self.supports_alter_constraint_enforced(constraint_type): + return + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + raise UnsupportedFeatureError( + self.name, + "ALTER CONSTRAINT ENFORCED/NOT ENFORCED", + ) + def supports_constraint_novalidate(self) -> bool: """NOT VALID constraint option (PostgreSQL-proprietary). @@ -52,123 +117,155 @@ def supports_drop_constraint_if_exists(self) -> bool: """ return True + def format_table_constraint(self, expr: "TableConstraint") -> Tuple[str, tuple]: + from rhosocial.activerecord.backend.expression.statements import TableConstraintType + + constraint_type = normalize_table_constraint_type(expr.constraint_type) + if getattr(expr, "enforced", None) is not None: + self._require_constraint_enforced(constraint_type) + if constraint_type == TableConstraintType.EXCLUDE: + return self._format_exclude_constraint(expr) + return cast(Tuple[str, tuple], super().format_table_constraint(expr)) + 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, - ) + from rhosocial.activerecord.backend.expression.statements import TableConstraintType - # Handle EXCLUDE constraint (PG-specific, not in base class) - if action.constraint.constraint_type == TableConstraintType.EXCLUDE: - parts = [] + constraint_type = normalize_table_constraint_type(action.constraint.constraint_type) + if constraint_type == TableConstraintType.EXCLUDE: + if getattr(action.constraint, "enforced", None) is not None: + self._require_constraint_enforced(constraint_type) + validation_sql = self._format_constraint_validation(action.constraint) 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 + return f"ADD {exclude_sql}{validation_sql}", tuple(params) + return cast(Tuple[str, tuple], super().format_add_table_constraint_action(action)) + + def format_alter_constraint_action(self, action: "AlterConstraint") -> Tuple[str, tuple]: + constraint_type = normalize_table_constraint_type(action.constraint_type) + self._require_enforceable_constraint(constraint_type) + return cast(Tuple[str, tuple], super().format_alter_constraint_action(action)) + + def format_validate_constraint_action(self, action: "ValidateConstraint") -> Tuple[str, tuple]: + return cast(Tuple[str, tuple], super().format_validate_constraint_action(action)) + + @staticmethod + def _validate_exclude_expression_sql(expression_sql: str, context: str) -> None: + if not isinstance(expression_sql, str) or not expression_sql.strip(): + raise ValueError(f"{context} must render a non-empty SQL expression") + depth = 0 + quote: Any = None + index = 0 + while index < len(expression_sql): + character = expression_sql[index] + if quote is not None: + if character == quote: + if index + 1 < len(expression_sql) and expression_sql[index + 1] == quote: + index += 2 + continue + quote = None + elif character == "\\" and quote == "'": + raise ValueError(f"{context} contains an unsupported string escape") + index += 1 + continue + if character in {"'", '"'}: + quote = character + elif character == "(": + depth += 1 + elif character == ")": + depth -= 1 + if depth < 0: + raise ValueError(f"{context} has unbalanced parentheses") + elif character == ";": + raise ValueError(f"{context} must not contain statement separators") + elif character == "-" and expression_sql[index : index + 2] == "--": + raise ValueError(f"{context} must not contain SQL comments") + elif character == "/" and expression_sql[index : index + 2] == "/*": + raise ValueError(f"{context} must not contain SQL comments") + index += 1 + if quote is not None or depth != 0: + raise ValueError(f"{context} has unbalanced quotes or parentheses") 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" + valid_using = frozenset({"gist", "btree", "spgist", "hash", "brin"}) + using = getattr(constraint, "using", "gist") + if using is None: + using = "gist" + if not isinstance(using, str): + raise ValueError("EXCLUDE index access method must be a string") + using = using.strip().lower() if using not in valid_using: raise ValueError( - f"Invalid index access method '{using}': must be one of {valid_using}" + f"Invalid index access method '{using}': must be one of {sorted(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: + + elements = list(getattr(constraint, "elements", None) or []) + if not elements: + raise ValueError("EXCLUDE constraint requires at least one element") + + params: List[Any] = [] + rendered_elements: List[str] = [] + for element in elements: + if not isinstance(element, (tuple, list)) or len(element) != 2: + raise ValueError("each EXCLUDE element must be an expression/operator pair") + expression, operator = element + if not isinstance(operator, str): + raise ValueError("EXCLUDE operator must be a string") + operator = "".join(operator.strip().lower().split()) + if operator in {"is", "isnot"} or re.fullmatch( + r"[a-z0-9_+\-*/<>=~!@#%^&|?$]+", operator + ) is None: + raise ValueError(f"Invalid exclude operator '{operator}'") + if isinstance(expression, str): + if not expression.strip(): + raise ValueError("EXCLUDE element expression must not be empty") + if "(" in expression or ")" in expression: raise ValueError( - f"Invalid exclude operator '{op}': must be one of {valid_ops}" + "parenthesized EXCLUDE elements must be supplied as SQL expressions" ) - 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() + expression_sql = self.format_identifier(expression) + else: + to_sql = getattr(expression, "to_sql", None) + if not callable(to_sql): + raise TypeError("EXCLUDE element expression must be an identifier or SQL expression") + if getattr(expression, "dialect", None) is not self: + raise ValueError("EXCLUDE element expression must use the PostgreSQL dialect") + expression_sql, expression_params = to_sql() + if expression_params: + raise ValueError("EXCLUDE expression must not contain bind parameters") + self._validate_exclude_expression_sql(expression_sql, "EXCLUDE element expression") + expression_sql = f"({expression_sql})" + rendered_elements.append(f"{expression_sql} WITH {operator}") + + parts: List[str] = [] + if constraint.name: + parts.extend(("CONSTRAINT", self.format_identifier(constraint.name))) + parts.extend(("EXCLUDE", "USING", using, f"({', '.join(rendered_elements)})")) + + where_expr = getattr(constraint, "where", None) + if where_expr is not None: + where_to_sql = getattr(where_expr, "to_sql", None) + if not callable(where_to_sql): + raise TypeError("EXCLUDE where must be a SQL expression") + if getattr(where_expr, "dialect", None) is not self: + raise ValueError("EXCLUDE where expression must use the PostgreSQL dialect") + where_sql, where_params = where_to_sql() + if where_params: + raise ValueError("EXCLUDE where expression must not contain bind parameters") + self._validate_exclude_expression_sql(where_sql, "EXCLUDE where expression") params.extend(where_params) parts.append(f"WHERE ({where_sql})") - # DEFERRABLE / NOT DEFERRABLE - if constraint.deferrable is True: - if constraint.initially_deferred is True: + if getattr(constraint, "deferrable", None) is True: + if getattr(constraint, "initially_deferred", None) is True: parts.append("DEFERRABLE INITIALLY DEFERRED") - elif constraint.initially_deferred is False: + elif getattr(constraint, "initially_deferred", None) is False: parts.append("DEFERRABLE INITIALLY IMMEDIATE") else: parts.append("DEFERRABLE") - elif constraint.deferrable is False: + elif getattr(constraint, "deferrable", None) is False: parts.append("NOT DEFERRABLE") - return ' '.join(parts), tuple(params) + return " ".join(parts), tuple(params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py index aa5b32e1..4de201ef 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py @@ -1,156 +1,376 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py -"""PostgreSQL DOMAIN DDL implementation. +"""PostgreSQL DOMAIN DDL capabilities and formatting.""" -Implements CREATE/ALTER/DROP DOMAIN formatting for the postgres dialect. +from __future__ import annotations -Version Requirements: -- CREATE/ALTER/DROP DOMAIN: PostgreSQL 9.6+ -""" - -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Any, List, Optional, Tuple, Type, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from ...expression.ddl.domain import AlterDomainActionType +from rhosocial.activerecord.backend.dialect.mixins.ddl_domain import DomainMixin +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AddDomainCheckAction, + AlterDomainExpression, + CreateDomainExpression, + DomainAlterAction, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + DropDomainCheckAction, + DropDomainDefaultAction, + DropDomainExpression, + DropDomainNotNullAction, + RenameDomainAction, + SetDomainDefaultAction, + SetDomainNotNullAction, +) +from ...expression.ddl.domain import ( + _UnsupportedDomainAction, + PostgresAddDomainCheckAction, + PostgresChangeDomainOwnerAction, + PostgresCreateDomainExpression, + PostgresDropDomainCheckAction, + PostgresRenameDomainConstraintAction, + PostgresSetDomainSchemaAction, + PostgresValidateDomainConstraintAction, +) if TYPE_CHECKING: - from ...expression.ddl.domain import ( - PostgresAlterDomainExpression, - PostgresCreateDomainExpression, - PostgresDropDomainExpression, + from ...expression.ddl.domain import _LegacySqlExpression + + +__all__ = ["PostgresDomainMixin"] + + +class PostgresDomainMixin(DomainMixin): + """PostgreSQL DOMAIN DDL support.""" + + if TYPE_CHECKING: + name: str + version: Tuple[int, int, int] + + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: ... + + _POSTGRES_DOMAIN_ACTIONS = ( + SetDomainDefaultAction, + DropDomainDefaultAction, + SetDomainNotNullAction, + DropDomainNotNullAction, + AddDomainCheckAction, + DropDomainCheckAction, + RenameDomainAction, + PostgresAddDomainCheckAction, + PostgresDropDomainCheckAction, + PostgresRenameDomainConstraintAction, + PostgresValidateDomainConstraintAction, + PostgresChangeDomainOwnerAction, + PostgresSetDomainSchemaAction, ) + def _format_domain_identifier(self, name: str, schema: Optional[str] = None) -> str: + value = f"{schema}.{name}" if schema is not None else name + parts = value.split(".") + if not parts or any(not part.strip() for part in parts): + raise ValueError("PostgreSQL identifiers must contain non-empty segments") + return ".".join(self.format_identifier(part) for part in parts) -class PostgresDomainMixin: - """PostgreSQL DOMAIN implementation.""" + def _format_domain_behavior(self, cascade: bool, restrict: bool) -> str: + if cascade and restrict: + raise ValueError("CASCADE and RESTRICT are mutually exclusive") + if cascade: + return "CASCADE" + if restrict: + return "RESTRICT" + return "" - # ------------------------------------------------------------------ # - # Capability switches - # ------------------------------------------------------------------ # - def supports_create_domain(self) -> bool: - """CREATE/ALTER/DROP DOMAIN require PostgreSQL 9.6+.""" + def supports_domains(self) -> bool: return self.version >= (9, 6, 0) - # ------------------------------------------------------------------ # - # Helpers - # ------------------------------------------------------------------ # - def _format_domain_ref(self, schema: Optional[str], name: str) -> str: - """Format ``name`` (optionally schema-qualified) as identifier(s).""" - if schema: - return f"{self.format_identifier(schema)}.{self.format_identifier(name)}" - return self.format_identifier(name) - - # ------------------------------------------------------------------ # - # CREATE DOMAIN - # ------------------------------------------------------------------ # - def format_create_domain_statement( - self, expr: "PostgresCreateDomainExpression" - ) -> Tuple[str, tuple]: - """Format a CREATE DOMAIN statement (PostgreSQL-specific). + def supports_create_domain(self) -> bool: + return self.supports_domains() + + def supports_alter_domain(self) -> bool: + return self.supports_domains() + + def supports_drop_domain(self) -> bool: + return self.supports_domains() + + def supports_domain_default(self) -> bool: + return self.supports_domains() - Args: - expr: :class:`PostgresCreateDomainExpression`. + def supports_domain_nullability(self, nullability: DomainNullability) -> bool: + return self.supports_domains() and nullability in set(DomainNullability) - Returns: - Tuple of (SQL string, params tuple). + def supports_domain_checks(self) -> bool: + return self.supports_domains() - Raises: - UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + def supports_named_domain_checks(self) -> bool: + return self.supports_domains() - """ - if not self.supports_create_domain(): + def supports_multiple_domain_checks(self) -> bool: + return self.supports_domains() + + def supports_domain_collation(self) -> bool: + return self.supports_domains() + + def supports_alter_domain_action( + self, + action_type: Type[DomainAlterAction], + ) -> bool: + if not self.supports_domains(): + return False + try: + return issubclass(action_type, self._POSTGRES_DOMAIN_ACTIONS) + except TypeError: + return False + + def supports_multiple_domain_alter_actions(self) -> bool: + return False + + def supports_drop_domain_if_exists(self) -> bool: + return self.supports_domains() + + def supports_drop_domain_cascade(self) -> bool: + return self.supports_domains() + + def supports_drop_domain_restrict(self) -> bool: + return self.supports_domains() + + def supports_unnamed_domain_check_drop(self) -> bool: + return False + + def format_create_domain_statement( + self, + expr: CreateDomainExpression, + ) -> Tuple[str, tuple]: + if not self.supports_domains() or not self.supports_create_domain(): raise UnsupportedFeatureError( self.name, "CREATE DOMAIN", suggestion="requires PostgreSQL 9.6+", ) - - parts: List[str] = [ + type_sql, type_params = expr.data_type.to_sql() + if type_params: + raise ValueError("DOMAIN data types must render without bind parameters") + parts = [ "CREATE DOMAIN", - self._format_domain_ref(expr.schema, expr.name), + self._format_domain_identifier( + expr.domain_name, + getattr(expr, "schema_name", None), + ), "AS", - expr.data_type, + type_sql, ] - if expr.collation: - parts.append(f"COLLATE {expr.collation}") + params = list(type_params) + if expr.collation is not None: + if not self.supports_domain_collation(): + raise UnsupportedFeatureError(self.name, "DOMAIN COLLATE") + parts.append(f"COLLATE {self._format_domain_identifier(expr.collation)}") if expr.default is not None: - parts.append(f"DEFAULT {expr.default}") - for constraint in expr.constraints: - parts.append(constraint) - return " ".join(parts), () + if not self.supports_domain_default(): + raise UnsupportedFeatureError(self.name, "DOMAIN DEFAULT") + default_sql, default_params = expr.default.to_sql() + if default_params: + raise ValueError("DOMAIN DEFAULT must render without bind parameters") + parts.append(f"DEFAULT {default_sql}") + if isinstance(expr, PostgresCreateDomainExpression): + clauses: List[Any] = list(expr._constraint_clauses) + else: + clauses = list(expr.checks) + if clauses and not self.supports_domain_checks(): + raise UnsupportedFeatureError(self.name, "DOMAIN CHECK") + if any( + isinstance(clause, DomainCheckConstraint) and clause.name is not None + for clause in clauses + ) and not self.supports_named_domain_checks(): + raise UnsupportedFeatureError(self.name, "named DOMAIN CHECK") + if len(clauses) > 1 and not self.supports_multiple_domain_checks(): + raise UnsupportedFeatureError(self.name, "multiple domain CHECK constraints") + for clause in clauses: + clause_sql, clause_params = clause.to_sql() + parts.append(clause_sql) + params.extend(clause_params) + if expr.nullability is not DomainNullability.UNSPECIFIED: + if not self.supports_domain_nullability(expr.nullability): + raise UnsupportedFeatureError( + self.name, + f"DOMAIN {expr.nullability.value}", + ) + parts.append(expr.nullability.value) + return " ".join(parts), tuple(params) - # ------------------------------------------------------------------ # - # ALTER DOMAIN - # ------------------------------------------------------------------ # - def format_postgres_alter_domain_statement( - self, expr: "PostgresAlterDomainExpression" + def format_alter_domain_statement( + self, + expr: AlterDomainExpression, ) -> Tuple[str, tuple]: - """Format an ALTER DOMAIN statement (PostgreSQL-specific). - - Args: - expr: :class:`PostgresAlterDomainExpression`. - - Returns: - Tuple of (SQL string, params tuple). - - Raises: - UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. - - """ - if not self.supports_create_domain(): + if not self.supports_domains() or not self.supports_alter_domain(): raise UnsupportedFeatureError( self.name, "ALTER DOMAIN", suggestion="requires PostgreSQL 9.6+", ) + if len(expr.actions) > 1 and not self.supports_multiple_domain_alter_actions(): + raise UnsupportedFeatureError(self.name, "multiple ALTER DOMAIN actions") + action_parts: List[str] = [] + action_params: List[Any] = [] + for action in expr.actions: + if not isinstance(action, _UnsupportedDomainAction) and not self.supports_alter_domain_action( + type(action) + ): + raise UnsupportedFeatureError( + self.name, + f"ALTER DOMAIN action {action.action_kind}", + ) + action_sql, params = action.to_sql() + action_parts.append(action_sql) + action_params.extend(params) + domain_ref = self._format_domain_identifier( + expr.domain_name, + getattr(expr, "schema_name", None), + ) + return ( + f"ALTER DOMAIN {domain_ref} {', '.join(action_parts)}", + tuple(action_params), + ) - base = f"ALTER DOMAIN {self._format_domain_ref(expr.schema, expr.name)}" - action = expr.action - if action is AlterDomainActionType.SET_DEFAULT: - return f"{base} SET DEFAULT {expr.new_value}", () - if action is AlterDomainActionType.DROP_DEFAULT: - return f"{base} DROP DEFAULT", () - if action is AlterDomainActionType.RENAME_TO: - return ( - f"{base} RENAME TO {self.format_identifier(expr.new_name)}", - (), - ) - raise ValueError(f"Unsupported ALTER DOMAIN action: {action}") + def format_postgres_alter_domain_statement( + self, + expr: AlterDomainExpression, + ) -> Tuple[str, tuple]: + return self.format_alter_domain_statement(expr) - # ------------------------------------------------------------------ # - # DROP DOMAIN - # ------------------------------------------------------------------ # def format_drop_domain_statement( - self, expr: "PostgresDropDomainExpression" + self, + expr: DropDomainExpression, ) -> Tuple[str, tuple]: - """Format a DROP DOMAIN statement (PostgreSQL-specific). - - Args: - expr: :class:`PostgresDropDomainExpression`. - - Returns: - Tuple of (SQL string, empty params tuple). - - Raises: - UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. - ValueError: if both ``cascade`` and ``restrict`` are True. - - """ - if not self.supports_create_domain(): + if not self.supports_domains() or not self.supports_drop_domain(): raise UnsupportedFeatureError( self.name, "DROP DOMAIN", suggestion="requires PostgreSQL 9.6+", ) - if expr.cascade and expr.restrict: - raise ValueError( - "DROP DOMAIN: CASCADE and RESTRICT are mutually exclusive" - ) - parts: List[str] = ["DROP DOMAIN"] - if expr.if_exists: + if_exists = getattr(expr, "if_exists", False) + cascade = getattr(expr, "cascade", False) + restrict = getattr(expr, "restrict", False) + if if_exists and not self.supports_drop_domain_if_exists(): + raise UnsupportedFeatureError(self.name, "DROP DOMAIN IF EXISTS") + if cascade and not self.supports_drop_domain_cascade(): + raise UnsupportedFeatureError(self.name, "DROP DOMAIN CASCADE") + if restrict and not self.supports_drop_domain_restrict(): + raise UnsupportedFeatureError(self.name, "DROP DOMAIN RESTRICT") + behavior = self._format_domain_behavior(cascade, restrict) + parts = ["DROP DOMAIN"] + if if_exists: parts.append("IF EXISTS") - parts.append(self._format_domain_ref(expr.schema, expr.name)) - if expr.cascade: - parts.append("CASCADE") - elif expr.restrict: - parts.append("RESTRICT") - return " ".join(parts), () \ No newline at end of file + parts.append( + self._format_domain_identifier( + expr.domain_name, + getattr(expr, "schema_name", None), + ) + ) + if behavior: + parts.append(behavior) + return " ".join(parts), () + + def format_domain_value_expression( + self, + expr: DomainValueExpression, + ) -> Tuple[str, tuple]: + if not self.supports_domains(): + raise UnsupportedFeatureError(self.name, "DOMAIN VALUE") + return "VALUE", () + + def format_domain_check_constraint( + self, + expr: DomainCheckConstraint, + ) -> Tuple[str, tuple]: + return DomainMixin.format_domain_check_constraint(self, expr) + + def format_domain_alter_action( + self, + expr: DomainAlterAction, + ) -> Tuple[str, tuple]: + if not self.supports_domains() or not self.supports_alter_domain(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN action") + if not self.supports_alter_domain_action(type(expr)): + raise UnsupportedFeatureError( + self.name, + f"ALTER DOMAIN action {expr.action_kind}", + ) + if isinstance(expr, SetDomainDefaultAction): + if not self.supports_domain_default(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN SET DEFAULT") + value_sql, params = expr.default.to_sql() + if params: + raise ValueError("ALTER DOMAIN DEFAULT must render without bind parameters") + return f"SET DEFAULT {value_sql}", () + if isinstance(expr, DropDomainDefaultAction): + if not self.supports_domain_default(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN DROP DEFAULT") + return "DROP DEFAULT", () + if isinstance(expr, SetDomainNotNullAction): + if not self.supports_domain_nullability(expr.nullability): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN SET NOT NULL") + return "SET NOT NULL", () + if isinstance(expr, DropDomainNotNullAction): + if not self.supports_domain_nullability(expr.nullability): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN DROP NOT NULL") + return "DROP NOT NULL", () + if isinstance(expr, PostgresAddDomainCheckAction): + check_sql, params = expr.check.to_sql() + suffix = " NOT VALID" if expr.not_valid else "" + return f"ADD {check_sql}{suffix}", tuple(params) + if isinstance(expr, AddDomainCheckAction): + check_sql, params = expr.check.to_sql() + return f"ADD {check_sql}", tuple(params) + if isinstance(expr, PostgresDropDomainCheckAction): + if expr.if_exists and not self.supports_drop_domain_if_exists(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN DROP CONSTRAINT IF EXISTS") + if expr.cascade and not self.supports_drop_domain_cascade(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN DROP CONSTRAINT CASCADE") + if expr.restrict and not self.supports_drop_domain_restrict(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN DROP CONSTRAINT RESTRICT") + parts = ["DROP CONSTRAINT"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.name or "")) + behavior = self._format_domain_behavior(expr.cascade, expr.restrict) + if behavior: + parts.append(behavior) + return " ".join(parts), () + if isinstance(expr, DropDomainCheckAction): + if expr.name is None: + if not self.supports_unnamed_domain_check_drop(): + raise UnsupportedFeatureError(self.name, "unnamed DOMAIN CHECK drop") + return "DROP CONSTRAINT", () + if not self.supports_named_domain_checks(): + raise UnsupportedFeatureError(self.name, "named DOMAIN CHECK drop") + return f"DROP CONSTRAINT {self.format_identifier(expr.name)}", () + if isinstance(expr, PostgresRenameDomainConstraintAction): + return ( + f"RENAME CONSTRAINT {self.format_identifier(expr.name)} " + f"TO {self.format_identifier(expr.new_name)}" + ), () + if isinstance(expr, PostgresValidateDomainConstraintAction): + return f"VALIDATE CONSTRAINT {self.format_identifier(expr.name)}", () + if isinstance(expr, PostgresChangeDomainOwnerAction): + return f"OWNER TO {self.format_identifier(expr.new_owner)}", () + if isinstance(expr, PostgresSetDomainSchemaAction): + return f"SET SCHEMA {self.format_identifier(expr.new_schema)}", () + if isinstance(expr, RenameDomainAction): + return f"RENAME TO {self.format_identifier(expr.new_name)}", () + raise UnsupportedFeatureError( + self.name, + f"ALTER DOMAIN action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) + + def _format_postgres_legacy_sql_expression( + self, + expr: "_LegacySqlExpression", + ) -> Tuple[str, tuple]: + return expr.sql, () + + def _format_postgres_unsupported_domain_action( + self, + expr: "_UnsupportedDomainAction", + ) -> Tuple[str, tuple]: + raise ValueError(f"Unsupported ALTER DOMAIN action: {expr.action}") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py index fe60a722..afcc4d99 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py @@ -172,6 +172,10 @@ def supports_partial_index(self) -> bool: """PostgreSQL supports partial indexes with WHERE clause.""" return True + def supports_drop_index_on_table(self) -> bool: + """PostgreSQL drops indexes by name without an ON
clause.""" + return False + def supports_functional_index(self) -> bool: """PostgreSQL supports expression/functional indexes.""" return True @@ -328,11 +332,11 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ """Format CREATE INDEX statement with PostgreSQL-specific options. Extends the generic format_create_index_statement to support: - - Operator classes via dialect_options["opclasses"] + - Operator classes via the typed ``opclasses`` field - CONCURRENTLY clause - INCLUDE clause with version checks - - NULLS NOT DISTINCT via dialect_options["nulls_not_distinct"] - - WITH storage parameters via dialect_options["with"] + - NULLS NOT DISTINCT via the typed ``nulls_not_distinct`` field + - WITH storage parameters via the typed ``with_options`` field """ all_params = [] parts = ["CREATE"] @@ -357,7 +361,7 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ parts.append(f"USING {expr.index_type}") # Columns with optional operator classes - opclasses = expr.dialect_options.get("opclasses", {}) + opclasses = getattr(expr, "opclasses", None) or {} col_parts = [] for col in expr.columns: if isinstance(col, ToSQLProtocol): @@ -385,7 +389,7 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ parts.append(f"INCLUDE ({include_cols})") # NULLS NOT DISTINCT (PG 15+, requires UNIQUE) - if expr.dialect_options.get("nulls_not_distinct"): + if getattr(expr, "nulls_not_distinct", False): if not expr.unique: raise ValueError("NULLS NOT DISTINCT is only valid for UNIQUE indexes") if expr.concurrent and not self.supports_concurrent_unique_nulls_not_distinct(): @@ -396,8 +400,8 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ raise ValueError("NULLS NOT DISTINCT requires PostgreSQL 15+") parts.append("NULLS NOT DISTINCT") - # WITH options via dialect_options - with_options = expr.dialect_options.get("with") + # WITH options via typed fields + with_options = getattr(expr, "with_options", None) or {} if with_options: opts = ", ".join(f"{k} = {v}" for k, v in with_options.items()) parts.append(f"WITH ({opts})") @@ -416,11 +420,11 @@ def format_drop_index_statement(self, expr: "DropIndexExpression") -> Tuple[str, """Format DROP INDEX statement with PostgreSQL-specific options. Extends the generic format_drop_index_statement to support: - - CONCURRENTLY clause (PG 18+) via dialect_options["concurrent"] + - CONCURRENTLY clause (PG 18+) via the typed ``concurrent`` field """ parts = ["DROP INDEX"] - if expr.dialect_options.get("concurrent"): + if getattr(expr, "concurrent", False): if not self.supports_streaming_btree_index_build(): raise ValueError("DROP INDEX CONCURRENTLY requires PostgreSQL 18+") parts.append("CONCURRENTLY") 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 0dfa40d8..2d2e9edf 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py @@ -6,9 +6,13 @@ partitioning with support for various PostgreSQL versions. """ -from typing import Any, List, Tuple, TYPE_CHECKING # noqa: F401 +from copy import copy +from typing import Any, List, Optional, Tuple, TYPE_CHECKING # noqa: F401 from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import PartitionClause if TYPE_CHECKING: from ...expression.ddl import ( @@ -77,14 +81,12 @@ def supports_hash_table_partitioning(self) -> bool: return self.supports_hash_partitioning() def supports_subpartitioning(self) -> bool: - """Check if nested (sub)partitioning is exposed through this API. - - PostgreSQL supports sub-partitioning but this API does not expose it yet. + """Check if nested partitioning is supported. Returns: - Always False. + True if PostgreSQL version >= 10. """ - return False + return self.supports_table_partitioning() def supports_partition_metadata_introspection(self) -> bool: """Check if partition metadata introspection is available. @@ -165,6 +167,8 @@ def format_partition_clause(self, expr) -> Tuple[str, tuple]: "Declarative table partitioning requires PostgreSQL 10+.", ) + if expr.dialect is not self: + raise ValueError("partition clause must use the PostgreSQL dialect") method = expr.method.upper() if method == "RANGE": if not self.supports_range_table_partitioning(): @@ -187,13 +191,21 @@ def format_partition_clause(self, expr) -> Tuple[str, tuple]: ) else: raise ValueError(f"Invalid PostgreSQL partition method: {expr.method}") + if method == "LIST" and len(expr.keys) != 1: + raise ValueError( + "PostgreSQL LIST partitioning accepts exactly one key expression" + ) key_parts: List[str] = [] params: List[Any] = [] for key in expr.keys: - key_sql, key_params = key.to_sql() + if key.dialect is not self: + raise ValueError("partition key expressions must use the PostgreSQL dialect") + bound_key = self._inline_partition_expression(key) + key_sql, key_params = bound_key.to_sql() + if key_params: + raise ValueError("partition key expressions must not contain bind parameters") key_parts.append(key_sql) - params.extend(key_params) return f" PARTITION BY {method} ({', '.join(key_parts)})", tuple(params) def supports_hash_partitioning(self) -> bool: @@ -240,15 +252,12 @@ def supports_concurrent_detach(self) -> bool: return self.version >= (14, 0, 0) def supports_concurrent_attach(self) -> bool: - """Check if CONCURRENTLY ATTACH is supported. - - Native feature, PostgreSQL 14+. Enables non-blocking partition - attachment without blocking concurrent queries. + """Check if CONCURRENTLY ATTACH is exposed by this implementation. Returns: - True if PostgreSQL version >= 14. + Always False. """ - return self.version >= (14, 0, 0) + return False def supports_partition_bounds_expression(self) -> bool: """Check if partition bounds expressions are supported. @@ -283,38 +292,95 @@ def supports_partitionwise_aggregate(self) -> bool: """ return self.version >= (11, 0, 0) - def format_partition_value(self, expr: "PartitionValue") -> Tuple[str, tuple]: - """Format a partition bound value from expression. - - Public method implementing the PartitionValue expression's - SQL generation, following the Expression-Dialect-Protocol pattern. - - Value handling: - - None → 'NULL' - - String 'MAXVALUE', 'MINVALUE', or 'DEFAULT' (case-insensitive) → as-is - - String/date/datetime/Decimal/numeric values → whitelist-formatted SQL literal - - Arbitrary objects are rejected by PartitionValue before formatting + def _inline_partition_expression( + self, + value: Any, + memo: Optional[dict] = None, + ) -> Any: + if memo is None: + memo = {} + identity = id(value) + if identity in memo: + return memo[identity] + if isinstance(value, Literal): + bound = copy(value) + bound.dialect = self + bound.inline_literals = True + memo[identity] = bound + return bound + if isinstance(value, BaseExpression): + bound = copy(value) + bound.dialect = self + memo[identity] = bound + for name, child in vars(value).items(): + if name == "_dialect": + continue + setattr(bound, name, self._inline_partition_expression(child, memo)) + return bound + if isinstance(value, list): + bound_list = [self._inline_partition_expression(child, memo) for child in value] + memo[identity] = bound_list + return bound_list + if isinstance(value, tuple): + bound_tuple = tuple(self._inline_partition_expression(child, memo) for child in value) + memo[identity] = bound_tuple + return bound_tuple + if isinstance(value, dict): + bound_dict = { + key: self._inline_partition_expression(child, memo) + for key, child in value.items() + } + memo[identity] = bound_dict + return bound_dict + return value - Args: - expr: PartitionValue with the bound value. + def format_partition_value(self, expr: "PartitionValue") -> Tuple[str, tuple]: + """Format a PostgreSQL partition bound value.""" + if not self.supports_table_partitioning(): + raise UnsupportedFeatureError( + self.name, + "partition boundary value", + "Declarative table partitioning requires PostgreSQL 10+.", + ) - Returns: - Tuple of (SQL string, empty params tuple). - """ from datetime import date, datetime from decimal import Decimal from math import isfinite value = expr.value + if isinstance(value, BaseExpression): + if not self.supports_partition_bounds_expression(): + raise UnsupportedFeatureError( + self.name, + "partition bound expressions", + "Partition bound expressions require PostgreSQL 12+.", + ) + bound_value = self._inline_partition_expression(value) + value_sql, value_params = bound_value.to_sql() + if value_params: + raise ValueError("partition bound expressions must not contain bind parameters") + return value_sql, () if value is None: return "NULL", () if isinstance(value, bool): raise TypeError("partition value must not be bool") if isinstance(value, str): - upper_val = value.upper() - if upper_val in {"MAXVALUE", "MINVALUE", "DEFAULT"}: - return upper_val, () - return f"'{value.replace(chr(39), chr(39)+chr(39))}'", () + upper_value = value.upper() + if expr.partition_type in {None, "RANGE"} and upper_value in { + "MINVALUE", + "MAXVALUE", + }: + return upper_value, () + if expr.partition_type is None and upper_value == "DEFAULT": + if not self.supports_default_partition(): + raise UnsupportedFeatureError( + self.name, + "DEFAULT partition boundary", + "DEFAULT partitions require PostgreSQL 11+.", + ) + return "DEFAULT", () + escaped = value.replace("'", "''") + return f"'{escaped}'", () if isinstance(value, int): return str(value), () if isinstance(value, float): @@ -330,107 +396,166 @@ def format_partition_value(self, expr: "PartitionValue") -> Tuple[str, tuple]: if isinstance(value, date): return f"'{value.isoformat()}'", () raise TypeError( - "partition value must be str, int, float, Decimal, " - f"date, datetime, or None, got {type(value).__name__}" + "partition value must be a supported expression, str, int, float, " + f"Decimal, date, datetime, or None, got {type(value).__name__}" ) - def format_create_partition_statement(self, expr: "PostgresCreatePartitionExpression") -> Tuple[str, tuple]: - """Format CREATE TABLE ... PARTITION OF statement from expression. - - Supported partition types are ``RANGE``, ``LIST``, and ``HASH``. - - - ``expr.partition_type`` — partition method (``RANGE`` / ``LIST`` / ``HASH``). - - ``expr.if_not_exists`` — add ``IF NOT EXISTS``. - - ``expr.schema`` — optional schema qualifier. - - ``expr.partition_name`` — new partition name. - - ``expr.parent_table`` — parent partitioned table. - - ``expr.partition_values`` — bound values (see below). - - ``expr.tablespace`` — optional tablespace. - - ``partition_values`` dict format: - - - RANGE: ``{"from": ..., "to": ...}`` or ``{"default": True}``. - - LIST: ``{"values": [..., ...]}`` or ``{"default": True}``. - - HASH: ``{"modulus": N, "remainder": M}``. - - Args: - expr: PostgresCreatePartitionExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) + def _format_list_partition_values(self, values: Any) -> Tuple[List[str], List[Any]]: + if not isinstance(values, (list, tuple)) or not values: + raise ValueError("LIST partition requires a non-empty 'values' list") + if any(isinstance(value, (list, tuple)) for value in values): + raise ValueError( + "PostgreSQL LIST partitioning accepts exactly one bound expression per value; " + "nested rows are not supported" + ) - Raises: - ValueError: If partition_type is invalid or required bound values are missing. + from ...expression.ddl import PartitionValue - """ + value_sql_parts = [] + params: List[Any] = [] + for value in values: + value_sql, value_params = PartitionValue( + self, + value, + partition_type="LIST", + ).to_sql() + value_sql_parts.append(value_sql) + params.extend(value_params) + return value_sql_parts, params + + def format_create_partition_statement( + self, + expr: "PostgresCreatePartitionExpression", + ) -> Tuple[str, tuple]: + """Format CREATE TABLE ... PARTITION OF for PostgreSQL.""" + if not self.supports_table_partitioning(): + raise UnsupportedFeatureError( + self.name, + "CREATE TABLE ... PARTITION OF", + "Declarative table partitioning requires PostgreSQL 10+.", + ) + if not isinstance(expr.partition_type, str): + raise TypeError("partition_type must be a string") partition_type = expr.partition_type.upper() - if partition_type not in ("RANGE", "LIST", "HASH"): + if partition_type not in {"RANGE", "LIST", "HASH"}: raise ValueError(f"Invalid partition_type: {partition_type}") + default_marker = expr.partition_values.get("default", False) + if not isinstance(default_marker, bool): + raise TypeError("partition_values['default'] must be a bool") + if default_marker: + if partition_type == "HASH": + raise ValueError("HASH partitions cannot use DEFAULT") + if not self.supports_default_partition(): + raise UnsupportedFeatureError( + self.name, + "DEFAULT partition", + "DEFAULT partitions require PostgreSQL 11+.", + ) + parts = ["CREATE TABLE"] if expr.if_not_exists: parts.append("IF NOT EXISTS") - - # Partition name with optional schema if expr.schema: - parts.append(f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.partition_name)}") + child_name = ( + f"{self.format_identifier(expr.schema)}." + f"{self.format_identifier(expr.partition_name)}" + ) else: - parts.append(self.format_identifier(expr.partition_name)) - - # PARTITION OF parent - if expr.schema: - parts.append( - f"PARTITION OF {self.format_identifier(expr.schema)}.{self.format_identifier(expr.parent_table)}" + child_name = self.format_identifier(expr.partition_name) + parts.append(child_name) + + parent_schema = expr.parent_schema if expr.parent_schema is not None else expr.schema + if parent_schema: + parent_name = ( + f"{self.format_identifier(parent_schema)}." + f"{self.format_identifier(expr.parent_table)}" ) else: - parts.append(f"PARTITION OF {self.format_identifier(expr.parent_table)}") - - if partition_type == "RANGE": - if "default" in expr.partition_values and expr.partition_values["default"]: - parts.append("DEFAULT") - else: - parts.append("FOR VALUES") - from_val = expr.partition_values.get("from") - to_val = expr.partition_values.get("to") - if from_val is None or to_val is None: - raise ValueError("RANGE partition requires 'from' and 'to' values") - from ...expression.ddl import PartitionValue - from_sql, _ = PartitionValue(dialect=self, value=from_val).to_sql() - to_sql, _ = PartitionValue(dialect=self, value=to_val).to_sql() - parts.append(f"FROM ({from_sql}) TO ({to_sql})") + parent_name = self.format_identifier(expr.parent_table) + parts.append(f"PARTITION OF {parent_name}") + + params: List[Any] = [] + from ...expression.ddl import PartitionValue + if default_marker: + parts.append("DEFAULT") + elif partition_type == "RANGE": + from_value = expr.partition_values.get("from") + to_value = expr.partition_values.get("to") + if from_value is None or to_value is None: + raise ValueError("RANGE partition requires 'from' and 'to' values") + from_values = list(from_value) if isinstance(from_value, (list, tuple)) else [from_value] + to_values = list(to_value) if isinstance(to_value, (list, tuple)) else [to_value] + if not from_values or not to_values: + raise ValueError("RANGE partition boundaries must not be empty") + if len(from_values) != len(to_values): + raise ValueError("RANGE partition boundaries must have the same column count") + from_sql_parts = [] + to_sql_parts = [] + for value in from_values: + value_sql, value_params = PartitionValue( + self, + value, + partition_type="RANGE", + ).to_sql() + from_sql_parts.append(value_sql) + params.extend(value_params) + for value in to_values: + value_sql, value_params = PartitionValue( + self, + value, + partition_type="RANGE", + ).to_sql() + to_sql_parts.append(value_sql) + params.extend(value_params) + parts.append( + f"FOR VALUES FROM ({', '.join(from_sql_parts)}) " + f"TO ({', '.join(to_sql_parts)})" + ) elif partition_type == "LIST": - if "default" in expr.partition_values and expr.partition_values["default"]: - parts.append("DEFAULT") - else: - parts.append("FOR VALUES") - values = expr.partition_values.get("values", []) - if not values: - raise ValueError("LIST partition requires 'values' list") - from ...expression.ddl import PartitionValue - vals_str = ", ".join( - PartitionValue(dialect=self, value=v).to_sql()[0] - for v in values + value_sql_parts, value_params = self._format_list_partition_values( + expr.partition_values.get("values") + ) + params.extend(value_params) + parts.append(f"FOR VALUES IN ({', '.join(value_sql_parts)})") + else: + if not self.supports_hash_partitioning(): + raise UnsupportedFeatureError( + self.name, + "HASH partitioning", + "HASH partitioning requires PostgreSQL 11+.", ) - parts.append(f"IN ({vals_str})") - - elif partition_type == "HASH": - parts.append("FOR VALUES") modulus = expr.partition_values.get("modulus") remainder = expr.partition_values.get("remainder") if modulus is None or remainder is None: raise ValueError("HASH partition requires 'modulus' and 'remainder'") - if not self.supports_hash_partitioning(): - raise ValueError("HASH partitioning requires PostgreSQL 11+") - parts.append(f"WITH (MODULUS {modulus}, REMAINDER {remainder})") - - # TABLESPACE + if isinstance(modulus, bool) or not isinstance(modulus, int): + raise TypeError("HASH modulus must be an int") + if modulus <= 0: + raise ValueError("HASH modulus must be a positive integer") + if isinstance(remainder, bool) or not isinstance(remainder, int): + raise TypeError("HASH remainder must be an int") + if not 0 <= remainder < modulus: + raise ValueError("HASH remainder must satisfy 0 <= remainder < modulus") + parts.append(f"FOR VALUES WITH (MODULUS {modulus}, REMAINDER {remainder})") + + if expr.partition_clause is not None: + if not isinstance(expr.partition_clause, PartitionClause): + raise TypeError("partition_clause must be a PartitionClause") + clause_sql, clause_params = expr.partition_clause.to_sql() + if clause_params: + raise ValueError("partition clause must not contain bind parameters") + parts.append(clause_sql.strip()) if expr.tablespace: parts.append(f"TABLESPACE {self.format_identifier(expr.tablespace)}") - return (" ".join(parts), ()) + return " ".join(parts), tuple(params) - def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpression") -> Tuple[str, tuple]: + def format_detach_partition_statement( + self, + expr: "PostgresDetachPartitionExpression", + ) -> Tuple[str, tuple]: """Format ALTER TABLE ... DETACH PARTITION statement from expression. - ``expr.parent_table`` — partitioned table name. @@ -446,10 +571,21 @@ def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpres Tuple of (SQL string, empty params tuple) """ + if not self.supports_table_partitioning(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE ... DETACH PARTITION", + "Declarative table partitioning requires PostgreSQL 10+.", + ) + parts = ["ALTER TABLE"] - if expr.schema: - parts.append(f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.parent_table)}") + parent_schema = expr.parent_schema if expr.parent_schema is not None else expr.schema + if parent_schema: + parts.append( + f"{self.format_identifier(parent_schema)}." + f"{self.format_identifier(expr.parent_table)}" + ) else: parts.append(self.format_identifier(expr.parent_table)) @@ -472,88 +608,127 @@ def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpres return (" ".join(parts), ()) - def format_attach_partition_statement(self, expr: "PostgresAttachPartitionExpression") -> Tuple[str, tuple]: - """Format ALTER TABLE ... ATTACH PARTITION statement from expression. - - - ``expr.parent_table`` — partitioned table name. - - ``expr.schema`` — optional schema qualifier. - - ``expr.partition_name`` — partition to attach. - - ``expr.partition_type`` — partition method (``RANGE`` / ``LIST`` / ``HASH``). - - ``expr.partition_values`` — bound values (see ``format_create_partition_statement``). - - ``expr.concurrently`` — add ``CONCURRENTLY`` for non-blocking attach (PG 14+). - - Args: - expr: PostgresAttachPartitionExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) + def format_attach_partition_statement( + self, + expr: "PostgresAttachPartitionExpression", + ) -> Tuple[str, tuple]: + """Format ALTER TABLE ... ATTACH PARTITION for PostgreSQL.""" + if not self.supports_table_partitioning(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE ... ATTACH PARTITION", + "Declarative table partitioning requires PostgreSQL 10+.", + ) + if expr.concurrently: + raise UnsupportedFeatureError( + self.name, + "ATTACH PARTITION CONCURRENTLY", + "Use non-concurrent ATTACH PARTITION.", + ) + if not isinstance(expr.partition_type, str): + raise TypeError("partition_type must be a string") + partition_type = expr.partition_type.upper() + if partition_type not in {"RANGE", "LIST", "HASH"}: + raise ValueError(f"Invalid partition_type: {partition_type}") - Raises: - ValueError: If partition_type is invalid or required bound values are missing. - ValueError: If concurrently is used on PostgreSQL < 14. + default_marker = expr.partition_values.get("default", False) + if not isinstance(default_marker, bool): + raise TypeError("partition_values['default'] must be a bool") + if default_marker: + if partition_type == "HASH": + raise ValueError("HASH partitions cannot use DEFAULT") + if not self.supports_default_partition(): + raise UnsupportedFeatureError( + self.name, + "DEFAULT partition", + "DEFAULT partitions require PostgreSQL 11+.", + ) - """ parts = ["ALTER TABLE"] - - if expr.schema: - parts.append(f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.parent_table)}") + parent_schema = expr.parent_schema if expr.parent_schema is not None else expr.schema + if parent_schema: + parent_name = ( + f"{self.format_identifier(parent_schema)}." + f"{self.format_identifier(expr.parent_table)}" + ) else: - parts.append(self.format_identifier(expr.parent_table)) - - parts.append("ATTACH PARTITION") - + parent_name = self.format_identifier(expr.parent_table) if expr.schema: - parts.append(f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.partition_name)}") + partition_name = ( + f"{self.format_identifier(expr.schema)}." + f"{self.format_identifier(expr.partition_name)}" + ) else: - parts.append(self.format_identifier(expr.partition_name)) + partition_name = self.format_identifier(expr.partition_name) + parts.extend((parent_name, "ATTACH PARTITION", partition_name)) - partition_type = expr.partition_type.upper() - if partition_type not in ("RANGE", "LIST", "HASH"): - raise ValueError(f"Invalid partition_type: {partition_type}") + params: List[Any] = [] + from ...expression.ddl import PartitionValue - # FOR VALUES / DEFAULT clause (same as create partition) - if "default" in expr.partition_values and expr.partition_values["default"]: + if default_marker: parts.append("DEFAULT") + elif partition_type == "RANGE": + from_value = expr.partition_values.get("from") + to_value = expr.partition_values.get("to") + if from_value is None or to_value is None: + raise ValueError("RANGE partition requires 'from' and 'to' values") + from_values = list(from_value) if isinstance(from_value, (list, tuple)) else [from_value] + to_values = list(to_value) if isinstance(to_value, (list, tuple)) else [to_value] + if not from_values or not to_values: + raise ValueError("RANGE partition boundaries must not be empty") + if len(from_values) != len(to_values): + raise ValueError("RANGE partition boundaries must have the same column count") + from_sql_parts = [] + to_sql_parts = [] + for value in from_values: + value_sql, value_params = PartitionValue( + self, + value, + partition_type="RANGE", + ).to_sql() + from_sql_parts.append(value_sql) + params.extend(value_params) + for value in to_values: + value_sql, value_params = PartitionValue( + self, + value, + partition_type="RANGE", + ).to_sql() + to_sql_parts.append(value_sql) + params.extend(value_params) + parts.append( + f"FOR VALUES FROM ({', '.join(from_sql_parts)}) " + f"TO ({', '.join(to_sql_parts)})" + ) + elif partition_type == "LIST": + value_sql_parts, value_params = self._format_list_partition_values( + expr.partition_values.get("values") + ) + params.extend(value_params) + parts.append(f"FOR VALUES IN ({', '.join(value_sql_parts)})") else: - parts.append("FOR VALUES") - - if partition_type == "RANGE": - from_val = expr.partition_values.get("from") - to_val = expr.partition_values.get("to") - if from_val is None or to_val is None: - raise ValueError("RANGE partition requires 'from' and 'to' values") - from ...expression.ddl import PartitionValue - from_sql, _ = PartitionValue(dialect=self, value=from_val).to_sql() - to_sql, _ = PartitionValue(dialect=self, value=to_val).to_sql() - parts.append(f"FROM ({from_sql}) TO ({to_sql})") - - elif partition_type == "LIST": - values = expr.partition_values.get("values", []) - if not values: - raise ValueError("LIST partition requires 'values' list") - from ...expression.ddl import PartitionValue - vals_str = ", ".join( - PartitionValue(dialect=self, value=v).to_sql()[0] - for v in values + if not self.supports_hash_partitioning(): + raise UnsupportedFeatureError( + self.name, + "HASH partitioning", + "HASH partitioning requires PostgreSQL 11+.", ) - parts.append(f"IN ({vals_str})") - - elif partition_type == "HASH": - modulus = expr.partition_values.get("modulus") - remainder = expr.partition_values.get("remainder") - if modulus is None or remainder is None: - raise ValueError("HASH partition requires 'modulus' and 'remainder'") - if not self.supports_hash_partitioning(): - raise ValueError("HASH partitioning requires PostgreSQL 11+") - parts.append(f"WITH (MODULUS {modulus}, REMAINDER {remainder})") - - # CONCURRENTLY (PG 14+) - if expr.concurrently: - if not self.supports_concurrent_attach(): - raise ValueError("ATTACH CONCURRENTLY requires PostgreSQL 14+") - parts.append("CONCURRENTLY") + modulus = expr.partition_values.get("modulus") + remainder = expr.partition_values.get("remainder") + if modulus is None or remainder is None: + raise ValueError("HASH partition requires 'modulus' and 'remainder'") + if isinstance(modulus, bool) or not isinstance(modulus, int): + raise TypeError("HASH modulus must be an int") + if modulus <= 0: + raise ValueError("HASH modulus must be a positive integer") + if isinstance(remainder, bool) or not isinstance(remainder, int): + raise TypeError("HASH remainder must be an int") + if not 0 <= remainder < modulus: + raise ValueError("HASH remainder must satisfy 0 <= remainder < modulus") + parts.append(f"FOR VALUES WITH (MODULUS {modulus}, REMAINDER {remainder})") + + return " ".join(parts), tuple(params) - return (" ".join(parts), ()) def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpression") -> Tuple[str, tuple]: """Format pg_catalog query for partition metadata introspection. @@ -579,9 +754,7 @@ def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpres Raises: UnsupportedFeatureError: If PostgreSQL version < 10. """ - from typing import List - - if not self.supports_partition_metadata_introspection(): + if not self.supports_table_partitioning(): raise UnsupportedFeatureError( self.name, "partition metadata introspection", @@ -601,9 +774,15 @@ def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpres pg_get_expr(child.relpartbound, child.oid) AS bound FROM pg_class parent JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace + LEFT JOIN pg_partitioned_table partitioned_parent + ON partitioned_parent.partrelid = parent.oid LEFT JOIN pg_inherits i ON i.inhparent = parent.oid LEFT JOIN pg_class child ON child.oid = i.inhrelid WHERE parent.relname = {self.p()}{schema_filter} + AND ( + parent.relkind = 'p' + OR partitioned_parent.partrelid IS NOT NULL + ) ORDER BY child.relname """ else: @@ -613,6 +792,12 @@ def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpres NULL::text AS bound FROM pg_class parent JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace + LEFT JOIN pg_partitioned_table partitioned_parent + ON partitioned_parent.partrelid = parent.oid WHERE parent.relname = {self.p()}{schema_filter} + AND ( + parent.relkind = 'p' + OR partitioned_parent.partrelid IS NOT NULL + ) """ return sql, tuple(params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/repack.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/repack.py new file mode 100644 index 00000000..e10c7ffa --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/repack.py @@ -0,0 +1,125 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/repack.py +"""PostgreSQL 19 native REPACK formatting and validation.""" + +from typing import List, Optional, Sequence, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.repack import PostgresRepackExpression + + +class PostgresRepackMixin: + """PostgreSQL native REPACK support, separate from the pg_repack extension.""" + + def supports_repack(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_repack_all_tables(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_repack_using_index(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_repack_concurrently(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_repack_analyze(self) -> bool: + return self.version >= (19, 0, 0) + + @staticmethod + def _repack_identifier(value: str, name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + if not value or "\x00" in value: + raise ValueError(f"{name} must be a non-empty identifier without NUL") + return value + + def _format_repack_identifier(self, value: str, name: str) -> str: + return self.format_identifier(self._repack_identifier(value, name)) + + @staticmethod + def _repack_columns(value: Optional[Sequence[str]]) -> Optional[List[str]]: + if value is None: + return None + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise TypeError("REPACK columns must be a sequence of identifiers") + columns = list(value) + if not columns: + raise ValueError("REPACK columns must not be empty") + if any(not isinstance(column, str) or not column or "\x00" in column for column in columns): + raise ValueError("REPACK columns contain an invalid identifier") + if len(columns) != len(set(columns)): + raise ValueError("REPACK columns must not contain duplicates") + return columns + + def format_repack_statement( + self, + expr: "PostgresRepackExpression", + ) -> Tuple[str, tuple]: + """Format a native PostgreSQL 19 REPACK statement.""" + if not self.supports_repack(): + raise UnsupportedFeatureError( + self.name, + "REPACK", + suggestion="requires PostgreSQL 19+", + ) + for name, value in ( + ("verbose", expr.verbose), + ("analyze", expr.analyze), + ("concurrently", expr.concurrently), + ): + if not isinstance(value, bool): + raise TypeError(f"{name} must be a bool") + columns = self._repack_columns(expr.columns) + if columns is not None and not expr.analyze: + raise ValueError("REPACK columns require ANALYZE") + if expr.analyze and expr.table_name is None: + raise ValueError("REPACK ANALYZE requires one table") + if expr.schema is not None and expr.table_name is None: + raise ValueError("REPACK schema requires one table") + if not isinstance(expr.all_using_index, bool): + raise TypeError("all_using_index must be a bool") + using_index = expr.using_index + if using_index is not None and not isinstance(using_index, (bool, str)): + raise TypeError("using_index must be a bool, string, or None") + if isinstance(using_index, str): + if not using_index: + raise ValueError("using_index must not be empty") + if "\x00" in using_index: + raise ValueError("using_index contains NUL") + if expr.table_name is None: + raise ValueError("a named REPACK index requires one table") + if expr.all_using_index and expr.table_name is not None: + raise ValueError("all_using_index cannot be combined with a table") + if expr.all_using_index and using_index not in {None, False, True}: + raise ValueError("all_using_index conflicts with using_index") + if expr.table_name is None and expr.concurrently: + raise ValueError("REPACK CONCURRENTLY requires one table") + parts = ["REPACK"] + options = [] + if expr.verbose: + options.append("VERBOSE") + if expr.analyze: + options.append("ANALYZE") + if expr.concurrently: + options.append("CONCURRENTLY") + if options: + parts.append("({})".format(", ".join(options))) + use_index = expr.all_using_index or using_index is True + if expr.table_name is not None: + table = self._format_repack_identifier(expr.table_name, "table_name") + if expr.schema is not None: + table = f"{self._format_repack_identifier(expr.schema, 'schema')}.{table}" + parts.append(table) + if columns is not None: + parts.append( + "({})".format(", ".join(self._format_repack_identifier(column, "column") for column in columns)) + ) + if use_index or isinstance(using_index, str): + parts.append("USING INDEX") + elif use_index: + parts.append("USING INDEX") + if isinstance(using_index, str): + parts.append(self._format_repack_identifier(using_index, "using_index")) + return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/type.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/type.py index 8d8c756c..79a3c2ba 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/type.py @@ -1,90 +1,551 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/type.py -from typing import Optional, Tuple, List +"""PostgreSQL user-defined TYPE DDL capabilities and formatting.""" +from __future__ import annotations -class PostgresTypeMixin: - """PostgreSQL type DDL implementation. +from typing import List, Optional, Sequence, Tuple, Type, Union, cast, TYPE_CHECKING - PostgreSQL supports user-defined types, particularly ENUM types. - """ +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins.user_defined_type import UserDefinedTypeMixin +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) +from rhosocial.activerecord.backend.expression.types import DataType +from ...expression.ddl.type import ( + PostgresAddEnumValueAction, + PostgresAddTypeAttributeAction, + PostgresAlterTypeAttributeAction, + PostgresBaseTypeDefinition, + PostgresChangeTypeOwnerAction, + PostgresCompositeTypeDefinition, + PostgresDropTypeAttributeAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresRenameEnumValueAction, + PostgresRenameTypeAction, + PostgresRenameTypeAttributeAction, + PostgresSetTypePropertiesAction, + PostgresSetTypeSchemaAction, + PostgresShellTypeDefinition, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + from ...expression.ddl.type import PostgresCompositeTypeAttribute + + +__all__ = ["PostgresTypeMixin"] + + +class PostgresTypeMixin(UserDefinedTypeMixin): + """PostgreSQL user-defined TYPE DDL support.""" + + if TYPE_CHECKING: + name: str + version: Tuple[int, int, int] + + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: ... + + def inline_sql_literal(self, value: object) -> str: ... + + def _as_dialect(self) -> "SQLDialectBase": + return cast("SQLDialectBase", self) + + _POSTGRES_TYPE_DEFINITIONS = ( + PostgresCompositeTypeDefinition, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresBaseTypeDefinition, + PostgresShellTypeDefinition, + ) + _POSTGRES_TYPE_ACTIONS = ( + PostgresRenameTypeAction, + PostgresSetTypeSchemaAction, + PostgresChangeTypeOwnerAction, + PostgresRenameTypeAttributeAction, + PostgresAddTypeAttributeAction, + PostgresDropTypeAttributeAction, + PostgresAlterTypeAttributeAction, + PostgresAddEnumValueAction, + PostgresRenameEnumValueAction, + PostgresSetTypePropertiesAction, + ) + _COMBINABLE_TYPE_ACTIONS = ( + PostgresAddTypeAttributeAction, + PostgresDropTypeAttributeAction, + PostgresAlterTypeAttributeAction, + ) + + def _format_qualified_identifier(self, name: str, schema: Optional[str] = None) -> str: + value = f"{schema}.{name}" if schema is not None else name + parts = value.split(".") + if not parts or any(not part.strip() for part in parts): + raise ValueError("PostgreSQL identifiers must contain non-empty segments") + return ".".join(self.format_identifier(part) for part in parts) + + def _format_type_data_type(self, data_type: DataType) -> str: + if not isinstance(data_type, DataType): + raise TypeError( + f"data_type must be a DataType instance, got {type(data_type).__name__}" + ) + sql, params = data_type.to_sql() + if params: + raise ValueError("TYPE data types must render without bind parameters") + return sql + + def _format_type_behavior(self, cascade: bool, restrict: bool) -> str: + if cascade and restrict: + raise ValueError("CASCADE and RESTRICT are mutually exclusive") + if cascade: + return "CASCADE" + if restrict: + return "RESTRICT" + return "" + + def _format_postgres_composite_attribute( + self, + expr: "PostgresCompositeTypeAttribute", + ) -> str: + parts = [ + self.format_identifier(expr.name), + self._format_type_data_type(expr.data_type), + ] + if expr.collation is not None: + parts.append(f"COLLATE {self._format_qualified_identifier(expr.collation)}") + return " ".join(parts) + + def _format_postgres_enum_value(self, value: str) -> str: + if not isinstance(value, str): + raise TypeError("ENUM labels must be strings") + return self.inline_sql_literal(value) + + def _format_postgres_base_type_definition( + self, + expr: PostgresBaseTypeDefinition, + ) -> str: + if expr.subscript_function is not None and self.version < (14, 0, 0): + raise UnsupportedFeatureError( + self.name, + "CREATE TYPE base SUBSCRIPT property", + suggestion="requires PostgreSQL 14+", + ) + clauses: List[str] = [] + function_values = ( + ("INPUT", expr.input_function), + ("OUTPUT", expr.output_function), + ("RECEIVE", expr.receive_function), + ("SEND", expr.send_function), + ("TYPMOD_IN", expr.type_modifier_input_function), + ("TYPMOD_OUT", expr.type_modifier_output_function), + ("ANALYZE", expr.analyze_function), + ("SUBSCRIPT", expr.subscript_function), + ) + for keyword, function_name in function_values: + if function_name is not None: + clauses.append(f"{keyword} = {self._format_qualified_identifier(function_name)}") + if expr.internallength is not None: + value = "VARIABLE" if isinstance(expr.internallength, str) else str(expr.internallength) + clauses.append(f"INTERNALLENGTH = {value}") + if expr.passedbyvalue: + clauses.append("PASSEDBYVALUE") + if expr.alignment is not None: + clauses.append(f"ALIGNMENT = {self.format_identifier(expr.alignment)}") + if expr.storage is not None: + clauses.append(f"STORAGE = {self.format_identifier(expr.storage)}") + if expr.like_type is not None: + clauses.append(f"LIKE = {self._format_type_data_type(expr.like_type)}") + if expr.category is not None: + clauses.append(f"CATEGORY = {self.format_identifier(expr.category)}") + if expr.preferred is not None: + clauses.append(f"PREFERRED = {'TRUE' if expr.preferred else 'FALSE'}") + if expr.has_default: + clauses.append(f"DEFAULT = {self.inline_sql_literal(expr.default)}") + if expr.element is not None: + clauses.append(f"ELEMENT = {self._format_type_data_type(expr.element)}") + if expr.delimiter is not None: + clauses.append(f"DELIMITER = {self.inline_sql_literal(expr.delimiter)}") + if expr.collatable is not None: + clauses.append(f"COLLATABLE = {'TRUE' if expr.collatable else 'FALSE'}") + return f"({', '.join(clauses)})" + + def supports_type_objects(self) -> bool: + return self.version >= (9, 6, 0) def supports_create_type(self) -> bool: - """CREATE TYPE is supported.""" - return True + return self.supports_type_objects() + + def supports_alter_type(self) -> bool: + return self.supports_type_objects() def supports_drop_type(self) -> bool: - """DROP TYPE is supported.""" + return self.supports_type_objects() + + def supported_type_definitions(self) -> Tuple[Type[TypeDefinition], ...]: + if not self.supports_type_objects(): + return () + return self._POSTGRES_TYPE_DEFINITIONS + + def supports_type_definition( + self, + definition_type: Type[TypeDefinition], + ) -> bool: + if not self.supports_type_objects(): + return False + try: + return any( + issubclass(definition_type, supported) + for supported in self.supported_type_definitions() + ) + except TypeError: + return False + + def supports_type_alter_action( + self, + action_type: Type[TypeAlterAction], + ) -> bool: + if not self.supports_type_objects(): + return False + try: + if not issubclass(action_type, self._POSTGRES_TYPE_ACTIONS): + return False + except TypeError: + return False + if issubclass(action_type, PostgresRenameEnumValueAction): + return self.version >= (10, 0, 0) + if issubclass(action_type, PostgresSetTypePropertiesAction): + return self.version >= (13, 0, 0) return True - def supports_type_if_not_exists(self) -> bool: - """PostgreSQL does NOT support IF NOT EXISTS for CREATE TYPE.""" + def supports_create_type_if_not_exists(self) -> bool: return False - def supports_type_if_exists(self) -> bool: - """DROP TYPE IF EXISTS is supported.""" - return True + def supports_create_type_or_replace(self) -> bool: + return False - def supports_type_cascade(self) -> bool: - """DROP TYPE CASCADE is supported.""" - return True + def supports_alter_type_if_exists(self) -> bool: + return False - def format_create_type_enum_statement( - self, name: str, values: List[str], schema: Optional[str] = None - ) -> Tuple[str, tuple]: - """Format CREATE TYPE ... AS ENUM statement. + def supports_drop_type_if_exists(self) -> bool: + return self.supports_type_objects() - PostgreSQL syntax: - CREATE TYPE [schema.]name AS ENUM ('value1', 'value2', ...); + def supports_multiple_type_alter_actions(self) -> bool: + return self.supports_type_objects() - Args: - name: Type name - values: List of enum values - schema: Optional schema name + def supports_drop_type_cascade(self) -> bool: + return self.supports_type_objects() - Returns: - Tuple of (SQL string, parameters tuple) - """ - if not values: - raise ValueError("ENUM type must have at least one value") + def supports_drop_type_restrict(self) -> bool: + return self.supports_type_objects() - type_name = self.format_identifier(name) - if schema: - type_name = f"{self.format_identifier(schema)}.{type_name}" + def supports_type_if_not_exists(self) -> bool: + return False - escaped_values = [f"'{self._escape_sql_string(str(v))}'" for v in values] - values_str = ", ".join(escaped_values) + def supports_type_if_exists(self) -> bool: + return self.supports_drop_type_if_exists() - sql = f"CREATE TYPE {type_name} AS ENUM ({values_str})" - return sql, () + def supports_type_cascade(self) -> bool: + return self.supports_drop_type_cascade() - def format_drop_type_statement( - self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False + def format_create_type_enum_statement( + self, + name: str, + values: Sequence[str], + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format DROP TYPE statement. - - PostgreSQL syntax: - DROP TYPE [IF EXISTS] [schema.]name [CASCADE]; + dialect = self._as_dialect() + expr = CreateTypeExpression( + dialect, + name, + PostgresEnumTypeDefinition(dialect, values), + schema_name=schema, + ) + return self.format_create_type_statement(expr) - Args: - name: Type name - schema: Optional schema name - if_exists: Whether to add IF EXISTS - cascade: Whether to add CASCADE + def format_create_type_statement( + self, + expr: CreateTypeExpression, + ) -> Tuple[str, tuple]: + if not self.supports_type_objects() or not self.supports_create_type(): + raise UnsupportedFeatureError(self.name, "CREATE TYPE") + if expr.if_not_exists and not self.supports_create_type_if_not_exists(): + raise UnsupportedFeatureError(self.name, "CREATE TYPE IF NOT EXISTS") + if expr.or_replace and not self.supports_create_type_or_replace(): + raise UnsupportedFeatureError(self.name, "CREATE OR REPLACE TYPE") + if not self.supports_type_definition(type(expr.definition)): + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {expr.definition.definition_kind}", + ) + definition_sql, definition_params = expr.definition.to_sql() + parts = ["CREATE"] + if expr.or_replace: + parts.append("OR REPLACE") + parts.append("TYPE") + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self._format_qualified_identifier(expr.type_name, expr.schema_name)) + if definition_sql: + parts.append(definition_sql) + return " ".join(parts), tuple(definition_params) - Returns: - Tuple of (SQL string, parameters tuple) - """ - parts = ["DROP TYPE"] + def _validate_type_action_version(self, expr: TypeAlterAction) -> None: + if isinstance(expr, PostgresRenameEnumValueAction) and self.version < (10, 0, 0): + raise UnsupportedFeatureError( + self.name, + "ALTER TYPE RENAME VALUE", + suggestion="requires PostgreSQL 10+", + ) + if isinstance(expr, PostgresSetTypePropertiesAction) and self.version < (13, 0, 0): + raise UnsupportedFeatureError( + self.name, + "ALTER TYPE SET properties", + suggestion="requires PostgreSQL 13+", + ) - if if_exists: + def format_alter_type_statement( + self, + expr: AlterTypeExpression, + ) -> Tuple[str, tuple]: + if not self.supports_type_objects() or not self.supports_alter_type(): + raise UnsupportedFeatureError(self.name, "ALTER TYPE") + if expr.if_exists and not self.supports_alter_type_if_exists(): + raise UnsupportedFeatureError(self.name, "ALTER TYPE IF EXISTS") + if len(expr.actions) > 1 and not self.supports_multiple_type_alter_actions(): + raise UnsupportedFeatureError(self.name, "multiple ALTER TYPE actions") + if len(expr.actions) > 1 and any( + not isinstance(action, self._COMBINABLE_TYPE_ACTIONS) + for action in expr.actions + ): + raise UnsupportedFeatureError( + self.name, + "multiple ALTER TYPE actions", + suggestion="PostgreSQL combines only composite attribute actions", + ) + action_parts: List[str] = [] + action_params: List[object] = [] + for action in expr.actions: + self._validate_type_action_version(action) + if not self.supports_type_alter_action(type(action)): + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {action.action_kind}", + ) + action_sql, params = action.to_sql() + action_parts.append(action_sql) + action_params.extend(params) + parts = ["ALTER TYPE"] + if expr.if_exists: parts.append("IF EXISTS") + parts.append(self._format_qualified_identifier(expr.type_name, expr.schema_name)) + parts.append(", ".join(action_parts)) + return " ".join(parts), tuple(action_params) - type_name = self.format_identifier(name) - if schema: - type_name = f"{self.format_identifier(schema)}.{type_name}" - parts.append(type_name) + def format_drop_type_statement( + self, + expr: Optional[Union[DropTypeExpression, str]] = None, + schema_name: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + *, + schema: Optional[str] = None, + name: Optional[str] = None, + ) -> Tuple[str, tuple]: + if expr is not None and name is not None: + raise ValueError("expr and name are mutually exclusive") + if expr is None: + if name is None: + raise TypeError("expr or name is required") + expr = name + if isinstance(expr, str): + if schema is not None and schema_name is not None and schema != schema_name: + raise ValueError("schema and schema_name must match when both are provided") + resolved_schema = schema if schema is not None else schema_name + expr = PostgresDropTypeExpression( + self._as_dialect(), + expr, + schema_name=resolved_schema, + if_exists=if_exists, + cascade=cascade, + restrict=restrict, + ) + if not self.supports_type_objects() or not self.supports_drop_type(): + raise UnsupportedFeatureError(self.name, "DROP TYPE") + if expr.if_exists and not self.supports_drop_type_if_exists(): + raise UnsupportedFeatureError(self.name, "DROP TYPE IF EXISTS") + behavior = self._format_type_behavior( + getattr(expr, "cascade", False), + getattr(expr, "restrict", False), + ) + if getattr(expr, "cascade", False) and not self.supports_drop_type_cascade(): + raise UnsupportedFeatureError(self.name, "DROP TYPE CASCADE") + if getattr(expr, "restrict", False) and not self.supports_drop_type_restrict(): + raise UnsupportedFeatureError(self.name, "DROP TYPE RESTRICT") + parts = ["DROP TYPE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_qualified_identifier(expr.type_name, expr.schema_name)) + if behavior: + parts.append(behavior) + return " ".join(parts), () - if cascade: - parts.append("CASCADE") + def format_type_definition( + self, + expr: TypeDefinition, + ) -> Tuple[str, tuple]: + if not self.supports_type_definition(type(expr)): + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + ) + if isinstance(expr, PostgresCompositeTypeDefinition): + attributes = ", ".join( + self._format_postgres_composite_attribute(attribute) + for attribute in expr.attributes + ) + return f"AS ({attributes})", () + if isinstance(expr, PostgresEnumTypeDefinition): + labels = ", ".join( + self._format_postgres_enum_value(label) for label in expr.labels + ) + return f"AS ENUM ({labels})", () + if isinstance(expr, PostgresRangeTypeDefinition): + if expr.multirange_type_name is not None and self.version < (14, 0, 0): + raise UnsupportedFeatureError( + self.name, + "CREATE TYPE MULTIRANGE_TYPE_NAME", + suggestion="requires PostgreSQL 14+", + ) + clauses = [f"SUBTYPE = {self._format_type_data_type(expr.subtype)}"] + optional_values = ( + ("SUBTYPE_OPCLASS", expr.subtype_operator_class), + ("COLLATION", expr.collation), + ("CANONICAL", expr.canonical_function), + ("SUBTYPE_DIFF", expr.subtype_diff_function), + ("MULTIRANGE_TYPE_NAME", expr.multirange_type_name), + ) + for keyword, value in optional_values: + if value is not None: + clauses.append(f"{keyword} = {self._format_qualified_identifier(value)}") + return f"AS RANGE ({', '.join(clauses)})", () + if isinstance(expr, PostgresBaseTypeDefinition): + return self._format_postgres_base_type_definition(expr), () + if isinstance(expr, PostgresShellTypeDefinition): + return "", () + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + ) - return " ".join(parts), () + def format_type_alter_action( + self, + expr: TypeAlterAction, + ) -> Tuple[str, tuple]: + self._validate_type_action_version(expr) + if not self.supports_type_alter_action(type(expr)): + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) + if isinstance(expr, PostgresRenameTypeAction): + return f"RENAME TO {self.format_identifier(expr.new_name)}", () + if isinstance(expr, PostgresSetTypeSchemaAction): + return f"SET SCHEMA {self.format_identifier(expr.new_schema)}", () + if isinstance(expr, PostgresChangeTypeOwnerAction): + return f"OWNER TO {self.format_identifier(expr.new_owner)}", () + if isinstance(expr, PostgresRenameTypeAttributeAction): + behavior = self._format_type_behavior(expr.cascade, expr.restrict) + sql = ( + f"RENAME ATTRIBUTE {self.format_identifier(expr.attribute_name)} " + f"TO {self.format_identifier(expr.new_name)}" + ) + return (f"{sql} {behavior}" if behavior else sql), () + if isinstance(expr, PostgresAddTypeAttributeAction): + parts = [ + "ADD ATTRIBUTE", + self.format_identifier(expr.attribute_name), + self._format_type_data_type(expr.data_type), + ] + if expr.collation is not None: + parts.append(f"COLLATE {self._format_qualified_identifier(expr.collation)}") + behavior = self._format_type_behavior(expr.cascade, expr.restrict) + if behavior: + parts.append(behavior) + return " ".join(parts), () + if isinstance(expr, PostgresDropTypeAttributeAction): + parts = ["DROP ATTRIBUTE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.attribute_name)) + behavior = self._format_type_behavior(expr.cascade, expr.restrict) + if behavior: + parts.append(behavior) + return " ".join(parts), () + if isinstance(expr, PostgresAlterTypeAttributeAction): + type_keyword = "SET DATA TYPE" if expr.set_data else "TYPE" + parts = [ + "ALTER ATTRIBUTE", + self.format_identifier(expr.attribute_name), + type_keyword, + self._format_type_data_type(expr.data_type), + ] + if expr.collation is not None: + parts.append(f"COLLATE {self._format_qualified_identifier(expr.collation)}") + behavior = self._format_type_behavior(expr.cascade, expr.restrict) + if behavior: + parts.append(behavior) + return " ".join(parts), () + if isinstance(expr, PostgresAddEnumValueAction): + if expr.if_not_exists and self.version < (9, 3, 0): + raise UnsupportedFeatureError( + self.name, + "ALTER TYPE ADD VALUE IF NOT EXISTS", + suggestion="requires PostgreSQL 9.3+", + ) + parts = ["ADD VALUE"] + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self._format_postgres_enum_value(expr.new_value)) + if expr.before is not None: + parts.extend(("BEFORE", self._format_postgres_enum_value(expr.before))) + elif expr.after is not None: + parts.extend(("AFTER", self._format_postgres_enum_value(expr.after))) + return " ".join(parts), () + if isinstance(expr, PostgresRenameEnumValueAction): + return ( + "RENAME VALUE " + f"{self._format_postgres_enum_value(expr.old_value)} " + f"TO {self._format_postgres_enum_value(expr.new_value)}" + ), () + if isinstance(expr, PostgresSetTypePropertiesAction): + if self.version < (13, 0, 0): + raise UnsupportedFeatureError( + self.name, + "ALTER TYPE SET properties", + suggestion="requires PostgreSQL 13+", + ) + if "SUBSCRIPT" in expr.properties and self.version < (14, 0, 0): + raise UnsupportedFeatureError( + self.name, + "ALTER TYPE SET SUBSCRIPT", + suggestion="requires PostgreSQL 14+", + ) + clauses: List[str] = [] + for name, value in expr.properties.items(): + if name == "STORAGE": + if value is None: + raise ValueError("STORAGE property cannot be None") + rendered_value = self.format_identifier(value) + else: + rendered_value = "NONE" if value is None else self._format_qualified_identifier(value) + clauses.append(f"{name} = {rendered_value}") + return f"SET ({', '.join(clauses)})", () + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py index 77d6e8fb..2950ba46 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py @@ -36,10 +36,35 @@ def supports_table_tablespace(self) -> bool: """TABLESPACE specification is supported in all versions.""" return True + def supports_alter_column_properties(self) -> bool: + """PostgreSQL supports ALTER COLUMN property changes.""" + return True + def supports_create_table_like(self) -> bool: """PostgreSQL supports CREATE TABLE (LIKE ...) with INCLUDING/EXCLUDING options.""" return True + def format_create_table_options(self, expr) -> Tuple[str, tuple]: + """Format the CREATE header modifiers for PostgreSQL. + + Accepts both the generic ``CreateTableOptions`` (renders ``OR REPLACE``) + and the PostgreSQL ``PostgresCreateTableOptions`` (adds ``UNLOGGED``). + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.table_options import ( + PostgresCreateTableOptions, + ) + + base_sql, params = super().format_create_table_options(expr) + parts = [base_sql] if base_sql else [] + if isinstance(expr, PostgresCreateTableOptions) and expr.unlogged: + if not self.supports_unlogged_table(): + raise UnsupportedFeatureError(self.name, "CREATE UNLOGGED TABLE") + parts.append("UNLOGGED") + return " ".join(parts), params + def format_create_table_statement(self, expr) -> Tuple[str, tuple]: """Render CREATE TABLE for PostgreSQL. @@ -59,9 +84,12 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: # Validate through the PartitionClause -> format_partition_clause chain. expr.partition.to_sql() + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.table_options import ( + PostgresCreateTableOptions, + ) table_options = getattr(expr, "table_options", None) if ( - table_options is not None + isinstance(table_options, PostgresCreateTableOptions) and table_options.unlogged and getattr(expr, "temporary", False) ): @@ -134,7 +162,16 @@ def format_create_table_like_statement(self, expr) -> Tuple[str, tuple]: return " ".join(parts), tuple(table_params) + tuple(source_params) def format_column_definition(self, col_def) -> Tuple[str, tuple]: - from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + """Format a single column definition with PostgreSQL-specific syntax. + + Accepts both the generic ``ColumnDefinition`` and the PostgreSQL + ``PostgresColumnDefinition``; the latter's PostgreSQL-only attributes + (``compression`` / ``storage`` / ``statistics``) are rendered here. + """ + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.column import ( + PostgresColumnDefinition, + ) + all_params: List[Any] = [] type_sql, _ = col_def.data_type.to_sql() if not re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", type_sql): @@ -144,15 +181,9 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: ) col_sql = f"{self.format_identifier(col_def.name)} {type_sql}" - identity = getattr(col_def, 'identity', None) - if not identity: - dialect_opts = col_def.dialect_options or {} - identity = dialect_opts.get("identity") - if identity: - if identity.upper() in ("ALWAYS", "BY DEFAULT"): - col_sql += f" GENERATED {identity.upper()} AS IDENTITY" - else: - raise ValueError(f"Invalid identity option '{identity}': must be 'ALWAYS' or 'BY DEFAULT'") + attr_sql, attr_params = self.format_column_attributes(col_def) + col_sql += attr_sql + all_params.extend(attr_params) auto_identity_added = False for constraint in col_def.constraints: if not auto_identity_added and getattr(constraint, 'is_auto_increment', False): @@ -161,11 +192,25 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: suffix, params = self.format_column_constraint(constraint) col_sql += suffix all_params.extend(params) - if col_def.comment: - escaped_comment = SQLDialectBase._escape_sql_string(col_def.comment) - col_sql += f" COMMENT '{escaped_comment}'" + if col_def.comment is not None: + # PostgreSQL has no inline COMMENT syntax (the de-facto + # vendor form is the standalone COMMENT ON statement); a comment + # on a column definition is never silently dropped. + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, "COLUMN COMMENT", + "PostgreSQL has no inline column comment; use a standalone " + "COMMENT ON COLUMN statement.", + ) if col_def.generated_expression is not None: gen_sql, gen_params = col_def.generated_expression.to_sql() col_sql += gen_sql all_params.extend(gen_params) + if isinstance(col_def, PostgresColumnDefinition): + if col_def.compression: + col_sql += f" COMPRESSION {col_def.compression}" + if col_def.storage is not None: + col_sql += f" STORAGE {col_def.storage.value}" + if col_def.statistics is not None: + col_sql += f" STATISTICS {col_def.statistics}" return col_sql, tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py index 256b4b68..3428f2bd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py @@ -4,7 +4,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expression.statements.ddl_view import CreateViewExpression + from ....expression.statements.ddl_view import CreateViewExpression class PostgresViewMixin: @@ -32,18 +32,6 @@ def supports_view_check_option(self) -> bool: def supports_cascade_view(self) -> bool: return True - def supports_materialized_view(self) -> bool: - return True - - def supports_refresh_materialized_view(self) -> bool: - return True - - def supports_materialized_view_tablespace(self) -> bool: - return True - - def supports_materialized_view_storage_options(self) -> bool: - return True - def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: """Format CREATE VIEW statement for PostgreSQL. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py index 9cd264f7..317221ce 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py @@ -2,12 +2,14 @@ """DML-related PostgreSQL mixins.""" from .vacuum import PostgresVacuumMixin +from .copy import PostgresCopyMixin from .stored_procedure import PostgresStoredProcedureMixin from .extended_statistics import PostgresExtendedStatisticsMixin from .locking import PostgresLockingMixin __all__ = [ "PostgresVacuumMixin", + "PostgresCopyMixin", "PostgresStoredProcedureMixin", "PostgresExtendedStatisticsMixin", "PostgresLockingMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/copy.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/copy.py new file mode 100644 index 00000000..d07f4aea --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/copy.py @@ -0,0 +1,603 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/copy.py +"""PostgreSQL COPY statement formatting and version gates.""" + +import re +from typing import Any, List, Optional, Sequence, Tuple, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from ...expression.copy import PostgresCopyFromExpression, PostgresCopyToExpression + + +class PostgresCopyMixin: + """PostgreSQL COPY support restricted to client-side streams.""" + + def supports_copy_to(self) -> bool: + return self.version >= (9, 6, 0) + + def supports_copy_from(self) -> bool: + return self.version >= (9, 6, 0) + + def supports_copy_json(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_copy_from_json(self) -> bool: + return False + + def supports_copy_force_array(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_copy_force_all(self) -> bool: + return self.version >= (17, 0, 0) + + def supports_copy_partitioned_table_to(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_copy_text_header(self) -> bool: + return self.version >= (15, 0, 0) + + def supports_copy_header_match(self) -> bool: + return self.version >= (15, 0, 0) + + def supports_copy_header_line_count(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_copy_on_error(self) -> bool: + return self.version >= (17, 0, 0) + + def supports_copy_reject_limit(self) -> bool: + return self.version >= (18, 0, 0) + + def supports_copy_set_null(self) -> bool: + return self.version >= (19, 0, 0) + + def supports_copy_log_verbosity(self) -> bool: + return self.version >= (17, 0, 0) + + def supports_copy_silent_log(self) -> bool: + return self.version >= (18, 0, 0) + + def _require_copy_version( + self, + supported: bool, + feature: str, + minimum: Tuple[int, int, int], + ) -> None: + if not supported: + major, minor, _ = minimum + raise UnsupportedFeatureError( + self.name, + feature, + suggestion=f"requires PostgreSQL {major}.{minor}+", + ) + + @staticmethod + def _scan_copy_sql(sql: str) -> Tuple[List[str], bool]: + words: List[str] = [] + has_separator = False + index = 0 + length = len(sql) + while index < length: + if sql.startswith("--", index): + newline = sql.find("\n", index + 2) + index = length if newline < 0 else newline + 1 + continue + if sql.startswith("/*", index): + end = sql.find("*/", index + 2) + if end < 0: + raise ValueError("COPY SQL contains an unterminated block comment") + index = end + 2 + continue + if sql[index] == "'": + index += 1 + while index < length: + if sql[index] == "\\" and index + 1 < length: + index += 2 + continue + if sql[index] != "'": + index += 1 + continue + if index + 1 < length and sql[index + 1] == "'": + index += 2 + continue + index += 1 + break + else: + raise ValueError("COPY SQL contains an unterminated string literal") + continue + if sql[index] == '"': + index += 1 + while index < length: + if sql[index] == '"': + if index + 1 < length and sql[index + 1] == '"': + index += 2 + continue + index += 1 + break + index += 1 + else: + raise ValueError("COPY SQL contains an unterminated quoted identifier") + continue + if sql[index] == "$": + match = re.match(r"\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$", sql[index:]) + if match: + tag = match.group(0) + end = sql.find(tag, index + len(tag)) + if end < 0: + raise ValueError("COPY SQL contains an unterminated dollar string") + index = end + len(tag) + continue + if sql[index] == ";": + has_separator = True + index += 1 + continue + if sql[index].isalpha() or sql[index] == "_": + end = index + 1 + while end < length and (sql[end].isalnum() or sql[end] in "_$"): + end += 1 + words.append(sql[index:end].upper()) + index = end + continue + index += 1 + return words, has_separator + + def _validate_copy_query(self, sql: str) -> None: + if not isinstance(sql, str) or not sql.strip(): + raise ValueError("COPY TO query rendered an empty SQL value") + if "\x00" in sql: + raise ValueError("COPY TO query contains a NUL character") + words, has_separator = self._scan_copy_sql(sql) + if has_separator: + raise ValueError("COPY TO query must contain exactly one read-only statement") + if not words or words[0] not in {"SELECT", "VALUES", "WITH", "TABLE"}: + raise ValueError("COPY TO query must start with SELECT, VALUES, WITH, or TABLE") + blocked = { + "ALTER", + "ANALYZE", + "COPY", + "CREATE", + "DELETE", + "DROP", + "GRANT", + "INSERT", + "INTO", + "MERGE", + "REPACK", + "REVOKE", + "TRUNCATE", + "UPDATE", + "VACUUM", + } + if blocked.intersection(words[1:]): + raise ValueError("COPY TO query must be read-only") + + def _validate_copy_predicate(self, sql: str) -> None: + if not isinstance(sql, str) or not sql.strip(): + raise ValueError("COPY FROM WHERE rendered an empty SQL value") + if "\x00" in sql: + raise ValueError("COPY FROM WHERE contains a NUL character") + _, has_separator = self._scan_copy_sql(sql) + if has_separator: + raise ValueError("COPY FROM WHERE must contain one SQL expression") + + def _validate_copy_child_dialect(self, expression: BaseExpression, name: str) -> None: + try: + child_dialect = expression.dialect + except ValueError as error: + raise TypeError(f"{name} must have a bound dialect") from error + if child_dialect is not self: + raise ValueError(f"{name} must use the PostgreSQL dialect") + + @staticmethod + def _copy_identifier(value: str, name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + if not value or "\x00" in value: + raise ValueError(f"{name} must be a non-empty identifier without NUL") + return value + + def _format_copy_identifier(self, value: str, name: str) -> str: + return self.format_identifier(self._copy_identifier(value, name)) + + @staticmethod + def _copy_column_list(values: Optional[Sequence[str]], name: str) -> Optional[List[str]]: + if values is None: + return None + if isinstance(values, (str, bytes)) or not isinstance(values, Sequence): + raise TypeError(f"{name} must be a sequence of identifiers") + columns = list(values) + if not columns: + raise ValueError(f"{name} must not be empty") + for column in columns: + if not isinstance(column, str): + raise TypeError(f"{name} must contain only string identifiers") + if not column or "\x00" in column: + raise ValueError(f"{name} contains an invalid identifier") + if len(columns) != len(set(columns)): + raise ValueError(f"{name} must not contain duplicates") + return columns + + @staticmethod + def _copy_string(value: Any, name: str, allow_empty: bool = True) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + if "\x00" in value: + raise ValueError(f"{name} must not contain NUL") + if not allow_empty and not value: + raise ValueError(f"{name} must not be empty") + return value + + @staticmethod + def _copy_character(value: Any, name: str) -> str: + character = PostgresCopyMixin._copy_string(value, name, allow_empty=False) + if len(character) != 1 or ord(character) > 127: + raise ValueError(f"{name} must be one single-byte character") + return character + + def _format_copy_string(self, value: Any, name: str) -> str: + string = self._copy_string(value, name) + escaped = string.replace("\\", "\\\\").replace("'", "''") + prefix = "E" if "\\" in string else "" + return f"{prefix}'{escaped}'" + + def _normalize_copy_format(self, value: Any, direction: str) -> str: + copy_format = str(getattr(value, "value", value)).lower() + if copy_format not in {"text", "csv", "json", "binary"}: + raise ValueError("COPY format must be text, csv, json, or binary") + if direction == "from" and copy_format == "json": + raise ValueError("PostgreSQL COPY FROM does not support JSON format") + return copy_format + + def _normalize_copy_header( + self, + value: Any, + direction: str, + copy_format: Optional[str] = None, + ) -> Optional[Union[bool, int, str]]: + if value is None: + return None + if isinstance(value, bool): + normalized: Union[bool, int, str] = value + elif isinstance(value, int): + if direction == "to" and value not in {0, 1}: + raise ValueError("COPY TO HEADER integer must be 0 or 1") + if value < 0: + raise ValueError("COPY HEADER integer must be non-negative") + normalized = value + elif isinstance(value, str): + if direction != "from" or value.strip().upper() != "MATCH": + raise ValueError("COPY TO does not support HEADER MATCH") + normalized = "MATCH" + else: + raise TypeError("COPY HEADER must be a bool, non-negative integer, or MATCH") + if copy_format == "text" and normalized not in (False, 0): + self._require_copy_version( + self.supports_copy_text_header(), + f"COPY {direction.upper()} text HEADER", + (15, 0, 0), + ) + if isinstance(normalized, int) and normalized > 1: + self._require_copy_version( + self.supports_copy_header_line_count(), + f"COPY {direction.upper()} HEADER line count", + (19, 0, 0), + ) + if normalized == "MATCH": + self._require_copy_version( + self.supports_copy_header_match(), + "COPY FROM HEADER MATCH", + (15, 0, 0), + ) + return normalized + + @staticmethod + def _format_copy_header(value: object) -> str: + if value is True: + return "HEADER" + if value is False: + return "HEADER FALSE" + if isinstance(value, int): + return f"HEADER {value}" + return "HEADER MATCH" + + def _normalize_force_columns( + self, + value: Any, + name: str, + target_columns: Optional[Sequence[str]], + ) -> Optional[Union[str, List[str]]]: + if value is None: + return None + if isinstance(value, str) and value == "*": + return "*" + if isinstance(value, str): + normalized_columns = [value] + else: + copied_columns = self._copy_column_list(value, name) + if copied_columns is None: + return None + normalized_columns = copied_columns + if target_columns is not None: + missing = set(normalized_columns) - set(target_columns) + if missing: + raise ValueError(f"{name} names columns absent from the COPY column list") + return normalized_columns + + def _format_copy_to_source( + self, + expr: "PostgresCopyToExpression", + ) -> Tuple[str, Tuple[Any, ...]]: + if (expr.table_name is None) == (expr.query is None): + raise ValueError("COPY TO requires exactly one table_name or query source") + if expr.query is not None: + if expr.schema is not None or expr.columns is not None or expr.partitioned: + raise ValueError("schema, columns, and partitioned are valid only for table COPY TO") + if not isinstance(expr.query, BaseExpression): + raise TypeError("COPY TO query must be a BaseExpression") + self._validate_copy_child_dialect(expr.query, "COPY TO query") + query_sql, params = expr.query.to_sql() + self._validate_copy_query(query_sql) + return f"({query_sql})", tuple(params) + if not isinstance(expr.table_name, str): + raise TypeError("COPY TO table_name must be a string") + table = self._format_copy_identifier(expr.table_name, "table_name") + if expr.schema is not None: + table = f"{self._format_copy_identifier(expr.schema, 'schema')}.{table}" + columns = self._copy_column_list(expr.columns, "columns") + if columns is not None: + table = "{} ({})".format( + table, + ", ".join(self._format_copy_identifier(column, "column") for column in columns), + ) + return table, () + + def format_copy_to_statement( + self, + expr: "PostgresCopyToExpression", + ) -> Tuple[str, Tuple[Any, ...]]: + """Format a client-stream-only ``COPY ... TO STDOUT`` statement.""" + self._require_copy_version(self.supports_copy_to(), "COPY TO", (9, 6, 0)) + copy_format = self._normalize_copy_format(expr.format, "to") + if copy_format == "json": + self._require_copy_version( + self.supports_copy_json(), + "COPY TO JSON format", + (19, 0, 0), + ) + if not isinstance(expr.partitioned, bool): + raise TypeError("partitioned must be a bool") + if expr.partitioned: + self._require_copy_version( + self.supports_copy_partitioned_table_to(), + "COPY TO partitioned table", + (19, 0, 0), + ) + source, params = self._format_copy_to_source(expr) + columns = self._copy_column_list(expr.columns, "columns") + options = [f"FORMAT {copy_format.upper()}"] + header = self._normalize_copy_header(expr.header, "to", copy_format) + if header is not None: + if copy_format in {"binary", "json"}: + raise ValueError(f"HEADER is not allowed with COPY TO {copy_format.upper()}") + options.append(self._format_copy_header(header)) + if expr.null is not None: + if copy_format in {"binary", "json"}: + raise ValueError(f"NULL is not allowed with COPY TO {copy_format.upper()}") + options.append(f"NULL {self._format_copy_string(expr.null, 'null')}") + if expr.delimiter is not None: + if copy_format not in {"text", "csv"}: + raise ValueError(f"DELIMITER is not allowed with COPY TO {copy_format.upper()}") + delimiter = self._copy_character(expr.delimiter, "delimiter") + options.append(f"DELIMITER {self._format_copy_string(delimiter, 'delimiter')}") + if expr.quote is not None or expr.escape is not None: + if copy_format != "csv": + raise ValueError("QUOTE and ESCAPE require COPY TO CSV format") + if expr.quote is not None: + quote = self._copy_character(expr.quote, "quote") + options.append(f"QUOTE {self._format_copy_string(quote, 'quote')}") + if expr.escape is not None: + escape = self._copy_character(expr.escape, "escape") + options.append(f"ESCAPE {self._format_copy_string(escape, 'escape')}") + if expr.force_quote is not None: + if copy_format != "csv": + raise ValueError("FORCE_QUOTE requires COPY TO CSV format") + force_quote = self._normalize_force_columns( + expr.force_quote, + "force_quote", + columns, + ) + if expr.query is not None and force_quote != "*": + raise ValueError("query COPY TO FORCE_QUOTE requires '*'") + if force_quote == "*": + options.append("FORCE_QUOTE *") + elif force_quote: + options.append( + "FORCE_QUOTE ({})".format( + ", ".join( + self._format_copy_identifier(column, "force_quote column") + for column in force_quote + ) + ) + ) + if expr.force_array is not None: + if not isinstance(expr.force_array, bool): + raise TypeError("force_array must be a bool") + if copy_format != "json": + raise ValueError("FORCE_ARRAY requires COPY TO JSON format") + self._require_copy_version( + self.supports_copy_force_array(), + "COPY TO FORCE_ARRAY", + (19, 0, 0), + ) + value = "" if expr.force_array else " FALSE" + options.append(f"FORCE_ARRAY{value}") + return f"COPY {source} TO STDOUT ({', '.join(options)})", params + + def _format_copy_from_where( + self, + expression: BaseExpression, + ) -> Tuple[str, Tuple[Any, ...]]: + self._validate_copy_child_dialect(expression, "COPY FROM WHERE") + where_sql, params = expression.to_sql() + self._validate_copy_predicate(where_sql) + return where_sql, tuple(params) + + def format_copy_from_statement( + self, + expr: "PostgresCopyFromExpression", + ) -> Tuple[str, Tuple[Any, ...]]: + """Format a client-stream-only ``COPY ... FROM STDIN`` statement.""" + self._require_copy_version(self.supports_copy_from(), "COPY FROM", (9, 6, 0)) + copy_format = self._normalize_copy_format(expr.format, "from") + if not isinstance(expr.table_name, str): + raise TypeError("COPY FROM table_name must be a string") + table = self._format_copy_identifier(expr.table_name, "table_name") + if expr.schema is not None: + table = f"{self._format_copy_identifier(expr.schema, 'schema')}.{table}" + columns = self._copy_column_list(expr.columns, "columns") + if columns is not None: + table = "{} ({})".format( + table, + ", ".join(self._format_copy_identifier(column, "column") for column in columns), + ) + options = [f"FORMAT {copy_format.upper()}"] + header = self._normalize_copy_header(expr.header, "from", copy_format) + if header is not None: + if copy_format == "binary": + raise ValueError("HEADER is not allowed with COPY FROM BINARY") + options.append(self._format_copy_header(header)) + if expr.null is not None: + if copy_format == "binary": + raise ValueError("NULL is not allowed with COPY FROM BINARY") + options.append(f"NULL {self._format_copy_string(expr.null, 'null')}") + if expr.default is not None: + if copy_format == "binary": + raise ValueError("DEFAULT is not allowed with COPY FROM BINARY") + options.append(f"DEFAULT {self._format_copy_string(expr.default, 'default')}") + if expr.delimiter is not None: + if copy_format not in {"text", "csv"}: + raise ValueError(f"DELIMITER is not allowed with COPY FROM {copy_format.upper()}") + delimiter = self._copy_character(expr.delimiter, "delimiter") + options.append(f"DELIMITER {self._format_copy_string(delimiter, 'delimiter')}") + if expr.quote is not None or expr.escape is not None: + if copy_format != "csv": + raise ValueError("QUOTE and ESCAPE require COPY FROM CSV format") + if expr.quote is not None: + quote = self._copy_character(expr.quote, "quote") + options.append(f"QUOTE {self._format_copy_string(quote, 'quote')}") + if expr.escape is not None: + escape = self._copy_character(expr.escape, "escape") + options.append(f"ESCAPE {self._format_copy_string(escape, 'escape')}") + force_not_null = self._normalize_force_columns( + expr.force_not_null, + "force_not_null", + columns, + ) + if force_not_null is not None: + if copy_format != "csv": + raise ValueError("FORCE_NOT_NULL requires COPY FROM CSV format") + if force_not_null == "*": + self._require_copy_version( + self.supports_copy_force_all(), + "COPY FROM FORCE_NOT_NULL *", + (17, 0, 0), + ) + target = ( + "*" + if force_not_null == "*" + else "({})".format( + ", ".join( + self._format_copy_identifier(column, "force_not_null column") for column in force_not_null + ) + ) + ) + options.append(f"FORCE_NOT_NULL {target}") + force_null = self._normalize_force_columns( + expr.force_null, + "force_null", + columns, + ) + if force_null is not None: + if copy_format != "csv": + raise ValueError("FORCE_NULL requires COPY FROM CSV format") + if force_null == "*": + self._require_copy_version( + self.supports_copy_force_all(), + "COPY FROM FORCE_NULL *", + (17, 0, 0), + ) + target = ( + "*" + if force_null == "*" + else "({})".format( + ", ".join(self._format_copy_identifier(column, "force_null column") for column in force_null) + ) + ) + options.append(f"FORCE_NULL {target}") + if not isinstance(expr.freeze, bool): + raise TypeError("freeze must be a bool") + if expr.freeze: + options.append("FREEZE") + on_error = None + if expr.on_error is not None: + on_error = str(getattr(expr.on_error, "value", expr.on_error)).lower() + if on_error not in {"stop", "ignore", "set_null"}: + raise ValueError("ON_ERROR must be stop, ignore, or set_null") + self._require_copy_version( + self.supports_copy_on_error(), + "COPY FROM ON_ERROR", + (17, 0, 0), + ) + if on_error in {"ignore", "set_null"} and copy_format not in {"text", "csv"}: + raise ValueError("ON_ERROR ignore/set_null require text or csv format") + if on_error == "set_null": + self._require_copy_version( + self.supports_copy_set_null(), + "COPY FROM ON_ERROR SET_NULL", + (19, 0, 0), + ) + options.append(f"ON_ERROR {on_error.upper()}") + if expr.reject_limit is not None: + if isinstance(expr.reject_limit, bool) or not isinstance(expr.reject_limit, int): + raise TypeError("reject_limit must be an integer") + if expr.reject_limit <= 0: + raise ValueError("reject_limit must be positive") + if on_error != "ignore": + raise ValueError("REJECT_LIMIT requires ON_ERROR ignore") + self._require_copy_version( + self.supports_copy_reject_limit(), + "COPY FROM REJECT_LIMIT", + (18, 0, 0), + ) + options.append(f"REJECT_LIMIT {expr.reject_limit}") + if expr.encoding is not None: + encoding = self._copy_string(expr.encoding, "encoding", allow_empty=False) + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", encoding) is None: + raise ValueError("encoding must be a PostgreSQL encoding name") + options.append(f"ENCODING {self._format_copy_string(encoding, 'encoding')}") + if expr.log_verbosity is not None: + verbosity = str(getattr(expr.log_verbosity, "value", expr.log_verbosity)).lower() + if verbosity not in {"default", "verbose", "silent"}: + raise ValueError("LOG_VERBOSITY must be default, verbose, or silent") + self._require_copy_version( + self.supports_copy_log_verbosity(), + "COPY FROM LOG_VERBOSITY", + (17, 0, 0), + ) + if verbosity == "silent": + self._require_copy_version( + self.supports_copy_silent_log(), + "COPY FROM LOG_VERBOSITY SILENT", + (18, 0, 0), + ) + options.append(f"LOG_VERBOSITY {verbosity.upper()}") + sql = f"COPY {table} FROM STDIN ({', '.join(options)})" + params: Tuple[Any, ...] = () + if expr.where is not None: + if not isinstance(expr.where, BaseExpression): + raise TypeError("COPY FROM WHERE must be a BaseExpression") + where_sql, params = self._format_copy_from_where(expr.where) + sql = f"{sql} WHERE {where_sql}" + return sql, params diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py index d0605175..09e53351 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py @@ -4,7 +4,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expression.statements.explain import ExplainExpression + from ....expression.statements.explain import ExplainExpression class PostgresExplainMixin: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py index 47040fef..7e19abf8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py @@ -40,6 +40,10 @@ TriggerListExpression, TriggerInfoExpression, ) + from ..expression.introspection import ( + PostgresMaterializedViewInfoExpression, + PostgresMaterializedViewListExpression, + ) class PostgresIntrospectionCapabilityMixin(IntrospectionMixin): @@ -446,6 +450,89 @@ def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple return (sql, (schema, view_name)) + def format_materialized_view_list_query( + self, expr: "PostgresMaterializedViewListExpression" + ) -> Tuple[str, tuple]: + """Format materialized view list query. + + Uses ``pg_matviews`` (a view over ``pg_class``/``pg_rewrite``) joined with + ``pg_index`` so callers can tell whether a materialized view is populated + and whether it carries the UNIQUE index that ``REFRESH ... CONCURRENTLY`` + requires. + + Args: + expr: Materialized view list expression with parameters. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + params = expr.get_params() + schema = params.get("schema") or self._get_default_schema() + include_system = params.get("include_system", False) + + sql = f""" + SELECT m.schemaname as schema_name, + m.matviewname as view_name, + m.definition as definition, + m.ispopulated as is_populated, + obj_description(c.oid) as comment, + EXISTS ( + SELECT 1 + FROM pg_index i + WHERE i.indrelid = c.oid AND i.indisunique + ) as has_unique_index, + pg_get_viewdef(c.oid, true) as view_definition + FROM pg_matviews m + JOIN pg_class c ON c.relname = m.matviewname + JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = m.schemaname + WHERE m.schemaname = {self.p()} + """ + + params_list = [schema] + + if not include_system: + # NOTE: doubled percent — psycopg treats a bare '%' as a placeholder start. + sql += " AND m.matviewname NOT LIKE 'pg_%%'" + + sql += " ORDER BY m.matviewname" + + return (sql, tuple(params_list)) + + def format_materialized_view_info_query( + self, expr: "PostgresMaterializedViewInfoExpression" + ) -> Tuple[str, tuple]: + """Format single materialized view information query. + + Args: + expr: Materialized view info expression with parameters. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + params = expr.get_params() + view_name = params.get("view_name", "") + schema = params.get("schema") or self._get_default_schema() + + sql = f""" + SELECT m.schemaname as schema_name, + m.matviewname as view_name, + m.definition as definition, + m.ispopulated as is_populated, + obj_description(c.oid) as comment, + EXISTS ( + SELECT 1 + FROM pg_index i + WHERE i.indrelid = c.oid AND i.indisunique + ) as has_unique_index, + pg_get_viewdef(c.oid, true) as view_definition + FROM pg_matviews m + JOIN pg_class c ON c.relname = m.matviewname + JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = m.schemaname + WHERE m.schemaname = {self.p()} AND m.matviewname = {self.p()} + """ + + return (sql, (schema, view_name)) + def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, tuple]: """Format trigger list query using pg_trigger. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py index 972964c0..8a5e026c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py @@ -1,25 +1,151 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py -from typing import Tuple, TYPE_CHECKING +"""PostgreSQL materialized view feature support implementation. + +PostgreSQL grammar (https://www.postgresql.org/docs/current/sql-creatematerializedview.html): + + CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] name [ (aliases) ] + [ WITH ( storage_parameter [= value] [, ...] ) ] + [ TABLESPACE tablespace_name ] AS query [ WITH [ NO ] DATA ] + + REFRESH MATERIALIZED VIEW [ CONCURRENTLY ] name [ WITH [ NO ] DATA ] + + ALTER MATERIALIZED VIEW name RENAME TO new_name + ALTER MATERIALIZED VIEW name SET SCHEMA new_schema + ALTER MATERIALIZED VIEW name SET ( storage_parameter [= value] [, ...] ) + ALTER MATERIALIZED VIEW name RESET ( storage_parameter [, ...] ) + ALTER MATERIALIZED VIEW name OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } + + DROP MATERIALIZED VIEW [ IF EXISTS ] name [ CASCADE ] + +Note: ``ALTER MATERIALIZED VIEW`` does **not** support ``SET TABLESPACE`` — a +materialized view cannot be relocated to another tablespace after creation. + +This mixin must precede the core ``ViewMixin`` in ``PostgresDialect`` so the +formatters below take precedence over the generic ones. +""" +from typing import Any, Dict, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins import ViewMixin +from rhosocial.activerecord.backend.expression.core import QualifiedIdentifierExpression + +from ..expression.ddl.mv import ( + _CURRENT_ROLE_KEYWORDS, + PostgresChangeMaterializedViewOwnerAction, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) +from ..storage_parameters import resolve_storage_parameter if TYPE_CHECKING: - from ...expression.ddl import PostgresRefreshMaterializedViewExpression - from ...expression.statements.ddl_view import CreateMaterializedViewExpression + from ....expression.statements.ddl_view import CreateMaterializedViewExpression + from ..expression.ddl.mv import ( + PostgresAlterMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + ) -class PostgresMaterializedViewMixin: - """PostgreSQL materialized view extended features implementation.""" +class PostgresMaterializedViewMixin(ViewMixin): + """PostgreSQL materialized view extended features implementation. + + Extends the core :class:`ViewMixin` so the generic REFRESH/DROP paths are + reachable through ``super()``; this class must stay *before* ``ViewMixin`` + in ``PostgresDialect``'s base list so its formatters win. + """ + + def supports_materialized_view(self) -> bool: + """Materialized views require PostgreSQL 9.3+.""" + return self.version >= (9, 3, 0) + + def supports_refresh_materialized_view(self) -> bool: + """REFRESH MATERIALIZED VIEW requires PostgreSQL 9.3+.""" + return self.version >= (9, 3, 0) def supports_materialized_view_concurrent_refresh(self) -> bool: """CONCURRENTLY is supported since PostgreSQL 9.4.""" return self.version >= (9, 4, 0) + def supports_materialized_view_if_not_exists(self) -> bool: + """CREATE MATERIALIZED VIEW IF NOT EXISTS is supported since PostgreSQL 9.4.""" + return self.version >= (9, 4, 0) + + def supports_materialized_view_tablespace(self) -> bool: + """TABLESPACE on CREATE MATERIALIZED VIEW is supported since PostgreSQL 9.3.""" + return self.version >= (9, 3, 0) + + def supports_materialized_view_storage_options(self) -> bool: + """WITH (storage_parameter) is supported since PostgreSQL 9.3.""" + return self.version >= (9, 3, 0) + + def supports_alter_materialized_view(self) -> bool: + """ALTER MATERIALIZED VIEW is supported since PostgreSQL 9.3.""" + return self.version >= (9, 3, 0) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _format_materialized_view_name(self, expr: Any) -> str: + """Render a materialized view name, schema-qualified when available. + + Args: + expr: Any MV expression exposing ``view_name`` and optionally ``schema``. + + Returns: + The quoted (and possibly schema-qualified) identifier. + """ + name_sql, _ = self.format_qualified_identifier( + QualifiedIdentifierExpression( + self, + schema=getattr(expr, "schema", None), + name=expr.view_name, + ) + ) + return name_sql + + def _format_storage_parameters(self, properties: Dict[Any, Any]) -> str: + """Render a ``KEY = value, ...`` storage parameter list. + + Names come from :class:`PostgresStorageParameter` (already lower case) and + are emitted bare: PostgreSQL folds unquoted identifiers to lower case, so + quoting is unnecessary and values are inserted verbatim because the + server accepts bare literals as well as quoted strings. + """ + rendered = [] + for key, value in properties.items(): + parameter = resolve_storage_parameter(key) + name = parameter.value if parameter is not None else str(key) + rendered.append(f"{name} = {value}") + return ", ".join(rendered) + + def _format_storage_parameter_names(self, parameters: Any) -> str: + """Render a ``key, ...`` storage parameter name list (for ``RESET``).""" + rendered = [] + for key in parameters: + parameter = resolve_storage_parameter(key) + rendered.append(parameter.value if parameter is not None else str(key)) + return ", ".join(rendered) + + # ------------------------------------------------------------------ + # Formatters + # ------------------------------------------------------------------ + def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewExpression") -> Tuple[str, tuple]: """Format CREATE MATERIALIZED VIEW statement for PostgreSQL. + Follows the PostgreSQL grammar order: + ``CREATE MATERIALIZED VIEW [IF NOT EXISTS] name [(aliases)] + [WITH (storage_parameter = value, ...)] [TABLESPACE name] AS query + [WITH [NO] DATA]``. + - ``expr.view_name`` — view name (identifier). + - ``expr.schema`` — optional schema (PostgreSQL extension expression). - ``expr.column_aliases`` — optional list of column aliases. - - ``expr.tablespace`` — optional tablespace. + - ``expr.if_not_exists`` — ``IF NOT EXISTS`` (PostgreSQL 9.4+). - ``expr.storage_options`` — optional dict of storage parameters (``WITH (… )``). + - ``expr.tablespace`` — optional tablespace. - ``expr.query`` — source SELECT expression. - ``expr.with_data`` — ``WITH DATA`` / ``WITH NO DATA``. @@ -29,23 +155,36 @@ def format_create_materialized_view_statement(self, expr: "CreateMaterializedVie Returns: Tuple of (SQL string, params tuple) + Raises: + UnsupportedFeatureError: If the dialect or the target server version + does not support the requested materialized view feature. """ + if not self.supports_materialized_view(): + raise UnsupportedFeatureError(self.name, "CREATE MATERIALIZED VIEW") + parts = ["CREATE MATERIALIZED VIEW"] - parts.append(self.format_identifier(expr.view_name)) + + if getattr(expr, "if_not_exists", False): + if not self.supports_materialized_view_if_not_exists(): + raise UnsupportedFeatureError( + self.name, + "CREATE MATERIALIZED VIEW IF NOT EXISTS", + f"Requires PostgreSQL 9.4+, current version is {self.version}.", + ) + parts.append("IF NOT EXISTS") + + parts.append(self._format_materialized_view_name(expr)) if expr.column_aliases: cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) parts.append(f"({cols})") + if expr.storage_options and self.supports_materialized_view_storage_options(): + parts.append(f"WITH ({self._format_storage_parameters(expr.storage_options)})") + 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}") @@ -56,6 +195,32 @@ def format_create_materialized_view_statement(self, expr: "CreateMaterializedVie return " ".join(parts), query_params + def format_drop_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format DROP MATERIALIZED VIEW statement for PostgreSQL. + + Extends the core formatter with schema qualification. + """ + if not self.supports_materialized_view(): + raise UnsupportedFeatureError(self.name, "DROP MATERIALIZED VIEW") + + parts = ["DROP MATERIALIZED VIEW"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_materialized_view_name(expr)) + if expr.cascade: + parts.append("CASCADE") + return " ".join(parts), () + + def format_refresh_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format REFRESH MATERIALIZED VIEW, gating CONCURRENTLY on PG 9.4+. + + The generic ``RefreshMaterializedViewExpression`` routes here, so the + version gate also protects callers that do not use the PostgreSQL + specific expression. + """ + self._check_concurrent_refresh_support(expr) + return super().format_refresh_materialized_view_statement(expr) + def format_refresh_materialized_view_pg_statement( self, expr: "PostgresRefreshMaterializedViewExpression" ) -> Tuple[str, tuple]: @@ -66,22 +231,87 @@ def format_refresh_materialized_view_pg_statement( Returns: Tuple of (SQL statement, parameters tuple) - """ - full_name = f"{expr.schema}.{expr.view_name}" if expr.schema else expr.view_name - sql = "REFRESH MATERIALIZED VIEW" + Raises: + UnsupportedFeatureError: If CONCURRENTLY is requested on PG < 9.4. + """ + self._check_concurrent_refresh_support(expr) + parts = ["REFRESH MATERIALIZED VIEW"] if expr.concurrent: - if not self.supports_materialized_view_concurrent_refresh(): - raise ValueError("CONCURRENTLY requires PostgreSQL 9.4+") - sql += " CONCURRENTLY" + parts.append("CONCURRENTLY") + parts.append(self._format_materialized_view_name(expr)) + if expr.with_data is not None: + parts.append("WITH DATA" if expr.with_data else "WITH NO DATA") + return " ".join(parts), () - sql += f" {full_name}" + def format_alter_materialized_view_statement( + self, expr: "PostgresAlterMaterializedViewExpression" + ) -> Tuple[str, tuple]: + """Format ALTER MATERIALIZED VIEW statement. - if expr.with_data is not None: - if expr.with_data: - sql += " WITH DATA" - else: - sql += " WITH NO DATA" + Each action renders as its own ``ALTER MATERIALIZED VIEW `` + statement; multiple actions are joined with ``";\\n"``. + + Args: + expr: PostgresAlterMaterializedViewExpression with the actions to apply + + Returns: + Tuple of (SQL statement, parameters tuple); ``params`` is always empty. + + Raises: + UnsupportedFeatureError: If the dialect does not support + ALTER MATERIALIZED VIEW. + """ + if not self.supports_alter_materialized_view(): + raise UnsupportedFeatureError(self.name, "ALTER MATERIALIZED VIEW") + + target = self._format_materialized_view_name(expr) + rendered = [ + f"ALTER MATERIALIZED VIEW {target} {self.format_materialized_view_alter_action(action)}" + for action in expr.actions + ] + return ";\n".join(rendered), () + + def format_materialized_view_alter_action(self, expr: Any) -> str: + """Render one ALTER MATERIALIZED VIEW action body. + + Args: + expr: A ``MaterializedViewAlterAction`` subclass instance. + + Returns: + The action SQL without the leading ``ALTER MATERIALIZED VIEW ``. + + Raises: + UnsupportedFeatureError: If the action is not a known PostgreSQL + materialized view action. + """ + if isinstance(expr, PostgresRenameMaterializedViewAction): + return f"RENAME TO {self.format_identifier(expr.new_name)}" + if isinstance(expr, PostgresSetMaterializedViewSchemaAction): + return f"SET SCHEMA {self.format_identifier(expr.new_schema)}" + if isinstance(expr, PostgresSetMaterializedViewPropertiesAction): + return f"SET ({self._format_storage_parameters(expr.properties)})" + if isinstance(expr, PostgresResetMaterializedViewPropertiesAction): + return f"RESET ({self._format_storage_parameter_names(expr.parameters)})" + if isinstance(expr, PostgresChangeMaterializedViewOwnerAction): + if expr.new_owner.upper() in _CURRENT_ROLE_KEYWORDS: + return f"OWNER TO {expr.new_owner.upper()}" + return f"OWNER TO {self.format_identifier(expr.new_owner)}" + raise UnsupportedFeatureError( + self.name, + f"ALTER MATERIALIZED VIEW action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) + + # ------------------------------------------------------------------ + # Internal checks + # ------------------------------------------------------------------ - return sql, () + def _check_concurrent_refresh_support(self, expr: Any) -> None: + """Raise when CONCURRENTLY is requested but unsupported by the server version.""" + if getattr(expr, "concurrent", False) and not self.supports_materialized_view_concurrent_refresh(): + raise UnsupportedFeatureError( + self.name, + "REFRESH MATERIALIZED VIEW CONCURRENTLY", + f"Requires PostgreSQL 9.4+, current version is {self.version}.", + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py index 806bd957..270863a6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py @@ -1,38 +1,176 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py -"""PostgreSQL Property Graph Query (SQL/PGQ) support mixin. +"""PostgreSQL property graph feature gates and formatter safeguards. -PostgreSQL 19+ natively supports SQL/PGQ: -- GRAPH_TABLE (MATCH ... COLUMNS ...) -- CREATE / DROP / ALTER PROPERTY GRAPH -- Vertex/edge pattern matching with WHERE +Why SQL/PGQ is gated off +------------------------ +PostgreSQL 19 Beta 4 withdrew the whole SQL/PGQ (SQL:2023 property graph) +feature before the 19.0 release. Every graph-specific grammar production was +pulled from the parser, so ``CREATE PROPERTY GRAPH``, ``CREATE VERTEX TABLE``, +``CREATE EDGE TABLE``, ``MATCH``, ``GRAPH_TABLE`` and ``PROPERTIES`` are now +plain syntax errors. -Known PG 19 PGQ limitations (not supported): -- Variable-length (quantified) path patterns (``+``, ``*``, ``{n,m}``) -- Comma-separated multiple patterns in a single MATCH clause -- Heterogeneous graphs require explicit edge label specification +Verified against a live PostgreSQL 19beta4 server: all of the above are rejected +with SQLSTATE 42601, and the supporting catalog objects are gone as well +(``pg_proc`` graph/vertex/edge functions: 0, ``pg_type`` graph/agtype types: 0, +``pg_class`` property-graph relations: 0, and the GRAPH_TABLE/VERTEX/EDGE/ +PROPERTY parser keywords: 0). The same probes against 19beta3 return 1 / 14 / 2 +/ 4 respectively, so the withdrawal is what removed them. + +Because no PostgreSQL release has ever shipped SQL/PGQ, the capability is +resolved from explicit opt-ins rather than from ``self.version``: a version +number is not evidence of support, and inferring support from it would +re-advertise a feature that does not exist. The expression classes and +formatters are intentionally left in place and reachable through +``graph_feature_overrides`` so the plumbing can be exercised and a future +implementation (or a compatibility shim) can opt in without a rewrite. """ +from typing import Callable, Mapping, Tuple, TYPE_CHECKING, cast + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.graph import ( + AlterPropertyGraphExpression, + ColumnsClause, + GraphEdge, + MatchClause, + ) + class PostgresPropertyGraphQueryMixin: - """PostgreSQL Property Graph Query support implementation. + """Fail-closed PostgreSQL property graph capabilities and validation. - SQL/PGQ is a native feature since PostgreSQL 19. + Every probe here answers ``False``. SQL/PGQ was withdrawn in PostgreSQL + 19 Beta 4 and shipped in no earlier release, so there is no server version + for which these capabilities may be reported as supported. See the module + docstring for the catalog evidence behind that decision. """ + #: Explicit opt-in keys. SQL/PGQ was withdrawn in PostgreSQL 19 Beta 4, so + #: nothing enables these by default -- they exist only for compatibility + #: testing and for a future opt-in implementation. + GRAPH_FEATURE_NAMES = frozenset(("graph_match", "graph_table")) + ALTER_ACTIONS = { + "add": "ADD", + "drop": "DROP", + } + ALTER_TARGETS = { + "edge tables": "EDGE TABLES", + "tables": "TABLES", + "vertex tables": "VERTEX TABLES", + } + def supports_graph_match(self) -> bool: - """MATCH clause is supported since PostgreSQL 19.""" - return self.version >= (19, 0, 0) + """Return the explicit graph MATCH capability override. + + Off unless a caller passes ``graph_feature_overrides={"graph_match": + True}``. Deliberately independent of ``self.version``: the MATCH clause + does not exist in any released PostgreSQL, including 19beta4. + """ + overrides = getattr(self, "_graph_feature_overrides", {}) or {} + return overrides.get("graph_match") is True def supports_quantified_path(self) -> bool: - """Quantified (variable-length) path patterns are **not** supported - in PostgreSQL 19.""" + """Return whether quantified graph paths are explicitly enabled. + + Hard-coded off with no override path. Quantified paths (``+``, ``*``, + ``{n,m}``) were part of the withdrawn SQL/PGQ grammar, and no + PostgreSQL release implements them. + """ return False def supports_comma_separated_patterns(self) -> bool: - """Comma-separated multi-pattern MATCH is **not** supported - in PostgreSQL 19.""" + """Return whether comma-separated graph patterns are explicitly enabled. + + Hard-coded off with no override path, for the same reason as + :meth:`supports_quantified_path`. + """ return False def supports_graph_table(self) -> bool: - """GRAPH_TABLE expression is supported since PostgreSQL 19.""" - return self.version >= (19, 0, 0) + """Return the explicit GRAPH_TABLE capability override. + + Requires both overrides, because ``GRAPH_TABLE`` wraps a MATCH clause + and is meaningless without it. + """ + overrides = getattr(self, "_graph_feature_overrides", {}) or {} + return ( + overrides.get("graph_match") is True + and overrides.get("graph_table") is True + ) + + def _runtime_attribute(self, name: str) -> object: + return getattr(self, name) + + def _require_graph_match(self) -> None: + if not self.supports_graph_match(): + dialect_name = cast(str, self._runtime_attribute("name")) + raise UnsupportedFeatureError(dialect_name, "graph MATCH clause") + + def _require_graph_table(self, feature_name: str) -> None: + if not self.supports_graph_table(): + dialect_name = cast(str, self._runtime_attribute("name")) + raise UnsupportedFeatureError(dialect_name, feature_name) + + def _delegate_graph_formatter( + self, method_name: str, expression: object + ) -> Tuple[str, tuple]: + formatter = cast( + Callable[[object], Tuple[str, tuple]], + getattr(super(), method_name), + ) + return formatter(expression) + + def format_graph_edge(self, edge: "GraphEdge") -> Tuple[str, tuple]: + """Validate and format a graph edge.""" + self._require_graph_match() + + from rhosocial.activerecord.backend.expression.graph import GraphEdge, GraphEdgeDirection + + if not isinstance(edge, GraphEdge): + raise TypeError("GraphEdge must be a GraphEdge expression") + if edge.table is not None and edge.variable is None: + raise ValueError("GraphEdge table requires a variable") + if not isinstance(edge.direction, GraphEdgeDirection): + raise ValueError(f"Invalid graph edge direction: {edge.direction!r}") + + return self._delegate_graph_formatter("format_graph_edge", edge) + + def format_match_clause(self, clause: "MatchClause") -> Tuple[str, tuple]: + """Validate and format a MATCH clause.""" + self._require_graph_match() + + return self._delegate_graph_formatter("format_match_clause", clause) + + def format_graph_columns_clause(self, columns: "ColumnsClause") -> Tuple[str, tuple]: + """Format a GRAPH_TABLE COLUMNS clause after checking capability.""" + self._require_graph_table("GRAPH_TABLE COLUMNS clause") + return self._delegate_graph_formatter("format_graph_columns_clause", columns) + + @staticmethod + def _normalize_alter_keyword(value: object, allowed: Mapping[str, str], label: str) -> str: + if isinstance(value, str): + normalized = allowed.get(value.casefold()) + if normalized is not None: + return normalized + raise ValueError(f"Invalid ALTER PROPERTY GRAPH {label}: {value!r}") + + def format_alter_property_graph_statement( + self, expr: "AlterPropertyGraphExpression" + ) -> Tuple[str, tuple]: + """Validate and format an ALTER PROPERTY GRAPH statement.""" + self._require_graph_table("ALTER PROPERTY GRAPH") + + from rhosocial.activerecord.backend.expression.graph import AlterPropertyGraphExpression + + if not isinstance(expr, AlterPropertyGraphExpression): + raise TypeError( + "ALTER PROPERTY GRAPH expression must be an " + "AlterPropertyGraphExpression" + ) + self._normalize_alter_keyword(expr.action, self.ALTER_ACTIONS, "action") + self._normalize_alter_keyword(expr.target, self.ALTER_TARGETS, "target") + return self._delegate_graph_formatter( + "format_alter_property_graph_statement", expr + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py index 49db620f..99603159 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py @@ -6,7 +6,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if TYPE_CHECKING: - from ...expression.statements.ddl_truncate import TruncateExpression + from ....expression.statements.ddl_truncate import TruncateExpression class PostgresTruncateMixin: @@ -29,7 +29,13 @@ def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tu - ``expr.cascade`` — add ``CASCADE``. """ parts = ["TRUNCATE TABLE"] - parts.append(self.format_identifier(expr.table_name)) + table_name = self.format_identifier(expr.table_name) + if expr.schema: + table_name = ( + f"{self.format_identifier(expr.schema)}." + f"{table_name}" + ) + parts.append(table_name) if expr.restart_identity: if not self.supports_truncate_restart_identity(): diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py index 03442f05..2d956717 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py @@ -13,8 +13,9 @@ - format_create_range_type: DDL statement """ -from typing import Tuple +from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.expression.statements.ddl_type import CreateTypeExpression class PostgresDataTypeMixin: """Mixin providing PostgreSQL data type support methods. @@ -23,6 +24,14 @@ class PostgresDataTypeMixin: Designed for multiple inheritance with SQLDialectBase. """ + if TYPE_CHECKING: + version: Tuple[int, int, int] + + def format_create_type_statement( + self, + expr: CreateTypeExpression, + ) -> Tuple[str, tuple]: ... + def supports_multirange_type(self) -> bool: """Whether multirange data types are supported. @@ -72,29 +81,8 @@ def supports_xid8_type(self) -> bool: """ return self.version >= (13, 0, 0) - def format_create_range_type(self, expr) -> Tuple[str, tuple]: - """Format CREATE TYPE AS RANGE statement from expression object. - - Args: - expr: PostgresCreateRangeTypeExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) - """ - exists_clause = "IF NOT EXISTS " if expr.if_not_exists else "" - type_name = f"{expr.schema}.{expr.name}" if expr.schema else expr.name - - options = [f"subtype={expr.subtype}"] - - if expr.subtype_opclass: - options.append(f"subtype_opclass={expr.subtype_opclass}") - if expr.collation: - options.append(f"collation={expr.collation}") - if expr.canonical: - options.append(f"canonical={expr.canonical}") - if expr.subtype_diff: - options.append(f"subtype_diff={expr.subtype_diff}") - - options_str = ", ".join(options) - sql = f"CREATE TYPE {exists_clause}{type_name} AS RANGE ({options_str})" - return (sql, ()) + def format_create_range_type( + self, + expr: CreateTypeExpression, + ) -> Tuple[str, tuple]: + return self.format_create_type_statement(expr) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py index aafe9250..8969dbd6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py @@ -6,6 +6,7 @@ import re from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin from rhosocial.activerecord.backend.dialect.protocols import DDLTypeSupport from rhosocial.activerecord.backend.expression.types import ( @@ -40,10 +41,6 @@ PostgresCharacterVaryingType, ) -if TYPE_CHECKING: - pass - - class PostgresTypeFormatSupportMixin(DDLTypeMixin, DDLTypeSupport): """PostgreSQL DataType formatting and parsing. @@ -52,6 +49,10 @@ class PostgresTypeFormatSupportMixin(DDLTypeMixin, DDLTypeSupport): ``DataType`` instances. """ + if TYPE_CHECKING: + name: str + version: Tuple[int, int, int] + # ------------------------------------------------------------------ # DDLTypeSupport — formatting # ------------------------------------------------------------------ @@ -162,23 +163,32 @@ def format_data_type_postgres_tstzrange(self, data_type) -> Tuple[str, tuple]: def format_data_type_postgres_daterange(self, data_type) -> Tuple[str, tuple]: return "DATERANGE", () + def _format_postgres_multirange_data_type(self, sql: str) -> Tuple[str, tuple]: + if self.version < (14, 0, 0): + raise UnsupportedFeatureError( + self.name, + sql, + suggestion="requires PostgreSQL 14+", + ) + return sql, () + def format_data_type_postgres_int4multirange(self, data_type) -> Tuple[str, tuple]: - return "INT4MULTIRANGE", () + return self._format_postgres_multirange_data_type("INT4MULTIRANGE") def format_data_type_postgres_int8multirange(self, data_type) -> Tuple[str, tuple]: - return "INT8MULTIRANGE", () + return self._format_postgres_multirange_data_type("INT8MULTIRANGE") def format_data_type_postgres_nummultirange(self, data_type) -> Tuple[str, tuple]: - return "NUMMULTIRANGE", () + return self._format_postgres_multirange_data_type("NUMMULTIRANGE") def format_data_type_postgres_tsmultirange(self, data_type) -> Tuple[str, tuple]: - return "TSMULTIRANGE", () + return self._format_postgres_multirange_data_type("TSMULTIRANGE") def format_data_type_postgres_tstzmultirange(self, data_type) -> Tuple[str, tuple]: - return "TSTZMULTIRANGE", () + return self._format_postgres_multirange_data_type("TSTZMULTIRANGE") def format_data_type_postgres_datemultirange(self, data_type) -> Tuple[str, tuple]: - return "DATEMULTIRANGE", () + return self._format_postgres_multirange_data_type("DATEMULTIRANGE") def format_data_type_postgres_oid(self, data_type) -> Tuple[str, tuple]: return "OID", () @@ -464,22 +474,22 @@ def supports_data_type_postgres_daterange(self) -> bool: return True def supports_data_type_postgres_int4multirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_int8multirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_nummultirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_tsmultirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_tstzmultirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_datemultirange(self) -> bool: - return True + return self.version >= (14, 0, 0) def supports_data_type_postgres_oid(self) -> bool: return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py index b9f2826e..b679093f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py @@ -1,112 +1,87 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py -"""PostgreSQL ENUM type support mixin. +"""PostgreSQL ENUM compatibility formatting.""" -This module provides the EnumTypeMixin class for handling PostgreSQL -enumerated type operations. -""" +from __future__ import annotations -from typing import Optional, List, Tuple, TYPE_CHECKING +from typing import Any, List, Optional, Sequence, Tuple, cast, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, +) +from ...expression.ddl.type import ( + PostgresAddEnumValueAction, + PostgresDropEnumTypeExpression, + PostgresEnumTypeDefinition, + PostgresRenameEnumValueAction, +) if TYPE_CHECKING: - from rhosocial.activerecord.backend.impl.postgres.expression.ddl.type import ( - PostgresCreateEnumTypeExpression, - PostgresDropEnumTypeExpression, - PostgresAlterEnumAddValueExpression, - EnumTypeNameExpression, - EnumValuesExpression, - CreateEnumTypeExpression, - DropEnumTypeExpression, - AlterEnumAddValueExpression, - ) + from rhosocial.activerecord.backend.dialect import SQLDialectBase +__all__ = ["EnumTypeMixin"] -class EnumTypeMixin: - """Mixin providing PostgreSQL ENUM type formatting methods. - This mixin implements the EnumTypeSupport protocol. - """ +class EnumTypeMixin: + if TYPE_CHECKING: + name: str + version: Tuple[int, int, int] - def format_enum_type_name(self, name: str, schema: Optional[str] = None) -> Tuple[str, tuple]: - """Format enum type name with optional schema. + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: ... - Args: - name: Type name - schema: Optional schema name + def inline_sql_literal(self, value: object) -> str: ... - Returns: - Tuple of (formatted type name, empty params tuple) + def format_create_type_statement(self, expr: CreateTypeExpression) -> Tuple[str, tuple]: ... - """ - if schema: - return (f"{schema}.{name}", ()) - return (name, ()) + def format_drop_type_statement(self, expr: DropTypeExpression) -> Tuple[str, tuple]: ... - def format_enum_type_name_expression(self, expr: "EnumTypeNameExpression") -> Tuple[str, tuple]: - """Format enum type name from expression object. + def format_alter_type_statement(self, expr: AlterTypeExpression) -> Tuple[str, tuple]: ... - Args: - expr: :class:`EnumTypeNameExpression` instance. + def _as_dialect(self) -> "SQLDialectBase": + return cast("SQLDialectBase", self) - Returns: - Tuple of (SQL string, empty params tuple). + def format_enum_type_name( + self, + name: str, + schema: Optional[str] = None, + ) -> Tuple[str, tuple]: + value = f"{schema}.{name}" if schema is not None else name + parts = value.split(".") + if not parts or any(not part.strip() for part in parts): + raise ValueError("ENUM type identifiers must contain non-empty segments") + return ".".join(self.format_identifier(part) for part in parts), () - """ + def format_enum_type_name_expression(self, expr: Any) -> Tuple[str, tuple]: return self.format_enum_type_name(expr.name, expr.schema) - def format_enum_values(self, values: List[str]) -> Tuple[str, tuple]: - """Format enum values list for SQL. - - Args: - values: List of enum values - - Returns: - Tuple of (SQL-formatted values string, empty params tuple) - - """ - return (", ".join(f"'{v}'" for v in values), ()) - - def format_enum_values_expression(self, expr: "EnumValuesExpression") -> Tuple[str, tuple]: - """Format enum values list from expression object. - - Args: - expr: :class:`EnumValuesExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). + def format_enum_values(self, values: Sequence[str]) -> Tuple[str, tuple]: + labels = list(values or []) + if any(not isinstance(label, str) for label in labels): + raise TypeError("ENUM values must be strings") + return ", ".join(self.inline_sql_literal(label) for label in labels), () - """ + def format_enum_values_expression(self, expr: Any) -> Tuple[str, tuple]: return self.format_enum_values(expr.values) def format_create_enum_type_raw( - self, name: str, values: List[str], schema: Optional[str] = None, if_not_exists: bool = False + self, + name: str, + values: List[str], + schema: Optional[str] = None, + if_not_exists: bool = False, ) -> Tuple[str, tuple]: - """Format CREATE TYPE statement for enum. - - Args: - name: Type name - values: Allowed values - schema: Optional schema - if_not_exists: Add IF NOT EXISTS - - Returns: - Tuple of (SQL statement string, empty params tuple) - - """ - full_name, _ = self.format_enum_type_name(name, schema) - values_str, _ = self.format_enum_values(values) - exists_clause = "IF NOT EXISTS " if if_not_exists else "" - return (f"CREATE TYPE {exists_clause}{full_name} AS ENUM ({values_str})", ()) - - def format_create_enum_type_raw_expression(self, expr: "CreateEnumTypeExpression") -> Tuple[str, tuple]: - """Format CREATE TYPE ... AS ENUM from expression object. - - Args: - expr: :class:`CreateEnumTypeExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). + dialect = self._as_dialect() + expr = CreateTypeExpression( + dialect, + name, + PostgresEnumTypeDefinition(dialect, values), + schema_name=schema, + if_not_exists=if_not_exists, + ) + return self.format_create_type_statement(expr) - """ + def format_create_enum_type_raw_expression(self, expr: Any) -> Tuple[str, tuple]: return self.format_create_enum_type_raw( expr.name, expr.values, @@ -115,35 +90,22 @@ def format_create_enum_type_raw_expression(self, expr: "CreateEnumTypeExpression ) def format_drop_enum_type_raw( - self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False + self, + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, ) -> Tuple[str, tuple]: - """Format DROP TYPE statement. - - Args: - name: Type name - schema: Optional schema - if_exists: Add IF EXISTS - cascade: Add CASCADE - - Returns: - Tuple of (SQL statement string, empty params tuple) - - """ - full_name, _ = self.format_enum_type_name(name, schema) - exists_clause = "IF EXISTS " if if_exists else "" - cascade_clause = " CASCADE" if cascade else "" - return (f"DROP TYPE {exists_clause}{full_name}{cascade_clause}", ()) - - def format_drop_enum_type_raw_expression(self, expr: "DropEnumTypeExpression") -> Tuple[str, tuple]: - """Format DROP TYPE from expression object. - - Args: - expr: :class:`DropEnumTypeExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). + expr = PostgresDropEnumTypeExpression( + self._as_dialect(), + name, + schema=schema, + if_exists=if_exists, + cascade=cascade, + ) + return self.format_drop_type_statement(expr) - """ + def format_drop_enum_type_raw_expression(self, expr: Any) -> Tuple[str, tuple]: return self.format_drop_enum_type_raw( expr.name, expr.schema, @@ -152,40 +114,24 @@ def format_drop_enum_type_raw_expression(self, expr: "DropEnumTypeExpression") - ) def format_alter_enum_add_value_raw( - self, type_name: str, new_value: str, schema: Optional[str] = None, - before: Optional[str] = None, after: Optional[str] = None - ) -> Tuple[str, tuple]: - """Format ALTER TYPE ADD VALUE statement. - - Args: - type_name: Type name - new_value: New value to add - schema: Optional schema - before: Add before this value - after: Add after this value - - Returns: - Tuple of (SQL statement string, empty params tuple) - - """ - full_name, _ = self.format_enum_type_name(type_name, schema) - sql = f"ALTER TYPE {full_name} ADD VALUE '{new_value}'" - if before: - sql += f" BEFORE '{before}'" - elif after: - sql += f" AFTER '{after}'" - return (sql, ()) - - def format_alter_enum_add_value_raw_expression(self, expr: "AlterEnumAddValueExpression") -> Tuple[str, tuple]: - """Format ALTER TYPE ADD VALUE from expression object. - - Args: - expr: :class:`AlterEnumAddValueExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ + self, + type_name: str, + new_value: str, + schema: Optional[str] = None, + before: Optional[str] = None, + after: Optional[str] = None, + ) -> Tuple[str, tuple]: + dialect = self._as_dialect() + action = PostgresAddEnumValueAction( + dialect, + new_value, + before=before, + after=after, + ) + expr = AlterTypeExpression(dialect, type_name, [action], schema_name=schema) + return self.format_alter_type_statement(expr) + + def format_alter_enum_add_value_raw_expression(self, expr: Any) -> Tuple[str, tuple]: return self.format_alter_enum_add_value_raw( expr.type_name, expr.new_value, @@ -194,47 +140,28 @@ def format_alter_enum_add_value_raw_expression(self, expr: "AlterEnumAddValueExp expr.after, ) - # ========================================================================= - # Convenience methods for EnumTypeSupport protocol - # ========================================================================= - def create_enum_type( - self, name: str, values: List[str], schema: Optional[str] = None, if_not_exists: bool = False + self, + name: str, + values: List[str], + schema: Optional[str] = None, + if_not_exists: bool = False, ) -> str: - """Generate CREATE TYPE statement for enum. - - Implements EnumTypeSupport protocol. - - Args: - name: Enum type name - values: List of allowed values - schema: Optional schema name - if_not_exists: Add IF NOT EXISTS clause (PG 9.1+) - - Returns: - SQL statement string - - """ - sql, _ = self.format_create_enum_type_raw(name, values, schema, if_not_exists) + sql, _ = self.format_create_enum_type_raw( + name, + values, + schema, + if_not_exists, + ) return sql def drop_enum_type( - self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False + self, + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, ) -> str: - """Generate DROP TYPE statement for enum. - - Implements EnumTypeSupport protocol. - - Args: - name: Enum type name - schema: Optional schema name - if_exists: Add IF EXISTS clause - cascade: Add CASCADE clause - - Returns: - SQL statement string - - """ sql, _ = self.format_drop_enum_type_raw(name, schema, if_exists, cascade) return sql @@ -246,43 +173,16 @@ def alter_enum_add_value( before: Optional[str] = None, after: Optional[str] = None, ) -> str: - """Generate ALTER TYPE ADD VALUE statement. - - Implements EnumTypeSupport protocol. - - Note: Requires PostgreSQL 9.1+ - - Args: - type_name: Enum type name - new_value: New value to add - schema: Optional schema name - before: Add before this value - after: Add after this value - - Returns: - SQL statement string - - """ - sql, _ = self.format_alter_enum_add_value_raw(type_name, new_value, schema, before, after) + sql, _ = self.format_alter_enum_add_value_raw( + type_name, + new_value, + schema, + before, + after, + ) return sql - # ========================================================================= - # Expression-based methods (for expression-dialect architecture) - # ========================================================================= - - def format_create_enum_type( - self, - expr: "PostgresCreateEnumTypeExpression", - ) -> Tuple[str, tuple]: - """Format CREATE TYPE ... AS ENUM from its expression node. - - Args: - expr: :class:`PostgresCreateEnumTypeExpression` instance. - - Returns: - Tuple of (SQL string, params tuple). - - """ + def format_create_enum_type(self, expr: Any) -> Tuple[str, tuple]: return self.format_create_enum_type_raw( expr.name, expr.values, @@ -290,19 +190,7 @@ def format_create_enum_type( expr.if_not_exists, ) - def format_drop_enum_type( - self, - expr: "PostgresDropEnumTypeExpression", - ) -> Tuple[str, tuple]: - """Format DROP TYPE from its expression node. - - Args: - expr: :class:`PostgresDropEnumTypeExpression` instance. - - Returns: - Tuple of (SQL string, params tuple). - - """ + def format_drop_enum_type(self, expr: Any) -> Tuple[str, tuple]: return self.format_drop_enum_type_raw( expr.name, expr.schema, @@ -310,19 +198,7 @@ def format_drop_enum_type( expr.cascade, ) - def format_alter_enum_add_value( - self, - expr: "PostgresAlterEnumAddValueExpression", - ) -> Tuple[str, tuple]: - """Format ALTER TYPE ADD VALUE from its expression node. - - Args: - expr: :class:`PostgresAlterEnumAddValueExpression` instance. - - Returns: - Tuple of (SQL string, params tuple). - - """ + def format_alter_enum_add_value(self, expr: Any) -> Tuple[str, tuple]: return self.format_alter_enum_add_value_raw( expr.type_name, expr.new_value, @@ -331,33 +207,10 @@ def format_alter_enum_add_value( expr.after, ) - def format_enum_type_expression(self, expr) -> Tuple[str, tuple]: - """Format a :class:`PostgresEnumType` type reference expression. - - The reference is schema-qualified when the expression carries a - schema: ``"schema"."name"`` / ``"name"``. - - Args: - expr: :class:`PostgresEnumType` instance. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - if expr.schema: - return (f"{expr.schema}.{expr.name}", ()) - return (expr.name, ()) - - def format_alter_enum_type_add_value(self, expr) -> Tuple[str, tuple]: - """Format ALTER TYPE ADD VALUE statement from expression object. - - Args: - expr: PostgresAlterEnumTypeAddValueExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) + def format_enum_type_expression(self, expr: Any) -> Tuple[str, tuple]: + return self.format_enum_type_name(expr.name, expr.schema) - """ + def format_alter_enum_type_add_value(self, expr: Any) -> Tuple[str, tuple]: return self.format_alter_enum_add_value_raw( expr.type_name, expr.new_value, @@ -366,16 +219,17 @@ def format_alter_enum_type_add_value(self, expr) -> Tuple[str, tuple]: expr.after, ) - def format_alter_enum_type_rename_value(self, expr) -> Tuple[str, tuple]: - """Format ALTER TYPE RENAME VALUE statement from expression object. - - Args: - expr: PostgresAlterEnumTypeRenameValueExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) - - """ - full_name, _ = self.format_enum_type_name(expr.type_name, expr.schema) - sql = f"ALTER TYPE {full_name} RENAME VALUE '{expr.old_value}' TO '{expr.new_value}'" - return (sql, ()) + def format_alter_enum_type_rename_value(self, expr: Any) -> Tuple[str, tuple]: + dialect = self._as_dialect() + action = PostgresRenameEnumValueAction( + dialect, + expr.old_value, + expr.new_value, + ) + statement = AlterTypeExpression( + dialect, + expr.type_name, + [action], + schema_name=expr.schema, + ) + return self.format_alter_type_statement(statement) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py index 73fac075..098ea29a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py @@ -1,21 +1,10 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py -"""PostgreSQL multirange type support mixin implementation. - -This module provides the MultirangeMixin class for handling PostgreSQL -multirange type operations. - -For SQL expression generation of multirange operators and functions, -use the function factories in ``functions/range.py`` instead of the -removed format_* methods. - -Methods retained in this mixin: -- supports_*: Capability detection -- format_create_multirange_type_statement: DDL statement -- format_multirange_agg_function: Complete SELECT query template -""" +"""PostgreSQL multirange support.""" from typing import Optional, Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + if TYPE_CHECKING: from rhosocial.activerecord.backend.impl.postgres.expression.ddl.multirange import ( CreateMultirangeTypeExpression, @@ -23,112 +12,75 @@ ) +__all__ = ["MultirangeMixin"] + + class MultirangeMixin: - """Mixin providing PostgreSQL multirange type support methods. + if TYPE_CHECKING: + name: str + version: Tuple[int, int, int] - This mixin implements the PostgresMultirangeSupport protocol. - Designed for multiple inheritance with SQLDialectBase. - """ + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: ... def supports_multirange(self) -> bool: - """Check if multirange types are supported. - - Returns: - True if PostgreSQL version >= 14.0 - """ return self.version >= (14, 0, 0) def supports_multirange_constructor(self) -> bool: - """Check if multirange constructor function is supported. + return self.version >= (14, 0, 0) - Returns: - True if PostgreSQL version >= 14.0 - """ + def supports_multirange_agg(self) -> bool: return self.version >= (14, 0, 0) def format_create_multirange_type_statement( - self, name: str, range_type: str, schema: Optional[str] = None + self, + name: str, + range_type: str, + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format CREATE TYPE statement for a custom multirange type. - - Note: PostgreSQL automatically creates multirange types when you create - a range type. This method is for documentation and explicit creation - if needed. - - Args: - name: The multirange type name - range_type: The associated range type - schema: Optional schema name - - Returns: - Tuple of (SQL statement, parameters) - - Example: - >>> format_create_multirange_type_statement('my_multirange', 'my_range') - ('CREATE TYPE my_multirange AS MULTIRANGE (my_range)', ()) - """ - full_name = f"{schema}.{name}" if schema else name - sql = f"CREATE TYPE {full_name} AS MULTIRANGE ({range_type})" - return (sql, ()) + raise UnsupportedFeatureError( + self.name, + "CREATE TYPE AS MULTIRANGE", + suggestion="set MULTIRANGE_TYPE_NAME on a RANGE definition", + ) def format_multirange_agg_function( - self, range_column: str, table_name: str, where_clause: Optional[str] = None, schema: Optional[str] = None + self, + range_column: str, + table_name: str, + where_clause: Optional[str] = None, + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format multirange_agg aggregate function call. - - The multirange_agg function aggregates multiple ranges into a multirange. - - Args: - range_column: The range column to aggregate - table_name: Table name - where_clause: Optional WHERE clause - schema: Optional schema name - - Returns: - Tuple of (SQL statement, parameters) - - Example: - >>> format_multirange_agg_function('period', 'events') - ('SELECT multirange_agg(period) FROM events', ()) - """ - full_table = f"{schema}.{table_name}" if schema else table_name - sql = f"SELECT multirange_agg({range_column}) FROM {full_table}" + if not self.supports_multirange_agg(): + raise UnsupportedFeatureError( + self.name, + "multirange_agg", + suggestion="requires PostgreSQL 14+", + ) + full_name = f"{schema}.{table_name}" if schema is not None else table_name + table_sql = ".".join(self.format_identifier(part) for part in full_name.split(".")) + column_sql = self.format_identifier(range_column) + sql = f"SELECT multirange_agg({column_sql}) FROM {table_sql}" if where_clause: sql += f" WHERE {where_clause}" - return (sql, ()) - - # ========================================================================= - # Expression-based format methods - # ========================================================================= + return sql, () def format_create_multirange_type_statement_expression( - self, expr: "CreateMultirangeTypeExpression" + self, + expr: "CreateMultirangeTypeExpression", ) -> Tuple[str, tuple]: - """Format CREATE TYPE ... AS MULTIRANGE from expression object. - - Args: - expr: :class:`CreateMultirangeTypeExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ return self.format_create_multirange_type_statement( - expr.name, expr.range_type, expr.schema + expr.name, + expr.range_type, + expr.schema, ) def format_multirange_agg_function_expression( - self, expr: "MultirangeAggFunctionExpression" + self, + expr: "MultirangeAggFunctionExpression", ) -> Tuple[str, tuple]: - """Format multirange_agg aggregate function call from expression object. - - Args: - expr: :class:`MultirangeAggFunctionExpression` instance. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ return self.format_multirange_agg_function( - expr.range_column, expr.table_name, expr.where_clause, expr.schema + expr.range_column, + expr.table_name, + expr.where_clause, + expr.schema, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py index 51a1b905..bd73c444 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py @@ -4,7 +4,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expression.statements.dml import OnConflictClause + from ....expression.statements.dml import OnConflictClause class PostgresUpsertMixin: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py index d844bcdf..6f5eeb91 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py @@ -25,6 +25,7 @@ from .ddl.rls_config import PostgresRlsConfigSupport from .ddl.table_settings import PostgresAlterTableSettingsSupport from .ddl.cluster import PostgresClusterSupport +from .ddl.repack import PostgresRepackSupport from .ddl.domain import PostgresDomainSupport from .ddl.collation import PostgresCollationDDLSupport from .ddl.foreign_table import PostgresForeignTableDDLSupport @@ -33,6 +34,7 @@ # DML protocols from .dml.vacuum import PostgresVacuumSupport +from .dml.copy import PostgresCopySupport from .dml.stored_procedure import PostgresStoredProcedureSupport from .dml.extended_statistics import PostgresExtendedStatisticsSupport from .dml.advisory_lock import PostgresAdvisoryLockSupport @@ -124,6 +126,7 @@ "PostgresRlsConfigSupport", "PostgresAlterTableSettingsSupport", "PostgresClusterSupport", + "PostgresRepackSupport", "PostgresDomainSupport", "PostgresCollationDDLSupport", "PostgresForeignTableDDLSupport", @@ -131,6 +134,7 @@ "PostgresPublicationSupport", # DML protocols "PostgresVacuumSupport", + "PostgresCopySupport", "PostgresStoredProcedureSupport", "PostgresExtendedStatisticsSupport", "PostgresAdvisoryLockSupport", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py index 26f778a5..31555ee9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py @@ -11,6 +11,7 @@ from .rls_config import PostgresRlsConfigSupport from .table_settings import PostgresAlterTableSettingsSupport from .cluster import PostgresClusterSupport +from .repack import PostgresRepackSupport from .domain import PostgresDomainSupport from .collation import PostgresCollationDDLSupport from .foreign_table import PostgresForeignTableDDLSupport @@ -28,6 +29,7 @@ "PostgresRlsConfigSupport", "PostgresAlterTableSettingsSupport", "PostgresClusterSupport", + "PostgresRepackSupport", "PostgresDomainSupport", "PostgresCollationDDLSupport", "PostgresForeignTableDDLSupport", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py index e3a9d854..18175421 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py @@ -33,6 +33,17 @@ class PostgresCommentSupport(Protocol): - All versions """ + def supports_comment_on(self) -> bool: + """Whether standalone ``COMMENT ON`` statements are supported. + + PostgreSQL has no inline ``COMMENT`` syntax; comments are annotated + through the standalone ``COMMENT ON`` statement (available since 7.2). + + Returns: + True + """ + ... + def format_comment_statement(self, expr: "PostgresCommentExpression") -> Tuple[str, tuple]: """Format COMMENT ON statement. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py index c799b56d..5be435ae 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py @@ -13,12 +13,18 @@ - EXCLUDE constraints """ -from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING +from typing import Optional, Protocol, runtime_checkable, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.protocols import ConstraintSupport if TYPE_CHECKING: # pragma: no cover - from rhosocial.activerecord.backend.expression.statements import AddTableConstraint + from rhosocial.activerecord.backend.expression.statements import ( + AddTableConstraint, + AlterConstraint, + TableConstraint, + TableConstraintType, + ValidateConstraint, + ) @runtime_checkable @@ -36,6 +42,24 @@ class PostgresConstraintSupport(ConstraintSupport, Protocol): - EXCLUDE: https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-EXCLUSION """ + def supports_constraint_enforced( + self, + constraint_type: Optional["TableConstraintType"] = None, + ) -> bool: + """Whether CREATE/ADD constraint enforcement control is supported.""" + ... + + def supports_alter_constraint_enforced( + self, + constraint_type: Optional["TableConstraintType"] = None, + ) -> bool: + """Whether ALTER CONSTRAINT enforcement control is supported.""" + ... + + def supports_validate_constraint(self) -> bool: + """Whether VALIDATE CONSTRAINT is supported.""" + ... + def supports_constraint_novalidate(self) -> bool: """Whether NOT VALID constraint option is supported. @@ -70,15 +94,17 @@ def supports_drop_constraint_if_exists(self) -> bool: def format_add_table_constraint_action( self, action: "AddTableConstraint" ) -> Tuple[str, tuple]: - """Format ALTER TABLE ADD CONSTRAINT with PostgreSQL extensions. + """Format ALTER TABLE ADD CONSTRAINT with PostgreSQL extensions.""" + ... - Adds EXCLUDE constraint support and the PostgreSQL-proprietary - ``NOT VALID`` suffix to the standard formatting. + def format_table_constraint(self, expr: "TableConstraint") -> Tuple[str, tuple]: + """Format CREATE TABLE constraints with PostgreSQL extensions.""" + ... - Args: - action: AddTableConstraint action to format. + def format_alter_constraint_action(self, action: "AlterConstraint") -> Tuple[str, tuple]: + """Format ALTER CONSTRAINT enforcement.""" + ... - Returns: - Tuple of (SQL string, parameters tuple) - """ + def format_validate_constraint_action(self, action: "ValidateConstraint") -> Tuple[str, tuple]: + """Format VALIDATE CONSTRAINT.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py index 7a0a64f0..87c382af 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py @@ -1,84 +1,19 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py -"""PostgreSQL DOMAIN protocol definition. +"""PostgreSQL DOMAIN DDL protocol.""" -This module contains the :class:`PostgresDomainSupport` protocol which -defines the interface for PostgreSQL's native CREATE/ALTER/DROP DOMAIN DDL. -""" +from typing import Protocol, Tuple, runtime_checkable -from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.protocols import DomainSupport +from rhosocial.activerecord.backend.expression.statements.ddl_domain import AlterDomainExpression -if TYPE_CHECKING: - from ...expression.ddl.domain import ( - PostgresAlterDomainExpression, - PostgresCreateDomainExpression, - PostgresDropDomainExpression, - ) +__all__ = ["PostgresDomainSupport"] -@runtime_checkable -class PostgresDomainSupport(Protocol): - """PostgreSQL DOMAIN protocol. - - Feature Source: Native support (no extension required) - - Official Documentation: - - CREATE DOMAIN: https://www.postgresql.org/docs/current/sql-createdomain.html - - ALTER DOMAIN: https://www.postgresql.org/docs/current/sql-alterdomain.html - - DROP DOMAIN: https://www.postgresql.org/docs/current/sql-dropdomain.html - - Version Requirements: - - CREATE/ALTER/DROP DOMAIN: PostgreSQL 9.6+ - """ - - def supports_create_domain(self) -> bool: - """Whether CREATE/ALTER/DROP DOMAIN is supported (9.6+).""" - ... - - def format_create_domain_statement( - self, expr: "PostgresCreateDomainExpression" - ) -> Tuple[str, tuple]: - """Format a ``CREATE DOMAIN`` statement. - - Args: - expr: ``PostgresCreateDomainExpression``. - - Returns: - Tuple of (SQL string, params tuple). - - Raises: - UnsupportedFeatureError: dialect predates PostgreSQL 9.6. - """ - ... +@runtime_checkable +class PostgresDomainSupport(DomainSupport, Protocol): def format_postgres_alter_domain_statement( - self, expr: "PostgresAlterDomainExpression" + self, + expr: AlterDomainExpression, ) -> Tuple[str, tuple]: - """Format an ``ALTER DOMAIN`` statement. - - Args: - expr: ``PostgresAlterDomainExpression``. - - Returns: - Tuple of (SQL string, params tuple). - - Raises: - UnsupportedFeatureError: dialect predates PostgreSQL 9.6. - """ ... - - def format_drop_domain_statement( - self, expr: "PostgresDropDomainExpression" - ) -> Tuple[str, tuple]: - """Format a ``DROP DOMAIN`` statement. - - Args: - expr: ``PostgresDropDomainExpression``. - - Returns: - Tuple of (SQL string, empty params tuple). - - Raises: - UnsupportedFeatureError: dialect predates PostgreSQL 9.6. - ValueError: both ``cascade`` and ``restrict``. - """ - ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py index f856c361..a724f765 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py @@ -81,10 +81,9 @@ def supports_concurrent_detach(self) -> bool: ... def supports_concurrent_attach(self) -> bool: - """Whether CONCURRENTLY ATTACH PARTITION is supported. + """Whether CONCURRENTLY ATTACH PARTITION is exposed. - Native feature, PostgreSQL 14+. - Enables non-blocking partition attachment. + This implementation always reports False. """ ... @@ -112,6 +111,30 @@ def supports_partitionwise_aggregate(self) -> bool: """ ... + def supports_add_partition(self) -> bool: + """Whether ADD PARTITION is supported.""" + ... + + def supports_drop_partition(self) -> bool: + """Whether DROP PARTITION is supported.""" + ... + + def supports_truncate_partition(self) -> bool: + """Whether TRUNCATE PARTITION is supported.""" + ... + + def supports_reorganize_partition(self) -> bool: + """Whether REORGANIZE PARTITION is supported.""" + ... + + def supports_attach_partition(self) -> bool: + """Whether ATTACH PARTITION is supported.""" + ... + + def supports_detach_partition(self) -> bool: + """Whether DETACH PARTITION is supported.""" + ... + def format_create_partition_statement(self, expr: "PostgresCreatePartitionExpression") -> Tuple[str, tuple]: """Format CREATE TABLE ... PARTITION OF statement from expression. @@ -148,10 +171,8 @@ def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpres def format_attach_partition_statement(self, expr: "PostgresAttachPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... ATTACH PARTITION statement from expression. - The expression supports: - - RANGE / LIST / HASH partition types with corresponding bound values. - - DEFAULT partition (PG 11+) via ``partition_values={"default": True}``. - - CONCURRENTLY mode (PG 14+) via ``concurrently=True``. + The expression supports RANGE, LIST, and HASH bounds. DEFAULT requires + PostgreSQL 11+. Concurrent attachment is rejected for every version. Args: expr: PostgresAttachPartitionExpression with attach details. @@ -161,8 +182,8 @@ def format_attach_partition_statement(self, expr: "PostgresAttachPartitionExpres Tuple of (SQL string, parameters tuple) Raises: - ValueError: If partition_type is invalid or required bounds missing. - ValueError: If concurrently is used on PostgreSQL < 14. + ValueError: If partition_type is invalid or required bounds are missing. + UnsupportedFeatureError: If ``concurrently=True``. """ ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/repack.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/repack.py new file mode 100644 index 00000000..a762e362 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/repack.py @@ -0,0 +1,39 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/repack.py +"""Runtime protocol for native PostgreSQL REPACK support.""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.repack import PostgresRepackExpression + + +@runtime_checkable +class PostgresRepackSupport(Protocol): + """PostgreSQL 19 native REPACK protocol.""" + + def supports_repack(self) -> bool: + """Whether native REPACK is supported.""" + ... + + def supports_repack_all_tables(self) -> bool: + """Whether whole-database REPACK is supported.""" + ... + + def supports_repack_using_index(self) -> bool: + """Whether REPACK USING INDEX is supported.""" + ... + + def supports_repack_concurrently(self) -> bool: + """Whether REPACK CONCURRENTLY is supported.""" + ... + + def supports_repack_analyze(self) -> bool: + """Whether REPACK ANALYZE is supported.""" + ... + + def format_repack_statement( + self, + expr: "PostgresRepackExpression", + ) -> Tuple[str, tuple]: + """Format a native REPACK statement.""" + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/type.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/type.py index 821915d9..69142060 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/type.py @@ -1,98 +1,49 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/type.py -"""PostgreSQL type DDL protocol. +"""PostgreSQL user-defined TYPE DDL protocol.""" -This module defines the protocol for PostgreSQL-specific type DDL features -that extend beyond standard SQL. -""" +from typing import List, Optional, Protocol, Tuple, Union, runtime_checkable -from typing import Protocol, runtime_checkable, Optional, Tuple, List +from rhosocial.activerecord.backend.dialect.protocols import UserDefinedTypeSupport +from rhosocial.activerecord.backend.expression.statements.ddl_type import DropTypeExpression -@runtime_checkable -class PostgresTypeSupport(Protocol): - """PostgreSQL type DDL protocol. - - Feature Source: Native support (no extension required) - - PostgreSQL supports user-defined types: - - ENUM types (CREATE TYPE ... AS ENUM) - - Composite types (CREATE TYPE ... AS) - - Range types - - Base types (with C functions) - - This protocol focuses on ENUM type DDL support. +__all__ = ["PostgresTypeSupport"] - Official Documentation: - - CREATE TYPE: https://www.postgresql.org/docs/current/sql-createtype.html - - DROP TYPE: https://www.postgresql.org/docs/current/sql-droptype.html - Version Requirements: - - All versions support ENUM types - """ - - def supports_create_type(self) -> bool: - """Whether CREATE TYPE is supported. - - Native feature, all versions. - """ +@runtime_checkable +class PostgresTypeSupport(UserDefinedTypeSupport, Protocol): + def supports_drop_type_cascade(self) -> bool: ... - def supports_drop_type(self) -> bool: - """Whether DROP TYPE is supported. - - Native feature, all versions. - """ + def supports_drop_type_restrict(self) -> bool: ... def supports_type_if_not_exists(self) -> bool: - """Whether CREATE TYPE IF NOT EXISTS is supported. - - PostgreSQL does NOT support IF NOT EXISTS for CREATE TYPE. - Always returns False. - """ ... def supports_type_if_exists(self) -> bool: - """Whether DROP TYPE IF EXISTS is supported. - - Native feature, all versions. - """ ... def supports_type_cascade(self) -> bool: - """Whether DROP TYPE CASCADE is supported. - - Native feature, all versions. - """ ... def format_create_type_enum_statement( - self, name: str, values: List[str], schema: Optional[str] = None + self, + name: str, + values: List[str], + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format CREATE TYPE ... AS ENUM statement. - - Args: - name: Type name - values: List of enum values - schema: Optional schema name - - Returns: - Tuple of (SQL string, parameters tuple) - """ ... def format_drop_type_statement( - self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False + self, + expr: Optional[Union[DropTypeExpression, str]] = None, + schema_name: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + *, + schema: Optional[str] = None, + name: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format DROP TYPE statement. - - Args: - name: Type name - schema: Optional schema name - if_exists: Whether to add IF EXISTS - cascade: Whether to add CASCADE - - Returns: - Tuple of (SQL string, parameters tuple) - """ ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py index 7e25d59c..66a84b84 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py @@ -2,12 +2,14 @@ """DML-related PostgreSQL protocols.""" from .vacuum import PostgresVacuumSupport +from .copy import PostgresCopySupport from .stored_procedure import PostgresStoredProcedureSupport from .extended_statistics import PostgresExtendedStatisticsSupport from .locking import PostgresLockingSupport __all__ = [ "PostgresVacuumSupport", + "PostgresCopySupport", "PostgresStoredProcedureSupport", "PostgresExtendedStatisticsSupport", "PostgresLockingSupport", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/copy.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/copy.py new file mode 100644 index 00000000..17980d97 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/copy.py @@ -0,0 +1,86 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/copy.py +"""Runtime protocol for PostgreSQL COPY expression support.""" + +from typing import Any, Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.copy import PostgresCopyFromExpression, PostgresCopyToExpression + + +@runtime_checkable +class PostgresCopySupport(Protocol): + """PostgreSQL client-stream COPY protocol.""" + + def supports_copy_to(self) -> bool: + """Whether COPY TO STDOUT is supported.""" + ... + + def supports_copy_from(self) -> bool: + """Whether COPY FROM STDIN is supported.""" + ... + + def supports_copy_json(self) -> bool: + """Whether COPY TO JSON is supported.""" + ... + + def supports_copy_from_json(self) -> bool: + """Whether COPY FROM JSON is supported; PostgreSQL never supports it.""" + ... + + def supports_copy_force_array(self) -> bool: + """Whether COPY TO FORCE_ARRAY is supported.""" + ... + + def supports_copy_force_all(self) -> bool: + """Whether COPY FROM FORCE_NOT_NULL/FORCE_NULL accept '*'.""" + ... + + def supports_copy_partitioned_table_to(self) -> bool: + """Whether direct COPY TO supports partitioned tables.""" + ... + + def supports_copy_text_header(self) -> bool: + """Whether COPY text HEADER is supported.""" + ... + + def supports_copy_header_match(self) -> bool: + """Whether COPY FROM HEADER MATCH is supported.""" + ... + + def supports_copy_header_line_count(self) -> bool: + """Whether numeric COPY HEADER line counts are supported.""" + ... + + def supports_copy_on_error(self) -> bool: + """Whether COPY FROM ON_ERROR is supported.""" + ... + + def supports_copy_reject_limit(self) -> bool: + """Whether COPY FROM REJECT_LIMIT is supported.""" + ... + + def supports_copy_set_null(self) -> bool: + """Whether COPY FROM ON_ERROR SET_NULL is supported.""" + ... + + def supports_copy_log_verbosity(self) -> bool: + """Whether COPY FROM LOG_VERBOSITY is supported.""" + ... + + def supports_copy_silent_log(self) -> bool: + """Whether COPY FROM LOG_VERBOSITY SILENT is supported.""" + ... + + def format_copy_to_statement( + self, + expr: "PostgresCopyToExpression", + ) -> Tuple[str, Tuple[Any, ...]]: + """Format COPY TO STDOUT.""" + ... + + def format_copy_from_statement( + self, + expr: "PostgresCopyFromExpression", + ) -> Tuple[str, Tuple[Any, ...]]: + """Format COPY FROM STDIN.""" + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/extension.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/extension.py index f63f22a1..c46137d3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/extension.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/extension.py @@ -10,7 +10,7 @@ from .base import PostgresExtensionInfo if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements import SQLQueryAndParams + from rhosocial.activerecord.backend.expression.bases import SQLQueryAndParams from ..expression.ddl.extension import PostgresCreateExtensionExpression, PostgresDropExtensionExpression diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py index fb3162a7..ed82eae3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py @@ -8,7 +8,10 @@ from typing import Any, Protocol, runtime_checkable, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expression.ddl import PostgresRefreshMaterializedViewExpression + from ..expression.ddl import ( + PostgresAlterMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + ) @runtime_checkable @@ -17,12 +20,14 @@ class PostgresMaterializedViewSupport(Protocol): PostgreSQL's materialized view support extends beyond SQL standard, including: - CONCURRENTLY refresh (requires unique index) + - IF NOT EXISTS on CREATE - TABLESPACE storage - WITH (storage_options) storage parameters + - ALTER MATERIALIZED VIEW actions Version requirements: - Basic materialized view: PostgreSQL 9.3+ - - CONCURRENTLY refresh: PostgreSQL 9.4+ + - CONCURRENTLY refresh / IF NOT EXISTS: PostgreSQL 9.4+ - TABLESPACE: PostgreSQL 9.3+ Note: These features don't require additional plugins, they're part of @@ -31,6 +36,14 @@ class PostgresMaterializedViewSupport(Protocol): Documentation: https://www.postgresql.org/docs/current/sql-creatematerializedview.html """ + def supports_materialized_view(self) -> bool: + """Whether materialized views are supported (PostgreSQL 9.3+).""" + ... + + def supports_refresh_materialized_view(self) -> bool: + """Whether REFRESH MATERIALIZED VIEW is supported (PostgreSQL 9.3+).""" + ... + def supports_materialized_view_concurrent_refresh(self) -> bool: """Whether CONCURRENTLY refresh for materialized views is supported. @@ -39,6 +52,60 @@ def supports_materialized_view_concurrent_refresh(self) -> bool: """ ... + def supports_materialized_view_if_not_exists(self) -> bool: + """Whether CREATE MATERIALIZED VIEW IF NOT EXISTS is supported (PG 9.4+).""" + ... + + def supports_materialized_view_tablespace(self) -> bool: + """Whether TABLESPACE can be specified for materialized views (PG 9.3+).""" + ... + + def supports_materialized_view_storage_options(self) -> bool: + """Whether WITH (storage_parameter) is supported for materialized views (PG 9.3+).""" + ... + + def supports_alter_materialized_view(self) -> bool: + """Whether ALTER MATERIALIZED VIEW is supported (PG 9.3+).""" + ... + + def format_create_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format CREATE MATERIALIZED VIEW statement for PostgreSQL. + + Supports column aliases, schema qualification, IF NOT EXISTS, + WITH (storage_options), TABLESPACE, and WITH DATA / WITH NO DATA. + + Args: + expr: CreateMaterializedViewExpression instance + + Returns: + Tuple of (SQL statement, parameters tuple) + """ + ... + + def format_drop_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format DROP MATERIALIZED VIEW statement for PostgreSQL. + + Supports schema qualification, IF EXISTS and CASCADE. + + Args: + expr: DropMaterializedViewExpression instance + + Returns: + Tuple of (SQL statement, parameters tuple) + """ + ... + + def format_refresh_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format REFRESH MATERIALIZED VIEW statement, gating CONCURRENTLY on PG 9.4+. + + Args: + expr: RefreshMaterializedViewExpression instance + + Returns: + Tuple of (SQL statement, parameters tuple) + """ + ... + def format_refresh_materialized_view_pg_statement( self, expr: "PostgresRefreshMaterializedViewExpression" ) -> Tuple[str, tuple]: @@ -52,16 +119,32 @@ def format_refresh_materialized_view_pg_statement( """ ... - def format_create_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: - """Format CREATE MATERIALIZED VIEW statement for PostgreSQL. + def format_alter_materialized_view_statement( + self, expr: "PostgresAlterMaterializedViewExpression" + ) -> Tuple[str, tuple]: + """Format ALTER MATERIALIZED VIEW statement. - Supports column aliases, TABLESPACE, WITH (storage_options), - and WITH DATA / WITH NO DATA. + Supports RENAME TO, SET SCHEMA, SET (), RESET () and OWNER TO actions. + Multiple actions are joined with ``";\\n"``. Args: - expr: CreateMaterializedViewExpression instance + expr: PostgresAlterMaterializedViewExpression with the actions to apply Returns: Tuple of (SQL statement, parameters tuple) """ ... + + def format_materialized_view_alter_action(self, expr: Any) -> str: + """Render one ALTER MATERIALIZED VIEW action body. + + Storage parameter names must come from + :class:`~....storage_parameters.PostgresStorageParameter`. + + Args: + expr: A ``MaterializedViewAlterAction`` subclass instance. + + Returns: + The action SQL without the leading ``ALTER MATERIALIZED VIEW ``. + """ + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py index 769fd361..3b8fd30f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py @@ -46,8 +46,8 @@ def supports_unlogged_table(self) -> bool: """Whether CREATE UNLOGGED TABLE is supported. UNLOGGED tables skip WAL writes (9.6+), trading crash safety for - speed; callers opt in via ``CreateTableOptions(unlogged=True)`` on - ``CreateTableExpression``. + speed; callers opt in via ``PostgresCreateTableOptions(unlogged=True)`` + on ``CreateTableExpression``. """ ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py index 467eb100..f3530318 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py @@ -1,87 +1,42 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py -"""PostgreSQL multirange type support protocol.""" +"""PostgreSQL multirange protocol.""" -from typing import Protocol, runtime_checkable, Optional, Tuple +from typing import Optional, Protocol, Tuple, runtime_checkable -@runtime_checkable -class PostgresMultirangeSupport(Protocol): - """PostgreSQL multirange type support protocol. - - PostgreSQL 14 introduced multirange types, which are arrays of - non-overlapping ranges. Each range type has a corresponding multirange type. - - Multirange types: - - int4multirange: Multiple int4range values - - int8multirange: Multiple int8range values - - nummultirange: Multiple numrange values - - tsmultirange: Multiple tsrange values - - tstzmultirange: Multiple tstzrange values - - datemultirange: Multiple daterange values +__all__ = ["PostgresMultirangeSupport"] - Version requirement: PostgreSQL 14+ - """ +@runtime_checkable +class PostgresMultirangeSupport(Protocol): def supports_multirange(self) -> bool: - """Check if multirange types are supported. - - Returns: - True if PostgreSQL version >= 14.0 - """ ... def supports_multirange_constructor(self) -> bool: - """Check if multirange constructor function is supported. - - The multirange constructor function (e.g., int4multirange(range1, range2)) - was added in PostgreSQL 14. + ... - Returns: - True if PostgreSQL version >= 14.0 - """ + def supports_multirange_agg(self) -> bool: ... def format_create_multirange_type_statement( - self, name: str, range_type: str, schema: Optional[str] = None + self, + name: str, + range_type: str, + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format CREATE TYPE statement for a custom multirange type. - - Note: PostgreSQL automatically creates multirange types when you create - a range type. This method is for documentation and explicit creation - if needed. - - Args: - name: The multirange type name - range_type: The associated range type - schema: Optional schema name - - Returns: - Tuple of (SQL statement, parameters) - """ ... def format_multirange_agg_function( - self, range_column: str, table_name: str, where_clause: Optional[str] = None, schema: Optional[str] = None + self, + range_column: str, + table_name: str, + where_clause: Optional[str] = None, + schema: Optional[str] = None, ) -> Tuple[str, tuple]: - """Format multirange_agg aggregate function call. - - The multirange_agg function aggregates multiple ranges into a multirange. - - Args: - range_column: The range column to aggregate - table_name: Table name - where_clause: Optional WHERE clause - schema: Optional schema name - - Returns: - Tuple of (SQL statement, parameters) - """ ... - def format_create_multirange_type_statement_expression(self, expr) -> Tuple[str, tuple]: - """Format CREATE TYPE for multirange from expression object.""" + def format_create_multirange_type_statement_expression(self, expr: object) -> Tuple[str, tuple]: ... - def format_multirange_agg_function_expression(self, expr) -> Tuple[str, tuple]: - """Format multirange_agg from expression object.""" + def format_multirange_agg_function_expression(self, expr: object) -> Tuple[str, tuple]: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py index ca2419ba..f1c4a7ca 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py @@ -15,10 +15,6 @@ def supports_temporary_view(self) -> bool: ... def supports_if_exists_view(self) -> bool: ... def supports_view_check_option(self) -> bool: ... def supports_cascade_view(self) -> bool: ... - def supports_materialized_view(self) -> bool: ... - def supports_refresh_materialized_view(self) -> bool: ... - def supports_materialized_view_tablespace(self) -> bool: ... - def supports_materialized_view_storage_options(self) -> bool: ... def format_create_view_statement(self, expr: Any) -> Tuple[str, tuple]: """Format CREATE VIEW statement for PostgreSQL. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/storage_parameters.py b/src/rhosocial/activerecord/backend/impl/postgres/storage_parameters.py new file mode 100644 index 00000000..3a76351a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/storage_parameters.py @@ -0,0 +1,235 @@ +# src/rhosocial/activerecord/backend/impl/postgres/storage_parameters.py +"""PostgreSQL relation storage parameters (reloptions). + +A materialized view is stored as a regular heap relation: the server maps +``RELKIND_MATVIEW`` onto ``RELOPT_KIND_HEAP`` in ``fillRelOptions()`` +(``src/backend/access/common/reloptions.c``). Therefore the parameters accepted +by ``CREATE MATERIALIZED VIEW ... WITH (...)`` / ``ALTER MATERIALIZED VIEW ... +SET (...)`` are exactly the *standard heap relation options* listed in +``stdRdOptionsTab`` in the same file. + +That set is enumerated by :class:`PostgresStorageParameter`, and every MV +storage-parameter expression validates against it — no pattern matching on +user input. + +Scope notes +----------- +* Namespaced options (``toast.autovacuum_enabled`` and friends) are not part of + this enum; pass them with ``allow_unlisted_storage_parameters=True``. +* ``USING method`` table access methods may register additional options + (``add_reloption_kind()`` / ``add_*_reloption()`` are exported server APIs, + e.g. Citus columnar adds ``compresslevel``). Those also require + ``allow_unlisted_storage_parameters=True``. +* ``min_version`` is only populated where PostgreSQL documents a specific + introduction version; ``None`` means "no client-side gate, the server + validates the value". +""" +from enum import Enum +from typing import Any, Dict, Optional, Tuple + + +__all__ = [ + "PostgresStorageParameter", + "PostgresStorageParameterValueType", + "resolve_storage_parameter", + "validate_storage_parameters", +] + + +class PostgresStorageParameterValueType(Enum): + """Value type of a storage parameter, mirroring PostgreSQL ``relopt_type``.""" + + INT = "int" + REAL = "real" + BOOL = "bool" + TERNARY = "ternary" + ENUM = "enum" + STRING = "string" + + +class PostgresStorageParameter(str, Enum): + """Standard heap relation options accepted by materialized views. + + The string value is the exact PostgreSQL option name (lower case, as stored + in ``pg_class.reloptions``). + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import ( + ... PostgresStorageParameter, + ... ) + >>> PostgresStorageParameter.FILLFACTOR.value + 'fillfactor' + >>> PostgresStorageParameter("autovacuum_enabled") is \\ + ... PostgresStorageParameter.AUTOVACUUM_ENABLED + True + """ + + FILLFACTOR = "fillfactor" + TOAST_TUPLE_TARGET = "toast_tuple_target" + PARALLEL_WORKERS = "parallel_workers" + USER_CATALOG_TABLE = "user_catalog_table" + VACUUM_INDEX_CLEANUP = "vacuum_index_cleanup" + VACUUM_TRUNCATE = "vacuum_truncate" + VACUUM_MAX_EAGER_FREEZE_FAILURE_RATE = "vacuum_max_eager_freeze_failure_rate" + + AUTOVACUUM_ENABLED = "autovacuum_enabled" + AUTOVACUUM_PARALLEL_WORKERS = "autovacuum_parallel_workers" + AUTOVACUUM_VACUUM_THRESHOLD = "autovacuum_vacuum_threshold" + AUTOVACUUM_VACUUM_MAX_THRESHOLD = "autovacuum_vacuum_max_threshold" + AUTOVACUUM_VACUUM_INSERT_THRESHOLD = "autovacuum_vacuum_insert_threshold" + AUTOVACUUM_ANALYZE_THRESHOLD = "autovacuum_analyze_threshold" + AUTOVACUUM_VACUUM_COST_LIMIT = "autovacuum_vacuum_cost_limit" + AUTOVACUUM_VACUUM_COST_DELAY = "autovacuum_vacuum_cost_delay" + AUTOVACUUM_VACUUM_SCALE_FACTOR = "autovacuum_vacuum_scale_factor" + AUTOVACUUM_VACUUM_INSERT_SCALE_FACTOR = "autovacuum_vacuum_insert_scale_factor" + AUTOVACUUM_ANALYZE_SCALE_FACTOR = "autovacuum_analyze_scale_factor" + AUTOVACUUM_FREEZE_MIN_AGE = "autovacuum_freeze_min_age" + AUTOVACUUM_FREEZE_MAX_AGE = "autovacuum_freeze_max_age" + AUTOVACUUM_FREEZE_TABLE_AGE = "autovacuum_freeze_table_age" + AUTOVACUUM_MULTIXACT_FREEZE_MIN_AGE = "autovacuum_multixact_freeze_min_age" + AUTOVACUUM_MULTIXACT_FREEZE_MAX_AGE = "autovacuum_multixact_freeze_max_age" + AUTOVACUUM_MULTIXACT_FREEZE_TABLE_AGE = "autovacuum_multixact_freeze_table_age" + LOG_AUTOVACUUM_MIN_DURATION = "log_autovacuum_min_duration" + LOG_AUTOANALYZE_MIN_DURATION = "log_autoanalyze_min_duration" + + @property + def value_type(self) -> PostgresStorageParameterValueType: + """The value type PostgreSQL expects for this parameter.""" + return _VALUE_TYPES[self] + + @property + def min_version(self) -> Optional[Tuple[int, int, int]]: + """First PostgreSQL version accepting this parameter, when documented. + + ``None`` means the parameter has been accepted since materialized views + exist, or that no client-side version gate is applied. + """ + return _MIN_VERSIONS.get(self) + + @property + def enum_values(self) -> Optional[Tuple[str, ...]]: + """Accepted values for ``ENUM`` parameters, ``None`` for other types.""" + return _ENUM_VALUES.get(self) + + +_VALUE_TYPES: Dict[PostgresStorageParameter, PostgresStorageParameterValueType] = { + PostgresStorageParameter.FILLFACTOR: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.TOAST_TUPLE_TARGET: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.PARALLEL_WORKERS: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.USER_CATALOG_TABLE: PostgresStorageParameterValueType.BOOL, + PostgresStorageParameter.VACUUM_INDEX_CLEANUP: PostgresStorageParameterValueType.ENUM, + PostgresStorageParameter.VACUUM_TRUNCATE: PostgresStorageParameterValueType.TERNARY, + PostgresStorageParameter.VACUUM_MAX_EAGER_FREEZE_FAILURE_RATE: ( + PostgresStorageParameterValueType.REAL + ), + PostgresStorageParameter.AUTOVACUUM_ENABLED: PostgresStorageParameterValueType.TERNARY, + PostgresStorageParameter.AUTOVACUUM_PARALLEL_WORKERS: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_VACUUM_THRESHOLD: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_VACUUM_MAX_THRESHOLD: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_VACUUM_INSERT_THRESHOLD: ( + PostgresStorageParameterValueType.INT + ), + PostgresStorageParameter.AUTOVACUUM_ANALYZE_THRESHOLD: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_VACUUM_COST_LIMIT: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_VACUUM_COST_DELAY: PostgresStorageParameterValueType.REAL, + PostgresStorageParameter.AUTOVACUUM_VACUUM_SCALE_FACTOR: ( + PostgresStorageParameterValueType.REAL + ), + PostgresStorageParameter.AUTOVACUUM_VACUUM_INSERT_SCALE_FACTOR: ( + PostgresStorageParameterValueType.REAL + ), + PostgresStorageParameter.AUTOVACUUM_ANALYZE_SCALE_FACTOR: ( + PostgresStorageParameterValueType.REAL + ), + PostgresStorageParameter.AUTOVACUUM_FREEZE_MIN_AGE: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_FREEZE_MAX_AGE: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_FREEZE_TABLE_AGE: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.AUTOVACUUM_MULTIXACT_FREEZE_MIN_AGE: ( + PostgresStorageParameterValueType.INT + ), + PostgresStorageParameter.AUTOVACUUM_MULTIXACT_FREEZE_MAX_AGE: ( + PostgresStorageParameterValueType.INT + ), + PostgresStorageParameter.AUTOVACUUM_MULTIXACT_FREEZE_TABLE_AGE: ( + PostgresStorageParameterValueType.INT + ), + PostgresStorageParameter.LOG_AUTOVACUUM_MIN_DURATION: PostgresStorageParameterValueType.INT, + PostgresStorageParameter.LOG_AUTOANALYZE_MIN_DURATION: PostgresStorageParameterValueType.INT, +} + +# Only versions PostgreSQL documents explicitly; None elsewhere. +_MIN_VERSIONS: Dict[PostgresStorageParameter, Optional[Tuple[int, int, int]]] = { + PostgresStorageParameter.PARALLEL_WORKERS: (11, 0, 0), + PostgresStorageParameter.VACUUM_TRUNCATE: (12, 0, 0), + PostgresStorageParameter.AUTOVACUUM_VACUUM_MAX_THRESHOLD: (12, 0, 0), + PostgresStorageParameter.AUTOVACUUM_VACUUM_INSERT_THRESHOLD: (13, 0, 0), + PostgresStorageParameter.AUTOVACUUM_VACUUM_INSERT_SCALE_FACTOR: (13, 0, 0), + PostgresStorageParameter.LOG_AUTOANALYZE_MIN_DURATION: (15, 0, 0), + PostgresStorageParameter.AUTOVACUUM_PARALLEL_WORKERS: (16, 0, 0), + PostgresStorageParameter.VACUUM_MAX_EAGER_FREEZE_FAILURE_RATE: (18, 0, 0), +} + +_ENUM_VALUES: Dict[PostgresStorageParameter, Tuple[str, ...]] = { + PostgresStorageParameter.VACUUM_INDEX_CLEANUP: ("auto", "enabled", "disabled"), +} + + +def resolve_storage_parameter(name: Any) -> Optional[PostgresStorageParameter]: + """Resolve a storage parameter from an enum member or its PostgreSQL name. + + Args: + name: A :class:`PostgresStorageParameter` member or its string value. + + Returns: + The matching member, or ``None`` when the name is not a standard heap + relation option. + """ + if isinstance(name, PostgresStorageParameter): + return name + if isinstance(name, str): + try: + return PostgresStorageParameter(name) + except ValueError: + return None + return None + + +def validate_storage_parameters( + names: Any, + field_name: str, + allow_unlisted: bool = False, +) -> Tuple[PostgresStorageParameter, ...]: + """Validate storage parameter names against :class:`PostgresStorageParameter`. + + Args: + names: Iterable of enum members and/or PostgreSQL option names. + field_name: Field name used in error messages. + allow_unlisted: Accept names outside the standard heap relation option + set (namespaced ``toast.*`` options, or options registered by a + table access method). The server remains the authority. + + Returns: + The resolved members, in input order. When ``allow_unlisted`` is set, + unlisted names are dropped from the result. + + Raises: + ValueError: If a name is not a standard storage parameter and + ``allow_unlisted`` is False. + TypeError: If ``names`` is not an iterable of names. + """ + if isinstance(names, (str, bytes)) or not hasattr(names, "__iter__"): + raise TypeError(f"{field_name} must be an iterable of storage parameter names") + + resolved = [] + for name in names: + parameter = resolve_storage_parameter(name) + if parameter is None: + if allow_unlisted: + continue + raise ValueError( + f"{field_name} contains an unknown PostgreSQL storage parameter: {name!r}. " + f"Pass allow_unlisted=True to forward names registered by a table " + f"access method or namespaced under 'toast.'." + ) + resolved.append(parameter) + return tuple(resolved) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py index e0dbf7ae..23a5b41b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from .backend import PostgresBackend - from .async_backend import AsyncPostgresBackend + from .backend.async_backend import AsyncPostgresBackend class PostgresTransactionMixin: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py b/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py index 843a1321..e27cc15f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py @@ -16,18 +16,23 @@ - Adding values: PostgreSQL 9.1+ """ -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, +) + from ..expression.ddl import ( - PostgresCreateEnumTypeExpression, - PostgresDropEnumTypeExpression, - PostgresAlterEnumTypeAddValueExpression, - PostgresAlterEnumTypeRenameValueExpression, + PostgresAddEnumValueAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRenameEnumValueAction, ) @@ -180,7 +185,7 @@ class EnumTypeManager: and deletion of PostgreSQL enum types. """ - def __init__(self, backend): + def __init__(self, backend: Any) -> None: """Initialize with a database backend. Args: @@ -205,11 +210,14 @@ def create_type(self, enum_type: PostgresEnumType, if_not_exists: bool = False) enum_type: PostgresEnumType instance if_not_exists: Use IF NOT EXISTS clause """ - expr = PostgresCreateEnumTypeExpression( + expr = CreateTypeExpression( dialect=self._backend.dialect, - name=enum_type.name, - values=enum_type.values, - schema=enum_type.schema, + type_name=enum_type.name, + definition=PostgresEnumTypeDefinition( + self._backend.dialect, + enum_type.values, + ), + schema_name=enum_type.schema, if_not_exists=if_not_exists, ) sql, params = expr.to_sql() @@ -224,10 +232,10 @@ def drop_type(self, enum_type: PostgresEnumType, if_exists: bool = False, cascad if_exists: Use IF EXISTS clause cascade: Use CASCADE clause """ - expr = PostgresDropEnumTypeExpression( + expr = PostgresDropTypeExpression( dialect=self._backend.dialect, - name=enum_type.name, - schema=enum_type.schema, + type_name=enum_type.name, + schema_name=enum_type.schema, if_exists=if_exists, cascade=cascade, ) @@ -253,14 +261,18 @@ def add_value( if new_value in enum_type.values: raise ValueError(f"Value '{new_value}' already exists in enum") - expr = PostgresAlterEnumTypeAddValueExpression( - dialect=self._backend.dialect, - type_name=enum_type.name, - new_value=new_value, - schema=enum_type.schema, + action = PostgresAddEnumValueAction( + self._backend.dialect, + new_value, before=before, after=after, ) + expr = AlterTypeExpression( + dialect=self._backend.dialect, + type_name=enum_type.name, + actions=[action], + schema_name=enum_type.schema, + ) sql, params = expr.to_sql() self._backend.execute(sql, params) enum_type._values.append(new_value) @@ -278,12 +290,16 @@ def rename_value(self, enum_type: PostgresEnumType, old_value: str, new_value: s if old_value not in enum_type.values: raise ValueError(f"Value '{old_value}' not found in enum") - expr = PostgresAlterEnumTypeRenameValueExpression( + action = PostgresRenameEnumValueAction( + self._backend.dialect, + old_value, + new_value, + ) + expr = AlterTypeExpression( dialect=self._backend.dialect, type_name=enum_type.name, - old_value=old_value, - new_value=new_value, - schema=enum_type.schema, + actions=[action], + schema_name=enum_type.schema, ) sql, params = expr.to_sql() self._backend.execute(sql, params) diff --git a/tests/providers/partition.py b/tests/providers/partition.py index dd6bc575..c6d082f6 100644 --- a/tests/providers/partition.py +++ b/tests/providers/partition.py @@ -6,6 +6,7 @@ from typing import Any, ClassVar, Dict, List, Optional, Tuple, Type from rhosocial.activerecord.base.field_proxy import FieldProxy +from rhosocial.activerecord.base import IdentityAttribute from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, @@ -18,6 +19,7 @@ from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend from rhosocial.activerecord.backend.impl.postgres.expression import ( PostgresAttachPartitionExpression, + PostgresColumnDefinition, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresPartitionMetadataExpression, @@ -252,7 +254,7 @@ def _create_parent_sql(self, dialect): dialect=dialect, table=self.TABLE_NAME, columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), dialect_options={"identity": "BY DEFAULT"}), + PostgresColumnDefinition(dialect, "id", BigIntType(dialect=dialect), attributes=[IdentityAttribute(generation="BY DEFAULT")]), ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "tenant_id", IntegerType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "payload", TextType(dialect=dialect)), @@ -319,7 +321,6 @@ def _event_model(self, backend): class PartitionEvent(ActiveRecord): __table_name__ = table_name - __primary_key__ = "id" __backend__ = backend c: ClassVar[FieldProxy] = FieldProxy() @@ -336,7 +337,6 @@ def _async_event_model(self, backend): class AsyncPartitionEvent(AsyncActiveRecord): __table_name__ = table_name - __primary_key__ = "id" __backend__ = backend c: ClassVar[FieldProxy] = FieldProxy() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py index ba63d63b..4d13f625 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py @@ -266,7 +266,7 @@ async def async_postgres_control_backend(): def postgres_dialect(): """Fixture providing PostgresDialect instance for testing transaction expressions.""" from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect - return PostgresDialect() + return PostgresDialect(version=(15, 0, 0)) # --- Extension Availability Utilities --- diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py index 18fa6841..3d335e1d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py @@ -122,11 +122,11 @@ def dialect(self): return PostgresDialect(version=(15, 0, 0)) def test_set_data_type_plain(self, dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterColumn, + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterColumn, ) - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DATA TYPE", new_value="NUMERIC(10,2)" ) sql, params = action.to_sql() @@ -135,16 +135,16 @@ def test_set_data_type_plain(self, dialect): def test_set_data_type_with_using(self, dialect): from rhosocial.activerecord.backend.expression import Column, Literal - from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterColumn, + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterColumn, ) - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DATA TYPE", new_value="NUMERIC(10,2)", - dialect_options={"using": Column(dialect, "price") + Literal(dialect, 1)}, + using=Column(dialect, "price") + Literal(dialect, 1), ) sql, serialized = action.to_sql() assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql @@ -153,16 +153,16 @@ def test_set_data_type_with_using(self, dialect): def test_set_data_type_using_rejected_for_other_ops(self, dialect): from rhosocial.activerecord.backend.expression import Column - from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterColumn, + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterColumn, ) - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DEFAULT", new_value="0", - dialect_options={"using": Column(dialect, "price")}, + using=Column(dialect, "price"), ) with pytest.raises(ValueError, match="USING"): action.to_sql() @@ -218,10 +218,10 @@ class TestPostgresCreateUnloggedTable: @staticmethod def _options(dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableOptions, + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateTableOptions, ) - return CreateTableOptions(dialect, unlogged=True) + return PostgresCreateTableOptions(dialect, unlogged=True) def test_unlogged_renders_qualifier(self, dialect): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( @@ -276,7 +276,9 @@ def test_version_gate_94(self): ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( CreateTableExpression, - CreateTableOptions, + ) + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateTableOptions, ) low = PostgresDialect(version=(9, 4, 0)) @@ -284,7 +286,7 @@ def test_version_gate_94(self): low, table="audit", columns=[ColumnDefinition(low, "id", TextType(dialect=low))], - table_options=CreateTableOptions(low, unlogged=True), + table_options=PostgresCreateTableOptions(low, unlogged=True), ) with pytest.raises(UnsupportedFeatureError): expr.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view.py new file mode 100644 index 00000000..957e192f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view.py @@ -0,0 +1,812 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view.py +"""PostgreSQL materialized view DDL tests (SQL generation only, no database). + +Covers the regression matrix documented in +``.claude/plan/2026-09-26/materialized-view-coverage.md``: + +- G1: ``PostgresMaterializedViewMixin`` must precede the core ``ViewMixin`` in + the MRO, otherwise the PostgreSQL CREATE formatter is dead code and + ``storage_options`` is silently dropped. +- G2/G3: version gates and exception contract for ``REFRESH ... CONCURRENTLY``. +- G4: schema qualification and identifier quoting for all MV statements. +- G5: ``CREATE MATERIALIZED VIEW IF NOT EXISTS`` (PG 9.4+). +- G6: ``ALTER MATERIALIZED VIEW`` (RENAME / SET SCHEMA / SET() / RESET() / OWNER). +- G8: protocol and package surface. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins import ViewMixin +from rhosocial.activerecord.backend.expression import ( + Column, + CreateMaterializedViewExpression, + DropMaterializedViewExpression, + QueryExpression, + RefreshMaterializedViewExpression, + TableExpression, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + MaterializedViewAlterAction, + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresCreateMaterializedViewExpression, + PostgresDropMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + PostgresRenameMaterializedViewAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) +from rhosocial.activerecord.backend.impl.postgres.mixins.materialized_view import ( + PostgresMaterializedViewMixin, +) +from rhosocial.activerecord.backend.impl.postgres.protocols.materialized_view import ( + PostgresMaterializedViewSupport, +) +from rhosocial.activerecord.backend.impl.postgres.storage_parameters import ( + PostgresStorageParameter, + PostgresStorageParameterValueType, + resolve_storage_parameter, + validate_storage_parameters, +) + +PG_93 = (9, 3, 0) +PG_94 = (9, 4, 0) +PG_15 = (15, 0, 0) + + +def _dialect(version=PG_15): + return PostgresDialect(version=version) + + +def _source_query(dialect): + return QueryExpression( + dialect=dialect, + select=[Column(dialect, "product_id")], + from_=TableExpression(dialect, "sales"), + ) + + +def _create(dialect, **kwargs): + kwargs.setdefault("view_name", "sales_summary") + kwargs.setdefault("query", _source_query(dialect)) + return CreateMaterializedViewExpression(dialect=dialect, **kwargs) + + +class TestMaterializedViewMixinResolution: + """Lock down mixin resolution so PG-specific MV formatting cannot go dead again.""" + + def test_pg_mv_mixin_precedes_core_view_mixin(self): + """T-01: PG MV mixin must come before the core ViewMixin.""" + mro = PostgresDialect.__mro__ + assert mro.index(PostgresMaterializedViewMixin) < mro.index(ViewMixin) + + def test_create_mv_formatter_owned_by_pg_mixin(self): + """T-02: CREATE MATERIALIZED VIEW must be formatted by the PG mixin.""" + owner = _dialect().format_create_materialized_view_statement.__qualname__ + assert owner.startswith("PostgresMaterializedViewMixin.") + + def test_drop_mv_formatter_owned_by_pg_mixin(self): + """T-03: the DROP path is PG-owned (schema aware) and keeps the core SQL shape.""" + dialect = _dialect() + owner = dialect.format_drop_materialized_view_statement.__qualname__ + assert owner.startswith("PostgresMaterializedViewMixin.") + sql, _ = DropMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", if_exists=True, cascade=True + ).to_sql() + assert sql == 'DROP MATERIALIZED VIEW IF EXISTS "sales_summary" CASCADE' + + @pytest.mark.parametrize( + "capability,formatter", + [ + ("supports_materialized_view_tablespace", "format_create_materialized_view_statement"), + ("supports_materialized_view_storage_options", "format_create_materialized_view_statement"), + ], + ) + def test_pg_only_capability_has_pg_owned_formatter(self, capability, formatter): + """T-04: a declared PG-only capability must have a PG-owned formatter behind it. + + This is the generic guard against "capability probe says True but the + formatter never runs" (the exact shape of the G1 defect). + """ + dialect = _dialect() + assert getattr(dialect, capability)() is True + owner = getattr(dialect, formatter).__qualname__ + assert owner.startswith("PostgresMaterializedViewMixin.") + + +class TestCreateMaterializedViewFormatting: + """G1: PostgreSQL CREATE MATERIALIZED VIEW specifics.""" + + def test_storage_options_rendered(self): + """T-05: storage_options must appear in the generated SQL.""" + dialect = _dialect() + sql, _ = _create(dialect, storage_options={"fillfactor": 70}).to_sql() + assert sql == ( + 'CREATE MATERIALIZED VIEW "sales_summary" WITH (fillfactor = 70) ' + 'AS SELECT "product_id" FROM "sales" WITH DATA' + ) + + def test_multiple_storage_options_rendered(self): + """T-06: every storage option is rendered, keys upper-cased.""" + dialect = _dialect() + sql, _ = _create( + dialect, + storage_options={"fillfactor": 70, "autovacuum_enabled": "true"}, + ).to_sql() + assert sql == ( + 'CREATE MATERIALIZED VIEW "sales_summary" ' + 'WITH (fillfactor = 70, autovacuum_enabled = true) ' + 'AS SELECT "product_id" FROM "sales" WITH DATA' + ) + + def test_tablespace_column_aliases_with_data(self): + """T-07: PG grammar order is column aliases -> WITH (...) -> TABLESPACE -> AS.""" + dialect = _dialect() + sql, _ = _create( + dialect, + column_aliases=["product_id", "total_sales"], + storage_options={"fillfactor": 90}, + tablespace="fast_ssd", + ).to_sql() + assert sql == ( + 'CREATE MATERIALIZED VIEW "sales_summary" ("product_id", "total_sales") ' + 'WITH (fillfactor = 90) TABLESPACE "fast_ssd" ' + 'AS SELECT "product_id" FROM "sales" WITH DATA' + ) + + def test_with_no_data(self): + """T-08: WITH NO DATA tail.""" + dialect = _dialect() + sql, _ = _create(dialect, with_data=False).to_sql() + assert sql.endswith("WITH NO DATA") + + def test_empty_storage_options_omitted(self): + """T-09: no empty ``WITH ()`` is emitted when storage_options is empty.""" + dialect = _dialect() + sql, _ = _create(dialect).to_sql() + assert "WITH (" not in sql + assert sql == ( + 'CREATE MATERIALIZED VIEW "sales_summary" ' + 'AS SELECT "product_id" FROM "sales" WITH DATA' + ) + + +class TestDropMaterializedViewFormatting: + """G1: DROP MATERIALIZED VIEW keeps IF EXISTS / CASCADE.""" + + def test_drop_plain(self): + dialect = _dialect() + sql, params = DropMaterializedViewExpression( + dialect=dialect, view_name="sales_summary" + ).to_sql() + assert sql == 'DROP MATERIALIZED VIEW "sales_summary"' + assert params == () + + def test_drop_if_exists_cascade(self): + dialect = _dialect() + sql, _ = DropMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", if_exists=True, cascade=True + ).to_sql() + assert sql == 'DROP MATERIALIZED VIEW IF EXISTS "sales_summary" CASCADE' + + +class TestRefreshMaterializedViewFormatting: + """G2: CONCURRENTLY must be gated on PostgreSQL 9.4+ on every code path.""" + + def test_refresh_plain(self): + dialect = _dialect() + sql, params = RefreshMaterializedViewExpression( + dialect=dialect, view_name="sales_summary" + ).to_sql() + assert sql == 'REFRESH MATERIALIZED VIEW "sales_summary"' + assert params == () + + @pytest.mark.parametrize("with_data,expected", [(True, " WITH DATA"), (False, " WITH NO DATA")]) + def test_refresh_with_data_variants(self, with_data, expected): + dialect = _dialect() + sql, _ = RefreshMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", with_data=with_data + ).to_sql() + assert sql == f'REFRESH MATERIALIZED VIEW "sales_summary"{expected}' + + def test_refresh_without_with_data_omits_clause(self): + dialect = _dialect() + sql, _ = RefreshMaterializedViewExpression( + dialect=dialect, view_name="sales_summary" + ).to_sql() + assert "WITH DATA" not in sql + + @pytest.mark.parametrize("version", [PG_94, (10, 0, 0), PG_15, (17, 0, 0)]) + def test_refresh_concurrently_allowed_since_94(self, version): + """T-12: CONCURRENTLY is emitted from PG 9.4 onwards.""" + dialect = _dialect(version) + sql, _ = RefreshMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", concurrent=True + ).to_sql() + assert sql == 'REFRESH MATERIALIZED VIEW CONCURRENTLY "sales_summary"' + + def test_refresh_concurrently_rejected_before_94(self): + """T-13: the generic expression must refuse CONCURRENTLY before PG 9.4.""" + dialect = _dialect(PG_93) + with pytest.raises(UnsupportedFeatureError) as exc: + RefreshMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", concurrent=True + ).to_sql() + assert "CONCURRENTLY" in str(exc.value) + + def test_pg_expression_shares_same_gate(self): + """T-15: the PostgreSQL expression is gated identically.""" + dialect = _dialect(PG_93) + with pytest.raises(UnsupportedFeatureError): + PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_summary", concurrently=True + ).to_sql() + + def test_pg_expression_renders_concurrently_since_94(self): + dialect = _dialect() + sql, _ = PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_summary", concurrently=True, with_data=True + ).to_sql() + assert sql == 'REFRESH MATERIALIZED VIEW CONCURRENTLY "sales_summary" WITH DATA' + + @pytest.mark.parametrize( + "version,feature,should_raise", + [ + (PG_93, "concurrent", True), + (PG_93, "if_not_exists", True), + (PG_93, "tablespace", False), + (PG_93, "storage_options", False), + (PG_94, "concurrent", False), + (PG_94, "if_not_exists", False), + ((9, 2, 0), "concurrent", True), + ((9, 2, 0), "if_not_exists", True), + ((9, 2, 0), "tablespace", True), + ((9, 2, 0), "storage_options", True), + ], + ) + def test_version_gate_matrix(self, version, feature, should_raise): + """T-16: version gate matrix across MV features.""" + dialect = _dialect(version) + build = { + "concurrent": lambda: RefreshMaterializedViewExpression( + dialect=dialect, view_name="mv", concurrent=True + ), + "if_not_exists": lambda: PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_source_query(dialect), + if_not_exists=True, + ), + "tablespace": lambda: PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_source_query(dialect), + tablespace="fast_ssd", + ), + "storage_options": lambda: PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_source_query(dialect), + storage_options={"fillfactor": 70}, + ), + }[feature] + if should_raise: + with pytest.raises(UnsupportedFeatureError): + build().to_sql() + else: + sql, _ = build().to_sql() + assert sql.startswith("CREATE MATERIALIZED VIEW") or sql.startswith( + "REFRESH MATERIALIZED VIEW" + ) + + +class TestMaterializedViewSchemaQualification: + """G4: MV names must be schema-qualified and quoted.""" + + def test_refresh_schema_qualified_and_quoted(self): + """T-17.""" + dialect = _dialect() + sql, _ = PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="mv_sales", schema="reporting" + ).to_sql() + assert sql == 'REFRESH MATERIALIZED VIEW "reporting"."mv_sales"' + + def test_mixed_case_and_reserved_word_quoted(self): + """T-18: the previous bare f-string concatenation broke here.""" + dialect = _dialect() + sql, _ = PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="Order Summary", schema="Reporting" + ).to_sql() + assert sql == 'REFRESH MATERIALIZED VIEW "Reporting"."Order Summary"' + + def test_create_mv_schema_qualified(self): + """T-19.""" + dialect = _dialect() + sql, _ = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv_sales", + query=_source_query(dialect), + schema="reporting", + ).to_sql() + assert sql.startswith('CREATE MATERIALIZED VIEW "reporting"."mv_sales" ') + + def test_drop_mv_schema_qualified(self): + """T-20.""" + dialect = _dialect() + sql, _ = PostgresDropMaterializedViewExpression( + dialect=dialect, view_name="mv_sales", schema="reporting", if_exists=True + ).to_sql() + assert sql == 'DROP MATERIALIZED VIEW IF EXISTS "reporting"."mv_sales"' + + def test_no_schema_emits_bare_identifier(self): + """T-21: no stray separator when schema is absent.""" + dialect = _dialect() + sql, _ = PostgresDropMaterializedViewExpression( + dialect=dialect, view_name="mv_sales" + ).to_sql() + assert sql == 'DROP MATERIALIZED VIEW "mv_sales"' + assert "." not in sql + + def test_rejects_empty_names(self): + dialect = _dialect() + with pytest.raises(ValueError): + PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name=" ", query=_source_query(dialect) + ) + with pytest.raises(ValueError): + PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=_source_query(dialect), schema="" + ) + + +class TestCreateMaterializedViewIfNotExists: + """G5: CREATE MATERIALIZED VIEW IF NOT EXISTS (PG 9.4+).""" + + def test_if_not_exists_rendered(self): + """T-22.""" + dialect = _dialect() + sql, _ = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + query=_source_query(dialect), + if_not_exists=True, + ).to_sql() + assert sql == ( + 'CREATE MATERIALIZED VIEW IF NOT EXISTS "sales_summary" ' + 'AS SELECT "product_id" FROM "sales" WITH DATA' + ) + + def test_if_not_exists_false_omitted(self): + """T-23.""" + dialect = _dialect() + sql, _ = PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="sales_summary", query=_source_query(dialect) + ).to_sql() + assert "IF NOT EXISTS" not in sql + + def test_if_not_exists_rejected_before_94(self): + """T-24.""" + dialect = _dialect(PG_93) + with pytest.raises(UnsupportedFeatureError): + PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="sales_summary", + query=_source_query(dialect), + if_not_exists=True, + ).to_sql() + + +class TestMaterializedViewExceptionContract: + """G3: MV feature gates raise UnsupportedFeatureError, never bare ValueError.""" + + @pytest.mark.parametrize( + "build", + [ + lambda dialect: RefreshMaterializedViewExpression( + dialect=dialect, view_name="mv", concurrent=True + ), + lambda dialect: PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="mv", concurrently=True + ), + lambda dialect: PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=_source_query(dialect), if_not_exists=True + ), + ], + ) + def test_all_mv_gates_raise_unsupported_feature_error(self, build): + """T-25.""" + dialect = _dialect(PG_93) + with pytest.raises(UnsupportedFeatureError) as exc: + build(dialect).to_sql() + assert exc.value.dialect_name == dialect.name + assert exc.value.feature_name + + def test_error_message_mentions_dialect_and_feature(self): + """T-26.""" + dialect = _dialect(PG_93) + with pytest.raises(UnsupportedFeatureError) as exc: + RefreshMaterializedViewExpression( + dialect=dialect, view_name="mv", concurrent=True + ).to_sql() + message = str(exc.value) + assert dialect.name in message + assert "CONCURRENTLY" in message + + def test_unsupported_feature_error_is_not_value_error(self): + """T-27: guards the core invariant so MV gates cannot regress to ValueError.""" + assert not issubclass(UnsupportedFeatureError, ValueError) + + +class TestAlterMaterializedView: + """G6: ALTER MATERIALIZED VIEW actions.""" + + def _alter(self, dialect, *actions, **kwargs): + return PostgresAlterMaterializedViewExpression( + dialect=dialect, view_name=kwargs.pop("view_name", "sales_summary"), + actions=list(actions), **kwargs + ) + + def test_rename_to(self): + """T-28.""" + dialect = _dialect() + sql, params = self._alter( + dialect, PostgresRenameMaterializedViewAction(dialect, "sales_summary_v2") + ).to_sql() + assert sql == 'ALTER MATERIALIZED VIEW "sales_summary" RENAME TO "sales_summary_v2"' + assert params == () + + def test_set_schema(self): + """T-29.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, PostgresSetMaterializedViewSchemaAction(dialect, "archive") + ).to_sql() + assert sql == 'ALTER MATERIALIZED VIEW "sales_summary" SET SCHEMA "archive"' + + def test_set_properties(self): + """T-30.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, + PostgresSetMaterializedViewPropertiesAction( + dialect, {"fillfactor": 90, "autovacuum_enabled": "true"} + ), + ).to_sql() + assert sql == ( + 'ALTER MATERIALIZED VIEW "sales_summary" ' + "SET (fillfactor = 90, autovacuum_enabled = true)" + ) + + def test_reset_parameters(self): + """T-31.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, + PostgresResetMaterializedViewPropertiesAction( + dialect, ["fillfactor", "autovacuum_enabled"] + ), + ).to_sql() + assert sql == ( + 'ALTER MATERIALIZED VIEW "sales_summary" RESET (fillfactor, autovacuum_enabled)' + ) + + def test_owner_to(self): + """T-32: role names are quoted, CURRENT_* keywords are not.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, PostgresChangeMaterializedViewOwnerAction(dialect, "app_owner") + ).to_sql() + assert sql == 'ALTER MATERIALIZED VIEW "sales_summary" OWNER TO "app_owner"' + + for keyword in ("CURRENT_USER", "CURRENT_ROLE", "SESSION_USER", "current_user"): + sql, _ = self._alter( + dialect, PostgresChangeMaterializedViewOwnerAction(dialect, keyword) + ).to_sql() + assert sql.endswith(f"OWNER TO {keyword.upper()}") + + def test_multiple_actions(self): + """T-33.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, + PostgresRenameMaterializedViewAction(dialect, "sales_summary_v2"), + PostgresSetMaterializedViewPropertiesAction(dialect, {"fillfactor": 90}), + ).to_sql() + assert sql == ( + 'ALTER MATERIALIZED VIEW "sales_summary" RENAME TO "sales_summary_v2";\n' + 'ALTER MATERIALIZED VIEW "sales_summary" SET (fillfactor = 90)' + ) + + def test_alter_target_schema_qualified(self): + """T-34.""" + dialect = _dialect() + sql, _ = self._alter( + dialect, + PostgresSetMaterializedViewSchemaAction(dialect, "archive"), + view_name="mv_sales", + schema="reporting", + ).to_sql() + assert sql == ( + 'ALTER MATERIALIZED VIEW "reporting"."mv_sales" SET SCHEMA "archive"' + ) + + def test_set_tablespace_not_offered(self): + """T-35: PostgreSQL cannot relocate a MV to another tablespace.""" + import rhosocial.activerecord.backend.impl.postgres.expression.ddl.mv as mv_module + + assert not hasattr(mv_module, "PostgresSetMaterializedViewTablespaceAction") + assert not any( + "TABLESPACE" in (action.__doc__ or "") + for action in ( + PostgresRenameMaterializedViewAction, + PostgresSetMaterializedViewSchemaAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresResetMaterializedViewPropertiesAction, + PostgresChangeMaterializedViewOwnerAction, + ) + ) + + def test_requires_at_least_one_action(self): + dialect = _dialect() + with pytest.raises(ValueError): + PostgresAlterMaterializedViewExpression( + dialect=dialect, view_name="mv", actions=[] + ) + + def test_rejects_non_action_objects(self): + dialect = _dialect() + with pytest.raises(TypeError): + PostgresAlterMaterializedViewExpression( + dialect=dialect, view_name="mv", actions=[object()] + ) + + def test_rejects_unlisted_storage_parameters(self): + """Names must exist in PostgresStorageParameter; no pattern matching.""" + dialect = _dialect() + for bogus in ("nonexistent_opt", "fillfactor = 70) --", "toast.autovacuum_enabled"): + with pytest.raises(ValueError) as exc: + PostgresSetMaterializedViewPropertiesAction(dialect, {bogus: 1}) + assert "unknown PostgreSQL storage parameter" in str(exc.value) + with pytest.raises(ValueError): + PostgresResetMaterializedViewPropertiesAction(dialect, [bogus]) + with pytest.raises(ValueError): + PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_source_query(dialect), + storage_options={bogus: 1}, + ) + + def test_allows_unlisted_storage_parameters_explicitly(self): + """Escape hatch for table access method options and toast.* names.""" + dialect = _dialect() + expression = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_source_query(dialect), + storage_options={"compresslevel": 4}, + allow_unlisted_storage_parameters=True, + ) + sql, _ = expression.to_sql() + assert "WITH (compresslevel = 4)" in sql + + def test_rejects_unknown_action(self): + dialect = _dialect() + + class RogueAction(MaterializedViewAlterAction): + action_kind = "rogue" + + with pytest.raises(UnsupportedFeatureError): + dialect.format_materialized_view_alter_action(RogueAction(dialect)) + + +class TestPostgresStorageParameterEnum: + """Storage parameter names are enumerated, never pattern matched.""" + + def test_enum_covers_heap_relation_options(self): + """The enum is the authoritative allow-list for MV reloptions.""" + values = {member.value for member in PostgresStorageParameter} + assert len(values) == len(list(PostgresStorageParameter)) + for expected in ( + "fillfactor", + "toast_tuple_target", + "parallel_workers", + "user_catalog_table", + "vacuum_index_cleanup", + "vacuum_truncate", + "autovacuum_enabled", + "autovacuum_vacuum_threshold", + "autovacuum_analyze_threshold", + "autovacuum_freeze_min_age", + "autovacuum_multixact_freeze_table_age", + "log_autovacuum_min_duration", + ): + assert expected in values + + def test_lookup_by_name_and_member(self): + assert resolve_storage_parameter("fillfactor") is PostgresStorageParameter.FILLFACTOR + assert ( + resolve_storage_parameter(PostgresStorageParameter.FILLFACTOR) + is PostgresStorageParameter.FILLFACTOR + ) + assert resolve_storage_parameter("toast_tuple_target") is ( + PostgresStorageParameter.TOAST_TUPLE_TARGET + ) + + def test_lookup_rejects_unknown(self): + assert resolve_storage_parameter("fillfactor ") is None + assert resolve_storage_parameter(None) is None + assert resolve_storage_parameter(70) is None + + def test_validate_accepts_mixed_forms(self): + resolved = validate_storage_parameters( + ["fillfactor", PostgresStorageParameter.AUTOVACUUM_ENABLED], + "properties", + ) + assert resolved == ( + PostgresStorageParameter.FILLFACTOR, + PostgresStorageParameter.AUTOVACUUM_ENABLED, + ) + + def test_validate_rejects_unknown_by_default(self): + with pytest.raises(ValueError): + validate_storage_parameters(["fillfactor", "compresslevel"], "properties") + + def test_validate_can_allow_unlisted(self): + assert validate_storage_parameters( + ["compresslevel"], "properties", allow_unlisted=True + ) == () + + def test_validate_rejects_non_iterable(self): + with pytest.raises(TypeError): + validate_storage_parameters("fillfactor", "properties") + + def test_value_types_are_declared(self): + assert ( + PostgresStorageParameter.FILLFACTOR.value_type + is PostgresStorageParameterValueType.INT + ) + assert ( + PostgresStorageParameter.AUTOVACUUM_VACUUM_SCALE_FACTOR.value_type + is PostgresStorageParameterValueType.REAL + ) + assert ( + PostgresStorageParameter.AUTOVACUUM_ENABLED.value_type + is PostgresStorageParameterValueType.TERNARY + ) + assert ( + PostgresStorageParameter.VACUUM_INDEX_CLEANUP.enum_values + == ("auto", "enabled", "disabled") + ) + assert PostgresStorageParameter.FILLFACTOR.enum_values is None + + def test_min_version_metadata(self): + assert PostgresStorageParameter.PARALLEL_WORKERS.min_version == (11, 0, 0) + assert PostgresStorageParameter.AUTOVACUUM_VACUUM_INSERT_THRESHOLD.min_version == (13, 0, 0) + assert PostgresStorageParameter.LOG_AUTOANALYZE_MIN_DURATION.min_version == (15, 0, 0) + assert PostgresStorageParameter.AUTOVACUUM_PARALLEL_WORKERS.min_version == (16, 0, 0) + assert PostgresStorageParameter.FILLFACTOR.min_version is None + + def test_enum_exported_from_package_root(self): + from rhosocial.activerecord.backend.impl import postgres as pg + + assert pg.PostgresStorageParameter is PostgresStorageParameter + assert "PostgresStorageParameter" in pg.__all__ + + def test_expressions_render_enum_members(self): + """Enum members and their string values render identically.""" + dialect = _dialect() + by_member = PostgresSetMaterializedViewPropertiesAction( + dialect, {PostgresStorageParameter.FILLFACTOR: 70} + ) + by_name = PostgresSetMaterializedViewPropertiesAction(dialect, {"fillfactor": 70}) + assert by_member.to_sql() == by_name.to_sql() + assert "fillfactor = 70" in by_member.to_sql() + + +class TestMaterializedViewProtocolAndSurface: + """G8: protocol and package surface stay in sync.""" + + def test_protocol_isinstance(self): + """T-36.""" + assert isinstance(_dialect(), PostgresMaterializedViewSupport) + + def test_protocol_declares_all_formatters(self): + """T-37: the protocol must cover every MV formatter the mixin exposes.""" + for name in ( + "supports_materialized_view", + "supports_materialized_view_concurrent_refresh", + "supports_materialized_view_if_not_exists", + "supports_materialized_view_tablespace", + "supports_materialized_view_storage_options", + "supports_alter_materialized_view", + "format_create_materialized_view_statement", + "format_drop_materialized_view_statement", + "format_refresh_materialized_view_statement", + "format_refresh_materialized_view_pg_statement", + "format_alter_materialized_view_statement", + "format_materialized_view_alter_action", + ): + assert hasattr(_dialect(), name), name + assert hasattr(PostgresMaterializedViewSupport, name), name + + def test_new_expressions_exported(self): + """T-38.""" + from rhosocial.activerecord.backend.impl.postgres import expression as pg_expression + + for name in ( + "PostgresCreateMaterializedViewExpression", + "PostgresDropMaterializedViewExpression", + "PostgresRefreshMaterializedViewExpression", + "PostgresAlterMaterializedViewExpression", + "PostgresRenameMaterializedViewAction", + "PostgresSetMaterializedViewSchemaAction", + "PostgresSetMaterializedViewPropertiesAction", + "PostgresResetMaterializedViewPropertiesAction", + "PostgresChangeMaterializedViewOwnerAction", + "MaterializedViewAlterAction", + ): + assert name in pg_expression.__all__, name + assert hasattr(pg_expression, name), name + + def test_format_method_targets_exist(self): + """T-39: every MV expression routes to a formatter the dialect implements.""" + dialect = _dialect() + query = _source_query(dialect) + expressions = [ + _create(dialect), + PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=query + ), + DropMaterializedViewExpression(dialect=dialect, view_name="mv"), + PostgresDropMaterializedViewExpression(dialect=dialect, view_name="mv"), + RefreshMaterializedViewExpression(dialect=dialect, view_name="mv"), + PostgresRefreshMaterializedViewExpression(dialect=dialect, name="mv"), + PostgresAlterMaterializedViewExpression( + dialect=dialect, + view_name="mv", + actions=[PostgresRenameMaterializedViewAction(dialect, "mv2")], + ), + ] + for expression in expressions: + formatter = getattr(dialect, expression.format_method, None) + assert callable(formatter), f"{type(expression).__name__} -> {expression.format_method}" + + def test_legacy_aliases_preserved(self): + """T-40: examples rely on the name=/concurrently= aliases.""" + dialect = _dialect() + expression = PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="sales_summary", concurrently=True + ) + assert expression.name == expression.view_name == "sales_summary" + assert expression.concurrently is True + assert expression.concurrent is True + + @pytest.mark.parametrize( + "version,expected", + [ + ((9, 2, 0), False), + (PG_93, True), + (PG_15, True), + ], + ) + def test_base_capability_probe_respects_version(self, version, expected): + """T-41a: materialized views themselves require PostgreSQL 9.3+.""" + assert _dialect(version).supports_materialized_view() is expected + + @pytest.mark.parametrize( + "version,expected", [(PG_93, False), (PG_94, True), (PG_15, True)] + ) + def test_concurrent_refresh_probe_matches_version(self, version, expected): + """T-41b.""" + assert _dialect(version).supports_materialized_view_concurrent_refresh() is expected + + def test_pg_only_capability_has_pg_owned_formatter_for_refresh(self): + """T-04 (refresh half): CONCURRENTLY gating lives in the PG mixin.""" + dialect = _dialect() + assert dialect.supports_materialized_view_concurrent_refresh() is True + owner = dialect.format_refresh_materialized_view_statement.__qualname__ + assert owner.startswith("PostgresMaterializedViewMixin.") diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_docs.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_docs.py new file mode 100644 index 00000000..eb441407 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_docs.py @@ -0,0 +1,134 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_docs.py +"""Documentation and example consistency for materialized views. + +Docs that drift from the implementation are a defect: the previous MV +documentation promised ``storage_options`` support that the MRO bug silently +disabled. These tests keep the docs, the example and the capability surface +aligned with the code. +""" + +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[6] +MV_DOC_PAGES = [ + PROJECT_ROOT / "docs" / "en_US" / "ddl" / "materialized_view.md", + PROJECT_ROOT / "docs" / "zh_CN" / "ddl" / "materialized_view.md", +] +DDL_INDEX_PAGES = [ + PROJECT_ROOT / "docs" / "en_US" / "ddl" / "README.md", + PROJECT_ROOT / "docs" / "zh_CN" / "ddl" / "README.md", +] +PROTOCOL_DOC_PAGES = [ + PROJECT_ROOT / "docs" / "en_US" / "postgres_specific_features" / "protocol_support.md", + PROJECT_ROOT / "docs" / "zh_CN" / "postgres_specific_features" / "protocol_support.md", +] +EXAMPLE = ( + PROJECT_ROOT + / "src" / "rhosocial" / "activerecord" / "backend" / "impl" / "postgres" + / "examples" / "ddl" / "materialized_view.py" +) +EXAMPLES_META = ( + PROJECT_ROOT + / "src" / "rhosocial" / "activerecord" / "backend" / "impl" / "postgres" + / "examples" / "conftest.py" +) + + +class TestMaterializedViewDocs: + """T-54: a dedicated page exists and documents every supported feature.""" + + @pytest.mark.parametrize("page", MV_DOC_PAGES, ids=lambda p: p.parent.parent.name) + def test_page_exists(self, page): + assert page.is_file(), f"missing documentation page: {page}" + + @pytest.mark.parametrize("page", MV_DOC_PAGES, ids=lambda p: p.parent.parent.name) + def test_page_documents_supported_features(self, page): + text = page.read_text(encoding="utf-8") + for token in ( + "PostgresCreateMaterializedViewExpression", + "PostgresRefreshMaterializedViewExpression", + "PostgresAlterMaterializedViewExpression", + "PostgresDropMaterializedViewExpression", + "if_not_exists", + "concurrently", + "TABLESPACE", + "storage", + "PostgresStorageParameter", + "list_materialized_views", + "is_populated", + "has_unique_index", + ): + assert token in text, f"{page.name} does not mention {token}" + + @pytest.mark.parametrize( + "page,marker", + [ + (MV_DOC_PAGES[0], "cannot be relocated"), + (MV_DOC_PAGES[1], "无法迁移到其他表空间"), + ], + ids=["en_US", "zh_CN"], + ) + def test_page_documents_alter_boundary(self, page, marker): + """SET TABLESPACE must be documented as unsupported for MVs. + + The code-side boundary is locked by + ``TestAlterMaterializedView::test_set_tablespace_not_offered``. + """ + text = page.read_text(encoding="utf-8") + assert "SET TABLESPACE" in text + assert marker in text + + @pytest.mark.parametrize("page", MV_DOC_PAGES, ids=lambda p: p.parent.parent.name) + def test_page_documents_version_requirements(self, page): + text = page.read_text(encoding="utf-8") + assert "9.3" in text + assert "9.4" in text + + @pytest.mark.parametrize("page", DDL_INDEX_PAGES, ids=lambda p: p.parent.parent.name) + def test_ddl_index_lists_mv_expressions(self, page): + text = page.read_text(encoding="utf-8") + assert "PostgresCreateMaterializedViewExpression" in text + assert "PostgresAlterMaterializedViewExpression" in text + assert "materialized_view.md" in text + + @pytest.mark.parametrize("page", PROTOCOL_DOC_PAGES, ids=lambda p: p.parent.parent.name) + def test_protocol_doc_lists_mv_support(self, page): + text = page.read_text(encoding="utf-8") + assert "PostgresMaterializedViewSupport" in text + assert "materialized_view.md" in text + + +class TestMaterializedViewExample: + """T-55 / T-56: the runnable example exercises the documented features.""" + + def test_example_exists(self): + assert EXAMPLE.is_file() + + def test_example_exercises_new_features(self): + text = EXAMPLE.read_text(encoding="utf-8") + for token in ( + "PostgresCreateMaterializedViewExpression", + "if_not_exists=True", + "PostgresStorageParameter", + "PostgresAlterMaterializedViewExpression", + "PostgresRenameMaterializedViewAction", + "PostgresSetMaterializedViewSchemaAction", + "PostgresSetMaterializedViewPropertiesAction", + "PostgresResetMaterializedViewPropertiesAction", + "PostgresChangeMaterializedViewOwnerAction", + "PostgresDropMaterializedViewExpression", + "list_materialized_views", + ): + assert token in text, f"example does not exercise {token}" + + def test_example_documents_sections(self): + text = EXAMPLE.read_text(encoding="utf-8") + assert "IF NOT EXISTS" in text + assert "ALTER MATERIALIZED VIEW" in text + assert "Introspection" in text + + def test_example_registered_in_metadata(self): + assert EXAMPLE.is_file() + assert "'ddl/materialized_view.py'" in EXAMPLES_META.read_text(encoding="utf-8") diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_introspection.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_introspection.py new file mode 100644 index 00000000..d2ceccc2 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_introspection.py @@ -0,0 +1,157 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_materialized_view_introspection.py +"""PostgreSQL materialized view introspection tests (SQL generation only). + +Covers the introspection half of the regression matrix documented in +``.claude/plan/2026-09-26/materialized-view-coverage.md``: + +- G7: materialized views are discoverable (``pg_matviews``), report whether they + are populated, and expose the UNIQUE index prerequisite for + ``REFRESH ... CONCURRENTLY``. +- G8: sync/async parity for the new introspector methods. +""" + +import pytest + +from rhosocial.activerecord.backend.introspection.types import IntrospectionScope +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.introspection import ( + PostgresMaterializedViewInfoExpression, + PostgresMaterializedViewListExpression, +) +from rhosocial.activerecord.backend.impl.postgres.introspection.introspector import ( + AsyncPostgreSQLIntrospector, + PostgreSQLIntrospectorMixin, + SyncPostgreSQLIntrospector, +) + + +def _dialect(): + return PostgresDialect(version=(15, 0, 0)) + + +class TestMaterializedViewIntrospectionSQL: + """The generated catalog queries must target relkind 'm' via pg_matviews.""" + + def test_list_query_uses_pg_matviews(self): + sql, params = PostgresMaterializedViewListExpression( + _dialect(), schema="reporting" + ).to_sql() + assert "pg_matviews" in sql + assert "ispopulated" in sql + assert "indisunique" in sql + assert params == ("reporting",) + + def test_list_query_reports_unique_index_and_populated_flag(self): + sql, _ = PostgresMaterializedViewListExpression(_dialect()).to_sql() + for column in ( + "is_populated", + "has_unique_index", + "definition", + "comment", + ): + assert f"as {column}" in sql + + def test_list_query_excludes_system_objects_by_default(self): + sql, _ = PostgresMaterializedViewListExpression(_dialect()).to_sql() + # doubled percent: psycopg would treat a bare '%' as a placeholder start + assert "NOT LIKE 'pg_%%'" in sql + + def test_list_query_can_include_system_objects(self): + sql, _ = PostgresMaterializedViewListExpression( + _dialect() + ).include_system(True).to_sql() + assert "NOT LIKE 'pg_%'" not in sql + + def test_info_query_filters_by_name(self): + sql, params = PostgresMaterializedViewInfoExpression( + _dialect(), "sales_summary", schema="reporting" + ).to_sql() + assert "pg_matviews" in sql + assert "m.matviewname = " in sql + assert params == ("reporting", "sales_summary") + + def test_info_query_defaults_to_connection_schema(self): + _, params = PostgresMaterializedViewInfoExpression(_dialect(), "sales_summary").to_sql() + assert params[0] == "public" + + +class TestMaterializedViewParsing: + """Row parsing must expose PostgreSQL specific MV state.""" + + @pytest.fixture + def parser(self): + return PostgreSQLIntrospectorMixin + + def _row(self, **overrides): + row = { + "schema_name": "reporting", + "view_name": "sales_summary", + "definition": " SELECT 1;", + "is_populated": True, + "has_unique_index": True, + "comment": "monthly rollup", + } + row.update(overrides) + return row + + def test_parse_exposes_materialized_state(self, parser): + info = parser._parse_materialized_view(self._row(), "public") + assert info.name == "sales_summary" + assert info.schema == "reporting" + assert info.definition == " SELECT 1;" + assert info.comment == "monthly rollup" + assert info.is_updatable is False + assert info.is_insertable is False + assert info.extra["is_materialized"] is True + assert info.extra["is_populated"] is True + assert info.extra["has_unique_index"] is True + + def test_parse_unpopulated_view(self, parser): + info = parser._parse_materialized_view( + self._row(is_populated=False, has_unique_index=False), "public" + ) + assert info.extra["is_populated"] is False + assert info.extra["has_unique_index"] is False + + def test_parse_falls_back_to_defaults(self, parser): + info = parser._parse_materialized_view({}, "public", "fallback_mv") + assert info.name == "fallback_mv" + assert info.schema == "public" + assert info.extra["is_populated"] is False + assert info.extra["has_unique_index"] is False + + def test_parse_list(self, parser): + infos = parser._parse_materialized_views( + [self._row(), self._row(view_name="sales_daily", is_populated=False)], + "public", + ) + assert [info.name for info in infos] == ["sales_summary", "sales_daily"] + assert [info.extra["is_populated"] for info in infos] == [True, False] + + def test_parse_info_returns_none_when_missing(self, parser): + assert parser._parse_materialized_view_info([], "missing_mv", "public") is None + + +class TestMaterializedViewIntrospectionSurface: + """Sync/async parity and cache-key isolation.""" + + @pytest.mark.parametrize( + "method", ["list_materialized_views", "get_materialized_view_info", "materialized_view_exists"] + ) + def test_sync_and_async_expose_same_methods(self, method): + assert hasattr(SyncPostgreSQLIntrospector, method) + assert hasattr(AsyncPostgreSQLIntrospector, method) + + def test_cache_keys_are_namespaced(self): + """MV lookups must not collide with regular view lookups.""" + introspector = SyncPostgreSQLIntrospector.__new__(SyncPostgreSQLIntrospector) + list_key = introspector._materialized_view_cache_key("public", include_system=False) + single_key = introspector._materialized_view_cache_key( + "public", view_name="sales_summary" + ) + system_key = introspector._materialized_view_cache_key("public", include_system=True) + view_key = introspector._make_cache_key(IntrospectionScope.VIEW, schema="public") + assert list_key != single_key + assert list_key != system_key + assert list_key != view_key + assert "matviews" in list_key diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py index 2318f5cf..2d183b96 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py @@ -1,88 +1,127 @@ -# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_pgq_format.py +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py +"""Tests for fail-closed PostgreSQL graph capability and formatter safeguards. + +SQL/PGQ was withdrawn in PostgreSQL 19 Beta 4 and shipped in no earlier +release, so ``supports_graph_match()`` / ``supports_graph_table()`` must report +False for every version. The formatter tests therefore drive the dialect through +the explicit ``graph_feature_overrides`` opt-in instead of relying on the +default, which keeps them meaningful while the capability stays off. """ -Tests for PostgreSQL PGQ dialect version gating and SQL formatting. -Uses requires_protocol markers for protocol-level capability documentation. -""" import pytest -from rhosocial.activerecord.backend.dialect.protocols import GraphSupport, GraphTableSupport from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.protocols import GraphSupport, GraphTableSupport from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.expression import ( - GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, - GraphColumn, ColumnsClause, GraphTableExpression, - TablePropertiesClause, VertexTable, EdgeTable, - CreatePropertyGraphExpression, DropPropertyGraphExpression, AlterPropertyGraphExpression, + ColumnsClause, + CreatePropertyGraphExpression, + DropPropertyGraphExpression, + EdgeTable, + GraphColumn, + GraphEdge, + GraphEdgeDirection, + GraphTableExpression, + GraphVertex, + MatchClause, + TablePropertiesClause, + VertexTable, ) -from rhosocial.activerecord.backend.expression.query_parts import WhereClause from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.query_parts import WhereClause class TestPGQProtocolVersionGating: - """Version gating tests for PGQ support in PostgreSQL.""" - - def test_supports_graph_match_pg19_true(self): - d = PostgresDialect((19, 0, 0)) - assert d.supports_graph_match() is True + """Capability and protocol tests for PostgreSQL graph features.""" - def test_supports_graph_match_pg15_false(self): - d = PostgresDialect((15, 0, 0)) - assert d.supports_graph_match() is False + @pytest.mark.parametrize("version", [(15, 0, 0), (19, 0, 4), (20, 0, 0)]) + def test_graph_capabilities_fail_closed(self, version): + dialect = PostgresDialect(version) - @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) - def test_supports_graph_table_pg19_true(self): - d = PostgresDialect((19, 0, 0)) - assert d.supports_graph_table() is True - - @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) - def test_supports_graph_table_pg15_false(self): - d = PostgresDialect((15, 0, 0)) - assert d.supports_graph_table() is False + assert dialect.supports_graph_match() is False + assert dialect.supports_graph_table() is False @pytest.mark.requires_protocol((GraphSupport, "supports_graph_match")) - def test_graph_support_protocol_implemented_pg19(self): - d = PostgresDialect((19, 0, 0)) - assert isinstance(d, GraphSupport) - assert isinstance(d, GraphTableSupport) - - def test_graph_support_protocol_implemented_pg15(self): - d = PostgresDialect((15, 0, 0)) - assert isinstance(d, GraphSupport) - assert isinstance(d, GraphTableSupport) + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_graph_support_protocols_are_implemented(self): + dialect = PostgresDialect((19, 0, 4)) + + assert isinstance(dialect, GraphSupport) + assert isinstance(dialect, GraphTableSupport) + + def test_explicit_graph_feature_override(self): + dialect = PostgresDialect( + (19, 0, 4), + graph_feature_overrides={ + "graph_match": True, + "graph_table": True, + }, + ) + + assert dialect.supports_graph_match() is True + assert dialect.supports_graph_table() is True + + def test_match_only_override_does_not_enable_graph_table(self): + dialect = PostgresDialect( + (19, 0, 4), + graph_feature_overrides={"graph_match": True}, + ) + + assert dialect.supports_graph_match() is True + assert dialect.supports_graph_table() is False + + def test_graph_table_override_without_match_fails_closed(self): + dialect = PostgresDialect( + (19, 0, 4), + graph_feature_overrides={"graph_table": True}, + ) + vertex = GraphVertex(dialect, "p", "person") + match = MatchClause(dialect, vertex) + columns = ColumnsClause(dialect, GraphColumn("p", "name")) + + assert dialect.supports_graph_match() is False + assert dialect.supports_graph_table() is False + with pytest.raises(UnsupportedFeatureError, match="GRAPH_TABLE"): + GraphTableExpression(dialect, "g", match, columns).to_sql() + + def test_unknown_graph_feature_override_is_rejected(self): + with pytest.raises(ValueError, match="Unknown graph feature override"): + PostgresDialect((19, 0, 4), graph_feature_overrides={"graph_sql": True}) + + def test_non_boolean_graph_feature_override_is_rejected(self): + with pytest.raises(TypeError, match="override values must be booleans"): + PostgresDialect((19, 0, 4), graph_feature_overrides={"graph_match": 1}) class TestPGQLimitationGating: - """Tests that PG 19 correctly reports unsupported features.""" - - def test_quantified_path_unsupported_pg19(self): - d = PostgresDialect((19, 0, 0)) - assert d.supports_quantified_path() is False + """Tests that withdrawn and unimplemented graph features stay disabled.""" - def test_quantified_path_unsupported_pg15(self): - d = PostgresDialect((15, 0, 0)) - assert d.supports_quantified_path() is False + def test_quantified_path_unsupported_by_default(self): + dialect = PostgresDialect((19, 0, 4)) + assert dialect.supports_quantified_path() is False - def test_comma_separated_patterns_unsupported_pg19(self): - d = PostgresDialect((19, 0, 0)) - assert d.supports_comma_separated_patterns() is False + def test_comma_separated_patterns_unsupported_by_default(self): + dialect = PostgresDialect((19, 0, 4)) + assert dialect.supports_comma_separated_patterns() is False - def test_comma_separated_patterns_unsupported_pg15(self): - d = PostgresDialect((15, 0, 0)) - assert d.supports_comma_separated_patterns() is False - - def test_quantified_path_raises_on_format(self): - d = PostgresDialect((19, 0, 0)) + def test_quantified_path_raises_on_format(self, pg19_dialect: PostgresDialect): from rhosocial.activerecord.backend.expression import QuantifiedPath - edge = GraphEdge(d, "e", "knows", GraphEdgeDirection.RIGHT) - qp = QuantifiedPath(d, edge) + + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + quantified_path = QuantifiedPath(pg19_dialect, edge) with pytest.raises(UnsupportedFeatureError): - qp.to_sql() + quantified_path.to_sql() @pytest.fixture def pg19_dialect(): - return PostgresDialect((19, 0, 0)) + return PostgresDialect( + (19, 0, 4), + graph_feature_overrides={ + "graph_match": True, + "graph_table": True, + }, + ) class TestPGQGraphVertexFormat: @@ -101,92 +140,101 @@ def test_anonymous(self, pg19_dialect: PostgresDialect): assert params == () def test_with_where(self, pg19_dialect: PostgresDialect): - where = WhereClause(pg19_dialect, - condition=Column(pg19_dialect, "age") > Literal(pg19_dialect, 18)) - v = GraphVertex(pg19_dialect, "p", "person", where=where) - sql, params = v.to_sql() + where = WhereClause( + pg19_dialect, + condition=Column(pg19_dialect, "age") > Literal(pg19_dialect, 18), + ) + vertex = GraphVertex(pg19_dialect, "p", "person", where=where) + sql, params = vertex.to_sql() assert "(p IS" in sql assert "WHERE" in sql assert params == (18,) def test_invalid_variable_name(self, pg19_dialect: PostgresDialect): - v = GraphVertex(pg19_dialect, "bad var", "person") + vertex = GraphVertex(pg19_dialect, "bad var", "person") with pytest.raises(ValueError, match="Invalid variable name"): - v.to_sql() - - + vertex.to_sql() class TestPGQGraphEdgeFormat: - """SQL formatting tests for GraphEdge with PG19 dialect.""" + """SQL formatting tests for GraphEdge with explicit capability overrides.""" def test_right(self, pg19_dialect: PostgresDialect): - e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) - sql, params = e.to_sql() + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + sql, params = edge.to_sql() assert sql == '-[e IS "knows"]->' def test_left(self, pg19_dialect: PostgresDialect): - e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.LEFT) - assert e.to_sql()[0] == '<-[e IS "knows"]-' + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.LEFT) + assert edge.to_sql()[0] == '<-[e IS "knows"]-' def test_any(self, pg19_dialect: PostgresDialect): - e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.ANY) - assert e.to_sql()[0] == '<-[e IS "knows"]->' + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.ANY) + assert edge.to_sql()[0] == '<-[e IS "knows"]->' def test_anonymous(self, pg19_dialect: PostgresDialect): - e = GraphEdge(pg19_dialect, direction=GraphEdgeDirection.RIGHT) - assert e.to_sql()[0] == '-[]->' + edge = GraphEdge(pg19_dialect, direction=GraphEdgeDirection.RIGHT) + assert edge.to_sql()[0] == '-[]->' def test_none_direction(self, pg19_dialect: PostgresDialect): - e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.NONE) - assert e.to_sql()[0] == '-[e IS "knows"]-' + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.NONE) + assert edge.to_sql()[0] == '-[e IS "knows"]-' - def test_table_without_variable(self, pg19_dialect: PostgresDialect): - e = GraphEdge(pg19_dialect, table="knows", direction=GraphEdgeDirection.RIGHT) - sql = e.to_sql()[0] - assert sql == '-[]->' + def test_table_without_variable_is_rejected(self, pg19_dialect: PostgresDialect): + edge = GraphEdge(pg19_dialect, table="knows", direction=GraphEdgeDirection.RIGHT) + with pytest.raises(ValueError, match="table requires a variable"): + edge.to_sql() + + def test_invalid_direction_is_rejected(self, pg19_dialect: PostgresDialect): + edge = GraphEdge(pg19_dialect, "e", "knows", direction="right") + with pytest.raises(ValueError, match="Invalid graph edge direction"): + edge.to_sql() def test_variable_only(self, pg19_dialect: PostgresDialect): - e = GraphEdge(pg19_dialect, variable="e", direction=GraphEdgeDirection.RIGHT) - assert e.to_sql()[0] == '-[e]->' + edge = GraphEdge(pg19_dialect, variable="e", direction=GraphEdgeDirection.RIGHT) + assert edge.to_sql()[0] == '-[e]->' def test_invalid_variable_name(self, pg19_dialect: PostgresDialect): - e = GraphEdge(pg19_dialect, variable="bad var", table="knows", direction=GraphEdgeDirection.RIGHT) + edge = GraphEdge( + pg19_dialect, + variable="bad var", + table="knows", + direction=GraphEdgeDirection.RIGHT, + ) with pytest.raises(ValueError, match="Invalid variable name"): - e.to_sql() + edge.to_sql() class TestPGQMatchClauseFormat: - """SQL formatting tests for MatchClause with PG19 dialect.""" + """SQL formatting tests for MatchClause with explicit capability overrides.""" def test_single_vertex(self, pg19_dialect: PostgresDialect): - v = GraphVertex(pg19_dialect, "p", "person") - m = MatchClause(pg19_dialect, v) - sql, params = m.to_sql() + vertex = GraphVertex(pg19_dialect, "p", "person") + match = MatchClause(pg19_dialect, vertex) + sql, params = match.to_sql() assert "MATCH" in sql assert "(p IS" in sql def test_path_pattern(self, pg19_dialect: PostgresDialect): - a = GraphVertex(pg19_dialect, "a", "person") - e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) - b = GraphVertex(pg19_dialect, "b", "person") - m = MatchClause(pg19_dialect, a, e, b) - sql, params = m.to_sql() + first = GraphVertex(pg19_dialect, "a", "person") + edge = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + second = GraphVertex(pg19_dialect, "b", "person") + match = MatchClause(pg19_dialect, first, edge, second) + sql, params = match.to_sql() assert "MATCH" in sql assert "(a IS" in sql assert "[e IS" in sql assert "(b IS" in sql - def test_empty_path(self, pg19_dialect: PostgresDialect): - m = MatchClause(pg19_dialect) - sql, params = m.to_sql() - assert sql == "MATCH " - assert params == () + def test_empty_path_is_rejected(self, pg19_dialect: PostgresDialect): + match = MatchClause(pg19_dialect) + with pytest.raises(ValueError, match="at least one path element"): + match.to_sql() def test_anonymous_vertex(self, pg19_dialect: PostgresDialect): - v = GraphVertex(pg19_dialect, variable=None, table="person") - m = MatchClause(pg19_dialect, v) - sql, params = m.to_sql() + vertex = GraphVertex(pg19_dialect, variable=None, table="person") + match = MatchClause(pg19_dialect, vertex) + sql, params = match.to_sql() assert sql == 'MATCH ("person")' assert params == () @@ -357,71 +405,102 @@ def test_alter_with_both_tables(self, pg19_dialect: PostgresDialect): assert '"person"' in sql assert '"knows"' in sql + @pytest.mark.parametrize( + ("action", "target", "message"), + [ + ("ADD; DROP TABLE people", "VERTEX TABLES", "action"), + ("ADD", "VERTEX TABLES; DROP TABLE people", "target"), + ("ALTER", "TABLES", "action"), + ("ADD", "ALL TABLES", "target"), + ], + ) + def test_alter_rejects_non_whitelisted_keywords( + self, pg19_dialect, action, target, message + ): + expr = AlterPropertyGraphExpression(pg19_dialect, "g", action, target) + + with pytest.raises(ValueError, match=f"Invalid ALTER PROPERTY GRAPH {message}"): + expr.to_sql() + class TestPGQUnsupportedFormat: - """Tests that PG15 raises errors for PGQ formatting.""" + """Tests that graph formatters fail closed without explicit opt-in.""" @pytest.fixture - def pg15_dialect(self): - return PostgresDialect((15, 0, 0)) - - def test_graph_vertex_unsupported(self, pg15_dialect: PostgresDialect): - v = GraphVertex(pg15_dialect, "p", "person") - with pytest.raises(Exception): - v.to_sql() - - def test_graph_edge_unsupported(self, pg15_dialect: PostgresDialect): - e = GraphEdge(pg15_dialect, "e", "knows", GraphEdgeDirection.RIGHT) - with pytest.raises(Exception): - e.to_sql() - - def test_graph_table_unsupported(self, pg15_dialect: PostgresDialect): - v = GraphVertex(pg15_dialect, "p", "person") - cols = ColumnsClause(pg15_dialect, GraphColumn("p", "name")) - m = MatchClause(pg15_dialect, v) - gt = GraphTableExpression(pg15_dialect, "g", m, cols) - with pytest.raises(Exception): - gt.to_sql() - - def test_vertex_table_unsupported(self, pg15_dialect: PostgresDialect): - vt = VertexTable(pg15_dialect, "person") + def withdrawn_dialect(self): + return PostgresDialect((19, 0, 4)) + + def test_graph_vertex_unsupported(self, withdrawn_dialect: PostgresDialect): + vertex = GraphVertex(withdrawn_dialect, "p", "person") with pytest.raises(UnsupportedFeatureError): - vt.to_sql() + vertex.to_sql() - def test_edge_table_unsupported(self, pg15_dialect: PostgresDialect): - et = EdgeTable(pg15_dialect, "knows", ["pid"], ["fid"]) + def test_graph_edge_unsupported(self, withdrawn_dialect: PostgresDialect): + edge = GraphEdge(withdrawn_dialect, "e", "knows", GraphEdgeDirection.RIGHT) with pytest.raises(UnsupportedFeatureError): - et.to_sql() + edge.to_sql() - def test_create_property_graph_unsupported(self, pg15_dialect: PostgresDialect): - vt = VertexTable(pg15_dialect, "person") - expr = CreatePropertyGraphExpression(pg15_dialect, "g", [vt]) + def test_match_clause_unsupported(self, withdrawn_dialect: PostgresDialect): + vertex = GraphVertex(withdrawn_dialect, "p", "person") + match = MatchClause(withdrawn_dialect, vertex) with pytest.raises(UnsupportedFeatureError): - expr.to_sql() + match.to_sql() - def test_drop_property_graph_unsupported(self, pg15_dialect: PostgresDialect): - expr = DropPropertyGraphExpression(pg15_dialect, "g") + def test_graph_columns_clause_unsupported(self, withdrawn_dialect: PostgresDialect): + columns = ColumnsClause(withdrawn_dialect, GraphColumn("p", "name")) with pytest.raises(UnsupportedFeatureError): - expr.to_sql() + columns.to_sql() - def test_alter_property_graph_unsupported(self, pg15_dialect: PostgresDialect): - vt = VertexTable(pg15_dialect, "person") - expr = AlterPropertyGraphExpression(pg15_dialect, "g", "ADD", "VERTEX TABLES", - vertex_tables=[vt]) + def test_graph_table_unsupported(self, withdrawn_dialect: PostgresDialect): + vertex = GraphVertex(withdrawn_dialect, "p", "person") + columns = ColumnsClause(withdrawn_dialect, GraphColumn("p", "name")) + match = MatchClause(withdrawn_dialect, vertex) + graph_table = GraphTableExpression(withdrawn_dialect, "g", match, columns) with pytest.raises(UnsupportedFeatureError): - expr.to_sql() + graph_table.to_sql() + + def test_vertex_table_unsupported(self, withdrawn_dialect: PostgresDialect): + vertex_table = VertexTable(withdrawn_dialect, "person") + with pytest.raises(UnsupportedFeatureError): + vertex_table.to_sql() + + def test_edge_table_unsupported(self, withdrawn_dialect: PostgresDialect): + edge_table = EdgeTable(withdrawn_dialect, "knows", ["pid"], ["fid"]) + with pytest.raises(UnsupportedFeatureError): + edge_table.to_sql() + + def test_create_property_graph_unsupported(self, withdrawn_dialect: PostgresDialect): + vertex_table = VertexTable(withdrawn_dialect, "person") + expression = CreatePropertyGraphExpression(withdrawn_dialect, "g", [vertex_table]) + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() + + def test_drop_property_graph_unsupported(self, withdrawn_dialect: PostgresDialect): + expression = DropPropertyGraphExpression(withdrawn_dialect, "g") + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() + + def test_alter_property_graph_unsupported(self, withdrawn_dialect: PostgresDialect): + expression = AlterPropertyGraphExpression( + withdrawn_dialect, + "g", + "ADD", + "VERTEX TABLES", + ) + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() - def test_table_properties_clause_unsupported(self, pg15_dialect: PostgresDialect): - p = TablePropertiesClause(pg15_dialect, columns=["id"]) + def test_table_properties_clause_unsupported(self, withdrawn_dialect: PostgresDialect): + properties = TablePropertiesClause(withdrawn_dialect, columns=["id"]) with pytest.raises(UnsupportedFeatureError): - p.to_sql() + properties.to_sql() - def test_table_properties_all_columns_unsupported(self, pg15_dialect: PostgresDialect): - p = TablePropertiesClause(pg15_dialect, columns=None) + def test_table_properties_all_columns_unsupported(self, withdrawn_dialect: PostgresDialect): + properties = TablePropertiesClause(withdrawn_dialect, columns=None) with pytest.raises(UnsupportedFeatureError): - p.to_sql() + properties.to_sql() - def test_table_properties_none_unsupported(self, pg15_dialect: PostgresDialect): - p = TablePropertiesClause(pg15_dialect, columns=[]) + def test_table_properties_none_unsupported(self, withdrawn_dialect: PostgresDialect): + properties = TablePropertiesClause(withdrawn_dialect, columns=[]) with pytest.raises(UnsupportedFeatureError): - p.to_sql() + properties.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py index 05aa0421..285070aa 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py @@ -4,6 +4,9 @@ These tests build expressions with a bare ``PostgresDialect`` (no DB connection) and assert on the generated SQL, so they run without a server. """ +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.impl.postgres.expression.ilike import ( ILIKEExpression, @@ -74,13 +77,13 @@ class TestEnumTypeNameExpression: def test_name_without_schema(self, postgres_dialect): expr = EnumTypeNameExpression(postgres_dialect, name="status") sql, params = expr.to_sql() - assert sql == "status" + assert sql == '"status"' assert params == () def test_name_with_schema(self, postgres_dialect): expr = EnumTypeNameExpression(postgres_dialect, name="status", schema="app") sql, params = expr.to_sql() - assert sql == "app.status" + assert sql == '"app"."status"' assert params == () @@ -108,17 +111,19 @@ def test_basic_create(self, postgres_dialect): postgres_dialect, name="status", values=["active", "inactive"], ) sql, params = expr.to_sql() - assert sql == "CREATE TYPE status AS ENUM ('active', 'inactive')" + assert sql == "CREATE TYPE \"status\" AS ENUM ('active', 'inactive')" assert params == () - def test_create_with_schema_and_if_not_exists(self, postgres_dialect): + def test_create_if_not_exists_fails_fast(self, postgres_dialect): expr = CreateEnumTypeExpression( - postgres_dialect, name="priority", values=["low", "high"], - schema="app", if_not_exists=True, + postgres_dialect, + name="priority", + values=["low", "high"], + schema="app", + if_not_exists=True, ) - sql, params = expr.to_sql() - assert sql == "CREATE TYPE IF NOT EXISTS app.priority AS ENUM ('low', 'high')" - assert params == () + with pytest.raises(UnsupportedFeatureError, match="CREATE TYPE IF NOT EXISTS"): + expr.to_sql() class TestDropEnumTypeExpression: @@ -127,7 +132,7 @@ class TestDropEnumTypeExpression: def test_basic_drop(self, postgres_dialect): expr = DropEnumTypeExpression(postgres_dialect, name="status") sql, params = expr.to_sql() - assert sql == "DROP TYPE status" + assert sql == "DROP TYPE \"status\"" assert params == () def test_drop_with_if_exists_and_cascade(self, postgres_dialect): @@ -136,7 +141,7 @@ def test_drop_with_if_exists_and_cascade(self, postgres_dialect): if_exists=True, cascade=True, ) sql, params = expr.to_sql() - assert sql == "DROP TYPE IF EXISTS app.status CASCADE" + assert sql == "DROP TYPE IF EXISTS \"app\".\"status\" CASCADE" assert params == () @@ -148,7 +153,7 @@ def test_basic_add_value(self, postgres_dialect): postgres_dialect, type_name="status", new_value="archived", ) sql, params = expr.to_sql() - assert sql == "ALTER TYPE status ADD VALUE 'archived'" + assert sql == "ALTER TYPE \"status\" ADD VALUE 'archived'" assert params == () def test_add_value_with_before_after(self, postgres_dialect): @@ -157,28 +162,19 @@ def test_add_value_with_before_after(self, postgres_dialect): schema="app", before="active", ) sql, params = expr.to_sql() - assert sql == "ALTER TYPE app.status ADD VALUE 'pending' BEFORE 'active'" + assert sql == "ALTER TYPE \"app\".\"status\" ADD VALUE 'pending' BEFORE 'active'" assert params == () class TestCreateMultirangeTypeExpression: - """Tests for CreateMultirangeTypeExpression.""" - - def test_basic_create(self, postgres_dialect): - expr = CreateMultirangeTypeExpression( - postgres_dialect, name="my_multirange", range_type="my_range", - ) - sql, params = expr.to_sql() - assert sql == "CREATE TYPE my_multirange AS MULTIRANGE (my_range)" - assert params == () - - def test_create_with_schema(self, postgres_dialect): + def test_explicit_multirange_ddl_fails_fast(self, postgres_dialect): expr = CreateMultirangeTypeExpression( - postgres_dialect, name="mr", range_type="int4range", schema="app", + postgres_dialect, + name="my_multirange", + range_type="my_range", ) - sql, params = expr.to_sql() - assert sql == "CREATE TYPE app.mr AS MULTIRANGE (int4range)" - assert params == () + with pytest.raises(UnsupportedFeatureError, match="CREATE TYPE AS MULTIRANGE"): + expr.to_sql() class TestMultirangeAggFunctionExpression: @@ -189,7 +185,7 @@ def test_basic_agg(self, postgres_dialect): postgres_dialect, range_column="period", table_name="events", ) sql, params = expr.to_sql() - assert sql == "SELECT multirange_agg(period) FROM events" + assert sql == 'SELECT multirange_agg("period") FROM "events"' assert params == () def test_agg_with_where_and_schema(self, postgres_dialect): @@ -198,5 +194,5 @@ def test_agg_with_where_and_schema(self, postgres_dialect): where_clause="status = 'active'", schema="public", ) sql, params = expr.to_sql() - assert sql == "SELECT multirange_agg(period) FROM public.events WHERE status = 'active'" + assert sql == "SELECT multirange_agg(\"period\") FROM \"public\".\"events\" WHERE status = 'active'" assert params == () diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/conftest.py new file mode 100644 index 00000000..1a24b64b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/conftest.py @@ -0,0 +1,145 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/conftest.py +"""Fixtures for PostgreSQL materialized view integration tests.""" + +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.expression import ( + Column, + FunctionCall, + GroupByHavingClause, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateMaterializedViewExpression, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +DDL = ExecutionOptions(stmt_type=StatementType.DDL) +DQL = ExecutionOptions(stmt_type=StatementType.DQL) + +_SETUP_SQL = [ + "DROP MATERIALIZED VIEW IF EXISTS mv_sales_summary CASCADE", + "DROP MATERIALIZED VIEW IF EXISTS mv_sales_daily CASCADE", + "DROP MATERIALIZED VIEW IF EXISTS mv_reporting.sales_summary CASCADE", + "DROP SCHEMA IF EXISTS mv_reporting CASCADE", + "DROP TABLE IF EXISTS mv_sales CASCADE", + """ + CREATE TABLE mv_sales ( + id SERIAL PRIMARY KEY, + product_id INTEGER NOT NULL, + amount DECIMAL(10,2) NOT NULL, + sale_date DATE NOT NULL + ) + """, + """ + INSERT INTO mv_sales (product_id, amount, sale_date) VALUES + (1, 100.00, '2024-01-01'), + (1, 150.00, '2024-01-02'), + (2, 200.00, '2024-01-01'), + (2, 250.00, '2024-01-03') + """, +] + +_TEARDOWN_SQL = [ + "DROP MATERIALIZED VIEW IF EXISTS mv_sales_summary CASCADE", + "DROP MATERIALIZED VIEW IF EXISTS mv_sales_daily CASCADE", + "DROP MATERIALIZED VIEW IF EXISTS mv_reporting.sales_summary CASCADE", + "DROP SCHEMA IF EXISTS mv_reporting CASCADE", + "DROP TABLE IF EXISTS mv_sales CASCADE", +] + + +@pytest.fixture +def summary_query(mv_backend): + """Aggregation over the mv_sales fixture table.""" + def _build(): + dialect = mv_backend.dialect + return QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "product_id"), + FunctionCall(dialect, "COUNT", Column(dialect, "id")), + FunctionCall(dialect, "SUM", Column(dialect, "amount")), + ], + from_=TableExpression(dialect, "mv_sales"), + group_by_having=GroupByHavingClause( + dialect=dialect, group_by=[Column(dialect, "product_id")] + ), + ) + + return _build + + +@pytest.fixture +def daily_query(mv_backend): + """Per-day aggregation over the mv_sales fixture table.""" + def _build(): + dialect = mv_backend.dialect + return QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "sale_date"), + FunctionCall(dialect, "SUM", Column(dialect, "amount")), + ], + from_=TableExpression(dialect, "mv_sales"), + group_by_having=GroupByHavingClause( + dialect=dialect, group_by=[Column(dialect, "sale_date")] + ), + ) + + return _build + + +@pytest.fixture +def mv_backend(postgres_backend_single): + """Backend with the mv_sales base table populated.""" + backend = postgres_backend_single + for statement in _SETUP_SQL: + backend.execute(statement) + backend.introspector.clear_cache() + yield backend + try: + backend.introspector.clear_cache() + for statement in _TEARDOWN_SQL: + backend.execute(statement) + except Exception: + pass + + +@pytest_asyncio.fixture +async def async_mv_backend(async_postgres_backend_single): + """Async backend with the mv_sales base table populated.""" + backend = async_postgres_backend_single + for statement in _SETUP_SQL: + await backend.execute(statement) + backend.introspector.clear_cache() + yield backend + try: + backend.introspector.clear_cache() + for statement in _TEARDOWN_SQL: + await backend.execute(statement) + except Exception: + pass + + +@pytest.fixture +def create_summary_mv(mv_backend, summary_query): + """Factory that creates a summary materialized view on the live server.""" + + def _create(view_name="mv_sales_summary", query=None, **kwargs): + expression = PostgresCreateMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name=view_name, + query=query if query is not None else summary_query(), + **kwargs, + ) + sql, params = expression.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + return expression + + return _create + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom.py index 539d37da..49550b40 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom.py @@ -9,6 +9,9 @@ from rhosocial.activerecord.backend.expression import CreateIndexExpression from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateIndexExpression, +) class TestPostgresBloomMixin: @@ -35,13 +38,13 @@ def test_format_bloom_index(self): def test_format_bloom_index_with_fill_factor(self): """CreateIndexExpression with fillfactor should include WITH (fillfactor = ...).""" - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( self.dialect, index_name="idx_name", table_name="table_name", columns=["col1", "col2"], index_type="bloom", - dialect_options={"with": {"fillfactor": 90}}, + with_options={"fillfactor": 90}, ) sql, _ = expr.to_sql() assert "bloom" in sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py index aef65b2c..0466260f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py @@ -8,8 +8,8 @@ - GIN index creation with btree_gin operator class on TEXT columns (text_ops) - Query data access through GIN index -btree_gin has no function factories. It works through CreateIndexExpression with -index_type="GIN" and dialect_options={"opclasses": {"column": "int4_ops"}} for +btree_gin has no function factories. It works through PostgresCreateIndexExpression +with index_type="GIN" and opclasses={"column": "int4_ops"} for specifying btree_gin operator classes that enable B-tree equivalence on GIN indexes. The btree_gin extension provides opclasses like int4_ops, int8_ops, text_ops, etc. that allow GIN indexes to support equality comparisons on scalar data types. @@ -32,7 +32,9 @@ QueryExpression, TableExpression, Column, - CreateIndexExpression, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateIndexExpression, ) from rhosocial.activerecord.backend.expression.statements import ValuesSource from rhosocial.activerecord.backend.expression.core import Literal @@ -114,14 +116,14 @@ def test_btree_gin_int4_ops_index(self, btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_value", table_name="test_btree_gin", columns=["value"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"value": "int4_ops"}}, + opclasses={"value": "int4_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -145,14 +147,14 @@ def test_btree_gin_text_ops_index(self, btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on TEXT column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_name", table_name="test_btree_gin", columns=["name"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"name": "text_ops"}}, + opclasses={"name": "text_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -176,14 +178,14 @@ def test_btree_gin_index_query(self, btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_value", table_name="test_btree_gin", columns=["value"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"value": "int4_ops"}}, + opclasses={"value": "int4_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -270,14 +272,14 @@ async def test_async_btree_gin_int4_ops_index(self, async_btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_value_async", table_name="test_btree_gin_async", columns=["value"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"value": "int4_ops"}}, + opclasses={"value": "int4_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) @@ -302,14 +304,14 @@ async def test_async_btree_gin_text_ops_index(self, async_btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on TEXT column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_name_async", table_name="test_btree_gin_async", columns=["name"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"name": "text_ops"}}, + opclasses={"name": "text_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) @@ -334,14 +336,14 @@ async def test_async_btree_gin_index_query(self, async_btree_gin_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GIN index with btree_gin operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gin_value_async", table_name="test_btree_gin_async", columns=["value"], index_type="GIN", if_not_exists=True, - dialect_options={"opclasses": {"value": "int4_ops"}}, + opclasses={"value": "int4_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py index 6b66c2d5..9a0fbe3a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py @@ -8,8 +8,8 @@ - GiST index creation with btree_gist operator class on INTEGER columns (gist_int4_ops) - Range queries using ComparisonPredicate on GiST-indexed columns -btree_gist has no function factories. It works through CreateIndexExpression with -index_type="GIST" and dialect_options={"opclasses": {"column": "gist_int4_ops"}} for +btree_gist has no function factories. It works through PostgresCreateIndexExpression +with index_type="GIST" and opclasses={"column": "gist_int4_ops"} for specifying btree_gist operator classes that enable B-tree equivalence on GiST indexes. The btree_gist extension provides opclasses like gist_int4_ops, gist_timestamp_ops, gist_text_ops, etc. that allow GiST indexes to support B-tree-like operations. @@ -33,7 +33,9 @@ QueryExpression, TableExpression, Column, - CreateIndexExpression, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateIndexExpression, ) from rhosocial.activerecord.backend.expression.statements import ValuesSource from rhosocial.activerecord.backend.expression.core import Literal @@ -117,14 +119,14 @@ def test_btree_gist_timestamp_index(self, btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on TIMESTAMP column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_created_at", table_name="test_btree_gist", columns=["created_at"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"created_at": "gist_timestamp_ops"}}, + opclasses={"created_at": "gist_timestamp_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -148,14 +150,14 @@ def test_btree_gist_int4_index(self, btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_value", table_name="test_btree_gist", columns=["value"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"value": "gist_int4_ops"}}, + opclasses={"value": "gist_int4_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -179,14 +181,14 @@ def test_btree_gist_range_query(self, btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on TIMESTAMP column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_created_at", table_name="test_btree_gist", columns=["created_at"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"created_at": "gist_timestamp_ops"}}, + opclasses={"created_at": "gist_timestamp_ops"}, ) sql, params = create_idx.to_sql() backend.execute(sql, params) @@ -282,14 +284,14 @@ async def test_async_btree_gist_timestamp_index(self, async_btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on TIMESTAMP column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_created_at_async", table_name="test_btree_gist_async", columns=["created_at"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"created_at": "gist_timestamp_ops"}}, + opclasses={"created_at": "gist_timestamp_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) @@ -314,14 +316,14 @@ async def test_async_btree_gist_int4_index(self, async_btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on INTEGER column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_value_async", table_name="test_btree_gist_async", columns=["value"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"value": "gist_int4_ops"}}, + opclasses={"value": "gist_int4_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) @@ -346,14 +348,14 @@ async def test_async_btree_gist_range_query(self, async_btree_gist_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) # Create a GiST index with btree_gist operator class on TIMESTAMP column - create_idx = CreateIndexExpression( + create_idx = PostgresCreateIndexExpression( dialect=dialect, index_name="idx_btree_gist_created_at_async", table_name="test_btree_gist_async", columns=["created_at"], index_type="GIST", if_not_exists=True, - dialect_options={"opclasses": {"created_at": "gist_timestamp_ops"}}, + opclasses={"created_at": "gist_timestamp_ops"}, ) sql, params = create_idx.to_sql() await backend.execute(sql, params) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py index a7ffff4b..4bf23423 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py @@ -10,7 +10,6 @@ from rhosocial.activerecord.backend.expression import Column, Literal from rhosocial.activerecord.backend.expression.statements import ( AddColumn, - AlterColumn, ColumnDefinition, DropColumn, DropTableConstraint, @@ -28,6 +27,7 @@ PostgresForceRlsExpression, PostgresAlterTableSettingsExpression, PostgresClusterExpression, + PostgresAlterColumn, ) from rhosocial.activerecord.backend.expression.types import ( TextType, @@ -199,12 +199,12 @@ def dialect(self): return PostgresDialect(version=(14, 0, 0)) def test_using_clause(self, dialect): - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DATA TYPE", new_value="NUMERIC(10,2)", - dialect_options={"using": Column(dialect, "price") + Literal(dialect, 1)}, + using=Column(dialect, "price") + Literal(dialect, 1), ) sql, serialized = action.to_sql() assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql @@ -212,13 +212,13 @@ def test_using_clause(self, dialect): assert serialized == (1,) def test_using_clause_with_cascade(self, dialect): - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DATA TYPE", new_value="NUMERIC(10,2)", cascade=True, - dialect_options={"using": Column(dialect, "price")}, + using=Column(dialect, "price"), ) sql, serialized = action.to_sql() assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql @@ -227,19 +227,19 @@ def test_using_clause_with_cascade(self, dialect): def test_using_rejected_for_non_set_data_type(self, dialect): """USING is only valid on SET DATA TYPE.""" - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DEFAULT", new_value="0", - dialect_options={"using": Column(dialect, "price")}, + using=Column(dialect, "price"), ) with pytest.raises(ValueError, match="USING"): action.to_sql() def test_without_using_unaffected(self, dialect): """No USING -> output matches the standard form.""" - action = AlterColumn( + action = PostgresAlterColumn( dialect, "price", "SET DATA TYPE", @@ -346,9 +346,10 @@ def test_create_enum_with_schema(self, dialect): ) assert sql == 'CREATE TYPE "app"."color" AS ENUM (\'red\')' - def test_create_enum_empty_values_raises(self, dialect): - with pytest.raises(ValueError, match="at least one value"): - dialect.format_create_type_enum_statement("color", []) + def test_create_enum_empty_values(self, dialect): + sql, params = dialect.format_create_type_enum_statement("color", []) + assert sql == 'CREATE TYPE "color" AS ENUM ()' + assert params == () def test_drop_type(self, dialect): sql, _ = dialect.format_drop_type_statement("color") @@ -360,3 +361,263 @@ def test_drop_type_full(self, dialect): ) assert sql == 'DROP TYPE IF EXISTS "app"."color" CASCADE' + +class TestPostgresConstraintEnforcement: + def test_capability_versions(self): + from rhosocial.activerecord.backend.expression import TableConstraintType + + assert PostgresDialect((17, 0, 0)).supports_constraint_enforced() is False + assert PostgresDialect((18, 0, 0)).supports_constraint_enforced() is True + pg18 = PostgresDialect((18, 0, 0)) + assert pg18.supports_alter_constraint_enforced() is False + assert pg18.supports_alter_constraint_enforced(TableConstraintType.FOREIGN_KEY) is True + assert pg18.supports_alter_constraint_enforced(TableConstraintType.CHECK) is False + pg19 = PostgresDialect((19, 0, 0)) + assert pg19.supports_alter_constraint_enforced(TableConstraintType.FOREIGN_KEY) is True + assert pg19.supports_alter_constraint_enforced(TableConstraintType.CHECK) is True + assert PostgresDialect((14, 0, 0)).supports_validate_constraint() is True + + def test_create_and_add_check_fk_enforcement(self): + from rhosocial.activerecord.backend.expression import ( + AddTableConstraint, + Column, + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, + ForeignKeyConstraint, + Literal, + TableConstraint, + TableConstraintType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + dialect = PostgresDialect((18, 0, 0)) + condition = Column(dialect, "age") > Literal(dialect, 0, inline_literals=True) + check = TableConstraint( + dialect, + TableConstraintType.CHECK, + name="age_check", + check_condition=condition, + enforced=False, + ) + fk = ForeignKeyConstraint( + dialect, + columns=["parent_id"], + foreign_key_table="people", + foreign_key_columns=["id"], + enforced=True, + ) + column_check = ColumnConstraint( + dialect, + ColumnConstraintType.CHECK, + check_condition=condition, + enforced=False, + ) + + assert check.to_sql()[0] == 'CONSTRAINT "age_check" CHECK ("age" > 0) NOT ENFORCED' + assert fk.to_sql()[0] == ( + 'FOREIGN KEY ("parent_id") REFERENCES "people"("id") ENFORCED' + ) + assert ColumnDefinition( + dialect, "age", IntegerType(dialect), [column_check] + ).to_sql()[0] == '"age" INTEGER CHECK ("age" > 0) NOT ENFORCED' + assert AddTableConstraint(dialect, check).to_sql()[0] == ( + 'ADD CONSTRAINT "age_check" CHECK ("age" > 0) NOT ENFORCED' + ) + + def test_not_valid_is_limited_to_check_and_fk(self): + from rhosocial.activerecord.backend.expression import AddTableConstraint + from rhosocial.activerecord.backend.expression.statements import ( + ConstraintValidation, + ForeignKeyConstraint, + TableConstraint, + TableConstraintType, + ) + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import PostgresExcludeConstraint + + dialect = PostgresDialect((18, 0, 0)) + constraint = ForeignKeyConstraint( + dialect, + columns=["parent_id"], + foreign_key_table="people", + foreign_key_columns=["id"], + validation=ConstraintValidation.NOVALIDATE, + ) + assert AddTableConstraint(dialect, constraint).to_sql()[0].endswith("NOT VALID") + string_constraint = TableConstraint( + dialect, + " check ", + check_condition=Column(dialect, "age") > Literal(dialect, 0, inline_literals=True), + validation=" not valid ", + ) + assert AddTableConstraint(dialect, string_constraint).to_sql()[0].endswith("NOT VALID") + assert "NOT VALID" not in string_constraint.to_sql()[0] + for constraint_type in (TableConstraintType.PRIMARY_KEY, TableConstraintType.UNIQUE): + table_constraint = TableConstraint( + dialect, + constraint_type, + columns=["id"], + validation=ConstraintValidation.NOVALIDATE, + ) + with pytest.raises(ValueError, match="NOT VALID"): + AddTableConstraint(dialect, table_constraint).to_sql() + exclude = PostgresExcludeConstraint( + dialect, + elements=[("range", "&&")], + validation=ConstraintValidation.NOVALIDATE, + ) + with pytest.raises(ValueError, match="NOT VALID"): + AddTableConstraint(dialect, exclude).to_sql() + + def test_alter_and_validate_actions(self): + from rhosocial.activerecord.backend.expression import ( + ColumnConstraintType, + TableConstraintType, + ) + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterConstraint, + PostgresValidateConstraint, + ) + + pg18 = PostgresDialect((18, 0, 0)) + fk_action = PostgresAlterConstraint( + pg18, + "parent_fk", + False, + constraint_type=ColumnConstraintType.FOREIGN_KEY, + ) + assert fk_action.constraint_type is TableConstraintType.FOREIGN_KEY + assert fk_action.to_sql()[0] == ( + 'ALTER CONSTRAINT "parent_fk" NOT ENFORCED' + ) + with pytest.raises(UnsupportedFeatureError): + PostgresAlterConstraint( + pg18, + "age_check", + False, + constraint_type=TableConstraintType.CHECK, + ).to_sql() + assert PostgresValidateConstraint(pg18, "age_check").to_sql()[0] == ( + 'VALIDATE CONSTRAINT "age_check"' + ) + + pg19 = PostgresDialect((19, 0, 0)) + assert PostgresAlterConstraint( + pg19, + "age_check", + False, + constraint_type=TableConstraintType.CHECK, + ).to_sql()[0] == 'ALTER CONSTRAINT "age_check" NOT ENFORCED' + with pytest.raises(TypeError): + PostgresAlterConstraint(pg19, "age_check", False) + + def test_exclude_create_and_add(self): + from rhosocial.activerecord.backend.expression import ( + AddTableConstraint, + Column, + ColumnDefinition, + CreateTableExpression, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import PostgresExcludeConstraint + + dialect = PostgresDialect((18, 0, 0)) + with pytest.raises(ValueError, match="at least one element"): + PostgresExcludeConstraint(dialect).to_sql() + exclude = PostgresExcludeConstraint( + dialect, + name="range_ex", + elements=[("range", "&&")], + ) + create = CreateTableExpression( + dialect, + "ranges", + [ColumnDefinition(dialect, "range", IntegerType(dialect))], + table_constraints=[exclude], + ) + assert create.to_sql()[0] == ( + 'CREATE TABLE "ranges" ("range" INTEGER, CONSTRAINT "range_ex" ' + 'EXCLUDE USING gist ("range" WITH &&))' + ) + assert AddTableConstraint(dialect, exclude).to_sql()[0] == ( + 'ADD CONSTRAINT "range_ex" EXCLUDE USING gist ("range" WITH &&)' + ) + + from rhosocial.activerecord.backend.expression.statements import ( + ConstraintValidation, + TableConstraint, + TableConstraintType, + ) + + not_valid_check = TableConstraint( + dialect, + TableConstraintType.CHECK, + check_condition=Column(dialect, "id") > 0, + validation=ConstraintValidation.NOVALIDATE, + ) + invalid_create = CreateTableExpression( + dialect, + "invalid_checks", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + table_constraints=[not_valid_check], + ) + with pytest.raises(ValueError, match="only valid when adding"): + invalid_create.to_sql() + + def test_exclude_expression_parentheses_and_recursive_binding(self): + from rhosocial.activerecord.backend.expression import ( + Column, + FunctionCall, + Literal, + RawSQLExpression, + RawSQLPredicate, + ) + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresExcludeConstraint, + ) + dialect = PostgresDialect((18, 0, 0)) + declared = PostgresExcludeConstraint( + dialect, + elements=[ + ( + FunctionCall(dialect, "LOWER", Column(dialect, "name")), + "=", + ) + ], + where=Column(dialect, "active") + == Literal(dialect, True, inline_literals=True), + ) + + assert declared.to_sql() == ( + 'EXCLUDE USING gist ((LOWER("name")) WITH =) ' + 'WHERE ("active" = TRUE)', + (), + ) + + with pytest.raises(ValueError, match="Invalid exclude operator"): + PostgresExcludeConstraint( + dialect, + elements=[("name", "is not")], + ).to_sql() + with pytest.raises(ValueError, match="parenthesized"): + PostgresExcludeConstraint( + dialect, + elements=[("(lower(name))", "=")], + ).to_sql() + with pytest.raises(ValueError, match="unbalanced"): + PostgresExcludeConstraint( + dialect, + elements=[(RawSQLExpression(dialect, "lower(name"), "=")], + ).to_sql() + with pytest.raises(ValueError, match="must not contain bind parameters"): + PostgresExcludeConstraint( + dialect, + elements=[(RawSQLExpression(dialect, "lower(name)", ("x",)), "=")], + ).to_sql() + with pytest.raises(ValueError, match="must not contain bind parameters"): + PostgresExcludeConstraint( + dialect, + elements=[("name", "=")], + where=RawSQLPredicate(dialect, "active = %s", (True,)), + ).to_sql() + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py index c729efa5..9b285096 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py @@ -9,13 +9,20 @@ import pytest -from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import ( + Column, + FunctionCall, + Literal, + RawSQLExpression, +) from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, CreateTableExpression, PartitionClause, PartitionStrategy, ) +from rhosocial.activerecord.backend.impl.dummy import DummyDialect from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PartitionValue, @@ -56,7 +63,7 @@ def test_basic_refresh(self, dialect): name="monthly_sales_summary", ) sql, params = expr.to_sql() - assert sql == "REFRESH MATERIALIZED VIEW monthly_sales_summary" + assert sql == 'REFRESH MATERIALIZED VIEW "monthly_sales_summary"' assert params == () def test_refresh_with_schema(self, dialect): @@ -67,7 +74,7 @@ def test_refresh_with_schema(self, dialect): schema="analytics", ) sql, params = expr.to_sql() - assert "analytics.monthly_sales_summary" in sql + assert sql == 'REFRESH MATERIALIZED VIEW "analytics"."monthly_sales_summary"' assert params == () def test_refresh_concurrently_pg13(self): @@ -79,8 +86,10 @@ def test_refresh_concurrently_pg13(self): concurrently=True, ) - with pytest.raises(ValueError, match="CONCURRENTLY requires PostgreSQL 9"): + with pytest.raises(UnsupportedFeatureError) as exc: expr.to_sql() + assert "CONCURRENTLY" in str(exc.value) + assert "9.4" in str(exc.value) def test_refresh_concurrently_pg94(self, dialect): """Test CONCURRENTLY refresh with PG 9.4+.""" @@ -151,7 +160,7 @@ def test_comment_on_table(self, dialect): assert params == ("User accounts table",) def test_comment_on_column(self, dialect): - """Test COMMENT ON COLUMN.""" + """Test COMMENT ON COLUMN (dotted target quoted segment-by-segment).""" expr = PostgresCommentExpression( dialect=dialect, object_type="COLUMN", @@ -160,7 +169,7 @@ def test_comment_on_column(self, dialect): ) sql, params = expr.to_sql() assert "COMMENT ON COLUMN" in sql - assert "users.email" in sql + assert '"users"."email"' in sql assert params == ("User email address",) def test_comment_on_index(self, dialect): @@ -221,7 +230,7 @@ def test_remove_comment(self, dialect): assert params == () def test_comment_with_schema(self, dialect): - """Test comment on object with schema.""" + """Test comment on object with schema (dotted target quoted per segment).""" expr = PostgresCommentExpression( dialect=dialect, object_type="TABLE", @@ -230,7 +239,7 @@ def test_comment_with_schema(self, dialect): schema="public", ) sql, params = expr.to_sql() - assert "public.users" in sql + assert '"public"."users"' in sql assert params == ("Public users table",) @@ -306,7 +315,7 @@ def test_create_hash_partitioned_parent_table_pg10(self): ), ) - with pytest.raises(Exception, match="HASH partitioning requires PostgreSQL 11"): + with pytest.raises(UnsupportedFeatureError, match="HASH partitioning requires PostgreSQL 11"): expr.to_sql() def test_create_hash_partitioned_parent_table_pg11(self): @@ -353,7 +362,7 @@ def test_create_partitioned_parent_table_pg9(self): ), ) - with pytest.raises(Exception, match="Declarative table partitioning requires PostgreSQL 10"): + with pytest.raises(UnsupportedFeatureError, match="Declarative table partitioning requires PostgreSQL 10"): expr.to_sql() def test_key_partitioning_is_rejected(self, dialect): @@ -365,6 +374,72 @@ def test_key_partitioning_is_rejected(self, dialect): keys=[Column(dialect, "id")], ) + def test_postgres_partition_clause_is_generic_subclass(self, dialect): + """PostgresPartitionClause derives from the generic PartitionClause.""" + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresPartitionClause, + ) + + clause = PostgresPartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ) + assert isinstance(clause, PartitionClause) + sql, params = clause.to_sql() + assert sql == ' PARTITION BY RANGE ("created_at")' + assert params == () + + def test_postgres_partition_clause_in_create_table(self, dialect): + """CreateTableExpression accepts the PostgreSQL-owned clause subclass.""" + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresPartitionClause, + ) + + expr = CreateTableExpression( + dialect=dialect, + table="events", + columns=[ + ColumnDefinition( + dialect, + "created_at", TimestampType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + ) + ], + partition=PostgresPartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ), + ) + sql, _ = expr.to_sql() + assert "PARTITION BY RANGE" in sql + + def test_partition_clause_rejects_cross_dialect_key(self, dialect): + clause = PartitionClause( + dialect, + PartitionStrategy.RANGE, + [Column(DummyDialect(), "created_at")], + ) + with pytest.raises(ValueError, match="same dialect|PostgreSQL dialect"): + clause.to_sql() + + def test_create_partition_rejects_cross_dialect_clause(self, dialect): + clause = PartitionClause( + DummyDialect(), + PartitionStrategy.HASH, + [Column(DummyDialect(), "bucket")], + ) + with pytest.raises(ValueError, match="same dialect"): + PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": "2026-01-01", "to": "2027-01-01"}, + partition_clause=clause, + ) + def test_multi_column_range_partitioned_parent_table(self, dialect): """Test creating a RANGE-partitioned parent table with multiple partition keys.""" expr = CreateTableExpression( @@ -395,6 +470,34 @@ def test_multi_column_range_partitioned_parent_table(self, dialect): assert 'PARTITION BY RANGE ("tenant_id", "created_at")' in sql assert params == () + def test_multi_column_list_partitioned_parent_table_rejected(self, dialect): + clause = PartitionClause( + dialect=dialect, + method=PartitionStrategy.LIST, + keys=[Column(dialect, "region"), Column(dialect, "status")], + ) + + with pytest.raises(ValueError, match="exactly one key expression"): + clause.to_sql() + + def test_child_multi_column_list_partition_clause_rejected(self, dialect): + clause = PartitionClause( + dialect=dialect, + method=PartitionStrategy.LIST, + keys=[Column(dialect, "region"), Column(dialect, "status")], + ) + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": "2026-01-01", "to": "2027-01-01"}, + partition_clause=clause, + ) + + with pytest.raises(ValueError, match="exactly one key expression"): + expr.to_sql() + class TestPostgresPartitionValue: """Test safe PostgreSQL partition bound value formatting.""" @@ -433,6 +536,49 @@ def test_partition_value_rejects_nonfinite_numbers(self, dialect, value): with pytest.raises(ValueError): PartitionValue(dialect=dialect, value=value) + @pytest.mark.parametrize("value", ["MINVALUE", "MAXVALUE", "DEFAULT"]) + def test_list_partition_value_quotes_special_range_strings(self, dialect, value): + expr = PartitionValue(dialect, value, partition_type="LIST") + assert expr.to_sql() == (f"'{value}'", ()) + + def test_partition_value_renders_function_call_with_inline_literals(self, dialect): + expr = PartitionValue( + dialect, + FunctionCall(dialect, "DATE_TRUNC", Literal(dialect, "month")), + ) + assert expr.to_sql() == ("DATE_TRUNC('month')", ()) + + def test_partition_value_rejects_parameterized_raw_sql(self, dialect): + expr = PartitionValue( + dialect, + RawSQLExpression(dialect, "DATE %s", ("2024-01-01",)), + ) + with pytest.raises(ValueError, match="must not contain bind parameters"): + expr.to_sql() + + def test_partition_value_rejects_cross_dialect_expression(self, dialect): + expression = FunctionCall(DummyDialect(), "DATE", niladic=True) + with pytest.raises(ValueError, match="same dialect"): + PartitionValue(dialect, expression) + + @pytest.mark.parametrize("expression_factory", [ + lambda dialect: Column(dialect, "created_at"), + lambda dialect: FunctionCall(dialect, "DATE", Column(dialect, "created_at")), + ]) + def test_partition_value_rejects_unsafe_expression(self, dialect, expression_factory): + with pytest.raises(TypeError, match="FunctionCall, Literal, or RawSQLExpression"): + PartitionValue(dialect, expression_factory(dialect)) + + def test_default_partition_value_requires_pg11(self): + dialect = PostgresDialect(version=(10, 0, 0)) + with pytest.raises(UnsupportedFeatureError, match="DEFAULT partitions require PostgreSQL 11"): + PartitionValue(dialect, "DEFAULT").to_sql() + + def test_partition_value_requires_pg10(self): + dialect = PostgresDialect(version=(9, 6, 0)) + with pytest.raises(UnsupportedFeatureError, match="requires PostgreSQL 10"): + PartitionValue(dialect, 1).to_sql() + class TestPostgresCreatePartitionExpression: """Test PostgresCreatePartitionExpression.""" @@ -498,7 +644,7 @@ def test_create_hash_partition_pg10(self): partition_type="HASH", partition_values={"modulus": 4, "remainder": 0}, ) - with pytest.raises(Exception, match="HASH partitioning requires PostgreSQL 11"): + with pytest.raises(UnsupportedFeatureError, match="HASH partitioning requires PostgreSQL 11"): expr.to_sql() def test_create_hash_partition_pg11(self, dialect): @@ -583,6 +729,203 @@ def test_create_partition_value_types(self, dialect, partition_type, partition_v assert expected_clause in sql assert params == () + def test_create_multi_column_range_partition(self, dialect): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + { + "from": [1, date(2026, 1, 1)], + "to": [2, date(2027, 1, 1)], + }, + ) + sql, params = expr.to_sql() + assert "FROM (1, '2026-01-01') TO (2, '2027-01-01')" in sql + assert params == () + + def test_create_multi_column_list_partition_rejected(self, dialect): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "LIST", + {"values": [["us", 1], ["eu", 2]]}, + ) + + with pytest.raises(ValueError, match="nested rows"): + expr.to_sql() + + def test_list_special_range_strings_remain_quoted(self, dialect): + expr = PostgresCreatePartitionExpression( + dialect, + "events_special", + "events", + "LIST", + {"values": ["MINVALUE", "MAXVALUE", "DEFAULT"]}, + ) + sql, _ = expr.to_sql() + assert "IN ('MINVALUE', 'MAXVALUE', 'DEFAULT')" in sql + + def test_child_partition_clause_order_and_schemas(self, dialect): + clause = PartitionClause( + dialect, + PartitionStrategy.HASH, + [Column(dialect, "bucket")], + ) + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": "2026-01-01", "to": "2027-01-01"}, + schema="child_schema", + parent_schema="parent_schema", + tablespace="fastspace", + partition_clause=clause, + ) + sql, params = expr.to_sql() + assert sql == ( + 'CREATE TABLE "child_schema"."events_p1" ' + 'PARTITION OF "parent_schema"."events" ' + "FOR VALUES FROM ('2026-01-01') TO ('2027-01-01') " + 'PARTITION BY HASH ("bucket") TABLESPACE "fastspace"' + ) + assert sql.index("FOR VALUES") < sql.index("PARTITION BY") < sql.index("TABLESPACE") + assert params == () + + def test_create_partition_rejects_nested_parameters(self, dialect): + clause = PartitionClause( + dialect, + PartitionStrategy.HASH, + [RawSQLExpression(dialect, "bucket %s", (1,))], + ) + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + { + "from": RawSQLExpression(dialect, "DATE %s", ("2026-01-01",)), + "to": "MAXVALUE", + }, + partition_clause=clause, + ) + with pytest.raises(ValueError, match="must not contain bind parameters"): + expr.to_sql() + + def test_create_partition_literals_are_inlined(self, dialect): + clause = PartitionClause( + dialect, + PartitionStrategy.HASH, + [FunctionCall(dialect, "LOWER", Literal(dialect, "bucket"))], + ) + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + { + "from": Literal(dialect, "2026-01-01"), + "to": Literal(dialect, "2027-01-01"), + }, + partition_clause=clause, + ) + + sql, params = expr.to_sql() + + assert params == () + assert "%s" not in sql + assert "LOWER('bucket')" in sql + assert "FROM ('2026-01-01')" in sql + + def test_create_list_partition_literals_are_inlined(self, dialect): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "LIST", + {"values": [FunctionCall(dialect, "LOWER", Literal(dialect, "ACTIVE"))]}, + ) + + sql, params = expr.to_sql() + + assert params == () + assert "%s" not in sql + assert "IN (LOWER('ACTIVE'))" in sql + + def test_create_default_partition_requires_pg11(self): + dialect = PostgresDialect(version=(10, 0, 0)) + expr = PostgresCreatePartitionExpression( + dialect, + "events_default", + "events", + "RANGE", + {"default": True}, + ) + with pytest.raises(UnsupportedFeatureError, match="DEFAULT partitions require PostgreSQL 11"): + expr.to_sql() + + @pytest.mark.parametrize("modulus", [True, "4", 4.0]) + def test_create_hash_rejects_non_integer_modulus(self, dialect, modulus): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "HASH", + {"modulus": modulus, "remainder": 0}, + ) + with pytest.raises(TypeError, match="modulus must be an int"): + expr.to_sql() + + @pytest.mark.parametrize("modulus", [0, -1]) + def test_create_hash_rejects_non_positive_modulus(self, dialect, modulus): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "HASH", + {"modulus": modulus, "remainder": 0}, + ) + with pytest.raises(ValueError, match="modulus must be a positive integer"): + expr.to_sql() + + @pytest.mark.parametrize("remainder", [True, "0", 0.0]) + def test_create_hash_rejects_non_integer_remainder(self, dialect, remainder): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "HASH", + {"modulus": 4, "remainder": remainder}, + ) + with pytest.raises(TypeError, match="remainder must be an int"): + expr.to_sql() + + @pytest.mark.parametrize("remainder", [-1, 4]) + def test_create_hash_rejects_out_of_range_remainder(self, dialect, remainder): + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "HASH", + {"modulus": 4, "remainder": remainder}, + ) + with pytest.raises(ValueError, match=r"0 <= remainder < modulus"): + expr.to_sql() + + def test_create_partition_requires_pg10(self): + dialect = PostgresDialect(version=(9, 6, 0)) + expr = PostgresCreatePartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": "2026-01-01", "to": "2027-01-01"}, + ) + with pytest.raises(UnsupportedFeatureError, match="requires PostgreSQL 10"): + expr.to_sql() + class TestPostgresDetachPartitionExpression: """Test PostgresDetachPartitionExpression.""" @@ -665,6 +1008,16 @@ def test_detach_with_schema(self, dialect): sql, params = expr.to_sql() assert '"sales".' in sql + def test_detach_partition_requires_pg10(self): + dialect = PostgresDialect(version=(9, 6, 0)) + expr = PostgresDetachPartitionExpression( + dialect, + "events_p1", + "events", + ) + with pytest.raises(UnsupportedFeatureError, match="requires PostgreSQL 10"): + expr.to_sql() + class TestPostgresAttachPartitionExpression: """Test PostgresAttachPartitionExpression.""" @@ -740,6 +1093,37 @@ def test_attach_list_partition_requires_values(self, dialect): with pytest.raises(ValueError, match="LIST partition requires"): expr.to_sql() + def test_attach_multi_column_list_partition_rejected(self, dialect): + expr = PostgresAttachPartitionExpression( + dialect, + "events_p1", + "events", + "LIST", + {"values": [["us", 1], ["eu", 2]]}, + ) + + with pytest.raises(ValueError, match="nested rows"): + expr.to_sql() + + def test_attach_partition_literals_are_inlined(self, dialect): + expr = PostgresAttachPartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + { + "from": Literal(dialect, "2026-01-01"), + "to": FunctionCall(dialect, "DATE_TRUNC", Literal(dialect, "year")), + }, + ) + + sql, params = expr.to_sql() + + assert params == () + assert "%s" not in sql + assert "FROM ('2026-01-01')" in sql + assert "TO (DATE_TRUNC('year'))" in sql + def test_attach_hash_partition_requires_pg11(self): """HASH attach requires PostgreSQL 11+.""" dialect = PostgresDialect(version=(10, 0, 0)) @@ -750,11 +1134,11 @@ def test_attach_hash_partition_requires_pg11(self): partition_type="HASH", partition_values={"modulus": 4, "remainder": 0}, ) - with pytest.raises(ValueError, match="HASH partitioning requires PostgreSQL 11"): + with pytest.raises(UnsupportedFeatureError, match="HASH partitioning requires PostgreSQL 11"): expr.to_sql() def test_attach_concurrently_pg13(self): - """ATTACH CONCURRENTLY requires PG 14+.""" + """ATTACH CONCURRENTLY is rejected on PG 13.""" dialect_pg13 = PostgresDialect(version=(13, 0, 0)) expr = PostgresAttachPartitionExpression( dialect=dialect_pg13, @@ -764,11 +1148,11 @@ def test_attach_concurrently_pg13(self): partition_values={"from": "2024-01-01", "to": "2024-04-01"}, concurrently=True, ) - with pytest.raises(ValueError, match="ATTACH CONCURRENTLY requires PostgreSQL 14"): + with pytest.raises(UnsupportedFeatureError, match="ATTACH PARTITION CONCURRENTLY"): expr.to_sql() def test_attach_concurrently_pg14(self, dialect): - """ATTACH CONCURRENTLY with PG 14+ includes CONCURRENTLY keyword.""" + """ATTACH CONCURRENTLY is rejected on PG 14+.""" expr = PostgresAttachPartitionExpression( dialect=dialect, partition_name="orders_2024_q1", @@ -777,10 +1161,8 @@ def test_attach_concurrently_pg14(self, dialect): partition_values={"from": "2024-01-01", "to": "2024-04-01"}, concurrently=True, ) - sql, params = expr.to_sql() - assert "CONCURRENTLY" in sql - assert "FOR VALUES" in sql - assert params == () + with pytest.raises(UnsupportedFeatureError, match="ATTACH PARTITION CONCURRENTLY"): + expr.to_sql() def test_attach_default_range_partition(self, dialect): """ATTACH DEFAULT partition for RANGE should emit DEFAULT keyword.""" @@ -810,6 +1192,58 @@ def test_attach_default_list_partition(self, dialect): assert "FOR VALUES" not in sql assert params == () + def test_attach_multi_column_range_partition(self, dialect): + expr = PostgresAttachPartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": [1, "2026-01-01"], "to": [2, "2027-01-01"]}, + ) + sql, params = expr.to_sql() + assert "FROM (1, '2026-01-01') TO (2, '2027-01-01')" in sql + assert params == () + + def test_attach_default_partition_requires_pg11(self): + dialect = PostgresDialect(version=(10, 0, 0)) + expr = PostgresAttachPartitionExpression( + dialect, + "events_default", + "events", + "LIST", + {"default": True}, + ) + with pytest.raises(UnsupportedFeatureError, match="DEFAULT partitions require PostgreSQL 11"): + expr.to_sql() + + @pytest.mark.parametrize("modulus,remainder", [ + ("4); DROP TABLE events; --", 0), + (4, "0); DROP TABLE events; --"), + (4, 4), + ]) + def test_attach_hash_rejects_invalid_integers(self, dialect, modulus, remainder): + expr = PostgresAttachPartitionExpression( + dialect, + "events_p1", + "events", + "HASH", + {"modulus": modulus, "remainder": remainder}, + ) + with pytest.raises((TypeError, ValueError)): + expr.to_sql() + + def test_attach_partition_requires_pg10(self): + dialect = PostgresDialect(version=(9, 6, 0)) + expr = PostgresAttachPartitionExpression( + dialect, + "events_p1", + "events", + "RANGE", + {"from": "2026-01-01", "to": "2027-01-01"}, + ) + with pytest.raises(UnsupportedFeatureError, match="requires PostgreSQL 10"): + expr.to_sql() + class TestPostgresPartitionMetadataExpression: """Test PostgreSQL partition metadata query expression.""" @@ -824,6 +1258,9 @@ def test_metadata_query_for_parent(self): sql, params = expr.to_sql() assert "pg_get_partkeydef" in sql assert "pg_inherits" in sql + assert "pg_partitioned_table" in sql + assert "parent.relkind = 'p'" in sql + assert "partitioned_parent.partrelid IS NOT NULL" in sql assert params == ("orders",) def test_metadata_query_with_schema(self): @@ -849,6 +1286,8 @@ def test_metadata_query_without_partitions(self): ) sql, params = expr.to_sql() assert "pg_inherits" not in sql + assert "pg_partitioned_table" in sql + assert "parent.relkind = 'p'" in sql assert "NULL::text AS name" in sql assert params == ("orders",) @@ -859,7 +1298,7 @@ def test_metadata_query_requires_pg10(self): dialect=dialect, parent_table="orders", ) - with pytest.raises(Exception, match="partition metadata introspection"): + with pytest.raises(UnsupportedFeatureError, match="partition metadata introspection"): expr.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py index b8032b8e..fa735898 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py @@ -11,11 +11,63 @@ from rhosocial.activerecord.backend.dialect.exceptions import ( UnsupportedFeatureError, ) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.serialization import ( + deserialize, + deserialize_json, + deserialize_xml, + serialize, + serialize_json, + serialize_xml, +) +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AlterDomainExpression, + CreateDomainExpression, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + DropDomainDefaultAction, + DropDomainNotNullAction, + RenameDomainAction, + SetDomainDefaultAction, + SetDomainNotNullAction, +) +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, +) +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PostgresVacuumExpression, # noqa: F401 PostgresAnalyzeExpression, # noqa: F401 AlterDomainActionType, + PostgresAddDomainCheckAction, + PostgresAddEnumValueAction, + PostgresAddTypeAttributeAction, + PostgresAlterEnumAddValueExpression, + PostgresCreateEnumTypeExpression, + PostgresDropEnumTypeExpression, + PostgresAlterTypeAttributeAction, + PostgresBaseTypeDefinition, + PostgresChangeDomainOwnerAction, + PostgresChangeTypeOwnerAction, + PostgresCompositeTypeAttribute, + PostgresCompositeTypeDefinition, PostgresCreateDomainExpression, + PostgresDropDomainCheckAction, + PostgresDropTypeAttributeAction, + PostgresDropTypeExpression, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresRenameDomainConstraintAction, + PostgresRenameEnumValueAction, + PostgresRenameTypeAction, + PostgresRenameTypeAttributeAction, + PostgresSetDomainSchemaAction, + PostgresSetTypePropertiesAction, + PostgresSetTypeSchemaAction, + PostgresShellTypeDefinition, + PostgresValidateDomainConstraintAction, PostgresAlterDomainExpression, PostgresDropDomainExpression, PostgresCreateCollationExpression, @@ -47,7 +99,7 @@ def test_create(self, dialect): dialect, "posint", "NUMERIC", default="0", constraints=["CHECK (VALUE > 0)"], ).to_sql() - assert sql == "CREATE DOMAIN \"posint\" AS NUMERIC DEFAULT 0 CHECK (VALUE > 0)" + assert sql == "CREATE DOMAIN \"posint\" AS DECIMAL DEFAULT 0 CHECK (VALUE > 0)" def test_alter_set_default(self, dialect): sql, _ = PostgresAlterDomainExpression( @@ -72,13 +124,13 @@ def test_create_with_schema_and_collation(self, dialect): dialect, "posint", "NUMERIC", schema="app", collation="C", ).to_sql() - assert sql == 'CREATE DOMAIN "app"."posint" AS NUMERIC COLLATE C' + assert sql == 'CREATE DOMAIN "app"."posint" AS DECIMAL COLLATE "C"' def test_create_without_default(self, dialect): sql, _ = PostgresCreateDomainExpression( dialect, "posint", "NUMERIC", constraints=["CHECK (VALUE > 0)"] ).to_sql() - assert sql == 'CREATE DOMAIN "posint" AS NUMERIC CHECK (VALUE > 0)' + assert sql == 'CREATE DOMAIN "posint" AS DECIMAL CHECK (VALUE > 0)' def test_alter_drop_default(self, dialect): sql, _ = PostgresAlterDomainExpression( @@ -123,6 +175,466 @@ def test_version_gate_96(self): PostgresDropDomainExpression(d, "posint").to_sql() + def test_typed_create_with_schema_and_all_clauses(self, dialect): + value = DomainValueExpression(dialect) + condition = value > Literal(dialect, 0, inline_literals=True) + check = DomainCheckConstraint(dialect, condition, name="positive") + sql, params = PostgresCreateDomainExpression( + dialect, + "amount", + IntegerType(dialect), + schema="app", + collation="public.catalog", + default=0, + checks=[check], + nullability=DomainNullability.NOT_NULL, + ).to_sql() + assert sql == ( + 'CREATE DOMAIN "app"."amount" AS INTEGER COLLATE "public"."catalog" ' + 'DEFAULT 0 CONSTRAINT "positive" CHECK (VALUE > 0) NOT NULL' + ) + assert params == () + + def test_create_domain_params_emit_one_constraint_key(self, dialect): + value = DomainValueExpression(dialect) + condition = value > Literal(dialect, 0, inline_literals=True) + legacy = PostgresCreateDomainExpression( + dialect, + "legacy", + IntegerType(dialect), + constraints=["CHECK (VALUE > 0)"], + ) + typed = PostgresCreateDomainExpression( + dialect, + "typed", + IntegerType(dialect), + checks=[condition], + ) + legacy_params = legacy.get_params() + typed_params = typed.get_params() + assert "constraints" in legacy_params + assert "checks" not in legacy_params + assert "checks" in typed_params + assert "constraints" not in typed_params + + def test_new_core_expression_rejects_raw_data_type(self, dialect): + with pytest.raises(TypeError, match="DataType"): + CreateDomainExpression(dialect, "raw_domain", "INTEGER") + + def test_all_domain_actions(self, dialect): + value = DomainValueExpression(dialect) + check = DomainCheckConstraint( + dialect, + value > Literal(dialect, 0, inline_literals=True), + name="positive", + ) + cases = [ + (SetDomainDefaultAction(dialect, 1), "SET DEFAULT 1"), + (DropDomainDefaultAction(dialect), "DROP DEFAULT"), + (SetDomainNotNullAction(dialect), "SET NOT NULL"), + (DropDomainNotNullAction(dialect), "DROP NOT NULL"), + ( + PostgresAddDomainCheckAction(dialect, check, not_valid=True), + 'ADD CONSTRAINT "positive" CHECK (VALUE > 0) NOT VALID', + ), + ( + PostgresDropDomainCheckAction( + dialect, + "positive", + if_exists=True, + cascade=True, + ), + 'DROP CONSTRAINT IF EXISTS "positive" CASCADE', + ), + ( + PostgresRenameDomainConstraintAction( + dialect, + "positive", + "nonnegative", + ), + 'RENAME CONSTRAINT "positive" TO "nonnegative"', + ), + ( + PostgresValidateDomainConstraintAction(dialect, "nonnegative"), + 'VALIDATE CONSTRAINT "nonnegative"', + ), + (PostgresChangeDomainOwnerAction(dialect, "app_owner"), 'OWNER TO "app_owner"'), + (RenameDomainAction(dialect, "amount_v2"), 'RENAME TO "amount_v2"'), + (PostgresSetDomainSchemaAction(dialect, "archive"), 'SET SCHEMA "archive"'), + ] + for action, expected in cases: + sql, params = PostgresAlterDomainExpression( + dialect, + "amount", + action, + schema="app", + ).to_sql() + assert sql == f'ALTER DOMAIN "app"."amount" {expected}' + assert params == () + + def test_domain_actions_are_not_combinable(self, dialect): + expression = AlterDomainExpression( + dialect, + "amount", + [DropDomainDefaultAction(dialect), SetDomainNotNullAction(dialect)], + ) + with pytest.raises(UnsupportedFeatureError, match="multiple ALTER DOMAIN actions"): + expression.to_sql() + + def test_drop_domain_flags(self, dialect): + sql, _ = PostgresDropDomainExpression( + dialect, + "amount", + schema="app", + if_exists=True, + restrict=True, + ).to_sql() + assert sql == 'DROP DOMAIN IF EXISTS "app"."amount" RESTRICT' + expression = PostgresDropDomainExpression( + dialect, + "amount", + cascade=True, + restrict=True, + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expression.to_sql() + + +class TestPostgresTypeDDLExpressions: + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_composite_definition_round_trip(self, dialect): + definition = PostgresCompositeTypeDefinition( + dialect, + [PostgresCompositeTypeAttribute("id", IntegerType(dialect))], + ) + restored = ( + deserialize(serialize(definition), dialect), + deserialize_json(serialize_json(definition), dialect), + deserialize_xml(serialize_xml(definition), dialect), + ) + assert all(item.get_params() == definition.get_params() for item in restored) + + def test_base_unset_round_trip(self, dialect): + definition = PostgresBaseTypeDefinition( + dialect, + input_function="box_in", + output_function="box_out", + ) + restored = ( + deserialize(serialize(definition), dialect), + deserialize_json(serialize_json(definition), dialect), + deserialize_xml(serialize_xml(definition), dialect), + ) + assert all(item.get_params() == definition.get_params() for item in restored) + + def test_compatibility_wrapper_params(self, dialect): + create = PostgresCreateEnumTypeExpression( + dialect, + "status", + ["active"], + if_not_exists=True, + ) + alter = PostgresAlterEnumAddValueExpression( + dialect, + "status", + "pending", + if_not_exists=True, + ) + drop = PostgresDropEnumTypeExpression( + dialect, + "status", + restrict=True, + ) + assert create.get_params()["if_not_exists"] is True + assert alter.get_params()["if_not_exists"] is True + assert drop.get_params()["restrict"] is True + + def test_composite_definition(self, dialect): + definition = PostgresCompositeTypeDefinition( + dialect, + [ + PostgresCompositeTypeAttribute("id", IntegerType(dialect)), + PostgresCompositeTypeAttribute( + "label", + TextType(dialect), + collation="public.catalog", + ), + ], + ) + sql, params = CreateTypeExpression( + dialect, + "app.address", + definition, + ).to_sql() + assert sql == ( + 'CREATE TYPE "app"."address" AS ("id" INTEGER, "label" TEXT ' + 'COLLATE "public"."catalog")' + ) + assert params == () + + def test_enum_definition_escapes_labels(self, dialect): + definition = PostgresEnumTypeDefinition( + dialect, + ["ready", "O'Reilly", ""], + ) + sql, params = CreateTypeExpression(dialect, "status", definition).to_sql() + assert sql == "CREATE TYPE \"status\" AS ENUM ('ready', 'O''Reilly', '')" + assert params == () + + def test_range_definition_with_multirange_name(self, dialect): + definition = PostgresRangeTypeDefinition( + dialect, + IntegerType(dialect), + subtype_operator_class="public.int_ops", + canonical_function="public.canonical", + multirange_type_name="app.span_multirange", + ) + sql, params = CreateTypeExpression(dialect, "app.span", definition).to_sql() + assert sql == ( + 'CREATE TYPE "app"."span" AS RANGE (SUBTYPE = INTEGER, ' + 'SUBTYPE_OPCLASS = "public"."int_ops", CANONICAL = "public"."canonical", ' + 'MULTIRANGE_TYPE_NAME = "app"."span_multirange")' + ) + assert params == () + + def test_range_multirange_name_version_gate(self): + dialect = PostgresDialect(version=(13, 0, 0)) + definition = PostgresRangeTypeDefinition( + dialect, + IntegerType(dialect), + multirange_type_name="span_multirange", + ) + with pytest.raises( + UnsupportedFeatureError, + match="CREATE TYPE MULTIRANGE_TYPE_NAME", + ): + CreateTypeExpression(dialect, "span", definition).to_sql() + + def test_base_definition(self, dialect): + definition = PostgresBaseTypeDefinition( + dialect, + input_function="public.box_in", + output_function="public.box_out", + internallength=16, + passedbyvalue=True, + storage="plain", + default=0, + element=IntegerType(dialect), + ) + sql, params = CreateTypeExpression(dialect, "box", definition).to_sql() + assert sql == ( + 'CREATE TYPE "box" (INPUT = "public"."box_in", ' + 'OUTPUT = "public"."box_out", INTERNALLENGTH = 16, PASSEDBYVALUE, ' + 'STORAGE = "plain", DEFAULT = 0, ELEMENT = INTEGER)' + ) + assert params == () + + def test_base_like_does_not_bypass_io_functions(self, dialect): + with pytest.raises(ValueError, match="input_function and output_function"): + PostgresBaseTypeDefinition( + dialect, + like_type=IntegerType(dialect), + ) + + def test_shell_definition(self, dialect): + sql, params = CreateTypeExpression( + dialect, + "public.pending_box", + PostgresShellTypeDefinition(dialect), + ).to_sql() + assert sql == 'CREATE TYPE "public"."pending_box"' + assert params == () + + def test_create_type_unsupported_clauses_fail_fast(self, dialect): + enum = PostgresEnumTypeDefinition(dialect, ["active"]) + with pytest.raises(UnsupportedFeatureError, match="CREATE TYPE IF NOT EXISTS"): + CreateTypeExpression( + dialect, + "status", + enum, + if_not_exists=True, + ).to_sql() + with pytest.raises(UnsupportedFeatureError, match="CREATE OR REPLACE TYPE"): + CreateTypeExpression( + dialect, + "status", + enum, + or_replace=True, + ).to_sql() + + def test_drop_type_flags(self, dialect): + sql, params = PostgresDropTypeExpression( + dialect, + "status", + schema_name="app", + if_exists=True, + cascade=True, + ).to_sql() + assert sql == 'DROP TYPE IF EXISTS "app"."status" CASCADE' + assert params == () + expression = PostgresDropTypeExpression( + dialect, + "status", + cascade=True, + restrict=True, + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expression.to_sql() + + def test_legacy_drop_type_name_keyword(self, dialect): + sql, params = dialect.format_drop_type_statement( + name="status", + schema="app", + if_exists=True, + ) + assert sql == 'DROP TYPE IF EXISTS "app"."status"' + assert params == () + + def test_all_type_actions(self, dialect): + cases = [ + (PostgresRenameTypeAction(dialect, "state"), 'RENAME TO "state"'), + (PostgresSetTypeSchemaAction(dialect, "archive"), 'SET SCHEMA "archive"'), + (PostgresChangeTypeOwnerAction(dialect, "type_owner"), 'OWNER TO "type_owner"'), + ( + PostgresRenameTypeAttributeAction( + dialect, + "label", + "name", + restrict=True, + ), + 'RENAME ATTRIBUTE "label" TO "name" RESTRICT', + ), + ( + PostgresAddTypeAttributeAction( + dialect, + "active", + IntegerType(dialect), + collation="public.catalog", + ), + 'ADD ATTRIBUTE "active" INTEGER COLLATE "public"."catalog"', + ), + ( + PostgresDropTypeAttributeAction( + dialect, + "active", + if_exists=True, + cascade=True, + ), + 'DROP ATTRIBUTE IF EXISTS "active" CASCADE', + ), + ( + PostgresAlterTypeAttributeAction( + dialect, + "active", + IntegerType(dialect), + set_data=True, + ), + 'ALTER ATTRIBUTE "active" SET DATA TYPE INTEGER', + ), + ( + PostgresAddEnumValueAction( + dialect, + "O'Reilly", + if_not_exists=True, + after="ready", + ), + "ADD VALUE IF NOT EXISTS 'O''Reilly' AFTER 'ready'", + ), + ( + PostgresRenameEnumValueAction(dialect, "old", "new"), + "RENAME VALUE 'old' TO 'new'", + ), + ( + PostgresSetTypePropertiesAction( + dialect, + {"SEND": "public.box_send", "STORAGE": "plain"}, + ), + 'SET (SEND = "public"."box_send", STORAGE = "plain")', + ), + ] + for action, expected in cases: + sql, params = AlterTypeExpression( + dialect, + "app.status", + [action], + ).to_sql() + assert sql == f'ALTER TYPE "app"."status" {expected}' + assert params == () + + def test_composite_actions_can_be_combined(self, dialect): + actions = [ + PostgresAddTypeAttributeAction(dialect, "active", IntegerType(dialect)), + PostgresDropTypeAttributeAction(dialect, "legacy"), + ] + sql, params = AlterTypeExpression(dialect, "app.address", actions).to_sql() + assert sql == ( + 'ALTER TYPE "app"."address" ADD ATTRIBUTE "active" INTEGER, ' + 'DROP ATTRIBUTE "legacy"' + ) + assert params == () + + def test_mixed_type_actions_cannot_be_combined(self, dialect): + actions = [ + PostgresAddTypeAttributeAction(dialect, "active", IntegerType(dialect)), + PostgresRenameTypeAction(dialect, "new_address"), + ] + with pytest.raises(UnsupportedFeatureError, match="multiple ALTER TYPE actions"): + AlterTypeExpression(dialect, "app.address", actions).to_sql() + + def test_type_version_boundaries(self): + enum = PostgresEnumTypeDefinition(PostgresDialect(version=(9, 6, 0)), ["a"]) + rename = PostgresRenameEnumValueAction( + PostgresDialect(version=(9, 6, 0)), + "a", + "b", + ) + with pytest.raises(UnsupportedFeatureError, match="RENAME VALUE"): + AlterTypeExpression( + PostgresDialect(version=(9, 6, 0)), + "status", + [rename], + ).to_sql() + sql, _ = AlterTypeExpression( + PostgresDialect(version=(10, 0, 0)), + "status", + [PostgresRenameEnumValueAction( + PostgresDialect(version=(10, 0, 0)), + "a", + "b", + )], + ).to_sql() + assert sql == 'ALTER TYPE "status" RENAME VALUE \'a\' TO \'b\'' + with pytest.raises(UnsupportedFeatureError, match="SET properties"): + AlterTypeExpression( + PostgresDialect(version=(12, 0, 0)), + "box", + [PostgresSetTypePropertiesAction( + PostgresDialect(version=(12, 0, 0)), + {"STORAGE": "plain"}, + )], + ).to_sql() + assert enum.labels == ["a"] + + def test_base_subscript_version_boundary(self): + for version in ((13, 0, 0), (14, 0, 0)): + dialect = PostgresDialect(version=version) + definition = PostgresBaseTypeDefinition( + dialect, + input_function="box_in", + output_function="box_out", + subscript_function="box_subscript", + ) + if version < (14, 0, 0): + with pytest.raises(UnsupportedFeatureError, match="SUBSCRIPT"): + CreateTypeExpression(dialect, "box", definition).to_sql() + else: + sql, _ = CreateTypeExpression(dialect, "box", definition).to_sql() + assert 'SUBSCRIPT = "box_subscript"' in sql + + class TestPostgresCollationDDLExpression: """CREATE / DROP COLLATION DDL.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py index d0e75ccc..47c23821 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py @@ -10,8 +10,30 @@ """ import pytest # noqa: F401 +from rhosocial.activerecord.backend.dialect import DomainSupport, UserDefinedTypeSupport +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins import DomainMixin, UserDefinedTypeMixin +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + DomainCheckConstraint, + DomainValueExpression, +) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAddDomainCheckAction, + PostgresCompositeTypeDefinition, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresBaseTypeDefinition, + PostgresRenameEnumValueAction, + PostgresSetTypePropertiesAction, + PostgresShellTypeDefinition, +) +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.domain import PostgresDomainMixin from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.partition import PostgresPartitionMixin +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.type import PostgresTypeMixin +from rhosocial.activerecord.backend.impl.postgres.protocols.ddl.domain import PostgresDomainSupport +from rhosocial.activerecord.backend.impl.postgres.protocols.ddl.type import PostgresTypeSupport class TestPostgresPartitionSupportFeatureDetection: @@ -53,10 +75,10 @@ def test_supports_range_and_list_partitioning_pg10(self): assert dialect.supports_range_table_partitioning() is True assert dialect.supports_list_table_partitioning() is True - def test_subpartitioning_not_exposed_yet(self): - """Nested partitioning is not exposed by this API yet.""" - dialect = PostgresDialect(version=(14, 0, 0)) - assert dialect.supports_subpartitioning() is False + def test_subpartitioning_pg10(self): + """Nested partitioning is available with PostgreSQL 10+.""" + assert PostgresDialect(version=(9, 6, 0)).supports_subpartitioning() is False + assert PostgresDialect(version=(10, 0, 0)).supports_subpartitioning() is True def test_partition_introspection_pg9(self): """Partition metadata introspection follows declarative partitioning support.""" @@ -123,9 +145,9 @@ def test_supports_concurrent_attach_pg13(self): assert dialect.supports_concurrent_attach() is False def test_supports_concurrent_attach_pg14(self): - """PostgreSQL 14 supports CONCURRENTLY ATTACH.""" + """CONCURRENTLY ATTACH remains disabled on PostgreSQL 14+.""" dialect = PostgresDialect(version=(14, 0, 0)) - assert dialect.supports_concurrent_attach() is True + assert dialect.supports_concurrent_attach() is False def test_supports_partition_bounds_expression_pg11(self): """Partition bounds expression requires PG 12+.""" @@ -203,9 +225,11 @@ def test_supports_hash_table_partitioning_direct(self): assert self._PartitionMixin().supports_hash_table_partitioning() is True assert self._PartitionMixinPg10().supports_hash_table_partitioning() is False - def test_subpartitioning_not_exposed_direct(self): - """Nested partitioning is not exposed by this API yet.""" - assert self._PartitionMixin().supports_subpartitioning() is False + def test_subpartitioning_direct(self): + """Nested partitioning follows declarative partitioning support.""" + assert self._PartitionMixin().supports_subpartitioning() is True + assert self._PartitionMixinPg10().supports_subpartitioning() is True + assert self._PartitionMixinLow().supports_subpartitioning() is False def test_partition_introspection_direct(self): """Partition metadata introspection follows table partitioning support.""" @@ -223,8 +247,8 @@ def test_partition_maintenance_capabilities_direct(self): assert mixin.supports_reorganize_partition() is False def test_supports_concurrent_attach_direct(self): - """CONCURRENTLY ATTACH follows PG version.""" - assert self._PartitionMixin().supports_concurrent_attach() is True + """CONCURRENTLY ATTACH is always disabled.""" + assert self._PartitionMixin().supports_concurrent_attach() is False assert self._PartitionMixinPg10().supports_concurrent_attach() is False @@ -368,6 +392,7 @@ def test_postgres_partition_support_is_runtime_checkable(self): from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( PostgresPartitionSupport, ) + dialect = PostgresDialect(version=(14, 0, 0)) assert isinstance(dialect, PostgresPartitionSupport) @@ -632,6 +657,94 @@ def test_baseline_19(self): assert d.supports_collation_ddl() is True assert d.supports_publication() is True + +class TestPostgresTypeDomainProtocols: + def test_protocol_derivation_and_runtime_support(self): + assert issubclass(PostgresTypeSupport, UserDefinedTypeSupport) + assert issubclass(PostgresDomainSupport, DomainSupport) + dialect = PostgresDialect(version=(14, 0, 0)) + assert isinstance(dialect, PostgresTypeSupport) + assert isinstance(dialect, UserDefinedTypeSupport) + assert isinstance(dialect, PostgresDomainSupport) + assert isinstance(dialect, DomainSupport) + + def test_postgres_mixins_and_protocols_precede_core_mro(self): + mro = PostgresDialect.__mro__ + assert mro.index(PostgresTypeMixin) < mro.index(UserDefinedTypeMixin) + assert mro.index(PostgresDomainMixin) < mro.index(DomainMixin) + assert mro.index(PostgresTypeSupport) < mro.index(UserDefinedTypeSupport) + assert mro.index(PostgresDomainSupport) < mro.index(DomainSupport) + + def test_dialect_initializes_core_validation_state(self) -> None: + dialect = PostgresDialect(version=(14, 0, 0)) + assert dialect.strict_validation is True + + def test_type_definitions_are_explicitly_registered(self): + dialect = PostgresDialect(version=(14, 0, 0)) + definitions = ( + PostgresCompositeTypeDefinition, + PostgresEnumTypeDefinition, + PostgresRangeTypeDefinition, + PostgresBaseTypeDefinition, + PostgresShellTypeDefinition, + ) + assert dialect.supported_type_definitions() == definitions + assert all(dialect.supports_type_definition(item) for item in definitions) + + def test_type_capability_version_gates(self): + old = PostgresDialect(version=(9, 5, 0)) + baseline = PostgresDialect(version=(9, 6, 0)) + assert old.supports_type_objects() is False + assert baseline.supports_type_objects() is True + assert baseline.supports_create_type_if_not_exists() is False + assert baseline.supports_create_type_or_replace() is False + assert baseline.supports_alter_type_if_exists() is False + assert baseline.supports_drop_type_if_exists() is True + assert baseline.supports_drop_type_cascade() is True + assert baseline.supports_drop_type_restrict() is True + + def test_multirange_data_type_gates(self): + pg13 = PostgresDialect(version=(13, 0, 0)) + pg14 = PostgresDialect(version=(14, 0, 0)) + assert pg13.supports_multirange_type() is False + assert pg13.supports_multirange() is False + assert pg13.supports_multirange_constructor() is False + assert pg13.supports_data_type_postgres_int4multirange() is False + with pytest.raises(UnsupportedFeatureError, match="INT4MULTIRANGE"): + pg13.format_data_type_postgres_int4multirange(None) + assert pg14.supports_multirange_type() is True + assert pg14.supports_multirange() is True + assert pg14.supports_multirange_constructor() is True + assert pg14.supports_data_type_postgres_int4multirange() is True + + def test_type_action_version_gates(self): + pg96 = PostgresDialect(version=(9, 6, 0)) + pg100 = PostgresDialect(version=(10, 0, 0)) + pg12 = PostgresDialect(version=(12, 0, 0)) + pg13 = PostgresDialect(version=(13, 0, 0)) + assert pg96.supports_type_alter_action(PostgresRenameEnumValueAction) is False + assert pg100.supports_type_alter_action(PostgresRenameEnumValueAction) is True + assert pg12.supports_type_alter_action(PostgresSetTypePropertiesAction) is False + assert pg13.supports_type_alter_action(PostgresSetTypePropertiesAction) is True + + def test_domain_capability_gates_and_actions(self): + old = PostgresDialect(version=(9, 5, 0)) + baseline = PostgresDialect(version=(9, 6, 0)) + assert old.supports_domains() is False + assert baseline.supports_create_domain() is True + assert baseline.supports_alter_domain() is True + assert baseline.supports_drop_domain() is True + assert baseline.supports_multiple_domain_alter_actions() is False + assert baseline.supports_drop_domain_if_exists() is True + assert baseline.supports_drop_domain_cascade() is True + assert baseline.supports_drop_domain_restrict() is True + check = DomainCheckConstraint( + baseline, + DomainValueExpression(baseline) > Literal(baseline, 0, inline_literals=True), + ) + assert baseline.supports_alter_domain_action(PostgresAddDomainCheckAction) is True + assert isinstance(check, DomainCheckConstraint) + def test_unsupported_94_rejects_unlogged(self): d = PostgresDialect(version=(9, 4, 0)) assert d.supports_unlogged_table() is False \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py new file mode 100644 index 00000000..c3bbf98b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py @@ -0,0 +1,466 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py + +try: + from typing import Annotated, Optional +except ImportError: + from typing_extensions import Annotated, Optional + +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraintType, + ColumnDefinition, + CreateTableExpression, + GeneratedColumnExpression, + GeneratedColumnType, + IndexDefinition, + PartitionStrategy, + StorageOptionsExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.expression.types import TextType +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression import ( + PostgresColumnOptions, + PostgresColumnStorage, + PostgresPartitionClause, + PostgresUUIDType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresCreateTableOptions, +) +from rhosocial.activerecord.base import ( + CharacterSetAttribute, + CollationAttribute, + DDLAnnotation, + DDLAnnotationHandler, + DDLSource, + IdentityAttribute, + UseColumn, + UseColumnAttributes, + UseComment, + UseConstraint, + UseGeneratedColumn, + UseIndex, + UseSqlType, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + + +DIALECT = PostgresDialect((16, 2, 0)) +SQL_TYPE = PostgresUUIDType() +FALLBACK_SQL_TYPE = TextType() +SQL_TYPE_MARKER = UseSqlType(SQL_TYPE, FALLBACK_SQL_TYPE) +DEFAULT_VALUE = Literal(DIALECT, "new", inline_literals=True) +DEFAULT_CONSTRAINT = UseConstraint( + ColumnConstraintType.DEFAULT, + name="df_status_default", + default_value=DEFAULT_VALUE, +) +UNIQUE_CONSTRAINT = UseConstraint( + ColumnConstraintType.UNIQUE, + name="uq_status", +) +COLLATION = CollationAttribute(name="C.UTF-8") +CHARACTER_SET = CharacterSetAttribute(name="UTF8") +IDENTITY = IdentityAttribute(generation="ALWAYS", start=10, increment=2) +ATTRIBUTES = UseColumnAttributes(COLLATION, CHARACTER_SET, IDENTITY) +COMMENT = UseComment("current order status") +FIRST_INDEX = UseIndex( + "idx_display_label", + unique=True, + type="BTREE", + include_columns=["tenant_id"], + if_not_exists=True, + tablespace="fastspace", +) +SECOND_INDEX = UseIndex("idx_display_label_suffix", type="BTREE") +GENERATED = GeneratedColumnExpression( + DIALECT, + Column(DIALECT, "order_id"), + GeneratedColumnType.STORED, +) +COLUMN_OPTION = PostgresColumnOptions( + compression="lz4", + storage=PostgresColumnStorage.EXTERNAL, + statistics=500, +) +SECOND_COLUMN_OPTION = PostgresColumnOptions( + compression="pglz", + storage=PostgresColumnStorage.MAIN, + statistics=1000, +) +TABLE_CONSTRAINTS = [ + TableConstraint( + DIALECT, + TableConstraintType.FOREIGN_KEY, + name="fk_order_tenant", + columns=["tenant_id"], + foreign_key_table="tenant", + foreign_key_columns=["id"], + ), + TableConstraint( + DIALECT, + TableConstraintType.UNIQUE, + name="uq_order_pair", + columns=["tenant_id", "order_id"], + ), +] +TABLE_INDEXES = [ + IndexDefinition( + DIALECT, + name="idx_table_status", + columns=["status"], + unique=True, + ), +] +TABLE_OPTIONS = [ + PostgresCreateTableOptions( + DIALECT, + unlogged=True, + ), +] +STORAGE_OPTIONS = [ + StorageOptionsExpression(DIALECT, {"fillfactor": 70}), +] +TABLE_PARTITION = PostgresPartitionClause( + DIALECT, + PartitionStrategy.RANGE, + [Column(DIALECT, "display_label")], +) +TABLE_INHERITS = ["order_audit_base"] +TABLE_TABLESPACE = "fastspace" +MODEL_FIELDS = ( + "tenant_id", + "order_id", + "status", + "label", + "computed", + "option_one", + "option_many", + "optional_note", +) +BATCH_METHODS = ( + "columns_name", + "columns_type", + "columns_constraints", + "columns_attributes", + "columns_indexes", + "columns_comment", + "columns_generated", + "columns_options", +) + + +class BackendColumnOptionsAnnotation(DDLAnnotation): + def __init__(self, *options): + self.options = options + + +class BackendColumnOptionsHandler(DDLAnnotationHandler): + annotation_types = (BackendColumnOptionsAnnotation,) + + @classmethod + def apply(cls, new_class, field_name, annotation, metadata): + metadata.add_column_options(*annotation.options) + + +class DDLDeclarations: + __table_name__ = "ddl_source_orders" + __primary_key__ = ("tenant_key", "order_key") + __table_constraints__ = TABLE_CONSTRAINTS + __table_indexes__ = TABLE_INDEXES + _feature_handlers = [BackendColumnOptionsHandler] + + tenant_id: Annotated[ + str, + UseColumn("tenant_key"), + SQL_TYPE_MARKER, + ] + order_id: Annotated[str, UseColumn("order_key")] + status: Annotated[ + str, + DEFAULT_CONSTRAINT, + UNIQUE_CONSTRAINT, + ATTRIBUTES, + COMMENT, + ] + label: Annotated[ + str, + UseColumn("display_label"), + FIRST_INDEX, + SECOND_INDEX, + ] + computed: Annotated[str, UseGeneratedColumn(GENERATED)] + option_one: Annotated[str, BackendColumnOptionsAnnotation(COLUMN_OPTION)] + option_many: Annotated[ + str, + BackendColumnOptionsAnnotation(COLUMN_OPTION, SECOND_COLUMN_OPTION), + ] + optional_note: Optional[str] = None + + @classmethod + def table_options(cls): + return TABLE_OPTIONS + + @classmethod + def table_storage_options(cls): + return STORAGE_OPTIONS + + @classmethod + def table_partition(cls): + return TABLE_PARTITION + + @classmethod + def table_inherits(cls): + return TABLE_INHERITS + + @classmethod + def table_tablespace(cls): + return TABLE_TABLESPACE + + +class SyncDDLSource(DDLDeclarations, ActiveRecord): + pass + + +class AsyncDDLSource(DDLDeclarations, AsyncActiveRecord): + pass + + +SINGLE_SQL_TYPE = PostgresUUIDType() +SINGLE_SQL_TYPE_MARKER = UseSqlType(SINGLE_SQL_TYPE) + + +class SingleKeyDeclarations: + __table_name__ = "ddl_source_single_key" + __primary_key__ = "record_pk" + + record_id: Annotated[ + str, + UseColumn("record_pk"), + SINGLE_SQL_TYPE_MARKER, + ] + + +class SyncSingleKey(SingleKeyDeclarations, ActiveRecord): + pass + + +class AsyncSingleKey(SingleKeyDeclarations, AsyncActiveRecord): + pass + + +def test_models_satisfy_ddl_source_and_preserve_field_order(): + for model in (SyncDDLSource, AsyncDDLSource): + assert isinstance(model, DDLSource) + assert model.ddl_field_names() == MODEL_FIELDS + assert model.field_python_type("tenant_id") is str + assert model.field_is_optional("optional_note") is True + assert model.field_is_optional("status") is False + + +def test_field_markers_preserve_declared_objects_and_order(): + assert SyncDDLSource.column_type("tenant_id") is SQL_TYPE_MARKER + assert SyncDDLSource.column_type("tenant_id").data_types == ( + SQL_TYPE, + FALLBACK_SQL_TYPE, + ) + assert SyncDDLSource.column_type("tenant_id").data_types[0] is SQL_TYPE + + constraints = SyncDDLSource.column_constraints("status") + assert constraints[0] is DEFAULT_CONSTRAINT.constraint + assert constraints[1] is UNIQUE_CONSTRAINT.constraint + assert constraints[0].default_value is DEFAULT_VALUE + assert [item.constraint_type for item in constraints] == [ + ColumnConstraintType.DEFAULT, + ColumnConstraintType.UNIQUE, + ColumnConstraintType.NOT_NULL, + ] + + attributes = SyncDDLSource.column_attributes("status") + assert attributes == [COLLATION, CHARACTER_SET, IDENTITY] + assert attributes[0] is COLLATION + assert attributes[1] is CHARACTER_SET + assert attributes[2] is IDENTITY + assert SyncDDLSource.column_comment("status") == COMMENT.comment + assert SyncDDLSource.generated_column("computed") is GENERATED + + indexes = SyncDDLSource.column_indexes("label") + assert [index.name for index in indexes] == [FIRST_INDEX.name, SECOND_INDEX.name] + assert all(index.columns == ["display_label"] for index in indexes) + assert indexes[0].unique is True + assert indexes[0].include_columns is FIRST_INDEX.include_columns + assert indexes[0].if_not_exists is True + assert indexes[0].tablespace == FIRST_INDEX.tablespace + + assert SyncDDLSource.column_options("option_one") is COLUMN_OPTION + options = SyncDDLSource.column_options("option_many") + assert options == [COLUMN_OPTION, SECOND_COLUMN_OPTION] + assert options[0] is COLUMN_OPTION + assert options[1] is SECOND_COLUMN_OPTION + + +def test_backend_types_and_column_options_are_collected_without_dialect_selection(): + sql_type = SyncDDLSource.column_type("tenant_id") + assert isinstance(sql_type, UseSqlType) + assert isinstance(sql_type.data_type, PostgresUUIDType) + assert sql_type.data_types[1] is FALLBACK_SQL_TYPE + + option = SyncDDLSource.column_options("option_one") + assert isinstance(option, PostgresColumnOptions) + assert option.compression == "lz4" + assert option.storage is PostgresColumnStorage.EXTERNAL + assert option.statistics == 500 + + +def test_primary_keys_use_physical_column_names_and_declaration_order(): + assert SyncDDLSource.columns_name() == { + "tenant_id": "tenant_key", + "order_id": "order_key", + "status": "status", + "label": "display_label", + "computed": "computed", + "option_one": "option_one", + "option_many": "option_many", + "optional_note": "optional_note", + } + + for field in ("tenant_id", "order_id"): + constraint_types = [ + item.constraint_type for item in SyncDDLSource.column_constraints(field) + ] + assert ColumnConstraintType.PRIMARY_KEY not in constraint_types + assert ColumnConstraintType.NOT_NULL in constraint_types + + single_types = [ + item.constraint_type for item in SyncSingleKey.column_constraints("record_id") + ] + assert single_types == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert SyncSingleKey.columns_name() == {"record_id": "record_pk"} + assert SyncSingleKey.column_type("record_id") is SINGLE_SQL_TYPE_MARKER + assert SyncSingleKey.table_constraints() == [] + + +def test_table_declarations_preserve_identity_order_and_composite_primary_key(): + constraints = SyncDDLSource.table_constraints() + assert constraints is not TABLE_CONSTRAINTS + assert constraints[0] is TABLE_CONSTRAINTS[0] + assert constraints[1] is TABLE_CONSTRAINTS[1] + assert [item.constraint_type for item in constraints] == [ + TableConstraintType.FOREIGN_KEY, + TableConstraintType.UNIQUE, + TableConstraintType.PRIMARY_KEY, + ] + assert constraints[2].columns == ["tenant_key", "order_key"] + assert len(TABLE_CONSTRAINTS) == 2 + + indexes = SyncDDLSource.table_indexes() + assert indexes is not TABLE_INDEXES + assert indexes[0] is TABLE_INDEXES[0] + assert [index.name for index in indexes] == ["idx_table_status"] + + assert SyncDDLSource.table_options() is TABLE_OPTIONS + assert SyncDDLSource.table_options()[0] is TABLE_OPTIONS[0] + assert isinstance(TABLE_OPTIONS[0], PostgresCreateTableOptions) + assert TABLE_OPTIONS[0].unlogged is True + assert SyncDDLSource.table_storage_options() is STORAGE_OPTIONS + assert SyncDDLSource.table_storage_options()[0] is STORAGE_OPTIONS[0] + assert SyncDDLSource.table_partition() is TABLE_PARTITION + assert SyncDDLSource.table_inherits() is TABLE_INHERITS + assert SyncDDLSource.table_tablespace() == TABLE_TABLESPACE + + +def test_collected_table_options_render_with_postgres_dialect(): + table_options = SyncDDLSource.table_options() + expression = CreateTableExpression( + DIALECT, + "ddl_source_orders", + [ColumnDefinition(DIALECT, "id", IntegerType(DIALECT))], + table_options=table_options[0], + ) + sql, params = expression.to_sql() + assert sql.startswith('CREATE UNLOGGED TABLE "ddl_source_orders"') + assert '"id" INTEGER' in sql + assert params == () + + +def test_batch_interfaces_cover_all_fields_and_preserve_selected_order(): + selected = ["label", "status", "tenant_id", "optional_note"] + for model in (SyncDDLSource, AsyncDDLSource): + for method_name in BATCH_METHODS: + method = getattr(model, method_name) + assert tuple(method()) == MODEL_FIELDS + assert tuple(method(selected)) == tuple(selected) + + assert model.columns_name(selected) == { + "label": "display_label", + "status": "status", + "tenant_id": "tenant_key", + "optional_note": "optional_note", + } + assert model.columns_type(selected)["tenant_id"] is SQL_TYPE_MARKER + assert model.columns_constraints(selected)["status"][0] is DEFAULT_CONSTRAINT.constraint + assert model.columns_attributes(selected)["status"][0] is COLLATION + assert model.columns_comment(selected)["status"] == COMMENT.comment + assert model.columns_generated(selected)["optional_note"] is None + assert model.columns_options(selected)["optional_note"] is None + + +def test_sync_and_async_sources_collect_the_same_declarations(): + assert AsyncDDLSource.column_type("tenant_id") is SyncDDLSource.column_type("tenant_id") + assert AsyncDDLSource.column_comment("status") == SyncDDLSource.column_comment("status") + assert AsyncDDLSource.generated_column("computed") is GENERATED + assert AsyncDDLSource.column_options("option_one") is COLUMN_OPTION + assert SyncDDLSource.column_options("option_many") == [ + COLUMN_OPTION, + SECOND_COLUMN_OPTION, + ] + assert AsyncDDLSource.column_options("option_many") == [ + COLUMN_OPTION, + SECOND_COLUMN_OPTION, + ] + + sync_attributes = SyncDDLSource.column_attributes("status") + async_attributes = AsyncDDLSource.column_attributes("status") + assert all(left is right for left, right in zip(sync_attributes, async_attributes)) + + sync_constraints = SyncDDLSource.column_constraints("status") + async_constraints = AsyncDDLSource.column_constraints("status") + assert sync_constraints[0] is async_constraints[0] + assert sync_constraints[1] is async_constraints[1] + assert [item.constraint_type for item in async_constraints] == [ + item.constraint_type for item in sync_constraints + ] + + sync_indexes = SyncDDLSource.column_indexes("label") + async_indexes = AsyncDDLSource.column_indexes("label") + assert [index.name for index in async_indexes] == [index.name for index in sync_indexes] + assert [index.columns for index in async_indexes] == [ + index.columns for index in sync_indexes + ] + + assert AsyncDDLSource.table_options() is TABLE_OPTIONS + assert AsyncDDLSource.table_storage_options() is STORAGE_OPTIONS + assert AsyncDDLSource.table_partition() is TABLE_PARTITION + assert AsyncDDLSource.table_inherits() is TABLE_INHERITS + assert AsyncDDLSource.table_tablespace() == TABLE_TABLESPACE + assert AsyncDDLSource.table_indexes()[0] is TABLE_INDEXES[0] + + sync_table_constraints = SyncDDLSource.table_constraints() + async_table_constraints = AsyncDDLSource.table_constraints() + assert sync_table_constraints[0] is async_table_constraints[0] + assert sync_table_constraints[1] is async_table_constraints[1] + assert async_table_constraints[2].columns == ["tenant_key", "order_key"] + + for model in (SyncSingleKey, AsyncSingleKey): + assert [item.constraint_type for item in model.column_constraints("record_id")] == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert model.column_name("record_id") == "record_pk" + assert model.column_type("record_id") is SINGLE_SQL_TYPE_MARKER diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py index 413024f0..34acd9a0 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py @@ -10,6 +10,7 @@ from enum import Enum from typing import Tuple # noqa: F401 +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.postgres import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.types import PostgresEnumType from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresEnumAdapter @@ -38,7 +39,7 @@ class TestPostgresEnumType: def setup_method(self): """Set up test fixtures.""" - self.dialect = PostgresDialect() + self.dialect = PostgresDialect(version=(14, 0, 0)) def test_create_enum_type(self): """Test creating an enum type.""" @@ -86,7 +87,7 @@ def test_to_sql(self): result = enum_type.to_sql() assert isinstance(result, tuple) assert len(result) == 2 - assert result[0] == 'status' + assert result[0] == '"status"' assert result[1] == () def test_to_sql_with_schema(self): @@ -98,7 +99,7 @@ def test_to_sql_with_schema(self): schema='app' ) result = enum_type.to_sql() - assert result == ('app.status', ()) + assert result == ('"app"."status"', ()) def test_create_type_sql(self): """Test CREATE TYPE SQL generation using PostgresCreateEnumTypeExpression.""" @@ -110,20 +111,18 @@ def test_create_type_sql(self): result = expr.to_sql() assert isinstance(result, tuple) sql, params = result - assert sql == "CREATE TYPE status AS ENUM ('pending', 'ready')" + assert sql == "CREATE TYPE \"status\" AS ENUM ('pending', 'ready')" assert params == () - def test_create_type_sql_if_not_exists(self): - """Test CREATE TYPE IF NOT EXISTS SQL generation.""" + def test_create_type_sql_if_not_exists_fails_fast(self): expr = PostgresCreateEnumTypeExpression( dialect=self.dialect, name='status', values=['a', 'b'], - if_not_exists=True + if_not_exists=True, ) - sql, params = expr.to_sql() - assert 'IF NOT EXISTS' in sql - assert params == () + with pytest.raises(UnsupportedFeatureError, match="CREATE TYPE IF NOT EXISTS"): + expr.to_sql() def test_drop_type_sql(self): """Test DROP TYPE SQL generation using PostgresDropEnumTypeExpression.""" @@ -132,7 +131,7 @@ def test_drop_type_sql(self): name='status' ) sql, params = expr.to_sql() - assert sql == "DROP TYPE status" + assert sql == "DROP TYPE \"status\"" assert params == () def test_drop_type_sql_if_exists(self): @@ -143,7 +142,7 @@ def test_drop_type_sql_if_exists(self): if_exists=True ) sql, params = expr.to_sql() - assert sql == "DROP TYPE IF EXISTS status" + assert sql == "DROP TYPE IF EXISTS \"status\"" assert params == () def test_drop_type_sql_cascade(self): @@ -165,7 +164,7 @@ def test_add_value_sql(self): new_value='failed' ) sql, params = expr.to_sql() - assert sql == "ALTER TYPE status ADD VALUE 'failed'" + assert sql == "ALTER TYPE \"status\" ADD VALUE 'failed'" assert params == () def test_add_value_sql_before(self): @@ -212,7 +211,7 @@ def test_rename_value_sql(self): new_value='draft' ) sql, params = expr.to_sql() - assert sql == "ALTER TYPE status RENAME VALUE 'pending' TO 'draft'" + assert sql == "ALTER TYPE \"status\" RENAME VALUE 'pending' TO 'draft'" assert params == () def test_rename_value_empty_raises_error(self): @@ -307,7 +306,7 @@ def test_to_database_invalid_type(self): def test_to_database_with_validation(self): """Test converting with enum type validation.""" adapter = PostgresEnumAdapter() - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) enum_type = PostgresEnumType( dialect=dialect, name='status', @@ -319,7 +318,7 @@ def test_to_database_with_validation(self): def test_to_database_validation_fails(self): """Test validation fails for invalid value.""" adapter = PostgresEnumAdapter() - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) enum_type = PostgresEnumType( dialect=dialect, name='status', @@ -376,62 +375,65 @@ class TestPostgresEnumTypeSupport: def test_dialect_implements_postgres_enum_type_support(self): """Test that PostgresDialect implements PostgresEnumTypeSupport.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) # Check via MRO from rhosocial.activerecord.backend.impl.postgres.protocols import PostgresEnumTypeSupport # noqa: F811 assert PostgresEnumTypeSupport in dialect.__class__.__mro__ def test_create_enum_type_method(self): """Test create_enum_type method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.create_enum_type('status', ['pending', 'ready']) - assert sql == "CREATE TYPE status AS ENUM ('pending', 'ready')" + assert sql == "CREATE TYPE \"status\" AS ENUM ('pending', 'ready')" def test_create_enum_type_with_schema(self): """Test create_enum_type with schema.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.create_enum_type('status', ['pending', 'ready'], schema='app') - assert sql == "CREATE TYPE app.status AS ENUM ('pending', 'ready')" - - def test_create_enum_type_if_not_exists(self): - """Test create_enum_type with IF NOT EXISTS.""" - dialect = PostgresDialect() - sql = dialect.create_enum_type('status', ['pending', 'ready'], if_not_exists=True) - assert 'IF NOT EXISTS' in sql + assert sql == "CREATE TYPE \"app\".\"status\" AS ENUM ('pending', 'ready')" + + def test_create_enum_type_if_not_exists_fails_fast(self): + dialect = PostgresDialect(version=(14, 0, 0)) + with pytest.raises(UnsupportedFeatureError, match="CREATE TYPE IF NOT EXISTS"): + dialect.create_enum_type( + 'status', + ['pending', 'ready'], + if_not_exists=True, + ) def test_drop_enum_type_method(self): """Test drop_enum_type method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.drop_enum_type('status') - assert sql == "DROP TYPE status" + assert sql == "DROP TYPE \"status\"" def test_drop_enum_type_if_exists(self): """Test drop_enum_type with IF EXISTS.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.drop_enum_type('status', if_exists=True) - assert sql == "DROP TYPE IF EXISTS status" + assert sql == "DROP TYPE IF EXISTS \"status\"" def test_drop_enum_type_cascade(self): """Test drop_enum_type with CASCADE.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.drop_enum_type('status', cascade=True) assert 'CASCADE' in sql def test_alter_enum_add_value_method(self): """Test alter_enum_add_value method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.alter_enum_add_value('status', 'failed') - assert sql == "ALTER TYPE status ADD VALUE 'failed'" + assert sql == "ALTER TYPE \"status\" ADD VALUE 'failed'" def test_alter_enum_add_value_before(self): """Test alter_enum_add_value with BEFORE.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.alter_enum_add_value('status', 'processing', before='ready') assert "BEFORE 'ready'" in sql def test_alter_enum_add_value_after(self): """Test alter_enum_add_value with AFTER.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql = dialect.alter_enum_add_value('status', 'processing', after='pending') assert "AFTER 'pending'" in sql @@ -441,19 +443,19 @@ class TestEnumTypeMixin: def test_format_enum_type_name(self): """Test format_enum_type_name via EnumTypeNameExpression.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql, params = EnumTypeNameExpression(dialect, name='status').to_sql() - assert sql == 'status' + assert sql == '"status"' assert params == () sql, params = EnumTypeNameExpression( dialect, name='status', schema='app' ).to_sql() - assert sql == 'app.status' + assert sql == '"app"."status"' assert params == () def test_format_enum_values(self): """Test format_enum_values via EnumValuesExpression.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) sql, params = EnumValuesExpression( dialect, values=['a', 'b', 'c'] ).to_sql() @@ -462,28 +464,28 @@ def test_format_enum_values(self): def test_format_create_enum_type(self): """Test format_create_enum_type method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) expr = PostgresCreateEnumTypeExpression( dialect=dialect, name='status', values=['pending', 'ready'] ) sql, params = expr.to_sql() - assert sql == "CREATE TYPE status AS ENUM ('pending', 'ready')" + assert sql == "CREATE TYPE \"status\" AS ENUM ('pending', 'ready')" assert params == () def test_format_drop_enum_type(self): """Test format_drop_enum_type method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) expr = PostgresDropEnumTypeExpression(dialect=dialect, name='status') sql, params = expr.to_sql() - assert sql == "DROP TYPE status" + assert sql == "DROP TYPE \"status\"" assert params == () def test_format_alter_enum_add_value(self): """Test format_alter_enum_add_value method.""" - dialect = PostgresDialect() + dialect = PostgresDialect(version=(14, 0, 0)) expr = PostgresAlterEnumAddValueExpression( dialect=dialect, type_name='status', new_value='failed' ) sql, params = expr.to_sql() - assert sql == "ALTER TYPE status ADD VALUE 'failed'" + assert sql == "ALTER TYPE \"status\" ADD VALUE 'failed'" assert params == () diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py index dfa112a3..59477dd8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py @@ -6,6 +6,7 @@ """ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PostgresVacuumExpression, @@ -229,7 +230,7 @@ def test_hash_partition_requires_pg11(self): partition_values={"modulus": 4, "remainder": 0} ) - with pytest.raises(ValueError, match="HASH partitioning requires PostgreSQL 11"): + with pytest.raises(UnsupportedFeatureError, match="HASH partitioning requires PostgreSQL 11"): expr.to_sql() def test_partition_with_tablespace(self, dialect): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py index 75d08005..27909d03 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py @@ -31,6 +31,7 @@ PostgresAlterIndexExpression, PostgresAlterIndexActionType, PostgresReindexExpression, + PostgresCreateIndexExpression, ) @@ -640,42 +641,42 @@ def test_unique(self): def test_nulls_not_distinct(self): d = PostgresDialect((15, 0, 0)) - expr = CreateIndexExpression( - d, "idx_u", "t", ["a"], unique=True, dialect_options={"nulls_not_distinct": True} + expr = PostgresCreateIndexExpression( + d, "idx_u", "t", ["a"], unique=True, nulls_not_distinct=True ) sql, _ = expr.to_sql() assert sql == 'CREATE UNIQUE INDEX "idx_u" ON "t" ("a") NULLS NOT DISTINCT' def test_nulls_not_distinct_pg14_raises(self): d = PostgresDialect((14, 0, 0)) - expr = CreateIndexExpression( - d, "idx_u", "t", ["a"], unique=True, dialect_options={"nulls_not_distinct": True} + expr = PostgresCreateIndexExpression( + d, "idx_u", "t", ["a"], unique=True, nulls_not_distinct=True ) with pytest.raises(ValueError, match="NULLS NOT DISTINCT requires PostgreSQL 15"): expr.to_sql() def test_nulls_not_distinct_non_unique_raises(self): d = PostgresDialect((15, 0, 0)) - expr = CreateIndexExpression( - d, "idx_u", "t", ["a"], unique=False, dialect_options={"nulls_not_distinct": True} + expr = PostgresCreateIndexExpression( + d, "idx_u", "t", ["a"], unique=False, nulls_not_distinct=True ) with pytest.raises(ValueError, match="NULLS NOT DISTINCT is only valid for UNIQUE"): expr.to_sql() def test_concurrent_and_nulls_not_distinct_pg15_raises(self): d = PostgresDialect((15, 0, 0)) - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( d, "idx_u", "t", ["a"], - unique=True, concurrent=True, dialect_options={"nulls_not_distinct": True}, + unique=True, concurrent=True, nulls_not_distinct=True, ) with pytest.raises(ValueError, match="CONCURRENTLY.*NULLS NOT DISTINCT.*PostgreSQL 16"): expr.to_sql() def test_concurrent_and_nulls_not_distinct_pg16(self): d = PostgresDialect((16, 0, 0)) - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( d, "idx_u", "t", ["a"], - unique=True, concurrent=True, dialect_options={"nulls_not_distinct": True}, + unique=True, concurrent=True, nulls_not_distinct=True, ) sql, _ = expr.to_sql() assert "NULLS NOT DISTINCT" in sql @@ -699,19 +700,19 @@ def test_drop_if_exists(self): def test_drop_concurrently(self): d = PostgresDialect((18, 0, 0)) - expr = DropIndexExpression(d, "idx_test", dialect_options={"concurrent": True}) + expr = DropIndexExpression(d, "idx_test", concurrent=True) sql, _ = expr.to_sql() assert sql == 'DROP INDEX CONCURRENTLY "idx_test"' def test_drop_concurrently_pg17_raises(self): d = PostgresDialect((17, 0, 0)) - expr = DropIndexExpression(d, "idx_test", dialect_options={"concurrent": True}) + expr = DropIndexExpression(d, "idx_test", concurrent=True) with pytest.raises(ValueError, match="DROP INDEX CONCURRENTLY requires PostgreSQL 18"): expr.to_sql() def test_drop_concurrently_if_exists(self): d = PostgresDialect((18, 0, 0)) - expr = DropIndexExpression(d, "idx_test", if_exists=True, dialect_options={"concurrent": True}) + expr = DropIndexExpression(d, "idx_test", if_exists=True, concurrent=True) sql, _ = expr.to_sql() assert sql == 'DROP INDEX CONCURRENTLY IF EXISTS "idx_test"' @@ -902,18 +903,18 @@ def test_where(self): def test_with_options(self): d = PostgresDialect() - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( d, "idx_w", "t", ["a"], - dialect_options={"with": {"fillfactor": 70}}, + with_options={"fillfactor": 70}, ) sql, _ = expr.to_sql() assert "WITH (fillfactor = 70)" in sql def test_opclasses(self): d = PostgresDialect() - expr = CreateIndexExpression( + expr = PostgresCreateIndexExpression( d, "idx_o", "t", ["a", "b"], - dialect_options={"opclasses": {"a": "text_pattern_ops"}}, + opclasses={"a": "text_pattern_ops"}, ) sql, _ = expr.to_sql() assert '"a" text_pattern_ops' in sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_materialized_view_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_materialized_view_integration.py new file mode 100644 index 00000000..c4ae17bb --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_materialized_view_integration.py @@ -0,0 +1,338 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_materialized_view_integration.py +"""PostgreSQL materialized view integration tests (require a live server). + +Covers the server-behaviour half of the regression matrix documented in +``.claude/plan/2026-09-26/materialized-view-coverage.md``: the SQL-generation unit +tests prove the emitted text, these prove PostgreSQL accepts it and that the +object behaves as documented. +""" + +import pytest + +from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.impl.postgres import PostgresStorageParameter +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterMaterializedViewExpression, + PostgresChangeMaterializedViewOwnerAction, + PostgresCreateMaterializedViewExpression, + PostgresDropMaterializedViewExpression, + PostgresRefreshMaterializedViewExpression, + PostgresRenameMaterializedViewAction, + PostgresSetMaterializedViewPropertiesAction, + PostgresSetMaterializedViewSchemaAction, +) + +from conftest import DDL, DQL + +pytestmark = pytest.mark.usefixtures("mv_backend") + + +def _query(backend, sql): + return backend.execute(sql, options=DQL) + + +def _reloptions(backend, view_name, schema="public"): + """Read pg_class.reloptions straight from the catalog.""" + result = backend.execute( + "SELECT reloptions FROM pg_class c " + "JOIN pg_namespace n ON n.oid = c.relnamespace " + "WHERE c.relname = %s AND n.nspname = %s", + (view_name, schema), + options=DQL, + ) + rows = result.data or [] + return rows[0]["reloptions"] if rows else None + + +class TestCreateMaterializedView: + def test_create_and_query(self, mv_backend, create_summary_mv): + create_summary_mv() + result = _query(mv_backend, "SELECT product_id FROM mv_sales_summary ORDER BY product_id") + assert [row["product_id"] for row in result.data or []] == [1, 2] + + def test_storage_options_applied_on_server(self, mv_backend, create_summary_mv): + """T-42: the server must accept and persist the storage parameters.""" + create_summary_mv( + storage_options={ + PostgresStorageParameter.FILLFACTOR: 70, + PostgresStorageParameter.AUTOVACUUM_ENABLED: "false", + } + ) + options = _reloptions(mv_backend, "mv_sales_summary") + assert options is not None + joined = " ".join(options) + assert "fillfactor=70" in joined + assert "autovacuum_enabled=false" in joined + + def test_if_not_exists_is_idempotent(self, mv_backend, create_summary_mv): + """T-45.""" + create_summary_mv(if_not_exists=True) + create_summary_mv(if_not_exists=True) + result = _query(mv_backend, "SELECT count(*) FROM mv_sales_summary") + assert result.data[0]["count"] == 2 + + def test_with_no_data_is_unqueryable_until_refresh(self, mv_backend, daily_query): + """T-44.""" + expression = PostgresCreateMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="mv_sales_daily", + query=daily_query(), + with_data=False, + ) + sql, params = expression.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + + with pytest.raises(DatabaseError) as exc: + _query(mv_backend, "SELECT count(*) FROM mv_sales_daily") + assert "has not been populated" in str(exc.value) + + refresh = PostgresRefreshMaterializedViewExpression( + dialect=mv_backend.dialect, name="mv_sales_daily", with_data=True + ) + sql, params = refresh.to_sql() + mv_backend.execute(sql, params) + mv_backend.introspector.clear_cache() + result = _query(mv_backend, "SELECT count(*) FROM mv_sales_daily") + assert result.data[0]["count"] == 3 + + +class TestRefreshMaterializedView: + def test_refresh_picks_up_new_rows(self, mv_backend, create_summary_mv): + create_summary_mv() + mv_backend.execute( + "INSERT INTO mv_sales (product_id, amount, sale_date) VALUES (3, 10.00, '2024-02-01')" + ) + refresh = PostgresRefreshMaterializedViewExpression( + dialect=mv_backend.dialect, name="mv_sales_summary" + ) + sql, params = refresh.to_sql() + mv_backend.execute(sql, params) + result = _query(mv_backend, "SELECT count(*) FROM mv_sales_summary") + assert result.data[0]["count"] == 3 + + def test_concurrent_refresh_requires_unique_index(self, mv_backend, create_summary_mv): + """T-43: without a UNIQUE index the server must reject CONCURRENTLY.""" + create_summary_mv() + refresh = PostgresRefreshMaterializedViewExpression( + dialect=mv_backend.dialect, name="mv_sales_summary", concurrently=True + ) + sql, params = refresh.to_sql() + with pytest.raises(DatabaseError) as exc: + mv_backend.execute(sql, params) + assert "unique" in str(exc.value).lower() + + mv_backend.execute( + "CREATE UNIQUE INDEX mv_sales_summary_pk ON mv_sales_summary (product_id)", + options=DDL, + ) + mv_backend.execute(sql, params) + result = _query(mv_backend, "SELECT count(*) FROM mv_sales_summary") + assert result.data[0]["count"] == 2 + + +class TestSchemaQualifiedMaterializedView: + def test_lifecycle_in_non_public_schema(self, mv_backend, create_summary_mv): + """T-46.""" + mv_backend.execute("CREATE SCHEMA IF NOT EXISTS mv_reporting", options=DDL) + create_summary_mv( + view_name="sales_summary", + schema="mv_reporting", + column_aliases=["product_id", "sale_count", "total_amount"], + ) + result = _query(mv_backend, "SELECT product_id FROM mv_reporting.sales_summary ORDER BY product_id") + assert [row["product_id"] for row in result.data or []] == [1, 2] + + alter = PostgresAlterMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="sales_summary", + schema="mv_reporting", + actions=[PostgresSetMaterializedViewSchemaAction(mv_backend.dialect, "public")], + ) + sql, params = alter.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + result = _query(mv_backend, "SELECT product_id FROM public.sales_summary") + assert len(result.data or []) == 2 + + +class TestAlterMaterializedView: + def test_rename(self, mv_backend, create_summary_mv): + """T-47.""" + create_summary_mv() + alter = PostgresAlterMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="mv_sales_summary", + actions=[PostgresRenameMaterializedViewAction(mv_backend.dialect, "mv_sales_renamed")], + ) + sql, params = alter.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + assert mv_backend.introspector.materialized_view_exists("mv_sales_renamed") + assert not mv_backend.introspector.materialized_view_exists("mv_sales_summary") + + def test_set_storage_properties(self, mv_backend, create_summary_mv): + create_summary_mv() + alter = PostgresAlterMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="mv_sales_summary", + actions=[ + PostgresSetMaterializedViewPropertiesAction( + mv_backend.dialect, {PostgresStorageParameter.FILLFACTOR: 85} + ) + ], + ) + sql, params = alter.to_sql() + mv_backend.execute(sql, params, options=DDL) + assert "fillfactor=85" in " ".join(_reloptions(mv_backend, "mv_sales_summary") or []) + + def test_owner_to_is_accepted(self, mv_backend, create_summary_mv): + create_summary_mv() + alter = PostgresAlterMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="mv_sales_summary", + actions=[PostgresChangeMaterializedViewOwnerAction(mv_backend.dialect, "CURRENT_USER")], + ) + sql, params = alter.to_sql() + mv_backend.execute(sql, params, options=DDL) + + +class TestMaterializedViewIntrospection: + def test_list_reports_populated_flag(self, mv_backend, create_summary_mv, daily_query): + """T-48.""" + create_summary_mv() + empty = PostgresCreateMaterializedViewExpression( + dialect=mv_backend.dialect, + view_name="mv_sales_daily", + query=daily_query(), + with_data=False, + ) + sql, params = empty.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + + views = {view.name: view for view in mv_backend.introspector.list_materialized_views()} + assert "mv_sales_summary" in views + assert "mv_sales_daily" in views + assert views["mv_sales_summary"].extra["is_populated"] is True + assert views["mv_sales_daily"].extra["is_populated"] is False + assert all(view.extra["is_materialized"] for view in views.values()) + assert all(view.is_updatable is False for view in views.values()) + + def test_list_reports_unique_index(self, mv_backend, create_summary_mv): + """T-49.""" + create_summary_mv() + mv_backend.introspector.clear_cache() + views = {view.name: view for view in mv_backend.introspector.list_materialized_views()} + assert views["mv_sales_summary"].extra["has_unique_index"] is False + + mv_backend.execute( + "CREATE UNIQUE INDEX mv_sales_summary_uidx ON mv_sales_summary (product_id)", + options=DDL, + ) + mv_backend.introspector.clear_cache() + views = {view.name: view for view in mv_backend.introspector.list_materialized_views()} + assert views["mv_sales_summary"].extra["has_unique_index"] is True + + def test_get_info_and_exists(self, mv_backend, create_summary_mv): + create_summary_mv() + mv_backend.introspector.clear_cache() + info = mv_backend.introspector.get_materialized_view_info("mv_sales_summary") + assert info is not None + assert info.schema == "public" + assert info.definition + assert mv_backend.introspector.materialized_view_exists("mv_sales_summary") + assert not mv_backend.introspector.materialized_view_exists("mv_missing") + + def test_regular_views_are_not_listed(self, mv_backend, create_summary_mv): + """MV enumeration must not leak into the regular view listing.""" + create_summary_mv() + mv_backend.execute("CREATE OR REPLACE VIEW mv_plain_view AS SELECT 1 AS one", options=DDL) + mv_backend.introspector.clear_cache() + view_names = {view.name for view in mv_backend.introspector.list_views()} + assert "mv_plain_view" in view_names + assert "mv_sales_summary" not in view_names + + def test_table_list_filters_materialized_view(self, mv_backend, create_summary_mv): + """T-50: relkind 'm' rows are reported and filterable.""" + create_summary_mv() + tables = mv_backend.introspector.list_tables(table_type="MATERIALIZED VIEW") + assert "mv_sales_summary" in {table.name for table in tables} + + def test_drop_cascade(self, mv_backend, create_summary_mv): + create_summary_mv() + drop = PostgresDropMaterializedViewExpression( + dialect=mv_backend.dialect, view_name="mv_sales_summary", if_exists=True, cascade=True + ) + sql, params = drop.to_sql() + mv_backend.execute(sql, params, options=DDL) + mv_backend.introspector.clear_cache() + assert not mv_backend.introspector.materialized_view_exists("mv_sales_summary") + + +class TestMaterializedViewAsyncParity: + """T-52 / T-53: the async backend must behave identically.""" + + async def test_create_refresh_drop(self, async_mv_backend, summary_query): + dialect = async_mv_backend.dialect + create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv_sales_summary", + query=summary_query(), + column_aliases=["product_id", "sale_count", "total_amount"], + ) + sql, params = create.to_sql() + await async_mv_backend.execute(sql, params, options=DDL) + + result = await async_mv_backend.execute( + "SELECT product_id FROM mv_sales_summary ORDER BY product_id", options=DQL + ) + assert [row["product_id"] for row in result.data or []] == [1, 2] + + await async_mv_backend.execute( + "INSERT INTO mv_sales (product_id, amount, sale_date) VALUES (3, 10.00, '2024-02-01')" + ) + refresh = PostgresRefreshMaterializedViewExpression( + dialect=dialect, name="mv_sales_summary" + ) + sql, params = refresh.to_sql() + await async_mv_backend.execute(sql, params) + + result = await async_mv_backend.execute( + "SELECT count(*) FROM mv_sales_summary", options=DQL + ) + assert result.data[0]["count"] == 3 + + drop = PostgresDropMaterializedViewExpression( + dialect=dialect, view_name="mv_sales_summary", if_exists=True, cascade=True + ) + sql, params = drop.to_sql() + await async_mv_backend.execute(sql, params, options=DDL) + + async def test_list_materialized_views(self, async_mv_backend, summary_query): + dialect = async_mv_backend.dialect + create = PostgresCreateMaterializedViewExpression( + dialect=dialect, + view_name="mv_sales_summary", + query=summary_query(), + ) + sql, params = create.to_sql() + await async_mv_backend.execute(sql, params, options=DDL) + async_mv_backend.introspector.clear_cache() + + views = await async_mv_backend.introspector.list_materialized_views() + names = {view.name for view in views} + assert "mv_sales_summary" in names + summary = next(view for view in views if view.name == "mv_sales_summary") + assert summary.extra["is_populated"] is True + assert await async_mv_backend.introspector.materialized_view_exists("mv_sales_summary") + + async def test_teardown_leaves_no_residue(self, async_mv_backend, daily_query): + """T-51: fixture teardown must remove every object it created.""" + dialect = async_mv_backend.dialect + create = PostgresCreateMaterializedViewExpression( + dialect=dialect, view_name="mv_sales_daily", query=daily_query(), with_data=False + ) + sql, params = create.to_sql() + await async_mv_backend.execute(sql, params, options=DDL) + assert await async_mv_backend.introspector.materialized_view_exists("mv_sales_daily") diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py new file mode 100644 index 00000000..2a91b48c --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py @@ -0,0 +1,50 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_model_truncate.py +"""PostgreSQL rendering tests for model-bound TRUNCATE expressions.""" + +from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( + TruncateExpression, +) + + +TABLE = "model_truncate_events" + + +class TestSyncModelTruncate: + def test_truncate_expression_renders_postgres_options(self, postgres_backend): + expression = TruncateExpression( + postgres_backend.dialect, + TABLE, + restart_identity=True, + cascade=True, + ) + + assert isinstance(expression, TruncateExpression) + assert expression.table_name == TABLE + assert expression.restart_identity is True + assert expression.cascade is True + assert expression.to_sql() == ( + f'TRUNCATE TABLE "{TABLE}" RESTART IDENTITY CASCADE', + (), + ) + + +class TestAsyncModelTruncate: + async def test_truncate_expression_renders_postgres_options( + self, + async_postgres_backend, + ): + expression = TruncateExpression( + async_postgres_backend.dialect, + TABLE, + restart_identity=True, + cascade=True, + ) + + assert isinstance(expression, TruncateExpression) + assert expression.table_name == TABLE + assert expression.restart_identity is True + assert expression.cascade is True + assert expression.to_sql() == ( + f'TRUNCATE TABLE "{TABLE}" RESTART IDENTITY CASCADE', + (), + ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py new file mode 100644 index 00000000..5b781c50 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py @@ -0,0 +1,730 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py +"""Tests for PostgreSQL COPY and native REPACK expressions and APIs.""" + +from contextlib import contextmanager +from unittest.mock import AsyncMock, Mock + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.errors import QueryError +from rhosocial.activerecord.backend.expression import ( + Column, + Literal, + QueryExpression, + RawSQLExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.serialization import deserialize, serialize +from rhosocial.activerecord.backend.impl.postgres import ( + AsyncPostgresBackend, + PostgresBackend, + PostgresCopyFromExpression, + PostgresCopyLogVerbosity, + PostgresCopyOnError, + PostgresCopyToExpression, + PostgresDialect, + PostgresRepackExpression, +) +from rhosocial.activerecord.backend.impl.postgres.mixins import ( + PostgresCopyMixin, + PostgresRepackMixin, +) +from rhosocial.activerecord.backend.impl.postgres.protocols import ( + PostgresCopySupport, + PostgresRepackSupport, +) + + +class SyncCopy: + def __init__(self, chunks=(), error=None): + self.chunks = list(chunks) + self.error = error + self.writes = [] + + def __iter__(self): + yield from self.chunks + if self.error is not None: + raise self.error + + def write(self, chunk): + self.writes.append(chunk) + + +class SyncCursor: + def __init__(self, copy, rowcount=0): + self.copy_object = copy + self.rowcount = rowcount + self.statements = [] + self.closed = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.closed = True + + @contextmanager + def copy(self, statement, params): + self.statements.append((statement, params)) + yield self.copy_object + + +class AsyncCopy: + def __init__(self, chunks=(), error=None): + self.chunks = list(chunks) + self.error = error + self.writes = [] + + async def __aiter__(self): + for chunk in self.chunks: + yield chunk + if self.error is not None: + raise self.error + + async def write(self, chunk): + self.writes.append(chunk) + + +class AsyncCopyContext: + def __init__(self, copy): + self.copy = copy + + async def __aenter__(self): + return self.copy + + async def __aexit__(self, exc_type, exc_value, traceback): + return None + + +class AsyncCursor: + def __init__(self, copy, rowcount=0): + self.copy_object = copy + self.rowcount = rowcount + self.statements = [] + self.closed = False + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + self.closed = True + + def copy(self, statement, params): + self.statements.append((statement, params)) + return AsyncCopyContext(self.copy_object) + + +def make_sync_backend(cursor): + backend = object.__new__(PostgresBackend) + backend._get_cursor = Mock(return_value=cursor) + backend._transaction_manager = None + backend._connection = None + return backend + + +def make_async_backend(cursor): + backend = object.__new__(AsyncPostgresBackend) + backend._get_cursor = AsyncMock(return_value=cursor) + backend._transaction_manager = None + backend._connection = None + return backend + + +def select_query(dialect): + return QueryExpression( + dialect, + select=[Column(dialect, "id")], + from_=TableExpression(dialect, "users"), + where=Column(dialect, "id") > Literal(dialect, 3), + ) + + +def test_copy_to_table_formatter_and_safe_literals(): + dialect = PostgresDialect((19, 0, 0)) + expression = PostgresCopyToExpression( + dialect, + table_name='users"; DROP TABLE users; --', + schema="app data", + columns=["id", "display'name"], + format="csv", + header=True, + null="NULL", + delimiter="|", + quote="'", + escape="\\", + ) + + sql, params = expression.to_sql() + + assert params == () + assert 'COPY "app data"."users""; DROP TABLE users; --"' in sql + assert '("id", "display\'name")' in sql + assert "FORMAT CSV" in sql + assert "HEADER" in sql + assert "NULL 'NULL'" in sql + assert "DELIMITER '|'" in sql + assert "QUOTE ''''" in sql + assert "ESCAPE E'\\\\'" in sql + assert "PROGRAM" not in sql + assert "STDOUT" in sql + + +def test_copy_to_requires_table_or_base_expression_query(): + dialect = PostgresDialect((19, 0, 0)) + with pytest.raises(ValueError, match="exactly one"): + PostgresCopyToExpression(dialect) + with pytest.raises(TypeError, match="BaseExpression"): + PostgresCopyToExpression(dialect, query="SELECT 1") + with pytest.raises(ValueError, match="not both"): + PostgresCopyToExpression(dialect, table_name="users", query=RawSQLExpression(dialect, "SELECT 1")) + with pytest.raises(TypeError): + PostgresCopyToExpression(dialect, table_name="users", program="cat input") + + +def test_copy_to_query_returns_parameters_and_rejects_mutations(): + dialect = PostgresDialect((19, 0, 0)) + expression = PostgresCopyToExpression(dialect, query=select_query(dialect)) + + sql, params = expression.to_sql() + + assert sql == 'COPY (SELECT "id" FROM "users" WHERE "id" > %s) TO STDOUT (FORMAT TEXT)' + assert params == (3,) + with pytest.raises(ValueError, match="read-only"): + PostgresCopyToExpression( + dialect, + query=RawSQLExpression(dialect, "SELECT 1; DELETE FROM users"), + ).to_sql() + with pytest.raises(ValueError, match="read-only"): + PostgresCopyToExpression( + dialect, + query=RawSQLExpression(dialect, "WITH gone AS (DELETE FROM users RETURNING id) SELECT * FROM gone"), + ).to_sql() + + +def test_copy_to_pg19_json_force_array_and_partitioned_gates(): + expression = PostgresCopyToExpression( + PostgresDialect((18, 0, 0)), + table_name="events", + format="json", + force_array=True, + partitioned=True, + ) + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 19"): + expression.to_sql() + + sql, params = PostgresCopyToExpression( + PostgresDialect((19, 0, 0)), + table_name="events", + format="json", + force_array=True, + partitioned=True, + ).to_sql() + assert sql == 'COPY "events" TO STDOUT (FORMAT JSON, FORCE_ARRAY)' + assert params == () + + +def test_copy_to_rejects_format_option_combinations(): + dialect = PostgresDialect((19, 0, 0)) + with pytest.raises(ValueError, match="JSON"): + PostgresCopyToExpression(dialect, table_name="events", format="json", header=True).to_sql() + with pytest.raises(ValueError, match="CSV"): + PostgresCopyToExpression(dialect, table_name="events", quote='"').to_sql() + with pytest.raises(ValueError, match="FORCE_ARRAY"): + PostgresCopyToExpression(dialect, table_name="events", force_array=True).to_sql() + with pytest.raises(ValueError, match="single-byte"): + PostgresCopyToExpression(dialect, table_name="events", delimiter="ab").to_sql() + + +def test_copy_from_csv_formatter_with_all_supported_options(): + dialect = PostgresDialect((19, 0, 0)) + expression = PostgresCopyFromExpression( + dialect, + 'users"; DROP TABLE users; --', + schema="app data", + columns=["id", "name"], + format="csv", + header="match", + null="NULL", + default="DEFAULT", + delimiter="|", + quote="'", + escape="\\", + force_not_null=["name"], + force_null="*", + on_error=PostgresCopyOnError.IGNORE, + reject_limit=5, + encoding="UTF8", + log_verbosity=PostgresCopyLogVerbosity.SILENT, + ) + + sql, params = expression.to_sql() + + assert params == () + assert 'COPY "app data"."users""; DROP TABLE users; --" ("id", "name") FROM STDIN' in sql + assert "FORMAT CSV" in sql + assert "HEADER MATCH" in sql + assert "NULL 'NULL'" in sql + assert "DEFAULT 'DEFAULT'" in sql + assert 'FORCE_NOT_NULL ("name")' in sql + assert "FORCE_NULL *" in sql + assert "ON_ERROR IGNORE" in sql + assert "REJECT_LIMIT 5" in sql + assert "ENCODING 'UTF8'" in sql + assert "LOG_VERBOSITY SILENT" in sql + assert "PROGRAM" not in sql + + +def test_copy_from_rejects_json_and_direction_options(): + dialect = PostgresDialect((19, 0, 0)) + with pytest.raises(ValueError, match="COPY FROM does not support JSON"): + PostgresCopyFromExpression(dialect, "users", format="json").to_sql() + with pytest.raises(TypeError): + PostgresCopyFromExpression(dialect, "users", stdout=True) + with pytest.raises(TypeError): + PostgresCopyFromExpression(dialect, "users", query=RawSQLExpression(dialect, "SELECT 1")) + + +def test_copy_header_version_boundaries(): + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 15"): + PostgresCopyToExpression( + PostgresDialect((14, 0, 0)), + table_name="users", + header=True, + ).to_sql() + assert ( + "HEADER" + in PostgresCopyToExpression( + PostgresDialect((15, 0, 0)), + table_name="users", + header=True, + ).to_sql()[0] + ) + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 15"): + PostgresCopyFromExpression( + PostgresDialect((14, 0, 0)), + "users", + header="match", + ).to_sql() + assert ( + "HEADER MATCH" + in PostgresCopyFromExpression( + PostgresDialect((15, 0, 0)), + "users", + header="match", + ).to_sql()[0] + ) + assert ( + "HEADER FALSE" + in PostgresCopyToExpression( + PostgresDialect((14, 0, 0)), + table_name="users", + header=False, + ).to_sql()[0] + ) + assert ( + "HEADER" + in PostgresCopyFromExpression( + PostgresDialect((14, 0, 0)), + "users", + format="csv", + header=True, + ).to_sql()[0] + ) + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 19"): + PostgresCopyFromExpression( + PostgresDialect((18, 0, 0)), + "users", + format="csv", + header=3, + ).to_sql() + assert ( + "HEADER 1" + in PostgresCopyFromExpression( + PostgresDialect((18, 0, 0)), + "users", + format="csv", + header=1, + ).to_sql()[0] + ) + assert ( + "HEADER 3" + in PostgresCopyFromExpression( + PostgresDialect((19, 0, 0)), + "users", + format="csv", + header=3, + ).to_sql()[0] + ) + with pytest.raises(ValueError, match="0 or 1"): + PostgresCopyToExpression( + PostgresDialect((19, 0, 0)), + table_name="users", + header=2, + ).to_sql() + + +def test_copy_error_option_version_boundaries(): + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 17"): + PostgresCopyFromExpression( + PostgresDialect((16, 0, 0)), + "users", + on_error="ignore", + ).to_sql() + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 18"): + PostgresCopyFromExpression( + PostgresDialect((17, 0, 0)), + "users", + on_error="ignore", + reject_limit=2, + ).to_sql() + assert ( + "REJECT_LIMIT 2" + in PostgresCopyFromExpression( + PostgresDialect((18, 0, 0)), + "users", + on_error="ignore", + reject_limit=2, + ).to_sql()[0] + ) + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 19"): + PostgresCopyFromExpression( + PostgresDialect((18, 0, 0)), + "users", + on_error="set_null", + ).to_sql() + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 17"): + PostgresCopyFromExpression( + PostgresDialect((16, 0, 0)), + "users", + log_verbosity="verbose", + ).to_sql() + assert ( + "LOG_VERBOSITY VERBOSE" + in PostgresCopyFromExpression( + PostgresDialect((17, 0, 0)), + "users", + log_verbosity="verbose", + ).to_sql()[0] + ) + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 18"): + PostgresCopyFromExpression( + PostgresDialect((17, 0, 0)), + "users", + log_verbosity="silent", + ).to_sql() + assert ( + "LOG_VERBOSITY SILENT" + in PostgresCopyFromExpression( + PostgresDialect((18, 0, 0)), + "users", + on_error="ignore", + log_verbosity="silent", + ).to_sql()[0] + ) + + +def test_copy_force_all_star_version_boundaries(): + for option in ("force_not_null", "force_null"): + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 17"): + PostgresCopyFromExpression( + PostgresDialect((16, 0, 0)), + "users", + format="csv", + **{option: "*"}, + ).to_sql() + assert ( + f"{option.upper()} *" + in PostgresCopyFromExpression( + PostgresDialect((17, 0, 0)), + "users", + format="csv", + **{option: "*"}, + ).to_sql()[0] + ) + assert ( + "FORCE_QUOTE *" + in PostgresCopyToExpression( + PostgresDialect((16, 0, 0)), + table_name="users", + format="csv", + force_quote="*", + ).to_sql()[0] + ) + + +def test_copy_from_rejects_invalid_option_relationships(): + dialect = PostgresDialect((19, 0, 0)) + with pytest.raises(ValueError, match="REJECT_LIMIT requires"): + PostgresCopyFromExpression(dialect, "users", reject_limit=1).to_sql() + with pytest.raises(ValueError, match="require text or csv"): + PostgresCopyFromExpression( + dialect, + "users", + format="binary", + on_error="ignore", + ).to_sql() + with pytest.raises(ValueError, match="QUOTE and ESCAPE"): + PostgresCopyFromExpression(dialect, "users", quote='"').to_sql() + with pytest.raises(ValueError, match="absent"): + PostgresCopyFromExpression( + dialect, + "users", + columns=["id"], + force_null=["missing"], + format="csv", + ).to_sql() + with pytest.raises(TypeError, match="BaseExpression"): + PostgresCopyFromExpression(dialect, "users", where="id > 0") + + +def test_copy_from_where_returns_parameters(): + dialect = PostgresDialect((19, 0, 0)) + expression = PostgresCopyFromExpression( + dialect, + "users", + where=Column(dialect, "id") > Literal(dialect, 7), + ) + + sql, params = expression.to_sql() + + assert sql.endswith('WHERE "id" > %s') + assert params == (7,) + + +def test_repack_version_gate_and_all_forms(): + expression = PostgresRepackExpression(PostgresDialect((18, 0, 0)), table_name="users") + with pytest.raises(UnsupportedFeatureError, match="PostgreSQL 19"): + expression.to_sql() + + dialect = PostgresDialect((19, 0, 0)) + assert PostgresRepackExpression(dialect).to_sql() == ("REPACK", ()) + assert PostgresRepackExpression( + dialect, + verbose=True, + all_using_index=True, + ).to_sql() == ("REPACK (VERBOSE) USING INDEX", ()) + assert PostgresRepackExpression( + dialect, + table_name="users", + concurrently=True, + using_index=True, + ).to_sql() == ('REPACK (CONCURRENTLY) "users" USING INDEX', ()) + assert PostgresRepackExpression( + dialect, + table_name="users", + schema="app data", + columns=["id", "name"], + analyze=True, + using_index='users"; DROP TABLE users; --', + ).to_sql() == ( + 'REPACK (ANALYZE) "app data"."users" ("id", "name") USING INDEX "users""; DROP TABLE users; --"', + (), + ) + + +def test_repack_strict_relationships(): + dialect = PostgresDialect((19, 0, 0)) + with pytest.raises(ValueError, match="columns require ANALYZE"): + PostgresRepackExpression(dialect, table_name="users", columns=["id"]).to_sql() + with pytest.raises(ValueError, match="ANALYZE requires one table"): + PostgresRepackExpression(dialect, analyze=True).to_sql() + with pytest.raises(ValueError, match="CONCURRENTLY requires one table"): + PostgresRepackExpression(dialect, concurrently=True).to_sql() + with pytest.raises(ValueError, match="named REPACK index"): + PostgresRepackExpression(dialect, using_index="users_idx").to_sql() + with pytest.raises(ValueError, match="cannot be combined"): + PostgresRepackExpression( + dialect, + table_name="users", + all_using_index=True, + ).to_sql() + + +def test_protocol_mro_and_serialization(): + dialect = PostgresDialect((19, 0, 0)) + assert isinstance(dialect, PostgresCopySupport) + assert isinstance(dialect, PostgresRepackSupport) + assert PostgresDialect.__mro__.index(PostgresCopyMixin) < PostgresDialect.__mro__.index(PostgresCopySupport) + assert PostgresDialect.__mro__.index(PostgresRepackMixin) < PostgresDialect.__mro__.index(PostgresRepackSupport) + + original = PostgresCopyToExpression(dialect, query=select_query(dialect)) + restored = deserialize(serialize(original), dialect) + assert isinstance(restored, PostgresCopyToExpression) + assert restored.to_sql() == original.to_sql() + + +def test_sync_copy_to_consumes_complete_stream_and_passes_parameters(): + dialect = PostgresDialect((19, 0, 0)) + copy = SyncCopy([b"first", memoryview(b"-second"), "third"]) + cursor = SyncCursor(copy) + backend = make_sync_backend(cursor) + + result = backend.copy_to(PostgresCopyToExpression(dialect, query=select_query(dialect))) + + assert result == b"first-secondthird" + assert cursor.statements[0][1] == (3,) + assert cursor.closed is True + + +def test_sync_copy_from_streams_chunks_and_returns_row_count(): + dialect = PostgresDialect((19, 0, 0)) + copy = SyncCopy() + cursor = SyncCursor(copy, rowcount=3) + backend = make_sync_backend(cursor) + + result = backend.copy_from( + PostgresCopyFromExpression(dialect, "users", format="csv"), + (b"1,one\n", memoryview(b"2,two\n"), "3,three\n"), + ) + + assert result == 3 + assert copy.writes == [b"1,one\n", memoryview(b"2,two\n"), "3,three\n"] + assert cursor.closed is True + + +def test_sync_copy_to_client_error_rolls_back_explicit_transaction(): + dialect = PostgresDialect((19, 0, 0)) + copy = SyncCopy([b"partial"], RuntimeError("client copy failure")) + cursor = SyncCursor(copy) + backend = make_sync_backend(cursor) + manager = Mock(is_active=True) + backend._transaction_manager = manager + backend._connection = Mock(closed=False) + + with pytest.raises(RuntimeError, match="client copy failure"): + backend.copy_to(PostgresCopyToExpression(dialect, table_name="users")) + + manager.rollback.assert_called_once_with() + + +def test_sync_copy_from_rejects_str_with_explicit_encoding(): + dialect = PostgresDialect((19, 0, 0)) + copy = SyncCopy() + cursor = SyncCursor(copy) + backend = make_sync_backend(cursor) + + with pytest.raises(TypeError, match="ENCODING"): + backend.copy_from( + PostgresCopyFromExpression(dialect, "users", encoding="LATIN1"), + "one\n", + ) + + assert copy.writes == [] + + +def test_sync_copy_client_error_rolls_back_explicit_transaction(): + dialect = PostgresDialect((19, 0, 0)) + copy = SyncCopy() + cursor = SyncCursor(copy) + backend = make_sync_backend(cursor) + manager = Mock(is_active=True) + backend._transaction_manager = manager + backend._connection = Mock(closed=False) + + def input_chunks(): + yield b"one\n" + raise RuntimeError("client copy failure") + + with pytest.raises(RuntimeError, match="client copy failure"): + backend.copy_from(PostgresCopyFromExpression(dialect, "users"), input_chunks()) + + manager.rollback.assert_called_once_with() + assert copy.writes == [b"one\n"] + + +@pytest.mark.asyncio +async def test_async_copy_to_client_error_rolls_back_explicit_transaction(): + dialect = PostgresDialect((19, 0, 0)) + copy = AsyncCopy([b"partial"], RuntimeError("client copy failure")) + cursor = AsyncCursor(copy) + backend = make_async_backend(cursor) + manager = Mock(is_active=True) + manager.rollback = AsyncMock() + backend._transaction_manager = manager + backend._connection = Mock(closed=False) + backend._connection.rollback = AsyncMock() + + with pytest.raises(RuntimeError, match="client copy failure"): + await backend.copy_to(PostgresCopyToExpression(dialect, table_name="users")) + + manager.rollback.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_async_copy_from_rejects_str_with_explicit_encoding(): + dialect = PostgresDialect((19, 0, 0)) + copy = AsyncCopy() + cursor = AsyncCursor(copy) + backend = make_async_backend(cursor) + + with pytest.raises(TypeError, match="ENCODING"): + await backend.copy_from( + PostgresCopyFromExpression(dialect, "users", encoding="LATIN1"), + "one\n", + ) + + assert copy.writes == [] + + +@pytest.mark.asyncio +async def test_async_copy_client_error_rolls_back_explicit_transaction(): + dialect = PostgresDialect((19, 0, 0)) + copy = AsyncCopy() + cursor = AsyncCursor(copy) + backend = make_async_backend(cursor) + manager = Mock(is_active=True) + manager.rollback = AsyncMock() + backend._transaction_manager = manager + backend._connection = Mock(closed=False) + backend._connection.rollback = AsyncMock() + + async def input_chunks(): + yield b"one\n" + raise RuntimeError("client copy failure") + + with pytest.raises(RuntimeError, match="client copy failure"): + await backend.copy_from(PostgresCopyFromExpression(dialect, "users"), input_chunks()) + + manager.rollback.assert_awaited_once_with() + assert copy.writes == [b"one\n"] + + +def test_sync_copy_converts_psycopg_errors(): + from psycopg.errors import ProgrammingError + + class ErrorCursor(SyncCursor): + @contextmanager + def copy(self, statement, params): + raise ProgrammingError("invalid COPY") + yield + + dialect = PostgresDialect((19, 0, 0)) + cursor = ErrorCursor(SyncCopy()) + backend = make_sync_backend(cursor) + backend._handle_error = Mock(side_effect=QueryError("converted")) + + with pytest.raises(QueryError, match="converted"): + backend.copy_to(PostgresCopyToExpression(dialect, table_name="users")) + + +@pytest.mark.asyncio +async def test_async_copy_apis_are_symmetric_and_consume_streams(): + dialect = PostgresDialect((19, 0, 0)) + output_cursor = AsyncCursor(AsyncCopy([b"one", b"two"])) + output_backend = make_async_backend(output_cursor) + output = await output_backend.copy_to(PostgresCopyToExpression(dialect, query=select_query(dialect))) + assert output == b"onetwo" + assert output_cursor.statements[0][1] == (3,) + assert output_cursor.closed is True + + input_copy = AsyncCopy() + input_cursor = AsyncCursor(input_copy, rowcount=2) + input_backend = make_async_backend(input_cursor) + count = await input_backend.copy_from( + PostgresCopyFromExpression(dialect, "users"), + [b"one\n", b"two\n"], + ) + assert count == 2 + assert input_copy.writes == [b"one\n", b"two\n"] + assert input_cursor.closed is True diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py new file mode 100644 index 00000000..ab599fb5 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py @@ -0,0 +1,124 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py +"""Live PostgreSQL COPY and REPACK integration tests.""" + +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal, QueryExpression, TableExpression +from rhosocial.activerecord.backend.impl.postgres import ( + PostgresCopyFromExpression, + PostgresCopyToExpression, + PostgresRepackExpression, +) + + +TABLE_NAME = "rhosocial_copy_repack_test" +PARTITIONED_TABLE_NAME = "rhosocial_copy_partitioned_test" +PARTITION_NAME = "rhosocial_copy_partitioned_test_0_10" + + +def prepare_table(backend): + backend.execute(f'DROP TABLE IF EXISTS "{TABLE_NAME}"') + backend.execute(f'CREATE TABLE "{TABLE_NAME}" ("id" integer, "name" text)') + + +async def async_prepare_table(backend): + await backend.execute(f'DROP TABLE IF EXISTS "{TABLE_NAME}"') + await backend.execute(f'CREATE TABLE "{TABLE_NAME}" ("id" integer, "name" text)') + + +def test_live_copy_round_trip_and_native_repack(postgres_backend): + dialect = postgres_backend.dialect + prepare_table(postgres_backend) + try: + copied = postgres_backend.copy_from( + PostgresCopyFromExpression(dialect, TABLE_NAME), + [b"1\tAlice\n", b"2\tBob\n"], + ) + assert copied == 2 + + query = QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "name")], + from_=TableExpression(dialect, TABLE_NAME), + where=Column(dialect, "id") > Literal(dialect, 1), + ) + assert postgres_backend.copy_to(PostgresCopyToExpression(dialect, query=query)) == b"2\tBob\n" + + if dialect.version >= (19, 0, 0): + json_data = postgres_backend.copy_to( + PostgresCopyToExpression( + dialect, + table_name=TABLE_NAME, + format="json", + force_array=True, + ) + ) + assert b'"name":"Alice"' in json_data + assert b'"name":"Bob"' in json_data + postgres_backend.execute(*PostgresRepackExpression(dialect, table_name=TABLE_NAME).to_sql()) + finally: + postgres_backend.execute(f'DROP TABLE IF EXISTS "{TABLE_NAME}"') + + +def test_live_pg19_partitioned_copy_to(postgres_backend): + dialect = postgres_backend.dialect + if dialect.version < (19, 0, 0): + pytest.skip("Direct partitioned COPY TO requires PostgreSQL 19+") + postgres_backend.execute(f'DROP TABLE IF EXISTS "{PARTITIONED_TABLE_NAME}"') + postgres_backend.execute( + f'CREATE TABLE "{PARTITIONED_TABLE_NAME}" ("id" integer, "name" text) PARTITION BY RANGE ("id")' + ) + postgres_backend.execute( + f'CREATE TABLE "{PARTITION_NAME}" PARTITION OF "{PARTITIONED_TABLE_NAME}" FOR VALUES FROM (0) TO (10)' + ) + try: + copied = postgres_backend.copy_from( + PostgresCopyFromExpression(dialect, PARTITION_NAME), + b"1\tPartitioned\n", + ) + assert copied == 1 + data = postgres_backend.copy_to( + PostgresCopyToExpression( + dialect, + table_name=PARTITIONED_TABLE_NAME, + partitioned=True, + ) + ) + assert data == b"1\tPartitioned\n" + finally: + postgres_backend.execute(f'DROP TABLE IF EXISTS "{PARTITIONED_TABLE_NAME}"') + + +@pytest.mark.asyncio +async def test_live_async_copy_round_trip_and_native_repack(async_postgres_backend): + dialect = async_postgres_backend.dialect + await async_prepare_table(async_postgres_backend) + try: + copied = await async_postgres_backend.copy_from( + PostgresCopyFromExpression(dialect, TABLE_NAME), + [b"1\tAlice\n", b"2\tBob\n"], + ) + assert copied == 2 + + query = QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "name")], + from_=TableExpression(dialect, TABLE_NAME), + where=Column(dialect, "id") > Literal(dialect, 1), + ) + copied_query = await async_postgres_backend.copy_to(PostgresCopyToExpression(dialect, query=query)) + assert copied_query == b"2\tBob\n" + + if dialect.version >= (19, 0, 0): + json_data = await async_postgres_backend.copy_to( + PostgresCopyToExpression( + dialect, + table_name=TABLE_NAME, + format="json", + force_array=True, + ) + ) + assert b'"name":"Alice"' in json_data + await async_postgres_backend.execute(*PostgresRepackExpression(dialect, table_name=TABLE_NAME).to_sql()) + finally: + await async_postgres_backend.execute(f'DROP TABLE IF EXISTS "{TABLE_NAME}"') diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py new file mode 100644 index 00000000..8cd07bf0 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py @@ -0,0 +1,97 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_column_definition.py +"""Tests for the PostgreSQL-specific column definition expressions.""" + +import pytest + +from rhosocial.activerecord.backend.expression import ColumnCommentClause, ColumnDefinition +from rhosocial.activerecord.backend.expression.types import TextType +from rhosocial.activerecord.backend.impl.postgres import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression import ( + PostgresColumnDefinition, + PostgresColumnOptions, + PostgresColumnStorage, +) + + +@pytest.fixture +def dialect(): + return PostgresDialect((15, 0, 0)) + + +def _column(dialect, **kwargs): + return PostgresColumnDefinition(dialect, "body", TextType(dialect), **kwargs) + + +def test_derives_generic_column_definition(): + assert ColumnDefinition in PostgresColumnDefinition.__mro__ + + +def test_compression(dialect): + sql, _ = _column(dialect, compression="lz4").to_sql() + assert sql == '"body" TEXT COMPRESSION lz4' + + +def test_storage(dialect): + sql, _ = _column(dialect, storage=PostgresColumnStorage.EXTENDED).to_sql() + assert sql == '"body" TEXT STORAGE EXTENDED' + + +def test_statistics(dialect): + sql, _ = _column(dialect, statistics=500).to_sql() + assert sql == '"body" TEXT STATISTICS 500' + + +def test_combined(dialect): + sql, _ = _column( + dialect, + compression="pglz", + storage=PostgresColumnStorage.EXTERNAL, + statistics=100, + ).to_sql() + assert sql == '"body" TEXT COMPRESSION pglz STORAGE EXTERNAL STATISTICS 100' + + +def test_generic_column_comment_raises_on_postgres(dialect): + # §5.19: PostgreSQL has no inline COMMENT syntax; a comment on a column + # definition raises instead of rendering SQL PostgreSQL would reject. + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + generic = ColumnDefinition( + dialect, "body", TextType(dialect), comment=ColumnCommentClause(dialect, "c") + ) + with pytest.raises(UnsupportedFeatureError, match="COLUMN COMMENT"): + generic.to_sql() + + +def test_invalid_storage_type(dialect): + with pytest.raises(TypeError, match="PostgresColumnStorage"): + _column(dialect, storage="EXTENDED") + + +def test_statistics_out_of_range(): + with pytest.raises(ValueError, match="0..10000"): + PostgresColumnOptions(statistics=20000) + + +def test_options_select_postgres_column_class(): + assert PostgresColumnOptions(compression="lz4").column_definition_class() is ( + PostgresColumnDefinition + ) + + +def test_options_apply_to(dialect): + options = PostgresColumnOptions( + compression="lz4", storage=PostgresColumnStorage.MAIN, statistics=10 + ) + col = PostgresColumnDefinition(dialect, "c", TextType(dialect)) + options.apply_to(col) + assert col.compression == "lz4" + assert col.storage is PostgresColumnStorage.MAIN + assert col.statistics == 10 + + +def test_options_apply_to_rejects_generic_column(dialect): + options = PostgresColumnOptions(compression="lz4") + generic = ColumnDefinition(dialect, "c", TextType(dialect)) + with pytest.raises(TypeError, match="PostgresColumnDefinition"): + options.apply_to(generic) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py index b54de82d..3ea2bb80 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py @@ -1,8 +1,16 @@ # tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py -""" -Real-world PGQ scenario tests for PostgreSQL 19+ using the expression system. - -All SQL is constructed via expression classes. No raw SQL strings are used. +"""Capability-gated SQL/PGQ integration scenarios. + +These scenarios need a live server that actually implements SQL/PGQ. That was +true of PostgreSQL 19 Beta 3 and is false from Beta 4 onwards, where the feature +was withdrawn before the 19.0 release, and it was never true before PostgreSQL +19 at all. With the capability off by default the fixtures below skip, so the +whole file is inert against a stock server; it stays here to exercise the +opt-in path once a server (or compatibility layer) provides the feature. + +The synchronous and asynchronous database tests skip unless the backend +explicitly reports GRAPH_TABLE support. All SQL is constructed through +expression classes. """ import pytest import pytest_asyncio @@ -90,11 +98,36 @@ def social_data(postgres_backend): backend.execute(*CreateTableExpression(dialect, "likes", likes_cols).to_sql()) people_data = ValuesSource(dialect, [ - [Literal(dialect, 1), Literal(dialect, "Alice"), Literal(dialect, "alice@example.com"), Literal(dialect, "NYC")], - [Literal(dialect, 2), Literal(dialect, "Bob"), Literal(dialect, "bob@example.com"), Literal(dialect, "NYC")], - [Literal(dialect, 3), Literal(dialect, "Charlie"), Literal(dialect, "charlie@example.com"), Literal(dialect, "LA")], - [Literal(dialect, 4), Literal(dialect, "Diana"), Literal(dialect, "diana@example.com"), Literal(dialect, "NYC")], - [Literal(dialect, 5), Literal(dialect, "Eve"), Literal(dialect, "eve@example.com"), Literal(dialect, "LA")], + [ + Literal(dialect, 1), + Literal(dialect, "Alice"), + Literal(dialect, "alice@example.com"), + Literal(dialect, "NYC"), + ], + [ + Literal(dialect, 2), + Literal(dialect, "Bob"), + Literal(dialect, "bob@example.com"), + Literal(dialect, "NYC"), + ], + [ + Literal(dialect, 3), + Literal(dialect, "Charlie"), + Literal(dialect, "charlie@example.com"), + Literal(dialect, "LA"), + ], + [ + Literal(dialect, 4), + Literal(dialect, "Diana"), + Literal(dialect, "diana@example.com"), + Literal(dialect, "NYC"), + ], + [ + Literal(dialect, 5), + Literal(dialect, "Eve"), + Literal(dialect, "eve@example.com"), + Literal(dialect, "LA"), + ], ]) backend.execute(*InsertExpression(dialect, "people", source=people_data).to_sql()) @@ -167,8 +200,15 @@ class TestSocialGraph: def test_single_hop_followers(self, postgres_backend, social_data): """Q1: Who does Alice follow?""" dialect = postgres_backend.dialect - a = GraphVertex(dialect, "a", "person", - where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + a = GraphVertex( + dialect, + "a", + "person", + where=WhereClause( + dialect, + condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"), + ), + ) f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) b = GraphVertex(dialect, "b", "person") match = MatchClause(dialect, a, f, b) @@ -188,8 +228,15 @@ def test_single_hop_followers(self, postgres_backend, social_data): def test_two_hop_recommendation(self, postgres_backend, social_data): """Q2: Friends of friends (two-hop).""" dialect = postgres_backend.dialect - a = GraphVertex(dialect, "a", "person", - where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + a = GraphVertex( + dialect, + "a", + "person", + where=WhereClause( + dialect, + condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"), + ), + ) f1 = GraphEdge(dialect, "f1", "follows", GraphEdgeDirection.RIGHT) b = GraphVertex(dialect, "b", "person") f2 = GraphEdge(dialect, "f2", "follows", GraphEdgeDirection.RIGHT) @@ -211,13 +258,20 @@ def test_two_hop_recommendation(self, postgres_backend, social_data): def test_likes_on_posts(self, postgres_backend, social_data): """Q3: Who liked Alice's posts?""" dialect = postgres_backend.dialect - a = GraphVertex(dialect, "a", "person", - where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + a = GraphVertex( + dialect, + "a", + "person", + where=WhereClause( + dialect, + condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"), + ), + ) p = GraphEdge(dialect, "p", "authored", GraphEdgeDirection.RIGHT) post = GraphVertex(dialect, "post", "post") - l = GraphEdge(dialect, "l", "likes", GraphEdgeDirection.LEFT) + like_edge = GraphEdge(dialect, "l", "likes", GraphEdgeDirection.LEFT) liker = GraphVertex(dialect, "liker", "person") - match = MatchClause(dialect, a, p, post, l, liker) + match = MatchClause(dialect, a, p, post, like_edge, liker) cols = ColumnsClause(dialect, GraphColumn("liker", "name", "liker_name")) gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") @@ -236,9 +290,9 @@ def test_graph_table_with_group_by(self, postgres_backend, social_data): a = GraphVertex(dialect, "a", "person") p = GraphEdge(dialect, "p", "authored", GraphEdgeDirection.RIGHT) post = GraphVertex(dialect, "post", "post") - l = GraphEdge(dialect, "l", "likes", GraphEdgeDirection.LEFT) + like_edge = GraphEdge(dialect, "l", "likes", GraphEdgeDirection.LEFT) liker = GraphVertex(dialect, "liker", "person") - match = MatchClause(dialect, a, p, post, l, liker) + match = MatchClause(dialect, a, p, post, like_edge, liker) cols = ColumnsClause(dialect, GraphColumn("a", "name", "author"), GraphColumn("liker", "name", "liker_name")) @@ -294,8 +348,15 @@ def test_graph_table_combined_with_regular_sql(self, postgres_backend, social_da def test_parameter_binding(self, postgres_backend, social_data): """Parameter binding via expression system.""" dialect = postgres_backend.dialect - a = GraphVertex(dialect, "a", "person", - where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + a = GraphVertex( + dialect, + "a", + "person", + where=WhereClause( + dialect, + condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"), + ), + ) f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) b = GraphVertex(dialect, "b", "person") match = MatchClause(dialect, a, f, b) @@ -374,8 +435,15 @@ async def async_social_data(self, async_postgres_backend): @pytest.mark.asyncio async def test_async_single_hop(self, async_postgres_backend, async_social_data): dialect = async_postgres_backend.dialect - a = GraphVertex(dialect, "a", "person", - where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + a = GraphVertex( + dialect, + "a", + "person", + where=WhereClause( + dialect, + condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"), + ), + ) f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) b = GraphVertex(dialect, "b", "person") match = MatchClause(dialect, a, f, b) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py index ae7ae482..e2632a58 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py @@ -40,7 +40,6 @@ class Author(ActiveRecord): __table_name__ = "authors" __schema_name__ = RISK_SCHEMA - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -51,7 +50,6 @@ class Author(ActiveRecord): class Book(ActiveRecord): __table_name__ = "books" __schema_name__ = RISK_SCHEMA - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py index dcced9f6..409aaf45 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py @@ -1,3 +1,13 @@ +from unittest.mock import patch + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import ( + ColumnDefinition, + CreateTableExpression, +) +from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.mixins.ddl_table import PostgresTableMixin @@ -56,3 +66,50 @@ def test_supports_table_inheritance_direct(self): def test_supports_table_tablespace_direct(self): assert self._TableMixin().supports_table_tablespace() is True + + +class TestPostgresTableDDLDeclarations: + @staticmethod + def _expression(dialect, table="child", *, inherits=None, tablespace=None): + return CreateTableExpression( + dialect, + table, + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + inherits=inherits, + tablespace=tablespace, + ) + + def test_table_declaration_defaults_are_absent(self): + expression = self._expression(PostgresDialect(version=(16, 0, 0))) + assert expression.inherits == [] + assert expression.tablespace is None + + def test_table_inherits_is_carried_and_rendered(self): + dialect = PostgresDialect(version=(16, 0, 0)) + expression = self._expression( + dialect, inherits=["parent_a", "parent_b"] + ) + assert expression.inherits == ["parent_a", "parent_b"] + sql, _ = expression.to_sql() + assert 'INHERITS ("parent_a", "parent_b")' in sql + + def test_table_tablespace_is_carried_and_rendered(self): + dialect = PostgresDialect(version=(16, 0, 0)) + expression = self._expression(dialect, tablespace="ts_data") + assert expression.tablespace == "ts_data" + sql, _ = expression.to_sql() + assert 'TABLESPACE "ts_data"' in sql + + def test_table_inherits_fails_fast_when_capability_disabled(self): + dialect = PostgresDialect(version=(16, 0, 0)) + with patch.object(dialect, "supports_table_inheritance", return_value=False): + with pytest.raises(UnsupportedFeatureError, match="INHERITS"): + self._expression( + dialect, inherits=["parent_a", "parent_b"] + ).to_sql() + + def test_table_tablespace_fails_fast_when_capability_disabled(self): + dialect = PostgresDialect(version=(16, 0, 0)) + with patch.object(dialect, "supports_table_tablespace", return_value=False): + with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): + self._expression(dialect, tablespace="ts_data").to_sql() 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 index 39dc4279..a9e8b113 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py @@ -1,3 +1,5 @@ +import pytest + 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 @@ -47,3 +49,55 @@ def test_async_backend_preserves_explicit_connection_config(self): assert backend.config is config assert isinstance(backend.dialect, PostgresDialect) + + def test_sync_backend_preserves_graph_feature_overrides(self): + backend = PostgresBackend( + graph_feature_overrides={"graph_match": True, "graph_table": True} + ) + + assert backend.dialect.supports_graph_match() is True + assert backend.dialect.supports_graph_table() is True + + def test_async_backend_preserves_graph_feature_overrides(self): + backend = AsyncPostgresBackend( + graph_feature_overrides={"graph_match": True, "graph_table": True} + ) + + assert backend.dialect.supports_graph_match() is True + assert backend.dialect.supports_graph_table() is True + + def test_sync_backend_preserves_overrides_during_version_replacement(self, monkeypatch): + backend = PostgresBackend( + graph_feature_overrides={"graph_match": True, "graph_table": True} + ) + backend._connection = object() + monkeypatch.setattr(backend, "get_server_version", lambda: (19, 0, 4)) + monkeypatch.setattr(backend, "_detect_extensions", lambda: {}) + + backend.introspect_and_adapt() + + assert backend.dialect.version == (19, 0, 4) + assert backend.dialect.supports_graph_match() is True + assert backend.dialect.supports_graph_table() is True + + @pytest.mark.asyncio + async def test_async_backend_preserves_overrides_during_version_replacement(self, monkeypatch): + backend = AsyncPostgresBackend( + graph_feature_overrides={"graph_match": True, "graph_table": True} + ) + backend._connection = object() + + async def get_server_version(): + return (19, 0, 4) + + async def detect_extensions(): + return {} + + monkeypatch.setattr(backend, "get_server_version", get_server_version) + monkeypatch.setattr(backend, "_detect_extensions", detect_extensions) + + await backend.introspect_and_adapt() + + assert backend.dialect.version == (19, 0, 4) + assert backend.dialect.supports_graph_match() is True + assert backend.dialect.supports_graph_table() is True diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_generated_column_capabilities.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_generated_column_capabilities.py new file mode 100644 index 00000000..ae0fe338 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_generated_column_capabilities.py @@ -0,0 +1,21 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_generated_column_capabilities.py +"""Explicit PostgreSQL generated-column capability assertions. + +The generic ``GeneratedColumnExpression``/formatter already covers these +features; this pins the capability bits and their version gate. +""" + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +def test_generated_columns_gated_on_pg12(): + assert PostgresDialect(version=(12, 0, 0)).supports_generated_columns() is True + assert PostgresDialect(version=(11, 0, 0)).supports_generated_columns() is False + + +def test_stored_generated_columns_follow_generated_columns(): + assert PostgresDialect(version=(12, 0, 0)).supports_stored_generated_columns() is True + + +def test_virtual_generated_columns_unsupported(): + assert PostgresDialect(version=(15, 0, 0)).supports_virtual_generated_columns() is False diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py index ba245139..f6fb2d27 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py @@ -11,6 +11,7 @@ import pytest import pytest_asyncio +from rhosocial.activerecord.backend.errors import IntegrityError from rhosocial.activerecord.backend.expression import ( Column, CreateIndexExpression, @@ -82,8 +83,22 @@ def _create_partitioned_parent_sql(dialect, table_name: str): dialect=dialect, table=table_name, columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition( + dialect, + "id", + BigIntType(dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), + ColumnDefinition( + dialect, + "created_at", + TimestampType(dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), ColumnDefinition(dialect, "payload", TextType(dialect=dialect)), ], partition=PartitionClause( @@ -206,10 +221,38 @@ def _create_production_parent_sql(dialect): dialect=dialect, table=PRODUCTION_PARTITION_TABLE, columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", TimestampType(precision=6, dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "tenant_id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "payload", TextType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition( + dialect, + "id", + BigIntType(dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), + ColumnDefinition( + dialect, + "created_at", + TimestampType(precision=6, dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), + ColumnDefinition( + dialect, + "tenant_id", + BigIntType(dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), + ColumnDefinition( + dialect, + "payload", + TextType(dialect=dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) + ], + ), ], table_constraints=[ TableConstraint( @@ -743,7 +786,7 @@ def test_default_partition_catches_overflow_and_blocks_conflicting_range( assert row["partition_name"] == "ar_partition_events_default" assert row["payload"] == "default-overflow" - with pytest.raises(Exception): + with pytest.raises(IntegrityError): postgres_backend.execute( *_create_range_partition_sql( dialect, @@ -961,7 +1004,7 @@ async def test_default_partition_catches_overflow_and_blocks_conflicting_range( assert row["partition_name"] == "ar_partition_events_default" assert row["payload"] == "default-overflow" - with pytest.raises(Exception): + with pytest.raises(IntegrityError): await async_postgres_backend.execute( *_create_range_partition_sql( dialect, @@ -1121,7 +1164,7 @@ def test_initial_year_partition_uses_microsecond_boundaries( ) assert [row["payload"] for row in rows] == ["year-start", "year-end"] - with pytest.raises(Exception): + with pytest.raises(IntegrityError): postgres_backend.execute( *_insert_production_events_expression( postgres_backend.dialect, @@ -1357,7 +1400,7 @@ async def test_initial_year_partition_uses_microsecond_boundaries( ) assert [row["payload"] for row in rows] == ["year-start", "year-end"] - with pytest.raises(Exception): + with pytest.raises(IntegrityError): await async_postgres_backend.execute( *_insert_production_events_expression( async_postgres_backend.dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_database_ddl.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_database_ddl.py new file mode 100644 index 00000000..ff0800b9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_database_ddl.py @@ -0,0 +1,29 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_database_ddl.py +"""Explicit PostgresDialect database DDL capability + rendering tests.""" + +from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseExpression, + DropDatabaseExpression, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +def _dialect(): + return PostgresDialect(version=(15, 0, 0)) + + +def test_database_capabilities(): + dialect = _dialect() + assert dialect.supports_create_database() is True + assert dialect.supports_drop_database() is True + + +def test_create_database_renders(): + sql, params = CreateDatabaseExpression(_dialect(), database_name="app").to_sql() + assert "CREATE DATABASE" in sql + assert params == () + + +def test_drop_database_renders(): + sql, _ = DropDatabaseExpression(_dialect(), database_name="app").to_sql() + assert "DROP DATABASE" in sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_ddl_improvements.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_ddl_improvements.py index fda7eeb0..f06ed4d7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_ddl_improvements.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_ddl_improvements.py @@ -33,9 +33,14 @@ def test_drop_view_cascade_supported(self): assert dialect.supports_cascade_view() is True def test_materialized_view_supported(self): - """PostgreSQL supports materialized views.""" - dialect = PostgresDialect() - assert dialect.supports_materialized_view() is True + """PostgreSQL supports materialized views (PG 9.3+). + + The probe is version aware, so the dialect must carry a version — + same contract as supports_table_partitioning(). + """ + assert PostgresDialect(version=(9, 3, 0)).supports_materialized_view() is True + assert PostgresDialect(version=(15, 0, 0)).supports_materialized_view() is True + assert PostgresDialect(version=(9, 2, 0)).supports_materialized_view() is False class TestPostgresColumnCapabilityGating: 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 60aeae37..3ffd1890 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 @@ -14,8 +14,9 @@ ColumnDefinition, ColumnConstraint, ColumnConstraintType, - TableConstraint, - TableConstraintType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresExcludeConstraint, ) from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.backend.expression.operators import ( @@ -100,14 +101,11 @@ def test_postgres_format_cast_expression_valid(dialect): def test_exclude_constraint_valid_using_methods(dialect): """Test valid index access methods are accepted.""" - constraint = TableConstraint( + constraint = PostgresExcludeConstraint( dialect, - constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", - dialect_options={ - "using": "gist", - "exclude_elements": [("int4range", "&&")], - }, + elements=[("int4range", "&&")], + using="gist", ) sql, params = dialect._format_exclude_constraint(constraint) @@ -124,29 +122,43 @@ def test_exclude_constraint_valid_operators(dialect): ] for elem, op in valid_ops: - constraint = TableConstraint( - dialect, - constraint_type=TableConstraintType.EXCLUDE, - name="test_exclude", - dialect_options={ - "exclude_elements": [(elem, op)], - }, - ) + constraint = PostgresExcludeConstraint( + dialect, + name="test_exclude", + elements=[(elem, op)], + ) sql, params = dialect._format_exclude_constraint(constraint) assert f"WITH {op}" in sql +def test_exclude_constraint_rejects_gin(dialect): + constraint = PostgresExcludeConstraint( + dialect, + elements=[("range", "&&")], + using="gin", + ) + with pytest.raises(ValueError, match="Invalid index access method"): + dialect._format_exclude_constraint(constraint) + + +def test_exclude_constraint_accepts_symbol_operator(dialect): + constraint = PostgresExcludeConstraint( + dialect, + elements=[("box", "|>>")], + ) + sql, params = dialect._format_exclude_constraint(constraint) + assert 'WITH |>>' in sql + assert params == () + + def test_exclude_constraint_rejects_invalid_using(dialect): """Test that invalid index access method is rejected.""" - constraint = TableConstraint( + constraint = PostgresExcludeConstraint( dialect, - constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", - dialect_options={ - "using": "invalid_method", - "exclude_elements": [("range", "&&")], - }, + elements=[("range", "&&")], + using="invalid_method", ) with pytest.raises(ValueError, match="Invalid index access method"): @@ -155,13 +167,10 @@ def test_exclude_constraint_rejects_invalid_using(dialect): def test_exclude_constraint_rejects_invalid_operator(dialect): """Test that invalid exclude operator is rejected.""" - constraint = TableConstraint( + constraint = PostgresExcludeConstraint( dialect, - constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", - dialect_options={ - "exclude_elements": [("range", "'; DROP TABLE users--")], - }, + elements=[("range", "'; DROP TABLE users--")], ) with pytest.raises(ValueError, match="Invalid exclude operator"): @@ -170,14 +179,11 @@ def test_exclude_constraint_rejects_invalid_operator(dialect): def test_exclude_constraint_sql_injection_prevention(dialect): """Test that SQL injection attempts are blocked.""" - constraint = TableConstraint( + constraint = PostgresExcludeConstraint( dialect, - constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", - dialect_options={ - "using": "gist", - "exclude_elements": [("range", "&&")], - }, + elements=[("range", "&&")], + using="gist", ) sql, params = dialect._format_exclude_constraint(constraint) @@ -187,6 +193,38 @@ def test_exclude_constraint_sql_injection_prevention(dialect): assert "/*" not in sql +def test_exclude_constraint_rejects_empty_elements(dialect): + constraint = PostgresExcludeConstraint(dialect, name="empty_exclude") + with pytest.raises(ValueError, match="at least one element"): + dialect._format_exclude_constraint(constraint) + + +def test_exclude_constraint_formats_expression_and_where(dialect): + from rhosocial.activerecord.backend.expression import Column, Literal + + where = Column(dialect, "active") == Literal(dialect, True, inline_literals=True) + constraint = PostgresExcludeConstraint( + dialect, + elements=[("range", "&&")], + where=where, + ) + + sql, params = dialect._format_exclude_constraint(constraint) + + assert sql == 'EXCLUDE USING gist ("range" WITH &&) WHERE ("active" = TRUE)' + assert params == () + + +def test_exclude_constraint_rejects_raw_where(dialect): + constraint = PostgresExcludeConstraint( + dialect, + elements=[("range", "&&")], + where="active = TRUE; DROP TABLE people", + ) + with pytest.raises(TypeError, match="where must be a SQL expression"): + dialect._format_exclude_constraint(constraint) + + class TestPostgresEnumSecurity: """Tests for CREATE TYPE ENUM value escaping (fix for SQL injection risk).""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py index 93b913ff..379709ed 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py @@ -85,6 +85,8 @@ def get_postgres_protocols(): dialect_protocols.SQLXMLQueryingSupport, dialect_protocols.CollationSupport, dialect_protocols.CTESupport, + dialect_protocols.ColumnAttributeSupport, + dialect_protocols.CommentSupport, dialect_protocols.FilterClauseSupport, dialect_protocols.WindowFunctionSupport, dialect_protocols.JSONSupport, @@ -117,6 +119,8 @@ def get_postgres_protocols(): # Generic protocols Postgres also satisfies (previously omitted). dialect_protocols.AlterTableModifierSupport, dialect_protocols.DDLTypeSupport, + dialect_protocols.UserDefinedTypeSupport, + dialect_protocols.DomainSupport, dialect_protocols.GraphTableSupport, dialect_protocols.ILIKESupport, dialect_protocols.TriggerSupport, @@ -201,6 +205,8 @@ def test_does_not_implement_protocol(self, dialect, protocol): def test_positive_and_negative_lists_partition_all_protocols(self): """Every generic protocol must be classified for Postgres.""" all_protos = set(get_all_generic_protocols()) + if dialect_protocols.DDLTypeSupport is dialect_protocols.DataTypeSupport: + all_protos.discard("DDLTypeSupport") positive = {p.__name__ for p in POSTGRES_PROTOCOLS if p.__module__ == dialect_protocols.__name__} negative = {p.__name__ for p in POSTGRES_NOT_IMPLEMENTED} @@ -241,6 +247,10 @@ def test_no_interface_overlap_between_protocols(self): ('PostgresLockingSupport', 'LockingSupport'), ('TriggerSupport', 'PostgresTriggerSupport'), ('PostgresTriggerSupport', 'TriggerSupport'), + ('UserDefinedTypeSupport', 'PostgresTypeSupport'), + ('PostgresTypeSupport', 'UserDefinedTypeSupport'), + ('DomainSupport', 'PostgresDomainSupport'), + ('PostgresDomainSupport', 'DomainSupport'), # SQLXMLSupport aggregates standard SQL/XML capability-family protocols ('SQLXMLSupport', 'SQLXMLParsingSupport'), ('SQLXMLParsingSupport', 'SQLXMLSupport'), @@ -330,6 +340,9 @@ def test_no_interface_overlap_between_protocols(self): # Generic generated-column capability also declared by PG features protocol ('GeneratedColumnSupport', 'PostgresFeaturesSupport'), ('PostgresFeaturesSupport', 'GeneratedColumnSupport'), + # Generic COMMENT ON capability restated by the PG comment protocol + ('CommentSupport', 'PostgresCommentSupport'), + ('PostgresCommentSupport', 'CommentSupport'), } violations = [] @@ -360,6 +373,8 @@ class TestPostgresProtocolDerivation: ("PostgresLockingSupport", "LockingSupport"), ("PostgresTriggerSupport", "TriggerSupport"), ("PostgresConstraintSupport", "ConstraintSupport"), + ("PostgresTypeSupport", "UserDefinedTypeSupport"), + ("PostgresDomainSupport", "DomainSupport"), ] @pytest.mark.parametrize("pg_name,generic_name", PROTOCOL_DERIVATIONS) @@ -394,12 +409,12 @@ class TestPostgresExpressionDialectSeparation: EXPRESSION_DIALECT_PAIRS = [ ("PostgresRefreshMaterializedViewExpression", "format_refresh_materialized_view_pg_statement"), - ("PostgresCreateEnumTypeExpression", "format_create_enum_type"), - ("PostgresDropEnumTypeExpression", "format_drop_enum_type"), - ("PostgresAlterEnumAddValueExpression", "format_alter_enum_add_value"), - ("PostgresAlterEnumTypeAddValueExpression", "format_alter_enum_type_add_value"), - ("PostgresAlterEnumTypeRenameValueExpression", "format_alter_enum_type_rename_value"), - ("PostgresCreateRangeTypeExpression", "format_create_range_type"), + ("PostgresCreateEnumTypeExpression", "format_create_type_statement"), + ("PostgresDropEnumTypeExpression", "format_drop_type_statement"), + ("PostgresAlterEnumAddValueExpression", "format_alter_type_statement"), + ("PostgresAlterEnumTypeAddValueExpression", "format_alter_type_statement"), + ("PostgresAlterEnumTypeRenameValueExpression", "format_alter_type_statement"), + ("PostgresCreateRangeTypeExpression", "format_create_type_statement"), ("PostgresVacuumExpression", "format_vacuum_statement"), ("PostgresAnalyzeExpression", "format_analyze_statement"), ("PostgresReindexExpression", "format_reindex_statement"), @@ -416,7 +431,11 @@ class TestPostgresExpressionDialectSeparation: ("PostgresPartitionMetadataExpression", "format_partition_metadata_query"), ("PostgresCreateStatisticsExpression", "format_create_statistics_statement"), ("PostgresDropStatisticsExpression", "format_drop_statistics_statement"), - ("PostgresAlterIndexExpression", "format_alter_index_statement"), + ("PostgresAlterIndexExpression", "format_alter_index_statement"), + ("PostgresCopyToExpression", "format_copy_to_statement"), + ("PostgresCopyFromExpression", "format_copy_from_statement"), + ("PostgresRepackExpression", "format_repack_statement"), + ] @pytest.mark.parametrize("expr_name,format_method", EXPRESSION_DIALECT_PAIRS) @@ -462,9 +481,14 @@ def test_expression_delegates_to_dialect(self, expr_name, format_method): (postgres_protocols.PostgresTriggerSupport, postgres_mixins.PostgresTriggerMixin), (postgres_protocols.PostgresCommentSupport, postgres_mixins.PostgresCommentMixin), (postgres_protocols.PostgresTypeSupport, postgres_mixins.PostgresTypeMixin), - (postgres_protocols.PostgresConstraintSupport, postgres_mixins.PostgresConstraintMixin), + (postgres_protocols.PostgresDomainSupport, postgres_mixins.PostgresDomainMixin), + (postgres_protocols.PostgresConstraintSupport, postgres_mixins.PostgresConstraintMixin), + (postgres_protocols.PostgresRepackSupport, postgres_mixins.PostgresRepackMixin), + # DML protocols - (postgres_protocols.PostgresVacuumSupport, postgres_mixins.PostgresVacuumMixin), + (postgres_protocols.PostgresVacuumSupport, postgres_mixins.PostgresVacuumMixin), + (postgres_protocols.PostgresCopySupport, postgres_mixins.PostgresCopyMixin), + (postgres_protocols.PostgresStoredProcedureSupport, postgres_mixins.PostgresStoredProcedureMixin), (postgres_protocols.PostgresExtendedStatisticsSupport, postgres_mixins.PostgresExtendedStatisticsMixin), (postgres_protocols.PostgresAdvisoryLockSupport, postgres_mixins.PostgresAdvisoryLockMixin), diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py index 4ecf2bd8..d3b9c99b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py @@ -14,80 +14,31 @@ BigIntType, BlobType, BooleanType, - CharType, DataType, DecimalType, - DoubleType, FloatType, IntegerType, - IntType, JsonBType, JsonType, - RealType, SmallIntType, TextType, TimeType, TimestampType, - TinyIntType, VarCharType, ) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression.types import ( - PostgresArrayType, - PostgresBigSerialType, PostgresBitType, - PostgresBoxType, PostgresByteaType, - PostgresCIDType, - PostgresCharacterVaryingType, - PostgresCidrType, - PostgresCircleType, - PostgresCitextType, - PostgresCubeType, - PostgresDateMultirangeType, - PostgresDateRangeType, - PostgresGeographyType, - PostgresGeometryType, PostgresHalfvecType, - PostgresHstoreType, PostgresInetType, - PostgresInt4MultirangeType, - PostgresInt4RangeType, - PostgresInt8MultirangeType, - PostgresInt8RangeType, PostgresJsonPathType, - PostgresLineSegmentType, - PostgresLineType, - PostgresLtreeType, - PostgresMacAddr8Type, - PostgresMacAddrType, - PostgresMoneyType, - PostgresNumMultirangeType, - PostgresNumRangeType, - PostgresOIDType, - PostgresPathType, - PostgresPgLSNType, PostgresPointType, - PostgresPolygonType, - PostgresRasterType, - PostgresRegClassType, - PostgresRegTypeType, PostgresSerialType, - PostgresSmallSerialType, PostgresSparsevecType, - PostgresTIDType, - PostgresTSQueryType, - PostgresTSVectorType, - PostgresTsMultirangeType, PostgresTsRangeType, - PostgresTsTzMultirangeType, - PostgresTsTzRangeType, PostgresUUIDType, - PostgresVarBitType, PostgresVectorType, - PostgresXID8Type, - PostgresXIDType, - PostgresXMLType, ) @@ -104,13 +55,13 @@ def dialect(): def test_format_data_type_postgres_enum_exists(dialect): """format_data_type_postgres_enum method exists on the dialect.""" assert hasattr(dialect, "format_data_type_postgres_enum") - assert callable(getattr(dialect, "format_data_type_postgres_enum")) + assert callable(dialect.format_data_type_postgres_enum) def test_supports_data_type_postgres_enum_exists(dialect): """supports_data_type_postgres_enum method exists on the dialect.""" assert hasattr(dialect, "supports_data_type_postgres_enum") - assert callable(getattr(dialect, "supports_data_type_postgres_enum")) + assert callable(dialect.supports_data_type_postgres_enum) assert dialect.supports_data_type_postgres_enum() is True @@ -124,7 +75,7 @@ def test_postgres_enum_expression_renders_via_format_enum_type_expression(dialec values=["pending", "processing", "ready"], ) sql, params = dialect.format_enum_type_expression(enum_ref) - assert sql == "video_status" + assert sql == '"video_status"' assert params == () @@ -138,7 +89,7 @@ def test_postgres_enum_expression_with_schema_renders_via_dialect(dialect): schema="app", ) sql, params = dialect.format_enum_type_expression(enum_ref) - assert sql == "app.video_status" + assert sql == '"app"."video_status"' assert params == () @@ -278,63 +229,23 @@ def test_suggested_data_types_keys_disjoint_from_supported(dialect): # --------------------------------------------------------------------------- -# W1: dialect_options forwarding and equality +# W1: type-param equality (dialect_options bag removed) # --------------------------------------------------------------------------- -def test_bit_type_dialect_options_forwarded(): - t1 = PostgresBitType(n=8, dialect_options={"flag": True}) - t2 = PostgresBitType(n=8, dialect_options={"flag": True}) - t3 = PostgresBitType(n=8, dialect_options={"flag": False}) - t4 = PostgresBitType(n=8) +def test_type_constructor_rejects_dialect_options(): + with pytest.raises(TypeError): + PostgresBitType(n=8, dialect_options={"flag": True}) - # Same options → equal - assert t1 == t2 - assert hash(t1) == hash(t2) - - # Different options → not equal - assert t1 != t3 - assert t1 != t4 - - -def test_vector_type_dialect_options_forwarded(): - t1 = PostgresVectorType(dim=384, dialect_options={"metric": "cosine"}) - t2 = PostgresVectorType(dim=384, dialect_options={"metric": "cosine"}) - t3 = PostgresVectorType(dim=384, dialect_options={"metric": "l2"}) - - assert t1 == t2 - assert hash(t1) == hash(t2) - assert t1 != t3 -def test_halfvec_type_dialect_options_forwarded(): - t1 = PostgresHalfvecType(dim=128, dialect_options={"quantize": True}) - t2 = PostgresHalfvecType(dim=128, dialect_options={"quantize": True}) - t3 = PostgresHalfvecType(dim=128) - assert t1 == t2 - assert hash(t1) == hash(t2) - assert t1 != t3 -def test_sparsevec_type_dialect_options_forwarded(): - t1 = PostgresSparsevecType(dim=16, dialect_options={"sparse": True}) - t2 = PostgresSparsevecType(dim=16, dialect_options={"sparse": True}) - t3 = PostgresSparsevecType(dim=16) - assert t1 == t2 - assert hash(t1) == hash(t2) - assert t1 != t3 -def test_varbit_type_dialect_options_forwarded(): - t1 = PostgresVarBitType(n=16, dialect_options={"strict": True}) - t2 = PostgresVarBitType(n=16, dialect_options={"strict": True}) - t3 = PostgresVarBitType(n=16) - assert t1 == t2 - assert hash(t1) == hash(t2) - assert t1 != t3 # --------------------------------------------------------------------------- diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py index 5c05e000..34d7f6f9 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py @@ -53,7 +53,6 @@ class PostgresMixedAnnotationModel(ActiveRecord): - metadata: Uses JSONB (adapter handles dict <-> str) """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[Optional[int], UseColumn("id")] = None @@ -68,7 +67,6 @@ class PostgresMixedAnnotationModel(ActiveRecord): class AsyncPostgresMixedAnnotationModel(AsyncActiveRecord): """Async version of PostgresMixedAnnotationModel.""" __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py310.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py310.py index a6c6df92..900034bb 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py310.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py310.py @@ -338,7 +338,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -360,7 +359,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -383,7 +381,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "users" - __primary_key__ = "id" # Python field: user_id, Database column: id user_id: Annotated[int | None, UseColumn("id")] = None @@ -404,7 +401,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: user_id, Database column: id @@ -426,7 +422,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "posts" - __primary_key__ = "id" # Python field: post_id, Database column: id post_id: Annotated[int | None, UseColumn("id")] = None @@ -453,7 +448,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: post_id, Database column: id @@ -481,7 +475,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "comments" - __primary_key__ = "id" # Python field: comment_id, Database column: id comment_id: Annotated[int | None, UseColumn("id")] = None @@ -508,7 +501,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: comment_id, Database column: id @@ -551,7 +543,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -567,7 +558,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. @@ -647,7 +637,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" # 1. Standard Python type name: str @@ -674,7 +663,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # 1. Standard Python type @@ -750,7 +738,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -760,7 +747,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py311.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py311.py index f912576e..c348093f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py311.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py311.py @@ -385,7 +385,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -404,7 +403,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -424,7 +422,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -443,7 +440,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -458,7 +454,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -474,7 +469,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -491,7 +485,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -507,7 +500,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -538,7 +530,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -552,7 +543,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -631,7 +621,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[int, UseColumn("id")] @@ -656,7 +645,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -721,7 +709,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -731,7 +718,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py312.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py312.py index c0178057..d0e8ce20 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py312.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py312.py @@ -372,7 +372,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -391,7 +390,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -411,7 +409,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -430,7 +427,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -445,7 +441,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -461,7 +456,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -478,7 +472,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -494,7 +487,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -525,7 +517,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -539,7 +530,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -618,7 +608,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[int, UseColumn("id")] @@ -640,7 +629,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -755,7 +743,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -765,7 +752,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None