duo-orm 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.
- duo_orm/__init__.py +82 -0
- duo_orm/basemodel.py +146 -0
- duo_orm/db.py +251 -0
- duo_orm/exceptions.py +84 -0
- duo_orm/executor.py +282 -0
- duo_orm/migrations/__init__.py +1 -0
- duo_orm/migrations/cli.py +164 -0
- duo_orm/migrations/config.py +239 -0
- duo_orm/patch.py +73 -0
- duo_orm/query.py +630 -0
- duo_orm/session.py +36 -0
- duo_orm-0.1.0.dist-info/METADATA +396 -0
- duo_orm-0.1.0.dist-info/RECORD +16 -0
- duo_orm-0.1.0.dist-info/WHEEL +5 -0
- duo_orm-0.1.0.dist-info/entry_points.txt +2 -0
- duo_orm-0.1.0.dist-info/top_level.txt +1 -0
duo_orm/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# duo_orm/__init__.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
DuoORM: An opinionated, modern ORM for Python.
|
|
5
|
+
|
|
6
|
+
This package provides a clean, symmetrical API for synchronous and
|
|
7
|
+
asynchronous database operations, built on SQLAlchemy 2.0.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
# Apply lightweight operator ergonomics
|
|
13
|
+
from . import patch # noqa: F401
|
|
14
|
+
|
|
15
|
+
# --- Import and re-export all common components for a modern 2.0 workflow ---
|
|
16
|
+
|
|
17
|
+
# 1. The Core Factory
|
|
18
|
+
from .db import Database
|
|
19
|
+
|
|
20
|
+
# 2. The Model Kit (Modern 2.0 Style)
|
|
21
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
22
|
+
|
|
23
|
+
# 3. The Associations & Constraints Kit
|
|
24
|
+
from sqlalchemy import Table, ForeignKey, UniqueConstraint, CheckConstraint, Index, not_
|
|
25
|
+
|
|
26
|
+
# 4. The Types Kit
|
|
27
|
+
from sqlalchemy import types
|
|
28
|
+
|
|
29
|
+
# 5. The SQL Functions & Helpers Kit
|
|
30
|
+
from sqlalchemy import func, text, select
|
|
31
|
+
|
|
32
|
+
# 6. The Lifecycle Events Kit
|
|
33
|
+
from sqlalchemy import event
|
|
34
|
+
|
|
35
|
+
# 7. The Custom Exceptions
|
|
36
|
+
from .exceptions import (
|
|
37
|
+
YourOrmError,
|
|
38
|
+
ConfigurationError,
|
|
39
|
+
ObjectNotFoundError,
|
|
40
|
+
MultipleObjectsFoundError,
|
|
41
|
+
InvalidQueryError,
|
|
42
|
+
UnsupportedOperationError,
|
|
43
|
+
IntegrityError,
|
|
44
|
+
ValidationError,
|
|
45
|
+
)
|
|
46
|
+
from .query import json, array
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
# Core Factory
|
|
51
|
+
"Database",
|
|
52
|
+
# Model Kit
|
|
53
|
+
"Mapped",
|
|
54
|
+
"mapped_column",
|
|
55
|
+
"relationship", # Often used in model definition
|
|
56
|
+
# Associations & Constraints Kit
|
|
57
|
+
"Table",
|
|
58
|
+
"ForeignKey",
|
|
59
|
+
"UniqueConstraint",
|
|
60
|
+
"CheckConstraint",
|
|
61
|
+
"Index",
|
|
62
|
+
"not_",
|
|
63
|
+
# Types Kit
|
|
64
|
+
"types",
|
|
65
|
+
# SQL Functions & Helpers Kit
|
|
66
|
+
"func",
|
|
67
|
+
"text",
|
|
68
|
+
"select",
|
|
69
|
+
# Lifecycle Events Kit
|
|
70
|
+
"event",
|
|
71
|
+
# Custom Exceptions
|
|
72
|
+
"YourOrmError",
|
|
73
|
+
"ConfigurationError",
|
|
74
|
+
"ObjectNotFoundError",
|
|
75
|
+
"MultipleObjectsFoundError",
|
|
76
|
+
"InvalidQueryError",
|
|
77
|
+
"UnsupportedOperationError",
|
|
78
|
+
"IntegrityError",
|
|
79
|
+
"ValidationError",
|
|
80
|
+
"json",
|
|
81
|
+
"array",
|
|
82
|
+
]
|
duo_orm/basemodel.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# duo_orm/basemodel.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import TYPE_CHECKING, TypeVar, Dict, Any, Iterable
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import inspect as sa_inspect, DateTime
|
|
8
|
+
from sqlalchemy.orm import mapped_column
|
|
9
|
+
|
|
10
|
+
from .query import QueryBuilder
|
|
11
|
+
from .executor import _save, _delete_instance, _bulk_create
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .db import Database
|
|
14
|
+
|
|
15
|
+
# This helps with type hinting for model instances.
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _YourOrmMethods:
|
|
20
|
+
"""
|
|
21
|
+
A mixin class providing the Active Record-style API.
|
|
22
|
+
|
|
23
|
+
This class is not intended to be used directly by the end-user. It will be
|
|
24
|
+
mixed into the user-facing base model that is generated by the Database
|
|
25
|
+
instance.
|
|
26
|
+
"""
|
|
27
|
+
# This class attribute will be set by the Database class when it creates
|
|
28
|
+
# the user-facing Model. This is the key that links the models back
|
|
29
|
+
# to the configured database instance.
|
|
30
|
+
_db: "Database" = None
|
|
31
|
+
|
|
32
|
+
# --- Class-level Querying API ---
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def _get_query_builder(cls: T) -> "QueryBuilder[T]":
|
|
36
|
+
"""Internal helper to create a QueryBuilder for this model."""
|
|
37
|
+
# The `cls._db` attribute provides the crucial link to the db instance.
|
|
38
|
+
return QueryBuilder(cls, db=cls._db)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def where(cls: T, *args) -> "QueryBuilder[T]":
|
|
42
|
+
"""Starts a query with a WHERE clause."""
|
|
43
|
+
return cls._get_query_builder().where(*args)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def all(cls: T):
|
|
47
|
+
"""Fetches all records for this model."""
|
|
48
|
+
return cls._get_query_builder().all()
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def first(cls: T):
|
|
52
|
+
"""Fetches the first record for this model."""
|
|
53
|
+
return cls._get_query_builder().first()
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def related(cls: T, relationship_attr, **kwargs) -> "QueryBuilder[T]":
|
|
57
|
+
"""Starts a query scoped by a relationship helper."""
|
|
58
|
+
return cls._get_query_builder().related(relationship_attr, **kwargs)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def order_by(cls: T, *args, **kwargs) -> "QueryBuilder[T]":
|
|
62
|
+
"""Starts a query with ordering applied."""
|
|
63
|
+
return cls._get_query_builder().order_by(*args, **kwargs)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def paginate(cls: T, *args, **kwargs) -> "QueryBuilder[T]":
|
|
67
|
+
"""Starts a query with pagination applied."""
|
|
68
|
+
return cls._get_query_builder().paginate(*args, **kwargs)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def bulk_create(cls: T, instances: list[T]):
|
|
72
|
+
"""Performs a bulk insert of multiple model instances."""
|
|
73
|
+
for instance in instances:
|
|
74
|
+
instance.validate()
|
|
75
|
+
instance._apply_timestamp_hooks(is_insert=True)
|
|
76
|
+
return _bulk_create(cls, instances)
|
|
77
|
+
|
|
78
|
+
# --- Instance-level Actions ---
|
|
79
|
+
|
|
80
|
+
def save(self: T):
|
|
81
|
+
"""
|
|
82
|
+
Saves the current instance to the database (INSERT or UPDATE).
|
|
83
|
+
"""
|
|
84
|
+
self.validate()
|
|
85
|
+
state = sa_inspect(self)
|
|
86
|
+
is_insert = state.transient or state.pending
|
|
87
|
+
self._apply_timestamp_hooks(is_insert=is_insert)
|
|
88
|
+
return _save(self)
|
|
89
|
+
|
|
90
|
+
def delete(self: T, *, force: bool = False):
|
|
91
|
+
"""Deletes the current instance."""
|
|
92
|
+
return _delete_instance(self)
|
|
93
|
+
|
|
94
|
+
# --- Developer Experience Helpers ---
|
|
95
|
+
|
|
96
|
+
def validate(self: T):
|
|
97
|
+
"""
|
|
98
|
+
Hook for subclasses to implement custom validation.
|
|
99
|
+
|
|
100
|
+
Raise ValidationError with an optional field/detail payload to block persistence.
|
|
101
|
+
"""
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def fields(cls) -> tuple[str, ...]:
|
|
106
|
+
"""Returns the column names defined on this model."""
|
|
107
|
+
mapper = sa_inspect(cls)
|
|
108
|
+
return tuple(col.key for col in mapper.columns)
|
|
109
|
+
|
|
110
|
+
def to_dict(self: T) -> Dict[str, Any]:
|
|
111
|
+
"""Serializes the instance to a plain dictionary of column values."""
|
|
112
|
+
mapper = sa_inspect(self.__class__)
|
|
113
|
+
return {col.key: getattr(self, col.key) for col in mapper.columns}
|
|
114
|
+
|
|
115
|
+
def _apply_timestamp_hooks(self: T, *, is_insert: bool):
|
|
116
|
+
"""Applies Django-style auto_now/auto_now_add semantics based on column info."""
|
|
117
|
+
now = _utcnow()
|
|
118
|
+
mapper = sa_inspect(self.__class__)
|
|
119
|
+
for column in mapper.columns:
|
|
120
|
+
info = getattr(column, "info", {}) or {}
|
|
121
|
+
targets: set[str] = set()
|
|
122
|
+
|
|
123
|
+
if info.get("auto_now"):
|
|
124
|
+
targets.update({"create", "update"})
|
|
125
|
+
if info.get("auto_now_add"):
|
|
126
|
+
targets.add("create")
|
|
127
|
+
|
|
128
|
+
set_on = info.get("set_on")
|
|
129
|
+
if set_on:
|
|
130
|
+
if isinstance(set_on, str):
|
|
131
|
+
targets.add(set_on)
|
|
132
|
+
elif isinstance(set_on, Iterable):
|
|
133
|
+
targets.update(str(value) for value in set_on)
|
|
134
|
+
|
|
135
|
+
if not targets:
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
if "update" in targets:
|
|
139
|
+
setattr(self, column.key, now)
|
|
140
|
+
if is_insert and "create" in targets:
|
|
141
|
+
setattr(self, column.key, now)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _utcnow() -> datetime:
|
|
146
|
+
return datetime.now(timezone.utc)
|
duo_orm/db.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# duo_orm/db.py
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager, asynccontextmanager
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import create_engine
|
|
6
|
+
from sqlalchemy.orm import sessionmaker, declarative_base
|
|
7
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
8
|
+
from sqlalchemy.engine import make_url
|
|
9
|
+
|
|
10
|
+
from .session import active_session_var, is_async_context
|
|
11
|
+
from .basemodel import _YourOrmMethods
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_DRIVER_CONFIG = {
|
|
15
|
+
"postgresql": {
|
|
16
|
+
"sync": "postgresql+psycopg",
|
|
17
|
+
"async": "postgresql+psycopg",
|
|
18
|
+
},
|
|
19
|
+
"oracle": {
|
|
20
|
+
"sync": "oracle+oracledb",
|
|
21
|
+
"async": "oracle+oracledb_async",
|
|
22
|
+
},
|
|
23
|
+
"mysql": {
|
|
24
|
+
"sync": "mysql+pymysql",
|
|
25
|
+
"async": "mysql+asyncmy",
|
|
26
|
+
},
|
|
27
|
+
"mssql": {
|
|
28
|
+
"sync": "mssql+pyodbc",
|
|
29
|
+
"async": "mssql+aioodbc",
|
|
30
|
+
},
|
|
31
|
+
"sqlite": {
|
|
32
|
+
"sync": "sqlite",
|
|
33
|
+
"async": "sqlite+aiosqlite",
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_DIALECT_ALIASES = {
|
|
38
|
+
"postgres": "postgresql",
|
|
39
|
+
"postgresql": "postgresql",
|
|
40
|
+
"mysql": "mysql",
|
|
41
|
+
"mariadb": "mysql",
|
|
42
|
+
"mssql": "mssql",
|
|
43
|
+
"sqlite": "sqlite",
|
|
44
|
+
"oracle": "oracle",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _normalize_dialect(url_str: str) -> tuple:
|
|
49
|
+
parsed = make_url(url_str)
|
|
50
|
+
drivername = parsed.drivername or ""
|
|
51
|
+
if "+" in drivername:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"Do not include driver in URLs. Provide only the base dialect "
|
|
54
|
+
"(e.g., postgresql://..., mysql://..., sqlite:///...). "
|
|
55
|
+
"Drivers are managed automatically."
|
|
56
|
+
)
|
|
57
|
+
base = _DIALECT_ALIASES.get(drivername.lower())
|
|
58
|
+
if not base or base not in _DRIVER_CONFIG:
|
|
59
|
+
raise ValueError(f"Unsupported or unknown dialect '{drivername}'.")
|
|
60
|
+
return parsed, base
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_urls(sync_url: str, async_url: str | None, derive_async: bool) -> tuple[str, str | None]:
|
|
64
|
+
parsed_sync, dialect = _normalize_dialect(sync_url)
|
|
65
|
+
drivers = _DRIVER_CONFIG[dialect]
|
|
66
|
+
|
|
67
|
+
resolved_sync = parsed_sync.set(drivername=drivers["sync"])
|
|
68
|
+
resolved_async: str | None = None
|
|
69
|
+
|
|
70
|
+
if async_url:
|
|
71
|
+
parsed_async, dialect_async = _normalize_dialect(async_url)
|
|
72
|
+
if dialect_async != dialect:
|
|
73
|
+
raise ValueError("async_url dialect must match the primary url dialect.")
|
|
74
|
+
resolved_async = parsed_async.set(drivername=drivers["async"]).render_as_string(hide_password=False)
|
|
75
|
+
elif derive_async:
|
|
76
|
+
resolved_async = parsed_sync.set(drivername=drivers["async"]).render_as_string(hide_password=False)
|
|
77
|
+
|
|
78
|
+
return resolved_sync.render_as_string(hide_password=False), resolved_async
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Database:
|
|
82
|
+
"""
|
|
83
|
+
The main class that manages database connections and sessions.
|
|
84
|
+
|
|
85
|
+
This class acts as a factory for a pre-configured, database-aware
|
|
86
|
+
base model class that users will inherit from.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, db_url: str, *, async_url: str | None = None, derive_async: bool = True):
|
|
90
|
+
if not db_url:
|
|
91
|
+
raise ValueError("Database URL cannot be empty.")
|
|
92
|
+
|
|
93
|
+
self._sync_url, self._async_url = _resolve_urls(db_url, async_url, derive_async)
|
|
94
|
+
self._db_url = self._sync_url
|
|
95
|
+
self._sync_engine = None
|
|
96
|
+
self._async_engine = None
|
|
97
|
+
self._sync_session_factory = None
|
|
98
|
+
self._async_session_factory = None
|
|
99
|
+
self._connected = False
|
|
100
|
+
|
|
101
|
+
# --- This is the new "factory" logic ---
|
|
102
|
+
|
|
103
|
+
# 1. Create a new, unique declarative base from SQLAlchemy.
|
|
104
|
+
Base = declarative_base()
|
|
105
|
+
|
|
106
|
+
# 2. Manufacture the final, user-facing Model class by combining
|
|
107
|
+
# SQLAlchemy's base with our custom Active Record methods.
|
|
108
|
+
class Model(Base, _YourOrmMethods):
|
|
109
|
+
__abstract__ = True # make the factory base unmapped; user models define tables
|
|
110
|
+
# This is the magic link that solves the flaw!
|
|
111
|
+
# We inject a reference to this specific `db` instance
|
|
112
|
+
# directly into the Model class itself.
|
|
113
|
+
_db = self
|
|
114
|
+
|
|
115
|
+
# 3. Attach the newly created Model class as an attribute to this
|
|
116
|
+
# instance, so the user can access it via `db.Model`.
|
|
117
|
+
self.Model = Model
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def url(self):
|
|
121
|
+
return self._db_url
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def sync_url(self):
|
|
125
|
+
return self._sync_url
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def async_url(self):
|
|
129
|
+
return self._async_url
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def metadata(self):
|
|
133
|
+
"""Returns the metadata from the manufactured Model class."""
|
|
134
|
+
# The metadata is now correctly associated with this db instance's models.
|
|
135
|
+
return self.Model.metadata
|
|
136
|
+
|
|
137
|
+
# --- The rest of the class remains the same ---
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def sync_engine(self):
|
|
141
|
+
if not self._sync_url:
|
|
142
|
+
raise RuntimeError("Sync engine is not configured for this Database.")
|
|
143
|
+
if self._sync_engine is None:
|
|
144
|
+
try:
|
|
145
|
+
self._sync_engine = create_engine(self._sync_url)
|
|
146
|
+
except TypeError as exc:
|
|
147
|
+
query_keys = make_url(self._sync_url).query.keys()
|
|
148
|
+
hint = ""
|
|
149
|
+
if query_keys:
|
|
150
|
+
hint = (
|
|
151
|
+
f" URL includes query parameters {sorted(query_keys)}; "
|
|
152
|
+
"verify they are supported by the chosen driver."
|
|
153
|
+
)
|
|
154
|
+
raise TypeError(f"Failed to create sync engine: {exc}.{hint}") from exc
|
|
155
|
+
return self._sync_engine
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def async_engine(self):
|
|
159
|
+
if not self._async_url:
|
|
160
|
+
raise RuntimeError("Async engine is not configured for this Database.")
|
|
161
|
+
if self._async_engine is None:
|
|
162
|
+
try:
|
|
163
|
+
self._async_engine = create_async_engine(self._async_url)
|
|
164
|
+
except TypeError as exc:
|
|
165
|
+
query_keys = make_url(self._async_url).query.keys()
|
|
166
|
+
hint = ""
|
|
167
|
+
if query_keys:
|
|
168
|
+
hint = (
|
|
169
|
+
f" URL includes query parameters {sorted(query_keys)}; "
|
|
170
|
+
"verify they are supported by the chosen driver."
|
|
171
|
+
)
|
|
172
|
+
raise TypeError(f"Failed to create async engine: {exc}.{hint}") from exc
|
|
173
|
+
return self._async_engine
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def sync_session_factory(self) -> sessionmaker:
|
|
177
|
+
if self._sync_session_factory is None:
|
|
178
|
+
self._sync_session_factory = sessionmaker(
|
|
179
|
+
bind=self.sync_engine, expire_on_commit=False
|
|
180
|
+
)
|
|
181
|
+
return self._sync_session_factory
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def async_session_factory(self) -> sessionmaker:
|
|
185
|
+
if self._async_session_factory is None:
|
|
186
|
+
self._async_session_factory = sessionmaker(
|
|
187
|
+
bind=self.async_engine,
|
|
188
|
+
class_=AsyncSession,
|
|
189
|
+
expire_on_commit=False,
|
|
190
|
+
)
|
|
191
|
+
return self._async_session_factory
|
|
192
|
+
|
|
193
|
+
def connect(self):
|
|
194
|
+
"""
|
|
195
|
+
Eagerly initialize engines and session factories to surface configuration errors early.
|
|
196
|
+
Safe to call multiple times.
|
|
197
|
+
"""
|
|
198
|
+
if self._connected:
|
|
199
|
+
return
|
|
200
|
+
# Touch available factories so any misconfiguration (bad URL, missing driver, etc.) raises immediately.
|
|
201
|
+
if self._sync_url:
|
|
202
|
+
_ = self.sync_engine
|
|
203
|
+
_ = self.sync_session_factory
|
|
204
|
+
if self._async_url:
|
|
205
|
+
_ = self.async_engine
|
|
206
|
+
_ = self.async_session_factory
|
|
207
|
+
self._connected = True
|
|
208
|
+
|
|
209
|
+
@contextmanager
|
|
210
|
+
def _sync_transaction_context(self):
|
|
211
|
+
with self.sync_session_factory() as session:
|
|
212
|
+
token = active_session_var.set(session)
|
|
213
|
+
try:
|
|
214
|
+
yield session
|
|
215
|
+
session.commit()
|
|
216
|
+
except Exception:
|
|
217
|
+
session.rollback()
|
|
218
|
+
raise
|
|
219
|
+
finally:
|
|
220
|
+
active_session_var.reset(token)
|
|
221
|
+
|
|
222
|
+
@asynccontextmanager
|
|
223
|
+
async def _async_transaction_context(self):
|
|
224
|
+
async with self.async_session_factory() as session:
|
|
225
|
+
token = active_session_var.set(session)
|
|
226
|
+
try:
|
|
227
|
+
yield session
|
|
228
|
+
await session.commit()
|
|
229
|
+
except Exception:
|
|
230
|
+
await session.rollback()
|
|
231
|
+
raise
|
|
232
|
+
finally:
|
|
233
|
+
active_session_var.reset(token)
|
|
234
|
+
|
|
235
|
+
def transaction(self):
|
|
236
|
+
if is_async_context():
|
|
237
|
+
return self._async_transaction_context()
|
|
238
|
+
else:
|
|
239
|
+
return self._sync_transaction_context()
|
|
240
|
+
|
|
241
|
+
@asynccontextmanager
|
|
242
|
+
async def standalone_session(self):
|
|
243
|
+
"""Provides a raw, unmanaged SQLAlchemy AsyncSession."""
|
|
244
|
+
async with self.async_session_factory() as session:
|
|
245
|
+
yield session
|
|
246
|
+
|
|
247
|
+
@contextmanager
|
|
248
|
+
def sync_standalone_session(self):
|
|
249
|
+
"""Provides a raw, unmanaged SQLAlchemy Session."""
|
|
250
|
+
with self.sync_session_factory() as session:
|
|
251
|
+
yield session
|
duo_orm/exceptions.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# duo_orm/exceptions.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
This module contains the set of custom exceptions raised by DuoORM.
|
|
5
|
+
|
|
6
|
+
Having custom exceptions allows users of the library to reliably catch
|
|
7
|
+
errors specific to the ORM's operation, rather than generic Python errors.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class YourOrmError(Exception):
|
|
12
|
+
"""Base exception for all errors raised by DuoORM."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigurationError(YourOrmError):
|
|
17
|
+
"""
|
|
18
|
+
Raised when there is a problem with the ORM's configuration.
|
|
19
|
+
|
|
20
|
+
For example, if a required configuration file is missing or a
|
|
21
|
+
database URL is not provided.
|
|
22
|
+
"""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ObjectNotFoundError(YourOrmError):
|
|
27
|
+
"""
|
|
28
|
+
Raised when a query that expects a single result finds none.
|
|
29
|
+
|
|
30
|
+
For example, a `.one()` or `.get()` method would raise this.
|
|
31
|
+
"""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MultipleObjectsFoundError(YourOrmError):
|
|
36
|
+
"""
|
|
37
|
+
Raised when a query that expects a single result finds multiple.
|
|
38
|
+
|
|
39
|
+
Similar to ObjectNotFoundError, this would be used by `.one()` or `.get()`.
|
|
40
|
+
"""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InvalidQueryError(YourOrmError):
|
|
45
|
+
"""
|
|
46
|
+
Raised when a query is constructed in an invalid way, for example
|
|
47
|
+
by passing an unsupported operator.
|
|
48
|
+
"""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UnsupportedOperationError(InvalidQueryError):
|
|
53
|
+
"""
|
|
54
|
+
Raised when an operation is not supported by the current database dialect.
|
|
55
|
+
|
|
56
|
+
For example, using JSONContains on a database that does not support JSON.
|
|
57
|
+
"""
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IntegrityError(YourOrmError):
|
|
62
|
+
"""
|
|
63
|
+
Raised when a database integrity constraint is violated.
|
|
64
|
+
|
|
65
|
+
This is a wrapper around the database driver's specific integrity error
|
|
66
|
+
(e.g., sqlalchemy.exc.IntegrityError) to provide a consistent API.
|
|
67
|
+
It typically signals a unique constraint violation.
|
|
68
|
+
"""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ValidationError(YourOrmError):
|
|
73
|
+
"""
|
|
74
|
+
Raised when model-level validation fails.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
field (str | None): Optional field name associated with the error.
|
|
78
|
+
detail (Any): Optional structured detail for the error.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, message: str, field: str | None = None, detail=None):
|
|
82
|
+
super().__init__(message)
|
|
83
|
+
self.field = field
|
|
84
|
+
self.detail = detail
|