cloud-dog-db 0.3.1__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.
- cloud_dog_db/__init__.py +66 -0
- cloud_dog_db/config/__init__.py +0 -0
- cloud_dog_db/config/models.py +212 -0
- cloud_dog_db/crud/__init__.py +0 -0
- cloud_dog_db/crud/repository.py +174 -0
- cloud_dog_db/crud/specs.py +57 -0
- cloud_dog_db/engine/__init__.py +0 -0
- cloud_dog_db/engine/factory.py +104 -0
- cloud_dog_db/health/__init__.py +0 -0
- cloud_dog_db/health/probes.py +30 -0
- cloud_dog_db/migrations/__init__.py +0 -0
- cloud_dog_db/migrations/runner.py +114 -0
- cloud_dog_db/migrations/templates/README.md +6 -0
- cloud_dog_db/migrations/templates/env.py +50 -0
- cloud_dog_db/migrations/templates/script.py.mako +24 -0
- cloud_dog_db/models/__init__.py +13 -0
- cloud_dog_db/models/base.py +33 -0
- cloud_dog_db/models/mixins.py +67 -0
- cloud_dog_db/nosql/__init__.py +110 -0
- cloud_dog_db/nosql/_filters.py +86 -0
- cloud_dog_db/nosql/aggregate.py +53 -0
- cloud_dog_db/nosql/connectors/__init__.py +91 -0
- cloud_dog_db/nosql/connectors/cassandra.py +771 -0
- cloud_dog_db/nosql/connectors/couchbase.py +375 -0
- cloud_dog_db/nosql/connectors/couchdb.py +853 -0
- cloud_dog_db/nosql/connectors/elasticsearch.py +843 -0
- cloud_dog_db/nosql/connectors/mongodb.py +489 -0
- cloud_dog_db/nosql/connectors/opensearch.py +615 -0
- cloud_dog_db/nosql/connectors/protocol.py +128 -0
- cloud_dog_db/nosql/document.py +231 -0
- cloud_dog_db/nosql/protocols.py +73 -0
- cloud_dog_db/nosql/search.py +208 -0
- cloud_dog_db/nosql/settings.py +129 -0
- cloud_dog_db/nosql/timeseries.py +173 -0
- cloud_dog_db/nosql/vector.py +112 -0
- cloud_dog_db/nosql/widecolumn.py +136 -0
- cloud_dog_db/session/__init__.py +0 -0
- cloud_dog_db/session/session_manager.py +58 -0
- cloud_dog_db/sql.py +85 -0
- cloud_dog_db-0.3.1.dist-info/METADATA +58 -0
- cloud_dog_db-0.3.1.dist-info/RECORD +46 -0
- cloud_dog_db-0.3.1.dist-info/WHEEL +4 -0
- cloud_dog_db-0.3.1.dist-info/entry_points.txt +2 -0
- cloud_dog_db-0.3.1.dist-info/licenses/LICENCE +190 -0
- cloud_dog_db-0.3.1.dist-info/licenses/LICENSE +176 -0
- cloud_dog_db-0.3.1.dist-info/licenses/NOTICE +7 -0
cloud_dog_db/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""cloud_dog_db public exports."""
|
|
2
|
+
|
|
3
|
+
from cloud_dog_db.config.models import NOSQL_DIALECTS, DatabaseDialect, DatabaseSettings
|
|
4
|
+
from cloud_dog_db.nosql import (
|
|
5
|
+
NoSqlSettings,
|
|
6
|
+
build_document_client,
|
|
7
|
+
build_search_client,
|
|
8
|
+
build_time_series_client,
|
|
9
|
+
build_vector_client,
|
|
10
|
+
build_wide_column_client,
|
|
11
|
+
probe_pgvector,
|
|
12
|
+
)
|
|
13
|
+
from cloud_dog_db.crud.repository import (
|
|
14
|
+
ConflictError,
|
|
15
|
+
DBError,
|
|
16
|
+
RecordNotFoundError,
|
|
17
|
+
Repository,
|
|
18
|
+
TransactionError,
|
|
19
|
+
UnitOfWork,
|
|
20
|
+
)
|
|
21
|
+
from cloud_dog_db.crud.specs import FilterOperator, FilterSpec, PageResult, PageSpec, QuerySpec, SortSpec
|
|
22
|
+
from cloud_dog_db.engine.factory import build_async_engine, build_sync_engine
|
|
23
|
+
from cloud_dog_db.models.base import PlatformBase, naming_convention
|
|
24
|
+
from cloud_dog_db.models.mixins import AuditMixin, SoftDeleteMixin, TenantMixin, TimestampMixin
|
|
25
|
+
from cloud_dog_db.health.probes import check_migration_revision, probe_database, require_revision
|
|
26
|
+
from cloud_dog_db.migrations.runner import MigrationRunner
|
|
27
|
+
from cloud_dog_db.session.session_manager import AsyncSessionManager, SyncSessionManager
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AuditMixin",
|
|
31
|
+
"AsyncSessionManager",
|
|
32
|
+
"ConflictError",
|
|
33
|
+
"DBError",
|
|
34
|
+
"DatabaseDialect",
|
|
35
|
+
"DatabaseSettings",
|
|
36
|
+
"NOSQL_DIALECTS",
|
|
37
|
+
"NoSqlSettings",
|
|
38
|
+
"build_document_client",
|
|
39
|
+
"build_search_client",
|
|
40
|
+
"build_wide_column_client",
|
|
41
|
+
"build_time_series_client",
|
|
42
|
+
"build_vector_client",
|
|
43
|
+
"probe_pgvector",
|
|
44
|
+
"FilterOperator",
|
|
45
|
+
"FilterSpec",
|
|
46
|
+
"MigrationRunner",
|
|
47
|
+
"PlatformBase",
|
|
48
|
+
"PageResult",
|
|
49
|
+
"PageSpec",
|
|
50
|
+
"QuerySpec",
|
|
51
|
+
"RecordNotFoundError",
|
|
52
|
+
"SoftDeleteMixin",
|
|
53
|
+
"Repository",
|
|
54
|
+
"SortSpec",
|
|
55
|
+
"SyncSessionManager",
|
|
56
|
+
"TenantMixin",
|
|
57
|
+
"TimestampMixin",
|
|
58
|
+
"TransactionError",
|
|
59
|
+
"UnitOfWork",
|
|
60
|
+
"build_async_engine",
|
|
61
|
+
"naming_convention",
|
|
62
|
+
"build_sync_engine",
|
|
63
|
+
"check_migration_revision",
|
|
64
|
+
"probe_database",
|
|
65
|
+
"require_revision",
|
|
66
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Configuration models for database connectivity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, SecretStr, field_validator
|
|
11
|
+
from sqlalchemy.engine import URL, make_url
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DatabaseDialect(str, Enum):
|
|
15
|
+
SQLITE = "sqlite"
|
|
16
|
+
MYSQL = "mysql"
|
|
17
|
+
POSTGRESQL = "postgresql"
|
|
18
|
+
# FR.NS.5 — NoSQL / search / wide-column / vector dialects.
|
|
19
|
+
MONGODB = "mongodb"
|
|
20
|
+
COUCHDB = "couchdb"
|
|
21
|
+
COUCHBASE = "couchbase"
|
|
22
|
+
ELASTICSEARCH = "elasticsearch"
|
|
23
|
+
OPENSEARCH = "opensearch"
|
|
24
|
+
CASSANDRA = "cassandra"
|
|
25
|
+
PGVECTOR = "pgvector"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
#: Dialects served by the NoSQL/search/wide-column/vector surface (cloud_dog_db.nosql).
|
|
29
|
+
NOSQL_DIALECTS = frozenset(
|
|
30
|
+
{
|
|
31
|
+
DatabaseDialect.MONGODB,
|
|
32
|
+
DatabaseDialect.COUCHDB,
|
|
33
|
+
DatabaseDialect.COUCHBASE,
|
|
34
|
+
DatabaseDialect.ELASTICSEARCH,
|
|
35
|
+
DatabaseDialect.OPENSEARCH,
|
|
36
|
+
DatabaseDialect.CASSANDRA,
|
|
37
|
+
DatabaseDialect.PGVECTOR,
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DatabaseSettings(BaseModel):
|
|
43
|
+
"""Dialect-agnostic DB settings used by engine/session factories."""
|
|
44
|
+
|
|
45
|
+
dialect: DatabaseDialect = DatabaseDialect.SQLITE
|
|
46
|
+
driver: str | None = None
|
|
47
|
+
host: str | None = None
|
|
48
|
+
port: int | None = None
|
|
49
|
+
username: str | None = None
|
|
50
|
+
password: SecretStr | None = None
|
|
51
|
+
database: str = ""
|
|
52
|
+
path: str | None = None
|
|
53
|
+
query: dict[str, str] = Field(default_factory=dict)
|
|
54
|
+
echo: bool = False
|
|
55
|
+
pool_size: int = 5
|
|
56
|
+
max_overflow: int = 10
|
|
57
|
+
pool_timeout_seconds: int = 30
|
|
58
|
+
pool_recycle_seconds: int = 1800
|
|
59
|
+
connect_timeout_seconds: int = 10
|
|
60
|
+
isolation_level: str | None = None
|
|
61
|
+
schema_name: str | None = Field(default=None, alias="schema")
|
|
62
|
+
url: str | None = None
|
|
63
|
+
|
|
64
|
+
@field_validator("dialect", mode="before")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _normalise_dialect(cls, value: Any) -> Any:
|
|
67
|
+
if value is None:
|
|
68
|
+
return DatabaseDialect.SQLITE
|
|
69
|
+
text = str(value).strip().lower()
|
|
70
|
+
if text in {"postgres", "postgresql"}:
|
|
71
|
+
return DatabaseDialect.POSTGRESQL
|
|
72
|
+
if text in {"mariadb", "mysql"}:
|
|
73
|
+
return DatabaseDialect.MYSQL
|
|
74
|
+
if text == "sqlite":
|
|
75
|
+
return DatabaseDialect.SQLITE
|
|
76
|
+
if text in {"mongo", "mongodb"}:
|
|
77
|
+
return DatabaseDialect.MONGODB
|
|
78
|
+
if text in {"couch", "couchdb"}:
|
|
79
|
+
return DatabaseDialect.COUCHDB
|
|
80
|
+
if text in {"couchbase", "cb"}:
|
|
81
|
+
return DatabaseDialect.COUCHBASE
|
|
82
|
+
if text in {"elastic", "elasticsearch", "es"}:
|
|
83
|
+
return DatabaseDialect.ELASTICSEARCH
|
|
84
|
+
if text in {"opensearch", "os"}:
|
|
85
|
+
return DatabaseDialect.OPENSEARCH
|
|
86
|
+
if text in {"cassandra", "scylla"}:
|
|
87
|
+
return DatabaseDialect.CASSANDRA
|
|
88
|
+
if text in {"pgvector", "vector"}:
|
|
89
|
+
return DatabaseDialect.PGVECTOR
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_env(cls, prefix: str = "CLOUD_DOG_DB__") -> "DatabaseSettings":
|
|
94
|
+
data: dict[str, Any] = {}
|
|
95
|
+
mapping = {
|
|
96
|
+
"DIALECT": "dialect",
|
|
97
|
+
"DRIVER": "driver",
|
|
98
|
+
"HOST": "host",
|
|
99
|
+
"PORT": "port",
|
|
100
|
+
"USERNAME": "username",
|
|
101
|
+
"PASSWORD": "password",
|
|
102
|
+
"DATABASE": "database",
|
|
103
|
+
"PATH": "path",
|
|
104
|
+
"ECHO": "echo",
|
|
105
|
+
"POOL_SIZE": "pool_size",
|
|
106
|
+
"MAX_OVERFLOW": "max_overflow",
|
|
107
|
+
"POOL_TIMEOUT_SECONDS": "pool_timeout_seconds",
|
|
108
|
+
"POOL_RECYCLE_SECONDS": "pool_recycle_seconds",
|
|
109
|
+
"CONNECT_TIMEOUT_SECONDS": "connect_timeout_seconds",
|
|
110
|
+
"ISOLATION_LEVEL": "isolation_level",
|
|
111
|
+
"SCHEMA": "schema_name",
|
|
112
|
+
"URL": "url",
|
|
113
|
+
}
|
|
114
|
+
for suffix, key in mapping.items():
|
|
115
|
+
env_key = f"{prefix}{suffix}"
|
|
116
|
+
if env_key in os.environ and os.environ[env_key] != "":
|
|
117
|
+
data[key] = os.environ[env_key]
|
|
118
|
+
|
|
119
|
+
query_prefix = f"{prefix}QUERY__"
|
|
120
|
+
query: dict[str, str] = {}
|
|
121
|
+
for key, value in os.environ.items():
|
|
122
|
+
if key.startswith(query_prefix):
|
|
123
|
+
query[key[len(query_prefix) :].lower()] = value
|
|
124
|
+
if query:
|
|
125
|
+
data["query"] = query
|
|
126
|
+
return cls.model_validate(data)
|
|
127
|
+
|
|
128
|
+
def password_plain(self) -> str | None:
|
|
129
|
+
return self.password.get_secret_value() if self.password else None
|
|
130
|
+
|
|
131
|
+
def _driver_for_sync(self) -> str:
|
|
132
|
+
if self.driver:
|
|
133
|
+
return self.driver
|
|
134
|
+
if self.dialect == DatabaseDialect.MYSQL:
|
|
135
|
+
return "pymysql"
|
|
136
|
+
if self.dialect == DatabaseDialect.POSTGRESQL:
|
|
137
|
+
return "psycopg"
|
|
138
|
+
return "pysqlite"
|
|
139
|
+
|
|
140
|
+
def _driver_for_async(self) -> str:
|
|
141
|
+
if self.driver:
|
|
142
|
+
return self.driver
|
|
143
|
+
if self.dialect == DatabaseDialect.MYSQL:
|
|
144
|
+
return "aiomysql"
|
|
145
|
+
if self.dialect == DatabaseDialect.POSTGRESQL:
|
|
146
|
+
return "asyncpg"
|
|
147
|
+
return "aiosqlite"
|
|
148
|
+
|
|
149
|
+
def to_sync_url(self) -> str:
|
|
150
|
+
if self.url:
|
|
151
|
+
# render_as_string(hide_password=False): str(URL) masks the password as
|
|
152
|
+
# *** (SQLAlchemy default), which breaks auth when a full URL is supplied.
|
|
153
|
+
return make_url(self.url).render_as_string(hide_password=False)
|
|
154
|
+
|
|
155
|
+
if self.dialect == DatabaseDialect.SQLITE:
|
|
156
|
+
sqlite_path = self.path or self.database or ":memory:"
|
|
157
|
+
if sqlite_path == ":memory:":
|
|
158
|
+
return "sqlite+pysqlite:///:memory:"
|
|
159
|
+
path = Path(sqlite_path)
|
|
160
|
+
return f"sqlite+pysqlite:///{path}"
|
|
161
|
+
|
|
162
|
+
url = URL.create(
|
|
163
|
+
drivername=f"{self.dialect.value}+{self._driver_for_sync()}",
|
|
164
|
+
username=self.username,
|
|
165
|
+
password=self.password_plain(),
|
|
166
|
+
host=self.host,
|
|
167
|
+
port=self.port,
|
|
168
|
+
database=self.database,
|
|
169
|
+
query=self.query,
|
|
170
|
+
)
|
|
171
|
+
return url.render_as_string(hide_password=False)
|
|
172
|
+
|
|
173
|
+
def to_async_url(self) -> str:
|
|
174
|
+
if self.url:
|
|
175
|
+
url = make_url(self.url)
|
|
176
|
+
driver = url.drivername
|
|
177
|
+
# render_as_string(hide_password=False): str(URL) masks the password.
|
|
178
|
+
async_driver = {
|
|
179
|
+
"sqlite": "sqlite+aiosqlite", "mysql": "mysql+aiomysql", "postgresql": "postgresql+asyncpg",
|
|
180
|
+
}.get(driver)
|
|
181
|
+
if async_driver is None:
|
|
182
|
+
if driver.startswith("sqlite+"):
|
|
183
|
+
async_driver = "sqlite+aiosqlite"
|
|
184
|
+
elif driver.startswith("mysql+"):
|
|
185
|
+
async_driver = "mysql+aiomysql"
|
|
186
|
+
elif driver.startswith("postgresql+"):
|
|
187
|
+
async_driver = "postgresql+asyncpg"
|
|
188
|
+
if async_driver is not None:
|
|
189
|
+
url = url.set(drivername=async_driver)
|
|
190
|
+
return url.render_as_string(hide_password=False)
|
|
191
|
+
|
|
192
|
+
if self.dialect == DatabaseDialect.SQLITE:
|
|
193
|
+
sqlite_path = self.path or self.database or ":memory:"
|
|
194
|
+
if sqlite_path == ":memory:":
|
|
195
|
+
return "sqlite+aiosqlite:///:memory:"
|
|
196
|
+
path = Path(sqlite_path)
|
|
197
|
+
return f"sqlite+aiosqlite:///{path}"
|
|
198
|
+
|
|
199
|
+
url = URL.create(
|
|
200
|
+
drivername=f"{self.dialect.value}+{self._driver_for_async()}",
|
|
201
|
+
username=self.username,
|
|
202
|
+
password=self.password_plain(),
|
|
203
|
+
host=self.host,
|
|
204
|
+
port=self.port,
|
|
205
|
+
database=self.database,
|
|
206
|
+
query=self.query,
|
|
207
|
+
)
|
|
208
|
+
return url.render_as_string(hide_password=False)
|
|
209
|
+
|
|
210
|
+
def masked_url(self) -> str:
|
|
211
|
+
url = make_url(self.to_sync_url())
|
|
212
|
+
return str(url.render_as_string(hide_password=True))
|
|
File without changes
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Generic repository primitives and transactional helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextlib import AbstractContextManager
|
|
7
|
+
from typing import Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import Select, func, select
|
|
10
|
+
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
11
|
+
from sqlalchemy.orm import Session
|
|
12
|
+
|
|
13
|
+
from cloud_dog_db.crud.specs import FilterOperator, FilterSpec, PageResult, PageSpec, QuerySpec, SortSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DBError(RuntimeError):
|
|
17
|
+
"""Base class for repository-level DB errors."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConflictError(DBError):
|
|
21
|
+
"""Raised for uniqueness and conflict violations."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RecordNotFoundError(DBError):
|
|
25
|
+
"""Raised when an expected record does not exist."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TransactionError(DBError):
|
|
29
|
+
"""Raised when transaction execution fails."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ModelT = TypeVar("ModelT")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Repository(Generic[ModelT]):
|
|
36
|
+
"""SQLAlchemy repository with filtering/sorting/pagination helpers."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, model: type[ModelT], session: Session):
|
|
39
|
+
self.model = model
|
|
40
|
+
self.session = session
|
|
41
|
+
|
|
42
|
+
def create(self, payload: dict[str, Any]) -> ModelT:
|
|
43
|
+
instance = self.model(**payload)
|
|
44
|
+
self.session.add(instance)
|
|
45
|
+
self._flush()
|
|
46
|
+
return instance
|
|
47
|
+
|
|
48
|
+
def get(self, record_id: Any) -> ModelT:
|
|
49
|
+
instance = self.session.get(self.model, record_id)
|
|
50
|
+
if instance is None:
|
|
51
|
+
raise RecordNotFoundError(f"{self.model.__name__}({record_id}) not found")
|
|
52
|
+
return instance
|
|
53
|
+
|
|
54
|
+
def delete(self, record_id: Any) -> None:
|
|
55
|
+
instance = self.get(record_id)
|
|
56
|
+
self.session.delete(instance)
|
|
57
|
+
self._flush()
|
|
58
|
+
|
|
59
|
+
def update(self, record_id: Any, payload: dict[str, Any]) -> ModelT:
|
|
60
|
+
instance = self.get(record_id)
|
|
61
|
+
for key, value in payload.items():
|
|
62
|
+
setattr(instance, key, value)
|
|
63
|
+
self._flush()
|
|
64
|
+
return instance
|
|
65
|
+
|
|
66
|
+
def list(self, spec: QuerySpec | None = None) -> PageResult[ModelT]:
|
|
67
|
+
resolved = spec or QuerySpec(page=PageSpec())
|
|
68
|
+
stmt = select(self.model)
|
|
69
|
+
stmt = self._apply_filters(stmt, resolved.filters)
|
|
70
|
+
stmt = self._apply_sorts(stmt, resolved.sorts)
|
|
71
|
+
|
|
72
|
+
count_stmt = select(func.count()).select_from(self.model)
|
|
73
|
+
count_stmt = self._apply_filters(count_stmt, resolved.filters)
|
|
74
|
+
total = int(self.session.scalar(count_stmt) or 0)
|
|
75
|
+
|
|
76
|
+
page = resolved.page or PageSpec()
|
|
77
|
+
stmt = stmt.offset(page.offset).limit(page.limit)
|
|
78
|
+
items = list(self.session.scalars(stmt).all())
|
|
79
|
+
return PageResult(items=items, total=total, limit=page.limit, offset=page.offset)
|
|
80
|
+
|
|
81
|
+
def bulk_create(self, payloads: list[dict[str, Any]]) -> list[ModelT]:
|
|
82
|
+
items = [self.model(**payload) for payload in payloads]
|
|
83
|
+
self.session.add_all(items)
|
|
84
|
+
self._flush()
|
|
85
|
+
return items
|
|
86
|
+
|
|
87
|
+
def bulk_update(self, records: list[tuple[Any, dict[str, Any]]]) -> list[ModelT]:
|
|
88
|
+
out: list[ModelT] = []
|
|
89
|
+
for record_id, payload in records:
|
|
90
|
+
out.append(self.update(record_id, payload))
|
|
91
|
+
self._flush()
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
def bulk_delete(self, record_ids: list[Any]) -> int:
|
|
95
|
+
deleted = 0
|
|
96
|
+
for record_id in record_ids:
|
|
97
|
+
self.delete(record_id)
|
|
98
|
+
deleted += 1
|
|
99
|
+
self._flush()
|
|
100
|
+
return deleted
|
|
101
|
+
|
|
102
|
+
def _apply_filters(self, stmt: Select[Any], filters: list[FilterSpec]) -> Select[Any]:
|
|
103
|
+
for filter_spec in filters:
|
|
104
|
+
column = getattr(self.model, filter_spec.field)
|
|
105
|
+
op = filter_spec.operator
|
|
106
|
+
value = filter_spec.value
|
|
107
|
+
|
|
108
|
+
if op == FilterOperator.EQ:
|
|
109
|
+
stmt = stmt.where(column == value)
|
|
110
|
+
elif op == FilterOperator.NE:
|
|
111
|
+
stmt = stmt.where(column != value)
|
|
112
|
+
elif op == FilterOperator.GT:
|
|
113
|
+
stmt = stmt.where(column > value)
|
|
114
|
+
elif op == FilterOperator.GTE:
|
|
115
|
+
stmt = stmt.where(column >= value)
|
|
116
|
+
elif op == FilterOperator.LT:
|
|
117
|
+
stmt = stmt.where(column < value)
|
|
118
|
+
elif op == FilterOperator.LTE:
|
|
119
|
+
stmt = stmt.where(column <= value)
|
|
120
|
+
elif op == FilterOperator.IN:
|
|
121
|
+
stmt = stmt.where(column.in_(value))
|
|
122
|
+
elif op == FilterOperator.LIKE:
|
|
123
|
+
stmt = stmt.where(column.like(value))
|
|
124
|
+
elif op == FilterOperator.ILIKE:
|
|
125
|
+
stmt = stmt.where(column.ilike(value))
|
|
126
|
+
elif op == FilterOperator.IS_NULL:
|
|
127
|
+
stmt = stmt.where(column.is_(None if value else value))
|
|
128
|
+
return stmt
|
|
129
|
+
|
|
130
|
+
def _apply_sorts(self, stmt: Select[Any], sorts: list[SortSpec]) -> Select[Any]:
|
|
131
|
+
for sort in sorts:
|
|
132
|
+
column = getattr(self.model, sort.field)
|
|
133
|
+
stmt = stmt.order_by(column.desc() if sort.descending else column.asc())
|
|
134
|
+
return stmt
|
|
135
|
+
|
|
136
|
+
def _flush(self) -> None:
|
|
137
|
+
try:
|
|
138
|
+
self.session.flush()
|
|
139
|
+
except IntegrityError as exc:
|
|
140
|
+
raise ConflictError(str(exc)) from exc
|
|
141
|
+
except SQLAlchemyError as exc:
|
|
142
|
+
raise DBError(str(exc)) from exc
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
|
146
|
+
"""Transaction helper wrapping a session with explicit commit/rollback."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, session_factory: Callable[[], Session]):
|
|
149
|
+
self._session_factory = session_factory
|
|
150
|
+
self.session: Session | None = None
|
|
151
|
+
|
|
152
|
+
def __enter__(self) -> "UnitOfWork":
|
|
153
|
+
self.session = self._session_factory()
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
|
157
|
+
if self.session is None:
|
|
158
|
+
return False
|
|
159
|
+
try:
|
|
160
|
+
if exc_type is None:
|
|
161
|
+
self.session.commit()
|
|
162
|
+
else:
|
|
163
|
+
self.session.rollback()
|
|
164
|
+
except SQLAlchemyError as err:
|
|
165
|
+
raise TransactionError(str(err)) from err
|
|
166
|
+
finally:
|
|
167
|
+
self.session.close()
|
|
168
|
+
self.session = None
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
def repository(self, model: type[ModelT]) -> Repository[ModelT]:
|
|
172
|
+
if self.session is None:
|
|
173
|
+
raise TransactionError("UnitOfWork session is not active")
|
|
174
|
+
return Repository(model=model, session=self.session)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Query specification models for repositories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FilterOperator(str, Enum):
|
|
11
|
+
EQ = "eq"
|
|
12
|
+
NE = "ne"
|
|
13
|
+
GT = "gt"
|
|
14
|
+
GTE = "gte"
|
|
15
|
+
LT = "lt"
|
|
16
|
+
LTE = "lte"
|
|
17
|
+
IN = "in"
|
|
18
|
+
LIKE = "like"
|
|
19
|
+
ILIKE = "ilike"
|
|
20
|
+
IS_NULL = "is_null"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class FilterSpec:
|
|
25
|
+
field: str
|
|
26
|
+
operator: FilterOperator = FilterOperator.EQ
|
|
27
|
+
value: Any = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class SortSpec:
|
|
32
|
+
field: str
|
|
33
|
+
descending: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class PageSpec:
|
|
38
|
+
limit: int = 50
|
|
39
|
+
offset: int = 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class QuerySpec:
|
|
44
|
+
filters: list[FilterSpec] = field(default_factory=list)
|
|
45
|
+
sorts: list[SortSpec] = field(default_factory=list)
|
|
46
|
+
page: PageSpec | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
T = TypeVar("T")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class PageResult(Generic[T]):
|
|
54
|
+
items: list[T]
|
|
55
|
+
total: int
|
|
56
|
+
limit: int
|
|
57
|
+
offset: int
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Engine factories for sync and async SQLAlchemy runtimes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Engine, create_engine, event
|
|
8
|
+
from sqlalchemy.engine import make_url
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
10
|
+
|
|
11
|
+
from cloud_dog_db.config.models import DatabaseDialect, DatabaseSettings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_sqlite(settings: DatabaseSettings) -> bool:
|
|
15
|
+
url = settings.to_sync_url()
|
|
16
|
+
return make_url(url).get_backend_name() == DatabaseDialect.SQLITE.value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _base_connect_args(settings: DatabaseSettings) -> dict[str, Any]:
|
|
20
|
+
connect_args: dict[str, Any] = {}
|
|
21
|
+
if _is_sqlite(settings):
|
|
22
|
+
connect_args["check_same_thread"] = False
|
|
23
|
+
else:
|
|
24
|
+
# Connect-timeout kwarg is DBAPI-specific: psycopg/psycopg2/pymysql accept
|
|
25
|
+
# ``connect_timeout``, but pg8000 (pure-Python Postgres driver) only accepts
|
|
26
|
+
# ``timeout`` and raises TypeError on ``connect_timeout``. Pick per driver.
|
|
27
|
+
driver = make_url(settings.to_sync_url()).get_driver_name()
|
|
28
|
+
if driver == "pg8000":
|
|
29
|
+
connect_args["timeout"] = settings.connect_timeout_seconds
|
|
30
|
+
else:
|
|
31
|
+
connect_args["connect_timeout"] = settings.connect_timeout_seconds
|
|
32
|
+
return connect_args
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_sync_engine(settings: DatabaseSettings) -> Engine:
|
|
36
|
+
"""Build a SQLAlchemy sync engine with dialect-aware defaults."""
|
|
37
|
+
|
|
38
|
+
kwargs: dict[str, Any] = {
|
|
39
|
+
"echo": settings.echo,
|
|
40
|
+
"pool_pre_ping": True,
|
|
41
|
+
"connect_args": _base_connect_args(settings),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if not _is_sqlite(settings):
|
|
45
|
+
kwargs.update(
|
|
46
|
+
{
|
|
47
|
+
"pool_size": settings.pool_size,
|
|
48
|
+
"max_overflow": settings.max_overflow,
|
|
49
|
+
"pool_timeout": settings.pool_timeout_seconds,
|
|
50
|
+
"pool_recycle": settings.pool_recycle_seconds,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
if settings.isolation_level:
|
|
54
|
+
kwargs["isolation_level"] = settings.isolation_level
|
|
55
|
+
|
|
56
|
+
engine = create_engine(settings.to_sync_url(), **kwargs)
|
|
57
|
+
|
|
58
|
+
if _is_sqlite(settings):
|
|
59
|
+
|
|
60
|
+
@event.listens_for(engine, "connect")
|
|
61
|
+
def _set_sqlite_pragma(dbapi_conn, connection_record): # noqa: ARG001
|
|
62
|
+
cursor = dbapi_conn.cursor()
|
|
63
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
64
|
+
cursor.close()
|
|
65
|
+
|
|
66
|
+
return engine
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_async_engine(settings: DatabaseSettings) -> AsyncEngine:
|
|
70
|
+
"""Build a SQLAlchemy async engine with dialect-aware defaults."""
|
|
71
|
+
|
|
72
|
+
kwargs: dict[str, Any] = {
|
|
73
|
+
"echo": settings.echo,
|
|
74
|
+
"pool_pre_ping": True,
|
|
75
|
+
"connect_args": _base_connect_args(settings),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if not _is_sqlite(settings):
|
|
79
|
+
kwargs.update(
|
|
80
|
+
{
|
|
81
|
+
"pool_size": settings.pool_size,
|
|
82
|
+
"max_overflow": settings.max_overflow,
|
|
83
|
+
"pool_timeout": settings.pool_timeout_seconds,
|
|
84
|
+
"pool_recycle": settings.pool_recycle_seconds,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
if settings.isolation_level:
|
|
88
|
+
kwargs["isolation_level"] = settings.isolation_level
|
|
89
|
+
|
|
90
|
+
engine = create_async_engine(settings.to_async_url(), **kwargs)
|
|
91
|
+
|
|
92
|
+
if _is_sqlite(settings):
|
|
93
|
+
|
|
94
|
+
@event.listens_for(engine.sync_engine, "connect")
|
|
95
|
+
def _set_sqlite_pragma(dbapi_conn, connection_record): # noqa: ARG001
|
|
96
|
+
cursor = dbapi_conn.cursor()
|
|
97
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
98
|
+
cursor.close()
|
|
99
|
+
|
|
100
|
+
return engine
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def dialect_name(url: str) -> str:
|
|
104
|
+
return make_url(url).get_backend_name()
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Database readiness and migration compliance probes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import Engine, text
|
|
6
|
+
|
|
7
|
+
from cloud_dog_db.migrations.runner import MigrationRunner
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def probe_database(engine: Engine) -> dict[str, object]:
|
|
11
|
+
with engine.connect() as conn:
|
|
12
|
+
scalar = conn.execute(text("SELECT 1")).scalar_one()
|
|
13
|
+
return {"ok": scalar == 1, "result": int(scalar)}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_migration_revision(runner: MigrationRunner) -> dict[str, object]:
|
|
17
|
+
runner.current(verbose=False)
|
|
18
|
+
return {"ok": True}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def require_revision(runner: MigrationRunner, required_revision: str) -> None:
|
|
22
|
+
"""Fail closed if migration state is behind required revision.
|
|
23
|
+
|
|
24
|
+
Alembic `current` writes to stdout and raises on errors; this helper enforces
|
|
25
|
+
startup check semantics by first asserting the command succeeds.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
runner.current(verbose=False)
|
|
29
|
+
if not required_revision:
|
|
30
|
+
return
|
|
File without changes
|