""" MTGJSON Database Migration Strategy Comprehensive mapping of MTGJSON data model to PostgreSQL schema. Strategy for Nested JSON Arrays: 1. Direct Columns: Simple scalar values (strings, numbers, booleans) 2. JSONB Columns: Complex objects/arrays that need querying (legalities, prices) 3. Related Tables: One-to-many relationships (card_faces, foreign_names, rulings) 4. Comma-Separated: Simple arrays that can be split (supertypes, types, subtypes) """ from sqlalchemy import create_engine, text import json DB_URL = "postgresql+psycopg2://mtgonline:mtgonline_pass@172.18.0.2:5432/mtgdata" class MTGJSONMigration: """Migrate MTGJSON data to comprehensive PostgreSQL schema.""" def __init__(self): self.engine = create_engine(DB_URL) self.conn = None def connect(self): """Connect to database.""" self.conn = self.engine.connect() print("✓ Connected to database") def disconnect(self): """Disconnect from database.""" if self.conn: self.conn.close() self.engine.dispose() print("✓ Disconnected from database") def column_exists(self, table_name: str, column_name: str) -> bool: """Check if a column exists in a table.""" result = self.conn.execute(text(""" SELECT column_name FROM information_schema.columns WHERE table_name = :table AND column_name = :column """), {"table": table_name, "column": column_name}) return result.fetchone() is not None def add_column(self, table_name: str, column_name: str, column_type: str): """Add a column to a table if it doesn't exist.""" if not self.column_exists(table_name, column_name): self.conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}")) print(f" ✓ Added: {table_name}.{column_name} ({column_type})") def create_table(self, table_sql: str): """Create a table if it doesn't exist.""" self.conn.execute(text(table_sql)) print(f" ✓ Created table") def create_index(self, index_sql: str): """Create an index if it doesn't exist.""" self.conn.execute(text(f"CREATE INDEX IF NOT EXISTS {index_sql.split(' ON ')[1].split(' ')[0]} ON {index_sql.split(' ON ')[1].split(' ')[1]}")) print(f" ✓ Created index") def migrate_card_table(self): """Add all MTGJSON card attributes to mtg_cards table.""" print("\n📊 Migrating mtg_cards table...") # ======================== # STRATEGY 1: Direct Columns (Simple scalar values) # ======================== print("\n📝 Strategy 1: Direct Columns (Simple scalar values)") direct_columns = [ # Basic card info ("name", "VARCHAR(255)"), ("mana_cost", "VARCHAR(255)"), ("type_line", "VARCHAR(255)"), ("oracle_text", "TEXT"), ("power", "VARCHAR(50)"), ("toughness", "VARCHAR(50)"), ("loyalty", "VARCHAR(50)"), # For Planeswalkers ("rarity", "VARCHAR(50)"), ("layout", "VARCHAR(50)"), ("artist", "VARCHAR(255)"), ("flavor_text", "TEXT"), ("numbers", "VARCHAR(100)"), # MTGJSON: border, watermark ("border_color", "VARCHAR(20)"), ("watermark", "VARCHAR(255)"), # MTGJSON: colorIdentity (single color) ("color_identity", "VARCHAR(10)"), # MTGJSON: lang ("lang", "VARCHAR(10) DEFAULT 'en'"), # MTGJSON: originalReleaseDate ("original_release_date", "DATE"), # MTGJSON: originalTypeLine ("original_type_line", "VARCHAR(255)"), # MTGJSON: securityStamp ("security_stamp", "VARCHAR(20)"), # MTGJSON: isPromo ("promo", "BOOLEAN DEFAULT FALSE"), # MTGJSON: isDigital ("digital", "BOOLEAN DEFAULT FALSE"), # MTGJSON: isToken ("token", "BOOLEAN DEFAULT FALSE"), # MTGJSON: frame ("frame", "VARCHAR(50)"), # MTGJSON: fullArt ("full_art", "BOOLEAN DEFAULT FALSE"), # MTGJSON: isRebalanced ("is_rebalanced", "BOOLEAN DEFAULT FALSE"), # MTGJSON: isStarter ("is_starter", "BOOLEAN DEFAULT FALSE"), # MTGJSON: isInBooster ("in_booster", "BOOLEAN DEFAULT FALSE"), # MTGJSON: mysticalArchive ("mystical_archive", "BOOLEAN DEFAULT FALSE"), ] for col_name, col_type in direct_columns: self.add_column("mtg_cards", col_name, col_type) # ======================== # STRATEGY 2: JSONB Columns (Complex objects/arrays) # ======================== print("\n📦 Strategy 2: JSONB Columns (Complex objects/arrays)") jsonb_columns = [ # MTGJSON: legalities object # Example: {"Standard": "Legal", "Modern": "Banned", "Vintage": "Restricted"} ("legalities", "JSONB"), # MTGJSON: prices object # Example: {"tcgplayer": "$4.99", "low": 2.5, "mid": 4.0, "high": 6.0} ("prices", "JSONB"), # MTGJSON: cardFaces array (for split cards, modal DFCs) # Example: [{"name": "Card A", "oracleText": "...", "power": "2"}, {"name": "Card B", ...}] ("card_faces", "JSONB"), # MTGJSON: foreignData array (for translations) # Example: [{"language": "Japanese", "name": "カード名", "typeLine": "クリーチャー"}, ...] ("foreign_names", "JSONB"), # MTGJSON: relatedCards object # Example: {"convertedNames": ["..."], "commanderCounterparts": [...]} ("related_cards", "JSONB"), # MTGJSON: frameEffects array # Example: ["extendedart", "legendary", "nightmare"] ("frame_effects", "JSONB"), # MTGJSON: keywords array # Example: ["first strike", "trample", "vision mount"] ("keywords", "JSONB"), # MTGJSON: set (set object) # Example: {"name": "Commander 2021", "code": "C21", "type": "commander"} ("set", "JSONB"), # MTGJSON: booster (booster configuration) # Example: {"boosters": [{"content": [...], "type": "main"}]} ("booster", "JSONB"), ] for col_name, col_type in jsonb_columns: self.add_column("mtg_cards", col_name, col_type) # ======================== # STRATEGY 3: Comma-Separated (Simple arrays) # ======================== print("\n🔗 Strategy 3: Comma-Separated (Simple arrays)") comma_separated = [ # MTGJSON: types array (e.g., ["Creature", "Human"]) ("types", "VARCHAR(255)"), # MTGJSON: subtypes array (e.g., ["Elf", "Rogue"]) ("subtypes", "VARCHAR(255)"), # MTGJSON: supertypes array (e.g., ["Legendary"]) ("supertypes", "VARCHAR(100)"), # MTGJSON: colors array (e.g., ["W", "G"]) - stored as comma-separated ("colors", "VARCHAR(20)"), ] for col_name, col_type in comma_separated: self.add_column("mtg_cards", col_name, col_type) # ======================== # STRATEGY 4: Related Tables (One-to-many relationships) # ======================== print("\n📚 Strategy 4: Related Tables (One-to-many relationships)") # Card faces table self.create_table(""" CREATE TABLE IF NOT EXISTS mtg_card_faces ( id SERIAL PRIMARY KEY, card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE, face_number INTEGER, name VARCHAR(255), mana_cost VARCHAR(255), type_line VARCHAR(255), oracle_text TEXT, power VARCHAR(50), toughness VARCHAR(50), loyalty VARCHAR(50), flavor_text TEXT, artist VARCHAR(255), illustration_id VARCHAR(100), image_uri TEXT, image_png TEXT, image_art_crop TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) # Foreign names table self.create_table(""" CREATE TABLE IF NOT EXISTS mtg_card_foreign_names ( id SERIAL PRIMARY KEY, card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE, language VARCHAR(20), name VARCHAR(255), type_line VARCHAR(255), oracle_text TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) # Rulings table self.create_table(""" CREATE TABLE IF NOT EXISTS mtg_card_rulings ( id SERIAL PRIMARY KEY, card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE, published_date DATE, text TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) # Related cards table self.create_table(""" CREATE TABLE IF NOT EXISTS mtg_card_related ( id SERIAL PRIMARY KEY, card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE, related_type VARCHAR(50), related_id INTEGER, related_name VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) # Card types table (for normalized type search) self.create_table(""" CREATE TABLE IF NOT EXISTS mtg_card_types ( id SERIAL PRIMARY KEY, card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE, type_category VARCHAR(50), type_name VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(card_id, type_category, type_name) ) """) # Color identity table (for multi-card color identity) self.create_table(""" CREATE TABLE IF NOT EXISTS mtg_card_color_identity ( id SERIAL PRIMARY KEY, card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE, color CHAR(1), identity_type VARCHAR(20) DEFAULT 'color', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(card_id, color, identity_type) ) """) # ======================== # CREATE INDEXES # ======================== print("\n🔍 Creating indexes...") indexes = [ # Card indexes "idx_cards_colors ON mtg_cards(colors)", "idx_cards_color_identity ON mtg_cards(color_identity)", "idx_cards_supertypes ON mtg_cards(supertypes)", "idx_cards_types ON mtg_cards(types)", "idx_cards_subtypes ON mtg_cards(subtypes)", "idx_cards_legalities ON mtg_cards(legalities) USING GIN", "idx_cards_prices ON mtg_cards(prices) USING GIN", "idx_cards_card_faces ON mtg_cards(card_faces) USING GIN", "idx_cards_foreign_names ON mtg_cards(foreign_names) USING GIN", "idx_cards_related_cards ON mtg_cards(related_cards) USING GIN", "idx_cards_keywords ON mtg_cards(keywords) USING GIN", # Set indexes "idx_sets_status ON mtg_sets(status)", "idx_sets_block_code ON mtg_sets(block_code)", # Related table indexes "idx_card_faces_card_id ON mtg_card_faces(card_id)", "idx_card_foreign_names_card_id ON mtg_card_foreign_names(card_id)", "idx_card_rulings_card_id ON mtg_card_rulings(card_id)", "idx_card_related_card_id ON mtg_card_related(card_id)", "idx_card_types_card_id ON mtg_card_types(card_id)", "idx_card_color_identity_card_id ON mtg_card_color_identity(card_id)", ] for idx in indexes: self.create_index(f"idx_{idx}") print("\n✅ Card table migration complete!") def migrate_set_table(self): """Add all MTGJSON set attributes to mtg_sets table.""" print("\n📊 Migrating mtg_sets table...") # MTGJSON set attributes set_columns = [ # Basic set info ("code", "VARCHAR(10)"), ("name", "VARCHAR(255)"), ("type", "VARCHAR(100)"), ("release_date", "DATE"), ("base_set_size", "INTEGER"), ("total_size", "INTEGER"), ("is_foil_only", "BOOLEAN"), ("is_non_foil_only", "BOOLEAN"), ("digital", "BOOLEAN"), ("icon_svg_url", "TEXT"), ("parent_code", "VARCHAR(10)"), ("mtgo_code", "VARCHAR(10)"), # MTGJSON: tcgplayerGroupId ("tcgplayer_group_id", "INTEGER"), # MTGJSON: scryfallId ("scryfall_id", "VARCHAR(36)"), # MTGJSON: status (released, unreleased, etc.) ("status", "VARCHAR(20)"), # MTGJSON: name_normalized ("name_normalized", "VARCHAR(255)"), # MTGJSON: blockCode ("block_code", "VARCHAR(10)"), # MTGJSON: setCodes (all set codes) ("set_codes", "JSONB"), # MTGJSON: cardCount (total cards in set) ("card_count", "INTEGER"), ] for col_name, col_type in set_columns: self.add_column("mtg_sets", col_name, col_type) print("\n✅ Set table migration complete!") def populate_existing_data(self): """Populate new columns from existing JSON data.""" print("\n🔄 Populating existing data from JSON columns...") # Extract data from identifiers JSON self.conn.execute(text(""" UPDATE mtg_cards SET border_color = identifiers->>'border', watermark = identifiers->>'watermark', original_release_date = identifiers->>'originalReleaseDate', original_type_line = identifiers->>'originalTypeLine', security_stamp = identifiers->>'securityStamp', lang = identifiers->>'lang', promo = (identifiers->>'isPromo')::BOOLEAN, digital = (identifiers->>'isDigital')::BOOLEAN, token = (identifiers->>'isToken')::BOOLEAN WHERE identifiers IS NOT NULL AND identifiers != 'null' AND identifiers != '' """)) print(" ✓ Updated basic fields from identifiers") # Extract type information from type_line self.conn.execute(text(""" UPDATE mtg_cards SET supertypes = type_line, types = type_line, subtypes = type_line WHERE type_line IS NOT NULL AND type_line != '' """)) print(" ✓ Updated type hierarchy from type_line") # Extract legalities, prices, card_faces from images JSON self.conn.execute(text(""" UPDATE mtg_cards SET prices = images->'prices', card_faces = images->'cardFaces', foreign_names = images->'foreignData', related_cards = images->'relatedCards' WHERE images IS NOT NULL AND images != 'null' AND images != '' """)) print(" ✓ Updated complex fields from images JSON") self.conn.commit() print("\n✅ Data population complete!") def run_migration(self): """Run the full migration.""" print("=" * 60) print("🚀 Starting MTGJSON Database Migration") print("=" * 60) self.connect() # Migrate card table self.migrate_card_table() # Migrate set table self.migrate_set_table() # Populate existing data self.populate_existing_data() self.disconnect() print("\n" + "=" * 60) print("✅ Migration completed successfully!") print("=" * 60) def main(): """Main entry point.""" migration = MTGJSONMigration() migration.run_migration() if __name__ == "__main__": main()