vs-db 0.1.0__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.
- vs_db/__init__.py +0 -0
- vs_db/base/__init__.py +0 -0
- vs_db/base/vs_audit_mixin.py +22 -0
- vs_db/base/vs_db_base.py +39 -0
- vs_db/base/vs_db_repo.py +132 -0
- vs_db/base/vs_soft_delete_mixin.py +14 -0
- vs_db/base/vs_version_mixin.py +12 -0
- vs_db/context/__init__.py +0 -0
- vs_db/context/db_context.py +39 -0
- vs_db/decorator/__init__.py +0 -0
- vs_db/decorator/vs_db_decorator.py +119 -0
- vs_db/middleware/__init__.py +0 -0
- vs_db/middleware/vs_db_middleware.py +39 -0
- vs_db/session/__init__.py +0 -0
- vs_db/session/vs_db_session_factory.py +100 -0
- vs_db/transaction/__init__.py +0 -0
- vs_db/transaction/vs_transaction_manager.py +64 -0
- vs_db-0.1.0.dist-info/METADATA +715 -0
- vs_db-0.1.0.dist-info/RECORD +22 -0
- vs_db-0.1.0.dist-info/WHEEL +5 -0
- vs_db-0.1.0.dist-info/licenses/LICENSE.txt +181 -0
- vs_db-0.1.0.dist-info/top_level.txt +1 -0
vs_db/__init__.py
ADDED
|
File without changes
|
vs_db/base/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import DateTime, event
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AuditMixin:
|
|
8
|
+
|
|
9
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
10
|
+
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@event.listens_for(AuditMixin, "before_insert", propagate=True)
|
|
14
|
+
def set_created_at(mapper, connection, target):
|
|
15
|
+
now = datetime.utcnow()
|
|
16
|
+
target.created_at = now
|
|
17
|
+
target.updated_at = now
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@event.listens_for(AuditMixin, "before_update", propagate=True)
|
|
21
|
+
def set_updated_at(mapper, connection, target):
|
|
22
|
+
target.updated_at = datetime.utcnow()
|
vs_db/base/vs_db_base.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from sqlalchemy import inspect
|
|
2
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
3
|
+
from datetime import datetime, date
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
class VsDbBase(DeclarativeBase):
|
|
8
|
+
"""
|
|
9
|
+
Shared declarative base for all Viveka entities.
|
|
10
|
+
|
|
11
|
+
All entity classes must inherit from VivekaBase so their table definitions
|
|
12
|
+
are registered in a single shared metadata object. This allows
|
|
13
|
+
VivekaDbBase.metadata.create_all(engine) to create every table in one call,
|
|
14
|
+
with correct dependency ordering for foreign keys.
|
|
15
|
+
|
|
16
|
+
Mapping is intentionally deferred: subclasses without __tablename__ in
|
|
17
|
+
their class body are marked __abstract__ so SQLAlchemy skips them.
|
|
18
|
+
The @table decorator sets __tablename__ and triggers mapping via
|
|
19
|
+
_as_declarative, preserving the Spring JPA-style decorator API.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def __init_subclass__(cls, **kwargs):
|
|
24
|
+
if '__tablename__' not in cls.__dict__:
|
|
25
|
+
cls.__abstract__ = True
|
|
26
|
+
super().__init_subclass__(**kwargs)
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict:
|
|
29
|
+
result = {}
|
|
30
|
+
for column in inspect(self).mapper.column_attrs:
|
|
31
|
+
value = getattr(self, column.key)
|
|
32
|
+
if isinstance(value, (datetime, date)):
|
|
33
|
+
value = value.isoformat()
|
|
34
|
+
elif isinstance(value, Decimal):
|
|
35
|
+
value = float(value)
|
|
36
|
+
elif isinstance(value, Enum):
|
|
37
|
+
value = value.value
|
|
38
|
+
result[column.key] = value
|
|
39
|
+
return result
|
vs_db/base/vs_db_repo.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Generic, List, Optional, Type, TypeVar, Union
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import select, func, exists, inspect, ColumnElement
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
|
7
|
+
|
|
8
|
+
T = TypeVar('T')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Page(Generic[T]):
|
|
13
|
+
items: List[T]
|
|
14
|
+
total: int
|
|
15
|
+
page: int
|
|
16
|
+
page_size: int
|
|
17
|
+
pages: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VsDbRepo(Generic[T]):
|
|
21
|
+
|
|
22
|
+
def __init__(self, model_class: Type[T]):
|
|
23
|
+
self.model_class = model_class
|
|
24
|
+
self._db: Optional[AsyncSession] = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def db(self) -> Optional[AsyncSession]:
|
|
28
|
+
if self._db is not None:
|
|
29
|
+
return self._db
|
|
30
|
+
from vs_db.context.db_context import get_session, has_session
|
|
31
|
+
return get_session() if has_session() else None
|
|
32
|
+
|
|
33
|
+
@db.setter
|
|
34
|
+
def db(self, value: Optional[AsyncSession]):
|
|
35
|
+
self._db = value
|
|
36
|
+
|
|
37
|
+
async def create(self, entity: T) -> T:
|
|
38
|
+
self.db.add(entity)
|
|
39
|
+
await self.db.flush()
|
|
40
|
+
await self.db.refresh(entity)
|
|
41
|
+
return entity
|
|
42
|
+
|
|
43
|
+
async def insert_all(self, entities: List[T]) -> List[T]:
|
|
44
|
+
self.db.add_all(entities)
|
|
45
|
+
await self.db.flush()
|
|
46
|
+
return entities
|
|
47
|
+
|
|
48
|
+
async def get_by_id(self, id: Any) -> Optional[T]:
|
|
49
|
+
return await self.db.get(self.model_class, id)
|
|
50
|
+
|
|
51
|
+
async def get_all(self, skip: int = 0, limit: int = 100) -> List[T]:
|
|
52
|
+
result = await self.db.execute(
|
|
53
|
+
select(self.model_class).offset(skip).limit(limit)
|
|
54
|
+
)
|
|
55
|
+
return list(result.scalars().all())
|
|
56
|
+
|
|
57
|
+
async def get_page(self, page: int = 1, page_size: int = 20) -> Page[T]:
|
|
58
|
+
total = await self.count()
|
|
59
|
+
result = await self.db.execute(
|
|
60
|
+
select(self.model_class).offset((page - 1) * page_size).limit(page_size)
|
|
61
|
+
)
|
|
62
|
+
items = list(result.scalars().all())
|
|
63
|
+
return Page(
|
|
64
|
+
items=items,
|
|
65
|
+
total=total,
|
|
66
|
+
page=page,
|
|
67
|
+
page_size=page_size,
|
|
68
|
+
pages=(total + page_size - 1) // page_size,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
async def update(self, entity: T) -> T:
|
|
72
|
+
merged = await self.db.merge(entity)
|
|
73
|
+
await self.db.flush()
|
|
74
|
+
return merged
|
|
75
|
+
|
|
76
|
+
async def delete(self, id: Any) -> bool:
|
|
77
|
+
entity = await self.get_by_id(id)
|
|
78
|
+
if entity:
|
|
79
|
+
await self.db.delete(entity)
|
|
80
|
+
await self.db.flush()
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
async def delete_entity(self, entity: T) -> bool:
|
|
85
|
+
await self.db.delete(entity)
|
|
86
|
+
await self.db.flush()
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
async def soft_delete(self, id: Any) -> bool:
|
|
90
|
+
entity = await self.get_by_id(id)
|
|
91
|
+
if entity and hasattr(entity, 'deleted_at'):
|
|
92
|
+
from datetime import datetime
|
|
93
|
+
entity.deleted_at = datetime.utcnow()
|
|
94
|
+
await self.db.flush()
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
async def exists(self, id: Any) -> bool:
|
|
99
|
+
pk = inspect(self.model_class).mapper.primary_key[0]
|
|
100
|
+
result = await self.db.execute(select(exists().where(pk == id)))
|
|
101
|
+
return result.scalar()
|
|
102
|
+
|
|
103
|
+
async def count(self) -> int:
|
|
104
|
+
result = await self.db.execute(
|
|
105
|
+
select(func.count()).select_from(self.model_class)
|
|
106
|
+
)
|
|
107
|
+
return result.scalar()
|
|
108
|
+
|
|
109
|
+
async def find_by_field(self, field: ColumnElement, value: Any) -> Optional[T]:
|
|
110
|
+
result = await self.db.execute(
|
|
111
|
+
select(self.model_class).where(field == value)
|
|
112
|
+
)
|
|
113
|
+
return result.scalars().first()
|
|
114
|
+
|
|
115
|
+
async def find_all_by_field(self, field: ColumnElement, value: Any) -> List[T]:
|
|
116
|
+
result = await self.db.execute(
|
|
117
|
+
select(self.model_class).where(field == value)
|
|
118
|
+
)
|
|
119
|
+
return list(result.scalars().all())
|
|
120
|
+
|
|
121
|
+
async def upsert(self, entity: T, conflict_fields: List[str]) -> T:
|
|
122
|
+
data = {c.key: getattr(entity, c.key) for c in inspect(entity).mapper.column_attrs}
|
|
123
|
+
pk = inspect(self.model_class).mapper.primary_key[0].name
|
|
124
|
+
stmt = (
|
|
125
|
+
mysql_insert(self.model_class)
|
|
126
|
+
.values(**data)
|
|
127
|
+
.on_duplicate_key_update(**{k: v for k, v in data.items() if k != pk})
|
|
128
|
+
)
|
|
129
|
+
result = await self.db.execute(stmt)
|
|
130
|
+
await self.db.flush()
|
|
131
|
+
entity_id = result.lastrowid or data.get(pk)
|
|
132
|
+
return await self.get_by_id(entity_id)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import DateTime
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SoftDeleteMixin:
|
|
9
|
+
|
|
10
|
+
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, default=None)
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def is_deleted(self) -> bool:
|
|
14
|
+
return self.deleted_at is not None
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from sqlalchemy import Integer
|
|
2
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
3
|
+
from sqlalchemy.orm import DeclarativeBaseNoMeta
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VersionMixin:
|
|
7
|
+
|
|
8
|
+
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
9
|
+
|
|
10
|
+
__mapper_args__ = {
|
|
11
|
+
"version_id_col": version,
|
|
12
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
_db_session: ContextVar[Optional[AsyncSession]] = ContextVar('db_session', default=None)
|
|
7
|
+
_pending_evictions: ContextVar[Optional[List[str]]] = ContextVar('pending_evictions', default=None)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_session() -> AsyncSession:
|
|
11
|
+
session = _db_session.get()
|
|
12
|
+
if session is None:
|
|
13
|
+
raise RuntimeError(
|
|
14
|
+
"No database session in context. "
|
|
15
|
+
"Ensure DatabaseSessionFactory is initialized and VsDbMiddleware is registered."
|
|
16
|
+
)
|
|
17
|
+
return session
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def has_session() -> bool:
|
|
21
|
+
return _db_session.get() is not None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def set_session(session: Optional[AsyncSession]) -> None:
|
|
25
|
+
_db_session.set(session)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init_pending_evictions() -> None:
|
|
29
|
+
_pending_evictions.set([])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add_pending_eviction(pattern: str) -> None:
|
|
33
|
+
evictions = _pending_evictions.get()
|
|
34
|
+
if evictions is not None:
|
|
35
|
+
evictions.append(pattern)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_pending_evictions() -> List[str]:
|
|
39
|
+
return _pending_evictions.get() or []
|
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Callable, List, Optional, Type, Tuple
|
|
5
|
+
|
|
6
|
+
from vs_common.log.vs_log_manager import VsLogManager
|
|
7
|
+
from vs_db.context.db_context import has_session, set_session
|
|
8
|
+
from vs_db.transaction.vs_transaction_manager import VsTransactionManager
|
|
9
|
+
|
|
10
|
+
_logger = VsLogManager.get_instance(__name__)
|
|
11
|
+
|
|
12
|
+
_entity_registry: List[Type] = []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_registered_entities() -> List[Type]:
|
|
16
|
+
return list(_entity_registry)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def entity(table: str):
|
|
20
|
+
def decorator(cls):
|
|
21
|
+
from vs_db.base.vs_db_base import VsDbBase
|
|
22
|
+
if not issubclass(cls, VsDbBase):
|
|
23
|
+
raise TypeError(f"{cls.__name__} must inherit from VsDbBase")
|
|
24
|
+
cls.__tablename__ = table
|
|
25
|
+
if '__abstract__' in cls.__dict__:
|
|
26
|
+
del cls.__abstract__
|
|
27
|
+
from sqlalchemy.orm.decl_base import _as_declarative
|
|
28
|
+
_as_declarative(cls._sa_registry, cls, cls.__dict__)
|
|
29
|
+
cls._is_entity = True
|
|
30
|
+
_entity_registry.append(cls)
|
|
31
|
+
return cls
|
|
32
|
+
return decorator
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Propagation(Enum):
|
|
36
|
+
REQUIRED = "REQUIRED"
|
|
37
|
+
REQUIRES_NEW = "REQUIRES_NEW"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Isolation(Enum):
|
|
41
|
+
DEFAULT = "DEFAULT"
|
|
42
|
+
READ_COMMITTED = "READ COMMITTED"
|
|
43
|
+
REPEATABLE_READ = "REPEATABLE READ"
|
|
44
|
+
SERIALIZABLE = "SERIALIZABLE"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def transactional(
|
|
48
|
+
func: Optional[Callable] = None,
|
|
49
|
+
*,
|
|
50
|
+
propagation: Propagation = Propagation.REQUIRED,
|
|
51
|
+
isolation: Isolation = Isolation.DEFAULT,
|
|
52
|
+
rollback_on: Tuple[Type[Exception], ...] = (Exception,),
|
|
53
|
+
):
|
|
54
|
+
def decorator(fn: Callable) -> Callable:
|
|
55
|
+
|
|
56
|
+
if asyncio.iscoroutinefunction(fn):
|
|
57
|
+
|
|
58
|
+
@functools.wraps(fn)
|
|
59
|
+
async def async_wrapper(self, *args, **kwargs):
|
|
60
|
+
from vs_db.session.vs_db_session_factory import VsDbSessionFactory
|
|
61
|
+
|
|
62
|
+
if propagation == Propagation.REQUIRES_NEW:
|
|
63
|
+
async with VsDbSessionFactory.get_instance().open_session() as new_session:
|
|
64
|
+
previous = None
|
|
65
|
+
if has_session():
|
|
66
|
+
previous = VsTransactionManager.get_session()
|
|
67
|
+
set_session(new_session)
|
|
68
|
+
try:
|
|
69
|
+
if isolation != Isolation.DEFAULT:
|
|
70
|
+
await new_session.execute(
|
|
71
|
+
__import__('sqlalchemy').text(f"SET TRANSACTION ISOLATION LEVEL {isolation.value}")
|
|
72
|
+
)
|
|
73
|
+
self.db = new_session
|
|
74
|
+
return await fn(self, *args, **kwargs)
|
|
75
|
+
finally:
|
|
76
|
+
set_session(previous)
|
|
77
|
+
|
|
78
|
+
if has_session():
|
|
79
|
+
session = VsTransactionManager.get_session()
|
|
80
|
+
self.db = session
|
|
81
|
+
_logger.debug(f"[{fn.__qualname__}] Reusing request session (savepoint)")
|
|
82
|
+
async with session.begin_nested():
|
|
83
|
+
return await fn(self, *args, **kwargs)
|
|
84
|
+
|
|
85
|
+
_logger.info(f"[{fn.__qualname__}] No session in context — opening standalone session")
|
|
86
|
+
async with VsDbSessionFactory.get_instance().open_session() as session:
|
|
87
|
+
set_session(session)
|
|
88
|
+
try:
|
|
89
|
+
if isolation != Isolation.DEFAULT:
|
|
90
|
+
await session.execute(
|
|
91
|
+
__import__('sqlalchemy').text(f"SET TRANSACTION ISOLATION LEVEL {isolation.value}")
|
|
92
|
+
)
|
|
93
|
+
self.db = session
|
|
94
|
+
return await fn(self, *args, **kwargs)
|
|
95
|
+
except rollback_on as e:
|
|
96
|
+
_logger.error(f"[{fn.__qualname__}] Rolling back: {e}")
|
|
97
|
+
raise
|
|
98
|
+
finally:
|
|
99
|
+
set_session(None)
|
|
100
|
+
|
|
101
|
+
return async_wrapper
|
|
102
|
+
|
|
103
|
+
else:
|
|
104
|
+
|
|
105
|
+
@functools.wraps(fn)
|
|
106
|
+
def sync_wrapper(self, *args, **kwargs):
|
|
107
|
+
if has_session():
|
|
108
|
+
self.db = VsTransactionManager.get_session()
|
|
109
|
+
return fn(self, *args, **kwargs)
|
|
110
|
+
raise RuntimeError(
|
|
111
|
+
f"[{fn.__qualname__}] No session in context. "
|
|
112
|
+
"Sync @transactional requires an active request session."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return sync_wrapper
|
|
116
|
+
|
|
117
|
+
if func is not None:
|
|
118
|
+
return decorator(func)
|
|
119
|
+
return decorator
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
2
|
+
from starlette.requests import Request
|
|
3
|
+
|
|
4
|
+
from vs_common.log.vs_log_manager import VsLogManager
|
|
5
|
+
from vs_db.context.db_context import set_session, init_pending_evictions, get_pending_evictions
|
|
6
|
+
from vs_db.session.vs_db_session_factory import VsDbSessionFactory
|
|
7
|
+
from vs_db.transaction.vs_transaction_manager import VsTransactionManager
|
|
8
|
+
|
|
9
|
+
_logger = VsLogManager.get_instance(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VsDbMiddleware(BaseHTTPMiddleware):
|
|
13
|
+
|
|
14
|
+
def __init__(self, app):
|
|
15
|
+
super().__init__(app)
|
|
16
|
+
self._session_factory = VsDbSessionFactory.get_instance().session_factory
|
|
17
|
+
_logger.info("VsDbMiddleware initialized")
|
|
18
|
+
|
|
19
|
+
async def dispatch(self, request: Request, call_next):
|
|
20
|
+
session = self._session_factory()
|
|
21
|
+
set_session(session)
|
|
22
|
+
init_pending_evictions()
|
|
23
|
+
try:
|
|
24
|
+
response = await call_next(request)
|
|
25
|
+
await VsTransactionManager.commit()
|
|
26
|
+
await self._flush_evictions()
|
|
27
|
+
return response
|
|
28
|
+
except Exception as e:
|
|
29
|
+
_logger.error(f"Request failed, rolling back: {e}")
|
|
30
|
+
await VsTransactionManager.rollback()
|
|
31
|
+
raise
|
|
32
|
+
finally:
|
|
33
|
+
await session.close()
|
|
34
|
+
set_session(None)
|
|
35
|
+
|
|
36
|
+
async def _flush_evictions(self) -> None:
|
|
37
|
+
from vs_common.cache.vs_cache_manager import VsCacheManager
|
|
38
|
+
for pattern in get_pending_evictions():
|
|
39
|
+
await VsCacheManager.delete_pattern(pattern)
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import threading
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncEngine, AsyncSession
|
|
7
|
+
|
|
8
|
+
from vs_common.log.vs_log_manager import VsLogManager
|
|
9
|
+
from vs_common.config.vs_base_config import VsBaseConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VsDbSessionFactory:
|
|
13
|
+
|
|
14
|
+
_instance: Optional['VsDbSessionFactory'] = None
|
|
15
|
+
_initialized: bool = False
|
|
16
|
+
_lock: threading.RLock = threading.RLock()
|
|
17
|
+
|
|
18
|
+
def __new__(cls, config: VsBaseConfig):
|
|
19
|
+
if cls._instance is None:
|
|
20
|
+
with cls._lock:
|
|
21
|
+
if cls._instance is None:
|
|
22
|
+
cls._instance = super().__new__(cls)
|
|
23
|
+
return cls._instance
|
|
24
|
+
|
|
25
|
+
def __init__(self, config: VsBaseConfig):
|
|
26
|
+
if VsDbSessionFactory._initialized:
|
|
27
|
+
return
|
|
28
|
+
with VsDbSessionFactory._lock:
|
|
29
|
+
if VsDbSessionFactory._initialized:
|
|
30
|
+
return
|
|
31
|
+
self._logger = VsLogManager.get_instance(__name__)
|
|
32
|
+
self._logger.info("Initializing VsDbSessionFactory...")
|
|
33
|
+
self._engine: AsyncEngine = self._create_engine(config)
|
|
34
|
+
self._session_factory = async_sessionmaker(
|
|
35
|
+
self._engine,
|
|
36
|
+
expire_on_commit=False,
|
|
37
|
+
class_=AsyncSession
|
|
38
|
+
)
|
|
39
|
+
VsDbSessionFactory._initialized = True
|
|
40
|
+
self._logger.info("VsDbSessionFactory ready")
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def get_instance(cls) -> 'VsDbSessionFactory':
|
|
44
|
+
if cls._instance is None or not cls._initialized:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
"VsDbSessionFactory is not initialized. "
|
|
47
|
+
"Call VsDbSessionFactory(config) at application startup."
|
|
48
|
+
)
|
|
49
|
+
return cls._instance
|
|
50
|
+
|
|
51
|
+
def _create_engine(self, config: VsBaseConfig) -> AsyncEngine:
|
|
52
|
+
db_url = config.get("database.url")
|
|
53
|
+
if not db_url:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"database.url not configured. "
|
|
56
|
+
"Set it in config.ini [database] section or DATABASE_URL environment variable."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
masked_url = re.sub(r'://([^:]+):([^@]+)@', r'://\1:****@', db_url)
|
|
60
|
+
self._logger.info(f"Connecting to: {masked_url}")
|
|
61
|
+
|
|
62
|
+
pool_size = config.get("database.pool_size", default=10, data_type=int)
|
|
63
|
+
max_overflow = config.get("database.max_overflow", default=20, data_type=int)
|
|
64
|
+
echo = config.get("database.echo", default=False, data_type=bool)
|
|
65
|
+
|
|
66
|
+
self._logger.info(f"Pool — size: {pool_size}, max_overflow: {max_overflow}, echo: {echo}")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
engine = create_async_engine(
|
|
70
|
+
db_url,
|
|
71
|
+
pool_size=pool_size,
|
|
72
|
+
max_overflow=max_overflow,
|
|
73
|
+
echo=echo,
|
|
74
|
+
pool_pre_ping=True,
|
|
75
|
+
)
|
|
76
|
+
self._logger.info("Async engine created")
|
|
77
|
+
return engine
|
|
78
|
+
except Exception as e:
|
|
79
|
+
self._logger.error(f"Failed to create engine: {e}", exc_info=True)
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
@asynccontextmanager
|
|
83
|
+
async def open_session(self):
|
|
84
|
+
session: AsyncSession = self._session_factory()
|
|
85
|
+
try:
|
|
86
|
+
yield session
|
|
87
|
+
await session.commit()
|
|
88
|
+
except Exception:
|
|
89
|
+
await session.rollback()
|
|
90
|
+
raise
|
|
91
|
+
finally:
|
|
92
|
+
await session.close()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def engine(self) -> AsyncEngine:
|
|
96
|
+
return self._engine
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def session_factory(self):
|
|
100
|
+
return self._session_factory
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
|
|
5
|
+
from vs_common.log.vs_log_manager import VsLogManager
|
|
6
|
+
from vs_db.context.db_context import get_session, has_session, set_session
|
|
7
|
+
from vs_db.session.vs_db_session_factory import VsDbSessionFactory
|
|
8
|
+
|
|
9
|
+
_logger = VsLogManager.get_instance(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VsTransactionManager:
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def get_session(cls) -> AsyncSession:
|
|
16
|
+
return get_session()
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
async def begin(cls) -> AsyncSession:
|
|
20
|
+
session = get_session()
|
|
21
|
+
if not session.in_transaction():
|
|
22
|
+
await session.begin()
|
|
23
|
+
_logger.debug("Transaction began")
|
|
24
|
+
return session
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
async def commit(cls) -> None:
|
|
28
|
+
session = get_session()
|
|
29
|
+
try:
|
|
30
|
+
await session.commit()
|
|
31
|
+
_logger.debug("Transaction committed")
|
|
32
|
+
except Exception as e:
|
|
33
|
+
_logger.error(f"Commit failed: {e}", exc_info=True)
|
|
34
|
+
await cls.rollback()
|
|
35
|
+
raise
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
async def rollback(cls) -> None:
|
|
39
|
+
session = get_session()
|
|
40
|
+
try:
|
|
41
|
+
await session.rollback()
|
|
42
|
+
_logger.debug("Transaction rolled back")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
_logger.error(f"Rollback failed: {e}", exc_info=True)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def is_active(cls) -> bool:
|
|
48
|
+
return has_session()
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
async def open_unmanaged_session(cls) -> AsyncSession:
|
|
52
|
+
session: AsyncSession = VsDbSessionFactory.get_instance().session_factory()
|
|
53
|
+
set_session(session)
|
|
54
|
+
await session.begin()
|
|
55
|
+
return session
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
async def close_unmanaged_session(cls) -> None:
|
|
59
|
+
if has_session():
|
|
60
|
+
session = get_session()
|
|
61
|
+
try:
|
|
62
|
+
await session.close()
|
|
63
|
+
finally:
|
|
64
|
+
set_session(None)
|