sqlrules-sqlite 1.0.1__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.
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlrules.plugins import PLUGIN_API_VERSION
4
+ from sqlrules.translators import TranslatorRegistry
5
+ from sqlrules_sqlite.json import translate_json_contains, translate_json_has_key
6
+ from sqlrules_sqlite.pattern import translate_pattern
7
+ from sqlrules_sqlite.regexp import register_regexp
8
+ from sqlrules_sqlite.type_check import translate_type_check
9
+
10
+ __version__ = "1.0.1"
11
+
12
+
13
+ class SQLitePlugin:
14
+ """Register SQLite-specific constraint translators.
15
+
16
+ The ``pattern`` and text-shaped ``type_check`` translators emit
17
+ ``column REGEXP pattern``. Call :func:`register_regexp` on each SQLite
18
+ connection before executing the resulting SQL.
19
+ """
20
+
21
+ name = "sqlite"
22
+ api_version = PLUGIN_API_VERSION
23
+
24
+ def register(self, registry: TranslatorRegistry) -> None:
25
+ registry.register_constraint(
26
+ "pattern",
27
+ translate_pattern,
28
+ on_conflict="replace",
29
+ )
30
+ registry.register_constraint(
31
+ "type_check",
32
+ translate_type_check,
33
+ on_conflict="replace",
34
+ )
35
+ registry.register_constraint(
36
+ "json_contains",
37
+ translate_json_contains,
38
+ on_conflict="replace",
39
+ )
40
+ registry.register_constraint(
41
+ "json_has_key",
42
+ translate_json_has_key,
43
+ on_conflict="replace",
44
+ )
45
+
46
+
47
+ __all__ = [
48
+ "SQLitePlugin",
49
+ "__version__",
50
+ "register_regexp",
51
+ "translate_json_contains",
52
+ "translate_json_has_key",
53
+ "translate_pattern",
54
+ "translate_type_check",
55
+ ]
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, cast
5
+
6
+ from sqlalchemy import String, func, literal, type_coerce
7
+ from sqlalchemy.sql.elements import ColumnElement
8
+
9
+ from sqlrules.ir import CompilationContext, Constraint
10
+
11
+
12
+ def _json_path_for_key(key: Any) -> str:
13
+ """Build a JSONPath for a single object key (never a full-path escape hatch)."""
14
+ text = str(key)
15
+ escaped = text.replace("\\", "\\\\").replace('"', '\\"')
16
+ return f'$."{escaped}"'
17
+
18
+
19
+ def _compact_dumps(value: Any) -> str:
20
+ return json.dumps(value, separators=(",", ":"))
21
+
22
+
23
+ def _extract_equals(
24
+ column: ColumnElement[Any],
25
+ path: str,
26
+ expected: Any,
27
+ ) -> ColumnElement[bool]:
28
+ """Compare ``json_extract`` to ``expected`` using SQLite JSON1 affinities."""
29
+ extracted = func.json_extract(column, path)
30
+ if expected is None:
31
+ return cast(ColumnElement[bool], func.json_type(column, path) == "null")
32
+ if isinstance(expected, bool):
33
+ return cast(ColumnElement[bool], extracted == (1 if expected else 0))
34
+ if isinstance(expected, (dict, list)):
35
+ compact = _compact_dumps(expected)
36
+ return cast(
37
+ ColumnElement[bool],
38
+ func.json(extracted) == func.json(literal(compact)),
39
+ )
40
+ if isinstance(expected, (int, float)):
41
+ return cast(ColumnElement[bool], extracted == expected)
42
+ if isinstance(expected, str):
43
+ return cast(ColumnElement[bool], extracted == expected)
44
+ compact = _compact_dumps(expected)
45
+ return cast(
46
+ ColumnElement[bool],
47
+ func.json(extracted) == func.json(literal(compact)),
48
+ )
49
+
50
+
51
+ def translate_json_contains(
52
+ constraint: Constraint,
53
+ column: ColumnElement[Any],
54
+ context: CompilationContext,
55
+ ) -> ColumnElement[bool]:
56
+ """Translate ``json_contains`` using SQLite JSON1 ``json_extract``.
57
+
58
+ For object payloads, checks that each top-level key extracts to the
59
+ expected JSON value. This is a deterministic subset of JSON containment
60
+ suitable for common filter models; nested deep-merge semantics are not
61
+ emulated.
62
+ """
63
+ value = constraint.value
64
+ if isinstance(value, dict):
65
+ if not value:
66
+ # Align with PostgreSQL ``@> '{}'``: require a non-NULL JSON object.
67
+ return cast(
68
+ ColumnElement[bool],
69
+ column.is_not(None) & (func.json_type(column) == "object"),
70
+ )
71
+ parts: list[ColumnElement[bool]] = []
72
+ for key, expected in value.items():
73
+ parts.append(_extract_equals(column, _json_path_for_key(key), expected))
74
+ expression = parts[0]
75
+ for part in parts[1:]:
76
+ expression = expression & part
77
+ return cast(ColumnElement[bool], expression)
78
+
79
+ # Scalar / array payload: compare whole-document JSON text via json().
80
+ compact = _compact_dumps(value)
81
+ return cast(
82
+ ColumnElement[bool],
83
+ func.json(type_coerce(column, String)) == func.json(literal(compact)),
84
+ )
85
+
86
+
87
+ def translate_json_has_key(
88
+ constraint: Constraint,
89
+ column: ColumnElement[Any],
90
+ context: CompilationContext,
91
+ ) -> ColumnElement[bool]:
92
+ """Translate ``json_has_key`` via ``json_type(column, path) IS NOT NULL``."""
93
+ path = _json_path_for_key(constraint.value)
94
+ return cast(ColumnElement[bool], func.json_type(column, path).is_not(None))
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+ from sqlalchemy.sql.elements import ColumnElement
6
+
7
+ from sqlrules.constraints import pattern_text
8
+ from sqlrules.ir import CompilationContext, Constraint
9
+
10
+
11
+ def translate_pattern(
12
+ constraint: Constraint,
13
+ column: ColumnElement[Any],
14
+ context: CompilationContext,
15
+ ) -> ColumnElement[bool]:
16
+ """Translate ``pattern`` to SQLite ``column REGEXP pattern``.
17
+
18
+ Case-insensitive patterns are encoded with a ``(?i)`` prefix so
19
+ :func:`sqlrules_sqlite.register_regexp` can apply ``re.IGNORECASE``.
20
+ Callers must enable REGEXP on the SQLite connection before execution.
21
+ """
22
+ pattern, ignore_case = pattern_text(constraint.value)
23
+ if ignore_case and not pattern.startswith("(?i)"):
24
+ pattern = f"(?i){pattern}"
25
+ return cast(ColumnElement[bool], column.op("REGEXP")(pattern))
File without changes
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sqlite3
5
+
6
+
7
+ def register_regexp(connection: sqlite3.Connection) -> None:
8
+ """Register a flag-aware ``REGEXP`` function on a SQLite connection.
9
+
10
+ The SQLRules SQLite ``pattern`` translator emits
11
+ ``column REGEXP pattern``. SQLite does not ship REGEXP by default;
12
+ call this once per connection before executing compiled SQL.
13
+
14
+ The helper interprets an optional ``(?i)`` prefix (inserted by the
15
+ pattern translator for case-insensitive ``PatternSpec`` values).
16
+
17
+ Invalid patterns raise ``re.error`` (surfaced by SQLite as an
18
+ operational error) instead of silently matching nothing.
19
+ """
20
+
21
+ def regexp(pattern: str | None, value: str | None) -> bool:
22
+ if pattern is None or value is None:
23
+ return False
24
+ flags = 0
25
+ if pattern.startswith("(?i)"):
26
+ flags |= re.IGNORECASE
27
+ pattern = pattern[4:]
28
+ return re.search(pattern, value, flags) is not None
29
+
30
+ connection.create_function("REGEXP", 2, regexp)
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime, time
4
+ from decimal import Decimal
5
+ from typing import Any, cast
6
+ from uuid import UUID
7
+
8
+ from sqlalchemy import Boolean, Date, DateTime, Float, Integer, Numeric, String, Time
9
+ from sqlalchemy.sql import func
10
+ from sqlalchemy.sql.elements import ColumnElement
11
+ from sqlalchemy.sql.sqltypes import NullType
12
+ from sqlalchemy.types import TypeEngine
13
+
14
+ from sqlrules.constraints import type_spec
15
+ from sqlrules.errors import UnsupportedConstraintError
16
+ from sqlrules.ir import CompilationContext, Constraint, TypeSpec
17
+
18
+ _INT_TEXT = r"^[+-]?(0|[1-9]\d*)$"
19
+ _FLOAT_TEXT = r"^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$"
20
+ _DATE_TEXT = r"^\d{4}-\d{2}-\d{2}$"
21
+ _DATETIME_TEXT = r"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:?\d{2}|Z)?$"
22
+ _TIME_TEXT = r"^\d{2}:\d{2}:\d{2}(\.\d+)?$"
23
+ _UUID_TEXT = (
24
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
25
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
26
+ )
27
+
28
+
29
+ def _is_type(column: ColumnElement[Any], *bases: type[TypeEngine[Any]]) -> bool:
30
+ col_type = column.type
31
+ if isinstance(col_type, NullType):
32
+ return False
33
+ return isinstance(col_type, bases)
34
+
35
+
36
+ def _regexp(column: ColumnElement[Any], pattern: str) -> ColumnElement[bool]:
37
+ return cast(ColumnElement[bool], column.op("REGEXP")(pattern))
38
+
39
+
40
+ def _wrap_none(
41
+ column: ColumnElement[Any],
42
+ predicate: ColumnElement[bool],
43
+ *,
44
+ allow_none: bool,
45
+ ) -> ColumnElement[bool]:
46
+ if allow_none:
47
+ return cast(ColumnElement[bool], column.is_(None) | predicate)
48
+ return predicate
49
+
50
+
51
+ def _unsupported(field: str, spec: TypeSpec, suggestion: str) -> None:
52
+ raise UnsupportedConstraintError(
53
+ field=field,
54
+ operator="type_check",
55
+ value=spec,
56
+ suggestion=suggestion,
57
+ )
58
+
59
+
60
+ def _predicate_int(
61
+ column: ColumnElement[Any],
62
+ spec: TypeSpec,
63
+ field: str,
64
+ ) -> ColumnElement[bool]:
65
+ if _is_type(column, Integer):
66
+ return cast(ColumnElement[bool], column.isnot(None))
67
+ if spec.strict:
68
+ # SQLite typeof can distinguish integer storage even on loosely typed columns.
69
+ return cast(ColumnElement[bool], func.typeof(column) == "integer")
70
+ if _is_type(column, String):
71
+ return _regexp(column, _INT_TEXT)
72
+ if _is_type(column, Float, Numeric):
73
+ return cast(ColumnElement[bool], column == column.cast(Integer).cast(Float))
74
+ # Affinity-agnostic: integer typeof OR integer-shaped text.
75
+ return cast(
76
+ ColumnElement[bool],
77
+ (func.typeof(column) == "integer") | _regexp(column, _INT_TEXT),
78
+ )
79
+
80
+
81
+ def _predicate_bool(
82
+ column: ColumnElement[Any],
83
+ spec: TypeSpec,
84
+ field: str,
85
+ ) -> ColumnElement[bool]:
86
+ if spec.strict:
87
+ if _is_type(column, Boolean, Integer):
88
+ return cast(ColumnElement[bool], column.in_((True, False, 0, 1)))
89
+ return cast(
90
+ ColumnElement[bool],
91
+ (func.typeof(column) == "integer") & column.in_((0, 1)),
92
+ )
93
+ _unsupported(
94
+ field,
95
+ spec,
96
+ "SQLite lax bool type_check is not supported. Use strict=True.",
97
+ )
98
+ raise AssertionError("unreachable")
99
+
100
+
101
+ def _predicate_str(
102
+ column: ColumnElement[Any],
103
+ spec: TypeSpec,
104
+ field: str,
105
+ ) -> ColumnElement[bool]:
106
+ if _is_type(column, String):
107
+ return cast(ColumnElement[bool], column.isnot(None))
108
+ return cast(ColumnElement[bool], func.typeof(column) == "text")
109
+
110
+
111
+ def _predicate_float(
112
+ column: ColumnElement[Any],
113
+ spec: TypeSpec,
114
+ field: str,
115
+ ) -> ColumnElement[bool]:
116
+ if _is_type(column, Float, Numeric, Integer):
117
+ return cast(ColumnElement[bool], column.isnot(None))
118
+ if spec.strict:
119
+ return cast(
120
+ ColumnElement[bool],
121
+ func.typeof(column).in_(("real", "integer")),
122
+ )
123
+ if _is_type(column, String):
124
+ return _regexp(column, _FLOAT_TEXT)
125
+ return cast(
126
+ ColumnElement[bool],
127
+ func.typeof(column).in_(("real", "integer")) | _regexp(column, _FLOAT_TEXT),
128
+ )
129
+
130
+
131
+ def _predicate_temporal(
132
+ column: ColumnElement[Any],
133
+ spec: TypeSpec,
134
+ field: str,
135
+ *,
136
+ sa_types: tuple[type[TypeEngine[Any]], ...],
137
+ text_pattern: str,
138
+ ) -> ColumnElement[bool]:
139
+ if _is_type(column, *sa_types):
140
+ return cast(ColumnElement[bool], column.isnot(None))
141
+ if spec.strict and not _is_type(column, String):
142
+ type_name = spec.python_type.__name__
143
+ _unsupported(
144
+ field,
145
+ spec,
146
+ f"SQLite strict {type_name} type_check requires a typed or String column.",
147
+ )
148
+ return _regexp(column, text_pattern)
149
+
150
+
151
+ def _predicate_uuid(
152
+ column: ColumnElement[Any],
153
+ spec: TypeSpec,
154
+ field: str,
155
+ ) -> ColumnElement[bool]:
156
+ if "uuid" in type(column.type).__name__.lower():
157
+ return cast(ColumnElement[bool], column.isnot(None))
158
+ if _is_type(column, String) or not spec.strict:
159
+ return _regexp(column, _UUID_TEXT)
160
+ _unsupported(
161
+ field,
162
+ spec,
163
+ "SQLite strict UUID type_check requires a UUID or String column.",
164
+ )
165
+ raise AssertionError("unreachable")
166
+
167
+
168
+ def _build_predicate(
169
+ column: ColumnElement[Any],
170
+ spec: TypeSpec,
171
+ field: str,
172
+ ) -> ColumnElement[bool]:
173
+ python_type = spec.python_type
174
+ if python_type is int:
175
+ return _predicate_int(column, spec, field)
176
+ if python_type is bool:
177
+ return _predicate_bool(column, spec, field)
178
+ if python_type is str:
179
+ return _predicate_str(column, spec, field)
180
+ if python_type in {float, Decimal}:
181
+ return _predicate_float(column, spec, field)
182
+ if python_type is date:
183
+ return _predicate_temporal(column, spec, field, sa_types=(Date,), text_pattern=_DATE_TEXT)
184
+ if python_type is datetime:
185
+ return _predicate_temporal(
186
+ column, spec, field, sa_types=(DateTime,), text_pattern=_DATETIME_TEXT
187
+ )
188
+ if python_type is time:
189
+ return _predicate_temporal(column, spec, field, sa_types=(Time,), text_pattern=_TIME_TEXT)
190
+ if python_type is UUID:
191
+ return _predicate_uuid(column, spec, field)
192
+ _unsupported(
193
+ field,
194
+ spec,
195
+ f"SQLite type_check has no translator for {python_type!r}.",
196
+ )
197
+ raise AssertionError("unreachable")
198
+
199
+
200
+ def translate_type_check(
201
+ constraint: Constraint,
202
+ column: ColumnElement[Any],
203
+ context: CompilationContext,
204
+ ) -> ColumnElement[bool]:
205
+ """Translate ``type_check`` using SQLite ``typeof`` / ``REGEXP``.
206
+
207
+ Text-shape checks use ``REGEXP``; call :func:`register_regexp` on the
208
+ connection before executing the SQL.
209
+ """
210
+ del context
211
+ spec = type_spec(constraint.value)
212
+ predicate = _build_predicate(column, spec, constraint.field)
213
+ return _wrap_none(column, predicate, allow_none=spec.allow_none)
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlrules-sqlite
3
+ Version: 1.0.1
4
+ Summary: SQLite dialect plugin for SQLRules (REGEXP helpers and JSON).
5
+ Project-URL: Homepage, https://github.com/eddiethedean/sqlrules
6
+ Project-URL: Repository, https://github.com/eddiethedean/sqlrules
7
+ Project-URL: Issues, https://github.com/eddiethedean/sqlrules/issues
8
+ Author: SQLRules Contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: pydantic,sqlalchemy,sqlite,sqlrules
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: sqlalchemy<3,>=2.0
18
+ Requires-Dist: sqlrules<2,>=1
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # sqlrules-sqlite
24
+
25
+ SQLite dialect plugin for [SQLRules](https://github.com/eddiethedean/sqlrules).
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install sqlrules-sqlite
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```python
36
+ import sqlite3
37
+ from typing import Annotated, Any
38
+
39
+ from pydantic import BaseModel, Field
40
+ from sqlalchemy import Column, MetaData, String, Table, create_engine, text
41
+
42
+ from sqlrules import Compiler, JsonContains, JsonHasKey
43
+ from sqlrules_sqlite import SQLitePlugin, register_regexp
44
+
45
+ class RowFilter(BaseModel):
46
+ name: Annotated[str, Field(pattern=r"^A")]
47
+ meta: Annotated[dict[str, Any], JsonContains({"active": True}), JsonHasKey("active")]
48
+
49
+ table = Table(
50
+ "rows",
51
+ MetaData(),
52
+ Column("name", String),
53
+ Column("meta", String),
54
+ )
55
+
56
+ compiler = Compiler(plugins=[SQLitePlugin()], dialect="sqlite")
57
+ rules = compiler.compile(RowFilter, table)
58
+
59
+ engine = create_engine("sqlite://")
60
+ with engine.raw_connection() as conn:
61
+ # SQLAlchemy 2 may wrap the DBAPI connection; unwrap if needed.
62
+ dbapi = conn.driver_connection if hasattr(conn, "driver_connection") else conn
63
+ register_regexp(dbapi)
64
+ ```
65
+
66
+ ## Operators
67
+
68
+ | IR operator | Notes |
69
+ |---|---|
70
+ | `pattern` | `column REGEXP pattern`; call `register_regexp(connection)` |
71
+ | `type_check` | `typeof` / `REGEXP` shape checks; text forms need `register_regexp` |
72
+ | `json_contains` | JSON1 `json_extract` equality for object keys |
73
+ | `json_has_key` | `json_type(column, '$.key') IS NOT NULL` |
74
+
75
+ Case-insensitive patterns (`re.IGNORECASE` / `PatternSpec.ignore_case`) are
76
+ encoded with a `(?i)` prefix understood by `register_regexp`.
77
+
78
+ ## Security note
79
+
80
+ `register_regexp` installs a Python `re.search` UDF. Untrusted
81
+ `Field(pattern=...)` values can cause **CPU denial of service** (ReDoS) in
82
+ your process — not SQL injection. Prefer static/allowlisted patterns. See
83
+ [SECURITY](https://sqlrules.readthedocs.io/en/latest/SECURITY.html).
@@ -0,0 +1,10 @@
1
+ sqlrules_sqlite/__init__.py,sha256=oLODNHzgXbK1XjmI5YcpnDKf7JXYJfj0Wr2Gcnc35ds,1575
2
+ sqlrules_sqlite/json.py,sha256=B9QCjD8FR7nja8Ct4O7fb2VzTPhALg3b_ODayBeKHBY,3326
3
+ sqlrules_sqlite/pattern.py,sha256=K1JBoM1bkxYRgWKz_Ti7o_IDFKtIeg9uYplr0ThVJBY,867
4
+ sqlrules_sqlite/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ sqlrules_sqlite/regexp.py,sha256=j3c_J3dnbAdXfKkhhb1CxiIPi1QB_ai5Mt30ODrmx3M,1038
6
+ sqlrules_sqlite/type_check.py,sha256=JQXzgdLWmdAnGXOBPza4yMwVhrWWeYyforv-hQSd9k4,6742
7
+ sqlrules_sqlite-1.0.1.dist-info/METADATA,sha256=yCg5Z14W7BSP9IquHzYJFKuu-oUzzHdDooT1R0qKnzU,2694
8
+ sqlrules_sqlite-1.0.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ sqlrules_sqlite-1.0.1.dist-info/licenses/LICENSE,sha256=cLMlkCH6RhjWAkXBWR7LazV98TOG0WkPtCKB5zqqkAs,1078
10
+ sqlrules_sqlite-1.0.1.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.