sqlrules-postgresql 1.0.1__tar.gz

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,22 @@
1
+ .venv/
2
+ venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ *$py.class
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .coverage
10
+ .coverage.*
11
+ htmlcov/
12
+ dist/
13
+ dist-plugins/
14
+ dist-missing/
15
+ build/
16
+ *.egg-info/
17
+ .DS_Store
18
+ .idea/
19
+ .vscode/
20
+ *.swp
21
+ *.swo
22
+ docs/_build/
@@ -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.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlrules-postgresql
3
+ Version: 1.0.1
4
+ Summary: PostgreSQL dialect plugin for SQLRules (regex, JSONB, ARRAY, range).
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: postgresql,pydantic,sqlalchemy,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-postgresql
24
+
25
+ PostgreSQL dialect plugin for [SQLRules](https://github.com/eddiethedean/sqlrules).
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install sqlrules-postgresql
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```python
36
+ import re
37
+ from typing import Annotated, Any
38
+
39
+ from pydantic import BaseModel, Field
40
+ from sqlalchemy import Column, MetaData, Table
41
+ from sqlalchemy.dialects.postgresql import ARRAY, INT4RANGE, JSONB, TEXT
42
+
43
+ from sqlrules import ArrayContains, Compiler, JsonContains, RangeContains
44
+ from sqlrules_postgresql import PostgresPlugin
45
+
46
+ class RowFilter(BaseModel):
47
+ name: Annotated[str, Field(pattern=re.compile(r"^a", re.I))]
48
+ meta: Annotated[dict[str, Any], JsonContains({"active": True})]
49
+ tags: Annotated[list[str], ArrayContains(["admin"])]
50
+ span: Annotated[int, RangeContains(5)]
51
+
52
+ table = Table(
53
+ "rows",
54
+ MetaData(),
55
+ Column("name", TEXT),
56
+ Column("meta", JSONB),
57
+ Column("tags", ARRAY(TEXT)),
58
+ Column("span", INT4RANGE),
59
+ )
60
+
61
+ compiler = Compiler(plugins=[PostgresPlugin()], dialect="postgresql")
62
+ rules = compiler.compile(RowFilter, table)
63
+ ```
64
+
65
+ ## Operators
66
+
67
+ | IR operator | SQLAlchemy / PostgreSQL |
68
+ |---|---|
69
+ | `pattern` | `~` or `~*` (when `PatternSpec.ignore_case`) |
70
+ | `type_check` | Shape/type predicates from `TypeSpec` (see matrix below) |
71
+ | `json_contains` | JSONB `contains` / `@>` |
72
+ | `json_has_key` | JSONB `has_key` / `?` |
73
+ | `array_contains` | array `contains` |
74
+ | `array_overlap` | array `overlap` / `&&` |
75
+ | `range_contains` | range `@>` |
76
+ | `range_overlap` | range `&&` |
77
+
78
+ ### `type_check` matrix (approximate)
79
+
80
+ Enable with `Compiler(..., emit_type_checks=True)`. Not full Pydantic
81
+ parity — inexpressible pairs raise.
82
+
83
+ | Python type | Lax | Strict |
84
+ |---|---|---|
85
+ | `int` | Integer column; String `~` digit pattern; numeric whole-number | Integer column only |
86
+ | `bool` | unsupported (raise) | Boolean `IN (true, false)` |
87
+ | `str` | String/Text `IS NOT NULL` | same |
88
+ | `float` / `Decimal` | numeric column; String float-ish `~` | numeric column |
89
+ | `date` / `datetime` / `time` / `UUID` | typed column or String format `~` | typed column |
90
+
91
+ `Optional[T]` → `(column IS NULL) OR <predicate>`.
92
+
93
+ ## Security note
94
+
95
+ `pattern` becomes a PostgreSQL regex (`~` / `~*`). Untrusted pattern strings
96
+ can cause expensive engine-side evaluation (ReDoS-class cost). Prefer
97
+ static/allowlisted patterns. See
98
+ [SECURITY](https://sqlrules.readthedocs.io/en/latest/SECURITY.html).
@@ -0,0 +1,76 @@
1
+ # sqlrules-postgresql
2
+
3
+ PostgreSQL dialect plugin for [SQLRules](https://github.com/eddiethedean/sqlrules).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install sqlrules-postgresql
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import re
15
+ from typing import Annotated, Any
16
+
17
+ from pydantic import BaseModel, Field
18
+ from sqlalchemy import Column, MetaData, Table
19
+ from sqlalchemy.dialects.postgresql import ARRAY, INT4RANGE, JSONB, TEXT
20
+
21
+ from sqlrules import ArrayContains, Compiler, JsonContains, RangeContains
22
+ from sqlrules_postgresql import PostgresPlugin
23
+
24
+ class RowFilter(BaseModel):
25
+ name: Annotated[str, Field(pattern=re.compile(r"^a", re.I))]
26
+ meta: Annotated[dict[str, Any], JsonContains({"active": True})]
27
+ tags: Annotated[list[str], ArrayContains(["admin"])]
28
+ span: Annotated[int, RangeContains(5)]
29
+
30
+ table = Table(
31
+ "rows",
32
+ MetaData(),
33
+ Column("name", TEXT),
34
+ Column("meta", JSONB),
35
+ Column("tags", ARRAY(TEXT)),
36
+ Column("span", INT4RANGE),
37
+ )
38
+
39
+ compiler = Compiler(plugins=[PostgresPlugin()], dialect="postgresql")
40
+ rules = compiler.compile(RowFilter, table)
41
+ ```
42
+
43
+ ## Operators
44
+
45
+ | IR operator | SQLAlchemy / PostgreSQL |
46
+ |---|---|
47
+ | `pattern` | `~` or `~*` (when `PatternSpec.ignore_case`) |
48
+ | `type_check` | Shape/type predicates from `TypeSpec` (see matrix below) |
49
+ | `json_contains` | JSONB `contains` / `@>` |
50
+ | `json_has_key` | JSONB `has_key` / `?` |
51
+ | `array_contains` | array `contains` |
52
+ | `array_overlap` | array `overlap` / `&&` |
53
+ | `range_contains` | range `@>` |
54
+ | `range_overlap` | range `&&` |
55
+
56
+ ### `type_check` matrix (approximate)
57
+
58
+ Enable with `Compiler(..., emit_type_checks=True)`. Not full Pydantic
59
+ parity — inexpressible pairs raise.
60
+
61
+ | Python type | Lax | Strict |
62
+ |---|---|---|
63
+ | `int` | Integer column; String `~` digit pattern; numeric whole-number | Integer column only |
64
+ | `bool` | unsupported (raise) | Boolean `IN (true, false)` |
65
+ | `str` | String/Text `IS NOT NULL` | same |
66
+ | `float` / `Decimal` | numeric column; String float-ish `~` | numeric column |
67
+ | `date` / `datetime` / `time` / `UUID` | typed column or String format `~` | typed column |
68
+
69
+ `Optional[T]` → `(column IS NULL) OR <predicate>`.
70
+
71
+ ## Security note
72
+
73
+ `pattern` becomes a PostgreSQL regex (`~` / `~*`). Untrusted pattern strings
74
+ can cause expensive engine-side evaluation (ReDoS-class cost). Prefer
75
+ static/allowlisted patterns. See
76
+ [SECURITY](https://sqlrules.readthedocs.io/en/latest/SECURITY.html).
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sqlrules-postgresql"
7
+ version = "1.0.1"
8
+ description = "PostgreSQL dialect plugin for SQLRules (regex, JSONB, ARRAY, range)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "SQLRules Contributors" }]
13
+ keywords = ["sqlrules", "postgresql", "pydantic", "sqlalchemy"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = [
21
+ "sqlrules>=1,<2",
22
+ "sqlalchemy>=2.0,<3",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/eddiethedean/sqlrules"
27
+ Repository = "https://github.com/eddiethedean/sqlrules"
28
+ Issues = "https://github.com/eddiethedean/sqlrules/issues"
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=8.0",
33
+ ]
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ include = [
37
+ "/src",
38
+ "/tests",
39
+ "/README.md",
40
+ "/LICENSE",
41
+ ]
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/sqlrules_postgresql"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ pythonpath = ["src"]
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlrules.plugins import PLUGIN_API_VERSION
4
+ from sqlrules.translators import TranslatorRegistry
5
+ from sqlrules_postgresql.array import translate_array_contains, translate_array_overlap
6
+ from sqlrules_postgresql.jsonb import translate_json_contains, translate_json_has_key
7
+ from sqlrules_postgresql.pattern import translate_pattern
8
+ from sqlrules_postgresql.range import translate_range_contains, translate_range_overlap
9
+ from sqlrules_postgresql.type_check import translate_type_check
10
+
11
+ __version__ = "1.0.1"
12
+
13
+
14
+ class PostgresPlugin:
15
+ """Register PostgreSQL-specific constraint translators."""
16
+
17
+ name = "postgresql"
18
+ api_version = PLUGIN_API_VERSION
19
+
20
+ def register(self, registry: TranslatorRegistry) -> None:
21
+ registry.register_constraint(
22
+ "pattern",
23
+ translate_pattern,
24
+ on_conflict="replace",
25
+ )
26
+ registry.register_constraint(
27
+ "type_check",
28
+ translate_type_check,
29
+ on_conflict="replace",
30
+ )
31
+ for operator, translator in (
32
+ ("json_contains", translate_json_contains),
33
+ ("json_has_key", translate_json_has_key),
34
+ ("array_contains", translate_array_contains),
35
+ ("array_overlap", translate_array_overlap),
36
+ ("range_contains", translate_range_contains),
37
+ ("range_overlap", translate_range_overlap),
38
+ ):
39
+ registry.register_constraint(operator, translator, on_conflict="replace")
40
+
41
+
42
+ __all__ = [
43
+ "PostgresPlugin",
44
+ "__version__",
45
+ "translate_array_contains",
46
+ "translate_array_overlap",
47
+ "translate_json_contains",
48
+ "translate_json_has_key",
49
+ "translate_pattern",
50
+ "translate_range_contains",
51
+ "translate_range_overlap",
52
+ "translate_type_check",
53
+ ]
@@ -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.ir import CompilationContext, Constraint
8
+
9
+
10
+ def translate_array_contains(
11
+ constraint: Constraint,
12
+ column: ColumnElement[Any],
13
+ context: CompilationContext,
14
+ ) -> ColumnElement[bool]:
15
+ """Translate ``array_contains`` to PostgreSQL array containment."""
16
+ return cast(ColumnElement[bool], column.contains(constraint.value))
17
+
18
+
19
+ def translate_array_overlap(
20
+ constraint: Constraint,
21
+ column: ColumnElement[Any],
22
+ context: CompilationContext,
23
+ ) -> ColumnElement[bool]:
24
+ """Translate ``array_overlap`` to PostgreSQL array overlap (``&&``)."""
25
+ return cast(ColumnElement[bool], column.overlap(constraint.value))
@@ -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.ir import CompilationContext, Constraint
8
+
9
+
10
+ def translate_json_contains(
11
+ constraint: Constraint,
12
+ column: ColumnElement[Any],
13
+ context: CompilationContext,
14
+ ) -> ColumnElement[bool]:
15
+ """Translate ``json_contains`` to JSONB containment (``@>``)."""
16
+ return cast(ColumnElement[bool], column.contains(constraint.value))
17
+
18
+
19
+ def translate_json_has_key(
20
+ constraint: Constraint,
21
+ column: ColumnElement[Any],
22
+ context: CompilationContext,
23
+ ) -> ColumnElement[bool]:
24
+ """Translate ``json_has_key`` to JSONB ``?`` / ``has_key``."""
25
+ return cast(ColumnElement[bool], column.has_key(constraint.value))
@@ -0,0 +1,19 @@
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 PostgreSQL ``~`` or case-insensitive ``~*``."""
17
+ pattern, ignore_case = pattern_text(constraint.value)
18
+ op = "~*" if ignore_case else "~"
19
+ return cast(ColumnElement[bool], column.op(op)(pattern))
@@ -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.ir import CompilationContext, Constraint
8
+
9
+
10
+ def translate_range_contains(
11
+ constraint: Constraint,
12
+ column: ColumnElement[Any],
13
+ context: CompilationContext,
14
+ ) -> ColumnElement[bool]:
15
+ """Translate ``range_contains`` to PostgreSQL range containment (``@>``)."""
16
+ return cast(ColumnElement[bool], column.op("@>")(constraint.value))
17
+
18
+
19
+ def translate_range_overlap(
20
+ constraint: Constraint,
21
+ column: ColumnElement[Any],
22
+ context: CompilationContext,
23
+ ) -> ColumnElement[bool]:
24
+ """Translate ``range_overlap`` to PostgreSQL range overlap (``&&``)."""
25
+ return cast(ColumnElement[bool], column.op("&&")(constraint.value))
@@ -0,0 +1,250 @@
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.elements import ColumnElement
10
+ from sqlalchemy.sql.sqltypes import NullType
11
+ from sqlalchemy.types import TypeEngine
12
+
13
+ from sqlrules.constraints import type_spec
14
+ from sqlrules.errors import UnsupportedConstraintError
15
+ from sqlrules.ir import CompilationContext, Constraint, TypeSpec
16
+
17
+ # Integer-like text (Pydantic lax int accepts digit strings).
18
+ _INT_TEXT = r"^[+-]?(0|[1-9]\d*)$"
19
+ # Float / Decimal-like text (simplified; not full Pydantic parity).
20
+ _FLOAT_TEXT = r"^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$"
21
+ # ISO-ish date / datetime / time (approximate).
22
+ _DATE_TEXT = r"^\d{4}-\d{2}-\d{2}$"
23
+ _DATETIME_TEXT = r"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:?\d{2}|Z)?$"
24
+ _TIME_TEXT = r"^\d{2}:\d{2}:\d{2}(\.\d+)?$"
25
+ _UUID_TEXT = (
26
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
27
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
28
+ )
29
+
30
+
31
+ def _col_type(column: ColumnElement[Any]) -> TypeEngine[Any]:
32
+ return column.type
33
+
34
+
35
+ def _is_type(column: ColumnElement[Any], *bases: type[TypeEngine[Any]]) -> bool:
36
+ col_type = _col_type(column)
37
+ if isinstance(col_type, NullType):
38
+ return False
39
+ return isinstance(col_type, bases)
40
+
41
+
42
+ def _regex(column: ColumnElement[Any], pattern: str) -> ColumnElement[bool]:
43
+ return cast(ColumnElement[bool], column.op("~")(pattern))
44
+
45
+
46
+ def _wrap_none(
47
+ column: ColumnElement[Any],
48
+ predicate: ColumnElement[bool],
49
+ *,
50
+ allow_none: bool,
51
+ ) -> ColumnElement[bool]:
52
+ if allow_none:
53
+ return cast(ColumnElement[bool], column.is_(None) | predicate)
54
+ return predicate
55
+
56
+
57
+ def _unsupported(field: str, spec: TypeSpec, suggestion: str) -> None:
58
+ raise UnsupportedConstraintError(
59
+ field=field,
60
+ operator="type_check",
61
+ value=spec,
62
+ suggestion=suggestion,
63
+ )
64
+
65
+
66
+ def _predicate_int(
67
+ column: ColumnElement[Any],
68
+ spec: TypeSpec,
69
+ field: str,
70
+ ) -> ColumnElement[bool]:
71
+ if _is_type(column, Integer):
72
+ # Typed integer column: every non-null value is already an int.
73
+ return cast(ColumnElement[bool], column.isnot(None))
74
+ if spec.strict:
75
+ _unsupported(
76
+ field,
77
+ spec,
78
+ "PostgreSQL strict int type_check requires an Integer column "
79
+ "(Pydantic strict mode rejects string/bool coercion).",
80
+ )
81
+ if _is_type(column, String):
82
+ return _regex(column, _INT_TEXT)
83
+ if _is_type(column, Float, Numeric):
84
+ # Whole numbers only (approximate Pydantic float→int when no fractional part).
85
+ return cast(ColumnElement[bool], column == column.cast(Integer).cast(Float))
86
+ _unsupported(
87
+ field,
88
+ spec,
89
+ "PostgreSQL lax int type_check supports Integer, String, Float, or Numeric columns.",
90
+ )
91
+ raise AssertionError("unreachable")
92
+
93
+
94
+ def _predicate_bool(
95
+ column: ColumnElement[Any],
96
+ spec: TypeSpec,
97
+ field: str,
98
+ ) -> ColumnElement[bool]:
99
+ if spec.strict:
100
+ if _is_type(column, Boolean):
101
+ return cast(ColumnElement[bool], column.in_((True, False)))
102
+ _unsupported(
103
+ field,
104
+ spec,
105
+ "PostgreSQL strict bool type_check requires a Boolean column.",
106
+ )
107
+ _unsupported(
108
+ field,
109
+ spec,
110
+ "PostgreSQL lax bool type_check is not supported (coercion set is not "
111
+ "deterministically expressible). Use Field(strict=True) or model_config "
112
+ "strict=True with a Boolean column.",
113
+ )
114
+ raise AssertionError("unreachable")
115
+
116
+
117
+ def _predicate_str(
118
+ column: ColumnElement[Any],
119
+ spec: TypeSpec,
120
+ field: str,
121
+ ) -> ColumnElement[bool]:
122
+ if _is_type(column, String):
123
+ return cast(ColumnElement[bool], column.isnot(None))
124
+ _unsupported(
125
+ field,
126
+ spec,
127
+ "PostgreSQL str type_check requires a String/Text column "
128
+ "(Pydantic does not coerce other SQL types to str by default).",
129
+ )
130
+ raise AssertionError("unreachable")
131
+
132
+
133
+ def _predicate_float(
134
+ column: ColumnElement[Any],
135
+ spec: TypeSpec,
136
+ field: str,
137
+ ) -> ColumnElement[bool]:
138
+ if _is_type(column, Float, Numeric, Integer):
139
+ return cast(ColumnElement[bool], column.isnot(None))
140
+ if spec.strict:
141
+ _unsupported(
142
+ field,
143
+ spec,
144
+ "PostgreSQL strict float/Decimal type_check requires a numeric column.",
145
+ )
146
+ if _is_type(column, String):
147
+ return _regex(column, _FLOAT_TEXT)
148
+ _unsupported(
149
+ field,
150
+ spec,
151
+ "PostgreSQL float/Decimal type_check supports numeric or String columns.",
152
+ )
153
+ raise AssertionError("unreachable")
154
+
155
+
156
+ def _predicate_temporal(
157
+ column: ColumnElement[Any],
158
+ spec: TypeSpec,
159
+ field: str,
160
+ *,
161
+ sa_types: tuple[type[TypeEngine[Any]], ...],
162
+ text_pattern: str,
163
+ ) -> ColumnElement[bool]:
164
+ if _is_type(column, *sa_types):
165
+ return cast(ColumnElement[bool], column.isnot(None))
166
+ if spec.strict:
167
+ type_name = spec.python_type.__name__
168
+ _unsupported(
169
+ field,
170
+ spec,
171
+ f"PostgreSQL strict {type_name} type_check requires a typed {type_name} column.",
172
+ )
173
+ if _is_type(column, String):
174
+ return _regex(column, text_pattern)
175
+ type_name = spec.python_type.__name__
176
+ _unsupported(
177
+ field,
178
+ spec,
179
+ f"PostgreSQL {type_name} type_check supports typed {type_name} or String columns.",
180
+ )
181
+ raise AssertionError("unreachable")
182
+
183
+
184
+ def _predicate_uuid(
185
+ column: ColumnElement[Any],
186
+ spec: TypeSpec,
187
+ field: str,
188
+ ) -> ColumnElement[bool]:
189
+ col_type = _col_type(column)
190
+ type_name = type(col_type).__name__.lower()
191
+ if "uuid" in type_name:
192
+ return cast(ColumnElement[bool], column.isnot(None))
193
+ if spec.strict:
194
+ _unsupported(
195
+ field,
196
+ spec,
197
+ "PostgreSQL strict UUID type_check requires a UUID column.",
198
+ )
199
+ if _is_type(column, String):
200
+ return _regex(column, _UUID_TEXT)
201
+ _unsupported(
202
+ field,
203
+ spec,
204
+ "PostgreSQL UUID type_check supports UUID or String columns.",
205
+ )
206
+ raise AssertionError("unreachable")
207
+
208
+
209
+ def _build_predicate(
210
+ column: ColumnElement[Any],
211
+ spec: TypeSpec,
212
+ field: str,
213
+ ) -> ColumnElement[bool]:
214
+ python_type = spec.python_type
215
+ if python_type is int:
216
+ return _predicate_int(column, spec, field)
217
+ if python_type is bool:
218
+ return _predicate_bool(column, spec, field)
219
+ if python_type is str:
220
+ return _predicate_str(column, spec, field)
221
+ if python_type in {float, Decimal}:
222
+ return _predicate_float(column, spec, field)
223
+ if python_type is date:
224
+ return _predicate_temporal(column, spec, field, sa_types=(Date,), text_pattern=_DATE_TEXT)
225
+ if python_type is datetime:
226
+ return _predicate_temporal(
227
+ column, spec, field, sa_types=(DateTime,), text_pattern=_DATETIME_TEXT
228
+ )
229
+ if python_type is time:
230
+ return _predicate_temporal(column, spec, field, sa_types=(Time,), text_pattern=_TIME_TEXT)
231
+ if python_type is UUID:
232
+ return _predicate_uuid(column, spec, field)
233
+ _unsupported(
234
+ field,
235
+ spec,
236
+ f"PostgreSQL type_check has no translator for {python_type!r}.",
237
+ )
238
+ raise AssertionError("unreachable")
239
+
240
+
241
+ def translate_type_check(
242
+ constraint: Constraint,
243
+ column: ColumnElement[Any],
244
+ context: CompilationContext,
245
+ ) -> ColumnElement[bool]:
246
+ """Translate ``type_check`` into a PostgreSQL shape/type predicate."""
247
+ del context # dialect hint unused; plugin is already PostgreSQL-specific
248
+ spec = type_spec(constraint.value)
249
+ predicate = _build_predicate(column, spec, constraint.field)
250
+ return _wrap_none(column, predicate, allow_none=spec.allow_none)
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Annotated, Any
5
+
6
+ from pydantic import BaseModel, Field
7
+ from sqlalchemy import Column, MetaData, String, Table
8
+ from sqlalchemy.dialects import postgresql
9
+ from sqlalchemy.dialects.postgresql import ARRAY, INT4RANGE, JSONB, TEXT
10
+ from sqlrules_postgresql import PostgresPlugin, __version__
11
+
12
+ from sqlrules import (
13
+ ArrayContains,
14
+ ArrayOverlap,
15
+ Compiler,
16
+ JsonContains,
17
+ JsonHasKey,
18
+ RangeContains,
19
+ RangeOverlap,
20
+ )
21
+ from sqlrules.conformance import run_basic_conformance
22
+
23
+
24
+ def _sql(expr: object, *, literal_binds: bool = True) -> str:
25
+ return str(
26
+ expr.compile( # type: ignore[union-attr]
27
+ dialect=postgresql.dialect(),
28
+ compile_kwargs={"literal_binds": literal_binds} if literal_binds else {},
29
+ )
30
+ )
31
+
32
+
33
+ def test_version() -> None:
34
+ assert __version__ == "1.0.1"
35
+
36
+
37
+ def test_conformance() -> None:
38
+ run_basic_conformance(PostgresPlugin(), operator="pattern")
39
+
40
+
41
+ def test_pattern_compiles() -> None:
42
+ """PG ``~`` is substring search; Pydantic validates fullmatch — patterns often use ^/$."""
43
+
44
+ class Filter(BaseModel):
45
+ name: Annotated[str, Field(pattern=r"^A")]
46
+
47
+ table = Table("items", MetaData(), Column("name", String))
48
+ rules = Compiler(
49
+ plugins=[PostgresPlugin()],
50
+ dialect="postgresql",
51
+ cache=False,
52
+ ).compile(Filter, table)
53
+ assert _sql(rules["name"][0]) == "items.name ~ '^A'"
54
+
55
+
56
+ def test_pattern_ignore_case_uses_star() -> None:
57
+ class Filter(BaseModel):
58
+ name: Annotated[str, Field(pattern=re.compile(r"^A", re.I))]
59
+
60
+ table = Table("items", MetaData(), Column("name", String))
61
+ rules = Compiler(
62
+ plugins=[PostgresPlugin()],
63
+ dialect="postgresql",
64
+ cache=False,
65
+ ).compile(Filter, table)
66
+ assert _sql(rules["name"][0]) == "items.name ~* '^A'"
67
+
68
+
69
+ def test_unanchored_pattern_emits_search_semantics() -> None:
70
+ """Unanchored patterns are emitted as-is (PG ``~`` search, not Pydantic fullmatch)."""
71
+
72
+ class Filter(BaseModel):
73
+ name: Annotated[str, Field(pattern=r"abc")]
74
+
75
+ table = Table("items", MetaData(), Column("name", String))
76
+ rules = Compiler(
77
+ plugins=[PostgresPlugin()],
78
+ dialect="postgresql",
79
+ cache=False,
80
+ ).compile(Filter, table)
81
+ assert _sql(rules["name"][0]) == "items.name ~ 'abc'"
82
+
83
+
84
+ def test_json_array_range_operators() -> None:
85
+ class Filter(BaseModel):
86
+ meta: Annotated[dict[str, Any], JsonContains({"active": True}), JsonHasKey("active")]
87
+ tags: Annotated[list[str], ArrayContains(["admin"]), ArrayOverlap(["x"])]
88
+ span: Annotated[int, RangeContains(5), RangeOverlap([1, 10])]
89
+
90
+ table = Table(
91
+ "rows",
92
+ MetaData(),
93
+ Column("meta", JSONB),
94
+ Column("tags", ARRAY(TEXT)),
95
+ Column("span", INT4RANGE),
96
+ )
97
+ rules = Compiler(
98
+ plugins=[PostgresPlugin()],
99
+ dialect="postgresql",
100
+ cache=False,
101
+ ).compile(Filter, table)
102
+
103
+ # JSONB containment: operator + bound payload (literal_binds unsupported for JSONB).
104
+ contains = rules["meta"][0]
105
+ assert getattr(contains.operator, "opstring", None) == "@>" # type: ignore[attr-defined]
106
+ assert contains.right.value == {"active": True} # type: ignore[attr-defined]
107
+ assert "@>" in _sql(contains, literal_binds=False)
108
+
109
+ assert _sql(rules["meta"][1]) == "rows.meta ? 'active'"
110
+ assert _sql(rules["tags"][0]) == "rows.tags @> ARRAY['admin']"
111
+ assert _sql(rules["tags"][1]) == "rows.tags && ARRAY['x']"
112
+ assert _sql(rules["span"][0]) == "rows.span @> 5"
113
+ overlap = rules["span"][1]
114
+ assert getattr(overlap.operator, "opstring", None) == "&&" # type: ignore[attr-defined]
115
+ assert overlap.right.value == [1, 10] # type: ignore[attr-defined]
116
+
117
+
118
+ def test_empty_json_contains_uses_containment() -> None:
119
+ class Filter(BaseModel):
120
+ meta: Annotated[dict[str, Any], JsonContains({})]
121
+
122
+ table = Table("rows", MetaData(), Column("meta", JSONB))
123
+ rules = Compiler(
124
+ plugins=[PostgresPlugin()],
125
+ dialect="postgresql",
126
+ cache=False,
127
+ ).compile(Filter, table)
128
+ expr = rules["meta"][0]
129
+ assert getattr(expr.operator, "opstring", None) == "@>" # type: ignore[attr-defined]
130
+ assert expr.right.value == {} # type: ignore[attr-defined]
131
+ assert "@>" in _sql(expr, literal_binds=False)
132
+
133
+
134
+ def test_type_check_int_and_str() -> None:
135
+ from sqlalchemy import Integer
136
+
137
+ class Filter(BaseModel):
138
+ age: int
139
+ name: str
140
+
141
+ table = Table(
142
+ "users",
143
+ MetaData(),
144
+ Column("age", Integer),
145
+ Column("name", String),
146
+ )
147
+ rules = Compiler(
148
+ plugins=[PostgresPlugin()],
149
+ dialect="postgresql",
150
+ emit_type_checks=True,
151
+ cache=False,
152
+ ).compile(Filter, table)
153
+ assert _sql(rules["age"][0], literal_binds=False).upper().count("IS NOT NULL") == 1
154
+ assert "users.age" in _sql(rules["age"][0], literal_binds=False)
155
+ assert "users.name" in _sql(rules["name"][0], literal_binds=False)
156
+ assert "IS NOT NULL" in _sql(rules["name"][0], literal_binds=False).upper()
157
+
158
+
159
+ def test_type_check_lax_int_on_string() -> None:
160
+ class Filter(BaseModel):
161
+ age: int
162
+
163
+ table = Table("rows", MetaData(), Column("age", String))
164
+ rules = Compiler(
165
+ plugins=[PostgresPlugin()],
166
+ dialect="postgresql",
167
+ emit_type_checks=True,
168
+ cache=False,
169
+ ).compile(Filter, table)
170
+ compiled = _sql(rules["age"][0])
171
+ assert "rows.age" in compiled
172
+ assert "~" in compiled
173
+ # Lax int-on-text uses a digit regex, not a bare IS NOT NULL.
174
+ assert "IS NOT NULL" not in compiled.upper() or "~" in compiled
175
+
176
+
177
+ def test_type_check_strict_bool() -> None:
178
+ from pydantic import ConfigDict
179
+ from sqlalchemy import Boolean
180
+
181
+ class Filter(BaseModel):
182
+ model_config = ConfigDict(strict=True)
183
+ active: bool
184
+
185
+ table = Table("rows", MetaData(), Column("active", Boolean))
186
+ rules = Compiler(
187
+ plugins=[PostgresPlugin()],
188
+ dialect="postgresql",
189
+ emit_type_checks=True,
190
+ cache=False,
191
+ ).compile(Filter, table)
192
+ compiled = _sql(rules["active"][0])
193
+ assert "rows.active" in compiled
194
+ assert "IN" in compiled.upper()
195
+ assert "true" in compiled.lower() or "True" in compiled
196
+
197
+
198
+ def test_type_check_optional_or_null() -> None:
199
+ from sqlalchemy import Integer
200
+
201
+ class Filter(BaseModel):
202
+ age: int | None = None
203
+
204
+ table = Table("rows", MetaData(), Column("age", Integer))
205
+ rules = Compiler(
206
+ plugins=[PostgresPlugin()],
207
+ dialect="postgresql",
208
+ emit_type_checks=True,
209
+ cache=False,
210
+ ).compile(Filter, table)
211
+ compiled = _sql(rules["age"][0], literal_binds=False).upper()
212
+ assert "IS NULL" in compiled
213
+ assert "IS NOT NULL" in compiled