|
| 1 | +# Database Migration: SQLite → Cosmos DB PostgreSQL |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +The todo-app currently uses SQLite (in-memory/local file). A Cosmos DB for PostgreSQL |
| 6 | +instance has been pre-staged and is ready to connect. This guide covers how to wire it up. |
| 7 | + |
| 8 | +## Pre-Staged Infrastructure |
| 9 | + |
| 10 | +| Setting | Value | |
| 11 | +|---------|-------| |
| 12 | +| Cluster | `todo-app-db` | |
| 13 | +| Host | `c-todo-app-db.qhb3l52muuda2z.postgres.cosmos.azure.com` | |
| 14 | +| Port | `5432` | |
| 15 | +| Database | `citus` (default) | |
| 16 | +| Admin user | `citus` | |
| 17 | +| Password | `PSEA-Lab-2026!` | |
| 18 | +| SSL | Required | |
| 19 | +| Resource Group | `rg-todo-app` | |
| 20 | +| Firewall | Azure services + all IPs (workshop only) | |
| 21 | + |
| 22 | +## Connection String |
| 23 | + |
| 24 | +``` |
| 25 | +postgresql://citus:PSEA-Lab-2026!@c-todo-app-db.qhb3l52muuda2z.postgres.cosmos.azure.com:5432/citus?sslmode=require |
| 26 | +``` |
| 27 | + |
| 28 | +## Step 1: Add psycopg2 to requirements.txt |
| 29 | + |
| 30 | +``` |
| 31 | +psycopg2-binary==2.9.9 |
| 32 | +``` |
| 33 | + |
| 34 | +## Step 2: Update app.py — Database Connection |
| 35 | + |
| 36 | +Replace the SQLite connection logic with: |
| 37 | + |
| 38 | +```python |
| 39 | +import os |
| 40 | +import psycopg2 |
| 41 | +from psycopg2.extras import RealDictCursor |
| 42 | + |
| 43 | +DATABASE_URL = os.environ.get( |
| 44 | + "DATABASE_URL", |
| 45 | + "sqlite:///todos.db" # fallback to SQLite for local dev |
| 46 | +) |
| 47 | + |
| 48 | +def get_db(): |
| 49 | + """Get a database connection.""" |
| 50 | + if DATABASE_URL.startswith("postgresql"): |
| 51 | + conn = psycopg2.connect(DATABASE_URL, cursor_factory=RealDictCursor) |
| 52 | + conn.autocommit = True |
| 53 | + return conn |
| 54 | + else: |
| 55 | + # Original SQLite logic |
| 56 | + import sqlite3 |
| 57 | + conn = sqlite3.connect("todos.db") |
| 58 | + conn.row_factory = sqlite3.Row |
| 59 | + return conn |
| 60 | +``` |
| 61 | + |
| 62 | +## Step 3: Update Schema (PostgreSQL version) |
| 63 | + |
| 64 | +Create `schema.sql`: |
| 65 | + |
| 66 | +```sql |
| 67 | +CREATE TABLE IF NOT EXISTS todos ( |
| 68 | + id SERIAL PRIMARY KEY, |
| 69 | + title TEXT NOT NULL, |
| 70 | + description TEXT DEFAULT '', |
| 71 | + completed BOOLEAN DEFAULT FALSE, |
| 72 | + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| 73 | +); |
| 74 | +``` |
| 75 | + |
| 76 | +Add an init function: |
| 77 | + |
| 78 | +```python |
| 79 | +def init_db(): |
| 80 | + """Initialize the database schema.""" |
| 81 | + conn = get_db() |
| 82 | + if DATABASE_URL.startswith("postgresql"): |
| 83 | + cur = conn.cursor() |
| 84 | + cur.execute(""" |
| 85 | + CREATE TABLE IF NOT EXISTS todos ( |
| 86 | + id SERIAL PRIMARY KEY, |
| 87 | + title TEXT NOT NULL, |
| 88 | + description TEXT DEFAULT '', |
| 89 | + completed BOOLEAN DEFAULT FALSE, |
| 90 | + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| 91 | + ) |
| 92 | + """) |
| 93 | + cur.close() |
| 94 | + conn.close() |
| 95 | + else: |
| 96 | + # Original SQLite init |
| 97 | + pass |
| 98 | +``` |
| 99 | + |
| 100 | +## Step 4: Update SQL Queries |
| 101 | + |
| 102 | +Key differences from SQLite to PostgreSQL: |
| 103 | + |
| 104 | +| SQLite | PostgreSQL | |
| 105 | +|--------|-----------| |
| 106 | +| `INTEGER PRIMARY KEY AUTOINCREMENT` | `SERIAL PRIMARY KEY` | |
| 107 | +| `?` parameter placeholder | `%s` parameter placeholder | |
| 108 | +| `datetime('now')` | `CURRENT_TIMESTAMP` | |
| 109 | +| `BOOLEAN` stored as 0/1 | `BOOLEAN` stored as true/false | |
| 110 | + |
| 111 | +Example — insert: |
| 112 | +```python |
| 113 | +# SQLite |
| 114 | +cur.execute("INSERT INTO todos (title) VALUES (?)", (title,)) |
| 115 | + |
| 116 | +# PostgreSQL |
| 117 | +cur.execute("INSERT INTO todos (title) VALUES (%s) RETURNING id", (title,)) |
| 118 | +``` |
| 119 | + |
| 120 | +Example — fetch: |
| 121 | +```python |
| 122 | +# SQLite (returns sqlite3.Row) |
| 123 | +todos = cur.execute("SELECT * FROM todos").fetchall() |
| 124 | + |
| 125 | +# PostgreSQL (returns dicts via RealDictCursor) |
| 126 | +cur.execute("SELECT * FROM todos ORDER BY created_at DESC") |
| 127 | +todos = cur.fetchall() |
| 128 | +``` |
| 129 | + |
| 130 | +## Step 5: Update Dockerfile |
| 131 | + |
| 132 | +Add PostgreSQL client library: |
| 133 | + |
| 134 | +```dockerfile |
| 135 | +# Add before pip install |
| 136 | +RUN apt-get update && apt-get install -y libpq-dev && rm -rf /var/lib/apt/lists/* |
| 137 | +``` |
| 138 | + |
| 139 | +## Step 6: Set Environment Variable on Container App |
| 140 | + |
| 141 | +```bash |
| 142 | +az containerapp update \ |
| 143 | + --resource-group rg-todo-app \ |
| 144 | + --name todo-app \ |
| 145 | + --set-env-vars \ |
| 146 | + DATABASE_URL="postgresql://citus:PSEA-Lab-2026!@c-todo-app-db.qhb3l52muuda2z.postgres.cosmos.azure.com:5432/citus?sslmode=require" |
| 147 | +``` |
| 148 | + |
| 149 | +## Step 7: Deploy |
| 150 | + |
| 151 | +Push to main — CI/CD will build and deploy automatically. The app will: |
| 152 | +1. Read `DATABASE_URL` from environment |
| 153 | +2. Connect to Cosmos DB PostgreSQL |
| 154 | +3. Create the `todos` table if it doesn't exist |
| 155 | +4. All todos persist across redeploys |
| 156 | + |
| 157 | +## Testing Locally |
| 158 | + |
| 159 | +You can connect to the database from any student VM or local machine: |
| 160 | + |
| 161 | +```bash |
| 162 | +# Using psql |
| 163 | +psql "postgresql://citus:PSEA-Lab-2026!@c-todo-app-db.qhb3l52muuda2z.postgres.cosmos.azure.com:5432/citus?sslmode=require" |
| 164 | + |
| 165 | +# Test query |
| 166 | +SELECT * FROM todos; |
| 167 | +``` |
| 168 | + |
| 169 | +Or run the app locally with the env var: |
| 170 | +```bash |
| 171 | +export DATABASE_URL="postgresql://citus:PSEA-Lab-2026!@c-todo-app-db.qhb3l52muuda2z.postgres.cosmos.azure.com:5432/citus?sslmode=require" |
| 172 | +python app.py |
| 173 | +``` |
| 174 | + |
| 175 | +Without `DATABASE_URL`, the app falls back to SQLite for local development. |
| 176 | + |
| 177 | +## Workshop Notes |
| 178 | + |
| 179 | +This migration is a great exercise for students — it touches: |
| 180 | +- Environment variables and configuration |
| 181 | +- SQL dialect differences |
| 182 | +- Docker dependencies |
| 183 | +- Cloud database connectivity |
| 184 | +- CI/CD (change deploys automatically) |
| 185 | + |
| 186 | +Consider making this a Sprint 1 issue for students to tackle with Claude Code. |
| 187 | + |
| 188 | +## Cleanup |
| 189 | + |
| 190 | +After the workshop, delete the database to stop charges: |
| 191 | +```bash |
| 192 | +az cosmosdb postgres cluster delete \ |
| 193 | + --resource-group rg-todo-app \ |
| 194 | + --cluster-name todo-app-db \ |
| 195 | + --yes |
| 196 | +``` |
0 commit comments