Skip to content

Commit c8e73ce

Browse files
committed
feat(sqlserver): add OPTION hints, PIVOT/UNPIVOT, TABLESAMPLE, SELECT INTO, columnstore and ALTER COLUMN DDL
1 parent 2563a72 commit c8e73ce

12 files changed

Lines changed: 1143 additions & 14 deletions

File tree

‎src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py‎

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError
1010

1111

12+
_SQL_SERVER_SPARSE_COLUMN_VERSION = (10, 0, 0)
13+
_SQL_SERVER_MASKED_COLUMN_VERSION = (13, 0, 0)
14+
15+
1216
class SQLServerAlterColumnModifierMixin:
1317
"""SQL Server ALTER TABLE column modifiers.
1418
@@ -57,4 +61,124 @@ def format_drop_table_constraint_action(self, action):
5761
result = f"DROP CONSTRAINT {name}"
5862
if getattr(action, "cascade", None):
5963
result += " CASCADE"
60-
return result, ()
64+
return result, ()
65+
66+
def format_alter_column_action(self, action):
67+
"""Dispatch an ALTER COLUMN action to a SQL Server-specific formatter.
68+
69+
SQL Server re-specifies the full column rather than applying the
70+
standard SET/DROP subclauses. The ``operation`` value selects the
71+
variant:
72+
73+
- ``SET DATA TYPE`` (or ``dialect_options["data_type"]``) renders the
74+
T-SQL ``ALTER COLUMN col type [COLLATE ...] [NULL | NOT NULL] [SPARSE]``
75+
form via ``format_alter_column_type_action``.
76+
- ``ADD MASKED`` renders dynamic data masking via
77+
``format_add_masked_action``.
78+
- ``DROP MASKED`` renders ``format_drop_masked_action``.
79+
"""
80+
operation = action.operation
81+
op_str = operation.value if hasattr(operation, "value") else str(operation)
82+
dialect_options = getattr(action, "dialect_options", None) or {}
83+
84+
if op_str == "ADD MASKED" or "masked_function" in dialect_options:
85+
return self.format_add_masked_action(action)
86+
if op_str == "DROP MASKED" or dialect_options.get("drop_masked"):
87+
return self.format_drop_masked_action(action)
88+
if op_str == "SET DATA TYPE" or "data_type" in dialect_options:
89+
return self.format_alter_column_type_action(action)
90+
91+
raise UnsupportedFeatureError(
92+
self.name,
93+
f"ALTER COLUMN {op_str}",
94+
"SQL Server requires re-specifying the full column. Use "
95+
"operation='SET DATA TYPE' with dialect_options to add "
96+
"NULL/NOT NULL, COLLATE, SPARSE, or MASKED.",
97+
)
98+
99+
def format_alter_column_type_action(self, action):
100+
"""Format a T-SQL ALTER COLUMN type change.
101+
102+
SQL Syntax:
103+
ALTER COLUMN column data_type [COLLATE collation]
104+
[NULL | NOT NULL] [SPARSE]
105+
106+
The data type comes from ``dialect_options["data_type"]`` or the
107+
action's ``new_value``. ``not_null``, ``collate``, and ``sparse``
108+
are carried in ``dialect_options``.
109+
"""
110+
dialect_options = getattr(action, "dialect_options", None) or {}
111+
data_type = dialect_options.get("data_type") or action.new_value
112+
if data_type is None:
113+
raise ValueError("data type is required for ALTER COLUMN")
114+
115+
parts = [
116+
"ALTER COLUMN",
117+
self.format_identifier(action.column_name),
118+
str(data_type),
119+
]
120+
121+
collate = dialect_options.get("collate")
122+
if collate:
123+
parts.append(f"COLLATE {collate}")
124+
125+
not_null = dialect_options.get("not_null")
126+
if not_null is not None:
127+
parts.append("NOT NULL" if not_null else "NULL")
128+
129+
if dialect_options.get("sparse"):
130+
if self.version < _SQL_SERVER_SPARSE_COLUMN_VERSION: # type: ignore[attr-defined]
131+
raise UnsupportedFeatureError(
132+
self.name,
133+
"SPARSE column",
134+
suggestion="requires SQL Server 2008+.",
135+
)
136+
parts.append("SPARSE")
137+
138+
return " ".join(parts), ()
139+
140+
def format_add_masked_action(self, action):
141+
"""Format ALTER COLUMN ... ADD MASKED (dynamic data masking, 2016+).
142+
143+
SQL Syntax:
144+
ALTER COLUMN column ADD MASKED WITH (FUNCTION = 'mask_function')
145+
146+
The mask function is carried in ``dialect_options["masked_function"]``
147+
or the action's ``new_value``.
148+
"""
149+
if self.version < _SQL_SERVER_MASKED_COLUMN_VERSION: # type: ignore[attr-defined]
150+
raise UnsupportedFeatureError(
151+
self.name,
152+
"ADD MASKED (dynamic data masking)",
153+
suggestion="requires SQL Server 2016+.",
154+
)
155+
156+
dialect_options = getattr(action, "dialect_options", None) or {}
157+
function = dialect_options.get("masked_function") or action.new_value
158+
if function is None:
159+
raise ValueError("masked_function is required for ADD MASKED")
160+
161+
escaped = str(function).replace("'", "''")
162+
return (
163+
f"ALTER COLUMN {self.format_identifier(action.column_name)} "
164+
f"ADD MASKED WITH (FUNCTION = '{escaped}')",
165+
(),
166+
)
167+
168+
def format_drop_masked_action(self, action):
169+
"""Format ALTER COLUMN ... DROP MASKED (dynamic data masking, 2016+).
170+
171+
SQL Syntax:
172+
ALTER COLUMN column DROP MASKED
173+
"""
174+
if self.version < _SQL_SERVER_MASKED_COLUMN_VERSION: # type: ignore[attr-defined]
175+
raise UnsupportedFeatureError(
176+
self.name,
177+
"DROP MASKED (dynamic data masking)",
178+
suggestion="requires SQL Server 2016+.",
179+
)
180+
181+
return (
182+
f"ALTER COLUMN {self.format_identifier(action.column_name)} DROP MASKED",
183+
(),
184+
)

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@
102102
from .collation import validate_sqlserver_collation_name
103103
from .alter_table_modifier import SQLServerAlterColumnModifierMixin
104104
from .mixins.sequence import SQLServerSequenceMixin
105+
from .mixins.pivot import SQLServerPivotMixin
106+
from .mixins.columnstore import SQLServerColumnstoreIndexMixin
105107
from .mixins.protocol_support import SQLServerProtocolSupportMixin
106108
# SQLServerPartitionMixin is registered lazily in _register_partition_formatters()
107109

@@ -178,6 +180,8 @@ class SQLServerDialect(
178180
SQLServerAlterColumnModifierMixin, # Before DDLColumnMixin to override format_*_action
179181
SQLServerProtocolSupportMixin, # SQL Server protocol contract implementations
180182
SQLServerSequenceMixin, # NEXT VALUE FOR formatter (2012+)
183+
SQLServerPivotMixin, # PIVOT / UNPIVOT formatters (2005+)
184+
SQLServerColumnstoreIndexMixin, # columnstore index DDL (2012+/2014+/2022+)
181185
DDLColumnMixin,
182186
DDLTypeMixin,
183187
# Feature mixins
@@ -1689,6 +1693,20 @@ def format_top_n_clause(self, n: int, percentage: bool = False) -> str:
16891693
return f"TOP {n} PERCENT"
16901694
return f"TOP {n}"
16911695

1696+
def format_query_option_clause(self, clause) -> Tuple[str, tuple]:
1697+
"""Format an OPTION query hint clause.
1698+
1699+
SQL Server exposes optimizer hints through the query-level OPTION
1700+
clause (available in all versions):
1701+
1702+
OPTION (hint1, hint2, ...)
1703+
1704+
The clause holds pre-rendered hint strings (see
1705+
``expression.option_hint`` factories such as ``recompile_hint``,
1706+
``maxdop_hint``, and ``optimize_for_hint``).
1707+
"""
1708+
return f"OPTION ({', '.join(clause.hints)})", ()
1709+
16921710

16931711
# Lazy import to avoid circular dependency (backend → dialect → mixins.types → backend)
16941712
def _register_type_formatters():

‎src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@
66
from .temporal import SQLServerTemporalPeriodDefinition, SQLServerSystemVersioningClause
77
from .openjson import SQLServerOpenJsonExpression, OpenJsonColumn
88
from .sequence import SQLServerNextValueForExpression
9+
from .option_hint import (
10+
SQLServerOptionHintClause,
11+
recompile_hint,
12+
maxdop_hint,
13+
optimize_for_hint,
14+
hash_join_hint,
15+
loop_join_hint,
16+
merge_join_hint,
17+
)
18+
from .pivot import SQLServerPivotExpression, SQLServerUnpivotExpression
19+
from .columnstore import SQLServerColumnstoreIndexExpression
920
from .functions import (
1021
SQLServerTryCastExpression,
1122
SQLServerTryConvertExpression,
@@ -24,6 +35,16 @@
2435
"SQLServerOpenJsonExpression",
2536
"OpenJsonColumn",
2637
"SQLServerNextValueForExpression",
38+
"SQLServerOptionHintClause",
39+
"recompile_hint",
40+
"maxdop_hint",
41+
"optimize_for_hint",
42+
"hash_join_hint",
43+
"loop_join_hint",
44+
"merge_join_hint",
45+
"SQLServerPivotExpression",
46+
"SQLServerUnpivotExpression",
47+
"SQLServerColumnstoreIndexExpression",
2748
"SQLServerTryCastExpression",
2849
"SQLServerTryConvertExpression",
2950
"SQLServerContainsPredicate",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# src/rhosocial/activerecord/backend/impl/sqlserver/expression/columnstore.py
2+
"""SQL Server columnstore index DDL expression.
3+
4+
Columnstore indexes are the analytic workload workhorse of SQL Server:
5+
6+
CREATE CLUSTERED COLUMNSTORE INDEX cci ON t; -- 2014+, updatable
7+
CREATE NONCLUSTERED COLUMNSTORE INDEX ncci ON t (c); -- 2012+, read-only
8+
CREATE COLUMNSTORE INDEX cci ON t ORDER (c1, c2); -- 2022+, ordered CCI
9+
10+
SQL generation and version gating are delegated to the dialect's
11+
``format_create_columnstore_index_statement`` formatter.
12+
"""
13+
14+
from typing import Optional, Sequence, TYPE_CHECKING
15+
16+
from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams
17+
18+
if TYPE_CHECKING:
19+
from ..dialect import SQLServerDialect
20+
21+
22+
class SQLServerColumnstoreIndexExpression(BaseExpression):
23+
"""SQL Server CREATE [CLUSTERED|NONCLUSTERED] COLUMNSTORE INDEX statement.
24+
25+
Attributes:
26+
index_name: Name of the columnstore index.
27+
table_name: Table on which the index is created.
28+
columns: Key columns. Required for NONCLUSTERED columnstore
29+
(``clustered=False``), forbidden for clustered columnstore.
30+
clustered: If ``None`` the CLUSTERED/NONCLUSTERED keyword is omitted
31+
(defaults to a clustered columnstore); ``True`` renders
32+
CLUSTERED, ``False`` renders NONCLUSTERED.
33+
order_columns: Optional ORDER (...) columns for ordered clustered
34+
columnstore (SQL Server 2022+).
35+
36+
Example:
37+
>>> expr = SQLServerColumnstoreIndexExpression(
38+
... dialect, "cci", "t", clustered=True, order_columns=["c1", "c2"]
39+
... )
40+
>>> sql, params = expr.to_sql()
41+
>>> assert sql == "CREATE CLUSTERED COLUMNSTORE INDEX [cci] ON [t] ORDER ([c1], [c2])"
42+
"""
43+
44+
def __init__(
45+
self,
46+
dialect: "SQLServerDialect",
47+
index_name: str,
48+
table_name: str,
49+
columns: Sequence[str] = (),
50+
clustered: Optional[bool] = None,
51+
order_columns: Sequence[str] = (),
52+
):
53+
super().__init__(dialect)
54+
self.index_name = index_name
55+
self.table_name = table_name
56+
self.columns = list(columns)
57+
self.clustered = clustered
58+
self.order_columns = list(order_columns)
59+
60+
def validate(self, strict: bool = True) -> None:
61+
if not strict:
62+
return
63+
if self.clustered is False and not self.columns:
64+
raise ValueError("NONCLUSTERED columnstore requires key columns")
65+
if self.clustered is not False and self.columns:
66+
raise ValueError("clustered columnstore does not allow key columns")
67+
if self.order_columns and self.clustered is False:
68+
raise ValueError("ORDER (...) is only valid for clustered columnstore")
69+
70+
def to_sql(self) -> SQLQueryAndParams:
71+
return self.dialect.format_create_columnstore_index_statement(self)
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# src/rhosocial/activerecord/backend/impl/sqlserver/expression/option_hint.py
2+
"""SQL Server OPTION query hint clause.
3+
4+
SQL Server exposes optimizer hints through the query-level ``OPTION`` clause
5+
rather than comment-style hints:
6+
7+
SELECT ... OPTION (MAXDOP 4, RECOMPILE, OPTIMIZE FOR (@p = 10));
8+
9+
This module defines ``SQLServerOptionHintClause`` together with factory
10+
helpers that produce the individual hint expressions. SQL generation is
11+
delegated to the dialect's ``format_query_option_clause`` formatter. The
12+
OPTION clause is available in every supported SQL Server version (2005+).
13+
"""
14+
15+
from typing import Any, Sequence, Tuple, TYPE_CHECKING
16+
17+
from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams
18+
19+
if TYPE_CHECKING:
20+
from ..dialect import SQLServerDialect
21+
22+
23+
def recompile_hint() -> str:
24+
"""Return a RECOMPILE hint forcing a new plan for each execution."""
25+
return "RECOMPILE"
26+
27+
28+
def maxdop_hint(degree: int) -> str:
29+
"""Return a MAXDOP hint limiting the degree of parallelism."""
30+
return f"MAXDOP {degree}"
31+
32+
33+
def optimize_for_hint(pairs: Sequence[Tuple[str, Any]]) -> str:
34+
"""Return an OPTIMIZE FOR hint.
35+
36+
Args:
37+
pairs: Sequence of ``(variable, value)`` tuples. A ``None`` value
38+
renders the ``UNKNOWN`` literal, e.g.
39+
``[("@p", 10), ("@q", None)]`` renders
40+
``OPTIMIZE FOR (@p = 10, @q = UNKNOWN)``.
41+
"""
42+
parts = []
43+
for variable, value in pairs:
44+
if value is None:
45+
parts.append(f"{variable} = UNKNOWN")
46+
else:
47+
parts.append(f"{variable} = {value}")
48+
return f"OPTIMIZE FOR ({', '.join(parts)})"
49+
50+
51+
def hash_join_hint() -> str:
52+
"""Return a HASH JOIN hint for the whole query."""
53+
return "HASH JOIN"
54+
55+
56+
def loop_join_hint() -> str:
57+
"""Return a LOOP JOIN hint for the whole query."""
58+
return "LOOP JOIN"
59+
60+
61+
def merge_join_hint() -> str:
62+
"""Return a MERGE JOIN hint for the whole query."""
63+
return "MERGE JOIN"
64+
65+
66+
class SQLServerOptionHintClause(BaseExpression):
67+
"""SQL Server OPTION query hint clause.
68+
69+
Holds the individual hint expressions and delegates rendering to the
70+
dialect's ``format_query_option_clause``.
71+
72+
Attributes:
73+
hints: List of hint strings (see the module-level factories).
74+
75+
Example:
76+
>>> clause = SQLServerOptionHintClause(
77+
... dialect, [maxdop_hint(4), recompile_hint(), optimize_for_hint([("@p", 10)])]
78+
... )
79+
>>> sql, params = clause.to_sql()
80+
>>> assert sql == "OPTION (MAXDOP 4, RECOMPILE, OPTIMIZE FOR (@p = 10))"
81+
"""
82+
83+
def __init__(self, dialect: "SQLServerDialect", hints: Sequence[str]):
84+
super().__init__(dialect)
85+
self.hints = list(hints)
86+
87+
def validate(self, strict: bool = True) -> None:
88+
if not strict:
89+
return
90+
if not self.hints:
91+
raise ValueError("at least one query hint is required")
92+
93+
def to_sql(self) -> SQLQueryAndParams:
94+
return self.dialect.format_query_option_clause(self)

0 commit comments

Comments
 (0)