dplex 0.1.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.
- dplex-0.1.0/LICENSE +2 -0
- dplex-0.1.0/PKG-INFO +21 -0
- dplex-0.1.0/README.md +1 -0
- dplex-0.1.0/dplex/__init__.py +50 -0
- dplex-0.1.0/dplex/repositories/dp_repo.py +141 -0
- dplex-0.1.0/dplex/repositories/query_builder.py +294 -0
- dplex-0.1.0/dplex/services/dp_filters.py +309 -0
- dplex-0.1.0/dplex/services/dp_service.py +728 -0
- dplex-0.1.0/dplex/services/filter_applier.py +467 -0
- dplex-0.1.0/dplex/services/filters.py +822 -0
- dplex-0.1.0/dplex/services/sort.py +24 -0
- dplex-0.1.0/dplex/types.py +78 -0
- dplex-0.1.0/pyproject.toml +92 -0
dplex-0.1.0/LICENSE
ADDED
dplex-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dplex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary:
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Igor Chesnokov
|
|
8
|
+
Author-email: front-gold@mail.ru
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Dist: pydantic (>=2.11.9,<3.0.0)
|
|
18
|
+
Requires-Dist: sqlalchemy (>=2.0.43,<3.0.0)
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# dplex
|
dplex-0.1.0/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# dplex
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dplex - Enterprise-grade data layer framework for Python
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dplex.repositories.dp_repo import DPRepo
|
|
6
|
+
from dplex.types import NullMarker, NULL
|
|
7
|
+
from dplex.services.dp_service import DPService
|
|
8
|
+
from dplex.services.dp_filters import DPFilters
|
|
9
|
+
from dplex.services.sort import Sort, Order, NullsPlacement
|
|
10
|
+
from dplex.services.filters import (
|
|
11
|
+
StringFilter,
|
|
12
|
+
IntFilter,
|
|
13
|
+
FloatFilter,
|
|
14
|
+
DecimalFilter,
|
|
15
|
+
BooleanFilter,
|
|
16
|
+
DateFilter,
|
|
17
|
+
DateTimeFilter,
|
|
18
|
+
TimeFilter,
|
|
19
|
+
TimestampFilter,
|
|
20
|
+
EnumFilter,
|
|
21
|
+
UUIDFilter,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Core classes
|
|
28
|
+
"DPRepo",
|
|
29
|
+
"DPService",
|
|
30
|
+
"DPFilters",
|
|
31
|
+
# Sort
|
|
32
|
+
"Sort",
|
|
33
|
+
"Order",
|
|
34
|
+
"NullsPlacement",
|
|
35
|
+
# Filters
|
|
36
|
+
"StringFilter",
|
|
37
|
+
"IntFilter",
|
|
38
|
+
"FloatFilter",
|
|
39
|
+
"DecimalFilter",
|
|
40
|
+
"BooleanFilter",
|
|
41
|
+
"DateFilter",
|
|
42
|
+
"DateTimeFilter",
|
|
43
|
+
"TimeFilter",
|
|
44
|
+
"TimestampFilter",
|
|
45
|
+
"EnumFilter",
|
|
46
|
+
"UUIDFilter",
|
|
47
|
+
# Null marker
|
|
48
|
+
"NULL",
|
|
49
|
+
"NullMarker",
|
|
50
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Any, Generic
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import select, func, and_, delete, ColumnElement, update
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
from sqlalchemy.orm import InstrumentedAttribute
|
|
7
|
+
|
|
8
|
+
from dplex.repositories.query_builder import QueryBuilder
|
|
9
|
+
from dplex.types import ModelType, KeyType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DPRepo(Generic[ModelType, KeyType]):
|
|
13
|
+
"""Базовый репозиторий с улучшенной типизацией"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
model: type[ModelType],
|
|
18
|
+
session: AsyncSession,
|
|
19
|
+
key_type: type[KeyType] = uuid.UUID,
|
|
20
|
+
id_field_name: str = "id",
|
|
21
|
+
) -> None:
|
|
22
|
+
self.model = model
|
|
23
|
+
self.session = session
|
|
24
|
+
self.key_type = key_type
|
|
25
|
+
self.id_field_name = id_field_name
|
|
26
|
+
self._id_column = self._get_id_column()
|
|
27
|
+
|
|
28
|
+
def _get_id_column(self) -> InstrumentedAttribute[KeyType]:
|
|
29
|
+
"""Получить типизированную ID колонку"""
|
|
30
|
+
if not hasattr(self.model, self.id_field_name):
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Model {self.model.__name__} does not have field '{self.id_field_name}'"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
column = getattr(self.model, self.id_field_name)
|
|
36
|
+
|
|
37
|
+
# Проверяем что это SQLAlchemy column
|
|
38
|
+
if not hasattr(column, "property"):
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Field '{self.id_field_name}' in {self.model.__name__} is not a SQLAlchemy column"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return column
|
|
44
|
+
|
|
45
|
+
def query(self) -> "QueryBuilder[ModelType]":
|
|
46
|
+
"""Создать типизированный query builder"""
|
|
47
|
+
return QueryBuilder(self, self.model)
|
|
48
|
+
|
|
49
|
+
def id_eq(self, value: KeyType) -> ColumnElement[bool]:
|
|
50
|
+
"""Возвращает условие для сравнения ID с entity_id"""
|
|
51
|
+
return self._id_column == value
|
|
52
|
+
|
|
53
|
+
def id_in(self, values: list[KeyType]) -> ColumnElement[bool]:
|
|
54
|
+
"""Возвращает условие для проверки ID в списке значений"""
|
|
55
|
+
return self._id_column.in_(values)
|
|
56
|
+
|
|
57
|
+
# Методы с использованием закешированной колонки
|
|
58
|
+
async def find_by_id(self, entity_id: KeyType) -> ModelType | None:
|
|
59
|
+
"""Найти сущность по ID"""
|
|
60
|
+
return await self.query().where(self.id_eq(entity_id)).find_one()
|
|
61
|
+
|
|
62
|
+
async def find_by_ids(self, entity_ids: list[KeyType]) -> list[ModelType]:
|
|
63
|
+
"""Найти сущности по списку ID"""
|
|
64
|
+
return await self.query().where(self.id_in(entity_ids)).find_all()
|
|
65
|
+
|
|
66
|
+
async def delete_by_id(self, entity_id: KeyType) -> None:
|
|
67
|
+
"""Удалить сущность по ID"""
|
|
68
|
+
stmt = delete(self.model).where(self.id_eq(entity_id))
|
|
69
|
+
await self.session.execute(stmt)
|
|
70
|
+
|
|
71
|
+
async def delete_by_ids(self, entity_ids: list[KeyType]) -> None:
|
|
72
|
+
"""Удалить сущности по ID"""
|
|
73
|
+
stmt = delete(self.model).where(self.id_in(entity_ids))
|
|
74
|
+
await self.session.execute(stmt)
|
|
75
|
+
|
|
76
|
+
async def update_by_id(self, entity_id: KeyType, values: dict[str, Any]) -> None:
|
|
77
|
+
"""Обновить сущность по ID"""
|
|
78
|
+
stmt = update(self.model).where(self.id_eq(entity_id)).values(**values)
|
|
79
|
+
await self.session.execute(stmt)
|
|
80
|
+
|
|
81
|
+
async def update_by_ids(
|
|
82
|
+
self, entity_ids: list[KeyType], values: dict[str, Any]
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Обновить сущности по ID"""
|
|
85
|
+
stmt = update(self.model).where(self.id_in(entity_ids)).values(**values)
|
|
86
|
+
await self.session.execute(stmt)
|
|
87
|
+
|
|
88
|
+
async def exists_by_id(self, entity_id: KeyType) -> bool:
|
|
89
|
+
"""Проверить существование сущности по ID"""
|
|
90
|
+
count = await self.query().where(self.id_eq(entity_id)).count()
|
|
91
|
+
return count > 0
|
|
92
|
+
|
|
93
|
+
async def create(self, entity: ModelType) -> ModelType:
|
|
94
|
+
"""Создать новую сущность"""
|
|
95
|
+
self.session.add(entity)
|
|
96
|
+
return entity
|
|
97
|
+
|
|
98
|
+
async def create_bulk(self, entities: list[ModelType]) -> list[ModelType]:
|
|
99
|
+
"""Создать несколько сущностей"""
|
|
100
|
+
self.session.add_all(entities)
|
|
101
|
+
return entities
|
|
102
|
+
|
|
103
|
+
async def save_changes(self) -> None:
|
|
104
|
+
"""Сохранить изменения в базе данных"""
|
|
105
|
+
await self.session.commit()
|
|
106
|
+
|
|
107
|
+
async def rollback(self) -> None:
|
|
108
|
+
"""Откатить изменения"""
|
|
109
|
+
await self.session.rollback()
|
|
110
|
+
|
|
111
|
+
# Методы для выполнения запросов билдера
|
|
112
|
+
async def execute_typed_query(
|
|
113
|
+
self, builder: "QueryBuilder[ModelType]"
|
|
114
|
+
) -> list[ModelType]:
|
|
115
|
+
"""Выполнить типизированный запрос"""
|
|
116
|
+
stmt = select(self.model)
|
|
117
|
+
|
|
118
|
+
if builder.filters:
|
|
119
|
+
stmt = stmt.where(and_(*builder.filters))
|
|
120
|
+
|
|
121
|
+
if builder.order_by_clauses:
|
|
122
|
+
stmt = stmt.order_by(*builder.order_by_clauses)
|
|
123
|
+
|
|
124
|
+
if builder.limit_value is not None:
|
|
125
|
+
stmt = stmt.limit(builder.limit_value)
|
|
126
|
+
|
|
127
|
+
if builder.offset_value is not None:
|
|
128
|
+
stmt = stmt.offset(builder.offset_value)
|
|
129
|
+
|
|
130
|
+
result = await self.session.scalars(stmt)
|
|
131
|
+
return list(result.all())
|
|
132
|
+
|
|
133
|
+
async def execute_typed_count(self, builder: "QueryBuilder[ModelType]") -> int:
|
|
134
|
+
"""Подсчитать записи через типизированный билдер"""
|
|
135
|
+
stmt = select(func.count()).select_from(self.model)
|
|
136
|
+
|
|
137
|
+
if builder.filters:
|
|
138
|
+
stmt = stmt.where(and_(*builder.filters))
|
|
139
|
+
|
|
140
|
+
result = await self.session.execute(stmt)
|
|
141
|
+
return result.scalar_one()
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
from typing import Any, Generic, TYPE_CHECKING
|
|
2
|
+
from sqlalchemy import ColumnElement, asc, desc, nullsfirst, nullslast
|
|
3
|
+
from sqlalchemy.orm import InstrumentedAttribute
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from dplex.services.sort import Sort, Order, NullsPlacement
|
|
7
|
+
from dplex.types import ModelType
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from dplex.repositories.dp_repo import DPRepo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QueryBuilder(Generic[ModelType]):
|
|
14
|
+
"""Query Builder с улучшенной типизацией и поддержкой Sort"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, repo: "DPRepo[ModelType, Any]", model: type[ModelType]) -> None:
|
|
17
|
+
self.repo = repo
|
|
18
|
+
self.model = model
|
|
19
|
+
self.filters: list[ColumnElement[bool]] = []
|
|
20
|
+
self.limit_value: int | None = None
|
|
21
|
+
self.offset_value: int | None = None
|
|
22
|
+
self.order_by_clauses: list[Any] = []
|
|
23
|
+
|
|
24
|
+
def where(self, condition: ColumnElement[bool]) -> "QueryBuilder[ModelType]":
|
|
25
|
+
"""WHERE condition (принимает готовое условие)"""
|
|
26
|
+
self.filters.append(condition)
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
def where_eq(
|
|
30
|
+
self, column: InstrumentedAttribute[Any], value: Any
|
|
31
|
+
) -> "QueryBuilder[ModelType]":
|
|
32
|
+
"""WHERE column = value"""
|
|
33
|
+
condition: ColumnElement[bool] = column == value
|
|
34
|
+
return self.where(condition)
|
|
35
|
+
|
|
36
|
+
def where_ne(
|
|
37
|
+
self, column: InstrumentedAttribute[Any], value: Any
|
|
38
|
+
) -> "QueryBuilder[ModelType]":
|
|
39
|
+
"""WHERE column != value"""
|
|
40
|
+
condition: ColumnElement[bool] = column != value
|
|
41
|
+
return self.where(condition)
|
|
42
|
+
|
|
43
|
+
def where_in(
|
|
44
|
+
self, column: InstrumentedAttribute[Any], values: list[Any]
|
|
45
|
+
) -> "QueryBuilder[ModelType]":
|
|
46
|
+
"""WHERE column IN (values)"""
|
|
47
|
+
if not values:
|
|
48
|
+
# Если список пустой, добавляем условие которое всегда false
|
|
49
|
+
condition: ColumnElement[bool] = column.in_([])
|
|
50
|
+
else:
|
|
51
|
+
condition = column.in_(values)
|
|
52
|
+
return self.where(condition)
|
|
53
|
+
|
|
54
|
+
def where_not_in(
|
|
55
|
+
self, column: InstrumentedAttribute[Any], values: list[Any]
|
|
56
|
+
) -> "QueryBuilder[ModelType]":
|
|
57
|
+
"""WHERE column NOT IN (values)"""
|
|
58
|
+
if not values:
|
|
59
|
+
# Если список пустой, условие всегда true - не добавляем фильтр
|
|
60
|
+
return self
|
|
61
|
+
condition: ColumnElement[bool] = ~column.in_(values)
|
|
62
|
+
return self.where(condition)
|
|
63
|
+
|
|
64
|
+
def where_is_null(
|
|
65
|
+
self, column: InstrumentedAttribute[Any]
|
|
66
|
+
) -> "QueryBuilder[ModelType]":
|
|
67
|
+
"""WHERE column IS NULL"""
|
|
68
|
+
condition: ColumnElement[bool] = column.is_(None)
|
|
69
|
+
return self.where(condition)
|
|
70
|
+
|
|
71
|
+
def where_is_not_null(
|
|
72
|
+
self, column: InstrumentedAttribute[Any]
|
|
73
|
+
) -> "QueryBuilder[ModelType]":
|
|
74
|
+
"""WHERE column IS NOT NULL"""
|
|
75
|
+
condition: ColumnElement[bool] = column.isnot(None)
|
|
76
|
+
return self.where(condition)
|
|
77
|
+
|
|
78
|
+
def where_like(
|
|
79
|
+
self, column: InstrumentedAttribute[Any], pattern: str
|
|
80
|
+
) -> "QueryBuilder[ModelType]":
|
|
81
|
+
"""WHERE column LIKE pattern"""
|
|
82
|
+
condition: ColumnElement[bool] = column.like(pattern)
|
|
83
|
+
return self.where(condition)
|
|
84
|
+
|
|
85
|
+
def where_ilike(
|
|
86
|
+
self, column: InstrumentedAttribute[Any], pattern: str
|
|
87
|
+
) -> "QueryBuilder[ModelType]":
|
|
88
|
+
"""WHERE column ILIKE pattern (case-insensitive)"""
|
|
89
|
+
condition: ColumnElement[bool] = column.ilike(pattern)
|
|
90
|
+
return self.where(condition)
|
|
91
|
+
|
|
92
|
+
def where_between(
|
|
93
|
+
self, column: InstrumentedAttribute[Any], start: Any, end: Any
|
|
94
|
+
) -> "QueryBuilder[ModelType]":
|
|
95
|
+
"""WHERE column BETWEEN start AND end"""
|
|
96
|
+
condition: ColumnElement[bool] = column.between(start, end)
|
|
97
|
+
return self.where(condition)
|
|
98
|
+
|
|
99
|
+
def where_gt(
|
|
100
|
+
self, column: InstrumentedAttribute[Any], value: Any
|
|
101
|
+
) -> "QueryBuilder[ModelType]":
|
|
102
|
+
"""WHERE column > value"""
|
|
103
|
+
condition: ColumnElement[bool] = column > value
|
|
104
|
+
return self.where(condition)
|
|
105
|
+
|
|
106
|
+
def where_gte(
|
|
107
|
+
self, column: InstrumentedAttribute[Any], value: Any
|
|
108
|
+
) -> "QueryBuilder[ModelType]":
|
|
109
|
+
"""WHERE column >= value"""
|
|
110
|
+
condition: ColumnElement[bool] = column >= value
|
|
111
|
+
return self.where(condition)
|
|
112
|
+
|
|
113
|
+
def where_lt(
|
|
114
|
+
self, column: InstrumentedAttribute[Any], value: Any
|
|
115
|
+
) -> "QueryBuilder[ModelType]":
|
|
116
|
+
"""WHERE column < value"""
|
|
117
|
+
condition: ColumnElement[bool] = column < value
|
|
118
|
+
return self.where(condition)
|
|
119
|
+
|
|
120
|
+
def where_lte(
|
|
121
|
+
self, column: InstrumentedAttribute[Any], value: Any
|
|
122
|
+
) -> "QueryBuilder[ModelType]":
|
|
123
|
+
"""WHERE column <= value"""
|
|
124
|
+
condition: ColumnElement[bool] = column <= value
|
|
125
|
+
return self.where(condition)
|
|
126
|
+
|
|
127
|
+
def limit(self, limit: int) -> "QueryBuilder[ModelType]":
|
|
128
|
+
"""LIMIT записей"""
|
|
129
|
+
if limit < 0:
|
|
130
|
+
raise ValueError("Limit must be non-negative")
|
|
131
|
+
self.limit_value = limit
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def offset(self, offset: int) -> "QueryBuilder[ModelType]":
|
|
135
|
+
"""OFFSET записей"""
|
|
136
|
+
if offset < 0:
|
|
137
|
+
raise ValueError("Offset must be non-negative")
|
|
138
|
+
self.offset_value = offset
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def paginate(self, page: int, per_page: int) -> "QueryBuilder[ModelType]":
|
|
142
|
+
"""Пагинация (page начинается с 1)"""
|
|
143
|
+
if page < 1:
|
|
144
|
+
raise ValueError("Page must be >= 1")
|
|
145
|
+
if per_page < 1:
|
|
146
|
+
raise ValueError("Per page must be >= 1")
|
|
147
|
+
self.limit_value = per_page
|
|
148
|
+
self.offset_value = (page - 1) * per_page
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def order_by(
|
|
152
|
+
self, column: InstrumentedAttribute[Any], desc_order: bool = False
|
|
153
|
+
) -> "QueryBuilder[ModelType]":
|
|
154
|
+
"""
|
|
155
|
+
ORDER BY column
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
column: Колонка для сортировки
|
|
159
|
+
desc_order: True для DESC, False для ASC (по умолчанию)
|
|
160
|
+
"""
|
|
161
|
+
order_clause = column.desc() if desc_order else column.asc()
|
|
162
|
+
self.order_by_clauses.append(order_clause)
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def order_by_desc(
|
|
166
|
+
self, column: InstrumentedAttribute[Any]
|
|
167
|
+
) -> "QueryBuilder[ModelType]":
|
|
168
|
+
"""ORDER BY column DESC"""
|
|
169
|
+
return self.order_by(column, desc_order=True)
|
|
170
|
+
|
|
171
|
+
def order_by_asc(
|
|
172
|
+
self, column: InstrumentedAttribute[Any]
|
|
173
|
+
) -> "QueryBuilder[ModelType]":
|
|
174
|
+
"""ORDER BY column ASC"""
|
|
175
|
+
return self.order_by(column, desc_order=False)
|
|
176
|
+
|
|
177
|
+
def order_by_with_nulls(
|
|
178
|
+
self,
|
|
179
|
+
column: InstrumentedAttribute[Any],
|
|
180
|
+
desc_order: bool = False,
|
|
181
|
+
nulls_placement: NullsPlacement | None = None,
|
|
182
|
+
) -> "QueryBuilder[ModelType]":
|
|
183
|
+
"""
|
|
184
|
+
ORDER BY column с управлением NULL значениями
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
column: Колонка для сортировки
|
|
188
|
+
desc_order: True для DESC, False для ASC
|
|
189
|
+
nulls_placement: Размещение NULL (FIRST или LAST)
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
qb.order_by_with_nulls(
|
|
193
|
+
User.created_at,
|
|
194
|
+
desc_order=True,
|
|
195
|
+
nulls_placement=NullsPlacement.LAST
|
|
196
|
+
)
|
|
197
|
+
"""
|
|
198
|
+
# Создаем базовую сортировку
|
|
199
|
+
if desc_order:
|
|
200
|
+
order_clause = desc(column)
|
|
201
|
+
else:
|
|
202
|
+
order_clause = asc(column)
|
|
203
|
+
|
|
204
|
+
# Применяем nulls placement если указан
|
|
205
|
+
if nulls_placement == NullsPlacement.FIRST:
|
|
206
|
+
order_clause = nullsfirst(order_clause)
|
|
207
|
+
elif nulls_placement == NullsPlacement.LAST:
|
|
208
|
+
order_clause = nullslast(order_clause)
|
|
209
|
+
|
|
210
|
+
self.order_by_clauses.append(order_clause)
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
def apply_sort(
|
|
214
|
+
self, sort_item: Sort[Any], column: InstrumentedAttribute[Any]
|
|
215
|
+
) -> "QueryBuilder[ModelType]":
|
|
216
|
+
"""
|
|
217
|
+
Применить Sort объект к query builder
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
sort_item: Объект Sort с параметрами сортировки
|
|
221
|
+
column: Колонка модели для сортировки
|
|
222
|
+
|
|
223
|
+
Example:
|
|
224
|
+
sort = Sort(
|
|
225
|
+
field=UserSortField.CREATED_AT,
|
|
226
|
+
order=Order.DESC,
|
|
227
|
+
nulls=NullsPlacement.LAST
|
|
228
|
+
)
|
|
229
|
+
qb.apply_sort(sort, User.created_at)
|
|
230
|
+
"""
|
|
231
|
+
desc_order = sort_item.order == Order.DESC
|
|
232
|
+
return self.order_by_with_nulls(column, desc_order, sort_item.nulls)
|
|
233
|
+
|
|
234
|
+
def apply_sorts(
|
|
235
|
+
self,
|
|
236
|
+
sort_list: list[Sort[Any]],
|
|
237
|
+
column_mapper: dict[Any, InstrumentedAttribute[Any]],
|
|
238
|
+
) -> "QueryBuilder[ModelType]":
|
|
239
|
+
"""
|
|
240
|
+
Применить список Sort объектов к query builder
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
sort_list: Список объектов Sort
|
|
244
|
+
column_mapper: Словарь для маппинга field -> column
|
|
245
|
+
{SortField.USERNAME: User.username, ...}
|
|
246
|
+
|
|
247
|
+
Example:
|
|
248
|
+
sorts = [
|
|
249
|
+
Sort(field=UserSortField.CREATED_AT, order=Order.DESC),
|
|
250
|
+
Sort(field=UserSortField.USERNAME, order=Order.ASC)
|
|
251
|
+
]
|
|
252
|
+
mapper = {
|
|
253
|
+
UserSortField.CREATED_AT: User.created_at,
|
|
254
|
+
UserSortField.USERNAME: User.username
|
|
255
|
+
}
|
|
256
|
+
qb.apply_sorts(sorts, mapper)
|
|
257
|
+
"""
|
|
258
|
+
for sort_item in sort_list:
|
|
259
|
+
column = column_mapper.get(sort_item.by)
|
|
260
|
+
if column is None:
|
|
261
|
+
raise ValueError(f"Column mapping not found for field: {sort_item.by}")
|
|
262
|
+
self.apply_sort(sort_item, column)
|
|
263
|
+
return self
|
|
264
|
+
|
|
265
|
+
def clear_order(self) -> "QueryBuilder[ModelType]":
|
|
266
|
+
"""Очистить сортировку"""
|
|
267
|
+
self.order_by_clauses = []
|
|
268
|
+
return self
|
|
269
|
+
|
|
270
|
+
async def find_all(self) -> list[ModelType]:
|
|
271
|
+
"""Выполнить запрос и вернуть все результаты"""
|
|
272
|
+
return await self.repo.execute_typed_query(self)
|
|
273
|
+
|
|
274
|
+
async def find_one(self) -> ModelType | None:
|
|
275
|
+
"""Выполнить запрос и вернуть первый результат"""
|
|
276
|
+
self.limit_value = 1
|
|
277
|
+
results = await self.find_all()
|
|
278
|
+
return results[0] if results else None
|
|
279
|
+
|
|
280
|
+
async def find_first(self) -> ModelType:
|
|
281
|
+
"""Выполнить запрос и вернуть первый результат, иначе ошибка"""
|
|
282
|
+
result = await self.find_one()
|
|
283
|
+
if result is None:
|
|
284
|
+
raise ValueError(f"No {self.model.__name__} found matching criteria")
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
async def count(self) -> int:
|
|
288
|
+
"""Подсчитать количество записей"""
|
|
289
|
+
return await self.repo.execute_typed_count(self)
|
|
290
|
+
|
|
291
|
+
async def exists(self) -> bool:
|
|
292
|
+
"""Проверить существование записей"""
|
|
293
|
+
count = await self.count()
|
|
294
|
+
return count > 0
|