sqlrules 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
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/__init__.py +12 -2
- sqlrules/cache.py +40 -0
- sqlrules/columns.py +3 -2
- sqlrules/compiler.py +75 -16
- sqlrules/constraints.py +111 -10
- sqlrules/errors.py +1 -1
- sqlrules/inspectors.py +1 -1
- sqlrules/ir.py +60 -1
- sqlrules/translators.py +35 -9
- {sqlrules-0.1.0.dist-info → sqlrules-0.2.0.dist-info}/METADATA +8 -3
- sqlrules-0.2.0.dist-info/RECORD +14 -0
- sqlrules-0.1.0.dist-info/RECORD +0 -13
- {sqlrules-0.1.0.dist-info → sqlrules-0.2.0.dist-info}/WHEEL +0 -0
- {sqlrules-0.1.0.dist-info → sqlrules-0.2.0.dist-info}/licenses/LICENSE +0 -0
sqlrules/__init__.py
CHANGED
|
@@ -10,21 +10,31 @@ from sqlrules.errors import (
|
|
|
10
10
|
TranslatorError,
|
|
11
11
|
UnsupportedConstraintError,
|
|
12
12
|
)
|
|
13
|
-
from sqlrules.ir import
|
|
13
|
+
from sqlrules.ir import (
|
|
14
|
+
CompilationContext,
|
|
15
|
+
Constraint,
|
|
16
|
+
Diagnostic,
|
|
17
|
+
FieldDescriptor,
|
|
18
|
+
FieldIR,
|
|
19
|
+
ModelIR,
|
|
20
|
+
)
|
|
14
21
|
from sqlrules.translators import SQLRulesWarning
|
|
15
22
|
|
|
16
|
-
__version__ = "0.
|
|
23
|
+
__version__ = "0.2.0"
|
|
17
24
|
|
|
18
25
|
__all__ = [
|
|
19
26
|
"CompilationContext",
|
|
20
27
|
"Compiler",
|
|
21
28
|
"ConfigurationError",
|
|
22
29
|
"Constraint",
|
|
30
|
+
"Diagnostic",
|
|
23
31
|
"FieldDescriptor",
|
|
32
|
+
"FieldIR",
|
|
24
33
|
"InternalCompilerError",
|
|
25
34
|
"InvalidModelError",
|
|
26
35
|
"InvalidTranslatorError",
|
|
27
36
|
"MissingColumnError",
|
|
37
|
+
"ModelIR",
|
|
28
38
|
"RegistryError",
|
|
29
39
|
"SQLRulesError",
|
|
30
40
|
"SQLRulesWarning",
|
sqlrules/cache.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Any
|
|
5
|
+
from weakref import WeakKeyDictionary
|
|
6
|
+
|
|
7
|
+
from sqlrules.ir import ModelIR
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ModelIRCache:
|
|
11
|
+
"""Thread-safe in-process cache of Phase-1 ModelIR keyed by model class.
|
|
12
|
+
|
|
13
|
+
Cached values are immutable and may be shared across threads. Column
|
|
14
|
+
objects are never stored here.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._lock = threading.RLock()
|
|
19
|
+
self._store: WeakKeyDictionary[type[Any], ModelIR] = WeakKeyDictionary()
|
|
20
|
+
|
|
21
|
+
def get(self, model: type[Any]) -> ModelIR | None:
|
|
22
|
+
with self._lock:
|
|
23
|
+
return self._store.get(model)
|
|
24
|
+
|
|
25
|
+
def put(self, model: type[Any], model_ir: ModelIR) -> ModelIR:
|
|
26
|
+
with self._lock:
|
|
27
|
+
self._store[model] = model_ir
|
|
28
|
+
return model_ir
|
|
29
|
+
|
|
30
|
+
def clear(self) -> None:
|
|
31
|
+
with self._lock:
|
|
32
|
+
self._store.clear()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Shared default cache for Compiler instances with cache=True.
|
|
36
|
+
_default_cache = ModelIRCache()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def default_cache() -> ModelIRCache:
|
|
40
|
+
return _default_cache
|
sqlrules/columns.py
CHANGED
|
@@ -55,8 +55,9 @@ def resolve_column(
|
|
|
55
55
|
for candidate in candidates:
|
|
56
56
|
if candidate in column_map:
|
|
57
57
|
column = _as_column_element(column_map[candidate])
|
|
58
|
-
if column is
|
|
59
|
-
|
|
58
|
+
if column is None:
|
|
59
|
+
raise MissingColumnError(field=field_name)
|
|
60
|
+
return column
|
|
60
61
|
|
|
61
62
|
columns = getattr(table, "c", None)
|
|
62
63
|
if columns is not None:
|
sqlrules/compiler.py
CHANGED
|
@@ -7,11 +7,19 @@ from typing import Any
|
|
|
7
7
|
from pydantic import BaseModel
|
|
8
8
|
from sqlalchemy.sql.elements import ColumnElement
|
|
9
9
|
|
|
10
|
+
from sqlrules.cache import ModelIRCache, default_cache
|
|
10
11
|
from sqlrules.columns import resolve_column
|
|
11
12
|
from sqlrules.constraints import ensure_supported_type, extract_constraints
|
|
12
13
|
from sqlrules.errors import ConfigurationError
|
|
13
14
|
from sqlrules.inspectors import inspect_model
|
|
14
|
-
from sqlrules.ir import
|
|
15
|
+
from sqlrules.ir import (
|
|
16
|
+
CompilationContext,
|
|
17
|
+
Diagnostic,
|
|
18
|
+
DiagnosticsCollector,
|
|
19
|
+
FieldIR,
|
|
20
|
+
ModelIR,
|
|
21
|
+
OnUnsupported,
|
|
22
|
+
)
|
|
15
23
|
from sqlrules.translators import TranslatorRegistry, default_registry
|
|
16
24
|
|
|
17
25
|
RulesDict = dict[str, list[ColumnElement[bool]]]
|
|
@@ -23,46 +31,94 @@ class Compiler:
|
|
|
23
31
|
*,
|
|
24
32
|
registry: TranslatorRegistry | None = None,
|
|
25
33
|
on_unsupported: OnUnsupported = "raise",
|
|
34
|
+
cache: bool = True,
|
|
35
|
+
model_cache: ModelIRCache | None = None,
|
|
26
36
|
) -> None:
|
|
27
37
|
if on_unsupported not in {"raise", "warn", "ignore"}:
|
|
28
38
|
raise ConfigurationError(option="on_unsupported", value=on_unsupported)
|
|
29
39
|
self.registry = registry or default_registry()
|
|
30
|
-
self.
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
self.on_unsupported = on_unsupported
|
|
41
|
+
self.cache_enabled = cache
|
|
42
|
+
self._model_cache = model_cache if model_cache is not None else default_cache()
|
|
43
|
+
self._collector = DiagnosticsCollector()
|
|
44
|
+
self.context = CompilationContext(
|
|
45
|
+
on_unsupported=on_unsupported,
|
|
46
|
+
collector=self._collector,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def diagnostics(self) -> tuple[Diagnostic, ...]:
|
|
51
|
+
"""Diagnostics from the most recent ``compile`` / ``bind`` call."""
|
|
52
|
+
return self._collector.snapshot()
|
|
53
|
+
|
|
54
|
+
def compile_model(self, model: type[BaseModel]) -> ModelIR:
|
|
55
|
+
"""Phase 1: inspect the model and extract constraint IR (no table binding)."""
|
|
56
|
+
self._collector.clear()
|
|
57
|
+
if self.cache_enabled:
|
|
58
|
+
cached = self._model_cache.get(model)
|
|
59
|
+
if cached is not None:
|
|
60
|
+
return cached
|
|
61
|
+
|
|
62
|
+
fields: list[FieldIR] = []
|
|
63
|
+
for descriptor in inspect_model(model):
|
|
64
|
+
ensure_supported_type(descriptor)
|
|
65
|
+
constraints = tuple(extract_constraints(descriptor))
|
|
66
|
+
fields.append(FieldIR(descriptor=descriptor, constraints=constraints))
|
|
67
|
+
|
|
68
|
+
model_ir = ModelIR(model=model, fields=tuple(fields))
|
|
69
|
+
if self.cache_enabled:
|
|
70
|
+
return self._model_cache.put(model, model_ir)
|
|
71
|
+
return model_ir
|
|
72
|
+
|
|
73
|
+
def bind(
|
|
33
74
|
self,
|
|
34
|
-
|
|
75
|
+
model_ir: ModelIR,
|
|
35
76
|
table: Any,
|
|
36
77
|
*,
|
|
37
78
|
column_map: Mapping[str, ColumnElement[Any]] | None = None,
|
|
38
79
|
) -> RulesDict:
|
|
39
|
-
|
|
80
|
+
"""Phase 2: resolve columns and translate IR into SQLAlchemy expressions."""
|
|
81
|
+
self._collector.clear()
|
|
82
|
+
# Refresh context in case on_unsupported was mutated (should not be).
|
|
83
|
+
self.context = CompilationContext(
|
|
84
|
+
on_unsupported=self.on_unsupported,
|
|
85
|
+
collector=self._collector,
|
|
86
|
+
)
|
|
40
87
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if not constraints:
|
|
88
|
+
rules: RulesDict = {}
|
|
89
|
+
for field_ir in model_ir.fields:
|
|
90
|
+
if not field_ir.constraints:
|
|
45
91
|
continue
|
|
46
92
|
|
|
93
|
+
descriptor = field_ir.descriptor
|
|
47
94
|
column = resolve_column(
|
|
48
|
-
|
|
95
|
+
descriptor.name,
|
|
49
96
|
table,
|
|
50
97
|
column_map,
|
|
51
|
-
alias=
|
|
52
|
-
aliases=
|
|
98
|
+
alias=descriptor.alias,
|
|
99
|
+
aliases=descriptor.aliases,
|
|
53
100
|
)
|
|
54
101
|
|
|
55
102
|
expressions: list[ColumnElement[bool]] = []
|
|
56
|
-
for constraint in constraints:
|
|
103
|
+
for constraint in field_ir.constraints:
|
|
57
104
|
expression = self.registry.translate(constraint, column, self.context)
|
|
58
105
|
if expression is not None:
|
|
59
106
|
expressions.append(expression)
|
|
60
107
|
|
|
61
108
|
if expressions:
|
|
62
|
-
rules[
|
|
109
|
+
rules[descriptor.name] = expressions
|
|
63
110
|
|
|
64
111
|
return rules
|
|
65
112
|
|
|
113
|
+
def compile(
|
|
114
|
+
self,
|
|
115
|
+
model: type[BaseModel],
|
|
116
|
+
table: Any,
|
|
117
|
+
*,
|
|
118
|
+
column_map: Mapping[str, ColumnElement[Any]] | None = None,
|
|
119
|
+
) -> RulesDict:
|
|
120
|
+
return self.bind(self.compile_model(model), table, column_map=column_map)
|
|
121
|
+
|
|
66
122
|
|
|
67
123
|
def compile(
|
|
68
124
|
model: type[BaseModel],
|
|
@@ -70,8 +126,11 @@ def compile(
|
|
|
70
126
|
*,
|
|
71
127
|
column_map: Mapping[str, ColumnElement[Any]] | None = None,
|
|
72
128
|
on_unsupported: OnUnsupported = "raise",
|
|
129
|
+
cache: bool = True,
|
|
73
130
|
) -> RulesDict:
|
|
74
|
-
return Compiler(on_unsupported=on_unsupported).compile(
|
|
131
|
+
return Compiler(on_unsupported=on_unsupported, cache=cache).compile(
|
|
132
|
+
model, table, column_map=column_map
|
|
133
|
+
)
|
|
75
134
|
|
|
76
135
|
|
|
77
136
|
def flatten(rules: RulesDict) -> list[ColumnElement[bool]]:
|
sqlrules/constraints.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from datetime import date, datetime, time, timedelta
|
|
4
5
|
from decimal import Decimal
|
|
5
6
|
from enum import Enum
|
|
@@ -23,11 +24,14 @@ from pydantic.fields import FieldInfo
|
|
|
23
24
|
from sqlrules.errors import UnsupportedConstraintError
|
|
24
25
|
from sqlrules.ir import Constraint, FieldDescriptor
|
|
25
26
|
|
|
26
|
-
_SUPPORTED_TYPES: frozenset[type[Any]] = frozenset(
|
|
27
|
-
|
|
27
|
+
_SUPPORTED_TYPES: frozenset[type[Any]] = frozenset(
|
|
28
|
+
{bool, int, float, Decimal, str, date, datetime, time, UUID}
|
|
29
|
+
)
|
|
30
|
+
_UNSUPPORTED_TYPES: frozenset[type[Any]] = frozenset({timedelta})
|
|
28
31
|
_UNSUPPORTED_ORIGINS: frozenset[Any] = frozenset({list, dict, tuple, set, frozenset})
|
|
29
32
|
_LENGTH_OPERATORS: frozenset[str] = frozenset({"min_length", "max_length"})
|
|
30
33
|
_NUMERIC_OPERATORS: frozenset[str] = frozenset({"gt", "ge", "lt", "le", "multiple_of"})
|
|
34
|
+
_TEMPORAL_TYPES: frozenset[type[Any]] = frozenset({date, datetime, time})
|
|
31
35
|
_VALIDATOR_TYPE_NAMES = frozenset(
|
|
32
36
|
{
|
|
33
37
|
"AfterValidator",
|
|
@@ -38,6 +42,33 @@ _VALIDATOR_TYPE_NAMES = frozenset(
|
|
|
38
42
|
"Strict",
|
|
39
43
|
}
|
|
40
44
|
)
|
|
45
|
+
# Keys that map to IR constraint operators.
|
|
46
|
+
_CONSTRAINT_KEYS: frozenset[str] = frozenset(
|
|
47
|
+
{
|
|
48
|
+
"gt",
|
|
49
|
+
"ge",
|
|
50
|
+
"lt",
|
|
51
|
+
"le",
|
|
52
|
+
"multiple_of",
|
|
53
|
+
"min_length",
|
|
54
|
+
"max_length",
|
|
55
|
+
"pattern",
|
|
56
|
+
"max_digits",
|
|
57
|
+
"decimal_places",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
# Pydantic / StringConstraints flags that are validation-only (not SQL operators).
|
|
61
|
+
_IGNORED_METADATA_KEYS: frozenset[str] = frozenset(
|
|
62
|
+
{
|
|
63
|
+
"strip_whitespace",
|
|
64
|
+
"to_upper",
|
|
65
|
+
"to_lower",
|
|
66
|
+
"strict",
|
|
67
|
+
"allow_inf_nan",
|
|
68
|
+
"ascii_only",
|
|
69
|
+
"coerce_numbers_to_str",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
41
72
|
|
|
42
73
|
|
|
43
74
|
def _unwrap_annotation(annotation: Any) -> tuple[Any, tuple[Any, ...]]:
|
|
@@ -96,7 +127,7 @@ def _concrete_type(field: FieldDescriptor) -> Any:
|
|
|
96
127
|
|
|
97
128
|
|
|
98
129
|
def ensure_supported_type(field: FieldDescriptor) -> None:
|
|
99
|
-
"""Raise when a field annotation is outside the
|
|
130
|
+
"""Raise when a field annotation is outside the support matrix."""
|
|
100
131
|
annotation = _concrete_type(field)
|
|
101
132
|
origin = get_origin(annotation)
|
|
102
133
|
|
|
@@ -127,7 +158,7 @@ def ensure_supported_type(field: FieldDescriptor) -> None:
|
|
|
127
158
|
field=field.name,
|
|
128
159
|
operator=type_name,
|
|
129
160
|
value=annotation,
|
|
130
|
-
suggestion=f"Type {type_name!r} is not supported
|
|
161
|
+
suggestion=f"Type {type_name!r} is not supported by SQLRules.",
|
|
131
162
|
)
|
|
132
163
|
|
|
133
164
|
# Unions with multiple non-None members and other unknown annotations.
|
|
@@ -136,10 +167,41 @@ def ensure_supported_type(field: FieldDescriptor) -> None:
|
|
|
136
167
|
field=field.name,
|
|
137
168
|
operator="type",
|
|
138
169
|
value=annotation,
|
|
139
|
-
suggestion=f"Annotation {type_name} is outside the SQLRules
|
|
170
|
+
suggestion=f"Annotation {type_name} is outside the SQLRules type support matrix.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _normalize_pattern(field_name: str, pattern: Any) -> str:
|
|
175
|
+
if isinstance(pattern, str):
|
|
176
|
+
return pattern
|
|
177
|
+
if isinstance(pattern, re.Pattern):
|
|
178
|
+
return str(pattern.pattern)
|
|
179
|
+
raise UnsupportedConstraintError(
|
|
180
|
+
field=field_name,
|
|
181
|
+
operator="pattern",
|
|
182
|
+
value=pattern,
|
|
183
|
+
suggestion="pattern must be a str or re.Pattern.",
|
|
140
184
|
)
|
|
141
185
|
|
|
142
186
|
|
|
187
|
+
def _constraints_from_mapping(field_name: str, data: dict[str, Any]) -> list[Constraint]:
|
|
188
|
+
"""Map whitelisted metadata keys into IR; ignore validation-only flags."""
|
|
189
|
+
constraints: list[Constraint] = []
|
|
190
|
+
for key, value in data.items():
|
|
191
|
+
if value is None or key in _IGNORED_METADATA_KEYS:
|
|
192
|
+
continue
|
|
193
|
+
if key == "pattern":
|
|
194
|
+
constraints.append(
|
|
195
|
+
Constraint(field_name, "pattern", _normalize_pattern(field_name, value))
|
|
196
|
+
)
|
|
197
|
+
elif key in _CONSTRAINT_KEYS:
|
|
198
|
+
constraints.append(Constraint(field_name, key, value))
|
|
199
|
+
else:
|
|
200
|
+
# Unknown keys remain unsupported operators (fail-fast).
|
|
201
|
+
constraints.append(Constraint(field_name, key, value))
|
|
202
|
+
return constraints
|
|
203
|
+
|
|
204
|
+
|
|
143
205
|
def _unsupported_constraints(field_name: str, item: Any) -> list[Constraint]:
|
|
144
206
|
if isinstance(item, Predicate):
|
|
145
207
|
return [Constraint(field_name, "predicate", item.func)]
|
|
@@ -154,11 +216,25 @@ def _unsupported_constraints(field_name: str, item: Any) -> list[Constraint]:
|
|
|
154
216
|
)
|
|
155
217
|
]
|
|
156
218
|
|
|
219
|
+
# First-class pattern attribute (e.g. _PydanticGeneralMetadata(pattern=...)).
|
|
220
|
+
pattern = getattr(item, "pattern", None)
|
|
221
|
+
if pattern is not None and isinstance(pattern, (str, re.Pattern)):
|
|
222
|
+
constraints = _constraints_from_mapping(
|
|
223
|
+
field_name,
|
|
224
|
+
{
|
|
225
|
+
key: value
|
|
226
|
+
for key, value in getattr(item, "__dict__", {}).items()
|
|
227
|
+
if key != "pattern" and value is not None
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
constraints.append(
|
|
231
|
+
Constraint(field_name, "pattern", _normalize_pattern(field_name, pattern))
|
|
232
|
+
)
|
|
233
|
+
return constraints
|
|
234
|
+
|
|
157
235
|
data = getattr(item, "__dict__", None)
|
|
158
236
|
if isinstance(data, dict) and data:
|
|
159
|
-
return
|
|
160
|
-
Constraint(field_name, key, value) for key, value in data.items() if value is not None
|
|
161
|
-
]
|
|
237
|
+
return _constraints_from_mapping(field_name, data)
|
|
162
238
|
|
|
163
239
|
return [Constraint(field_name, type_name, item)]
|
|
164
240
|
|
|
@@ -191,12 +267,29 @@ def _reject_type_operator_mismatch(field: FieldDescriptor, constraint: Constrain
|
|
|
191
267
|
return
|
|
192
268
|
|
|
193
269
|
if annotation is bool and constraint.operator in _LENGTH_OPERATORS | _NUMERIC_OPERATORS:
|
|
270
|
+
raise UnsupportedConstraintError(
|
|
271
|
+
field=field.name,
|
|
272
|
+
operator=constraint.operator,
|
|
273
|
+
value=constraint.value,
|
|
274
|
+
suggestion=("Bool fields only support Literal/equality-style constraints in SQLRules."),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if annotation is UUID and constraint.operator in _LENGTH_OPERATORS | _NUMERIC_OPERATORS:
|
|
278
|
+
raise UnsupportedConstraintError(
|
|
279
|
+
field=field.name,
|
|
280
|
+
operator=constraint.operator,
|
|
281
|
+
value=constraint.value,
|
|
282
|
+
suggestion=("UUID fields only support Literal/Enum-style constraints in SQLRules."),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if annotation in _TEMPORAL_TYPES and constraint.operator == "multiple_of":
|
|
194
286
|
raise UnsupportedConstraintError(
|
|
195
287
|
field=field.name,
|
|
196
288
|
operator=constraint.operator,
|
|
197
289
|
value=constraint.value,
|
|
198
290
|
suggestion=(
|
|
199
|
-
"
|
|
291
|
+
"multiple_of is not valid for date/datetime/time fields; "
|
|
292
|
+
"use gt/ge/lt/le range constraints."
|
|
200
293
|
),
|
|
201
294
|
)
|
|
202
295
|
|
|
@@ -216,6 +309,14 @@ def _reject_type_operator_mismatch(field: FieldDescriptor, constraint: Constrain
|
|
|
216
309
|
suggestion="Numeric comparison constraints are not valid for str fields.",
|
|
217
310
|
)
|
|
218
311
|
|
|
312
|
+
if constraint.operator == "pattern" and annotation is not str:
|
|
313
|
+
raise UnsupportedConstraintError(
|
|
314
|
+
field=field.name,
|
|
315
|
+
operator=constraint.operator,
|
|
316
|
+
value=constraint.value,
|
|
317
|
+
suggestion="pattern constraints require a str field annotation.",
|
|
318
|
+
)
|
|
319
|
+
|
|
219
320
|
|
|
220
321
|
def extract_constraints(field: FieldDescriptor) -> list[Constraint]:
|
|
221
322
|
constraints: list[Constraint] = []
|
|
@@ -250,7 +351,7 @@ def extract_constraints(field: FieldDescriptor) -> list[Constraint]:
|
|
|
250
351
|
constraints.append(Constraint(field.name, "enum", values))
|
|
251
352
|
|
|
252
353
|
for constraint in constraints:
|
|
253
|
-
if constraint.operator in _LENGTH_OPERATORS | _NUMERIC_OPERATORS:
|
|
354
|
+
if constraint.operator in _LENGTH_OPERATORS | _NUMERIC_OPERATORS | {"pattern"}:
|
|
254
355
|
_reject_type_operator_mismatch(field, constraint)
|
|
255
356
|
|
|
256
357
|
return constraints
|
sqlrules/errors.py
CHANGED
|
@@ -39,7 +39,7 @@ class UnsupportedConstraintError(SQLRulesError):
|
|
|
39
39
|
|
|
40
40
|
def __str__(self) -> str:
|
|
41
41
|
message = (
|
|
42
|
-
f"Field {self.field!r}: constraint {self.operator!r} is not supported by SQLRules
|
|
42
|
+
f"Field {self.field!r}: constraint {self.operator!r} is not supported by SQLRules."
|
|
43
43
|
)
|
|
44
44
|
if self.suggestion:
|
|
45
45
|
return f"{message} {self.suggestion}"
|
sqlrules/inspectors.py
CHANGED
|
@@ -24,7 +24,7 @@ def _field_aliases(field: Any) -> tuple[str | None, tuple[str, ...]]:
|
|
|
24
24
|
|
|
25
25
|
for attr in ("validation_alias", "serialization_alias"):
|
|
26
26
|
value = getattr(field, attr, None)
|
|
27
|
-
# AliasPath / AliasChoices are not used for column binding
|
|
27
|
+
# AliasPath / AliasChoices are not used for column binding.
|
|
28
28
|
if isinstance(value, (AliasPath, AliasChoices)):
|
|
29
29
|
continue
|
|
30
30
|
candidate = _string_alias(value)
|
sqlrules/ir.py
CHANGED
|
@@ -4,6 +4,7 @@ from dataclasses import dataclass, field
|
|
|
4
4
|
from typing import Any, Literal
|
|
5
5
|
|
|
6
6
|
OnUnsupported = Literal["raise", "warn", "ignore"]
|
|
7
|
+
DiagnosticSeverity = Literal["warning", "info"]
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
@dataclass(frozen=True, slots=True)
|
|
@@ -22,7 +23,65 @@ class FieldDescriptor:
|
|
|
22
23
|
aliases: tuple[str, ...] = ()
|
|
23
24
|
|
|
24
25
|
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Diagnostic:
|
|
28
|
+
severity: DiagnosticSeverity
|
|
29
|
+
field: str
|
|
30
|
+
operator: str
|
|
31
|
+
value: Any = None
|
|
32
|
+
message: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class DiagnosticsCollector:
|
|
37
|
+
"""Mutable collector; snapshots are immutable tuples."""
|
|
38
|
+
|
|
39
|
+
_items: list[Diagnostic] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
def add(self, diagnostic: Diagnostic) -> None:
|
|
42
|
+
self._items.append(diagnostic)
|
|
43
|
+
|
|
44
|
+
def clear(self) -> None:
|
|
45
|
+
self._items.clear()
|
|
46
|
+
|
|
47
|
+
def snapshot(self) -> tuple[Diagnostic, ...]:
|
|
48
|
+
return tuple(self._items)
|
|
49
|
+
|
|
50
|
+
|
|
25
51
|
@dataclass(frozen=True, slots=True)
|
|
26
52
|
class CompilationContext:
|
|
27
53
|
on_unsupported: OnUnsupported = "raise"
|
|
28
|
-
|
|
54
|
+
collector: DiagnosticsCollector | None = None
|
|
55
|
+
|
|
56
|
+
def record(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
severity: DiagnosticSeverity,
|
|
60
|
+
field: str,
|
|
61
|
+
operator: str,
|
|
62
|
+
value: Any = None,
|
|
63
|
+
message: str = "",
|
|
64
|
+
) -> None:
|
|
65
|
+
if self.collector is None:
|
|
66
|
+
return
|
|
67
|
+
self.collector.add(
|
|
68
|
+
Diagnostic(
|
|
69
|
+
severity=severity,
|
|
70
|
+
field=field,
|
|
71
|
+
operator=operator,
|
|
72
|
+
value=value,
|
|
73
|
+
message=message,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class FieldIR:
|
|
80
|
+
descriptor: FieldDescriptor
|
|
81
|
+
constraints: tuple[Constraint, ...]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class ModelIR:
|
|
86
|
+
model: type[Any]
|
|
87
|
+
fields: tuple[FieldIR, ...]
|
sqlrules/translators.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import operator
|
|
4
|
+
import sys
|
|
5
|
+
import types
|
|
4
6
|
import warnings
|
|
5
7
|
from collections.abc import Callable
|
|
6
8
|
from typing import Any, cast
|
|
@@ -18,6 +20,19 @@ class SQLRulesWarning(UserWarning):
|
|
|
18
20
|
"""Warning emitted when an unsupported constraint is skipped."""
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
def _warning_stacklevel() -> int:
|
|
24
|
+
"""Stacklevel that attributes warnings to the first frame outside sqlrules."""
|
|
25
|
+
frame: types.FrameType | None = sys._getframe(1)
|
|
26
|
+
level = 1
|
|
27
|
+
while frame is not None:
|
|
28
|
+
module = frame.f_globals.get("__name__", "")
|
|
29
|
+
if not (isinstance(module, str) and module.startswith("sqlrules")):
|
|
30
|
+
return level
|
|
31
|
+
frame = frame.f_back
|
|
32
|
+
level += 1
|
|
33
|
+
return 2
|
|
34
|
+
|
|
35
|
+
|
|
21
36
|
def _binary(op: Callable[[Any, Any], Any]) -> Translator:
|
|
22
37
|
def translate(
|
|
23
38
|
constraint: Constraint,
|
|
@@ -104,6 +119,10 @@ class TranslatorRegistry:
|
|
|
104
119
|
) -> ColumnElement[bool] | None:
|
|
105
120
|
translator = self.lookup(constraint.operator)
|
|
106
121
|
if translator is None:
|
|
122
|
+
message = (
|
|
123
|
+
f"Field {constraint.field!r}: constraint {constraint.operator!r} "
|
|
124
|
+
"is not supported and will be skipped."
|
|
125
|
+
)
|
|
107
126
|
if context.on_unsupported == "raise":
|
|
108
127
|
raise UnsupportedConstraintError(
|
|
109
128
|
field=constraint.field,
|
|
@@ -112,15 +131,21 @@ class TranslatorRegistry:
|
|
|
112
131
|
suggestion=("Remove the constraint, or set on_unsupported='warn'/'ignore'."),
|
|
113
132
|
)
|
|
114
133
|
if context.on_unsupported == "warn":
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
context.record(
|
|
135
|
+
severity="warning",
|
|
136
|
+
field=constraint.field,
|
|
137
|
+
operator=constraint.operator,
|
|
138
|
+
value=constraint.value,
|
|
139
|
+
message=message,
|
|
140
|
+
)
|
|
141
|
+
warnings.warn(message, SQLRulesWarning, stacklevel=_warning_stacklevel())
|
|
142
|
+
else:
|
|
143
|
+
context.record(
|
|
144
|
+
severity="info",
|
|
145
|
+
field=constraint.field,
|
|
146
|
+
operator=constraint.operator,
|
|
147
|
+
value=constraint.value,
|
|
148
|
+
message=message,
|
|
124
149
|
)
|
|
125
150
|
return None
|
|
126
151
|
|
|
@@ -147,4 +172,5 @@ def default_registry() -> TranslatorRegistry:
|
|
|
147
172
|
registry.register("max_length", _max_length)
|
|
148
173
|
registry.register("literal", _in_values)
|
|
149
174
|
registry.register("enum", _in_values)
|
|
175
|
+
# pattern is extracted into IR but has no portable core translator.
|
|
150
176
|
return registry
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlrules
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Compile constrained Pydantic models into SQLAlchemy WHERE-rule dictionaries.
|
|
5
5
|
Project-URL: Homepage, https://github.com/eddiethedean/sqlrules
|
|
6
6
|
Project-URL: Documentation, https://github.com/eddiethedean/sqlrules#readme
|
|
@@ -29,11 +29,13 @@ Requires-Dist: annotated-types>=0.6
|
|
|
29
29
|
Requires-Dist: pydantic>=2.0
|
|
30
30
|
Requires-Dist: sqlalchemy>=2.0
|
|
31
31
|
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
32
33
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
33
34
|
Requires-Dist: pre-commit>=3.7; extra == 'dev'
|
|
34
35
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
35
36
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
36
37
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
38
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
37
39
|
Description-Content-Type: text/markdown
|
|
38
40
|
|
|
39
41
|
# SQLRules
|
|
@@ -79,7 +81,7 @@ pip install sqlrules
|
|
|
79
81
|
|
|
80
82
|
Requires Python 3.10+, Pydantic v2, and SQLAlchemy 2.x.
|
|
81
83
|
|
|
82
|
-
## Supported constraints (0.
|
|
84
|
+
## Supported constraints (0.2)
|
|
83
85
|
|
|
84
86
|
| Constraint | SQLAlchemy expression |
|
|
85
87
|
|---|---|
|
|
@@ -89,6 +91,8 @@ Requires Python 3.10+, Pydantic v2, and SQLAlchemy 2.x.
|
|
|
89
91
|
| `Literal[...]` | `column.in_(...)` |
|
|
90
92
|
| `Enum` | `column.in_(...)` |
|
|
91
93
|
|
|
94
|
+
`pattern` is extracted into IR but has no portable core translator.
|
|
95
|
+
|
|
92
96
|
Unsupported constraints raise `UnsupportedConstraintError` by default.
|
|
93
97
|
Use `on_unsupported="warn"` or `"ignore"` to change that policy for unknown
|
|
94
98
|
constraint operators (unsupported types always raise).
|
|
@@ -96,7 +100,7 @@ constraint operators (unsupported types always raise).
|
|
|
96
100
|
## Public API
|
|
97
101
|
|
|
98
102
|
```python
|
|
99
|
-
sqlrules.compile(model, table, *, column_map=None, on_unsupported="raise")
|
|
103
|
+
sqlrules.compile(model, table, *, column_map=None, on_unsupported="raise", cache=True)
|
|
100
104
|
sqlrules.where(rules) # flatten all expressions
|
|
101
105
|
sqlrules.flatten(rules) # alias of where()
|
|
102
106
|
```
|
|
@@ -120,6 +124,7 @@ pip install -e ".[dev]"
|
|
|
120
124
|
pytest
|
|
121
125
|
ruff check .
|
|
122
126
|
mypy src/sqlrules
|
|
127
|
+
python -m benchmarks.bench_compile
|
|
123
128
|
```
|
|
124
129
|
|
|
125
130
|
## License
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
sqlrules/__init__.py,sha256=33UBpq4vkVgxLh6m9EU4RNM1gFRdnnZwXtYuZHJjvY4,982
|
|
2
|
+
sqlrules/cache.py,sha256=9NBDunUMvQR3bsx9QxIiO8L8JNbpI1tdLlFNapUZMj8,1051
|
|
3
|
+
sqlrules/columns.py,sha256=TcDhFG8wxgKC3-kwXhOBP27Ekrlfncm-WWrUkMLCplo,2428
|
|
4
|
+
sqlrules/compiler.py,sha256=UgtZfhCGFyt1bMu25koGaCkcd-Z9Lcer5KDF6JIe-bM,4621
|
|
5
|
+
sqlrules/constraints.py,sha256=YcI2TstNoW9GGR4TfoWBzMlmKKNPsqNjTdUr3Cd3rk8,12349
|
|
6
|
+
sqlrules/errors.py,sha256=SOfRXfhwb9wIyrPjukVP_alKEV83hlORyngN0NuCD_8,2533
|
|
7
|
+
sqlrules/inspectors.py,sha256=mEi7ixA1aQe7lyZtcFvLHVqVsAXt_tm60YNgJrYbrp8,1700
|
|
8
|
+
sqlrules/ir.py,sha256=Lq53l4ELGzrYZ0ZqM_fqeLau1kSrod91zjEUIO7_JE4,1945
|
|
9
|
+
sqlrules/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
sqlrules/translators.py,sha256=f21pBuDk6r5xKstnEgRiL8ZubFjo5QvbeHJDzOY10nU,5818
|
|
11
|
+
sqlrules-0.2.0.dist-info/METADATA,sha256=28Q8NuNDJLpREon9qGQruaR66_34QQtbTsy4Pr-2kIE,3988
|
|
12
|
+
sqlrules-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
13
|
+
sqlrules-0.2.0.dist-info/licenses/LICENSE,sha256=cLMlkCH6RhjWAkXBWR7LazV98TOG0WkPtCKB5zqqkAs,1078
|
|
14
|
+
sqlrules-0.2.0.dist-info/RECORD,,
|
sqlrules-0.1.0.dist-info/RECORD
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
sqlrules/__init__.py,sha256=51kBzKMVXNdUUgWNYzKcSFJfPA3ZlX2OiFUfZ7aljss,875
|
|
2
|
-
sqlrules/columns.py,sha256=AKLzYvYkHC-jIMPXMDLj37kUUPU3hIHCDoZYhwa9064,2373
|
|
3
|
-
sqlrules/compiler.py,sha256=iIPNGq5TN2cQ21HtrG6uRaYqksDfdvKE9h5KrnjHZu4,2529
|
|
4
|
-
sqlrules/constraints.py,sha256=oSgNzzstRxyskeKddQTNo6Cbgc_tXnx8A4KYY7_a4po,8921
|
|
5
|
-
sqlrules/errors.py,sha256=5R-tj_TROF_aWBcB-zB-e2crmAri3hhEH7bZlslyN5w,2537
|
|
6
|
-
sqlrules/inspectors.py,sha256=h6TsUVyLTyNie0up3ZM1QLReYtKBlHBcwDbJxbS8gGs,1707
|
|
7
|
-
sqlrules/ir.py,sha256=gdz9u2pnVM8WTjdUCTq1MdKA3U_NV3paVwqZqG1pc8Q,623
|
|
8
|
-
sqlrules/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
sqlrules/translators.py,sha256=mKTcSPMZewqpjVRf58iGyZJlATTyYFZmklLz3qXNIjs,4955
|
|
10
|
-
sqlrules-0.1.0.dist-info/METADATA,sha256=d9QCDHGEdUA_mr_mSFReh8wGcdaGV-3-Lj1Wdk7doZo,3788
|
|
11
|
-
sqlrules-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
-
sqlrules-0.1.0.dist-info/licenses/LICENSE,sha256=cLMlkCH6RhjWAkXBWR7LazV98TOG0WkPtCKB5zqqkAs,1078
|
|
13
|
-
sqlrules-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|