Skip to content

Commit 8d4b59a

Browse files
authored
feat: Add identity Mixin for Primary Keys (#473)
The sequences based BigInt key offers the most compatibility, but many would prefer to use the Identity column when the database supports it. This changes implements a basic Identity primary key mixin
1 parent e626875 commit 8d4b59a

File tree

7 files changed

+242
-23
lines changed

7 files changed

+242
-23
lines changed

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ default_language_version:
22
python: "3"
33
repos:
44
- repo: https://github.com/compilerla/conventional-pre-commit
5-
rev: v4.1.0
5+
rev: v4.2.0
66
hooks:
77
- id: conventional-pre-commit
88
stages: [commit-msg]

advanced_alchemy/base.py

+39
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from advanced_alchemy.mixins import (
2828
AuditColumns,
2929
BigIntPrimaryKey,
30+
IdentityPrimaryKey,
3031
NanoIDPrimaryKey,
3132
UUIDPrimaryKey,
3233
UUIDv6PrimaryKey,
@@ -49,6 +50,9 @@
4950
"BigIntBase",
5051
"BigIntBaseT",
5152
"CommonTableAttributes",
53+
"IdentityAuditBase",
54+
"IdentityBase",
55+
"IdentityBaseT",
5256
"ModelProtocol",
5357
"NanoIDAuditBase",
5458
"NanoIDBase",
@@ -77,6 +81,8 @@
7781
"""Type variable for :class:`UUIDBase`."""
7882
BigIntBaseT = TypeVar("BigIntBaseT", bound="BigIntBase")
7983
"""Type variable for :class:`BigIntBase`."""
84+
IdentityBaseT = TypeVar("IdentityBaseT", bound="IdentityBase")
85+
"""Type variable for :class:`IdentityBase`."""
8086
UUIDv6BaseT = TypeVar("UUIDv6BaseT", bound="UUIDv6Base")
8187
"""Type variable for :class:`UUIDv6Base`."""
8288
UUIDv7BaseT = TypeVar("UUIDv7BaseT", bound="UUIDv7Base")
@@ -475,6 +481,39 @@ class BigIntAuditBase(CommonTableAttributes, BigIntPrimaryKey, AuditColumns, Adv
475481
__abstract__ = True
476482

477483

484+
class IdentityBase(IdentityPrimaryKey, CommonTableAttributes, AdvancedDeclarativeBase, AsyncAttrs):
485+
"""Base for all SQLAlchemy declarative models with database IDENTITY primary keys.
486+
487+
This model uses the database native IDENTITY feature for generating primary keys
488+
instead of using database sequences.
489+
490+
.. seealso::
491+
:class:`advanced_alchemy.mixins.IdentityPrimaryKey`
492+
:class:`CommonTableAttributes`
493+
:class:`AdvancedDeclarativeBase`
494+
:class:`AsyncAttrs`
495+
"""
496+
497+
__abstract__ = True
498+
499+
500+
class IdentityAuditBase(CommonTableAttributes, IdentityPrimaryKey, AuditColumns, AdvancedDeclarativeBase, AsyncAttrs):
501+
"""Base for declarative models with database IDENTITY primary keys and audit columns.
502+
503+
This model uses the database native IDENTITY feature for generating primary keys
504+
instead of using database sequences.
505+
506+
.. seealso::
507+
:class:`CommonTableAttributes`
508+
:class:`advanced_alchemy.mixins.IdentityPrimaryKey`
509+
:class:`advanced_alchemy.mixins.AuditColumns`
510+
:class:`AdvancedDeclarativeBase`
511+
:class:`AsyncAttrs`
512+
"""
513+
514+
__abstract__ = True
515+
516+
478517
class DefaultBase(CommonTableAttributes, AdvancedDeclarativeBase, AsyncAttrs):
479518
"""Base for all SQLAlchemy declarative models. No primary key is added.
480519

advanced_alchemy/mixins/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from advanced_alchemy.mixins.audit import AuditColumns
2-
from advanced_alchemy.mixins.bigint import BigIntPrimaryKey
2+
from advanced_alchemy.mixins.bigint import BigIntPrimaryKey, IdentityPrimaryKey
33
from advanced_alchemy.mixins.nanoid import NanoIDPrimaryKey
44
from advanced_alchemy.mixins.sentinel import SentinelMixin
55
from advanced_alchemy.mixins.slug import SlugKey
@@ -9,6 +9,7 @@
99
__all__ = (
1010
"AuditColumns",
1111
"BigIntPrimaryKey",
12+
"IdentityPrimaryKey",
1213
"NanoIDPrimaryKey",
1314
"SentinelMixin",
1415
"SlugKey",

advanced_alchemy/mixins/bigint.py

+18
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,21 @@ def id(cls) -> Mapped[int]:
3636
Sequence(f"{cls.__tablename__}_id_seq", **seq_kwargs), # type: ignore[attr-defined]
3737
primary_key=True,
3838
)
39+
40+
41+
@declarative_mixin
42+
class IdentityPrimaryKey:
43+
"""Primary Key Field Mixin using database IDENTITY feature.
44+
45+
This mixin uses the database's native IDENTITY feature rather than a sequence.
46+
This can be more efficient for databases that support IDENTITY natively.
47+
"""
48+
49+
@declared_attr
50+
def id(cls) -> Mapped[int]:
51+
"""Primary key column using IDENTITY."""
52+
return mapped_column(
53+
BigIntIdentity,
54+
primary_key=True,
55+
autoincrement=True,
56+
)

docs/usage/modeling.rst

+8
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ Advanced Alchemy provides several base classes optimized for different use cases
2020
- BIGINT primary keys for tables
2121
* - ``BigIntAuditBase``
2222
- BIGINT primary keys for tables, Automatic created_at/updated_at timestamps
23+
* - ``IdentityBase``
24+
- Primary keys using database IDENTITY feature instead of sequences
25+
* - ``IdentityAuditBase``
26+
- Primary keys using database IDENTITY feature, Automatic created_at/updated_at timestamps
2327
* - ``UUIDBase``
2428
- UUID primary keys
2529
* - ``UUIDv6Base``
@@ -53,6 +57,10 @@ Additionally, Advanced Alchemy provides mixins to enhance model functionality:
5357
* - ``AuditColumns``
5458
- | Automatic created_at/updated_at timestamps
5559
| Tracks record modifications
60+
* - ``BigIntPrimaryKey``
61+
- | Adds BigInt primary key with sequence
62+
* - ``IdentityPrimaryKey``
63+
- | Adds primary key using database IDENTITY feature
5664
* - ``UniqueMixin``
5765
- | Automatic Select or Create for many-to-many relationships
5866

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from typing import TYPE_CHECKING
5+
6+
import pytest
7+
from sqlalchemy import Column, ForeignKey, String, Table, create_engine, select
8+
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
9+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
10+
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship, sessionmaker
11+
12+
if TYPE_CHECKING:
13+
from pytest import MonkeyPatch
14+
15+
16+
@pytest.mark.xdist_group("loader")
17+
def test_ap_sync(monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
18+
from sqlalchemy.orm import DeclarativeBase
19+
20+
from advanced_alchemy import base, mixins
21+
22+
orm_registry = base.create_registry()
23+
24+
class NewIdentityBase(mixins.IdentityPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
25+
registry = orm_registry
26+
27+
monkeypatch.setattr(base, "IdentityBase", NewIdentityBase)
28+
29+
product_tag_table = Table(
30+
"product_tag",
31+
orm_registry.metadata,
32+
Column("product_id", ForeignKey("product.id", ondelete="CASCADE"), primary_key=True), # pyright: ignore[reportUnknownArgumentType]
33+
Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True), # pyright: ignore[reportUnknownArgumentType]
34+
)
35+
36+
class Tag(NewIdentityBase):
37+
name: Mapped[str] = mapped_column(index=True)
38+
products: Mapped[list[Product]] = relationship(
39+
secondary=lambda: product_tag_table,
40+
back_populates="product_tags",
41+
cascade="all, delete",
42+
passive_deletes=True,
43+
lazy="noload",
44+
)
45+
46+
class Product(NewIdentityBase):
47+
name: Mapped[str] = mapped_column(String(length=50)) # pyright: ignore
48+
product_tags: Mapped[list[Tag]] = relationship(
49+
secondary=lambda: product_tag_table,
50+
back_populates="products",
51+
cascade="all, delete",
52+
passive_deletes=True,
53+
lazy="joined",
54+
)
55+
tags: AssociationProxy[list[str]] = association_proxy(
56+
"product_tags",
57+
"name",
58+
creator=lambda name: Tag(name=name), # pyright: ignore[reportUnknownArgumentType,reportUnknownLambdaType]
59+
)
60+
61+
engine = create_engine(f"sqlite:///{tmp_path}/test.sqlite1.db", echo=True)
62+
session_factory: sessionmaker[Session] = sessionmaker(engine, expire_on_commit=False)
63+
64+
with engine.begin() as conn:
65+
Product.metadata.create_all(conn)
66+
67+
with session_factory() as db_session:
68+
product_1 = Product(name="Product 1", tags=["a new tag", "second tag"])
69+
db_session.add(product_1)
70+
71+
tags = db_session.execute(select(Tag)).unique().fetchall()
72+
assert len(tags) == 2
73+
74+
product_2 = Product(name="Product 2", tags=["third tag"])
75+
db_session.add(product_2)
76+
tags = db_session.execute(select(Tag)).unique().fetchall()
77+
assert len(tags) == 3
78+
79+
product_2.tags = []
80+
db_session.add(product_2)
81+
82+
product_2_validate = db_session.execute(select(Product).where(Product.name == "Product 2")).unique().fetchone()
83+
assert product_2_validate
84+
tags_2 = db_session.execute(select(Tag)).unique().fetchall()
85+
assert len(product_2_validate[0].product_tags) == 0
86+
assert len(tags_2) == 3
87+
# add more assertions
88+
89+
90+
@pytest.mark.xdist_group("loader")
91+
async def test_ap_async(monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
92+
from sqlalchemy.orm import DeclarativeBase
93+
94+
from advanced_alchemy import base, mixins
95+
96+
orm_registry = base.create_registry()
97+
98+
class NewIdentityBase(mixins.IdentityPrimaryKey, base.CommonTableAttributes, DeclarativeBase):
99+
registry = orm_registry
100+
101+
monkeypatch.setattr(base, "IdentityBase", NewIdentityBase)
102+
103+
product_tag_table = Table(
104+
"product_tag",
105+
orm_registry.metadata,
106+
Column("product_id", ForeignKey("product.id", ondelete="CASCADE"), primary_key=True), # pyright: ignore[reportUnknownArgumentType]
107+
Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True), # pyright: ignore[reportUnknownArgumentType]
108+
)
109+
110+
class Tag(NewIdentityBase):
111+
name: Mapped[str] = mapped_column(index=True)
112+
products: Mapped[list[Product]] = relationship(
113+
secondary=lambda: product_tag_table,
114+
back_populates="product_tags",
115+
cascade="all, delete",
116+
passive_deletes=True,
117+
lazy="noload",
118+
)
119+
120+
class Product(NewIdentityBase):
121+
name: Mapped[str] = mapped_column(String(length=50)) # pyright: ignore
122+
product_tags: Mapped[list[Tag]] = relationship(
123+
secondary=lambda: product_tag_table,
124+
back_populates="products",
125+
cascade="all, delete",
126+
passive_deletes=True,
127+
lazy="joined",
128+
)
129+
tags: AssociationProxy[list[str]] = association_proxy(
130+
"product_tags",
131+
"name",
132+
creator=lambda name: Tag(name=name), # pyright: ignore[reportUnknownArgumentType,reportUnknownLambdaType]
133+
)
134+
135+
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/test.sqlite2.db", echo=True)
136+
session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(engine, expire_on_commit=False)
137+
138+
async with engine.begin() as conn:
139+
await conn.run_sync(Tag.metadata.create_all)
140+
141+
async with session_factory() as db_session:
142+
product_1 = Product(name="Product 1 async", tags=["a new tag", "second tag"])
143+
db_session.add(product_1)
144+
145+
tags = await db_session.execute(select(Tag))
146+
assert len(tags.unique().fetchall()) == 2
147+
148+
product_2 = Product(name="Product 2 async", tags=["third tag"])
149+
db_session.add(product_2)
150+
tags = await db_session.execute(select(Tag))
151+
assert len(tags.unique().fetchall()) == 3
152+
153+
# add more assertions

0 commit comments

Comments
 (0)