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,173 @@
|
|
|
1
|
+
"""Sort translation for SQLAlchemy."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
7
|
+
|
|
8
|
+
from pyrsql.core.options import SortOptions
|
|
9
|
+
from pyrsql.orms.sqlalchemy.json_path import (
|
|
10
|
+
SQLAlchemyJSONPathExpressionBuilder,
|
|
11
|
+
)
|
|
12
|
+
from pyrsql.orms.sqlalchemy.resolver import SQLAlchemyPathResolver
|
|
13
|
+
from pyrsql.orms.sqlalchemy.type_inference import (
|
|
14
|
+
infer_sql_function_python_type,
|
|
15
|
+
is_string_python_type,
|
|
16
|
+
)
|
|
17
|
+
from pyrsql.orms.sqlalchemy.types import (
|
|
18
|
+
SQLAlchemyJoinPlan,
|
|
19
|
+
SQLAlchemyResolvedPath,
|
|
20
|
+
)
|
|
21
|
+
from pyrsql.selector.semantic import (
|
|
22
|
+
SemanticColumnSelector,
|
|
23
|
+
SemanticLiteralSelector,
|
|
24
|
+
SemanticSelector,
|
|
25
|
+
)
|
|
26
|
+
from pyrsql.sorting.ast import SortDirection
|
|
27
|
+
from pyrsql.sorting.semantic import SemanticSortField
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SQLAlchemySortTranslator:
|
|
31
|
+
"""Translates semantic sort fields to SQLAlchemy order clauses."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
path_resolver: SQLAlchemyPathResolver | None = None,
|
|
37
|
+
json_path_builder: SQLAlchemyJSONPathExpressionBuilder | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._path_resolver = path_resolver or SQLAlchemyPathResolver()
|
|
40
|
+
self._json_path_builder = (
|
|
41
|
+
json_path_builder or SQLAlchemyJSONPathExpressionBuilder()
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def translate(
|
|
45
|
+
self,
|
|
46
|
+
model: type[Any],
|
|
47
|
+
fields: tuple[SemanticSortField, ...],
|
|
48
|
+
*,
|
|
49
|
+
options: SortOptions | None = None,
|
|
50
|
+
) -> tuple[
|
|
51
|
+
tuple[SQLAlchemyJoinPlan, ...],
|
|
52
|
+
tuple[ColumnElement[Any], ...],
|
|
53
|
+
]:
|
|
54
|
+
"""Translates semantic sort fields for a mapped model."""
|
|
55
|
+
joins: list[SQLAlchemyJoinPlan] = []
|
|
56
|
+
order_clauses: list[ColumnElement[Any]] = []
|
|
57
|
+
for field in fields:
|
|
58
|
+
selector_joins, expression, python_type = self._translate_selector(
|
|
59
|
+
model,
|
|
60
|
+
field.selector,
|
|
61
|
+
options=options,
|
|
62
|
+
)
|
|
63
|
+
joins.extend(selector_joins)
|
|
64
|
+
order_clauses.append(
|
|
65
|
+
self._build_order_clause(
|
|
66
|
+
expression,
|
|
67
|
+
python_type,
|
|
68
|
+
field,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return tuple(joins), tuple(order_clauses)
|
|
72
|
+
|
|
73
|
+
def _translate_selector(
|
|
74
|
+
self,
|
|
75
|
+
model: type[Any],
|
|
76
|
+
selector: SemanticSelector,
|
|
77
|
+
*,
|
|
78
|
+
options: SortOptions | None = None,
|
|
79
|
+
) -> tuple[
|
|
80
|
+
tuple[SQLAlchemyJoinPlan, ...],
|
|
81
|
+
ColumnElement[Any],
|
|
82
|
+
type[Any] | None,
|
|
83
|
+
]:
|
|
84
|
+
"""Translates a semantic selector recursively."""
|
|
85
|
+
if isinstance(selector, SemanticColumnSelector):
|
|
86
|
+
resolved_path = self._path_resolver.resolve(
|
|
87
|
+
model,
|
|
88
|
+
selector.field_path,
|
|
89
|
+
field_policy=options.field_policy if options else None,
|
|
90
|
+
)
|
|
91
|
+
return (
|
|
92
|
+
resolved_path.joins,
|
|
93
|
+
self._resolve_column_expression(resolved_path),
|
|
94
|
+
str if resolved_path.is_json else resolved_path.python_type,
|
|
95
|
+
)
|
|
96
|
+
if isinstance(selector, SemanticLiteralSelector):
|
|
97
|
+
python_type = (
|
|
98
|
+
type(selector.value) if selector.value is not None else None
|
|
99
|
+
)
|
|
100
|
+
return (), sa.literal(selector.value), python_type
|
|
101
|
+
|
|
102
|
+
joins: list[SQLAlchemyJoinPlan] = []
|
|
103
|
+
argument_expressions: list[ColumnElement[Any]] = []
|
|
104
|
+
argument_types: list[type[Any] | None] = []
|
|
105
|
+
for argument in selector.arguments:
|
|
106
|
+
argument_joins, argument_expression, argument_type = (
|
|
107
|
+
self._translate_selector(
|
|
108
|
+
model,
|
|
109
|
+
argument,
|
|
110
|
+
options=options,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
joins.extend(argument_joins)
|
|
114
|
+
argument_expressions.append(argument_expression)
|
|
115
|
+
argument_types.append(argument_type)
|
|
116
|
+
function_expression = getattr(sa.func, selector.function_name)(
|
|
117
|
+
*argument_expressions
|
|
118
|
+
)
|
|
119
|
+
return (
|
|
120
|
+
tuple(joins),
|
|
121
|
+
cast(ColumnElement[Any], function_expression),
|
|
122
|
+
infer_sql_function_python_type(
|
|
123
|
+
selector.function_name,
|
|
124
|
+
tuple(argument_types),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _resolve_column_expression(
|
|
129
|
+
self,
|
|
130
|
+
resolved_path: SQLAlchemyResolvedPath,
|
|
131
|
+
) -> ColumnElement[Any]:
|
|
132
|
+
"""Builds the effective SQL expression for a resolved path."""
|
|
133
|
+
base_expression = resolved_path.leaf_attribute
|
|
134
|
+
if not resolved_path.is_json:
|
|
135
|
+
return base_expression
|
|
136
|
+
return self._json_path_builder.build_sort_expression(
|
|
137
|
+
base_expression,
|
|
138
|
+
resolved_path.json_path,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _build_order_clause(
|
|
142
|
+
self,
|
|
143
|
+
expression: ColumnElement[Any],
|
|
144
|
+
python_type: type[Any] | None,
|
|
145
|
+
field: SemanticSortField,
|
|
146
|
+
) -> ColumnElement[Any]:
|
|
147
|
+
"""Builds an ORDER BY clause for a resolved sort field."""
|
|
148
|
+
if field.ignore_case and is_string_python_type(python_type):
|
|
149
|
+
expression = cast(ColumnElement[Any], sa.func.lower(expression))
|
|
150
|
+
if field.direction is SortDirection.DESCENDING:
|
|
151
|
+
return cast(ColumnElement[Any], expression.desc())
|
|
152
|
+
return cast(ColumnElement[Any], expression.asc())
|
|
153
|
+
|
|
154
|
+
def _is_string_type(self, python_type: type[Any] | None) -> bool:
|
|
155
|
+
"""Returns whether the resolved Python type is string-compatible."""
|
|
156
|
+
if python_type is None:
|
|
157
|
+
return False
|
|
158
|
+
return issubclass(python_type, str)
|
|
159
|
+
|
|
160
|
+
def _infer_function_python_type(
|
|
161
|
+
self,
|
|
162
|
+
function_name: str,
|
|
163
|
+
argument_types: tuple[type[Any] | None, ...],
|
|
164
|
+
) -> type[Any] | None:
|
|
165
|
+
"""Infers the Python type for common SQL functions."""
|
|
166
|
+
normalized_name = function_name.lower()
|
|
167
|
+
if normalized_name in {"lower", "upper", "concat"}:
|
|
168
|
+
return str
|
|
169
|
+
if normalized_name == "coalesce":
|
|
170
|
+
for argument_type in argument_types:
|
|
171
|
+
if argument_type is not None:
|
|
172
|
+
return argument_type
|
|
173
|
+
return None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Helpers for mutating SQLAlchemy Select statements."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.sql import Select
|
|
6
|
+
|
|
7
|
+
from pyrsql.core.joins import JoinHint
|
|
8
|
+
from pyrsql.orms.sqlalchemy.errors import SQLAlchemyORMError
|
|
9
|
+
from pyrsql.orms.sqlalchemy.types import SQLAlchemyJoinPlan, SQLAlchemySelect
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def require_sqlalchemy_select(target: object) -> SQLAlchemySelect:
|
|
13
|
+
"""Validates and narrows an ORM target to a SQLAlchemy Select."""
|
|
14
|
+
if not isinstance(target, Select):
|
|
15
|
+
raise TypeError("SQLAlchemy ORM expects a sqlalchemy.sql.Select.")
|
|
16
|
+
return target
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def apply_relationship_joins(
|
|
20
|
+
statement: SQLAlchemySelect,
|
|
21
|
+
joins: tuple[SQLAlchemyJoinPlan, ...],
|
|
22
|
+
*,
|
|
23
|
+
join_hints: Mapping[str, JoinHint] | None = None,
|
|
24
|
+
) -> SQLAlchemySelect:
|
|
25
|
+
"""Applies relationship joins once each while preserving order."""
|
|
26
|
+
join_hints = join_hints or {}
|
|
27
|
+
deduplicated_joins = dict.fromkeys(joins)
|
|
28
|
+
updated_statement = statement
|
|
29
|
+
for join_plan in deduplicated_joins:
|
|
30
|
+
resolved_hint = join_hints.get(join_plan.key, join_plan.default_hint)
|
|
31
|
+
if resolved_hint is JoinHint.INNER:
|
|
32
|
+
updated_statement = updated_statement.join(join_plan.attribute)
|
|
33
|
+
continue
|
|
34
|
+
if resolved_hint is JoinHint.LEFT:
|
|
35
|
+
updated_statement = updated_statement.outerjoin(join_plan.attribute)
|
|
36
|
+
continue
|
|
37
|
+
raise SQLAlchemyORMError(
|
|
38
|
+
"SQLAlchemy ORM does not support RIGHT joins via join_hints."
|
|
39
|
+
)
|
|
40
|
+
return updated_statement
|