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 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()
@@ -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
@@ -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)