belgie-alchemy 0.1.0a4__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.
@@ -0,0 +1,90 @@
1
+ from datetime import UTC, datetime
2
+ from pathlib import Path
3
+ from tempfile import TemporaryDirectory
4
+
5
+ import pytest
6
+ from sqlalchemy import Integer, event, select
7
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
8
+ from sqlalchemy.orm import Mapped, mapped_column
9
+
10
+ from belgie_alchemy.__tests__.fixtures.models import User
11
+ from belgie_alchemy.base import NAMING_CONVENTION, Base
12
+ from belgie_alchemy.types import DateTimeUTC
13
+
14
+
15
+ def test_type_annotation_map_uses_datetimeutc() -> None:
16
+ mapping = Base.type_annotation_map
17
+ assert mapping[datetime] is DateTimeUTC
18
+
19
+
20
+ def test_datetime_annotation_auto_uses_datetimeutc() -> None:
21
+ """Verify that Mapped[datetime] automatically uses DateTimeUTC without explicit column type."""
22
+
23
+ class TestModel(Base):
24
+ __tablename__ = "test_auto_datetime"
25
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, init=False)
26
+ # No explicit DateTimeUTC type specified - should be inferred from type_annotation_map
27
+ timestamp: Mapped[datetime]
28
+
29
+ timestamp_column = TestModel.__table__.c.timestamp # type: ignore[attr-defined]
30
+ assert isinstance(timestamp_column.type, DateTimeUTC)
31
+
32
+
33
+ def test_naming_convention_applied() -> None:
34
+ assert Base.metadata.naming_convention == NAMING_CONVENTION
35
+
36
+
37
+ def test_dataclass_kw_only_init() -> None:
38
+ user = User(email="a@b.com")
39
+ assert user.email == "a@b.com"
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_file_based_sqlite_database() -> None:
44
+ """Test that models work correctly with file-based SQLite database."""
45
+ with TemporaryDirectory() as tmpdir:
46
+ db_path = Path(tmpdir) / "test.db"
47
+ db_url = f"sqlite+aiosqlite:///{db_path}"
48
+
49
+ # Create engine with file-based database
50
+ engine = create_async_engine(db_url, echo=False)
51
+
52
+ # Enable foreign keys for SQLite
53
+ @event.listens_for(engine.sync_engine, "connect")
54
+ def _enable_fk(dbapi_conn, _connection_record) -> None:
55
+ cursor = dbapi_conn.cursor()
56
+ cursor.execute("PRAGMA foreign_keys=ON")
57
+ cursor.close()
58
+
59
+ # Create tables
60
+ async with engine.begin() as conn:
61
+ await conn.run_sync(Base.metadata.create_all)
62
+
63
+ # Create session factory
64
+ session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
65
+
66
+ # Test basic operations
67
+ async with session_factory() as session:
68
+ # Create user
69
+ user = User(email="file_db_test@example.com", name="Test User")
70
+ session.add(user)
71
+ await session.commit()
72
+
73
+ user_id = user.id
74
+
75
+ # Verify persistence by reading in new session
76
+ async with session_factory() as session:
77
+ result = await session.execute(select(User).where(User.id == user_id))
78
+ retrieved_user = result.scalar_one()
79
+
80
+ assert retrieved_user.email == "file_db_test@example.com"
81
+ assert retrieved_user.name == "Test User"
82
+ assert retrieved_user.created_at is not None
83
+ assert retrieved_user.created_at.tzinfo is UTC
84
+
85
+ # Cleanup
86
+ await engine.dispose()
87
+
88
+ # Verify database file was created
89
+ assert db_path.exists()
90
+ assert db_path.stat().st_size > 0
@@ -0,0 +1,39 @@
1
+ """Pytest fixtures for alchemy tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import pytest_asyncio
8
+
9
+ from belgie_alchemy.__tests__.fixtures.database import get_test_engine, get_test_session_factory
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import AsyncGenerator
13
+
14
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
15
+
16
+
17
+ @pytest_asyncio.fixture
18
+ async def alchemy_engine() -> AsyncGenerator[AsyncEngine, None]:
19
+ """Create an isolated in-memory SQLite engine for testing.
20
+
21
+ Each test gets its own in-memory database, so Base.metadata.create_all
22
+ is safe even when tests run in parallel - there's no shared state.
23
+ """
24
+ engine = await get_test_engine()
25
+ yield engine
26
+ await engine.dispose()
27
+
28
+
29
+ @pytest_asyncio.fixture
30
+ async def alchemy_session_factory(alchemy_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
31
+ return await get_test_session_factory(alchemy_engine)
32
+
33
+
34
+ @pytest_asyncio.fixture
35
+ async def alchemy_session(
36
+ alchemy_session_factory: async_sessionmaker[AsyncSession],
37
+ ) -> AsyncGenerator[AsyncSession, None]:
38
+ async with alchemy_session_factory() as session:
39
+ yield session
@@ -0,0 +1,12 @@
1
+ from belgie_alchemy.__tests__.fixtures.database import get_test_db, get_test_engine, get_test_session_factory
2
+ from belgie_alchemy.__tests__.fixtures.models import Account, OAuthState, Session, User
3
+
4
+ __all__ = [
5
+ "Account",
6
+ "OAuthState",
7
+ "Session",
8
+ "User",
9
+ "get_test_db",
10
+ "get_test_engine",
11
+ "get_test_session_factory",
12
+ ]
@@ -0,0 +1,38 @@
1
+ from collections.abc import AsyncGenerator
2
+
3
+ from sqlalchemy import event
4
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
5
+
6
+ from belgie_alchemy import Base
7
+
8
+ TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
9
+
10
+
11
+ async def get_test_engine() -> AsyncEngine:
12
+ engine = create_async_engine(
13
+ TEST_DATABASE_URL,
14
+ echo=False,
15
+ )
16
+
17
+ @event.listens_for(engine.sync_engine, "connect")
18
+ def _enable_foreign_keys(dbapi_conn, _connection_record) -> None:
19
+ cursor = dbapi_conn.cursor()
20
+ cursor.execute("PRAGMA foreign_keys=ON")
21
+ cursor.close()
22
+
23
+ async with engine.begin() as conn:
24
+ await conn.run_sync(Base.metadata.create_all)
25
+ return engine
26
+
27
+
28
+ async def get_test_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
29
+ return async_sessionmaker(
30
+ engine,
31
+ class_=AsyncSession,
32
+ expire_on_commit=False,
33
+ )
34
+
35
+
36
+ async def get_test_db(session_factory: async_sessionmaker[AsyncSession]) -> AsyncGenerator[AsyncSession, None]:
37
+ async with session_factory() as session:
38
+ yield session
@@ -0,0 +1,119 @@
1
+ """Test models for alchemy tests.
2
+
3
+ Defines concrete auth models for testing. These mirror the examples in
4
+ examples/alchemy/auth_models.py and demonstrate how users would define
5
+ their own models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime # noqa: TC003
11
+ from uuid import UUID # noqa: TC003
12
+
13
+ from sqlalchemy import JSON, ForeignKey, Text, UniqueConstraint
14
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
15
+
16
+ from belgie_alchemy import Base, DateTimeUTC, PrimaryKeyMixin, TimestampMixin
17
+
18
+
19
+ class User(Base, PrimaryKeyMixin, TimestampMixin):
20
+ """Test User model."""
21
+
22
+ __tablename__ = "users"
23
+
24
+ email: Mapped[str] = mapped_column(unique=True, index=True)
25
+ email_verified: Mapped[bool] = mapped_column(default=False)
26
+ name: Mapped[str | None] = mapped_column(default=None)
27
+ image: Mapped[str | None] = mapped_column(default=None)
28
+ scopes: Mapped[list[str] | None] = mapped_column(JSON, default=None)
29
+ custom_field: Mapped[str | None] = mapped_column(default=None)
30
+
31
+ accounts: Mapped[list[Account]] = relationship(
32
+ back_populates="user",
33
+ cascade="all, delete-orphan",
34
+ init=False,
35
+ )
36
+ sessions: Mapped[list[Session]] = relationship(
37
+ back_populates="user",
38
+ cascade="all, delete-orphan",
39
+ init=False,
40
+ )
41
+ oauth_states: Mapped[list[OAuthState]] = relationship(
42
+ back_populates="user",
43
+ cascade="all, delete-orphan",
44
+ init=False,
45
+ )
46
+
47
+
48
+ class Account(Base, PrimaryKeyMixin, TimestampMixin):
49
+ """Test Account model."""
50
+
51
+ __tablename__ = "accounts"
52
+
53
+ user_id: Mapped[UUID] = mapped_column(
54
+ ForeignKey("users.id", ondelete="cascade", onupdate="cascade"),
55
+ nullable=False,
56
+ )
57
+ provider: Mapped[str] = mapped_column(Text)
58
+ provider_account_id: Mapped[str] = mapped_column(Text)
59
+ access_token: Mapped[str | None] = mapped_column(default=None)
60
+ refresh_token: Mapped[str | None] = mapped_column(default=None)
61
+ expires_at: Mapped[datetime | None] = mapped_column(DateTimeUTC, default=None)
62
+ token_type: Mapped[str | None] = mapped_column(default=None)
63
+ scope: Mapped[str | None] = mapped_column(default=None)
64
+ id_token: Mapped[str | None] = mapped_column(default=None)
65
+
66
+ user: Mapped[User] = relationship(
67
+ back_populates="accounts",
68
+ lazy="selectin",
69
+ init=False,
70
+ )
71
+
72
+ __table_args__ = (
73
+ UniqueConstraint(
74
+ "provider",
75
+ "provider_account_id",
76
+ name="uq_accounts_provider_provider_account_id",
77
+ ),
78
+ )
79
+
80
+
81
+ class Session(Base, PrimaryKeyMixin, TimestampMixin):
82
+ """Test Session model."""
83
+
84
+ __tablename__ = "sessions"
85
+
86
+ user_id: Mapped[UUID] = mapped_column(
87
+ ForeignKey("users.id", ondelete="cascade", onupdate="cascade"),
88
+ nullable=False,
89
+ )
90
+ expires_at: Mapped[datetime] = mapped_column(DateTimeUTC)
91
+ ip_address: Mapped[str | None] = mapped_column(default=None)
92
+ user_agent: Mapped[str | None] = mapped_column(default=None)
93
+
94
+ user: Mapped[User] = relationship(
95
+ back_populates="sessions",
96
+ lazy="selectin",
97
+ init=False,
98
+ )
99
+
100
+
101
+ class OAuthState(Base, PrimaryKeyMixin, TimestampMixin):
102
+ """Test OAuthState model."""
103
+
104
+ __tablename__ = "oauth_states"
105
+
106
+ state: Mapped[str] = mapped_column(unique=True, index=True)
107
+ user_id: Mapped[UUID | None] = mapped_column(
108
+ ForeignKey("users.id", ondelete="set null", onupdate="cascade"),
109
+ nullable=True,
110
+ )
111
+ expires_at: Mapped[datetime] = mapped_column(DateTimeUTC)
112
+ code_verifier: Mapped[str | None] = mapped_column(default=None)
113
+ redirect_url: Mapped[str | None] = mapped_column(default=None)
114
+
115
+ user: Mapped[User] | None = relationship(
116
+ back_populates="oauth_states",
117
+ lazy="selectin",
118
+ init=False,
119
+ )
File without changes
@@ -0,0 +1,80 @@
1
+ from uuid import UUID
2
+
3
+ import pytest
4
+ from sqlalchemy.exc import IntegrityError
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from belgie_alchemy.__tests__.fixtures.models import User
8
+
9
+
10
+ def test_primary_key_mixin_defaults() -> None:
11
+ id_column = User.__table__.c.id # type: ignore[attr-defined]
12
+ assert id_column.primary_key
13
+ assert str(id_column.server_default.arg) == "gen_random_uuid()"
14
+ assert id_column.index
15
+
16
+
17
+ def test_primary_key_client_side_generation() -> None:
18
+ """Test that UUID is generated client-side via default_factory."""
19
+ user1 = User(email="user1@example.com")
20
+ user2 = User(email="user2@example.com")
21
+
22
+ # UUIDs should be generated automatically
23
+ assert isinstance(user1.id, UUID)
24
+ assert isinstance(user2.id, UUID)
25
+
26
+ # UUIDs should be unique
27
+ assert user1.id != user2.id
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_primary_key_persists_client_generated_uuid(alchemy_session: AsyncSession) -> None:
32
+ """Test that client-generated UUIDs are persisted correctly."""
33
+ user = User(email="persist@example.com")
34
+ original_id = user.id
35
+
36
+ alchemy_session.add(user)
37
+ await alchemy_session.commit()
38
+
39
+ # Refresh from database
40
+ await alchemy_session.refresh(user)
41
+
42
+ # UUID should be unchanged
43
+ assert user.id == original_id
44
+ assert isinstance(user.id, UUID)
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_primary_key_unique_constraint(alchemy_session: AsyncSession) -> None:
49
+ """Test that duplicate UUIDs are rejected by unique constraint."""
50
+ user1 = User(email="user1@example.com")
51
+ alchemy_session.add(user1)
52
+ await alchemy_session.commit()
53
+
54
+ # Save the ID and expunge user1 to avoid identity conflicts
55
+ user1_id = user1.id
56
+ alchemy_session.expunge(user1)
57
+
58
+ # Try to create another user with the same ID
59
+ user2 = User(email="user2@example.com")
60
+ user2.id = user1_id # Force same ID
61
+
62
+ alchemy_session.add(user2)
63
+ with pytest.raises(IntegrityError):
64
+ await alchemy_session.commit()
65
+
66
+ await alchemy_session.rollback()
67
+
68
+
69
+ def test_timestamp_mixin_defaults() -> None:
70
+ user = User(email="defaults@example.com")
71
+ assert user.created_at is not None
72
+ assert user.updated_at is not None
73
+ assert user.deleted_at is None
74
+
75
+
76
+ def test_mark_deleted_sets_timestamp() -> None:
77
+ user = User(email="x@example.com")
78
+ assert user.deleted_at is None
79
+ user.mark_deleted()
80
+ assert user.deleted_at is not None
File without changes