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/query.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""High-level query object."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pyrsql.core.compiler import CompilationResult
|
|
7
|
+
from pyrsql.core.options import QueryOptions
|
|
8
|
+
from pyrsql.orms.base import ORM
|
|
9
|
+
from pyrsql.parsing.ast import Expression
|
|
10
|
+
from pyrsql.parsing.parser import Parser
|
|
11
|
+
from pyrsql.semantic.analyzer import SemanticAnalyzer
|
|
12
|
+
from pyrsql.semantic.ast import SemanticExpression
|
|
13
|
+
|
|
14
|
+
_DEFAULT_QUERY_OPTIONS = QueryOptions()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_query_options(
|
|
18
|
+
options: QueryOptions | None,
|
|
19
|
+
) -> QueryOptions:
|
|
20
|
+
"""Returns the provided options or the shared immutable default."""
|
|
21
|
+
return options or _DEFAULT_QUERY_OPTIONS
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_query_expression(
|
|
25
|
+
query_text: str,
|
|
26
|
+
*,
|
|
27
|
+
options: QueryOptions,
|
|
28
|
+
) -> Expression:
|
|
29
|
+
"""Parses raw query text into a syntax tree."""
|
|
30
|
+
return Parser(
|
|
31
|
+
query_text,
|
|
32
|
+
limits=options.parse_limits,
|
|
33
|
+
operator_registry=options.operator_registry,
|
|
34
|
+
).parse()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _analyze_query_expression(
|
|
38
|
+
expression: Expression,
|
|
39
|
+
*,
|
|
40
|
+
options: QueryOptions,
|
|
41
|
+
) -> SemanticExpression:
|
|
42
|
+
"""Analyzes a syntax tree into a semantic expression."""
|
|
43
|
+
return SemanticAnalyzer(options).analyze(expression)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class Query:
|
|
48
|
+
"""Represents a parsed ORM-neutral query request.
|
|
49
|
+
|
|
50
|
+
The query preserves the raw text, the parsed expression tree, and the
|
|
51
|
+
semantic representation used by later compilation steps.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
text: Raw RSQL text that produced the query.
|
|
55
|
+
options: Normalized query configuration used during parsing.
|
|
56
|
+
expression: Parsed syntax tree, if parsing succeeded.
|
|
57
|
+
semantic_expression: Semantic expression tree, if analysis succeeded.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
text: str
|
|
61
|
+
options: QueryOptions
|
|
62
|
+
expression: Expression | None = None
|
|
63
|
+
semantic_expression: SemanticExpression | None = None
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def parse(
|
|
67
|
+
cls,
|
|
68
|
+
query_text: str,
|
|
69
|
+
*,
|
|
70
|
+
options: QueryOptions | None = None,
|
|
71
|
+
) -> "Query":
|
|
72
|
+
"""Parses raw RSQL text into a query object.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
query_text: Raw RSQL text to parse.
|
|
76
|
+
options: Optional query configuration.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A parsed query object.
|
|
80
|
+
"""
|
|
81
|
+
resolved_options = _resolve_query_options(options)
|
|
82
|
+
expression = cls.parse_expression(query_text, options=resolved_options)
|
|
83
|
+
semantic_expression = cls.analyze_expression(
|
|
84
|
+
expression,
|
|
85
|
+
options=resolved_options,
|
|
86
|
+
)
|
|
87
|
+
return cls(
|
|
88
|
+
text=query_text,
|
|
89
|
+
options=resolved_options,
|
|
90
|
+
expression=expression,
|
|
91
|
+
semantic_expression=semantic_expression,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def parse_expression(
|
|
96
|
+
query_text: str,
|
|
97
|
+
*,
|
|
98
|
+
options: QueryOptions,
|
|
99
|
+
) -> Expression:
|
|
100
|
+
"""Parses raw RSQL text into a syntax tree.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
query_text: Raw RSQL text to parse.
|
|
104
|
+
options: Query configuration used by the parser.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The parsed syntax tree.
|
|
108
|
+
"""
|
|
109
|
+
return _parse_query_expression(query_text, options=options)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def analyze_expression(
|
|
113
|
+
expression: Expression,
|
|
114
|
+
*,
|
|
115
|
+
options: QueryOptions,
|
|
116
|
+
) -> SemanticExpression:
|
|
117
|
+
"""Analyzes a syntax tree into a semantic expression.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
expression: Parsed syntax tree to analyze.
|
|
121
|
+
options: Query configuration used by semantic analysis.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The semantic expression tree.
|
|
125
|
+
"""
|
|
126
|
+
return _analyze_query_expression(expression, options=options)
|
|
127
|
+
|
|
128
|
+
def compile(self, *, orm: ORM) -> CompilationResult:
|
|
129
|
+
"""Compiles the query using the provided ORM.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
orm: ORM adapter used to compile the query.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The ORM-specific compilation result.
|
|
136
|
+
"""
|
|
137
|
+
compiled_query = orm.compile_query(self)
|
|
138
|
+
return CompilationResult(
|
|
139
|
+
orm_name=orm.name,
|
|
140
|
+
compiled_query=compiled_query,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def apply(
|
|
144
|
+
self,
|
|
145
|
+
target: Any,
|
|
146
|
+
model: type[Any],
|
|
147
|
+
*,
|
|
148
|
+
orm: ORM,
|
|
149
|
+
) -> Any:
|
|
150
|
+
"""Compiles and applies the query using the provided ORM.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
target: ORM-specific target to mutate.
|
|
154
|
+
model: ORM model class used to resolve the query.
|
|
155
|
+
orm: ORM adapter used to compile the query.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
The value returned by the ORM-specific apply operation.
|
|
159
|
+
"""
|
|
160
|
+
return self.compile(orm=orm).apply(target=target, model=model)
|
pyrsql/core/sort.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""High-level sort object."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pyrsql.core.compiler import SortCompilationResult
|
|
7
|
+
from pyrsql.core.options import SortOptions
|
|
8
|
+
from pyrsql.orms.base import ORM
|
|
9
|
+
from pyrsql.sorting.analyzer import SortAnalyzer
|
|
10
|
+
from pyrsql.sorting.ast import SortField
|
|
11
|
+
from pyrsql.sorting.parser import SortParser
|
|
12
|
+
from pyrsql.sorting.semantic import SemanticSortField
|
|
13
|
+
|
|
14
|
+
_DEFAULT_SORT_OPTIONS = SortOptions()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_sort_options(
|
|
18
|
+
options: SortOptions | None,
|
|
19
|
+
) -> SortOptions:
|
|
20
|
+
"""Returns the provided options or the shared immutable default."""
|
|
21
|
+
return options or _DEFAULT_SORT_OPTIONS
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_sort_fields(
|
|
25
|
+
sort_text: str | None,
|
|
26
|
+
*,
|
|
27
|
+
options: SortOptions,
|
|
28
|
+
) -> tuple[SortField, ...]:
|
|
29
|
+
"""Parses raw sort text into sort fields."""
|
|
30
|
+
return SortParser(
|
|
31
|
+
sort_text,
|
|
32
|
+
limits=options.sort_limits,
|
|
33
|
+
).parse()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _analyze_sort_fields(
|
|
37
|
+
fields: tuple[SortField, ...],
|
|
38
|
+
*,
|
|
39
|
+
options: SortOptions,
|
|
40
|
+
) -> tuple[SemanticSortField, ...]:
|
|
41
|
+
"""Analyzes sort fields into semantic sort fields."""
|
|
42
|
+
return SortAnalyzer(options).analyze(fields)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Sort:
|
|
47
|
+
"""Represents an ORM-neutral parsed sort request.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
text: Raw sort text used to build the request.
|
|
51
|
+
options: Normalized sort configuration used during parsing.
|
|
52
|
+
fields: Parsed sort fields, if parsing succeeded.
|
|
53
|
+
semantic_fields: Semantic sort fields, if analysis succeeded.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
text: str | None
|
|
57
|
+
options: SortOptions
|
|
58
|
+
fields: tuple[SortField, ...] = ()
|
|
59
|
+
semantic_fields: tuple[SemanticSortField, ...] = ()
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def parse(
|
|
63
|
+
cls,
|
|
64
|
+
sort_text: str | None,
|
|
65
|
+
*,
|
|
66
|
+
options: SortOptions | None = None,
|
|
67
|
+
) -> "Sort":
|
|
68
|
+
"""Parses raw sort text into a sort object.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
sort_text: Raw sort text to parse.
|
|
72
|
+
options: Optional sort configuration.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A parsed sort object.
|
|
76
|
+
"""
|
|
77
|
+
resolved_options = _resolve_sort_options(options)
|
|
78
|
+
fields = cls.parse_fields(sort_text, options=resolved_options)
|
|
79
|
+
semantic_fields = cls.analyze_fields(fields, options=resolved_options)
|
|
80
|
+
return cls(
|
|
81
|
+
text=sort_text,
|
|
82
|
+
options=resolved_options,
|
|
83
|
+
fields=fields,
|
|
84
|
+
semantic_fields=semantic_fields,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def parse_fields(
|
|
89
|
+
sort_text: str | None,
|
|
90
|
+
*,
|
|
91
|
+
options: SortOptions,
|
|
92
|
+
) -> tuple[SortField, ...]:
|
|
93
|
+
"""Parses raw sort text into sort fields.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
sort_text: Raw sort text to parse.
|
|
97
|
+
options: Sort configuration used by the parser.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The parsed sort fields.
|
|
101
|
+
"""
|
|
102
|
+
return _parse_sort_fields(sort_text, options=options)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def analyze_fields(
|
|
106
|
+
fields: tuple[SortField, ...],
|
|
107
|
+
*,
|
|
108
|
+
options: SortOptions,
|
|
109
|
+
) -> tuple[SemanticSortField, ...]:
|
|
110
|
+
"""Analyzes sort fields into semantic sort fields.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
fields: Parsed sort fields to analyze.
|
|
114
|
+
options: Sort configuration used by semantic analysis.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The semantic sort fields.
|
|
118
|
+
"""
|
|
119
|
+
return _analyze_sort_fields(fields, options=options)
|
|
120
|
+
|
|
121
|
+
def compile(self, *, orm: ORM) -> SortCompilationResult:
|
|
122
|
+
"""Compiles the sort using the provided ORM.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
orm: ORM adapter used to compile the sort.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The ORM-specific sort compilation result.
|
|
129
|
+
"""
|
|
130
|
+
compiled_sort = orm.compile_sort(self)
|
|
131
|
+
return SortCompilationResult(
|
|
132
|
+
orm_name=orm.name,
|
|
133
|
+
compiled_sort=compiled_sort,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def apply(
|
|
137
|
+
self,
|
|
138
|
+
target: Any,
|
|
139
|
+
model: type[Any],
|
|
140
|
+
*,
|
|
141
|
+
orm: ORM,
|
|
142
|
+
) -> Any:
|
|
143
|
+
"""Compiles and applies the sort using the provided ORM.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
target: ORM-specific target to mutate.
|
|
147
|
+
model: ORM model class used to resolve the sort.
|
|
148
|
+
orm: ORM adapter used to compile the sort.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The value returned by the ORM-specific apply operation.
|
|
152
|
+
"""
|
|
153
|
+
return self.compile(orm=orm).apply(target=target, model=model)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""FastAPI + SQLAlchemy integration helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from fastapi import Depends
|
|
11
|
+
except ImportError as error: # pragma: no cover - import guard
|
|
12
|
+
raise ImportError(
|
|
13
|
+
"FastAPI integration requires installing the 'fastapi' extra: "
|
|
14
|
+
"pip install pyrsql[fastapi]"
|
|
15
|
+
) from error
|
|
16
|
+
|
|
17
|
+
from sqlalchemy import func, select
|
|
18
|
+
|
|
19
|
+
from pyrsql.adapters.fastapi import (
|
|
20
|
+
CriteriaDependency,
|
|
21
|
+
FastAPICriteriaConfig,
|
|
22
|
+
RequestCriteria,
|
|
23
|
+
)
|
|
24
|
+
from pyrsql.orms.sqlalchemy import SQLAlchemyORM
|
|
25
|
+
from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect
|
|
26
|
+
|
|
27
|
+
_DEFAULT_SQLALCHEMY_ORM = SQLAlchemyORM()
|
|
28
|
+
_DEFAULT_FASTAPI_CRITERIA_CONFIG = FastAPICriteriaConfig()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class SQLAlchemyPaginatedSelect:
|
|
33
|
+
"""Carries the list and count statements for a paginated query flow."""
|
|
34
|
+
|
|
35
|
+
statement: SQLAlchemySelect
|
|
36
|
+
count_statement: SQLAlchemySelect
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FastAPISQLAlchemyIntegration:
|
|
40
|
+
"""Composes the FastAPI adapter with the SQLAlchemy ORM integration."""
|
|
41
|
+
|
|
42
|
+
__slots__ = (
|
|
43
|
+
"orm",
|
|
44
|
+
"criteria_config",
|
|
45
|
+
"_criteria_dependency",
|
|
46
|
+
"_select_dependencies",
|
|
47
|
+
"_count_select_dependencies",
|
|
48
|
+
"_paginated_select_dependencies",
|
|
49
|
+
"_base_selects",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
orm: SQLAlchemyORM | None = None,
|
|
56
|
+
criteria_config: FastAPICriteriaConfig | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Creates an integration helper for FastAPI and SQLAlchemy."""
|
|
59
|
+
self.orm = orm or _DEFAULT_SQLALCHEMY_ORM
|
|
60
|
+
self.criteria_config = (
|
|
61
|
+
criteria_config or _DEFAULT_FASTAPI_CRITERIA_CONFIG
|
|
62
|
+
)
|
|
63
|
+
self._criteria_dependency = CriteriaDependency(self.criteria_config)
|
|
64
|
+
self._select_dependencies: dict[
|
|
65
|
+
SQLAlchemyModel, Callable[..., SQLAlchemySelect]
|
|
66
|
+
] = {}
|
|
67
|
+
self._count_select_dependencies: dict[
|
|
68
|
+
SQLAlchemyModel, Callable[..., SQLAlchemySelect]
|
|
69
|
+
] = {}
|
|
70
|
+
self._paginated_select_dependencies: dict[
|
|
71
|
+
SQLAlchemyModel, Callable[..., SQLAlchemyPaginatedSelect]
|
|
72
|
+
] = {}
|
|
73
|
+
self._base_selects: dict[SQLAlchemyModel, SQLAlchemySelect] = {}
|
|
74
|
+
|
|
75
|
+
def criteria_dependency(self) -> CriteriaDependency:
|
|
76
|
+
"""Returns a configured FastAPI dependency for request criteria."""
|
|
77
|
+
return self._criteria_dependency
|
|
78
|
+
|
|
79
|
+
def _apply_query(
|
|
80
|
+
self,
|
|
81
|
+
statement: SQLAlchemySelect,
|
|
82
|
+
model: SQLAlchemyModel,
|
|
83
|
+
criteria: RequestCriteria,
|
|
84
|
+
) -> SQLAlchemySelect:
|
|
85
|
+
"""Applies only the filtering part of request criteria."""
|
|
86
|
+
if criteria.query is None:
|
|
87
|
+
return statement
|
|
88
|
+
return cast(
|
|
89
|
+
SQLAlchemySelect,
|
|
90
|
+
criteria.query.apply(statement, model, orm=self.orm),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _apply_sort_and_page(
|
|
94
|
+
self,
|
|
95
|
+
statement: SQLAlchemySelect,
|
|
96
|
+
model: SQLAlchemyModel,
|
|
97
|
+
criteria: RequestCriteria,
|
|
98
|
+
) -> SQLAlchemySelect:
|
|
99
|
+
"""Applies sort and page semantics on top of a filtered statement."""
|
|
100
|
+
updated_statement = statement
|
|
101
|
+
if criteria.sort is not None:
|
|
102
|
+
updated_statement = cast(
|
|
103
|
+
SQLAlchemySelect,
|
|
104
|
+
criteria.sort.apply(updated_statement, model, orm=self.orm),
|
|
105
|
+
)
|
|
106
|
+
if criteria.page_request is not None:
|
|
107
|
+
updated_statement = cast(
|
|
108
|
+
SQLAlchemySelect,
|
|
109
|
+
criteria.page_request.apply(
|
|
110
|
+
updated_statement,
|
|
111
|
+
model,
|
|
112
|
+
orm=self.orm,
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
return updated_statement
|
|
116
|
+
|
|
117
|
+
def _filtered_select(
|
|
118
|
+
self,
|
|
119
|
+
model: SQLAlchemyModel,
|
|
120
|
+
criteria: RequestCriteria,
|
|
121
|
+
) -> SQLAlchemySelect:
|
|
122
|
+
"""Builds the common filtered select used by list and count flows."""
|
|
123
|
+
return self._apply_query(self._base_select(model), model, criteria)
|
|
124
|
+
|
|
125
|
+
def _base_select(self, model: SQLAlchemyModel) -> SQLAlchemySelect:
|
|
126
|
+
"""Returns a cached base select(model) statement."""
|
|
127
|
+
statement = self._base_selects.get(model)
|
|
128
|
+
if statement is not None:
|
|
129
|
+
return statement
|
|
130
|
+
statement = select(model)
|
|
131
|
+
self._base_selects[model] = statement
|
|
132
|
+
return statement
|
|
133
|
+
|
|
134
|
+
def _count_from_filtered_select(
|
|
135
|
+
self,
|
|
136
|
+
filtered_statement: SQLAlchemySelect,
|
|
137
|
+
) -> SQLAlchemySelect:
|
|
138
|
+
"""Builds a count statement from an already-filtered select."""
|
|
139
|
+
return select(func.count()).select_from( # pylint: disable=not-callable
|
|
140
|
+
filtered_statement.order_by(None).subquery()
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def apply(
|
|
144
|
+
self,
|
|
145
|
+
statement: SQLAlchemySelect,
|
|
146
|
+
model: SQLAlchemyModel,
|
|
147
|
+
criteria: RequestCriteria,
|
|
148
|
+
) -> SQLAlchemySelect:
|
|
149
|
+
"""Applies request criteria to an existing SQLAlchemy select."""
|
|
150
|
+
return cast(
|
|
151
|
+
SQLAlchemySelect,
|
|
152
|
+
criteria.apply(statement, model, orm=self.orm),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def select(
|
|
156
|
+
self,
|
|
157
|
+
model: SQLAlchemyModel,
|
|
158
|
+
criteria: RequestCriteria,
|
|
159
|
+
) -> SQLAlchemySelect:
|
|
160
|
+
"""Builds a select statement for a model and applies criteria."""
|
|
161
|
+
filtered_statement = self._filtered_select(model, criteria)
|
|
162
|
+
return self._apply_sort_and_page(
|
|
163
|
+
filtered_statement,
|
|
164
|
+
model,
|
|
165
|
+
criteria,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def count_select(
|
|
169
|
+
self,
|
|
170
|
+
model: SQLAlchemyModel,
|
|
171
|
+
criteria: RequestCriteria,
|
|
172
|
+
) -> SQLAlchemySelect:
|
|
173
|
+
"""Builds a count statement from the filtered query semantics only."""
|
|
174
|
+
return self._count_from_filtered_select(
|
|
175
|
+
self._filtered_select(model, criteria)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def paginated_select(
|
|
179
|
+
self,
|
|
180
|
+
model: SQLAlchemyModel,
|
|
181
|
+
criteria: RequestCriteria,
|
|
182
|
+
) -> SQLAlchemyPaginatedSelect:
|
|
183
|
+
"""Builds both list and count statements for a paginated flow."""
|
|
184
|
+
filtered_statement = self._filtered_select(model, criteria)
|
|
185
|
+
return SQLAlchemyPaginatedSelect(
|
|
186
|
+
statement=self._apply_sort_and_page(
|
|
187
|
+
filtered_statement,
|
|
188
|
+
model,
|
|
189
|
+
criteria,
|
|
190
|
+
),
|
|
191
|
+
count_statement=self._count_from_filtered_select(
|
|
192
|
+
filtered_statement
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def select_dependency(
|
|
197
|
+
self,
|
|
198
|
+
model: SQLAlchemyModel,
|
|
199
|
+
) -> Callable[..., SQLAlchemySelect]:
|
|
200
|
+
"""Returns a FastAPI dependency that yields a filtered select."""
|
|
201
|
+
cached_dependency = self._select_dependencies.get(model)
|
|
202
|
+
if cached_dependency is not None:
|
|
203
|
+
return cached_dependency
|
|
204
|
+
criteria_dependency = self.criteria_dependency()
|
|
205
|
+
|
|
206
|
+
def dependency(
|
|
207
|
+
criteria: Any = Depends(criteria_dependency),
|
|
208
|
+
) -> SQLAlchemySelect:
|
|
209
|
+
return self.select(model, cast(RequestCriteria, criteria))
|
|
210
|
+
|
|
211
|
+
self._select_dependencies[model] = dependency
|
|
212
|
+
return dependency
|
|
213
|
+
|
|
214
|
+
def count_select_dependency(
|
|
215
|
+
self,
|
|
216
|
+
model: SQLAlchemyModel,
|
|
217
|
+
) -> Callable[..., SQLAlchemySelect]:
|
|
218
|
+
"""Returns a FastAPI dependency that yields a count select."""
|
|
219
|
+
cached_dependency = self._count_select_dependencies.get(model)
|
|
220
|
+
if cached_dependency is not None:
|
|
221
|
+
return cached_dependency
|
|
222
|
+
criteria_dependency = self.criteria_dependency()
|
|
223
|
+
|
|
224
|
+
def dependency(
|
|
225
|
+
criteria: Any = Depends(criteria_dependency),
|
|
226
|
+
) -> SQLAlchemySelect:
|
|
227
|
+
return self.count_select(model, cast(RequestCriteria, criteria))
|
|
228
|
+
|
|
229
|
+
self._count_select_dependencies[model] = dependency
|
|
230
|
+
return dependency
|
|
231
|
+
|
|
232
|
+
def paginated_select_dependency(
|
|
233
|
+
self,
|
|
234
|
+
model: SQLAlchemyModel,
|
|
235
|
+
) -> Callable[..., SQLAlchemyPaginatedSelect]:
|
|
236
|
+
"""Returns a FastAPI dependency yielding list and count statements."""
|
|
237
|
+
cached_dependency = self._paginated_select_dependencies.get(model)
|
|
238
|
+
if cached_dependency is not None:
|
|
239
|
+
return cached_dependency
|
|
240
|
+
criteria_dependency = self.criteria_dependency()
|
|
241
|
+
|
|
242
|
+
def dependency(
|
|
243
|
+
criteria: Any = Depends(criteria_dependency),
|
|
244
|
+
) -> SQLAlchemyPaginatedSelect:
|
|
245
|
+
return self.paginated_select(
|
|
246
|
+
model,
|
|
247
|
+
cast(RequestCriteria, criteria),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
self._paginated_select_dependencies[model] = dependency
|
|
251
|
+
return dependency
|
pyrsql/orms/__init__.py
ADDED
pyrsql/orms/base.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Shared ORM contracts."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from pyrsql.core.page import PageRequest
|
|
8
|
+
from pyrsql.core.query import Query
|
|
9
|
+
from pyrsql.core.sort import Sort
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CompiledQuery(Protocol):
|
|
13
|
+
"""Protocol implemented by ORM-specific compiled query objects."""
|
|
14
|
+
|
|
15
|
+
def apply(self, target: Any, model: type[Any]) -> Any:
|
|
16
|
+
"""Applies a compiled query to an ORM-specific target.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
target: ORM-specific target to mutate.
|
|
20
|
+
model: ORM model class used to resolve the query.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The value returned by the compiled query application.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CompiledSort(Protocol):
|
|
28
|
+
"""Protocol implemented by ORM-specific compiled sort objects."""
|
|
29
|
+
|
|
30
|
+
def apply(self, target: Any, model: type[Any]) -> Any:
|
|
31
|
+
"""Applies a compiled sort to an ORM-specific target.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
target: ORM-specific target to mutate.
|
|
35
|
+
model: ORM model class used to resolve the sort.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The value returned by the compiled sort application.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CompiledPageRequest(Protocol):
|
|
43
|
+
"""Protocol implemented by ORM-specific compiled page objects."""
|
|
44
|
+
|
|
45
|
+
def apply(self, target: Any, model: type[Any]) -> Any:
|
|
46
|
+
"""Applies a compiled page request to an ORM-specific target.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
target: ORM-specific target to mutate.
|
|
50
|
+
model: ORM model class used to resolve the page request.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The value returned by the compiled page application.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ORM(ABC):
|
|
58
|
+
"""Abstract ORM contract used by the pyrsql public API."""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def name(self) -> str:
|
|
63
|
+
"""The stable ORM name."""
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def compile_query(self, query: "Query") -> CompiledQuery:
|
|
67
|
+
"""Compiles a high-level query into an ORM-specific form.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
query: High-level query to compile.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A compiled query object for this ORM.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def compile_sort(self, sort: "Sort") -> CompiledSort:
|
|
78
|
+
"""Compiles a high-level sort into an ORM-specific form.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
sort: High-level sort request to compile.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
A compiled sort object for this ORM.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def compile_page_request(
|
|
89
|
+
self,
|
|
90
|
+
page_request: "PageRequest",
|
|
91
|
+
) -> CompiledPageRequest:
|
|
92
|
+
"""Compiles a pagination request into an ORM-specific form.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
page_request: Pagination request to compile.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A compiled page request object for this ORM.
|
|
99
|
+
"""
|