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
@@ -0,0 +1,110 @@
1
+ """Many2many query builder."""
2
+
3
+ from typing import TYPE_CHECKING, Literal
4
+
5
+ if TYPE_CHECKING:
6
+ from ..protocol import BuilderProtocol
7
+ from ...orm.model import DotModel
8
+
9
+
10
+ class Many2ManyMixin:
11
+ """Mixin for many-to-many relation queries."""
12
+
13
+ __slots__ = ()
14
+
15
+ def build_get_many2many(
16
+ self: "BuilderProtocol",
17
+ id: int,
18
+ relation_table: "DotModel",
19
+ many2many_table: str,
20
+ column1: str,
21
+ column2: str,
22
+ fields: list[str],
23
+ order: Literal["desc", "asc"] = "desc",
24
+ start: int | None = None,
25
+ end: int | None = None,
26
+ sort: str = "id",
27
+ limit: int | None = 10,
28
+ ) -> tuple[str, tuple]:
29
+ """Build SELECT for M2M relation."""
30
+ if not fields:
31
+ fields = relation_table.get_store_fields()
32
+
33
+ # явно указать для sql запроса что эти поля относятся
34
+ # к связанной таблице
35
+ fields_prefixed = [f"p.{field}" for field in fields]
36
+ fields_select_stmt = ", ".join(fields_prefixed)
37
+
38
+ stmt = f"""
39
+ SELECT {fields_select_stmt}
40
+ FROM {relation_table.__table__} p
41
+ JOIN {many2many_table} pt ON p.id = pt.{column1}
42
+ JOIN {self.table} t ON pt.{column2} = t.id
43
+ WHERE t.id = %s
44
+ ORDER BY {sort} {order}
45
+ """
46
+
47
+ val: tuple = (id,)
48
+
49
+ if end is not None and start is not None:
50
+ stmt += "LIMIT %s OFFSET %s"
51
+ val += (end - start, start)
52
+ elif limit:
53
+ stmt += "LIMIT %s"
54
+ val += (limit,)
55
+
56
+ return stmt, val
57
+
58
+ def build_get_many2many_multiple(
59
+ self: "BuilderProtocol",
60
+ ids: list[int],
61
+ relation_table: "DotModel",
62
+ many2many_table: str,
63
+ column1: str,
64
+ column2: str,
65
+ fields: list[str] | None = None,
66
+ limit: int = 80,
67
+ ) -> tuple[str, tuple]:
68
+ """
69
+ Оптимизированная версия, когда необходимо получить сразу несколько свзяей m2m
70
+ у нескольких записей. Не просто один список на одну записиь.
71
+ А N списков на N записей.
72
+
73
+ Returns:
74
+ tuple[str, tuple]: SQL statement and parameter values
75
+ """
76
+ if not fields:
77
+ fields = relation_table.get_store_fields()
78
+
79
+ # явно указать для sql запроса что эти поля относятся
80
+ # к связанной таблице
81
+ fields_prefixed = [f"p.{field}" for field in fields]
82
+
83
+ # добавляем ид из таблицы связи для последующего маппинга записей
84
+ # имеется ввиду за один запрос достаются все записи для всех ид
85
+ # а далее в питоне для каждого ид остаются только его
86
+ fields_prefixed.append(f"pt.{column2} as m2m_id")
87
+
88
+ fields_select_stmt = ", ".join(fields_prefixed)
89
+ query_placeholders = ", ".join(["%s"] * len(ids))
90
+
91
+ stmt = f"""
92
+ SELECT {fields_select_stmt}
93
+ FROM {relation_table.__table__} p
94
+ JOIN {many2many_table} pt ON p.id = pt.{column1}
95
+ JOIN {self.table} t ON pt.{column2} = t.id
96
+ WHERE t.id IN ({query_placeholders})
97
+ LIMIT %s
98
+ """
99
+
100
+ val = (*ids, limit)
101
+ return stmt, val
102
+
103
+
104
+ # SELECT * FROM (
105
+ # SELECT p.*, pt.column2 as m2m_id,
106
+ # ROW_NUMBER() OVER (PARTITION BY pt.column2 ORDER BY p.id) as rn
107
+ # FROM relation_table p
108
+ # JOIN m2m_table pt ON p.id = pt.column1
109
+ # WHERE pt.column2 IN (%s, %s, ...)
110
+ # ) sub WHERE rn <= {limit_per_parent}
@@ -0,0 +1,96 @@
1
+ """Relations query builder."""
2
+
3
+ from typing import TYPE_CHECKING, Self
4
+
5
+ if TYPE_CHECKING:
6
+ from ..protocol import BuilderProtocol
7
+
8
+ from ..request_builder import RequestBuilder
9
+ from ...fields import Field, Many2many, Many2one, One2many
10
+
11
+
12
+ class RelationsMixin:
13
+ """Mixin for relation queries."""
14
+
15
+ __slots__ = ()
16
+
17
+ def build_search_relation(
18
+ self: "BuilderProtocol",
19
+ fields_relation: list[tuple[str, Field]],
20
+ records: list | None = None,
21
+ ) -> list[RequestBuilder]:
22
+ """
23
+ Build optimized queries for loading relations.
24
+ Avoids N+1 by batching relation queries.
25
+ """
26
+ if records is None:
27
+ records = []
28
+
29
+ request_list: list[RequestBuilder] = []
30
+ ids: list[int] = [record.id for record in records]
31
+
32
+ if not ids:
33
+ return request_list
34
+
35
+ for name, field in fields_relation:
36
+ # Default fields for relations
37
+ fields = ["id"]
38
+ if field.relation_table and field.relation_table.get_fields().get(
39
+ "name"
40
+ ):
41
+ fields.append("name")
42
+
43
+ req: RequestBuilder | None = None
44
+
45
+ if isinstance(field, One2many):
46
+ stmt, val = field.relation_table._builder.build_search(
47
+ fields=[*fields, field.relation_table_field],
48
+ filter=[(field.relation_table_field, "in", ids)],
49
+ )
50
+ req = RequestBuilder(
51
+ stmt=stmt,
52
+ value=val,
53
+ field_name=name,
54
+ field=field,
55
+ fields=fields,
56
+ )
57
+
58
+ elif isinstance(field, Many2many):
59
+ stmt, val = self.build_get_many2many_multiple(
60
+ ids=ids,
61
+ relation_table=field.relation_table,
62
+ many2many_table=field.many2many_table,
63
+ column1=field.column1,
64
+ column2=field.column2,
65
+ fields=fields,
66
+ )
67
+ req = RequestBuilder(
68
+ stmt=stmt,
69
+ value=val,
70
+ field_name=name,
71
+ field=field,
72
+ )
73
+
74
+ elif isinstance(field, Many2one):
75
+ ids_m2o: list[int] = [
76
+ getattr(record, name) for record in records
77
+ ]
78
+ # оставляем только уникальные ид, так как в m2o несколько записей
79
+ # могут ссылаться на одну сущность
80
+ ids_m2o = list(set(ids_m2o))
81
+
82
+ stmt, val = field.relation_table._builder.build_search(
83
+ fields=fields,
84
+ filter=[("id", "in", ids_m2o)],
85
+ )
86
+ req = RequestBuilder(
87
+ stmt=stmt,
88
+ value=val,
89
+ field_name=name,
90
+ field=field,
91
+ )
92
+
93
+ if req:
94
+ request_list.append(req)
95
+
96
+ return request_list
@@ -0,0 +1,63 @@
1
+ """Protocol defining what mixins expect from the base class."""
2
+
3
+ from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable
4
+
5
+ from ..components.filter_parser import FilterExpression
6
+
7
+ if TYPE_CHECKING:
8
+ from ..model import DotModel
9
+ from ..fields import Field
10
+ from ..components.dialect import Dialect
11
+ from ..components.filter_parser import FilterParser
12
+
13
+
14
+ @runtime_checkable
15
+ class BuilderProtocol(Protocol):
16
+ """Contract that mixins expect from the base class."""
17
+
18
+ table: str
19
+ fields: dict[str, "Field"]
20
+ dialect: "Dialect"
21
+ filter_parser: "FilterParser"
22
+
23
+ def get_store_fields(self) -> list[str]:
24
+ """Returns field names that are stored in DB."""
25
+ ...
26
+
27
+ def build_search(
28
+ self,
29
+ fields: list[str] | None = None,
30
+ start: int | None = None,
31
+ end: int | None = None,
32
+ limit: int = 80,
33
+ order: Literal["DESC", "ASC", "desc", "asc"] = "DESC",
34
+ sort: str = "id",
35
+ filter: FilterExpression | None = None,
36
+ raw: bool = False,
37
+ ) -> tuple[str, tuple]: ...
38
+
39
+ def build_get_many2many_multiple(
40
+ self: "BuilderProtocol",
41
+ ids: list[int],
42
+ relation_table: "DotModel",
43
+ many2many_table: str,
44
+ column1: str,
45
+ column2: str,
46
+ fields: list[str] | None = None,
47
+ limit: int = 80,
48
+ ) -> tuple[str, tuple]: ...
49
+
50
+ def build_get_many2many(
51
+ self,
52
+ id: int,
53
+ relation_table: "DotModel",
54
+ many2many_table: str,
55
+ column1: str,
56
+ column2: str,
57
+ fields: list[str],
58
+ order: Literal["desc", "asc"] = "desc",
59
+ start: int | None = None,
60
+ end: int | None = None,
61
+ sort: str = "id",
62
+ limit: int | None = 10,
63
+ ) -> tuple[str, tuple]: ...
@@ -0,0 +1,144 @@
1
+ """Request builder for relation queries."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass, field as d_field
5
+ from typing import Any, Callable, ClassVar
6
+ from enum import Enum, auto
7
+
8
+ from ..fields import (
9
+ AttachmentMany2one,
10
+ AttachmentOne2many,
11
+ Field,
12
+ Many2many,
13
+ Many2one,
14
+ One2many,
15
+ )
16
+
17
+
18
+ class FetchMode(Enum):
19
+ """Database cursor fetch mode."""
20
+
21
+ FETCHALL = auto()
22
+ FETCHONE = auto()
23
+ FETCHMANY = auto()
24
+
25
+
26
+ # Type aliases for relation fields
27
+ RelationField = Many2many | One2many | Many2one
28
+ FormRelationField = (
29
+ Many2many | One2many | Many2one | AttachmentMany2one | AttachmentOne2many
30
+ )
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class RequestBuilder:
35
+ """
36
+ Container for relation query request.
37
+
38
+ Immutable data container with computed properties for
39
+ determining how to process query results.
40
+ """
41
+
42
+ stmt: str | None
43
+ value: Any
44
+ field_name: str
45
+ field: Field
46
+ fields: list[str] = d_field(default_factory=lambda: ["id", "name"])
47
+ fetch_mode: FetchMode = FetchMode.FETCHALL
48
+
49
+ # Mapping for prepare functions based on field type
50
+ _PREPARE_FUNCS: ClassVar[dict[type, str]] = {
51
+ Many2many: "prepare_list_ids",
52
+ One2many: "prepare_list_ids",
53
+ Many2one: "prepare_list_ids",
54
+ }
55
+
56
+ @property
57
+ def function_cursor(self) -> str:
58
+ """Returns cursor method name based on fetch mode."""
59
+ return self.fetch_mode.name.lower()
60
+
61
+ @property
62
+ def function_prepare(self) -> Callable:
63
+ """
64
+ Returns appropriate prepare function based on field type.
65
+
66
+ For relation fields (Many2many, One2many, Many2one),
67
+ returns prepare_list_ids from relation_table.
68
+ """
69
+ for field_type, method_name in self._PREPARE_FUNCS.items():
70
+ if isinstance(self.field, field_type):
71
+ return getattr(self.field.relation_table, method_name)
72
+
73
+ # Fallback for non-relation fields
74
+ # TODO: помоему тут ошибка relation_table всегда будет пустое
75
+ # а реквест билдер всеравно не используется в не связей
76
+ # и else никогда не вызывается
77
+ return getattr(self.field.relation_table, "prepare_list_id")
78
+
79
+
80
+ @dataclass(slots=True)
81
+ class RequestBuilderForm(RequestBuilder):
82
+ """
83
+ Container for form relation query request.
84
+
85
+ Extends RequestBuilder with form-specific prepare functions.
86
+ """
87
+
88
+ # Override mapping for form context
89
+ _PREPARE_FUNCS: ClassVar[dict[type, str]] = {
90
+ Many2many: "prepare_form_ids",
91
+ One2many: "prepare_form_ids",
92
+ Many2one: "prepare_form_ids",
93
+ AttachmentMany2one: "prepare_form_ids",
94
+ AttachmentOne2many: "prepare_form_ids",
95
+ }
96
+
97
+ @property
98
+ def function_prepare(self) -> Callable:
99
+ """
100
+ Returns appropriate prepare function for form context.
101
+
102
+ Form context uses prepare_form_ids/prepare_form_id methods.
103
+ """
104
+ for field_type, method_name in self._PREPARE_FUNCS.items():
105
+ if isinstance(self.field, field_type):
106
+ return getattr(self.field.relation_table, method_name)
107
+
108
+ return getattr(self.field.relation_table, "prepare_form_id")
109
+
110
+
111
+ def create_request_builder(
112
+ stmt: str | None,
113
+ value: Any,
114
+ field_name: str,
115
+ field: Field,
116
+ fields: list[str] | None = None,
117
+ *,
118
+ form_mode: bool = False,
119
+ ) -> RequestBuilder:
120
+ """
121
+ Factory function for creating appropriate RequestBuilder.
122
+
123
+ Args:
124
+ stmt: SQL statement
125
+ value: Query parameters
126
+ field_name: Name of the field
127
+ field: Field instance
128
+ fields: Fields to select (default: ["id", "name"])
129
+ form_mode: If True, creates RequestBuilderForm
130
+
131
+ Returns:
132
+ RequestBuilder or RequestBuilderForm instance
133
+ """
134
+ if fields is None:
135
+ fields = ["id", "name"]
136
+
137
+ cls = RequestBuilderForm if form_mode else RequestBuilder
138
+ return cls(
139
+ stmt=stmt,
140
+ value=value,
141
+ field_name=field_name,
142
+ field=field,
143
+ fields=fields,
144
+ )
@@ -0,0 +1,18 @@
1
+ """
2
+ Extracted components for cleaner architecture.
3
+
4
+ These components can be used standalone or by existing ORM classes.
5
+ """
6
+
7
+ from .dialect import Dialect, POSTGRES, MYSQL, get_dialect
8
+ from .filter_parser import FilterParser, FilterExpression, FilterTriplet
9
+
10
+ __all__ = [
11
+ "Dialect",
12
+ "POSTGRES",
13
+ "MYSQL",
14
+ "get_dialect",
15
+ "FilterParser",
16
+ "FilterExpression",
17
+ "FilterTriplet",
18
+ ]
@@ -0,0 +1,99 @@
1
+ """
2
+ Database dialect definitions.
3
+
4
+ Centralizes all dialect-specific logic in one place.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Literal
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Dialect:
13
+ """
14
+ Database dialect configuration.
15
+
16
+ Contains all dialect-specific settings:
17
+ - name: dialect identifier
18
+ - escape: character for escaping identifiers ("` for MySQL, " for Postgres)
19
+ - placeholder: parameter placeholder style (%s for MySQL, $N for Postgres)
20
+ - supports_returning: whether INSERT ... RETURNING is supported
21
+ """
22
+
23
+ name: Literal["postgres", "mysql", "clickhouse"]
24
+ escape: str
25
+ placeholder: str
26
+ supports_returning: bool
27
+
28
+ def escape_identifier(self, identifier: str) -> str:
29
+ """Escape a column/table name."""
30
+ return f"{self.escape}{identifier}{self.escape}"
31
+
32
+ def make_placeholders(self, count: int, start: int = 1) -> str:
33
+ """
34
+ Generate placeholder string for given count of parameters.
35
+
36
+ For MySQL: %s, %s, %s
37
+ For Postgres: $1, $2, $3
38
+ For Clickhouse: %s, %s, %s
39
+ """
40
+ if self.name == "postgres":
41
+ return ", ".join(f"${i}" for i in range(start, start + count))
42
+ else:
43
+ return ", ".join(["%s"] * count)
44
+
45
+ def make_placeholder(self, index: int = 1) -> str:
46
+ """Generate single placeholder."""
47
+ if self.name == "postgres":
48
+ return f"${index}"
49
+ return "%s"
50
+
51
+ def get_no_transaction_session(self):
52
+ """Get appropriate session class for this dialect."""
53
+ if self.name == "postgres":
54
+ from ..databases.postgres.session import NoTransactionSession
55
+
56
+ return NoTransactionSession
57
+ elif self.name == "mysql":
58
+ from ..databases.mysql.session import NoTransactionSession
59
+
60
+ return NoTransactionSession
61
+ else:
62
+ from ..databases.clickhouse.session import NoTransactionSession
63
+
64
+ return NoTransactionSession
65
+
66
+
67
+ # Pre-defined dialects
68
+ POSTGRES = Dialect(
69
+ name="postgres",
70
+ escape='"',
71
+ placeholder="$",
72
+ supports_returning=True,
73
+ )
74
+
75
+ MYSQL = Dialect(
76
+ name="mysql",
77
+ escape="`",
78
+ placeholder="%s",
79
+ supports_returning=False,
80
+ )
81
+
82
+ CLICKHOUSE = Dialect(
83
+ name="clickhouse",
84
+ escape="`",
85
+ placeholder="%s",
86
+ supports_returning=False,
87
+ )
88
+
89
+
90
+ def get_dialect(name: str) -> Dialect:
91
+ """Get dialect by name."""
92
+ if name == "postgres":
93
+ return POSTGRES
94
+ elif name == "mysql":
95
+ return MYSQL
96
+ elif name == "clickhouse":
97
+ return CLICKHOUSE
98
+ else:
99
+ raise ValueError(f"Unknown dialect: {name}")