From 6ec6f9938c069c4b8da1521118688b0dde7808ea Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 15 Oct 2026 11:27:55 +0100 Subject: [PATCH 1/2] Keep the Query Tool execute options when its transaction is re-created. #10598 Re-initialising the Query Tool after a password prompt, a missing master password key or a Kerberos ticket refresh sent no execute options, so the server reset auto commit, auto rollback and server cursor to the preferences whilst the toolbar still showed the user's choices. The initialise request now sends the current toolbar values, falling back to the preferences when they are absent, and changing the connection now carries server cursor over as well as auto commit and auto rollback. Closes #10598 --- web/pgadmin/tools/sqleditor/__init__.py | 13 ++ .../js/components/QueryToolComponent.jsx | 19 ++- .../tests/test_execute_options_reinit.py | 112 ++++++++++++++++++ .../sqleditor/QueryToolInitialise.spec.js | 68 +++++++++++ 4 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 web/pgadmin/tools/sqleditor/tests/test_execute_options_reinit.py create mode 100644 web/regression/javascript/sqleditor/QueryToolInitialise.spec.js diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index a605d23a7b4..7bb85a56ce6 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -452,6 +452,11 @@ def panel(trans_id): params=json.dumps(params)) +# Execute options that can be changed from the Query Tool toolbar, and which +# are kept on the transaction object. +EXECUTE_OPTIONS = ('auto_commit', 'auto_rollback', 'server_cursor') + + @blueprint.route( '/initialize/sqleditor////' '', @@ -496,6 +501,13 @@ def initialize_sqleditor(trans_id, sgid, sid, did=None): 'role': data['role'] if 'role' in data else None, 'password': data['password'] if 'password' in data else None } + # The execute options currently chosen on the toolbar, sent when the + # transaction is re-created, so they are kept rather than reset to the + # preferences. Anything other than a boolean falls back to the + # preference in _init_sqleditor. + for option in EXECUTE_OPTIONS: + if isinstance(data.get(option), bool): + kwargs[option] = data[option] is_error, errmsg, conn_id, version = _init_sqleditor( trans_id, connect, sgid, sid, did, data['dbname'], **kwargs) @@ -723,6 +735,7 @@ def update_sqleditor_connection(trans_id, sgid, sid, did): 'password': data['password'] if 'password' in data else None, 'auto_commit': getattr(trans_obj, 'auto_commit', None), 'auto_rollback': getattr(trans_obj, 'auto_rollback', None), + 'server_cursor': getattr(trans_obj, 'server_cursor', None), } is_error, errmsg, conn_id, version = _init_sqleditor( diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx index 4cc98eee0a5..49cb7f5ffc6 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx @@ -115,6 +115,21 @@ const FIXED_PREF = { }, }; +// The body of the request that initialises the transaction. A Query Tool +// sends the execute options currently chosen on the toolbar, so that they +// are kept rather than reset to the preferences when the transaction is +// re-created; only boolean values are sent, and the server uses the +// preference for any that are missing. +export function getInitialiseRequestData(params, connParams, toolbarOptions) { + if(params.is_query_tool) { + return { + ..._.pickBy(_.pick(toolbarOptions ?? {}, ['auto_commit', 'auto_rollback', 'server_cursor']), _.isBoolean), + ...connParams, + }; + } + return {sql_filter: params.sql_filter, server_cursor: params.server_cursor, ...connParams}; +} + export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedNodeInfo, qtPanelDocker, qtPanelId, eventBusObj}) { const containerRef = React.useRef(null); const preferencesStore = usePreferences(); @@ -397,9 +412,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN password: password, dbname: selectedConn.database_name }; - api.post(baseUrl, qtState.params.is_query_tool ? - {...requestParams} : - {sql_filter: qtState.params.sql_filter, server_cursor: qtState.params.server_cursor, ...requestParams}) + api.post(baseUrl, getInitialiseRequestData(qtState.params, requestParams, toolbarOptionsRef.current)) .then(()=>{ setQtStatePartial({ connected: true, diff --git a/web/pgadmin/tools/sqleditor/tests/test_execute_options_reinit.py b/web/pgadmin/tools/sqleditor/tests/test_execute_options_reinit.py new file mode 100644 index 00000000000..0c28751180c --- /dev/null +++ b/web/pgadmin/tools/sqleditor/tests/test_execute_options_reinit.py @@ -0,0 +1,112 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import json +import pickle +import secrets + +from pgadmin.browser.server_groups.servers.databases.tests import utils as \ + database_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils + +# The preference defaults, which the freshly created test database uses. +PREFERENCE_OPTIONS = { + 'auto_commit': True, + 'auto_rollback': False, + 'server_cursor': False, +} +CHANGED_OPTIONS = { + 'auto_commit': False, + 'auto_rollback': True, + 'server_cursor': True, +} + + +class TestExecuteOptionsReinit(BaseTestGenerator): + """ + The execute options chosen on the Query Tool toolbar must be kept when + the transaction is re-created, rather than reset to the preferences + (issue #10598). + """ + scenarios = [ + ('Initialise with the toolbar execute options', dict( + change_connection=False, init_options=CHANGED_OPTIONS, + expected=CHANGED_OPTIONS)), + ('Initialise with some of the toolbar execute options', dict( + change_connection=False, + init_options={'server_cursor': True}, + expected=dict(PREFERENCE_OPTIONS, server_cursor=True))), + ('Initialise without execute options uses the preferences', dict( + change_connection=False, init_options={}, + expected=PREFERENCE_OPTIONS)), + ('Initialise ignores execute options that are not booleans', dict( + change_connection=False, + init_options={'auto_commit': 'false', 'auto_rollback': None, + 'server_cursor': 1}, + expected=PREFERENCE_OPTIONS)), + ('Changing the connection keeps the execute options', dict( + change_connection=True, init_options={}, + expected=CHANGED_OPTIONS)), + ] + + def setUp(self): + database_info = parent_node_dict["database"][-1] + self.db_name = database_info["db_name"] + self.server_id = database_info["server_id"] + self.db_id = database_info["db_id"] + db_con = database_utils.connect_database(self, + utils.SERVER_GROUP, + self.server_id, + self.db_id) + if not db_con["info"] == "Database connected.": + raise Exception("Could not connect to the database.") + + self.trans_id = str(secrets.choice(range(1, 9999999))) + url = '/sqleditor/initialize/sqleditor/{0}/{1}/{2}/{3}'.format( + self.trans_id, utils.SERVER_GROUP, self.server_id, self.db_id) + response = self.tester.post(url, data=json.dumps(dict( + self.init_options, dbname=self.db_name))) + self.assertEqual(response.status_code, 200) + + def runTest(self): + if self.change_connection: + # Change the options from the toolbar, then change the + # connection, which creates a new transaction. + for option, value in CHANGED_OPTIONS.items(): + response = self.tester.post( + '/sqleditor/{0}/{1}'.format(option, self.trans_id), + data=json.dumps(value)) + self.assertEqual(response.status_code, 200) + + url = ('/sqleditor/initialize/sqleditor/update_connection/' + '{0}/{1}/{2}/{3}').format(self.trans_id, + utils.SERVER_GROUP, + self.server_id, self.db_id) + response = self.tester.post(url, data=json.dumps({ + 'user': self.server['username'], + 'role': None, + 'password': self.server['db_password'], + 'database_name': self.db_name, + }), content_type='html/json') + self.assertEqual(response.status_code, 200) + self.trans_id = json.loads( + response.data.decode('utf-8'))['data']['trans_id'] + + with self.tester.session_transaction() as sess: + command_obj = pickle.loads( + sess['gridData'][str(self.trans_id)]['command_obj']) + + for option, value in self.expected.items(): + self.assertIs(getattr(command_obj, option), value, option) + + def tearDown(self): + self.tester.delete('/sqleditor/close/{0}'.format(self.trans_id)) + database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/regression/javascript/sqleditor/QueryToolInitialise.spec.js b/web/regression/javascript/sqleditor/QueryToolInitialise.spec.js new file mode 100644 index 00000000000..b732c6d8e6c --- /dev/null +++ b/web/regression/javascript/sqleditor/QueryToolInitialise.spec.js @@ -0,0 +1,68 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// QueryToolComponent.jsx pulls in editors and viewers that this test never +// renders, and whose ESM-only dependencies Jest cannot load. +jest.mock('../../../pgadmin/static/js/components/ReactCodeMirror', () => ({ + __esModule: true, + default: ({ value }) => value, +})); +jest.mock('../../../pgadmin/static/js/components/JsonEditor', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../../../pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer', () => ({ + GeometryViewer: () => null, +})); +jest.mock('../../../pgadmin/tools/sqleditor/static/js/components/sections/GraphVisualiser', () => ({ + GraphVisualiser: () => null, +})); + +import { getInitialiseRequestData } from '../../../pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx'; + +const connParams = { + user: 'postgres', + role: null, + password: '', + dbname: 'postgres', +}; + +describe('getInitialiseRequestData', () => { + it('sends the toolbar execute options for a Query Tool', () => { + expect(getInitialiseRequestData({is_query_tool: true}, connParams, { + auto_commit: false, + auto_rollback: true, + server_cursor: true, + explain_verbose: true, + open_in_new_tab: false, + })).toEqual({ + ...connParams, + auto_commit: false, + auto_rollback: true, + server_cursor: true, + }); + }); + + it('leaves out execute options that are not known yet', () => { + expect(getInitialiseRequestData({is_query_tool: true}, connParams, null)) + .toEqual(connParams); + expect(getInitialiseRequestData({is_query_tool: true}, connParams, {})) + .toEqual(connParams); + expect(getInitialiseRequestData({is_query_tool: true}, connParams, { + auto_commit: undefined, server_cursor: false, + })).toEqual({...connParams, server_cursor: false}); + }); + + it('does not send the toolbar execute options for View/Edit Data', () => { + expect(getInitialiseRequestData({ + is_query_tool: false, sql_filter: 'id > 1', server_cursor: true, + }, connParams, {auto_commit: false, auto_rollback: true, server_cursor: false})) + .toEqual({...connParams, sql_filter: 'id > 1', server_cursor: true}); + }); +}); From 82ede2b95fdae98a603cfd836cb8fc13d966692d Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 15 Oct 2026 22:11:57 +0100 Subject: [PATCH 2/2] Only carry boolean execute options over when the Query Tool connection is changed. The update_connection path copied whatever value the toolbar endpoints had stored on the transaction, whereas initialize_sqleditor() only accepts booleans. Apply the same check so anything else falls back to the preferences. --- web/pgadmin/tools/sqleditor/__init__.py | 9 ++++++--- .../sqleditor/tests/test_execute_options_reinit.py | 10 ++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index 7bb85a56ce6..54d7658e6d1 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -733,10 +733,13 @@ def update_sqleditor_connection(trans_id, sgid, sid, did): 'user': data['user'], 'role': data['role'] if 'role' in data else None, 'password': data['password'] if 'password' in data else None, - 'auto_commit': getattr(trans_obj, 'auto_commit', None), - 'auto_rollback': getattr(trans_obj, 'auto_rollback', None), - 'server_cursor': getattr(trans_obj, 'server_cursor', None), } + # Keep the execute options from the old transaction, but only + # booleans, as initialize_sqleditor() does; anything else falls + # back to the preferences. + for option in EXECUTE_OPTIONS: + value = getattr(trans_obj, option, None) + kwargs[option] = value if isinstance(value, bool) else None is_error, errmsg, conn_id, version = _init_sqleditor( new_trans_id, connect, sgid, sid, did, data['database_name'], diff --git a/web/pgadmin/tools/sqleditor/tests/test_execute_options_reinit.py b/web/pgadmin/tools/sqleditor/tests/test_execute_options_reinit.py index 0c28751180c..5ad0a4eab6f 100644 --- a/web/pgadmin/tools/sqleditor/tests/test_execute_options_reinit.py +++ b/web/pgadmin/tools/sqleditor/tests/test_execute_options_reinit.py @@ -54,7 +54,13 @@ class TestExecuteOptionsReinit(BaseTestGenerator): expected=PREFERENCE_OPTIONS)), ('Changing the connection keeps the execute options', dict( change_connection=True, init_options={}, - expected=CHANGED_OPTIONS)), + toolbar_options=CHANGED_OPTIONS, expected=CHANGED_OPTIONS)), + ('Changing the connection ignores execute options that are not ' + 'booleans', dict( + change_connection=True, init_options={}, + toolbar_options={'auto_commit': 'false', 'auto_rollback': 1, + 'server_cursor': 1}, + expected=PREFERENCE_OPTIONS)), ] def setUp(self): @@ -80,7 +86,7 @@ def runTest(self): if self.change_connection: # Change the options from the toolbar, then change the # connection, which creates a new transaction. - for option, value in CHANGED_OPTIONS.items(): + for option, value in self.toolbar_options.items(): response = self.tester.post( '/sqleditor/{0}/{1}'.format(option, self.trans_id), data=json.dumps(value))