duckling-orm 0.0.3__tar.gz → 0.0.4__tar.gz

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 (26) hide show
  1. {duckling_orm-0.0.3/src/duckling_orm.egg-info → duckling_orm-0.0.4}/PKG-INFO +29 -2
  2. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/README.md +28 -1
  3. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/pyproject.toml +2 -1
  4. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling/__init__.py +3 -0
  5. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling/document.py +64 -16
  6. duckling_orm-0.0.4/src/duckling/ids.py +26 -0
  7. {duckling_orm-0.0.3 → duckling_orm-0.0.4/src/duckling_orm.egg-info}/PKG-INFO +29 -2
  8. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling_orm.egg-info/SOURCES.txt +2 -0
  9. duckling_orm-0.0.4/tests/test_custom_ids.py +84 -0
  10. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/LICENSE +0 -0
  11. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/setup.cfg +0 -0
  12. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling/connection.py +0 -0
  13. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling/exceptions.py +0 -0
  14. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling/fields.py +0 -0
  15. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling/init.py +0 -0
  16. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling/operators.py +0 -0
  17. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling/query.py +0 -0
  18. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling_orm.egg-info/dependency_links.txt +0 -0
  19. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling_orm.egg-info/requires.txt +0 -0
  20. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/src/duckling_orm.egg-info/top_level.txt +0 -0
  21. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/tests/test_connection.py +0 -0
  22. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/tests/test_document.py +0 -0
  23. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/tests/test_fields.py +0 -0
  24. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/tests/test_init.py +0 -0
  25. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/tests/test_operators.py +0 -0
  26. {duckling_orm-0.0.3 → duckling_orm-0.0.4}/tests/test_query.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duckling-orm
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: A Beanie-inspired ORM for DuckDB — async-first, Pydantic-powered.
5
5
  Author: Duckling Contributors
6
6
  License: Apache-2.0
@@ -108,7 +108,8 @@ init_duckling_sync(database="app.db", document_models=[User])
108
108
 
109
109
  ### Defining Models
110
110
 
111
- Duckling models are Pydantic `BaseModel` subclasses with an auto-generated `id` primary key:
111
+ Duckling models are Pydantic `BaseModel` subclasses. By default, `id` is an
112
+ auto-generated, auto-incrementing integer primary key:
112
113
 
113
114
  ```python
114
115
  from duckling import Document, IndexSpec
@@ -129,6 +130,32 @@ class Product(Document):
129
130
 
130
131
  **Supported types:** `str`, `int`, `float`, `bool`, `bytes`, `datetime.date`, `datetime.datetime`, `datetime.time`, `uuid.UUID`, `Optional[T]`, `List[T]` (→ JSON), `dict` (→ JSON), nested Pydantic models (→ JSON), `Enum`.
131
132
 
133
+ ### Custom Primary Keys (UUID, ULID, string)
134
+
135
+ Override `id` with any type other than `int` to opt out of the auto-increment
136
+ sequence and use your own primary key — a UUID, a sortable ULID, or a plain
137
+ caller-supplied string:
138
+
139
+ ```python
140
+ import uuid
141
+ from pydantic import Field
142
+ from duckling import Document, generate_ulid
143
+
144
+ class Session(Document):
145
+ id: str = Field(default_factory=generate_ulid) # sortable string PK
146
+ user_email: str
147
+
148
+ class ApiKey(Document):
149
+ id: uuid.UUID = Field(default_factory=uuid.uuid4) # UUID PK
150
+ label: str
151
+
152
+ class Tag(Document):
153
+ id: str # no default — caller must supply an id
154
+ name: str
155
+ ```
156
+
157
+ Inserting a document whose `id` already exists raises `DocumentAlreadyExists`.
158
+
132
159
  ### Indexed Fields
133
160
 
134
161
  ```python
@@ -77,7 +77,8 @@ init_duckling_sync(database="app.db", document_models=[User])
77
77
 
78
78
  ### Defining Models
79
79
 
80
- Duckling models are Pydantic `BaseModel` subclasses with an auto-generated `id` primary key:
80
+ Duckling models are Pydantic `BaseModel` subclasses. By default, `id` is an
81
+ auto-generated, auto-incrementing integer primary key:
81
82
 
82
83
  ```python
83
84
  from duckling import Document, IndexSpec
@@ -98,6 +99,32 @@ class Product(Document):
98
99
 
99
100
  **Supported types:** `str`, `int`, `float`, `bool`, `bytes`, `datetime.date`, `datetime.datetime`, `datetime.time`, `uuid.UUID`, `Optional[T]`, `List[T]` (→ JSON), `dict` (→ JSON), nested Pydantic models (→ JSON), `Enum`.
100
101
 
102
+ ### Custom Primary Keys (UUID, ULID, string)
103
+
104
+ Override `id` with any type other than `int` to opt out of the auto-increment
105
+ sequence and use your own primary key — a UUID, a sortable ULID, or a plain
106
+ caller-supplied string:
107
+
108
+ ```python
109
+ import uuid
110
+ from pydantic import Field
111
+ from duckling import Document, generate_ulid
112
+
113
+ class Session(Document):
114
+ id: str = Field(default_factory=generate_ulid) # sortable string PK
115
+ user_email: str
116
+
117
+ class ApiKey(Document):
118
+ id: uuid.UUID = Field(default_factory=uuid.uuid4) # UUID PK
119
+ label: str
120
+
121
+ class Tag(Document):
122
+ id: str # no default — caller must supply an id
123
+ name: str
124
+ ```
125
+
126
+ Inserting a document whose `id` already exists raises `DocumentAlreadyExists`.
127
+
101
128
  ### Indexed Fields
102
129
 
103
130
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "duckling-orm"
7
- version = "0.0.3"
7
+ version = "0.0.4"
8
8
  description = "A Beanie-inspired ORM for DuckDB — async-first, Pydantic-powered."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -44,6 +44,7 @@ dev = [
44
44
 
45
45
 
46
46
 
47
+
47
48
  [tool.setuptools.packages.find]
48
49
  where = ["src"]
49
50
  include = ["duckling*"]
@@ -40,6 +40,7 @@ from .fields import (
40
40
  IndexSpec,
41
41
  SortDirection,
42
42
  )
43
+ from .ids import generate_ulid
43
44
  from .init import init_duckling, init_duckling_sync
44
45
  from .operators import (
45
46
  And,
@@ -84,6 +85,8 @@ __all__ = [
84
85
  "SortDirection",
85
86
  "FieldProxy",
86
87
  "Expression",
88
+ # IDs
89
+ "generate_ulid",
87
90
  # Query
88
91
  "FindQuery",
89
92
  # Operators
@@ -46,10 +46,11 @@ from typing import (
46
46
  get_type_hints,
47
47
  )
48
48
 
49
+ import duckdb
49
50
  from pydantic import BaseModel, ConfigDict
50
51
 
51
52
  from .connection import DucklingSession, get_session
52
- from .exceptions import DocumentNotFound, InvalidQueryError
53
+ from .exceptions import DocumentAlreadyExists, DocumentNotFound, InvalidQueryError
53
54
  from .fields import Expression, FieldProxy, IndexSpec, SortDirection
54
55
  from .query import FindQuery
55
56
 
@@ -287,6 +288,35 @@ class Document(BaseModel, metaclass=DocumentMeta):
287
288
  indexed.append((name, arg))
288
289
  return indexed
289
290
 
291
+ # ── Primary key strategy ──────────────────
292
+
293
+ @classmethod
294
+ def _is_auto_increment_id(cls) -> bool:
295
+ """
296
+ True if the `id` field resolves to `int` (the default primary key
297
+ strategy: an auto-incrementing INTEGER backed by a DuckDB SEQUENCE).
298
+
299
+ Any other resolved type (str, uuid.UUID, ...) is treated as a
300
+ caller/default_factory-supplied primary key with no sequence.
301
+ """
302
+ import typing
303
+
304
+ hints = get_type_hints(cls, include_extras=True)
305
+ py_type = hints.get("id", int)
306
+
307
+ origin = get_origin(py_type)
308
+ if origin is getattr(typing, "Annotated", None):
309
+ py_type = get_args(py_type)[0]
310
+ origin = get_origin(py_type)
311
+
312
+ args = get_args(py_type)
313
+ if args:
314
+ non_none = [a for a in args if a is not type(None)]
315
+ if non_none:
316
+ py_type = non_none[0]
317
+
318
+ return py_type is int
319
+
290
320
  # ── Table creation ────────────────────────
291
321
 
292
322
  @classmethod
@@ -296,24 +326,28 @@ class Document(BaseModel, metaclass=DocumentMeta):
296
326
  col_types = cls._get_column_types()
297
327
  indexed = dict(cls._get_indexed_fields())
298
328
 
329
+ if cls._is_auto_increment_id():
330
+ id_def = f'"id" INTEGER PRIMARY KEY DEFAULT(nextval(\'seq_{table}_id\'))'
331
+ else:
332
+ id_def = f'"id" {col_types["id"]} PRIMARY KEY'
333
+
299
334
  columns = []
300
335
  for col_name, col_type in col_types.items():
301
- parts = [f'"{col_name}"', col_type]
302
336
  if col_name == "id":
303
- parts = ['"id"', "INTEGER PRIMARY KEY DEFAULT(nextval('seq_" + table + "_id'))"]
304
- continue # handled separately
337
+ continue
338
+ parts = [f'"{col_name}"', col_type]
305
339
  if col_name in indexed and indexed[col_name].unique:
306
340
  parts.append("UNIQUE")
307
341
  columns.append(" ".join(parts))
308
342
 
309
- # id column first
310
- col_defs = [f'"id" INTEGER PRIMARY KEY DEFAULT(nextval(\'seq_{table}_id\'))']
311
- col_defs.extend(columns)
343
+ col_defs = [id_def] + columns
312
344
 
313
345
  return f'CREATE TABLE IF NOT EXISTS "{table}" (\n ' + ",\n ".join(col_defs) + "\n)"
314
346
 
315
347
  @classmethod
316
- def _build_sequence_sql(cls) -> str:
348
+ def _build_sequence_sql(cls) -> Optional[str]:
349
+ if not cls._is_auto_increment_id():
350
+ return None
317
351
  table = cls._get_table_name()
318
352
  return f"CREATE SEQUENCE IF NOT EXISTS seq_{table}_id START 1"
319
353
 
@@ -321,7 +355,9 @@ class Document(BaseModel, metaclass=DocumentMeta):
321
355
  def _create_table_sync(cls) -> None:
322
356
  """Create the table synchronously."""
323
357
  session = get_session()
324
- session.execute(cls._build_sequence_sql())
358
+ seq_sql = cls._build_sequence_sql()
359
+ if seq_sql:
360
+ session.execute(seq_sql)
325
361
  session.execute(cls._build_create_table_sql())
326
362
 
327
363
  # Create indexes
@@ -370,8 +406,10 @@ class Document(BaseModel, metaclass=DocumentMeta):
370
406
  table = self._get_table_name()
371
407
  data = self._to_row_dict()
372
408
 
373
- # Remove id if None (auto-generated)
374
- if data.get("id") is None:
409
+ # Remove id if None so the auto-increment sequence default applies.
410
+ # A non-auto-increment (str/UUID) id left None is kept so the
411
+ # missing-primary-key error surfaces from the database.
412
+ if self._is_auto_increment_id() and data.get("id") is None:
375
413
  data.pop("id", None)
376
414
 
377
415
  columns = list(data.keys())
@@ -381,7 +419,12 @@ class Document(BaseModel, metaclass=DocumentMeta):
381
419
 
382
420
  sql = f'INSERT INTO "{table}" ({col_str}) VALUES ({placeholders}) RETURNING "id"'
383
421
 
384
- row = await session.async_fetchone(sql, values)
422
+ try:
423
+ row = await session.async_fetchone(sql, values)
424
+ except duckdb.ConstraintException as e:
425
+ raise DocumentAlreadyExists(
426
+ f"{self.__class__.__name__} with id={data.get('id')!r} already exists"
427
+ ) from e
385
428
  if row:
386
429
  self.id = row[0]
387
430
  return self
@@ -392,7 +435,7 @@ class Document(BaseModel, metaclass=DocumentMeta):
392
435
  table = self._get_table_name()
393
436
  data = self._to_row_dict()
394
437
 
395
- if data.get("id") is None:
438
+ if self._is_auto_increment_id() and data.get("id") is None:
396
439
  data.pop("id", None)
397
440
 
398
441
  columns = list(data.keys())
@@ -401,7 +444,12 @@ class Document(BaseModel, metaclass=DocumentMeta):
401
444
  values = [data[c] for c in columns]
402
445
 
403
446
  sql = f'INSERT INTO "{table}" ({col_str}) VALUES ({placeholders}) RETURNING "id"'
404
- row = session.fetchone(sql, values)
447
+ try:
448
+ row = session.fetchone(sql, values)
449
+ except duckdb.ConstraintException as e:
450
+ raise DocumentAlreadyExists(
451
+ f"{self.__class__.__name__} with id={data.get('id')!r} already exists"
452
+ ) from e
405
453
  if row:
406
454
  self.id = row[0]
407
455
  return self
@@ -541,7 +589,7 @@ class Document(BaseModel, metaclass=DocumentMeta):
541
589
  # ── Query: get by id ──────────────────────
542
590
 
543
591
  @classmethod
544
- async def get(cls: Type[T], doc_id: int) -> Optional[T]:
592
+ async def get(cls: Type[T], doc_id: Any) -> Optional[T]:
545
593
  """Get a document by its primary key id."""
546
594
  session = get_session()
547
595
  table = cls._get_table_name()
@@ -553,7 +601,7 @@ class Document(BaseModel, metaclass=DocumentMeta):
553
601
  return cls._from_row(row, cls._get_column_names())
554
602
 
555
603
  @classmethod
556
- def get_sync(cls: Type[T], doc_id: int) -> Optional[T]:
604
+ def get_sync(cls: Type[T], doc_id: Any) -> Optional[T]:
557
605
  """Get a document by id synchronously."""
558
606
  session = get_session()
559
607
  table = cls._get_table_name()
@@ -0,0 +1,26 @@
1
+ """ID generation helpers for custom primary keys."""
2
+
3
+ import os
4
+ import time
5
+
6
+ _CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
7
+
8
+
9
+ def generate_ulid() -> str:
10
+ """
11
+ Generate a 26-character ULID (48-bit ms timestamp + 80-bit randomness),
12
+ lexicographically sortable by creation time.
13
+
14
+ Usage:
15
+ class Session(Document):
16
+ id: str = Field(default_factory=generate_ulid)
17
+ """
18
+ ms = int(time.time() * 1000)
19
+ randomness = int.from_bytes(os.urandom(10), "big")
20
+ value = (ms << 80) | randomness
21
+
22
+ chars = []
23
+ for _ in range(26):
24
+ value, rem = divmod(value, 32)
25
+ chars.append(_CROCKFORD[rem])
26
+ return "".join(reversed(chars))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duckling-orm
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: A Beanie-inspired ORM for DuckDB — async-first, Pydantic-powered.
5
5
  Author: Duckling Contributors
6
6
  License: Apache-2.0
@@ -108,7 +108,8 @@ init_duckling_sync(database="app.db", document_models=[User])
108
108
 
109
109
  ### Defining Models
110
110
 
111
- Duckling models are Pydantic `BaseModel` subclasses with an auto-generated `id` primary key:
111
+ Duckling models are Pydantic `BaseModel` subclasses. By default, `id` is an
112
+ auto-generated, auto-incrementing integer primary key:
112
113
 
113
114
  ```python
114
115
  from duckling import Document, IndexSpec
@@ -129,6 +130,32 @@ class Product(Document):
129
130
 
130
131
  **Supported types:** `str`, `int`, `float`, `bool`, `bytes`, `datetime.date`, `datetime.datetime`, `datetime.time`, `uuid.UUID`, `Optional[T]`, `List[T]` (→ JSON), `dict` (→ JSON), nested Pydantic models (→ JSON), `Enum`.
131
132
 
133
+ ### Custom Primary Keys (UUID, ULID, string)
134
+
135
+ Override `id` with any type other than `int` to opt out of the auto-increment
136
+ sequence and use your own primary key — a UUID, a sortable ULID, or a plain
137
+ caller-supplied string:
138
+
139
+ ```python
140
+ import uuid
141
+ from pydantic import Field
142
+ from duckling import Document, generate_ulid
143
+
144
+ class Session(Document):
145
+ id: str = Field(default_factory=generate_ulid) # sortable string PK
146
+ user_email: str
147
+
148
+ class ApiKey(Document):
149
+ id: uuid.UUID = Field(default_factory=uuid.uuid4) # UUID PK
150
+ label: str
151
+
152
+ class Tag(Document):
153
+ id: str # no default — caller must supply an id
154
+ name: str
155
+ ```
156
+
157
+ Inserting a document whose `id` already exists raises `DocumentAlreadyExists`.
158
+
132
159
  ### Indexed Fields
133
160
 
134
161
  ```python
@@ -6,6 +6,7 @@ src/duckling/connection.py
6
6
  src/duckling/document.py
7
7
  src/duckling/exceptions.py
8
8
  src/duckling/fields.py
9
+ src/duckling/ids.py
9
10
  src/duckling/init.py
10
11
  src/duckling/operators.py
11
12
  src/duckling/query.py
@@ -15,6 +16,7 @@ src/duckling_orm.egg-info/dependency_links.txt
15
16
  src/duckling_orm.egg-info/requires.txt
16
17
  src/duckling_orm.egg-info/top_level.txt
17
18
  tests/test_connection.py
19
+ tests/test_custom_ids.py
18
20
  tests/test_document.py
19
21
  tests/test_fields.py
20
22
  tests/test_init.py
@@ -0,0 +1,84 @@
1
+ """Tests for custom (non auto-increment) primary keys — str, UUID, ULID."""
2
+
3
+ import uuid
4
+
5
+ import pytest
6
+
7
+ from duckling import DocumentAlreadyExists, get_session
8
+
9
+ from .models import ApiKey, Session, Tag
10
+
11
+
12
+ class TestSyncCustomIds:
13
+ def test_ulid_id_is_generated_and_unique(self, sync_db):
14
+ a = Session(user_email="a@test.com")
15
+ b = Session(user_email="b@test.com")
16
+ a.insert_sync()
17
+ b.insert_sync()
18
+
19
+ assert isinstance(a.id, str) and len(a.id) == 26
20
+ assert a.id != b.id
21
+
22
+ def test_uuid_id_round_trip(self, sync_db):
23
+ key = ApiKey(label="prod")
24
+ key.insert_sync()
25
+ assert isinstance(key.id, uuid.UUID)
26
+
27
+ found = ApiKey.get_sync(key.id)
28
+ assert found is not None
29
+ assert found.label == "prod"
30
+
31
+ def test_caller_supplied_id_round_trip(self, sync_db):
32
+ tag = Tag(id="billing", name="Billing")
33
+ tag.insert_sync()
34
+
35
+ found = Tag.get_sync("billing")
36
+ assert found is not None
37
+ assert found.name == "Billing"
38
+
39
+ def test_duplicate_caller_supplied_id_raises(self, sync_db):
40
+ Tag(id="dup", name="First").insert_sync()
41
+ with pytest.raises(DocumentAlreadyExists):
42
+ Tag(id="dup", name="Second").insert_sync()
43
+
44
+ def test_no_sequence_created_for_string_id_model(self, sync_db):
45
+ session = get_session()
46
+ rows = session.fetchall(
47
+ "SELECT sequence_name FROM duckdb_sequences() WHERE sequence_name = ?",
48
+ ["seq_tags_id"],
49
+ )
50
+ assert rows == []
51
+
52
+
53
+ class TestAsyncCustomIds:
54
+ @pytest.mark.asyncio
55
+ async def test_ulid_id_is_generated_and_unique(self, async_db):
56
+ a = Session(user_email="a@test.com")
57
+ b = Session(user_email="b@test.com")
58
+ await a.insert()
59
+ await b.insert()
60
+
61
+ assert isinstance(a.id, str) and len(a.id) == 26
62
+ assert a.id != b.id
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_duplicate_caller_supplied_id_raises(self, async_db):
66
+ await Tag(id="dup", name="First").insert()
67
+ with pytest.raises(DocumentAlreadyExists):
68
+ await Tag(id="dup", name="Second").insert()
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_recreate_tables_with_mixed_pk_types(self):
72
+ from duckling import get_session, init_duckling
73
+
74
+ from .models import ALL_MODELS
75
+
76
+ session = await init_duckling(database=":memory:", document_models=ALL_MODELS)
77
+ conn = session.connection
78
+
79
+ # Reuse the same live connection so recreate_tables actually drops
80
+ # and rebuilds tables/sequences that exist, instead of a fresh :memory: db.
81
+ await init_duckling(
82
+ connection=conn, document_models=ALL_MODELS, recreate_tables=True
83
+ )
84
+ assert get_session().connection is conn
File without changes
File without changes