Skip to content

Commit 70aa6ab

Browse files
authored
feat(sqlserver): partition maintenance and DDL helpers (#13)
* feat(sqlserver): partition maintenance and DDL on the core report contract * ci: test against core feature/partition-maintenance * chore: add changelog fragment for PR #13 * test(sqlserver): isolate live partition fixtures per test
1 parent 9c9a249 commit 70aa6ab

16 files changed

Lines changed: 5412 additions & 32 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ jobs:
5454
5555
- name: Install activerecord and testsuite from PyPI
5656
run: |
57-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
57+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
5858
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
5959
6060
- name: Install dependencies
@@ -144,7 +144,7 @@ jobs:
144144
145145
- name: Install activerecord and testsuite from PyPI
146146
run: |
147-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
147+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
148148
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
149149
150150
- name: Install dependencies
@@ -234,7 +234,7 @@ jobs:
234234
235235
- name: Install activerecord and testsuite from PyPI
236236
run: |
237-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
237+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
238238
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
239239
240240
- name: Install dependencies
@@ -324,7 +324,7 @@ jobs:
324324
325325
- name: Install activerecord and testsuite from PyPI
326326
run: |
327-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
327+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
328328
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
329329
330330
- name: Install dependencies
@@ -414,7 +414,7 @@ jobs:
414414
415415
- name: Install activerecord and testsuite from PyPI
416416
run: |
417-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
417+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
418418
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
419419
420420
- name: Install dependencies
@@ -504,7 +504,7 @@ jobs:
504504
505505
- name: Install activerecord and testsuite from PyPI
506506
run: |
507-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
507+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
508508
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
509509
510510
- name: Install dependencies
@@ -595,7 +595,7 @@ jobs:
595595
596596
- name: Install activerecord and testsuite from PyPI
597597
run: |
598-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
598+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
599599
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
600600
601601
- name: Install dependencies
@@ -685,7 +685,7 @@ jobs:
685685
686686
- name: Install activerecord and testsuite from PyPI
687687
run: |
688-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
688+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
689689
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
690690
691691
- name: Install dependencies
@@ -784,7 +784,7 @@ jobs:
784784
785785
- name: Install activerecord and testsuite from PyPI
786786
run: |
787-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
787+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
788788
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
789789
790790
- name: Install dependencies

‎README.md‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,20 @@ in SQL Server 2016; a server below that floor refuses the expression, and
179179
`supports_functions()["seconds_between"]` reports `False`. An `end` earlier
180180
than `start` subtracts to a negative value with no extra sign handling.
181181

182+
### Partitioned tables
183+
184+
SQL Server partitioning is a partition function plus a partition scheme plus a
185+
table built `ON <scheme>(<column>)` -- not a `PARTITION BY` clause. The DDL
186+
expressions cover `CREATE PARTITION FUNCTION` / `SCHEME`, `SPLIT RANGE`,
187+
`MERGE RANGE`, `SWITCH`, `NEXT USED` and the two drops, and the maintenance
188+
executor provides `ensure`, `list`, `retention_boundaries`, `drop_or_merge`,
189+
`drop_before` and `validate` with sync and async forms. There is no
190+
subpartitioning, and a partition is removed by merging its boundary, never by
191+
dropping it.
192+
193+
See [Table partitioning](docs/partition.md) for the model, examples and the
194+
maintenance contract.
195+
182196
## Version Gates
183197

184198
Gates below are the versions at which this backend's own `supports_*`
@@ -204,14 +218,18 @@ oldest release carry no gate and are omitted.
204218
- **No native arrays, and no JSON *type*.** JSON is reachable through
205219
functions (`JSON_VALUE`, `JSON_QUERY`) on 2016+, not through a column
206220
type, and `supports_json_type` gates on that.
207-
- **Table partitioning is metadata-only.** Partition functions,
208-
schemes and aligned indexes are introspectable through
209-
`introspect partitions` / `list_partitions`, but partition *lifecycle*
210-
(`add_partition`, `attach_partition`, `detach_partition`,
211-
`list_table_partitioning`, `range_table_partitioning`,
212-
`hash_table_partitioning`, `subpartitioning`) is not implemented: those
213-
switches are all `False` at every probed version and a model does not take
214-
a `__partition__` attribute.
221+
- **No generic partition verbs.** Table partitioning exists through
222+
partition functions and schemes (`supports_table_partitioning`,
223+
`supports_partition_function`, `supports_partition_scheme`,
224+
`supports_split_partition`, `supports_merge_partition` and
225+
`supports_switch_partition` are `True` on 2008+), but the generic
226+
`add_partition`, `drop_partition`, `attach_partition`, `detach_partition`,
227+
`truncate_partition` and `reorganize_partition` switches stay `False`:
228+
SQL Server spells those operations differently or not at all.
229+
`supports_subpartitioning()` is `False` at every version -- SQL Server has
230+
no subpartitions. A model does not take a `__partition__` attribute;
231+
partition state is managed by the backend helper in
232+
[docs/partition.md](docs/partition.md).
215233
- Also unsupported: `alter_type`, `constraint_enforced`, `create_or_replace_view`,
216234
`deferrable_*`, `fetch_with_ties`, `fk_match`, `returning_alias`,
217235
`returning_wildcard`, `set_type`, `transaction_mode`.
@@ -237,7 +255,9 @@ PYTHONPATH=tests .venv3.14-ubuntu26.04/bin/pytest \
237255

238256
[Database introspection](docs/introspection.md) states what this backend
239257
answers, where each answer comes from, and the version floors that decide
240-
whether it can be asked at all.
258+
whether it can be asked at all. [Table partitioning](docs/partition.md)
259+
describes SQL Server's function/scheme model, the DDL expressions and the
260+
maintenance helper.
241261

242262
Backend notes live in [.claude/architecture.md](.claude/architecture.md) and
243263
[.claude/testing.md](.claude/testing.md); core library documentation is at

‎changelog.d/13.added.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added SQL Server partition DDL and backend maintenance helpers, including `ensure`, `list`, `validate`, `retention_boundaries`, `drop_or_merge` and `drop_before`.

‎docs/partition.md‎

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# Partitioning on SQL Server
2+
3+
SQL Server does not spell partitioning the way PostgreSQL or MySQL do. There
4+
is no `PARTITION BY` clause and no `PARTITION OF`. A partitioned table is
5+
three catalogue objects working together:
6+
7+
```
8+
CREATE PARTITION FUNCTION pf_logs (DATE)
9+
AS RANGE RIGHT FOR VALUES ('2026-01-01', '2026-02-01');
10+
11+
CREATE PARTITION SCHEME ps_logs
12+
AS PARTITION pf_logs ALL TO ([PRIMARY]);
13+
14+
CREATE TABLE dbo.logs (
15+
id INT NOT NULL,
16+
created_at DATE NOT NULL,
17+
CONSTRAINT pk_logs PRIMARY KEY (id, created_at)
18+
) ON [ps_logs] ([created_at]);
19+
```
20+
21+
- The **function** fixes the boundary values. `N` boundaries describe
22+
`N + 1` partitions, and `RANGE RIGHT` (the default) puts the boundary value
23+
in the partition to its right.
24+
- The **scheme** maps each partition to a filegroup. `ALL TO` uses one
25+
filegroup; an explicit list needs one name per partition.
26+
- The table is built `ON <scheme> (<column>)`. The partitioning column must
27+
be part of every unique index on the table, which is why the primary key
28+
above includes `created_at`.
29+
30+
## No subpartitions
31+
32+
SQL Server has no subpartitioning at any version. `supports_subpartitioning()`
33+
returns `False`, and `SQLServerPartitionPlan` refuses a plan that asks for
34+
another strategy or a `subpartition_by` at construction:
35+
36+
```python
37+
from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError
38+
39+
SQLServerPartitionPlan(
40+
function="pf_logs",
41+
data_type="DATE",
42+
boundaries=(date(2026, 1, 1),),
43+
subpartition_by="HASH",
44+
) # raises UnsupportedFeatureError
45+
```
46+
47+
There is also no `ADD PARTITION` / `DROP PARTITION`: adding a partition means
48+
`SPLIT RANGE`, and removing one means `MERGE RANGE`. `supports_add_partition()`
49+
and `supports_drop_partition()` stay `False`; the SQL Server-specific
50+
capability flags are `supports_split_partition()`, `supports_merge_partition()`
51+
and `supports_switch_partition()`.
52+
53+
## DDL expressions
54+
55+
Each statement is an expression node; the dialect renders the text.
56+
57+
```python
58+
from datetime import date
59+
60+
from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import (
61+
SQLServerDropPartitionFunctionExpression,
62+
SQLServerDropPartitionSchemeExpression,
63+
SQLServerPartitionFunctionExpression,
64+
SQLServerPartitionFunctionMergeExpression,
65+
SQLServerPartitionFunctionSplitExpression,
66+
SQLServerPartitionSchemeExpression,
67+
SQLServerPartitionSchemeNextUsedExpression,
68+
SQLServerSwitchPartitionExpression,
69+
)
70+
71+
SQLServerPartitionFunctionExpression(
72+
dialect, "pf_logs", "DATE", [date(2026, 1, 1), date(2026, 2, 1)]
73+
).to_sql()
74+
# CREATE PARTITION FUNCTION [pf_logs] (DATE)
75+
# AS RANGE RIGHT FOR VALUES ('2026-01-01', '2026-02-01')
76+
77+
SQLServerPartitionSchemeExpression(
78+
dialect, "ps_logs", "pf_logs", [], all_filegroup="PRIMARY"
79+
).to_sql()
80+
# CREATE PARTITION SCHEME [ps_logs] AS PARTITION [pf_logs] ALL TO ([PRIMARY])
81+
82+
SQLServerPartitionFunctionSplitExpression(
83+
dialect, "pf_logs", date(2026, 1, 15)
84+
).to_sql()
85+
# ALTER PARTITION FUNCTION [pf_logs] () SPLIT RANGE ('2026-01-15')
86+
87+
SQLServerPartitionFunctionMergeExpression(
88+
dialect, "pf_logs", date(2026, 1, 15)
89+
).to_sql()
90+
# ALTER PARTITION FUNCTION [pf_logs] () MERGE RANGE ('2026-01-15')
91+
92+
SQLServerPartitionSchemeNextUsedExpression(
93+
dialect, "ps_logs", "PRIMARY"
94+
).to_sql()
95+
# ALTER PARTITION SCHEME [ps_logs] NEXT USED [PRIMARY]
96+
97+
SQLServerSwitchPartitionExpression(
98+
dialect, "logs", "logs_archive", source_partition=1
99+
).to_sql()
100+
# ALTER TABLE [logs] SWITCH PARTITION 1 TO [logs_archive]
101+
102+
SQLServerDropPartitionSchemeExpression(dialect, "ps_logs").to_sql()
103+
# DROP PARTITION SCHEME [ps_logs]
104+
105+
SQLServerDropPartitionFunctionExpression(dialect, "pf_logs").to_sql()
106+
# DROP PARTITION FUNCTION [pf_logs]
107+
```
108+
109+
`SPLIT RANGE` consumes a destination from the scheme. If the scheme has no
110+
filegroup marked `NEXT USED`, the server refuses the split, so the maintenance
111+
helper emits `NEXT USED` before each split.
112+
113+
## Maintenance
114+
115+
`rhosocial.activerecord.backend.impl.sqlserver.partition` provides the plan and
116+
one executor with six operations. The executor is owner-agnostic: no table
117+
name, partition column or retention policy is embedded in it; `drop_before`
118+
takes the cutoff from the caller and reads the boundaries from the catalogue.
119+
120+
```python
121+
from rhosocial.activerecord.backend.impl.sqlserver.partition import (
122+
SQLServerPartitionMaintenance,
123+
SQLServerPartitionPlan,
124+
)
125+
126+
plan = SQLServerPartitionPlan(
127+
function="pf_logs",
128+
data_type="DATE",
129+
boundaries=(date(2026, 1, 1), date(2026, 2, 1), date(2026, 3, 1)),
130+
scheme="ps_logs",
131+
all_filegroup="PRIMARY",
132+
)
133+
134+
maintenance = SQLServerPartitionMaintenance(backend)
135+
136+
# Idempotent: creates the function and scheme when absent, splits only the
137+
# boundaries the server does not already hold.
138+
report = maintenance.ensure(plan)
139+
140+
# Catalogue report: functions, schemes, tables, per-partition ranges.
141+
report = maintenance.list(plan, table="logs", schema="dbo")
142+
143+
# Checks only; never repairs. The issues tuple says what is wrong.
144+
report = maintenance.validate(plan, table="logs", schema="dbo")
145+
146+
# SQL Server has no DROP PARTITION: merge the boundaries away.
147+
report = maintenance.drop_or_merge(plan, [date(2026, 1, 1)], dry_run=True)
148+
149+
# Retention: compute the boundaries at or before a cutoff, then merge them.
150+
boundaries = maintenance.retention_boundaries(plan, date(2026, 2, 1))
151+
report = maintenance.drop_before(plan, date(2026, 2, 1), dry_run=True)
152+
```
153+
154+
### `ensure(plan, dry_run=False)`
155+
156+
- Function absent: creates it with the plan's full boundary list, then the
157+
scheme when one was named.
158+
- Function present: splits only missing boundaries, in ascending order. The
159+
server decides whether a boundary exists (the value is cast to the
160+
function's type), so date/time and decimal spellings cannot produce a false
161+
"missing".
162+
- Scheme absent: creates it. With explicit `filegroups`, the function must
163+
already have the plan's partition count; otherwise use `all_filegroup`.
164+
- Direction or data-type mismatch: raises `ValueError`. Repairing that in
165+
place means dropping the function, and dropping a function used by a table
166+
is not something a maintenance call should do silently.
167+
168+
`dry_run=True` returns the statements without executing them.
169+
170+
### `list(plan=None, *, table=None, schema=None)`
171+
172+
Reads `sys.partition_functions`, `sys.partition_schemes`,
173+
`sys.destination_data_spaces`, `sys.tables`, `sys.indexes`,
174+
`sys.index_columns` and `sys.partitions`. The report carries:
175+
176+
- `functions` / `schemes` / `tables` with filegroups, type, direction and
177+
partition count;
178+
- `ranges` for the requested table: partition number, low/high boundary
179+
text, row count and data bytes;
180+
- `partitions`: the same rows as the core `PartitionInfo` contract (`name`
181+
is `<table>_<number>`, `parent` is the table, `level` is 1, `bound` joins
182+
low/high and `extras` carries the scheme, function and partition number);
183+
- `missing_boundaries` and `existing_boundaries`, when a plan was given;
184+
- `issues`, which only report; nothing is repaired.
185+
186+
A real `ensure` / `drop_or_merge` run re-reads the catalogue before returning;
187+
a dry run keeps the pre-execution snapshot. SQL Server's DDL auto-commits, so
188+
the executor stops at the first `DatabaseError`, records it in `errors`, and
189+
leaves the statements that did run in `operations`.
190+
191+
`sql_variant` boundary values are cast to text for display, with date/time
192+
values in ISO style so the report does not depend on the login language.
193+
194+
### `drop_or_merge(plan, boundaries=None, *, dry_run=True, drop_scaffolding=False)`
195+
196+
SQL Server cannot drop a partition. `MERGE RANGE` removes the boundary and
197+
folds the partition's rows into the partition on the left. `boundaries`
198+
names exactly which boundaries to merge -- there is no "before date" or
199+
retention rule here; that decision belongs to the application.
200+
201+
`dry_run` defaults to `True` because merging moves rows. With
202+
`drop_scaffolding=True`, the scheme and function are dropped afterwards, but
203+
only when the catalogue shows no table built on the scheme. When a table uses
204+
it, the report says why the drop was refused.
205+
206+
### `retention_boundaries(plan, cutoff, *, table=None, schema=None)`
207+
208+
Reads the function's boundary values (or a table's range upper bounds when
209+
`table` is passed) and returns the ones at or before `cutoff`. Nothing is
210+
executed. SQL Server's boundary is the upper bound of the partition to its
211+
left in both RANGE directions, so `<= cutoff` means "the partition is wholly
212+
before or ends on the cutoff".
213+
214+
### `drop_before(plan, cutoff, *, dry_run=True, drop_scaffolding=False)`
215+
216+
Computes `retention_boundaries(plan, cutoff)` and calls `drop_or_merge` with
217+
them. It implements the core `PartitionRetention` protocol. A dry run only
218+
renders `MERGE RANGE` statements; a real run re-reads the catalogue afterwards
219+
and reports what is left.
220+
221+
### `validate(plan, *, table=None, schema=None)`
222+
223+
Reports, without changing anything:
224+
225+
- a missing function or scheme;
226+
- a direction, data type or boundary-count mismatch;
227+
- a scheme mapped to another function;
228+
- a scheme whose destination count does not match the partition count;
229+
- a table on another scheme/function, or with missing partition numbers.
230+
231+
The report's `issues` tuple is the whole result; there is no automatic fix.
232+
233+
## Boundaries and ranges
234+
235+
SQL Server requires boundary values in ascending order and supports one
236+
direction per function:
237+
238+
- `RANGE RIGHT` (default): the boundary value belongs to the partition on the
239+
right. With boundaries `2026-01-01`, `2026-02-01`, `2026-03-01`, the rows
240+
are `(-inf, 2026-01-01)`, `[2026-01-01, 2026-02-01)`,
241+
`[2026-02-01, 2026-03-01)`, `[2026-03-01, +inf)`.
242+
- `RANGE LEFT`: the boundary value belongs to the partition on the left.
243+
244+
A plan sorts its boundaries and refuses duplicates. Removing a boundary with
245+
`MERGE RANGE` does not change the other boundaries; it only widens one
246+
partition to absorb its neighbour.
247+
248+
## Live coverage
249+
250+
`tests/.../feature/backend/test_sqlserver_partition_live.py` exercises
251+
`CREATE PARTITION FUNCTION` / `SCHEME`, `SPLIT RANGE`, `MERGE RANGE`, `SWITCH`
252+
and catalogue reads against the SQL Server 2022 image in `docker-compose.yml`.
253+
It skips -- rather than passes -- when no scenario is configured or the server
254+
cannot be reached. The offline suites cover the same decisions with canned
255+
catalogue rows and assert every rendered statement.

0 commit comments

Comments
 (0)