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.
- dotorm/__init__.py +87 -0
- dotorm/access.py +151 -0
- dotorm/builder/__init__.py +0 -0
- dotorm/builder/builder.py +72 -0
- dotorm/builder/helpers.py +63 -0
- dotorm/builder/mixins/__init__.py +11 -0
- dotorm/builder/mixins/crud.py +246 -0
- dotorm/builder/mixins/m2m.py +110 -0
- dotorm/builder/mixins/relations.py +96 -0
- dotorm/builder/protocol.py +63 -0
- dotorm/builder/request_builder.py +144 -0
- dotorm/components/__init__.py +18 -0
- dotorm/components/dialect.py +99 -0
- dotorm/components/filter_parser.py +195 -0
- dotorm/databases/__init__.py +13 -0
- dotorm/databases/abstract/__init__.py +25 -0
- dotorm/databases/abstract/dialect.py +134 -0
- dotorm/databases/abstract/pool.py +10 -0
- dotorm/databases/abstract/session.py +67 -0
- dotorm/databases/abstract/types.py +36 -0
- dotorm/databases/clickhouse/__init__.py +8 -0
- dotorm/databases/clickhouse/pool.py +60 -0
- dotorm/databases/clickhouse/session.py +100 -0
- dotorm/databases/mysql/__init__.py +13 -0
- dotorm/databases/mysql/pool.py +69 -0
- dotorm/databases/mysql/session.py +128 -0
- dotorm/databases/mysql/transaction.py +39 -0
- dotorm/databases/postgres/__init__.py +23 -0
- dotorm/databases/postgres/pool.py +133 -0
- dotorm/databases/postgres/session.py +174 -0
- dotorm/databases/postgres/transaction.py +82 -0
- dotorm/decorators.py +379 -0
- dotorm/exceptions.py +9 -0
- dotorm/fields.py +604 -0
- dotorm/integrations/__init__.py +0 -0
- dotorm/integrations/pydantic.py +275 -0
- dotorm/model.py +802 -0
- dotorm/orm/__init__.py +15 -0
- dotorm/orm/mixins/__init__.py +13 -0
- dotorm/orm/mixins/access.py +67 -0
- dotorm/orm/mixins/ddl.py +250 -0
- dotorm/orm/mixins/many2many.py +175 -0
- dotorm/orm/mixins/primary.py +218 -0
- dotorm/orm/mixins/relations.py +513 -0
- dotorm/orm/protocol.py +147 -0
- dotorm/orm/utils.py +39 -0
- dotorm-2.0.8.dist-info/METADATA +1240 -0
- dotorm-2.0.8.dist-info/RECORD +50 -0
- dotorm-2.0.8.dist-info/WHEEL +4 -0
- dotorm-2.0.8.dist-info/licenses/LICENSE +21 -0
dotorm/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DotORM v2
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# Fields
|
|
6
|
+
from .fields import (
|
|
7
|
+
Field,
|
|
8
|
+
Integer,
|
|
9
|
+
BigInteger,
|
|
10
|
+
SmallInteger,
|
|
11
|
+
Char,
|
|
12
|
+
Selection,
|
|
13
|
+
Text,
|
|
14
|
+
Boolean,
|
|
15
|
+
Decimal,
|
|
16
|
+
Datetime,
|
|
17
|
+
Date,
|
|
18
|
+
Time,
|
|
19
|
+
Float,
|
|
20
|
+
JSONField,
|
|
21
|
+
Binary,
|
|
22
|
+
Many2one,
|
|
23
|
+
One2many,
|
|
24
|
+
Many2many,
|
|
25
|
+
One2one,
|
|
26
|
+
AttachmentMany2one,
|
|
27
|
+
AttachmentOne2many,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Model
|
|
31
|
+
from .model import DotModel
|
|
32
|
+
from .model import Model, JsonMode
|
|
33
|
+
|
|
34
|
+
# Components
|
|
35
|
+
from .components import (
|
|
36
|
+
Dialect,
|
|
37
|
+
POSTGRES,
|
|
38
|
+
MYSQL,
|
|
39
|
+
FilterParser,
|
|
40
|
+
FilterExpression,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Exceptions
|
|
44
|
+
from .exceptions import (
|
|
45
|
+
OrmConfigurationFieldException,
|
|
46
|
+
OrmUpdateEmptyParamsException,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__version__ = "2.0.8"
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
# Fields
|
|
53
|
+
"Field",
|
|
54
|
+
"Integer",
|
|
55
|
+
"BigInteger",
|
|
56
|
+
"SmallInteger",
|
|
57
|
+
"Char",
|
|
58
|
+
"Selection",
|
|
59
|
+
"Text",
|
|
60
|
+
"Boolean",
|
|
61
|
+
"Decimal",
|
|
62
|
+
"Datetime",
|
|
63
|
+
"Date",
|
|
64
|
+
"Time",
|
|
65
|
+
"Float",
|
|
66
|
+
"JSONField",
|
|
67
|
+
"Binary",
|
|
68
|
+
"Many2one",
|
|
69
|
+
"One2many",
|
|
70
|
+
"Many2many",
|
|
71
|
+
"One2one",
|
|
72
|
+
"AttachmentMany2one",
|
|
73
|
+
"AttachmentOne2many",
|
|
74
|
+
# Model
|
|
75
|
+
"DotModel",
|
|
76
|
+
"Model",
|
|
77
|
+
"JsonMode",
|
|
78
|
+
# Components
|
|
79
|
+
"Dialect",
|
|
80
|
+
"POSTGRES",
|
|
81
|
+
"MYSQL",
|
|
82
|
+
"FilterParser",
|
|
83
|
+
"FilterExpression",
|
|
84
|
+
# Exceptions
|
|
85
|
+
"OrmConfigurationFieldException",
|
|
86
|
+
"OrmUpdateEmptyParamsException",
|
|
87
|
+
]
|
dotorm/access.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Контекст доступа для DotORM.
|
|
3
|
+
|
|
4
|
+
Использование:
|
|
5
|
+
|
|
6
|
+
# При старте приложения (security/app.py):
|
|
7
|
+
set_access_checker(SecurityAccessChecker(env))
|
|
8
|
+
|
|
9
|
+
# При каждом запросе (verify_access):
|
|
10
|
+
set_access_session(session) # Session из security модуля
|
|
11
|
+
|
|
12
|
+
# В DotModel автоматически:
|
|
13
|
+
# - check_table_access() перед CRUD операциями
|
|
14
|
+
# - check_row_access() для конкретных записей (get/update/delete)
|
|
15
|
+
# - get_domain_filter() для фильтрации выборки (search)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from contextvars import ContextVar
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
from typing import TypeVar, Generic
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Operation(StrEnum):
|
|
24
|
+
"""Операции доступа."""
|
|
25
|
+
|
|
26
|
+
READ = "read"
|
|
27
|
+
CREATE = "create"
|
|
28
|
+
UPDATE = "update"
|
|
29
|
+
DELETE = "delete"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Generic тип для Session
|
|
33
|
+
TSession = TypeVar("TSession")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AccessChecker(Generic[TSession]):
|
|
37
|
+
"""
|
|
38
|
+
Базовый класс проверки доступа.
|
|
39
|
+
|
|
40
|
+
По умолчанию разрешает всё.
|
|
41
|
+
Модуль security наследует и переопределяет методы.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
async def check_access(
|
|
45
|
+
self,
|
|
46
|
+
session: TSession,
|
|
47
|
+
model: str,
|
|
48
|
+
operation: Operation,
|
|
49
|
+
record_ids: list[int] | None = None,
|
|
50
|
+
) -> tuple[bool, list]:
|
|
51
|
+
"""
|
|
52
|
+
Единая проверка доступа: ACL + Rules.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
session: Сессия пользователя
|
|
56
|
+
model: Имя модели (таблицы)
|
|
57
|
+
operation: Операция (read/create/update/delete)
|
|
58
|
+
record_ids: ID записей (для проверки Rules)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
(has_access, domain_filter):
|
|
62
|
+
- has_access: True если доступ разрешён
|
|
63
|
+
- domain_filter: фильтр для search (пустой если не нужен)
|
|
64
|
+
"""
|
|
65
|
+
return True, []
|
|
66
|
+
|
|
67
|
+
async def check_table_access(
|
|
68
|
+
self,
|
|
69
|
+
session: TSession,
|
|
70
|
+
model: str,
|
|
71
|
+
operation: Operation,
|
|
72
|
+
) -> bool:
|
|
73
|
+
"""
|
|
74
|
+
Проверяет доступ к таблице (ACL уровень).
|
|
75
|
+
"""
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
async def check_row_access(
|
|
79
|
+
self,
|
|
80
|
+
session: TSession,
|
|
81
|
+
model: str,
|
|
82
|
+
operation: Operation,
|
|
83
|
+
record_ids: list[int],
|
|
84
|
+
) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Проверяет доступ к записям (Rules уровень).
|
|
87
|
+
|
|
88
|
+
Для одной или нескольких записей проверяет что они
|
|
89
|
+
попадают под domain из Rules.
|
|
90
|
+
"""
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
async def get_domain_filter(
|
|
94
|
+
self,
|
|
95
|
+
session: TSession,
|
|
96
|
+
model: str,
|
|
97
|
+
operation: Operation,
|
|
98
|
+
) -> list:
|
|
99
|
+
"""
|
|
100
|
+
Возвращает domain-фильтр для ограничения выборки.
|
|
101
|
+
|
|
102
|
+
Используется для search — добавляется к filter ДО запроса.
|
|
103
|
+
"""
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AccessDenied(Exception):
|
|
108
|
+
"""Доступ запрещён."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, message: str = "Access denied"):
|
|
111
|
+
self.message = message
|
|
112
|
+
super().__init__(self.message)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ============================================================
|
|
116
|
+
# State
|
|
117
|
+
# ============================================================
|
|
118
|
+
|
|
119
|
+
_state: dict = {"checker": AccessChecker()}
|
|
120
|
+
|
|
121
|
+
_access_session: ContextVar = ContextVar("access_session", default=None)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ============================================================
|
|
125
|
+
# Public API
|
|
126
|
+
# ============================================================
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def set_access_checker(checker: AccessChecker) -> None:
|
|
130
|
+
"""Устанавливает AccessChecker (один раз при старте)."""
|
|
131
|
+
_state["checker"] = checker
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_access_checker() -> AccessChecker:
|
|
135
|
+
"""Возвращает текущий AccessChecker."""
|
|
136
|
+
return _state["checker"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def set_access_session(session) -> None:
|
|
140
|
+
"""Устанавливает сессию для текущего запроса."""
|
|
141
|
+
_access_session.set(session)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_access_session():
|
|
145
|
+
"""Возвращает сессию текущего запроса."""
|
|
146
|
+
return _access_session.get()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def clear_access_session() -> None:
|
|
150
|
+
"""Очищает сессию (после завершения post_init)."""
|
|
151
|
+
_access_session.set(None)
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQL Query Builder.
|
|
3
|
+
|
|
4
|
+
Combines base state with mixin functionality.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from ..components.filter_parser import FilterParser
|
|
10
|
+
|
|
11
|
+
from .mixins import (
|
|
12
|
+
CRUDMixin,
|
|
13
|
+
Many2ManyMixin,
|
|
14
|
+
RelationsMixin,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ..fields import Field
|
|
19
|
+
from ..components.dialect import Dialect
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Builder(
|
|
23
|
+
CRUDMixin,
|
|
24
|
+
Many2ManyMixin,
|
|
25
|
+
RelationsMixin,
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Unified SQL query builder.
|
|
29
|
+
|
|
30
|
+
Inherits from:
|
|
31
|
+
- CRUDMixin: create, read, update, delete operations
|
|
32
|
+
- Many2ManyMixin: M2M relation queries
|
|
33
|
+
- RelationsMixin: Batch relation loading
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
builder = Builder(
|
|
37
|
+
table="users",
|
|
38
|
+
fields=model_fields,
|
|
39
|
+
dialect=POSTGRES,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Simple queries
|
|
43
|
+
sql, vals = builder.build_get(id=1)
|
|
44
|
+
sql, vals = builder.build_delete(id=1)
|
|
45
|
+
|
|
46
|
+
# Search with filter
|
|
47
|
+
sql, vals = builder.build_search(
|
|
48
|
+
fields=["id", "name"],
|
|
49
|
+
filter=[("status", "=", "active")],
|
|
50
|
+
limit=10,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
MRO ensures BuilderBase.__init__ is called and provides
|
|
54
|
+
all attributes that mixins expect.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
__slots__ = ("table", "fields", "dialect", "filter_parser")
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
table: str,
|
|
62
|
+
fields: dict[str, "Field"],
|
|
63
|
+
dialect: "Dialect",
|
|
64
|
+
) -> None:
|
|
65
|
+
self.table = table
|
|
66
|
+
self.fields = fields
|
|
67
|
+
self.dialect = dialect
|
|
68
|
+
self.filter_parser = FilterParser(dialect)
|
|
69
|
+
|
|
70
|
+
def get_store_fields(self) -> list[str]:
|
|
71
|
+
"""Returns only fields that are stored in DB (store=True)."""
|
|
72
|
+
return [name for name, field in self.fields.items() if field.store]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Helper functions for SQL building."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_sql_update_from_schema(
|
|
8
|
+
sql: str,
|
|
9
|
+
payload_dict: dict[str, Any],
|
|
10
|
+
id: int | list[int],
|
|
11
|
+
) -> tuple[str, tuple]:
|
|
12
|
+
"""Составляет запрос обновления (update).
|
|
13
|
+
|
|
14
|
+
Arguments:
|
|
15
|
+
sql -- текст шаблона запроса
|
|
16
|
+
payload_dict -- сериализованные данные модели
|
|
17
|
+
id -- идентификатор или список идентификаторов
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
sql -- текст запроса с подстановками (биндингами)
|
|
21
|
+
values_list -- значения для биндинга
|
|
22
|
+
"""
|
|
23
|
+
if not payload_dict:
|
|
24
|
+
raise ValueError("payload_dict cannot be empty")
|
|
25
|
+
|
|
26
|
+
fields_list, values_list = zip(*payload_dict.items())
|
|
27
|
+
values_list = tuple(values_list)
|
|
28
|
+
|
|
29
|
+
if isinstance(id, list):
|
|
30
|
+
values_list += tuple(id)
|
|
31
|
+
where_placeholder = ", ".join(["%s"] * len(id))
|
|
32
|
+
else:
|
|
33
|
+
values_list += (id,)
|
|
34
|
+
where_placeholder = "%s"
|
|
35
|
+
|
|
36
|
+
query_placeholders = ", ".join(f"{field}=%s" for field in fields_list)
|
|
37
|
+
sql = sql % (query_placeholders, where_placeholder)
|
|
38
|
+
return sql, values_list
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_sql_create_from_schema(
|
|
42
|
+
sql: str,
|
|
43
|
+
payload_dict: dict[str, Any],
|
|
44
|
+
) -> tuple[str, tuple]:
|
|
45
|
+
"""Составляет запрос создания (insert).
|
|
46
|
+
|
|
47
|
+
Arguments:
|
|
48
|
+
sql -- текст шаблона запроса
|
|
49
|
+
payload_dict -- сериализованные данные модели
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
sql -- текст запроса с подстановками (биндингами)
|
|
53
|
+
values_list -- значения для биндинга
|
|
54
|
+
"""
|
|
55
|
+
if not payload_dict:
|
|
56
|
+
raise ValueError("payload_dict cannot be empty")
|
|
57
|
+
|
|
58
|
+
fields_list, values_list = zip(*payload_dict.items())
|
|
59
|
+
|
|
60
|
+
query_columns = ", ".join(fields_list)
|
|
61
|
+
query_placeholders = ", ".join(["%s"] * len(values_list))
|
|
62
|
+
sql = sql % (query_columns, query_placeholders)
|
|
63
|
+
return sql, values_list
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""CRUD operations mixin."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
4
|
+
|
|
5
|
+
from ...components.filter_parser import FilterExpression
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ..protocol import BuilderProtocol
|
|
9
|
+
|
|
10
|
+
from ..helpers import (
|
|
11
|
+
build_sql_create_from_schema,
|
|
12
|
+
build_sql_update_from_schema,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Allowed order values (uppercase for comparison)
|
|
17
|
+
_ALLOWED_ORDER = frozenset({"ASC", "DESC"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CRUDMixin:
|
|
21
|
+
"""
|
|
22
|
+
Mixin providing basic CRUD query builders.
|
|
23
|
+
|
|
24
|
+
Builder работает с dict, а не с моделью.
|
|
25
|
+
Сериализация модели в dict происходит в ORM слое.
|
|
26
|
+
|
|
27
|
+
Expects: table, fields, dialect, get_store_fields(), filter_parser
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__slots__ = ()
|
|
31
|
+
|
|
32
|
+
def build_delete(self: "BuilderProtocol") -> str:
|
|
33
|
+
return f"DELETE FROM {self.table} WHERE id=%s"
|
|
34
|
+
|
|
35
|
+
def build_delete_bulk(self: "BuilderProtocol", count: int) -> str:
|
|
36
|
+
args = ",".join(["%s"] * count)
|
|
37
|
+
return f"DELETE FROM {self.table} WHERE id IN ({args})"
|
|
38
|
+
|
|
39
|
+
def build_create(
|
|
40
|
+
self: "BuilderProtocol",
|
|
41
|
+
payload_dict: dict[str, Any],
|
|
42
|
+
) -> tuple[str, tuple]:
|
|
43
|
+
"""Build INSERT query from dict."""
|
|
44
|
+
stmt = f"INSERT INTO {self.table} (%s) VALUES (%s)"
|
|
45
|
+
stmt, values_list = build_sql_create_from_schema(stmt, payload_dict)
|
|
46
|
+
return stmt, values_list
|
|
47
|
+
|
|
48
|
+
def build_create_bulk(
|
|
49
|
+
self: "BuilderProtocol",
|
|
50
|
+
payloads_dicts: list[dict[str, Any]],
|
|
51
|
+
) -> tuple[str, list]:
|
|
52
|
+
"""Build bulk INSERT query with multiple VALUES."""
|
|
53
|
+
if not payloads_dicts:
|
|
54
|
+
raise ValueError("payloads_dicts cannot be empty")
|
|
55
|
+
|
|
56
|
+
# Берём ключи из первого payload
|
|
57
|
+
fields_list = list(payloads_dicts[0].keys())
|
|
58
|
+
query_columns = ", ".join(fields_list)
|
|
59
|
+
|
|
60
|
+
# Собираем все значения в плоский список
|
|
61
|
+
all_values = []
|
|
62
|
+
value_groups = []
|
|
63
|
+
param_index = 1
|
|
64
|
+
|
|
65
|
+
for payload_dict in payloads_dicts:
|
|
66
|
+
placeholders = []
|
|
67
|
+
for field in fields_list:
|
|
68
|
+
placeholders.append(f"${param_index}")
|
|
69
|
+
all_values.append(payload_dict[field])
|
|
70
|
+
param_index += 1
|
|
71
|
+
value_groups.append(f"({', '.join(placeholders)})")
|
|
72
|
+
|
|
73
|
+
values_clause = ", ".join(value_groups)
|
|
74
|
+
stmt = f"INSERT INTO {self.table} ({query_columns}) VALUES {values_clause}"
|
|
75
|
+
|
|
76
|
+
return stmt, all_values
|
|
77
|
+
|
|
78
|
+
def build_update(
|
|
79
|
+
self: "BuilderProtocol",
|
|
80
|
+
payload_dict: dict[str, Any],
|
|
81
|
+
id: int,
|
|
82
|
+
) -> tuple[str, tuple]:
|
|
83
|
+
"""Build UPDATE query from dict."""
|
|
84
|
+
stmt = f"UPDATE {self.table} SET %s WHERE id = %s"
|
|
85
|
+
stmt, values_list = build_sql_update_from_schema(
|
|
86
|
+
stmt, payload_dict, id
|
|
87
|
+
)
|
|
88
|
+
return stmt, values_list
|
|
89
|
+
|
|
90
|
+
def build_update_bulk(
|
|
91
|
+
self: "BuilderProtocol",
|
|
92
|
+
payload_dict: dict[str, Any],
|
|
93
|
+
ids: list[int],
|
|
94
|
+
) -> tuple[str, tuple]:
|
|
95
|
+
"""Build bulk UPDATE query from dict."""
|
|
96
|
+
stmt = f"UPDATE {self.table} SET %s WHERE id IN (%s)"
|
|
97
|
+
stmt, values_list = build_sql_update_from_schema(
|
|
98
|
+
stmt, payload_dict, ids
|
|
99
|
+
)
|
|
100
|
+
return stmt, values_list
|
|
101
|
+
|
|
102
|
+
def build_get(
|
|
103
|
+
self: "BuilderProtocol",
|
|
104
|
+
id: int,
|
|
105
|
+
fields: list[str] | None = None,
|
|
106
|
+
) -> tuple[str, list]:
|
|
107
|
+
"""
|
|
108
|
+
Build SELECT by ID query.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
id: Record ID
|
|
112
|
+
fields: Fields to select (empty = all stored)
|
|
113
|
+
"""
|
|
114
|
+
escape = self.dialect.escape
|
|
115
|
+
store_fields = self.get_store_fields()
|
|
116
|
+
|
|
117
|
+
selected_fields = fields if fields else store_fields
|
|
118
|
+
fields_stmt = ", ".join(
|
|
119
|
+
f"{escape}{name}{escape}" for name in selected_fields
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
stmt = f"SELECT {fields_stmt} FROM {self.table} WHERE id = %s LIMIT 1"
|
|
123
|
+
return stmt, [id]
|
|
124
|
+
|
|
125
|
+
def build_table_len(self: "BuilderProtocol") -> tuple[str, None]:
|
|
126
|
+
stmt = f"SELECT COUNT(*) FROM {self.table}"
|
|
127
|
+
return stmt, None
|
|
128
|
+
|
|
129
|
+
def build_search(
|
|
130
|
+
self: "BuilderProtocol",
|
|
131
|
+
fields: list[str] | None = None,
|
|
132
|
+
start: int | None = None,
|
|
133
|
+
end: int | None = None,
|
|
134
|
+
limit: int = 80,
|
|
135
|
+
order: Literal["DESC", "ASC", "desc", "asc"] = "DESC",
|
|
136
|
+
sort: str = "id",
|
|
137
|
+
filter: FilterExpression | None = None,
|
|
138
|
+
raw: bool = False,
|
|
139
|
+
) -> tuple[str, tuple]:
|
|
140
|
+
"""
|
|
141
|
+
Build search query.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
fields: Fields to select (default: ["id"])
|
|
145
|
+
start: Offset start
|
|
146
|
+
end: Offset end
|
|
147
|
+
limit: Max records
|
|
148
|
+
order: Sort order (ASC/DESC)
|
|
149
|
+
sort: Sort field
|
|
150
|
+
filter: Filter expression
|
|
151
|
+
raw: Return raw dict instead of model
|
|
152
|
+
"""
|
|
153
|
+
escape = self.dialect.escape
|
|
154
|
+
store_fields = self.get_store_fields()
|
|
155
|
+
|
|
156
|
+
if fields is None:
|
|
157
|
+
fields = ["id"]
|
|
158
|
+
|
|
159
|
+
# поставить защиту, хотя по идее защита есть в ОРМ
|
|
160
|
+
order_upper = order.upper()
|
|
161
|
+
if order_upper not in _ALLOWED_ORDER:
|
|
162
|
+
raise ValueError(f"Invalid order: {order}")
|
|
163
|
+
if sort not in store_fields:
|
|
164
|
+
sort = store_fields[0]
|
|
165
|
+
# raise ValueError(f"Invalid sort field: {sort}")
|
|
166
|
+
|
|
167
|
+
fields_store_stmt = ", ".join(
|
|
168
|
+
f"{escape}{name}{escape}"
|
|
169
|
+
for name in fields
|
|
170
|
+
if name in store_fields
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
where = ""
|
|
174
|
+
where_values: tuple = ()
|
|
175
|
+
|
|
176
|
+
if filter:
|
|
177
|
+
where_clause, where_values = self.filter_parser.parse(filter)
|
|
178
|
+
where = f"WHERE {where_clause}"
|
|
179
|
+
|
|
180
|
+
stmt = (
|
|
181
|
+
f"SELECT {fields_store_stmt} FROM {self.table} "
|
|
182
|
+
f"{where} ORDER BY {sort} {order_upper} "
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
val: tuple = ()
|
|
186
|
+
|
|
187
|
+
if end is not None and start is not None:
|
|
188
|
+
stmt += "LIMIT %s OFFSET %s"
|
|
189
|
+
val = (end - start, start)
|
|
190
|
+
elif limit:
|
|
191
|
+
stmt += "LIMIT %s"
|
|
192
|
+
val = (limit,)
|
|
193
|
+
|
|
194
|
+
# Prepend where values
|
|
195
|
+
if where_values:
|
|
196
|
+
val = where_values + val
|
|
197
|
+
|
|
198
|
+
return stmt, val
|
|
199
|
+
|
|
200
|
+
def build_search_count(
|
|
201
|
+
self: "BuilderProtocol",
|
|
202
|
+
filter: FilterExpression | None = None,
|
|
203
|
+
) -> tuple[str, tuple]:
|
|
204
|
+
"""
|
|
205
|
+
Build COUNT query with filter.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
filter: Filter expression
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Tuple of (query, values)
|
|
212
|
+
"""
|
|
213
|
+
where = ""
|
|
214
|
+
where_values: tuple = ()
|
|
215
|
+
|
|
216
|
+
if filter:
|
|
217
|
+
where_clause, where_values = self.filter_parser.parse(filter)
|
|
218
|
+
where = f"WHERE {where_clause}"
|
|
219
|
+
|
|
220
|
+
stmt = f"SELECT COUNT(*) as count FROM {self.table} {where}"
|
|
221
|
+
|
|
222
|
+
return stmt, where_values
|
|
223
|
+
|
|
224
|
+
def build_exists(
|
|
225
|
+
self: "BuilderProtocol",
|
|
226
|
+
filter: FilterExpression | None = None,
|
|
227
|
+
) -> tuple[str, tuple]:
|
|
228
|
+
"""
|
|
229
|
+
Build EXISTS query with filter.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
filter: Filter expression
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Tuple of (query, values)
|
|
236
|
+
"""
|
|
237
|
+
where = ""
|
|
238
|
+
where_values: tuple = ()
|
|
239
|
+
|
|
240
|
+
if filter:
|
|
241
|
+
where_clause, where_values = self.filter_parser.parse(filter)
|
|
242
|
+
where = f"WHERE {where_clause}"
|
|
243
|
+
|
|
244
|
+
stmt = f"SELECT 1 FROM {self.table} {where} LIMIT 1"
|
|
245
|
+
|
|
246
|
+
return stmt, where_values
|