Working with Data 🗄️¶
This tutorial covers data handling in Tatami applications - from simple in-memory storage to full database integration with SQLAlchemy, plus validation, serialization, and best practices.
Data Layer Architecture¶
Tatami promotes a clean separation of concerns:
🎯 Routers: Handle HTTP requests/responses 🧠 Services: Business logic and validation 🗄️ Repositories: Data access and persistence 📋 Models: Data structures and validation
This pattern keeps your code organized and testable.
Setting Up Data Models¶
Pydantic Models for API¶
Use Pydantic models for request/response validation:
# models/user_models.py
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field, EmailStr
class UserBase(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: EmailStr
age: Optional[int] = Field(None, ge=13, le=120)
class UserCreate(UserBase):
password: str = Field(min_length=8)
class UserUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
email: Optional[EmailStr] = None
age: Optional[int] = Field(None, ge=13, le=120)
class UserResponse(UserBase):
id: int
created_at: datetime
updated_at: datetime
is_active: bool = True
class UserList(BaseModel):
users: List[UserResponse]
total: int
page: int
page_size: int
Database Models with SQLAlchemy¶
For database persistence, use SQLAlchemy:
# models/database.py
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
email = Column(String(255), unique=True, index=True, nullable=False)
password_hash = Column(String(255), nullable=False)
age = Column(Integer, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
In-Memory Data Storage¶
For simple applications or prototyping, start with in-memory storage:
# services/user_service.py
from typing import List, Optional, Dict
from datetime import datetime
from tatami.di import injectable
from models.user_models import UserCreate, UserUpdate, UserResponse
@injectable
class InMemoryUserService:
def __init__(self):
self.users: Dict[int, dict] = {}
self.next_id = 1
def create_user(self, user_data: UserCreate) -> UserResponse:
# Check if email already exists
if any(u["email"] == user_data.email for u in self.users.values()):
raise ValueError("Email already registered")
# Create user
user = {
"id": self.next_id,
"name": user_data.name,
"email": user_data.email,
"age": user_data.age,
"is_active": True,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow(),
}
self.users[self.next_id] = user
self.next_id += 1
return UserResponse(**user)
def get_user(self, user_id: int) -> Optional[UserResponse]:
user = self.users.get(user_id)
return UserResponse(**user) if user else None
def get_users(self, skip: int = 0, limit: int = 100) -> List[UserResponse]:
users = list(self.users.values())[skip:skip + limit]
return [UserResponse(**user) for user in users]
def update_user(self, user_id: int, updates: UserUpdate) -> Optional[UserResponse]:
user = self.users.get(user_id)
if not user:
return None
# Update only provided fields
update_data = updates.dict(exclude_unset=True)
for key, value in update_data.items():
user[key] = value
user["updated_at"] = datetime.utcnow()
return UserResponse(**user)
def delete_user(self, user_id: int) -> bool:
return self.users.pop(user_id, None) is not None
Database Integration¶
Setting Up SQLAlchemy¶
First, install the required packages:
pip install sqlalchemy databases[sqlite] alembic
Create database configuration:
# config/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models.database import Base
# Database URL (SQLite for development)
DATABASE_URL = "sqlite:///./app.db"
# Create engine
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create tables
def create_tables():
Base.metadata.create_all(bind=engine)
# Dependency to get database session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
Repository Pattern¶
Create a repository for data access:
# repositories/user_repository.py
from typing import List, Optional
from sqlalchemy.orm import Session
from models.database import User
from models.user_models import UserCreate, UserUpdate
class UserRepository:
def __init__(self, db: Session):
self.db = db
def create(self, user_data: UserCreate) -> User:
# Hash password (use bcrypt in real applications)
password_hash = self._hash_password(user_data.password)
db_user = User(
name=user_data.name,
email=user_data.email,
age=user_data.age,
password_hash=password_hash
)
self.db.add(db_user)
self.db.commit()
self.db.refresh(db_user)
return db_user
def get_by_id(self, user_id: int) -> Optional[User]:
return self.db.query(User).filter(User.id == user_id).first()
def get_by_email(self, email: str) -> Optional[User]:
return self.db.query(User).filter(User.email == email).first()
def get_all(self, skip: int = 0, limit: int = 100) -> List[User]:
return self.db.query(User).offset(skip).limit(limit).all()
def update(self, user_id: int, updates: UserUpdate) -> Optional[User]:
user = self.get_by_id(user_id)
if not user:
return None
update_data = updates.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(user, key, value)
self.db.commit()
self.db.refresh(user)
return user
def delete(self, user_id: int) -> bool:
user = self.get_by_id(user_id)
if user:
self.db.delete(user)
self.db.commit()
return True
return False
def _hash_password(self, password: str) -> str:
# In real applications, use bcrypt or similar
import hashlib
return hashlib.sha256(password.encode()).hexdigest()
Service Layer with Database¶
Update your service to use the repository:
# services/user_service.py
from typing import List, Optional
from sqlalchemy.orm import Session
from tatami.di import injectable
from repositories.user_repository import UserRepository
from models.user_models import UserCreate, UserUpdate, UserResponse
@injectable
class UserService:
def __init__(self, db: Session):
self.user_repo = UserRepository(db)
def create_user(self, user_data: UserCreate) -> UserResponse:
# Check if email exists
if self.user_repo.get_by_email(user_data.email):
raise ValueError("Email already registered")
# Create user
user = self.user_repo.create(user_data)
return self._to_response_model(user)
def get_user(self, user_id: int) -> Optional[UserResponse]:
user = self.user_repo.get_by_id(user_id)
return self._to_response_model(user) if user else None
def get_users(self, skip: int = 0, limit: int = 100) -> List[UserResponse]:
users = self.user_repo.get_all(skip=skip, limit=limit)
return [self._to_response_model(user) for user in users]
def update_user(self, user_id: int, updates: UserUpdate) -> Optional[UserResponse]:
user = self.user_repo.update(user_id, updates)
return self._to_response_model(user) if user else None
def delete_user(self, user_id: int) -> bool:
return self.user_repo.delete(user_id)
def _to_response_model(self, user) -> UserResponse:
return UserResponse(
id=user.id,
name=user.name,
email=user.email,
age=user.age,
is_active=user.is_active,
created_at=user.created_at,
updated_at=user.updated_at
)
Router with Database Integration¶
Create a router that uses the database-backed service:
# routers/users.py
from typing import List
from tatami import router, get, post, put, delete
from tatami.param import Query
from sqlalchemy.orm import Session
from config.database import get_db
from services.user_service import UserService
from models.user_models import UserCreate, UserUpdate, UserResponse, UserList
class Users(router('/users')):
"""User management with database backend"""
@get
def list_users(
self,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db)
) -> UserList:
"""Get paginated list of users"""
user_service = UserService(db)
users = user_service.get_users(skip=skip, limit=limit)
# Get total count for pagination
total = len(user_service.get_users(skip=0, limit=1000)) # Simple approach
return UserList(
users=users,
total=total,
page=skip // limit + 1,
page_size=limit
)
@get('/{user_id}')
def get_user(self, user_id: int, db: Session = Depends(get_db)) -> UserResponse:
"""Get user by ID"""
user_service = UserService(db)
user = user_service.get_user(user_id)
if not user:
return {"error": "User not found"}, 404
return user
@post('/')
def create_user(self, user: UserCreate, db: Session = Depends(get_db)) -> UserResponse:
"""Create a new user"""
user_service = UserService(db)
try:
return user_service.create_user(user)
except ValueError as e:
return {"error": str(e)}, 400
@put('/{user_id}')
def update_user(
self,
user_id: int,
updates: UserUpdate,
db: Session = Depends(get_db)
) -> UserResponse:
"""Update an existing user"""
user_service = UserService(db)
user = user_service.update_user(user_id, updates)
if not user:
return {"error": "User not found"}, 404
return user
@delete('/{user_id}')
def delete_user(self, user_id: int, db: Session = Depends(get_db)) -> dict:
"""Delete a user"""
user_service = UserService(db)
if user_service.delete_user(user_id):
return {"message": "User deleted successfully"}
return {"error": "User not found"}, 404
Data Validation & Serialization¶
Custom Validators¶
Create custom validation functions:
# validators/user_validators.py
from pydantic import validator
import re
class UserCreate(BaseModel):
name: str
email: EmailStr
password: str
@validator('name')
def validate_name(cls, v):
if not v.strip():
raise ValueError('Name cannot be empty')
if len(v) < 2:
raise ValueError('Name must be at least 2 characters')
return v.title() # Capitalize
@validator('password')
def validate_password(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters')
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain lowercase letter')
if not re.search(r'\d', v):
raise ValueError('Password must contain a number')
return v
Response Serialization¶
Control what data is returned:
class UserResponse(BaseModel):
id: int
name: str
email: str
age: Optional[int]
is_active: bool
created_at: datetime
class Config:
# Automatically convert SQLAlchemy models
from_attributes = True
# Don't include password hash
exclude = {'password_hash'}
Advanced Database Operations¶
Relationships and Joins¶
Define relationships between models:
# models/database.py
from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
email = Column(String(255), unique=True, nullable=False)
# Relationship to posts
posts = relationship("Post", back_populates="author")
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True)
title = Column(String(200), nullable=False)
content = Column(Text, nullable=False)
author_id = Column(Integer, ForeignKey("users.id"))
# Relationship to user
author = relationship("User", back_populates="posts")
Query Operations¶
Implement complex queries:
# repositories/post_repository.py
class PostRepository:
def __init__(self, db: Session):
self.db = db
def get_posts_by_user(self, user_id: int) -> List[Post]:
return self.db.query(Post).filter(Post.author_id == user_id).all()
def search_posts(self, query: str) -> List[Post]:
return self.db.query(Post).filter(
Post.title.ilike(f"%{query}%") |
Post.content.ilike(f"%{query}%")
).all()
def get_popular_posts(self, limit: int = 10) -> List[Post]:
# Complex query with joins and aggregations
return self.db.query(Post)\
.join(User)\
.order_by(Post.created_at.desc())\
.limit(limit)\
.all()
Database Migrations¶
Use Alembic for database schema management:
# Initialize Alembic
alembic init alembic
# Create migration
alembic revision --autogenerate -m "Create users table"
# Apply migration
alembic upgrade head
Example migration file:
# alembic/versions/001_create_users.py
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table('users',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('email', sa.String(255), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
)
op.create_index('ix_users_email', 'users', ['email'], unique=True)
def downgrade():
op.drop_index('ix_users_email', table_name='users')
op.drop_table('users')
Testing Data Layer¶
Test your data operations:
# tests/test_user_service.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models.database import Base
from services.user_service import UserService
from models.user_models import UserCreate
@pytest.fixture
def test_db():
# Create in-memory SQLite database for testing
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine)
return SessionLocal()
def test_create_user(test_db):
user_service = UserService(test_db)
user_data = UserCreate(
name="Test User",
email="test@example.com",
password="testpass123"
)
user = user_service.create_user(user_data)
assert user.name == "Test User"
assert user.email == "test@example.com"
assert user.id is not None
Best Practices¶
🔐 Security¶
Never store plain text passwords
Use parameterized queries to prevent SQL injection
Validate and sanitize all input data
Implement proper access controls
📊 Performance¶
Use database indexes on frequently queried columns
Implement pagination for large datasets
Use lazy loading for relationships
Consider caching for read-heavy operations
🧪 Testing¶
Use separate test databases
Mock external dependencies
Test edge cases and error conditions
Use factories for test data generation
🏗️ Architecture¶
Keep business logic in services
Use repositories for data access
Separate read and write models if needed
Consider CQRS for complex domains
What’s Next?¶
You now know how to handle data in Tatami! Next topics:
Dependency injection for better service management
Middleware for request processing
Testing strategies for data operations
Advanced patterns like CQRS and event sourcing
Keep building great data-driven APIs! 📊