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 ADDED
@@ -0,0 +1,2 @@
1
+ License
2
+
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