dotorm
Advanced tools
+151
| """ | ||
| ΠΠΎΠ½ΡΠ΅ΠΊΡΡ Π΄ΠΎΡΡΡΠΏΠ° Π΄Π»Ρ DotORM. | ||
| ΠΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°Π½ΠΈΠ΅: | ||
| # ΠΡΠΈ ΡΡΠ°ΡΡΠ΅ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ (security/app.py): | ||
| set_access_checker(SecurityAccessChecker(env)) | ||
| # ΠΡΠΈ ΠΊΠ°ΠΆΠ΄ΠΎΠΌ Π·Π°ΠΏΡΠΎΡΠ΅ (verify_access): | ||
| set_access_session(session) # Session ΠΈΠ· security ΠΌΠΎΠ΄ΡΠ»Ρ | ||
| # Π DotModel Π°Π²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΈ: | ||
| # - check_table_access() ΠΏΠ΅ΡΠ΅Π΄ CRUD ΠΎΠΏΠ΅ΡΠ°ΡΠΈΡΠΌΠΈ | ||
| # - check_row_access() Π΄Π»Ρ ΠΊΠΎΠ½ΠΊΡΠ΅ΡΠ½ΡΡ Π·Π°ΠΏΠΈΡΠ΅ΠΉ (get/update/delete) | ||
| # - get_domain_filter() Π΄Π»Ρ ΡΠΈΠ»ΡΡΡΠ°ΡΠΈΠΈ Π²ΡΠ±ΠΎΡΠΊΠΈ (search) | ||
| """ | ||
| from contextvars import ContextVar | ||
| from enum import StrEnum | ||
| from typing import TypeVar, Generic | ||
| class Operation(StrEnum): | ||
| """ΠΠΏΠ΅ΡΠ°ΡΠΈΠΈ Π΄ΠΎΡΡΡΠΏΠ°.""" | ||
| READ = "read" | ||
| CREATE = "create" | ||
| UPDATE = "update" | ||
| DELETE = "delete" | ||
| # Generic ΡΠΈΠΏ Π΄Π»Ρ Session | ||
| TSession = TypeVar("TSession") | ||
| class AccessChecker(Generic[TSession]): | ||
| """ | ||
| ΠΠ°Π·ΠΎΠ²ΡΠΉ ΠΊΠ»Π°ΡΡ ΠΏΡΠΎΠ²Π΅ΡΠΊΠΈ Π΄ΠΎΡΡΡΠΏΠ°. | ||
| ΠΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ ΡΠ°Π·ΡΠ΅ΡΠ°Π΅Ρ Π²ΡΡ. | ||
| ΠΠΎΠ΄ΡΠ»Ρ security Π½Π°ΡΠ»Π΅Π΄ΡΠ΅Ρ ΠΈ ΠΏΠ΅ΡΠ΅ΠΎΠΏΡΠ΅Π΄Π΅Π»ΡΠ΅Ρ ΠΌΠ΅ΡΠΎΠ΄Ρ. | ||
| """ | ||
| async def check_access( | ||
| self, | ||
| session: TSession, | ||
| model: str, | ||
| operation: Operation, | ||
| record_ids: list[int] | None = None, | ||
| ) -> tuple[bool, list]: | ||
| """ | ||
| ΠΠ΄ΠΈΠ½Π°Ρ ΠΏΡΠΎΠ²Π΅ΡΠΊΠ° Π΄ΠΎΡΡΡΠΏΠ°: ACL + Rules. | ||
| Args: | ||
| session: Π‘Π΅ΡΡΠΈΡ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ | ||
| model: ΠΠΌΡ ΠΌΠΎΠ΄Π΅Π»ΠΈ (ΡΠ°Π±Π»ΠΈΡΡ) | ||
| operation: ΠΠΏΠ΅ΡΠ°ΡΠΈΡ (read/create/update/delete) | ||
| record_ids: ID Π·Π°ΠΏΠΈΡΠ΅ΠΉ (Π΄Π»Ρ ΠΏΡΠΎΠ²Π΅ΡΠΊΠΈ Rules) | ||
| Returns: | ||
| (has_access, domain_filter): | ||
| - has_access: True Π΅ΡΠ»ΠΈ Π΄ΠΎΡΡΡΠΏ ΡΠ°Π·ΡΠ΅ΡΡΠ½ | ||
| - domain_filter: ΡΠΈΠ»ΡΡΡ Π΄Π»Ρ search (ΠΏΡΡΡΠΎΠΉ Π΅ΡΠ»ΠΈ Π½Π΅ Π½ΡΠΆΠ΅Π½) | ||
| """ | ||
| return True, [] | ||
| async def check_table_access( | ||
| self, | ||
| session: TSession, | ||
| model: str, | ||
| operation: Operation, | ||
| ) -> bool: | ||
| """ | ||
| ΠΡΠΎΠ²Π΅ΡΡΠ΅Ρ Π΄ΠΎΡΡΡΠΏ ΠΊ ΡΠ°Π±Π»ΠΈΡΠ΅ (ACL ΡΡΠΎΠ²Π΅Π½Ρ). | ||
| """ | ||
| return True | ||
| async def check_row_access( | ||
| self, | ||
| session: TSession, | ||
| model: str, | ||
| operation: Operation, | ||
| record_ids: list[int], | ||
| ) -> bool: | ||
| """ | ||
| ΠΡΠΎΠ²Π΅ΡΡΠ΅Ρ Π΄ΠΎΡΡΡΠΏ ΠΊ Π·Π°ΠΏΠΈΡΡΠΌ (Rules ΡΡΠΎΠ²Π΅Π½Ρ). | ||
| ΠΠ»Ρ ΠΎΠ΄Π½ΠΎΠΉ ΠΈΠ»ΠΈ Π½Π΅ΡΠΊΠΎΠ»ΡΠΊΠΈΡ Π·Π°ΠΏΠΈΡΠ΅ΠΉ ΠΏΡΠΎΠ²Π΅ΡΡΠ΅Ρ ΡΡΠΎ ΠΎΠ½ΠΈ | ||
| ΠΏΠΎΠΏΠ°Π΄Π°ΡΡ ΠΏΠΎΠ΄ domain ΠΈΠ· Rules. | ||
| """ | ||
| return True | ||
| async def get_domain_filter( | ||
| self, | ||
| session: TSession, | ||
| model: str, | ||
| operation: Operation, | ||
| ) -> list: | ||
| """ | ||
| ΠΠΎΠ·Π²ΡΠ°ΡΠ°Π΅Ρ domain-ΡΠΈΠ»ΡΡΡ Π΄Π»Ρ ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½ΠΈΡ Π²ΡΠ±ΠΎΡΠΊΠΈ. | ||
| ΠΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΡΡΡ Π΄Π»Ρ search β Π΄ΠΎΠ±Π°Π²Π»ΡΠ΅ΡΡΡ ΠΊ filter ΠΠ Π·Π°ΠΏΡΠΎΡΠ°. | ||
| """ | ||
| return [] | ||
| class AccessDenied(Exception): | ||
| """ΠΠΎΡΡΡΠΏ Π·Π°ΠΏΡΠ΅ΡΡΠ½.""" | ||
| def __init__(self, message: str = "Access denied"): | ||
| self.message = message | ||
| super().__init__(self.message) | ||
| # ============================================================ | ||
| # State | ||
| # ============================================================ | ||
| _state: dict = {"checker": AccessChecker()} | ||
| _access_session: ContextVar = ContextVar("access_session", default=None) | ||
| # ============================================================ | ||
| # Public API | ||
| # ============================================================ | ||
| def set_access_checker(checker: AccessChecker) -> None: | ||
| """Π£ΡΡΠ°Π½Π°Π²Π»ΠΈΠ²Π°Π΅Ρ AccessChecker (ΠΎΠ΄ΠΈΠ½ ΡΠ°Π· ΠΏΡΠΈ ΡΡΠ°ΡΡΠ΅).""" | ||
| _state["checker"] = checker | ||
| def get_access_checker() -> AccessChecker: | ||
| """ΠΠΎΠ·Π²ΡΠ°ΡΠ°Π΅Ρ ΡΠ΅ΠΊΡΡΠΈΠΉ AccessChecker.""" | ||
| return _state["checker"] | ||
| def set_access_session(session) -> None: | ||
| """Π£ΡΡΠ°Π½Π°Π²Π»ΠΈΠ²Π°Π΅Ρ ΡΠ΅ΡΡΠΈΡ Π΄Π»Ρ ΡΠ΅ΠΊΡΡΠ΅Π³ΠΎ Π·Π°ΠΏΡΠΎΡΠ°.""" | ||
| _access_session.set(session) | ||
| def get_access_session(): | ||
| """ΠΠΎΠ·Π²ΡΠ°ΡΠ°Π΅Ρ ΡΠ΅ΡΡΠΈΡ ΡΠ΅ΠΊΡΡΠ΅Π³ΠΎ Π·Π°ΠΏΡΠΎΡΠ°.""" | ||
| return _access_session.get() | ||
| def clear_access_session() -> None: | ||
| """ΠΡΠΈΡΠ°Π΅Ρ ΡΠ΅ΡΡΠΈΡ (ΠΏΠΎΡΠ»Π΅ Π·Π°Π²Π΅ΡΡΠ΅Π½ΠΈΡ post_init).""" | ||
| _access_session.set(None) |
| """Access control mixin for DotModel.""" | ||
| from typing import TYPE_CHECKING | ||
| from ...access import ( | ||
| get_access_checker, | ||
| get_access_session, | ||
| AccessDenied, | ||
| Operation, | ||
| ) | ||
| if TYPE_CHECKING: | ||
| from ..protocol import DotModelProtocol | ||
| _Base = DotModelProtocol | ||
| else: | ||
| _Base = object | ||
| class AccessMixin(_Base): | ||
| """ | ||
| Mixin Π΄ΠΎΠ±Π°Π²Π»ΡΡΡΠΈΠΉ ΠΏΡΠΎΠ²Π΅ΡΠΊΡ Π΄ΠΎΡΡΡΠΏΠ° Π² CRUD ΠΎΠΏΠ΅ΡΠ°ΡΠΈΠΈ. | ||
| ΠΡΠ»ΠΈ AccessSession Π½Π΅ ΡΡΡΠ°Π½ΠΎΠ²Π»Π΅Π½Π° β ΠΏΡΠΎΠ²Π΅ΡΠΊΠΈ ΠΏΡΠΎΠΏΡΡΠΊΠ°ΡΡΡΡ. | ||
| SystemSession Π΄Π°ΡΡ ΠΏΠΎΠ»Π½ΡΠΉ Π΄ΠΎΡΡΡΠΏ. | ||
| """ | ||
| @classmethod | ||
| async def _check_access( | ||
| cls, | ||
| operation: Operation, | ||
| record_ids: list[int] | None = None, | ||
| filter: list | None = None, | ||
| ) -> list | None: | ||
| """ | ||
| ΠΠ΄ΠΈΠ½ΡΠΉ ΠΌΠ΅ΡΠΎΠ΄ ΠΏΡΠΎΠ²Π΅ΡΠΊΠΈ Π΄ΠΎΡΡΡΠΏΠ°. | ||
| Args: | ||
| operation: Operation.READ / CREATE / UPDATE / DELETE | ||
| record_ids: ID Π·Π°ΠΏΠΈΡΠ΅ΠΉ (Π΄Π»Ρ get/update/delete) | ||
| filter: ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»ΡΡΠΊΠΈΠΉ ΡΠΈΠ»ΡΡΡ (Π΄Π»Ρ search) | ||
| Returns: | ||
| ΠΠΎΠ΄ΠΈΡΠΈΡΠΈΡΠΎΠ²Π°Π½Π½ΡΠΉ filter Ρ domain (Π΄Π»Ρ search) | ||
| Raises: | ||
| AccessDenied: Π΅ΡΠ»ΠΈ Π΄ΠΎΡΡΡΠΏ Π·Π°ΠΏΡΠ΅ΡΡΠ½ | ||
| """ | ||
| session = get_access_session() | ||
| if session is None: | ||
| return filter | ||
| checker = get_access_checker() | ||
| has_access, domain = await checker.check_access( | ||
| session, cls.__table__, operation, record_ids | ||
| ) | ||
| if not has_access: | ||
| raise AccessDenied( | ||
| f"No {operation.value} access to {cls.__table__}" | ||
| ) | ||
| if domain: | ||
| return filter + domain if filter else domain | ||
| return filter |
| """Utility functions for dotorm.""" | ||
| import asyncio | ||
| from typing import Any, Coroutine, Sequence | ||
| async def execute_maybe_parallel( | ||
| coroutines: Sequence[Coroutine[Any, Any, Any]], | ||
| ) -> list[Any]: | ||
| """ | ||
| Execute coroutines in parallel or sequentially depending on transaction context. | ||
| If inside a transaction (single connection), executes sequentially to avoid | ||
| asyncpg "another operation is in progress" error. | ||
| If outside transaction (pool), executes in parallel for better performance. | ||
| Args: | ||
| coroutines: List of coroutines to execute | ||
| Returns: | ||
| List of results in the same order as input coroutines | ||
| """ | ||
| from ..databases.postgres.transaction import get_current_session | ||
| if not coroutines: | ||
| return [] | ||
| # Check if we're inside a transaction | ||
| if get_current_session() is not None: | ||
| # Inside transaction - execute sequentially | ||
| results = [] | ||
| for coro in coroutines: | ||
| result = await coro | ||
| results.append(result) | ||
| return results | ||
| else: | ||
| # Outside transaction - execute in parallel | ||
| return list(await asyncio.gather(*coroutines)) |
@@ -32,3 +32,3 @@ """ | ||
| from .model import DotModel | ||
| from .model import Model, JsonMode, depends | ||
| from .model import Model, JsonMode | ||
@@ -50,3 +50,3 @@ # Components | ||
| __version__ = "2.0.7" | ||
| __version__ = "2.0.8" | ||
@@ -80,3 +80,2 @@ __all__ = [ | ||
| "JsonMode", | ||
| "depends", | ||
| # Components | ||
@@ -83,0 +82,0 @@ "Dialect", |
@@ -17,10 +17,3 @@ """Request builder for relation queries.""" | ||
| # Re-export from components for backward compatibility | ||
| from ..components.filter_parser import ( | ||
| FilterExpression, | ||
| FilterTriplet, | ||
| SQLOperator, | ||
| ) | ||
| class FetchMode(Enum): | ||
@@ -27,0 +20,0 @@ """Database cursor fetch mode.""" |
@@ -125,3 +125,5 @@ """ | ||
| else: | ||
| raise ValueError(f"Operator '{op}' cannot be used with None") | ||
| raise ValueError( | ||
| f"Operator '{op}' cannot be used with None" | ||
| ) | ||
| clause = f"{field} {op} %s" | ||
@@ -128,0 +130,0 @@ return clause, (value,) |
@@ -60,2 +60,8 @@ """PostgreSQL connection pool management.""" | ||
| except ( | ||
| asyncpg.InvalidCatalogNameError, | ||
| asyncpg.ConnectionDoesNotExistError, | ||
| ): | ||
| # ΠΠ Π½Π΅ ΡΡΡΠ΅ΡΡΠ²ΡΠ΅Ρ β ΠΏΡΠΎΠ±ΡΠ°ΡΡΠ²Π°Π΅ΠΌ Π½Π°Π²Π΅ΡΡ Π΄Π»Ρ ΡΠΎΠ·Π΄Π°Π½ΠΈΡ | ||
| raise | ||
| except ( | ||
| ConnectionError, | ||
@@ -103,3 +109,3 @@ TimeoutError, | ||
| """ | ||
| stmt_foreign_keys = [] | ||
| stmt_foreign_keys: list[tuple[str, str]] = [] | ||
@@ -112,9 +118,19 @@ async with ContainerTransaction(self.pool) as session: | ||
| # ΡΠΎΠ·Π΄Π°Π΅ΠΌ Π²Π½Π΅ΡΠ½ΠΈΠ΅ ΠΊΠ»ΡΡΠΈ, Π΄Π»Ρ ΡΡΡΠ»ΠΎΡΠ½ΠΎΠΉ ΡΠ΅Π»ΠΎΡΡΠ½ΠΎΡΡΠΈ | ||
| # Π½Π° ΠΏΠΎΠ»Ρ many2one Π° ΡΠ°ΠΊΠΆΠ΅ Π½Π° ΠΊΠΎΠ»ΠΎΠ½ΠΊΠΈ ΠΏΠΎΠ»Π΅ΠΉ many2many | ||
| for stmt_foreign_key in stmt_foreign_keys: | ||
| try: | ||
| await session.execute(stmt_foreign_key) | ||
| except asyncpg.exceptions.DuplicateObjectError: | ||
| # FK already exists | ||
| pass | ||
| if not stmt_foreign_keys: | ||
| return | ||
| # ΠΠ΅Π΄ΡΠΏΠ»ΠΈΠΊΠ°ΡΠΈΡ ΠΏΠΎ ΠΈΠΌΠ΅Π½ΠΈ FK (M2M ΡΠ°Π±Π»ΠΈΡΡ ΠΌΠΎΠ³ΡΡ Π΄ΡΠ±Π»ΠΈΡΠΎΠ²Π°ΡΡΡΡ) | ||
| unique_fks = { | ||
| fk_name: fk_sql for fk_name, fk_sql in stmt_foreign_keys | ||
| } | ||
| # ΠΠΎΠ»ΡΡΠ°Π΅ΠΌ ΡΡΡΠ΅ΡΡΠ²ΡΡΡΠΈΠ΅ FK ΠΎΠ΄Π½ΠΈΠΌ Π·Π°ΠΏΡΠΎΡΠΎΠΌ | ||
| existing_fk_result = await session.execute( | ||
| "SELECT conname FROM pg_constraint WHERE contype = 'f'" | ||
| ) | ||
| existing_fk_names = {row["conname"] for row in existing_fk_result} | ||
| # Π‘ΠΎΠ·Π΄Π°ΡΠΌ ΡΠΎΠ»ΡΠΊΠΎ ΠΎΡΡΡΡΡΡΠ²ΡΡΡΠΈΠ΅ FK | ||
| for fk_name, fk_sql in unique_fks.items(): | ||
| if fk_name not in existing_fk_names: | ||
| await session.execute(fk_sql) |
+16
-2
@@ -181,2 +181,16 @@ """ | ||
| def depends(*field_names: str) -> Callable[[Callable], Callable]: | ||
| """ | ||
| ΠΠ΅ΠΊΠΎΡΠ°ΡΠΎΡ Π΄Π»Ρ Π²ΡΡΠΈΡΠ»ΡΠ΅ΠΌΡΡ ΠΏΠΎΠ»Π΅ΠΉ. | ||
| Π£ΠΊΠ°Π·ΡΠ²Π°Π΅Ρ, ΠΎΡ ΠΊΠ°ΠΊΠΈΡ ΠΏΠΎΠ»Π΅ΠΉ Π·Π°Π²ΠΈΡΠΈΡ Π²ΡΡΠΈΡΠ»Π΅Π½ΠΈΠ΅. | ||
| ΠΠΎΠ΄Π΄Π΅ΡΠΆΠΈΠ²Π°Π΅Ρ Π²Π»ΠΎΠΆΠ΅Π½Π½ΡΠ΅ Π·Π°Π²ΠΈΡΠΈΠΌΠΎΡΡΠΈ. | ||
| """ | ||
| def decorator(func: Callable) -> Callable: | ||
| func.compute_deps = set(field_names) | ||
| return func | ||
| return decorator | ||
| # def model( | ||
@@ -361,4 +375,4 @@ # func: Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]], | ||
| # ΠΠ΅ΡΠ΅Π½ΠΎΡΠΈΠΌ ΠΌΠ΅ΡΠ°Π΄Π°Π½Π½ΡΠ΅ Π½Π° wrapper | ||
| wrapper._onchange_fields = fields | ||
| wrapper._is_onchange = True | ||
| wrapper._onchange_fields = fields # type: ignore | ||
| wrapper._is_onchange = True # type: ignore | ||
@@ -365,0 +379,0 @@ return wrapper |
+30
-25
@@ -37,2 +37,7 @@ """ORM field definitions.""" | ||
| relation_table_field - Field name in related model | ||
| schema_required - Override required status in API schema validation | ||
| True = required in schema (even if nullable) | ||
| False = optional in schema (even if not nullable) | ||
| None = auto-detect from type annotation | ||
| """ | ||
@@ -50,2 +55,3 @@ | ||
| required: bool | None = None | ||
| schema_required: bool | None = None | ||
| sql_type: str | ||
@@ -67,2 +73,5 @@ indexable: bool = True | ||
| def __init__(self, **kwargs: Any) -> None: | ||
| # schema_required - ΠΏΠ΅ΡΠ΅ΠΎΠΏΡΠ΅Π΄Π΅Π»ΡΠ΅Ρ ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»ΡΠ½ΠΎΡΡΡ Π² API ΡΡ Π΅ΠΌΠ΅ | ||
| self.schema_required = kwargs.pop("schema_required", None) | ||
| # Π΄ΠΎΠ±Π°Π²Π»ΡΠ΅ΠΌ ΠΏΠΎΠ»Π΅ required Π΄Π»Ρ ΡΠ΄ΠΎΠ±ΡΡΠ²Π° ΡΠ°Π±ΠΎΡΡ | ||
@@ -80,9 +89,2 @@ # ΠΊΠΎΡΠΎΡΠΎΠ΅ ΠΏΠ΅ΡΠ΅ΠΎΠΏΡΠ΅Π΄Π΅Π»ΡΠ΅Ρ null | ||
| self.store = kwargs.pop("store", self.store) | ||
| # self.primary_key = kwargs.pop("primary_key", False) | ||
| # self.null = kwargs.pop("null", True) | ||
| # self.unique = kwargs.pop("unique", False) | ||
| # self.description = kwargs.pop("description", None) | ||
| # self.default = kwargs.pop("default", None) | ||
| # self.ondelete = "restrict" if self.required else "set null" | ||
| # self.ondelete = kwargs.pop("null", self.null) | ||
| self.ondelete = ( | ||
@@ -218,7 +220,7 @@ "set null" if kwargs.pop("null", self.null) else "restrict" | ||
| Selection field - Π²ΡΠ±ΠΎΡ ΠΈΠ· ΡΠΏΠΈΡΠΊΠ° ΠΎΠΏΡΠΈΠΉ. | ||
| Π₯ΡΠ°Π½ΠΈΡΡΡ ΠΊΠ°ΠΊ VARCHAR, Π½ΠΎ ΠΈΠΌΠ΅Π΅Ρ ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½Π½ΡΠΉ Π½Π°Π±ΠΎΡ Π΄ΠΎΠΏΡΡΡΠΈΠΌΡΡ Π·Π½Π°ΡΠ΅Π½ΠΈΠΉ. | ||
| ΠΠΎΠ΄Π΄Π΅ΡΠΆΠΈΠ²Π°Π΅Ρ ΡΠ°ΡΡΠΈΡΠ΅Π½ΠΈΠ΅ ΡΠ΅ΡΠ΅Π· @extend Ρ selection_add: | ||
| # ΠΠ°Π·ΠΎΠ²Π°Ρ ΠΌΠΎΠ΄Π΅Π»Ρ | ||
@@ -231,3 +233,3 @@ class ChatConnector(DotModel): | ||
| ) | ||
| # Π Π°ΡΡΠΈΡΠ΅Π½ΠΈΠ΅ ΠΈΠ· Π΄ΡΡΠ³ΠΎΠ³ΠΎ ΠΌΠΎΠ΄ΡΠ»Ρ | ||
@@ -237,3 +239,3 @@ @extend(ChatConnector) | ||
| type = Selection(selection_add=[("telegram", "Telegram")]) | ||
| Args: | ||
@@ -245,3 +247,3 @@ options: Π‘ΠΏΠΈΡΠΎΠΊ ΠΊΠΎΡΡΠ΅ΠΆΠ΅ΠΉ (value, label) - Π±Π°Π·ΠΎΠ²ΡΠ΅ ΠΎΠΏΡΠΈΠΈ | ||
| """ | ||
| def __init__( | ||
@@ -251,3 +253,3 @@ self, | ||
| selection_add: list[tuple[str, str]] | None = None, | ||
| **kwargs | ||
| **kwargs, | ||
| ): | ||
@@ -260,9 +262,9 @@ # ΠΠ°Π·ΠΎΠ²ΡΠ΅ ΠΎΠΏΡΠΈΠΈ | ||
| self._selection_add = selection_add | ||
| # ΠΠ»Ρ Char Π½ΡΠΆΠ΅Π½ max_length | ||
| if "max_length" not in kwargs: | ||
| kwargs["max_length"] = 64 | ||
| super().__init__(**kwargs) | ||
| @property | ||
@@ -272,3 +274,3 @@ def options(self) -> list[tuple[str, str]]: | ||
| return self._base_options + self._added_options | ||
| @options.setter | ||
@@ -278,10 +280,10 @@ def options(self, value: list[tuple[str, str]]): | ||
| self._base_options = value or [] | ||
| def add_options(self, new_options: list[tuple[str, str]]) -> None: | ||
| """ | ||
| ΠΠΎΠ±Π°Π²ΠΈΡΡ ΠΎΠΏΡΠΈΠΈ ΠΊ ΠΏΠΎΠ»Ρ. | ||
| ΠΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΡΡΡ ΡΠΈΡΡΠ΅ΠΌΠΎΠΉ ΡΠ°ΡΡΠΈΡΠ΅Π½ΠΈΠΉ (@extend) Π΄Π»Ρ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΡ | ||
| Π½ΠΎΠ²ΡΡ Π·Π½Π°ΡΠ΅Π½ΠΈΠΉ Π² Selection ΠΏΠΎΠ»Π΅. | ||
| Args: | ||
@@ -291,9 +293,12 @@ new_options: Π‘ΠΏΠΈΡΠΎΠΊ ΠΊΠΎΡΡΠ΅ΠΆΠ΅ΠΉ (value, label) | ||
| for opt in new_options: | ||
| if opt not in self._base_options and opt not in self._added_options: | ||
| if ( | ||
| opt not in self._base_options | ||
| and opt not in self._added_options | ||
| ): | ||
| self._added_options.append(opt) | ||
| def get_values(self) -> list[str]: | ||
| """ΠΠΎΠ»ΡΡΠΈΡΡ ΡΠΏΠΈΡΠΎΠΊ Π΄ΠΎΠΏΡΡΡΠΈΠΌΡΡ Π·Π½Π°ΡΠ΅Π½ΠΈΠΉ (Π±Π΅Π· labels).""" | ||
| return [opt[0] for opt in self.options] | ||
| def get_label(self, value: str) -> str | None: | ||
@@ -305,3 +310,3 @@ """ΠΠΎΠ»ΡΡΠΈΡΡ label Π΄Π»Ρ Π·Π½Π°ΡΠ΅Π½ΠΈΡ.""" | ||
| return None | ||
| def is_selection_add(self) -> bool: | ||
@@ -308,0 +313,0 @@ """ΠΡΠΎΠ²Π΅ΡΠΈΡΡ ΡΠ²Π»ΡΠ΅ΡΡΡ Π»ΠΈ ΡΡΠΎ ΡΠ°ΡΡΠΈΡΠ΅Π½ΠΈΠ΅ΠΌ (selection_add).""" |
| try: | ||
| from pydantic import create_model, ConfigDict | ||
| from pydantic.fields import FieldInfo, Field | ||
| from pydantic.fields import Field | ||
| except: | ||
@@ -38,3 +38,4 @@ print("pydantic lib not installed") | ||
| **params, | ||
| ) # type: ignore | ||
| # field_name=(list[Literal[*allowed_fields]], ...), | ||
| ) | ||
| fields_relation.append(SchemaGetFieldRelationInput) | ||
@@ -229,6 +230,5 @@ | ||
| if isinstance(field_value, DotField): | ||
| if not field_value.null: | ||
| required = Ellipsis | ||
| else: | ||
| required = None | ||
| # ΠΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΠΌ ΠΌΠ΅ΡΠΎΠ΄ ΠΌΠΎΠ΄Π΅Π»ΠΈ Π΄Π»Ρ ΠΎΠΏΡΠ΅Π΄Π΅Π»Π΅Π½ΠΈΡ ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»ΡΠ½ΠΎΡΡΠΈ | ||
| is_required = cls._is_field_required(name, field_value) | ||
| required = Ellipsis if is_required else None | ||
@@ -235,0 +235,0 @@ # Π΅ΡΠ»ΠΈ Π΅ΡΡΡ Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅ ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ |
+121
-44
@@ -7,2 +7,3 @@ """DotModel - main ORM model class.""" | ||
| import json | ||
| from types import UnionType | ||
| from typing import ( | ||
@@ -12,2 +13,3 @@ TYPE_CHECKING, | ||
| Any, | ||
| Awaitable, | ||
| Callable, | ||
@@ -18,2 +20,4 @@ ClassVar, | ||
| dataclass_transform, | ||
| get_origin, | ||
| get_args, | ||
| ) | ||
@@ -60,18 +64,5 @@ | ||
| UPDATE = 4 | ||
| NESTED_LIST = 5 # ΠΠ»Ρ Π²Π»ΠΎΠΆΠ΅Π½Π½ΡΡ Π·Π°ΠΏΠΈΡΠ΅ΠΉ Π²Π½ΡΡΡΠΈ FORM | ||
| def depends(*field_names: str) -> Callable[[Callable], Callable]: | ||
| """ | ||
| ΠΠ΅ΠΊΠΎΡΠ°ΡΠΎΡ Π΄Π»Ρ Π²ΡΡΠΈΡΠ»ΡΠ΅ΠΌΡΡ ΠΏΠΎΠ»Π΅ΠΉ. | ||
| Π£ΠΊΠ°Π·ΡΠ²Π°Π΅Ρ, ΠΎΡ ΠΊΠ°ΠΊΠΈΡ ΠΏΠΎΠ»Π΅ΠΉ Π·Π°Π²ΠΈΡΠΈΡ Π²ΡΡΠΈΡΠ»Π΅Π½ΠΈΠ΅. | ||
| ΠΠΎΠ΄Π΄Π΅ΡΠΆΠΈΠ²Π°Π΅Ρ Π²Π»ΠΎΠΆΠ΅Π½Π½ΡΠ΅ Π·Π°Π²ΠΈΡΠΈΠΌΠΎΡΡΠΈ. | ||
| """ | ||
| def decorator(func: Callable) -> Callable: | ||
| func.compute_deps = set(field_names) | ||
| return func | ||
| return decorator | ||
| @dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) | ||
@@ -86,2 +77,3 @@ class ModelMetaclass(ABCMeta): ... | ||
| from .orm.mixins.relations import OrmRelationsMixin | ||
| from .orm.mixins.access import AccessMixin | ||
@@ -94,2 +86,3 @@ | ||
| OrmPrimaryMixin, | ||
| AccessMixin, | ||
| metaclass=ModelMetaclass, | ||
@@ -444,18 +437,4 @@ ): | ||
| for name, field in cls.get_fields().items(): | ||
| if field.default is not None: | ||
| if callable(field.default): | ||
| # Π΅ΡΠ»ΠΈ ΠΊΠΎΡΡΡΠΈΠ½Π° ΡΠΎ ΡΠ΄Π΅Π»Π°ΡΡ Π°Π²Π΅ΠΉΡ | ||
| if asyncio.iscoroutinefunction(field.default): | ||
| res = await field.default() | ||
| default_values.update({name: res}) | ||
| # ΠΈΠ½Π°ΡΠ΅ ΠΏΡΠΎΡΡΠΎ Π²ΡΠ·ΠΎΠ² | ||
| else: | ||
| default_values.update({name: field.default()}) | ||
| else: | ||
| default_values.update({name: field.default}) | ||
| elif isinstance(field, (One2many, Many2many)): | ||
| # Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅ ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ Π΄Π»Ρ m2m ΠΈ o2m | ||
| # Π΅ΡΠ»ΠΈ Ρ ΠΊΠ»ΠΈΠ΅Π½ΡΠ° ΠΏΠ΅ΡΠ΅Π΄Π°Π½ ΡΠΏΠΈΡΠΎΠΊ Π²Π»ΠΎΠΆΠ΅Π½Π½ΡΡ ΠΏΠΎΠ»Π΅ΠΉ, | ||
| # ΡΠΎ ΡΡΡΠ°Π½ΠΎΠ²ΠΈΡΡ Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅ ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ | ||
| # ΠΠ»Ρ One2many ΠΈ Many2many Π²ΡΠ΅Π³Π΄Π° Π²ΠΎΠ·Π²ΡΠ°ΡΠ°Π΅ΠΌ ΡΡΡΡΠΊΡΡΡΡ x2m_default | ||
| if isinstance(field, (One2many, Many2many)): | ||
| fields_nested = fields_client_nested.get(name) | ||
@@ -473,2 +452,14 @@ if fields_nested: | ||
| elif field.default is not None: | ||
| if callable(field.default): | ||
| # Π΅ΡΠ»ΠΈ ΠΊΠΎΡΡΡΠΈΠ½Π° ΡΠΎ ΡΠ΄Π΅Π»Π°ΡΡ Π°Π²Π΅ΠΉΡ | ||
| if asyncio.iscoroutinefunction(field.default): | ||
| res = await field.default() | ||
| default_values.update({name: res}) | ||
| # ΠΈΠ½Π°ΡΠ΅ ΠΏΡΠΎΡΡΠΎ Π²ΡΠ·ΠΎΠ² | ||
| else: | ||
| default_values.update({name: field.default()}) | ||
| else: | ||
| default_values.update({name: field.default}) | ||
| return default_values | ||
@@ -495,2 +486,65 @@ | ||
| @classmethod | ||
| def _is_field_required(cls, field_name: str, field: Field) -> bool: | ||
| """ | ||
| ΠΠΏΡΠ΅Π΄Π΅Π»ΡΠ΅Ρ, ΡΠ²Π»ΡΠ΅ΡΡΡ Π»ΠΈ ΠΏΠΎΠ»Π΅ ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»ΡΠ½ΡΠΌ Π΄Π»Ρ API ΡΡ Π΅ΠΌΡ. | ||
| ΠΡΠΈΠΎΡΠΈΡΠ΅Ρ: | ||
| 1. schema_required (Π΅ΡΠ»ΠΈ Π·Π°Π΄Π°Π½) β ΡΠ²Π½ΠΎΠ΅ ΠΏΠ΅ΡΠ΅ΠΎΠΏΡΠ΅Π΄Π΅Π»Π΅Π½ΠΈΠ΅ Π΄Π»Ρ ΡΡ Π΅ΠΌΡ | ||
| 2. Primary key β Π²ΡΠ΅Π³Π΄Π° Π½Π΅ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»Π΅Π½ (Π°Π²ΡΠΎΠ³Π΅Π½Π΅ΡΠ°ΡΠΈΡ) | ||
| 3. required (Π΅ΡΠ»ΠΈ Π·Π°Π΄Π°Π½) β ΠΎΠ±ΡΠ°Ρ ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»ΡΠ½ΠΎΡΡΡ | ||
| 4. ΠΠ½Π½ΠΎΡΠ°ΡΠΈΡ ΡΠΈΠΏΠ° β Π°Π²ΡΠΎΠΎΠΏΡΠ΅Π΄Π΅Π»Π΅Π½ΠΈΠ΅ | ||
| Args: | ||
| field_name: ΠΠΌΡ ΠΏΠΎΠ»Ρ | ||
| field: ΠΠ±ΡΠ΅ΠΊΡ Field | ||
| Returns: | ||
| True Π΅ΡΠ»ΠΈ ΠΏΠΎΠ»Π΅ ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»ΡΠ½ΠΎ Π² ΡΡ Π΅ΠΌΠ΅, False ΠΈΠ½Π°ΡΠ΅ | ||
| """ | ||
| # 1. schema_required ΠΈΠΌΠ΅Π΅Ρ Π²ΡΡΡΠΈΠΉ ΠΏΡΠΈΠΎΡΠΈΡΠ΅Ρ Π΄Π»Ρ ΡΡ Π΅ΠΌΡ | ||
| if field.schema_required is not None: | ||
| return field.schema_required | ||
| # 2. Primary key Π½Π΅ ΡΡΠ΅Π±ΡΠ΅Ρ Π²Π²ΠΎΠ΄Π° ΠΎΡ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ | ||
| if field.primary_key: | ||
| return False | ||
| # 3. ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ ΡΠ²Π½ΠΎ Π·Π°Π΄Π°Π½Π½ΡΠΉ Π°ΡΡΠΈΠ±ΡΡ required | ||
| if field.required is not None: | ||
| return field.required | ||
| # 4. ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ Π°Π½Π½ΠΎΡΠ°ΡΠΈΡ ΡΠΈΠΏΠ° | ||
| annotations = getattr(cls, "__annotations__", {}) | ||
| if field_name not in annotations: | ||
| # ΠΡΠ»ΠΈ Π°Π½Π½ΠΎΡΠ°ΡΠΈΠΈ Π½Π΅Ρ, ΡΠΌΠΎΡΡΠΈΠΌ Π½Π° null ΠΈΠ· ΠΏΠΎΠ»Ρ | ||
| return not field.null | ||
| py_type = annotations[field_name] | ||
| # Π‘ΡΡΠΎΠΊΠΎΠ²Π°Ρ Π°Π½Π½ΠΎΡΠ°ΡΠΈΡ: "Model | None" | ||
| if isinstance(py_type, str): | ||
| if "None" in py_type: | ||
| return False | ||
| # ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ Π½Π° list Π² ΡΡΡΠΎΠΊΠ΅ | ||
| if py_type.startswith("list[") or py_type.startswith("List["): | ||
| return False | ||
| return True | ||
| origin = get_origin(py_type) | ||
| # Π‘ΠΏΠΈΡΠΊΠΈ Π½Π΅ ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»ΡΠ½Ρ (One2many, Many2many) | ||
| if origin is list: | ||
| return False | ||
| # Union ΡΠΈΠΏ: ΠΏΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ Π½Π°Π»ΠΈΡΠΈΠ΅ None | ||
| if origin is UnionType or origin is Union: | ||
| args = get_args(py_type) | ||
| if type(None) in args: | ||
| return False | ||
| return True | ||
| # ΠΡΠΎΡΡΠΎΠΉ ΡΠΈΠΏ Π±Π΅Π· None - ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»Π΅Π½ | ||
| return True | ||
| @classmethod | ||
| def get_fields_info_list(cls, fields_list: list[str]): | ||
@@ -501,2 +555,3 @@ """Get field info for list view.""" | ||
| if name in fields_list: | ||
| required = cls._is_field_required(name, field) | ||
| if field.relation: | ||
@@ -512,2 +567,3 @@ fields_info.append( | ||
| ), | ||
| "required": required, | ||
| } | ||
@@ -521,2 +577,3 @@ ) | ||
| "options": field.options or [], | ||
| "required": required, | ||
| } | ||
@@ -532,2 +589,3 @@ ) | ||
| if name in fields_list: | ||
| required = cls._is_field_required(name, field) | ||
| if field.relation: | ||
@@ -544,2 +602,3 @@ fields_info.append( | ||
| "relatedField": (field.relation_table_field or ""), | ||
| "required": required, | ||
| } | ||
@@ -553,2 +612,3 @@ ) | ||
| "options": field.options or [], | ||
| "required": required, | ||
| } | ||
@@ -613,2 +673,3 @@ ) | ||
| if mode == JsonMode.LIST: | ||
| # ΠΡΠΈ search: field ΡΡΠΎ list | ||
| fields_json[field_name] = [ | ||
@@ -619,8 +680,20 @@ { | ||
| } | ||
| for rec in field | ||
| ] | ||
| elif mode == JsonMode.NESTED_LIST: | ||
| # ΠΠ»ΠΎΠΆΠ΅Π½Π½Π°Ρ ΡΠ΅ΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΡ ΠΈΠ· FORM: field ΡΡΠΎ dict Ρ data | ||
| fields_json[field_name] = [ | ||
| { | ||
| "id": rec.id, | ||
| "name": rec.name or str(rec.id), | ||
| } | ||
| for rec in field["data"] | ||
| ] | ||
| elif mode == JsonMode.FORM: | ||
| # TODO: ΡΡΡ Π½Π°Π΄ΠΎ ΠΎΡΡΠ°Π²ΠΈΡΡ ΡΠΎΠ»ΡΠΊΠΎ ΡΠ΅ ΠΏΠΎΠ»Ρ ΠΊΠΎΡΠΎΡΡΠ΅ Π΅ΡΡΡ Π² ΡΠ΅ΠΊΡΡΠΈΠΉ ΠΌΠΎΠΌΠ΅Π½Ρ | ||
| # ΠΡΠΈ FORM (get) field ΡΡΠΎ dict Ρ data/fields/total | ||
| fields_json[field_name] = { | ||
| "data": [rec.json() for rec in field["data"]], | ||
| "data": [ | ||
| rec.json(mode=JsonMode.NESTED_LIST) | ||
| for rec in field["data"] | ||
| ], | ||
| "fields": field["fields"], | ||
@@ -685,7 +758,9 @@ "total": field["total"], | ||
| for attr_name in dir(cls): | ||
| if attr_name.startswith("_"): | ||
| attr = getattr(cls, attr_name, None) | ||
| if attr and callable(attr) and hasattr(attr, "_is_onchange"): | ||
| onchange_fields = getattr(attr, "_onchange_fields", ()) | ||
| fields_with_onchange.update(onchange_fields) | ||
| # ΠΡΠΎΠΏΡΡΠΊΠ°Π΅ΠΌ dunder ΠΌΠ΅ΡΠΎΠ΄Ρ | ||
| if attr_name.startswith("__"): | ||
| continue | ||
| attr = getattr(cls, attr_name, None) | ||
| if attr and callable(attr) and hasattr(attr, "_is_onchange"): | ||
| onchange_fields = getattr(attr, "_onchange_fields", ()) | ||
| fields_with_onchange.update(onchange_fields) | ||
@@ -708,8 +783,10 @@ return list(fields_with_onchange) | ||
| for attr_name in dir(cls): | ||
| if attr_name.startswith("_"): | ||
| attr = getattr(cls, attr_name, None) | ||
| if attr and callable(attr) and hasattr(attr, "_is_onchange"): | ||
| onchange_fields = getattr(attr, "_onchange_fields", ()) | ||
| if field_name in onchange_fields: | ||
| handlers.append(attr_name) | ||
| # ΠΡΠΎΠΏΡΡΠΊΠ°Π΅ΠΌ dunder ΠΌΠ΅ΡΠΎΠ΄Ρ | ||
| if attr_name.startswith("__"): | ||
| continue | ||
| attr = getattr(cls, attr_name, None) | ||
| if attr and callable(attr) and hasattr(attr, "_is_onchange"): | ||
| onchange_fields = getattr(attr, "_onchange_fields", ()) | ||
| if field_name in onchange_fields: | ||
| handlers.append(attr_name) | ||
@@ -734,3 +811,3 @@ return handlers | ||
| for handler_name in handlers: | ||
| handler = getattr(self, handler_name, None) | ||
| handler: Awaitable | None = getattr(self, handler_name, None) | ||
| if handler and callable(handler): | ||
@@ -737,0 +814,0 @@ handler_result = await handler() |
+53
-31
@@ -110,2 +110,5 @@ """DDL Mixin - provides table creation functionality.""" | ||
| Π°Π²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΈ ΠΏΡΠΈ ΡΠΎΠ·Π΄Π°Π½ΠΈΠΈ ΠΎΡΠ½ΠΎΠ²Π½ΠΎΠΉ ΠΌΠΎΠ΄Π΅Π»ΠΈ. | ||
| Returns: | ||
| list[tuple[str, str]]: Π‘ΠΏΠΈΡΠΎΠΊ ΠΊΠΎΡΡΠ΅ΠΆΠ΅ΠΉ (fk_name, fk_sql) Π΄Π»Ρ ΡΠΎΠ·Π΄Π°Π½ΠΈΡ FK | ||
| """ | ||
@@ -122,5 +125,5 @@ # ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ ΡΠ»Π°Π³ __auto_create__ | ||
| fields_created: list = [] | ||
| # Π³ΠΎΡΠΎΠ²ΡΠΉ Π·Π°ΠΏΡΠΎΡ Π½Π° Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΡ FK | ||
| many2one_fields_fk: list[str] = [] | ||
| many2many_fields_fk: list[str] = [] | ||
| # Π³ΠΎΡΠΎΠ²ΡΠΉ Π·Π°ΠΏΡΠΎΡ Π½Π° Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΡ FK: ΡΠΏΠΈΡΠΎΠΊ ΠΊΠΎΡΡΠ΅ΠΆΠ΅ΠΉ (fk_name, fk_sql) | ||
| many2one_fields_fk: list[tuple[str, str]] = [] | ||
| many2many_fields_fk: list[tuple[str, str]] = [] | ||
| # Π·Π°ΠΏΡΠΎΡΡ Π½Π° ΡΠΎΠ·Π΄Π°Π½ΠΈΠ΅ ΠΈΠ½Π΄Π΅ΠΊΡΠΎΠ² | ||
@@ -153,7 +156,12 @@ index_statements: list[str] = [] | ||
| if isinstance(field, Many2one): | ||
| # Π½Π΅ Π·Π°Π±ΡΡΡ ΡΠΎΠ·Π΄Π°ΡΡ FK Π΄Π»Ρ many2one | ||
| # ALTER TABLE %s ADD FOREIGN KEY (%s) REFERENCES %s(%s) ON DELETE %s", | ||
| many2one_fields_fk.append( | ||
| f"""ALTER TABLE IF EXISTS {cls.__table__} ADD FOREIGN KEY ({field_name}) REFERENCES "{field.relation_table.__table__}" (id) ON DELETE {field.ondelete}""" | ||
| # FK Ρ ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½Π½ΡΠΌ CONSTRAINT | ||
| fk_name = f"fk_{cls.__table__}_{field_name}" | ||
| fk_sql = ( | ||
| f'ALTER TABLE IF EXISTS "{cls.__table__}" ' | ||
| f'ADD CONSTRAINT "{fk_name}" ' | ||
| f'FOREIGN KEY ("{field_name}") ' | ||
| f'REFERENCES "{field.relation_table.__table__}" (id) ' | ||
| f"ON DELETE {field.ondelete}" | ||
| ) | ||
| many2one_fields_fk.append((fk_name, fk_sql)) | ||
@@ -168,3 +176,3 @@ # ΡΠΎΠ·Π΄Π°Π½ΠΈΠ΅ ΠΈΠ½Π΄Π΅ΠΊΡΠ° Π΄Π»Ρ ΠΏΠΎΠ»Ρ Ρ index=True | ||
| index_statements.append( | ||
| f'CREATE INDEX IF NOT EXISTS "{index_name}" ON {cls.__table__} ("{field_name}")' | ||
| f'CREATE INDEX IF NOT EXISTS "{index_name}" ON "{cls.__table__}" ("{field_name}")' | ||
| ) | ||
@@ -178,16 +186,31 @@ | ||
| if field.relation and isinstance(field, Many2many): | ||
| # id_column = '"id" SERIAL PRIMARY KEY' | ||
| column1 = f'"{field.column1}" INTEGER NOT NULL' | ||
| column2 = f'"{field.column2}" INTEGER NOT NULL' | ||
| create_table_sql = f"""\ | ||
| CREATE TABLE IF NOT EXISTS {field.many2many_table} (\ | ||
| CREATE TABLE IF NOT EXISTS "{field.many2many_table}" (\ | ||
| {', '.join([column1, column2])}\ | ||
| ); | ||
| """ | ||
| many2many_fields_fk.append( | ||
| f"""ALTER TABLE IF EXISTS "{field.many2many_table}" ADD FOREIGN KEY ({field.column2}) REFERENCES {cls.__table__} (id) ON DELETE {field.ondelete}""" | ||
| # FK Ρ ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½Π½ΡΠΌΠΈ CONSTRAINT | ||
| fk_name1 = f"fk_{field.many2many_table}_{field.column2}" | ||
| fk_sql1 = ( | ||
| f'ALTER TABLE IF EXISTS "{field.many2many_table}" ' | ||
| f'ADD CONSTRAINT "{fk_name1}" ' | ||
| f'FOREIGN KEY ("{field.column2}") ' | ||
| f'REFERENCES "{cls.__table__}" (id) ' | ||
| f"ON DELETE {field.ondelete}" | ||
| ) | ||
| many2many_fields_fk.append( | ||
| f"""ALTER TABLE IF EXISTS "{field.many2many_table}" ADD FOREIGN KEY ({field.column1}) REFERENCES "{field.relation_table.__table__}" (id) ON DELETE {field.ondelete}""" | ||
| fk_name2 = f"fk_{field.many2many_table}_{field.column1}" | ||
| fk_sql2 = ( | ||
| f'ALTER TABLE IF EXISTS "{field.many2many_table}" ' | ||
| f'ADD CONSTRAINT "{fk_name2}" ' | ||
| f'FOREIGN KEY ("{field.column1}") ' | ||
| f'REFERENCES "{field.relation_table.__table__}" (id) ' | ||
| f"ON DELETE {field.ondelete}" | ||
| ) | ||
| many2many_fields_fk.append((fk_name1, fk_sql1)) | ||
| many2many_fields_fk.append((fk_name2, fk_sql2)) | ||
| await session.execute(create_table_sql) | ||
@@ -198,3 +221,3 @@ | ||
| index_statements.append( | ||
| f'CREATE INDEX IF NOT EXISTS "{m2m_index_name}" ON {field.many2many_table} ("{field.column1}", "{field.column2}")' | ||
| f'CREATE INDEX IF NOT EXISTS "{m2m_index_name}" ON "{field.many2many_table}" ("{field.column1}", "{field.column2}")' | ||
| ) | ||
@@ -204,3 +227,3 @@ | ||
| create_table_sql = f"""\ | ||
| CREATE TABLE IF NOT EXISTS {cls.__table__} (\ | ||
| CREATE TABLE IF NOT EXISTS "{cls.__table__}" (\ | ||
| {', '.join(fields_created_declaration)}\ | ||
@@ -212,13 +235,18 @@ );""" | ||
| # Π΅ΡΠ»ΠΈ ΡΠ°Π±Π»ΠΈΡΡ ΡΠΆΠ΅ Π±ΡΠ»ΠΈ ΡΠΎΠ·Π΄Π°Π½Ρ, Π½ΠΎ ΠΏΠΎΡΠ²ΠΈΠ»ΠΈΡΡ Π½ΠΎΠ²ΡΠ΅ ΠΏΠΎΠ»Ρ | ||
| # Π½Π΅ΠΎΠ±Ρ ΠΎΠ΄ΠΈΠΌΠΎ ΠΈΡ Π΄ΠΎΠ±Π°Π²ΠΈΡΡ | ||
| # ΠΠΠ’ΠΠΠΠΠΠ¦ΠΠ―: ΠΏΠΎΠ»ΡΡΠ°Π΅ΠΌ Π²ΡΠ΅ ΠΊΠΎΠ»ΠΎΠ½ΠΊΠΈ ΡΠ°Π±Π»ΠΈΡΡ ΠΠΠΠΠ Π·Π°ΠΏΡΠΎΡΠΎΠΌ | ||
| existing_columns_sql = f""" | ||
| SELECT column_name | ||
| FROM information_schema.columns | ||
| WHERE table_name = '{cls.__table__}' | ||
| """ | ||
| existing_columns_result = await session.execute(existing_columns_sql) | ||
| existing_columns = { | ||
| row["column_name"] for row in existing_columns_result | ||
| } | ||
| # ΠΠΎΠ±Π°Π²Π»ΡΠ΅ΠΌ ΡΠΎΠ»ΡΠΊΠΎ ΠΎΡΡΡΡΡΡΠ²ΡΡΡΠΈΠ΅ ΠΊΠΎΠ»ΠΎΠ½ΠΊΠΈ | ||
| for field_name, field_declaration in fields_created: | ||
| sql = f"""SELECT column_name FROM information_schema.columns | ||
| WHERE table_name='{cls.__table__}' and column_name='{field_name}';""" | ||
| field_exist = await session.execute(sql) | ||
| # field_exist Π±ΡΠ΄Π΅Ρ ΠΏΡΡΡΡΠΌ ΡΠΏΠΈΡΠΊΠΎΠΌ [] Π΅ΡΠ»ΠΈ ΠΊΠΎΠ»ΠΎΠ½ΠΊΠΈ Π½Π΅Ρ | ||
| if not field_exist: | ||
| if field_name not in existing_columns: | ||
| await session.execute( | ||
| f"""ALTER TABLE {cls.__table__} ADD COLUMN {field_declaration};""" | ||
| f'ALTER TABLE "{cls.__table__}" ADD COLUMN {field_declaration};' | ||
| ) | ||
@@ -231,7 +259,1 @@ | ||
| return many2one_fields_fk + many2many_fields_fk | ||
| # async def __aiter__(self) -> typing.Iterator[Self]: | ||
| # """ Return an iterator over ``self``. """ | ||
| # recs = await self.search() | ||
| # for rec in recs: | ||
| # yield rec |
@@ -15,2 +15,3 @@ """Many2many ORM operations mixin.""" | ||
| from ...decorators import hybridmethod | ||
| from ..utils import execute_maybe_parallel | ||
@@ -133,4 +134,4 @@ | ||
| ] | ||
| # Π΅ΡΠ»ΠΈ ΠΎΠ΄ΠΈΠ½ ΠΈΠ· Π·Π°ΠΏΡΠΎΡΠΎΠ² Ρ ΠΎΡΠΈΠ±ΠΊΠΎΠΉ ΡΡΠ°Π·Ρ ΠΏΡΠ΅ΠΊΡΠ°ΡΠΈΡΡ Π²ΡΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ ΠΈ Π²ΡΠΊΠΈΠ½ΡΡΡ ΠΎΡΠΈΠ±ΠΊΡ | ||
| results = await asyncio.gather(*execute_list) | ||
| # Π²ΡΠΏΠΎΠ»Π½ΡΠ΅ΠΌ ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°ΡΠ΅Π»ΡΠ½ΠΎ Π² ΡΡΠ°Π½Π·Π°ΠΊΡΠΈΠΈ, ΠΏΠ°ΡΠ°Π»Π»Π΅Π»ΡΠ½ΠΎ Π²Π½Π΅ ΡΡΠ°Π½Π·Π°ΠΊΡΠΈΠΈ | ||
| results = await execute_maybe_parallel(execute_list) | ||
@@ -137,0 +138,0 @@ # ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ (ΠΏΠΎΠ»ΡΡΠ΅Π½Π½ΡΡ ΠΎΠΏΡΠΈΠΌΠΈΠ·ΠΈΡΠΎΠ²Π°Π½Π½ΡΡ Π·Π°ΠΏΡΠΎΡΠΎΠ²) ΠΏΠΎΠ»Π΅ΠΉ ΡΠ²ΡΠ·Π΅ΠΉ |
@@ -5,2 +5,7 @@ """Primary ORM operations mixin.""" | ||
| from ...access import Operation | ||
| from ...components.dialect import POSTGRES | ||
| from ...model import JsonMode | ||
| from ...decorators import hybridmethod | ||
| if TYPE_CHECKING: | ||
@@ -14,7 +19,3 @@ from ..protocol import DotModelProtocol | ||
| from ...components.dialect import POSTGRES | ||
| from ...model import JsonMode | ||
| from ...decorators import hybridmethod | ||
| # TypeVar for generic payload - accepts any DotModel subclass | ||
@@ -43,2 +44,4 @@ _M = TypeVar("_M", bound="DotModel") | ||
| async def delete(self, session=None): | ||
| await self._check_access(Operation.DELETE, record_ids=[self.id]) | ||
| session = self._get_db_session(session) | ||
@@ -51,2 +54,6 @@ stmt = self._builder.build_delete() | ||
| cls = self.__class__ | ||
| # ΠΠ΄Π½Π° ΠΏΡΠΎΠ²Π΅ΡΠΊΠ° Π΄Π»Ρ Π²ΡΠ΅Ρ ID | ||
| await cls._check_access(Operation.DELETE, record_ids=ids) | ||
| session = cls._get_db_session(session) | ||
@@ -62,2 +69,4 @@ stmt = cls._builder.build_delete_bulk(len(ids)) | ||
| ): | ||
| await self._check_access(Operation.UPDATE, record_ids=[self.id]) | ||
| session = self._get_db_session(session) | ||
@@ -69,3 +78,2 @@ if payload is None: | ||
| # Π‘Π΅ΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΡ Π² ORM ΡΠ»ΠΎΠ΅, Π° Π½Π΅ Π² Builder | ||
| if fields: | ||
@@ -98,5 +106,8 @@ payload_dict = payload.json( | ||
| cls = self.__class__ | ||
| # ΠΠ΄Π½Π° ΠΏΡΠΎΠ²Π΅ΡΠΊΠ° Π΄Π»Ρ Π²ΡΠ΅Ρ ID | ||
| await cls._check_access(Operation.UPDATE, record_ids=ids) | ||
| session = cls._get_db_session(session) | ||
| # Π‘Π΅ΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΡ Π² ORM ΡΠ»ΠΎΠ΅ | ||
| payload_dict = payload.json( | ||
@@ -115,5 +126,8 @@ exclude=payload.get_none_update_fields_set(), | ||
| cls = self.__class__ | ||
| # ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ table access Π΄ΠΎ ΡΠΎΠ·Π΄Π°Π½ΠΈΡ | ||
| await cls._check_access(Operation.CREATE) | ||
| session = cls._get_db_session(session) | ||
| # Π‘Π΅ΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΡ Π² ORM ΡΠ»ΠΎΠ΅ | ||
| payload_dict = payload.json( | ||
@@ -128,3 +142,2 @@ exclude=payload.get_none_update_fields_set(), | ||
| # Use dialect instead of _is_postgres | ||
| if cls._dialect.supports_returning: | ||
@@ -134,15 +147,22 @@ stmt += " RETURNING id" | ||
| assert record is not None | ||
| return record[0]["id"] | ||
| record_id = record[0]["id"] | ||
| else: | ||
| record = await session.execute(stmt, values, cursor="lastrowid") | ||
| assert record is not None | ||
| record_id = record | ||
| # TODO: ΡΠΎΠ·Π΄Π°Π½ΠΈΠ΅ relations ΠΏΠΎΠ»Π΅ΠΉ | ||
| record = await session.execute(stmt, values, cursor="lastrowid") | ||
| assert record is not None | ||
| return record | ||
| # ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ row access ΠΏΠΎΡΠ»Π΅ ΡΠΎΠ·Π΄Π°Π½ΠΈΡ (Π΄Π»Ρ Rules ΡΠΈΠΏΠ° "ΡΠΎΠ»ΡΠΊΠΎ ΡΠ²ΠΎΠΈ Π·Π°ΠΏΠΈΡΠΈ") | ||
| await cls._check_access(Operation.CREATE, record_ids=[record_id]) | ||
| return record_id | ||
| @hybridmethod | ||
| async def create_bulk(self, payload: list[_M], session=None): | ||
| cls = self.__class__ | ||
| # ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ table access Π΄ΠΎ ΡΠΎΠ·Π΄Π°Π½ΠΈΡ | ||
| await cls._check_access(Operation.CREATE) | ||
| session = cls._get_db_session(session) | ||
| # ΠΡΠΊΠ»ΡΡΠ°Π΅ΠΌ primary_key ΠΏΠΎΠ»Ρ | ||
| exclude_fields = { | ||
@@ -154,3 +174,2 @@ name | ||
| # Π‘Π΅ΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΡ | ||
| payloads_dicts = [ | ||
@@ -169,2 +188,8 @@ p.json( | ||
| records = await session.execute(stmt, values, cursor="fetch") | ||
| # ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ row access ΠΏΠΎΡΠ»Π΅ ΡΠΎΠ·Π΄Π°Π½ΠΈΡ | ||
| if records: | ||
| created_ids = [r["id"] for r in records] | ||
| await cls._check_access(Operation.CREATE, record_ids=created_ids) | ||
| return records | ||
@@ -175,2 +200,5 @@ | ||
| cls = self.__class__ | ||
| await cls._check_access(Operation.READ, record_ids=[id]) | ||
| session = cls._get_db_session(session) | ||
@@ -184,3 +212,2 @@ | ||
| if not record: | ||
| # return None | ||
| raise ValueError("Record not found") | ||
@@ -196,3 +223,2 @@ assert isinstance(record, cls) | ||
| # Use dialect for column name | ||
| if cls._dialect == POSTGRES: | ||
@@ -199,0 +225,0 @@ prepare = lambda rows: [r["count"] for r in rows] |
| """Relations ORM operations mixin.""" | ||
| import asyncio | ||
| from typing import TYPE_CHECKING, Any, Literal, Self, TypeVar | ||
| from ...components.filter_parser import FilterExpression | ||
| from ...decorators import hybridmethod | ||
| from ...access import Operation | ||
| from ...builder.request_builder import ( | ||
| RequestBuilderForm, | ||
| ) | ||
| from ...fields import ( | ||
| AttachmentMany2one, | ||
| AttachmentOne2many, | ||
| Many2many, | ||
| Many2one, | ||
| One2many, | ||
| One2one, | ||
| ) | ||
| from ..utils import execute_maybe_parallel | ||
@@ -19,16 +32,3 @@ if TYPE_CHECKING: | ||
| from ...builder.request_builder import ( | ||
| FilterExpression, | ||
| RequestBuilderForm, | ||
| ) | ||
| from ...fields import ( | ||
| AttachmentMany2one, | ||
| AttachmentOne2many, | ||
| Many2many, | ||
| Many2one, | ||
| One2many, | ||
| One2one, | ||
| ) | ||
| class OrmRelationsMixin(_Base): | ||
@@ -72,2 +72,6 @@ """ | ||
| cls = self.__class__ | ||
| # Access check + apply domain filter | ||
| filter = await cls._check_access(Operation.READ, filter=filter) | ||
| session = cls._get_db_session(session) | ||
@@ -332,4 +336,4 @@ | ||
| # Π΅ΡΠ»ΠΈ ΠΎΠ΄ΠΈΠ½ ΠΈΠ· Π·Π°ΠΏΡΠΎΡΠΎΠ² Ρ ΠΎΡΠΈΠ±ΠΊΠΎΠΉ ΡΡΠ°Π·Ρ ΠΏΡΠ΅ΠΊΡΠ°ΡΠΈΡΡ Π²ΡΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ ΠΈ Π²ΡΠΊΠΈΠ½ΡΡΡ ΠΎΡΠΈΠ±ΠΊΡ | ||
| results = await asyncio.gather(*execute_list) | ||
| # Π²ΡΠΏΠΎΠ»Π½ΡΠ΅ΠΌ ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°ΡΠ΅Π»ΡΠ½ΠΎ Π² ΡΡΠ°Π½Π·Π°ΠΊΡΠΈΠΈ, ΠΏΠ°ΡΠ°Π»Π»Π΅Π»ΡΠ½ΠΎ Π²Π½Π΅ ΡΡΠ°Π½Π·Π°ΠΊΡΠΈΠΈ | ||
| results = await execute_maybe_parallel(execute_list) | ||
@@ -509,8 +513,5 @@ # Π΄ΠΎΠ±Π°Π²Π»ΡΠ΅ΠΌ Π°ΡΡΠΈΠ±ΡΡΡ ΠΊ ΠΈΡΡ ΠΎΠ΄Π½ΠΎΠΌΡ ΠΎΠ±ΡΠ΅ΠΊΡΡ, | ||
| # 1 conn | ||
| results = tuple() | ||
| for request in request_list: | ||
| res = await asyncio.gather(request) | ||
| results += tuple(res) | ||
| # Π²ΡΠΏΠΎΠ»Π½ΡΠ΅ΠΌ ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°ΡΠ΅Π»ΡΠ½ΠΎ | ||
| results = await execute_maybe_parallel(request_list) | ||
| return record_raw |
@@ -18,2 +18,3 @@ """Protocols defining what ORM mixins expect from the model class.""" | ||
| from ..fields import Field | ||
| from ..access import Operation | ||
| import aiomysql | ||
@@ -45,2 +46,11 @@ import asyncpg | ||
| # Access control (from AccessMixin) | ||
| @classmethod | ||
| async def _check_access( | ||
| cls, | ||
| operation: "Operation", | ||
| record_ids: list[int] | None = None, | ||
| filter: list | None = None, | ||
| ) -> list | None: ... | ||
| # Field introspection | ||
@@ -47,0 +57,0 @@ @classmethod |
+1
-1
| Metadata-Version: 2.4 | ||
| Name: dotorm | ||
| Version: 2.0.7 | ||
| Version: 2.0.8 | ||
| Summary: Async Python ORM for PostgreSQL, MySQL and ClickHouse with dot-notation access | ||
@@ -5,0 +5,0 @@ Project-URL: Homepage, https://github.com/shurshilov/dotorm |
+1
-1
@@ -7,3 +7,3 @@ [build-system] | ||
| name = "dotorm" | ||
| version = "2.0.7" | ||
| version = "2.0.8" | ||
| description = "Async Python ORM for PostgreSQL, MySQL and ClickHouse with dot-notation access" | ||
@@ -10,0 +10,0 @@ readme = "README.md" |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
315992
4.57%51
6.25%4895
7.02%