sqlrules-mssql 1.0.0__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ __pycache__/
11
11
  htmlcov/
12
12
  dist/
13
13
  dist-plugins/
14
+ dist-missing/
14
15
  build/
15
16
  *.egg-info/
16
17
  .DS_Store
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.5
2
+ Name: sqlrules-mssql
3
+ Version: 2.0.0
4
+ Summary: SQL Server dialect plugin for SQLRules (JSON and LEN string ops).
5
+ Project-URL: Homepage, https://github.com/eddiethedean/sqlrules
6
+ Project-URL: Repository, https://github.com/eddiethedean/sqlrules
7
+ Project-URL: Issues, https://github.com/eddiethedean/sqlrules/issues
8
+ Author: SQLRules Contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: mssql,pydantic,sqlalchemy,sqlrules,sqlserver
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: sqlalchemy<3,>=2.0
18
+ Requires-Dist: sqlrules<3,>=2
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # sqlrules-mssql
24
+
25
+ SQL Server backend provider for [SQLRules](https://github.com/eddiethedean/sqlrules).
26
+ The package version follows the core 2.x line.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install "sqlrules>=2,<3" "sqlrules-mssql>=2,<3"
32
+ ```
33
+
34
+ ## Use
35
+
36
+ ```python
37
+ from typing import Annotated, Any
38
+
39
+ from pydantic import Field
40
+ from sqlalchemy import Column, MetaData, String, Table
41
+
42
+ from sqlrules import Compiler, JsonContains, JsonHasKey, RuleSchema, where
43
+ from sqlrules_mssql import MssqlPlugin
44
+
45
+ rows = Table(
46
+ "rows",
47
+ MetaData(),
48
+ Column("name", String),
49
+ Column("meta", String), # SQL Server stores JSON documents in text columns.
50
+ )
51
+
52
+
53
+ class RowRules(RuleSchema):
54
+ name: Annotated[str, Field(min_length=2, max_length=40)]
55
+ meta: Annotated[dict[str, Any], JsonContains({"active": True}), JsonHasKey("active")]
56
+
57
+
58
+ provider = MssqlPlugin(server_version=(16, 0), compatibility_level=160)
59
+ compiled = Compiler(plugins=[provider]).compile(RowRules, rows)
60
+ statement = rows.select().where(*where(compiled))
61
+ ```
62
+
63
+ ## Capabilities
64
+
65
+ - SQL Server `LEN` length constraints
66
+ - Guarded JSON helpers for text columns validated with `ISJSON` (`server_version` >= 13 and explicit `compatibility_level` >= 130)
67
+ - `JsonContains` supports null, boolean, and string values; nested objects and arrays compare structurally, while numeric JSON values raise `CapabilityError` because exact numeric equality cannot be guaranteed from `OPENJSON` text values
68
+ - Safe lax text-to-int/float conversions on SQL Server 2012+
69
+ - No built-in regex translator
70
+ - Text-to-Decimal is a compile-time capability error
71
+
72
+ String Literal and Enum fields require an explicit binary or
73
+ case-sensitive collation. See the [type support matrix](https://sqlrules.readthedocs.io/en/latest/TYPE_SUPPORT.html).
@@ -0,0 +1,51 @@
1
+ # sqlrules-mssql
2
+
3
+ SQL Server backend provider for [SQLRules](https://github.com/eddiethedean/sqlrules).
4
+ The package version follows the core 2.x line.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install "sqlrules>=2,<3" "sqlrules-mssql>=2,<3"
10
+ ```
11
+
12
+ ## Use
13
+
14
+ ```python
15
+ from typing import Annotated, Any
16
+
17
+ from pydantic import Field
18
+ from sqlalchemy import Column, MetaData, String, Table
19
+
20
+ from sqlrules import Compiler, JsonContains, JsonHasKey, RuleSchema, where
21
+ from sqlrules_mssql import MssqlPlugin
22
+
23
+ rows = Table(
24
+ "rows",
25
+ MetaData(),
26
+ Column("name", String),
27
+ Column("meta", String), # SQL Server stores JSON documents in text columns.
28
+ )
29
+
30
+
31
+ class RowRules(RuleSchema):
32
+ name: Annotated[str, Field(min_length=2, max_length=40)]
33
+ meta: Annotated[dict[str, Any], JsonContains({"active": True}), JsonHasKey("active")]
34
+
35
+
36
+ provider = MssqlPlugin(server_version=(16, 0), compatibility_level=160)
37
+ compiled = Compiler(plugins=[provider]).compile(RowRules, rows)
38
+ statement = rows.select().where(*where(compiled))
39
+ ```
40
+
41
+ ## Capabilities
42
+
43
+ - SQL Server `LEN` length constraints
44
+ - Guarded JSON helpers for text columns validated with `ISJSON` (`server_version` >= 13 and explicit `compatibility_level` >= 130)
45
+ - `JsonContains` supports null, boolean, and string values; nested objects and arrays compare structurally, while numeric JSON values raise `CapabilityError` because exact numeric equality cannot be guaranteed from `OPENJSON` text values
46
+ - Safe lax text-to-int/float conversions on SQL Server 2012+
47
+ - No built-in regex translator
48
+ - Text-to-Decimal is a compile-time capability error
49
+
50
+ String Literal and Enum fields require an explicit binary or
51
+ case-sensitive collation. See the [type support matrix](https://sqlrules.readthedocs.io/en/latest/TYPE_SUPPORT.html).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sqlrules-mssql"
7
- version = "1.0.0"
7
+ version = "2.0.0"
8
8
  description = "SQL Server dialect plugin for SQLRules (JSON and LEN string ops)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -18,7 +18,7 @@ classifiers = [
18
18
  "Typing :: Typed",
19
19
  ]
20
20
  dependencies = [
21
- "sqlrules>=1,<2",
21
+ "sqlrules>=2,<3",
22
22
  "sqlalchemy>=2.0,<3",
23
23
  ]
24
24
 
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any, cast
5
+
6
+ from sqlalchemy import String, case, func, literal
7
+ from sqlalchemy.sql.elements import ColumnElement
8
+
9
+ from sqlrules.backend import prepare_scalar, total_predicate
10
+ from sqlrules.errors import CapabilityError
11
+ from sqlrules.ir import CompilationContext, PreparedValue, RuleField
12
+ from sqlrules.plugins import PLUGIN_API_VERSION
13
+ from sqlrules.translators import TranslatorRegistry
14
+ from sqlrules_mssql.json import translate_json_contains, translate_json_has_key
15
+ from sqlrules_mssql.length import translate_max_length, translate_min_length
16
+
17
+ __version__ = "2.0.0"
18
+
19
+
20
+ class MssqlPlugin:
21
+ """Register SQL Server constraint translators.
22
+
23
+ Does not register ``pattern`` — SQL Server has no portable regex operator
24
+ that SQLRules can emit deterministically. Provide a custom translator if
25
+ needed. Scalar types are prepared by the backend provider.
26
+ """
27
+
28
+ name = "mssql"
29
+ api_version = PLUGIN_API_VERSION
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ server_version: tuple[int, ...] | None = None,
35
+ compatibility_level: int | None = None,
36
+ ) -> None:
37
+ self.server_version = tuple(server_version) if server_version is not None else None
38
+ self.compatibility_level = compatibility_level
39
+
40
+ def capabilities(self) -> Mapping[str, Any]:
41
+ return {
42
+ "backend": self.name,
43
+ "server_version": self.server_version,
44
+ "compatibility_level": self.compatibility_level,
45
+ "json_markers_supported": self.server_version is not None
46
+ and self.server_version >= (13, 0)
47
+ and self.compatibility_level is not None
48
+ and self.compatibility_level >= 130,
49
+ "native_scalar_types": (
50
+ "bool",
51
+ "int",
52
+ "float",
53
+ "decimal",
54
+ "str",
55
+ "date",
56
+ "datetime",
57
+ "time",
58
+ "uuid",
59
+ ),
60
+ "safe_text_numeric_conversion": self.server_version is not None
61
+ and self.server_version >= (11, 0),
62
+ "safe_text_numeric_targets": (
63
+ ("int", "float")
64
+ if self.server_version is not None and self.server_version >= (11, 0)
65
+ else ()
66
+ ),
67
+ "assumptions": (
68
+ "TRY_CAST provides safe numeric conversion on SQL Server 2012 and newer.",
69
+ "Text-to-numeric conversion is accepted only when TRY_CAST succeeds.",
70
+ ),
71
+ }
72
+
73
+ def prepare_value(
74
+ self,
75
+ column: Any,
76
+ field: RuleField,
77
+ context: CompilationContext,
78
+ ) -> PreparedValue:
79
+ if field.python_type is dict and any(
80
+ item.operator in {"json_contains", "json_has_key"} for item in field.constraints
81
+ ):
82
+ if self.server_version is None or self.server_version < (13, 0):
83
+ raise CapabilityError(
84
+ self.name,
85
+ field.name,
86
+ "json",
87
+ type(column.type).__name__,
88
+ "SQL Server JSON markers require SQL Server 2016 or newer; "
89
+ "pass server_version explicitly.",
90
+ )
91
+ if self.compatibility_level is None or self.compatibility_level < 130:
92
+ raise CapabilityError(
93
+ self.name,
94
+ field.name,
95
+ "json",
96
+ type(column.type).__name__,
97
+ "OPENJSON requires database compatibility level 130 or higher; "
98
+ "pass compatibility_level explicitly.",
99
+ )
100
+ source_name = type(column.type).__name__.lower()
101
+ if not isinstance(column.type, String) and "json" not in source_name:
102
+ raise CapabilityError(
103
+ self.name,
104
+ field.name,
105
+ "json",
106
+ source_name,
107
+ "SQL Server JSON markers require a text or JSON column.",
108
+ )
109
+ valid = total_predicate(func.isjson(column) == 1)
110
+ safe_json = case((valid, column), else_=literal("{}"))
111
+ return PreparedValue(
112
+ source=column,
113
+ value=cast(ColumnElement[Any], safe_json),
114
+ valid=valid,
115
+ is_null=cast(ColumnElement[bool], column.is_(None)),
116
+ logical_type="json",
117
+ coercion="validated-json-text",
118
+ capability="isjson-guarded",
119
+ )
120
+ return prepare_scalar(column, field, context, backend=self.name)
121
+
122
+ def register(self, registry: TranslatorRegistry) -> None:
123
+ registry.register_constraint(
124
+ "min_length",
125
+ translate_min_length,
126
+ on_conflict="replace",
127
+ )
128
+ registry.register_constraint(
129
+ "max_length",
130
+ translate_max_length,
131
+ on_conflict="replace",
132
+ )
133
+ registry.register_constraint(
134
+ "json_contains",
135
+ translate_json_contains,
136
+ on_conflict="replace",
137
+ )
138
+ registry.register_constraint(
139
+ "json_has_key",
140
+ translate_json_has_key,
141
+ on_conflict="replace",
142
+ )
143
+
144
+
145
+ __all__ = [
146
+ "MssqlPlugin",
147
+ "__version__",
148
+ "translate_json_contains",
149
+ "translate_json_has_key",
150
+ "translate_max_length",
151
+ "translate_min_length",
152
+ ]
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+ from sqlalchemy import Integer, String, Unicode, and_, exists, func, literal, select
6
+ from sqlalchemy import cast as sa_cast
7
+ from sqlalchemy import column as sa_column
8
+ from sqlalchemy.sql.elements import ColumnElement
9
+
10
+ from sqlrules.errors import CapabilityError
11
+ from sqlrules.ir import CompilationContext, Constraint
12
+
13
+
14
+ def _json_path_for_key(key: Any) -> str:
15
+ """Build a JSONPath for a single object key (never a full-path escape hatch)."""
16
+ text = str(key)
17
+ # SQL Server JSON path quotes use doubled double-quotes inside the name.
18
+ escaped = text.replace('"', '""')
19
+ return f'$."{escaped}"'
20
+
21
+
22
+ def _json_path_for_array_index(index: int) -> str:
23
+ """Build a JSONPath for one zero-based array element."""
24
+ return f"$[{index}]"
25
+
26
+
27
+ def _exact_text_equals(
28
+ left: ColumnElement[Any],
29
+ right: ColumnElement[Any],
30
+ ) -> ColumnElement[bool]:
31
+ """Compare text ordinally and distinguish trailing spaces on SQL Server."""
32
+ left_text = sa_cast(left, Unicode()).collate("Latin1_General_100_BIN2")
33
+ right_text = sa_cast(right, Unicode()).collate("Latin1_General_100_BIN2")
34
+ return cast(
35
+ ColumnElement[bool],
36
+ (left_text == right_text) & (func.datalength(left_text) == func.datalength(right_text)),
37
+ )
38
+
39
+
40
+ def _openjson_key_exists(
41
+ column: ColumnElement[Any],
42
+ key: str,
43
+ *,
44
+ json_type: int | None = None,
45
+ ) -> ColumnElement[bool]:
46
+ """True when OPENJSON lists ``key`` (optionally with a specific JSON type)."""
47
+ return _openjson_key_matches(column, key, json_type=json_type)
48
+
49
+
50
+ def _openjson_key_matches(
51
+ column: ColumnElement[Any],
52
+ key: str,
53
+ *,
54
+ json_type: int | None = None,
55
+ expected_value: str | None = None,
56
+ ) -> ColumnElement[bool]:
57
+ """True when a top-level JSON key has the requested type and value."""
58
+ oj = (
59
+ func.openjson(column)
60
+ .table_valued(
61
+ sa_column("key", String),
62
+ sa_column("value", Unicode),
63
+ sa_column("type", Integer),
64
+ )
65
+ .alias("oj")
66
+ )
67
+ predicate = _exact_text_equals(
68
+ oj.c.key,
69
+ literal(key, type_=Unicode()),
70
+ )
71
+ if json_type is not None:
72
+ predicate = predicate & (oj.c.type == json_type)
73
+ if expected_value is not None:
74
+ predicate = predicate & _exact_text_equals(
75
+ oj.c.value,
76
+ literal(expected_value, type_=Unicode()),
77
+ )
78
+ return cast(ColumnElement[bool], exists(select(1).select_from(oj).where(predicate)))
79
+
80
+
81
+ def _openjson_child_count(document: ColumnElement[Any]) -> ColumnElement[Any]:
82
+ children = func.openjson(document).table_valued(sa_column("key", String)).alias("children")
83
+ return select(func.count()).select_from(children).scalar_subquery()
84
+
85
+
86
+ def _openjson_object_equals(
87
+ document: ColumnElement[Any],
88
+ expected: dict[Any, Any],
89
+ field: str,
90
+ ) -> ColumnElement[bool]:
91
+ parts: list[ColumnElement[bool]] = [
92
+ cast(ColumnElement[bool], _openjson_child_count(document) == len(expected))
93
+ ]
94
+ parts.extend(
95
+ _openjson_value_equals(document, str(key), value, field) for key, value in expected.items()
96
+ )
97
+ return cast(ColumnElement[bool], and_(*parts))
98
+
99
+
100
+ def _openjson_array_equals(
101
+ document: ColumnElement[Any],
102
+ expected: list[Any],
103
+ field: str,
104
+ ) -> ColumnElement[bool]:
105
+ parts: list[ColumnElement[bool]] = [
106
+ cast(ColumnElement[bool], _openjson_child_count(document) == len(expected))
107
+ ]
108
+ parts.extend(
109
+ _openjson_value_equals(
110
+ document,
111
+ str(index),
112
+ value,
113
+ field,
114
+ nested_path=_json_path_for_array_index(index),
115
+ )
116
+ for index, value in enumerate(expected)
117
+ )
118
+ return cast(ColumnElement[bool], and_(*parts))
119
+
120
+
121
+ def _openjson_value_equals(
122
+ document: ColumnElement[Any],
123
+ key: str,
124
+ expected: Any,
125
+ field: str,
126
+ *,
127
+ nested_path: str | None = None,
128
+ ) -> ColumnElement[bool]:
129
+ if expected is None:
130
+ return _openjson_key_exists(document, key, json_type=0)
131
+ if isinstance(expected, bool):
132
+ return _openjson_key_matches(
133
+ document,
134
+ key,
135
+ json_type=3,
136
+ expected_value="true" if expected else "false",
137
+ )
138
+ if isinstance(expected, str):
139
+ return _openjson_key_matches(
140
+ document,
141
+ key,
142
+ json_type=1,
143
+ expected_value=expected,
144
+ )
145
+ if isinstance(expected, dict):
146
+ nested = func.json_query(document, nested_path or _json_path_for_key(key))
147
+ return cast(
148
+ ColumnElement[bool],
149
+ _openjson_key_exists(document, key, json_type=5)
150
+ & _openjson_object_equals(nested, expected, field),
151
+ )
152
+ if isinstance(expected, list):
153
+ nested = func.json_query(document, nested_path or _json_path_for_key(key))
154
+ return cast(
155
+ ColumnElement[bool],
156
+ _openjson_key_exists(document, key, json_type=4)
157
+ & _openjson_array_equals(nested, expected, field),
158
+ )
159
+ if isinstance(expected, (int, float)):
160
+ raise CapabilityError(
161
+ "mssql",
162
+ field,
163
+ "exact JSON numeric equality",
164
+ "OPENJSON.value text",
165
+ "SQL Server exposes JSON numbers as text, and the provider cannot "
166
+ "guarantee exact numeric equivalence without lossy conversion.",
167
+ )
168
+ raise CapabilityError(
169
+ "mssql",
170
+ field,
171
+ "JSON scalar equality",
172
+ type(expected).__name__,
173
+ "Only null, boolean, string, object, and array values are supported by "
174
+ "the SQL Server JSON containment translator.",
175
+ )
176
+
177
+
178
+ def _is_json_root(
179
+ document: ColumnElement[Any],
180
+ root_marker: str,
181
+ ) -> ColumnElement[bool]:
182
+ """Check the root shape using functions available since SQL Server 2016."""
183
+ text: ColumnElement[Any] = sa_cast(document, Unicode())
184
+ # LTRIM on supported SQL Server versions removes spaces only. Normalize
185
+ # the other JSON whitespace characters first so valid pretty-printed
186
+ # documents receive the same root-shape check.
187
+ for whitespace in ("\t", "\n", "\r"):
188
+ text = cast(
189
+ ColumnElement[Any],
190
+ func.replace(
191
+ text,
192
+ literal(whitespace, type_=Unicode()),
193
+ literal(" ", type_=Unicode()),
194
+ ),
195
+ )
196
+ return cast(
197
+ ColumnElement[bool],
198
+ (func.isjson(document) == 1)
199
+ & (func.left(func.ltrim(text), 1) == literal(root_marker, type_=Unicode())),
200
+ )
201
+
202
+
203
+ def _is_json_object(document: ColumnElement[Any]) -> ColumnElement[bool]:
204
+ return _is_json_root(document, "{")
205
+
206
+
207
+ def _is_json_array(document: ColumnElement[Any]) -> ColumnElement[bool]:
208
+ return _is_json_root(document, "[")
209
+
210
+
211
+ def translate_json_contains(
212
+ constraint: Constraint,
213
+ column: ColumnElement[Any],
214
+ context: CompilationContext,
215
+ ) -> ColumnElement[bool]:
216
+ """Translate ``json_contains`` using SQL Server JSON functions.
217
+
218
+ Object payloads contain their requested top-level keys. Nested objects and
219
+ arrays are matched exactly by structure, independent of whitespace and
220
+ object key order. Numeric comparisons are rejected because ``OPENJSON``
221
+ exposes numbers as text and SQL Server cannot guarantee exact equality
222
+ without a potentially lossy conversion.
223
+ """
224
+ value = constraint.value
225
+ if isinstance(value, dict):
226
+ if not value:
227
+ return _is_json_object(column)
228
+ parts: list[ColumnElement[bool]] = []
229
+ for key, expected in value.items():
230
+ parts.append(_openjson_value_equals(column, str(key), expected, constraint.field))
231
+ expression = parts[0]
232
+ for part in parts[1:]:
233
+ expression = expression & part
234
+ return cast(ColumnElement[bool], _is_json_object(column) & expression)
235
+ if isinstance(value, list):
236
+ return cast(
237
+ ColumnElement[bool],
238
+ _is_json_array(column) & _openjson_array_equals(column, value, constraint.field),
239
+ )
240
+ raise CapabilityError(
241
+ "mssql",
242
+ constraint.field,
243
+ "JSON scalar containment",
244
+ type(value).__name__,
245
+ "SQL Server's JSON containment translator supports object and array payloads; "
246
+ "top-level scalar payloads cannot be compared reliably.",
247
+ )
248
+
249
+
250
+ def translate_json_has_key(
251
+ constraint: Constraint,
252
+ column: ColumnElement[Any],
253
+ context: CompilationContext,
254
+ ) -> ColumnElement[bool]:
255
+ """Translate ``json_has_key`` via ``OPENJSON`` key presence (includes JSON null)."""
256
+ return cast(
257
+ ColumnElement[bool],
258
+ _is_json_object(column) & _openjson_key_exists(column, str(constraint.value)),
259
+ )
@@ -2,15 +2,24 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any, cast
4
4
 
5
- from sqlalchemy import func, literal
5
+ from sqlalchemy import Unicode, func, literal
6
+ from sqlalchemy import cast as sa_cast
6
7
  from sqlalchemy.sql.elements import ColumnElement
7
8
 
8
9
  from sqlrules.ir import CompilationContext, Constraint
9
10
 
10
11
 
11
12
  def _char_length(column: ColumnElement[Any]) -> ColumnElement[Any]:
12
- """Character length including trailing spaces (``LEN`` alone strips them)."""
13
- return func.len(column.concat(literal("."))) - 1
13
+ """Count Unicode code points and trailing spaces on supported SQL Server.
14
+
15
+ SQL Server ``LEN`` counts a UTF-16 surrogate pair as two under ordinary
16
+ collations. Convert to an unbounded Unicode expression with an SC-aware
17
+ collation before measuring; the appended character keeps trailing spaces
18
+ in the count.
19
+ """
20
+ unicode_column = sa_cast(column, Unicode()).collate("Latin1_General_100_CI_AS_SC")
21
+ sentinel = literal(".", type_=Unicode())
22
+ return func.len(unicode_column.concat(sentinel)) - 1
14
23
 
15
24
 
16
25
  def translate_min_length(