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.
Files changed (46) hide show
  1. cloud_dog_db/__init__.py +66 -0
  2. cloud_dog_db/config/__init__.py +0 -0
  3. cloud_dog_db/config/models.py +212 -0
  4. cloud_dog_db/crud/__init__.py +0 -0
  5. cloud_dog_db/crud/repository.py +174 -0
  6. cloud_dog_db/crud/specs.py +57 -0
  7. cloud_dog_db/engine/__init__.py +0 -0
  8. cloud_dog_db/engine/factory.py +104 -0
  9. cloud_dog_db/health/__init__.py +0 -0
  10. cloud_dog_db/health/probes.py +30 -0
  11. cloud_dog_db/migrations/__init__.py +0 -0
  12. cloud_dog_db/migrations/runner.py +114 -0
  13. cloud_dog_db/migrations/templates/README.md +6 -0
  14. cloud_dog_db/migrations/templates/env.py +50 -0
  15. cloud_dog_db/migrations/templates/script.py.mako +24 -0
  16. cloud_dog_db/models/__init__.py +13 -0
  17. cloud_dog_db/models/base.py +33 -0
  18. cloud_dog_db/models/mixins.py +67 -0
  19. cloud_dog_db/nosql/__init__.py +110 -0
  20. cloud_dog_db/nosql/_filters.py +86 -0
  21. cloud_dog_db/nosql/aggregate.py +53 -0
  22. cloud_dog_db/nosql/connectors/__init__.py +91 -0
  23. cloud_dog_db/nosql/connectors/cassandra.py +771 -0
  24. cloud_dog_db/nosql/connectors/couchbase.py +375 -0
  25. cloud_dog_db/nosql/connectors/couchdb.py +853 -0
  26. cloud_dog_db/nosql/connectors/elasticsearch.py +843 -0
  27. cloud_dog_db/nosql/connectors/mongodb.py +489 -0
  28. cloud_dog_db/nosql/connectors/opensearch.py +615 -0
  29. cloud_dog_db/nosql/connectors/protocol.py +128 -0
  30. cloud_dog_db/nosql/document.py +231 -0
  31. cloud_dog_db/nosql/protocols.py +73 -0
  32. cloud_dog_db/nosql/search.py +208 -0
  33. cloud_dog_db/nosql/settings.py +129 -0
  34. cloud_dog_db/nosql/timeseries.py +173 -0
  35. cloud_dog_db/nosql/vector.py +112 -0
  36. cloud_dog_db/nosql/widecolumn.py +136 -0
  37. cloud_dog_db/session/__init__.py +0 -0
  38. cloud_dog_db/session/session_manager.py +58 -0
  39. cloud_dog_db/sql.py +85 -0
  40. cloud_dog_db-0.3.1.dist-info/METADATA +58 -0
  41. cloud_dog_db-0.3.1.dist-info/RECORD +46 -0
  42. cloud_dog_db-0.3.1.dist-info/WHEEL +4 -0
  43. cloud_dog_db-0.3.1.dist-info/entry_points.txt +2 -0
  44. cloud_dog_db-0.3.1.dist-info/licenses/LICENCE +190 -0
  45. cloud_dog_db-0.3.1.dist-info/licenses/LICENSE +176 -0
  46. cloud_dog_db-0.3.1.dist-info/licenses/NOTICE +7 -0
@@ -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