Skip to content

Commit f49c967

Browse files
Added support to download binary data from result grid. #4011
1 parent 0a539c3 commit f49c967

File tree

8 files changed

+208
-10
lines changed

8 files changed

+208
-10
lines changed

docs/en_US/preferences.rst

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,11 +444,17 @@ Use the fields on the *File Downloads* panel to manage file downloads related pr
444444

445445
* When the *Automatically open downloaded files?* switch is set to *True*
446446
the downloaded file will automatically open in the system's default
447-
application associated with that file type.
447+
application associated with that file type. **Note:** This option is applicable and
448+
visible only in desktop mode.
449+
450+
* When the *Enable binary data download?* switch is set to *True*,
451+
binary data can be downloaded from the result grid. Default is set to *False*
452+
to prevent excessive memory usage on the server.
448453

449454
* When the *Prompt for the download location?* switch is set to *True*
450455
a prompt will appear after clicking the download button, allowing you
451-
to choose the download location.
456+
to choose the download location. **Note:** This option is applicable and
457+
visible only in desktop mode.
452458

453459
**Note:** File Downloads related settings are applicable and visible only in desktop mode.
454460

web/pgadmin/misc/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,18 @@ def register_preferences(self):
166166
)
167167
)
168168

169+
self.preference.register(
170+
'file_downloads', 'enable_binary_data_download',
171+
gettext("Enable binary data download?"),
172+
'boolean', False,
173+
category_label=PREF_LABEL_FILE_DOWNLOADS,
174+
help_str=gettext(
175+
'If set to True, binary data can be downloaded '
176+
'from the result grid. The default is False to '
177+
'prevent excessive memory usage on the server.'
178+
)
179+
)
180+
169181
def get_exposed_url_endpoints(self):
170182
"""
171183
Returns:

web/pgadmin/tools/sqleditor/__init__.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import secrets
1515
from urllib.parse import unquote
1616
from threading import Lock
17+
from io import BytesIO
1718
import threading
1819
import math
1920

@@ -23,7 +24,8 @@
2324

2425
from config import PG_DEFAULT_DRIVER, ALLOW_SAVE_PASSWORD
2526
from werkzeug.user_agent import UserAgent
26-
from flask import Response, url_for, render_template, session, current_app
27+
from flask import Response, url_for, render_template, session, current_app, \
28+
send_file
2729
from flask import request
2830
from flask_babel import gettext
2931
from pgadmin.tools.sqleditor.utils.query_tool_connection_check \
@@ -70,6 +72,8 @@
7072
from pgadmin.browser.server_groups.servers.utils import \
7173
convert_connection_parameter, get_db_disp_restriction
7274
from pgadmin.misc.workspaces import check_and_delete_adhoc_server
75+
from pgadmin.utils.driver.psycopg3.typecast import \
76+
register_binary_data_typecasters, register_binary_typecasters
7377

7478
MODULE_NAME = 'sqleditor'
7579
TRANSACTION_STATUS_CHECK_FAILED = gettext("Transaction status check failed.")
@@ -147,6 +151,7 @@ def get_exposed_url_endpoints(self):
147151
'sqleditor.server_cursor',
148152
'sqleditor.nlq_chat_stream',
149153
'sqleditor.explain_analyze_stream',
154+
'sqleditor.download_binary_data',
150155
]
151156

152157
def on_logout(self):
@@ -2182,6 +2187,66 @@ def start_query_download_tool(trans_id):
21822187
return internal_server_error(errormsg=err_msg)
21832188

21842189

2190+
@blueprint.route(
2191+
'/download_binary_data/<int:trans_id>',
2192+
methods=["POST"], endpoint='download_binary_data'
2193+
)
2194+
@pga_login_required
2195+
def download_binary_data(trans_id):
2196+
"""
2197+
This method is used to download binary data.
2198+
"""
2199+
2200+
(status, error_msg, conn, trans_obj,
2201+
session_obj) = check_transaction_status(trans_id)
2202+
2203+
if isinstance(error_msg, Response):
2204+
return error_msg
2205+
if error_msg == ERROR_MSG_TRANS_ID_NOT_FOUND:
2206+
return make_json_response(
2207+
success=0,
2208+
errormsg=error_msg,
2209+
info='DATAGRID_TRANSACTION_REQUIRED',
2210+
status=404
2211+
)
2212+
2213+
if not status or conn is None or trans_obj is None or \
2214+
session_obj is None:
2215+
return internal_server_error(
2216+
errormsg=TRANSACTION_STATUS_CHECK_FAILED
2217+
)
2218+
2219+
cur = conn._Connection__async_cursor
2220+
if cur is None:
2221+
return internal_server_error(
2222+
errormsg=gettext('No active result cursor.')
2223+
)
2224+
2225+
data = request.values if request.values else request.get_json(silent=True)
2226+
if data is None:
2227+
return make_json_response(
2228+
status=410,
2229+
success=0,
2230+
errormsg=gettext(
2231+
"Could not find the required parameters (rowpos, colpos)."
2232+
)
2233+
)
2234+
2235+
binary_data = conn.download_binary_data(cur, data)
2236+
2237+
if binary_data is None:
2238+
return bad_request(
2239+
errormsg=gettext('The selected cell contains NULL.')
2240+
)
2241+
2242+
return send_file(
2243+
BytesIO(binary_data),
2244+
as_attachment=True,
2245+
download_name='binary_data',
2246+
mimetype='application/octet-stream'
2247+
)
2248+
2249+
21852250
@blueprint.route(
21862251
'/status/<int:trans_id>',
21872252
methods=["GET"],

web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const QUERY_TOOL_EVENTS = {
3030
TRIGGER_SELECT_ALL: 'TRIGGER_SELECT_ALL',
3131
TRIGGER_SAVE_QUERY_TOOL_DATA: 'TRIGGER_SAVE_QUERY_TOOL_DATA',
3232
TRIGGER_GET_QUERY_CONTENT: 'TRIGGER_GET_QUERY_CONTENT',
33+
TRIGGER_SAVE_BINARY_DATA: 'TRIGGER_SAVE_BINARY_DATA',
3334

3435
COPY_DATA: 'COPY_DATA',
3536
SET_LIMIT_VALUE: 'SET_LIMIT_VALUE',

web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@
66
// This software is released under the PostgreSQL Licence
77
//
88
//////////////////////////////////////////////////////////////
9+
import { useContext } from 'react';
910
import { styled } from '@mui/material/styles';
1011
import _ from 'lodash';
1112
import PropTypes from 'prop-types';
13+
import gettext from 'sources/gettext';
1214
import CustomPropTypes from '../../../../../../static/js/custom_prop_types';
1315
import usePreferences from '../../../../../../preferences/static/js/store';
14-
16+
import GetAppRoundedIcon from '@mui/icons-material/GetAppRounded';
17+
import { PgIconButton } from '../../../../../../static/js/components/Buttons';
18+
import { QUERY_TOOL_EVENTS } from '../QueryToolConstants';
19+
import { QueryToolEventsContext } from '../QueryToolComponent';
20+
import { DataGridExtrasContext } from './index';
1521

1622
const StyledNullAndDefaultFormatter = styled(NullAndDefaultFormatter)(({theme}) => ({
1723
'& .Formatters-disabledCell': {
@@ -68,12 +74,20 @@ export function NumberFormatter({row, column}) {
6874
}
6975
NumberFormatter.propTypes = FormatterPropTypes;
7076

71-
export function BinaryFormatter({row, column}) {
77+
export function BinaryFormatter({row, column, ...props}) {
7278
let value = row[column.key];
79+
const eventBus = useContext(QueryToolEventsContext);
80+
const dataGridExtras = useContext(DataGridExtrasContext);
81+
const downloadBinaryData = usePreferences().getPreferences('misc', 'enable_binary_data_download').value;
82+
83+
const absoluteRowPos = (dataGridExtras?.startRowNum ?? 1) - 1 + props.rowIdx;
7384

7485
return (
7586
<StyledNullAndDefaultFormatter value={value} column={column}>
76-
<span className='Formatters-disabledCell'>[{value}]</span>
87+
<span className='Formatters-disabledCell'>[{value}]</span>&nbsp;&nbsp;
88+
{downloadBinaryData &&
89+
<PgIconButton size="xs" title={gettext('Download binary data')} icon={<GetAppRoundedIcon />}
90+
onClick={()=>eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, absoluteRowPos, column.pos)}/>}
7791
</StyledNullAndDefaultFormatter>
7892
);
7993
}

web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,23 @@ export class ResultSetUtils {
493493
}
494494
}
495495

496+
async saveBinaryResultsToFile(fileName, rowPos, colPos, onProgress) {
497+
try {
498+
await DownloadUtils.downloadFileStream({
499+
url: url_for('sqleditor.download_binary_data', {
500+
'trans_id': this.transId,
501+
}),
502+
options: {
503+
method: 'POST',
504+
body: JSON.stringify({filename: fileName, rowpos: rowPos, colpos: colPos})
505+
}}, fileName, 'application/octet-stream', onProgress);
506+
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS_END);
507+
} catch (error) {
508+
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS_END);
509+
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.HANDLE_API_ERROR, error);
510+
}
511+
}
512+
496513
includeFilter(reqData) {
497514
return this.api.post(
498515
url_for('sqleditor.inclusive_filter', {
@@ -1048,6 +1065,15 @@ export function ResultSet() {
10481065
setLoaderText('');
10491066
});
10501067

1068+
eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, async (rowPos, colPos)=>{
1069+
let fileName = 'data-' + new Date().getTime();
1070+
setLoaderText(gettext('Downloading results...'));
1071+
await rsu.current.saveBinaryResultsToFile(fileName, rowPos, colPos, (p)=>{
1072+
setLoaderText(gettext('Downloading results(%s)...', p));
1073+
});
1074+
setLoaderText('');
1075+
});
1076+
10511077
eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_SET_LIMIT, async (limit)=>{
10521078
setLoaderText(gettext('Setting the limit on the result...'));
10531079
try {

web/pgadmin/utils/driver/psycopg3/connection.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@
3030
from pgadmin.model import User
3131
from pgadmin.utils.exception import ConnectionLost, CryptKeyMissing
3232
from pgadmin.utils import get_complete_file_path
33+
from pgadmin.utils.ajax import internal_server_error
3334
from ..abstract import BaseConnection
3435
from .cursor import DictCursor, AsyncDictCursor, AsyncDictServerCursor
35-
from .typecast import register_global_typecasters,\
36-
register_string_typecasters, register_binary_typecasters, \
37-
register_array_to_string_typecasters, ALL_JSON_TYPES, \
38-
register_numeric_typecasters
36+
from .typecast import register_binary_data_typecasters,\
37+
register_global_typecasters, register_string_typecasters,\
38+
register_binary_typecasters, register_array_to_string_typecasters,\
39+
register_numeric_typecasters, ALL_JSON_TYPES
3940
from .encoding import get_encoding, configure_driver_encodings
4041
from pgadmin.utils import csv_lib as csv
4142
from pgadmin.utils.master_password import get_crypt_key
@@ -1913,3 +1914,43 @@ def mogrify(self, query, parameters):
19131914
return _cur.mogrify(query, parameters)
19141915
else:
19151916
return query
1917+
1918+
def download_binary_data(self, cur, params):
1919+
"""
1920+
This function will return the binary data for the given query.
1921+
:param cur: cursor object
1922+
:param params: row/col params
1923+
:return:
1924+
"""
1925+
try:
1926+
register_binary_data_typecasters(cur)
1927+
row_pos = int(params['rowpos'])
1928+
col_pos = int(params['colpos'])
1929+
if row_pos < 0 or col_pos < 0:
1930+
raise ValueError
1931+
1932+
# Save the current cursor position
1933+
saved_pos = cur.rownumber if cur.rownumber is not None else 0
1934+
1935+
try:
1936+
# Scroll to the requested row and fetch it
1937+
cur.scroll(row_pos, mode='absolute')
1938+
row = cur.fetchone()
1939+
finally:
1940+
# Always restore the cursor position
1941+
cur.scroll(saved_pos, mode='absolute')
1942+
1943+
if row is None or col_pos >= len(row):
1944+
return internal_server_error(
1945+
errormsg=gettext('Requested cell is out of range.')
1946+
)
1947+
return row[col_pos]
1948+
except (ValueError, IndexError, TypeError) as e:
1949+
current_app.logger.error(e)
1950+
return internal_server_error(
1951+
errormsg='Invalid row/column position.'
1952+
)
1953+
finally:
1954+
# Always restore the original typecasters
1955+
# (works on connection or cursor)
1956+
register_binary_typecasters(cur)

web/pgadmin/utils/driver/psycopg3/typecast.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,21 @@ def register_array_to_string_typecasters(connection=None):
212212
TextLoaderpgAdmin)
213213

214214

215+
def register_binary_data_typecasters(cur):
216+
# Register type caster to fetch original binary data for bytea type.
217+
cur.adapters.register_loader(17,
218+
ByteaDataLoader)
219+
220+
cur.adapters.register_loader(1001,
221+
ByteaDataLoader)
222+
223+
cur.adapters.register_loader(17,
224+
ByteaBinaryDataLoader)
225+
226+
cur.adapters.register_loader(1001,
227+
ByteaBinaryDataLoader)
228+
229+
215230
class InetLoader(InetLoader):
216231
def load(self, data):
217232
if isinstance(data, memoryview):
@@ -240,6 +255,24 @@ def load(self, data):
240255
return 'binary data' if data is not None else None
241256

242257

258+
class ByteaDataLoader(Loader):
259+
# Loads the actual binary data.
260+
def load(self, data):
261+
if data is None:
262+
return None
263+
raw = bytes(data) if isinstance(data, memoryview) else data
264+
if isinstance(raw, bytes) and raw.startswith(b'\\x'):
265+
return bytes.fromhex(raw[2:].decode())
266+
return raw
267+
268+
269+
class ByteaBinaryDataLoader(Loader):
270+
format = _pq_Format.BINARY
271+
272+
def load(self, data):
273+
return data if data is not None else None
274+
275+
243276
class TextLoaderpgAdmin(TextLoader):
244277
def load(self, data):
245278
postgres_encoding, python_encoding = get_encoding(

0 commit comments

Comments
 (0)