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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""ORM-neutral semantic analysis."""
|
|
2
|
+
|
|
3
|
+
from pyrsql.core.options import QueryOptions
|
|
4
|
+
from pyrsql.parsing.ast import ComparisonNode, Expression, LogicalNode
|
|
5
|
+
from pyrsql.selector.analyzer import SelectorSemanticAnalyzer
|
|
6
|
+
from pyrsql.selector.ast import Selector
|
|
7
|
+
from pyrsql.selector.semantic import SemanticSelector
|
|
8
|
+
from pyrsql.semantic.ast import (
|
|
9
|
+
SemanticComparison,
|
|
10
|
+
SemanticExpression,
|
|
11
|
+
SemanticLogical,
|
|
12
|
+
)
|
|
13
|
+
from pyrsql.semantic.errors import (
|
|
14
|
+
FieldBlacklistedError,
|
|
15
|
+
FieldNotWhitelistedError,
|
|
16
|
+
FunctionBlacklistedError,
|
|
17
|
+
FunctionNotWhitelistedError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SemanticAnalyzer:
|
|
22
|
+
"""Applies orm-neutral semantic rules to parsed expressions."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, options: QueryOptions) -> None:
|
|
25
|
+
self._options = options
|
|
26
|
+
self._field_whitelist = options.field_whitelist
|
|
27
|
+
self._field_blacklist = options.field_blacklist
|
|
28
|
+
self._procedure_policy = options.procedure_policy
|
|
29
|
+
self._selector_analyzer = SelectorSemanticAnalyzer(
|
|
30
|
+
options.field_mapping
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def analyze(self, expression: Expression) -> SemanticExpression:
|
|
34
|
+
"""Analyzes a parsed AST into a semantic expression tree."""
|
|
35
|
+
if isinstance(expression, ComparisonNode):
|
|
36
|
+
return self._analyze_comparison(expression)
|
|
37
|
+
if not isinstance(expression, LogicalNode):
|
|
38
|
+
raise TypeError("Expected LogicalNode")
|
|
39
|
+
return SemanticLogical(
|
|
40
|
+
span=expression.span,
|
|
41
|
+
operator=expression.operator,
|
|
42
|
+
children=tuple(
|
|
43
|
+
self.analyze(child) for child in expression.children
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def _analyze_comparison(
|
|
48
|
+
self,
|
|
49
|
+
expression: ComparisonNode,
|
|
50
|
+
) -> SemanticComparison:
|
|
51
|
+
"""Analyzes a comparison node."""
|
|
52
|
+
return SemanticComparison(
|
|
53
|
+
span=expression.span,
|
|
54
|
+
selector=self._analyze_selector(
|
|
55
|
+
expression.selector,
|
|
56
|
+
expression=expression,
|
|
57
|
+
),
|
|
58
|
+
operator=expression.operator,
|
|
59
|
+
arguments=expression.arguments,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _analyze_selector(
|
|
63
|
+
self,
|
|
64
|
+
selector: Selector,
|
|
65
|
+
*,
|
|
66
|
+
expression: ComparisonNode,
|
|
67
|
+
) -> SemanticSelector:
|
|
68
|
+
"""Analyzes a parsed selector recursively."""
|
|
69
|
+
return self._selector_analyzer.analyze(
|
|
70
|
+
selector,
|
|
71
|
+
validate_field=lambda field_path: self._enforce_field_access_policy(
|
|
72
|
+
field_path,
|
|
73
|
+
expression,
|
|
74
|
+
),
|
|
75
|
+
validate_function=(
|
|
76
|
+
lambda function_name: self._enforce_function_access_policy(
|
|
77
|
+
function_name,
|
|
78
|
+
expression=expression,
|
|
79
|
+
)
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def _enforce_field_access_policy(
|
|
84
|
+
self,
|
|
85
|
+
field_path: str,
|
|
86
|
+
expression: ComparisonNode,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Validates whitelist and blacklist rules."""
|
|
89
|
+
if self._field_whitelist and field_path not in self._field_whitelist:
|
|
90
|
+
raise FieldNotWhitelistedError(
|
|
91
|
+
message=f"Field {field_path!r} is not allowed",
|
|
92
|
+
span=expression.span,
|
|
93
|
+
)
|
|
94
|
+
if field_path in self._field_blacklist:
|
|
95
|
+
raise FieldBlacklistedError(
|
|
96
|
+
message=f"Field {field_path!r} is blocked",
|
|
97
|
+
span=expression.span,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _enforce_function_access_policy(
|
|
101
|
+
self,
|
|
102
|
+
function_name: str,
|
|
103
|
+
*,
|
|
104
|
+
expression: ComparisonNode,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Validates whitelist and blacklist rules for functions."""
|
|
107
|
+
if not self._procedure_policy.is_whitelisted(function_name):
|
|
108
|
+
raise FunctionNotWhitelistedError(
|
|
109
|
+
message=f"Function {function_name!r} is not whitelisted",
|
|
110
|
+
span=expression.span,
|
|
111
|
+
)
|
|
112
|
+
if self._procedure_policy.is_blacklisted(function_name):
|
|
113
|
+
raise FunctionBlacklistedError(
|
|
114
|
+
message=f"Function {function_name!r} is blacklisted",
|
|
115
|
+
span=expression.span,
|
|
116
|
+
)
|
pyrsql/semantic/ast.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Semantic expression nodes."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pyrsql.parsing.ast import Argument, LogicalOperator
|
|
6
|
+
from pyrsql.parsing.operators import ComparisonOperator
|
|
7
|
+
from pyrsql.parsing.source import SourceSpan
|
|
8
|
+
from pyrsql.selector.semantic import SemanticSelector
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class SemanticNode:
|
|
13
|
+
"""Base semantic node."""
|
|
14
|
+
|
|
15
|
+
span: SourceSpan
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class SemanticComparison(SemanticNode):
|
|
20
|
+
"""Comparison expression after selector normalization."""
|
|
21
|
+
|
|
22
|
+
selector: SemanticSelector
|
|
23
|
+
operator: ComparisonOperator
|
|
24
|
+
arguments: tuple[Argument, ...]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class SemanticLogical(SemanticNode):
|
|
29
|
+
"""Logical semantic node."""
|
|
30
|
+
|
|
31
|
+
operator: LogicalOperator
|
|
32
|
+
children: tuple["SemanticExpression", ...]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
SemanticExpression = SemanticComparison | SemanticLogical
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Semantic analysis exceptions."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pyrsql.parsing.source import SourceSpan
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class SemanticError(ValueError):
|
|
10
|
+
"""Base exception for semantic analysis failures."""
|
|
11
|
+
|
|
12
|
+
message: str
|
|
13
|
+
span: SourceSpan
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
"""Formats semantic errors with source position data."""
|
|
17
|
+
return (
|
|
18
|
+
f"{self.message} at index {self.span.start.index} "
|
|
19
|
+
f"(line {self.span.start.line}, column {self.span.start.column})"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class FieldNotWhitelistedError(SemanticError):
|
|
25
|
+
"""Raised when a selector is not allowed by the whitelist."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class FieldBlacklistedError(SemanticError):
|
|
30
|
+
"""Raised when a selector is blocked by the blacklist."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class FunctionNotWhitelistedError(SemanticError):
|
|
35
|
+
"""Raised when a function selector is not allowed by the whitelist."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class FunctionBlacklistedError(SemanticError):
|
|
40
|
+
"""Raised when a function selector is blocked by the blacklist."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Sorting primitives for pyrsql."""
|
|
2
|
+
|
|
3
|
+
from pyrsql.sorting.ast import SortDirection, SortField
|
|
4
|
+
from pyrsql.sorting.errors import (
|
|
5
|
+
SortFieldBlacklistedError,
|
|
6
|
+
SortFieldNotWhitelistedError,
|
|
7
|
+
SortFunctionBlacklistedError,
|
|
8
|
+
SortFunctionNotWhitelistedError,
|
|
9
|
+
SortParseError,
|
|
10
|
+
)
|
|
11
|
+
from pyrsql.sorting.limits import SortLimits
|
|
12
|
+
from pyrsql.sorting.parser import SortParser
|
|
13
|
+
from pyrsql.sorting.semantic import SemanticSortField
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"SemanticSortField",
|
|
17
|
+
"SortDirection",
|
|
18
|
+
"SortField",
|
|
19
|
+
"SortFieldBlacklistedError",
|
|
20
|
+
"SortFieldNotWhitelistedError",
|
|
21
|
+
"SortFunctionBlacklistedError",
|
|
22
|
+
"SortFunctionNotWhitelistedError",
|
|
23
|
+
"SortLimits",
|
|
24
|
+
"SortParseError",
|
|
25
|
+
"SortParser",
|
|
26
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Semantic analysis for sort expressions."""
|
|
2
|
+
|
|
3
|
+
from pyrsql.core.options import SortOptions
|
|
4
|
+
from pyrsql.selector.analyzer import SelectorSemanticAnalyzer
|
|
5
|
+
from pyrsql.selector.ast import Selector
|
|
6
|
+
from pyrsql.selector.semantic import SemanticSelector
|
|
7
|
+
from pyrsql.sorting.ast import SortField
|
|
8
|
+
from pyrsql.sorting.errors import (
|
|
9
|
+
SortFieldBlacklistedError,
|
|
10
|
+
SortFieldNotWhitelistedError,
|
|
11
|
+
SortFunctionBlacklistedError,
|
|
12
|
+
SortFunctionNotWhitelistedError,
|
|
13
|
+
)
|
|
14
|
+
from pyrsql.sorting.semantic import SemanticSortField
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SortAnalyzer:
|
|
18
|
+
"""Applies selector mapping and access rules to parsed sort fields."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, options: SortOptions) -> None:
|
|
21
|
+
self._field_whitelist = options.field_whitelist
|
|
22
|
+
self._field_blacklist = options.field_blacklist
|
|
23
|
+
self._procedure_policy = options.procedure_policy
|
|
24
|
+
self._selector_analyzer = SelectorSemanticAnalyzer(
|
|
25
|
+
options.field_mapping
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def analyze(
|
|
29
|
+
self,
|
|
30
|
+
fields: tuple[SortField, ...],
|
|
31
|
+
) -> tuple[SemanticSortField, ...]:
|
|
32
|
+
"""Analyzes parsed sort fields into semantic sort fields."""
|
|
33
|
+
return tuple(self._analyze_field(field) for field in fields)
|
|
34
|
+
|
|
35
|
+
def _analyze_field(self, field: SortField) -> SemanticSortField:
|
|
36
|
+
"""Analyzes a single parsed sort field."""
|
|
37
|
+
return SemanticSortField(
|
|
38
|
+
selector=self._analyze_selector(field.selector),
|
|
39
|
+
direction=field.direction,
|
|
40
|
+
ignore_case=field.ignore_case,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def _analyze_selector(
|
|
44
|
+
self,
|
|
45
|
+
selector: Selector,
|
|
46
|
+
) -> SemanticSelector:
|
|
47
|
+
"""Analyzes a parsed selector recursively."""
|
|
48
|
+
return self._selector_analyzer.analyze(
|
|
49
|
+
selector,
|
|
50
|
+
validate_field=self._validate_field_access,
|
|
51
|
+
validate_function=self._validate_function_access,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def _validate_field_access(self, field_path: str) -> None:
|
|
55
|
+
"""Validates whitelist and blacklist rules for a sort field."""
|
|
56
|
+
if self._field_whitelist and field_path not in self._field_whitelist:
|
|
57
|
+
raise SortFieldNotWhitelistedError(
|
|
58
|
+
f"Field {field_path!r} is not allowed by the whitelist."
|
|
59
|
+
)
|
|
60
|
+
if field_path in self._field_blacklist:
|
|
61
|
+
raise SortFieldBlacklistedError(
|
|
62
|
+
f"Field {field_path!r} is blocked by the blacklist."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _validate_function_access(self, function_name: str) -> None:
|
|
66
|
+
"""Validates whitelist and blacklist rules for a sort function."""
|
|
67
|
+
if not self._procedure_policy.is_whitelisted(function_name):
|
|
68
|
+
raise SortFunctionNotWhitelistedError(
|
|
69
|
+
f"Function {function_name!r} is not whitelisted."
|
|
70
|
+
)
|
|
71
|
+
if self._procedure_policy.is_blacklisted(function_name):
|
|
72
|
+
raise SortFunctionBlacklistedError(
|
|
73
|
+
f"Function {function_name!r} is blacklisted."
|
|
74
|
+
)
|
pyrsql/sorting/ast.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""AST nodes for pyrsql sort expressions."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from pyrsql.selector.ast import Selector
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SortDirection(Enum):
|
|
10
|
+
"""Supported sort directions."""
|
|
11
|
+
|
|
12
|
+
ASCENDING = "asc"
|
|
13
|
+
DESCENDING = "desc"
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_raw(cls, raw_direction: str) -> "SortDirection | None":
|
|
17
|
+
"""Matches a raw token to a supported sort direction.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
raw_direction: Raw direction token from the sort expression.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The matching direction, or None when the token is unsupported.
|
|
24
|
+
"""
|
|
25
|
+
match raw_direction.lower():
|
|
26
|
+
case "asc":
|
|
27
|
+
return cls.ASCENDING
|
|
28
|
+
case "desc":
|
|
29
|
+
return cls.DESCENDING
|
|
30
|
+
case _:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class SortField:
|
|
36
|
+
"""Single parsed sort field.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
selector: Selector to sort by.
|
|
40
|
+
direction: Sort direction to apply.
|
|
41
|
+
ignore_case: Whether case should be ignored when sorting.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
selector: Selector
|
|
45
|
+
direction: SortDirection
|
|
46
|
+
ignore_case: bool
|
pyrsql/sorting/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Errors raised while parsing or analyzing sort expressions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SortParseError(ValueError):
|
|
5
|
+
"""Raised when a sort expression is malformed."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SortFieldNotWhitelistedError(ValueError):
|
|
9
|
+
"""Raised when a sort selector is not allowed by the whitelist."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SortFieldBlacklistedError(ValueError):
|
|
13
|
+
"""Raised when a sort selector is blocked by the blacklist."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SortFunctionNotWhitelistedError(ValueError):
|
|
17
|
+
"""Raised when a sort function is not allowed by the whitelist."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SortFunctionBlacklistedError(ValueError):
|
|
21
|
+
"""Raised when a sort function is blocked by the blacklist."""
|
pyrsql/sorting/limits.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Security and complexity limits for sort parsing."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class SortLimits:
|
|
8
|
+
"""Configurable limits for sort parsing."""
|
|
9
|
+
|
|
10
|
+
max_sort_length: int = 4096
|
|
11
|
+
max_fields: int = 32
|
|
12
|
+
max_field_path_length: int = 256
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_SORT_LIMITS = SortLimits()
|
pyrsql/sorting/parser.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Parser for pyrsql sort expressions."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
from pyrsql.selector.ast import Selector
|
|
6
|
+
from pyrsql.selector.parser import DEFAULT_SELECTOR_PARSER, SelectorParseError
|
|
7
|
+
from pyrsql.sorting.ast import SortDirection, SortField
|
|
8
|
+
from pyrsql.sorting.errors import SortParseError
|
|
9
|
+
from pyrsql.sorting.limits import DEFAULT_SORT_LIMITS, SortLimits
|
|
10
|
+
|
|
11
|
+
_EMPTY_FIELDS: Final[tuple[SortField, ...]] = ()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SortParser:
|
|
15
|
+
"""Parses sort expressions into orm-neutral field descriptors."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
source: str | None,
|
|
20
|
+
*,
|
|
21
|
+
limits: SortLimits | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._source = (source or "").strip()
|
|
24
|
+
self._limits = limits or DEFAULT_SORT_LIMITS
|
|
25
|
+
self._selector_parser = DEFAULT_SELECTOR_PARSER
|
|
26
|
+
|
|
27
|
+
def parse(self) -> tuple[SortField, ...]:
|
|
28
|
+
"""Parses the configured sort expression."""
|
|
29
|
+
if not self._source:
|
|
30
|
+
return _EMPTY_FIELDS
|
|
31
|
+
|
|
32
|
+
max_sort_length = self._limits.max_sort_length
|
|
33
|
+
max_fields = self._limits.max_fields
|
|
34
|
+
if len(self._source) > max_sort_length:
|
|
35
|
+
raise SortParseError(
|
|
36
|
+
"Sort expression exceeds the maximum supported length of "
|
|
37
|
+
f"{max_sort_length}."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
fields: list[SortField] = []
|
|
41
|
+
for clause_index, clause in enumerate(self._source.split(";"), start=1):
|
|
42
|
+
parsed_field = self._parse_clause(clause, clause_index=clause_index)
|
|
43
|
+
if parsed_field is None:
|
|
44
|
+
continue
|
|
45
|
+
fields.append(parsed_field)
|
|
46
|
+
if len(fields) > max_fields:
|
|
47
|
+
raise SortParseError(
|
|
48
|
+
"Sort expression exceeds the maximum supported field "
|
|
49
|
+
f"count of {max_fields}."
|
|
50
|
+
)
|
|
51
|
+
return tuple(fields)
|
|
52
|
+
|
|
53
|
+
def _parse_clause(
|
|
54
|
+
self,
|
|
55
|
+
clause: str,
|
|
56
|
+
*,
|
|
57
|
+
clause_index: int,
|
|
58
|
+
) -> SortField | None:
|
|
59
|
+
"""Parses a single semicolon-delimited sort clause."""
|
|
60
|
+
try:
|
|
61
|
+
parts = list(
|
|
62
|
+
self._selector_parser.split_top_level(
|
|
63
|
+
clause,
|
|
64
|
+
delimiter=",",
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
except SelectorParseError as error:
|
|
68
|
+
raise SortParseError(str(error)) from error
|
|
69
|
+
if not parts:
|
|
70
|
+
return None
|
|
71
|
+
if len(parts) > 3:
|
|
72
|
+
raise SortParseError(
|
|
73
|
+
"Sort clause "
|
|
74
|
+
f"#{clause_index} {clause!r} has too many comma-separated "
|
|
75
|
+
"parts."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
selector = self._parse_selector(parts[0], clause_index=clause_index)
|
|
79
|
+
|
|
80
|
+
direction = SortDirection.ASCENDING
|
|
81
|
+
if len(parts) > 1:
|
|
82
|
+
direction = self._parse_direction(
|
|
83
|
+
parts[1],
|
|
84
|
+
clause,
|
|
85
|
+
clause_index=clause_index,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
ignore_case = False
|
|
89
|
+
if len(parts) > 2:
|
|
90
|
+
ignore_case = self._parse_ignore_case(
|
|
91
|
+
parts[2],
|
|
92
|
+
clause,
|
|
93
|
+
clause_index=clause_index,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return SortField(
|
|
97
|
+
selector=selector,
|
|
98
|
+
direction=direction,
|
|
99
|
+
ignore_case=ignore_case,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _parse_direction(
|
|
103
|
+
self,
|
|
104
|
+
raw_direction: str,
|
|
105
|
+
clause: str,
|
|
106
|
+
*,
|
|
107
|
+
clause_index: int,
|
|
108
|
+
) -> SortDirection:
|
|
109
|
+
"""Parses the direction token for a sort clause."""
|
|
110
|
+
direction = SortDirection.from_raw(raw_direction)
|
|
111
|
+
if direction is not None:
|
|
112
|
+
return direction
|
|
113
|
+
raise SortParseError(
|
|
114
|
+
f"Sort clause #{clause_index} {clause!r} has unsupported direction "
|
|
115
|
+
f"{raw_direction!r}."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _parse_ignore_case(
|
|
119
|
+
self,
|
|
120
|
+
raw_flag: str,
|
|
121
|
+
clause: str,
|
|
122
|
+
*,
|
|
123
|
+
clause_index: int,
|
|
124
|
+
) -> bool:
|
|
125
|
+
"""Parses the ignore-case modifier for a sort clause."""
|
|
126
|
+
match raw_flag.lower():
|
|
127
|
+
case "ic":
|
|
128
|
+
return True
|
|
129
|
+
case _:
|
|
130
|
+
raise SortParseError(
|
|
131
|
+
f"Sort clause #{clause_index} {clause!r} has unsupported "
|
|
132
|
+
"modifier "
|
|
133
|
+
f"{raw_flag!r}."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _parse_selector(
|
|
137
|
+
self,
|
|
138
|
+
raw_selector: str,
|
|
139
|
+
*,
|
|
140
|
+
clause_index: int,
|
|
141
|
+
) -> Selector:
|
|
142
|
+
"""Parses a sort selector recursively."""
|
|
143
|
+
try:
|
|
144
|
+
return self._selector_parser.parse(
|
|
145
|
+
raw_selector,
|
|
146
|
+
max_length=self._limits.max_field_path_length,
|
|
147
|
+
context=f"Sort selector in clause #{clause_index}",
|
|
148
|
+
)
|
|
149
|
+
except SelectorParseError as error:
|
|
150
|
+
raise SortParseError(str(error)) from error
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Semantic sort nodes."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pyrsql.selector.semantic import SemanticSelector
|
|
6
|
+
from pyrsql.sorting.ast import SortDirection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class SemanticSortField:
|
|
11
|
+
"""Single sort field after selector normalization."""
|
|
12
|
+
|
|
13
|
+
selector: SemanticSelector
|
|
14
|
+
direction: SortDirection
|
|
15
|
+
ignore_case: bool
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyrsql
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: ORM-oriented RSQL support for Python applications.
|
|
5
|
+
Keywords: rsql,orm,sqlalchemy,fastapi,query-language,rest
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Requires-Dist: ciso8601>=2.3,<3.0
|
|
17
|
+
Requires-Dist: msgspec>=0.18,<1.0
|
|
18
|
+
Requires-Dist: typing-extensions>=4.10.0 ; python_full_version < '3.11'
|
|
19
|
+
Requires-Dist: fastapi>=0.110,<1.0 ; extra == 'fastapi'
|
|
20
|
+
Requires-Dist: sqlalchemy>=2.0,<3.0 ; extra == 'sqlalchemy'
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Project-URL: Homepage, https://github.com/wskr00/pyrsql
|
|
23
|
+
Project-URL: Repository, https://github.com/wskr00/pyrsql
|
|
24
|
+
Project-URL: Issues, https://github.com/wskr00/pyrsql/issues
|
|
25
|
+
Project-URL: Documentation, https://github.com/wskr00/pyrsql/blob/main/README.md
|
|
26
|
+
Provides-Extra: fastapi
|
|
27
|
+
Provides-Extra: sqlalchemy
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
## pyrsql
|
|
31
|
+
|
|
32
|
+
`pyrsql` is an ORM-oriented RSQL library for Python.
|
|
33
|
+
|
|
34
|
+
The project is being designed to support multiple ORMs, starting with
|
|
35
|
+
`SQLAlchemy 2.0`. The core package remains ORM-neutral, while ORM
|
|
36
|
+
implementations live in `orms`, and framework-specific integration lives
|
|
37
|
+
in `adapters`.
|
|
38
|
+
|
|
39
|
+
Current `SQLAlchemy` support includes:
|
|
40
|
+
|
|
41
|
+
- filters
|
|
42
|
+
- sorting
|
|
43
|
+
- pagination
|
|
44
|
+
- `distinct`
|
|
45
|
+
- `join_hints`
|
|
46
|
+
- PostgreSQL-style JSON / JSONB path support
|
|
47
|
+
- temporal JSON path semantics via `JSONOptions(use_datetime=True)`
|
|
48
|
+
|
|
49
|
+
Current adapter support includes:
|
|
50
|
+
|
|
51
|
+
- `FastAPI` request extraction via dependencies
|
|
52
|
+
- configurable `filter`, `sort`, `page`, and `size` parameters
|
|
53
|
+
- class-based and factory-based FastAPI dependencies
|
|
54
|
+
- `HTTP 422` translation for pyrsql parse and semantic failures
|
|
55
|
+
|
|
56
|
+
## Development Principles
|
|
57
|
+
|
|
58
|
+
- Object-oriented design
|
|
59
|
+
- SOLID principles
|
|
60
|
+
- Google Python Style Guide
|
|
61
|
+
- Strong typing
|
|
62
|
+
- ORM-neutral public API
|
|
63
|
+
|
|
64
|
+
## Documentation
|
|
65
|
+
|
|
66
|
+
- [Architecture](docs/architecture.md)
|
|
67
|
+
- [Usage](docs/usage.md)
|
|
68
|
+
- [Reference](docs/reference.md)
|
|
69
|
+
- [FastAPI Adapter Usage](docs/usage.md#fastapi-adapter)
|
|
70
|
+
- [Google Python Style Guide](docs/pyguide.md)
|
|
71
|
+
- [Quality and Tooling](docs/quality.md)
|
|
72
|
+
- [Testing](docs/testing.md)
|