From f207818afa376bc5439d02a93663124032a797fc Mon Sep 17 00:00:00 2001 From: Khushboo Vashi Date: Wed, 2 Jul 2025 07:17:01 +0000 Subject: [PATCH] Implemented a server-side cursor to enhance performance when retrieving large datasets. #5797 --- .../query_tool_server_cursor_execute_menu.png | Bin 0 -> 46149 bytes docs/en_US/preferences.rst | 4 + docs/en_US/query_tool.rst | 28 +++ .../components/PgTree/FileTreeItem/index.tsx | 1 + .../js/helpers/ObjectExplorerToolbar.jsx | 2 +- web/pgadmin/tools/sqleditor/__init__.py | 181 ++++++++++-------- web/pgadmin/tools/sqleditor/command.py | 9 + .../sqleditor/static/js/SQLEditorModule.js | 2 +- .../js/components/QueryToolComponent.jsx | 20 +- .../js/components/sections/MainToolBar.jsx | 16 +- .../js/components/sections/ResultSet.jsx | 6 +- .../components/sections/ResultSetToolbar.jsx | 20 +- .../js/components/sections/StatusBar.jsx | 6 +- .../sqleditor/static/js/show_view_data.js | 12 +- .../sqleditor/tests/test_server_cursor.py | 111 +++++++++++ .../sqleditor/utils/query_tool_preferences.py | 11 ++ .../sqleditor/utils/start_running_query.py | 34 ++-- .../utils/driver/psycopg3/connection.py | 39 ++-- web/pgadmin/utils/driver/psycopg3/cursor.py | 36 ++-- 19 files changed, 391 insertions(+), 147 deletions(-) create mode 100644 docs/en_US/images/query_tool_server_cursor_execute_menu.png create mode 100644 web/pgadmin/tools/sqleditor/tests/test_server_cursor.py diff --git a/docs/en_US/images/query_tool_server_cursor_execute_menu.png b/docs/en_US/images/query_tool_server_cursor_execute_menu.png new file mode 100644 index 0000000000000000000000000000000000000000..94f5f8c6990abc9a4e56e7fec37243dafe83527e GIT binary patch literal 46149 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≺616W%NIu%@z)kx9u*`^9{aV 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
7)$+Xm#(Oloiv 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 Query Tool > Options and set "Use server cursor?" to True. +* Alternatively, you can enable it on a per-session basis via the Query Tool’s Execute menu. + +.. image:: images/query_tool_server_cursor_execute_menu.png + :alt: Query Tool Server Cursor + :align: center + + +Limitations: + +1. Transaction Requirement: Server-side cursors work only in transaction mode. +If enabled pgAdmin will automatically ensure queries run within a transaction. + +2. Limited Use Case: Use server-side cursors only when fetching large datasets. + +3. Pagination Limitation: In the Result Grid, the First and Last page buttons will be disabled, +as server-side cursors do not return a total row count. Consequently, the total number of rows +will not be displayed after execution. diff --git a/web/pgadmin/static/js/components/PgTree/FileTreeItem/index.tsx b/web/pgadmin/static/js/components/PgTree/FileTreeItem/index.tsx index 547415046..ea7b2fcc4 100644 --- a/web/pgadmin/static/js/components/PgTree/FileTreeItem/index.tsx +++ b/web/pgadmin/static/js/components/PgTree/FileTreeItem/index.tsx @@ -134,6 +134,7 @@ export class FileTreeItem extends React.Component => { + this.props.changeDirectoryCount(FileOrDir.parent); if(FileOrDir._loaded !== true) { this.events.dispatch(FileTreeXEvent.onTreeEvents, window.event, 'added', FileOrDir); diff --git a/web/pgadmin/static/js/helpers/ObjectExplorerToolbar.jsx b/web/pgadmin/static/js/helpers/ObjectExplorerToolbar.jsx index aca503f81..fe9319517 100644 --- a/web/pgadmin/static/js/helpers/ObjectExplorerToolbar.jsx +++ b/web/pgadmin/static/js/helpers/ObjectExplorerToolbar.jsx @@ -73,7 +73,7 @@ export default function ObjectExplorerToolbar() { } menuItem={menus['query_tool']} shortcut={browserPref?.sub_menu_query_tool} /> - } menuItem={menus['view_all_rows_context'] ?? + } menuItem={menus['view_all_rows_context'] ?? {label :gettext('All Rows')}} shortcut={browserPref?.sub_menu_view_data} /> } menuItem={menus['view_filtered_rows_context'] ?? { label : gettext('Filtered Rows...')}} /> diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index 25cdf624b..cd4be02d3 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -146,7 +146,8 @@ class SqlEditorModule(PgAdminModule): 'sqleditor.get_new_connection_user', 'sqleditor._check_server_connection_status', 'sqleditor.get_new_connection_role', - 'sqleditor.connect_server' + 'sqleditor.connect_server', + 'sqleditor.server_cursor', ] def on_logout(self): @@ -203,9 +204,15 @@ def initialize_viewdata(trans_id, cmd_type, obj_type, sgid, sid, did, obj_id): """ if request.data: - filter_sql = json.loads(request.data) + _data = json.loads(request.data) else: - filter_sql = request.args or request.form + _data = request.args or request.form + + filter_sql = _data['filter_sql'] if 'filter_sql' in _data else None + server_cursor = _data['server_cursor'] if\ + 'server_cursor' in _data and ( + _data['server_cursor'] == 'true' or _data['server_cursor'] is True + ) else False # Create asynchronous connection using random connection id. conn_id = str(secrets.choice(range(1, 9999999))) @@ -242,8 +249,9 @@ def initialize_viewdata(trans_id, cmd_type, obj_type, sgid, sid, did, obj_id): command_obj = ObjectRegistry.get_object( obj_type, conn_id=conn_id, sgid=sgid, sid=sid, did=did, obj_id=obj_id, cmd_type=cmd_type, - sql_filter=filter_sql + sql_filter=filter_sql, server_cursor=server_cursor ) + except ObjectGone: raise except Exception as e: @@ -354,6 +362,8 @@ def panel(trans_id): if 'database_name' in params: params['database_name'] = ( underscore_escape(params['database_name'])) + params['server_cursor'] = params[ + 'server_cursor'] if 'server_cursor' in params else False return render_template( "sqleditor/index.html", @@ -485,6 +495,8 @@ def _init_sqleditor(trans_id, connect, sgid, sid, did, dbname=None, **kwargs): kwargs['auto_commit'] = pref.preference('auto_commit').get() if kwargs.get('auto_rollback', None) is None: kwargs['auto_rollback'] = pref.preference('auto_rollback').get() + if kwargs.get('server_cursor', None) is None: + kwargs['server_cursor'] = pref.preference('server_cursor').get() try: conn = manager.connection(conn_id=conn_id, @@ -544,6 +556,7 @@ def _init_sqleditor(trans_id, connect, sgid, sid, did, dbname=None, **kwargs): # Set the value of auto commit and auto rollback specified in Preferences command_obj.set_auto_commit(kwargs['auto_commit']) command_obj.set_auto_rollback(kwargs['auto_rollback']) + command_obj.set_server_cursor(kwargs['server_cursor']) # Set the value of database name, that will be used later command_obj.dbname = dbname if dbname else None @@ -909,8 +922,15 @@ def start_view_data(trans_id): update_session_grid_transaction(trans_id, session_obj) + if trans_obj.server_cursor: + conn.release_async_cursor() + conn.execute_void("BEGIN;") + # Execute sql asynchronously - status, result = conn.execute_async(sql) + status, result = conn.execute_async( + sql, + server_cursor=trans_obj.server_cursor) + else: status = False result = error_msg @@ -947,6 +967,7 @@ def start_query_tool(trans_id): ) connect = 'connect' in request.args and request.args['connect'] == '1' + is_error, errmsg = check_and_upgrade_to_qt(trans_id, connect) if is_error: return make_json_response(success=0, errormsg=errmsg, @@ -1209,6 +1230,7 @@ def poll(trans_id): 'transaction_status': transaction_status, 'data_obj': data_obj, 'pagination': pagination, + 'server_cursor': trans_obj.server_cursor, } ) @@ -1837,11 +1859,59 @@ def check_and_upgrade_to_qt(trans_id, connect): 'conn_id': data.conn_id } is_error, errmsg, _, _ = _init_sqleditor( - trans_id, connect, data.sgid, data.sid, data.did, **kwargs) + trans_id, connect, data.sgid, data.sid, data.did, + **kwargs) return is_error, errmsg +def set_pref_options(trans_id, operation): + if request.data: + _data = json.loads(request.data) + else: + _data = request.args or request.form + + connect = 'connect' in request.args and request.args['connect'] == '1' + + is_error, errmsg = check_and_upgrade_to_qt(trans_id, connect) + if is_error: + return make_json_response(success=0, errormsg=errmsg, + info=ERROR_MSG_FAIL_TO_PROMOTE_QT, + status=404) + + # Check the transaction and connection status + status, error_msg, conn, trans_obj, session_obj = \ + check_transaction_status(trans_id) + + if error_msg == ERROR_MSG_TRANS_ID_NOT_FOUND: + return make_json_response(success=0, errormsg=error_msg, + info='DATAGRID_TRANSACTION_REQUIRED', + status=404) + + if (status and conn is not None and + trans_obj is not None and session_obj is not None): + + res = None + + if operation == 'auto_commit': + # Call the set_auto_commit method of transaction object + trans_obj.set_auto_commit(_data) + elif operation == 'auto_rollback': + trans_obj.set_auto_rollback(_data) + elif operation == 'server_cursor': + trans_obj.set_server_cursor(_data) + + # As we changed the transaction object we need to + # restore it and update the session variable. + session_obj['command_obj'] = pickle.dumps(trans_obj, -1) + update_session_grid_transaction(trans_id, session_obj) + else: + status = False + res = error_msg + + return make_json_response(data={'status': status, 'result': res}) + + @blueprint.route( '/auto_commit/', methods=["PUT", "POST"], endpoint='auto_commit' @@ -1854,45 +1924,7 @@ def set_auto_commit(trans_id): Args: trans_id: unique transaction id """ - if request.data: - auto_commit = json.loads(request.data) - else: - auto_commit = request.args or request.form - - connect = 'connect' in request.args and request.args['connect'] == '1' - - is_error, errmsg = check_and_upgrade_to_qt(trans_id, connect) - if is_error: - return make_json_response(success=0, errormsg=errmsg, - info=ERROR_MSG_FAIL_TO_PROMOTE_QT, - status=404) - - # Check the transaction and connection status - status, error_msg, conn, trans_obj, session_obj = \ - check_transaction_status(trans_id) - - if error_msg == ERROR_MSG_TRANS_ID_NOT_FOUND: - return make_json_response(success=0, errormsg=error_msg, - info='DATAGRID_TRANSACTION_REQUIRED', - status=404) - - if status and conn is not None and \ - trans_obj is not None and session_obj is not None: - - res = None - - # Call the set_auto_commit method of transaction object - trans_obj.set_auto_commit(auto_commit) - - # As we changed the transaction object we need to - # restore it and update the session variable. - session_obj['command_obj'] = pickle.dumps(trans_obj, -1) - update_session_grid_transaction(trans_id, session_obj) - else: - status = False - res = error_msg - - return make_json_response(data={'status': status, 'result': res}) + return set_pref_options(trans_id, 'auto_commit') @blueprint.route( @@ -1902,50 +1934,27 @@ def set_auto_commit(trans_id): @pga_login_required def set_auto_rollback(trans_id): """ - This method is used to set the value for auto commit . + This method is used to set the value for auto rollback . Args: trans_id: unique transaction id """ - if request.data: - auto_rollback = json.loads(request.data) - else: - auto_rollback = request.args or request.form + return set_pref_options(trans_id, 'auto_rollback') - connect = 'connect' in request.args and request.args['connect'] == '1' - is_error, errmsg = check_and_upgrade_to_qt(trans_id, connect) - if is_error: - return make_json_response(success=0, errormsg=errmsg, - info=ERROR_MSG_FAIL_TO_PROMOTE_QT, - status=404) +@blueprint.route( + '/server_cursor/', + methods=["PUT", "POST"], endpoint='server_cursor' +) +@pga_login_required +def set_server_cursor(trans_id): + """ + This method is used to set the value for server cursor. - # Check the transaction and connection status - status, error_msg, conn, trans_obj, session_obj = \ - check_transaction_status(trans_id) - - if error_msg == ERROR_MSG_TRANS_ID_NOT_FOUND: - return make_json_response(success=0, errormsg=error_msg, - info='DATAGRID_TRANSACTION_REQUIRED', - status=404) - - if status and conn is not None and \ - trans_obj is not None and session_obj is not None: - - res = None - - # Call the set_auto_rollback method of transaction object - trans_obj.set_auto_rollback(auto_rollback) - - # As we changed the transaction object we need to - # restore it and update the session variable. - session_obj['command_obj'] = pickle.dumps(trans_obj, -1) - update_session_grid_transaction(trans_id, session_obj) - else: - status = False - res = error_msg - - return make_json_response(data={'status': status, 'result': res}) + Args: + trans_id: unique transaction id + """ + return set_pref_options(trans_id, 'server_cursor') @blueprint.route( @@ -2181,12 +2190,18 @@ def start_query_download_tool(trans_id): if not sql: sql = trans_obj.get_sql(sync_conn) if sql and query_commited: + if trans_obj.server_cursor: + sync_conn.release_async_cursor() + sync_conn.execute_void("BEGIN;") # Re-execute the query to ensure the latest data is included - sync_conn.execute_async(sql) + sync_conn.execute_async(sql, server_cursor=trans_obj.server_cursor) # This returns generator of records. status, gen, conn_obj = \ sync_conn.execute_on_server_as_csv(records=10) + if trans_obj.server_cursor and query_commited: + sync_conn.execute_void("COMMIT;") + if not status: return make_json_response( data={ diff --git a/web/pgadmin/tools/sqleditor/command.py b/web/pgadmin/tools/sqleditor/command.py index 02965b2c6..08d875752 100644 --- a/web/pgadmin/tools/sqleditor/command.py +++ b/web/pgadmin/tools/sqleditor/command.py @@ -365,6 +365,8 @@ class GridCommand(BaseCommand, SQLFilter, FetchedRowTracker): self.limit = 100 self.thread_native_id = None + self.server_cursor = kwargs['server_cursor'] if\ + 'server_cursor' in kwargs else None def get_primary_keys(self, *args, **kwargs): return None, None @@ -425,6 +427,9 @@ class GridCommand(BaseCommand, SQLFilter, FetchedRowTracker): def set_thread_native_id(self, thread_native_id): self.thread_native_id = thread_native_id + def set_server_cursor(self, server_cursor): + self.server_cursor = server_cursor + class TableCommand(GridCommand): """ @@ -816,6 +821,7 @@ class QueryToolCommand(BaseCommand, FetchedRowTracker): self.table_has_oids = False self.columns_types = None self.thread_native_id = None + self.server_cursor = False def get_sql(self, default_conn=None): return None @@ -917,6 +923,9 @@ class QueryToolCommand(BaseCommand, FetchedRowTracker): def set_auto_commit(self, auto_commit): self.auto_commit = auto_commit + def set_server_cursor(self, server_cursor): + self.server_cursor = server_cursor + def __set_updatable_results_attrs(self, sql_path, table_oid, conn): # Set template path for sql scripts and the table object id diff --git a/web/pgadmin/tools/sqleditor/static/js/SQLEditorModule.js b/web/pgadmin/tools/sqleditor/static/js/SQLEditorModule.js index 609cc545c..c58ca6f9c 100644 --- a/web/pgadmin/tools/sqleditor/static/js/SQLEditorModule.js +++ b/web/pgadmin/tools/sqleditor/static/js/SQLEditorModule.js @@ -125,7 +125,7 @@ export default class SQLEditor { priority: 101, label: gettext('All Rows'), permission: AllPermissionTypes.TOOLS_QUERY_TOOL, - }, { + },{ name: 'view_first_100_rows_context_' + supportedNode, node: supportedNode, module: this, diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx index dcfa293fa..47e42f958 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx @@ -130,12 +130,14 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN connected_once: false, connection_status: null, connection_status_msg: '', + server_cursor: preferencesStore.getPreferencesForModule('sqleditor').server_cursor === true, params: { ...params, title: _.unescape(params.title), is_query_tool: params.is_query_tool == 'true', node_name: retrieveNodeName(selectedNodeInfo), - dbname: _.unescape(params.database_name) || getDatabaseLabel(selectedNodeInfo) + dbname: _.unescape(params.database_name) || getDatabaseLabel(selectedNodeInfo), + server_cursor: preferencesStore.getPreferencesForModule('sqleditor').server_cursor === true, }, connection_list: [{ sgid: params.sgid, @@ -318,7 +320,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN setQtStatePartial({ editor_disabled: false }); }; - const initializeQueryTool = (password, explainObject=null, macroSQL='', executeCursor=false, reexecute=false)=>{ + const initializeQueryTool = (password, explainObject=null, macroSQL='', executeCursor=false, executeServerCursor=false, reexecute=false)=>{ let selectedConn = _.find(qtState.connection_list, (c)=>c.is_selected); let baseUrl = ''; if(qtState.params.is_query_tool) { @@ -336,12 +338,14 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN ...qtState.params, }); } + eventBus.current.fireEvent(QUERY_TOOL_EVENTS.SERVER_CURSOR, executeServerCursor); api.post(baseUrl, qtState.params.is_query_tool ? { user: selectedConn.user, role: selectedConn.role, password: password, dbname: selectedConn.database_name - } : qtState.params.sql_filter) + } : {sql_filter: qtState.params.sql_filter, + server_cursor: qtState.params.server_cursor}) .then(()=>{ setQtStatePartial({ connected: true, @@ -350,7 +354,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN }); //this condition works if user is in View/Edit Data or user does not saved server or tunnel password and disconnected the server and executing the query if(!qtState.params.is_query_tool || reexecute) { - eventBus.current.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_EXECUTION, explainObject, macroSQL, executeCursor); + eventBus.current.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_EXECUTION, explainObject, macroSQL, executeCursor, executeServerCursor); let msg = `${selectedConn['server_name']}/${selectedConn['database_name']} - Database connected`; pgAdmin.Browser.notifier.success(_.escape(msg)); } @@ -856,6 +860,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN api: api, modal: modal, params: qtState.params, + server_cursor: qtState.server_cursor, preferences: qtState.preferences, mainContainerRef: containerRef, editor_disabled: qtState.editor_disabled, @@ -892,7 +897,11 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN }; }); }, - }), [qtState.params, qtState.preferences, containerRef.current, qtState.editor_disabled, qtState.eol, qtState.current_file]); + updateServerCursor: (state) => { + setQtStatePartial(state); + }, + }), [qtState.params, qtState.preferences, containerRef.current, qtState.editor_disabled, qtState.eol, qtState.current_file, qtState.server_cursor]); + const queryToolConnContextValue = React.useMemo(()=>({ connected: qtState.connected, @@ -952,6 +961,7 @@ QueryToolComponent.propTypes = { bgcolor: PropTypes.string, fgcolor: PropTypes.string, is_query_tool: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]).isRequired, + server_cursor: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), user: PropTypes.string, role: PropTypes.string, server_name: PropTypes.string, diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx index da0e64ad5..5d54969e4 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx @@ -46,7 +46,7 @@ const StyledBox = styled(Box)(({theme}) => ({ ...theme.mixins.panelBorder.bottom, })); -function autoCommitRollback(type, api, transId, value) { +function changeQueryExecutionSettings(type, api, transId, value) { let url = url_for(`sqleditor.${type}`, { 'trans_id': transId, }); @@ -123,8 +123,11 @@ export function MainToolBar({containerRef, onFilterClick, onManageMacros, onAddT const checkMenuClick = useCallback((e)=>{ setCheckedMenuItems((prev)=>{ let newVal = !prev[e.value]; - if(e.value === 'auto_commit' || e.value === 'auto_rollback') { - autoCommitRollback(e.value, queryToolCtx.api, queryToolCtx.params.trans_id, newVal) + if (e.value === 'server_cursor') { + queryToolCtx.updateServerCursor({server_cursor: newVal}); + } + if(e.value === 'auto_commit' || e.value === 'auto_rollback' || e.value === 'server_cursor') { + changeQueryExecutionSettings(e.value, queryToolCtx.api, queryToolCtx.params.trans_id, newVal) .catch ((error)=>{ newVal = prev[e.value]; eventBus.fireEvent(QUERY_TOOL_EVENTS.HANDLE_API_ERROR, error, { @@ -264,8 +267,8 @@ export function MainToolBar({containerRef, onFilterClick, onManageMacros, onAddT }; useEffect(()=>{ if(isInTxn()) { - setDisableButton('commit', false); - setDisableButton('rollback', false); + setDisableButton('commit', queryToolCtx.params.server_cursor && !queryToolCtx.params.is_query_tool ?true:false); + setDisableButton('rollback', queryToolCtx.params.server_cursor && !queryToolCtx.params.is_query_tool ?true:false); setDisableButton('execute-options', true); } else { setDisableButton('commit', true); @@ -338,6 +341,7 @@ export function MainToolBar({containerRef, onFilterClick, onManageMacros, onAddT explain_settings: queryToolPref.explain_settings, explain_wal: queryToolPref.explain_wal, open_in_new_tab: queryToolPref.open_in_new_tab, + server_cursor: queryToolPref.server_cursor, }); } } @@ -625,6 +629,8 @@ export function MainToolBar({containerRef, onFilterClick, onManageMacros, onAddT onClick={checkMenuClick}>{gettext('Auto commit?')} {gettext('Auto rollback on error?')} + {gettext('Use server cursor?')} { const from = (pageNo-1) * pagination.page_size + 1; const to = from + pagination.page_size - 1; - eventBus.fireEvent(QUERY_TOOL_EVENTS.FETCH_WINDOW, from, to); + eventBus.fireEvent(QUERY_TOOL_EVENTS.FETCH_WINDOW, from, to, serverCursor); clearSelection(); }; @@ -205,16 +205,16 @@ function PaginationInputs({pagination, totalRowCount, clearSelection}) { /> : {gettext('Showing rows: %s to %s', inputs.from, inputs.to)}} - {editPageRange && eventBus.fireEvent(QUERY_TOOL_EVENTS.FETCH_WINDOW, inputs.from, inputs.to)} disabled={errorInputs.from || errorInputs.to} icon={} />} - setEditPageRange((prev)=>!prev)} icon={editPageRange ? : } - /> + />}
 
{gettext('Page No:')} @@ -228,15 +228,16 @@ function PaginationInputs({pagination, totalRowCount, clearSelection}) { value={inputs.pageNo} onChange={(value)=>onInputChange('pageNo', value)} onKeyDown={onInputKeydownPageNo} + disabled={serverCursor} error={errorInputs['pageNo']} /> {gettext('of')} {pagination.page_count}
 
- goToPage(1)} icon={}/> + goToPage(1)} icon={}/> goToPage(pagination.page_no-1)} icon={}/> - goToPage(pagination.page_no+1)} icon={}/> - goToPage(pagination.page_count)} icon={} /> + goToPage(pagination.page_no+1)} icon={}/> + goToPage(pagination.page_count)} icon={} /> ); @@ -245,6 +246,7 @@ PaginationInputs.propTypes = { pagination: PropTypes.object, totalRowCount: PropTypes.number, clearSelection: PropTypes.func, + serverCursor: PropTypes.bool, }; export function ResultSetToolbar({query, canEdit, totalRowCount, pagination, allRowsSelect}) { const eventBus = useContext(QueryToolEventsContext); @@ -450,7 +452,7 @@ export function ResultSetToolbar({query, canEdit, totalRowCount, pagination, all {totalRowCount > 0 && - + } { eventBus.registerListener(QUERY_TOOL_EVENTS.CURSOR_ACTIVITY, (newPos)=>{ @@ -82,6 +83,9 @@ export function StatusBar({eol, handleEndOfLineChange}) { eventBus.registerListener(QUERY_TOOL_EVENTS.SELECTED_ROWS_COLS_CELL_CHANGED, (rows)=>{ setSelectedRowsCount(rows); }); + eventBus.registerListener(QUERY_TOOL_EVENTS.SERVER_CURSOR, (server_cursor)=>{ + setServerCursor(server_cursor); + }); }, []); useEffect(()=>{ @@ -111,7 +115,7 @@ export function StatusBar({eol, handleEndOfLineChange}) { return ( - {gettext('Total rows: %s', rowsCount)} + {serverCursor && gettext('Query executed with server cursor')} {!serverCursor && gettext('Total rows: %s', rowsCount)} {lastTaskText && {lastTaskText} {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}.{msec.toString().padStart(3, '0')} } diff --git a/web/pgadmin/tools/sqleditor/static/js/show_view_data.js b/web/pgadmin/tools/sqleditor/static/js/show_view_data.js index e67fa8574..b576e0099 100644 --- a/web/pgadmin/tools/sqleditor/static/js/show_view_data.js +++ b/web/pgadmin/tools/sqleditor/static/js/show_view_data.js @@ -77,7 +77,8 @@ export function showViewData( connectionData, treeIdentifier, transId, - filter=false + filter=false, + server_cursor=false ) { const node = pgBrowser.tree.findNodeByDomElement(treeIdentifier); if (node === undefined || !node.getData()) { @@ -100,7 +101,7 @@ export function showViewData( return; } - const gridUrl = generateUrl(transId, connectionData, node.getData(), parentData); + const gridUrl = generateUrl(transId, connectionData, node.getData(), parentData, server_cursor); const queryToolTitle = generateViewDataTitle(pgBrowser, treeIdentifier); if(filter) { @@ -109,7 +110,7 @@ export function showViewData( showFilterDialog(pgBrowser, treeIdentifier, queryToolMod, transId, gridUrl, queryToolTitle, validateUrl); } else { - queryToolMod.launch(transId, gridUrl, false, queryToolTitle); + queryToolMod.launch(transId, gridUrl, false, queryToolTitle, {server_cursor: server_cursor}); } } @@ -145,7 +146,7 @@ export function retrieveNodeName(parentData) { return ''; } -function generateUrl(trans_id, connectionData, nodeData, parentData) { +function generateUrl(trans_id, connectionData, nodeData, parentData, server_cursor=false) { let url_endpoint = url_for('sqleditor.panel', { 'trans_id': trans_id, }); @@ -157,7 +158,8 @@ function generateUrl(trans_id, connectionData, nodeData, parentData) { +`&sgid=${parentData.server_group._id}` +`&sid=${parentData.server._id}` +`&did=${parentData.database._id}` - +`&server_type=${parentData.server.server_type}`; + +`&server_type=${parentData.server.server_type}` + +`&server_cursor=${server_cursor}`; if(!parentData.server.username && parentData.server.user?.name) { url_endpoint += `&user=${parentData.server.user?.name}`; diff --git a/web/pgadmin/tools/sqleditor/tests/test_server_cursor.py b/web/pgadmin/tools/sqleditor/tests/test_server_cursor.py new file mode 100644 index 000000000..6f3f914c9 --- /dev/null +++ b/web/pgadmin/tools/sqleditor/tests/test_server_cursor.py @@ -0,0 +1,111 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## +from pgadmin.utils.route import BaseTestGenerator +from pgadmin.browser.server_groups.servers.databases.tests import utils as \ + database_utils +from regression.python_test_utils import test_utils +import json +from pgadmin.utils import server_utils +import secrets +import config +from pgadmin.tools.sqleditor.tests.execute_query_test_utils \ + import async_poll + + +class TestExecuteServerCursor(BaseTestGenerator): + """ + This class validates download csv + """ + scenarios = [ + ( + 'Execute with server cursor', + dict( + sql='SELECT 1', + init_url='/sqleditor/initialize/sqleditor/{0}/{1}/{2}/{3}', + ) + ) + ] + + def setUp(self): + self._db_name = 'server_cursor_' + str( + secrets.choice(range(10000, 65535))) + self._sid = self.server_information['server_id'] + + server_utils.connect_server(self, self._sid) + + self._did = test_utils.create_database( + self.server, self._db_name + ) + + # This method is responsible for initiating query hit at least once, + # so that download csv works + def initiate_sql_query_tool(self, trans_id, sql_query): + + # This code is to ensure to create a async cursor so that downloading + # csv can work. + # Start query tool transaction + + url = '/sqleditor/query_tool/start/{0}'.format(trans_id) + response = self.tester.post(url, data=json.dumps({"sql": sql_query}), + content_type='html/json') + self.assertEqual(response.status_code, 200) + + return async_poll(tester=self.tester, + poll_url='/sqleditor/poll/{0}'.format(trans_id)) + + def set_server_cursor(self, server_cursor): + _url = '/sqleditor/server_cursor/{0}'.format(self.trans_id) + res = self.tester.post(_url, data=json.dumps(server_cursor)) + self.assertEqual(res.status_code, 200) + + def runTest(self): + + db_con = database_utils.connect_database(self, + test_utils.SERVER_GROUP, + self._sid, + self._did) + if db_con["info"] != "Database connected.": + raise Exception("Could not connect to the database.") + + # Initialize query tool + self.trans_id = str(secrets.choice(range(1, 9999999))) + url = self.init_url.format( + self.trans_id, test_utils.SERVER_GROUP, self._sid, self._did) + res = self.tester.post(url, data=json.dumps({ + "dbname": self._db_name + })) + self.assertEqual(res.status_code, 200) + + self.set_server_cursor(True) + + response = self.initiate_sql_query_tool(self.trans_id, self.sql) + + self.assertEqual(response.status_code, 200) + _resp = json.loads(response.data.decode()) + self.assertTrue(_resp['data']['server_cursor']) + + self.set_server_cursor(False) + + # Close query tool + url = '/sqleditor/close/{0}'.format(self.trans_id) + response = self.tester.delete(url) + self.assertEqual(response.status_code, 200) + + database_utils.disconnect_database(self, self._sid, self._did) + + def tearDown(self): + main_conn = test_utils.get_db_connection( + self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode'] + ) + test_utils.drop_database(main_conn, self._db_name) diff --git a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py index edf6bc2f7..df0c1893b 100644 --- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py +++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py @@ -80,6 +80,17 @@ def register_query_tool_preferences(self): 'Tool tabs.') ) + self.server_cursor = self.preference.register( + 'Options', 'server_cursor', + gettext("Use server cursor?"), 'boolean', False, + category_label=PREF_LABEL_OPTIONS, + help_str=gettext('If set to True, the dataset will be fetched using a' + ' server-side cursor after the query is executed.' + ' This allows controlled data transfer to the client,' + ' enabling examination of large datasets without' + ' loading them entirely into memory.') + ) + self.show_prompt_save_query_changes = self.preference.register( 'Options', 'prompt_save_query_changes', gettext("Prompt to save unsaved query changes?"), 'boolean', True, diff --git a/web/pgadmin/tools/sqleditor/utils/start_running_query.py b/web/pgadmin/tools/sqleditor/utils/start_running_query.py index b11113c0f..c888667cb 100644 --- a/web/pgadmin/tools/sqleditor/utils/start_running_query.py +++ b/web/pgadmin/tools/sqleditor/utils/start_running_query.py @@ -101,7 +101,7 @@ class StartRunningQuery: session_obj, effective_sql_statement, trans_id, - transaction_object + transaction_object, ) can_edit = transaction_object.can_edit() @@ -137,18 +137,20 @@ class StartRunningQuery: StartRunningQuery.save_transaction_in_session(session_obj, trans_id, trans_obj) - # If auto commit is False and transaction status is Idle - # then call is_begin_not_required() function to check BEGIN - # is required or not. + if trans_obj.server_cursor and sql != 'COMMIT;' and sql != 'ROLLBACK;': + conn.release_async_cursor() if StartRunningQuery.is_begin_required_for_sql_query(trans_obj, - conn, sql): + conn, sql + ): conn.execute_void("BEGIN;") is_rollback_req = StartRunningQuery.is_rollback_statement_required( trans_obj, conn) + trans_obj.set_thread_native_id(None) + @copy_current_request_context def asyn_exec_query(conn, sql, trans_obj, is_rollback_req, app): @@ -156,9 +158,15 @@ class StartRunningQuery: # and formatted_error is True. with app.app_context(): try: - _, _ = conn.execute_async(sql) - # # If the transaction aborted for some reason and - # # Auto RollBack is True then issue a rollback to cleanup. + if trans_obj.server_cursor and (sql == 'COMMIT;' or + sql == 'ROLLBACK;'): + conn.execute_void(sql) + else: + _, _ = conn.execute_async( + sql, server_cursor=trans_obj.server_cursor) + # If the transaction aborted for some reason and + # Auto RollBack is True then issue a rollback + # to cleanup. if is_rollback_req: conn.execute_void("ROLLBACK;") except Exception as e: @@ -178,10 +186,12 @@ class StartRunningQuery: @staticmethod def is_begin_required_for_sql_query(trans_obj, conn, sql): - return (not trans_obj.auto_commit and - conn.transaction_status() == TX_STATUS_IDLE and - is_begin_required(sql) - ) + + return ((trans_obj.server_cursor and trans_obj.auto_commit) or ( + not trans_obj.auto_commit and + conn.transaction_status() == TX_STATUS_IDLE and + is_begin_required(sql) + )) @staticmethod def is_rollback_statement_required(trans_obj, conn): diff --git a/web/pgadmin/utils/driver/psycopg3/connection.py b/web/pgadmin/utils/driver/psycopg3/connection.py index d72736ebb..51dcb8b6e 100644 --- a/web/pgadmin/utils/driver/psycopg3/connection.py +++ b/web/pgadmin/utils/driver/psycopg3/connection.py @@ -17,6 +17,7 @@ import os import secrets import datetime import asyncio +import copy from collections import deque import psycopg from flask import g, current_app @@ -30,7 +31,7 @@ from pgadmin.model import User from pgadmin.utils.exception import ConnectionLost, CryptKeyMissing from pgadmin.utils import get_complete_file_path from ..abstract import BaseConnection -from .cursor import DictCursor, AsyncDictCursor +from .cursor import DictCursor, AsyncDictCursor, AsyncDictServerCursor from .typecast import register_global_typecasters,\ register_string_typecasters, register_binary_typecasters, \ register_array_to_string_typecasters, ALL_JSON_TYPES @@ -186,6 +187,7 @@ class Connection(BaseConnection): self.use_binary_placeholder = use_binary_placeholder self.array_to_string = array_to_string self.qtLiteral = get_driver(config.PG_DEFAULT_DRIVER).qtLiteral + self._autocommit = True super(Connection, self).__init__() @@ -358,6 +360,7 @@ class Connection(BaseConnection): prepare_threshold=manager.prepare_threshold ) pg_conn = asyncio.run(connectdbserver()) + pg_conn.server_cursor_factory = AsyncDictServerCursor else: pg_conn = psycopg.Connection.connect( connection_string, @@ -704,9 +707,10 @@ WHERE db.datname = current_database()""") self.conn_id.encode('utf-8') ), None) - if self.connected() and cur and not cur.closed and \ - (not server_cursor or (server_cursor and cur.name)): - return True, cur + if self.connected() and cur and not cur.closed: + if not server_cursor or ( + server_cursor and type(cur) is AsyncDictServerCursor): + return True, cur if not self.connected(): errmsg = "" @@ -732,8 +736,10 @@ WHERE db.datname = current_database()""") if server_cursor: # Providing name to cursor will create server side cursor. cursor_name = "CURSOR:{0}".format(self.conn_id) + self.conn.server_cursor_factory = AsyncDictServerCursor cur = self.conn.cursor( - name=cursor_name + name=cursor_name, + scrollable=scrollable ) else: cur = self.conn.cursor(scrollable=scrollable) @@ -893,7 +899,10 @@ WHERE db.datname = current_database()""") def gen(conn_obj, trans_obj, quote='strings', quote_char="'", field_separator=',', replace_nulls_with=None): - cur.scroll(0, mode='absolute') + try: + cur.scroll(0, mode='absolute') + except Exception as e: + print(str(e)) results = cur.fetchmany(records) if not results: yield gettext('The query executed did not return any data.') @@ -1037,7 +1046,15 @@ WHERE db.datname = current_database()""") return True, None - def execute_async(self, query, params=None, formatted_exception_msg=True): + def release_async_cursor(self): + if self.__async_cursor and not self.__async_cursor.closed: + try: + self.__async_cursor.close_cursor() + except Exception as e: + print("EXception==", str(e)) + + def execute_async(self, query, params=None, formatted_exception_msg=True, + server_cursor=False): """ This function executes the given query asynchronously and returns result. @@ -1048,10 +1065,11 @@ WHERE db.datname = current_database()""") formatted_exception_msg: if True then function return the formatted exception message """ - self.__async_cursor = None self.__async_query_error = None - status, cur = self.__cursor(scrollable=True) + + status, cur = self.__cursor(scrollable=True, + server_cursor=server_cursor) if not status: return False, str(cur) @@ -1501,7 +1519,7 @@ Failed to reset the connection to the server due to following error: else: status = 1 - if not cur: + if not cur or cur.closed: return False, self.CURSOR_NOT_FOUND result = None @@ -1533,7 +1551,6 @@ Failed to reset the connection to the server due to following error: result = [] try: result = cur.fetchall(_tupples=True) - except psycopg.ProgrammingError: result = None except psycopg.Error: diff --git a/web/pgadmin/utils/driver/psycopg3/cursor.py b/web/pgadmin/utils/driver/psycopg3/cursor.py index bdb25a063..6b00dc8ac 100644 --- a/web/pgadmin/utils/driver/psycopg3/cursor.py +++ b/web/pgadmin/utils/driver/psycopg3/cursor.py @@ -15,10 +15,9 @@ result. import asyncio from collections import OrderedDict -import psycopg from flask import g, current_app -from psycopg import Cursor as _cursor, AsyncCursor as _async_cursor -from typing import Any, Sequence +from psycopg import (Cursor as _cursor, AsyncCursor as _async_cursor, + AsyncServerCursor as _async_server_cursor) from psycopg.rows import dict_row, tuple_row from psycopg._encodings import py_codecs as encodings from .encoding import configure_driver_encodings @@ -220,6 +219,7 @@ class AsyncDictCursor(_async_cursor): def __init__(self, *args, **kwargs): self._odt_desc = None _async_cursor.__init__(self, *args, row_factory=dict_row) + self.cursor = _async_cursor def _dict_tuple(self, tup): """ @@ -234,8 +234,8 @@ class AsyncDictCursor(_async_cursor): Transform the regular description to wrapper object, which handles duplicate column name. """ - self._odt_desc = _async_cursor.__getattribute__(self, 'description') - pgresult = _async_cursor.__getattribute__(self, 'pgresult') + self._odt_desc = self.cursor.__getattribute__(self, 'description') + pgresult = self.cursor.__getattribute__(self, 'pgresult') desc = self._odt_desc if desc is None or len(desc) == 0: @@ -289,21 +289,21 @@ class AsyncDictCursor(_async_cursor): if params is not None and len(params) == 0: params = None - return await _async_cursor.execute(self, query, params) + return await self.cursor.execute(self, query, params) def executemany(self, query, params=None): """ Execute many function of regular cursor. """ self._odt_desc = None - return _async_cursor.executemany(self, query, params) + return self.cursor.executemany(self, query, params) async def _close_cursor(self): """ Close the cursor. """ - await _async_cursor.close(self) + await self.cursor.close(self) def close_cursor(self): """ @@ -328,13 +328,13 @@ class AsyncDictCursor(_async_cursor): """ Fetch many tuples as ordered dictionary list. """ - return await _async_cursor.fetchmany(self, size) + return await self.cursor.fetchmany(self, size) async def _fetchall(self): """ Fetch all tuples as ordered dictionary list. """ - return await _async_cursor.fetchall(self) + return await self.cursor.fetchall(self) def fetchall(self, _tupples=False): """ @@ -353,7 +353,7 @@ class AsyncDictCursor(_async_cursor): """ Fetch all tuples as ordered dictionary list. """ - return await _async_cursor.fetchone(self) + return await self.cursor.fetchone(self) def fetchone(self): """ @@ -382,7 +382,7 @@ class AsyncDictCursor(_async_cursor): """ Fetch all tuples as ordered dictionary list. """ - return await _async_cursor.scroll(self, position, mode=mode) + return await self.cursor.scroll(self, position, mode=mode) def scroll(self, position, mode="absolute"): """ @@ -395,3 +395,15 @@ class AsyncDictCursor(_async_cursor): return self.pgresult.ntuples else: return -1 + + +class AsyncDictServerCursor(AsyncDictCursor, _async_server_cursor): + + def __init__(self, *args, name=None, **kwargs): + self._odt_desc = None + _async_server_cursor.__init__(self, name=name, *args, + row_factory=dict_row) + self.cursor = _async_server_cursor + + def get_rowcount(self): + return 1