sqlrules 0.3.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
@@ -18,27 +18,47 @@ from sqlrules.ir import (
18
18
  FieldDescriptor,
19
19
  FieldIR,
20
20
  ModelIR,
21
+ PatternSpec,
22
+ )
23
+ from sqlrules.markers import (
24
+ ArrayContains,
25
+ ArrayOverlap,
26
+ ConstraintMarker,
27
+ FullTextMatch,
28
+ JsonContains,
29
+ JsonHasKey,
30
+ RangeContains,
31
+ RangeOverlap,
21
32
  )
22
33
  from sqlrules.plugins import PLUGIN_API_VERSION, SQLRulesPlugin
23
34
  from sqlrules.translators import SQLRulesWarning
24
35
 
25
- __version__ = "0.3.0"
36
+ __version__ = "0.4.0"
26
37
 
27
38
  __all__ = [
28
39
  "PLUGIN_API_VERSION",
40
+ "ArrayContains",
41
+ "ArrayOverlap",
29
42
  "CompilationContext",
30
43
  "Compiler",
31
44
  "ConfigurationError",
32
45
  "Constraint",
46
+ "ConstraintMarker",
33
47
  "Diagnostic",
34
48
  "FieldDescriptor",
35
49
  "FieldIR",
50
+ "FullTextMatch",
36
51
  "InternalCompilerError",
37
52
  "InvalidModelError",
38
53
  "InvalidTranslatorError",
54
+ "JsonContains",
55
+ "JsonHasKey",
39
56
  "MissingColumnError",
40
57
  "ModelIR",
58
+ "PatternSpec",
41
59
  "PluginError",
60
+ "RangeContains",
61
+ "RangeOverlap",
42
62
  "RegistryError",
43
63
  "SQLRulesError",
44
64
  "SQLRulesPlugin",
sqlrules/conformance.py CHANGED
@@ -108,15 +108,23 @@ def assert_translates_operator(
108
108
  f"returned {type(expression)!r}, expected ColumnElement"
109
109
  )
110
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}))
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())
114
115
  if compiled_a != compiled_b:
115
116
  raise AssertionError(f"Plugin {plugin.name!r}: non-deterministic compile for {operator!r}")
116
117
  return expression
117
118
 
118
119
 
119
- def run_basic_conformance(plugin: SQLRulesPlugin, *, operator: str = "pattern") -> None:
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:
120
128
  """Run the standard conformance checks for a dialect/translator plugin."""
121
129
  if getattr(plugin, "api_version", None) != PLUGIN_API_VERSION:
122
130
  raise PluginError(
@@ -129,7 +137,13 @@ def run_basic_conformance(plugin: SQLRulesPlugin, *, operator: str = "pattern")
129
137
  assert_plugin_api_compatible(plugin)
130
138
  assert_builtins_preserved(plugin, on_conflict="replace")
131
139
  assert_register_idempotent_ignore(plugin)
132
- assert_translates_operator(plugin, operator=operator)
140
+ assert_translates_operator(
141
+ plugin,
142
+ operator=operator,
143
+ model=model,
144
+ table=table,
145
+ field=field,
146
+ )
133
147
 
134
148
  # Conflict under raise: only meaningful if the plugin re-registers an op
135
149
  # already present after the first registration.
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/ir.py CHANGED
@@ -8,6 +8,14 @@ OnConflict = Literal["raise", "replace", "ignore"]
8
8
  DiagnosticSeverity = Literal["warning", "info"]
9
9
 
10
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
+
11
19
  @dataclass(frozen=True, slots=True)
12
20
  class Constraint:
13
21
  field: str
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
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlrules
3
- Version: 0.3.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
@@ -84,19 +84,27 @@ Requires Python 3.10+, Pydantic v2, and SQLAlchemy 2.x.
84
84
  Optional dialect plugins:
85
85
 
86
86
  ```bash
87
- pip install sqlrules-postgresql # pattern PostgreSQL ~
88
- pip install sqlrules-sqlite # pattern SQLite REGEXP
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
89
91
  ```
90
92
 
91
93
  ```python
92
- from sqlrules import Compiler
94
+ from typing import Annotated, Any
95
+ from pydantic import BaseModel, Field
96
+ from sqlrules import Compiler, JsonContains
93
97
  from sqlrules_postgresql import PostgresPlugin
94
98
 
99
+ class RowFilter(BaseModel):
100
+ name: Annotated[str, Field(pattern=r"^A")]
101
+ meta: Annotated[dict[str, Any], JsonContains({"active": True})]
102
+
95
103
  compiler = Compiler(plugins=[PostgresPlugin()], dialect="postgresql")
96
- rules = compiler.compile(UserFilter, users)
104
+ rules = compiler.compile(RowFilter, table)
97
105
  ```
98
106
 
99
- ## Supported constraints (0.3)
107
+ ## Supported constraints (0.4)
100
108
 
101
109
  | Constraint | SQLAlchemy expression |
102
110
  |---|---|
@@ -106,9 +114,11 @@ rules = compiler.compile(UserFilter, users)
106
114
  | `Literal[...]` | `column.in_(...)` |
107
115
  | `Enum` | `column.in_(...)` |
108
116
 
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`).
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.
112
122
 
113
123
  Unsupported constraints raise `UnsupportedConstraintError` by default.
114
124
  Use `on_unsupported="warn"` or `"ignore"` to change that policy for unknown
@@ -147,8 +157,10 @@ and [roadmap](docs/ROADMAP.md).
147
157
 
148
158
  ```bash
149
159
  pip install -e ".[dev]"
150
- pip install -e packages/sqlrules-postgresql -e packages/sqlrules-sqlite
151
- pytest tests packages/sqlrules-postgresql/tests packages/sqlrules-sqlite/tests
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
152
164
  ruff check .
153
165
  mypy src/sqlrules
154
166
  python -m benchmarks.bench_compile
@@ -1,16 +1,17 @@
1
- sqlrules/__init__.py,sha256=IEIU4ESvzydICwS2CnqXzIz9KA5PiQvHw-FlMybfkvM,1130
1
+ sqlrules/__init__.py,sha256=60Wwpv204ZOs36qCHvRWD8r8ZYLvAS3qunLvB3qSWNY,1513
2
2
  sqlrules/cache.py,sha256=9NBDunUMvQR3bsx9QxIiO8L8JNbpI1tdLlFNapUZMj8,1051
3
3
  sqlrules/columns.py,sha256=TcDhFG8wxgKC3-kwXhOBP27Ekrlfncm-WWrUkMLCplo,2428
4
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
5
+ sqlrules/conformance.py,sha256=WZfjnv3JfQZ2J93VE3kBD8AFlrz0DX6XjuZ7Z_0D0I0,5646
6
+ sqlrules/constraints.py,sha256=nHuX2xuoSnyx-l6D6hk8Ncb946r_IkehtQ2iumbmbzs,14346
7
7
  sqlrules/errors.py,sha256=qcSCcnL19kvfzAd1eMN1pVVNbV3cg1WLb94uGna8w-M,2989
8
8
  sqlrules/inspectors.py,sha256=mEi7ixA1aQe7lyZtcFvLHVqVsAXt_tm60YNgJrYbrp8,1700
9
- sqlrules/ir.py,sha256=ekZ6HLxrJofGvlxZwh2BZty5oV1uzJQjSfZf0GXb6z8,2097
9
+ sqlrules/ir.py,sha256=jUw8BjZeTBHt5o0KcwBfbUfC7VlcEl4McQahTfZ0DAA,2285
10
+ sqlrules/markers.py,sha256=mAFlZYn050wCKCungONWawIvnYlXTF7aG_VzZ7Hz5GQ,2159
10
11
  sqlrules/plugins.py,sha256=ClXVdISpgNzVQkQCJk9ptgnXxqWLwi99EBcNP9wYXyI,1740
11
12
  sqlrules/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
13
  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,,
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,,