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 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.2.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
- self.registry = registry or default_registry()
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,
@@ -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({list, dict, tuple, set, 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 container support."
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) -> str:
175
- if isinstance(pattern, str):
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 str(pattern.pattern)
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 isinstance(item, Gt):
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 _LENGTH_OPERATORS | _NUMERIC_OPERATORS | {"pattern"}:
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
- return (
89
- f"Invalid configuration value for {self.option!r}: {self.value!r}. "
90
- "Use one of: 'raise', 'warn', 'ignore'."
91
- if self.option == "on_unsupported"
92
- else f"Invalid configuration value for {self.option!r}: {self.value!r}."
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 RegistryError, TranslatorError, UnsupportedConstraintError
14
- from sqlrules.ir import CompilationContext, Constraint
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
- if not replace and operator_name in self._translators:
108
- raise RegistryError(f"Translator for {operator_name!r} is already registered.")
109
- self._translators[operator_name] = translator
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.2.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
- ## Supported constraints (0.2)
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 translator.
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
- [architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
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
- pytest
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,,
@@ -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,,