Skip to content

Commit 0854ecf

Browse files
authored
Merge pull request #32 from soapun/feature/async-sqla
Async SQLAlchemy Support
2 parents f06a990 + af88820 commit 0854ecf

11 files changed

+1890
-1
lines changed

src/py_pglite/pytest_plugin.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,14 @@ def _should_disable_django_plugin(config: pytest.Config) -> bool:
8989
# Smart fixture loading with perfect isolation
9090
if HAS_SQLALCHEMY:
9191
try:
92+
# async
93+
from py_pglite.sqlalchemy.fixtures import pglite_async_engine
94+
from py_pglite.sqlalchemy.fixtures import pglite_async_session
95+
from py_pglite.sqlalchemy.fixtures import pglite_async_sqlalchemy_manager
9296
from py_pglite.sqlalchemy.fixtures import pglite_config
9397
from py_pglite.sqlalchemy.fixtures import pglite_engine
9498
from py_pglite.sqlalchemy.fixtures import pglite_session
99+
from py_pglite.sqlalchemy.fixtures import pglite_sqlalchemy_async_engine
95100
from py_pglite.sqlalchemy.fixtures import pglite_sqlalchemy_engine
96101
from py_pglite.sqlalchemy.fixtures import pglite_sqlalchemy_manager
97102
from py_pglite.sqlalchemy.fixtures import pglite_sqlalchemy_session

src/py_pglite/sqlalchemy/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,36 @@
44
"""
55

66
# Import fixtures and utilities
7+
from py_pglite.sqlalchemy.fixtures import pglite_async_engine
8+
from py_pglite.sqlalchemy.fixtures import pglite_async_session
9+
from py_pglite.sqlalchemy.fixtures import pglite_async_sqlalchemy_manager
710
from py_pglite.sqlalchemy.fixtures import pglite_engine
811
from py_pglite.sqlalchemy.fixtures import pglite_session
12+
from py_pglite.sqlalchemy.fixtures import pglite_sqlalchemy_async_engine
913
from py_pglite.sqlalchemy.fixtures import pglite_sqlalchemy_engine
1014
from py_pglite.sqlalchemy.fixtures import pglite_sqlalchemy_session
1115
from py_pglite.sqlalchemy.manager import SQLAlchemyPGliteManager
16+
from py_pglite.sqlalchemy.manager_async import SQLAlchemyAsyncPGliteManager
1217
from py_pglite.sqlalchemy.utils import create_all_tables
1318
from py_pglite.sqlalchemy.utils import drop_all_tables
1419
from py_pglite.sqlalchemy.utils import get_session_class
1520

1621

1722
__all__ = [
1823
# Manager
24+
"SQLAlchemyAsyncPGliteManager",
1925
"SQLAlchemyPGliteManager",
2026
# Utilities
2127
"create_all_tables",
2228
"drop_all_tables",
2329
"get_session_class",
2430
# Fixtures
31+
"pglite_async_engine",
32+
"pglite_async_session",
33+
"pglite_async_sqlalchemy_manager",
2534
"pglite_engine",
2635
"pglite_session",
36+
"pglite_sqlalchemy_async_engine",
2737
"pglite_sqlalchemy_engine",
2838
"pglite_sqlalchemy_session",
2939
]

src/py_pglite/sqlalchemy/fixtures.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,30 @@
33
import logging
44
import time
55

6+
from collections.abc import AsyncGenerator
67
from collections.abc import Generator
78
from typing import Any
89

910
import pytest
1011

1112
from sqlalchemy import text
1213
from sqlalchemy.engine import Engine
14+
from sqlalchemy.ext.asyncio import AsyncEngine
15+
from sqlalchemy.ext.asyncio import AsyncSession
16+
from sqlalchemy.ext.asyncio import async_sessionmaker
1317
from sqlalchemy.orm import Session
1418
from sqlalchemy.orm import sessionmaker
1519

1620
from py_pglite.config import PGliteConfig
1721
from py_pglite.sqlalchemy.manager import SQLAlchemyPGliteManager
22+
from py_pglite.sqlalchemy.manager_async import SQLAlchemyAsyncPGliteManager
1823

1924

2025
# Try to import SQLModel
2126
try:
2227
from sqlmodel import Session as SQLModelSession
2328
from sqlmodel import SQLModel
29+
from sqlmodel.ext.asyncio.session import AsyncSession as SQLModelAsyncSession
2430

2531
HAS_SQLMODEL = True
2632
except ImportError:
@@ -55,6 +61,25 @@ def pglite_sqlalchemy_manager(
5561
manager.stop()
5662

5763

64+
@pytest.fixture(scope="session")
65+
async def pglite_async_sqlalchemy_manager(
66+
pglite_config: PGliteConfig,
67+
) -> AsyncGenerator[SQLAlchemyAsyncPGliteManager, None, None]:
68+
"""Pytest fixture providing an async SQLAlchemy-enabled PGlite manager."""
69+
manager = SQLAlchemyAsyncPGliteManager(pglite_config)
70+
manager.start()
71+
72+
is_ready = await manager.wait_for_ready()
73+
# Wait for database to be ready
74+
if not is_ready:
75+
raise RuntimeError("Failed to start PGlite database")
76+
77+
try:
78+
yield manager
79+
finally:
80+
await manager.stop()
81+
82+
5883
@pytest.fixture(scope="session")
5984
def pglite_engine(pglite_sqlalchemy_manager: SQLAlchemyPGliteManager) -> Engine:
6085
"""Pytest fixture providing a SQLAlchemy engine connected to PGlite.
@@ -65,6 +90,18 @@ def pglite_engine(pglite_sqlalchemy_manager: SQLAlchemyPGliteManager) -> Engine:
6590
return pglite_sqlalchemy_manager.get_engine()
6691

6792

93+
@pytest.fixture(scope="session")
94+
def pglite_async_engine(
95+
pglite_async_sqlalchemy_manager: SQLAlchemyAsyncPGliteManager,
96+
) -> AsyncEngine:
97+
"""Pytest fixture providing a SQLAlchemy async engine connected to PGlite.
98+
99+
Uses the SQLAlchemy-enabled manager to ensure proper SQLAlchemy integration.
100+
"""
101+
# Use the shared engine from manager (no custom parameters to avoid conflicts)
102+
return pglite_async_sqlalchemy_manager.get_engine()
103+
104+
68105
@pytest.fixture(scope="session")
69106
def pglite_sqlalchemy_engine(
70107
pglite_sqlalchemy_manager: SQLAlchemyPGliteManager,
@@ -74,6 +111,15 @@ def pglite_sqlalchemy_engine(
74111
return pglite_sqlalchemy_manager.get_engine()
75112

76113

114+
@pytest.fixture(scope="session")
115+
def pglite_sqlalchemy_async_engine(
116+
pglite_async_sqlalchemy_manager: SQLAlchemyAsyncPGliteManager,
117+
) -> AsyncEngine:
118+
"""Pytest fixture providing an optimized SQLAlchemy async engine connected to PGlite."""
119+
# Use the shared engine from manager (no custom parameters to avoid conflicts)
120+
return pglite_async_sqlalchemy_manager.get_engine()
121+
122+
77123
@pytest.fixture(scope="function")
78124
def pglite_session(pglite_engine: Engine) -> Generator[Any, None, None]:
79125
"""Pytest fixture providing a SQLAlchemy/SQLModel session with proper isolation.
@@ -161,6 +207,96 @@ def pglite_session(pglite_engine: Engine) -> Generator[Any, None, None]:
161207
logger.warning(f"Error closing session: {e}")
162208

163209

210+
@pytest.fixture(scope="function")
211+
async def pglite_async_session(
212+
pglite_async_engine: AsyncEngine,
213+
) -> AsyncGenerator[Any, None, None]:
214+
"""Pytest fixture providing a SQLAlchemy/SQLModel async session with proper isolation.
215+
216+
This fixture ensures database isolation between tests by cleaning all data
217+
at the start of each test.
218+
"""
219+
# Clean up data before test starts
220+
logger.info("Starting database cleanup before test...")
221+
retry_count = 3
222+
for attempt in range(retry_count):
223+
try:
224+
async with pglite_async_engine.connect() as conn:
225+
# Get all table names from information_schema
226+
result = await conn.execute(
227+
text("""
228+
SELECT table_name
229+
FROM information_schema.tables
230+
WHERE table_schema = 'public'
231+
AND table_type = 'BASE TABLE'
232+
""")
233+
)
234+
235+
table_names = [row[0] for row in result]
236+
logger.info(f"Found tables to clean: {table_names}")
237+
238+
if table_names:
239+
# Disable foreign key checks for faster cleanup
240+
await conn.execute(text("SET session_replication_role = replica;"))
241+
242+
# Truncate all tables
243+
for table_name in table_names:
244+
logger.info(f"Truncating table: {table_name}")
245+
await conn.execute(
246+
text(
247+
f'TRUNCATE TABLE "{table_name}" '
248+
f"RESTART IDENTITY CASCADE;"
249+
)
250+
)
251+
252+
# Re-enable foreign key checks
253+
await conn.execute(text("SET session_replication_role = DEFAULT;"))
254+
255+
# Commit the cleanup
256+
await conn.commit()
257+
logger.info("Database cleanup completed successfully")
258+
else:
259+
logger.info("No tables found to clean")
260+
break # Success, exit retry loop
261+
262+
except Exception as e:
263+
logger.info(f"Database cleanup attempt {attempt + 1} failed: {e}")
264+
if attempt == retry_count - 1:
265+
logger.warning(
266+
"Database cleanup failed after all retries, continuing anyway"
267+
)
268+
else:
269+
time.sleep(0.5) # Brief pause before retry
270+
271+
# Create session - prefer SQLModel if available
272+
if HAS_SQLMODEL and SQLModelAsyncSession is not None:
273+
session = SQLModelAsyncSession(pglite_async_engine)
274+
# Create tables if using SQLModel with retry logic
275+
if SQLModel is not None:
276+
for attempt in range(3):
277+
try:
278+
async with pglite_async_engine.begin() as conn:
279+
await conn.run_sync(SQLModel.metadata.create_all)
280+
break
281+
except Exception as e:
282+
logger.warning(f"Table creation attempt {attempt + 1} failed: {e}")
283+
if attempt == 2:
284+
raise
285+
time.sleep(0.5)
286+
else:
287+
session_local = async_sessionmaker(bind=pglite_async_engine)
288+
session = session_local() # type: ignore[assignment]
289+
290+
try:
291+
yield session
292+
finally:
293+
# Close the session safely
294+
try:
295+
await session.close()
296+
except Exception as e:
297+
logger.warning(f"Error closing session: {e}")
298+
299+
164300
@pytest.fixture(scope="function")
165301
def pglite_sqlalchemy_session(pglite_session: Session) -> Session:
166302
"""Legacy fixture name for backwards compatibility."""

0 commit comments

Comments
 (0)