Overview
This document establishes database interaction standards for the Tux Discord bot. Our architecture uses SQLModel with SQLAlchemy for type-safe database operations, following clean architecture principles with proper separation of concerns.
Architecture Overview
Core Components
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Commands/ │ │ Controllers │ │ Database │
│ Services │───▶│ (Business │───▶│ Service │
│ │ │ Logic) │ │ (Session Mgmt) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌─────────────────┐
│ Models │ │ PostgreSQL │
│ (SQLModel) │ │ Database │
└──────────────────┘ └─────────────────┘
Layer Responsibilities
- Commands/Services: User interaction, validation, orchestration
- Controllers: Business logic, data transformation, error handling
- Database Service: Session management, connection handling
- Models: Data structure, relationships, validation
Database Service Usage
Dependency Injection Pattern
# ✅ GOOD: Proper dependency injection
class MyCog(BaseCog):
def __init__(self, bot: Tux) -> None:
super().__init__(bot)
self.guild_controller = self.db.guild_config # Injected via BaseCog
# ❌ BAD: Direct instantiation
class MyCog(BaseCog):
def __init__(self, bot: Tux) -> None:
super().__init__(bot)
self.guild_controller = GuildConfigController() # Missing DB service
Service Access Patterns
# ✅ GOOD: Use injected controllers
async def my_command(self, ctx: commands.Context[Tux]) -> None:
config = await self.db.guild_config.get_config_by_guild_id(ctx.guild.id)
# ✅ GOOD: Direct service access when needed
async def advanced_operation(self) -> None:
async with self.db.session() as session:
# Complex multi-table operations
result = await session.execute(custom_query)
Controller Patterns
Standard Controller Structure
from tux.database.controllers.base import BaseController
from tux.database.models import MyModel
from tux.database.service import DatabaseService
class MyController(BaseController[MyModel]):
"""Controller for MyModel with business logic."""
def __init__(self, db: DatabaseService | None = None):
super().__init__(MyModel, db)
# Business logic methods
async def get_by_name(self, name: str) -> MyModel | None:
"""Get model by name with business validation."""
return await self.find_one(filters=MyModel.name == name)
async def create_with_validation(self, **data) -> MyModel:
"""Create model with business rules."""
# Validation logic
if not self._validate_data(data):
raise ValueError("Invalid data")
return await self.create(**data)
CRUD Operations
# ✅ Standard CRUD patterns
class UserController(BaseController[User]):
async def get_user(self, user_id: int) -> User | None:
"""Get user by ID."""
return await self.get_by_id(user_id)
async def create_user(self, **user_data) -> User:
"""Create new user."""
return await self.create(**user_data)
async def update_user(self, user_id: int, **updates) -> User | None:
"""Update existing user."""
return await self.update_by_id(user_id, **updates)
async def delete_user(self, user_id: int) -> bool:
"""Delete user."""
return await self.delete_by_id(user_id)
async def find_users_by_guild(self, guild_id: int) -> list[User]:
"""Find users in specific guild."""
return await self.find_all(filters=User.guild_id == guild_id)
Error Handling Patterns
Controller-Level Error Handling
async def get_or_create_config(self, guild_id: int) -> GuildConfig | None:
"""Get or create guild config with proper error handling."""
try:
config = await self.get_by_id(guild_id)
if config:
return config
# Create with defaults
return await self.create(guild_id=guild_id, **DEFAULT_CONFIG)
except IntegrityError as e:
logger.warning(f"Guild {guild_id} config already exists: {e}")
# Retry get operation
return await self.get_by_id(guild_id)
except Exception as e:
logger.error(f"Failed to get/create config for guild {guild_id}: {e}")
return None
Transaction Error Handling
async def complex_operation(self, data: dict) -> bool:
"""Complex multi-step operation with transaction."""
try:
async with self.db.transaction() as session:
# Step 1
user = await self.create_user(session, **data['user'])
# Step 2
config = await self.create_config(session, user_id=user.id)
# Step 3
await self.update_stats(session, user_id=user.id)
return True
except Exception as e:
logger.error(f"Complex operation failed: {e}")
# Transaction automatically rolled back
return False
Query Patterns
Simple Queries
# ✅ GOOD: Use controller methods
users = await self.db.user.find_all(
filters=User.guild_id == guild_id,
limit=10,
order_by=User.created_at.desc()
)
# ✅ GOOD: Single record with fallback
user = await self.db.user.get_by_id(user_id)
if not user:
user = await self.db.user.create(user_id=user_id, **defaults)
Complex Queries
# ✅ GOOD: Custom queries when needed
async def get_top_users_by_activity(self, guild_id: int, limit: int = 10) -> list[User]:
"""Get most active users with custom query."""
async with self.db.session() as session:
query = (
select(User)
.where(User.guild_id == guild_id)
.order_by(User.message_count.desc(), User.last_active.desc())
.limit(limit)
)
result = await session.execute(query)
return result.scalars().all()
Relationship Queries
# ✅ GOOD: Eager loading for relationships
async def get_user_with_cases(self, user_id: int) -> User | None:
"""Get user with all moderation cases."""
return await self.db.user.find_one(
filters=User.id == user_id,
options=[selectinload(User.cases)]
)
# ✅ GOOD: Relationship filtering
async def get_users_with_active_cases(self, guild_id: int) -> list[User]:
"""Get users with active moderation cases."""
return await self.db.user.find_all(
filters=and_(
User.guild_id == guild_id,
User.cases.any(Case.is_active == True)
)
)
Transaction Management
Automatic Transactions
# ✅ GOOD: Controller methods use automatic transactions
async def update_user_stats(self, user_id: int, **stats) -> User | None:
"""Update user statistics (automatically transactional)."""
return await self.db.user.update_by_id(user_id, **stats)
Manual Transactions
# ✅ GOOD: Manual transactions for complex operations
async def transfer_points(self, from_user: int, to_user: int, points: int) -> bool:
"""Transfer points between users."""
try:
async with self.db.transaction() as session:
# Deduct from sender
sender = await session.get(User, from_user)
if sender.points < points:
raise ValueError("Insufficient points")
sender.points -= points
# Add to receiver
receiver = await session.get(User, to_user)
receiver.points += points
# Log transaction
await session.merge(PointsTransaction(
from_user=from_user,
to_user=to_user,
amount=points
))
return True
except Exception as e:
logger.error(f"Points transfer failed: {e}")
return False
Performance Patterns
Efficient Queries
# ✅ GOOD: Use pagination for large datasets
async def get_all_users_paginated(self, guild_id: int, page: int = 1) -> PaginationResult[User]:
"""Get users with pagination."""
return await self.db.user.paginate(
filters=User.guild_id == guild_id,
page=page,
per_page=50
)
# ✅ GOOD: Bulk operations
async def update_multiple_users(self, updates: list[dict]) -> int:
"""Bulk update users."""
return await self.db.user.bulk_update(updates)
Caching Patterns
from functools import lru_cache
from typing import Optional
class GuildConfigController(BaseController[GuildConfig]):
@lru_cache(maxsize=128)
async def get_cached_config(self, guild_id: int) -> GuildConfig | None:
"""Get guild config with caching."""
return await self.get_by_id(guild_id)
async def update_config(self, guild_id: int, **updates) -> GuildConfig | None:
"""Update config and invalidate cache."""
result = await self.update_by_id(guild_id, **updates)
# Clear cache for this guild
self.get_cached_config.cache_clear()
return result
Model Patterns
Model Definition
from sqlmodel import SQLModel, Field, Relationship
from datetime import datetime
from typing import Optional
class User(SQLModel, table=True):
"""User model with proper typing and validation."""
__tablename__ = "users"
# Primary key
id: int = Field(primary_key=True)
# Required fields
discord_id: int = Field(unique=True, index=True)
guild_id: int = Field(index=True)
username: str = Field(max_length=100)
# Optional fields with defaults
points: int = Field(default=0, ge=0) # Validation: >= 0
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: Optional[datetime] = Field(default=None)
# Relationships
cases: list["Case"] = Relationship(back_populates="user")
# Validation
@validator('username')
def validate_username(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Username cannot be empty')
return v.strip()
Relationship Patterns
# ✅ GOOD: Proper relationship definition
class Guild(SQLModel, table=True):
id: int = Field(primary_key=True)
discord_id: int = Field(unique=True)
# One-to-many
users: list[User] = Relationship(back_populates="guild")
cases: list[Case] = Relationship(back_populates="guild")
class User(SQLModel, table=True):
id: int = Field(primary_key=True)
guild_id: int = Field(foreign_key="guilds.id")
# Many-to-one
guild: Guild = Relationship(back_populates="users")
cases: list[Case] = Relationship(back_populates="user")
Migration Patterns
Migration Structure
"""Add user points system
Revision ID: abc123
Revises: def456
Create Date: 2024-01-01 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = 'abc123'
down_revision = 'def456'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add points column to users table."""
op.add_column('users', sa.Column('points', sa.Integer(), nullable=False, server_default='0'))
op.create_index('ix_users_points', 'users', ['points'])
def downgrade() -> None:
"""Remove points column from users table."""
op.drop_index('ix_users_points', 'users')
op.drop_column('users', 'points')
Data Migration
def upgrade() -> None:
"""Migrate old data format to new format."""
# Schema changes first
op.add_column('users', sa.Column('new_field', sa.String(100)))
# Data migration
connection = op.get_bind()
connection.execute(
text("UPDATE users SET new_field = CONCAT('prefix_', old_field)")
)
# Cleanup
op.drop_column('users', 'old_field')
Testing Patterns
Controller Testing
import pytest
from tux.database.service import DatabaseService
from tux.database.controllers import UserController
@pytest.fixture
async def user_controller(db_service: DatabaseService):
"""Create user controller for testing."""
return UserController(db_service)
async def test_create_user(user_controller: UserController):
"""Test user creation."""
user_data = {
"discord_id": 123456789,
"guild_id": 987654321,
"username": "testuser"
}
user = await user_controller.create_user(**user_data)
assert user.discord_id == 123456789
assert user.username == "testuser"
assert user.points == 0 # Default value
async def test_get_nonexistent_user(user_controller: UserController):
"""Test getting non-existent user."""
user = await user_controller.get_user(999999)
assert user is None
Integration Testing
async def test_user_points_transfer(db_service: DatabaseService):
"""Test points transfer between users."""
user_controller = UserController(db_service)
# Setup
sender = await user_controller.create_user(
discord_id=111, guild_id=1, username="sender", points=100
)
receiver = await user_controller.create_user(
discord_id=222, guild_id=1, username="receiver", points=0
)
# Transfer
success = await user_controller.transfer_points(sender.id, receiver.id, 50)
# Verify
assert success is True
updated_sender = await user_controller.get_user(sender.id)
updated_receiver = await user_controller.get_user(receiver.id)
assert updated_sender.points == 50
assert updated_receiver.points == 50
Anti-Patterns to Avoid
❌ Direct Session Usage in Commands
# BAD: Direct session management in commands
@commands.command()
async def bad_command(self, ctx):
async with self.bot.db.session() as session:
user = await session.get(User, ctx.author.id)
# Complex logic here...
❌ Missing Error Handling
# BAD: No error handling
async def create_user(self, **data):
return await self.db.user.create(**data) # Can fail silently
# GOOD: Proper error handling
async def create_user(self, **data):
try:
return await self.db.user.create(**data)
except IntegrityError:
logger.warning(f"User already exists: {data.get('discord_id')}")
return None
except Exception as e:
logger.error(f"Failed to create user: {e}")
raise
❌ N+1 Query Problems
# BAD: N+1 queries
users = await self.db.user.find_all()
for user in users:
cases = await self.db.case.find_all(filters=Case.user_id == user.id)
# GOOD: Eager loading
users = await self.db.user.find_all(
options=[selectinload(User.cases)]
)
Performance Guidelines
Query Optimization
- Use indexes for frequently queried fields
- Limit result sets with pagination
- Eager load relationships when needed
- Use bulk operations for multiple records
- Cache frequently accessed data
Connection Management
- Use connection pooling (handled by DatabaseService)
- Close sessions properly (automatic with context managers)
- Avoid long-running transactions
- Monitor connection usage
Security Considerations
Input Validation
# ✅ GOOD: Validate inputs
async def update_user_points(self, user_id: int, points: int) -> User | None:
if points < 0:
raise ValueError("Points cannot be negative")
if points > MAX_POINTS:
raise ValueError(f"Points cannot exceed {MAX_POINTS}")
return await self.update_by_id(user_id, points=points)
SQL Injection Prevention
# ✅ GOOD: Use parameterized queries (SQLAlchemy handles this)
users = await self.find_all(filters=User.username == username)
# ❌ BAD: Never use string formatting for queries
# query = f"SELECT * FROM users WHERE username = '{username}'" # NEVER DO THIS
Quick Reference
Common Operations
# Get single record
user = await self.db.user.get_by_id(user_id)
# Create record
user = await self.db.user.create(discord_id=123, username="test")
# Update record
user = await self.db.user.update_by_id(user_id, points=100)
# Delete record
success = await self.db.user.delete_by_id(user_id)
# Find with filters
users = await self.db.user.find_all(
filters=User.guild_id == guild_id,
limit=10
)
# Pagination
result = await self.db.user.paginate(page=1, per_page=20)
# Transaction
async with self.db.transaction() as session:
# Multiple operations
pass
Error Handling Checklist
- [ ] Handle
IntegrityError
for unique constraints - [ ] Handle
NoResultFound
for required records - [ ] Log errors with appropriate context
- [ ] Provide meaningful error messages
- [ ] Use transactions for multi-step operations
- [ ] Validate inputs before database operations
This guide should be followed for all database interactions in the Tux codebase. Regular reviews should ensure these patterns are consistently applied.