|
| 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