Skip to content

Commit 3edb307

Browse files
committed
feat(backend): add PostgreSQL DDL coverage-completion expressions and CREATE UNLOGGED TABLE (#53)
* feat(backend): add coverage-gap DDL expressions for PostgreSQL Add expression/mixin/protocol triads covering the remaining PostgreSQL DDL coverage gaps, each version-gated and exposed through a @runtime_checkable Protocol: - RLS table configuration (ENABLE/DISABLE/ALWAYS/FORCE) — PG 9.5+ - ALTER TABLE SET LOGGED/UNLOGGED (9.6+) and SET ACCESS METHOD (15+) - CLUSTER (index re-cluster) — 9.6+ - CREATE/ALTER/DROP DOMAIN (9.6+; ALTER supports SET DEFAULT/RENAME NOT NULL) - CREATE/DROP COLLATION objects (9.6+) - CREATE/DROP FOREIGN TABLE (9.6+) - CREATE/DROP FUNCTION and CREATE/DROP AGGREGATE (9.6+) - CREATE/DROP PUBLICATION / SUBSCRIPTION (10+) PostgresAlterColumnModifierMixin.format_alter_column_action now renders the ALTER COLUMN ... SET DATA TYPE ... USING (expr) conversion (opt-in via dialect_options['using']), raising ValueError when used on non-type ops. PostgreSQL extensions only — core rhosocial-activerecord untouched. Adds pure-SQL unit tests (no DB) for capability switches, protocol conformance, and SQL generation. Closes the plan at .claude/plan/2026-08-07 postgres-coverage-gap-analysis.md. * docs(backend): add JSONExpression type hints to enhanced JSONB mixin and protocol * feat(backend): complete DDL coverage gaps RENAME and CREATE UNLOGGED TABLE Plan 4.6/4.7 closure: - CREATE UNLOGGED TABLE via dialect_options={'unlogged_table': True} on CreateTableExpression, version-gated at 9.5+, rendering through PostgresTableMixin.format_create_table_statement; TEMPORARY wins over UNLOGGED. Exposes supports_unlogged_table on PostgresTableSupport. - RENAME COLUMN / RENAME TABLE verified through core actions and covered by tests (already supported by core DDL; no new mixin needed). Tests: - Extend test_alter_table_if_exists.py with ALTER COLUMN SET DATA TYPE [USING] and RENAME subclause coverage plus CREATE UNLOGGED TABLE. - Add plan §6.3 version-baseline sweep at PG 9.6/10/11/14/15/17/19 with UnsupportedFeatureError gates. * test(backend): split DDL expression tests and close DDL mixin coverage gaps * chore(backend): clean up unused imports and long-line style in DDL code paths Static-check pass over source and tests touched by the DDL coverage work: - src: drop unused imports (types, ddl/__init__, named_expressions/partition), fix E501 in examples, restore noqa where intentional - tests: remove unused imports, add E402 noqa to late imports, drop unused backend binding var No behavioral change.; src and changed tests pass ruff.
1 parent e47bc78 commit 3edb307

61 files changed

Lines changed: 4321 additions & 47 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎changelog.d/53.added.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added PostgreSQL coverage-completion DDL expressions and dialect support. New expression/mixin/protocol triads in the postgres backend package cover: `ALTER TABLE ... ENABLE/DISABLE [ALWAYS] ROW LEVEL SECURITY` and `FORCE/NO FORCE ROW LEVEL SECURITY` (`PostgresAlterTableRlsExpression`, `PostgresForceRlsExpression`; PG 9.5+); `ALTER TABLE ... SET LOGGED/UNLOGGED` (PG 9.6+) and `SET ACCESS METHOD` (PG 15+); `CLUSTER` (PG 9.6+, index re-cluster only); `CREATE/ALTER/DROP DOMAIN` (`PostgresAlterDomainExpression` supports SET DEFAULT / RENAME); `CREATE/DROP COLLATION` objects; `CREATE/DROP FOREIGN TABLE`; `CREATE/DROP FUNCTION` and `CREATE/DROP AGGREGATE` (PG 9.6+); and `CREATE/DROP PUBLICATION` / `CREATE/DROP SUBSCRIPTION` (PG 10+). Additionally `PostgresAlterColumnModifierMixin.format_alter_column_action` now renders PostgreSQL's `ALTER COLUMN ... SET DATA TYPE ... USING (expr)` conversion expression (opt-in via `dialect_options={"using": ...}`; rejected on non-type operations). Each feature has a version-gated capability switch and a matching `@runtime_checkable` Protocol (`PostgresRlsConfigSupport`, `PostgresAlterTableSettingsSupport`, `PostgresClusterSupport`, `PostgresDomainSupport`, `PostgresCollationDDLSupport`, `PostgresForeignTableDDLSupport`, `PostgresRoutineDDLSupport`, `PostgresPublicationSupport`). All are PostgreSQL extensions implemented only in the postgres backend package — the core library is untouched.

‎src/rhosocial/activerecord/backend/impl/postgres/dialect.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,14 @@
163163
PostgresTypeMixin,
164164
PostgresConstraintMixin,
165165
PostgresPolicyMixin,
166+
PostgresRlsConfigMixin,
167+
PostgresAlterTableSettingsMixin,
168+
PostgresClusterMixin,
169+
PostgresDomainMixin,
170+
PostgresCollationDDLMixin,
171+
PostgresForeignTableMixin,
172+
PostgresRoutineMixin,
173+
PostgresPublicationMixin,
166174
# Type mixins
167175
EnumTypeMixin,
168176
TypesDataTypeMixin,
@@ -254,6 +262,14 @@
254262
PostgresTypeSupport,
255263
PostgresConstraintSupport,
256264
PostgresPolicySupport,
265+
PostgresRlsConfigSupport,
266+
PostgresAlterTableSettingsSupport,
267+
PostgresClusterSupport,
268+
PostgresDomainSupport,
269+
PostgresCollationDDLSupport,
270+
PostgresForeignTableDDLSupport,
271+
PostgresRoutineDDLSupport,
272+
PostgresPublicationSupport,
257273
# Type feature protocols
258274
PostgresMultirangeSupport,
259275
PostgresEnumTypeSupport,
@@ -400,6 +416,14 @@ class PostgresDialect(
400416
PostgresTypeMixin,
401417
PostgresConstraintMixin,
402418
PostgresPolicyMixin,
419+
PostgresRlsConfigMixin,
420+
PostgresAlterTableSettingsMixin,
421+
PostgresClusterMixin,
422+
PostgresDomainMixin,
423+
PostgresCollationDDLMixin,
424+
PostgresForeignTableMixin,
425+
PostgresRoutineMixin,
426+
PostgresPublicationMixin,
403427
# Type mixins
404428
EnumTypeMixin,
405429
TypesDataTypeMixin,
@@ -526,6 +550,14 @@ class PostgresDialect(
526550
PostgresTypeSupport,
527551
PostgresConstraintSupport,
528552
PostgresPolicySupport,
553+
PostgresRlsConfigSupport,
554+
PostgresAlterTableSettingsSupport,
555+
PostgresClusterSupport,
556+
PostgresDomainSupport,
557+
PostgresCollationDDLSupport,
558+
PostgresForeignTableDDLSupport,
559+
PostgresRoutineDDLSupport,
560+
PostgresPublicationSupport,
529561
# Type feature protocols
530562
PostgresMultirangeSupport,
531563
PostgresEnumTypeSupport,

‎src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@
149149

150150
# Verify table structure
151151
from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression
152-
from rhosocial.activerecord.backend.expression.core import WildcardExpression, Column
152+
from rhosocial.activerecord.backend.expression.core import Column
153153
from rhosocial.activerecord.backend.expression.query_parts import OrderByClause
154154

155155
verify_expr = QueryExpression(

‎src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@
156156
},
157157
)
158158
sql, params = create_mv.to_sql()
159-
print(f"Create MV with column aliases and storage options:")
159+
print("Create MV with column aliases and storage options:")
160160
print(f" SQL: {sql}")
161161
backend.execute(sql, params)
162162

‎src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
ColumnConstraint, ColumnConstraintType,
2020
)
2121
from rhosocial.activerecord.backend.expression.types import (
22-
VarCharType, ArrayType, IntegerType, TextType,
22+
VarCharType, ArrayType, TextType,
2323
)
2424
from rhosocial.activerecord.backend.impl.postgres.expression.types import (
2525
PostgresSerialType,

‎src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@
107107
assert col_b.data_type.__class__.__name__ == col_a.data_type.__class__.__name__, (
108108
f"type mismatch in {name}.{col_b.name}"
109109
)
110-
print(f"Table data roundtrip: all columns and types match")
110+
print("Table data roundtrip: all columns and types match")
111111
print(f"Added tables: {diff.added_tables}")
112112
print(f"Removed tables: {diff.removed_tables}")
113113
print(f"Modified tables: {diff.modified_tables}")

‎src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
- type.py - Enum/Range type expressions
1313
- extension.py - Extension DDL expressions
1414
- policy.py - Row-Level Security POLICY expressions (CREATE/ALTER/DROP)
15+
- rls_config.py - Row-Level Security table configuration (ENABLE/DISABLE/FORCE)
16+
- table_settings.py - ALTER TABLE SET LOGGED/UNLOGGED/ACCESS METHOD
17+
- cluster.py - CLUSTER expressions
18+
- domain.py - CREATE/ALTER/DROP DOMAIN expressions
19+
- collation.py - CREATE/DROP COLLATION object expressions
20+
- foreign_table.py - CREATE/DROP FOREIGN TABLE expressions
21+
- routine.py - CREATE/DROP FUNCTION / AGGREGATE expressions
22+
- publication.py - CREATE/DROP PUBLICATION / SUBSCRIPTION expressions
1523
1624
Missing expressions — why?
1725
============================
@@ -93,6 +101,42 @@
93101
PostgresAlterPolicyExpression,
94102
PostgresDropPolicyExpression,
95103
)
104+
from .rls_config import (
105+
RlsConfigurationMode,
106+
PostgresAlterTableRlsExpression,
107+
PostgresForceRlsExpression,
108+
)
109+
from .table_settings import (
110+
LoggingMode,
111+
PostgresAlterTableSettingsExpression,
112+
)
113+
from .cluster import PostgresClusterExpression
114+
from .domain import (
115+
AlterDomainActionType,
116+
PostgresCreateDomainExpression,
117+
PostgresAlterDomainExpression,
118+
PostgresDropDomainExpression,
119+
)
120+
from .collation import (
121+
PostgresCreateCollationExpression,
122+
PostgresDropCollationExpression,
123+
)
124+
from .foreign_table import (
125+
PostgresCreateForeignTableExpression,
126+
PostgresDropForeignTableExpression,
127+
)
128+
from .routine import (
129+
PostgresCreateFunctionExpression,
130+
PostgresDropFunctionExpression,
131+
PostgresCreateAggregateExpression,
132+
PostgresDropAggregateExpression,
133+
)
134+
from .publication import (
135+
PostgresCreatePublicationExpression,
136+
PostgresDropPublicationExpression,
137+
PostgresCreateSubscriptionExpression,
138+
PostgresDropSubscriptionExpression,
139+
)
96140

97141
__all__ = [
98142
# vacuum
@@ -136,4 +180,34 @@
136180
"PostgresCreatePolicyExpression",
137181
"PostgresAlterPolicyExpression",
138182
"PostgresDropPolicyExpression",
183+
# rls_config
184+
"RlsConfigurationMode",
185+
"PostgresAlterTableRlsExpression",
186+
"PostgresForceRlsExpression",
187+
# table_settings
188+
"LoggingMode",
189+
"PostgresAlterTableSettingsExpression",
190+
# cluster
191+
"PostgresClusterExpression",
192+
# domain
193+
"AlterDomainActionType",
194+
"PostgresCreateDomainExpression",
195+
"PostgresAlterDomainExpression",
196+
"PostgresDropDomainExpression",
197+
# collation
198+
"PostgresCreateCollationExpression",
199+
"PostgresDropCollationExpression",
200+
# foreign_table
201+
"PostgresCreateForeignTableExpression",
202+
"PostgresDropForeignTableExpression",
203+
# routine
204+
"PostgresCreateFunctionExpression",
205+
"PostgresDropFunctionExpression",
206+
"PostgresCreateAggregateExpression",
207+
"PostgresDropAggregateExpression",
208+
# publication
209+
"PostgresCreatePublicationExpression",
210+
"PostgresDropPublicationExpression",
211+
"PostgresCreateSubscriptionExpression",
212+
"PostgresDropSubscriptionExpression",
139213
]
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py
2+
"""
3+
PostgreSQL DDL expressions: CLUSTER.
4+
5+
PostgreSQL Documentation:
6+
- CLUSTER: https://www.postgresql.org/docs/current/sql-cluster.html
7+
8+
Version Requirements:
9+
- CLUSTER table [ USING index ]: PostgreSQL 9.6+ (all supported versions)
10+
- VERBOSE option: PostgreSQL 9.6+ (all supported versions)
11+
"""
12+
13+
from typing import Optional, Tuple, TYPE_CHECKING
14+
15+
from rhosocial.activerecord.backend.expression.bases import BaseExpression
16+
17+
if TYPE_CHECKING:
18+
from rhosocial.activerecord.backend.dialect import SQLDialectBase
19+
20+
21+
__all__ = ["PostgresClusterExpression"]
22+
23+
24+
class PostgresClusterExpression(BaseExpression):
25+
"""PostgreSQL CLUSTER statement expression.
26+
27+
Physically reorders a table (or all tables) to match the order of the
28+
index used for clustering. Commonly run inside a transaction after a
29+
bulk load.
30+
31+
Attributes:
32+
table_name: Name of the table to cluster. If None, clusters all
33+
tables that have previously been clustered (``CLUSTER`` alone).
34+
schema: Optional schema for the table.
35+
using_index: Optional index name for ``CLUSTER table USING index``.
36+
verbose: When True, report the table being clustered (``VERBOSE``).
37+
38+
Example:
39+
>>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect
40+
>>> dialect = PostgresDialect((14, 0, 0))
41+
>>> expr = PostgresClusterExpression(
42+
... dialect, table_name="orders", using_index="orders_pkey"
43+
... )
44+
>>> sql, params = expr.to_sql() # doctest: +SKIP
45+
46+
"""
47+
48+
def __init__(
49+
self,
50+
dialect: "SQLDialectBase",
51+
table_name: Optional[str] = None,
52+
schema: Optional[str] = None,
53+
using_index: Optional[str] = None,
54+
verbose: bool = False,
55+
):
56+
super().__init__(dialect)
57+
self.table_name = table_name
58+
self.schema = schema
59+
self.using_index = using_index
60+
self.verbose = verbose
61+
62+
def to_sql(self) -> "Tuple[str, tuple]":
63+
"""Generate the CLUSTER statement.
64+
65+
Returns:
66+
Tuple of (SQL string, empty params tuple).
67+
68+
"""
69+
return self.dialect.format_cluster_statement(self)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py
2+
"""
3+
PostgreSQL DDL expressions: COLLATION objects.
4+
5+
PostgreSQL Documentation:
6+
- CREATE COLLATION: https://www.postgresql.org/docs/current/sql-createcollation.html
7+
- ALTER COLLATION: https://www.postgresql.org/docs/current/sql-altercollation.html
8+
- DROP COLLATION: https://www.postgresql.org/docs/current/sql-dropcollation.html
9+
10+
Version Requirements:
11+
- CREATE/DROP COLLATION: PostgreSQL 9.6+ (all supported versions)
12+
"""
13+
14+
from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING
15+
16+
from rhosocial.activerecord.backend.expression.bases import BaseExpression
17+
18+
if TYPE_CHECKING:
19+
from rhosocial.activerecord.backend.dialect import SQLDialectBase
20+
21+
22+
__all__ = ["PostgresCreateCollationExpression", "PostgresDropCollationExpression"]
23+
24+
25+
class PostgresCreateCollationExpression(BaseExpression):
26+
"""PostgreSQL CREATE COLLATION statement expression.
27+
28+
Defines a new collation object, typically from an OS locale.
29+
30+
Attributes:
31+
name: Name of the collation.
32+
schema: Optional schema for the collation.
33+
locale: Optional ``LOCALE`` (provider-agnostic shorthand).
34+
lc_collate: Optional ``LC_COLLATE``.
35+
lc_ctype: Optional ``LC_CTYPE``.
36+
provider: Optional ``PROVIDER`` (e.g. ``libc`` or ``icu``).
37+
version: Optional ``VERSION``.
38+
if_not_exists: When True, add ``IF NOT EXISTS``.
39+
dialect_options: Reserved.
40+
41+
"""
42+
43+
def __init__(
44+
self,
45+
dialect: "SQLDialectBase",
46+
name: str,
47+
schema: Optional[str] = None,
48+
locale: Optional[str] = None,
49+
lc_collate: Optional[str] = None,
50+
lc_ctype: Optional[str] = None,
51+
provider: Optional[str] = None,
52+
version: Optional[str] = None,
53+
if_not_exists: bool = False,
54+
dialect_options: Optional[Dict[str, Any]] = None,
55+
):
56+
super().__init__(dialect)
57+
self.name = name
58+
self.schema = schema
59+
self.locale = locale
60+
self.lc_collate = lc_collate
61+
self.lc_ctype = lc_ctype
62+
self.provider = provider
63+
self.version = version
64+
self.if_not_exists = if_not_exists
65+
self.dialect_options = dialect_options or {}
66+
67+
def to_sql(self) -> "Tuple[str, tuple]":
68+
"""Generate the CREATE COLLATION statement.
69+
70+
Returns:
71+
Tuple of (SQL string, empty params tuple).
72+
73+
"""
74+
return self.dialect.format_create_collation_ddl_statement(self)
75+
76+
77+
class PostgresDropCollationExpression(BaseExpression):
78+
"""PostgreSQL DROP COLLATION statement expression.
79+
80+
Attributes:
81+
name: Name of the collation to drop.
82+
schema: Optional schema for the collation.
83+
if_exists: When True, add ``IF EXISTS``.
84+
cascade: When True, add ``CASCADE``.
85+
restrict: When True, add ``RESTRICT``. Mutually exclusive with
86+
``cascade``.
87+
88+
"""
89+
90+
def __init__(
91+
self,
92+
dialect: "SQLDialectBase",
93+
name: str,
94+
schema: Optional[str] = None,
95+
if_exists: bool = False,
96+
cascade: bool = False,
97+
restrict: bool = False,
98+
):
99+
super().__init__(dialect)
100+
self.name = name
101+
self.schema = schema
102+
self.if_exists = if_exists
103+
self.cascade = cascade
104+
self.restrict = restrict
105+
106+
def to_sql(self) -> "Tuple[str, tuple]":
107+
"""Return the DROP COLLATION statement.
108+
109+
Returns:
110+
Tuple of (SQL string, empty params tuple).
111+
112+
"""
113+
return self.dialect.format_drop_collation_ddl_statement(self)

0 commit comments

Comments
 (0)