Initial commit
This commit is contained in:
67
migrations/001_initial_schema.sql
Normal file
67
migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
-- Migration 001: Initial Schema
|
||||
-- Creates the core tables for the weight tracker app
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(100),
|
||||
height_cm DECIMAL(5,1),
|
||||
age INTEGER,
|
||||
gender VARCHAR(20),
|
||||
goal_weight_kg DECIMAL(5,1),
|
||||
starting_weight_kg DECIMAL(5,1),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Check-ins table
|
||||
CREATE TABLE IF NOT EXISTS checkins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
weight_kg DECIMAL(5,1) NOT NULL,
|
||||
bmi DECIMAL(4,1),
|
||||
notes TEXT,
|
||||
checked_in_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Milestones table
|
||||
CREATE TABLE IF NOT EXISTS milestones (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
milestone_key VARCHAR(50) NOT NULL,
|
||||
achieved_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(user_id, milestone_key)
|
||||
);
|
||||
|
||||
-- Challenges table
|
||||
CREATE TABLE IF NOT EXISTS challenges (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
target_type VARCHAR(30) NOT NULL,
|
||||
target_value DECIMAL(8,2) NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Challenge participants
|
||||
CREATE TABLE IF NOT EXISTS challenge_participants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
challenge_id INTEGER REFERENCES challenges(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
completed BOOLEAN DEFAULT FALSE,
|
||||
completed_at TIMESTAMP,
|
||||
UNIQUE(challenge_id, user_id)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_checkins_user_date ON checkins(user_id, checked_in_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_milestones_user ON milestones(user_id);
|
||||
|
||||
-- Migrations tracking table
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
106
migrations/runner.py
Normal file
106
migrations/runner.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Database Migration Runner
|
||||
|
||||
Reads SQL migration files from the migrations/ directory, checks which
|
||||
have already been applied via the schema_migrations table, and runs
|
||||
any unapplied migrations in order.
|
||||
|
||||
Usage:
|
||||
python migrations/runner.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
def get_migration_files(migrations_dir):
|
||||
"""Find all SQL migration files and return sorted by version number."""
|
||||
pattern = re.compile(r"^(\d+)_.+\.sql$")
|
||||
files = []
|
||||
for filename in os.listdir(migrations_dir):
|
||||
match = pattern.match(filename)
|
||||
if match:
|
||||
version = int(match.group(1))
|
||||
files.append((version, filename))
|
||||
return sorted(files, key=lambda x: x[0])
|
||||
|
||||
|
||||
def ensure_migrations_table(conn):
|
||||
"""Create schema_migrations table if it doesn't exist."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_applied_versions(conn):
|
||||
"""Get set of already-applied migration versions."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT version FROM schema_migrations ORDER BY version")
|
||||
return {row[0] for row in cur.fetchall()}
|
||||
|
||||
|
||||
def run_migration(conn, version, filepath):
|
||||
"""Run a single migration file inside a transaction."""
|
||||
print(f" Applying migration {version}: {os.path.basename(filepath)}...")
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
sql = f.read()
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
cur.execute(
|
||||
"INSERT INTO schema_migrations (version) VALUES (%s)",
|
||||
(version,),
|
||||
)
|
||||
conn.commit()
|
||||
print(f" ✓ Migration {version} applied successfully")
|
||||
|
||||
|
||||
def main():
|
||||
load_dotenv()
|
||||
database_url = os.environ.get("DATABASE_URL")
|
||||
if not database_url:
|
||||
print("ERROR: DATABASE_URL not set in .env")
|
||||
sys.exit(1)
|
||||
|
||||
# Determine migrations directory
|
||||
migrations_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
print(f"Connecting to database...")
|
||||
conn = psycopg2.connect(database_url)
|
||||
|
||||
try:
|
||||
ensure_migrations_table(conn)
|
||||
applied = get_applied_versions(conn)
|
||||
migrations = get_migration_files(migrations_dir)
|
||||
|
||||
pending = [(v, f) for v, f in migrations if v not in applied]
|
||||
|
||||
if not pending:
|
||||
print("All migrations are up to date.")
|
||||
return
|
||||
|
||||
print(f"Found {len(pending)} pending migration(s):")
|
||||
for version, filename in pending:
|
||||
filepath = os.path.join(migrations_dir, filename)
|
||||
run_migration(conn, version, filepath)
|
||||
|
||||
print("\nAll migrations applied successfully!")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"\nERROR: Migration failed: {e}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user