From 35687b62e83eb68999db64b86f36e6691fd1ff88 Mon Sep 17 00:00:00 2001 From: pravesh-sharma Date: Fri, 26 Sep 2025 16:16:57 +0530 Subject: [PATCH 1/5] Added support to download binary data from result grid. #4011 --- docs/en_US/preferences.rst | 10 +++- web/pgadmin/misc/__init__.py | 12 +++++ web/pgadmin/tools/sqleditor/__init__.py | 50 ++++++++++++++++++- .../js/components/QueryToolConstants.js | 1 + .../QueryToolDataGrid/Formatters.jsx | 15 ++++-- .../js/components/sections/ResultSet.jsx | 26 ++++++++++ web/pgadmin/utils/driver/psycopg3/typecast.py | 41 +++++++++++++++ 7 files changed, 149 insertions(+), 6 deletions(-) diff --git a/docs/en_US/preferences.rst b/docs/en_US/preferences.rst index 7fc51ecbdb7..ff0adb947fd 100644 --- a/docs/en_US/preferences.rst +++ b/docs/en_US/preferences.rst @@ -444,11 +444,17 @@ Use the fields on the *File Downloads* panel to manage file downloads related pr * When the *Automatically open downloaded files?* switch is set to *True* the downloaded file will automatically open in the system's default - application associated with that file type. + application associated with that file type. **Note:** This option is applicable and + visible only in desktop mode. + +* When the *Enable binary data download?* switch is set to *True*, + binary data can be downloaded from the result grid. Default is set to *False* + to prevent excessive memory usage on the server. * When the *Prompt for the download location?* switch is set to *True* a prompt will appear after clicking the download button, allowing you - to choose the download location. + to choose the download location. **Note:** This option is applicable and + visible only in desktop mode. **Note:** File Downloads related settings are applicable and visible only in desktop mode. diff --git a/web/pgadmin/misc/__init__.py b/web/pgadmin/misc/__init__.py index 0b8cbe4dc37..972245c0ead 100644 --- a/web/pgadmin/misc/__init__.py +++ b/web/pgadmin/misc/__init__.py @@ -166,6 +166,18 @@ def register_preferences(self): ) ) + self.preference.register( + 'file_downloads', 'enable_binary_data_download', + gettext("Enable binary data download?"), + 'boolean', False, + category_label=PREF_LABEL_FILE_DOWNLOADS, + help_str=gettext( + 'If set to True, binary data can be downloaded ' + 'from the result grid. The default is False to ' + 'prevent excessive memory usage on the server.' + ) + ) + def get_exposed_url_endpoints(self): """ Returns: diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index eadf72eb5a3..535be8e38c2 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -14,6 +14,7 @@ import secrets from urllib.parse import unquote from threading import Lock +from io import BytesIO import threading import math @@ -23,7 +24,8 @@ from config import PG_DEFAULT_DRIVER, ALLOW_SAVE_PASSWORD from werkzeug.user_agent import UserAgent -from flask import Response, url_for, render_template, session, current_app +from flask import Response, url_for, render_template, session, current_app, \ + send_file from flask import request from flask_babel import gettext from pgadmin.tools.sqleditor.utils.query_tool_connection_check \ @@ -70,6 +72,8 @@ from pgadmin.browser.server_groups.servers.utils import \ convert_connection_parameter, get_db_disp_restriction from pgadmin.misc.workspaces import check_and_delete_adhoc_server +from pgadmin.utils.driver.psycopg3.typecast import \ + register_binary_data_typecasters MODULE_NAME = 'sqleditor' TRANSACTION_STATUS_CHECK_FAILED = gettext("Transaction status check failed.") @@ -147,6 +151,7 @@ def get_exposed_url_endpoints(self): 'sqleditor.server_cursor', 'sqleditor.nlq_chat_stream', 'sqleditor.explain_analyze_stream', + 'sqleditor.download_binary_data', ] def on_logout(self): @@ -2182,6 +2187,49 @@ def start_query_download_tool(trans_id): return internal_server_error(errormsg=err_msg) +@blueprint.route( + '/download_binary_data/', + methods=["POST"], endpoint='download_binary_data' +) +@pga_login_required +def download_binary_data(trans_id): + """ + This method is used to download binary data. + """ + + (status, error_msg, conn, trans_obj, + session_obj) = check_transaction_status(trans_id) + + cur = conn._Connection__async_cursor + register_binary_data_typecasters(cur) + if not status or conn is None or trans_obj is None or \ + session_obj is None: + return internal_server_error( + errormsg=TRANSACTION_STATUS_CHECK_FAILED + ) + + data = request.values if request.values else request.get_json(silent=True) + if data is None: + return make_json_response( + status=410, + success=0, + errormsg=gettext( + "Could not find the required parameter (query)." + ) + ) + col_pos = data['colpos'] + cur.scroll(int(data['rowpos'])) + binary_data = cur.fetchone() + binary_data = binary_data[col_pos] + + return send_file( + BytesIO(binary_data), + as_attachment=True, + download_name='binary_data', + mimetype='application/octet-stream' + ) + + @blueprint.route( '/status/', methods=["GET"], diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js index 48c98e806bb..50ea29d870a 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js @@ -30,6 +30,7 @@ export const QUERY_TOOL_EVENTS = { TRIGGER_SELECT_ALL: 'TRIGGER_SELECT_ALL', TRIGGER_SAVE_QUERY_TOOL_DATA: 'TRIGGER_SAVE_QUERY_TOOL_DATA', TRIGGER_GET_QUERY_CONTENT: 'TRIGGER_GET_QUERY_CONTENT', + TRIGGER_SAVE_BINARY_DATA: 'TRIGGER_SAVE_BINARY_DATA', COPY_DATA: 'COPY_DATA', SET_LIMIT_VALUE: 'SET_LIMIT_VALUE', diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx index e5a8991626e..8ef59be2742 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx @@ -6,12 +6,17 @@ // This software is released under the PostgreSQL Licence // ////////////////////////////////////////////////////////////// +import { useContext } from 'react'; import { styled } from '@mui/material/styles'; import _ from 'lodash'; import PropTypes from 'prop-types'; +import gettext from 'sources/gettext'; import CustomPropTypes from '../../../../../../static/js/custom_prop_types'; import usePreferences from '../../../../../../preferences/static/js/store'; - +import GetAppRoundedIcon from '@mui/icons-material/GetAppRounded'; +import { PgIconButton } from '../../../../../../static/js/components/Buttons'; +import { QUERY_TOOL_EVENTS } from '../QueryToolConstants'; +import { QueryToolEventsContext } from '../QueryToolComponent'; const StyledNullAndDefaultFormatter = styled(NullAndDefaultFormatter)(({theme}) => ({ '& .Formatters-disabledCell': { @@ -70,10 +75,14 @@ NumberFormatter.propTypes = FormatterPropTypes; export function BinaryFormatter({row, column}) { let value = row[column.key]; - + const eventBus = useContext(QueryToolEventsContext); + const downloadBinaryData = usePreferences().getPreferences('misc', 'enable_binary_data_download').value; return ( - [{value}] + [{value}]   + {downloadBinaryData && + } + onClick={()=>eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, row.__temp_PK, column.pos)}/>} ); } diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx index 1a408da77ee..def3fceef6f 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx @@ -493,6 +493,23 @@ export class ResultSetUtils { } } + async saveBinaryResultsToFile(fileName, rowPos, colPos, onProgress) { + try { + await DownloadUtils.downloadFileStream({ + url: url_for('sqleditor.download_binary_data', { + 'trans_id': this.transId, + }), + options: { + method: 'POST', + body: JSON.stringify({filename: fileName, rowpos: rowPos, colpos: colPos}) + }}, fileName, 'application/octet-stream', onProgress); + this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS_END); + } catch (error) { + this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS_END); + this.eventBus.fireEvent(QUERY_TOOL_EVENTS.HANDLE_API_ERROR, error); + } + } + includeFilter(reqData) { return this.api.post( url_for('sqleditor.inclusive_filter', { @@ -1038,6 +1055,15 @@ export function ResultSet() { setLoaderText(''); }); + eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, async (rowPos, colPos)=>{ + let fileName = 'data-' + new Date().getTime(); + setLoaderText(gettext('Downloading results...')); + await rsu.current.saveBinaryResultsToFile(fileName, rowPos, colPos, (p)=>{ + setLoaderText(gettext('Downloading results(%s)...', p)); + }); + setLoaderText(''); + }); + eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_SET_LIMIT, async (limit)=>{ setLoaderText(gettext('Setting the limit on the result...')); try { diff --git a/web/pgadmin/utils/driver/psycopg3/typecast.py b/web/pgadmin/utils/driver/psycopg3/typecast.py index b906a23f95c..6d700427297 100644 --- a/web/pgadmin/utils/driver/psycopg3/typecast.py +++ b/web/pgadmin/utils/driver/psycopg3/typecast.py @@ -212,6 +212,21 @@ def register_array_to_string_typecasters(connection=None): TextLoaderpgAdmin) +def register_binary_data_typecasters(cur): + # Register type caster to fetch original binary data for bytea type. + cur.adapters.register_loader(17, + ByteaDataLoader) + + cur.adapters.register_loader(1001, + ByteaDataLoader) + + cur.adapters.register_loader(17, + ByteaBinaryDataLoader) + + cur.adapters.register_loader(1001, + ByteaBinaryDataLoader) + + class InetLoader(InetLoader): def load(self, data): if isinstance(data, memoryview): @@ -240,6 +255,32 @@ def load(self, data): return 'binary data' if data is not None else None +class ByteaDataLoader(Loader): + # Loads the actual binary data. + def load(self, data): + if data: + if isinstance(data, memoryview): + data = bytes(data).decode() + if data.startswith('\\x'): + data = data[2:] + try: + return bytes.fromhex(data) + except ValueError: + # In case of error while converting hex to bytes, return + # original data. + return data + else: + return data + return data if data is not None else None + + +class ByteaBinaryDataLoader(Loader): + format = _pq_Format.BINARY + + def load(self, data): + return data if data is not None else None + + class TextLoaderpgAdmin(TextLoader): def load(self, data): postgres_encoding, python_encoding = get_encoding( From 1a5514b3c0e9f7e5f329a72b9fe95d0a55f210dc Mon Sep 17 00:00:00 2001 From: pravesh-sharma Date: Fri, 20 Feb 2026 07:46:42 +0530 Subject: [PATCH 2/5] Some more review fix --- web/pgadmin/tools/sqleditor/__init__.py | 34 +++++++++++++++++-- .../QueryToolDataGrid/Formatters.jsx | 4 +-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index 535be8e38c2..ee62917cc35 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -2200,14 +2200,24 @@ def download_binary_data(trans_id): (status, error_msg, conn, trans_obj, session_obj) = check_transaction_status(trans_id) - cur = conn._Connection__async_cursor - register_binary_data_typecasters(cur) + if error_msg: + return internal_server_error( + errormsg=error_msg + ) + if not status or conn is None or trans_obj is None or \ session_obj is None: return internal_server_error( errormsg=TRANSACTION_STATUS_CHECK_FAILED ) + cur = conn._Connection__async_cursor + if cur is None: + return internal_server_error( + errormsg=gettext('No active result cursor.') + ) + register_binary_data_typecasters(cur) + data = request.values if request.values else request.get_json(silent=True) if data is None: return make_json_response( @@ -2222,7 +2232,25 @@ def download_binary_data(trans_id): binary_data = cur.fetchone() binary_data = binary_data[col_pos] - return send_file( + try: + row_pos = int(data['rowpos']) + col_pos = int(data['colpos']) + if row_pos < 0 or col_pos < 0: + raise ValueError + cur.scroll(row_pos) + row = cur.fetchone() + if row is None or col_pos >= len(row): + return internal_server_error( + errormsg=gettext('Requested cell is out of range.') + ) + binary_data = row[col_pos] + except (ValueError, IndexError, TypeError) as e: + current_app.logger.error(e) + return internal_server_error( + errormsg='Invalid row/column position.' + ) + + return send_file( BytesIO(binary_data), as_attachment=True, download_name='binary_data', diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx index 8ef59be2742..6e194fa8125 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx @@ -73,7 +73,7 @@ export function NumberFormatter({row, column}) { } NumberFormatter.propTypes = FormatterPropTypes; -export function BinaryFormatter({row, column}) { +export function BinaryFormatter({row, column, ...props}) { let value = row[column.key]; const eventBus = useContext(QueryToolEventsContext); const downloadBinaryData = usePreferences().getPreferences('misc', 'enable_binary_data_download').value; @@ -82,7 +82,7 @@ export function BinaryFormatter({row, column}) { [{value}]   {downloadBinaryData && } - onClick={()=>eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, row.__temp_PK, column.pos)}/>} + onClick={()=>eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, props.rowIdx, column.pos)}/>} ); } From a524eaaa56976069fe830e6c432ebc00aaf37bc8 Mon Sep 17 00:00:00 2001 From: pravesh-sharma Date: Fri, 20 Feb 2026 10:17:52 +0530 Subject: [PATCH 3/5] Fixed review comments. --- web/pgadmin/tools/sqleditor/__init__.py | 13 +++++++++---- .../components/QueryToolDataGrid/Formatters.jsx | 15 ++++++++++++--- .../js/components/QueryToolDataGrid/index.jsx | 4 ++-- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index ee62917cc35..0e675f7b32e 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -2200,9 +2200,14 @@ def download_binary_data(trans_id): (status, error_msg, conn, trans_obj, session_obj) = check_transaction_status(trans_id) - if error_msg: - return internal_server_error( - errormsg=error_msg + if isinstance(error_msg, Response): + return error_msg + 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 not status or conn is None or trans_obj is None or \ @@ -2250,7 +2255,7 @@ def download_binary_data(trans_id): errormsg='Invalid row/column position.' ) - return send_file( + return send_file( BytesIO(binary_data), as_attachment=True, download_name='binary_data', diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx index 6e194fa8125..990b27a5cfb 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx @@ -17,6 +17,7 @@ import GetAppRoundedIcon from '@mui/icons-material/GetAppRounded'; import { PgIconButton } from '../../../../../../static/js/components/Buttons'; import { QUERY_TOOL_EVENTS } from '../QueryToolConstants'; import { QueryToolEventsContext } from '../QueryToolComponent'; +import { DataGridExtrasContext } from './index'; const StyledNullAndDefaultFormatter = styled(NullAndDefaultFormatter)(({theme}) => ({ '& .Formatters-disabledCell': { @@ -73,16 +74,24 @@ export function NumberFormatter({row, column}) { } NumberFormatter.propTypes = FormatterPropTypes; -export function BinaryFormatter({row, column, ...props}) { +export function BinaryFormatter({row, column}) { let value = row[column.key]; const eventBus = useContext(QueryToolEventsContext); + const dataGridExtras = useContext(DataGridExtrasContext); const downloadBinaryData = usePreferences().getPreferences('misc', 'enable_binary_data_download').value; + + // Use clientPK as the absolute row position + // rowKeyGetter returns the clientPK value which is a sequential counter (0, 1, 2, ...) + // that persists across pagination and represents the 0-based absolute position in the result set + const absoluteRowPos = parseInt(dataGridExtras?.rowKeyGetter?.(row) ?? 0); + console.log(absoluteRowPos) + return ( [{value}]   - {downloadBinaryData && + {downloadBinaryData && } - onClick={()=>eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, props.rowIdx, column.pos)}/>} + onClick={()=>eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, absoluteRowPos, column.pos)}/>} ); } diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx index 432f8e10ad3..4eb6e59f1d6 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx @@ -429,8 +429,8 @@ export default function QueryToolDataGrid({columns, rows, totalRowCount, dataCha }, []); const dataGridExtras = useMemo(()=>({ - onSelectedCellChange, handleShortcuts, startRowNum - }), [onSelectedCellChange]); + onSelectedCellChange, handleShortcuts, startRowNum, rowKeyGetter: props.rowKeyGetter + }), [onSelectedCellChange, props.rowKeyGetter]); // Save column width to window object on resize const handleColumnResize = (column, width) => { From 4aacb389c565661eb17568920db15be70ba304cb Mon Sep 17 00:00:00 2001 From: pravesh-sharma Date: Tue, 24 Feb 2026 12:35:23 +0530 Subject: [PATCH 4/5] Fixed review comments. --- web/pgadmin/tools/sqleditor/__init__.py | 17 +++++++++++------ .../components/QueryToolDataGrid/Formatters.jsx | 8 ++------ .../js/components/QueryToolDataGrid/index.jsx | 4 ++-- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index 0e675f7b32e..9515ffbf42f 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -73,7 +73,7 @@ convert_connection_parameter, get_db_disp_restriction from pgadmin.misc.workspaces import check_and_delete_adhoc_server from pgadmin.utils.driver.psycopg3.typecast import \ - register_binary_data_typecasters + register_binary_data_typecasters, register_binary_typecasters MODULE_NAME = 'sqleditor' TRANSACTION_STATUS_CHECK_FAILED = gettext("Transaction status check failed.") @@ -2229,13 +2229,9 @@ def download_binary_data(trans_id): status=410, success=0, errormsg=gettext( - "Could not find the required parameter (query)." + "Could not find the required parameters (rowpos, colpos)." ) ) - col_pos = data['colpos'] - cur.scroll(int(data['rowpos'])) - binary_data = cur.fetchone() - binary_data = binary_data[col_pos] try: row_pos = int(data['rowpos']) @@ -2254,6 +2250,15 @@ def download_binary_data(trans_id): return internal_server_error( errormsg='Invalid row/column position.' ) + finally: + # Always restore the original typecasters + # (works on connection or cursor) + register_binary_typecasters(cur) + + if binary_data is None: + return bad_request( + errormsg=gettext('The selected cell contains NULL.') + ) return send_file( BytesIO(binary_data), diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx index 990b27a5cfb..c32dffa52f5 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx @@ -74,17 +74,13 @@ export function NumberFormatter({row, column}) { } NumberFormatter.propTypes = FormatterPropTypes; -export function BinaryFormatter({row, column}) { +export function BinaryFormatter({row, column, ...props}) { let value = row[column.key]; const eventBus = useContext(QueryToolEventsContext); const dataGridExtras = useContext(DataGridExtrasContext); const downloadBinaryData = usePreferences().getPreferences('misc', 'enable_binary_data_download').value; - // Use clientPK as the absolute row position - // rowKeyGetter returns the clientPK value which is a sequential counter (0, 1, 2, ...) - // that persists across pagination and represents the 0-based absolute position in the result set - const absoluteRowPos = parseInt(dataGridExtras?.rowKeyGetter?.(row) ?? 0); - console.log(absoluteRowPos) + const absoluteRowPos = (dataGridExtras?.startRowNum ?? 1) - 1 + props.rowIdx; return ( diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx index 4eb6e59f1d6..432f8e10ad3 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx @@ -429,8 +429,8 @@ export default function QueryToolDataGrid({columns, rows, totalRowCount, dataCha }, []); const dataGridExtras = useMemo(()=>({ - onSelectedCellChange, handleShortcuts, startRowNum, rowKeyGetter: props.rowKeyGetter - }), [onSelectedCellChange, props.rowKeyGetter]); + onSelectedCellChange, handleShortcuts, startRowNum + }), [onSelectedCellChange]); // Save column width to window object on resize const handleColumnResize = (column, width) => { From 908ea2c6060a22013190e5bf709ef75abc9af599 Mon Sep 17 00:00:00 2001 From: pravesh-sharma Date: Mon, 9 Mar 2026 11:49:27 +0530 Subject: [PATCH 5/5] Fixed review comments --- web/pgadmin/tools/sqleditor/__init__.py | 16 ++++++++++++--- web/pgadmin/utils/driver/psycopg3/typecast.py | 20 ++++++------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index 9515ffbf42f..e8a0cf40e4a 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -2221,7 +2221,6 @@ def download_binary_data(trans_id): return internal_server_error( errormsg=gettext('No active result cursor.') ) - register_binary_data_typecasters(cur) data = request.values if request.values else request.get_json(silent=True) if data is None: @@ -2234,12 +2233,23 @@ def download_binary_data(trans_id): ) try: + register_binary_data_typecasters(cur) row_pos = int(data['rowpos']) col_pos = int(data['colpos']) if row_pos < 0 or col_pos < 0: raise ValueError - cur.scroll(row_pos) - row = cur.fetchone() + + # Save the current cursor position + saved_pos = cur.rownumber if cur.rownumber is not None else 0 + + try: + # Scroll to the requested row and fetch it + cur.scroll(row_pos, mode='absolute') + row = cur.fetchone() + finally: + # Always restore the cursor position + cur.scroll(saved_pos, mode='absolute') + if row is None or col_pos >= len(row): return internal_server_error( errormsg=gettext('Requested cell is out of range.') diff --git a/web/pgadmin/utils/driver/psycopg3/typecast.py b/web/pgadmin/utils/driver/psycopg3/typecast.py index 6d700427297..ac48b96e054 100644 --- a/web/pgadmin/utils/driver/psycopg3/typecast.py +++ b/web/pgadmin/utils/driver/psycopg3/typecast.py @@ -258,20 +258,12 @@ def load(self, data): class ByteaDataLoader(Loader): # Loads the actual binary data. def load(self, data): - if data: - if isinstance(data, memoryview): - data = bytes(data).decode() - if data.startswith('\\x'): - data = data[2:] - try: - return bytes.fromhex(data) - except ValueError: - # In case of error while converting hex to bytes, return - # original data. - return data - else: - return data - return data if data is not None else None + if data is None: + return None + raw = bytes(data) if isinstance(data, memoryview) else data + if isinstance(raw, str) and raw.startswith('\\x'): + return bytes.fromhex(raw[2:]) + return raw class ByteaBinaryDataLoader(Loader):