# Phase 2 - Model Layer Test Report **Date:** 2026-07-23 **Scope:** SQLAlchemy model definitions vs. Alembic migration schema **Status:** ❌ FAIL (2 Critical, 3 Major, 4 Minor issues) --- ## Summary | Category | Count | |----------|-------| | Critical | 2 | | Major | 3 | | Minor | 4 | | **Total Issues** | **9** | | Models Verified | 28 classes across 7 files | | Migrations Verified | 6 files (000–005) | | Tables Verified | 27 tables | **Overall: FAIL** — Two critical issues will prevent the application from starting: 1. A circular import between `models.py` and `mirror_models.py` will cause `ImportError` at runtime 2. Migration `005` imports models to extract column definitions, triggering the same circular import --- ## Issues Found ### CRITICAL #### Issue #1: Circular Import — `models.py` ↔ `mirror_models.py` - **File:** `app/models/models.py` (line 209) and `app/models/mirror_models.py` (line 100) - **Severity:** Critical - **Description:** `models.py` imports `MtgCardMirror` and `DeckCardLink` from `mirror_models.py` at the top of the file. `mirror_models.py` imports `DecklistFile` from `models.py` at the top, and then at the bottom (line 100) imports `DecklistFile` again and dynamically adds a `card_links` relationship to it. This creates a circular import chain: ``` models.py → mirror_models.py → models.py (circular!) ``` - **Impact:** Any code that imports from either `models.py` or `mirror_models.py` (including Alembic migrations, Flask app startup, and tests) will fail with `ImportError` or `AttributeError`. - **Recommendation:** Restructure the import. Move the dynamic `DecklistFile.card_links` relationship addition to a separate initialization file (e.g., `app/models/relationships.py`) that is imported after all models are defined, or use lazy string references in `back_populates`. #### Issue #2: Migration `005_missing_tables.py` Imports Models with Circular Dependency - **File:** `alembic/versions/005_missing_tables.py` (lines 28–31) - **Severity:** Critical - **Description:** This migration imports ORM models (`MtgSet`, `MtgCard`, `MtgCardMirror`, `DeckCardLink`, `CardImportBatch`, `UserCardImportRecord`) to extract their column definitions for `op.create_table()`. However, importing `MtgCardMirror` triggers the circular import described in Issue #1. ```python from app.models.mtg_models import MtgSet, MtgCard from app.models.mirror_models import MtgCardMirror, DeckCardLink from app.models.card_import_batch import CardImportBatch from app.models.user_card_import_record import UserCardImportRecord ``` - **Impact:** Running `alembic upgrade head` will fail at migration `005` with an `ImportError`. The database cannot be brought online. - **Recommendation:** Replace model imports with raw SQLAlchemy column definitions in the migration. Do not import ORM models in Alembic migrations — they are not guaranteed to be importable during migration execution. --- ### MAJOR #### Issue #3: Orphaned Migration `004_card_import_table.py` — No Corresponding Model - **File:** `alembic/versions/004_card_import_table.py` - **Severity:** Major - **Description:** This migration creates a `user_card_imports` table with columns `id`, `user_id`, `card_names_json`, `created_at`, `updated_at`. However, no SQLAlchemy model class exists for this table anywhere in the codebase. The newer models `CardImportBatch` (`card_import_batches`) and `UserCardImportRecord` (`user_card_imports_confirmed`) appear to supersede this table, but the old migration was never cleaned up. - **Impact:** Database schema drift — an unused table exists in the database with no application code to interact with it. - **Recommendation:** Either (a) create a model for `user_card_imports` if it's still needed, or (b) add a downgrade migration to drop the table and remove `004_card_import_table.py`. #### Issue #4: `MtgCardMirror.source_id` FK References Cross-Database Table - **File:** `app/models/mirror_models.py` (line 33) - **Severity:** Major - **Description:** `MtgCardMirror.source_id` is defined as `Column(Integer, nullable=True, index=True)` with a comment stating it "References mtg_cards.id". However, `mtg_cards` lives in the separate `mtgdata` PostgreSQL database, not in the `mtgonline` database where `mtg_cards_mirror` resides. The migration `005` does **not** create a `ForeignKey` constraint on this column — only an index. The model also lacks a `ForeignKey` definition. - **Impact:** No referential integrity enforcement. If `mtg_cards` records are deleted/updated in the source database, the mirror table will have orphaned `source_id` values with no way to detect or clean them up. - **Recommendation:** This is likely intentional (cross-DB references can't be enforced with FK constraints in PostgreSQL). Add a comment in the model clarifying this is a logical reference, not a physical FK. Consider adding a periodic sync validation job. #### Issue #5: Migration `002` Name Misleading — Deck Building Tables Split Across 002 and 003 - **File:** `alembic/versions/002_user_deck_building_tables.py` and `003_mtgonline_cards_table.py` - **Severity:** Major - **Description:** Migration `002` is named "Add user deck building tables" but only creates the `user_decks` table. Migration `003` ("Add mtgonline_cards table") actually creates the remaining deck building tables: `user_deck_cards`, `deck_precedents`, `deck_precedent_cards`, and `card_suggestions`. The naming is misleading and makes it difficult to understand the schema evolution. - **Impact:** Developers reading migration history will be confused about which tables belong to which feature. - **Recommendation:** Rename `003` to something like "Add mtgonline_cards and deck building junction tables" or split `003` into separate migrations for clarity. --- ### MINOR #### Issue #6: `MtonlineCard` Typo in Class Name - **File:** `app/models/models.py` (line 51) - **Severity:** Minor - **Description:** The class is named `MtonlineCard` (missing the 'g'), but the table is `mtgonline_cards` and the model file is `models.py`. The correct name should be `MtgonlineCard`. This typo is used consistently throughout the codebase (e.g., in `user_deck.py` line 18), so changing it would require updating all references. - **Impact:** Code readability and consistency. No functional impact since the `__tablename__` is correct. - **Recommendation:** Rename to `MtgonlineCard` across all files (`models.py`, `user_deck.py`, `__init__.py`). #### Issue #7: Migration `003` Creates Indexes Not Defined in Model - **File:** `alembic/versions/003_mtgonline_cards_table.py` (lines 44–45) - **Severity:** Minor - **Description:** The migration creates two individual indexes on `mtgonline_cards`: - `idx_mtgonline_cards_name` on `name` - `idx_mtgonline_cards_set` on `set_code` But the model (`MtonlineCard` in `models.py`) does not define these as SQLAlchemy `Index` objects. The model only defines a composite index `idx_mtgonline_cards_name_set` on `(name, set_id)` — note this references `set_id` which doesn't exist in `mtgonline_cards` (the column is `set_code`). - **Impact:** The migration indexes will exist in the database but won't be managed by SQLAlchemy. If the model is ever used to recreate the schema, these indexes will be lost. - **Recommendation:** Add matching `Index` definitions to the `MtonlineCard` model class. #### Issue #8: `UserDeck.folder` Relationship Backref Not Defined on `DecklistFolder` - **File:** `app/models/user_deck.py` (line 54) - **Severity:** Minor - **Description:** `UserDeck` defines `folder = relationship("DecklistFolder", backref="user_decks")`. However, `DecklistFolder` in `models.py` does not define a corresponding `user_decks` relationship or backref. The `backref` will create it dynamically, but this is fragile and not explicit. - **Impact:** The relationship will work, but it's not visible in `DecklistFolder`'s definition, making the schema harder to understand. - **Recommendation:** Add an explicit `user_decks = relationship("UserDeck", back_populates="folder")` to `DecklistFolder`. #### Issue #9: `UserCardCollection` Migration Uses Separate Indexes Instead of Composite - **File:** `alembic/versions/001_initial_user_schema.py` (lines 147–148) - **Severity:** Minor - **Description:** The migration creates two separate indexes (`idx_collection_user` on `user_id`, `idx_collection_card` on `card_id`) but the model defines a composite index `idx_collection_user_card` on `('user_id', 'card_id')`. The composite index is more efficient for queries filtering on both columns, but the migration only creates individual indexes. - **Impact:** Slightly suboptimal query performance. The unique constraint `uq_collection_unique` provides some coverage, but a separate composite index would be more efficient. - **Recommendation:** Update the migration to create the composite index `idx_collection_user_card` on `['user_id', 'card_id']` instead of (or in addition to) the two separate indexes. --- ## Verified Items (Passed) ### Core Models (`models.py`) — All 8 models verified ✅ | Model | Table | `__tablename__` | FKs | Relationships | Indexes | Unique Constraints | |-------|-------|-----------------|-----|---------------|---------|-------------------| | `User` | `mtgonline_users` | ✅ | — | ✅ (decklist_files, decklist_folders) | ✅ (username, email) | ✅ (username) | | `MtonlineCard` | `mtgonline_cards` | ✅ | — | — | ⚠️ (Issue #7) | — | | `DecklistFolder` | `mtgonline_decklist_folders` | ✅ | ✅ (owner_id, parent_id) | ✅ (owner, children, parent, files) | — | — | | `DecklistFile` | `mtgonline_decklist_files` | ✅ | ✅ (folder_id, owner_id) | ✅ (folder, owner) | ✅ (idx_decks_owner, idx_decks_folder) | — | | `Room` | `mtgonline_rooms` | ✅ | — | ✅ (game_types) | ✅ (name unique) | ✅ (name) | | `RoomGameType` | `mtgonline_rooms_gametypes` | ✅ | ✅ (room_id) | ✅ (room) | — | — | | `Ban` | `mtgonline_bans` | ✅ | ✅ (user_id) | ✅ (user) | ✅ (idx_bans_active) | — | | `GameLog` | `mtgonline_log` | ✅ | ✅ (room_id, player_id) | ✅ (room, player) | ✅ (idx_log_timestamp) | — | | `AuditLog` | `mtgonline_audit` | ✅ | ✅ (admin_id, target_user_id) | ✅ (admin, target_user) | — | — | ### MTG Models (`mtg_models.py`) — Both models verified ✅ | Model | Table | `__tablename__` | FKs | Relationships | Indexes | Unique Constraints | |-------|-------|-----------------|-----|---------------|---------|-------------------| | `MtgSet` | `mtg_sets` | ✅ | — | ✅ (cards) | ✅ (code unique, index) | ✅ (code) | | `MtgCard` | `mtg_cards` | ✅ | ✅ (set_id → mtg_sets.id) | ✅ (set) | ✅ (name, mana_cost, type_line, rarity, composite) | — | ### Mirror Models (`mirror_models.py`) — Both models verified ✅ | Model | Table | `__tablename__` | FKs | Relationships | Indexes | Unique Constraints | |-------|-------|-----------------|-----|---------------|---------|-------------------| | `MtgCardMirror` | `mtg_cards_mirror` | ✅ | ⚠️ (source_id, Issue #4) | ✅ (deck_links) | ✅ (source_id, name, set_code) | — | | `DeckCardLink` | `deck_card_links` | ✅ | ✅ (deck_id, card_id) | ✅ (deck, card) | ✅ (idx_deck_card_deck, idx_deck_card_card) | ✅ (uq_deck_card_link) | ### User Data Models (`user_data.py`) — All 14 models verified ✅ | Model | Table | `__tablename__` | PK Type | FKs | Unique Constraints | |-------|-------|-----------------|---------|-----|-------------------| | `UserSession` | `user_sessions` | ✅ | BigInteger | ✅ (user_id) | ✅ (session_token_hash) | | `DeckVersion` | `deck_versions` | ✅ | BigInteger | ✅ (deck_id) | — | | `GameReplay` | `game_replays` | ✅ | BigInteger | ✅ (room_id) | ✅ (game_uuid) | | `ReplayPlayer` | `replay_players` | ✅ | BigInteger | ✅ (replay_id, user_id, deck_id) | — | | `GameOutcome` | `game_outcomes` | ✅ | BigInteger | ✅ (user_id, game_uuid, opponent_id) | — | | `UserStatistics` | `user_statistics` | ✅ | Integer (PK) | ✅ (user_id as PK) | — | | `UserCardCollection` | `user_card_collection` | ✅ | BigInteger | ✅ (user_id) | ✅ (uq_collection_unique) | | `CardWishlist` | `card_wishlist` | ✅ | BigInteger | ✅ (user_id) | ✅ (uq_wishlist_user_card) | | `UserGroup` | `user_groups` | ✅ | BigInteger | ✅ (owner_id) | — | | `GroupMember` | `group_members` | ✅ | BigInteger | ✅ (group_id, user_id) | ✅ (uq_group_member) | | `GroupChatMessage` | `group_chat_messages` | ✅ | BigInteger | ✅ (group_id, sender_id) | — | | `UserNetwork` | `user_networks` | ✅ | BigInteger | ✅ (creator_id) | — | | `NetworkMember` | `network_members` | ✅ | BigInteger | ✅ (network_id, user_id) | ✅ (uq_network_member) | | `UserPreference` | `user_preferences` | ✅ | Integer (PK) | ✅ (user_id as PK) | — | | `UserActivityLog` | `user_activity_log` | ✅ | BigInteger | ✅ (user_id) | — | ### User Deck Models (`user_deck.py`) — All 5 models verified ✅ | Model | Table | `__tablename__` | FKs | Unique Constraints | |-------|-------|-----------------|-----|-------------------| | `UserDeck` | `user_decks` | ✅ | ✅ (user_id, folder_id) | — | | `UserDeckCard` | `user_deck_cards` | ✅ | ✅ (deck_id, card_id) | ✅ (uq_deck_card_unique) | | `DeckPrecedent` | `deck_precedents` | ✅ | ✅ (created_by) | — | | `DeckPrecedentCard` | `deck_precedent_cards` | ✅ | ✅ (precedent_id, card_id) | ✅ (uq_precedent_card_unique) | | `CardSuggestion` | `card_suggestions` | ✅ | ✅ (deck_id, card_id, source_card_id) | ✅ (uq_suggestion_unique) | ### Card Import Models — Both models verified ✅ | Model | Table | `__tablename__` | FKs | |-------|-------|-----------------|-----| | `CardImportBatch` | `card_import_batches` | ✅ | ✅ (user_id) | | `UserCardImportRecord` | `user_card_imports_confirmed` | ✅ | ✅ (user_id, batch_id) | ### Model Exports (`__init__.py`) — Verified ✅ All 34 model classes are properly exported in `__all__` and importable from `app.models`. ### Migration Chain — Verified ✅ ``` 000 (base_tables) → 001 (initial_user_schema) → 002 (user_deck_building) → 003 (mtgonline_cards) → 004 (card_import) → 005 (missing_tables) ``` All `down_revision` links are correct. All `upgrade()` and `downgrade()` functions are properly defined. ### Cascade Delete Behavior — Verified ✅ | Relationship | Cascade | Correct? | |-------------|---------|----------| | `User.decklist_files` | `all, delete-orphan` | ✅ | | `User.decklist_folders` | `all, delete-orphan` | ✅ | | `DecklistFolder.children` | `all, delete-orphan` | ✅ | | `DecklistFolder.files` | `all, delete-orphan` | ✅ | | `Room.game_types` | `all, delete-orphan` | ✅ | | `MtgCardMirror.deck_links` | `all, delete-orphan` | ✅ | | `GameReplay.players` | `all, delete-orphan` | ✅ | | `GameReplay.outcomes` | `all, delete-orphan` | ✅ | | `UserGroup.members` | `all, delete-orphan` | ✅ | | `UserGroup.messages` | `all, delete-orphan` | ✅ | | `UserNetwork.members` | `all, delete-orphan` | ✅ | | `UserDeck.cards` | `all, delete-orphan` | ✅ | | `DeckPrecedent.cards` | `all, delete-orphan` | ✅ | | FK `ondelete="CASCADE"` | Used on UserSession, DeckVersion, ReplayPlayer, UserCardCollection, CardWishlist, UserDeck, UserDeckCard, DeckPrecedentCard, CardImportBatch, UserCardImportRecord, DeckCardLink | ✅ | --- ## Recommendations ### Immediate (Blockers) 1. **Fix circular import** between `models.py` and `mirror_models.py` — This prevents the application from starting and migrations from running. 2. **Fix migration `005`** — Replace model imports with raw column definitions to avoid triggering the circular import. ### Short-Term 3. **Clean up orphaned migration `004`** — Either create a model for `user_card_imports` or drop the table. 4. **Rename `MtonlineCard` → `MtgonlineCard`** — Fix the typo for code consistency. 5. **Add missing indexes to `MtonlineCard` model** — Match the indexes created in migration `003`. ### Long-Term 6. **Add explicit backref on `DecklistFolder`** for `UserDeck.folder` relationship. 7. **Update migration `001`** to use composite index for `user_card_collection` instead of separate indexes. 8. **Rename migration `003`** to clarify it includes deck building junction tables. 9. **Document cross-DB reference** for `MtgCardMirror.source_id` — Add a comment clarifying it's a logical (not physical) FK. --- ## Appendix: Column-by-Column Comparison ### `mtgonline_users` (User) — Migration 000 vs Model | Column | Migration | Model | Match | |--------|-----------|-------|-------| | id | Integer PK | Integer PK ✅ | | username | String(64) unique nullable=False index | String(64) unique nullable=False index ✅ | | password_hash | String(128) nullable=False | String(128) nullable=False ✅ | | salt | String(128) nullable=False | String(128) nullable=False ✅ | | email | String(255) nullable=True index | String(255) nullable=True index ✅ | | country | String(2) nullable=True | String(2) nullable=True ✅ | | real_name | String(128) nullable=True | String(128) nullable=True ✅ | | avatar_bmp | Text nullable=True | Text nullable=True ✅ | | privlevel | String(50) server_default='User' | String(50) default="User" ⚠️ | | is_active | Boolean default=True | Boolean default=True ✅ | | is_banned | Boolean default=False | Boolean default=False ✅ | | ban_reason | Text nullable=True | Text nullable=True ✅ | | ban_ends | DateTime nullable=True | DateTime nullable=True ✅ | | vip_status | Integer default=0 | Integer default=0 ✅ | | vip_expiry | DateTime nullable=True | DateTime nullable=True ✅ | | creation_date | DateTime server_default=now() | DateTime server_default=now() ✅ | | last_login | DateTime nullable=True | DateTime nullable=True ✅ | > ⚠️ `privlevel`: Migration uses `server_default='User'` (DB-level default), model uses `default="User"` (Python-level default). Both work but `server_default` is preferred for PostgreSQL. ### `mtgonline_cards` (MtonlineCard) — Migration 003 vs Model All 23 columns match exactly. Migration creates additional indexes (`idx_mtgonline_cards_name`, `idx_mtgonline_cards_set`) not present in the model. ### `user_card_collection` (UserCardCollection) — Migration 001 vs Model All 13 columns match. Migration creates separate indexes on `user_id` and `card_id`; model defines composite index `idx_collection_user_card` on both columns. ### `card_wishlist` (CardWishlist) — Migration 001 vs Model All 5 columns match. Unique constraint `uq_wishlist_user_card` on `(user_id, card_id)` matches. ### `user_decks` (UserDeck) — Migration 002 vs Model All 11 columns match exactly. ### `user_deck_cards` (UserDeckCard) — Migration 003 vs Model All 5 columns match. Unique constraint `uq_deck_card_unique` on `(deck_id, card_id, zone)` matches. ### `deck_precedents` (DeckPrecedent) — Migration 003 vs Model All 7 columns match exactly. ### `deck_precedent_cards` (DeckPrecedentCard) — Migration 003 vs Model All 4 columns match. Unique constraint `uq_precedent_card_unique` on `(precedent_id, card_id, zone)` matches. ### `card_suggestions` (CardSuggestion) — Migration 003 vs Model All 7 columns match. Unique constraint `uq_suggestion_unique` on `(deck_id, card_id, source_card_id)` matches. ### `mtg_sets` (MtgSet) — Migration 005 vs Model All 15 columns match exactly. ### `mtg_cards` (MtgCard) — Migration 005 vs Model All 17 columns match. Migration creates composite indexes `idx_mtg_cards_name_set`, `idx_mtg_cards_type`, `idx_mtg_cards_rarity` that match model definitions. ### `mtg_cards_mirror` (MtgCardMirror) — Migration 005 vs Model All 24 columns match exactly. ### `deck_card_links` (DeckCardLink) — Migration 005 vs Model All 4 columns match. Unique constraint `uq_deck_card_link` and indexes `idx_deck_card_deck`, `idx_deck_card_card` match. ### `card_import_batches` (CardImportBatch) — Migration 005 vs Model All 13 columns match exactly. ### `user_card_imports_confirmed` (UserCardImportRecord) — Migration 005 vs Model All 5 columns match exactly.