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.
- sqlrules_postgresql-1.0.1/.gitignore +22 -0
- sqlrules_postgresql-1.0.1/LICENSE +21 -0
- sqlrules_postgresql-1.0.1/PKG-INFO +98 -0
- sqlrules_postgresql-1.0.1/README.md +76 -0
- sqlrules_postgresql-1.0.1/pyproject.toml +48 -0
- sqlrules_postgresql-1.0.1/src/sqlrules_postgresql/__init__.py +53 -0
- sqlrules_postgresql-1.0.1/src/sqlrules_postgresql/array.py +25 -0
- sqlrules_postgresql-1.0.1/src/sqlrules_postgresql/jsonb.py +25 -0
- sqlrules_postgresql-1.0.1/src/sqlrules_postgresql/pattern.py +19 -0
- sqlrules_postgresql-1.0.1/src/sqlrules_postgresql/py.typed +0 -0
- sqlrules_postgresql-1.0.1/src/sqlrules_postgresql/range.py +25 -0
- sqlrules_postgresql-1.0.1/src/sqlrules_postgresql/type_check.py +250 -0
- sqlrules_postgresql-1.0.1/tests/test_postgres_plugin.py +213 -0
|
@@ -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))
|
|
File without changes
|
|
@@ -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
|