dotorm 2.0.8__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.
Files changed (50) hide show
  1. dotorm/__init__.py +87 -0
  2. dotorm/access.py +151 -0
  3. dotorm/builder/__init__.py +0 -0
  4. dotorm/builder/builder.py +72 -0
  5. dotorm/builder/helpers.py +63 -0
  6. dotorm/builder/mixins/__init__.py +11 -0
  7. dotorm/builder/mixins/crud.py +246 -0
  8. dotorm/builder/mixins/m2m.py +110 -0
  9. dotorm/builder/mixins/relations.py +96 -0
  10. dotorm/builder/protocol.py +63 -0
  11. dotorm/builder/request_builder.py +144 -0
  12. dotorm/components/__init__.py +18 -0
  13. dotorm/components/dialect.py +99 -0
  14. dotorm/components/filter_parser.py +195 -0
  15. dotorm/databases/__init__.py +13 -0
  16. dotorm/databases/abstract/__init__.py +25 -0
  17. dotorm/databases/abstract/dialect.py +134 -0
  18. dotorm/databases/abstract/pool.py +10 -0
  19. dotorm/databases/abstract/session.py +67 -0
  20. dotorm/databases/abstract/types.py +36 -0
  21. dotorm/databases/clickhouse/__init__.py +8 -0
  22. dotorm/databases/clickhouse/pool.py +60 -0
  23. dotorm/databases/clickhouse/session.py +100 -0
  24. dotorm/databases/mysql/__init__.py +13 -0
  25. dotorm/databases/mysql/pool.py +69 -0
  26. dotorm/databases/mysql/session.py +128 -0
  27. dotorm/databases/mysql/transaction.py +39 -0
  28. dotorm/databases/postgres/__init__.py +23 -0
  29. dotorm/databases/postgres/pool.py +133 -0
  30. dotorm/databases/postgres/session.py +174 -0
  31. dotorm/databases/postgres/transaction.py +82 -0
  32. dotorm/decorators.py +379 -0
  33. dotorm/exceptions.py +9 -0
  34. dotorm/fields.py +604 -0
  35. dotorm/integrations/__init__.py +0 -0
  36. dotorm/integrations/pydantic.py +275 -0
  37. dotorm/model.py +802 -0
  38. dotorm/orm/__init__.py +15 -0
  39. dotorm/orm/mixins/__init__.py +13 -0
  40. dotorm/orm/mixins/access.py +67 -0
  41. dotorm/orm/mixins/ddl.py +250 -0
  42. dotorm/orm/mixins/many2many.py +175 -0
  43. dotorm/orm/mixins/primary.py +218 -0
  44. dotorm/orm/mixins/relations.py +513 -0
  45. dotorm/orm/protocol.py +147 -0
  46. dotorm/orm/utils.py +39 -0
  47. dotorm-2.0.8.dist-info/METADATA +1240 -0
  48. dotorm-2.0.8.dist-info/RECORD +50 -0
  49. dotorm-2.0.8.dist-info/WHEEL +4 -0
  50. dotorm-2.0.8.dist-info/licenses/LICENSE +21 -0
dotorm/fields.py ADDED
@@ -0,0 +1,604 @@
1
+ """ORM field definitions."""
2
+
3
+ import datetime
4
+ from decimal import Decimal as PythonDecimal
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any, Callable, Type
7
+
8
+ if TYPE_CHECKING:
9
+ from .model import DotModel
10
+
11
+
12
+ log = logging.getLogger("dotorm")
13
+ from .exceptions import OrmConfigurationFieldException
14
+
15
+
16
+ class Field[FieldType]:
17
+ """
18
+ Base field class.
19
+
20
+ Attributes:
21
+ sql_type - DB field type
22
+ indexable - Can the field be indexed?
23
+ store - Is the field stored in DB? (False for computed/virtual)
24
+ required - Analog of null but reversed
25
+
26
+ index - Create index in database?
27
+ primary_key - Is the field primary key?
28
+ null - Is the column nullable?
29
+ unique - Is the field unique?
30
+ description - Field description
31
+ default - Default value
32
+ options - List options for selection field
33
+
34
+ relation - Is this a relation field?
35
+ relation_table - Related model class
36
+ relation_table_field - Field name in related model
37
+
38
+ schema_required - Override required status in API schema validation
39
+ True = required in schema (even if nullable)
40
+ False = optional in schema (even if not nullable)
41
+ None = auto-detect from type annotation
42
+ """
43
+
44
+ # DB attributes
45
+ index: bool = False
46
+ primary_key: bool = False
47
+ null: bool = True
48
+ unique: bool = False
49
+ description: str | None = None
50
+ ondelete: str = "set null"
51
+
52
+ # ORM attributes
53
+ required: bool | None = None
54
+ schema_required: bool | None = None
55
+ sql_type: str
56
+ indexable: bool = True
57
+ store: bool = True
58
+ default: FieldType | None = None
59
+
60
+ string: str = ""
61
+ options: list[str] | None = None
62
+ compute: Callable | None = None
63
+ # compute_deps: Set[str]
64
+ # is_computed: bool = False
65
+ relation: bool = False
66
+ relation_table_field: str | None = None
67
+ # наверное перенести в класс relation
68
+ _relation_table: "DotModel | None" = None
69
+
70
+ def __init__(self, **kwargs: Any) -> None:
71
+ # schema_required - переопределяет обязательность в API схеме
72
+ self.schema_required = kwargs.pop("schema_required", None)
73
+
74
+ # добавляем поле required для удобства работы
75
+ # которое переопределяет null
76
+ self.required = kwargs.pop("required", None)
77
+ if self.required is not None:
78
+ if self.required:
79
+ self.null = False
80
+ else:
81
+ self.null = True
82
+
83
+ # self.compute_deps: Set[str] = kwargs.pop("compute_deps", set())
84
+ self.indexable = kwargs.pop("indexable", self.indexable)
85
+ self.store = kwargs.pop("store", self.store)
86
+ self.ondelete = (
87
+ "set null" if kwargs.pop("null", self.null) else "restrict"
88
+ )
89
+
90
+ for name, value in kwargs.items():
91
+ setattr(self, name, value)
92
+ self.validation()
93
+
94
+ # обман тайп чекера.
95
+ # TODO: В идеале, сделать так чтобы тип поля менялся если это инстанс или если это класс.
96
+ # 1. Возможно это необходимо сделать в классе скорей всего модели
97
+ # 2. Или перейти на pep-0593 (Integer = Annotated[int, Integer(primary_key=True)])
98
+ # но тогда в классе не будет типа Field и мы получим такую же ситуаци но в классе
99
+ def __new__(cls, *args: Any, **kwargs: Any) -> FieldType:
100
+ return super().__new__(cls)
101
+
102
+ def validation(self):
103
+ if not self.indexable and (self.unique or self.index):
104
+ raise OrmConfigurationFieldException(
105
+ f"{self.__class__.__name__} can't be indexed"
106
+ )
107
+
108
+ if self.primary_key:
109
+ # UNIQUE or PRIMARY KEY constraint to prevent duplicate values
110
+ self.unique = True
111
+
112
+ if self.sql_type == "INTEGER":
113
+ self.sql_type = "SERIAL"
114
+ elif self.sql_type == "BIGINT":
115
+ self.sql_type = "BIGSERIAL"
116
+ elif self.sql_type == "SMALLINT":
117
+ self.sql_type = "SMALLSERIAL"
118
+ else:
119
+ raise OrmConfigurationFieldException(
120
+ f"{self.__class__.__name__} primary_key supported only for integer, bigint, smallint fields"
121
+ )
122
+
123
+ if not self.store:
124
+ raise OrmConfigurationFieldException(
125
+ f"{self.__class__.__name__} primary_key required store db"
126
+ )
127
+ if self.null:
128
+ log.debug(
129
+ f"{self.__class__.__name__} can't be both null=True and primary_key=True. Null will be ignored."
130
+ )
131
+ self.null = False
132
+ if self.index:
133
+ # self.index = False
134
+ raise OrmConfigurationFieldException(
135
+ f"{self.__class__.__name__} can't be both index=True and primary_key=True. Primary key have index already."
136
+ )
137
+ # первичный ключ уже автоинкрементируется как SERIAL и имеет значение по умолчанию
138
+ # DEFAULT nextval('tablename_colname_seq')
139
+ if self.default:
140
+ # self.default = None
141
+ raise OrmConfigurationFieldException(
142
+ f"{self.__class__.__name__} can't be both default=True and primary_key=True. Primary key autoincrement already."
143
+ )
144
+
145
+ if self.unique:
146
+ if self.index:
147
+ # self.index = False
148
+ raise OrmConfigurationFieldException(
149
+ f"{self.__class__.__name__} can't be both index=True and unique=True. Index will be ignored."
150
+ )
151
+
152
+ @property
153
+ def relation_table(self) -> "DotModel | None":
154
+ # если модель задана через лямбда функцию
155
+ if (
156
+ self._relation_table
157
+ and not isinstance(self._relation_table, type)
158
+ and callable(self._relation_table)
159
+ ):
160
+ return self._relation_table()
161
+ # если модель задана классом
162
+ return self._relation_table
163
+
164
+ @relation_table.setter
165
+ def relation_table(self, table):
166
+ self._relation_table = table
167
+
168
+
169
+ class Integer(Field[int]):
170
+ """Integer field (32-bit signed)."""
171
+
172
+ field_type = int
173
+ sql_type = "INTEGER"
174
+
175
+
176
+ class BigInteger(Field[int]):
177
+ """Big integer field (64-bit signed)."""
178
+
179
+ sql_type = "BIGINT"
180
+
181
+
182
+ class SmallInteger(Field[int]):
183
+ """Small integer field (16-bit signed)."""
184
+
185
+ sql_type = "SMALLINT"
186
+
187
+
188
+ class Char(Field[str]):
189
+ """Character field with optional max_length."""
190
+
191
+ field_type = str
192
+
193
+ def __init__(self, max_length: int | None = None, **kwargs: Any) -> None:
194
+ if max_length:
195
+ if not isinstance(max_length, int):
196
+ raise OrmConfigurationFieldException(
197
+ "'max_length' should be int, got %s" % type(max_length)
198
+ )
199
+ if max_length < 1:
200
+ raise OrmConfigurationFieldException(
201
+ "'max_length' must be >= 1"
202
+ )
203
+ self.max_length = max_length
204
+ super().__init__(**kwargs)
205
+
206
+ @property
207
+ def sql_type(self) -> str:
208
+ if self.max_length:
209
+ return f"VARCHAR({self.max_length})"
210
+ return "VARCHAR"
211
+
212
+
213
+ class Selection(Char):
214
+ """
215
+ Selection field - выбор из списка опций.
216
+
217
+ Хранится как VARCHAR, но имеет ограниченный набор допустимых значений.
218
+
219
+ Поддерживает расширение через @extend с selection_add:
220
+
221
+ # Базовая модель
222
+ class ChatConnector(DotModel):
223
+ __table__ = "chat_connector"
224
+ type = Selection(
225
+ options=[("internal", "Internal")],
226
+ default="internal",
227
+ )
228
+
229
+ # Расширение из другого модуля
230
+ @extend(ChatConnector)
231
+ class ChatConnectorTelegramMixin:
232
+ type = Selection(selection_add=[("telegram", "Telegram")])
233
+
234
+ Args:
235
+ options: Список кортежей (value, label) - базовые опции
236
+ selection_add: Дополнительные опции для расширения существующего поля
237
+ default: Значение по умолчанию
238
+ required: Обязательное поле
239
+ """
240
+
241
+ def __init__(
242
+ self,
243
+ options: list[tuple[str, str]] | None = None,
244
+ selection_add: list[tuple[str, str]] | None = None,
245
+ **kwargs,
246
+ ):
247
+ # Базовые опции
248
+ self._base_options: list[tuple[str, str]] = options or []
249
+ # Опции добавленные через selection_add (из @extend)
250
+ self._added_options: list[tuple[str, str]] = []
251
+ # selection_add при инициализации (для @extend)
252
+ self._selection_add = selection_add
253
+
254
+ # Для Char нужен max_length
255
+ if "max_length" not in kwargs:
256
+ kwargs["max_length"] = 64
257
+
258
+ super().__init__(**kwargs)
259
+
260
+ @property
261
+ def options(self) -> list[tuple[str, str]]:
262
+ """Все опции включая добавленные через extend."""
263
+ return self._base_options + self._added_options
264
+
265
+ @options.setter
266
+ def options(self, value: list[tuple[str, str]]):
267
+ """Установить базовые опции."""
268
+ self._base_options = value or []
269
+
270
+ def add_options(self, new_options: list[tuple[str, str]]) -> None:
271
+ """
272
+ Добавить опции к полю.
273
+
274
+ Используется системой расширений (@extend) для добавления
275
+ новых значений в Selection поле.
276
+
277
+ Args:
278
+ new_options: Список кортежей (value, label)
279
+ """
280
+ for opt in new_options:
281
+ if (
282
+ opt not in self._base_options
283
+ and opt not in self._added_options
284
+ ):
285
+ self._added_options.append(opt)
286
+
287
+ def get_values(self) -> list[str]:
288
+ """Получить список допустимых значений (без labels)."""
289
+ return [opt[0] for opt in self.options]
290
+
291
+ def get_label(self, value: str) -> str | None:
292
+ """Получить label для значения."""
293
+ for opt_value, opt_label in self.options:
294
+ if opt_value == value:
295
+ return opt_label
296
+ return None
297
+
298
+ def is_selection_add(self) -> bool:
299
+ """Проверить является ли это расширением (selection_add)."""
300
+ return self._selection_add is not None
301
+
302
+
303
+ class Text(Field[str]):
304
+ """Large text field."""
305
+
306
+ field_type = str
307
+ indexable = False
308
+ sql_type = "TEXT"
309
+
310
+ def __init__(self, **kwargs: Any) -> None:
311
+ super().__init__(**kwargs)
312
+ if self.unique:
313
+ raise OrmConfigurationFieldException(
314
+ "TextField doesn't support unique indexes, consider CharField or another strategy"
315
+ )
316
+ if self.index:
317
+ raise OrmConfigurationFieldException(
318
+ "TextField can't be indexed, consider CharField"
319
+ )
320
+
321
+ class _db_mysql:
322
+ sql_type = "LONGTEXT"
323
+
324
+
325
+ class Boolean(Field[bool]):
326
+ """Boolean field."""
327
+
328
+ field_type = bool
329
+ sql_type = "BOOL"
330
+
331
+
332
+ class Decimal(Field[PythonDecimal]):
333
+ """Accurate decimal field."""
334
+
335
+ def __init__(
336
+ self, max_digits: int, decimal_places: int, **kwargs: Any
337
+ ) -> None:
338
+ if int(max_digits) < 1:
339
+ raise OrmConfigurationFieldException("'max_digits' must be >= 1")
340
+ if int(decimal_places) < 0:
341
+ raise OrmConfigurationFieldException(
342
+ "'decimal_places' must be >= 0"
343
+ )
344
+
345
+ self.max_digits = int(max_digits)
346
+ self.decimal_places = int(decimal_places)
347
+ super().__init__(**kwargs)
348
+
349
+ @property
350
+ def sql_type(self) -> str:
351
+ return f"DECIMAL({self.max_digits},{self.decimal_places})"
352
+
353
+
354
+ class Datetime(Field[datetime.datetime]):
355
+ """Datetime field."""
356
+
357
+ sql_type = "TIMESTAMPTZ"
358
+
359
+ class _db_mysql:
360
+ sql_type = "DATETIME(6)"
361
+
362
+ class _db_postgres:
363
+ sql_type = "TIMESTAMPTZ"
364
+
365
+
366
+ class Date(Field[datetime.date]):
367
+ """Date field."""
368
+
369
+ sql_type = "DATE"
370
+
371
+
372
+ class Time(Field[datetime.time]):
373
+ """Time field."""
374
+
375
+ sql_type = "TIME"
376
+
377
+ class _db_mysql:
378
+ sql_type = "TIME(6)"
379
+
380
+ class _db_postgres:
381
+ sql_type = "TIMETZ"
382
+
383
+
384
+ class Float(Field[float]):
385
+ """Float (double) field."""
386
+
387
+ sql_type = "DOUBLE PRECISION"
388
+
389
+ class _db_mysql:
390
+ sql_type = "DOUBLE"
391
+
392
+
393
+ class JSONField(Field[dict | list]):
394
+ """JSON field."""
395
+
396
+ sql_type = "JSONB"
397
+ indexable = False
398
+
399
+ class _db_mysql:
400
+ sql_type = "JSON"
401
+
402
+
403
+ class Binary(Field[bytes]):
404
+ """Binary bytes field."""
405
+
406
+ sql_type = "BYTEA"
407
+ indexable = False
408
+
409
+ class _db_mysql:
410
+ sql_type = "VARBINARY"
411
+
412
+
413
+ # ==================== RELATION FIELDS ====================
414
+
415
+
416
+ class Many2one[T: "DotModel"](Field[T]):
417
+ """Many-to-one relation field."""
418
+
419
+ field_type = Type
420
+ sql_type = "INTEGER"
421
+ relation = True
422
+ relation_table: "DotModel"
423
+
424
+ def __init__(self, relation_table: T, **kwargs: Any) -> None:
425
+ self._relation_table = relation_table
426
+ super().__init__(**kwargs)
427
+
428
+
429
+ class AttachmentMany2one[T: "DotModel"](Field[T]):
430
+ """Many-to-one attachment field."""
431
+
432
+ field_type = Type
433
+ sql_type = "INTEGER"
434
+ relation = True
435
+ relation_table: "DotModel"
436
+
437
+ def __init__(self, relation_table: T, **kwargs: Any) -> None:
438
+ self._relation_table = relation_table
439
+ super().__init__(**kwargs)
440
+
441
+
442
+ class AttachmentOne2many[T: "DotModel"](Field[list[T]]):
443
+ """One-to-many attachment field."""
444
+
445
+ field_type = list[Type]
446
+ store = False
447
+ relation = True
448
+ relation_table: "DotModel"
449
+ relation_table_field: str
450
+
451
+ def __init__(
452
+ self,
453
+ relation_table: T,
454
+ relation_table_field: str,
455
+ **kwargs: Any,
456
+ ) -> None:
457
+ self._relation_table = relation_table
458
+ self.relation_table_field = relation_table_field
459
+ super().__init__(**kwargs)
460
+
461
+
462
+ # class Many2manyAccessor[T: "DotModel"]:
463
+ # """
464
+ # Accessor для работы с M2M полем на экземпляре модели.
465
+
466
+ # Позволяет использовать удобный синтаксис:
467
+ # await chat.member_ids.link([user1_id, user2_id])
468
+ # await chat.member_ids.unlink([user_id])
469
+
470
+ # Вместо:
471
+ # await chat.link_many2many(field=Chat.member_ids, values=[[chat.id, user_id]])
472
+ # """
473
+
474
+ # __slots__ = ("_instance", "_field", "_data")
475
+
476
+ # def __init__(
477
+ # self,
478
+ # instance: "DotModel",
479
+ # field: "Many2many[T]",
480
+ # data: list[T] | None = None,
481
+ # ):
482
+ # self._instance = instance
483
+ # self._field = field
484
+ # self._data = data
485
+
486
+ # async def link(self, ids: list[int], session=None):
487
+ # """
488
+ # Добавить связи M2M.
489
+
490
+ # Args:
491
+ # ids: Список ID записей для связывания
492
+ # session: Сессия БД
493
+
494
+ # Example:
495
+ # await chat.member_ids.link([user1_id, user2_id])
496
+ # """
497
+ # values = [[self._instance.id, id] for id in ids]
498
+ # return await self._instance.link_many2many(
499
+ # self._field, values, session
500
+ # )
501
+
502
+ # async def unlink(self, ids: list[int], session=None):
503
+ # """
504
+ # Удалить связи M2M.
505
+
506
+ # Args:
507
+ # ids: Список ID записей для отвязывания
508
+ # session: Сессия БД
509
+
510
+ # Example:
511
+ # await chat.member_ids.unlink([user_id])
512
+ # """
513
+ # return await self._instance.unlink_many2many(self._field, ids, session)
514
+
515
+ # # Поддержка итерации по загруженным данным
516
+ # def __iter__(self):
517
+ # if self._data is None:
518
+ # return iter([])
519
+ # return iter(self._data)
520
+
521
+ # def __len__(self):
522
+ # if self._data is None:
523
+ # return 0
524
+ # return len(self._data)
525
+
526
+ # def __bool__(self):
527
+ # return self._data is not None and len(self._data) > 0
528
+
529
+
530
+ class Many2many[T: "DotModel"](Field[list[T]]):
531
+ """Many-to-many relation field."""
532
+
533
+ field_type = list[Type]
534
+ store = False
535
+ relation = True
536
+ relation_table: "DotModel"
537
+ many2many_table: str
538
+
539
+ def __init__(
540
+ self,
541
+ relation_table: T,
542
+ many2many_table: str,
543
+ column1: str,
544
+ column2: str,
545
+ **kwargs: Any,
546
+ ) -> None:
547
+ self.relation_table = relation_table
548
+ self.many2many_table = many2many_table
549
+ self.column1: str = column1
550
+ self.column2 = column2
551
+ super().__init__(**kwargs)
552
+
553
+ # def __get__(
554
+ # self, instance: "DotModel | None", owner: type
555
+ # ) -> "Many2many[T] | Many2manyAccessor[T]":
556
+ # if instance is None:
557
+ # # Доступ через класс — возвращаем дескриптор (для get_fields и т.д.)
558
+ # return self
559
+ # # Доступ через экземпляр — возвращаем accessor
560
+ # # Получаем данные если они были загружены в __dict__
561
+ # data = instance.__dict__.get(self._field_name)
562
+ # return Many2manyAccessor(instance, self, data)
563
+
564
+ # def __set_name__(self, owner: type, name: str):
565
+ # self._field_name = name
566
+
567
+
568
+ class One2many[T: "DotModel"](Field[list[T]]):
569
+ """One-to-many relation field."""
570
+
571
+ field_type = list[Type]
572
+ store = False
573
+ relation = True
574
+ relation_table: "DotModel"
575
+ relation_table_field: str
576
+
577
+ def __init__(
578
+ self,
579
+ relation_table: T,
580
+ relation_table_field: str,
581
+ **kwargs: Any,
582
+ ) -> None:
583
+ self._relation_table = relation_table
584
+ self.relation_table_field = relation_table_field
585
+ super().__init__(**kwargs)
586
+
587
+
588
+ class One2one[T: "DotModel"](Field[T]):
589
+ """One-to-one relation field."""
590
+
591
+ field_type = Type
592
+ store = False
593
+ relation = True
594
+ relation_table: "DotModel"
595
+
596
+ def __init__(
597
+ self,
598
+ relation_table: T,
599
+ relation_table_field: str,
600
+ **kwargs: Any,
601
+ ) -> None:
602
+ self._relation_table = relation_table
603
+ self.relation_table_field = relation_table_field
604
+ super().__init__(**kwargs)
File without changes