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