sqlrules 0.1.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 ADDED
@@ -0,0 +1,37 @@
1
+ from sqlrules.compiler import Compiler, compile, flatten, where
2
+ from sqlrules.errors import (
3
+ ConfigurationError,
4
+ InternalCompilerError,
5
+ InvalidModelError,
6
+ InvalidTranslatorError,
7
+ MissingColumnError,
8
+ RegistryError,
9
+ SQLRulesError,
10
+ TranslatorError,
11
+ UnsupportedConstraintError,
12
+ )
13
+ from sqlrules.ir import CompilationContext, Constraint, FieldDescriptor
14
+ from sqlrules.translators import SQLRulesWarning
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = [
19
+ "CompilationContext",
20
+ "Compiler",
21
+ "ConfigurationError",
22
+ "Constraint",
23
+ "FieldDescriptor",
24
+ "InternalCompilerError",
25
+ "InvalidModelError",
26
+ "InvalidTranslatorError",
27
+ "MissingColumnError",
28
+ "RegistryError",
29
+ "SQLRulesError",
30
+ "SQLRulesWarning",
31
+ "TranslatorError",
32
+ "UnsupportedConstraintError",
33
+ "__version__",
34
+ "compile",
35
+ "flatten",
36
+ "where",
37
+ ]
sqlrules/columns.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any
5
+
6
+ from sqlalchemy.sql.elements import ColumnElement
7
+
8
+ from sqlrules.errors import MissingColumnError
9
+
10
+
11
+ def _as_column_element(value: Any) -> ColumnElement[Any] | None:
12
+ """Return a ColumnElement if *value* is (or unwraps to) one."""
13
+ if isinstance(value, ColumnElement):
14
+ return value
15
+ clause_element = getattr(value, "__clause_element__", None)
16
+ if callable(clause_element):
17
+ try:
18
+ unwrapped = clause_element()
19
+ except Exception: # pragma: no cover - defensive
20
+ return None
21
+ if isinstance(unwrapped, ColumnElement):
22
+ return unwrapped
23
+ return None
24
+
25
+
26
+ def _candidates(field_name: str, aliases: Sequence[str] | None) -> list[str]:
27
+ """Build lookup names: aliases first (when set), then the field name."""
28
+ ordered: list[str] = []
29
+ if aliases:
30
+ for alias in aliases:
31
+ if alias and alias not in ordered:
32
+ ordered.append(alias)
33
+ if field_name not in ordered:
34
+ ordered.append(field_name)
35
+ return ordered
36
+
37
+
38
+ def resolve_column(
39
+ field_name: str,
40
+ table: Any,
41
+ column_map: Mapping[str, ColumnElement[Any]] | None = None,
42
+ *,
43
+ alias: str | None = None,
44
+ aliases: Sequence[str] | None = None,
45
+ ) -> ColumnElement[Any]:
46
+ alias_list: list[str] = []
47
+ if aliases:
48
+ alias_list.extend(aliases)
49
+ elif alias:
50
+ alias_list.append(alias)
51
+
52
+ candidates = _candidates(field_name, alias_list)
53
+
54
+ if column_map:
55
+ for candidate in candidates:
56
+ if candidate in column_map:
57
+ column = _as_column_element(column_map[candidate])
58
+ if column is not None:
59
+ return column
60
+
61
+ columns = getattr(table, "c", None)
62
+ if columns is not None:
63
+ for candidate in candidates:
64
+ if candidate in columns:
65
+ column = _as_column_element(columns[candidate])
66
+ if column is not None:
67
+ return column
68
+
69
+ # ORM mapped classes expose columns as attributes; only accept clause elements.
70
+ for candidate in candidates:
71
+ attr = getattr(table, candidate, None)
72
+ column = _as_column_element(attr)
73
+ if column is not None:
74
+ return column
75
+
76
+ raise MissingColumnError(field=field_name)
sqlrules/compiler.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from itertools import chain
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+ from sqlalchemy.sql.elements import ColumnElement
9
+
10
+ from sqlrules.columns import resolve_column
11
+ from sqlrules.constraints import ensure_supported_type, extract_constraints
12
+ from sqlrules.errors import ConfigurationError
13
+ from sqlrules.inspectors import inspect_model
14
+ from sqlrules.ir import CompilationContext, OnUnsupported
15
+ from sqlrules.translators import TranslatorRegistry, default_registry
16
+
17
+ RulesDict = dict[str, list[ColumnElement[bool]]]
18
+
19
+
20
+ class Compiler:
21
+ def __init__(
22
+ self,
23
+ *,
24
+ registry: TranslatorRegistry | None = None,
25
+ on_unsupported: OnUnsupported = "raise",
26
+ ) -> None:
27
+ if on_unsupported not in {"raise", "warn", "ignore"}:
28
+ raise ConfigurationError(option="on_unsupported", value=on_unsupported)
29
+ self.registry = registry or default_registry()
30
+ self.context = CompilationContext(on_unsupported=on_unsupported)
31
+
32
+ def compile(
33
+ self,
34
+ model: type[BaseModel],
35
+ table: Any,
36
+ *,
37
+ column_map: Mapping[str, ColumnElement[Any]] | None = None,
38
+ ) -> RulesDict:
39
+ rules: RulesDict = {}
40
+
41
+ for field in inspect_model(model):
42
+ ensure_supported_type(field)
43
+ constraints = extract_constraints(field)
44
+ if not constraints:
45
+ continue
46
+
47
+ column = resolve_column(
48
+ field.name,
49
+ table,
50
+ column_map,
51
+ alias=field.alias,
52
+ aliases=field.aliases,
53
+ )
54
+
55
+ expressions: list[ColumnElement[bool]] = []
56
+ for constraint in constraints:
57
+ expression = self.registry.translate(constraint, column, self.context)
58
+ if expression is not None:
59
+ expressions.append(expression)
60
+
61
+ if expressions:
62
+ rules[field.name] = expressions
63
+
64
+ return rules
65
+
66
+
67
+ def compile(
68
+ model: type[BaseModel],
69
+ table: Any,
70
+ *,
71
+ column_map: Mapping[str, ColumnElement[Any]] | None = None,
72
+ on_unsupported: OnUnsupported = "raise",
73
+ ) -> RulesDict:
74
+ return Compiler(on_unsupported=on_unsupported).compile(model, table, column_map=column_map)
75
+
76
+
77
+ def flatten(rules: RulesDict) -> list[ColumnElement[bool]]:
78
+ return list(chain.from_iterable(rules.values()))
79
+
80
+
81
+ def where(rules: RulesDict) -> list[ColumnElement[bool]]:
82
+ return flatten(rules)
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime, time, timedelta
4
+ from decimal import Decimal
5
+ from enum import Enum
6
+ from types import UnionType
7
+ from typing import Annotated, Any, Literal, Union, get_args, get_origin
8
+ from uuid import UUID
9
+
10
+ from annotated_types import (
11
+ Ge,
12
+ GroupedMetadata,
13
+ Gt,
14
+ Le,
15
+ Lt,
16
+ MaxLen,
17
+ MinLen,
18
+ MultipleOf,
19
+ Predicate,
20
+ )
21
+ from pydantic.fields import FieldInfo
22
+
23
+ from sqlrules.errors import UnsupportedConstraintError
24
+ from sqlrules.ir import Constraint, FieldDescriptor
25
+
26
+ _SUPPORTED_TYPES: frozenset[type[Any]] = frozenset({bool, int, float, Decimal, str, date, datetime})
27
+ _UNSUPPORTED_TYPES: frozenset[type[Any]] = frozenset({UUID, time, timedelta})
28
+ _UNSUPPORTED_ORIGINS: frozenset[Any] = frozenset({list, dict, tuple, set, frozenset})
29
+ _LENGTH_OPERATORS: frozenset[str] = frozenset({"min_length", "max_length"})
30
+ _NUMERIC_OPERATORS: frozenset[str] = frozenset({"gt", "ge", "lt", "le", "multiple_of"})
31
+ _VALIDATOR_TYPE_NAMES = frozenset(
32
+ {
33
+ "AfterValidator",
34
+ "BeforeValidator",
35
+ "PlainValidator",
36
+ "WrapValidator",
37
+ "FieldValidator",
38
+ "Strict",
39
+ }
40
+ )
41
+
42
+
43
+ def _unwrap_annotation(annotation: Any) -> tuple[Any, tuple[Any, ...]]:
44
+ """Unwrap Optional/Union[..., None] and Annotated layers.
45
+
46
+ Returns the concrete annotation and any metadata collected from Annotated
47
+ wrappers (used when Pydantic leaves field.metadata empty for
48
+ ``Annotated[T, ...] | None``).
49
+ """
50
+ collected: list[Any] = []
51
+ current = annotation
52
+
53
+ while True:
54
+ origin = get_origin(current)
55
+ if origin is Annotated:
56
+ args = get_args(current)
57
+ if not args:
58
+ break
59
+ current = args[0]
60
+ collected.extend(args[1:])
61
+ continue
62
+ if origin is Union or origin is UnionType:
63
+ non_none = [arg for arg in get_args(current) if arg is not type(None)]
64
+ if len(non_none) == 1:
65
+ current = non_none[0]
66
+ continue
67
+ break
68
+
69
+ return current, tuple(collected)
70
+
71
+
72
+ def _iter_metadata(metadata: Any) -> Any:
73
+ for item in metadata:
74
+ if item is None:
75
+ continue
76
+ if isinstance(item, GroupedMetadata):
77
+ yield from _iter_metadata(item)
78
+ elif isinstance(item, FieldInfo):
79
+ # Annotated[..., Field(...)] | None leaves FieldInfo in the annotation
80
+ # metadata when field.metadata is empty.
81
+ yield from _iter_metadata(item.metadata)
82
+ else:
83
+ yield item
84
+
85
+
86
+ def _field_metadata(field: FieldDescriptor) -> tuple[Any, ...]:
87
+ if field.metadata:
88
+ return field.metadata
89
+ _, annotated_metadata = _unwrap_annotation(field.annotation)
90
+ return annotated_metadata
91
+
92
+
93
+ def _concrete_type(field: FieldDescriptor) -> Any:
94
+ annotation, _ = _unwrap_annotation(field.annotation)
95
+ return annotation
96
+
97
+
98
+ def ensure_supported_type(field: FieldDescriptor) -> None:
99
+ """Raise when a field annotation is outside the v0.1 support matrix."""
100
+ annotation = _concrete_type(field)
101
+ origin = get_origin(annotation)
102
+
103
+ if origin is Literal:
104
+ return
105
+
106
+ if origin in _UNSUPPORTED_ORIGINS:
107
+ raise UnsupportedConstraintError(
108
+ field=field.name,
109
+ operator=getattr(origin, "__name__", str(origin)),
110
+ value=annotation,
111
+ suggestion=(
112
+ "Remove the field or wait for a future SQLRules release with container support."
113
+ ),
114
+ )
115
+
116
+ if isinstance(annotation, type) and issubclass(annotation, Enum):
117
+ return
118
+
119
+ if annotation in _SUPPORTED_TYPES:
120
+ return
121
+
122
+ if annotation in _UNSUPPORTED_TYPES or (
123
+ isinstance(annotation, type) and annotation in _UNSUPPORTED_TYPES
124
+ ):
125
+ type_name = getattr(annotation, "__name__", str(annotation))
126
+ raise UnsupportedConstraintError(
127
+ field=field.name,
128
+ operator=type_name,
129
+ value=annotation,
130
+ suggestion=f"Type {type_name!r} is not supported in SQLRules 0.1.",
131
+ )
132
+
133
+ # Unions with multiple non-None members and other unknown annotations.
134
+ type_name = getattr(annotation, "__name__", repr(annotation))
135
+ raise UnsupportedConstraintError(
136
+ field=field.name,
137
+ operator="type",
138
+ value=annotation,
139
+ suggestion=f"Annotation {type_name} is outside the SQLRules 0.1 type support matrix.",
140
+ )
141
+
142
+
143
+ def _unsupported_constraints(field_name: str, item: Any) -> list[Constraint]:
144
+ if isinstance(item, Predicate):
145
+ return [Constraint(field_name, "predicate", item.func)]
146
+
147
+ type_name = type(item).__name__
148
+ if type_name in _VALIDATOR_TYPE_NAMES:
149
+ return [
150
+ Constraint(
151
+ field_name,
152
+ type_name,
153
+ getattr(item, "func", getattr(item, "strict", item)),
154
+ )
155
+ ]
156
+
157
+ data = getattr(item, "__dict__", None)
158
+ if isinstance(data, dict) and data:
159
+ return [
160
+ Constraint(field_name, key, value) for key, value in data.items() if value is not None
161
+ ]
162
+
163
+ return [Constraint(field_name, type_name, item)]
164
+
165
+
166
+ def _reject_type_operator_mismatch(field: FieldDescriptor, constraint: Constraint) -> None:
167
+ annotation = _concrete_type(field)
168
+ origin = get_origin(annotation)
169
+
170
+ if origin is Literal:
171
+ if (
172
+ constraint.operator not in {"literal", "enum"}
173
+ and constraint.operator in _LENGTH_OPERATORS | _NUMERIC_OPERATORS
174
+ ):
175
+ raise UnsupportedConstraintError(
176
+ field=field.name,
177
+ operator=constraint.operator,
178
+ value=constraint.value,
179
+ suggestion=(f"Constraint {constraint.operator!r} is not valid for Literal fields."),
180
+ )
181
+ return
182
+
183
+ if isinstance(annotation, type) and issubclass(annotation, Enum):
184
+ if constraint.operator in _LENGTH_OPERATORS | _NUMERIC_OPERATORS:
185
+ raise UnsupportedConstraintError(
186
+ field=field.name,
187
+ operator=constraint.operator,
188
+ value=constraint.value,
189
+ suggestion=f"Constraint {constraint.operator!r} is not valid for Enum fields.",
190
+ )
191
+ return
192
+
193
+ if annotation is bool and constraint.operator in _LENGTH_OPERATORS | _NUMERIC_OPERATORS:
194
+ raise UnsupportedConstraintError(
195
+ field=field.name,
196
+ operator=constraint.operator,
197
+ value=constraint.value,
198
+ suggestion=(
199
+ "Bool fields only support Literal/equality-style constraints in SQLRules 0.1."
200
+ ),
201
+ )
202
+
203
+ if constraint.operator in _LENGTH_OPERATORS and annotation is not str:
204
+ raise UnsupportedConstraintError(
205
+ field=field.name,
206
+ operator=constraint.operator,
207
+ value=constraint.value,
208
+ suggestion="Length constraints require a str field annotation.",
209
+ )
210
+
211
+ if constraint.operator in _NUMERIC_OPERATORS and annotation is str:
212
+ raise UnsupportedConstraintError(
213
+ field=field.name,
214
+ operator=constraint.operator,
215
+ value=constraint.value,
216
+ suggestion="Numeric comparison constraints are not valid for str fields.",
217
+ )
218
+
219
+
220
+ def extract_constraints(field: FieldDescriptor) -> list[Constraint]:
221
+ constraints: list[Constraint] = []
222
+
223
+ for item in _iter_metadata(_field_metadata(field)):
224
+ if isinstance(item, Gt):
225
+ constraints.append(Constraint(field.name, "gt", item.gt))
226
+ elif isinstance(item, Ge):
227
+ constraints.append(Constraint(field.name, "ge", item.ge))
228
+ elif isinstance(item, Lt):
229
+ constraints.append(Constraint(field.name, "lt", item.lt))
230
+ elif isinstance(item, Le):
231
+ constraints.append(Constraint(field.name, "le", item.le))
232
+ elif isinstance(item, MultipleOf):
233
+ constraints.append(Constraint(field.name, "multiple_of", item.multiple_of))
234
+ elif isinstance(item, MinLen):
235
+ constraints.append(Constraint(field.name, "min_length", item.min_length))
236
+ elif isinstance(item, MaxLen):
237
+ constraints.append(Constraint(field.name, "max_length", item.max_length))
238
+ else:
239
+ constraints.extend(_unsupported_constraints(field.name, item))
240
+
241
+ annotation = _concrete_type(field)
242
+ origin = get_origin(annotation)
243
+ args = get_args(annotation)
244
+
245
+ if origin is Literal:
246
+ constraints.append(Constraint(field.name, "literal", args))
247
+
248
+ if isinstance(annotation, type) and issubclass(annotation, Enum):
249
+ values = tuple(member.value for member in annotation)
250
+ constraints.append(Constraint(field.name, "enum", values))
251
+
252
+ for constraint in constraints:
253
+ if constraint.operator in _LENGTH_OPERATORS | _NUMERIC_OPERATORS:
254
+ _reject_type_operator_mismatch(field, constraint)
255
+
256
+ return constraints
sqlrules/errors.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ class SQLRulesError(Exception):
8
+ """Base class for all public SQLRules exceptions."""
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class InvalidModelError(SQLRulesError):
13
+ model: Any
14
+
15
+ def __str__(self) -> str:
16
+ return (
17
+ f"Expected a Pydantic BaseModel subclass, got {self.model!r}. "
18
+ "Pass a model class such as class UserFilter(BaseModel): ..."
19
+ )
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class MissingColumnError(SQLRulesError):
24
+ field: str
25
+
26
+ def __str__(self) -> str:
27
+ return (
28
+ f"No SQLAlchemy column found for field {self.field!r}. "
29
+ "Provide a matching table column, ORM attribute, or column_map entry."
30
+ )
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class UnsupportedConstraintError(SQLRulesError):
35
+ field: str
36
+ operator: str
37
+ value: Any = None
38
+ suggestion: str | None = None
39
+
40
+ def __str__(self) -> str:
41
+ message = (
42
+ f"Field {self.field!r}: constraint {self.operator!r} is not supported by SQLRules 0.1."
43
+ )
44
+ if self.suggestion:
45
+ return f"{message} {self.suggestion}"
46
+ return f"{message} Remove the constraint, or set on_unsupported='warn'/'ignore'."
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class TranslatorError(SQLRulesError):
51
+ field: str
52
+ operator: str
53
+ message: str
54
+
55
+ def __str__(self) -> str:
56
+ return (
57
+ f"Translator failed for field {self.field!r}, "
58
+ f"operator {self.operator!r}: {self.message}"
59
+ )
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class InvalidTranslatorError(SQLRulesError):
64
+ operator: str
65
+ translator: Any
66
+
67
+ def __str__(self) -> str:
68
+ return (
69
+ f"Invalid translator for operator {self.operator!r}: {self.translator!r}. "
70
+ "Translators must be callables returning a SQLAlchemy boolean expression."
71
+ )
72
+
73
+
74
+ @dataclass(slots=True)
75
+ class RegistryError(SQLRulesError):
76
+ message: str
77
+
78
+ def __str__(self) -> str:
79
+ return self.message
80
+
81
+
82
+ @dataclass(slots=True)
83
+ class ConfigurationError(SQLRulesError):
84
+ option: str
85
+ value: Any
86
+
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
+ )
94
+
95
+
96
+ class InternalCompilerError(SQLRulesError):
97
+ """Unexpected internal compiler failure."""
sqlrules/inspectors.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import AliasChoices, AliasPath, BaseModel
6
+
7
+ from sqlrules.errors import InvalidModelError
8
+ from sqlrules.ir import FieldDescriptor
9
+
10
+
11
+ def _string_alias(value: Any) -> str | None:
12
+ if isinstance(value, str):
13
+ return value
14
+ return None
15
+
16
+
17
+ def _field_aliases(field: Any) -> tuple[str | None, tuple[str, ...]]:
18
+ """Return primary alias and extra string alias candidates for column binding."""
19
+ aliases: list[str] = []
20
+
21
+ primary = _string_alias(field.alias)
22
+ if primary:
23
+ aliases.append(primary)
24
+
25
+ for attr in ("validation_alias", "serialization_alias"):
26
+ value = getattr(field, attr, None)
27
+ # AliasPath / AliasChoices are not used for column binding in 0.1.
28
+ if isinstance(value, (AliasPath, AliasChoices)):
29
+ continue
30
+ candidate = _string_alias(value)
31
+ if candidate and candidate not in aliases:
32
+ aliases.append(candidate)
33
+
34
+ primary_alias = aliases[0] if aliases else None
35
+ return primary_alias, tuple(aliases)
36
+
37
+
38
+ def inspect_model(model: type[BaseModel]) -> list[FieldDescriptor]:
39
+ if not isinstance(model, type) or not issubclass(model, BaseModel):
40
+ raise InvalidModelError(model=model)
41
+
42
+ descriptors: list[FieldDescriptor] = []
43
+ for name, field in model.model_fields.items():
44
+ primary_alias, aliases = _field_aliases(field)
45
+ descriptors.append(
46
+ FieldDescriptor(
47
+ name=name,
48
+ alias=primary_alias,
49
+ aliases=aliases,
50
+ annotation=field.annotation,
51
+ metadata=tuple(field.metadata),
52
+ )
53
+ )
54
+ return descriptors
sqlrules/ir.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Literal
5
+
6
+ OnUnsupported = Literal["raise", "warn", "ignore"]
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class Constraint:
11
+ field: str
12
+ operator: str
13
+ value: Any
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class FieldDescriptor:
18
+ name: str
19
+ alias: str | None
20
+ annotation: Any
21
+ metadata: tuple[Any, ...] = ()
22
+ aliases: tuple[str, ...] = ()
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class CompilationContext:
27
+ on_unsupported: OnUnsupported = "raise"
28
+ diagnostics: tuple[str, ...] = field(default_factory=tuple)
sqlrules/py.typed ADDED
File without changes
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ import operator
4
+ import warnings
5
+ from collections.abc import Callable
6
+ from typing import Any, cast
7
+
8
+ from sqlalchemy import func
9
+ from sqlalchemy.sql.elements import ColumnElement
10
+
11
+ from sqlrules.errors import RegistryError, TranslatorError, UnsupportedConstraintError
12
+ from sqlrules.ir import CompilationContext, Constraint
13
+
14
+ Translator = Callable[[Constraint, ColumnElement[Any], CompilationContext], ColumnElement[bool]]
15
+
16
+
17
+ class SQLRulesWarning(UserWarning):
18
+ """Warning emitted when an unsupported constraint is skipped."""
19
+
20
+
21
+ def _binary(op: Callable[[Any, Any], Any]) -> Translator:
22
+ def translate(
23
+ constraint: Constraint,
24
+ column: ColumnElement[Any],
25
+ context: CompilationContext,
26
+ ) -> ColumnElement[bool]:
27
+ return cast(ColumnElement[bool], op(column, constraint.value))
28
+
29
+ return translate
30
+
31
+
32
+ def _multiple_of(
33
+ constraint: Constraint,
34
+ column: ColumnElement[Any],
35
+ context: CompilationContext,
36
+ ) -> ColumnElement[bool]:
37
+ value = constraint.value
38
+ try:
39
+ non_positive = value <= 0
40
+ except TypeError as exc:
41
+ raise UnsupportedConstraintError(
42
+ field=constraint.field,
43
+ operator="multiple_of",
44
+ value=value,
45
+ suggestion="multiple_of requires a positive numeric value.",
46
+ ) from exc
47
+ if non_positive:
48
+ raise UnsupportedConstraintError(
49
+ field=constraint.field,
50
+ operator="multiple_of",
51
+ value=value,
52
+ suggestion="multiple_of requires a positive numeric value.",
53
+ )
54
+ return cast(ColumnElement[bool], (column % value) == 0)
55
+
56
+
57
+ def _min_length(
58
+ constraint: Constraint,
59
+ column: ColumnElement[Any],
60
+ context: CompilationContext,
61
+ ) -> ColumnElement[bool]:
62
+ return cast(ColumnElement[bool], func.length(column) >= constraint.value)
63
+
64
+
65
+ def _max_length(
66
+ constraint: Constraint,
67
+ column: ColumnElement[Any],
68
+ context: CompilationContext,
69
+ ) -> ColumnElement[bool]:
70
+ return cast(ColumnElement[bool], func.length(column) <= constraint.value)
71
+
72
+
73
+ def _in_values(
74
+ constraint: Constraint,
75
+ column: ColumnElement[Any],
76
+ context: CompilationContext,
77
+ ) -> ColumnElement[bool]:
78
+ return cast(ColumnElement[bool], column.in_(constraint.value))
79
+
80
+
81
+ class TranslatorRegistry:
82
+ def __init__(self) -> None:
83
+ self._translators: dict[str, Translator] = {}
84
+
85
+ def register(
86
+ self,
87
+ operator_name: str,
88
+ translator: Translator,
89
+ *,
90
+ replace: bool = False,
91
+ ) -> None:
92
+ if not replace and operator_name in self._translators:
93
+ raise RegistryError(f"Translator for {operator_name!r} is already registered.")
94
+ self._translators[operator_name] = translator
95
+
96
+ def lookup(self, operator_name: str) -> Translator | None:
97
+ return self._translators.get(operator_name)
98
+
99
+ def translate(
100
+ self,
101
+ constraint: Constraint,
102
+ column: ColumnElement[Any],
103
+ context: CompilationContext,
104
+ ) -> ColumnElement[bool] | None:
105
+ translator = self.lookup(constraint.operator)
106
+ if translator is None:
107
+ if context.on_unsupported == "raise":
108
+ raise UnsupportedConstraintError(
109
+ field=constraint.field,
110
+ operator=constraint.operator,
111
+ value=constraint.value,
112
+ suggestion=("Remove the constraint, or set on_unsupported='warn'/'ignore'."),
113
+ )
114
+ if context.on_unsupported == "warn":
115
+ # sqlrules.compile → Compiler.compile → translate → warn
116
+ # stacklevel=4 attributes the warning to the caller's code.
117
+ warnings.warn(
118
+ (
119
+ f"Field {constraint.field!r}: constraint {constraint.operator!r} "
120
+ "is not supported and will be skipped."
121
+ ),
122
+ SQLRulesWarning,
123
+ stacklevel=4,
124
+ )
125
+ return None
126
+
127
+ try:
128
+ return translator(constraint, column, context)
129
+ except UnsupportedConstraintError:
130
+ raise
131
+ except Exception as exc: # pragma: no cover - defensive wrapper
132
+ raise TranslatorError(
133
+ field=constraint.field,
134
+ operator=constraint.operator,
135
+ message=str(exc),
136
+ ) from exc
137
+
138
+
139
+ def default_registry() -> TranslatorRegistry:
140
+ registry = TranslatorRegistry()
141
+ registry.register("gt", _binary(operator.gt))
142
+ registry.register("ge", _binary(operator.ge))
143
+ registry.register("lt", _binary(operator.lt))
144
+ registry.register("le", _binary(operator.le))
145
+ registry.register("multiple_of", _multiple_of)
146
+ registry.register("min_length", _min_length)
147
+ registry.register("max_length", _max_length)
148
+ registry.register("literal", _in_values)
149
+ registry.register("enum", _in_values)
150
+ return registry
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlrules
3
+ Version: 0.1.0
4
+ Summary: Compile constrained Pydantic models into SQLAlchemy WHERE-rule dictionaries.
5
+ Project-URL: Homepage, https://github.com/eddiethedean/sqlrules
6
+ Project-URL: Documentation, https://github.com/eddiethedean/sqlrules#readme
7
+ Project-URL: Repository, https://github.com/eddiethedean/sqlrules
8
+ Project-URL: Changelog, https://github.com/eddiethedean/sqlrules/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/eddiethedean/sqlrules/issues
10
+ Author: SQLRules Contributors
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: compiler,constraints,filters,pydantic,sqlalchemy,where
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Database
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: annotated-types>=0.6
29
+ Requires-Dist: pydantic>=2.0
30
+ Requires-Dist: sqlalchemy>=2.0
31
+ Provides-Extra: dev
32
+ Requires-Dist: mypy>=1.10; extra == 'dev'
33
+ Requires-Dist: pre-commit>=3.7; extra == 'dev'
34
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
35
+ Requires-Dist: pytest>=8.0; extra == 'dev'
36
+ Requires-Dist: ruff>=0.5; extra == 'dev'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # SQLRules
40
+
41
+ **Compile constrained Pydantic models into SQLAlchemy WHERE-rule dictionaries.**
42
+
43
+ One package. One job. Deterministic output. Zero database dependency.
44
+
45
+ ```python
46
+ from typing import Annotated
47
+ from pydantic import BaseModel, Field
48
+ from sqlalchemy import Table, Column, Integer, String, MetaData
49
+
50
+ import sqlrules
51
+
52
+ metadata = MetaData()
53
+
54
+ users = Table(
55
+ "users",
56
+ metadata,
57
+ Column("age", Integer),
58
+ Column("name", String),
59
+ )
60
+
61
+ class UserFilter(BaseModel):
62
+ age: Annotated[int, Field(ge=18, le=65)]
63
+ name: Annotated[str, Field(min_length=2)]
64
+
65
+ rules = sqlrules.compile(UserFilter, users)
66
+ # {
67
+ # "age": [users.c.age >= 18, users.c.age <= 65],
68
+ # "name": [func.length(users.c.name) >= 2],
69
+ # }
70
+
71
+ stmt = users.select().where(*sqlrules.where(rules))
72
+ ```
73
+
74
+ ## Install
75
+
76
+ ```bash
77
+ pip install sqlrules
78
+ ```
79
+
80
+ Requires Python 3.10+, Pydantic v2, and SQLAlchemy 2.x.
81
+
82
+ ## Supported constraints (0.1)
83
+
84
+ | Constraint | SQLAlchemy expression |
85
+ |---|---|
86
+ | `gt` / `ge` / `lt` / `le` | `column > / >= / < / <= value` |
87
+ | `multiple_of` | `column % value == 0` |
88
+ | `min_length` / `max_length` | `func.length(column) >= / <= value` |
89
+ | `Literal[...]` | `column.in_(...)` |
90
+ | `Enum` | `column.in_(...)` |
91
+
92
+ Unsupported constraints raise `UnsupportedConstraintError` by default.
93
+ Use `on_unsupported="warn"` or `"ignore"` to change that policy for unknown
94
+ constraint operators (unsupported types always raise).
95
+
96
+ ## Public API
97
+
98
+ ```python
99
+ sqlrules.compile(model, table, *, column_map=None, on_unsupported="raise")
100
+ sqlrules.where(rules) # flatten all expressions
101
+ sqlrules.flatten(rules) # alias of where()
102
+ ```
103
+
104
+ ## Non-goals
105
+
106
+ SQLRules is not an ORM, validator, query builder, SQL string generator,
107
+ migration tool, or database client. It only compiles supported Pydantic
108
+ constraints into SQLAlchemy expressions.
109
+
110
+ ## Documentation
111
+
112
+ See [`docs/index.md`](docs/index.md) for the full documentation set,
113
+ including the [spec](docs/SPEC.md), [API](docs/API.md),
114
+ [architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
115
+
116
+ ## Development
117
+
118
+ ```bash
119
+ pip install -e ".[dev]"
120
+ pytest
121
+ ruff check .
122
+ mypy src/sqlrules
123
+ ```
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,13 @@
1
+ sqlrules/__init__.py,sha256=51kBzKMVXNdUUgWNYzKcSFJfPA3ZlX2OiFUfZ7aljss,875
2
+ sqlrules/columns.py,sha256=AKLzYvYkHC-jIMPXMDLj37kUUPU3hIHCDoZYhwa9064,2373
3
+ sqlrules/compiler.py,sha256=iIPNGq5TN2cQ21HtrG6uRaYqksDfdvKE9h5KrnjHZu4,2529
4
+ sqlrules/constraints.py,sha256=oSgNzzstRxyskeKddQTNo6Cbgc_tXnx8A4KYY7_a4po,8921
5
+ sqlrules/errors.py,sha256=5R-tj_TROF_aWBcB-zB-e2crmAri3hhEH7bZlslyN5w,2537
6
+ sqlrules/inspectors.py,sha256=h6TsUVyLTyNie0up3ZM1QLReYtKBlHBcwDbJxbS8gGs,1707
7
+ sqlrules/ir.py,sha256=gdz9u2pnVM8WTjdUCTq1MdKA3U_NV3paVwqZqG1pc8Q,623
8
+ sqlrules/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ sqlrules/translators.py,sha256=mKTcSPMZewqpjVRf58iGyZJlATTyYFZmklLz3qXNIjs,4955
10
+ sqlrules-0.1.0.dist-info/METADATA,sha256=d9QCDHGEdUA_mr_mSFReh8wGcdaGV-3-Lj1Wdk7doZo,3788
11
+ sqlrules-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
12
+ sqlrules-0.1.0.dist-info/licenses/LICENSE,sha256=cLMlkCH6RhjWAkXBWR7LazV98TOG0WkPtCKB5zqqkAs,1078
13
+ sqlrules-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SQLRules Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.