sqlrules-postgresql 1.0.1__tar.gz → 2.0.0__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,83 @@
1
+ Metadata-Version: 2.5
2
+ Name: sqlrules-postgresql
3
+ Version: 2.0.0
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<3,>=2
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 backend provider for [SQLRules](https://github.com/eddiethedean/sqlrules).
26
+ The package version follows the core 2.x line.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install "sqlrules>=2,<3" "sqlrules-postgresql>=2,<3"
32
+ ```
33
+
34
+ ## Use
35
+
36
+ ```python
37
+ from typing import Annotated, Any
38
+
39
+ from pydantic import Field
40
+ from sqlalchemy import Column, MetaData, String, Table
41
+ from sqlalchemy.dialects.postgresql import ARRAY, INT4RANGE, JSONB
42
+
43
+ from sqlrules import ArrayContains, Compiler, JsonContains, RangeContains, RuleSchema, where
44
+ from sqlrules_postgresql import PostgresPlugin
45
+
46
+ rows = Table(
47
+ "rows",
48
+ MetaData(),
49
+ Column("name", String),
50
+ Column("meta", JSONB),
51
+ Column("tags", ARRAY(String)),
52
+ Column("span", INT4RANGE),
53
+ )
54
+
55
+
56
+ class RowRules(RuleSchema):
57
+ name: Annotated[str, Field(pattern=r"^A")]
58
+ meta: Annotated[dict[str, Any], JsonContains({"active": True})]
59
+ tags: Annotated[list[str], ArrayContains(["admin"])]
60
+ span: Annotated[int, RangeContains(5)]
61
+
62
+
63
+ compiled = Compiler(plugins=[PostgresPlugin(server_version=(16, 0))]).compile(RowRules, rows)
64
+ statement = rows.select().where(*where(compiled))
65
+ ```
66
+
67
+ ## Capabilities
68
+
69
+ - `pattern`: PostgreSQL `~` / `~*`
70
+ - JSONB containment and key membership
71
+ - ARRAY containment and overlap
72
+ - Range containment and overlap
73
+ - Safe lax text-to-int/float/Decimal parsing on PostgreSQL 16+
74
+
75
+ String Literal and Enum fields require a column with `C` or `POSIX`
76
+ collation. Review the [type support matrix](https://sqlrules.readthedocs.io/en/latest/TYPE_SUPPORT.html)
77
+ for the exact conversion profile and limitations.
78
+
79
+ ## Pattern cost
80
+
81
+ Untrusted regular expressions can cause expensive engine-side evaluation.
82
+ Prefer patterns authored with the rule model. See the SQLRules
83
+ [security notes](https://sqlrules.readthedocs.io/en/latest/SECURITY.html).
@@ -0,0 +1,61 @@
1
+ # sqlrules-postgresql
2
+
3
+ PostgreSQL backend provider for [SQLRules](https://github.com/eddiethedean/sqlrules).
4
+ The package version follows the core 2.x line.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install "sqlrules>=2,<3" "sqlrules-postgresql>=2,<3"
10
+ ```
11
+
12
+ ## Use
13
+
14
+ ```python
15
+ from typing import Annotated, Any
16
+
17
+ from pydantic import Field
18
+ from sqlalchemy import Column, MetaData, String, Table
19
+ from sqlalchemy.dialects.postgresql import ARRAY, INT4RANGE, JSONB
20
+
21
+ from sqlrules import ArrayContains, Compiler, JsonContains, RangeContains, RuleSchema, where
22
+ from sqlrules_postgresql import PostgresPlugin
23
+
24
+ rows = Table(
25
+ "rows",
26
+ MetaData(),
27
+ Column("name", String),
28
+ Column("meta", JSONB),
29
+ Column("tags", ARRAY(String)),
30
+ Column("span", INT4RANGE),
31
+ )
32
+
33
+
34
+ class RowRules(RuleSchema):
35
+ name: Annotated[str, Field(pattern=r"^A")]
36
+ meta: Annotated[dict[str, Any], JsonContains({"active": True})]
37
+ tags: Annotated[list[str], ArrayContains(["admin"])]
38
+ span: Annotated[int, RangeContains(5)]
39
+
40
+
41
+ compiled = Compiler(plugins=[PostgresPlugin(server_version=(16, 0))]).compile(RowRules, rows)
42
+ statement = rows.select().where(*where(compiled))
43
+ ```
44
+
45
+ ## Capabilities
46
+
47
+ - `pattern`: PostgreSQL `~` / `~*`
48
+ - JSONB containment and key membership
49
+ - ARRAY containment and overlap
50
+ - Range containment and overlap
51
+ - Safe lax text-to-int/float/Decimal parsing on PostgreSQL 16+
52
+
53
+ String Literal and Enum fields require a column with `C` or `POSIX`
54
+ collation. Review the [type support matrix](https://sqlrules.readthedocs.io/en/latest/TYPE_SUPPORT.html)
55
+ for the exact conversion profile and limitations.
56
+
57
+ ## Pattern cost
58
+
59
+ Untrusted regular expressions can cause expensive engine-side evaluation.
60
+ Prefer patterns authored with the rule model. See the SQLRules
61
+ [security notes](https://sqlrules.readthedocs.io/en/latest/SECURITY.html).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sqlrules-postgresql"
7
- version = "1.0.1"
7
+ version = "2.0.0"
8
8
  description = "PostgreSQL dialect plugin for SQLRules (regex, JSONB, ARRAY, range)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -18,7 +18,7 @@ classifiers = [
18
18
  "Typing :: Typed",
19
19
  ]
20
20
  dependencies = [
21
- "sqlrules>=1,<2",
21
+ "sqlrules>=2,<3",
22
22
  "sqlalchemy>=2.0,<3",
23
23
  ]
24
24
 
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ from sqlalchemy.dialects.postgresql import JSONB
7
+
8
+ from sqlrules.backend import prepare_scalar
9
+ from sqlrules.errors import CapabilityError
10
+ from sqlrules.ir import CompilationContext, PreparedValue, RuleField
11
+ from sqlrules.plugins import PLUGIN_API_VERSION
12
+ from sqlrules.translators import TranslatorRegistry
13
+ from sqlrules_postgresql.array import translate_array_contains, translate_array_overlap
14
+ from sqlrules_postgresql.jsonb import translate_json_contains, translate_json_has_key
15
+ from sqlrules_postgresql.pattern import translate_pattern
16
+ from sqlrules_postgresql.range import translate_range_contains, translate_range_overlap
17
+
18
+ __version__ = "2.0.0"
19
+
20
+
21
+ class PostgresPlugin:
22
+ """Register PostgreSQL-specific constraint translators."""
23
+
24
+ name = "postgresql"
25
+ api_version = PLUGIN_API_VERSION
26
+
27
+ def __init__(self, *, server_version: tuple[int, ...] | None = None) -> None:
28
+ self.server_version = tuple(server_version) if server_version is not None else None
29
+
30
+ def capabilities(self) -> Mapping[str, Any]:
31
+ return {
32
+ "backend": self.name,
33
+ "server_version": self.server_version,
34
+ "native_scalar_types": (
35
+ "bool",
36
+ "int",
37
+ "float",
38
+ "decimal",
39
+ "str",
40
+ "date",
41
+ "datetime",
42
+ "time",
43
+ "uuid",
44
+ ),
45
+ "safe_text_numeric_conversion": self.server_version is not None
46
+ and self.server_version >= (16, 0),
47
+ "safe_text_numeric_targets": (
48
+ ("int", "float", "decimal")
49
+ if self.server_version is not None and self.server_version >= (16, 0)
50
+ else ()
51
+ ),
52
+ "assumptions": ("PostgreSQL native column types provide logical type evidence.",),
53
+ }
54
+
55
+ def prepare_value(
56
+ self,
57
+ column: Any,
58
+ field: RuleField,
59
+ context: CompilationContext,
60
+ ) -> PreparedValue:
61
+ if (
62
+ field.python_type is dict
63
+ and any(
64
+ item.operator in {"json_contains", "json_has_key"} for item in field.constraints
65
+ )
66
+ and not isinstance(column.type, JSONB)
67
+ ):
68
+ raise CapabilityError(
69
+ self.name,
70
+ field.name,
71
+ "jsonb",
72
+ type(column.type).__name__,
73
+ "PostgreSQL JSON markers require a JSONB column; generic JSON does not provide "
74
+ "the containment and key operators used by SQLRules.",
75
+ )
76
+ return prepare_scalar(column, field, context, backend=self.name)
77
+
78
+ def register(self, registry: TranslatorRegistry) -> None:
79
+ registry.register_constraint(
80
+ "pattern",
81
+ translate_pattern,
82
+ on_conflict="replace",
83
+ )
84
+ for operator, translator in (
85
+ ("json_contains", translate_json_contains),
86
+ ("json_has_key", translate_json_has_key),
87
+ ("array_contains", translate_array_contains),
88
+ ("array_overlap", translate_array_overlap),
89
+ ("range_contains", translate_range_contains),
90
+ ("range_overlap", translate_range_overlap),
91
+ ):
92
+ registry.register_constraint(operator, translator, on_conflict="replace")
93
+
94
+
95
+ __all__ = [
96
+ "PostgresPlugin",
97
+ "__version__",
98
+ "translate_array_contains",
99
+ "translate_array_overlap",
100
+ "translate_json_contains",
101
+ "translate_json_has_key",
102
+ "translate_pattern",
103
+ "translate_range_contains",
104
+ "translate_range_overlap",
105
+ ]
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any, cast
4
4
 
5
+ from sqlalchemy import func
5
6
  from sqlalchemy.sql.elements import ColumnElement
6
7
 
7
8
  from sqlrules.ir import CompilationContext, Constraint
@@ -22,4 +23,7 @@ def translate_json_has_key(
22
23
  context: CompilationContext,
23
24
  ) -> ColumnElement[bool]:
24
25
  """Translate ``json_has_key`` to JSONB ``?`` / ``has_key``."""
25
- return cast(ColumnElement[bool], column.has_key(constraint.value))
26
+ return cast(
27
+ ColumnElement[bool],
28
+ (func.jsonb_typeof(column) == "object") & column.has_key(constraint.value),
29
+ )
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Annotated, Any
5
+
6
+ from pydantic import Field
7
+ from sqlalchemy import Column, MetaData, String, Table
8
+ from sqlalchemy.dialects.postgresql import ARRAY, INT4RANGE, JSONB, dialect
9
+ from sqlrules_postgresql import PostgresPlugin, __version__
10
+
11
+ from sqlrules import ArrayContains, Compiler, JsonContains, RangeContains, RuleSchema
12
+ from sqlrules.conformance import run_basic_conformance
13
+
14
+
15
+ def test_version_and_plugin_conformance() -> None:
16
+ assert __version__ == "2.0.0"
17
+ run_basic_conformance(PostgresPlugin(server_version=(16, 0)), operator="pattern")
18
+
19
+
20
+ def test_pattern_json_array_and_range_compile_with_v2_provider() -> None:
21
+ class Rules(RuleSchema):
22
+ name: Annotated[str, Field(pattern=re.compile(r"^a", re.I))]
23
+ meta: Annotated[dict[str, Any], JsonContains({"active": True})]
24
+ tags: Annotated[list[str], ArrayContains(["admin"])]
25
+ span: Annotated[int, RangeContains(5)]
26
+
27
+ table = Table(
28
+ "rows",
29
+ MetaData(),
30
+ Column("name", String),
31
+ Column("meta", JSONB),
32
+ Column("tags", ARRAY(String)),
33
+ Column("span", INT4RANGE),
34
+ )
35
+ compiled = Compiler(plugins=[PostgresPlugin(server_version=(16, 0))]).compile(Rules, table)
36
+ assert len(compiled.fields) == 4
37
+ assert compiled.fields[-1].logical_type == "range"
38
+ sql = str(compiled.predicate.compile(dialect=dialect()))
39
+ assert "~*" in sql
40
+ assert "@>" in sql
41
+ assert "@>" in sql
42
+
43
+
44
+ def test_string_literal_requires_explicit_deterministic_collation() -> None:
45
+ from typing import Literal
46
+
47
+ from sqlrules import CapabilityError
48
+
49
+ class Rules(RuleSchema):
50
+ status: Literal["ready", "pending"]
51
+
52
+ table = Table("rows", MetaData(), Column("status", String))
53
+ try:
54
+ Compiler(plugins=[PostgresPlugin()]).compile(Rules, table)
55
+ except CapabilityError as exc:
56
+ assert "C or POSIX" in str(exc)
57
+ else: # pragma: no cover - assertion is the capability contract
58
+ raise AssertionError("string literal compilation needs explicit collation")
@@ -1,98 +0,0 @@
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).
@@ -1,76 +0,0 @@
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).
@@ -1,53 +0,0 @@
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
- ]
@@ -1,250 +0,0 @@
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)
@@ -1,213 +0,0 @@
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