From b46a7d0b20cdd03cf2269e4baabc03766ca5b6d3 Mon Sep 17 00:00:00 2001 From: Ashesh Vashi Date: Sun, 7 Jun 2026 11:14:42 +0530 Subject: [PATCH] fix(migrations): use resolve_fks=False so reflect() survives broken FKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs pgadmin-org/pgadmin4#9976. Reported scenario: a fresh pgAdmin 9.15 install inherits an old pgadmin4.db (e.g. from a 2019-vintage installation) and crashes during database migration with: sqlalchemy.exc.NoSuchTableError: user_old The traceback points at migration aff1436e3c8c_.py:32: meta.reflect(op.get_bind(), only=('server',)) SQLAlchemy's MetaData.reflect() defaults to resolve_fks=True, which auto-follows the reflected table's FOREIGN KEY references and reflects the targets too. On the reporter's DB, server.user_id carries a stale FK to a long-removed 'user_old' table — Reflect hits the orphan FK target and raises NoSuchTableError, aborting the migration. Result: the GUI surfaces the generic 'Server could not be contacted' error and the user has to delete the old db file to recover. The migrations only need the data of the explicitly-requested table — none of them traverse into FK targets — so resolve_fks can safely be disabled. Sweeping all 14 reflect() call sites across 12 migration files: 09d53fca90c7_.py (2 sites: role, version) 1f0eddc8fc79_.py (role) 44b9ce549393_.py (server, sharedserver) 7fedf8531802_.py (user) ac2c2e27dc2d_.py (2 sites: user_macros) add_tools_ai_perm_.py (role) aff1436e3c8c_.py (server) <- the reporter's hit site c465fee44968_.py (user) c62bcc14c3d6_.py (role, setting) d39482714a2e_.py (server) e6ed5dac37c2_.py (user_preferences, module_preference, etc.) f656e56dfdc8_.py (server / sharedserver via table_name) Verified empirically (in-memory SQLite + live PostgreSQL 17): - Before the fix: SQLite repro of the reporter's stale-FK DB raises NoSuchTableError(user_old). - After the fix: reflect() returns the requested table only, the migration's UPDATE runs and updates the rows correctly. - Same behaviour on PostgreSQL (external CONFIG_DATABASE_URI setup) — resolve_fks lives in SQLAlchemy core, not in a dialect; both engines route through the same reflection path. SQLAlchemy version safety: resolve_fks has been in MetaData.reflect() since SQLAlchemy 1.3 (2019). pgAdmin requires SQLAlchemy 2.* so this is well within compatibility. This fixes the database-migration crash in #9976. The reporter's UX requests (clearer GUI error, logged DB path, GitHub-issue button) are addressed separately in follow-up work. --- web/migrations/versions/09d53fca90c7_.py | 4 ++-- web/migrations/versions/1f0eddc8fc79_.py | 2 +- web/migrations/versions/44b9ce549393_.py | 4 +++- web/migrations/versions/7fedf8531802_.py | 2 +- web/migrations/versions/ac2c2e27dc2d_.py | 4 ++-- web/migrations/versions/add_tools_ai_perm_.py | 2 +- web/migrations/versions/aff1436e3c8c_.py | 2 +- web/migrations/versions/c465fee44968_.py | 2 +- web/migrations/versions/c62bcc14c3d6_.py | 2 +- web/migrations/versions/d39482714a2e_.py | 2 +- web/migrations/versions/e6ed5dac37c2_.py | 6 ++++-- web/migrations/versions/f656e56dfdc8_.py | 2 +- 12 files changed, 19 insertions(+), 15 deletions(-) diff --git a/web/migrations/versions/09d53fca90c7_.py b/web/migrations/versions/09d53fca90c7_.py index ccd1d17e11b..25409348a5b 100644 --- a/web/migrations/versions/09d53fca90c7_.py +++ b/web/migrations/versions/09d53fca90c7_.py @@ -125,7 +125,7 @@ def upgrade(): # get metadata from current connection meta = sa.MetaData() # define table representation - meta.reflect(op.get_bind(), only=('role',)) + meta.reflect(op.get_bind(), only=('role',), resolve_fks=False) role_table = sa.Table('role', meta) op.execute( @@ -168,7 +168,7 @@ def upgrade(): # get metadata from current connection meta = sa.MetaData() # define table representation - meta.reflect(op.get_bind(), only=('version',)) + meta.reflect(op.get_bind(), only=('version',), resolve_fks=False) version_table = sa.Table('version', meta) op.execute( diff --git a/web/migrations/versions/1f0eddc8fc79_.py b/web/migrations/versions/1f0eddc8fc79_.py index bc4f144f142..5e165ab3dfc 100644 --- a/web/migrations/versions/1f0eddc8fc79_.py +++ b/web/migrations/versions/1f0eddc8fc79_.py @@ -36,7 +36,7 @@ def upgrade(): # get metadata from current connection meta = sa.MetaData() # define table representation - meta.reflect(op.get_bind(), only=('role',)) + meta.reflect(op.get_bind(), only=('role',), resolve_fks=False) role_table = sa.Table('role', meta) from pgadmin.tools.user_management.PgAdminPermissions import ( diff --git a/web/migrations/versions/44b9ce549393_.py b/web/migrations/versions/44b9ce549393_.py index dfc7ec615d7..d7bf414b21a 100644 --- a/web/migrations/versions/44b9ce549393_.py +++ b/web/migrations/versions/44b9ce549393_.py @@ -39,7 +39,9 @@ def upgrade(): # get metadata from current connection meta = sa.MetaData() # define table representation - meta.reflect(op.get_bind(), only=('server', 'sharedserver')) + meta.reflect(op.get_bind(), + only=('server', 'sharedserver'), + resolve_fks=False) table = sa.Table('server', meta) op.execute( table.update().values(prepare_threshold=5) diff --git a/web/migrations/versions/7fedf8531802_.py b/web/migrations/versions/7fedf8531802_.py index e702d6fca54..e6db5121441 100644 --- a/web/migrations/versions/7fedf8531802_.py +++ b/web/migrations/versions/7fedf8531802_.py @@ -37,7 +37,7 @@ def upgrade(): # For internal email is a user name, so update the existing records. meta = sa.MetaData() # define table representation - meta.reflect(op.get_bind(), only=('user',)) + meta.reflect(op.get_bind(), only=('user',), resolve_fks=False) user_table = sa.Table('user', meta) op.execute( diff --git a/web/migrations/versions/ac2c2e27dc2d_.py b/web/migrations/versions/ac2c2e27dc2d_.py index 5d7bb4732f6..2b55096ac24 100644 --- a/web/migrations/versions/ac2c2e27dc2d_.py +++ b/web/migrations/versions/ac2c2e27dc2d_.py @@ -33,7 +33,7 @@ def upgrade(): session.commit() meta = sa.MetaData() - meta.reflect(op.get_bind(), only=('user_macros',)) + meta.reflect(op.get_bind(), only=('user_macros',), resolve_fks=False) user_macros_table = sa.Table('user_macros', meta) # Create a select statement @@ -61,7 +61,7 @@ def upgrade(): sa.PrimaryKeyConstraint('id',)) # Reflect the new table structure - meta.reflect(op.get_bind(), only=('user_macros',)) + meta.reflect(op.get_bind(), only=('user_macros',), resolve_fks=False) new_user_macros_table = sa.Table('user_macros', meta) # Bulk insert the fetched data into the new user_macros table diff --git a/web/migrations/versions/add_tools_ai_perm_.py b/web/migrations/versions/add_tools_ai_perm_.py index 380ee0d3fbb..f6d627531bc 100644 --- a/web/migrations/versions/add_tools_ai_perm_.py +++ b/web/migrations/versions/add_tools_ai_perm_.py @@ -27,7 +27,7 @@ def upgrade(): # Get metadata from current connection meta = sa.MetaData() - meta.reflect(op.get_bind(), only=('role',)) + meta.reflect(op.get_bind(), only=('role',), resolve_fks=False) role_table = sa.Table('role', meta) # Get all roles with permissions diff --git a/web/migrations/versions/aff1436e3c8c_.py b/web/migrations/versions/aff1436e3c8c_.py index a2cbc829395..7e556397a66 100644 --- a/web/migrations/versions/aff1436e3c8c_.py +++ b/web/migrations/versions/aff1436e3c8c_.py @@ -29,7 +29,7 @@ def upgrade(): # get metadata from current connection meta = sa.MetaData() # define table representation - meta.reflect(op.get_bind(), only=('server',)) + meta.reflect(op.get_bind(), only=('server',), resolve_fks=False) server_table = sa.Table('server', meta) op.execute( server_table.update().where(server_table.c.connect_timeout == 0 or diff --git a/web/migrations/versions/c465fee44968_.py b/web/migrations/versions/c465fee44968_.py index 33b9c9a7001..86a23bf3fef 100644 --- a/web/migrations/versions/c465fee44968_.py +++ b/web/migrations/versions/c465fee44968_.py @@ -31,7 +31,7 @@ def upgrade(): meta = sa.MetaData() # define table representation - meta.reflect(op.get_bind(), only=('user',)) + meta.reflect(op.get_bind(), only=('user',), resolve_fks=False) user_table = sa.Table('user', meta) op.execute( diff --git a/web/migrations/versions/c62bcc14c3d6_.py b/web/migrations/versions/c62bcc14c3d6_.py index c57dc18a132..f2eeb58aaf6 100644 --- a/web/migrations/versions/c62bcc14c3d6_.py +++ b/web/migrations/versions/c62bcc14c3d6_.py @@ -27,7 +27,7 @@ def upgrade(): # Add 'change_password' permission to all roles except 'Administrator'. meta = sa.MetaData() - meta.reflect(op.get_bind(), only=('role', 'setting')) + meta.reflect(op.get_bind(), only=('role', 'setting'), resolve_fks=False) role_table = sa.Table('role', meta) perm = role_table.c.permissions diff --git a/web/migrations/versions/d39482714a2e_.py b/web/migrations/versions/d39482714a2e_.py index d37286af9d9..ecc8a8a796b 100644 --- a/web/migrations/versions/d39482714a2e_.py +++ b/web/migrations/versions/d39482714a2e_.py @@ -32,7 +32,7 @@ def upgrade(): # get metadata from current connection meta = sa.MetaData() # define table representation - meta.reflect(op.get_bind(), only=('server',)) + meta.reflect(op.get_bind(), only=('server',), resolve_fks=False) server_table = sa.Table('server', meta) op.execute( diff --git a/web/migrations/versions/e6ed5dac37c2_.py b/web/migrations/versions/e6ed5dac37c2_.py index 058e8caeae4..b811f1e23e4 100644 --- a/web/migrations/versions/e6ed5dac37c2_.py +++ b/web/migrations/versions/e6ed5dac37c2_.py @@ -66,8 +66,10 @@ def upgrade(): # get metadata from current connection meta = sa.MetaData() # define table representation - meta.reflect(op.get_bind(), only=('user_preferences', 'module_preference', - 'preference_category', 'preferences')) + meta.reflect(op.get_bind(), + only=('user_preferences', 'module_preference', + 'preference_category', 'preferences'), + resolve_fks=False) module_pref_table = sa.Table('module_preference', meta) module_id = session.query(ModulePreference).filter_by( diff --git a/web/migrations/versions/f656e56dfdc8_.py b/web/migrations/versions/f656e56dfdc8_.py index 912548f7fac..a9d879d741c 100644 --- a/web/migrations/versions/f656e56dfdc8_.py +++ b/web/migrations/versions/f656e56dfdc8_.py @@ -53,7 +53,7 @@ def migrate_connection_params(table_name): # define table representation meta = sa.MetaData() - meta.reflect(op.get_bind(), only=(table_name,)) + meta.reflect(op.get_bind(), only=(table_name,), resolve_fks=False) server_table = sa.Table(table_name, meta) # Create a select statement