belgie-alchemy 0.1.0a4__py3-none-any.whl → 0.3.0__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.
@@ -1,416 +0,0 @@
1
- """Integration tests for DatabaseSettings with environment variables.
2
-
3
- This module tests that DatabaseSettings can be correctly loaded from environment
4
- variables using Pydantic's settings mechanism with separate prefixes.
5
-
6
- Environment variable format (NO double underscores!):
7
- - Type selector: BELGIE_DATABASE_TYPE=postgres or sqlite
8
- - SQLite vars: BELGIE_SQLITE_DATABASE, BELGIE_SQLITE_ENABLE_FOREIGN_KEYS, etc.
9
- - Postgres vars: BELGIE_POSTGRES_HOST, BELGIE_POSTGRES_PORT, BELGIE_POSTGRES_DATABASE, etc.
10
-
11
- Examples:
12
- # SQLite
13
- BELGIE_DATABASE_TYPE=sqlite
14
- BELGIE_SQLITE_DATABASE=:memory:
15
- BELGIE_SQLITE_ENABLE_FOREIGN_KEYS=true
16
-
17
- # PostgreSQL
18
- BELGIE_DATABASE_TYPE=postgres
19
- BELGIE_POSTGRES_HOST=localhost
20
- BELGIE_POSTGRES_PORT=5432
21
- BELGIE_POSTGRES_DATABASE=mydb
22
- BELGIE_POSTGRES_USERNAME=user
23
- BELGIE_POSTGRES_PASSWORD=pass
24
- """
25
-
26
- import os
27
- from importlib.util import find_spec
28
- from urllib.parse import urlparse
29
-
30
- import pytest
31
- from pydantic import ValidationError
32
- from sqlalchemy import text
33
- from sqlalchemy.ext.asyncio import AsyncSession
34
-
35
- from belgie_alchemy.settings import DatabaseSettings
36
-
37
- ASYNC_PG_AVAILABLE = find_spec("asyncpg") is not None
38
-
39
-
40
- # ==================== SQLite Tests ====================
41
-
42
-
43
- @pytest.mark.integration
44
- def test_sqlite_from_env_minimal(monkeypatch: pytest.MonkeyPatch) -> None:
45
- """Test loading minimal SQLite configuration from environment variables."""
46
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
47
- monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
48
-
49
- db = DatabaseSettings.from_env()
50
-
51
- assert db.dialect.type == "sqlite"
52
- assert db.dialect.database == ":memory:"
53
- assert db.dialect.enable_foreign_keys is True # Default value
54
- assert db.dialect.echo is False # Default value
55
-
56
-
57
- @pytest.mark.integration
58
- def test_sqlite_from_env_full(monkeypatch: pytest.MonkeyPatch) -> None:
59
- """Test loading full SQLite configuration from environment variables."""
60
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
61
- monkeypatch.setenv("BELGIE_SQLITE_DATABASE", "/tmp/test.db") # noqa: S108
62
- monkeypatch.setenv("BELGIE_SQLITE_ENABLE_FOREIGN_KEYS", "false")
63
- monkeypatch.setenv("BELGIE_SQLITE_ECHO", "true")
64
-
65
- db = DatabaseSettings.from_env()
66
-
67
- assert db.dialect.type == "sqlite"
68
- assert db.dialect.database == "/tmp/test.db" # noqa: S108
69
- assert db.dialect.enable_foreign_keys is False
70
- assert db.dialect.echo is True
71
-
72
-
73
- @pytest.mark.asyncio
74
- @pytest.mark.integration
75
- async def test_sqlite_from_env_creates_working_engine(monkeypatch: pytest.MonkeyPatch) -> None:
76
- """Test that SQLite engine created from env vars works correctly."""
77
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
78
- monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
79
-
80
- db = DatabaseSettings.from_env()
81
-
82
- # Test engine works
83
- async with db.engine.connect() as conn:
84
- result = await conn.execute(text("SELECT 1 as test"))
85
- value = result.scalar_one()
86
- assert value == 1
87
-
88
- await db.engine.dispose()
89
-
90
-
91
- @pytest.mark.asyncio
92
- @pytest.mark.integration
93
- async def test_sqlite_from_env_foreign_keys_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
94
- """Test that foreign key enforcement from env vars works."""
95
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
96
- monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
97
- monkeypatch.setenv("BELGIE_SQLITE_ENABLE_FOREIGN_KEYS", "true")
98
-
99
- db = DatabaseSettings.from_env()
100
-
101
- async with db.engine.connect() as conn:
102
- result = await conn.execute(text("PRAGMA foreign_keys"))
103
- value = result.scalar_one()
104
- assert value == 1
105
-
106
- await db.engine.dispose()
107
-
108
-
109
- @pytest.mark.integration
110
- def test_sqlite_defaults_when_type_not_set(monkeypatch: pytest.MonkeyPatch) -> None:
111
- """Test that SQLite is used when BELGIE_DATABASE_TYPE is not set."""
112
- monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
113
-
114
- db = DatabaseSettings.from_env()
115
-
116
- assert db.dialect.type == "sqlite"
117
- assert db.dialect.database == ":memory:"
118
-
119
-
120
- # ==================== PostgreSQL Tests ====================
121
-
122
-
123
- @pytest.mark.integration
124
- def test_postgres_from_env_minimal(monkeypatch: pytest.MonkeyPatch) -> None:
125
- """Test loading minimal PostgreSQL configuration from environment variables."""
126
- if not ASYNC_PG_AVAILABLE:
127
- pytest.skip("asyncpg not installed")
128
-
129
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
130
- monkeypatch.setenv("BELGIE_POSTGRES_HOST", "localhost")
131
- monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", "testdb")
132
- monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", "testuser")
133
- monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", "testpass")
134
-
135
- db = DatabaseSettings.from_env()
136
-
137
- assert db.dialect.type == "postgres"
138
- assert db.dialect.host == "localhost"
139
- assert db.dialect.port == 5432 # Default value
140
- assert db.dialect.database == "testdb"
141
- assert db.dialect.username == "testuser"
142
- assert db.dialect.password.get_secret_value() == "testpass"
143
- assert db.dialect.pool_size == 5 # Default value
144
- assert db.dialect.max_overflow == 10 # Default value
145
-
146
-
147
- @pytest.mark.integration
148
- def test_postgres_from_env_full(monkeypatch: pytest.MonkeyPatch) -> None:
149
- """Test loading full PostgreSQL configuration from environment variables."""
150
- if not ASYNC_PG_AVAILABLE:
151
- pytest.skip("asyncpg not installed")
152
-
153
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
154
- monkeypatch.setenv("BELGIE_POSTGRES_HOST", "db.example.com")
155
- monkeypatch.setenv("BELGIE_POSTGRES_PORT", "5433")
156
- monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", "mydb")
157
- monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", "admin")
158
- monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", "secret123")
159
- monkeypatch.setenv("BELGIE_POSTGRES_POOL_SIZE", "20")
160
- monkeypatch.setenv("BELGIE_POSTGRES_MAX_OVERFLOW", "30")
161
- monkeypatch.setenv("BELGIE_POSTGRES_POOL_TIMEOUT", "60.0")
162
- monkeypatch.setenv("BELGIE_POSTGRES_POOL_RECYCLE", "7200")
163
- monkeypatch.setenv("BELGIE_POSTGRES_POOL_PRE_PING", "false")
164
- monkeypatch.setenv("BELGIE_POSTGRES_ECHO", "true")
165
-
166
- db = DatabaseSettings.from_env()
167
-
168
- assert db.dialect.type == "postgres"
169
- assert db.dialect.host == "db.example.com"
170
- assert db.dialect.port == 5433
171
- assert db.dialect.database == "mydb"
172
- assert db.dialect.username == "admin"
173
- assert db.dialect.password.get_secret_value() == "secret123"
174
- assert db.dialect.pool_size == 20
175
- assert db.dialect.max_overflow == 30
176
- assert db.dialect.pool_timeout == 60.0
177
- assert db.dialect.pool_recycle == 7200
178
- assert db.dialect.pool_pre_ping is False
179
- assert db.dialect.echo is True
180
-
181
-
182
- @pytest.mark.asyncio
183
- @pytest.mark.integration
184
- async def test_postgres_from_env_actual_connection(monkeypatch: pytest.MonkeyPatch) -> None:
185
- """Test that PostgreSQL connection from env vars works with real database.
186
-
187
- Requires POSTGRES_TEST_URL environment variable to be set.
188
- Format: postgresql://user:pass@host:port/database
189
- """
190
- if not ASYNC_PG_AVAILABLE:
191
- pytest.skip("asyncpg not installed")
192
-
193
- # Check if real PostgreSQL is available
194
- test_url = os.getenv("POSTGRES_TEST_URL")
195
- if not test_url:
196
- pytest.skip("POSTGRES_TEST_URL not set - skipping live connection test")
197
-
198
- # Parse the test URL to set up environment variables
199
- parsed = urlparse(test_url)
200
-
201
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
202
- monkeypatch.setenv("BELGIE_POSTGRES_HOST", parsed.hostname or "localhost")
203
- monkeypatch.setenv("BELGIE_POSTGRES_PORT", str(parsed.port or 5432))
204
- monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", parsed.path.lstrip("/") if parsed.path else "postgres")
205
- monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", parsed.username or "postgres")
206
- monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", parsed.password or "")
207
-
208
- try:
209
- db = DatabaseSettings.from_env()
210
-
211
- # Test basic connection
212
- async with db.engine.connect() as conn:
213
- result = await conn.execute(text("SELECT 1 as test"))
214
- value = result.scalar_one()
215
- assert value == 1
216
-
217
- # Test session creation
218
- async with db.session_maker() as session:
219
- assert isinstance(session, AsyncSession)
220
- result = await session.execute(text("SELECT version()"))
221
- version = result.scalar_one()
222
- assert "PostgreSQL" in version
223
-
224
- await db.engine.dispose()
225
-
226
- except OSError as e:
227
- pytest.skip(f"Could not connect to PostgreSQL: {e}")
228
-
229
-
230
- # ==================== Validation & Edge Cases ====================
231
-
232
-
233
- @pytest.mark.integration
234
- def test_missing_required_postgres_fields(monkeypatch: pytest.MonkeyPatch) -> None:
235
- """Test that missing required PostgreSQL fields raise validation error."""
236
- if not ASYNC_PG_AVAILABLE:
237
- pytest.skip("asyncpg not installed")
238
-
239
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
240
- monkeypatch.setenv("BELGIE_POSTGRES_HOST", "localhost")
241
- # Missing database, username, password
242
-
243
- with pytest.raises(ValidationError):
244
- DatabaseSettings.from_env()
245
-
246
-
247
- @pytest.mark.integration
248
- def test_invalid_port_number(monkeypatch: pytest.MonkeyPatch) -> None:
249
- """Test that invalid port number raises validation error."""
250
- if not ASYNC_PG_AVAILABLE:
251
- pytest.skip("asyncpg not installed")
252
-
253
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
254
- monkeypatch.setenv("BELGIE_POSTGRES_HOST", "localhost")
255
- monkeypatch.setenv("BELGIE_POSTGRES_PORT", "-1") # Invalid
256
- monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", "testdb")
257
- monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", "user")
258
- monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", "pass")
259
-
260
- with pytest.raises(ValidationError, match="Input should be greater than 0"):
261
- DatabaseSettings.from_env()
262
-
263
-
264
- @pytest.mark.integration
265
- def test_case_insensitive_boolean_values(monkeypatch: pytest.MonkeyPatch) -> None:
266
- """Test that boolean environment variables accept various formats."""
267
- test_cases = [
268
- ("true", True),
269
- ("True", True),
270
- ("TRUE", True),
271
- ("1", True),
272
- ("false", False),
273
- ("False", False),
274
- ("FALSE", False),
275
- ("0", False),
276
- ]
277
-
278
- for env_value, expected in test_cases:
279
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
280
- monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
281
- monkeypatch.setenv("BELGIE_SQLITE_ECHO", env_value)
282
-
283
- db = DatabaseSettings.from_env()
284
- assert db.dialect.echo is expected, f"Expected {expected} for env value '{env_value}'"
285
-
286
-
287
- @pytest.mark.asyncio
288
- @pytest.mark.integration
289
- async def test_session_maker_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
290
- """Test that session_maker created from env vars works correctly."""
291
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
292
- monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
293
-
294
- db = DatabaseSettings.from_env()
295
-
296
- # Verify session maker settings
297
- assert db.session_maker.kw["expire_on_commit"] is False
298
-
299
- # Test session creation
300
- async with db.session_maker() as session:
301
- assert isinstance(session, AsyncSession)
302
- result = await session.execute(text("SELECT 1"))
303
- assert result.scalar_one() == 1
304
-
305
- await db.engine.dispose()
306
-
307
-
308
- @pytest.mark.asyncio
309
- @pytest.mark.integration
310
- async def test_dependency_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
311
- """Test that dependency generator from env vars works correctly."""
312
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
313
- monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
314
-
315
- db = DatabaseSettings.from_env()
316
-
317
- # Test dependency yields sessions
318
- sessions = []
319
- async for session in db.dependency():
320
- sessions.append(session)
321
- result = await session.execute(text("SELECT 1"))
322
- assert result.scalar_one() == 1
323
- break
324
-
325
- async for session in db.dependency():
326
- sessions.append(session)
327
- break
328
-
329
- # Verify different sessions
330
- assert len(sessions) == 2
331
- assert sessions[0] is not sessions[1]
332
-
333
- await db.engine.dispose()
334
-
335
-
336
- # ==================== Mixed Configuration Tests ====================
337
-
338
-
339
- @pytest.mark.integration
340
- def test_can_override_with_direct_instantiation(monkeypatch: pytest.MonkeyPatch) -> None:
341
- """Test that direct instantiation still works regardless of env vars."""
342
- # Set env vars that would load postgres
343
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
344
- monkeypatch.setenv("BELGIE_POSTGRES_HOST", "localhost")
345
- monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", "testdb")
346
- monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", "user")
347
- monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", "pass")
348
-
349
- # But directly instantiate SQLite
350
- db = DatabaseSettings(dialect={"type": "sqlite", "database": ":memory:"})
351
-
352
- assert db.dialect.type == "sqlite"
353
- assert db.dialect.database == ":memory:"
354
-
355
-
356
- @pytest.mark.integration
357
- def test_from_env_vs_direct_instantiation(monkeypatch: pytest.MonkeyPatch) -> None:
358
- """Test the difference between from_env() and direct instantiation."""
359
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
360
- monkeypatch.setenv("BELGIE_SQLITE_DATABASE", "/tmp/from_env.db") # noqa: S108
361
- monkeypatch.setenv("BELGIE_SQLITE_ECHO", "true")
362
-
363
- # from_env() reads environment variables
364
- db_from_env = DatabaseSettings.from_env()
365
- assert db_from_env.dialect.database == "/tmp/from_env.db" # noqa: S108
366
- assert db_from_env.dialect.echo is True
367
-
368
- # Direct instantiation with explicit values overrides env vars
369
- db_direct = DatabaseSettings(dialect={"type": "sqlite", "database": ":memory:", "echo": False})
370
- assert db_direct.dialect.database == ":memory:"
371
- assert db_direct.dialect.echo is False
372
-
373
-
374
- @pytest.mark.integration
375
- def test_no_database_type_env_var_defaults_to_sqlite(monkeypatch: pytest.MonkeyPatch) -> None:
376
- """Test that missing BELGIE_DATABASE_TYPE defaults to SQLite."""
377
- # Don't set BELGIE_DATABASE_TYPE
378
- monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
379
-
380
- db = DatabaseSettings.from_env()
381
-
382
- assert db.dialect.type == "sqlite"
383
-
384
-
385
- @pytest.mark.integration
386
- def test_postgres_connection_string_format(monkeypatch: pytest.MonkeyPatch) -> None:
387
- """Test that Postgres settings correctly build connection URL."""
388
- if not ASYNC_PG_AVAILABLE:
389
- pytest.skip("asyncpg not installed")
390
-
391
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
392
- monkeypatch.setenv("BELGIE_POSTGRES_HOST", "testhost.example.com")
393
- monkeypatch.setenv("BELGIE_POSTGRES_PORT", "5433")
394
- monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", "testdb")
395
- monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", "testuser")
396
- monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", "testpass123")
397
-
398
- db = DatabaseSettings.from_env()
399
-
400
- # Verify URL components
401
- assert db.engine.url.get_backend_name() == "postgresql"
402
- assert db.engine.url.get_driver_name() == "asyncpg"
403
- assert db.engine.url.host == "testhost.example.com"
404
- assert db.engine.url.port == 5433
405
- assert db.engine.url.database == "testdb"
406
- assert db.engine.url.username == "testuser"
407
-
408
-
409
- @pytest.mark.integration
410
- def test_sqlite_missing_database_field(monkeypatch: pytest.MonkeyPatch) -> None:
411
- """Test that SQLite requires database field."""
412
- monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
413
- # Missing BELGIE_SQLITE_DATABASE
414
-
415
- with pytest.raises(ValidationError):
416
- DatabaseSettings.from_env()
File without changes
@@ -1,155 +0,0 @@
1
- from datetime import UTC, datetime, timedelta, timezone
2
- from zoneinfo import ZoneInfo
3
-
4
- import pytest
5
- from sqlalchemy import Integer
6
- from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
7
- from sqlalchemy.orm import Mapped, mapped_column
8
-
9
- from belgie_alchemy.base import Base
10
- from belgie_alchemy.types import DateTimeUTC
11
-
12
-
13
- class Event(Base):
14
- __tablename__ = "events"
15
-
16
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, init=False)
17
- happened_at: Mapped[datetime] = mapped_column(DateTimeUTC, nullable=False)
18
-
19
-
20
- @pytest.mark.asyncio
21
- async def test_datetimeutc_roundtrip(
22
- alchemy_engine: AsyncEngine,
23
- alchemy_session: AsyncSession,
24
- ) -> None:
25
- async with alchemy_engine.begin() as conn:
26
- await conn.run_sync(Event.__table__.create, checkfirst=True)
27
-
28
- naive = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
29
- aware = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
30
-
31
- event1 = Event(happened_at=naive)
32
- event2 = Event(happened_at=aware)
33
-
34
- alchemy_session.add_all([event1, event2])
35
- await alchemy_session.commit()
36
-
37
- rows = (await alchemy_session.execute(Event.__table__.select())).all()
38
- values = [row.happened_at for row in rows]
39
- assert all(val.tzinfo is UTC for val in values)
40
-
41
-
42
- def test_datetimeutc_rejects_non_datetime() -> None:
43
- """Test that DateTimeUTC rejects non-datetime values with helpful error."""
44
- dt = DateTimeUTC()
45
- with pytest.raises(TypeError, match=r"DateTimeUTC requires datetime object, got str"):
46
- dt.process_bind_param("not-a-datetime", None) # type: ignore[arg-type]
47
-
48
-
49
- def test_datetimeutc_error_message_includes_guidance() -> None:
50
- """Test that error message provides actionable guidance."""
51
- dt = DateTimeUTC()
52
- with pytest.raises(TypeError, match=r"datetime\.combine"):
53
- dt.process_bind_param("2024-01-01", None) # type: ignore[arg-type]
54
-
55
-
56
- def test_datetimeutc_handles_none() -> None:
57
- """Test that DateTimeUTC properly handles None values."""
58
- dt = DateTimeUTC()
59
- result = dt.process_bind_param(None, None)
60
- assert result is None
61
-
62
- result = dt.process_result_value(None, None)
63
- assert result is None
64
-
65
-
66
- def test_datetimeutc_converts_naive_to_utc() -> None:
67
- """Test that naive datetimes are converted to UTC-aware."""
68
- dt = DateTimeUTC()
69
- naive = datetime(2024, 1, 1, 12, 0, 0) # noqa: DTZ001 - intentionally testing naive datetime
70
-
71
- result = dt.process_bind_param(naive, None)
72
-
73
- assert result is not None
74
- assert result.tzinfo is UTC
75
- assert result.year == 2024
76
- assert result.month == 1
77
- assert result.day == 1
78
- assert result.hour == 12
79
-
80
-
81
- def test_datetimeutc_converts_other_timezones_to_utc() -> None:
82
- """Test that datetimes in other timezones are converted to UTC."""
83
- dt = DateTimeUTC()
84
-
85
- # Create datetime in US/Eastern (UTC-5 in winter)
86
- eastern = ZoneInfo("America/New_York")
87
- eastern_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=eastern)
88
-
89
- result = dt.process_bind_param(eastern_time, None)
90
-
91
- assert result is not None
92
- assert result.tzinfo is UTC
93
- # 12:00 EST should be 17:00 UTC (approximately, depending on DST)
94
- assert result.hour in (16, 17) # Allow for DST variations
95
-
96
-
97
- def test_datetimeutc_preserves_utc_datetime() -> None:
98
- """Test that UTC datetimes are preserved as-is."""
99
- dt = DateTimeUTC()
100
- utc_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
101
-
102
- result = dt.process_bind_param(utc_time, None)
103
-
104
- assert result is not None
105
- assert result == utc_time
106
- assert result.tzinfo is UTC
107
-
108
-
109
- @pytest.mark.asyncio
110
- async def test_datetimeutc_naive_datetime_roundtrip(
111
- alchemy_engine: AsyncEngine,
112
- alchemy_session: AsyncSession,
113
- ) -> None:
114
- """Test that naive datetimes are stored and retrieved as UTC-aware."""
115
- async with alchemy_engine.begin() as conn:
116
- await conn.run_sync(Event.__table__.create, checkfirst=True)
117
-
118
- # Create event with naive datetime
119
- naive_time = datetime(2024, 6, 15, 14, 30, 0) # noqa: DTZ001 - intentionally testing naive datetime
120
- event = Event(happened_at=naive_time)
121
-
122
- alchemy_session.add(event)
123
- await alchemy_session.commit()
124
-
125
- # Retrieve and verify it's UTC-aware
126
- await alchemy_session.refresh(event)
127
- assert event.happened_at.tzinfo is UTC
128
- assert event.happened_at.year == 2024
129
- assert event.happened_at.month == 6
130
- assert event.happened_at.day == 15
131
- assert event.happened_at.hour == 14
132
-
133
-
134
- @pytest.mark.asyncio
135
- async def test_datetimeutc_timezone_conversion_roundtrip(
136
- alchemy_engine: AsyncEngine,
137
- alchemy_session: AsyncSession,
138
- ) -> None:
139
- """Test that datetimes in other timezones are converted to UTC on storage."""
140
- async with alchemy_engine.begin() as conn:
141
- await conn.run_sync(Event.__table__.create, checkfirst=True)
142
-
143
- # Create event with Tokyo time (UTC+9)
144
- tokyo_tz = timezone(timedelta(hours=9))
145
- tokyo_time = datetime(2024, 1, 1, 21, 0, 0, tzinfo=tokyo_tz) # 21:00 Tokyo
146
-
147
- event = Event(happened_at=tokyo_time)
148
- alchemy_session.add(event)
149
- await alchemy_session.commit()
150
-
151
- # Retrieve and verify it's converted to UTC
152
- await alchemy_session.refresh(event)
153
- assert event.happened_at.tzinfo is UTC
154
- assert event.happened_at.hour == 12 # 21:00 - 9 hours = 12:00 UTC
155
- assert event.happened_at.day == 1
belgie_alchemy/base.py DELETED
@@ -1,25 +0,0 @@
1
- from datetime import datetime
2
- from typing import Any, ClassVar, Final
3
-
4
- from sqlalchemy import MetaData
5
- from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
6
-
7
- from belgie_alchemy.types import DateTimeUTC
8
-
9
- NAMING_CONVENTION: Final[dict[str, str]] = {
10
- "ix": "ix_%(column_0_label)s",
11
- "uq": "uq_%(table_name)s_%(column_0_name)s",
12
- "ck": "ck_%(table_name)s_%(constraint_name)s",
13
- "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
14
- "pk": "pk_%(table_name)s",
15
- }
16
-
17
- TYPE_ANNOTATION_MAP: Final[dict[type | Any, object]] = {
18
- datetime: DateTimeUTC,
19
- }
20
-
21
-
22
- class Base(MappedAsDataclass, DeclarativeBase):
23
- metadata = MetaData(naming_convention=NAMING_CONVENTION)
24
- type_annotation_map = TYPE_ANNOTATION_MAP
25
- __sa_dataclass_kwargs__: ClassVar[dict[str, bool]] = {"kw_only": True, "repr": True, "eq": True}
belgie_alchemy/mixins.py DELETED
@@ -1,83 +0,0 @@
1
- from datetime import datetime
2
- from uuid import UUID, uuid4
3
-
4
- from sqlalchemy import func
5
- from sqlalchemy.orm import Mapped, MappedAsDataclass, declarative_mixin, mapped_column
6
-
7
- from belgie_alchemy.types import DateTimeUTC
8
-
9
-
10
- @declarative_mixin
11
- class PrimaryKeyMixin(MappedAsDataclass):
12
- """Mixin that adds a UUID primary key column.
13
-
14
- Inherits from MappedAsDataclass to support standalone usage without Base.
15
- When used with Base (which also inherits MappedAsDataclass), the duplicate
16
- inheritance is safely handled by Python's MRO (Method Resolution Order).
17
-
18
- The id field is excluded from __init__ (init=False) and is automatically
19
- generated both client-side (default_factory=uuid4) and server-side
20
- (server_default=gen_random_uuid()) for maximum compatibility.
21
-
22
- Usage:
23
- class MyModel(Base, PrimaryKeyMixin, TimestampMixin):
24
- __tablename__ = "my_table"
25
- name: Mapped[str]
26
-
27
- The UUID is:
28
- - Generated client-side by default (uuid4)
29
- - Has server-side fallback (gen_random_uuid() for PostgreSQL)
30
- - Indexed and unique for efficient lookups
31
- """
32
-
33
- id: Mapped[UUID] = mapped_column(
34
- primary_key=True,
35
- default_factory=uuid4,
36
- server_default=func.gen_random_uuid(),
37
- index=True,
38
- unique=True,
39
- init=False,
40
- )
41
-
42
-
43
- @declarative_mixin
44
- class TimestampMixin(MappedAsDataclass):
45
- """Mixin that adds automatic timestamp tracking columns.
46
-
47
- Inherits from MappedAsDataclass to support standalone usage without Base.
48
- When used with Base (which also inherits MappedAsDataclass), the duplicate
49
- inheritance is safely handled by Python's MRO (Method Resolution Order).
50
-
51
- All timestamp fields are excluded from __init__ (init=False) and are
52
- automatically managed by the database using UTC-aware datetimes.
53
-
54
- Usage:
55
- class MyModel(Base, PrimaryKeyMixin, TimestampMixin):
56
- __tablename__ = "my_table"
57
- name: Mapped[str]
58
-
59
- Fields:
60
- created_at: Set automatically on insert (UTC-aware)
61
- updated_at: Set automatically on insert and update (UTC-aware)
62
- deleted_at: NULL by default, set via mark_deleted() for soft deletion
63
-
64
- Soft Deletion:
65
- Use mark_deleted() to mark an entity as deleted without removing it
66
- from the database. Remember to commit the session after calling.
67
- """
68
-
69
- created_at: Mapped[datetime] = mapped_column(DateTimeUTC, default=func.now(), init=False)
70
- updated_at: Mapped[datetime] = mapped_column(DateTimeUTC, default=func.now(), onupdate=func.now(), init=False)
71
- deleted_at: Mapped[datetime | None] = mapped_column(DateTimeUTC, nullable=True, default=None, init=False)
72
-
73
- def mark_deleted(self) -> None:
74
- """Mark this entity as deleted by setting deleted_at timestamp.
75
-
76
- Note: This only sets the field, it does not persist to the database.
77
- You must commit the session to save the change.
78
-
79
- Example:
80
- user.mark_deleted()
81
- await session.commit() # Persist the soft delete
82
- """
83
- self.deleted_at = func.now()