diff --git a/docs/module/models.md b/docs/module/models.md index bf07232..ff17c4b 100644 --- a/docs/module/models.md +++ b/docs/module/models.md @@ -24,7 +24,9 @@ All timestamp columns are timezone-aware (`TIMESTAMPTZ`). All defaults are serve ### [`UUIDMixin`](../reference/models.md#fastapi_toolsets.models.UUIDMixin) -Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_random_uuid()` (requires PostgreSQL 13+). The value is retrieved via `RETURNING` after insert, so it is available on the Python object immediately after `flush()`. +Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_random_uuid()`. The value is retrieved via `RETURNING` after insert, so it is available on the Python object immediately after `flush()`. + +!!! warning "Requires PostgreSQL 13+" ```python from fastapi_toolsets.models import UUIDMixin @@ -36,10 +38,34 @@ class User(Base, UUIDMixin): # id is None before flush user = User(username="alice") +session.add(user) await session.flush() print(user.id) # UUID('...') ``` +### [`UUIDv7Mixin`](../reference/models.md#fastapi_toolsets.models.UUIDv7Mixin) + +!!! info "Added in `v2.3`" + +Adds a `id: UUID` primary key generated server-side by PostgreSQL using `uuidv7()`. It's a time-ordered UUID format that encodes a millisecond-precision timestamp in the most significant bits, making it naturally sortable and index-friendly. + +!!! warning "Requires PostgreSQL 18+" + +```python +from fastapi_toolsets.models import UUIDv7Mixin + +class Event(Base, UUIDv7Mixin): + __tablename__ = "events" + + name: Mapped[str] + +# id is None before flush +event = Event(name="user.signup") +session.add(event) +await session.flush() +print(event.id) # UUID('019...') +``` + ### [`CreatedAtMixin`](../reference/models.md#fastapi_toolsets.models.CreatedAtMixin) Adds a `created_at: datetime` column set to `clock_timestamp()` on insert. The column has no `onupdate` hook — it is intentionally immutable after the row is created. diff --git a/docs/reference/models.md b/docs/reference/models.md index e988392..7e0fb53 100644 --- a/docs/reference/models.md +++ b/docs/reference/models.md @@ -7,6 +7,7 @@ You can import them directly from `fastapi_toolsets.models`: ```python from fastapi_toolsets.models import ( UUIDMixin, + UUIDv7Mixin, CreatedAtMixin, UpdatedAtMixin, TimestampMixin, @@ -15,6 +16,8 @@ from fastapi_toolsets.models import ( ## ::: fastapi_toolsets.models.UUIDMixin +## ::: fastapi_toolsets.models.UUIDv7Mixin + ## ::: fastapi_toolsets.models.CreatedAtMixin ## ::: fastapi_toolsets.models.UpdatedAtMixin diff --git a/src/fastapi_toolsets/models.py b/src/fastapi_toolsets/models.py index 72a8127..9752e28 100644 --- a/src/fastapi_toolsets/models.py +++ b/src/fastapi_toolsets/models.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Mapped, mapped_column __all__ = [ "UUIDMixin", + "UUIDv7Mixin", "CreatedAtMixin", "UpdatedAtMixin", "TimestampMixin", @@ -24,6 +25,16 @@ class UUIDMixin: ) +class UUIDv7Mixin: + """Mixin that adds a UUIDv7 primary key auto-generated by the database.""" + + id: Mapped[uuid.UUID] = mapped_column( + Uuid, + primary_key=True, + server_default=text("uuidv7()"), + ) + + class CreatedAtMixin: """Mixin that adds a ``created_at`` timestamp column.""" diff --git a/tests/test_models.py b/tests/test_models.py index 74fa5ed..953d113 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -11,6 +11,7 @@ from fastapi_toolsets.models import ( CreatedAtMixin, TimestampMixin, UUIDMixin, + UUIDv7Mixin, UpdatedAtMixin, ) @@ -48,6 +49,12 @@ class TimestampModel(MixinBase, TimestampMixin): name: Mapped[str] = mapped_column(String(50)) +class UUIDv7Model(MixinBase, UUIDv7Mixin): + __tablename__ = "mixin_uuidv7_models" + + name: Mapped[str] = mapped_column(String(50)) + + class FullMixinModel(MixinBase, UUIDMixin, UpdatedAtMixin): __tablename__ = "mixin_full_models" @@ -233,6 +240,50 @@ class TestTimestampMixin: assert "updated_at" in col_names +class TestUUIDv7Mixin: + @pytest.mark.anyio + async def test_uuid7_generated_by_db(self, mixin_session): + """UUIDv7 is generated server-side and populated after flush.""" + obj = UUIDv7Model(name="test") + mixin_session.add(obj) + await mixin_session.flush() + + assert obj.id is not None + assert isinstance(obj.id, uuid.UUID) + + @pytest.mark.anyio + async def test_uuid7_is_primary_key(self): + """UUIDv7Mixin adds id as primary key column.""" + pk_cols = [c.name for c in UUIDv7Model.__table__.primary_key] + assert pk_cols == ["id"] + + @pytest.mark.anyio + async def test_each_row_gets_unique_uuid7(self, mixin_session): + """Each inserted row gets a distinct UUIDv7.""" + a = UUIDv7Model(name="a") + b = UUIDv7Model(name="b") + mixin_session.add_all([a, b]) + await mixin_session.flush() + + assert a.id != b.id + + @pytest.mark.anyio + async def test_uuid7_version(self, mixin_session): + """Generated UUIDs have version 7.""" + obj = UUIDv7Model(name="test") + mixin_session.add(obj) + await mixin_session.flush() + + assert obj.id.version == 7 + + @pytest.mark.anyio + async def test_uuid7_server_default_set(self): + """Column has uuidv7() as server default.""" + col = UUIDv7Model.__table__.c["id"] + assert col.server_default is not None + assert "uuidv7" in str(col.server_default.arg) + + class TestFullMixinModel: @pytest.mark.anyio async def test_combined_mixins_work_together(self, mixin_session):