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/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."""