sqlrules-mssql 1.0.1__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.
- sqlrules_mssql-2.0.0/PKG-INFO +73 -0
- sqlrules_mssql-2.0.0/README.md +51 -0
- {sqlrules_mssql-1.0.1 → sqlrules_mssql-2.0.0}/pyproject.toml +2 -2
- sqlrules_mssql-2.0.0/src/sqlrules_mssql/__init__.py +152 -0
- sqlrules_mssql-2.0.0/src/sqlrules_mssql/json.py +259 -0
- {sqlrules_mssql-1.0.1 → sqlrules_mssql-2.0.0}/src/sqlrules_mssql/length.py +12 -3
- sqlrules_mssql-2.0.0/tests/test_mssql_plugin.py +114 -0
- sqlrules_mssql-1.0.1/PKG-INFO +0 -60
- sqlrules_mssql-1.0.1/README.md +0 -38
- sqlrules_mssql-1.0.1/src/sqlrules_mssql/__init__.py +0 -59
- sqlrules_mssql-1.0.1/src/sqlrules_mssql/json.py +0 -125
- sqlrules_mssql-1.0.1/src/sqlrules_mssql/type_check.py +0 -232
- sqlrules_mssql-1.0.1/tests/test_mssql_plugin.py +0 -176
- {sqlrules_mssql-1.0.1 → sqlrules_mssql-2.0.0}/.gitignore +0 -0
- {sqlrules_mssql-1.0.1 → sqlrules_mssql-2.0.0}/LICENSE +0 -0
- {sqlrules_mssql-1.0.1 → sqlrules_mssql-2.0.0}/src/sqlrules_mssql/py.typed +0 -0
|
@@ -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 = "
|
|
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>=
|
|
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
|
-
"""
|
|
13
|
-
|
|
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(
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
from sqlalchemy import Column, MetaData, String, Table
|
|
7
|
+
from sqlalchemy.dialects.mssql import dialect
|
|
8
|
+
from sqlrules_mssql import MssqlPlugin, __version__
|
|
9
|
+
|
|
10
|
+
from sqlrules import Compiler, JsonContains, JsonHasKey, RuleSchema
|
|
11
|
+
from sqlrules.conformance import run_basic_conformance
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_version_and_plugin_conformance() -> None:
|
|
15
|
+
assert __version__ == "2.0.0"
|
|
16
|
+
run_basic_conformance(MssqlPlugin(), operator="min_length")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_length_and_json_constraints_compile() -> None:
|
|
20
|
+
class Rules(RuleSchema):
|
|
21
|
+
name: Annotated[str, Field(min_length=2, max_length=40)]
|
|
22
|
+
meta: Annotated[dict[str, Any], JsonContains({"active": True}), JsonHasKey("active")]
|
|
23
|
+
|
|
24
|
+
table = Table(
|
|
25
|
+
"rows",
|
|
26
|
+
MetaData(),
|
|
27
|
+
Column("name", String),
|
|
28
|
+
Column("meta", String),
|
|
29
|
+
)
|
|
30
|
+
compiled = Compiler(
|
|
31
|
+
plugins=[MssqlPlugin(server_version=(16, 0), compatibility_level=160)]
|
|
32
|
+
).compile(Rules, table)
|
|
33
|
+
sql = str(compiled.predicate.compile(dialect=dialect()))
|
|
34
|
+
assert "len(" in sql.lower()
|
|
35
|
+
assert "isjson" in sql.lower()
|
|
36
|
+
assert "left(ltrim" in sql.lower()
|
|
37
|
+
assert "openjson" in sql.lower()
|
|
38
|
+
assert " as oj" in sql.lower()
|
|
39
|
+
assert "as oj(" not in sql.lower()
|
|
40
|
+
assert compiled.fields[1].coercion == "validated-json-text"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_empty_json_contains_checks_for_an_object_root() -> None:
|
|
44
|
+
class Rules(RuleSchema):
|
|
45
|
+
meta: Annotated[dict[str, Any], JsonContains({})]
|
|
46
|
+
|
|
47
|
+
table = Table("rows", MetaData(), Column("meta", String))
|
|
48
|
+
compiled = Compiler(
|
|
49
|
+
plugins=[MssqlPlugin(server_version=(16, 0), compatibility_level=160)]
|
|
50
|
+
).compile(Rules, table)
|
|
51
|
+
sql = str(compiled.predicate.compile(dialect=dialect())).lower()
|
|
52
|
+
assert "left(ltrim" in sql
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_text_to_integer_uses_try_cast_and_digit_validation() -> None:
|
|
56
|
+
class Rules(RuleSchema):
|
|
57
|
+
value: int
|
|
58
|
+
|
|
59
|
+
table = Table("rows", MetaData(), Column("value", String))
|
|
60
|
+
compiled = Compiler(plugins=[MssqlPlugin(server_version=(16, 0))]).compile(Rules, table)
|
|
61
|
+
sql = str(compiled.predicate.compile(dialect=dialect()))
|
|
62
|
+
assert "TRY_CAST" in sql
|
|
63
|
+
assert "NOT LIKE" in sql
|
|
64
|
+
assert compiled.fields[0].coercion == "text-to-int"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_sql_server_totalizes_predicates_with_case_expressions() -> None:
|
|
68
|
+
class Rules(RuleSchema):
|
|
69
|
+
value: int
|
|
70
|
+
|
|
71
|
+
table = Table("rows", MetaData(), Column("value", String))
|
|
72
|
+
compiled = Compiler(plugins=[MssqlPlugin(server_version=(16, 0))]).compile(Rules, table)
|
|
73
|
+
sql = str(compiled.predicate.compile(dialect=dialect())).lower()
|
|
74
|
+
assert "case when" in sql
|
|
75
|
+
assert "coalesce(" not in sql
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_pattern_remains_a_capability_error() -> None:
|
|
79
|
+
class Rules(RuleSchema):
|
|
80
|
+
name: Annotated[str, Field(pattern="^A")]
|
|
81
|
+
|
|
82
|
+
table = Table("rows", MetaData(), Column("name", String))
|
|
83
|
+
from sqlrules import CapabilityError
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
Compiler(plugins=[MssqlPlugin()]).compile(Rules, table)
|
|
87
|
+
except CapabilityError as exc:
|
|
88
|
+
assert "pattern" in str(exc)
|
|
89
|
+
else: # pragma: no cover - assertion is the capability contract
|
|
90
|
+
raise AssertionError("SQL Server does not promise regex equivalence")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_json_requires_server_version_and_database_compatibility_level() -> None:
|
|
94
|
+
class Rules(RuleSchema):
|
|
95
|
+
meta: Annotated[dict[str, Any], JsonContains({"active": True})]
|
|
96
|
+
|
|
97
|
+
table = Table("rows", MetaData(), Column("meta", String))
|
|
98
|
+
from sqlrules import CapabilityError
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
Compiler(plugins=[MssqlPlugin(server_version=(16, 0))]).compile(Rules, table)
|
|
102
|
+
except CapabilityError as exc:
|
|
103
|
+
assert "compatibility level 130" in str(exc)
|
|
104
|
+
else: # pragma: no cover - assertion is the capability contract
|
|
105
|
+
raise AssertionError("OPENJSON requires an explicit compatibility level")
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
Compiler(plugins=[MssqlPlugin(server_version=(16, 0), compatibility_level=120)]).compile(
|
|
109
|
+
Rules, table
|
|
110
|
+
)
|
|
111
|
+
except CapabilityError as exc:
|
|
112
|
+
assert "compatibility level 130" in str(exc)
|
|
113
|
+
else: # pragma: no cover - assertion is the capability contract
|
|
114
|
+
raise AssertionError("OPENJSON requires compatibility level 130 or higher")
|