pyrsql 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyrsql/__init__.py +63 -0
- pyrsql/adapters/__init__.py +3 -0
- pyrsql/adapters/fastapi/__init__.py +15 -0
- pyrsql/adapters/fastapi/config.py +76 -0
- pyrsql/adapters/fastapi/criteria.py +56 -0
- pyrsql/adapters/fastapi/dependency.py +217 -0
- pyrsql/adapters/fastapi/errors.py +107 -0
- pyrsql/api.py +76 -0
- pyrsql/core/__init__.py +45 -0
- pyrsql/core/compiler.py +81 -0
- pyrsql/core/conversion.py +206 -0
- pyrsql/core/custom.py +14 -0
- pyrsql/core/field_policy.py +97 -0
- pyrsql/core/joins.py +11 -0
- pyrsql/core/json/__init__.py +19 -0
- pyrsql/core/json/options.py +31 -0
- pyrsql/core/json/path.py +35 -0
- pyrsql/core/json/query.py +39 -0
- pyrsql/core/json/values.py +72 -0
- pyrsql/core/options.py +416 -0
- pyrsql/core/page.py +115 -0
- pyrsql/core/procedure_policy.py +61 -0
- pyrsql/core/query.py +160 -0
- pyrsql/core/sort.py +153 -0
- pyrsql/integrations/__init__.py +8 -0
- pyrsql/integrations/fastapi/__init__.py +8 -0
- pyrsql/integrations/fastapi/sqlalchemy.py +251 -0
- pyrsql/orms/__init__.py +15 -0
- pyrsql/orms/base.py +99 -0
- pyrsql/orms/sqlalchemy/__init__.py +39 -0
- pyrsql/orms/sqlalchemy/coercion.py +57 -0
- pyrsql/orms/sqlalchemy/compiled.py +56 -0
- pyrsql/orms/sqlalchemy/compiled_page.py +40 -0
- pyrsql/orms/sqlalchemy/compiled_sort.py +56 -0
- pyrsql/orms/sqlalchemy/custom.py +25 -0
- pyrsql/orms/sqlalchemy/errors.py +17 -0
- pyrsql/orms/sqlalchemy/introspection.py +107 -0
- pyrsql/orms/sqlalchemy/json_path.py +351 -0
- pyrsql/orms/sqlalchemy/orm.py +65 -0
- pyrsql/orms/sqlalchemy/resolver.py +202 -0
- pyrsql/orms/sqlalchemy/sorter.py +173 -0
- pyrsql/orms/sqlalchemy/statement.py +40 -0
- pyrsql/orms/sqlalchemy/translator.py +560 -0
- pyrsql/orms/sqlalchemy/type_inference.py +27 -0
- pyrsql/orms/sqlalchemy/types.py +64 -0
- pyrsql/parsing/__init__.py +32 -0
- pyrsql/parsing/ast.py +61 -0
- pyrsql/parsing/errors.py +25 -0
- pyrsql/parsing/lexer.py +264 -0
- pyrsql/parsing/limits.py +15 -0
- pyrsql/parsing/operators.py +201 -0
- pyrsql/parsing/parser.py +292 -0
- pyrsql/parsing/source.py +48 -0
- pyrsql/parsing/tokens.py +30 -0
- pyrsql/py.typed +0 -0
- pyrsql/selector/__init__.py +30 -0
- pyrsql/selector/analyzer.py +58 -0
- pyrsql/selector/ast.py +31 -0
- pyrsql/selector/parser.py +130 -0
- pyrsql/selector/semantic.py +34 -0
- pyrsql/semantic/__init__.py +23 -0
- pyrsql/semantic/analyzer.py +116 -0
- pyrsql/semantic/ast.py +35 -0
- pyrsql/semantic/errors.py +40 -0
- pyrsql/sorting/__init__.py +26 -0
- pyrsql/sorting/analyzer.py +74 -0
- pyrsql/sorting/ast.py +46 -0
- pyrsql/sorting/errors.py +21 -0
- pyrsql/sorting/limits.py +15 -0
- pyrsql/sorting/parser.py +150 -0
- pyrsql/sorting/semantic.py +15 -0
- pyrsql-0.0.1.dist-info/METADATA +72 -0
- pyrsql-0.0.1.dist-info/RECORD +74 -0
- pyrsql-0.0.1.dist-info/WHEEL +4 -0
pyrsql/core/compiler.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""ORM-neutral compilation result objects."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pyrsql.orms.base import CompiledPageRequest, CompiledQuery, CompiledSort
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class CompilationResult:
|
|
11
|
+
"""Wraps an ORM-specific compiled query object.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
orm_name: Name of the ORM used to produce the compilation.
|
|
15
|
+
compiled_query: ORM-specific compiled query payload.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
orm_name: str
|
|
19
|
+
compiled_query: CompiledQuery
|
|
20
|
+
|
|
21
|
+
def apply(self, target: Any, model: type[Any]) -> Any:
|
|
22
|
+
"""Applies the compiled query to an ORM-specific target.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
target: ORM-specific target to mutate.
|
|
26
|
+
model: ORM model class used to resolve the query.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The value returned by the compiled query application.
|
|
30
|
+
"""
|
|
31
|
+
return self.compiled_query.apply(target=target, model=model)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class SortCompilationResult:
|
|
36
|
+
"""Wraps an ORM-specific compiled sort object.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
orm_name: Name of the ORM used to produce the compilation.
|
|
40
|
+
compiled_sort: ORM-specific compiled sort payload.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
orm_name: str
|
|
44
|
+
compiled_sort: CompiledSort
|
|
45
|
+
|
|
46
|
+
def apply(self, target: Any, model: type[Any]) -> Any:
|
|
47
|
+
"""Applies the compiled sort to an ORM-specific target.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
target: ORM-specific target to mutate.
|
|
51
|
+
model: ORM model class used to resolve the sort.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The value returned by the compiled sort application.
|
|
55
|
+
"""
|
|
56
|
+
return self.compiled_sort.apply(target=target, model=model)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class PageCompilationResult:
|
|
61
|
+
"""Wraps an ORM-specific compiled page request object.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
orm_name: Name of the ORM used to produce the compilation.
|
|
65
|
+
compiled_page: ORM-specific compiled page payload.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
orm_name: str
|
|
69
|
+
compiled_page: CompiledPageRequest
|
|
70
|
+
|
|
71
|
+
def apply(self, target: Any, model: type[Any]) -> Any:
|
|
72
|
+
"""Applies the compiled page request to an ORM-specific target.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
target: ORM-specific target to mutate.
|
|
76
|
+
model: ORM model class used to resolve the page request.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The value returned by the compiled page application.
|
|
80
|
+
"""
|
|
81
|
+
return self.compiled_page.apply(target=target, model=model)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""ORM-neutral value conversion support."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Any
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
import ciso8601
|
|
13
|
+
|
|
14
|
+
ValueConverter = Callable[[str], Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ValueConversionError(ValueError):
|
|
18
|
+
"""Raised when a raw RSQL value cannot be converted."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _convert_bool(raw_value: str) -> bool:
|
|
22
|
+
"""Converts a string to bool using explicit accepted values."""
|
|
23
|
+
lowered = raw_value.lower()
|
|
24
|
+
if lowered == "true":
|
|
25
|
+
return True
|
|
26
|
+
if lowered == "false":
|
|
27
|
+
return False
|
|
28
|
+
raise ValueConversionError(f"Cannot convert {raw_value!r} to bool.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class ValueConverterRegistry:
|
|
33
|
+
"""Immutable registry of orm-neutral string-to-type converters."""
|
|
34
|
+
|
|
35
|
+
converters: Mapping[type[Any], ValueConverter]
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
"""Normalizes the converter mapping into an immutable view."""
|
|
39
|
+
object.__setattr__(
|
|
40
|
+
self,
|
|
41
|
+
"converters",
|
|
42
|
+
MappingProxyType(dict(self.converters)),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def with_converter(
|
|
46
|
+
self,
|
|
47
|
+
target_type: type[Any],
|
|
48
|
+
converter: ValueConverter,
|
|
49
|
+
) -> "ValueConverterRegistry":
|
|
50
|
+
"""Returns a new registry with one additional converter."""
|
|
51
|
+
updated = dict(self.converters)
|
|
52
|
+
updated[target_type] = converter
|
|
53
|
+
return ValueConverterRegistry(updated)
|
|
54
|
+
|
|
55
|
+
def convert(
|
|
56
|
+
self,
|
|
57
|
+
raw_value: str,
|
|
58
|
+
target_type: type[Any] | None,
|
|
59
|
+
) -> Any:
|
|
60
|
+
"""Converts a raw string into the requested target type."""
|
|
61
|
+
if target_type is None:
|
|
62
|
+
return raw_value
|
|
63
|
+
|
|
64
|
+
if target_type is dt.datetime:
|
|
65
|
+
return self._convert_datetime(raw_value)
|
|
66
|
+
|
|
67
|
+
converter = self._find_registered_converter(target_type)
|
|
68
|
+
if converter is not None:
|
|
69
|
+
try:
|
|
70
|
+
return converter(raw_value)
|
|
71
|
+
except Exception as error: # pragma: no cover - defensive wrap
|
|
72
|
+
raise ValueConversionError(
|
|
73
|
+
f"Failed to convert {raw_value!r} to "
|
|
74
|
+
f"{target_type.__name__}."
|
|
75
|
+
) from error
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
if issubclass(target_type, Enum):
|
|
79
|
+
return self._convert_enum(raw_value, target_type)
|
|
80
|
+
if issubclass(target_type, str):
|
|
81
|
+
return raw_value
|
|
82
|
+
except TypeError as error: # pragma: no cover - invalid type object
|
|
83
|
+
raise ValueConversionError(
|
|
84
|
+
f"Unsupported target type {target_type!r}."
|
|
85
|
+
) from error
|
|
86
|
+
|
|
87
|
+
return self._construct_from_string(raw_value, target_type)
|
|
88
|
+
|
|
89
|
+
def _find_registered_converter(
|
|
90
|
+
self,
|
|
91
|
+
target_type: type[Any],
|
|
92
|
+
) -> ValueConverter | None:
|
|
93
|
+
"""Finds the most specific registered converter for a type."""
|
|
94
|
+
for candidate in target_type.__mro__:
|
|
95
|
+
converter = self.converters.get(candidate)
|
|
96
|
+
if converter is not None:
|
|
97
|
+
return converter
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def _convert_enum(
|
|
101
|
+
self,
|
|
102
|
+
raw_value: str,
|
|
103
|
+
target_type: type[Any],
|
|
104
|
+
) -> Any:
|
|
105
|
+
"""Converts a string into an enum member by name first."""
|
|
106
|
+
try:
|
|
107
|
+
return target_type[raw_value]
|
|
108
|
+
except KeyError:
|
|
109
|
+
try:
|
|
110
|
+
return target_type(raw_value)
|
|
111
|
+
except ValueError as error:
|
|
112
|
+
raise ValueConversionError(
|
|
113
|
+
f"Failed to convert {raw_value!r} to "
|
|
114
|
+
f"{target_type.__name__}."
|
|
115
|
+
) from error
|
|
116
|
+
|
|
117
|
+
def _convert_datetime(self, raw_value: str) -> dt.datetime:
|
|
118
|
+
"""Converts a string into datetime with LocalDate-style fallback."""
|
|
119
|
+
try:
|
|
120
|
+
parsed_datetime = ciso8601.parse_datetime(raw_value)
|
|
121
|
+
except ValueError:
|
|
122
|
+
parsed_datetime = None
|
|
123
|
+
if parsed_datetime is not None:
|
|
124
|
+
return parsed_datetime
|
|
125
|
+
try:
|
|
126
|
+
parsed_date = dt.date.fromisoformat(raw_value)
|
|
127
|
+
except ValueError as date_error:
|
|
128
|
+
raise ValueConversionError(
|
|
129
|
+
f"Failed to convert {raw_value!r} to datetime."
|
|
130
|
+
) from date_error
|
|
131
|
+
return dt.datetime.combine(parsed_date, dt.time.min)
|
|
132
|
+
|
|
133
|
+
def _construct_from_string(
|
|
134
|
+
self,
|
|
135
|
+
raw_value: str,
|
|
136
|
+
target_type: type[Any],
|
|
137
|
+
) -> Any:
|
|
138
|
+
"""Attempts a plain constructor-based conversion as a fallback."""
|
|
139
|
+
try:
|
|
140
|
+
return target_type(raw_value)
|
|
141
|
+
except Exception as error:
|
|
142
|
+
raise ValueConversionError(
|
|
143
|
+
f"Failed to convert {raw_value!r} to {target_type.__name__}."
|
|
144
|
+
) from error
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True, slots=True)
|
|
148
|
+
class FieldValueConverterSet:
|
|
149
|
+
"""Immutable field-scoped value converter configuration."""
|
|
150
|
+
|
|
151
|
+
field_converters: Mapping[str, ValueConverter]
|
|
152
|
+
model_field_converters: Mapping[type[Any], Mapping[str, ValueConverter]]
|
|
153
|
+
|
|
154
|
+
def __post_init__(self) -> None:
|
|
155
|
+
"""Normalizes nested converter mappings into immutable views."""
|
|
156
|
+
object.__setattr__(
|
|
157
|
+
self,
|
|
158
|
+
"field_converters",
|
|
159
|
+
MappingProxyType(dict(self.field_converters)),
|
|
160
|
+
)
|
|
161
|
+
object.__setattr__(
|
|
162
|
+
self,
|
|
163
|
+
"model_field_converters",
|
|
164
|
+
MappingProxyType(
|
|
165
|
+
{
|
|
166
|
+
model: MappingProxyType(dict(converters))
|
|
167
|
+
for model, converters in self.model_field_converters.items()
|
|
168
|
+
}
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def resolve(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
model: type[Any] | None,
|
|
176
|
+
field_name: str | None,
|
|
177
|
+
field_path: str | None,
|
|
178
|
+
) -> ValueConverter | None:
|
|
179
|
+
"""Resolves the most specific converter configured for a field."""
|
|
180
|
+
if model is not None and field_name is not None:
|
|
181
|
+
model_converters = self.model_field_converters.get(model)
|
|
182
|
+
if model_converters is not None:
|
|
183
|
+
converter = model_converters.get(field_name)
|
|
184
|
+
if converter is not None:
|
|
185
|
+
return converter
|
|
186
|
+
if field_path is not None:
|
|
187
|
+
return self.field_converters.get(field_path)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
DEFAULT_VALUE_CONVERTER_REGISTRY = ValueConverterRegistry(
|
|
192
|
+
{
|
|
193
|
+
bool: _convert_bool,
|
|
194
|
+
int: int,
|
|
195
|
+
float: float,
|
|
196
|
+
Decimal: Decimal,
|
|
197
|
+
UUID: UUID,
|
|
198
|
+
dt.date: dt.date.fromisoformat,
|
|
199
|
+
dt.time: dt.time.fromisoformat,
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
DEFAULT_FIELD_VALUE_CONVERTER_SET = FieldValueConverterSet(
|
|
204
|
+
field_converters={},
|
|
205
|
+
model_field_converters={},
|
|
206
|
+
)
|
pyrsql/core/custom.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""ORM-neutral custom predicate definitions."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pyrsql.parsing.operators import ComparisonOperator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class CustomPredicateDefinition:
|
|
11
|
+
"""Defines a custom predicate independently of any orm."""
|
|
12
|
+
|
|
13
|
+
operator: ComparisonOperator
|
|
14
|
+
argument_type: type[Any]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Shared field mapping and access-policy helpers."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class FieldPolicySet:
|
|
10
|
+
"""ORM-neutral field mapping and access-policy configuration.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
field_mapping: Global selector-to-field mapping.
|
|
14
|
+
field_whitelist: Globally allowed field paths.
|
|
15
|
+
field_blacklist: Globally blocked field paths.
|
|
16
|
+
model_field_mapping: Model-specific selector-to-field mappings.
|
|
17
|
+
model_field_whitelist: Model-specific allowed field names.
|
|
18
|
+
model_field_blacklist: Model-specific blocked field names.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def is_empty(self) -> bool:
|
|
23
|
+
"""Whether the policy carries no active restrictions."""
|
|
24
|
+
return not any(
|
|
25
|
+
(
|
|
26
|
+
self.field_mapping,
|
|
27
|
+
self.field_whitelist,
|
|
28
|
+
self.field_blacklist,
|
|
29
|
+
self.model_field_mapping,
|
|
30
|
+
self.model_field_whitelist,
|
|
31
|
+
self.model_field_blacklist,
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
field_mapping: Mapping[str, str]
|
|
36
|
+
field_whitelist: frozenset[str]
|
|
37
|
+
field_blacklist: frozenset[str]
|
|
38
|
+
model_field_mapping: Mapping[type[Any], Mapping[str, str]]
|
|
39
|
+
model_field_whitelist: Mapping[type[Any], frozenset[str]]
|
|
40
|
+
model_field_blacklist: Mapping[type[Any], frozenset[str]]
|
|
41
|
+
|
|
42
|
+
def map_model_field(
|
|
43
|
+
self,
|
|
44
|
+
model: type[Any],
|
|
45
|
+
selector: str,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Maps one selector segment for a specific model when configured.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
model: ORM model used to look up a model-specific mapping.
|
|
51
|
+
selector: Raw selector segment to map.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The mapped selector segment, or the original value when no mapping
|
|
55
|
+
is configured.
|
|
56
|
+
"""
|
|
57
|
+
mapping = self.model_field_mapping.get(model)
|
|
58
|
+
if mapping is None:
|
|
59
|
+
return selector
|
|
60
|
+
return mapping.get(selector, selector)
|
|
61
|
+
|
|
62
|
+
def validate_global_field_access(self, field_path: str) -> None:
|
|
63
|
+
"""Validates global whitelist and blacklist rules.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
field_path: Dotted field path to validate.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: If the field path is not allowed or is blocked.
|
|
70
|
+
"""
|
|
71
|
+
if self.field_whitelist and field_path not in self.field_whitelist:
|
|
72
|
+
raise ValueError(f"Field {field_path!r} is not allowed.")
|
|
73
|
+
if field_path in self.field_blacklist:
|
|
74
|
+
raise ValueError(f"Field {field_path!r} is blocked.")
|
|
75
|
+
|
|
76
|
+
def validate_model_field_access(
|
|
77
|
+
self,
|
|
78
|
+
model: type[Any],
|
|
79
|
+
field_name: str,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Validates model-specific whitelist and blacklist rules.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
model: ORM model used to look up model-specific rules.
|
|
85
|
+
field_name: Field name to validate.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: If the field name is not allowed or is blocked.
|
|
89
|
+
"""
|
|
90
|
+
whitelist = self.model_field_whitelist.get(model)
|
|
91
|
+
if whitelist is not None and field_name not in whitelist:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"Field {model.__name__}.{field_name} is not allowed."
|
|
94
|
+
)
|
|
95
|
+
blacklist = self.model_field_blacklist.get(model)
|
|
96
|
+
if blacklist is not None and field_name in blacklist:
|
|
97
|
+
raise ValueError(f"Field {model.__name__}.{field_name} is blocked.")
|
pyrsql/core/joins.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""ORM-neutral JSON query primitives."""
|
|
2
|
+
|
|
3
|
+
from pyrsql.core.json.options import JSONOptions
|
|
4
|
+
from pyrsql.core.json.path import JSONPath
|
|
5
|
+
from pyrsql.core.json.query import JSONPathComparison
|
|
6
|
+
from pyrsql.core.json.values import (
|
|
7
|
+
DEFAULT_JSON_SCALAR_NORMALIZER,
|
|
8
|
+
JSONScalarNormalizer,
|
|
9
|
+
JSONScalarValue,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DEFAULT_JSON_SCALAR_NORMALIZER",
|
|
14
|
+
"JSONOptions",
|
|
15
|
+
"JSONPath",
|
|
16
|
+
"JSONPathComparison",
|
|
17
|
+
"JSONScalarNormalizer",
|
|
18
|
+
"JSONScalarValue",
|
|
19
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""ORM-neutral JSON options."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class JSONOptions:
|
|
8
|
+
"""ORM-neutral JSON behavior flags.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
path_exists_function: SQL function used for JSON path existence.
|
|
12
|
+
path_exists_tz_function: SQL function used for timezone-aware JSON path
|
|
13
|
+
existence checks.
|
|
14
|
+
use_datetime: Whether JSON values should be interpreted as datetimes
|
|
15
|
+
when possible.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
path_exists_function: str = "jsonb_path_exists"
|
|
19
|
+
path_exists_tz_function: str = "jsonb_path_exists_tz"
|
|
20
|
+
use_datetime: bool = False
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
"""Validates configured SQL function names.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ValueError: If either configured SQL function name is empty.
|
|
27
|
+
"""
|
|
28
|
+
if not self.path_exists_function:
|
|
29
|
+
raise ValueError("path_exists_function cannot be empty.")
|
|
30
|
+
if not self.path_exists_tz_function:
|
|
31
|
+
raise ValueError("path_exists_tz_function cannot be empty.")
|
pyrsql/core/json/path.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Immutable JSON path primitives."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class JSONPath:
|
|
8
|
+
"""Represents an ORM-neutral JSON path.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
segments: Ordered path segments from the JSON root to the target.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
segments: tuple[str, ...] = ()
|
|
15
|
+
_dot_path: str = field(init=False, repr=False, compare=False)
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
"""Validates path segments.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
ValueError: If any segment is empty.
|
|
22
|
+
"""
|
|
23
|
+
for segment in self.segments:
|
|
24
|
+
if not segment:
|
|
25
|
+
raise ValueError("JSON path segments cannot be empty.")
|
|
26
|
+
object.__setattr__(self, "_dot_path", ".".join(self.segments))
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_root(self) -> bool:
|
|
30
|
+
"""Whether the path targets the root JSON value."""
|
|
31
|
+
return not self.segments
|
|
32
|
+
|
|
33
|
+
def to_dot_path(self) -> str:
|
|
34
|
+
"""The path rendered as a dotted string."""
|
|
35
|
+
return self._dot_path
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""ORM-neutral JSON query models."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pyrsql.core.json.path import JSONPath
|
|
6
|
+
from pyrsql.core.json.values import (
|
|
7
|
+
DEFAULT_JSON_SCALAR_NORMALIZER,
|
|
8
|
+
JSONScalarNormalizer,
|
|
9
|
+
JSONScalarValue,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class JSONPathComparison:
|
|
15
|
+
"""Represents one JSON path comparison after semantic normalization."""
|
|
16
|
+
|
|
17
|
+
path: JSONPath
|
|
18
|
+
operator_name: str
|
|
19
|
+
values: tuple[JSONScalarValue, ...]
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_raw_arguments(
|
|
23
|
+
cls,
|
|
24
|
+
*,
|
|
25
|
+
path: JSONPath,
|
|
26
|
+
operator_name: str,
|
|
27
|
+
raw_arguments: tuple[tuple[str, bool], ...],
|
|
28
|
+
normalizer: JSONScalarNormalizer | None = None,
|
|
29
|
+
) -> "JSONPathComparison":
|
|
30
|
+
"""Builds a normalized JSON comparison from raw RSQL arguments."""
|
|
31
|
+
value_normalizer = normalizer or DEFAULT_JSON_SCALAR_NORMALIZER
|
|
32
|
+
return cls(
|
|
33
|
+
path=path,
|
|
34
|
+
operator_name=operator_name,
|
|
35
|
+
values=tuple(
|
|
36
|
+
value_normalizer.normalize(raw_value, quoted=quoted)
|
|
37
|
+
for raw_value, quoted in raw_arguments
|
|
38
|
+
),
|
|
39
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""ORM-neutral JSON value normalization."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import msgspec
|
|
8
|
+
|
|
9
|
+
_INTEGER_PATTERN = re.compile(r"^-?\d+$")
|
|
10
|
+
_FLOAT_PATTERN = re.compile(r"^-?\d+\.\d+$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class JSONScalarValue:
|
|
15
|
+
"""Represents one normalized JSON scalar or structured value."""
|
|
16
|
+
|
|
17
|
+
value: Any
|
|
18
|
+
json_literal: str
|
|
19
|
+
python_type: type[Any] | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JSONScalarNormalizer:
|
|
23
|
+
"""Normalizes raw RSQL arguments to JSON-aware values."""
|
|
24
|
+
|
|
25
|
+
def normalize(
|
|
26
|
+
self,
|
|
27
|
+
raw_value: str,
|
|
28
|
+
*,
|
|
29
|
+
quoted: bool,
|
|
30
|
+
) -> JSONScalarValue:
|
|
31
|
+
"""Builds one JSON value from a raw RSQL argument."""
|
|
32
|
+
if quoted:
|
|
33
|
+
parsed_json = self._try_parse_json(raw_value)
|
|
34
|
+
if parsed_json is not None:
|
|
35
|
+
return self._from_python_value(parsed_json)
|
|
36
|
+
return self._from_python_value(raw_value)
|
|
37
|
+
|
|
38
|
+
match raw_value.lower():
|
|
39
|
+
case "true":
|
|
40
|
+
return self._from_python_value(True)
|
|
41
|
+
case "false":
|
|
42
|
+
return self._from_python_value(False)
|
|
43
|
+
case "null":
|
|
44
|
+
return self._from_python_value(None)
|
|
45
|
+
case _:
|
|
46
|
+
pass
|
|
47
|
+
if _INTEGER_PATTERN.fullmatch(raw_value):
|
|
48
|
+
return self._from_python_value(int(raw_value))
|
|
49
|
+
if _FLOAT_PATTERN.fullmatch(raw_value):
|
|
50
|
+
return self._from_python_value(float(raw_value))
|
|
51
|
+
return self._from_python_value(raw_value)
|
|
52
|
+
|
|
53
|
+
def _from_python_value(self, value: Any) -> JSONScalarValue:
|
|
54
|
+
"""Creates a normalized JSON value from a Python object."""
|
|
55
|
+
return JSONScalarValue(
|
|
56
|
+
value=value,
|
|
57
|
+
json_literal=msgspec.json.encode(value).decode("utf-8"),
|
|
58
|
+
python_type=type(value) if value is not None else None,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _try_parse_json(self, raw_value: str) -> Any | None:
|
|
62
|
+
"""Parses quoted JSON arguments when they contain JSON values."""
|
|
63
|
+
try:
|
|
64
|
+
parsed = msgspec.json.decode(raw_value)
|
|
65
|
+
except msgspec.DecodeError:
|
|
66
|
+
return None
|
|
67
|
+
if isinstance(parsed, str):
|
|
68
|
+
return None
|
|
69
|
+
return parsed
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
DEFAULT_JSON_SCALAR_NORMALIZER = JSONScalarNormalizer()
|