sqlrules 0.2.0__py3-none-any.whl → 0.4.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 +26 -1
- sqlrules/compiler.py +67 -2
- sqlrules/conformance.py +160 -0
- sqlrules/constraints.py +61 -11
- sqlrules/errors.py +22 -6
- sqlrules/ir.py +13 -0
- sqlrules/markers.py +86 -0
- sqlrules/plugins.py +57 -0
- sqlrules/translators.py +72 -5
- {sqlrules-0.2.0.dist-info → sqlrules-0.4.0.dist-info}/METADATA +44 -5
- sqlrules-0.4.0.dist-info/RECORD +17 -0
- sqlrules-0.2.0.dist-info/RECORD +0 -14
- {sqlrules-0.2.0.dist-info → sqlrules-0.4.0.dist-info}/WHEEL +0 -0
- {sqlrules-0.2.0.dist-info → sqlrules-0.4.0.dist-info}/licenses/LICENSE +0 -0
sqlrules/__init__.py
CHANGED
|
@@ -5,6 +5,7 @@ from sqlrules.errors import (
|
|
|
5
5
|
InvalidModelError,
|
|
6
6
|
InvalidTranslatorError,
|
|
7
7
|
MissingColumnError,
|
|
8
|
+
PluginError,
|
|
8
9
|
RegistryError,
|
|
9
10
|
SQLRulesError,
|
|
10
11
|
TranslatorError,
|
|
@@ -17,26 +18,50 @@ from sqlrules.ir import (
|
|
|
17
18
|
FieldDescriptor,
|
|
18
19
|
FieldIR,
|
|
19
20
|
ModelIR,
|
|
21
|
+
PatternSpec,
|
|
20
22
|
)
|
|
23
|
+
from sqlrules.markers import (
|
|
24
|
+
ArrayContains,
|
|
25
|
+
ArrayOverlap,
|
|
26
|
+
ConstraintMarker,
|
|
27
|
+
FullTextMatch,
|
|
28
|
+
JsonContains,
|
|
29
|
+
JsonHasKey,
|
|
30
|
+
RangeContains,
|
|
31
|
+
RangeOverlap,
|
|
32
|
+
)
|
|
33
|
+
from sqlrules.plugins import PLUGIN_API_VERSION, SQLRulesPlugin
|
|
21
34
|
from sqlrules.translators import SQLRulesWarning
|
|
22
35
|
|
|
23
|
-
__version__ = "0.
|
|
36
|
+
__version__ = "0.4.0"
|
|
24
37
|
|
|
25
38
|
__all__ = [
|
|
39
|
+
"PLUGIN_API_VERSION",
|
|
40
|
+
"ArrayContains",
|
|
41
|
+
"ArrayOverlap",
|
|
26
42
|
"CompilationContext",
|
|
27
43
|
"Compiler",
|
|
28
44
|
"ConfigurationError",
|
|
29
45
|
"Constraint",
|
|
46
|
+
"ConstraintMarker",
|
|
30
47
|
"Diagnostic",
|
|
31
48
|
"FieldDescriptor",
|
|
32
49
|
"FieldIR",
|
|
50
|
+
"FullTextMatch",
|
|
33
51
|
"InternalCompilerError",
|
|
34
52
|
"InvalidModelError",
|
|
35
53
|
"InvalidTranslatorError",
|
|
54
|
+
"JsonContains",
|
|
55
|
+
"JsonHasKey",
|
|
36
56
|
"MissingColumnError",
|
|
37
57
|
"ModelIR",
|
|
58
|
+
"PatternSpec",
|
|
59
|
+
"PluginError",
|
|
60
|
+
"RangeContains",
|
|
61
|
+
"RangeOverlap",
|
|
38
62
|
"RegistryError",
|
|
39
63
|
"SQLRulesError",
|
|
64
|
+
"SQLRulesPlugin",
|
|
40
65
|
"SQLRulesWarning",
|
|
41
66
|
"TranslatorError",
|
|
42
67
|
"UnsupportedConstraintError",
|
sqlrules/compiler.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from collections.abc import Mapping
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
4
|
from itertools import chain
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
@@ -18,8 +18,10 @@ from sqlrules.ir import (
|
|
|
18
18
|
DiagnosticsCollector,
|
|
19
19
|
FieldIR,
|
|
20
20
|
ModelIR,
|
|
21
|
+
OnConflict,
|
|
21
22
|
OnUnsupported,
|
|
22
23
|
)
|
|
24
|
+
from sqlrules.plugins import SQLRulesPlugin, validate_plugin
|
|
23
25
|
from sqlrules.translators import TranslatorRegistry, default_registry
|
|
24
26
|
|
|
25
27
|
RulesDict = dict[str, list[ColumnElement[bool]]]
|
|
@@ -30,20 +32,45 @@ class Compiler:
|
|
|
30
32
|
self,
|
|
31
33
|
*,
|
|
32
34
|
registry: TranslatorRegistry | None = None,
|
|
35
|
+
plugins: Sequence[SQLRulesPlugin] | None = None,
|
|
36
|
+
on_conflict: OnConflict = "raise",
|
|
33
37
|
on_unsupported: OnUnsupported = "raise",
|
|
38
|
+
dialect: str | None = None,
|
|
34
39
|
cache: bool = True,
|
|
35
40
|
model_cache: ModelIRCache | None = None,
|
|
36
41
|
) -> None:
|
|
37
42
|
if on_unsupported not in {"raise", "warn", "ignore"}:
|
|
38
43
|
raise ConfigurationError(option="on_unsupported", value=on_unsupported)
|
|
39
|
-
|
|
44
|
+
if on_conflict not in {"raise", "replace", "ignore"}:
|
|
45
|
+
raise ConfigurationError(option="on_conflict", value=on_conflict)
|
|
46
|
+
|
|
47
|
+
base = registry if registry is not None else default_registry()
|
|
48
|
+
plugin_list = list(plugins or ())
|
|
49
|
+
resolved: TranslatorRegistry
|
|
50
|
+
if plugin_list:
|
|
51
|
+
# Copy so plugins cannot mutate a caller-owned or shared registry.
|
|
52
|
+
aware = _PluginAwareRegistry(
|
|
53
|
+
base.copy(),
|
|
54
|
+
default_on_conflict=on_conflict,
|
|
55
|
+
)
|
|
56
|
+
for plugin in plugin_list:
|
|
57
|
+
validate_plugin(plugin).register(aware)
|
|
58
|
+
# Freeze to a plain registry after registration.
|
|
59
|
+
resolved = aware.copy()
|
|
60
|
+
else:
|
|
61
|
+
resolved = base
|
|
62
|
+
self.registry = resolved
|
|
63
|
+
|
|
40
64
|
self.on_unsupported = on_unsupported
|
|
65
|
+
self.on_conflict = on_conflict
|
|
66
|
+
self.dialect = dialect
|
|
41
67
|
self.cache_enabled = cache
|
|
42
68
|
self._model_cache = model_cache if model_cache is not None else default_cache()
|
|
43
69
|
self._collector = DiagnosticsCollector()
|
|
44
70
|
self.context = CompilationContext(
|
|
45
71
|
on_unsupported=on_unsupported,
|
|
46
72
|
collector=self._collector,
|
|
73
|
+
dialect=dialect,
|
|
47
74
|
)
|
|
48
75
|
|
|
49
76
|
@property
|
|
@@ -83,6 +110,7 @@ class Compiler:
|
|
|
83
110
|
self.context = CompilationContext(
|
|
84
111
|
on_unsupported=self.on_unsupported,
|
|
85
112
|
collector=self._collector,
|
|
113
|
+
dialect=self.dialect,
|
|
86
114
|
)
|
|
87
115
|
|
|
88
116
|
rules: RulesDict = {}
|
|
@@ -120,6 +148,43 @@ class Compiler:
|
|
|
120
148
|
return self.bind(self.compile_model(model), table, column_map=column_map)
|
|
121
149
|
|
|
122
150
|
|
|
151
|
+
class _PluginAwareRegistry(TranslatorRegistry):
|
|
152
|
+
"""Registry that applies Compiler.on_conflict as the default for register()."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
base: TranslatorRegistry,
|
|
157
|
+
*,
|
|
158
|
+
default_on_conflict: OnConflict,
|
|
159
|
+
) -> None:
|
|
160
|
+
super().__init__()
|
|
161
|
+
self._translators = dict(base._translators)
|
|
162
|
+
self._default_on_conflict = default_on_conflict
|
|
163
|
+
|
|
164
|
+
def register(
|
|
165
|
+
self,
|
|
166
|
+
operator_name: str,
|
|
167
|
+
translator: Any,
|
|
168
|
+
*,
|
|
169
|
+
replace: bool = False,
|
|
170
|
+
) -> None:
|
|
171
|
+
on_conflict: OnConflict = "replace" if replace else self._default_on_conflict
|
|
172
|
+
self.register_constraint(operator_name, translator, on_conflict=on_conflict)
|
|
173
|
+
|
|
174
|
+
def register_constraint(
|
|
175
|
+
self,
|
|
176
|
+
operator_name: str,
|
|
177
|
+
translator: Any,
|
|
178
|
+
*,
|
|
179
|
+
on_conflict: OnConflict | None = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
super().register_constraint(
|
|
182
|
+
operator_name,
|
|
183
|
+
translator,
|
|
184
|
+
on_conflict=on_conflict if on_conflict is not None else self._default_on_conflict,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
123
188
|
def compile(
|
|
124
189
|
model: type[BaseModel],
|
|
125
190
|
table: Any,
|
sqlrules/conformance.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Plugin conformance helpers for official and third-party plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
from sqlalchemy import Column, MetaData, String, Table
|
|
10
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
11
|
+
|
|
12
|
+
from sqlrules.compiler import Compiler
|
|
13
|
+
from sqlrules.errors import PluginError, RegistryError
|
|
14
|
+
from sqlrules.plugins import PLUGIN_API_VERSION, SQLRulesPlugin, validate_plugin
|
|
15
|
+
from sqlrules.translators import TranslatorRegistry, default_registry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def assert_plugin_api_compatible(plugin: SQLRulesPlugin) -> None:
|
|
19
|
+
"""Raise PluginError if the plugin does not match the current plugin API."""
|
|
20
|
+
validate_plugin(plugin)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def assert_register_conflict_raise(plugin: SQLRulesPlugin) -> None:
|
|
24
|
+
"""Registering the same plugin twice with on_conflict='raise' must fail.
|
|
25
|
+
|
|
26
|
+
Plugins that only add new operators (not in the default registry) are
|
|
27
|
+
registered twice against a fresh default registry; the second pass must
|
|
28
|
+
raise RegistryError for any overlapping operator.
|
|
29
|
+
"""
|
|
30
|
+
validate_plugin(plugin)
|
|
31
|
+
registry = default_registry()
|
|
32
|
+
plugin.register(registry)
|
|
33
|
+
try:
|
|
34
|
+
plugin.register(registry)
|
|
35
|
+
except RegistryError:
|
|
36
|
+
return
|
|
37
|
+
raise AssertionError(
|
|
38
|
+
f"Plugin {plugin.name!r} did not raise RegistryError on duplicate registration"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def assert_register_idempotent_ignore(plugin: SQLRulesPlugin) -> None:
|
|
43
|
+
"""Registering twice with on_conflict='ignore' must succeed and keep builtins."""
|
|
44
|
+
validate_plugin(plugin)
|
|
45
|
+
builtins = default_registry().operators()
|
|
46
|
+
compiler = Compiler(plugins=[plugin], on_conflict="ignore", cache=False)
|
|
47
|
+
first_ops = compiler.registry.operators()
|
|
48
|
+
# Re-apply via a second compiler with the same plugin.
|
|
49
|
+
compiler2 = Compiler(
|
|
50
|
+
registry=compiler.registry.copy(),
|
|
51
|
+
plugins=[plugin],
|
|
52
|
+
on_conflict="ignore",
|
|
53
|
+
cache=False,
|
|
54
|
+
)
|
|
55
|
+
assert compiler2.registry.operators() == first_ops
|
|
56
|
+
assert builtins <= compiler2.registry.operators()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def assert_builtins_preserved(
|
|
60
|
+
plugin: SQLRulesPlugin,
|
|
61
|
+
*,
|
|
62
|
+
on_conflict: str = "raise",
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Plugins must not drop built-in operators."""
|
|
65
|
+
validate_plugin(plugin)
|
|
66
|
+
builtins = default_registry().operators()
|
|
67
|
+
compiler = Compiler(plugins=[plugin], on_conflict=on_conflict, cache=False) # type: ignore[arg-type]
|
|
68
|
+
missing = builtins - compiler.registry.operators()
|
|
69
|
+
if missing:
|
|
70
|
+
raise AssertionError(
|
|
71
|
+
f"Plugin {plugin.name!r} removed built-in operators: {sorted(missing)}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def assert_translates_operator(
|
|
76
|
+
plugin: SQLRulesPlugin,
|
|
77
|
+
*,
|
|
78
|
+
operator: str,
|
|
79
|
+
model: type[BaseModel] | None = None,
|
|
80
|
+
table: Any | None = None,
|
|
81
|
+
field: str = "name",
|
|
82
|
+
) -> ColumnElement[bool]:
|
|
83
|
+
"""Compile with the plugin and assert ``operator`` yields a ColumnElement."""
|
|
84
|
+
validate_plugin(plugin)
|
|
85
|
+
|
|
86
|
+
if model is None:
|
|
87
|
+
|
|
88
|
+
class PatternFilter(BaseModel):
|
|
89
|
+
name: Annotated[str, Field(pattern=r"^a")]
|
|
90
|
+
|
|
91
|
+
model = PatternFilter
|
|
92
|
+
|
|
93
|
+
if table is None:
|
|
94
|
+
table = Table("items", MetaData(), Column("name", String))
|
|
95
|
+
|
|
96
|
+
compiler = Compiler(plugins=[plugin], on_conflict="replace", cache=False)
|
|
97
|
+
if operator not in compiler.registry:
|
|
98
|
+
raise AssertionError(f"Plugin {plugin.name!r} did not register operator {operator!r}")
|
|
99
|
+
|
|
100
|
+
rules = compiler.compile(model, table)
|
|
101
|
+
if field not in rules or not rules[field]:
|
|
102
|
+
raise AssertionError(f"Plugin {plugin.name!r}: expected expressions for field {field!r}")
|
|
103
|
+
|
|
104
|
+
expression = rules[field][0]
|
|
105
|
+
if not isinstance(expression, ColumnElement):
|
|
106
|
+
raise AssertionError(
|
|
107
|
+
f"Plugin {plugin.name!r}: translator for {operator!r} "
|
|
108
|
+
f"returned {type(expression)!r}, expected ColumnElement"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Deterministic compile smoke check (avoid literal_binds; values may be
|
|
112
|
+
# structured IR objects such as PatternSpec).
|
|
113
|
+
compiled_a = str(expression.compile())
|
|
114
|
+
compiled_b = str(expression.compile())
|
|
115
|
+
if compiled_a != compiled_b:
|
|
116
|
+
raise AssertionError(f"Plugin {plugin.name!r}: non-deterministic compile for {operator!r}")
|
|
117
|
+
return expression
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_basic_conformance(
|
|
121
|
+
plugin: SQLRulesPlugin,
|
|
122
|
+
*,
|
|
123
|
+
operator: str = "pattern",
|
|
124
|
+
model: type[BaseModel] | None = None,
|
|
125
|
+
table: Any | None = None,
|
|
126
|
+
field: str = "name",
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Run the standard conformance checks for a dialect/translator plugin."""
|
|
129
|
+
if getattr(plugin, "api_version", None) != PLUGIN_API_VERSION:
|
|
130
|
+
raise PluginError(
|
|
131
|
+
message=(
|
|
132
|
+
f"Plugin {getattr(plugin, 'name', plugin)!r} api_version mismatch "
|
|
133
|
+
f"(expected {PLUGIN_API_VERSION!r})."
|
|
134
|
+
),
|
|
135
|
+
plugin=plugin,
|
|
136
|
+
)
|
|
137
|
+
assert_plugin_api_compatible(plugin)
|
|
138
|
+
assert_builtins_preserved(plugin, on_conflict="replace")
|
|
139
|
+
assert_register_idempotent_ignore(plugin)
|
|
140
|
+
assert_translates_operator(
|
|
141
|
+
plugin,
|
|
142
|
+
operator=operator,
|
|
143
|
+
model=model,
|
|
144
|
+
table=table,
|
|
145
|
+
field=field,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Conflict under raise: only meaningful if the plugin re-registers an op
|
|
149
|
+
# already present after the first registration.
|
|
150
|
+
registry = default_registry()
|
|
151
|
+
plugin.register(registry)
|
|
152
|
+
with contextlib.suppress(RegistryError):
|
|
153
|
+
# If the plugin uses on_conflict="replace" internally, duplicate
|
|
154
|
+
# registration may succeed; that is acceptable for dialect overrides.
|
|
155
|
+
plugin.register(registry)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def registry_without_builtins() -> TranslatorRegistry:
|
|
159
|
+
"""Empty registry for isolated translator unit tests."""
|
|
160
|
+
return TranslatorRegistry()
|
sqlrules/constraints.py
CHANGED
|
@@ -22,15 +22,18 @@ from annotated_types import (
|
|
|
22
22
|
from pydantic.fields import FieldInfo
|
|
23
23
|
|
|
24
24
|
from sqlrules.errors import UnsupportedConstraintError
|
|
25
|
-
from sqlrules.ir import Constraint, FieldDescriptor
|
|
25
|
+
from sqlrules.ir import Constraint, FieldDescriptor, PatternSpec
|
|
26
|
+
from sqlrules.markers import ConstraintMarker
|
|
26
27
|
|
|
27
28
|
_SUPPORTED_TYPES: frozenset[type[Any]] = frozenset(
|
|
28
29
|
{bool, int, float, Decimal, str, date, datetime, time, UUID}
|
|
29
30
|
)
|
|
31
|
+
_SUPPORTED_CONTAINER_ORIGINS: frozenset[Any] = frozenset({list, dict})
|
|
30
32
|
_UNSUPPORTED_TYPES: frozenset[type[Any]] = frozenset({timedelta})
|
|
31
|
-
_UNSUPPORTED_ORIGINS: frozenset[Any] = frozenset({
|
|
33
|
+
_UNSUPPORTED_ORIGINS: frozenset[Any] = frozenset({tuple, set, frozenset})
|
|
32
34
|
_LENGTH_OPERATORS: frozenset[str] = frozenset({"min_length", "max_length"})
|
|
33
35
|
_NUMERIC_OPERATORS: frozenset[str] = frozenset({"gt", "ge", "lt", "le", "multiple_of"})
|
|
36
|
+
_PORTABLE_OPERATORS: frozenset[str] = _LENGTH_OPERATORS | _NUMERIC_OPERATORS | {"pattern"}
|
|
34
37
|
_TEMPORAL_TYPES: frozenset[type[Any]] = frozenset({date, datetime, time})
|
|
35
38
|
_VALIDATOR_TYPE_NAMES = frozenset(
|
|
36
39
|
{
|
|
@@ -126,6 +129,13 @@ def _concrete_type(field: FieldDescriptor) -> Any:
|
|
|
126
129
|
return annotation
|
|
127
130
|
|
|
128
131
|
|
|
132
|
+
def _is_container_type(annotation: Any) -> bool:
|
|
133
|
+
origin = get_origin(annotation)
|
|
134
|
+
if origin in _SUPPORTED_CONTAINER_ORIGINS:
|
|
135
|
+
return True
|
|
136
|
+
return annotation in _SUPPORTED_CONTAINER_ORIGINS
|
|
137
|
+
|
|
138
|
+
|
|
129
139
|
def ensure_supported_type(field: FieldDescriptor) -> None:
|
|
130
140
|
"""Raise when a field annotation is outside the support matrix."""
|
|
131
141
|
annotation = _concrete_type(field)
|
|
@@ -134,16 +144,20 @@ def ensure_supported_type(field: FieldDescriptor) -> None:
|
|
|
134
144
|
if origin is Literal:
|
|
135
145
|
return
|
|
136
146
|
|
|
137
|
-
if origin in _UNSUPPORTED_ORIGINS:
|
|
147
|
+
if origin in _UNSUPPORTED_ORIGINS or annotation in _UNSUPPORTED_ORIGINS:
|
|
138
148
|
raise UnsupportedConstraintError(
|
|
139
149
|
field=field.name,
|
|
140
|
-
operator=getattr(origin, "__name__", str(origin)),
|
|
150
|
+
operator=getattr(origin or annotation, "__name__", str(origin or annotation)),
|
|
141
151
|
value=annotation,
|
|
142
152
|
suggestion=(
|
|
143
|
-
"Remove the field or wait for a future SQLRules release with
|
|
153
|
+
"Remove the field or wait for a future SQLRules release with "
|
|
154
|
+
f"{getattr(origin or annotation, '__name__', origin or annotation)!r} support."
|
|
144
155
|
),
|
|
145
156
|
)
|
|
146
157
|
|
|
158
|
+
if _is_container_type(annotation):
|
|
159
|
+
return
|
|
160
|
+
|
|
147
161
|
if isinstance(annotation, type) and issubclass(annotation, Enum):
|
|
148
162
|
return
|
|
149
163
|
|
|
@@ -171,11 +185,16 @@ def ensure_supported_type(field: FieldDescriptor) -> None:
|
|
|
171
185
|
)
|
|
172
186
|
|
|
173
187
|
|
|
174
|
-
def _normalize_pattern(field_name: str, pattern: Any) ->
|
|
175
|
-
if isinstance(pattern,
|
|
188
|
+
def _normalize_pattern(field_name: str, pattern: Any) -> PatternSpec:
|
|
189
|
+
if isinstance(pattern, PatternSpec):
|
|
176
190
|
return pattern
|
|
191
|
+
if isinstance(pattern, str):
|
|
192
|
+
return PatternSpec(pattern=pattern, ignore_case=False)
|
|
177
193
|
if isinstance(pattern, re.Pattern):
|
|
178
|
-
return
|
|
194
|
+
return PatternSpec(
|
|
195
|
+
pattern=str(pattern.pattern),
|
|
196
|
+
ignore_case=bool(pattern.flags & re.IGNORECASE),
|
|
197
|
+
)
|
|
179
198
|
raise UnsupportedConstraintError(
|
|
180
199
|
field=field_name,
|
|
181
200
|
operator="pattern",
|
|
@@ -184,6 +203,15 @@ def _normalize_pattern(field_name: str, pattern: Any) -> str:
|
|
|
184
203
|
)
|
|
185
204
|
|
|
186
205
|
|
|
206
|
+
def pattern_text(value: Any) -> tuple[str, bool]:
|
|
207
|
+
"""Return ``(pattern, ignore_case)`` from a ``pattern`` constraint value."""
|
|
208
|
+
if isinstance(value, PatternSpec):
|
|
209
|
+
return value.pattern, value.ignore_case
|
|
210
|
+
if isinstance(value, str):
|
|
211
|
+
return value, False
|
|
212
|
+
raise TypeError(f"pattern value must be str or PatternSpec, got {type(value)!r}")
|
|
213
|
+
|
|
214
|
+
|
|
187
215
|
def _constraints_from_mapping(field_name: str, data: dict[str, Any]) -> list[Constraint]:
|
|
188
216
|
"""Map whitelisted metadata keys into IR; ignore validation-only flags."""
|
|
189
217
|
constraints: list[Constraint] = []
|
|
@@ -202,7 +230,16 @@ def _constraints_from_mapping(field_name: str, data: dict[str, Any]) -> list[Con
|
|
|
202
230
|
return constraints
|
|
203
231
|
|
|
204
232
|
|
|
233
|
+
def _is_constraint_marker(item: Any) -> bool:
|
|
234
|
+
if isinstance(item, type):
|
|
235
|
+
return False
|
|
236
|
+
return isinstance(item, ConstraintMarker)
|
|
237
|
+
|
|
238
|
+
|
|
205
239
|
def _unsupported_constraints(field_name: str, item: Any) -> list[Constraint]:
|
|
240
|
+
if _is_constraint_marker(item):
|
|
241
|
+
return [Constraint(field_name, item.operator, item.value)]
|
|
242
|
+
|
|
206
243
|
if isinstance(item, Predicate):
|
|
207
244
|
return [Constraint(field_name, "predicate", item.func)]
|
|
208
245
|
|
|
@@ -218,7 +255,7 @@ def _unsupported_constraints(field_name: str, item: Any) -> list[Constraint]:
|
|
|
218
255
|
|
|
219
256
|
# First-class pattern attribute (e.g. _PydanticGeneralMetadata(pattern=...)).
|
|
220
257
|
pattern = getattr(item, "pattern", None)
|
|
221
|
-
if pattern is not None and isinstance(pattern, (str, re.Pattern)):
|
|
258
|
+
if pattern is not None and isinstance(pattern, (str, re.Pattern, PatternSpec)):
|
|
222
259
|
constraints = _constraints_from_mapping(
|
|
223
260
|
field_name,
|
|
224
261
|
{
|
|
@@ -243,6 +280,17 @@ def _reject_type_operator_mismatch(field: FieldDescriptor, constraint: Constrain
|
|
|
243
280
|
annotation = _concrete_type(field)
|
|
244
281
|
origin = get_origin(annotation)
|
|
245
282
|
|
|
283
|
+
if _is_container_type(annotation) and constraint.operator in _PORTABLE_OPERATORS:
|
|
284
|
+
raise UnsupportedConstraintError(
|
|
285
|
+
field=field.name,
|
|
286
|
+
operator=constraint.operator,
|
|
287
|
+
value=constraint.value,
|
|
288
|
+
suggestion=(
|
|
289
|
+
"Portable constraints are not valid on list/dict fields; "
|
|
290
|
+
"use sqlrules.markers (e.g. JsonContains, ArrayContains) instead."
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
|
|
246
294
|
if origin is Literal:
|
|
247
295
|
if (
|
|
248
296
|
constraint.operator not in {"literal", "enum"}
|
|
@@ -322,7 +370,9 @@ def extract_constraints(field: FieldDescriptor) -> list[Constraint]:
|
|
|
322
370
|
constraints: list[Constraint] = []
|
|
323
371
|
|
|
324
372
|
for item in _iter_metadata(_field_metadata(field)):
|
|
325
|
-
if
|
|
373
|
+
if _is_constraint_marker(item):
|
|
374
|
+
constraints.append(Constraint(field.name, item.operator, item.value))
|
|
375
|
+
elif isinstance(item, Gt):
|
|
326
376
|
constraints.append(Constraint(field.name, "gt", item.gt))
|
|
327
377
|
elif isinstance(item, Ge):
|
|
328
378
|
constraints.append(Constraint(field.name, "ge", item.ge))
|
|
@@ -351,7 +401,7 @@ def extract_constraints(field: FieldDescriptor) -> list[Constraint]:
|
|
|
351
401
|
constraints.append(Constraint(field.name, "enum", values))
|
|
352
402
|
|
|
353
403
|
for constraint in constraints:
|
|
354
|
-
if constraint.operator in
|
|
404
|
+
if constraint.operator in _PORTABLE_OPERATORS:
|
|
355
405
|
_reject_type_operator_mismatch(field, constraint)
|
|
356
406
|
|
|
357
407
|
return constraints
|
sqlrules/errors.py
CHANGED
|
@@ -85,12 +85,28 @@ class ConfigurationError(SQLRulesError):
|
|
|
85
85
|
value: Any
|
|
86
86
|
|
|
87
87
|
def __str__(self) -> str:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
if self.option == "on_unsupported":
|
|
89
|
+
return (
|
|
90
|
+
f"Invalid configuration value for {self.option!r}: {self.value!r}. "
|
|
91
|
+
"Use one of: 'raise', 'warn', 'ignore'."
|
|
92
|
+
)
|
|
93
|
+
if self.option == "on_conflict":
|
|
94
|
+
return (
|
|
95
|
+
f"Invalid configuration value for {self.option!r}: {self.value!r}. "
|
|
96
|
+
"Use one of: 'raise', 'replace', 'ignore'."
|
|
97
|
+
)
|
|
98
|
+
return f"Invalid configuration value for {self.option!r}: {self.value!r}."
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(slots=True)
|
|
102
|
+
class PluginError(SQLRulesError):
|
|
103
|
+
"""Raised when a plugin fails validation or registration."""
|
|
104
|
+
|
|
105
|
+
message: str
|
|
106
|
+
plugin: Any = None
|
|
107
|
+
|
|
108
|
+
def __str__(self) -> str:
|
|
109
|
+
return self.message
|
|
94
110
|
|
|
95
111
|
|
|
96
112
|
class InternalCompilerError(SQLRulesError):
|
sqlrules/ir.py
CHANGED
|
@@ -4,9 +4,18 @@ from dataclasses import dataclass, field
|
|
|
4
4
|
from typing import Any, Literal
|
|
5
5
|
|
|
6
6
|
OnUnsupported = Literal["raise", "warn", "ignore"]
|
|
7
|
+
OnConflict = Literal["raise", "replace", "ignore"]
|
|
7
8
|
DiagnosticSeverity = Literal["warning", "info"]
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class PatternSpec:
|
|
13
|
+
"""Normalized ``pattern`` constraint value (preserves case-folding intent)."""
|
|
14
|
+
|
|
15
|
+
pattern: str
|
|
16
|
+
ignore_case: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
10
19
|
@dataclass(frozen=True, slots=True)
|
|
11
20
|
class Constraint:
|
|
12
21
|
field: str
|
|
@@ -30,6 +39,7 @@ class Diagnostic:
|
|
|
30
39
|
operator: str
|
|
31
40
|
value: Any = None
|
|
32
41
|
message: str = ""
|
|
42
|
+
code: str = ""
|
|
33
43
|
|
|
34
44
|
|
|
35
45
|
@dataclass(slots=True)
|
|
@@ -52,6 +62,7 @@ class DiagnosticsCollector:
|
|
|
52
62
|
class CompilationContext:
|
|
53
63
|
on_unsupported: OnUnsupported = "raise"
|
|
54
64
|
collector: DiagnosticsCollector | None = None
|
|
65
|
+
dialect: str | None = None
|
|
55
66
|
|
|
56
67
|
def record(
|
|
57
68
|
self,
|
|
@@ -61,6 +72,7 @@ class CompilationContext:
|
|
|
61
72
|
operator: str,
|
|
62
73
|
value: Any = None,
|
|
63
74
|
message: str = "",
|
|
75
|
+
code: str = "",
|
|
64
76
|
) -> None:
|
|
65
77
|
if self.collector is None:
|
|
66
78
|
return
|
|
@@ -71,6 +83,7 @@ class CompilationContext:
|
|
|
71
83
|
operator=operator,
|
|
72
84
|
value=value,
|
|
73
85
|
message=message,
|
|
86
|
+
code=code,
|
|
74
87
|
)
|
|
75
88
|
)
|
|
76
89
|
|
sqlrules/markers.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Dialect-oriented constraint markers for Annotated metadata.
|
|
2
|
+
|
|
3
|
+
Markers are extracted into IR as ``Constraint(field, marker.operator, marker.value)``.
|
|
4
|
+
Core does not translate these operators; dialect plugins register translators.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class ConstraintMarker(Protocol):
|
|
15
|
+
"""Protocol for Annotated metadata that becomes a SQLRules IR constraint."""
|
|
16
|
+
|
|
17
|
+
operator: str
|
|
18
|
+
value: Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class JsonContains:
|
|
23
|
+
"""Require JSON/JSONB containment (``json_contains``)."""
|
|
24
|
+
|
|
25
|
+
value: Any
|
|
26
|
+
operator: str = field(default="json_contains", init=False, repr=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class JsonHasKey:
|
|
31
|
+
"""Require a JSON object key (``json_has_key``)."""
|
|
32
|
+
|
|
33
|
+
value: Any
|
|
34
|
+
operator: str = field(default="json_has_key", init=False, repr=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ArrayContains:
|
|
39
|
+
"""Require array containment (``array_contains``)."""
|
|
40
|
+
|
|
41
|
+
value: Any
|
|
42
|
+
operator: str = field(default="array_contains", init=False, repr=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class ArrayOverlap:
|
|
47
|
+
"""Require array overlap (``array_overlap``)."""
|
|
48
|
+
|
|
49
|
+
value: Any
|
|
50
|
+
operator: str = field(default="array_overlap", init=False, repr=False)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class RangeContains:
|
|
55
|
+
"""Require range containment (``range_contains``)."""
|
|
56
|
+
|
|
57
|
+
value: Any
|
|
58
|
+
operator: str = field(default="range_contains", init=False, repr=False)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class RangeOverlap:
|
|
63
|
+
"""Require range overlap (``range_overlap``)."""
|
|
64
|
+
|
|
65
|
+
value: Any
|
|
66
|
+
operator: str = field(default="range_overlap", init=False, repr=False)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class FullTextMatch:
|
|
71
|
+
"""Require a full-text match (``fulltext_match``)."""
|
|
72
|
+
|
|
73
|
+
value: Any
|
|
74
|
+
operator: str = field(default="fulltext_match", init=False, repr=False)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"ArrayContains",
|
|
79
|
+
"ArrayOverlap",
|
|
80
|
+
"ConstraintMarker",
|
|
81
|
+
"FullTextMatch",
|
|
82
|
+
"JsonContains",
|
|
83
|
+
"JsonHasKey",
|
|
84
|
+
"RangeContains",
|
|
85
|
+
"RangeOverlap",
|
|
86
|
+
]
|
sqlrules/plugins.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from sqlrules.errors import PluginError
|
|
6
|
+
from sqlrules.translators import TranslatorRegistry
|
|
7
|
+
|
|
8
|
+
PLUGIN_API_VERSION = "1"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class SQLRulesPlugin(Protocol):
|
|
13
|
+
"""Explicit extension that registers translators onto a registry."""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
api_version: str
|
|
17
|
+
|
|
18
|
+
def register(self, registry: TranslatorRegistry) -> None:
|
|
19
|
+
"""Register constraint translators on ``registry``."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_plugin(plugin: Any) -> SQLRulesPlugin:
|
|
23
|
+
"""Validate a plugin object against the versioned plugin API."""
|
|
24
|
+
if not isinstance(plugin, SQLRulesPlugin):
|
|
25
|
+
raise PluginError(
|
|
26
|
+
message=(
|
|
27
|
+
f"Plugin {plugin!r} does not implement SQLRulesPlugin "
|
|
28
|
+
"(requires name, api_version, and register(registry))."
|
|
29
|
+
),
|
|
30
|
+
plugin=plugin,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
name = getattr(plugin, "name", None)
|
|
34
|
+
if not isinstance(name, str) or not name:
|
|
35
|
+
raise PluginError(
|
|
36
|
+
message=f"Plugin {plugin!r} must declare a non-empty string name.",
|
|
37
|
+
plugin=plugin,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
api_version = getattr(plugin, "api_version", None)
|
|
41
|
+
if api_version != PLUGIN_API_VERSION:
|
|
42
|
+
raise PluginError(
|
|
43
|
+
message=(
|
|
44
|
+
f"Plugin {name!r} declares api_version={api_version!r}, "
|
|
45
|
+
f"but SQLRules plugin API is {PLUGIN_API_VERSION!r}."
|
|
46
|
+
),
|
|
47
|
+
plugin=plugin,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
register = getattr(plugin, "register", None)
|
|
51
|
+
if not callable(register):
|
|
52
|
+
raise PluginError(
|
|
53
|
+
message=f"Plugin {name!r} must provide a callable register(registry) method.",
|
|
54
|
+
plugin=plugin,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return plugin
|
sqlrules/translators.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import inspect
|
|
3
4
|
import operator
|
|
4
5
|
import sys
|
|
5
6
|
import types
|
|
@@ -10,8 +11,13 @@ from typing import Any, cast
|
|
|
10
11
|
from sqlalchemy import func
|
|
11
12
|
from sqlalchemy.sql.elements import ColumnElement
|
|
12
13
|
|
|
13
|
-
from sqlrules.errors import
|
|
14
|
-
|
|
14
|
+
from sqlrules.errors import (
|
|
15
|
+
InvalidTranslatorError,
|
|
16
|
+
RegistryError,
|
|
17
|
+
TranslatorError,
|
|
18
|
+
UnsupportedConstraintError,
|
|
19
|
+
)
|
|
20
|
+
from sqlrules.ir import CompilationContext, Constraint, OnConflict
|
|
15
21
|
|
|
16
22
|
Translator = Callable[[Constraint, ColumnElement[Any], CompilationContext], ColumnElement[bool]]
|
|
17
23
|
|
|
@@ -93,6 +99,32 @@ def _in_values(
|
|
|
93
99
|
return cast(ColumnElement[bool], column.in_(constraint.value))
|
|
94
100
|
|
|
95
101
|
|
|
102
|
+
def _validate_translator(operator_name: str, translator: Any) -> Translator:
|
|
103
|
+
if not callable(translator):
|
|
104
|
+
raise InvalidTranslatorError(operator=operator_name, translator=translator)
|
|
105
|
+
try:
|
|
106
|
+
signature = inspect.signature(translator)
|
|
107
|
+
except (TypeError, ValueError):
|
|
108
|
+
return cast(Translator, translator)
|
|
109
|
+
|
|
110
|
+
params = list(signature.parameters.values())
|
|
111
|
+
if any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params):
|
|
112
|
+
return cast(Translator, translator)
|
|
113
|
+
|
|
114
|
+
positional = [
|
|
115
|
+
p
|
|
116
|
+
for p in params
|
|
117
|
+
if p.kind
|
|
118
|
+
in (
|
|
119
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
120
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
121
|
+
)
|
|
122
|
+
]
|
|
123
|
+
if len(positional) < 3:
|
|
124
|
+
raise InvalidTranslatorError(operator=operator_name, translator=translator)
|
|
125
|
+
return cast(Translator, translator)
|
|
126
|
+
|
|
127
|
+
|
|
96
128
|
class TranslatorRegistry:
|
|
97
129
|
def __init__(self) -> None:
|
|
98
130
|
self._translators: dict[str, Translator] = {}
|
|
@@ -104,13 +136,46 @@ class TranslatorRegistry:
|
|
|
104
136
|
*,
|
|
105
137
|
replace: bool = False,
|
|
106
138
|
) -> None:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
139
|
+
self.register_constraint(
|
|
140
|
+
operator_name,
|
|
141
|
+
translator,
|
|
142
|
+
on_conflict="replace" if replace else "raise",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def register_constraint(
|
|
146
|
+
self,
|
|
147
|
+
operator_name: str,
|
|
148
|
+
translator: Translator,
|
|
149
|
+
*,
|
|
150
|
+
on_conflict: OnConflict = "raise",
|
|
151
|
+
) -> None:
|
|
152
|
+
if on_conflict not in {"raise", "replace", "ignore"}:
|
|
153
|
+
raise RegistryError(
|
|
154
|
+
f"Invalid on_conflict value {on_conflict!r}. "
|
|
155
|
+
"Use one of: 'raise', 'replace', 'ignore'."
|
|
156
|
+
)
|
|
157
|
+
validated = _validate_translator(operator_name, translator)
|
|
158
|
+
if operator_name in self._translators:
|
|
159
|
+
if on_conflict == "raise":
|
|
160
|
+
raise RegistryError(f"Translator for {operator_name!r} is already registered.")
|
|
161
|
+
if on_conflict == "ignore":
|
|
162
|
+
return
|
|
163
|
+
self._translators[operator_name] = validated
|
|
110
164
|
|
|
111
165
|
def lookup(self, operator_name: str) -> Translator | None:
|
|
112
166
|
return self._translators.get(operator_name)
|
|
113
167
|
|
|
168
|
+
def operators(self) -> frozenset[str]:
|
|
169
|
+
return frozenset(self._translators)
|
|
170
|
+
|
|
171
|
+
def __contains__(self, operator_name: object) -> bool:
|
|
172
|
+
return isinstance(operator_name, str) and operator_name in self._translators
|
|
173
|
+
|
|
174
|
+
def copy(self) -> TranslatorRegistry:
|
|
175
|
+
clone = TranslatorRegistry()
|
|
176
|
+
clone._translators = dict(self._translators)
|
|
177
|
+
return clone
|
|
178
|
+
|
|
114
179
|
def translate(
|
|
115
180
|
self,
|
|
116
181
|
constraint: Constraint,
|
|
@@ -137,6 +202,7 @@ class TranslatorRegistry:
|
|
|
137
202
|
operator=constraint.operator,
|
|
138
203
|
value=constraint.value,
|
|
139
204
|
message=message,
|
|
205
|
+
code="unsupported_constraint",
|
|
140
206
|
)
|
|
141
207
|
warnings.warn(message, SQLRulesWarning, stacklevel=_warning_stacklevel())
|
|
142
208
|
else:
|
|
@@ -146,6 +212,7 @@ class TranslatorRegistry:
|
|
|
146
212
|
operator=constraint.operator,
|
|
147
213
|
value=constraint.value,
|
|
148
214
|
message=message,
|
|
215
|
+
code="unsupported_constraint",
|
|
149
216
|
)
|
|
150
217
|
return None
|
|
151
218
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlrules
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -81,7 +81,30 @@ pip install sqlrules
|
|
|
81
81
|
|
|
82
82
|
Requires Python 3.10+, Pydantic v2, and SQLAlchemy 2.x.
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
Optional dialect plugins:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install sqlrules-postgresql # ~ / ~*, JSONB, ARRAY, range
|
|
88
|
+
pip install sqlrules-sqlite # REGEXP helper + JSON
|
|
89
|
+
pip install sqlrules-mysql # REGEXP, JSON, full-text
|
|
90
|
+
pip install sqlrules-mssql # JSON + LEN string ops
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from typing import Annotated, Any
|
|
95
|
+
from pydantic import BaseModel, Field
|
|
96
|
+
from sqlrules import Compiler, JsonContains
|
|
97
|
+
from sqlrules_postgresql import PostgresPlugin
|
|
98
|
+
|
|
99
|
+
class RowFilter(BaseModel):
|
|
100
|
+
name: Annotated[str, Field(pattern=r"^A")]
|
|
101
|
+
meta: Annotated[dict[str, Any], JsonContains({"active": True})]
|
|
102
|
+
|
|
103
|
+
compiler = Compiler(plugins=[PostgresPlugin()], dialect="postgresql")
|
|
104
|
+
rules = compiler.compile(RowFilter, table)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Supported constraints (0.4)
|
|
85
108
|
|
|
86
109
|
| Constraint | SQLAlchemy expression |
|
|
87
110
|
|---|---|
|
|
@@ -91,7 +114,11 @@ Requires Python 3.10+, Pydantic v2, and SQLAlchemy 2.x.
|
|
|
91
114
|
| `Literal[...]` | `column.in_(...)` |
|
|
92
115
|
| `Enum` | `column.in_(...)` |
|
|
93
116
|
|
|
94
|
-
`pattern` is extracted into IR but has no portable core
|
|
117
|
+
`pattern` is extracted into IR (`PatternSpec`) but has no portable core
|
|
118
|
+
translator. Use a dialect plugin or a custom registry translator.
|
|
119
|
+
|
|
120
|
+
Dialect markers (`JsonContains`, `ArrayContains`, `RangeContains`,
|
|
121
|
+
`FullTextMatch`, …) live in `sqlrules.markers` and require a dialect plugin.
|
|
95
122
|
|
|
96
123
|
Unsupported constraints raise `UnsupportedConstraintError` by default.
|
|
97
124
|
Use `on_unsupported="warn"` or `"ignore"` to change that policy for unknown
|
|
@@ -103,6 +130,14 @@ constraint operators (unsupported types always raise).
|
|
|
103
130
|
sqlrules.compile(model, table, *, column_map=None, on_unsupported="raise", cache=True)
|
|
104
131
|
sqlrules.where(rules) # flatten all expressions
|
|
105
132
|
sqlrules.flatten(rules) # alias of where()
|
|
133
|
+
|
|
134
|
+
compiler = sqlrules.Compiler(
|
|
135
|
+
plugins=[...], # optional SQLRulesPlugin instances
|
|
136
|
+
on_conflict="raise", # raise | replace | ignore
|
|
137
|
+
dialect=None, # optional hint for translators
|
|
138
|
+
on_unsupported="raise",
|
|
139
|
+
cache=True,
|
|
140
|
+
)
|
|
106
141
|
```
|
|
107
142
|
|
|
108
143
|
## Non-goals
|
|
@@ -115,13 +150,17 @@ constraints into SQLAlchemy expressions.
|
|
|
115
150
|
|
|
116
151
|
See [`docs/index.md`](docs/index.md) for the full documentation set,
|
|
117
152
|
including the [spec](docs/SPEC.md), [API](docs/API.md),
|
|
118
|
-
[
|
|
153
|
+
[plugin system](docs/PLUGIN_SYSTEM.md), [architecture](docs/ARCHITECTURE.md),
|
|
154
|
+
and [roadmap](docs/ROADMAP.md).
|
|
119
155
|
|
|
120
156
|
## Development
|
|
121
157
|
|
|
122
158
|
```bash
|
|
123
159
|
pip install -e ".[dev]"
|
|
124
|
-
|
|
160
|
+
pip install -e packages/sqlrules-postgresql -e packages/sqlrules-sqlite \
|
|
161
|
+
-e packages/sqlrules-mysql -e packages/sqlrules-mssql
|
|
162
|
+
pytest tests packages/sqlrules-postgresql/tests packages/sqlrules-sqlite/tests \
|
|
163
|
+
packages/sqlrules-mysql/tests packages/sqlrules-mssql/tests
|
|
125
164
|
ruff check .
|
|
126
165
|
mypy src/sqlrules
|
|
127
166
|
python -m benchmarks.bench_compile
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
sqlrules/__init__.py,sha256=60Wwpv204ZOs36qCHvRWD8r8ZYLvAS3qunLvB3qSWNY,1513
|
|
2
|
+
sqlrules/cache.py,sha256=9NBDunUMvQR3bsx9QxIiO8L8JNbpI1tdLlFNapUZMj8,1051
|
|
3
|
+
sqlrules/columns.py,sha256=TcDhFG8wxgKC3-kwXhOBP27Ekrlfncm-WWrUkMLCplo,2428
|
|
4
|
+
sqlrules/compiler.py,sha256=sVlpcP5qv-BUjKSNwd4Q6I3aQvqVOgDo89EAwxTqo5I,6799
|
|
5
|
+
sqlrules/conformance.py,sha256=WZfjnv3JfQZ2J93VE3kBD8AFlrz0DX6XjuZ7Z_0D0I0,5646
|
|
6
|
+
sqlrules/constraints.py,sha256=nHuX2xuoSnyx-l6D6hk8Ncb946r_IkehtQ2iumbmbzs,14346
|
|
7
|
+
sqlrules/errors.py,sha256=qcSCcnL19kvfzAd1eMN1pVVNbV3cg1WLb94uGna8w-M,2989
|
|
8
|
+
sqlrules/inspectors.py,sha256=mEi7ixA1aQe7lyZtcFvLHVqVsAXt_tm60YNgJrYbrp8,1700
|
|
9
|
+
sqlrules/ir.py,sha256=jUw8BjZeTBHt5o0KcwBfbUfC7VlcEl4McQahTfZ0DAA,2285
|
|
10
|
+
sqlrules/markers.py,sha256=mAFlZYn050wCKCungONWawIvnYlXTF7aG_VzZ7Hz5GQ,2159
|
|
11
|
+
sqlrules/plugins.py,sha256=ClXVdISpgNzVQkQCJk9ptgnXxqWLwi99EBcNP9wYXyI,1740
|
|
12
|
+
sqlrules/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
sqlrules/translators.py,sha256=aw8Q8hgfNUqdUEosUXgMW4IqTTBNUtSA-Yc2aCiPKNs,7949
|
|
14
|
+
sqlrules-0.4.0.dist-info/METADATA,sha256=icGrZdcSygSpxjeX-rYsDJvRYy6xGA0owVvXQf0yy2Y,5485
|
|
15
|
+
sqlrules-0.4.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
16
|
+
sqlrules-0.4.0.dist-info/licenses/LICENSE,sha256=cLMlkCH6RhjWAkXBWR7LazV98TOG0WkPtCKB5zqqkAs,1078
|
|
17
|
+
sqlrules-0.4.0.dist-info/RECORD,,
|
sqlrules-0.2.0.dist-info/RECORD
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
|
File without changes
|