Skip to content

Commit bb41b6b

Browse files
authored
feat: add uuidv7 primary key mixin (#22)
* feat: add explicit uuidv7 primary key mixin * build: updated the project dependencies * fix: support uuidv7 primary key compatibility
1 parent 22435ba commit bb41b6b

File tree

6 files changed

+350
-56
lines changed

6 files changed

+350
-56
lines changed

src/brussels/__tests__/mixins/test_primary_key_mixin.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from sqlalchemy.orm import Mapped, Session, mapped_column
1010

1111
from brussels.base import DataclassBase
12-
from brussels.mixins import PrimaryKeyMixin, TimestampMixin
12+
from brussels.mixins import PrimaryKeyMixin, TimestampMixin, UUIDv7PrimaryKeyMixin
1313

1414

1515
class Widget(DataclassBase, PrimaryKeyMixin, TimestampMixin):
@@ -18,6 +18,12 @@ class Widget(DataclassBase, PrimaryKeyMixin, TimestampMixin):
1818
name: Mapped[str] = mapped_column()
1919

2020

21+
class UUIDv7Widget(DataclassBase, UUIDv7PrimaryKeyMixin, TimestampMixin):
22+
__tablename__ = "primary_key_v7_widgets"
23+
24+
name: Mapped[str] = mapped_column()
25+
26+
2127
@pytest.fixture
2228
def engine() -> Iterator[Engine]:
2329
engine = create_engine("sqlite:///:memory:")
@@ -57,3 +63,41 @@ def test_id_default_factory_generates_uuid_on_flush(engine: Engine) -> None:
5763
session.flush()
5864

5965
assert isinstance(widget.id, UUID)
66+
67+
68+
def test_uuidv7_id_column_definition() -> None:
69+
table = cast("Table", UUIDv7Widget.__table__)
70+
column = table.c.id
71+
72+
assert column.primary_key is True
73+
74+
insert_default = column.default
75+
assert insert_default is not None
76+
compiled_insert_default = cast("Any", insert_default).arg.compile(dialect=postgresql.dialect())
77+
assert "uuidv7" in str(compiled_insert_default)
78+
79+
server_default = column.server_default
80+
assert server_default is not None
81+
compiled_server_default = cast("Any", server_default).arg.compile(dialect=postgresql.dialect())
82+
assert "uuidv7" in str(compiled_server_default)
83+
84+
85+
def test_uuidv7_id_not_in_init_signature() -> None:
86+
signature = inspect.signature(UUIDv7Widget)
87+
assert "id" not in signature.parameters
88+
89+
widget_cls = cast("Any", UUIDv7Widget)
90+
with pytest.raises(TypeError):
91+
widget_cls(id=uuid4(), name="widget")
92+
93+
94+
def test_uuidv7_id_is_not_populated_before_flush() -> None:
95+
widget = UUIDv7Widget(name="widget")
96+
97+
assert widget.id is None
98+
99+
100+
def test_uuidv7_models_satisfy_primary_key_mixin_contract() -> None:
101+
widget = UUIDv7Widget(name="widget")
102+
103+
assert isinstance(widget, PrimaryKeyMixin)

src/brussels/__tests__/types/file/test_file.py

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
from datetime import UTC, datetime
44
from typing import TYPE_CHECKING, cast
5-
from uuid import UUID
5+
from uuid import UUID, uuid4
66

77
import pytest
88
from sqlalchemy.orm import Mapped, Session, mapped_column
99

1010
from brussels.base import Base, DataclassBase
11-
from brussels.mixins import PrimaryKeyMixin
11+
from brussels.mixins import PrimaryKeyMixin, UUIDv7PrimaryKeyMixin
1212

1313
try:
1414
from obstore.store import MemoryStore
@@ -20,6 +20,7 @@
2020
if TYPE_CHECKING:
2121
from obstore import GetOptions, PutMode
2222
from sqlalchemy import Engine
23+
from sqlalchemy.ext.asyncio import AsyncSession
2324

2425
from brussels.types.file._types import RemoteMetadataField
2526

@@ -45,6 +46,12 @@ class OtherFileModel(DataclassBase, PrimaryKeyMixin):
4546
file: Mapped[RemoteMetadata | None] = mapped_column(RemoteStorage(store=MemoryStore()), nullable=True, default=None)
4647

4748

49+
class UUIDv7FileModel(DataclassBase, UUIDv7PrimaryKeyMixin):
50+
__tablename__ = "uuidv7_file_model"
51+
52+
file: Mapped[RemoteMetadata | None] = mapped_column(RemoteStorage(store=MemoryStore()), nullable=True, default=None)
53+
54+
4855
class TablelessPrimaryKeyModel(PrimaryKeyMixin):
4956
pass
5057

@@ -54,10 +61,29 @@ def _configure_store(store_ops: FakeStoreOps) -> None:
5461
remote_storage.store = store_ops
5562

5663

64+
def _configure_uuidv7_store(store_ops: FakeStoreOps) -> None:
65+
remote_storage = cast("RemoteStorage", UUIDv7FileModel.__table__.c["file"].type)
66+
remote_storage.store = store_ops
67+
68+
5769
def _file_handle(model: FileModel) -> RemoteFile:
5870
return RemoteFile.from_metadata(model, FileModel.file)
5971

6072

73+
def _uuidv7_file_handle(model: UUIDv7FileModel) -> RemoteFile:
74+
return RemoteFile.from_metadata(model, UUIDv7FileModel.file)
75+
76+
77+
class AsyncSessionShim:
78+
def __init__(self, sync_session: Session, *, on_flush=None) -> None:
79+
self.sync_session = sync_session
80+
self._on_flush = on_flush
81+
82+
async def flush(self) -> None:
83+
if self._on_flush is not None:
84+
self._on_flush()
85+
86+
6187
def test_from_metadata_rejects_models_without_primary_key_mixin() -> None:
6288
model = NoPrimaryKeyMixinModel(id=1)
6389

@@ -105,6 +131,14 @@ def test_from_metadata_accepts_models_with_primary_key_mixin() -> None:
105131
assert remote_file.field_name == "file"
106132

107133

134+
def test_from_metadata_accepts_uuidv7_primary_key_models() -> None:
135+
model = UUIDv7FileModel()
136+
137+
remote_file = RemoteFile.from_metadata(model, UUIDv7FileModel.file)
138+
139+
assert remote_file.field_name == "file"
140+
141+
108142
def test_put_sync_without_sqlalchemy_session_raises_and_does_not_call_store() -> None:
109143
store_ops = FakeStoreOps()
110144
_configure_store(store_ops)
@@ -120,6 +154,62 @@ def test_put_sync_without_sqlalchemy_session_raises_and_does_not_call_store() ->
120154
assert model.file is None
121155

122156

157+
def test_put_sync_flush_populates_uuidv7_id_before_key_generation(
158+
engine: Engine,
159+
monkeypatch: pytest.MonkeyPatch,
160+
) -> None:
161+
store_ops = FakeStoreOps()
162+
_configure_uuidv7_store(store_ops)
163+
164+
with Session(engine) as session:
165+
model = UUIDv7FileModel()
166+
session.add(model)
167+
assigned_id = uuid4()
168+
flush_calls = 0
169+
170+
def fake_flush() -> None:
171+
nonlocal flush_calls
172+
flush_calls += 1
173+
if model.id is None:
174+
model.id = assigned_id
175+
176+
monkeypatch.setattr(session, "flush", fake_flush)
177+
178+
put_result = _uuidv7_file_handle(model).put(
179+
b"hello",
180+
content_type="text/plain",
181+
session=session,
182+
flush=True,
183+
)
184+
185+
assert put_result == {"e_tag": None, "version": None}
186+
assert flush_calls == 2
187+
assert model.id == assigned_id
188+
assert model.file is not None
189+
assert model.file.key == f"{assigned_id}/file"
190+
assert model.file.status == "pending"
191+
assert store_ops.calls == []
192+
193+
194+
def test_put_sync_rejects_uuidv7_model_without_flush(engine: Engine) -> None:
195+
store_ops = FakeStoreOps()
196+
_configure_uuidv7_store(store_ops)
197+
198+
with Session(engine) as session:
199+
model = UUIDv7FileModel()
200+
session.add(model)
201+
202+
with pytest.raises(ValueError, match=r"Pass flush=True or flush the model before calling put"):
203+
_uuidv7_file_handle(model).put(
204+
b"hello",
205+
content_type="text/plain",
206+
session=session,
207+
)
208+
209+
assert model.file is None
210+
assert store_ops.calls == []
211+
212+
123213
def test_put_sync_defers_and_commits_when_sqlalchemy_session_is_attached(engine: Engine) -> None:
124214
store_ops = FakeStoreOps()
125215
_configure_store(store_ops)
@@ -256,6 +346,58 @@ async def test_put_rejects_invalid_model_id_type(engine: Engine) -> None:
256346
await _file_handle(model).put_async(b"data")
257347

258348

349+
@pytest.mark.asyncio
350+
async def test_put_async_flush_populates_uuidv7_id_before_key_generation(engine: Engine) -> None:
351+
store_ops = FakeStoreOps()
352+
_configure_uuidv7_store(store_ops)
353+
354+
with Session(engine) as session:
355+
model = UUIDv7FileModel()
356+
session.add(model)
357+
assigned_id = uuid4()
358+
flush_calls = 0
359+
360+
def on_flush() -> None:
361+
nonlocal flush_calls
362+
flush_calls += 1
363+
if model.id is None:
364+
model.id = assigned_id
365+
366+
await _uuidv7_file_handle(model).put_async(
367+
b"data",
368+
content_type="text/plain",
369+
session=cast("AsyncSession", AsyncSessionShim(session, on_flush=on_flush)),
370+
flush=True,
371+
)
372+
373+
assert flush_calls == 2
374+
assert model.id == assigned_id
375+
assert model.file is not None
376+
assert model.file.key == f"{assigned_id}/file"
377+
assert model.file.status == "pending"
378+
assert store_ops.calls == []
379+
380+
381+
@pytest.mark.asyncio
382+
async def test_put_async_rejects_uuidv7_model_without_flush(engine: Engine) -> None:
383+
store_ops = FakeStoreOps()
384+
_configure_uuidv7_store(store_ops)
385+
386+
with Session(engine) as session:
387+
model = UUIDv7FileModel()
388+
session.add(model)
389+
390+
with pytest.raises(ValueError, match=r"Pass flush=True or flush the model before calling put_async"):
391+
await _uuidv7_file_handle(model).put_async(
392+
b"data",
393+
content_type="text/plain",
394+
session=session,
395+
)
396+
397+
assert model.file is None
398+
assert store_ops.calls == []
399+
400+
259401
@pytest.mark.asyncio
260402
async def test_put_allows_uuid_model_id(engine: Engine) -> None:
261403
store_ops = FakeStoreOps()

src/brussels/mixins/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from brussels.mixins.ordered import OrderedMixin
2-
from brussels.mixins.primary_key import PrimaryKeyMixin
2+
from brussels.mixins.primary_key import PrimaryKeyMixin, UUIDv7PrimaryKeyMixin
33
from brussels.mixins.timestamp import TimestampMixin
44

5-
__all__ = ["OrderedMixin", "PrimaryKeyMixin", "TimestampMixin"]
5+
__all__ = ["OrderedMixin", "PrimaryKeyMixin", "TimestampMixin", "UUIDv7PrimaryKeyMixin"]

src/brussels/mixins/primary_key.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,30 @@ class MyModel(DataclassBase, PrimaryKeyMixin, TimestampMixin):
3232
server_default=func.gen_random_uuid(),
3333
init=False,
3434
)
35+
36+
37+
@declarative_mixin
38+
class UUIDv7PrimaryKeyMixin(PrimaryKeyMixin):
39+
"""Mixin that adds a PostgreSQL 18+ UUIDv7 primary key column.
40+
41+
Extends PrimaryKeyMixin so models continue to satisfy APIs that require the
42+
existing primary-key mixin contract. The id field is excluded from __init__
43+
(init=False) and is generated during insert using PostgreSQL's uuidv7()
44+
function. Because the UUID is database generated, ID-derived workflows
45+
require the row to be flushed or already persisted.
46+
47+
Usage:
48+
class MyModel(DataclassBase, UUIDv7PrimaryKeyMixin, TimestampMixin):
49+
__tablename__ = "my_table"
50+
name: Mapped[str]
51+
52+
This mixin is only supported on PostgreSQL 18+ because it relies on the
53+
built-in uuidv7() database function.
54+
"""
55+
56+
id: Mapped[UUID] = mapped_column(
57+
primary_key=True,
58+
insert_default=func.uuidv7(),
59+
server_default=func.uuidv7(),
60+
init=False,
61+
)

src/brussels/types/file/file.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ def _model_id(self) -> str:
7272
raise TypeError(msg)
7373
return str(model_id)
7474

75+
@staticmethod
76+
def _missing_model_id_message(*, operation: str) -> str:
77+
return (
78+
f"RemoteStorage {operation} requires model.id to be set. "
79+
f"Pass flush=True or flush the model before calling {operation}."
80+
)
81+
7582
@staticmethod
7683
def _flush_sync(*, session: Session | None, flush: bool) -> None:
7784
if not flush or session is None:
@@ -86,6 +93,44 @@ async def _flush_async(*, session: Session | AsyncSession | None, flush: bool) -
8693
if isawaitable(maybe_awaitable):
8794
await maybe_awaitable
8895

96+
def _ensure_model_id_ready_sync(
97+
self,
98+
*,
99+
session: Session | None,
100+
flush: bool,
101+
operation: str,
102+
) -> None:
103+
if getattr(self.model, "id", None) is not None:
104+
return
105+
if not flush:
106+
raise ValueError(self._missing_model_id_message(operation=operation))
107+
108+
self._flush_sync(session=session, flush=True)
109+
if getattr(self.model, "id", None) is not None:
110+
return
111+
112+
msg = f"RemoteStorage {operation} requires model.id to be set after flush."
113+
raise ValueError(msg)
114+
115+
async def _ensure_model_id_ready_async(
116+
self,
117+
*,
118+
session: Session | AsyncSession | None,
119+
flush: bool,
120+
operation: str,
121+
) -> None:
122+
if getattr(self.model, "id", None) is not None:
123+
return
124+
if not flush:
125+
raise ValueError(self._missing_model_id_message(operation=operation))
126+
127+
await self._flush_async(session=session, flush=True)
128+
if getattr(self.model, "id", None) is not None:
129+
return
130+
131+
msg = f"RemoteStorage {operation} requires model.id to be set after flush."
132+
raise ValueError(msg)
133+
89134
@staticmethod
90135
def _resolve_sync_session(
91136
*,
@@ -168,6 +213,11 @@ def put( # noqa: PLR0913
168213
flush: bool = False,
169214
) -> PutResult:
170215
resolved_session = self._required_sync_session(session=session, operation="put")
216+
self._ensure_model_id_ready_sync(
217+
session=session or resolved_session,
218+
flush=flush,
219+
operation="put",
220+
)
171221
metadata = self._prepare_pending_metadata(
172222
key=key,
173223
content_type=content_type,
@@ -207,6 +257,11 @@ async def put_async( # noqa: PLR0913
207257
flush: bool = False,
208258
) -> PutResult:
209259
resolved_session = self._required_sync_session(session=session, operation="put")
260+
await self._ensure_model_id_ready_async(
261+
session=session or resolved_session,
262+
flush=flush,
263+
operation="put_async",
264+
)
210265
metadata = self._prepare_pending_metadata(
211266
key=key,
212267
content_type=content_type,

0 commit comments

Comments
 (0)