duckling-orm 0.0.3__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.
- duckling/__init__.py +123 -0
- duckling/connection.py +173 -0
- duckling/document.py +598 -0
- duckling/exceptions.py +33 -0
- duckling/fields.py +261 -0
- duckling/init.py +142 -0
- duckling/operators.py +148 -0
- duckling/query.py +391 -0
- duckling_orm-0.0.3.dist-info/METADATA +349 -0
- duckling_orm-0.0.3.dist-info/RECORD +13 -0
- duckling_orm-0.0.3.dist-info/WHEEL +5 -0
- duckling_orm-0.0.3.dist-info/licenses/LICENSE +202 -0
- duckling_orm-0.0.3.dist-info/top_level.txt +1 -0
duckling/document.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Document base class for Duckling — the core of the ORM.
|
|
3
|
+
|
|
4
|
+
Inherits from Pydantic BaseModel and adds DuckDB persistence, just like
|
|
5
|
+
Beanie's Document wraps MongoDB documents.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from duckling import Document, Indexed
|
|
9
|
+
|
|
10
|
+
class User(Document):
|
|
11
|
+
name: str
|
|
12
|
+
email: Indexed(str, unique=True)
|
|
13
|
+
age: int = 0
|
|
14
|
+
|
|
15
|
+
class Settings:
|
|
16
|
+
table_name = "users"
|
|
17
|
+
|
|
18
|
+
# CRUD
|
|
19
|
+
user = User(name="Alice", email="alice@example.com", age=30)
|
|
20
|
+
await user.insert()
|
|
21
|
+
await user.save() # upsert
|
|
22
|
+
await user.delete()
|
|
23
|
+
|
|
24
|
+
# Queries
|
|
25
|
+
users = await User.find(User.age > 25).to_list()
|
|
26
|
+
user = await User.find_one(User.email == "alice@example.com")
|
|
27
|
+
count = await User.find_all().count()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import datetime
|
|
34
|
+
import uuid
|
|
35
|
+
from typing import (
|
|
36
|
+
Any,
|
|
37
|
+
ClassVar,
|
|
38
|
+
Dict,
|
|
39
|
+
List,
|
|
40
|
+
Optional,
|
|
41
|
+
Sequence,
|
|
42
|
+
Type,
|
|
43
|
+
TypeVar,
|
|
44
|
+
get_args,
|
|
45
|
+
get_origin,
|
|
46
|
+
get_type_hints,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
from pydantic import BaseModel, ConfigDict
|
|
50
|
+
|
|
51
|
+
from .connection import DucklingSession, get_session
|
|
52
|
+
from .exceptions import DocumentNotFound, InvalidQueryError
|
|
53
|
+
from .fields import Expression, FieldProxy, IndexSpec, SortDirection
|
|
54
|
+
from .query import FindQuery
|
|
55
|
+
|
|
56
|
+
T = TypeVar("T", bound="Document")
|
|
57
|
+
|
|
58
|
+
# Python → DuckDB type mapping
|
|
59
|
+
_TYPE_MAP: dict[type, str] = {
|
|
60
|
+
int: "BIGINT",
|
|
61
|
+
float: "DOUBLE",
|
|
62
|
+
str: "VARCHAR",
|
|
63
|
+
bool: "BOOLEAN",
|
|
64
|
+
bytes: "BLOB",
|
|
65
|
+
datetime.date: "DATE",
|
|
66
|
+
datetime.datetime: "TIMESTAMP",
|
|
67
|
+
datetime.time: "TIME",
|
|
68
|
+
uuid.UUID: "UUID",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _python_type_to_duckdb(py_type: Any) -> str:
|
|
73
|
+
"""Convert a Python / Pydantic type annotation to a DuckDB column type."""
|
|
74
|
+
# Handle Optional[X]
|
|
75
|
+
origin = get_origin(py_type)
|
|
76
|
+
if origin is type(None):
|
|
77
|
+
return "VARCHAR"
|
|
78
|
+
|
|
79
|
+
# Optional[X] shows up as Union[X, None]
|
|
80
|
+
args = get_args(py_type)
|
|
81
|
+
if args:
|
|
82
|
+
# typing.Annotated — check for IndexSpec
|
|
83
|
+
import typing
|
|
84
|
+
if origin is getattr(typing, "Annotated", None):
|
|
85
|
+
# First arg is the actual type
|
|
86
|
+
return _python_type_to_duckdb(args[0])
|
|
87
|
+
|
|
88
|
+
# Union types (Optional)
|
|
89
|
+
non_none = [a for a in args if a is not type(None)]
|
|
90
|
+
if non_none:
|
|
91
|
+
return _python_type_to_duckdb(non_none[0])
|
|
92
|
+
|
|
93
|
+
# List/dict → JSON-like storage
|
|
94
|
+
if origin in (list, List, dict, Dict):
|
|
95
|
+
return "JSON"
|
|
96
|
+
|
|
97
|
+
# Direct lookup
|
|
98
|
+
if py_type in _TYPE_MAP:
|
|
99
|
+
return _TYPE_MAP[py_type]
|
|
100
|
+
|
|
101
|
+
# Enum
|
|
102
|
+
import enum
|
|
103
|
+
if isinstance(py_type, type) and issubclass(py_type, enum.Enum):
|
|
104
|
+
return "VARCHAR"
|
|
105
|
+
|
|
106
|
+
# Nested Pydantic model → JSON
|
|
107
|
+
if isinstance(py_type, type) and issubclass(py_type, BaseModel):
|
|
108
|
+
return "JSON"
|
|
109
|
+
|
|
110
|
+
return "VARCHAR"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _python_value_to_duckdb(value: Any) -> Any:
|
|
114
|
+
"""Convert a Python value for DuckDB insertion."""
|
|
115
|
+
if value is None:
|
|
116
|
+
return None
|
|
117
|
+
if isinstance(value, (BaseModel,)):
|
|
118
|
+
return value.model_dump_json()
|
|
119
|
+
if isinstance(value, (dict, list)):
|
|
120
|
+
import json
|
|
121
|
+
return json.dumps(value)
|
|
122
|
+
if isinstance(value, uuid.UUID):
|
|
123
|
+
return str(value)
|
|
124
|
+
import enum
|
|
125
|
+
if isinstance(value, enum.Enum):
|
|
126
|
+
return value.value
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _duckdb_value_to_python(value: Any, py_type: Any) -> Any:
|
|
131
|
+
"""Convert a DuckDB value back to the expected Python type."""
|
|
132
|
+
if value is None:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
origin = get_origin(py_type)
|
|
136
|
+
args = get_args(py_type)
|
|
137
|
+
|
|
138
|
+
# Handle Annotated
|
|
139
|
+
import typing
|
|
140
|
+
if origin is getattr(typing, "Annotated", None) and args:
|
|
141
|
+
py_type = args[0]
|
|
142
|
+
origin = get_origin(py_type)
|
|
143
|
+
args = get_args(py_type)
|
|
144
|
+
|
|
145
|
+
# Handle Optional
|
|
146
|
+
if args:
|
|
147
|
+
non_none = [a for a in args if a is not type(None)]
|
|
148
|
+
if non_none:
|
|
149
|
+
py_type = non_none[0]
|
|
150
|
+
origin = get_origin(py_type)
|
|
151
|
+
args = get_args(py_type)
|
|
152
|
+
|
|
153
|
+
# Nested Pydantic model
|
|
154
|
+
if isinstance(py_type, type) and issubclass(py_type, BaseModel):
|
|
155
|
+
import json
|
|
156
|
+
if isinstance(value, str):
|
|
157
|
+
return py_type.model_validate_json(value)
|
|
158
|
+
if isinstance(value, dict):
|
|
159
|
+
return py_type.model_validate(value)
|
|
160
|
+
|
|
161
|
+
# List / Dict from JSON
|
|
162
|
+
if origin in (list, List, dict, Dict):
|
|
163
|
+
import json
|
|
164
|
+
if isinstance(value, str):
|
|
165
|
+
return json.loads(value)
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
# UUID
|
|
169
|
+
if py_type is uuid.UUID:
|
|
170
|
+
if isinstance(value, str):
|
|
171
|
+
return uuid.UUID(value)
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
# Enum
|
|
175
|
+
import enum
|
|
176
|
+
if isinstance(py_type, type) and issubclass(py_type, enum.Enum):
|
|
177
|
+
return py_type(value)
|
|
178
|
+
|
|
179
|
+
return value
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class DocumentMeta(type(BaseModel)):
|
|
183
|
+
"""
|
|
184
|
+
Metaclass for Document that installs FieldProxy descriptors on the class,
|
|
185
|
+
enabling `User.name == "Alice"` style query expressions.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __new__(mcs, name: str, bases: tuple, namespace: dict, **kwargs):
|
|
189
|
+
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
|
|
190
|
+
|
|
191
|
+
# Skip the base Document class itself
|
|
192
|
+
if name == "Document" and not any(
|
|
193
|
+
hasattr(b, "_is_duckling_document") for b in bases
|
|
194
|
+
):
|
|
195
|
+
cls._is_duckling_document = True
|
|
196
|
+
return cls
|
|
197
|
+
|
|
198
|
+
# For every model field, create a FieldProxy accessible on the class
|
|
199
|
+
cls._field_proxies = {}
|
|
200
|
+
for field_name, field_info in cls.model_fields.items():
|
|
201
|
+
proxy = FieldProxy(field_name, field_info.annotation)
|
|
202
|
+
cls._field_proxies[field_name] = proxy
|
|
203
|
+
|
|
204
|
+
return cls
|
|
205
|
+
|
|
206
|
+
def __getattr__(cls, name: str):
|
|
207
|
+
# Return FieldProxy for query building when accessing fields on the class
|
|
208
|
+
if name.startswith("_") or name == "model_fields":
|
|
209
|
+
raise AttributeError(name)
|
|
210
|
+
proxies = cls.__dict__.get("_field_proxies", {})
|
|
211
|
+
if name in proxies:
|
|
212
|
+
return proxies[name]
|
|
213
|
+
raise AttributeError(
|
|
214
|
+
f"type object {cls.__name__!r} has no attribute {name!r}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class Document(BaseModel, metaclass=DocumentMeta):
|
|
219
|
+
"""
|
|
220
|
+
Base document class for Duckling ORM.
|
|
221
|
+
|
|
222
|
+
Subclass this and define your fields using standard Pydantic syntax.
|
|
223
|
+
Use the inner `Settings` class for table configuration.
|
|
224
|
+
|
|
225
|
+
Example:
|
|
226
|
+
class Product(Document):
|
|
227
|
+
name: str
|
|
228
|
+
price: float
|
|
229
|
+
in_stock: bool = True
|
|
230
|
+
|
|
231
|
+
class Settings:
|
|
232
|
+
table_name = "products"
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
model_config = ConfigDict(
|
|
236
|
+
arbitrary_types_allowed=True,
|
|
237
|
+
populate_by_name=True,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Auto-generated primary key
|
|
241
|
+
id: Optional[int] = None
|
|
242
|
+
|
|
243
|
+
# ── Inner Settings class ──────────────────
|
|
244
|
+
|
|
245
|
+
class Settings:
|
|
246
|
+
table_name: Optional[str] = None
|
|
247
|
+
indexes: list = []
|
|
248
|
+
|
|
249
|
+
# ── Table name resolution ─────────────────
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
def _get_table_name(cls) -> str:
|
|
253
|
+
if hasattr(cls, "Settings") and hasattr(cls.Settings, "table_name") and cls.Settings.table_name:
|
|
254
|
+
return cls.Settings.table_name
|
|
255
|
+
# Auto-generate from class name: UserProfile → user_profile
|
|
256
|
+
import re
|
|
257
|
+
name = cls.__name__
|
|
258
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def _get_column_names(cls) -> list[str]:
|
|
262
|
+
return list(cls.model_fields.keys())
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def _get_column_types(cls) -> dict[str, str]:
|
|
266
|
+
"""Map field names to DuckDB column types."""
|
|
267
|
+
import typing
|
|
268
|
+
hints = get_type_hints(cls, include_extras=True)
|
|
269
|
+
result = {}
|
|
270
|
+
for name in cls.model_fields:
|
|
271
|
+
py_type = hints.get(name, str)
|
|
272
|
+
result[name] = _python_type_to_duckdb(py_type)
|
|
273
|
+
return result
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def _get_indexed_fields(cls) -> list[tuple[str, IndexSpec]]:
|
|
277
|
+
"""Return fields that have Indexed() annotations."""
|
|
278
|
+
import typing
|
|
279
|
+
hints = get_type_hints(cls, include_extras=True)
|
|
280
|
+
indexed = []
|
|
281
|
+
for name in cls.model_fields:
|
|
282
|
+
py_type = hints.get(name)
|
|
283
|
+
if py_type and get_origin(py_type) is getattr(typing, "Annotated", None):
|
|
284
|
+
args = get_args(py_type)
|
|
285
|
+
for arg in args[1:]:
|
|
286
|
+
if isinstance(arg, IndexSpec):
|
|
287
|
+
indexed.append((name, arg))
|
|
288
|
+
return indexed
|
|
289
|
+
|
|
290
|
+
# ── Table creation ────────────────────────
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def _build_create_table_sql(cls) -> str:
|
|
294
|
+
"""Generate CREATE TABLE IF NOT EXISTS SQL."""
|
|
295
|
+
table = cls._get_table_name()
|
|
296
|
+
col_types = cls._get_column_types()
|
|
297
|
+
indexed = dict(cls._get_indexed_fields())
|
|
298
|
+
|
|
299
|
+
columns = []
|
|
300
|
+
for col_name, col_type in col_types.items():
|
|
301
|
+
parts = [f'"{col_name}"', col_type]
|
|
302
|
+
if col_name == "id":
|
|
303
|
+
parts = ['"id"', "INTEGER PRIMARY KEY DEFAULT(nextval('seq_" + table + "_id'))"]
|
|
304
|
+
continue # handled separately
|
|
305
|
+
if col_name in indexed and indexed[col_name].unique:
|
|
306
|
+
parts.append("UNIQUE")
|
|
307
|
+
columns.append(" ".join(parts))
|
|
308
|
+
|
|
309
|
+
# id column first
|
|
310
|
+
col_defs = [f'"id" INTEGER PRIMARY KEY DEFAULT(nextval(\'seq_{table}_id\'))']
|
|
311
|
+
col_defs.extend(columns)
|
|
312
|
+
|
|
313
|
+
return f'CREATE TABLE IF NOT EXISTS "{table}" (\n ' + ",\n ".join(col_defs) + "\n)"
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def _build_sequence_sql(cls) -> str:
|
|
317
|
+
table = cls._get_table_name()
|
|
318
|
+
return f"CREATE SEQUENCE IF NOT EXISTS seq_{table}_id START 1"
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def _create_table_sync(cls) -> None:
|
|
322
|
+
"""Create the table synchronously."""
|
|
323
|
+
session = get_session()
|
|
324
|
+
session.execute(cls._build_sequence_sql())
|
|
325
|
+
session.execute(cls._build_create_table_sql())
|
|
326
|
+
|
|
327
|
+
# Create indexes
|
|
328
|
+
table = cls._get_table_name()
|
|
329
|
+
for field_name, spec in cls._get_indexed_fields():
|
|
330
|
+
idx_name = f"idx_{table}_{field_name}"
|
|
331
|
+
unique = "UNIQUE " if spec.unique else ""
|
|
332
|
+
try:
|
|
333
|
+
session.execute(
|
|
334
|
+
f'CREATE {unique}INDEX IF NOT EXISTS "{idx_name}" ON "{table}" ("{field_name}")'
|
|
335
|
+
)
|
|
336
|
+
except Exception:
|
|
337
|
+
pass # Index may already exist
|
|
338
|
+
|
|
339
|
+
@classmethod
|
|
340
|
+
async def _create_table(cls) -> None:
|
|
341
|
+
"""Create the table asynchronously."""
|
|
342
|
+
await asyncio.to_thread(cls._create_table_sync)
|
|
343
|
+
|
|
344
|
+
# ── Row serialization ─────────────────────
|
|
345
|
+
|
|
346
|
+
def _to_row_dict(self) -> dict[str, Any]:
|
|
347
|
+
"""Convert this document to a dict of column → value for DuckDB."""
|
|
348
|
+
data = {}
|
|
349
|
+
for name in self.model_fields:
|
|
350
|
+
val = getattr(self, name)
|
|
351
|
+
data[name] = _python_value_to_duckdb(val)
|
|
352
|
+
return data
|
|
353
|
+
|
|
354
|
+
@classmethod
|
|
355
|
+
def _from_row(cls: Type[T], row: tuple, columns: list[str]) -> T:
|
|
356
|
+
"""Create a document instance from a database row."""
|
|
357
|
+
import typing
|
|
358
|
+
hints = get_type_hints(cls, include_extras=True)
|
|
359
|
+
data = {}
|
|
360
|
+
for col_name, value in zip(columns, row):
|
|
361
|
+
py_type = hints.get(col_name, str)
|
|
362
|
+
data[col_name] = _duckdb_value_to_python(value, py_type)
|
|
363
|
+
return cls.model_validate(data)
|
|
364
|
+
|
|
365
|
+
# ── CRUD: Insert ──────────────────────────
|
|
366
|
+
|
|
367
|
+
async def insert(self: T) -> T:
|
|
368
|
+
"""Insert this document into the database."""
|
|
369
|
+
session = get_session()
|
|
370
|
+
table = self._get_table_name()
|
|
371
|
+
data = self._to_row_dict()
|
|
372
|
+
|
|
373
|
+
# Remove id if None (auto-generated)
|
|
374
|
+
if data.get("id") is None:
|
|
375
|
+
data.pop("id", None)
|
|
376
|
+
|
|
377
|
+
columns = list(data.keys())
|
|
378
|
+
placeholders = ", ".join("?" for _ in columns)
|
|
379
|
+
col_str = ", ".join(f'"{c}"' for c in columns)
|
|
380
|
+
values = [data[c] for c in columns]
|
|
381
|
+
|
|
382
|
+
sql = f'INSERT INTO "{table}" ({col_str}) VALUES ({placeholders}) RETURNING "id"'
|
|
383
|
+
|
|
384
|
+
row = await session.async_fetchone(sql, values)
|
|
385
|
+
if row:
|
|
386
|
+
self.id = row[0]
|
|
387
|
+
return self
|
|
388
|
+
|
|
389
|
+
def insert_sync(self: T) -> T:
|
|
390
|
+
"""Insert this document synchronously."""
|
|
391
|
+
session = get_session()
|
|
392
|
+
table = self._get_table_name()
|
|
393
|
+
data = self._to_row_dict()
|
|
394
|
+
|
|
395
|
+
if data.get("id") is None:
|
|
396
|
+
data.pop("id", None)
|
|
397
|
+
|
|
398
|
+
columns = list(data.keys())
|
|
399
|
+
placeholders = ", ".join("?" for _ in columns)
|
|
400
|
+
col_str = ", ".join(f'"{c}"' for c in columns)
|
|
401
|
+
values = [data[c] for c in columns]
|
|
402
|
+
|
|
403
|
+
sql = f'INSERT INTO "{table}" ({col_str}) VALUES ({placeholders}) RETURNING "id"'
|
|
404
|
+
row = session.fetchone(sql, values)
|
|
405
|
+
if row:
|
|
406
|
+
self.id = row[0]
|
|
407
|
+
return self
|
|
408
|
+
|
|
409
|
+
# ── CRUD: Insert Many ─────────────────────
|
|
410
|
+
|
|
411
|
+
@classmethod
|
|
412
|
+
async def insert_many(cls: Type[T], documents: Sequence[T]) -> list[T]:
|
|
413
|
+
"""Bulk insert multiple documents."""
|
|
414
|
+
if not documents:
|
|
415
|
+
return []
|
|
416
|
+
|
|
417
|
+
session = get_session()
|
|
418
|
+
table = cls._get_table_name()
|
|
419
|
+
|
|
420
|
+
results = []
|
|
421
|
+
for doc in documents:
|
|
422
|
+
inserted = await doc.insert()
|
|
423
|
+
results.append(inserted)
|
|
424
|
+
return results
|
|
425
|
+
|
|
426
|
+
@classmethod
|
|
427
|
+
def insert_many_sync(cls: Type[T], documents: Sequence[T]) -> list[T]:
|
|
428
|
+
"""Bulk insert multiple documents synchronously."""
|
|
429
|
+
return [doc.insert_sync() for doc in documents]
|
|
430
|
+
|
|
431
|
+
# ── CRUD: Save (Upsert) ───────────────────
|
|
432
|
+
|
|
433
|
+
async def save(self: T) -> T:
|
|
434
|
+
"""
|
|
435
|
+
Save (upsert) this document.
|
|
436
|
+
If the document has an id and exists → UPDATE.
|
|
437
|
+
Otherwise → INSERT.
|
|
438
|
+
"""
|
|
439
|
+
if self.id is not None:
|
|
440
|
+
session = get_session()
|
|
441
|
+
table = self._get_table_name()
|
|
442
|
+
data = self._to_row_dict()
|
|
443
|
+
data.pop("id")
|
|
444
|
+
|
|
445
|
+
if not data:
|
|
446
|
+
return self
|
|
447
|
+
|
|
448
|
+
set_parts = [f'"{col}" = ?' for col in data]
|
|
449
|
+
values = list(data.values()) + [self.id]
|
|
450
|
+
|
|
451
|
+
sql = f'UPDATE "{table}" SET {", ".join(set_parts)} WHERE "id" = ?'
|
|
452
|
+
await session.async_execute(sql, values)
|
|
453
|
+
return self
|
|
454
|
+
else:
|
|
455
|
+
return await self.insert()
|
|
456
|
+
|
|
457
|
+
def save_sync(self: T) -> T:
|
|
458
|
+
"""Save (upsert) this document synchronously."""
|
|
459
|
+
if self.id is not None:
|
|
460
|
+
session = get_session()
|
|
461
|
+
table = self._get_table_name()
|
|
462
|
+
data = self._to_row_dict()
|
|
463
|
+
data.pop("id")
|
|
464
|
+
|
|
465
|
+
if not data:
|
|
466
|
+
return self
|
|
467
|
+
|
|
468
|
+
set_parts = [f'"{col}" = ?' for col in data]
|
|
469
|
+
values = list(data.values()) + [self.id]
|
|
470
|
+
|
|
471
|
+
sql = f'UPDATE "{table}" SET {", ".join(set_parts)} WHERE "id" = ?'
|
|
472
|
+
session.execute(sql, values)
|
|
473
|
+
return self
|
|
474
|
+
else:
|
|
475
|
+
return self.insert_sync()
|
|
476
|
+
|
|
477
|
+
# ── CRUD: Delete ──────────────────────────
|
|
478
|
+
|
|
479
|
+
async def delete(self) -> None:
|
|
480
|
+
"""Delete this document from the database."""
|
|
481
|
+
if self.id is None:
|
|
482
|
+
raise InvalidQueryError("Cannot delete a document without an id")
|
|
483
|
+
|
|
484
|
+
session = get_session()
|
|
485
|
+
table = self._get_table_name()
|
|
486
|
+
await session.async_execute(f'DELETE FROM "{table}" WHERE "id" = ?', [self.id])
|
|
487
|
+
|
|
488
|
+
def delete_sync(self) -> None:
|
|
489
|
+
"""Delete this document synchronously."""
|
|
490
|
+
if self.id is None:
|
|
491
|
+
raise InvalidQueryError("Cannot delete a document without an id")
|
|
492
|
+
|
|
493
|
+
session = get_session()
|
|
494
|
+
table = self._get_table_name()
|
|
495
|
+
session.execute(f'DELETE FROM "{table}" WHERE "id" = ?', [self.id])
|
|
496
|
+
|
|
497
|
+
# ── CRUD: Delete All ──────────────────────
|
|
498
|
+
|
|
499
|
+
@classmethod
|
|
500
|
+
async def delete_all(cls) -> None:
|
|
501
|
+
"""Delete all documents in the table."""
|
|
502
|
+
session = get_session()
|
|
503
|
+
table = cls._get_table_name()
|
|
504
|
+
await session.async_execute(f'DELETE FROM "{table}"')
|
|
505
|
+
|
|
506
|
+
@classmethod
|
|
507
|
+
def delete_all_sync(cls) -> None:
|
|
508
|
+
"""Delete all documents synchronously."""
|
|
509
|
+
session = get_session()
|
|
510
|
+
table = cls._get_table_name()
|
|
511
|
+
session.execute(f'DELETE FROM "{table}"')
|
|
512
|
+
|
|
513
|
+
# ── Query: find / find_one / find_all ─────
|
|
514
|
+
|
|
515
|
+
@classmethod
|
|
516
|
+
def find(cls: Type[T], *conditions: Expression) -> FindQuery[T]:
|
|
517
|
+
"""
|
|
518
|
+
Create a query builder with optional filter conditions.
|
|
519
|
+
|
|
520
|
+
Usage:
|
|
521
|
+
users = await User.find(User.age > 25).to_list()
|
|
522
|
+
users = await User.find(User.name == "Alice", User.active == True).to_list()
|
|
523
|
+
"""
|
|
524
|
+
return FindQuery(cls, *conditions)
|
|
525
|
+
|
|
526
|
+
@classmethod
|
|
527
|
+
def find_all(cls: Type[T]) -> FindQuery[T]:
|
|
528
|
+
"""Return a query for all documents (no filter)."""
|
|
529
|
+
return FindQuery(cls)
|
|
530
|
+
|
|
531
|
+
@classmethod
|
|
532
|
+
async def find_one(cls: Type[T], *conditions: Expression) -> Optional[T]:
|
|
533
|
+
"""Find a single document matching the conditions."""
|
|
534
|
+
return await FindQuery(cls, *conditions).first_or_none()
|
|
535
|
+
|
|
536
|
+
@classmethod
|
|
537
|
+
def find_one_sync(cls: Type[T], *conditions: Expression) -> Optional[T]:
|
|
538
|
+
"""Find a single document synchronously."""
|
|
539
|
+
return FindQuery(cls, *conditions).first_or_none_sync()
|
|
540
|
+
|
|
541
|
+
# ── Query: get by id ──────────────────────
|
|
542
|
+
|
|
543
|
+
@classmethod
|
|
544
|
+
async def get(cls: Type[T], doc_id: int) -> Optional[T]:
|
|
545
|
+
"""Get a document by its primary key id."""
|
|
546
|
+
session = get_session()
|
|
547
|
+
table = cls._get_table_name()
|
|
548
|
+
row = await session.async_fetchone(
|
|
549
|
+
f'SELECT * FROM "{table}" WHERE "id" = ?', [doc_id]
|
|
550
|
+
)
|
|
551
|
+
if row is None:
|
|
552
|
+
return None
|
|
553
|
+
return cls._from_row(row, cls._get_column_names())
|
|
554
|
+
|
|
555
|
+
@classmethod
|
|
556
|
+
def get_sync(cls: Type[T], doc_id: int) -> Optional[T]:
|
|
557
|
+
"""Get a document by id synchronously."""
|
|
558
|
+
session = get_session()
|
|
559
|
+
table = cls._get_table_name()
|
|
560
|
+
row = session.fetchone(
|
|
561
|
+
f'SELECT * FROM "{table}" WHERE "id" = ?', [doc_id]
|
|
562
|
+
)
|
|
563
|
+
if row is None:
|
|
564
|
+
return None
|
|
565
|
+
return cls._from_row(row, cls._get_column_names())
|
|
566
|
+
|
|
567
|
+
# ── Query: count ──────────────────────────
|
|
568
|
+
|
|
569
|
+
@classmethod
|
|
570
|
+
async def count(cls) -> int:
|
|
571
|
+
"""Count all documents in the table."""
|
|
572
|
+
return await cls.find_all().count()
|
|
573
|
+
|
|
574
|
+
@classmethod
|
|
575
|
+
def count_sync(cls) -> int:
|
|
576
|
+
"""Count all documents synchronously."""
|
|
577
|
+
return cls.find_all().count_sync()
|
|
578
|
+
|
|
579
|
+
# ── Refresh ───────────────────────────────
|
|
580
|
+
|
|
581
|
+
async def refresh(self: T) -> T:
|
|
582
|
+
"""Reload this document's data from the database."""
|
|
583
|
+
if self.id is None:
|
|
584
|
+
raise InvalidQueryError("Cannot refresh a document without an id")
|
|
585
|
+
|
|
586
|
+
fresh = await self.__class__.get(self.id)
|
|
587
|
+
if fresh is None:
|
|
588
|
+
raise DocumentNotFound(f"{self.__class__.__name__} with id={self.id} not found")
|
|
589
|
+
|
|
590
|
+
for field_name in self.model_fields:
|
|
591
|
+
setattr(self, field_name, getattr(fresh, field_name))
|
|
592
|
+
return self
|
|
593
|
+
|
|
594
|
+
# ── Repr ──────────────────────────────────
|
|
595
|
+
|
|
596
|
+
def __repr__(self) -> str:
|
|
597
|
+
fields = ", ".join(f"{k}={getattr(self, k)!r}" for k in self.model_fields)
|
|
598
|
+
return f"{self.__class__.__name__}({fields})"
|
duckling/exceptions.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Custom exceptions for the Duckling ORM."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DucklingError(Exception):
|
|
5
|
+
"""Base exception for all Duckling errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DocumentNotFound(DucklingError):
|
|
9
|
+
"""Raised when a document is not found."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DocumentAlreadyExists(DucklingError):
|
|
13
|
+
"""Raised when inserting a document that already exists."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotInitializedError(DucklingError):
|
|
17
|
+
"""Raised when Duckling has not been initialized."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CollectionNotFound(DucklingError):
|
|
21
|
+
"""Raised when a table/collection does not exist."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidQueryError(DucklingError):
|
|
25
|
+
"""Raised when a query is malformed."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ValidationError(DucklingError):
|
|
29
|
+
"""Raised when document validation fails."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConnectionError(DucklingError):
|
|
33
|
+
"""Raised when a database connection fails."""
|