feat: add database models with cascade delete
Implements SQLAlchemy 2.0 async ORM models (Config, Subscription, ExportLog) with cascade delete-orphan relationships. Upgrades SQLAlchemy to 2.0.49 for Python 3.14 compatibility. Adds pytest conftest with in-memory SQLite fixture; all 4 TDD tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Integer, String, Text, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Config(Base):
|
||||
__tablename__ = "configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
token: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
||||
base_yaml: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
|
||||
subscriptions: Mapped[list["Subscription"]] = relationship(
|
||||
back_populates="config", cascade="all, delete-orphan"
|
||||
)
|
||||
export_logs: Mapped[list["ExportLog"]] = relationship(
|
||||
back_populates="config", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
url: Mapped[str] = mapped_column(String, nullable=False)
|
||||
last_fetched_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
config: Mapped["Config"] = relationship(back_populates="subscriptions")
|
||||
|
||||
|
||||
class ExportLog(Base):
|
||||
__tablename__ = "export_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False)
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
node_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
success: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
config: Mapped["Config"] = relationship(back_populates="export_logs")
|
||||
|
||||
|
||||
def make_engine(database_url: str):
|
||||
return create_async_engine(database_url)
|
||||
|
||||
|
||||
def make_session_factory(engine):
|
||||
return async_sessionmaker(engine, expire_on_commit=False)
|
||||
@@ -1,6 +1,6 @@
|
||||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.1
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
sqlalchemy[asyncio]==2.0.49
|
||||
aiosqlite==0.20.0
|
||||
httpx==0.28.0
|
||||
pyyaml==6.0.2
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
from models import Base
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
Session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with Session() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,67 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from models import Config, Subscription, ExportLog
|
||||
|
||||
|
||||
async def test_config_insert_and_retrieve(db_session):
|
||||
token = str(uuid.uuid4())
|
||||
config = Config(name="my-config", token=token, base_yaml="proxies: []")
|
||||
db_session.add(config)
|
||||
await db_session.commit()
|
||||
|
||||
result = await db_session.execute(select(Config).where(Config.token == token))
|
||||
found = result.scalar_one()
|
||||
assert found.id is not None
|
||||
assert found.name == "my-config"
|
||||
assert found.created_at is not None
|
||||
|
||||
|
||||
async def test_subscription_belongs_to_config(db_session):
|
||||
config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []")
|
||||
db_session.add(config)
|
||||
await db_session.flush()
|
||||
|
||||
sub = Subscription(config_id=config.id, name="provider1", url="https://example.com/sub")
|
||||
db_session.add(sub)
|
||||
await db_session.commit()
|
||||
|
||||
result = await db_session.execute(select(Subscription).where(Subscription.config_id == config.id))
|
||||
subs = result.scalars().all()
|
||||
assert len(subs) == 1
|
||||
assert subs[0].name == "provider1"
|
||||
|
||||
|
||||
async def test_cascade_delete_removes_subscriptions(db_session):
|
||||
config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []")
|
||||
db_session.add(config)
|
||||
await db_session.flush()
|
||||
|
||||
sub = Subscription(config_id=config.id, name="p", url="https://example.com/sub")
|
||||
log = ExportLog(config_id=config.id, node_count=5, success=True)
|
||||
db_session.add(sub)
|
||||
db_session.add(log)
|
||||
await db_session.commit()
|
||||
|
||||
sub_id = sub.id
|
||||
log_id = log.id
|
||||
|
||||
await db_session.delete(config)
|
||||
await db_session.commit()
|
||||
|
||||
assert (await db_session.get(Subscription, sub_id)) is None
|
||||
assert (await db_session.get(ExportLog, log_id)) is None
|
||||
|
||||
|
||||
async def test_export_log_defaults(db_session):
|
||||
config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []")
|
||||
db_session.add(config)
|
||||
await db_session.flush()
|
||||
|
||||
log = ExportLog(config_id=config.id)
|
||||
db_session.add(log)
|
||||
await db_session.commit()
|
||||
|
||||
assert log.success is True
|
||||
assert log.node_count == 0
|
||||
assert log.fetched_at is not None
|
||||
Reference in New Issue
Block a user