diff --git a/migrations/001_create_certificates_table.sql b/migrations/001_create_certificates_table.sql deleted file mode 100644 index 009a89fa..00000000 --- a/migrations/001_create_certificates_table.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Migration: Create certificates table for tracking completed path certificates --- This table stores certificate records with verification codes - -CREATE TABLE IF NOT EXISTS certificates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - path_id INTEGER NOT NULL, - verification_code TEXT UNIQUE NOT NULL, - completion_date TIMESTAMP NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (path_id) REFERENCES paths(id) ON DELETE CASCADE, - UNIQUE(user_id, path_id) -); - --- Create index for fast verification code lookups -CREATE INDEX idx_certificates_verification_code ON certificates(verification_code); - --- Create index for user certificate retrieval -CREATE INDEX idx_certificates_user_id ON certificates(user_id); - --- Create index for path certificate retrieval -CREATE INDEX idx_certificates_path_id ON certificates(path_id); diff --git a/migrations/002_create_discussion_tables.sql b/migrations/002_create_discussion_tables.sql deleted file mode 100644 index a3fd5e58..00000000 --- a/migrations/002_create_discussion_tables.sql +++ /dev/null @@ -1,31 +0,0 @@ --- Migration: Create discussion threads and comments tables --- Enables community discussion for learning paths - -CREATE TABLE IF NOT EXISTS discussion_threads ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - title TEXT NOT NULL, - body TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - FOREIGN KEY (path_id) REFERENCES paths(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS thread_comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - thread_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - body TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - FOREIGN KEY (thread_id) REFERENCES discussion_threads(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - --- Create indexes for efficient queries -CREATE INDEX idx_threads_path_id ON discussion_threads(path_id); -CREATE INDEX idx_threads_user_id ON discussion_threads(user_id); -CREATE INDEX idx_threads_updated_at ON discussion_threads(updated_at DESC); -CREATE INDEX idx_comments_thread_id ON thread_comments(thread_id); -CREATE INDEX idx_comments_user_id ON thread_comments(user_id); diff --git a/migrations/003_create_bookmarks_table.sql b/migrations/003_create_bookmarks_table.sql deleted file mode 100644 index c44aaef6..00000000 --- a/migrations/003_create_bookmarks_table.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Migration: Create bookmarks table for authenticated users --- Stores bookmarks for paths, topics, and resources - -CREATE TABLE IF NOT EXISTS bookmarks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - resource_type TEXT NOT NULL, - resource_id INTEGER NOT NULL, - resource_name TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id, resource_type, resource_id) -); - --- Create indexes for efficient queries -CREATE INDEX idx_bookmarks_user_id ON bookmarks(user_id); -CREATE INDEX idx_bookmarks_resource_type ON bookmarks(resource_type); -CREATE INDEX idx_bookmarks_created_at ON bookmarks(created_at DESC); -CREATE INDEX idx_bookmarks_composite ON bookmarks(user_id, resource_type); diff --git a/migrations/004_create_progress_tables.sql b/migrations/004_create_progress_tables.sql deleted file mode 100644 index 51f1d801..00000000 --- a/migrations/004_create_progress_tables.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Migration: Create progress tracking tables --- Tracks user progress on topics and paths - -CREATE TABLE IF NOT EXISTS user_topic_progress ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - topic_id INTEGER NOT NULL, - completed INTEGER DEFAULT 0, - completed_at TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id, topic_id) -); - -CREATE TABLE IF NOT EXISTS user_activity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - topic_id INTEGER NOT NULL, - date DATE NOT NULL, - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id, topic_id, date) -); - --- Create indexes for efficient queries -CREATE INDEX idx_progress_user_id ON user_topic_progress(user_id); -CREATE INDEX idx_progress_completed ON user_topic_progress(completed); -CREATE INDEX idx_activity_user_id ON user_activity(user_id); -CREATE INDEX idx_activity_date ON user_activity(date); diff --git a/migrations/005_create_user_game_progress.sql b/migrations/005_create_user_game_progress.sql deleted file mode 100644 index 8e68d883..00000000 --- a/migrations/005_create_user_game_progress.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Migration: Create user game progress table --- Syncs gamification progress (searches, views, completions, badges) across devices - -CREATE TABLE IF NOT EXISTS user_game_progress ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL UNIQUE, - data TEXT NOT NULL DEFAULT '{}', - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX idx_game_progress_user_id ON user_game_progress(user_id); diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 00000000..6411f119 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,21 @@ +# Migrations + +DevPath does not use SQL migration files. The database schema is defined exclusively by +the SQLAlchemy models in `src/models.py` and is created with `db.create_all()` at startup +(see `src/app.py`, `src/seed_db.py`, and `tests/conftest.py`). + +## Intended data model (source of truth: `src/models.py`) + +- `projects` — the `Project` model +- `users` — the `User` model +- `project_progress` — the `ProjectProgress` model +- `user_game_progress` — the `UserGameProgress` model + +Learning paths are kept in memory (`src/utils/learning_path.py`), not in the database, so +no `paths` table exists. + +Hand-written SQL files previously placed in this directory referenced tables that no model +defines (including a dangling `FOREIGN KEY ... REFERENCES paths(id)` on a nonexistent +`paths` table) and could not be applied by any workflow. They were removed. If a real +migration workflow is ever needed, use Flask-Migrate/Alembic so the schema stays in sync +with `src/models.py`. diff --git a/tests/test_db_schema_consistency.py b/tests/test_db_schema_consistency.py new file mode 100644 index 00000000..eddcf16a --- /dev/null +++ b/tests/test_db_schema_consistency.py @@ -0,0 +1,53 @@ +# tests/test_db_schema_consistency.py +# Regression test for issue #1877: every table referenced anywhere in the schema +# (SQLAlchemy foreign keys and any hand-written migration SQL) must be a real table. + +import glob +import os +import re + +MODEL_TABLENAME_RE = re.compile(r"__tablename__\s*=\s*['\"]([^'\"]+)['\"]") +MODEL_FK_RE = re.compile(r"db\.ForeignKey\(['\"]([^'\"]+)['\"]\)") +SQL_CREATE_TABLE_RE = re.compile(r"CREATE TABLE\s+IF NOT EXISTS\s+(\w+)", re.IGNORECASE) +SQL_REFERENCES_RE = re.compile(r"REFERENCES\s+(\w+)\s*\(", re.IGNORECASE) + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _model_source(): + path = os.path.join(ROOT, "src", "models.py") + with open(path, encoding="utf-8") as f: + return f.read() + + +def _model_tables(): + return set(MODEL_TABLENAME_RE.findall(_model_source())) + + +def test_model_foreign_keys_reference_real_tables(): + """Every db.ForeignKey(...) in src/models.py must target a defined table.""" + tables = _model_tables() + content = _model_source() + refs = {m.group(1).split(".")[0] for m in MODEL_FK_RE.finditer(content)} + missing = sorted(refs - tables) + assert not missing, ( + "src/models.py declares foreign keys to undefined tables: " + f"{missing}" + ) + + +def test_sql_migrations_reference_only_known_tables(): + """Migration SQL may only reference model tables or tables created by + an earlier migration in the same set — no dangling foreign keys.""" + known = set(_model_tables()) + sql_files = sorted(glob.glob(os.path.join(ROOT, "migrations", "*.sql"))) + for path in sql_files: + with open(path, encoding="utf-8") as f: + content = f.read() + refs = set(SQL_REFERENCES_RE.findall(content)) + missing = sorted(refs - known) + assert not missing, ( + f"{os.path.relpath(path, ROOT)} references unknown tables: " + f"{missing}" + ) + known |= set(SQL_CREATE_TABLE_RE.findall(content))