INFO 153B/253B: Backend Web Architecture
Week 7
Kay Ashaolu - Instructor
Suk Min Hwang - GSI
Quick check-in on O'Reilly Chapters 6 & 9
Model.query# Our Week 3 API - data lost on restart!
items = [
{"id": 1, "name": "Chair", "price": 49.99},
{"id": 2, "name": "Table", "price": 149.99}
]
# Server restarts... all items gone!
# With SQLAlchemy - data persists forever
item = ItemModel.query.get(1) # Still there after restart!
INSERT INTO items (name, price)
VALUES ('Chair', 49.99);
SELECT * FROM items
WHERE price > 20;
item = ItemModel(
name='Chair',
price=49.99
)
db.session.add(item)
items = ItemModel.query.filter(
ItemModel.price > 20
).all()
| Term | Definition | Example |
|---|---|---|
| Model | Python class = database table | class ItemModel(db.Model) |
| Column | Table column definition | db.Column(db.String(80)) |
| Session | Staging area for changes | db.session.add(item) |
| Commit | Save changes to disk | db.session.commit() |
| Query | Retrieve data from database | ItemModel.query.all() |
add(), delete(), then commit()Model.query.filter().all()flask db migrate, flask db upgradeModels, columns, and relationships
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class BookModel(db.Model):
__tablename__ = "books" # Table name in database
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
author = db.Column(db.String(100), nullable=False)
year = db.Column(db.Integer)
isbn = db.Column(db.String(13), unique=True)
def to_dict(self):
return {
'id': self.id,
'title': self.title,
'author': self.author,
'year': self.year,
'isbn': self.isbn
}
| SQLAlchemy Type | Python Type | SQL Type |
|---|---|---|
db.Integer | int | INTEGER |
db.String(n) | str | VARCHAR(n) |
db.Text | str | TEXT |
db.Float | float | FLOAT |
db.Boolean | bool | BOOLEAN |
db.DateTime | datetime | DATETIME |
db.Date | date | DATE |
class ArticleModel(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text) # Unlimited text
views = db.Column(db.Integer, default=0)
published = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class UserModel(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
# Required field - cannot be NULL
email = db.Column(db.String(120), nullable=False, unique=True)
# Optional field with default
is_active = db.Column(db.Boolean, default=True)
# Field with index for faster queries
username = db.Column(db.String(80), index=True)
# Default value from function
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class StoreModel(db.Model):
__tablename__ = "stores"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80), nullable=False)
# One store has many items
items = db.relationship('ItemModel', back_populates='store', lazy='dynamic')
class ItemModel(db.Model):
__tablename__ = "items"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80), nullable=False)
price = db.Column(db.Float, nullable=False)
# Foreign key: links to stores.id
store_id = db.Column(db.Integer, db.ForeignKey('stores.id'), nullable=False)
# Relationship to access the store object
store = db.relationship('StoreModel', back_populates='items')
# Get a store's items
store = StoreModel.query.get(1)
for item in store.items:
print(f"{item.name}: ${item.price}")
# Get an item's store
item = ItemModel.query.get(5)
print(f"Sold at: {item.store.name}")
# Create an item for a store
new_item = ItemModel(
name="Laptop",
price=999.99,
store_id=store.id # Link via foreign key
)
db.session.add(new_item)
db.session.commit()
store.items returns all items for that storeitem.store returns the store object# Without lazy='dynamic' - all items loaded immediately
items = db.relationship('ItemModel', back_populates='store')
store = StoreModel.query.get(1)
# store.items is a list - all items already loaded!
# With lazy='dynamic' - query object returned
items = db.relationship('ItemModel', back_populates='store', lazy='dynamic')
store = StoreModel.query.get(1)
# store.items is a query - no items loaded yet!
# Now you can filter before loading
expensive = store.items.filter(ItemModel.price > 100).all()
cheap = store.items.filter(ItemModel.price < 20).all()
lazy='dynamic' for large collections!Create, Read, Update, Delete with SQLAlchemy
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
@app.route('/api/books', methods=['POST'])
def create_book():
data = request.get_json()
book = BookModel(
title=data['title'],
author=data['author'],
year=data.get('year'),
isbn=data.get('isbn')
)
try:
db.session.add(book) # Stage the new record
db.session.commit() # Save to database
except IntegrityError:
abort(400, description="A book with that ISBN already exists")
except SQLAlchemyError:
abort(500, description="An error occurred while creating the book")
return jsonify(book.to_dict()), 201
db.session.add() stages the object (not saved yet)db.session.commit() saves to diskIntegrityError for unique constraint violations# Get all books
@app.route('/api/books')
def get_books():
books = BookModel.query.all()
return jsonify([book.to_dict() for book in books])
# Get one book by ID (404 if not found)
@app.route('/api/books/<int:book_id>')
def get_book(book_id):
book = BookModel.query.get_or_404(book_id)
return jsonify(book.to_dict())
# Filter with conditions
@app.route('/api/books/search')
def search_books():
author = request.args.get('author')
query = BookModel.query
if author:
query = query.filter(BookModel.author.ilike(f'%{author}%'))
return jsonify([b.to_dict() for b in query.all()])
.all() returns a list of all matching records.get_or_404(id) returns one record or aborts with 404.filter() adds WHERE conditions.ilike() for case-insensitive LIKE queries@app.route('/api/books/<int:book_id>', methods=['PUT'])
def update_book(book_id):
book = BookModel.query.get_or_404(book_id)
data = request.get_json()
# Update only provided fields
book.title = data.get('title', book.title)
book.author = data.get('author', book.author)
book.year = data.get('year', book.year)
book.isbn = data.get('isbn', book.isbn)
db.session.commit() # No need to add() - it's already tracked
return jsonify(book.to_dict())
add() again - SQLAlchemy tracks changescommit() saves all changesdata.get('field', book.field) to keep existing value if not provided.
@app.route('/api/books/<int:book_id>', methods=['DELETE'])
def delete_book(book_id):
book = BookModel.query.get_or_404(book_id)
db.session.delete(book)
db.session.commit()
return jsonify({'message': 'Book deleted'}), 200
db.session.delete() marks for deletioncommit() actually removes from databaseclass StoreModel(db.Model):
__tablename__ = "stores"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80), nullable=False)
# cascade='all, delete' - delete items when store is deleted
items = db.relationship(
'ItemModel',
back_populates='store',
lazy='dynamic',
cascade='all, delete' # Delete orphans automatically
)
Version control for your database schema
books tablepage_count column# app.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///books.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
migrate = Migrate(app, db) # Connect Flask-Migrate
# Your models...
class BookModel(db.Model):
# ...
# Install
pip install flask-migrate
# Initialize migrations (run once)
flask db init
Migrate(app, db) connects Flask-Migrate to your appflask db init creates the migrations/ folder# 1. Initialize (only once per project)
flask db init
# 2. Create first migration (from empty database)
flask db migrate -m "Create books table"
# 3. Apply migration to database
flask db upgrade
# 4. Later: add a column to your model
# ... edit BookModel to add page_count column ...
# 5. Generate new migration
flask db migrate -m "Add page_count to books"
# 6. Apply the new migration
flask db upgrade
# migrations/versions/a1b2c3d4_add_page_count.py
revision = 'a1b2c3d4'
down_revision = '9z8y7x6w' # Previous migration
def upgrade():
# Apply change
op.add_column('books',
sa.Column('page_count', sa.Integer(), nullable=True)
)
def downgrade():
# Undo change
op.drop_column('books', 'page_count')
| Command | Description |
|---|---|
flask db init | Initialize migrations folder (once per project) |
flask db migrate -m "message" | Generate new migration from model changes |
flask db upgrade | Apply all pending migrations |
flask db downgrade | Revert the last migration |
flask db current | Show current revision |
flask db history | Show migration history |
-m "message" with migrate.
Like git commits, messages help you understand changes later!
# Problem: Adding a NOT NULL column with default
# Existing rows would have NULL, but we want a default value
def upgrade():
# 1. Add column as nullable first
op.add_column('books',
sa.Column('status', sa.String(20), nullable=True)
)
# 2. Set default value for existing rows
op.execute("UPDATE books SET status = 'available'")
# 3. Now make it NOT NULL (optional)
# op.alter_column('books', 'status', nullable=False)
Production-ready database patterns
app.config['SQLALCHEMY_DATABASE_URI'] = \
'sqlite:///books.db'
app.config['SQLALCHEMY_DATABASE_URI'] = \
'postgresql://user:pass@host/db'
import os
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv(
'DATABASE_URL',
'sqlite:///books.db' # Default for development
)
# .env file (never commit this!)
DATABASE_URL=postgresql://user:password@localhost/myapp
# Or set in your environment
export DATABASE_URL=postgresql://user:password@localhost/myapp
.env to .gitignore!class BookModel(db.Model):
__tablename__ = "books"
id = db.Column(db.Integer, primary_key=True)
# Indexed: fast lookups by ISBN
isbn = db.Column(db.String(13), unique=True, index=True)
# Indexed: fast search by author
author = db.Column(db.String(100), index=True)
# Not indexed: rarely searched alone
page_count = db.Column(db.Integer)
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
@app.route('/api/books', methods=['POST'])
def create_book():
data = request.get_json()
try:
book = BookModel(**data)
db.session.add(book)
db.session.commit()
return jsonify(book.to_dict()), 201
except IntegrityError:
db.session.rollback() # Undo any partial changes
return jsonify({'error': 'Book with that ISBN exists'}), 400
except SQLAlchemyError as e:
db.session.rollback()
return jsonify({'error': 'Database error'}), 500
Key takeaways from today
| Week | Topic | Building On |
|---|---|---|
| 8 | Async Task Queues | Background jobs with Celery + Redis |
| 9 | System Design Theory | Scalability, reliability, CAP theorem |
| 10 | System Design Building Blocks | Load balancers, caches, queues |
SQLAlchemy + Flask-Migrate
BookModel with CRUD routesLibrary Book Manager
git clone <your-repo-url>
cd in-class-exploration-week-7-<your-username>
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
python app.py
curl http://localhost:5000/api/books
curl -X POST http://localhost:5000/api/books \
-H "Content-Type: application/json" \
-d '{"title": "Clean Code", "author": "Robert Martin"}'
# Restart server... book is gone!
# Create a book
curl -X POST http://localhost:5000/api/books \
-H "Content-Type: application/json" \
-d '{"title": "Clean Code", "author": "Robert Martin", "year": 2008}'
# Get all books
curl http://localhost:5000/api/books
# Restart the server (Ctrl+C, then python app.py)
# Get books again - should still be there!
curl http://localhost:5000/api/books
git add .
git commit -m "In-class exploration submission"
git push
Want to keep working? Continue after submitting - just push additional changes.
Website: groups.ischool.berkeley.edu/i253/sp26
Email: kay@ischool.berkeley.edu