diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0190425..b076c5d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,7 +54,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies @@ -144,7 +144,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies @@ -234,7 +234,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies @@ -324,7 +324,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies @@ -414,7 +414,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies @@ -504,7 +504,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies @@ -595,7 +595,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies @@ -685,7 +685,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies @@ -784,7 +784,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies diff --git a/README.md b/README.md index 261c290..c7d96a6 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,20 @@ in SQL Server 2016; a server below that floor refuses the expression, and `supports_functions()["seconds_between"]` reports `False`. An `end` earlier than `start` subtracts to a negative value with no extra sign handling. +### Partitioned tables + +SQL Server partitioning is a partition function plus a partition scheme plus a +table built `ON ()` -- not a `PARTITION BY` clause. The DDL +expressions cover `CREATE PARTITION FUNCTION` / `SCHEME`, `SPLIT RANGE`, +`MERGE RANGE`, `SWITCH`, `NEXT USED` and the two drops, and the maintenance +executor provides `ensure`, `list`, `retention_boundaries`, `drop_or_merge`, +`drop_before` and `validate` with sync and async forms. There is no +subpartitioning, and a partition is removed by merging its boundary, never by +dropping it. + +See [Table partitioning](docs/partition.md) for the model, examples and the +maintenance contract. + ## Version Gates Gates below are the versions at which this backend's own `supports_*` @@ -204,14 +218,18 @@ oldest release carry no gate and are omitted. - **No native arrays, and no JSON *type*.** JSON is reachable through functions (`JSON_VALUE`, `JSON_QUERY`) on 2016+, not through a column type, and `supports_json_type` gates on that. -- **Table partitioning is metadata-only.** Partition functions, - schemes and aligned indexes are introspectable through - `introspect partitions` / `list_partitions`, but partition *lifecycle* - (`add_partition`, `attach_partition`, `detach_partition`, - `list_table_partitioning`, `range_table_partitioning`, - `hash_table_partitioning`, `subpartitioning`) is not implemented: those - switches are all `False` at every probed version and a model does not take - a `__partition__` attribute. +- **No generic partition verbs.** Table partitioning exists through + partition functions and schemes (`supports_table_partitioning`, + `supports_partition_function`, `supports_partition_scheme`, + `supports_split_partition`, `supports_merge_partition` and + `supports_switch_partition` are `True` on 2008+), but the generic + `add_partition`, `drop_partition`, `attach_partition`, `detach_partition`, + `truncate_partition` and `reorganize_partition` switches stay `False`: + SQL Server spells those operations differently or not at all. + `supports_subpartitioning()` is `False` at every version -- SQL Server has + no subpartitions. A model does not take a `__partition__` attribute; + partition state is managed by the backend helper in + [docs/partition.md](docs/partition.md). - Also unsupported: `alter_type`, `constraint_enforced`, `create_or_replace_view`, `deferrable_*`, `fetch_with_ties`, `fk_match`, `returning_alias`, `returning_wildcard`, `set_type`, `transaction_mode`. @@ -237,7 +255,9 @@ PYTHONPATH=tests .venv3.14-ubuntu26.04/bin/pytest \ [Database introspection](docs/introspection.md) states what this backend answers, where each answer comes from, and the version floors that decide -whether it can be asked at all. +whether it can be asked at all. [Table partitioning](docs/partition.md) +describes SQL Server's function/scheme model, the DDL expressions and the +maintenance helper. Backend notes live in [.claude/architecture.md](.claude/architecture.md) and [.claude/testing.md](.claude/testing.md); core library documentation is at diff --git a/changelog.d/13.added.md b/changelog.d/13.added.md new file mode 100644 index 0000000..c7709c6 --- /dev/null +++ b/changelog.d/13.added.md @@ -0,0 +1 @@ +Added SQL Server partition DDL and backend maintenance helpers, including `ensure`, `list`, `validate`, `retention_boundaries`, `drop_or_merge` and `drop_before`. diff --git a/docs/partition.md b/docs/partition.md new file mode 100644 index 0000000..1c37552 --- /dev/null +++ b/docs/partition.md @@ -0,0 +1,255 @@ +# Partitioning on SQL Server + +SQL Server does not spell partitioning the way PostgreSQL or MySQL do. There +is no `PARTITION BY` clause and no `PARTITION OF`. A partitioned table is +three catalogue objects working together: + +``` +CREATE PARTITION FUNCTION pf_logs (DATE) + AS RANGE RIGHT FOR VALUES ('2026-01-01', '2026-02-01'); + +CREATE PARTITION SCHEME ps_logs + AS PARTITION pf_logs ALL TO ([PRIMARY]); + +CREATE TABLE dbo.logs ( + id INT NOT NULL, + created_at DATE NOT NULL, + CONSTRAINT pk_logs PRIMARY KEY (id, created_at) +) ON [ps_logs] ([created_at]); +``` + +- The **function** fixes the boundary values. `N` boundaries describe + `N + 1` partitions, and `RANGE RIGHT` (the default) puts the boundary value + in the partition to its right. +- The **scheme** maps each partition to a filegroup. `ALL TO` uses one + filegroup; an explicit list needs one name per partition. +- The table is built `ON ()`. The partitioning column must + be part of every unique index on the table, which is why the primary key + above includes `created_at`. + +## No subpartitions + +SQL Server has no subpartitioning at any version. `supports_subpartitioning()` +returns `False`, and `SQLServerPartitionPlan` refuses a plan that asks for +another strategy or a `subpartition_by` at construction: + +```python +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +SQLServerPartitionPlan( + function="pf_logs", + data_type="DATE", + boundaries=(date(2026, 1, 1),), + subpartition_by="HASH", +) # raises UnsupportedFeatureError +``` + +There is also no `ADD PARTITION` / `DROP PARTITION`: adding a partition means +`SPLIT RANGE`, and removing one means `MERGE RANGE`. `supports_add_partition()` +and `supports_drop_partition()` stay `False`; the SQL Server-specific +capability flags are `supports_split_partition()`, `supports_merge_partition()` +and `supports_switch_partition()`. + +## DDL expressions + +Each statement is an expression node; the dialect renders the text. + +```python +from datetime import date + +from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( + SQLServerDropPartitionFunctionExpression, + SQLServerDropPartitionSchemeExpression, + SQLServerPartitionFunctionExpression, + SQLServerPartitionFunctionMergeExpression, + SQLServerPartitionFunctionSplitExpression, + SQLServerPartitionSchemeExpression, + SQLServerPartitionSchemeNextUsedExpression, + SQLServerSwitchPartitionExpression, +) + +SQLServerPartitionFunctionExpression( + dialect, "pf_logs", "DATE", [date(2026, 1, 1), date(2026, 2, 1)] +).to_sql() +# CREATE PARTITION FUNCTION [pf_logs] (DATE) +# AS RANGE RIGHT FOR VALUES ('2026-01-01', '2026-02-01') + +SQLServerPartitionSchemeExpression( + dialect, "ps_logs", "pf_logs", [], all_filegroup="PRIMARY" +).to_sql() +# CREATE PARTITION SCHEME [ps_logs] AS PARTITION [pf_logs] ALL TO ([PRIMARY]) + +SQLServerPartitionFunctionSplitExpression( + dialect, "pf_logs", date(2026, 1, 15) +).to_sql() +# ALTER PARTITION FUNCTION [pf_logs] () SPLIT RANGE ('2026-01-15') + +SQLServerPartitionFunctionMergeExpression( + dialect, "pf_logs", date(2026, 1, 15) +).to_sql() +# ALTER PARTITION FUNCTION [pf_logs] () MERGE RANGE ('2026-01-15') + +SQLServerPartitionSchemeNextUsedExpression( + dialect, "ps_logs", "PRIMARY" +).to_sql() +# ALTER PARTITION SCHEME [ps_logs] NEXT USED [PRIMARY] + +SQLServerSwitchPartitionExpression( + dialect, "logs", "logs_archive", source_partition=1 +).to_sql() +# ALTER TABLE [logs] SWITCH PARTITION 1 TO [logs_archive] + +SQLServerDropPartitionSchemeExpression(dialect, "ps_logs").to_sql() +# DROP PARTITION SCHEME [ps_logs] + +SQLServerDropPartitionFunctionExpression(dialect, "pf_logs").to_sql() +# DROP PARTITION FUNCTION [pf_logs] +``` + +`SPLIT RANGE` consumes a destination from the scheme. If the scheme has no +filegroup marked `NEXT USED`, the server refuses the split, so the maintenance +helper emits `NEXT USED` before each split. + +## Maintenance + +`rhosocial.activerecord.backend.impl.sqlserver.partition` provides the plan and +one executor with six operations. The executor is owner-agnostic: no table +name, partition column or retention policy is embedded in it; `drop_before` +takes the cutoff from the caller and reads the boundaries from the catalogue. + +```python +from rhosocial.activerecord.backend.impl.sqlserver.partition import ( + SQLServerPartitionMaintenance, + SQLServerPartitionPlan, +) + +plan = SQLServerPartitionPlan( + function="pf_logs", + data_type="DATE", + boundaries=(date(2026, 1, 1), date(2026, 2, 1), date(2026, 3, 1)), + scheme="ps_logs", + all_filegroup="PRIMARY", +) + +maintenance = SQLServerPartitionMaintenance(backend) + +# Idempotent: creates the function and scheme when absent, splits only the +# boundaries the server does not already hold. +report = maintenance.ensure(plan) + +# Catalogue report: functions, schemes, tables, per-partition ranges. +report = maintenance.list(plan, table="logs", schema="dbo") + +# Checks only; never repairs. The issues tuple says what is wrong. +report = maintenance.validate(plan, table="logs", schema="dbo") + +# SQL Server has no DROP PARTITION: merge the boundaries away. +report = maintenance.drop_or_merge(plan, [date(2026, 1, 1)], dry_run=True) + +# Retention: compute the boundaries at or before a cutoff, then merge them. +boundaries = maintenance.retention_boundaries(plan, date(2026, 2, 1)) +report = maintenance.drop_before(plan, date(2026, 2, 1), dry_run=True) +``` + +### `ensure(plan, dry_run=False)` + +- Function absent: creates it with the plan's full boundary list, then the + scheme when one was named. +- Function present: splits only missing boundaries, in ascending order. The + server decides whether a boundary exists (the value is cast to the + function's type), so date/time and decimal spellings cannot produce a false + "missing". +- Scheme absent: creates it. With explicit `filegroups`, the function must + already have the plan's partition count; otherwise use `all_filegroup`. +- Direction or data-type mismatch: raises `ValueError`. Repairing that in + place means dropping the function, and dropping a function used by a table + is not something a maintenance call should do silently. + +`dry_run=True` returns the statements without executing them. + +### `list(plan=None, *, table=None, schema=None)` + +Reads `sys.partition_functions`, `sys.partition_schemes`, +`sys.destination_data_spaces`, `sys.tables`, `sys.indexes`, +`sys.index_columns` and `sys.partitions`. The report carries: + +- `functions` / `schemes` / `tables` with filegroups, type, direction and + partition count; +- `ranges` for the requested table: partition number, low/high boundary + text, row count and data bytes; +- `partitions`: the same rows as the core `PartitionInfo` contract (`name` + is `_`, `parent` is the table, `level` is 1, `bound` joins + low/high and `extras` carries the scheme, function and partition number); +- `missing_boundaries` and `existing_boundaries`, when a plan was given; +- `issues`, which only report; nothing is repaired. + +A real `ensure` / `drop_or_merge` run re-reads the catalogue before returning; +a dry run keeps the pre-execution snapshot. SQL Server's DDL auto-commits, so +the executor stops at the first `DatabaseError`, records it in `errors`, and +leaves the statements that did run in `operations`. + +`sql_variant` boundary values are cast to text for display, with date/time +values in ISO style so the report does not depend on the login language. + +### `drop_or_merge(plan, boundaries=None, *, dry_run=True, drop_scaffolding=False)` + +SQL Server cannot drop a partition. `MERGE RANGE` removes the boundary and +folds the partition's rows into the partition on the left. `boundaries` +names exactly which boundaries to merge -- there is no "before date" or +retention rule here; that decision belongs to the application. + +`dry_run` defaults to `True` because merging moves rows. With +`drop_scaffolding=True`, the scheme and function are dropped afterwards, but +only when the catalogue shows no table built on the scheme. When a table uses +it, the report says why the drop was refused. + +### `retention_boundaries(plan, cutoff, *, table=None, schema=None)` + +Reads the function's boundary values (or a table's range upper bounds when +`table` is passed) and returns the ones at or before `cutoff`. Nothing is +executed. SQL Server's boundary is the upper bound of the partition to its +left in both RANGE directions, so `<= cutoff` means "the partition is wholly +before or ends on the cutoff". + +### `drop_before(plan, cutoff, *, dry_run=True, drop_scaffolding=False)` + +Computes `retention_boundaries(plan, cutoff)` and calls `drop_or_merge` with +them. It implements the core `PartitionRetention` protocol. A dry run only +renders `MERGE RANGE` statements; a real run re-reads the catalogue afterwards +and reports what is left. + +### `validate(plan, *, table=None, schema=None)` + +Reports, without changing anything: + +- a missing function or scheme; +- a direction, data type or boundary-count mismatch; +- a scheme mapped to another function; +- a scheme whose destination count does not match the partition count; +- a table on another scheme/function, or with missing partition numbers. + +The report's `issues` tuple is the whole result; there is no automatic fix. + +## Boundaries and ranges + +SQL Server requires boundary values in ascending order and supports one +direction per function: + +- `RANGE RIGHT` (default): the boundary value belongs to the partition on the + right. With boundaries `2026-01-01`, `2026-02-01`, `2026-03-01`, the rows + are `(-inf, 2026-01-01)`, `[2026-01-01, 2026-02-01)`, + `[2026-02-01, 2026-03-01)`, `[2026-03-01, +inf)`. +- `RANGE LEFT`: the boundary value belongs to the partition on the left. + +A plan sorts its boundaries and refuses duplicates. Removing a boundary with +`MERGE RANGE` does not change the other boundaries; it only widens one +partition to absorb its neighbour. + +## Live coverage + +`tests/.../feature/backend/test_sqlserver_partition_live.py` exercises +`CREATE PARTITION FUNCTION` / `SCHEME`, `SPLIT RANGE`, `MERGE RANGE`, `SWITCH` +and catalogue reads against the SQL Server 2022 image in `docker-compose.yml`. +It skips -- rather than passes -- when no scenario is configured or the server +cannot be reached. The offline suites cover the same decisions with canned +catalogue rows and assert every rendered statement. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py index d717c9e..d8e3892 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py @@ -12,16 +12,47 @@ - SQLServerPartitionSchemeExpression — CREATE PARTITION SCHEME """ +import re +from abc import ABC, abstractmethod from enum import Enum from typing import Any, Optional, Sequence, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams +from rhosocial.activerecord.backend.expression.introspection import IntrospectionExpression from rhosocial.activerecord.backend.expression.statements import PartitionClause, PartitionStrategy if TYPE_CHECKING: from ..dialect import SQLServerDialect +#: The type spellings a partition function accepts: a name, optionally with +#: one or two non-negative integers as its parameters. The type is written +#: into the catalog question that compares a boundary against it, so the +#: spelling is refused at construction rather than pasted into SQL. +_PARTITION_DATA_TYPE = re.compile( + r"^[A-Za-z_][A-Za-z0-9_]*(\(\s*\d+(\s*,\s*\d+)?\s*\))?$" +) + + +def validate_partition_data_type(data_type: str) -> str: + """Return *data_type* if it is a safe partition-function type spelling. + + Args: + data_type: The declared type of a partition function column, + e.g. ``DATE``, ``INT``, ``NVARCHAR(50)`` or ``DECIMAL(10, 2)``. + + Raises: + ValueError: The spelling is not a plain SQL type with optional + parameters. + """ + if not isinstance(data_type, str) or not _PARTITION_DATA_TYPE.match(data_type.strip()): + raise ValueError( + f"invalid partition-function data type {data_type!r}; expected e.g. " + f"DATE, INT, NVARCHAR(50) or DECIMAL(10, 2)" + ) + return data_type.strip() + + class SQLServerPartitionRangeDirection(str, Enum): """Which side of each boundary value belongs to the partition. @@ -149,3 +180,381 @@ def __init__( def to_sql(self) -> SQLQueryAndParams: return self.dialect.format_partition_scheme_expression(self) + + +class _SQLServerPartitionFunctionRangeExpression(BaseExpression, ABC): + """Shared shape of the two ``ALTER PARTITION FUNCTION`` range verbs. + + SQL Server removes or adds one boundary at a time: there is no + ``DROP PARTITION`` and no way to create an empty partition on its own. The + two statements differ only by the verb, so the rendering is shared in the + dialect and each concrete class carries its own renderer. + + Abstract so the expression matrix does not take this shape for a statement + of its own: it has no verb, and only the two concrete classes do. + """ + + KIND_RESERVED = ("SQLServer",) + + #: ``SPLIT`` or ``MERGE``; each concrete subclass fixes it. + verb: str = "" + + def __init__( + self, + dialect: "SQLServerDialect", + function_name: str, + boundary_value: Any, + ): + """Record one range operation. + + Args: + dialect: The dialect that renders the statement. + function_name: Partition function name, optionally dotted. + boundary_value: The boundary the operation acts on. SQL Server + accepts only a constant here, so the value is rendered as an + inline literal rather than as a bind parameter. + """ + super().__init__(dialect) + if not isinstance(function_name, str) or not function_name.strip(): + raise ValueError("function_name must be a non-empty string") + self.function_name = function_name + self.boundary_value = boundary_value + + @abstractmethod + def to_sql(self) -> SQLQueryAndParams: + """Render the concrete range statement.""" + + +class SQLServerPartitionFunctionSplitExpression( + _SQLServerPartitionFunctionRangeExpression +): + """``ALTER PARTITION FUNCTION ... SPLIT RANGE`` -- adds one boundary. + + Splitting an existing partition at a new boundary is how a partitioned + table gains a partition in SQL Server; the partition function itself is + created with a fixed number of boundaries and the scheme must have a + ``NEXT USED`` filegroup before a split can consume one. + """ + + verb = "SPLIT" + + def to_sql(self) -> SQLQueryAndParams: + return self.dialect.format_partition_function_split_expression(self) + + +class SQLServerPartitionFunctionMergeExpression( + _SQLServerPartitionFunctionRangeExpression +): + """``ALTER PARTITION FUNCTION ... MERGE RANGE`` -- removes one boundary. + + Merging two adjacent partitions is the only way to remove a partition: + SQL Server has no ``DROP PARTITION``. The rows before the boundary become + part of the partition on their left, so this is a data-affecting + operation, not metadata-only. + """ + + verb = "MERGE" + + def to_sql(self) -> SQLQueryAndParams: + return self.dialect.format_partition_function_merge_expression(self) + + +class SQLServerPartitionSchemeNextUsedExpression(BaseExpression): + """``ALTER PARTITION SCHEME ... NEXT USED`` -- marks a split destination. + + A ``SPLIT RANGE`` consumes one destination from the scheme; the scheme + must have a filegroup marked ``NEXT USED`` or the split is refused. This + statement marks one, so the maintenance executor emits it before each + split rather than relying on the state left by the previous one. + """ + + KIND_RESERVED = ("SQLServer",) + + def __init__( + self, + dialect: "SQLServerDialect", + scheme_name: str, + filegroup: str, + ): + super().__init__(dialect) + if not isinstance(scheme_name, str) or not scheme_name.strip(): + raise ValueError("scheme_name must be a non-empty string") + if not isinstance(filegroup, str) or not filegroup.strip(): + raise ValueError("filegroup must be a non-empty string") + self.scheme_name = scheme_name + self.filegroup = filegroup + + def to_sql(self) -> SQLQueryAndParams: + return self.dialect.format_partition_scheme_next_used_expression(self) + + +class SQLServerSwitchPartitionExpression(BaseExpression): + """``ALTER TABLE ... SWITCH [PARTITION n] TO ... [PARTITION m]``. + + ``SWITCH`` moves a whole partition's storage to another table without + copying rows. The target must be structurally identical and empty, and + the source and target must share a filegroup. The statement is the + SQL Server way to archive or restore one range of a partitioned table. + + A missing source partition means "switch the whole table", which is how a + non-partitioned table becomes a partition's target; a missing target + partition means the target is not partitioned. + """ + + KIND_RESERVED = ("SQLServer",) + + def __init__( + self, + dialect: "SQLServerDialect", + table: str, + target_table: str, + *, + source_partition: Optional[int] = None, + target_partition: Optional[int] = None, + schema: Optional[str] = None, + target_schema: Optional[str] = None, + ): + """ + Args: + dialect: The dialect that renders the statement. + table: Source table name, optionally schema-qualified with dots. + target_table: Target table name, optionally schema-qualified. + source_partition: Source partition number, or ``None`` for the + whole source table. + target_partition: Target partition number, or ``None`` when the + target is not partitioned. + schema: Source schema, when not written into ``table``. + target_schema: Target schema, when not written into + ``target_table``. + + Raises: + ValueError: A name is empty or a partition number is not positive. + """ + super().__init__(dialect) + for label, value, table_schema in ( + ("table", table, schema), + ("target_table", target_table, target_schema), + ): + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{label} must be a non-empty string") + if table_schema is not None and "." in value: + raise ValueError( + f"{label} must not carry dots when its schema is passed " + f"separately" + ) + for label, value in ( + ("source_partition", source_partition), + ("target_partition", target_partition), + ): + if value is not None and (not isinstance(value, int) or value < 1): + raise ValueError(f"{label} must be a positive integer or None") + self.table = table + self.target_table = target_table + self.source_partition = source_partition + self.target_partition = target_partition + self.schema = schema + self.target_schema = target_schema + + def to_sql(self) -> SQLQueryAndParams: + return self.dialect.format_switch_partition_expression(self) + + +class SQLServerDropPartitionFunctionExpression(BaseExpression): + """``DROP PARTITION FUNCTION`` -- removes the function itself. + + SQL Server refuses this while any partition scheme uses the function, so + the maintenance helper checks dependents first; there is no + ``IF EXISTS`` spelling for these objects. + """ + + KIND_RESERVED = ("SQLServer",) + + def __init__(self, dialect: "SQLServerDialect", function_name: str): + super().__init__(dialect) + if not isinstance(function_name, str) or not function_name.strip(): + raise ValueError("function_name must be a non-empty string") + self.function_name = function_name + + def to_sql(self) -> SQLQueryAndParams: + return self.dialect.format_drop_partition_function_expression(self) + + +class SQLServerDropPartitionSchemeExpression(BaseExpression): + """``DROP PARTITION SCHEME`` -- removes the scheme itself. + + A scheme can only be dropped when no table is built ``ON`` it; dropping a + scheme does not drop the function it maps. + """ + + KIND_RESERVED = ("SQLServer",) + + def __init__(self, dialect: "SQLServerDialect", scheme_name: str): + super().__init__(dialect) + if not isinstance(scheme_name, str) or not scheme_name.strip(): + raise ValueError("scheme_name must be a non-empty string") + self.scheme_name = scheme_name + + def to_sql(self) -> SQLQueryAndParams: + return self.dialect.format_drop_partition_scheme_expression(self) + + +class _SQLServerPartitionCatalogStatement(IntrospectionExpression, ABC): + """Shared shape of the catalogue questions partition maintenance asks. + + Every question is a ``SELECT`` over ``sys.partition_*`` / ``sys.tables``, + optionally narrowed by one name. The statement carries the filter, the + dialect writes the text, and the maintenance executor only executes what + comes back -- the same split the rest of this backend's catalogue + questions use. + + Abstract because it asks no question of its own; each concrete class + supplies a filter and a renderer. + """ + + KIND_RESERVED = ("SQLServer",) + + @abstractmethod + def filter_value(self) -> Optional[str]: + """The one name this statement narrows by, or ``None``.""" + + @abstractmethod + def to_sql(self) -> SQLQueryAndParams: + """Render this catalogue question.""" + + +class SQLServerPartitionFunctionQueryStatement(_SQLServerPartitionCatalogStatement): + """Ask for partition functions, their type, direction and boundaries.""" + + def __init__( + self, + dialect: "SQLServerDialect", + *, + function_name: Optional[str] = None, + ): + super().__init__(dialect) + self.function_name = function_name + + @property + def format_method(self) -> str: + return "format_partition_function_query_statement" + + def filter_value(self) -> Optional[str]: + return self.function_name + + def to_sql(self) -> SQLQueryAndParams: + return self.dialect.format_partition_function_query_statement(self) + + +class SQLServerPartitionSchemeQueryStatement(_SQLServerPartitionCatalogStatement): + """Ask for partition schemes, their function and destination filegroups.""" + + def __init__( + self, + dialect: "SQLServerDialect", + *, + scheme_name: Optional[str] = None, + function_name: Optional[str] = None, + ): + super().__init__(dialect) + self.scheme_name = scheme_name + self.function_name = function_name + + @property + def format_method(self) -> str: + return "format_partition_scheme_query_statement" + + def filter_value(self) -> Optional[str]: + return self.scheme_name + + def to_sql(self) -> SQLQueryAndParams: + return self.dialect.format_partition_scheme_query_statement(self) + + +class SQLServerPartitionTableQueryStatement(_SQLServerPartitionCatalogStatement): + """Ask which tables are partitioned, on which scheme and column.""" + + def __init__( + self, + dialect: "SQLServerDialect", + *, + table_name: Optional[str] = None, + schema: Optional[str] = None, + scheme_name: Optional[str] = None, + ): + super().__init__(dialect) + self.table_name = table_name + self.schema = schema + self.scheme_name = scheme_name + + @property + def format_method(self) -> str: + return "format_partition_table_query_statement" + + def filter_value(self) -> Optional[str]: + return self.table_name + + def to_sql(self) -> SQLQueryAndParams: + return self.dialect.format_partition_table_query_statement(self) + + +class SQLServerPartitionRangeQueryStatement(_SQLServerPartitionCatalogStatement): + """Ask for one table's partitions: numbers, row counts, bounds and bytes.""" + + def __init__( + self, + dialect: "SQLServerDialect", + *, + table_name: str, + schema: Optional[str] = None, + ): + super().__init__(dialect) + if not isinstance(table_name, str) or not table_name.strip(): + raise ValueError("table_name must be a non-empty string") + self.table_name = table_name + self.schema = schema + + @property + def format_method(self) -> str: + return "format_partition_range_query_statement" + + def filter_value(self) -> Optional[str]: + return self.table_name + + def to_sql(self) -> SQLQueryAndParams: + return self.dialect.format_partition_range_query_statement(self) + + +class SQLServerPartitionBoundaryQueryStatement(_SQLServerPartitionCatalogStatement): + """Ask whether one boundary already exists in a partition function. + + The comparison is made by the server, with the boundary cast to the + function's declared type. This is what keeps ``ensure`` from splitting at + a boundary that is already there: ``SPLIT RANGE`` refuses an existing + boundary, and comparing rendered text is unreliable across date/time + styles and decimal spellings. + """ + + def __init__( + self, + dialect: "SQLServerDialect", + *, + function_name: str, + data_type: str, + boundary_value: Any, + ): + super().__init__(dialect) + if not isinstance(function_name, str) or not function_name.strip(): + raise ValueError("function_name must be a non-empty string") + self.function_name = function_name + self.data_type = validate_partition_data_type(data_type) + self.boundary_value = boundary_value + + @property + def format_method(self) -> str: + return "format_partition_boundary_query_statement" + + def filter_value(self) -> Optional[str]: + return self.function_name + + def to_sql(self) -> SQLQueryAndParams: + return self.dialect.format_partition_boundary_query_statement(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py index b0e59cd..eba6cd2 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py @@ -12,9 +12,10 @@ - SWITCH, SPLIT, MERGE: SQL Server 2008+ """ -from typing import Tuple, TYPE_CHECKING +from typing import Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.objects import Table from ..expression.objects import PartitionFunction, PartitionScheme from ..expression.partition import SQLServerPartitionByRangeClause @@ -22,14 +23,61 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import PartitionClause from ..expression.partition import ( + SQLServerDropPartitionFunctionExpression, + SQLServerDropPartitionSchemeExpression, + SQLServerPartitionBoundaryQueryStatement, SQLServerPartitionFunctionExpression, + SQLServerPartitionFunctionMergeExpression, + SQLServerPartitionFunctionQueryStatement, + SQLServerPartitionFunctionSplitExpression, + SQLServerPartitionRangeQueryStatement, SQLServerPartitionSchemeExpression, + SQLServerPartitionSchemeNextUsedExpression, + SQLServerPartitionSchemeQueryStatement, + SQLServerPartitionTableQueryStatement, + SQLServerSwitchPartitionExpression, ) _SQL_SERVER_PARTITION_VERSION = (10, 0, 0) +def _partition_table_name(dialect, name: str, schema: Optional[str]) -> str: + """Render a table name, with *schema* when the caller supplied one. + + Module-level rather than a mixin method because the partition formatters + are copied onto :class:`SQLServerDialect`; only ``supports_`` and + ``format_`` members travel, and a private helper would be left behind. + """ + if schema: + return dialect.qualified_object_name(Table, name, schema_name=schema) + return dialect.qualified_object_name_from_dotted(Table, name) + + +def _partition_boundary_text(alias: str) -> str: + """A readable, type-aware rendering of one ``sql_variant`` boundary. + + ``sys.partition_range_values.value`` is ``sql_variant`` and pyodbc cannot + fetch it, so every projection casts it to text. Date/time values are + rendered ISO-style rather than in the server's language-dependent default, + so a report reads the same on any login. The text is for display only: + boundary comparison is done by the server through + ``SQLServerPartitionBoundaryQueryStatement``. + """ + base = f"SQL_VARIANT_PROPERTY({alias}.value, 'BaseType')" + return ( + f"CASE WHEN {base} = 'date' " + f"THEN CONVERT(NVARCHAR(10), CAST({alias}.value AS date), 126) " + f"WHEN {base} IN ('datetime', 'smalldatetime', 'datetime2') " + f"THEN CONVERT(NVARCHAR(33), CAST({alias}.value AS datetime2), 126) " + f"WHEN {base} = 'datetimeoffset' " + f"THEN CONVERT(NVARCHAR(40), CAST({alias}.value AS datetimeoffset), 126) " + f"WHEN {base} = 'time' " + f"THEN CONVERT(NVARCHAR(20), CAST({alias}.value AS time), 126) " + f"ELSE CONVERT(NVARCHAR(4000), CAST({alias}.value AS NVARCHAR(4000))) END" + ) + + class SQLServerPartitionMixin: """SQL Server table partitioning implementation.""" @@ -257,3 +305,245 @@ def format_partition_scheme_expression( ) return sql, () + + # --- Maintenance: SPLIT, MERGE, SWITCH, NEXT USED and drops --------- # + # + # SQL Server removes a partition by merging its boundary away; it never + # drops one. These formatters spell the four maintenance statements, plus + # the two drops, so the maintenance executor only constructs nodes and + # executes what comes back. + + + + def format_partition_function_split_expression( + self, expr: "SQLServerPartitionFunctionSplitExpression" + ) -> Tuple[str, tuple]: + """Render ``ALTER PARTITION FUNCTION ... SPLIT RANGE``.""" + self.check_feature_support( # type: ignore[attr-defined] + "supports_split_partition", + "SPLIT RANGE", + "Use a SQL Server version that supports table partitioning.", + ) + name = self.qualified_object_name_from_dotted( + PartitionFunction, expr.function_name + ) + value = self.inline_sql_literal(expr.boundary_value) + return f"ALTER PARTITION FUNCTION {name} () SPLIT RANGE ({value})", () + + def format_partition_function_merge_expression( + self, expr: "SQLServerPartitionFunctionMergeExpression" + ) -> Tuple[str, tuple]: + """Render ``ALTER PARTITION FUNCTION ... MERGE RANGE``.""" + self.check_feature_support( # type: ignore[attr-defined] + "supports_merge_partition", + "MERGE RANGE", + "Use a SQL Server version that supports table partitioning.", + ) + name = self.qualified_object_name_from_dotted( + PartitionFunction, expr.function_name + ) + value = self.inline_sql_literal(expr.boundary_value) + return f"ALTER PARTITION FUNCTION {name} () MERGE RANGE ({value})", () + + def format_partition_scheme_next_used_expression( + self, expr: "SQLServerPartitionSchemeNextUsedExpression" + ) -> Tuple[str, tuple]: + """Render ``ALTER PARTITION SCHEME ... NEXT USED``.""" + scheme = self.qualified_object_name(PartitionScheme, expr.scheme_name) + filegroup = self.format_identifier(expr.filegroup) # type: ignore[attr-defined] + return f"ALTER PARTITION SCHEME {scheme} NEXT USED {filegroup}", () + + def format_switch_partition_expression( + self, expr: "SQLServerSwitchPartitionExpression" + ) -> Tuple[str, tuple]: + """Render ``ALTER TABLE ... SWITCH [PARTITION n] TO ...``.""" + self.check_feature_support( # type: ignore[attr-defined] + "supports_switch_partition", + "SWITCH PARTITION", + "Use a SQL Server version that supports table partitioning.", + ) + source = _partition_table_name(self, expr.table, expr.schema) + target = _partition_table_name(self, expr.target_table, expr.target_schema) + source_partition = ( + f" PARTITION {expr.source_partition}" + if expr.source_partition is not None + else "" + ) + target_partition = ( + f" PARTITION {expr.target_partition}" + if expr.target_partition is not None + else "" + ) + return ( + f"ALTER TABLE {source} SWITCH{source_partition} " + f"TO {target}{target_partition}", + (), + ) + + def format_drop_partition_function_expression( + self, expr: "SQLServerDropPartitionFunctionExpression" + ) -> Tuple[str, tuple]: + """Render ``DROP PARTITION FUNCTION``.""" + name = self.qualified_object_name_from_dotted( + PartitionFunction, expr.function_name + ) + return f"DROP PARTITION FUNCTION {name}", () + + def format_drop_partition_scheme_expression( + self, expr: "SQLServerDropPartitionSchemeExpression" + ) -> Tuple[str, tuple]: + """Render ``DROP PARTITION SCHEME``.""" + name = self.qualified_object_name(PartitionScheme, expr.scheme_name) + return f"DROP PARTITION SCHEME {name}", () + + # --- Catalogue questions ------------------------------------------- # + # + # The text is fixed apart from one optional name filter, so each formatter + # builds it directly; the statement carries the filter and the executor + # runs what comes back. + + def format_partition_function_query_statement( + self, expr: "SQLServerPartitionFunctionQueryStatement" + ) -> Tuple[str, tuple]: + """Render the partition-function listing, one row per boundary.""" + where = "" + params: tuple = () + if expr.function_name: + where = "WHERE pf.name = ?" + params = (expr.function_name,) + sql = ( + "SELECT pf.name AS function_name, tp.name AS data_type, " + "pf.boundary_value_on_right AS boundary_value_on_right, " + "pf.fanout AS partition_count, prv.boundary_id AS boundary_id, " + f"{_partition_boundary_text('prv')} AS boundary_value " + "FROM sys.partition_functions AS pf " + "JOIN sys.partition_parameters AS pp " + "ON pf.function_id = pp.function_id AND pp.parameter_id = 1 " + "JOIN sys.types AS tp " + "ON pp.system_type_id = tp.system_type_id " + "AND pp.user_type_id = tp.user_type_id " + "LEFT JOIN sys.partition_range_values AS prv " + "ON pf.function_id = prv.function_id " + f"{where} ORDER BY pf.name, prv.boundary_id" + ) + return sql, params + + def format_partition_scheme_query_statement( + self, expr: "SQLServerPartitionSchemeQueryStatement" + ) -> Tuple[str, tuple]: + """Render the partition-scheme listing, one row per destination.""" + conditions = [] + params: tuple = () + if expr.scheme_name: + conditions.append("ps.name = ?") + params += (expr.scheme_name,) + if expr.function_name: + conditions.append("pf.name = ?") + params += (expr.function_name,) + where = f"WHERE {' AND '.join(conditions)} " if conditions else "" + sql = ( + "SELECT ps.name AS scheme_name, pf.name AS function_name, " + "pf.fanout AS partition_count, dds.destination_id AS destination_id, " + "ds.name AS filegroup_name " + "FROM sys.partition_schemes AS ps " + "JOIN sys.partition_functions AS pf " + "ON ps.function_id = pf.function_id " + "JOIN sys.destination_data_spaces AS dds " + "ON ps.data_space_id = dds.partition_scheme_id " + "JOIN sys.data_spaces AS ds " + "ON dds.data_space_id = ds.data_space_id " + f"{where} ORDER BY ps.name, dds.destination_id" + ) + return sql, params + + def format_partition_table_query_statement( + self, expr: "SQLServerPartitionTableQueryStatement" + ) -> Tuple[str, tuple]: + """Render the listing of tables built on a partition scheme.""" + conditions = [] + params: tuple = () + if expr.schema: + conditions.append("s.name = ?") + params += (expr.schema,) + if expr.table_name: + conditions.append("t.name = ?") + params += (expr.table_name,) + if expr.scheme_name: + conditions.append("ps.name = ?") + params += (expr.scheme_name,) + where = f"WHERE {' AND '.join(conditions)} " if conditions else "" + sql = ( + "SELECT t.name AS table_name, s.name AS schema_name, " + "ps.name AS scheme_name, pf.name AS function_name, " + "c.name AS partition_column, pf.fanout AS partition_count " + "FROM sys.tables AS t " + "JOIN sys.schemas AS s ON t.schema_id = s.schema_id " + "JOIN sys.indexes AS i " + "ON t.object_id = i.object_id AND i.index_id IN (0, 1) " + "JOIN sys.partition_schemes AS ps ON i.data_space_id = ps.data_space_id " + "JOIN sys.partition_functions AS pf " + "ON ps.function_id = pf.function_id " + "LEFT JOIN sys.index_columns AS ic " + "ON ic.object_id = i.object_id AND ic.index_id = i.index_id " + "AND ic.partition_ordinal = 1 " + "LEFT JOIN sys.columns AS c " + "ON c.object_id = ic.object_id AND c.column_id = ic.column_id " + f"{where}GROUP BY t.name, s.name, ps.name, pf.name, c.name, pf.fanout " + "ORDER BY t.name" + ) + return sql, params + + def format_partition_range_query_statement( + self, expr: "SQLServerPartitionRangeQueryStatement" + ) -> Tuple[str, tuple]: + """Render one table's partitions, with bounds, rows and bytes.""" + conditions = ["t.name = ?"] + params: tuple = (expr.table_name,) + if expr.schema: + conditions.insert(0, "s.name = ?") + params = (expr.schema,) + params + sql = ( + "WITH boundary_text AS (" + "SELECT prv.function_id, prv.boundary_id, " + f"{_partition_boundary_text('prv')} AS boundary_value " + "FROM sys.partition_range_values AS prv" + ") " + "SELECT t.name AS table_name, s.name AS schema_name, " + "stats.partition_number AS partition_number, " + "stats.row_count AS row_count, " + "stats.used_page_count AS used_page_count, " + "low_b.boundary_value AS low_boundary, " + "high_b.boundary_value AS high_boundary " + "FROM sys.tables AS t " + "JOIN sys.schemas AS s ON t.schema_id = s.schema_id " + "JOIN sys.indexes AS i " + "ON t.object_id = i.object_id AND i.index_id IN (0, 1) " + "JOIN sys.partition_schemes AS ps ON i.data_space_id = ps.data_space_id " + "JOIN sys.partition_functions AS pf ON ps.function_id = pf.function_id " + "JOIN sys.dm_db_partition_stats AS stats " + "ON stats.object_id = i.object_id AND stats.index_id = i.index_id " + "LEFT JOIN boundary_text AS low_b " + "ON low_b.function_id = pf.function_id " + "AND low_b.boundary_id = stats.partition_number - 1 " + "LEFT JOIN boundary_text AS high_b " + "ON high_b.function_id = pf.function_id " + "AND high_b.boundary_id = stats.partition_number " + f"WHERE {' AND '.join(conditions)} " + "ORDER BY stats.partition_number" + ) + return sql, params + + def format_partition_boundary_query_statement( + self, expr: "SQLServerPartitionBoundaryQueryStatement" + ) -> Tuple[str, tuple]: + """Render the question "does this boundary already exist?".""" + sql = ( + "SELECT CASE WHEN EXISTS (" + "SELECT 1 FROM sys.partition_range_values AS prv " + "JOIN sys.partition_functions AS pf " + "ON prv.function_id = pf.function_id " + "WHERE pf.name = ? " + f"AND prv.value = CAST(? AS {expr.data_type})" + ") THEN 1 ELSE 0 END AS boundary_exists" + ) + return sql, (expr.function_name, expr.boundary_value) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/partition/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/partition/__init__.py new file mode 100644 index 0000000..c712247 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/partition/__init__.py @@ -0,0 +1,50 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/partition/__init__.py +"""SQL Server partition planning and maintenance. + +SQL Server does not partition with ``PARTITION BY``. Its model is three +objects: a ``PARTITION FUNCTION`` that fixes the boundary values, a +``PARTITION SCHEME`` that maps each resulting partition to a filegroup, and a +table built ``ON ()``. Removing a partition is not a ``DROP``: +the boundary is merged away with ``MERGE RANGE``. There is no subpartitioning +at any version, and no ``PARTITION OF``. + +This package holds the backend-level objects and executor for that model: + +:mod:`plan` + :class:`SQLServerPartitionPlan` -- the function, boundaries, direction and + optional scheme, with no table name and no retention rule. +:mod:`report` + Value objects describing what the catalogue answered and what an executor + did (or would do under a dry run). +:mod:`maintenance` + :class:`SQLServerPartitionMaintenance` and + :class:`AsyncSQLServerPartitionMaintenance`, each with ``ensure``, ``list``, + ``retention_boundaries``, ``drop_or_merge``, ``drop_before`` and + ``validate``. + +Table names are arguments, never defaults: the application owns which table +is partitioned and which rows it keeps. +""" + +from .maintenance import AsyncSQLServerPartitionMaintenance, SQLServerPartitionMaintenance +from .plan import SQLServerPartitionPlan +from .report import ( + PartitionFunctionInfo, + PartitionOperation, + PartitionRangeInfo, + PartitionReport, + PartitionSchemeInfo, + PartitionTableInfo, +) + +__all__ = [ + "AsyncSQLServerPartitionMaintenance", + "PartitionFunctionInfo", + "PartitionOperation", + "PartitionRangeInfo", + "PartitionReport", + "PartitionSchemeInfo", + "PartitionTableInfo", + "SQLServerPartitionMaintenance", + "SQLServerPartitionPlan", +] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/partition/maintenance.py b/src/rhosocial/activerecord/backend/impl/sqlserver/partition/maintenance.py new file mode 100644 index 0000000..b98336c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/partition/maintenance.py @@ -0,0 +1,1610 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/partition/maintenance.py +"""SQL Server partition maintenance: plan in, catalogue out, DDL in order. + +SQL Server's maintenance vocabulary differs from every other backend's. There +is no ``ADD``/``DROP PARTITION``: the boundary count *is* the partition count, +so a partition is added with ``SPLIT RANGE`` and removed with ``MERGE RANGE``. +``SWITCH`` moves a whole partition to another table without copying rows. + +The executor is owner-agnostic. It knows a plan's function, boundaries, +direction and optional scheme; it knows nothing about which application owns +the table, which rows it keeps, or when it is allowed to delete them. Table +names are arguments to ``list`` and ``validate`` -- never defaults -- and no +retention policy is embedded: ``drop_or_merge`` merges exactly the boundaries +it is given, while ``drop_before`` reads the catalogue to choose them. + +SQL Server's DDL is statement-scoped and auto-commits, so the executor does +not promise a transaction across several statements. It executes the planned +statements in order and reports each one's rendered SQL, so a failure can be +read as "these before, this one failed" rather than as a value that pretends +to be atomic. + +Public surface is mirrored sync/async: + +* :class:`SQLServerPartitionMaintenance` +* :class:`AsyncSQLServerPartitionMaintenance` + +Both expose ``ensure``, ``list``, ``retention_boundaries``, +``drop_or_merge``, ``drop_before`` and ``validate``. +""" + +from dataclasses import replace +from datetime import date, datetime, time +from decimal import Decimal +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.partition import PartitionInfo + +from ..expression.partition import ( + SQLServerDropPartitionFunctionExpression, + SQLServerDropPartitionSchemeExpression, + SQLServerPartitionBoundaryQueryStatement, + SQLServerPartitionFunctionExpression, + SQLServerPartitionFunctionMergeExpression, + SQLServerPartitionFunctionQueryStatement, + SQLServerPartitionFunctionSplitExpression, + SQLServerPartitionRangeQueryStatement, + SQLServerPartitionSchemeExpression, + SQLServerPartitionSchemeNextUsedExpression, + SQLServerPartitionSchemeQueryStatement, + SQLServerPartitionTableQueryStatement, +) +from .plan import SQLServerPartitionPlan +from .report import ( + PartitionFunctionInfo, + PartitionOperation, + PartitionRangeInfo, + PartitionReport, + PartitionSchemeInfo, + PartitionTableInfo, +) + +__all__ = [ + "AsyncSQLServerPartitionMaintenance", + "SQLServerPartitionMaintenance", +] + +#: SQL Server's fixed page size; ``sys.partitions.used_page_count`` is pages. +_PAGE_BYTES = 8192 + + +def _as_int(value: Any) -> Optional[int]: + """Read a catalogue integer, tolerating the driver's string spelling.""" + if value is None: + return None + return int(value) + + +def _boundary_parameter(value: Any) -> Any: + """Spell a boundary in a form every ODBC driver can bind. + + The legacy ``SQL Server`` ODBC driver has no ``SQLBindParameter`` support + for ``date``/``time`` (it answers ``HYC00``), while Driver 18 binds them + natively. The query casts the parameter to the function's type anyway, + so an ISO string is accepted by both and asks the same question. + """ + if isinstance(value, datetime): + return value.isoformat(sep=" ") + if isinstance(value, (date, time)): + return value.isoformat() + if isinstance(value, Decimal): + return str(value) + return value + + +#: Partition-function types grouped by the comparison they allow. The base +#: spelling is enough: ``DECIMAL(10, 2)`` compares like ``DECIMAL`` and +#: ``NVARCHAR(50)`` like text. +_INT_TYPES = frozenset({"tinyint", "smallint", "int", "bigint"}) +_NUMBER_TYPES = frozenset({"decimal", "numeric", "money", "smallmoney", "float", "real"}) +_DATETIME_TYPES = frozenset({"datetime", "datetime2", "smalldatetime", "datetimeoffset"}) + + +def _boundary_family(data_type: str) -> str: + """Group a partition-function type into the comparison it supports.""" + base = data_type.split("(", 1)[0].strip().lower() + if base == "date": + return "date" + if base in _DATETIME_TYPES: + return "datetime" + if base == "time": + return "time" + if base in _INT_TYPES: + return "int" + if base in _NUMBER_TYPES: + return "number" + return "text" + + +def _trim_iso_fraction(text: str) -> str: + """Keep at most six fractional digits, as pre-3.11 parsers require.""" + dot = text.find(".") + if dot < 0: + return text + end = dot + 1 + while end < len(text) and text[end].isdigit(): + end += 1 + digits = text[dot + 1:end] + if len(digits) <= 6: + return text + return f"{text[:dot + 1]}{digits[:6]}{text[end:]}" + + +def _parse_catalogue_boundary(data_type: str, text: str) -> Any: + """Read one catalogue boundary as the Python value its type names. + + The catalogue query renders every boundary as text so all ODBC drivers can + fetch it. Retention still has to compare those values, so the text is read + back into the function's declared family. A ``datetimeoffset`` boundary is + compared by wall-clock time: retention is a date decision, and an offset + that the caller did not supply has no better meaning here. + """ + family = _boundary_family(data_type) + try: + if family == "date": + return date.fromisoformat(text[:10]) + if family == "datetime": + normalized = _trim_iso_fraction(text).replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + return parsed.replace(tzinfo=None) if parsed.tzinfo is not None else parsed + if family == "time": + return time.fromisoformat(_trim_iso_fraction(text).replace("Z", "+00:00")) + if family == "int": + return int(text) + if family == "number": + return Decimal(text) + return text + except (ValueError, ArithmeticError) as exc: + raise ValueError( + f"catalogue boundary {text!r} is not a valid {data_type} value" + ) from exc + + +def _coerce_cutoff(data_type: str, value: Any) -> Any: + """Read *value* as the comparand for a function of *data_type*.""" + family = _boundary_family(data_type) + try: + if family == "date": + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + return date.fromisoformat(str(value)[:10]) + if family == "datetime": + if isinstance(value, datetime): + parsed = value + elif isinstance(value, date): + parsed = datetime.combine(value, time.min) + else: + normalized = _trim_iso_fraction(str(value)).replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + return parsed.replace(tzinfo=None) if parsed.tzinfo is not None else parsed + if family == "time": + if isinstance(value, time): + return value + return time.fromisoformat( + _trim_iso_fraction(str(value)).replace("Z", "+00:00") + ) + if family == "int": + return int(value) + if family == "number": + return Decimal(str(value)) + if isinstance(value, datetime): + return value.isoformat(sep=" ") + if isinstance(value, (date, time)): + return value.isoformat() + if isinstance(value, Decimal): + return format(value, "f") + return str(value) + except (ValueError, TypeError, ArithmeticError) as exc: + raise ValueError(f"cutoff {value!r} is not a valid {data_type} value") from exc + + +def _range_bound(low: Optional[str], high: Optional[str]) -> Optional[str]: + """Join a partition's catalogue bounds into one display string.""" + if low is None and high is None: + return None + left = "" if low is None else low + right = "" if high is None else high + return f"{left}..{right}" + + +class _SQLServerPartitionMaintenanceBase: + """Catalogue parsing, planning and rendering shared by both executors. + + The two executors implement only the I/O seams (``_read_functions``, + ``_read_schemes``, ``_read_tables``, ``_read_ranges``, + ``_read_boundary_exists``, ``_execute``). Every decision -- what is + missing, what a dry run would do, which issue a validation found -- lives + here once, so sync and async cannot drift apart. + """ + + def __init__(self, backend: Any) -> None: + self.backend = backend + self.dialect = backend.dialect + + # --------------------------------------------------------------- I/O + def _read_functions( + self, function_name: Optional[str] = None + ) -> Tuple[PartitionFunctionInfo, ...]: + raise NotImplementedError + + def _read_schemes( + self, + scheme_name: Optional[str] = None, + function_name: Optional[str] = None, + ) -> Tuple[PartitionSchemeInfo, ...]: + raise NotImplementedError + + def _read_tables( + self, + table_name: Optional[str] = None, + schema: Optional[str] = None, + scheme_name: Optional[str] = None, + ) -> Tuple[PartitionTableInfo, ...]: + raise NotImplementedError + + def _read_ranges( + self, table_name: str, schema: Optional[str] = None + ) -> Tuple[PartitionRangeInfo, ...]: + raise NotImplementedError + + def _read_boundary_exists( + self, plan: SQLServerPartitionPlan + ) -> Tuple[bool, ...]: + raise NotImplementedError + + def _execute(self, sql: str, params: Tuple[Any, ...]) -> None: + raise NotImplementedError + + # ------------------------------------------------------------- parsing + @staticmethod + def _parse_functions(rows: Sequence[Any]) -> Tuple[PartitionFunctionInfo, ...]: + grouped: Dict[str, Dict[str, Any]] = {} + for row in rows: + name = row.get("function_name") + if not name: + continue + bucket = grouped.setdefault( + name, + { + "data_type": str(row.get("data_type") or ""), + "direction": ( + "RIGHT" + if _as_int(row.get("boundary_value_on_right")) == 1 + else "LEFT" + ), + "partition_count": _as_int(row.get("partition_count")) or 0, + "boundaries": [], + }, + ) + if row.get("boundary_value") is not None: + bucket["boundaries"].append(str(row["boundary_value"])) + return tuple( + PartitionFunctionInfo( + name=name, + data_type=bucket["data_type"], + range_direction=bucket["direction"], + boundary_values=tuple(bucket["boundaries"]), + partition_count=bucket["partition_count"], + ) + for name, bucket in grouped.items() + ) + + @staticmethod + def _parse_schemes(rows: Sequence[Any]) -> Tuple[PartitionSchemeInfo, ...]: + grouped: Dict[str, Dict[str, Any]] = {} + for row in rows: + name = row.get("scheme_name") + if not name: + continue + bucket = grouped.setdefault( + name, + { + "function": str(row.get("function_name") or ""), + "partition_count": _as_int(row.get("partition_count")) or 0, + "filegroups": [], + }, + ) + if row.get("filegroup_name"): + bucket["filegroups"].append(str(row["filegroup_name"])) + return tuple( + PartitionSchemeInfo( + name=name, + function=bucket["function"], + filegroups=tuple(bucket["filegroups"]), + partition_count=bucket["partition_count"], + ) + for name, bucket in grouped.items() + ) + + @staticmethod + def _parse_tables(rows: Sequence[Any]) -> Tuple[PartitionTableInfo, ...]: + tables = [] + seen = set() + for row in rows: + name = row.get("table_name") + if not name: + continue + schema = row.get("schema_name") + key = (schema, name) + if key in seen: + continue + seen.add(key) + tables.append( + PartitionTableInfo( + name=str(name), + schema=None if schema is None else str(schema), + scheme=str(row.get("scheme_name") or ""), + function=str(row.get("function_name") or ""), + partition_column=( + None + if row.get("partition_column") is None + else str(row["partition_column"]) + ), + partition_count=_as_int(row.get("partition_count")) or 0, + ) + ) + return tuple(tables) + + @staticmethod + def _parse_ranges(rows: Sequence[Any]) -> Tuple[PartitionRangeInfo, ...]: + return tuple( + PartitionRangeInfo( + table=str(row.get("table_name") or ""), + schema=( + None + if row.get("schema_name") is None + else str(row["schema_name"]) + ), + partition_number=_as_int(row.get("partition_number")) or 0, + low_boundary=( + None + if row.get("low_boundary") is None + else str(row["low_boundary"]) + ), + high_boundary=( + None + if row.get("high_boundary") is None + else str(row["high_boundary"]) + ), + row_count=_as_int(row.get("row_count")), + data_bytes=( + None + if row.get("used_page_count") is None + else _as_int(row["used_page_count"]) * _PAGE_BYTES + ), + ) + for row in rows + ) + + # ------------------------------------------------------------- planning + @staticmethod + def _find_function( + functions: Sequence[PartitionFunctionInfo], name: str + ) -> Optional[PartitionFunctionInfo]: + for info in functions: + if info.name == name: + return info + return None + + @staticmethod + def _find_scheme( + schemes: Sequence[PartitionSchemeInfo], name: Optional[str] + ) -> Optional[PartitionSchemeInfo]: + if name is None: + return None + for info in schemes: + if info.name == name: + return info + return None + + def _plan_ensure( + self, + plan: SQLServerPartitionPlan, + functions: Sequence[PartitionFunctionInfo], + schemes: Sequence[PartitionSchemeInfo], + exists: Sequence[bool], + ) -> Tuple[ + Tuple[Any, ...], + List[Tuple[PartitionOperation, Tuple[Any, ...]]], + List[str], + ]: + """Decide what ``ensure`` would do; no I/O happens here.""" + planned: List[Tuple[PartitionOperation, Tuple[Any, ...]]] = [] + issues: List[str] = [] + info = self._find_function(functions, plan.function) + + def add(action: str, name: str, statement: Any) -> None: + sql, params = statement.to_sql() + planned.append((PartitionOperation(action=action, name=name, sql=sql), params)) + + if info is None: + add( + "create_function", + plan.function, + SQLServerPartitionFunctionExpression( + self.dialect, + plan.function, + plan.data_type, + list(plan.boundaries), + range_direction=plan.range_direction, + ), + ) + if plan.scheme: + add( + "create_scheme", + plan.scheme, + SQLServerPartitionSchemeExpression( + self.dialect, + plan.scheme, + plan.function, + list(plan.filegroups), + all_filegroup=plan.all_filegroup, + ), + ) + return (), planned, issues + + if info.range_direction != plan.range_direction.value: + raise ValueError( + f"partition function {plan.function!r} is RANGE " + f"{info.range_direction}; the plan asks for RANGE " + f"{plan.range_direction.value}. Dropping and recreating the " + f"function would move every row; do it explicitly instead." + ) + if info.data_type.upper() != plan.data_type.upper(): + raise ValueError( + f"partition function {plan.function!r} partitions on " + f"{info.data_type}, but the plan declares {plan.data_type}. " + f"Change the plan's data_type or recreate the function." + ) + + missing = tuple( + boundary + for boundary, present in zip(plan.boundaries, exists) + if not present + ) + + if plan.scheme: + scheme_info = self._find_scheme(schemes, plan.scheme) + if scheme_info is None: + if ( + plan.filegroups + and info.partition_count != plan.partition_count + ): + raise ValueError( + f"partition function {plan.function!r} currently has " + f"{info.partition_count} partitions, but the plan " + f"provides {len(plan.filegroups)} explicit filegroups " + f"for {plan.partition_count}. Create the scheme with " + f"all_filegroup, or bring the boundary count into step " + f"before supplying explicit destinations." + ) + add( + "create_scheme", + plan.scheme, + SQLServerPartitionSchemeExpression( + self.dialect, + plan.scheme, + plan.function, + list(plan.filegroups), + all_filegroup=plan.all_filegroup, + ), + ) + scheme_info = PartitionSchemeInfo( + name=plan.scheme, + function=plan.function, + filegroups=tuple(plan.filegroups), + partition_count=info.partition_count, + ) + else: + if scheme_info.function != plan.function: + raise ValueError( + f"partition scheme {plan.scheme!r} maps function " + f"{scheme_info.function!r}, not {plan.function!r}." + ) + # A scheme may declare one extra destination for NEXT USED -- + # ALL TO does exactly that -- so only a shortfall is a + # partition that has nowhere to live. + if scheme_info.destination_count < info.partition_count: + issues.append( + f"scheme {plan.scheme!r} declares " + f"{scheme_info.destination_count} destinations for " + f"{info.partition_count} partitions" + ) + + for boundary in missing: + if plan.scheme and plan.next_filegroup: + add( + "next_used", + plan.next_filegroup, + SQLServerPartitionSchemeNextUsedExpression( + self.dialect, plan.scheme, plan.next_filegroup + ), + ) + add( + "split", + str(boundary), + SQLServerPartitionFunctionSplitExpression( + self.dialect, plan.function, boundary + ), + ) + return missing, planned, issues + + def _plan_list( + self, + plan: Optional[SQLServerPartitionPlan], + functions: Sequence[PartitionFunctionInfo], + schemes: Sequence[PartitionSchemeInfo], + tables: Sequence[PartitionTableInfo], + ranges: Sequence[PartitionRangeInfo], + exists: Sequence[bool] = (), + ) -> PartitionReport: + """Build the reporting half of ``list``; no I/O happens here.""" + issues: List[str] = [] + missing: Tuple[Any, ...] = () + existing: Tuple[str, ...] = () + + if plan is not None: + info = self._find_function(functions, plan.function) + if info is None: + issues.append(f"partition function {plan.function!r} does not exist") + missing = tuple(plan.boundaries) + else: + existing = info.boundary_values + missing = tuple( + boundary + for boundary, present in zip(plan.boundaries, exists) + if not present + ) + if info.range_direction != plan.range_direction.value: + issues.append( + f"partition function {plan.function!r} is RANGE " + f"{info.range_direction}, not RANGE " + f"{plan.range_direction.value}" + ) + if info.data_type.upper() != plan.data_type.upper(): + issues.append( + f"partition function {plan.function!r} partitions on " + f"{info.data_type}, not {plan.data_type}" + ) + if info.partition_count != plan.partition_count: + issues.append( + f"partition function {plan.function!r} has " + f"{info.partition_count} partitions, but the plan " + f"describes {plan.partition_count}" + ) + if plan.scheme: + scheme_info = self._find_scheme(schemes, plan.scheme) + if scheme_info is None: + issues.append( + f"partition scheme {plan.scheme!r} does not exist" + ) + else: + if scheme_info.function != plan.function: + issues.append( + f"partition scheme {plan.scheme!r} maps " + f"{scheme_info.function!r}, not " + f"{plan.function!r}" + ) + if scheme_info.destination_count < info.partition_count: + issues.append( + f"scheme {plan.scheme!r} declares " + f"{scheme_info.destination_count} destinations " + f"for {info.partition_count} partitions" + ) + + return PartitionReport( + target=plan.function if plan is not None else "(all)", + function=None if plan is None else plan.function, + scheme=None if plan is None else plan.scheme, + functions=tuple(functions), + schemes=tuple(schemes), + tables=tuple(tables), + partitions=self._core_partitions(ranges, tables), + ranges=tuple(ranges), + missing_boundaries=missing, + existing_boundaries=existing, + issues=tuple(issues), + ) + + def _plan_validate( + self, + report: PartitionReport, + plan: SQLServerPartitionPlan, + table: Optional[str], + ) -> Tuple[str, ...]: + """Add the table-level checks ``validate`` makes to *report*.""" + issues = list(report.issues) + info = report.function_info(plan.function) + if table is not None: + table_info = report.table_info(table) + if table_info is None: + issues.append( + f"table {table!r} is not partitioned or does not exist" + ) + else: + if plan.scheme and table_info.scheme != plan.scheme: + issues.append( + f"table {table!r} is built on {table_info.scheme!r}, " + f"not {plan.scheme!r}" + ) + if table_info.function != plan.function: + issues.append( + f"table {table!r} uses function " + f"{table_info.function!r}, not {plan.function!r}" + ) + if ( + info is not None + and table_info.partition_count != info.partition_count + ): + issues.append( + f"table {table!r} reports " + f"{table_info.partition_count} partitions, but function " + f"{plan.function!r} has {info.partition_count}" + ) + if info is not None: + expected = set(range(1, info.partition_count + 1)) + observed = {row.partition_number for row in report.ranges} + if observed != expected: + issues.append( + f"table {table!r} partitions are " + f"{sorted(observed)}, expected {sorted(expected)}" + ) + return tuple(issues) + + def _plan_drop( + self, + plan: SQLServerPartitionPlan, + functions: Sequence[PartitionFunctionInfo], + schemes: Sequence[PartitionSchemeInfo], + tables: Sequence[PartitionTableInfo], + requested: Sequence[Any], + exists: Sequence[bool], + drop_scaffolding: bool, + ) -> List[Tuple[PartitionOperation, Tuple[Any, ...]]]: + """Decide what ``drop_or_merge`` would do; catalogue is read.""" + planned: List[Tuple[PartitionOperation, Tuple[Any, ...]]] = [] + + def add(action: str, name: str, statement: Any) -> None: + sql, params = statement.to_sql() + planned.append((PartitionOperation(action=action, name=name, sql=sql), params)) + + info = self._find_function(functions, plan.function) + if info is None: + return planned + + for boundary, present in zip(requested, exists): + if not present: + continue + add( + "merge", + str(boundary), + SQLServerPartitionFunctionMergeExpression( + self.dialect, plan.function, boundary + ), + ) + + if not drop_scaffolding: + return planned + + # A scheme can be dropped only when no table is built on it; the + # function can be dropped only when no scheme maps it. Both checks + # read the catalogue before any statement runs, so a dry run and a + # real run agree on which drops are safe. + candidate_schemes = [ + scheme + for scheme in schemes + if scheme.function == plan.function + and (plan.scheme is None or scheme.name == plan.scheme) + ] + dropped_schemes: List[str] = [] + for scheme in candidate_schemes: + users = [t for t in tables if t.scheme == scheme.name] + if users: + continue + add( + "drop_scheme", + scheme.name, + SQLServerDropPartitionSchemeExpression(self.dialect, scheme.name), + ) + dropped_schemes.append(scheme.name) + + remaining_schemes = [ + scheme + for scheme in schemes + if scheme.function == plan.function + and scheme.name not in dropped_schemes + ] + if remaining_schemes: + return planned + add( + "drop_function", + plan.function, + SQLServerDropPartitionFunctionExpression(self.dialect, plan.function), + ) + return planned + + # ------------------------------------------------------------- reporting + @staticmethod + def _core_partitions( + ranges: Sequence[PartitionRangeInfo], + tables: Sequence[PartitionTableInfo], + ) -> Tuple[PartitionInfo, ...]: + """Map the engine's per-partition rows into the core contract.""" + by_table = {(table.schema, table.name): table for table in tables} + rows = [] + for row in ranges: + table = by_table.get((row.schema, row.table)) + extras: Dict[str, Any] = { + "partition_number": row.partition_number, + "table": row.table, + } + if table is not None: + extras["scheme"] = table.scheme + extras["function"] = table.function + rows.append( + PartitionInfo( + name=f"{row.table}_{row.partition_number}", + parent=row.table, + level=1, + method="RANGE", + bound=_range_bound(row.low_boundary, row.high_boundary), + schema=row.schema, + row_count=row.row_count, + size_bytes=row.data_bytes, + extras=extras, + ) + ) + return tuple(rows) + + @staticmethod + def _report( + plan: Optional[SQLServerPartitionPlan], + functions: Sequence[PartitionFunctionInfo], + schemes: Sequence[PartitionSchemeInfo], + tables: Sequence[PartitionTableInfo], + ranges: Sequence[PartitionRangeInfo], + missing: Sequence[Any], + existing: Sequence[str], + issues: Sequence[str], + planned: Sequence[Tuple[PartitionOperation, Tuple[Any, ...]]], + dry_run: bool, + ) -> PartitionReport: + return PartitionReport( + target=plan.function if plan is not None else "(all)", + function=None if plan is None else plan.function, + scheme=None if plan is None else plan.scheme, + functions=tuple(functions), + schemes=tuple(schemes), + tables=tuple(tables), + partitions=_SQLServerPartitionMaintenanceBase._core_partitions( + ranges, tables + ), + ranges=tuple(ranges), + missing_boundaries=tuple(missing), + existing_boundaries=tuple(existing), + issues=tuple(issues), + operations=tuple(operation for operation, _ in planned), + dry_run=dry_run, + ) + + def _run( + self, + planned: Sequence[Tuple[PartitionOperation, Tuple[Any, ...]]], + dry_run: bool, + ) -> Tuple[Tuple[str, ...], int]: + """Execute the planned statements, stopping at the first error. + + Returns: + ``(errors, completed)``: the failure text (empty when nothing + failed) and how many statements ran successfully. SQL Server's + DDL auto-commits, so a failure is reported rather than rolled back. + """ + if dry_run: + return (), 0 + completed = 0 + for operation, params in planned: + try: + self._execute(operation.sql, params) + except DatabaseError as exc: + return ( + f"{operation.action} {operation.name!r} failed: {exc}", + ), completed + completed += 1 + return (), completed + + # -------------------------------------------------------------- gates + def _require_support(self, plan: SQLServerPartitionPlan) -> None: + if not self.dialect.supports_table_partitioning(): + raise UnsupportedFeatureError( + "SQL Server", + "table partitioning", + "Partitioning requires SQL Server 2008 or later.", + ) + if not self.dialect.supports_partition_function(): + raise UnsupportedFeatureError( + "SQL Server", "partition functions", "Requires SQL Server 2008+." + ) + if plan.scheme and not self.dialect.supports_partition_scheme(): + raise UnsupportedFeatureError( + "SQL Server", "partition schemes", "Requires SQL Server 2008+." + ) + if self.dialect.supports_subpartitioning(): + raise UnsupportedFeatureError( + "SQL Server", + "subpartitioning", + "This backend must not claim subpartitioning.", + ) + if not self.dialect.supports_split_partition(): + raise UnsupportedFeatureError( + "SQL Server", "SPLIT RANGE", "Requires SQL Server 2008+." + ) + + def _require_merge(self) -> None: + if not self.dialect.supports_table_partitioning(): + raise UnsupportedFeatureError( + "SQL Server", "table partitioning", "Requires SQL Server 2008+." + ) + if not self.dialect.supports_merge_partition(): + raise UnsupportedFeatureError( + "SQL Server", "MERGE RANGE", "Requires SQL Server 2008+." + ) + if self.dialect.supports_subpartitioning(): + raise UnsupportedFeatureError( + "SQL Server", + "subpartitioning", + "This backend must not claim subpartitioning.", + ) + + +class SQLServerPartitionMaintenance(_SQLServerPartitionMaintenanceBase): + """Synchronous SQL Server partition maintenance. + + Example: + >>> maintenance = SQLServerPartitionMaintenance(backend) + >>> report = maintenance.ensure(plan) + >>> dry = maintenance.drop_or_merge(plan, [date(2026, 1, 1)]) + """ + + def _fetch_rows(self, statement: Any) -> Sequence[Any]: + sql, params = statement.to_sql() + result = self.backend.execute(sql, params) + return result.data or [] + + def _read_functions( + self, function_name: Optional[str] = None + ) -> Tuple[PartitionFunctionInfo, ...]: + return self._parse_functions( + self._fetch_rows( + SQLServerPartitionFunctionQueryStatement( + self.dialect, function_name=function_name + ) + ) + ) + + def _read_schemes( + self, + scheme_name: Optional[str] = None, + function_name: Optional[str] = None, + ) -> Tuple[PartitionSchemeInfo, ...]: + return self._parse_schemes( + self._fetch_rows( + SQLServerPartitionSchemeQueryStatement( + self.dialect, + scheme_name=scheme_name, + function_name=function_name, + ) + ) + ) + + def _read_tables( + self, + table_name: Optional[str] = None, + schema: Optional[str] = None, + scheme_name: Optional[str] = None, + ) -> Tuple[PartitionTableInfo, ...]: + return self._parse_tables( + self._fetch_rows( + SQLServerPartitionTableQueryStatement( + self.dialect, + table_name=table_name, + schema=schema, + scheme_name=scheme_name, + ) + ) + ) + + def _read_ranges( + self, table_name: str, schema: Optional[str] = None + ) -> Tuple[PartitionRangeInfo, ...]: + return self._parse_ranges( + self._fetch_rows( + SQLServerPartitionRangeQueryStatement( + self.dialect, table_name=table_name, schema=schema + ) + ) + ) + + def _read_boundary_exists( + self, plan: SQLServerPartitionPlan + ) -> Tuple[bool, ...]: + exists = [] + for boundary in plan.boundaries: + rows = self._fetch_rows( + SQLServerPartitionBoundaryQueryStatement( + self.dialect, + function_name=plan.function, + data_type=plan.data_type, + boundary_value=_boundary_parameter(boundary), + ) + ) + exists.append(bool(rows and _as_int(rows[0].get("boundary_exists")))) + return tuple(exists) + + def _execute(self, sql: str, params: Tuple[Any, ...]) -> None: + self.backend.execute(sql, params) + + def ensure( + self, plan: SQLServerPartitionPlan, *, dry_run: bool = False + ) -> PartitionReport: + """Create what is missing and split absent boundaries. + + The function is created with the plan's full boundary list when it + does not exist; when it does, only boundaries the server does not + already hold are split in. A scheme is created on demand. Existing + objects are never altered: a direction or type mismatch raises + ``ValueError`` rather than being repaired behind the caller's back. + + Args: + plan: The function, boundaries and optional scheme to ensure. + dry_run: Build the statement list without executing it. + + Returns: + A report whose ``operations`` are the statements run (or would be + run), and whose ``issues`` are inconsistencies found while + planning. + """ + self._require_support(plan) + functions = self._read_functions(plan.function) + exists = self._read_boundary_exists(plan) + schemes = self._read_schemes(plan.scheme, plan.function) if plan.scheme else () + missing, planned, issues = self._plan_ensure(plan, functions, schemes, exists) + existing = () + info = self._find_function(functions, plan.function) + if info is not None: + existing = info.boundary_values + report = self._report( + plan, functions, schemes, (), (), missing, existing, issues, planned, dry_run + ) + errors, completed = self._run(planned, dry_run) + if dry_run: + return report + post = self.list(plan) + return replace( + post, + operations=tuple(operation for operation, _ in planned[:completed]), + issues=tuple(dict.fromkeys((*issues, *post.issues))), + errors=errors, + dry_run=False, + ) + + def list( + self, + plan: Optional[SQLServerPartitionPlan] = None, + *, + table: Optional[str] = None, + schema: Optional[str] = None, + ) -> PartitionReport: + """Read the catalogue and report the tree around a plan or table. + + Args: + plan: When given, narrows to its function and scheme and reports + its missing boundaries. + table: When given, also returns that table's per-partition rows. + schema: Schema the requested table lives in. + """ + function_name = None if plan is None else plan.function + scheme_name = None if plan is None else plan.scheme + functions = self._read_functions(function_name) + schemes = self._read_schemes(scheme_name, function_name) if plan else () + tables = self._read_tables( + table, schema, None if (plan is None or table) else plan.scheme + ) + ranges = self._read_ranges(table, schema) if table else () + exists = self._read_boundary_exists(plan) if plan is not None else () + return self._plan_list(plan, functions, schemes, tables, ranges, exists) + + def retention_boundaries( + self, + plan: SQLServerPartitionPlan, + cutoff: Any, + *, + table: Optional[str] = None, + schema: Optional[str] = None, + ) -> Tuple[Any, ...]: + """Return the boundaries a retention cut would merge away. + + SQL Server's function boundary is the upper bound of the partition on + its left, whichever RANGE direction the function declares, so a + boundary qualifies when it is less than or equal to *cutoff*. + + Args: + plan: Plan naming the function and its boundary type. + cutoff: The oldest value to keep. + table: When given, read the table's partition ranges instead of + the function's boundary list. + schema: Schema the table lives in. + + Returns: + Boundary values, in ascending order, whose partition lies wholly + before or on *cutoff*. Nothing is executed. + """ + function = self._find_function( + self._read_functions(plan.function), plan.function + ) + if table is not None: + texts = tuple( + row.high_boundary + for row in self._read_ranges(table, schema) + if row.high_boundary is not None + ) + elif function is not None: + texts = function.boundary_values + else: + return () + cutoff_value = _coerce_cutoff(plan.data_type, cutoff) + values = tuple( + _parse_catalogue_boundary(plan.data_type, text) for text in texts + ) + return tuple(value for value in values if value <= cutoff_value) + + def drop_before( + self, + plan: SQLServerPartitionPlan, + cutoff: Any, + *, + dry_run: bool = True, + drop_scaffolding: bool = False, + ) -> PartitionReport: + """Merge the partitions a retention cut puts at or before *cutoff*. + + The boundaries are computed from the catalogue by + :meth:`retention_boundaries` and handed to :meth:`drop_or_merge`. + SQL Server cannot drop a partition, so this is a ``MERGE RANGE`` + operation, not a metadata-only drop. + + Args: + plan: The plan whose function is acted on. + cutoff: The oldest value to keep. + dry_run: Render the statements without executing them (the + default). + drop_scaffolding: Also drop the scheme and function when unused. + """ + boundaries = self.retention_boundaries(plan, cutoff) + return self.drop_or_merge( + plan, + boundaries, + dry_run=dry_run, + drop_scaffolding=drop_scaffolding, + ) + + def drop_or_merge( + self, + plan: SQLServerPartitionPlan, + boundaries: Optional[Sequence[Any]] = None, + *, + dry_run: bool = True, + drop_scaffolding: bool = False, + ) -> PartitionReport: + """Merge boundaries away, or drop the function and scheme behind them. + + SQL Server has no ``DROP PARTITION``; a boundary is removed with + ``MERGE RANGE``, which folds its rows into the partition on the left. + ``boundaries`` names exactly what to merge -- there is no retention + policy here, and no "before date" shorthand. When omitted, the plan's + own boundaries are merged, collapsing the function to one partition. + + ``drop_scaffolding`` drops the scheme and then the function, but only + when the catalogue shows no table built on either. A table using the + scheme keeps both, and the report says why. + + Args: + plan: The plan whose function and scheme are acted on. + boundaries: Boundary values to merge; defaults to the plan's. + dry_run: Leave the statements unexecuted (the default). + drop_scaffolding: Also drop the scheme and function when unused. + """ + self._require_merge() + requested = tuple(plan.boundaries if boundaries is None else boundaries) + functions = self._read_functions(plan.function) + schemes = self._read_schemes(function_name=plan.function) + tables = self._read_tables() + exists = self._read_boundary_exists_for(plan, requested) + planned = self._plan_drop( + plan, + functions, + schemes, + tables, + requested, + exists, + drop_scaffolding, + ) + + issues = [] + info = self._find_function(functions, plan.function) + if info is None: + issues.append(f"partition function {plan.function!r} does not exist") + for boundary, present in zip(requested, exists): + if not present: + issues.append( + f"boundary {boundary!r} is not present in " + f"{plan.function!r}; nothing to merge" + ) + if drop_scaffolding: + for scheme in schemes: + if scheme.function != plan.function: + continue + if plan.scheme is not None and scheme.name != plan.scheme: + continue + users = [t for t in tables if t.scheme == scheme.name] + if users: + issues.append( + f"scheme {scheme.name!r} is used by " + f"{len(users)} table(s); it cannot be dropped" + ) + dropped = { + operation.name + for operation, _ in planned + if operation.action == "drop_scheme" + } + remaining = [ + scheme + for scheme in schemes + if scheme.function == plan.function and scheme.name not in dropped + ] + if remaining: + issues.append( + f"partition function {plan.function!r} is still mapped by " + f"scheme(s) {[s.name for s in remaining]}; it cannot be dropped" + ) + report = self._report( + plan, functions, schemes, tables, (), (), (), issues, planned, dry_run + ) + errors, completed = self._run(planned, dry_run) + if dry_run: + return report + post = self.list(plan) + return replace( + post, + operations=tuple(operation for operation, _ in planned[:completed]), + issues=tuple(dict.fromkeys((*issues, *post.issues))), + errors=errors, + dry_run=False, + ) + + def validate( + self, + plan: SQLServerPartitionPlan, + *, + table: Optional[str] = None, + schema: Optional[str] = None, + ) -> PartitionReport: + """Report gaps, mismatches and partition-number holes; repair nothing. + + Args: + plan: The plan to validate against the catalogue. + table: Optionally also check that this table is on the plan's + scheme, uses its function, and has one row per partition. + schema: Schema the table lives in. + """ + report = self.list(plan, table=table, schema=schema) + issues = self._plan_validate(report, plan, table) + return replace(report, issues=issues) + + def _read_boundary_exists_for( + self, plan: SQLServerPartitionPlan, boundaries: Sequence[Any] + ) -> Tuple[bool, ...]: + exists = [] + for boundary in boundaries: + rows = self._fetch_rows( + SQLServerPartitionBoundaryQueryStatement( + self.dialect, + function_name=plan.function, + data_type=plan.data_type, + boundary_value=_boundary_parameter(boundary), + ) + ) + exists.append(bool(rows and _as_int(rows[0].get("boundary_exists")))) + return tuple(exists) + + +class AsyncSQLServerPartitionMaintenance(_SQLServerPartitionMaintenanceBase): + """Asynchronous SQL Server partition maintenance. + + Mirrors :class:`SQLServerPartitionMaintenance` method for method; only + the I/O seams await. + """ + + async def _fetch_rows(self, statement: Any) -> Sequence[Any]: + sql, params = statement.to_sql() + result = await self.backend.execute(sql, params) + return result.data or [] + + async def _read_functions( + self, function_name: Optional[str] = None + ) -> Tuple[PartitionFunctionInfo, ...]: + return self._parse_functions( + await self._fetch_rows( + SQLServerPartitionFunctionQueryStatement( + self.dialect, function_name=function_name + ) + ) + ) + + async def _read_schemes( + self, + scheme_name: Optional[str] = None, + function_name: Optional[str] = None, + ) -> Tuple[PartitionSchemeInfo, ...]: + return self._parse_schemes( + await self._fetch_rows( + SQLServerPartitionSchemeQueryStatement( + self.dialect, + scheme_name=scheme_name, + function_name=function_name, + ) + ) + ) + + async def _read_tables( + self, + table_name: Optional[str] = None, + schema: Optional[str] = None, + scheme_name: Optional[str] = None, + ) -> Tuple[PartitionTableInfo, ...]: + return self._parse_tables( + await self._fetch_rows( + SQLServerPartitionTableQueryStatement( + self.dialect, + table_name=table_name, + schema=schema, + scheme_name=scheme_name, + ) + ) + ) + + async def _read_ranges( + self, table_name: str, schema: Optional[str] = None + ) -> Tuple[PartitionRangeInfo, ...]: + return self._parse_ranges( + await self._fetch_rows( + SQLServerPartitionRangeQueryStatement( + self.dialect, table_name=table_name, schema=schema + ) + ) + ) + + async def _read_boundary_exists( + self, plan: SQLServerPartitionPlan + ) -> Tuple[bool, ...]: + exists = [] + for boundary in plan.boundaries: + rows = await self._fetch_rows( + SQLServerPartitionBoundaryQueryStatement( + self.dialect, + function_name=plan.function, + data_type=plan.data_type, + boundary_value=_boundary_parameter(boundary), + ) + ) + exists.append(bool(rows and _as_int(rows[0].get("boundary_exists")))) + return tuple(exists) + + async def _read_boundary_exists_for( + self, plan: SQLServerPartitionPlan, boundaries: Sequence[Any] + ) -> Tuple[bool, ...]: + exists = [] + for boundary in boundaries: + rows = await self._fetch_rows( + SQLServerPartitionBoundaryQueryStatement( + self.dialect, + function_name=plan.function, + data_type=plan.data_type, + boundary_value=_boundary_parameter(boundary), + ) + ) + exists.append(bool(rows and _as_int(rows[0].get("boundary_exists")))) + return tuple(exists) + + async def _execute(self, sql: str, params: Tuple[Any, ...]) -> None: + await self.backend.execute(sql, params) + + async def _run( + self, + planned: Sequence[Tuple[PartitionOperation, Tuple[Any, ...]]], + dry_run: bool, + ) -> Tuple[Tuple[str, ...], int]: + """Execute the planned statements asynchronously, stopping at the first error. + + Returns: + ``(errors, completed)``: the failure text (empty when nothing + failed) and how many statements ran successfully. + """ + if dry_run: + return (), 0 + completed = 0 + for operation, params in planned: + try: + await self._execute(operation.sql, params) + except DatabaseError as exc: + return ( + f"{operation.action} {operation.name!r} failed: {exc}", + ), completed + completed += 1 + return (), completed + + async def ensure( + self, plan: SQLServerPartitionPlan, *, dry_run: bool = False + ) -> PartitionReport: + """Create what is missing and split absent boundaries asynchronously. + + The function is created with the plan's full boundary list when it + does not exist; when it does, only boundaries the server does not + already hold are split in. A scheme is created on demand. Existing + objects are never altered: a direction or type mismatch raises + ``ValueError`` rather than being repaired behind the caller's back. + + Args: + plan: The function, boundaries and optional scheme to ensure. + dry_run: Build the statement list without executing it. + + Returns: + A report whose ``operations`` are the statements run (or would be + run), and whose ``issues`` are inconsistencies found while + planning. + """ + self._require_support(plan) + functions = await self._read_functions(plan.function) + exists = await self._read_boundary_exists(plan) + schemes = ( + await self._read_schemes(plan.scheme, plan.function) if plan.scheme else () + ) + missing, planned, issues = self._plan_ensure(plan, functions, schemes, exists) + existing = () + info = self._find_function(functions, plan.function) + if info is not None: + existing = info.boundary_values + report = self._report( + plan, functions, schemes, (), (), missing, existing, issues, planned, dry_run + ) + errors, completed = await self._run(planned, dry_run) + if dry_run: + return report + post = await self.list(plan) + return replace( + post, + operations=tuple(operation for operation, _ in planned[:completed]), + issues=tuple(dict.fromkeys((*issues, *post.issues))), + errors=errors, + dry_run=False, + ) + + async def list( + self, + plan: Optional[SQLServerPartitionPlan] = None, + *, + table: Optional[str] = None, + schema: Optional[str] = None, + ) -> PartitionReport: + """Read the catalogue and report the tree asynchronously. + + Args: + plan: When given, narrows to its function and scheme and reports + its missing boundaries. + table: When given, also returns that table's per-partition rows. + schema: Schema the requested table lives in. + """ + function_name = None if plan is None else plan.function + scheme_name = None if plan is None else plan.scheme + functions = await self._read_functions(function_name) + schemes = ( + await self._read_schemes(scheme_name, function_name) if plan else () + ) + tables = await self._read_tables( + table, schema, None if (plan is None or table) else plan.scheme + ) + ranges = await self._read_ranges(table, schema) if table else () + exists = await self._read_boundary_exists(plan) if plan is not None else () + return self._plan_list(plan, functions, schemes, tables, ranges, exists) + + async def retention_boundaries( + self, + plan: SQLServerPartitionPlan, + cutoff: Any, + *, + table: Optional[str] = None, + schema: Optional[str] = None, + ) -> Tuple[Any, ...]: + """Return the boundaries a retention cut would merge away asynchronously. + + SQL Server's function boundary is the upper bound of the partition on + its left, whichever RANGE direction the function declares, so a + boundary qualifies when it is less than or equal to *cutoff*. + + Args: + plan: Plan naming the function and its boundary type. + cutoff: The oldest value to keep. + table: When given, read the table's partition ranges instead of + the function's boundary list. + schema: Schema the table lives in. + + Returns: + Boundary values, in ascending order, whose partition lies wholly + before or on *cutoff*. Nothing is executed. + """ + function = self._find_function( + await self._read_functions(plan.function), plan.function + ) + if table is not None: + texts = tuple( + row.high_boundary + for row in await self._read_ranges(table, schema) + if row.high_boundary is not None + ) + elif function is not None: + texts = function.boundary_values + else: + return () + cutoff_value = _coerce_cutoff(plan.data_type, cutoff) + values = tuple( + _parse_catalogue_boundary(plan.data_type, text) for text in texts + ) + return tuple(value for value in values if value <= cutoff_value) + + async def drop_before( + self, + plan: SQLServerPartitionPlan, + cutoff: Any, + *, + dry_run: bool = True, + drop_scaffolding: bool = False, + ) -> PartitionReport: + """Merge the partitions at or before *cutoff* asynchronously. + + The boundaries are computed from the catalogue by + :meth:`retention_boundaries` and handed to :meth:`drop_or_merge`. + SQL Server cannot drop a partition, so this is a ``MERGE RANGE`` + operation, not a metadata-only drop. + + Args: + plan: The plan whose function is acted on. + cutoff: The oldest value to keep. + dry_run: Render the statements without executing them (the + default). + drop_scaffolding: Also drop the scheme and function when unused. + """ + boundaries = await self.retention_boundaries(plan, cutoff) + return await self.drop_or_merge( + plan, + boundaries, + dry_run=dry_run, + drop_scaffolding=drop_scaffolding, + ) + + async def drop_or_merge( + self, + plan: SQLServerPartitionPlan, + boundaries: Optional[Sequence[Any]] = None, + *, + dry_run: bool = True, + drop_scaffolding: bool = False, + ) -> PartitionReport: + """Merge boundaries away, or drop the unused scaffolding, asynchronously. + + SQL Server has no ``DROP PARTITION``; a boundary is removed with + ``MERGE RANGE``, which folds its rows into the partition on the left. + ``boundaries`` names exactly what to merge -- there is no retention + policy here, and no "before date" shorthand. When omitted, the plan's + own boundaries are merged, collapsing the function to one partition. + + ``drop_scaffolding`` drops the scheme and then the function, but only + when the catalogue shows no table built on either. A table using the + scheme keeps both, and the report says why. + + Args: + plan: The plan whose function and scheme are acted on. + boundaries: Boundary values to merge; defaults to the plan's. + dry_run: Leave the statements unexecuted (the default). + drop_scaffolding: Also drop the scheme and function when unused. + """ + self._require_merge() + requested = tuple(plan.boundaries if boundaries is None else boundaries) + functions = await self._read_functions(plan.function) + schemes = await self._read_schemes(function_name=plan.function) + tables = await self._read_tables() + exists = await self._read_boundary_exists_for(plan, requested) + planned = self._plan_drop( + plan, + functions, + schemes, + tables, + requested, + exists, + drop_scaffolding, + ) + + issues = [] + info = self._find_function(functions, plan.function) + if info is None: + issues.append(f"partition function {plan.function!r} does not exist") + for boundary, present in zip(requested, exists): + if not present: + issues.append( + f"boundary {boundary!r} is not present in " + f"{plan.function!r}; nothing to merge" + ) + if drop_scaffolding: + for scheme in schemes: + if scheme.function != plan.function: + continue + if plan.scheme is not None and scheme.name != plan.scheme: + continue + users = [t for t in tables if t.scheme == scheme.name] + if users: + issues.append( + f"scheme {scheme.name!r} is used by " + f"{len(users)} table(s); it cannot be dropped" + ) + dropped = { + operation.name + for operation, _ in planned + if operation.action == "drop_scheme" + } + remaining = [ + scheme + for scheme in schemes + if scheme.function == plan.function and scheme.name not in dropped + ] + if remaining: + issues.append( + f"partition function {plan.function!r} is still mapped by " + f"scheme(s) {[s.name for s in remaining]}; it cannot be dropped" + ) + report = self._report( + plan, functions, schemes, tables, (), (), (), issues, planned, dry_run + ) + errors, completed = await self._run(planned, dry_run) + if dry_run: + return report + post = await self.list(plan) + return replace( + post, + operations=tuple(operation for operation, _ in planned[:completed]), + issues=tuple(dict.fromkeys((*issues, *post.issues))), + errors=errors, + dry_run=False, + ) + + async def validate( + self, + plan: SQLServerPartitionPlan, + *, + table: Optional[str] = None, + schema: Optional[str] = None, + ) -> PartitionReport: + """Report gaps, mismatches and partition-number holes asynchronously. + + Nothing is repaired. + + Args: + plan: The plan to validate against the catalogue. + table: Optionally also check that this table is on the plan's + scheme, uses its function, and has one row per partition. + schema: Schema the table lives in. + """ + report = await self.list(plan, table=table, schema=schema) + issues = self._plan_validate(report, plan, table) + return replace(report, issues=issues) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/partition/plan.py b/src/rhosocial/activerecord/backend/impl/sqlserver/partition/plan.py new file mode 100644 index 0000000..d57b57f --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/partition/plan.py @@ -0,0 +1,198 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/partition/plan.py +"""The owner-agnostic description of a SQL Server partition layout. + +A plan names the partition function, the boundary values and the optional +partition scheme. It carries no table name, no column name and no retention +rule: those belong to the application that owns the data, exactly as they do +for the other backends' partition helpers. + +SQL Server has one strategy -- RANGE, with either side of a boundary owning +the value -- and no subpartitioning at any version. The two fields that could +ask for another strategy are present so the refusal is explicit at construction +rather than a conventional promise not to pass them. +""" + +from dataclasses import dataclass +from datetime import date, datetime, time +from decimal import Decimal +from typing import Any, Optional, Tuple + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +from ..expression.partition import ( + SQLServerPartitionRangeDirection, + _as_range_direction, + validate_partition_data_type, +) + +__all__ = ["SQLServerPartitionPlan"] + + +def _boundary_token(value: Any) -> Tuple[str, str]: + """A comparable token for one boundary value. + + The token is used only to find duplicates inside one plan, never to read + the catalogue: boundary comparison against the server is done by the + server, with the value cast to the function's declared type. + """ + if isinstance(value, bool): + return "bool", "1" if value else "0" + if isinstance(value, int): + return "int", str(value) + if isinstance(value, (float, Decimal)): + return "number", format(value, "f") + if isinstance(value, datetime): + return "datetime", value.isoformat(sep=" ") + if isinstance(value, date): + return "date", value.isoformat() + if isinstance(value, time): + return "time", value.isoformat() + if isinstance(value, str): + return "str", value + raise ValueError( + f"unsupported partition boundary {value!r}; use a number, string or " + f"date/time value" + ) + + +@dataclass(frozen=True) +class SQLServerPartitionPlan: + """A partition function, its boundaries and an optional scheme. + + Attributes: + function: Partition function name, optionally schema-qualified with + dots. It is a name the caller owns; the library has no default. + data_type: The boundary column's declared type, e.g. ``DATE``, + ``INT`` or ``NVARCHAR(50)``. + boundaries: Boundary values in ascending order. ``N`` boundaries + describe ``N + 1`` partitions; the plan sorts them and refuses + duplicates. + range_direction: ``RIGHT`` (the default in SQL Server: the boundary + value belongs to the partition on its right) or ``LEFT``. + scheme: Partition scheme name, or ``None`` when the caller only wants + the function managed. + filegroups: Explicit destinations for the scheme, one per partition. + Mutually exclusive with ``all_filegroup``; when given, + ``len(filegroups)`` must equal ``len(boundaries) + 1``. + all_filegroup: A single filegroup for every partition. Mutually + exclusive with ``filegroups``. + next_filegroup: The filegroup a ``SPLIT RANGE`` consumes. Defaults to + the last explicit filegroup, or to ``all_filegroup``; it is + recorded here so the executor can emit ``NEXT USED`` before each + split instead of relying on the scheme's current state. + strategy: Always ``"RANGE"``. Present so a non-RANGE request is + refused explicitly rather than silently interpreted as RANGE. + subpartition_by: Always ``None``. SQL Server has no subpartitioning; + any value is refused with :class:`UnsupportedFeatureError`. + + Example: + >>> plan = SQLServerPartitionPlan( + ... function="pf_logs", + ... data_type="DATE", + ... boundaries=(date(2026, 1, 1), date(2026, 2, 1)), + ... scheme="ps_logs", + ... all_filegroup="PRIMARY", + ... ) + """ + + function: str + data_type: str + boundaries: Tuple[Any, ...] + range_direction: SQLServerPartitionRangeDirection = ( + SQLServerPartitionRangeDirection.RIGHT + ) + scheme: Optional[str] = None + filegroups: Tuple[str, ...] = () + all_filegroup: Optional[str] = None + next_filegroup: Optional[str] = None + strategy: str = "RANGE" + subpartition_by: Optional[str] = None + + def __post_init__(self) -> None: + """Normalise and validate the plan. + + Raises: + ValueError: A name is empty, a type is not a plain SQL type, the + boundaries are empty, duplicated, or mutually incomparable, or + the scheme/filegroup combination is inconsistent. + UnsupportedFeatureError: ``strategy`` is not RANGE, or a + subpartitioning policy was requested. + """ + if not isinstance(self.function, str) or not self.function.strip(): + raise ValueError("function must be a non-empty string") + data_type = validate_partition_data_type(self.data_type) + + if not isinstance(self.strategy, str) or self.strategy.strip().upper() != "RANGE": + raise UnsupportedFeatureError( + "SQL Server", + f"{self.strategy!r} partitioning", + "SQL Server supports RANGE partitioning only.", + ) + if self.subpartition_by is not None: + raise UnsupportedFeatureError( + "SQL Server", + f"subpartitioning by {self.subpartition_by!r}", + "SQL Server has no subpartitioning; use one RANGE level.", + ) + + values = tuple(self.boundaries) + if not values: + raise ValueError("boundaries must contain at least one value") + tokens = [_boundary_token(value) for value in values] + if len(set(tokens)) != len(tokens): + raise ValueError("boundaries must be unique") + try: + sorted_values = tuple(sorted(values)) + except TypeError as exc: + raise ValueError("boundaries must be mutually comparable") from exc + + direction = _as_range_direction(self.range_direction) + + scheme = self.scheme + if scheme is not None and (not isinstance(scheme, str) or not scheme.strip()): + raise ValueError("scheme must be a non-empty string or None") + filegroups = tuple(self.filegroups) + if scheme is None: + if filegroups or self.all_filegroup is not None or self.next_filegroup is not None: + raise ValueError( + "filegroups, all_filegroup and next_filegroup require a scheme" + ) + else: + if not filegroups and self.all_filegroup is None: + raise ValueError( + "a scheme needs either explicit filegroups or all_filegroup" + ) + if filegroups and self.all_filegroup is not None: + raise ValueError( + "filegroups and all_filegroup are mutually exclusive" + ) + if filegroups and len(filegroups) != len(sorted_values) + 1: + raise ValueError( + f"{len(filegroups)} filegroups cannot serve " + f"{len(sorted_values) + 1} partitions; pass one filegroup " + f"per partition" + ) + next_filegroup = self.next_filegroup + if next_filegroup is None: + next_filegroup = filegroups[-1] if filegroups else self.all_filegroup + elif filegroups and next_filegroup not in filegroups: + raise ValueError( + f"next_filegroup {next_filegroup!r} is not one of the plan's " + f"filegroups" + ) + elif self.all_filegroup is not None and next_filegroup != self.all_filegroup: + raise ValueError( + f"next_filegroup {next_filegroup!r} must equal " + f"all_filegroup {self.all_filegroup!r}" + ) + object.__setattr__(self, "next_filegroup", next_filegroup) + + object.__setattr__(self, "data_type", data_type) + object.__setattr__(self, "boundaries", sorted_values) + object.__setattr__(self, "range_direction", direction) + object.__setattr__(self, "filegroups", filegroups) + + @property + def partition_count(self) -> int: + """How many partitions the plan describes.""" + return len(self.boundaries) + 1 diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/partition/report.py b/src/rhosocial/activerecord/backend/impl/sqlserver/partition/report.py new file mode 100644 index 0000000..82df82a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/partition/report.py @@ -0,0 +1,173 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/partition/report.py +"""Value objects returned by SQL Server partition maintenance. + +The objects keep the catalogue's own spelling -- the function's type name, +the scheme's filegroups, the boundary text the server rendered -- because a +value this package cannot parse is still information an operator needs. +""" + +from dataclasses import dataclass +from typing import Any, Optional, Tuple + +from rhosocial.activerecord.backend.partition import ( + PartitionOperation as CorePartitionOperation, + PartitionReport as CorePartitionReport, +) + +__all__ = [ + "PartitionFunctionInfo", + "PartitionOperation", + "PartitionRangeInfo", + "PartitionReport", + "PartitionSchemeInfo", + "PartitionTableInfo", +] + + +@dataclass(frozen=True) +class PartitionFunctionInfo: + """One partition function as the catalogue reports it. + + Attributes: + name: Function name. + data_type: The declared boundary type, e.g. ``date``. + range_direction: ``LEFT`` or ``RIGHT``. + boundary_values: Boundary text in boundary-id order, kept verbatim. + partition_count: ``sys.partition_functions.fanout`` -- one more than + the boundary count. + """ + + name: str + data_type: str + range_direction: str + boundary_values: Tuple[str, ...] = () + partition_count: int = 0 + + +@dataclass(frozen=True) +class PartitionSchemeInfo: + """One partition scheme and its destinations. + + Attributes: + name: Scheme name. + function: The partition function the scheme maps. + filegroups: Destination filegroup names in destination order. + partition_count: The function's partition count at read time. + """ + + name: str + function: str + filegroups: Tuple[str, ...] = () + partition_count: int = 0 + + @property + def destination_count(self) -> int: + """How many destinations the scheme currently declares.""" + return len(self.filegroups) + + +@dataclass(frozen=True) +class PartitionTableInfo: + """One table built on a partition scheme. + + Attributes: + name: Table name. + schema: Schema the table lives in. + scheme: Partition scheme the table is built on. + function: Partition function behind that scheme. + partition_column: The column named in ``ON scheme(column)``, when the + catalogue could resolve it. + partition_count: The function's partition count at read time. + """ + + name: str + schema: Optional[str] + scheme: str + function: str + partition_column: Optional[str] = None + partition_count: int = 0 + + +@dataclass(frozen=True) +class PartitionRangeInfo: + """One partition of one table. + + Attributes: + table: Table name. + schema: Schema the table lives in. + partition_number: SQL Server's partition number, from 1. + low_boundary: Lower boundary text, or ``None`` for the first + partition. + high_boundary: Upper boundary text, or ``None`` for the last + partition. + row_count: ``sys.partitions.rows``. + data_bytes: ``used_page_count`` multiplied by SQL Server's fixed 8 KiB + page. + """ + + table: str + schema: Optional[str] + partition_number: int + low_boundary: Optional[str] = None + high_boundary: Optional[str] = None + row_count: Optional[int] = None + data_bytes: Optional[int] = None + + +# SQL Server's operation record is the core contract's record; the core field +# ``parent``/``level`` are optional because SQL Server has no tree. +PartitionOperation = CorePartitionOperation + + +@dataclass(frozen=True) +class PartitionReport(CorePartitionReport): + """What the catalogue contains, and what an executor did or would do. + + SQL Server's report keeps the core envelope and adds the four catalogue + collections its model has: functions, schemes, tables and per-partition + ranges. ``target`` is the plan's function name (SQL Server always needs a + function) or ``"(all)"`` for a catalogue-wide read. + + Attributes: + function: The plan's function name, when the request named one. + scheme: The plan's scheme name, when the request named one. + functions: Every partition function the catalogue returned. + schemes: Every partition scheme the catalogue returned. + tables: Every partitioned table the catalogue returned. + ranges: Per-partition rows for the requested table, when one was + requested. + missing_boundaries: Plan boundary values the function does not + contain. Empty when the function itself is absent (all of the + plan is missing, not just some boundaries). + existing_boundaries: Boundary text the catalogue reported, in order. + """ + + function: Optional[str] = None + scheme: Optional[str] = None + functions: Tuple[PartitionFunctionInfo, ...] = () + schemes: Tuple[PartitionSchemeInfo, ...] = () + tables: Tuple[PartitionTableInfo, ...] = () + ranges: Tuple[PartitionRangeInfo, ...] = () + missing_boundaries: Tuple[Any, ...] = () + existing_boundaries: Tuple[str, ...] = () + + def function_info(self, name: str) -> Optional[PartitionFunctionInfo]: + """Return the named function, or ``None`` when it was not reported.""" + for info in self.functions: + if info.name == name: + return info + return None + + def scheme_info(self, name: str) -> Optional[PartitionSchemeInfo]: + """Return the named scheme, or ``None`` when it was not reported.""" + for info in self.schemes: + if info.name == name: + return info + return None + + def table_info(self, name: str) -> Optional[PartitionTableInfo]: + """Return the named table, or ``None`` when it was not reported.""" + for info in self.tables: + if info.name == name: + return info + return None diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/partition.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/partition.py index 34f363d..f3d1f8a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/partition.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/partition.py @@ -10,14 +10,20 @@ - SQL Server 2008+ (basic partitioning) """ -from typing import Protocol, Sequence, Tuple, runtime_checkable, TYPE_CHECKING +from typing import Protocol, Tuple, runtime_checkable, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.protocols import PartitionSupport if TYPE_CHECKING: from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( + SQLServerDropPartitionFunctionExpression, + SQLServerDropPartitionSchemeExpression, SQLServerPartitionFunctionExpression, + SQLServerPartitionFunctionMergeExpression, + SQLServerPartitionFunctionSplitExpression, SQLServerPartitionSchemeExpression, + SQLServerPartitionSchemeNextUsedExpression, + SQLServerSwitchPartitionExpression, ) @@ -64,3 +70,39 @@ def format_partition_function_expression(self, expr: "SQLServerPartitionFunction def format_partition_scheme_expression(self, expr: "SQLServerPartitionSchemeExpression") -> Tuple[str, tuple]: """Format CREATE PARTITION SCHEME statement.""" ... + + def format_partition_function_split_expression( + self, expr: "SQLServerPartitionFunctionSplitExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER PARTITION FUNCTION ... SPLIT RANGE``.""" + ... + + def format_partition_function_merge_expression( + self, expr: "SQLServerPartitionFunctionMergeExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER PARTITION FUNCTION ... MERGE RANGE``.""" + ... + + def format_partition_scheme_next_used_expression( + self, expr: "SQLServerPartitionSchemeNextUsedExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER PARTITION SCHEME ... NEXT USED``.""" + ... + + def format_switch_partition_expression( + self, expr: "SQLServerSwitchPartitionExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... SWITCH [PARTITION n] TO``.""" + ... + + def format_drop_partition_function_expression( + self, expr: "SQLServerDropPartitionFunctionExpression" + ) -> Tuple[str, tuple]: + """Format ``DROP PARTITION FUNCTION``.""" + ... + + def format_drop_partition_scheme_expression( + self, expr: "SQLServerDropPartitionSchemeExpression" + ) -> Tuple[str, tuple]: + """Format ``DROP PARTITION SCHEME``.""" + ... diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/partition_syntax_fixtures.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/partition_syntax_fixtures.py new file mode 100644 index 0000000..84765ef --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/partition_syntax_fixtures.py @@ -0,0 +1,721 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/partition_syntax_fixtures.py +"""Fixture-driven matrix of SQL Server partition syntax families. + +Each :class:`PartitionSyntaxCase` names one family of SQL Server's three-tier +partition model -- the ``RANGE LEFT``/``RANGE RIGHT`` function, the scheme +destinations, ``NEXT USED``, ``SPLIT``/``MERGE``/``SWITCH``, the dependency +checked drops, and the maintenance executor's decisions -- and carries the +builders the offline matrix uses to exercise it. + +The cases do not repeat the exact assertions of +``test_sqlserver_partition.py`` or ``test_sqlserver_partition_maintenance.py``; +they collect the families those files cover into one parameterised table so a +new syntax family is added in one place. No server is needed: every builder +returns a plan, an expression, or a maintenance report built from canned +catalogue rows. +""" + +from dataclasses import dataclass +from datetime import date +from typing import Any, Callable, Mapping, Optional, Sequence, Tuple + +import pytest + +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( + SQLServerDropPartitionFunctionExpression, + SQLServerDropPartitionSchemeExpression, + SQLServerPartitionFunctionExpression, + SQLServerPartitionFunctionMergeExpression, + SQLServerPartitionFunctionSplitExpression, + SQLServerPartitionSchemeExpression, + SQLServerPartitionSchemeNextUsedExpression, + SQLServerSwitchPartitionExpression, +) +from rhosocial.activerecord.backend.impl.sqlserver.partition import ( + PartitionFunctionInfo, + PartitionRangeInfo, + PartitionReport, + PartitionSchemeInfo, + PartitionTableInfo, + SQLServerPartitionMaintenance, + SQLServerPartitionPlan, +) + +#: Build a plan with no arguments, or refuse at construction when it asks for +#: something SQL Server cannot express. +PlanBuilder = Callable[[], SQLServerPartitionPlan] + +#: Render one family's statements; the plan is ``None`` only for a case that +#: does not need one (``SWITCH`` names two tables instead). +DDLBuilder = Callable[ + [SQLServerDialect, Optional[SQLServerPartitionPlan]], Sequence[Any] +] + +#: Run a maintenance operation set and return its reports, keyed by operation. +MaintenanceBuilder = Callable[ + [SQLServerDialect], Mapping[str, PartitionReport] +] + +#: Reserved for a live suite: set up the family against a server, then check +#: what only a server can answer. The offline matrix never calls either hook. +LiveHook = Callable[[Any, SQLServerPartitionPlan], Any] + +_BOUNDARIES = (date(2026, 1, 1), date(2026, 2, 1)) +_THREE_BOUNDARIES = _BOUNDARIES + (date(2026, 3, 1),) +_SPLIT_BOUNDARY = date(2026, 3, 1) + + +@dataclass(frozen=True) +class MaintenanceExpectation: + """What one maintenance operation should decide. + + Attributes: + operation: The report key the case's maintenance builder returns. + actions: Expected ``PartitionOperation.action`` values, in order. + missing_boundaries: Expected ``report.missing_boundaries``; ``None`` + skips the check. + existing_boundaries: Expected ``report.existing_boundaries``; ``None`` + skips the check. + issue_fragments: Substrings that must appear in at least one issue. + sql_fragments: Substrings that must appear in at least one operation's + rendered SQL. + expect_dry_run: Expected ``report.dry_run``; ``None`` skips the check, + because a read-only ``list``/``validate`` is neither a dry run nor + an execution. + """ + + operation: str + actions: Tuple[str, ...] = () + missing_boundaries: Optional[Tuple[Any, ...]] = None + existing_boundaries: Optional[Tuple[str, ...]] = None + issue_fragments: Tuple[str, ...] = () + sql_fragments: Tuple[str, ...] = () + expect_dry_run: Optional[bool] = None + + +@dataclass(frozen=True) +class PartitionSyntaxCase: + """One SQL Server partition syntax family and how to exercise it. + + Attributes: + name: Stable identifier used as the parameterised test id. + description: One sentence saying which family this is. + plan_builder: Builds the ``SQLServerPartitionPlan`` under test, if any. + ddl_builder: Renders the family's DDL expressions from a dialect and + the plan (``None`` when the family needs no DDL assertions). + expected_fragments: Substrings required in the rendered DDL. + forbidden_fragments: Substrings that must not appear in the DDL. + expected_partition_count: Expected ``plan.partition_count``. + expected_direction: Expected ``plan.range_direction.value``. + expected_filegroups: Expected explicit ``plan.filegroups``. + expected_next_filegroup: Expected ``plan.next_filegroup``. + refuse_subpartition: The plan builder must raise + ``UnsupportedFeatureError`` for a requested subpartitioning policy. + maintenance_builder: Runs the maintenance operations to inspect. + maintenance_expectations: One expectation per operation the builder + returns. + live_setup: Optional live-suite setup hook (offline tests ignore it). + live_check: Optional live-suite assertion hook (offline tests ignore it). + """ + + name: str + description: str + plan_builder: Optional[PlanBuilder] = None + ddl_builder: Optional[DDLBuilder] = None + expected_fragments: Tuple[str, ...] = () + forbidden_fragments: Tuple[str, ...] = () + expected_partition_count: Optional[int] = None + expected_direction: Optional[str] = None + expected_filegroups: Optional[Tuple[str, ...]] = None + expected_next_filegroup: Optional[str] = None + refuse_subpartition: bool = False + maintenance_builder: Optional[MaintenanceBuilder] = None + maintenance_expectations: Tuple[MaintenanceExpectation, ...] = () + live_setup: Optional[LiveHook] = None + live_check: Optional[LiveHook] = None + + +def _plan_builder(**overrides: Any) -> PlanBuilder: + """Return a builder for a plan carrying *overrides*.""" + + def build() -> SQLServerPartitionPlan: + return SQLServerPartitionPlan(**overrides) + + return build + + +def _function_ddl( + dialect: SQLServerDialect, plan: Optional[SQLServerPartitionPlan] +) -> Sequence[Any]: + """Render ``CREATE PARTITION FUNCTION`` from the plan.""" + return ( + SQLServerPartitionFunctionExpression( + dialect, + plan.function, + plan.data_type, + plan.boundaries, + range_direction=plan.range_direction, + ), + ) + + +def _scheme_ddl( + dialect: SQLServerDialect, plan: Optional[SQLServerPartitionPlan] +) -> Sequence[Any]: + """Render ``CREATE PARTITION SCHEME`` from the plan's destinations.""" + return ( + SQLServerPartitionSchemeExpression( + dialect, + plan.scheme, + plan.function, + plan.filegroups, + all_filegroup=plan.all_filegroup, + ), + ) + + +def _next_used_ddl( + dialect: SQLServerDialect, plan: Optional[SQLServerPartitionPlan] +) -> Sequence[Any]: + """Render ``ALTER PARTITION SCHEME ... NEXT USED``.""" + return ( + SQLServerPartitionSchemeNextUsedExpression( + dialect, plan.scheme, plan.next_filegroup + ), + ) + + +def _split_ddl( + dialect: SQLServerDialect, plan: Optional[SQLServerPartitionPlan] +) -> Sequence[Any]: + """Render ``SPLIT RANGE`` at a boundary the plan does not yet hold.""" + return ( + SQLServerPartitionFunctionSplitExpression( + dialect, plan.function, _SPLIT_BOUNDARY + ), + ) + + +def _merge_ddl( + dialect: SQLServerDialect, plan: Optional[SQLServerPartitionPlan] +) -> Sequence[Any]: + """Render ``MERGE RANGE`` at the plan's first boundary.""" + return ( + SQLServerPartitionFunctionMergeExpression( + dialect, plan.function, plan.boundaries[0] + ), + ) + + +def _switch_ddl( + dialect: SQLServerDialect, plan: Optional[SQLServerPartitionPlan] +) -> Sequence[Any]: + """Render a schema-qualified ``SWITCH PARTITION``.""" + return ( + SQLServerSwitchPartitionExpression( + dialect, + "logs", + "logs_archive", + source_partition=2, + schema="dbo", + target_schema="dbo", + ), + ) + + +def _drops_ddl( + dialect: SQLServerDialect, plan: Optional[SQLServerPartitionPlan] +) -> Sequence[Any]: + """Render the scheme drop before the function drop.""" + return ( + SQLServerDropPartitionSchemeExpression(dialect, plan.scheme), + SQLServerDropPartitionFunctionExpression(dialect, plan.function), + ) + + +# --------------------------------------------------------------------------- +# Canned catalogue rows for the maintenance families +# --------------------------------------------------------------------------- + + +def _canned_function() -> PartitionFunctionInfo: + """A function with two boundaries, so it has three partitions.""" + return PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=("2026-01-01", "2026-02-01"), + partition_count=3, + ) + + +def _canned_scheme() -> PartitionSchemeInfo: + """The scheme mapping the canned function, with a spare destination.""" + return PartitionSchemeInfo( + name="ps_logs", + function="pf_logs", + filegroups=("PRIMARY", "PRIMARY", "PRIMARY", "PRIMARY"), + partition_count=3, + ) + + +def _canned_table() -> PartitionTableInfo: + """One table built on the canned scheme.""" + return PartitionTableInfo( + name="logs", + schema="dbo", + scheme="ps_logs", + function="pf_logs", + partition_column="created_at", + partition_count=3, + ) + + +def _canned_ranges() -> Tuple[PartitionRangeInfo, ...]: + """The three partitions the canned table occupies.""" + return tuple( + PartitionRangeInfo(table="logs", schema="dbo", partition_number=number) + for number in (1, 2, 3) + ) + + +def _two_partition_plan() -> SQLServerPartitionPlan: + """A plan with two boundaries and ``ALL TO [PRIMARY]``.""" + return SQLServerPartitionPlan( + function="pf_logs", + data_type="DATE", + boundaries=_BOUNDARIES, + scheme="ps_logs", + all_filegroup="PRIMARY", + ) + + +def _three_partition_plan() -> SQLServerPartitionPlan: + """A plan adding one boundary to :func:`_two_partition_plan`.""" + return SQLServerPartitionPlan( + function="pf_logs", + data_type="DATE", + boundaries=_THREE_BOUNDARIES, + scheme="ps_logs", + all_filegroup="PRIMARY", + ) + + +class _FakeMaintenance(SQLServerPartitionMaintenance): + """A maintenance executor whose catalogue answers are supplied up front. + + It overrides only the I/O seams, so every decision under test runs the + same code path as the real executor while ``_execute`` records statements + instead of running them. + """ + + def __init__( + self, + *, + dialect: SQLServerDialect, + functions: Sequence[PartitionFunctionInfo] = (), + schemes: Sequence[PartitionSchemeInfo] = (), + tables: Sequence[PartitionTableInfo] = (), + ranges: Sequence[PartitionRangeInfo] = (), + exists: Optional[Mapping[Any, bool]] = None, + ) -> None: + self.dialect = dialect + self.functions = tuple(functions) + self.schemes = tuple(schemes) + self.tables = tuple(tables) + self.ranges = tuple(ranges) + self.exists = dict(exists or {}) + self.executed = [] + + def _read_functions( + self, function_name: Optional[str] = None + ) -> Tuple[PartitionFunctionInfo, ...]: + return tuple( + f for f in self.functions if function_name in (None, f.name) + ) + + def _read_schemes( + self, + scheme_name: Optional[str] = None, + function_name: Optional[str] = None, + ) -> Tuple[PartitionSchemeInfo, ...]: + return tuple( + s + for s in self.schemes + if scheme_name in (None, s.name) + and function_name in (None, s.function) + ) + + def _read_tables( + self, + table_name: Optional[str] = None, + schema: Optional[str] = None, + scheme_name: Optional[str] = None, + ) -> Tuple[PartitionTableInfo, ...]: + return tuple( + t + for t in self.tables + if table_name in (None, t.name) + and schema in (None, t.schema) + and scheme_name in (None, t.scheme) + ) + + def _read_ranges( + self, table_name: str, schema: Optional[str] = None + ) -> Tuple[PartitionRangeInfo, ...]: + return tuple(r for r in self.ranges if r.table == table_name) + + def _read_boundary_exists( + self, plan: SQLServerPartitionPlan + ) -> Tuple[bool, ...]: + return tuple(self.exists.get(boundary, False) for boundary in plan.boundaries) + + def _read_boundary_exists_for( + self, plan: SQLServerPartitionPlan, boundaries: Sequence[Any] + ) -> Tuple[bool, ...]: + return tuple(self.exists.get(boundary, False) for boundary in boundaries) + + def _execute(self, sql: str, params: Tuple[Any, ...]) -> None: + self.executed.append(sql) + + +def _drop_maintenance(dialect: SQLServerDialect) -> Mapping[str, PartitionReport]: + """Plan the two dependency-checked drop outcomes. + + The unused scheme is dropped before the function; the used scheme keeps + both because the catalogue reports a table built on it. + """ + function = _canned_function() + scheme = _canned_scheme() + table = _canned_table() + plan = _two_partition_plan() + unused = _FakeMaintenance( + dialect=dialect, functions=(function,), schemes=(scheme,) + ) + used = _FakeMaintenance( + dialect=dialect, + functions=(function,), + schemes=(scheme,), + tables=(table,), + ) + return { + "drop_unused": unused.drop_or_merge( + plan, [], dry_run=True, drop_scaffolding=True + ), + "drop_refused": used.drop_or_merge( + plan, [], dry_run=True, drop_scaffolding=True + ), + } + + +def _all_maintenance_operations( + dialect: SQLServerDialect, +) -> Mapping[str, PartitionReport]: + """Run ``ensure``, ``list``, ``drop_or_merge`` and ``validate`` once.""" + function = _canned_function() + scheme = _canned_scheme() + table = _canned_table() + ranges = _canned_ranges() + plan_two = _two_partition_plan() + plan_three = _three_partition_plan() + ensure_fake = _FakeMaintenance( + dialect=dialect, + functions=(function,), + schemes=(scheme,), + exists={ + date(2026, 1, 1): True, + date(2026, 2, 1): True, + date(2026, 3, 1): False, + }, + ) + report_fake = _FakeMaintenance( + dialect=dialect, + functions=(function,), + schemes=(scheme,), + tables=(table,), + ranges=ranges, + exists={date(2026, 1, 1): True, date(2026, 2, 1): True}, + ) + return { + "ensure": ensure_fake.ensure(plan_three, dry_run=True), + "list": report_fake.list(plan_two), + "drop_or_merge": report_fake.drop_or_merge( + plan_two, [date(2026, 1, 1)], dry_run=True, drop_scaffolding=True + ), + "validate": report_fake.validate(plan_two, table="logs", schema="dbo"), + } + + +def _live_ensure( + backend: Any, plan: SQLServerPartitionPlan +) -> PartitionReport: + """Reserved for a live suite: create the planned scaffolding.""" + return SQLServerPartitionMaintenance(backend).ensure(plan) + + +def _live_function_count_matches( + backend: Any, plan: SQLServerPartitionPlan +) -> bool: + """Reserved for a live suite: the function now has the planned fanout.""" + report = SQLServerPartitionMaintenance(backend).list(plan) + info = report.function_info(plan.function) + return info is not None and info.partition_count == plan.partition_count + + +# --------------------------------------------------------------------------- +# The matrix +# --------------------------------------------------------------------------- + +CASES: Tuple[PartitionSyntaxCase, ...] = ( + PartitionSyntaxCase( + name="range_right", + description="RANGE RIGHT owns the boundary value and is the default.", + plan_builder=_plan_builder( + function="pf_logs", data_type="DATE", boundaries=_BOUNDARIES + ), + ddl_builder=_function_ddl, + expected_fragments=( + "CREATE PARTITION FUNCTION [pf_logs] (DATE)", + "AS RANGE RIGHT FOR VALUES", + "'2026-01-01'", + "'2026-02-01'", + ), + forbidden_fragments=("RANGE LEFT", "CREATE PARTITION SCHEME"), + expected_partition_count=3, + expected_direction="RIGHT", + live_setup=_live_ensure, + live_check=_live_function_count_matches, + ), + PartitionSyntaxCase( + name="range_left", + description="RANGE LEFT owns the boundary value on its left side.", + plan_builder=_plan_builder( + function="pf_events", + data_type="INT", + boundaries=(100, 200), + range_direction="LEFT", + ), + ddl_builder=_function_ddl, + expected_fragments=( + "CREATE PARTITION FUNCTION [pf_events] (INT)", + "AS RANGE LEFT FOR VALUES (100, 200)", + ), + forbidden_fragments=("RANGE RIGHT",), + expected_partition_count=3, + expected_direction="LEFT", + live_setup=_live_ensure, + live_check=_live_function_count_matches, + ), + PartitionSyntaxCase( + name="scheme_all_primary", + description="A scheme may send every partition ALL TO one filegroup.", + plan_builder=_plan_builder( + function="pf_logs", + data_type="DATE", + boundaries=_BOUNDARIES, + scheme="ps_logs", + all_filegroup="PRIMARY", + ), + ddl_builder=_scheme_ddl, + expected_fragments=( + "CREATE PARTITION SCHEME [ps_logs] AS PARTITION [pf_logs] " + "ALL TO ([PRIMARY])", + ), + forbidden_fragments=("TO ([PRIMARY],",), + expected_partition_count=3, + expected_direction="RIGHT", + expected_filegroups=(), + expected_next_filegroup="PRIMARY", + ), + PartitionSyntaxCase( + name="scheme_filegroups", + description="A scheme may name one destination per partition.", + plan_builder=_plan_builder( + function="pf_logs", + data_type="DATE", + boundaries=_BOUNDARIES, + scheme="ps_logs", + filegroups=("PRIMARY", "fg1", "fg2"), + ), + ddl_builder=_scheme_ddl, + expected_fragments=("TO ([PRIMARY], [fg1], [fg2])",), + forbidden_fragments=("ALL TO",), + expected_partition_count=3, + expected_direction="RIGHT", + expected_filegroups=("PRIMARY", "fg1", "fg2"), + expected_next_filegroup="fg2", + ), + PartitionSyntaxCase( + name="next_used", + description="NEXT USED marks the filegroup a later SPLIT consumes.", + plan_builder=_plan_builder( + function="pf_logs", + data_type="DATE", + boundaries=_BOUNDARIES, + scheme="ps_logs", + filegroups=("PRIMARY", "fg1", "fg2"), + ), + ddl_builder=_next_used_ddl, + expected_fragments=( + "ALTER PARTITION SCHEME [ps_logs] NEXT USED [fg2]", + ), + forbidden_fragments=("SPLIT RANGE",), + expected_partition_count=3, + expected_direction="RIGHT", + expected_filegroups=("PRIMARY", "fg1", "fg2"), + expected_next_filegroup="fg2", + ), + PartitionSyntaxCase( + name="multi_boundary", + description="N boundaries describe N + 1 partitions.", + plan_builder=_plan_builder( + function="pf_logs", data_type="DATE", boundaries=_THREE_BOUNDARIES + ), + ddl_builder=_function_ddl, + expected_fragments=( + "FOR VALUES ('2026-01-01', '2026-02-01', '2026-03-01')", + ), + forbidden_fragments=("RANGE LEFT",), + expected_partition_count=4, + expected_direction="RIGHT", + live_setup=_live_ensure, + live_check=_live_function_count_matches, + ), + PartitionSyntaxCase( + name="no_subpartition_refused", + description="SQL Server has no subpartitioning at any version.", + plan_builder=_plan_builder( + function="pf_logs", + data_type="DATE", + boundaries=_BOUNDARIES, + subpartition_by="HASH", + ), + refuse_subpartition=True, + ), + PartitionSyntaxCase( + name="split", + description="SPLIT RANGE adds one boundary, and therefore one partition.", + plan_builder=_plan_builder( + function="pf_logs", data_type="DATE", boundaries=_BOUNDARIES + ), + ddl_builder=_split_ddl, + expected_fragments=( + "ALTER PARTITION FUNCTION [pf_logs] () SPLIT RANGE ('2026-03-01')", + ), + forbidden_fragments=("MERGE RANGE", "DROP PARTITION"), + expected_partition_count=3, + expected_direction="RIGHT", + live_setup=_live_ensure, + live_check=_live_function_count_matches, + ), + PartitionSyntaxCase( + name="merge", + description="MERGE RANGE removes one boundary without dropping a table.", + plan_builder=_plan_builder( + function="pf_logs", data_type="DATE", boundaries=_BOUNDARIES + ), + ddl_builder=_merge_ddl, + expected_fragments=( + "ALTER PARTITION FUNCTION [pf_logs] () MERGE RANGE ('2026-01-01')", + ), + forbidden_fragments=("SPLIT RANGE", "DROP PARTITION"), + expected_partition_count=3, + expected_direction="RIGHT", + live_setup=_live_ensure, + live_check=_live_function_count_matches, + ), + PartitionSyntaxCase( + name="switch", + description="SWITCH PARTITION moves storage to another table.", + ddl_builder=_switch_ddl, + expected_fragments=( + "ALTER TABLE [dbo].[logs] SWITCH PARTITION 2 TO [dbo].[logs_archive]", + ), + forbidden_fragments=("SPLIT RANGE", "MERGE RANGE", "DROP TABLE"), + ), + PartitionSyntaxCase( + name="drop_function_scheme", + description="Drops are dependency-checked and have no IF EXISTS spelling.", + plan_builder=_plan_builder( + function="pf_logs", + data_type="DATE", + boundaries=_BOUNDARIES, + scheme="ps_logs", + all_filegroup="PRIMARY", + ), + ddl_builder=_drops_ddl, + expected_fragments=( + "DROP PARTITION SCHEME [ps_logs]", + "DROP PARTITION FUNCTION [pf_logs]", + ), + forbidden_fragments=("IF EXISTS", "CASCADE"), + expected_partition_count=3, + expected_direction="RIGHT", + maintenance_builder=_drop_maintenance, + maintenance_expectations=( + MaintenanceExpectation( + operation="drop_unused", + actions=("drop_scheme", "drop_function"), + sql_fragments=( + "DROP PARTITION SCHEME [ps_logs]", + "DROP PARTITION FUNCTION [pf_logs]", + ), + expect_dry_run=True, + ), + MaintenanceExpectation( + operation="drop_refused", + actions=(), + issue_fragments=("used by 1 table(s)",), + sql_fragments=(), + expect_dry_run=True, + ), + ), + ), + PartitionSyntaxCase( + name="maintenance_ensure_list_drop_merge_validate", + description="The four decisions run against canned catalogue rows.", + plan_builder=_two_partition_plan, + maintenance_builder=_all_maintenance_operations, + maintenance_expectations=( + MaintenanceExpectation( + operation="ensure", + actions=("next_used", "split"), + missing_boundaries=(date(2026, 3, 1),), + existing_boundaries=("2026-01-01", "2026-02-01"), + sql_fragments=( + "ALTER PARTITION SCHEME [ps_logs] NEXT USED [PRIMARY]", + "SPLIT RANGE ('2026-03-01')", + ), + expect_dry_run=True, + ), + MaintenanceExpectation( + operation="list", + actions=(), + missing_boundaries=(), + existing_boundaries=("2026-01-01", "2026-02-01"), + ), + MaintenanceExpectation( + operation="drop_or_merge", + actions=("merge",), + issue_fragments=("used by 1 table(s)",), + sql_fragments=("MERGE RANGE ('2026-01-01')",), + expect_dry_run=True, + ), + MaintenanceExpectation( + operation="validate", + actions=(), + missing_boundaries=(), + ), + ), + ), +) + + +@pytest.fixture( + params=CASES, + ids=[case.name for case in CASES], +) +def partition_syntax_case(request: pytest.FixtureRequest) -> PartitionSyntaxCase: + """Yield one declared syntax family at a time, identified by its name.""" + return request.param diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_partition_syntax_matrix.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_partition_syntax_matrix.py new file mode 100644 index 0000000..f00a83a --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_partition_syntax_matrix.py @@ -0,0 +1,179 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_partition_syntax_matrix.py +"""Offline matrix over SQL Server's partition syntax families. + +Every test is parameterised by ``partition_syntax_case`` from +``partition_syntax_fixtures.py``. The matrix asserts the four things a family +can be wrong about without a server: the plan's normalised count, direction +and destinations; the rendered DDL's required and forbidden fragments; the +explicit refusal of subpartitioning; and the maintenance executor's decisions +against canned catalogue rows. + +No connection is opened. The ``sqlserver_dialect`` fixture is the existing +offline fixture from ``feature/backend/conftest.py``. +""" + +from typing import Optional + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.partition import ( + SQLServerPartitionPlan, +) + +from partition_syntax_fixtures import ( # noqa: F401 - registers the fixture + CASES, + PartitionSyntaxCase, + partition_syntax_case, +) + + +def _plan_for( + case: PartitionSyntaxCase, +) -> Optional[SQLServerPartitionPlan]: + """Build the case's plan, or ``None`` when the family needs no plan.""" + if case.plan_builder is None: + return None + return case.plan_builder() + + +class TestPartitionSyntaxMatrix: + """The matrix itself is a table, so its ids must be stable and unique.""" + + def test_case_ids_are_unique(self): + names = [case.name for case in CASES] + assert len(names) == len(set(names)) + + def test_every_case_has_a_description(self): + assert all(case.description.strip() for case in CASES) + + +class TestPartitionSyntaxPlan: + """Plan normalisation: partition count, direction and destinations.""" + + def test_count_direction_and_filegroups( + self, partition_syntax_case # noqa: F811 - pytest fixture + ): + case = partition_syntax_case + if case.refuse_subpartition: + return + plan = _plan_for(case) + if plan is None: + return + if case.expected_partition_count is not None: + assert plan.partition_count == case.expected_partition_count, ( + f"{case.name}: expected {case.expected_partition_count} " + f"partitions, got {plan.partition_count}" + ) + if case.expected_direction is not None: + assert plan.range_direction.value == case.expected_direction, ( + f"{case.name}: expected RANGE {case.expected_direction}, " + f"got RANGE {plan.range_direction.value}" + ) + if case.expected_filegroups is not None: + assert plan.filegroups == case.expected_filegroups, ( + f"{case.name}: expected filegroups " + f"{case.expected_filegroups!r}, got {plan.filegroups!r}" + ) + if case.expected_next_filegroup is not None: + assert plan.next_filegroup == case.expected_next_filegroup, ( + f"{case.name}: expected NEXT USED " + f"{case.expected_next_filegroup!r}, got " + f"{plan.next_filegroup!r}" + ) + + def test_subpartitioning_is_refused( + self, partition_syntax_case # noqa: F811 - pytest fixture + ): + case = partition_syntax_case + if not case.refuse_subpartition: + return + with pytest.raises(UnsupportedFeatureError, match="subpartitioning"): + case.plan_builder() + + +class TestPartitionSyntaxDDL: + """Rendered statements carry the required spelling and nothing else.""" + + def test_required_and_forbidden_fragments( + self, + partition_syntax_case, # noqa: F811 - pytest fixture + sqlserver_dialect: SQLServerDialect, + ): + case = partition_syntax_case + if case.ddl_builder is None: + return + plan = _plan_for(case) + rendered = [] + for statement in case.ddl_builder(sqlserver_dialect, plan): + sql, params = statement.to_sql() + assert params == (), ( + f"{case.name}: partition DDL binds no parameters, got {params!r}" + ) + rendered.append(sql) + for fragment in case.expected_fragments: + assert any(fragment in sql for sql in rendered), ( + f"{case.name}: expected {fragment!r} in {rendered!r}" + ) + for fragment in case.forbidden_fragments: + assert all(fragment not in sql for sql in rendered), ( + f"{case.name}: forbidden {fragment!r} appeared in {rendered!r}" + ) + + +class TestPartitionSyntaxMaintenance: + """Maintenance decisions run against canned catalogue rows.""" + + def test_planning_decisions( + self, + partition_syntax_case, # noqa: F811 - pytest fixture + sqlserver_dialect: SQLServerDialect, + ): + case = partition_syntax_case + if case.maintenance_builder is None: + return + reports = case.maintenance_builder(sqlserver_dialect) + assert set(reports) == { + expectation.operation for expectation in case.maintenance_expectations + }, f"{case.name}: unexpected maintenance report keys {sorted(reports)}" + for expectation in case.maintenance_expectations: + report = reports[expectation.operation] + if expectation.expect_dry_run is not None: + assert report.dry_run is expectation.expect_dry_run, ( + f"{case.name}/{expectation.operation}: expected dry_run=" + f"{expectation.expect_dry_run}, got {report.dry_run}" + ) + assert [op.action for op in report.operations] == list( + expectation.actions + ), ( + f"{case.name}/{expectation.operation}: expected actions " + f"{list(expectation.actions)!r}, got " + f"{[op.action for op in report.operations]!r}" + ) + if expectation.missing_boundaries is not None: + assert report.missing_boundaries == ( + expectation.missing_boundaries + ), ( + f"{case.name}/{expectation.operation}: expected missing " + f"boundaries {expectation.missing_boundaries!r}, got " + f"{report.missing_boundaries!r}" + ) + if expectation.existing_boundaries is not None: + assert report.existing_boundaries == ( + expectation.existing_boundaries + ), ( + f"{case.name}/{expectation.operation}: expected existing " + f"boundaries {expectation.existing_boundaries!r}, got " + f"{report.existing_boundaries!r}" + ) + for fragment in expectation.issue_fragments: + assert any(fragment in issue for issue in report.issues), ( + f"{case.name}/{expectation.operation}: expected an issue " + f"containing {fragment!r}, got {report.issues!r}" + ) + for fragment in expectation.sql_fragments: + assert any(fragment in op.sql for op in report.operations), ( + f"{case.name}/{expectation.operation}: expected " + f"{fragment!r} in {[op.sql for op in report.operations]!r}" + ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py index 4a8ed16..0871bdd 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py @@ -1,8 +1,7 @@ -import inspect -from typing import Set, Tuple +from datetime import date +from typing import Set import pytest -from rhosocial.activerecord.backend.dialect import SQLDialectBase from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect import protocols as dialect_protocols from rhosocial.activerecord.backend.expression import ( @@ -18,15 +17,6 @@ ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import DateType, IntegerType, VarCharType -from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( - SQLServerPartitionByRangeClause, -) -from rhosocial.activerecord.backend.dialect.mixins import ( - DDLColumnMixin, - ExpressionMixin, - DDLTypeMixin, - TableMixin, -) from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect from rhosocial.activerecord.backend.impl.sqlserver.protocols import SQLServerPartitionSupport from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( @@ -34,6 +24,17 @@ SQLServerPartitionSchemeExpression, SQLServerPartitionByRangeClause, SQLServerPartitionRangeDirection, + SQLServerPartitionFunctionSplitExpression, + SQLServerPartitionFunctionMergeExpression, + SQLServerPartitionSchemeNextUsedExpression, + SQLServerSwitchPartitionExpression, + SQLServerDropPartitionFunctionExpression, + SQLServerDropPartitionSchemeExpression, + SQLServerPartitionFunctionQueryStatement, + SQLServerPartitionSchemeQueryStatement, + SQLServerPartitionTableQueryStatement, + SQLServerPartitionRangeQueryStatement, + SQLServerPartitionBoundaryQueryStatement, ) @@ -274,6 +275,188 @@ def test_create_table_with_partition_on_old_version(self): stmt.to_sql() +class TestSQLServerPartitionMaintenanceExpressions: + """SPLIT/MERGE/SWITCH/NEXT USED and the two drops.""" + + def test_split_range(self): + d = SQLServerDialect(SQL_SERVER_2022) + expr = SQLServerPartitionFunctionSplitExpression( + d, "pf_sales", date(2026, 2, 1) + ) + sql, params = expr.to_sql() + assert sql == ( + "ALTER PARTITION FUNCTION [pf_sales] () " + "SPLIT RANGE ('2026-02-01')" + ) + assert params == () + + def test_merge_range_accepts_a_dotted_function_name(self): + d = SQLServerDialect(SQL_SERVER_2022) + expr = SQLServerPartitionFunctionMergeExpression( + d, "dbo.pf_sales", date(2026, 2, 1) + ) + sql, _ = expr.to_sql() + assert sql == ( + "ALTER PARTITION FUNCTION [dbo].[pf_sales] () " + "MERGE RANGE ('2026-02-01')" + ) + + def test_next_used(self): + d = SQLServerDialect(SQL_SERVER_2022) + expr = SQLServerPartitionSchemeNextUsedExpression( + d, "ps_sales", "PRIMARY" + ) + sql, _ = expr.to_sql() + assert sql == "ALTER PARTITION SCHEME [ps_sales] NEXT USED [PRIMARY]" + + def test_switch_one_partition(self): + d = SQLServerDialect(SQL_SERVER_2022) + expr = SQLServerSwitchPartitionExpression( + d, "logs", "logs_archive", source_partition=2 + ) + sql, params = expr.to_sql() + assert sql == ( + "ALTER TABLE [logs] SWITCH PARTITION 2 TO [logs_archive]" + ) + assert params == () + + def test_switch_with_schemas_on_both_tables(self): + d = SQLServerDialect(SQL_SERVER_2022) + expr = SQLServerSwitchPartitionExpression( + d, + "logs", + "logs_archive", + source_partition=1, + schema="dbo", + target_schema="dbo", + ) + sql, _ = expr.to_sql() + assert sql == ( + "ALTER TABLE [dbo].[logs] SWITCH PARTITION 1 " + "TO [dbo].[logs_archive]" + ) + + def test_switch_whole_table(self): + d = SQLServerDialect(SQL_SERVER_2022) + expr = SQLServerSwitchPartitionExpression(d, "logs", "logs_archive") + sql, _ = expr.to_sql() + assert sql == "ALTER TABLE [logs] SWITCH TO [logs_archive]" + + def test_switch_refuses_dots_and_a_separate_schema(self): + d = SQLServerDialect(SQL_SERVER_2022) + with pytest.raises(ValueError, match="must not carry dots"): + SQLServerSwitchPartitionExpression( + d, "dbo.logs", "archive", schema="dbo" + ) + + def test_switch_refuses_a_non_positive_partition(self): + d = SQLServerDialect(SQL_SERVER_2022) + with pytest.raises(ValueError, match="positive integer"): + SQLServerSwitchPartitionExpression( + d, "logs", "archive", source_partition=0 + ) + + def test_drops(self): + d = SQLServerDialect(SQL_SERVER_2022) + assert SQLServerDropPartitionFunctionExpression( + d, "pf_sales" + ).to_sql() == ("DROP PARTITION FUNCTION [pf_sales]", ()) + assert SQLServerDropPartitionSchemeExpression( + d, "ps_sales" + ).to_sql() == ("DROP PARTITION SCHEME [ps_sales]", ()) + + def test_split_and_merge_are_refused_on_old_versions(self): + d = SQLServerDialect(SQL_SERVER_2005) + with pytest.raises(UnsupportedFeatureError): + SQLServerPartitionFunctionSplitExpression( + d, "pf", date(2026, 1, 1) + ).to_sql() + with pytest.raises(UnsupportedFeatureError): + SQLServerPartitionFunctionMergeExpression( + d, "pf", date(2026, 1, 1) + ).to_sql() + + +class TestSQLServerPartitionCatalogueQuestions: + """The questions maintenance asks of ``sys.partition_*``.""" + + def test_function_list_projects_boundaries_and_type(self): + d = SQLServerDialect(SQL_SERVER_2022) + sql, params = SQLServerPartitionFunctionQueryStatement(d).to_sql() + assert params == () + assert "FROM sys.partition_functions AS pf" in sql + assert "sys.partition_range_values AS prv" in sql + assert "boundary_value_on_right" in sql + assert "SQL_VARIANT_PROPERTY" in sql + assert "ORDER BY pf.name, prv.boundary_id" in sql + + def test_function_list_takes_one_optional_name(self): + d = SQLServerDialect(SQL_SERVER_2022) + sql, params = SQLServerPartitionFunctionQueryStatement( + d, function_name="pf_sales" + ).to_sql() + assert "WHERE pf.name = ?" in sql + assert params == ("pf_sales",) + + def test_scheme_list_projects_destinations(self): + d = SQLServerDialect(SQL_SERVER_2022) + sql, params = SQLServerPartitionSchemeQueryStatement(d).to_sql() + assert "sys.partition_schemes AS ps" in sql + assert "sys.destination_data_spaces AS dds" in sql + assert params == () + + def test_scheme_list_filters_by_function_and_name(self): + d = SQLServerDialect(SQL_SERVER_2022) + sql, params = SQLServerPartitionSchemeQueryStatement( + d, scheme_name="ps_sales", function_name="pf_sales" + ).to_sql() + assert "WHERE ps.name = ? AND pf.name = ?" in sql + assert params == ("ps_sales", "pf_sales") + + def test_table_list_resolves_the_partition_column(self): + d = SQLServerDialect(SQL_SERVER_2022) + sql, params = SQLServerPartitionTableQueryStatement( + d, table_name="logs", schema="dbo" + ).to_sql() + assert "sys.index_columns AS ic" in sql + assert "partition_ordinal = 1" in sql + assert "WHERE s.name = ? AND t.name = ?" in sql + assert params == ("dbo", "logs") + + def test_range_list_carries_bounds_rows_and_pages(self): + d = SQLServerDialect(SQL_SERVER_2022) + sql, params = SQLServerPartitionRangeQueryStatement( + d, table_name="logs", schema="dbo" + ).to_sql() + assert "WITH boundary_text AS" in sql + assert "sys.dm_db_partition_stats AS stats" in sql + assert "stats.partition_number" in sql + assert "stats.used_page_count" in sql + assert "WHERE s.name = ? AND t.name = ?" in sql + assert params == ("dbo", "logs") + + def test_boundary_question_casts_to_the_function_type(self): + d = SQLServerDialect(SQL_SERVER_2022) + sql, params = SQLServerPartitionBoundaryQueryStatement( + d, + function_name="pf_sales", + data_type="DATE", + boundary_value=date(2026, 1, 1), + ).to_sql() + assert "prv.value = CAST(? AS DATE)" in sql + assert params == ("pf_sales", date(2026, 1, 1)) + + def test_boundary_question_refuses_an_unsafe_type(self): + d = SQLServerDialect(SQL_SERVER_2022) + with pytest.raises(ValueError, match="invalid partition-function data type"): + SQLServerPartitionBoundaryQueryStatement( + d, + function_name="pf", + data_type="DATE; DROP TABLE x", + boundary_value=date(2026, 1, 1), + ) + + # Helper wrappers for tests class SqlServerPartitionClause(SQLServerPartitionByRangeClause): """Test helper that constructs the KVS dict for scheme name.""" diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition_live.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition_live.py new file mode 100644 index 0000000..a1574df --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition_live.py @@ -0,0 +1,244 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition_live.py +"""Live tests for SQL Server partition DDL and maintenance. + +These are skipped -- not passed -- when no scenario is configured or the +configured server cannot be reached, so a machine without the 2022 image does +not report a false success. The offline suites in +``test_sqlserver_partition.py`` and +``test_sqlserver_partition_maintenance.py`` cover the rendering and the +decisions; this file covers what only a server can answer: whether +``CREATE PARTITION FUNCTION`` / ``SCHEME`` / ``SPLIT`` / ``MERGE`` / ``SWITCH`` +really work, and where rows land. + +One scenario is used, like the other single-scenario live suites. +""" + +import os +from datetime import date + +import pytest + +from providers.scenarios import get_enabled_scenarios, get_scenario + +from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( + SQLServerSwitchPartitionExpression, +) +from rhosocial.activerecord.backend.impl.sqlserver.partition import ( + SQLServerPartitionMaintenance, + SQLServerPartitionPlan, +) + +_SUFFIX = f"_{os.getpid()}" +_FUNCTION = f"pf_part{_SUFFIX}" +_SCHEME = f"ps_part{_SUFFIX}" +_TABLE = f"t_part{_SUFFIX}" +_STAGE = f"t_stage{_SUFFIX}" + +_BOUNDARIES = (date(2026, 1, 1), date(2026, 2, 1), date(2026, 3, 1)) +_EXTRA = date(2026, 1, 15) + + +def _plan(boundaries=_BOUNDARIES): + return SQLServerPartitionPlan( + function=_FUNCTION, + data_type="DATE", + boundaries=boundaries, + scheme=_SCHEME, + all_filegroup="PRIMARY", + ) + + +def _cleanup(backend): + """Remove the probe objects, children first, tolerating partial state.""" + for sql in ( + f"IF OBJECT_ID('dbo.{_STAGE}', 'U') IS NOT NULL DROP TABLE dbo.{_STAGE}", + f"IF OBJECT_ID('dbo.{_TABLE}', 'U') IS NOT NULL DROP TABLE dbo.{_TABLE}", + ( + f"IF EXISTS (SELECT 1 FROM sys.partition_schemes " + f"WHERE name = '{_SCHEME}') DROP PARTITION SCHEME {_SCHEME}" + ), + ( + f"IF EXISTS (SELECT 1 FROM sys.partition_functions " + f"WHERE name = '{_FUNCTION}') DROP PARTITION FUNCTION {_FUNCTION}" + ), + ): + try: + backend.execute(sql) + except Exception: + pass + + +@pytest.fixture(scope="function") +def partition_backend(): + """A backend against the first scenario for one test, or a skip when unavailable.""" + scenarios = get_enabled_scenarios() + if not scenarios: + pytest.skip("No SQL Server scenarios configured") + scenario_name = next(iter(scenarios)) + backend_class, config = get_scenario(scenario_name) + backend = backend_class(connection_config=config) + try: + backend.connect() + backend.execute("SELECT 1") + except Exception as exc: # pragma: no cover - environment dependent + try: + backend.disconnect() + except Exception: + pass + pytest.skip(f"SQL Server is unavailable: {exc}") + _cleanup(backend) + yield backend + _cleanup(backend) + backend.disconnect() + + +@pytest.fixture(scope="function") +def partition_fixture(partition_backend): + """A real function, scheme and partitioned table with four partitions. + + Rebuilt for every test: the split and merge tests mutate the function, and + xdist is free to run those on a different worker than the tests that expect + the four-partition baseline. + """ + maintenance = SQLServerPartitionMaintenance(partition_backend) + plan = _plan() + report = maintenance.ensure(plan) + assert {op.action for op in report.operations} == { + "create_function", + "create_scheme", + } + partition_backend.execute( + f"CREATE TABLE dbo.{_TABLE} (" + "id INT NOT NULL, created_at DATE NOT NULL, " + f"CONSTRAINT pk_{_TABLE} PRIMARY KEY (id, created_at)) " + f"ON [{_SCHEME}] ([created_at])" + ) + partition_backend.execute( + f"CREATE TABLE dbo.{_STAGE} (" + "id INT NOT NULL, created_at DATE NOT NULL, " + f"CONSTRAINT pk_{_STAGE} PRIMARY KEY (id, created_at))" + ) + partition_backend.execute( + f"INSERT INTO dbo.{_TABLE} (id, created_at) VALUES " + "(1, '2025-12-01'), (2, '2026-01-15'), " + "(3, '2026-02-15'), (4, '2026-03-15')" + ) + yield maintenance, plan + _cleanup(partition_backend) + + +def _partition_of(backend, created_at: date) -> int: + # Bind an ISO string rather than a ``date``: the legacy ``SQL Server`` + # ODBC driver has no SQLBindParameter support for ``date`` (HYC00), while + # the implicit conversion from the string is accepted by every version. + row = backend.execute( + "SELECT $PARTITION." + f"{_FUNCTION}(?) AS partition_number", + (created_at.isoformat(),), + ).data + return int(row[0]["partition_number"]) + + +class TestSQLServerPartitionLive: + """What the server confirms about SQL Server's function/scheme model.""" + + def test_ensure_created_a_function_and_scheme(self, partition_backend, partition_fixture): + maintenance, plan = partition_fixture + report = maintenance.list(plan) + info = report.function_info(_FUNCTION) + assert info is not None + assert info.range_direction == "RIGHT" + assert info.partition_count == 4 + assert len(info.boundary_values) == 3 + scheme = report.scheme_info(_SCHEME) + assert scheme is not None + assert scheme.function == _FUNCTION + assert scheme.destination_count >= 1 + assert maintenance.validate(plan).issues == () + + def test_rows_land_in_the_expected_partitions(self, partition_backend, partition_fixture): + assert _partition_of(partition_backend, date(2025, 12, 1)) == 1 + assert _partition_of(partition_backend, date(2026, 1, 15)) == 2 + assert _partition_of(partition_backend, date(2026, 2, 15)) == 3 + assert _partition_of(partition_backend, date(2026, 3, 15)) == 4 + + def test_list_reports_the_table_and_each_partition(self, partition_backend, partition_fixture): + maintenance, plan = partition_fixture + report = maintenance.list(plan, table=_TABLE, schema="dbo") + table = report.table_info(_TABLE) + assert table is not None + assert table.scheme == _SCHEME + assert table.partition_column == "created_at" + assert [row.partition_number for row in report.ranges] == [1, 2, 3, 4] + assert report.ranges[0].low_boundary is None + assert report.ranges[-1].high_boundary is None + assert sum(row.row_count or 0 for row in report.ranges) == 4 + assert maintenance.validate(plan, table=_TABLE, schema="dbo").issues == () + + def test_split_adds_a_partition(self, partition_backend, partition_fixture): + maintenance, _ = partition_fixture + split_plan = _plan(_BOUNDARIES + (_EXTRA,)) + report = maintenance.ensure(split_plan) + actions = [op.action for op in report.operations] + assert actions == ["next_used", "split"] + info = maintenance.list(split_plan).function_info(_FUNCTION) + assert info is not None and info.partition_count == 5 + assert _partition_of(partition_backend, _EXTRA) == 3 + + def test_merge_removes_the_partition_without_dropping_it( + self, partition_backend, partition_fixture + ): + maintenance, _ = partition_fixture + # The split is this test's precondition, not another test's side effect: + # xdist may run the split test on a different worker. + maintenance.ensure(_plan(_BOUNDARIES + (_EXTRA,))) + report = maintenance.drop_or_merge( + _plan(_BOUNDARIES + (_EXTRA,)), [_EXTRA], dry_run=False + ) + assert [op.action for op in report.operations] == ["merge"] + info = maintenance.list(_plan()).function_info(_FUNCTION) + assert info is not None and info.partition_count == 4 + assert _partition_of(partition_backend, date(2026, 1, 15)) == 2 + + def test_switch_moves_a_partition_to_another_table(self, partition_backend, partition_fixture): + maintenance, plan = partition_fixture + switch = SQLServerSwitchPartitionExpression( + partition_backend.dialect, + _TABLE, + _STAGE, + source_partition=1, + schema="dbo", + target_schema="dbo", + ) + partition_backend.execute(*switch.to_sql()) + before = partition_backend.execute( + f"SELECT COUNT(*) AS c FROM dbo.{_TABLE} WHERE created_at = '2025-12-01'" + ).data[0]["c"] + moved = partition_backend.execute( + f"SELECT id FROM dbo.{_STAGE}" + ).data + assert before == 0 + assert [row["id"] for row in moved] == [1] + assert maintenance.validate(plan, table=_TABLE, schema="dbo").issues == () + + def test_scaffolding_is_refused_while_the_table_uses_it( + self, partition_backend, partition_fixture + ): + maintenance, plan = partition_fixture + report = maintenance.drop_or_merge( + plan, dry_run=True, drop_scaffolding=True + ) + assert all(op.action != "drop_function" for op in report.operations) + assert any("used by" in issue for issue in report.issues) + + def test_drop_before_renders_the_live_retention_boundaries( + self, partition_fixture + ): + maintenance, plan = partition_fixture + boundaries = maintenance.retention_boundaries(plan, date(2026, 2, 1)) + report = maintenance.drop_before(plan, date(2026, 2, 1)) + assert report.dry_run is True + assert report.errors == () + assert [op.action for op in report.operations] == ["merge"] * len( + boundaries + ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition_maintenance.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition_maintenance.py new file mode 100644 index 0000000..0911ed5 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition_maintenance.py @@ -0,0 +1,1005 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition_maintenance.py +"""Unit tests for SQL Server partition planning and maintenance. + +These run without a server: a fake maintenance object answers the catalogue +questions from canned rows and records the statements the real executor would +run. The catalogue SQL itself is asserted in +``test_sqlserver_partition.py``; this file is about decisions -- which +boundaries are missing, what a dry run would do, and why a drop is refused. +""" + +import asyncio +from datetime import date, datetime + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.partition import ( + AsyncPartitionEnsurer, + AsyncPartitionInventory, + AsyncPartitionRetention, + AsyncPartitionValidator, + PartitionEnsurer, + PartitionInventory, + PartitionReport as CorePartitionReport, + PartitionRetention, + PartitionValidator, +) +from rhosocial.activerecord.backend.impl.sqlserver.partition import ( + AsyncSQLServerPartitionMaintenance, + PartitionFunctionInfo, + PartitionRangeInfo, + PartitionReport, + PartitionSchemeInfo, + PartitionTableInfo, + SQLServerPartitionMaintenance, + SQLServerPartitionPlan, +) + +DIALECT = SQLServerDialect((16, 0, 0)) +OLD_DIALECT = SQLServerDialect((9, 0, 0)) + + +def _plan(boundaries=(date(2026, 1, 1), date(2026, 2, 1)), **kwargs): + return SQLServerPartitionPlan( + function=kwargs.pop("function", "pf_logs"), + data_type=kwargs.pop("data_type", "DATE"), + boundaries=boundaries, + **kwargs, + ) + + +class FakeMaintenance(SQLServerPartitionMaintenance): + """A maintenance executor whose catalogue answers are supplied up front.""" + + def __init__( + self, + *, + dialect=DIALECT, + functions=(), + schemes=(), + tables=(), + ranges=(), + exists=None, + ): + self.dialect = dialect + self.functions = tuple(functions) + self.schemes = tuple(schemes) + self.tables = tuple(tables) + self.ranges = tuple(ranges) + self.exists = dict(exists or {}) + self.executed = [] + + def _read_functions(self, function_name=None): + return tuple(f for f in self.functions if function_name in (None, f.name)) + + def _read_schemes(self, scheme_name=None, function_name=None): + return tuple( + s + for s in self.schemes + if scheme_name in (None, s.name) + and function_name in (None, s.function) + ) + + def _read_tables(self, table_name=None, schema=None, scheme_name=None): + return tuple( + t + for t in self.tables + if table_name in (None, t.name) + and schema in (None, t.schema) + and scheme_name in (None, t.scheme) + ) + + def _read_ranges(self, table_name, schema=None): + return tuple(r for r in self.ranges if r.table == table_name) + + def _read_boundary_exists(self, plan): + return tuple(self.exists.get(boundary, False) for boundary in plan.boundaries) + + def _read_boundary_exists_for(self, plan, boundaries): + return tuple(self.exists.get(boundary, False) for boundary in boundaries) + + def _execute(self, sql, params): + self.executed.append(sql) + + +class FakeAsyncMaintenance(AsyncSQLServerPartitionMaintenance): + """The async executor with the same canned answers as the sync fake.""" + + def __init__(self, **kwargs): + self.sync = FakeMaintenance(**kwargs) + self.dialect = self.sync.dialect + self.executed = self.sync.executed + + async def _read_functions(self, function_name=None): + return self.sync._read_functions(function_name) + + async def _read_schemes(self, scheme_name=None, function_name=None): + return self.sync._read_schemes(scheme_name, function_name) + + async def _read_tables(self, table_name=None, schema=None, scheme_name=None): + return self.sync._read_tables(table_name, schema, scheme_name) + + async def _read_ranges(self, table_name, schema=None): + return self.sync._read_ranges(table_name, schema) + + async def _read_boundary_exists(self, plan): + return self.sync._read_boundary_exists(plan) + + async def _read_boundary_exists_for(self, plan, boundaries): + return self.sync._read_boundary_exists_for(plan, boundaries) + + async def _execute(self, sql, params): + self.sync._execute(sql, params) + + +class ScriptedMaintenance(FakeMaintenance): + """A fake executor that can fail or mutate its catalogue on a statement.""" + + def __init__(self, *, fail_at=None, on_execute=None, **kwargs): + super().__init__(**kwargs) + self.fail_at = fail_at + self.on_execute = on_execute + self.attempts = 0 + + def _execute(self, sql, params): + if self.fail_at is not None and self.attempts == self.fail_at: + self.attempts += 1 + raise DatabaseError("scripted failure") + self.attempts += 1 + super()._execute(sql, params) + if self.on_execute is not None: + self.on_execute(self, sql, params) + + +class ScriptedAsyncMaintenance(FakeAsyncMaintenance): + """The async executor over a scripted sync fake.""" + + def __init__(self, sync): + self.sync = sync + self.dialect = sync.dialect + self.executed = sync.executed + + +def _function_info( + boundaries=("2026-01-01", "2026-02-01", "2026-03-01"), + *, + data_type="date", +): + return [ + PartitionFunctionInfo( + name="pf_logs", + data_type=data_type, + range_direction="RIGHT", + boundary_values=tuple(boundaries), + partition_count=len(boundaries) + 1, + ) + ] + + +class TestSQLServerPartitionPlan: + """Plan normalisation and explicit refusals.""" + + def test_boundaries_are_sorted_and_counted(self): + plan = _plan(boundaries=(date(2026, 2, 1), date(2026, 1, 1))) + assert plan.boundaries == (date(2026, 1, 1), date(2026, 2, 1)) + assert plan.partition_count == 3 + + def test_duplicate_boundaries_are_refused(self): + with pytest.raises(ValueError, match="unique"): + _plan(boundaries=(date(2026, 1, 1), date(2026, 1, 1))) + + def test_empty_boundaries_are_refused(self): + with pytest.raises(ValueError, match="at least one"): + _plan(boundaries=()) + + def test_scheme_requires_one_destination_per_partition(self): + with pytest.raises(ValueError, match="filegroups cannot serve"): + _plan(scheme="ps_logs", filegroups=("PRIMARY", "fg1")) + + def test_all_filegroup_and_explicit_filegroups_are_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + _plan( + scheme="ps_logs", + filegroups=("PRIMARY", "fg1", "fg2"), + all_filegroup="PRIMARY", + ) + + def test_next_filegroup_defaults_to_the_last_explicit_destination(self): + plan = _plan( + scheme="ps_logs", + filegroups=("PRIMARY", "fg1", "fg2"), + ) + assert plan.next_filegroup == "fg2" + + def test_next_filegroup_defaults_to_all_filegroup(self): + plan = _plan(scheme="ps_logs", all_filegroup="PRIMARY") + assert plan.next_filegroup == "PRIMARY" + + def test_non_range_strategy_is_refused(self): + with pytest.raises(UnsupportedFeatureError, match="HASH"): + _plan(strategy="HASH") + + def test_subpartitioning_is_refused(self): + with pytest.raises(UnsupportedFeatureError, match="subpartitioning"): + _plan(subpartition_by="HASH") + + def test_invalid_data_type_is_refused(self): + with pytest.raises(ValueError, match="invalid partition-function data type"): + _plan(data_type="DATE; DROP TABLE x") + + def test_capability_gate_refuses_an_unsupported_version(self): + maintenance = FakeMaintenance(dialect=OLD_DIALECT) + with pytest.raises(UnsupportedFeatureError, match="table partitioning"): + maintenance.ensure(_plan()) + + +class TestSQLServerPartitionCatalogueParsing: + """Rows as the catalogue returns them become the report's value objects.""" + + def test_functions_are_grouped_by_boundary(self): + functions = FakeMaintenance._parse_functions( + [ + { + "function_name": "pf", + "data_type": "date", + "boundary_value_on_right": 1, + "partition_count": 3, + "boundary_id": 1, + "boundary_value": "2026-01-01", + }, + { + "function_name": "pf", + "data_type": "date", + "boundary_value_on_right": 1, + "partition_count": 3, + "boundary_id": 2, + "boundary_value": "2026-02-01", + }, + ] + ) + assert len(functions) == 1 + info = functions[0] + assert info.name == "pf" + assert info.range_direction == "RIGHT" + assert info.boundary_values == ("2026-01-01", "2026-02-01") + assert info.partition_count == 3 + + def test_schemes_keep_destination_order(self): + schemes = FakeMaintenance._parse_schemes( + [ + { + "scheme_name": "ps", + "function_name": "pf", + "partition_count": 3, + "destination_id": 2, + "filegroup_name": "fg1", + }, + { + "scheme_name": "ps", + "function_name": "pf", + "partition_count": 3, + "destination_id": 1, + "filegroup_name": "PRIMARY", + }, + ] + ) + assert len(schemes) == 1 + assert schemes[0].filegroups == ("fg1", "PRIMARY") + assert schemes[0].destination_count == 2 + + def test_tables_and_ranges_are_parsed(self): + tables = FakeMaintenance._parse_tables( + [ + { + "table_name": "logs", + "schema_name": "dbo", + "scheme_name": "ps", + "function_name": "pf", + "partition_column": "created_at", + "partition_count": 3, + } + ] + ) + assert tables[0].partition_column == "created_at" + + ranges = FakeMaintenance._parse_ranges( + [ + { + "table_name": "logs", + "schema_name": "dbo", + "partition_number": 2, + "row_count": 10, + "used_page_count": 2, + "low_boundary": "2026-01-01", + "high_boundary": "2026-02-01", + } + ] + ) + assert ranges[0].data_bytes == 2 * 8192 + assert ranges[0].low_boundary == "2026-01-01" + + +class TestSQLServerPartitionEnsure: + """``ensure`` creates, splits and reports without touching what exists.""" + + def test_missing_function_is_created_with_its_scheme(self): + maintenance = FakeMaintenance() + report = maintenance.ensure( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), dry_run=True + ) + assert [op.action for op in report.operations] == [ + "create_function", + "create_scheme", + ] + assert report.operations[0].sql == ( + "CREATE PARTITION FUNCTION [pf_logs] (DATE) AS RANGE RIGHT " + "FOR VALUES ('2026-01-01', '2026-02-01')" + ) + assert report.operations[1].sql == ( + "CREATE PARTITION SCHEME [ps_logs] AS PARTITION [pf_logs] " + "ALL TO ([PRIMARY])" + ) + assert maintenance.executed == [] + assert report.dry_run is True + + def test_real_ensure_executes_the_planned_statements(self): + maintenance = FakeMaintenance() + maintenance.ensure( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), dry_run=False + ) + assert len(maintenance.executed) == 2 + + def test_existing_function_splits_only_missing_boundaries(self): + maintenance = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=("2026-01-01",), + partition_count=2, + ) + ], + schemes=[ + PartitionSchemeInfo( + name="ps_logs", function="pf_logs", filegroups=("PRIMARY",) + ) + ], + exists={date(2026, 1, 1): True, date(2026, 2, 1): False}, + ) + report = maintenance.ensure( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), dry_run=True + ) + assert report.missing_boundaries == (date(2026, 2, 1),) + assert [op.action for op in report.operations] == ["next_used", "split"] + assert report.operations[0].sql == ( + "ALTER PARTITION SCHEME [ps_logs] NEXT USED [PRIMARY]" + ) + assert report.operations[1].sql == ( + "ALTER PARTITION FUNCTION [pf_logs] () SPLIT RANGE ('2026-02-01')" + ) + + def test_idempotent_ensure_plans_nothing(self): + maintenance = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=("2026-01-01", "2026-02-01"), + partition_count=3, + ) + ], + schemes=[ + PartitionSchemeInfo( + name="ps_logs", function="pf_logs", filegroups=("PRIMARY",) + ) + ], + exists={date(2026, 1, 1): True, date(2026, 2, 1): True}, + ) + report = maintenance.ensure( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), dry_run=True + ) + assert report.operations == () + + def test_direction_mismatch_is_refused_rather_than_repaired(self): + maintenance = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=(), + partition_count=1, + ) + ] + ) + with pytest.raises(ValueError, match="is RANGE RIGHT"): + maintenance.ensure( + _plan(range_direction="LEFT"), dry_run=True + ) + + def test_explicit_filegroups_need_the_function_to_match_already(self): + maintenance = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=("2026-01-01",), + partition_count=2, + ) + ], + exists={date(2026, 1, 1): True, date(2026, 2, 1): False}, + ) + with pytest.raises(ValueError, match="explicit filegroups"): + maintenance.ensure( + _plan( + scheme="ps_logs", + filegroups=("PRIMARY", "fg1", "fg2"), + ), + dry_run=True, + ) + + def test_async_ensure_matches_the_sync_plan(self): + kwargs = dict( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=("2026-01-01",), + partition_count=2, + ) + ], + schemes=[ + PartitionSchemeInfo( + name="ps_logs", function="pf_logs", filegroups=("PRIMARY",) + ) + ], + exists={date(2026, 1, 1): True, date(2026, 2, 1): False}, + ) + plan = _plan(scheme="ps_logs", all_filegroup="PRIMARY") + sync_report = FakeMaintenance(**kwargs).ensure(plan, dry_run=True) + async_maintenance = FakeAsyncMaintenance(**kwargs) + + async def run(): + return await async_maintenance.ensure(plan, dry_run=True) + + import asyncio + + async_report = asyncio.run(run()) + assert async_report.operations == sync_report.operations + + +class TestSQLServerPartitionListAndValidate: + """``list`` reports, and ``validate`` never repairs.""" + + def test_list_reports_missing_boundaries_and_issues(self): + maintenance = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=("2026-01-01",), + partition_count=2, + ) + ], + exists={date(2026, 1, 1): True, date(2026, 2, 1): False}, + ) + report = maintenance.list(_plan()) + assert report.missing_boundaries == (date(2026, 2, 1),) + assert report.existing_boundaries == ("2026-01-01",) + assert any("2 partitions" in issue for issue in report.issues) + + def test_list_reports_a_missing_function_with_every_boundary_missing(self): + report = FakeMaintenance().list(_plan()) + assert report.missing_boundaries == (date(2026, 1, 1), date(2026, 2, 1)) + assert report.issues == ("partition function 'pf_logs' does not exist",) + + def test_validate_flags_a_table_on_the_wrong_scheme_and_holes(self): + maintenance = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=("2026-01-01", "2026-02-01"), + partition_count=3, + ) + ], + schemes=[ + PartitionSchemeInfo( + name="ps_logs", function="pf_logs", filegroups=("PRIMARY",) + ) + ], + tables=[ + PartitionTableInfo( + name="logs", + schema="dbo", + scheme="ps_other", + function="pf_other", + partition_count=3, + ) + ], + ranges=[ + PartitionRangeInfo(table="logs", schema="dbo", partition_number=1), + PartitionRangeInfo(table="logs", schema="dbo", partition_number=3), + ], + exists={date(2026, 1, 1): True, date(2026, 2, 1): True}, + ) + report = maintenance.validate( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), table="logs" + ) + assert any("built on 'ps_other'" in issue for issue in report.issues) + assert any("uses function 'pf_other'" in issue for issue in report.issues) + assert any("partitions are [1, 3]" in issue for issue in report.issues) + assert report.operations == () + + +class TestSQLServerPartitionDropOrMerge: + """SQL Server has no DROP PARTITION; the executor merges boundaries.""" + + def test_dry_run_is_the_default_and_executes_nothing(self): + maintenance = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=("2026-01-01", "2026-02-01"), + partition_count=3, + ) + ], + exists={date(2026, 1, 1): True}, + ) + report = maintenance.drop_or_merge(_plan(), [date(2026, 1, 1)]) + assert report.dry_run is True + assert [op.action for op in report.operations] == ["merge"] + assert report.operations[0].sql == ( + "ALTER PARTITION FUNCTION [pf_logs] () MERGE RANGE ('2026-01-01')" + ) + assert maintenance.executed == [] + + def test_real_merge_executes_the_statement(self): + maintenance = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=("2026-01-01", "2026-02-01"), + partition_count=3, + ) + ], + exists={date(2026, 1, 1): True}, + ) + report = maintenance.drop_or_merge( + _plan(), [date(2026, 1, 1)], dry_run=False + ) + assert report.operations[0].action == "merge" + assert maintenance.executed == [report.operations[0].sql] + + def test_absent_boundary_is_reported_not_merged(self): + report = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=("2026-01-01",), + partition_count=2, + ) + ] + ).drop_or_merge(_plan(), [date(2026, 2, 1)]) + assert report.operations == () + assert any("is not present" in issue for issue in report.issues) + + def test_scaffolding_is_kept_when_a_table_uses_the_scheme(self): + maintenance = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=(), + partition_count=1, + ) + ], + schemes=[ + PartitionSchemeInfo( + name="ps_logs", function="pf_logs", filegroups=("PRIMARY",) + ) + ], + tables=[ + PartitionTableInfo( + name="logs", + schema="dbo", + scheme="ps_logs", + function="pf_logs", + partition_count=1, + ) + ], + ) + report = maintenance.drop_or_merge( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), + dry_run=True, + drop_scaffolding=True, + ) + assert report.operations == () + assert any("used by 1 table" in issue for issue in report.issues) + + def test_unused_scaffolding_is_dropped_scheme_then_function(self): + maintenance = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=("2026-01-01",), + partition_count=2, + ) + ], + schemes=[ + PartitionSchemeInfo( + name="ps_logs", function="pf_logs", filegroups=("PRIMARY",) + ) + ], + exists={date(2026, 1, 1): True}, + ) + report = maintenance.drop_or_merge( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), + [date(2026, 1, 1)], + dry_run=True, + drop_scaffolding=True, + ) + actions = [op.action for op in report.operations] + assert actions == ["merge", "drop_scheme", "drop_function"] + assert report.operations[-2].sql == "DROP PARTITION SCHEME [ps_logs]" + assert report.operations[-1].sql == "DROP PARTITION FUNCTION [pf_logs]" + + def test_one_unused_scheme_can_go_while_another_keeps_the_function(self): + maintenance = FakeMaintenance( + functions=[ + PartitionFunctionInfo( + name="pf_logs", + data_type="date", + range_direction="RIGHT", + boundary_values=(), + partition_count=1, + ) + ], + schemes=[ + PartitionSchemeInfo( + name="ps_unused", function="pf_logs", filegroups=("PRIMARY",) + ), + PartitionSchemeInfo( + name="ps_used", function="pf_logs", filegroups=("PRIMARY",) + ), + ], + tables=[ + PartitionTableInfo( + name="logs", + schema="dbo", + scheme="ps_used", + function="pf_logs", + partition_count=1, + ) + ], + ) + report = maintenance.drop_or_merge( + _plan(), dry_run=True, drop_scaffolding=True + ) + assert [op.action for op in report.operations] == ["drop_scheme"] + assert report.operations[0].name == "ps_unused" + assert any("used by 1 table" in issue for issue in report.issues) + assert any("still mapped by scheme" in issue for issue in report.issues) + + +class TestSQLServerPartitionRetention: + """Retention is derived from the catalogue, never guessed.""" + + @staticmethod + def _maintenance(): + boundaries = (date(2026, 1, 1), date(2026, 2, 1), date(2026, 3, 1)) + return FakeMaintenance( + functions=_function_info(("2026-01-01", "2026-02-01", "2026-03-01")), + exists={boundary: True for boundary in boundaries}, + ) + + def test_boundaries_at_or_before_the_cutoff_are_returned(self): + maintenance = self._maintenance() + plan = _plan() + assert maintenance.retention_boundaries(plan, date(2026, 2, 1)) == ( + date(2026, 1, 1), + date(2026, 2, 1), + ) + assert maintenance.executed == [] + + def test_nothing_before_the_first_boundary_is_returned(self): + maintenance = self._maintenance() + assert maintenance.retention_boundaries(_plan(), date(2025, 12, 31)) == () + + def test_a_missing_function_has_nothing_to_retain(self): + assert FakeMaintenance().retention_boundaries(_plan(), date(2026, 2, 1)) == () + + def test_table_ranges_can_supply_the_upper_bounds(self): + maintenance = FakeMaintenance( + ranges=[ + PartitionRangeInfo( + table="logs", schema="dbo", partition_number=1 + ), + PartitionRangeInfo( + table="logs", + schema="dbo", + partition_number=2, + low_boundary=None, + high_boundary="2026-01-01", + ), + PartitionRangeInfo( + table="logs", + schema="dbo", + partition_number=3, + low_boundary="2026-01-01", + high_boundary="2026-02-01", + ), + ] + ) + assert maintenance.retention_boundaries( + _plan(), date(2026, 2, 1), table="logs", schema="dbo" + ) == (date(2026, 1, 1), date(2026, 2, 1)) + + def test_datetime_boundaries_tolerate_seven_fractional_digits(self): + maintenance = FakeMaintenance( + functions=_function_info( + ("2026-01-01T00:00:00.0000000", "2026-02-01T00:00:00.0000000"), + data_type="datetime2", + ) + ) + plan = _plan( + boundaries=(datetime(2026, 1, 1), datetime(2026, 2, 1)), + data_type="DATETIME2", + ) + assert maintenance.retention_boundaries(plan, date(2026, 2, 1)) == ( + datetime(2026, 1, 1), + datetime(2026, 2, 1), + ) + + def test_integer_boundaries_compare_as_integers(self): + maintenance = FakeMaintenance( + functions=_function_info(("100", "200"), data_type="int"), + exists={100: True, 200: True}, + ) + plan = _plan(boundaries=(100, 200), data_type="INT") + assert maintenance.retention_boundaries(plan, 150) == (100,) + + def test_drop_before_renders_only_by_default(self): + maintenance = self._maintenance() + report = maintenance.drop_before(_plan(), date(2026, 2, 1)) + assert report.dry_run is True + assert [op.action for op in report.operations] == ["merge", "merge"] + assert all("MERGE RANGE" in op.sql for op in report.operations) + assert maintenance.executed == [] + assert report.errors == () + + def test_drop_before_executes_when_asked(self): + maintenance = self._maintenance() + report = maintenance.drop_before(_plan(), date(2026, 2, 1), dry_run=False) + assert [op.action for op in report.operations] == ["merge", "merge"] + assert len(maintenance.executed) == 2 + + def test_drop_before_can_drop_the_scaffolding_too(self): + maintenance = self._maintenance() + report = maintenance.drop_before( + _plan(), date(2026, 2, 1), drop_scaffolding=True + ) + assert [op.action for op in report.operations] == [ + "merge", + "merge", + "drop_function", + ] + + def test_async_drop_before_matches_the_sync_plan(self): + boundaries = (date(2026, 1, 1), date(2026, 2, 1), date(2026, 3, 1)) + kwargs = dict( + functions=_function_info(("2026-01-01", "2026-02-01", "2026-03-01")), + exists={boundary: True for boundary in boundaries}, + ) + plan = _plan() + sync_report = FakeMaintenance(**kwargs).drop_before(plan, date(2026, 2, 1)) + async_maintenance = FakeAsyncMaintenance(**kwargs) + + async def run(): + return await async_maintenance.drop_before(plan, date(2026, 2, 1)) + + async_report = asyncio.run(run()) + assert async_report.operations == sync_report.operations + assert async_report.dry_run is True + + +class TestSQLServerPartitionErrors: + """A non-transactional engine reports failures instead of pretending.""" + + def test_a_failed_statement_is_recorded_and_later_ones_stop(self): + maintenance = ScriptedMaintenance(fail_at=1) + report = maintenance.ensure( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), dry_run=False + ) + assert len(report.errors) == 1 + assert report.errors[0].startswith("create_scheme") + assert "scripted failure" in report.errors[0] + assert [op.action for op in report.operations] == ["create_function"] + assert len(maintenance.executed) == 1 + + def test_completed_merges_stay_visible_after_a_failure(self): + maintenance = ScriptedMaintenance( + functions=_function_info(("2026-01-01", "2026-02-01")), + exists={date(2026, 1, 1): True, date(2026, 2, 1): True}, + fail_at=1, + ) + report = maintenance.drop_or_merge( + _plan(), [date(2026, 1, 1), date(2026, 2, 1)], dry_run=False + ) + assert [op.action for op in report.operations] == ["merge"] + assert report.operations[0].name == str(date(2026, 1, 1)) + assert len(report.errors) == 1 + assert report.errors[0].startswith("merge ") + assert len(maintenance.executed) == 1 + + def test_async_failure_is_recorded_without_later_statements(self): + maintenance = ScriptedAsyncMaintenance(ScriptedMaintenance(fail_at=0)) + + async def run(): + return await maintenance.ensure( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), dry_run=False + ) + + report = asyncio.run(run()) + assert len(report.errors) == 1 + assert report.errors[0].startswith("create_function") + assert report.operations == () + + +class TestSQLServerPartitionPostState: + """A real run reports the catalogue it left behind, not the one it read.""" + + def test_ensure_reports_the_boundaries_it_created(self): + boundary = date(2026, 2, 1) + + def on_execute(maintenance, sql, params): + if "SPLIT RANGE" in sql: + maintenance.functions = tuple( + _function_info(("2026-01-01", "2026-02-01")) + ) + maintenance.exists[boundary] = True + + maintenance = ScriptedMaintenance( + functions=_function_info(("2026-01-01",)), + schemes=[ + PartitionSchemeInfo( + name="ps_logs", function="pf_logs", filegroups=("PRIMARY",) + ) + ], + exists={date(2026, 1, 1): True, boundary: False}, + on_execute=on_execute, + ) + report = maintenance.ensure( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), dry_run=False + ) + assert report.missing_boundaries == () + assert report.existing_boundaries == ("2026-01-01", "2026-02-01") + + def test_dry_run_keeps_the_pre_execution_snapshot(self): + maintenance = ScriptedMaintenance( + functions=_function_info(("2026-01-01",)), + schemes=[ + PartitionSchemeInfo( + name="ps_logs", function="pf_logs", filegroups=("PRIMARY",) + ) + ], + exists={date(2026, 1, 1): True, date(2026, 2, 1): False}, + ) + report = maintenance.ensure( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), dry_run=True + ) + assert report.missing_boundaries == (date(2026, 2, 1),) + assert report.existing_boundaries == ("2026-01-01",) + assert report.dry_run is True + + +class TestSQLServerPartitionCoreMapping: + """The engine rows are also exposed as core ``PartitionInfo`` rows.""" + + def test_ranges_become_core_partition_rows(self): + maintenance = FakeMaintenance( + functions=_function_info(("2026-01-01", "2026-02-01")), + schemes=[ + PartitionSchemeInfo( + name="ps_logs", function="pf_logs", filegroups=("PRIMARY",) + ) + ], + tables=[ + PartitionTableInfo( + name="logs", + schema="dbo", + scheme="ps_logs", + function="pf_logs", + partition_count=3, + ) + ], + ranges=[ + PartitionRangeInfo( + table="logs", + schema="dbo", + partition_number=1, + high_boundary="2026-01-01", + row_count=4, + data_bytes=8192, + ), + PartitionRangeInfo( + table="logs", + schema="dbo", + partition_number=2, + low_boundary="2026-01-01", + high_boundary="2026-02-01", + ), + PartitionRangeInfo( + table="logs", + schema="dbo", + partition_number=3, + low_boundary="2026-02-01", + ), + ], + exists={date(2026, 1, 1): True, date(2026, 2, 1): True}, + ) + report = maintenance.list( + _plan(scheme="ps_logs", all_filegroup="PRIMARY"), + table="logs", + schema="dbo", + ) + assert [row.partition_number for row in report.ranges] == [1, 2, 3] + assert len(report.partitions) == 3 + first, second, third = report.partitions + assert first.name == "logs_1" + assert first.parent == "logs" + assert first.level == 1 + assert first.method == "RANGE" + assert first.bound == "..2026-01-01" + assert second.bound == "2026-01-01..2026-02-01" + assert third.bound == "2026-02-01.." + assert first.schema == "dbo" + assert first.row_count == 4 + assert first.size_bytes == 8192 + assert first.extras["partition_number"] == 1 + assert first.extras["scheme"] == "ps_logs" + assert first.extras["function"] == "pf_logs" + + +def test_report_is_the_core_contract_envelope(): + report = PartitionReport(target="pf_logs") + + assert isinstance(report, CorePartitionReport) + assert report.function is None + assert report.dry_run is False + + +def test_the_sync_executor_offers_inventory_ensure_validation_and_retention(): + executor = FakeMaintenance() + + assert isinstance(executor, PartitionInventory) + assert isinstance(executor, PartitionEnsurer) + assert isinstance(executor, PartitionValidator) + assert isinstance(executor, PartitionRetention) + + +def test_the_async_executor_offers_inventory_ensure_validation_and_retention(): + executor = FakeAsyncMaintenance() + + assert isinstance(executor, AsyncPartitionInventory) + assert isinstance(executor, AsyncPartitionEnsurer) + assert isinstance(executor, AsyncPartitionValidator) + assert isinstance(executor, AsyncPartitionRetention)