sqlrules 0.2.0__py3-none-any.whl → 0.3.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 +6 -1
- sqlrules/compiler.py +67 -2
- sqlrules/conformance.py +146 -0
- sqlrules/errors.py +22 -6
- sqlrules/ir.py +5 -0
- sqlrules/plugins.py +57 -0
- sqlrules/translators.py +72 -5
- {sqlrules-0.2.0.dist-info → sqlrules-0.3.0.dist-info}/METADATA +31 -4
- sqlrules-0.3.0.dist-info/RECORD +16 -0
- sqlrules-0.2.0.dist-info/RECORD +0 -14
- {sqlrules-0.2.0.dist-info → sqlrules-0.3.0.dist-info}/WHEEL +0 -0
- {sqlrules-0.2.0.dist-info → sqlrules-0.3.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,
|
|
@@ -18,11 +19,13 @@ from sqlrules.ir import (
|
|
|
18
19
|
FieldIR,
|
|
19
20
|
ModelIR,
|
|
20
21
|
)
|
|
22
|
+
from sqlrules.plugins import PLUGIN_API_VERSION, SQLRulesPlugin
|
|
21
23
|
from sqlrules.translators import SQLRulesWarning
|
|
22
24
|
|
|
23
|
-
__version__ = "0.
|
|
25
|
+
__version__ = "0.3.0"
|
|
24
26
|
|
|
25
27
|
__all__ = [
|
|
28
|
+
"PLUGIN_API_VERSION",
|
|
26
29
|
"CompilationContext",
|
|
27
30
|
"Compiler",
|
|
28
31
|
"ConfigurationError",
|
|
@@ -35,8 +38,10 @@ __all__ = [
|
|
|
35
38
|
"InvalidTranslatorError",
|
|
36
39
|
"MissingColumnError",
|
|
37
40
|
"ModelIR",
|
|
41
|
+
"PluginError",
|
|
38
42
|
"RegistryError",
|
|
39
43
|
"SQLRulesError",
|
|
44
|
+
"SQLRulesPlugin",
|
|
40
45
|
"SQLRulesWarning",
|
|
41
46
|
"TranslatorError",
|
|
42
47
|
"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,146 @@
|
|
|
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 (dialect-neutral string form).
|
|
112
|
+
compiled_a = str(expression.compile(compile_kwargs={"literal_binds": True}))
|
|
113
|
+
compiled_b = str(expression.compile(compile_kwargs={"literal_binds": True}))
|
|
114
|
+
if compiled_a != compiled_b:
|
|
115
|
+
raise AssertionError(f"Plugin {plugin.name!r}: non-deterministic compile for {operator!r}")
|
|
116
|
+
return expression
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def run_basic_conformance(plugin: SQLRulesPlugin, *, operator: str = "pattern") -> None:
|
|
120
|
+
"""Run the standard conformance checks for a dialect/translator plugin."""
|
|
121
|
+
if getattr(plugin, "api_version", None) != PLUGIN_API_VERSION:
|
|
122
|
+
raise PluginError(
|
|
123
|
+
message=(
|
|
124
|
+
f"Plugin {getattr(plugin, 'name', plugin)!r} api_version mismatch "
|
|
125
|
+
f"(expected {PLUGIN_API_VERSION!r})."
|
|
126
|
+
),
|
|
127
|
+
plugin=plugin,
|
|
128
|
+
)
|
|
129
|
+
assert_plugin_api_compatible(plugin)
|
|
130
|
+
assert_builtins_preserved(plugin, on_conflict="replace")
|
|
131
|
+
assert_register_idempotent_ignore(plugin)
|
|
132
|
+
assert_translates_operator(plugin, operator=operator)
|
|
133
|
+
|
|
134
|
+
# Conflict under raise: only meaningful if the plugin re-registers an op
|
|
135
|
+
# already present after the first registration.
|
|
136
|
+
registry = default_registry()
|
|
137
|
+
plugin.register(registry)
|
|
138
|
+
with contextlib.suppress(RegistryError):
|
|
139
|
+
# If the plugin uses on_conflict="replace" internally, duplicate
|
|
140
|
+
# registration may succeed; that is acceptable for dialect overrides.
|
|
141
|
+
plugin.register(registry)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def registry_without_builtins() -> TranslatorRegistry:
|
|
145
|
+
"""Empty registry for isolated translator unit tests."""
|
|
146
|
+
return TranslatorRegistry()
|
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,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
|
+
OnConflict = Literal["raise", "replace", "ignore"]
|
|
7
8
|
DiagnosticSeverity = Literal["warning", "info"]
|
|
8
9
|
|
|
9
10
|
|
|
@@ -30,6 +31,7 @@ class Diagnostic:
|
|
|
30
31
|
operator: str
|
|
31
32
|
value: Any = None
|
|
32
33
|
message: str = ""
|
|
34
|
+
code: str = ""
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
@dataclass(slots=True)
|
|
@@ -52,6 +54,7 @@ class DiagnosticsCollector:
|
|
|
52
54
|
class CompilationContext:
|
|
53
55
|
on_unsupported: OnUnsupported = "raise"
|
|
54
56
|
collector: DiagnosticsCollector | None = None
|
|
57
|
+
dialect: str | None = None
|
|
55
58
|
|
|
56
59
|
def record(
|
|
57
60
|
self,
|
|
@@ -61,6 +64,7 @@ class CompilationContext:
|
|
|
61
64
|
operator: str,
|
|
62
65
|
value: Any = None,
|
|
63
66
|
message: str = "",
|
|
67
|
+
code: str = "",
|
|
64
68
|
) -> None:
|
|
65
69
|
if self.collector is None:
|
|
66
70
|
return
|
|
@@ -71,6 +75,7 @@ class CompilationContext:
|
|
|
71
75
|
operator=operator,
|
|
72
76
|
value=value,
|
|
73
77
|
message=message,
|
|
78
|
+
code=code,
|
|
74
79
|
)
|
|
75
80
|
)
|
|
76
81
|
|
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.3.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,22 @@ 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 # pattern → PostgreSQL ~
|
|
88
|
+
pip install sqlrules-sqlite # pattern → SQLite REGEXP
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from sqlrules import Compiler
|
|
93
|
+
from sqlrules_postgresql import PostgresPlugin
|
|
94
|
+
|
|
95
|
+
compiler = Compiler(plugins=[PostgresPlugin()], dialect="postgresql")
|
|
96
|
+
rules = compiler.compile(UserFilter, users)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Supported constraints (0.3)
|
|
85
100
|
|
|
86
101
|
| Constraint | SQLAlchemy expression |
|
|
87
102
|
|---|---|
|
|
@@ -92,6 +107,8 @@ Requires Python 3.10+, Pydantic v2, and SQLAlchemy 2.x.
|
|
|
92
107
|
| `Enum` | `column.in_(...)` |
|
|
93
108
|
|
|
94
109
|
`pattern` is extracted into IR but has no portable core translator.
|
|
110
|
+
Use a custom registry translator or a dialect plugin
|
|
111
|
+
(`sqlrules-postgresql`, `sqlrules-sqlite`).
|
|
95
112
|
|
|
96
113
|
Unsupported constraints raise `UnsupportedConstraintError` by default.
|
|
97
114
|
Use `on_unsupported="warn"` or `"ignore"` to change that policy for unknown
|
|
@@ -103,6 +120,14 @@ constraint operators (unsupported types always raise).
|
|
|
103
120
|
sqlrules.compile(model, table, *, column_map=None, on_unsupported="raise", cache=True)
|
|
104
121
|
sqlrules.where(rules) # flatten all expressions
|
|
105
122
|
sqlrules.flatten(rules) # alias of where()
|
|
123
|
+
|
|
124
|
+
compiler = sqlrules.Compiler(
|
|
125
|
+
plugins=[...], # optional SQLRulesPlugin instances
|
|
126
|
+
on_conflict="raise", # raise | replace | ignore
|
|
127
|
+
dialect=None, # optional hint for translators
|
|
128
|
+
on_unsupported="raise",
|
|
129
|
+
cache=True,
|
|
130
|
+
)
|
|
106
131
|
```
|
|
107
132
|
|
|
108
133
|
## Non-goals
|
|
@@ -115,13 +140,15 @@ constraints into SQLAlchemy expressions.
|
|
|
115
140
|
|
|
116
141
|
See [`docs/index.md`](docs/index.md) for the full documentation set,
|
|
117
142
|
including the [spec](docs/SPEC.md), [API](docs/API.md),
|
|
118
|
-
[
|
|
143
|
+
[plugin system](docs/PLUGIN_SYSTEM.md), [architecture](docs/ARCHITECTURE.md),
|
|
144
|
+
and [roadmap](docs/ROADMAP.md).
|
|
119
145
|
|
|
120
146
|
## Development
|
|
121
147
|
|
|
122
148
|
```bash
|
|
123
149
|
pip install -e ".[dev]"
|
|
124
|
-
|
|
150
|
+
pip install -e packages/sqlrules-postgresql -e packages/sqlrules-sqlite
|
|
151
|
+
pytest tests packages/sqlrules-postgresql/tests packages/sqlrules-sqlite/tests
|
|
125
152
|
ruff check .
|
|
126
153
|
mypy src/sqlrules
|
|
127
154
|
python -m benchmarks.bench_compile
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
sqlrules/__init__.py,sha256=IEIU4ESvzydICwS2CnqXzIz9KA5PiQvHw-FlMybfkvM,1130
|
|
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=QV3lTfVtj0vcWUfP8NO_RD7HBon-sANSGdIpVttn_xM,5469
|
|
6
|
+
sqlrules/constraints.py,sha256=YcI2TstNoW9GGR4TfoWBzMlmKKNPsqNjTdUr3Cd3rk8,12349
|
|
7
|
+
sqlrules/errors.py,sha256=qcSCcnL19kvfzAd1eMN1pVVNbV3cg1WLb94uGna8w-M,2989
|
|
8
|
+
sqlrules/inspectors.py,sha256=mEi7ixA1aQe7lyZtcFvLHVqVsAXt_tm60YNgJrYbrp8,1700
|
|
9
|
+
sqlrules/ir.py,sha256=ekZ6HLxrJofGvlxZwh2BZty5oV1uzJQjSfZf0GXb6z8,2097
|
|
10
|
+
sqlrules/plugins.py,sha256=ClXVdISpgNzVQkQCJk9ptgnXxqWLwi99EBcNP9wYXyI,1740
|
|
11
|
+
sqlrules/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
sqlrules/translators.py,sha256=aw8Q8hgfNUqdUEosUXgMW4IqTTBNUtSA-Yc2aCiPKNs,7949
|
|
13
|
+
sqlrules-0.3.0.dist-info/METADATA,sha256=DcOg6uAGJlk2brXQloOv09vEWJbBvOfu1EfMsP9mlAY,4898
|
|
14
|
+
sqlrules-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
15
|
+
sqlrules-0.3.0.dist-info/licenses/LICENSE,sha256=cLMlkCH6RhjWAkXBWR7LazV98TOG0WkPtCKB5zqqkAs,1078
|
|
16
|
+
sqlrules-0.3.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
|