sqlrules-mysql 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-mysql
3
+ Version: 2.0.0
4
+ Summary: MySQL dialect plugin for SQLRules (REGEXP, JSON, full-text).
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: mysql,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-mysql
24
+
25
+ MySQL backend provider for [SQLRules](https://github.com/eddiethedean/sqlrules).
26
+ The package version follows the core 2.x line.
27
+
28
+ This provider targets MySQL 8.0+. MariaDB is outside the supported server
29
+ matrix because its regular-expression function API differs from MySQL's.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install "sqlrules>=2,<3" "sqlrules-mysql>=2,<3"
35
+ ```
36
+
37
+ ## Use
38
+
39
+ ```python
40
+ from typing import Annotated, Any
41
+
42
+ from pydantic import Field
43
+ from sqlalchemy import Column, JSON, MetaData, String, Table
44
+
45
+ from sqlrules import Compiler, FullTextMatch, JsonContains, RuleSchema, where
46
+ from sqlrules_mysql import MysqlPlugin
47
+
48
+ rows = Table(
49
+ "rows",
50
+ MetaData(),
51
+ Column("name", String),
52
+ Column("meta", JSON),
53
+ Column("body", String),
54
+ )
55
+
56
+
57
+ class RowRules(RuleSchema):
58
+ name: Annotated[str, Field(pattern=r"^A")]
59
+ meta: Annotated[dict[str, Any], JsonContains({"active": True})]
60
+ body: Annotated[str, FullTextMatch("sqlrules")]
61
+
62
+
63
+ provider = MysqlPlugin(server_version=(8, 0, 36))
64
+ compiled = Compiler(plugins=[provider]).compile(RowRules, rows)
65
+ statement = rows.select().where(*where(compiled))
66
+ ```
67
+
68
+ ## Capabilities
69
+
70
+ - `pattern`: MySQL `REGEXP`
71
+ - JSON containment and key lookup
72
+ - Full-text matching (requires a matching FULLTEXT index)
73
+ - Safe lax text-to-int conversion on MySQL 8.0+
74
+
75
+ Text-to-float and text-to-Decimal are compile-time capability errors. String
76
+ Literal and Enum fields need an explicit binary or case-sensitive collation.
77
+ See the [type support matrix](https://sqlrules.readthedocs.io/en/latest/TYPE_SUPPORT.html).
78
+
79
+ ## Pattern cost
80
+
81
+ Untrusted regular expressions and full-text queries can be expensive. Prefer
82
+ static or allowlisted values. See the SQLRules
83
+ [security notes](https://sqlrules.readthedocs.io/en/latest/SECURITY.html).
@@ -0,0 +1,61 @@
1
+ # sqlrules-mysql
2
+
3
+ MySQL backend provider for [SQLRules](https://github.com/eddiethedean/sqlrules).
4
+ The package version follows the core 2.x line.
5
+
6
+ This provider targets MySQL 8.0+. MariaDB is outside the supported server
7
+ matrix because its regular-expression function API differs from MySQL's.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install "sqlrules>=2,<3" "sqlrules-mysql>=2,<3"
13
+ ```
14
+
15
+ ## Use
16
+
17
+ ```python
18
+ from typing import Annotated, Any
19
+
20
+ from pydantic import Field
21
+ from sqlalchemy import Column, JSON, MetaData, String, Table
22
+
23
+ from sqlrules import Compiler, FullTextMatch, JsonContains, RuleSchema, where
24
+ from sqlrules_mysql import MysqlPlugin
25
+
26
+ rows = Table(
27
+ "rows",
28
+ MetaData(),
29
+ Column("name", String),
30
+ Column("meta", JSON),
31
+ Column("body", String),
32
+ )
33
+
34
+
35
+ class RowRules(RuleSchema):
36
+ name: Annotated[str, Field(pattern=r"^A")]
37
+ meta: Annotated[dict[str, Any], JsonContains({"active": True})]
38
+ body: Annotated[str, FullTextMatch("sqlrules")]
39
+
40
+
41
+ provider = MysqlPlugin(server_version=(8, 0, 36))
42
+ compiled = Compiler(plugins=[provider]).compile(RowRules, rows)
43
+ statement = rows.select().where(*where(compiled))
44
+ ```
45
+
46
+ ## Capabilities
47
+
48
+ - `pattern`: MySQL `REGEXP`
49
+ - JSON containment and key lookup
50
+ - Full-text matching (requires a matching FULLTEXT index)
51
+ - Safe lax text-to-int conversion on MySQL 8.0+
52
+
53
+ Text-to-float and text-to-Decimal are compile-time capability errors. String
54
+ Literal and Enum fields need an explicit binary or case-sensitive collation.
55
+ See the [type support matrix](https://sqlrules.readthedocs.io/en/latest/TYPE_SUPPORT.html).
56
+
57
+ ## Pattern cost
58
+
59
+ Untrusted regular expressions and full-text queries can be expensive. Prefer
60
+ static or allowlisted values. See the SQLRules
61
+ [security notes](https://sqlrules.readthedocs.io/en/latest/SECURITY.html).
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sqlrules-mysql"
7
- version = "1.0.1"
8
- description = "MySQL/MariaDB dialect plugin for SQLRules (REGEXP, JSON, full-text)."
7
+ version = "2.0.0"
8
+ description = "MySQL dialect plugin for SQLRules (REGEXP, JSON, full-text)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
12
12
  authors = [{ name = "SQLRules Contributors" }]
13
- keywords = ["sqlrules", "mysql", "mariadb", "pydantic", "sqlalchemy"]
13
+ keywords = ["sqlrules", "mysql", "pydantic", "sqlalchemy"]
14
14
  classifiers = [
15
15
  "Development Status :: 5 - Production/Stable",
16
16
  "License :: OSI Approved :: MIT License",
@@ -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,87 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ from sqlrules.backend import prepare_scalar
7
+ from sqlrules.ir import CompilationContext, PreparedValue, RuleField
8
+ from sqlrules.plugins import PLUGIN_API_VERSION
9
+ from sqlrules.translators import TranslatorRegistry
10
+ from sqlrules_mysql.fulltext import translate_fulltext_match
11
+ from sqlrules_mysql.json import translate_json_contains, translate_json_has_key
12
+ from sqlrules_mysql.length import translate_max_length, translate_min_length
13
+ from sqlrules_mysql.pattern import translate_pattern
14
+
15
+ __version__ = "2.0.0"
16
+
17
+
18
+ class MysqlPlugin:
19
+ """Register MySQL constraint translators."""
20
+
21
+ name = "mysql"
22
+ api_version = PLUGIN_API_VERSION
23
+
24
+ def __init__(self, *, server_version: tuple[int, ...] | None = None) -> None:
25
+ self.server_version = tuple(server_version) if server_version is not None else None
26
+
27
+ def capabilities(self) -> Mapping[str, Any]:
28
+ return {
29
+ "backend": self.name,
30
+ "server_version": self.server_version,
31
+ "native_scalar_types": (
32
+ "bool",
33
+ "int",
34
+ "float",
35
+ "decimal",
36
+ "str",
37
+ "date",
38
+ "datetime",
39
+ "time",
40
+ ),
41
+ "safe_text_numeric_conversion": self.server_version is not None
42
+ and self.server_version >= (8, 0),
43
+ "safe_text_numeric_targets": (
44
+ ("int",)
45
+ if self.server_version is not None and self.server_version >= (8, 0)
46
+ else ()
47
+ ),
48
+ "assumptions": (
49
+ "MySQL 8.0 REGEXP and native column types provide the advertised type evidence.",
50
+ "Lax integer/float/Decimal conversions accept the documented lexical profile.",
51
+ ),
52
+ }
53
+
54
+ def prepare_value(
55
+ self,
56
+ column: Any,
57
+ field: RuleField,
58
+ context: CompilationContext,
59
+ ) -> PreparedValue:
60
+ return prepare_scalar(column, field, context, backend=self.name)
61
+
62
+ def register(self, registry: TranslatorRegistry) -> None:
63
+ registry.register_constraint(
64
+ "pattern",
65
+ translate_pattern,
66
+ on_conflict="replace",
67
+ )
68
+ for operator, translator in (
69
+ ("min_length", translate_min_length),
70
+ ("max_length", translate_max_length),
71
+ ("json_contains", translate_json_contains),
72
+ ("json_has_key", translate_json_has_key),
73
+ ("fulltext_match", translate_fulltext_match),
74
+ ):
75
+ registry.register_constraint(operator, translator, on_conflict="replace")
76
+
77
+
78
+ __all__ = [
79
+ "MysqlPlugin",
80
+ "__version__",
81
+ "translate_fulltext_match",
82
+ "translate_max_length",
83
+ "translate_min_length",
84
+ "translate_json_contains",
85
+ "translate_json_has_key",
86
+ "translate_pattern",
87
+ ]
@@ -22,9 +22,7 @@ def translate_json_contains(
22
22
  context: CompilationContext,
23
23
  ) -> ColumnElement[bool]:
24
24
  """Translate ``json_contains`` to MySQL ``JSON_CONTAINS``."""
25
- payload = constraint.value
26
- if not isinstance(payload, str):
27
- payload = json.dumps(payload, separators=(",", ":"))
25
+ payload = json.dumps(constraint.value, separators=(",", ":"))
28
26
  return cast(ColumnElement[bool], func.json_contains(column, payload) == 1)
29
27
 
30
28
 
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+ from sqlalchemy import func
6
+ from sqlalchemy.sql.elements import ColumnElement
7
+
8
+ from sqlrules.ir import CompilationContext, Constraint
9
+
10
+
11
+ def translate_min_length(
12
+ constraint: Constraint,
13
+ column: ColumnElement[Any],
14
+ context: CompilationContext,
15
+ ) -> ColumnElement[bool]:
16
+ """Compare MySQL character count, not encoded byte length."""
17
+ return cast(ColumnElement[bool], func.char_length(column) >= constraint.value)
18
+
19
+
20
+ def translate_max_length(
21
+ constraint: Constraint,
22
+ column: ColumnElement[Any],
23
+ context: CompilationContext,
24
+ ) -> ColumnElement[bool]:
25
+ """Compare MySQL character count, not encoded byte length."""
26
+ return cast(ColumnElement[bool], func.char_length(column) <= constraint.value)
27
+
28
+
29
+ __all__ = ["translate_max_length", "translate_min_length"]
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+ from sqlalchemy import func
6
+ from sqlalchemy.sql.elements import ColumnElement
7
+
8
+ from sqlrules.constraints import pattern_text
9
+ from sqlrules.ir import CompilationContext, Constraint
10
+
11
+
12
+ def translate_pattern(
13
+ constraint: Constraint,
14
+ column: ColumnElement[Any],
15
+ context: CompilationContext,
16
+ ) -> ColumnElement[bool]:
17
+ """Translate ``pattern`` to MySQL ``REGEXP_LIKE``.
18
+
19
+ MySQL's default matching follows the expression collation. Use
20
+ ``REGEXP_LIKE``'s match type to keep SQLRules patterns case-sensitive by
21
+ default and preserve an explicit ``re.IGNORECASE`` flag.
22
+ """
23
+ pattern, ignore_case = pattern_text(constraint.value)
24
+ match_type = "i" if ignore_case else "c"
25
+ return cast(ColumnElement[bool], func.regexp_like(column, pattern, match_type))
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any
4
+
5
+ from pydantic import Field
6
+ from sqlalchemy import JSON, Column, MetaData, String, Table
7
+ from sqlalchemy.dialects.mysql import dialect
8
+ from sqlrules_mysql import MysqlPlugin, __version__
9
+
10
+ from sqlrules import Compiler, FullTextMatch, JsonContains, RuleSchema
11
+ from sqlrules.conformance import run_basic_conformance
12
+
13
+
14
+ def test_version_and_plugin_conformance() -> None:
15
+ assert __version__ == "2.0.0"
16
+ run_basic_conformance(MysqlPlugin(), operator="pattern")
17
+
18
+
19
+ def test_pattern_json_and_fulltext_constraints_compile() -> None:
20
+ class Rules(RuleSchema):
21
+ name: Annotated[str, Field(pattern=r"^A")]
22
+ meta: Annotated[dict[str, Any], JsonContains({"active": True})]
23
+ body: Annotated[str, FullTextMatch("sqlrules")]
24
+
25
+ table = Table(
26
+ "rows",
27
+ MetaData(),
28
+ Column("name", String),
29
+ Column("meta", JSON),
30
+ Column("body", String),
31
+ )
32
+ compiled = Compiler(plugins=[MysqlPlugin(server_version=(8, 0, 36))]).compile(Rules, table)
33
+ statement = compiled.predicate.compile(dialect=dialect())
34
+ sql = str(statement).lower()
35
+ assert "regexp_like" in sql
36
+ assert "c" in statement.params.values()
37
+ assert "json_contains" in sql
38
+ assert "match" in sql
39
+
40
+
41
+ def test_integer_text_profile_is_server_version_gated() -> None:
42
+ class Rules(RuleSchema):
43
+ count: int
44
+
45
+ table = Table("rows", MetaData(), Column("count", String))
46
+ from sqlrules import CapabilityError
47
+
48
+ try:
49
+ Compiler(plugins=[MysqlPlugin()]).compile(Rules, table)
50
+ except CapabilityError as exc:
51
+ assert "MySQL 8.0+" in str(exc)
52
+ else: # pragma: no cover - assertion is the capability contract
53
+ raise AssertionError("text-to-integer needs a configured MySQL version")
54
+
55
+ compiled = Compiler(plugins=[MysqlPlugin(server_version=(8, 0, 36))]).compile(Rules, table)
56
+ assert compiled.fields[0].coercion == "text-to-int"
@@ -1,66 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: sqlrules-mysql
3
- Version: 1.0.1
4
- Summary: MySQL/MariaDB dialect plugin for SQLRules (REGEXP, JSON, full-text).
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: mariadb,mysql,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-mysql
24
-
25
- MySQL / MariaDB dialect plugin for [SQLRules](https://github.com/eddiethedean/sqlrules).
26
-
27
- ## Install
28
-
29
- ```bash
30
- pip install sqlrules-mysql
31
- ```
32
-
33
- ## Usage
34
-
35
- ```python
36
- from typing import Annotated, Any
37
-
38
- from pydantic import BaseModel, Field
39
-
40
- from sqlrules import Compiler, FullTextMatch, JsonContains
41
- from sqlrules_mysql import MysqlPlugin
42
-
43
- class RowFilter(BaseModel):
44
- name: Annotated[str, Field(pattern=r"^A")]
45
- meta: Annotated[dict[str, Any], JsonContains({"active": True})]
46
- body: Annotated[str, FullTextMatch("sqlrules")]
47
-
48
- compiler = Compiler(plugins=[MysqlPlugin()], dialect="mysql")
49
- ```
50
-
51
- ## Operators
52
-
53
- | IR operator | Notes |
54
- |---|---|
55
- | `pattern` | `REGEXP` (case-insensitive under typical collations) |
56
- | `type_check` | Shape/type predicates from `TypeSpec` (partial matrix) |
57
- | `json_contains` | `JSON_CONTAINS(column, payload) = 1` |
58
- | `json_has_key` | `JSON_CONTAINS_PATH(column, 'one', '$.key') = 1` |
59
- | `fulltext_match` | `MATCH(column) AGAINST (value)` — requires a FULLTEXT index |
60
-
61
- ## Security note
62
-
63
- `pattern` / `fulltext_match` values are bound parameters, but evaluation cost
64
- is engine-dependent. Prefer static/allowlisted patterns and queries from
65
- untrusted input. See
66
- [SECURITY](https://sqlrules.readthedocs.io/en/latest/SECURITY.html).
@@ -1,44 +0,0 @@
1
- # sqlrules-mysql
2
-
3
- MySQL / MariaDB dialect plugin for [SQLRules](https://github.com/eddiethedean/sqlrules).
4
-
5
- ## Install
6
-
7
- ```bash
8
- pip install sqlrules-mysql
9
- ```
10
-
11
- ## Usage
12
-
13
- ```python
14
- from typing import Annotated, Any
15
-
16
- from pydantic import BaseModel, Field
17
-
18
- from sqlrules import Compiler, FullTextMatch, JsonContains
19
- from sqlrules_mysql import MysqlPlugin
20
-
21
- class RowFilter(BaseModel):
22
- name: Annotated[str, Field(pattern=r"^A")]
23
- meta: Annotated[dict[str, Any], JsonContains({"active": True})]
24
- body: Annotated[str, FullTextMatch("sqlrules")]
25
-
26
- compiler = Compiler(plugins=[MysqlPlugin()], dialect="mysql")
27
- ```
28
-
29
- ## Operators
30
-
31
- | IR operator | Notes |
32
- |---|---|
33
- | `pattern` | `REGEXP` (case-insensitive under typical collations) |
34
- | `type_check` | Shape/type predicates from `TypeSpec` (partial matrix) |
35
- | `json_contains` | `JSON_CONTAINS(column, payload) = 1` |
36
- | `json_has_key` | `JSON_CONTAINS_PATH(column, 'one', '$.key') = 1` |
37
- | `fulltext_match` | `MATCH(column) AGAINST (value)` — requires a FULLTEXT index |
38
-
39
- ## Security note
40
-
41
- `pattern` / `fulltext_match` values are bound parameters, but evaluation cost
42
- is engine-dependent. Prefer static/allowlisted patterns and queries from
43
- untrusted input. See
44
- [SECURITY](https://sqlrules.readthedocs.io/en/latest/SECURITY.html).
@@ -1,46 +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_mysql.fulltext import translate_fulltext_match
6
- from sqlrules_mysql.json import translate_json_contains, translate_json_has_key
7
- from sqlrules_mysql.pattern import translate_pattern
8
- from sqlrules_mysql.type_check import translate_type_check
9
-
10
- __version__ = "1.0.1"
11
-
12
-
13
- class MysqlPlugin:
14
- """Register MySQL / MariaDB constraint translators."""
15
-
16
- name = "mysql"
17
- api_version = PLUGIN_API_VERSION
18
-
19
- def register(self, registry: TranslatorRegistry) -> None:
20
- registry.register_constraint(
21
- "pattern",
22
- translate_pattern,
23
- on_conflict="replace",
24
- )
25
- registry.register_constraint(
26
- "type_check",
27
- translate_type_check,
28
- on_conflict="replace",
29
- )
30
- for operator, translator in (
31
- ("json_contains", translate_json_contains),
32
- ("json_has_key", translate_json_has_key),
33
- ("fulltext_match", translate_fulltext_match),
34
- ):
35
- registry.register_constraint(operator, translator, on_conflict="replace")
36
-
37
-
38
- __all__ = [
39
- "MysqlPlugin",
40
- "__version__",
41
- "translate_fulltext_match",
42
- "translate_json_contains",
43
- "translate_json_has_key",
44
- "translate_pattern",
45
- "translate_type_check",
46
- ]
@@ -1,22 +0,0 @@
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 MySQL/MariaDB ``REGEXP``.
17
-
18
- MySQL ``REGEXP`` is case-insensitive for non-binary collations. SQLRules
19
- follows that dialect behavior rather than inventing ``REGEXP BINARY``.
20
- """
21
- pattern, _ignore_case = pattern_text(constraint.value)
22
- return cast(ColumnElement[bool], column.op("REGEXP")(pattern))
@@ -1,238 +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
- _INT_TEXT = r"^[+-]?(0|[1-9][0-9]*)$"
18
- _FLOAT_TEXT = r"^[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?$"
19
- _DATE_TEXT = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
20
- _DATETIME_TEXT = (
21
- r"^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}"
22
- r"(\.[0-9]+)?([+-][0-9]{2}:?[0-9]{2}|Z)?$"
23
- )
24
- _TIME_TEXT = r"^[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?$"
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 _is_type(column: ColumnElement[Any], *bases: type[TypeEngine[Any]]) -> bool:
32
- col_type = column.type
33
- if isinstance(col_type, NullType):
34
- return False
35
- return isinstance(col_type, bases)
36
-
37
-
38
- def _regexp(column: ColumnElement[Any], pattern: str) -> ColumnElement[bool]:
39
- return cast(ColumnElement[bool], column.op("REGEXP")(pattern))
40
-
41
-
42
- def _wrap_none(
43
- column: ColumnElement[Any],
44
- predicate: ColumnElement[bool],
45
- *,
46
- allow_none: bool,
47
- ) -> ColumnElement[bool]:
48
- if allow_none:
49
- return cast(ColumnElement[bool], column.is_(None) | predicate)
50
- return predicate
51
-
52
-
53
- def _unsupported(field: str, spec: TypeSpec, suggestion: str) -> None:
54
- raise UnsupportedConstraintError(
55
- field=field,
56
- operator="type_check",
57
- value=spec,
58
- suggestion=suggestion,
59
- )
60
-
61
-
62
- def _predicate_int(
63
- column: ColumnElement[Any],
64
- spec: TypeSpec,
65
- field: str,
66
- ) -> ColumnElement[bool]:
67
- if _is_type(column, Integer):
68
- return cast(ColumnElement[bool], column.isnot(None))
69
- if spec.strict:
70
- _unsupported(
71
- field,
72
- spec,
73
- "MySQL strict int type_check requires an Integer column.",
74
- )
75
- if _is_type(column, String):
76
- return _regexp(column, _INT_TEXT)
77
- if _is_type(column, Float, Numeric):
78
- return cast(ColumnElement[bool], column == column.cast(Integer).cast(Float))
79
- _unsupported(
80
- field,
81
- spec,
82
- "MySQL lax int type_check supports Integer, String, Float, or Numeric columns.",
83
- )
84
- raise AssertionError("unreachable")
85
-
86
-
87
- def _predicate_bool(
88
- column: ColumnElement[Any],
89
- spec: TypeSpec,
90
- field: str,
91
- ) -> ColumnElement[bool]:
92
- if spec.strict:
93
- if _is_type(column, Boolean, Integer):
94
- return cast(ColumnElement[bool], column.in_((True, False, 0, 1)))
95
- _unsupported(
96
- field,
97
- spec,
98
- "MySQL strict bool type_check requires a Boolean/Integer column.",
99
- )
100
- _unsupported(
101
- field,
102
- spec,
103
- "MySQL lax bool type_check is not supported. Use strict=True.",
104
- )
105
- raise AssertionError("unreachable")
106
-
107
-
108
- def _predicate_str(
109
- column: ColumnElement[Any],
110
- spec: TypeSpec,
111
- field: str,
112
- ) -> ColumnElement[bool]:
113
- if _is_type(column, String):
114
- return cast(ColumnElement[bool], column.isnot(None))
115
- _unsupported(
116
- field,
117
- spec,
118
- "MySQL str type_check requires a String/Text column.",
119
- )
120
- raise AssertionError("unreachable")
121
-
122
-
123
- def _predicate_float(
124
- column: ColumnElement[Any],
125
- spec: TypeSpec,
126
- field: str,
127
- ) -> ColumnElement[bool]:
128
- if _is_type(column, Float, Numeric, Integer):
129
- return cast(ColumnElement[bool], column.isnot(None))
130
- if spec.strict:
131
- _unsupported(
132
- field,
133
- spec,
134
- "MySQL strict float/Decimal type_check requires a numeric column.",
135
- )
136
- if _is_type(column, String):
137
- return _regexp(column, _FLOAT_TEXT)
138
- _unsupported(
139
- field,
140
- spec,
141
- "MySQL float/Decimal type_check supports numeric or String columns.",
142
- )
143
- raise AssertionError("unreachable")
144
-
145
-
146
- def _predicate_temporal(
147
- column: ColumnElement[Any],
148
- spec: TypeSpec,
149
- field: str,
150
- *,
151
- sa_types: tuple[type[TypeEngine[Any]], ...],
152
- text_pattern: str,
153
- ) -> ColumnElement[bool]:
154
- if _is_type(column, *sa_types):
155
- return cast(ColumnElement[bool], column.isnot(None))
156
- if spec.strict:
157
- type_name = spec.python_type.__name__
158
- _unsupported(
159
- field,
160
- spec,
161
- f"MySQL strict {type_name} type_check requires a typed {type_name} column.",
162
- )
163
- if _is_type(column, String):
164
- return _regexp(column, text_pattern)
165
- type_name = spec.python_type.__name__
166
- _unsupported(
167
- field,
168
- spec,
169
- f"MySQL {type_name} type_check supports typed {type_name} or String columns.",
170
- )
171
- raise AssertionError("unreachable")
172
-
173
-
174
- def _predicate_uuid(
175
- column: ColumnElement[Any],
176
- spec: TypeSpec,
177
- field: str,
178
- ) -> ColumnElement[bool]:
179
- if "uuid" in type(column.type).__name__.lower():
180
- return cast(ColumnElement[bool], column.isnot(None))
181
- if spec.strict:
182
- _unsupported(
183
- field,
184
- spec,
185
- "MySQL strict UUID type_check requires a UUID column.",
186
- )
187
- if _is_type(column, String):
188
- return _regexp(column, _UUID_TEXT)
189
- _unsupported(
190
- field,
191
- spec,
192
- "MySQL UUID type_check supports UUID or String columns.",
193
- )
194
- raise AssertionError("unreachable")
195
-
196
-
197
- def _build_predicate(
198
- column: ColumnElement[Any],
199
- spec: TypeSpec,
200
- field: str,
201
- ) -> ColumnElement[bool]:
202
- python_type = spec.python_type
203
- if python_type is int:
204
- return _predicate_int(column, spec, field)
205
- if python_type is bool:
206
- return _predicate_bool(column, spec, field)
207
- if python_type is str:
208
- return _predicate_str(column, spec, field)
209
- if python_type in {float, Decimal}:
210
- return _predicate_float(column, spec, field)
211
- if python_type is date:
212
- return _predicate_temporal(column, spec, field, sa_types=(Date,), text_pattern=_DATE_TEXT)
213
- if python_type is datetime:
214
- return _predicate_temporal(
215
- column, spec, field, sa_types=(DateTime,), text_pattern=_DATETIME_TEXT
216
- )
217
- if python_type is time:
218
- return _predicate_temporal(column, spec, field, sa_types=(Time,), text_pattern=_TIME_TEXT)
219
- if python_type is UUID:
220
- return _predicate_uuid(column, spec, field)
221
- _unsupported(
222
- field,
223
- spec,
224
- f"MySQL type_check has no translator for {python_type!r}.",
225
- )
226
- raise AssertionError("unreachable")
227
-
228
-
229
- def translate_type_check(
230
- constraint: Constraint,
231
- column: ColumnElement[Any],
232
- context: CompilationContext,
233
- ) -> ColumnElement[bool]:
234
- """Translate ``type_check`` into MySQL shape/type predicates."""
235
- del context
236
- spec = type_spec(constraint.value)
237
- predicate = _build_predicate(column, spec, constraint.field)
238
- return _wrap_none(column, predicate, allow_none=spec.allow_none)
@@ -1,128 +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, Text
8
- from sqlalchemy.dialects import mysql
9
- from sqlrules_mysql import MysqlPlugin, __version__
10
-
11
- from sqlrules import Compiler, FullTextMatch, JsonContains, JsonHasKey
12
- from sqlrules.conformance import run_basic_conformance
13
-
14
-
15
- def _sql(expr: object, *, literal_binds: bool = True) -> str:
16
- return str(
17
- expr.compile( # type: ignore[union-attr]
18
- dialect=mysql.dialect(),
19
- compile_kwargs={"literal_binds": literal_binds} if literal_binds else {},
20
- )
21
- )
22
-
23
-
24
- def test_version() -> None:
25
- assert __version__ == "1.0.1"
26
-
27
-
28
- def test_conformance() -> None:
29
- run_basic_conformance(MysqlPlugin(), operator="pattern")
30
-
31
-
32
- def test_pattern_and_json_compile() -> None:
33
- """MySQL REGEXP is CI for non-binary collations; ignore_case is intentionally unused."""
34
-
35
- class Filter(BaseModel):
36
- name: Annotated[str, Field(pattern=r"^A")]
37
- meta: Annotated[dict[str, Any], JsonContains({"active": True}), JsonHasKey("active")]
38
- body: Annotated[str, FullTextMatch("hello")]
39
-
40
- table = Table(
41
- "items",
42
- MetaData(),
43
- Column("name", String),
44
- Column("meta", Text),
45
- Column("body", Text),
46
- )
47
- rules = Compiler(
48
- plugins=[MysqlPlugin()],
49
- dialect="mysql",
50
- cache=False,
51
- ).compile(Filter, table)
52
-
53
- assert _sql(rules["name"][0]) == "items.name REGEXP '^A'"
54
-
55
- contains_sql = _sql(rules["meta"][0]).lower()
56
- assert "json_contains(items.meta" in contains_sql
57
- assert '{"active":true}' in contains_sql.replace(" ", "")
58
- assert "json_contains_path" not in contains_sql
59
-
60
- has_key_sql = _sql(rules["meta"][1]).lower()
61
- assert "json_contains_path(items.meta" in has_key_sql
62
- assert '$."active"' in has_key_sql or "$" in has_key_sql
63
-
64
- body_sql = _sql(rules["body"][0]).lower()
65
- assert "match" in body_sql and "against" in body_sql
66
- assert "hello" in body_sql
67
-
68
-
69
- def test_pattern_ignore_case_does_not_invent_binary() -> None:
70
- """Case-insensitive PatternSpec must not invent REGEXP BINARY (collation owns CI)."""
71
-
72
- class Filter(BaseModel):
73
- name: Annotated[str, Field(pattern=re.compile(r"^A", re.I))]
74
-
75
- table = Table("items", MetaData(), Column("name", String))
76
- rules = Compiler(
77
- plugins=[MysqlPlugin()],
78
- dialect="mysql",
79
- cache=False,
80
- ).compile(Filter, table)
81
- compiled = _sql(rules["name"][0])
82
- assert compiled == "items.name REGEXP '^A'"
83
- assert "BINARY" not in compiled.upper()
84
- assert "(?i)" not in compiled
85
-
86
-
87
- def test_type_check_int_and_lax_string() -> None:
88
- from sqlalchemy import Integer
89
-
90
- class Typed(BaseModel):
91
- age: int
92
-
93
- class Textual(BaseModel):
94
- age: int
95
-
96
- typed_table = Table("users", MetaData(), Column("age", Integer))
97
- text_table = Table("rows", MetaData(), Column("age", String))
98
- compiler = Compiler(
99
- plugins=[MysqlPlugin()],
100
- dialect="mysql",
101
- emit_type_checks=True,
102
- cache=False,
103
- )
104
- typed_sql = _sql(compiler.compile(Typed, typed_table)["age"][0], literal_binds=False)
105
- text_sql = _sql(compiler.compile(Textual, text_table)["age"][0])
106
- assert "users.age" in typed_sql or "`users`.age" in typed_sql
107
- assert "IS NOT NULL" in typed_sql.upper()
108
- assert "age REGEXP" in text_sql.replace("`", "")
109
- assert "REGEXP" in text_sql
110
- assert "^[+-]?(0|[1-9][0-9]*)$" in text_sql
111
-
112
-
113
- def test_type_check_optional_allow_none() -> None:
114
- from sqlalchemy import Integer
115
-
116
- class Filter(BaseModel):
117
- age: int | None = None
118
-
119
- table = Table("users", MetaData(), Column("age", Integer))
120
- rules = Compiler(
121
- plugins=[MysqlPlugin()],
122
- dialect="mysql",
123
- emit_type_checks=True,
124
- cache=False,
125
- ).compile(Filter, table)
126
- compiled = _sql(rules["age"][0], literal_binds=False).upper()
127
- assert "IS NULL" in compiled
128
- assert "IS NOT NULL" in compiled
File without changes