Skip to content

Commit f692806

Browse files
Added support to download binary data from result grid. #4011
1 parent 965a27d commit f692806

File tree

7 files changed

+149
-6
lines changed

7 files changed

+149
-6
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: 49 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
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,49 @@ 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+
cur = conn._Connection__async_cursor
2204+
register_binary_data_typecasters(cur)
2205+
if not status or conn is None or trans_obj is None or \
2206+
session_obj is None:
2207+
return internal_server_error(
2208+
errormsg=TRANSACTION_STATUS_CHECK_FAILED
2209+
)
2210+
2211+
data = request.values if request.values else request.get_json(silent=True)
2212+
if data is None:
2213+
return make_json_response(
2214+
status=410,
2215+
success=0,
2216+
errormsg=gettext(
2217+
"Could not find the required parameter (query)."
2218+
)
2219+
)
2220+
col_pos = data['colpos']
2221+
cur.scroll(int(data['rowpos']))
2222+
binary_data = cur.fetchone()
2223+
binary_data = binary_data[col_pos]
2224+
2225+
return send_file(
2226+
BytesIO(binary_data),
2227+
as_attachment=True,
2228+
download_name='binary_data',
2229+
mimetype='application/octet-stream'
2230+
)
2231+
2232+
21852233
@blueprint.route(
21862234
'/status/<int:trans_id>',
21872235
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: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
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';
1520

1621
const StyledNullAndDefaultFormatter = styled(NullAndDefaultFormatter)(({theme}) => ({
1722
'& .Formatters-disabledCell': {
@@ -70,10 +75,14 @@ NumberFormatter.propTypes = FormatterPropTypes;
7075

7176
export function BinaryFormatter({row, column}) {
7277
let value = row[column.key];
73-
78+
const eventBus = useContext(QueryToolEventsContext);
79+
const downloadBinaryData = usePreferences().getPreferences('misc', 'enable_binary_data_download').value;
7480
return (
7581
<StyledNullAndDefaultFormatter value={value} column={column}>
76-
<span className='Formatters-disabledCell'>[{value}]</span>
82+
<span className='Formatters-disabledCell'>[{value}]</span>&nbsp;&nbsp;
83+
{downloadBinaryData &&
84+
<PgIconButton size="xs" title={gettext('Download binary data')} icon={<GetAppRoundedIcon />}
85+
onClick={()=>eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, row.__temp_PK, column.pos)}/>}
7786
</StyledNullAndDefaultFormatter>
7887
);
7988
}

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', {
@@ -1038,6 +1055,15 @@ export function ResultSet() {
10381055
setLoaderText('');
10391056
});
10401057

1058+
eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, async (rowPos, colPos)=>{
1059+
let fileName = 'data-' + new Date().getTime();
1060+
setLoaderText(gettext('Downloading results...'));
1061+
await rsu.current.saveBinaryResultsToFile(fileName, rowPos, colPos, (p)=>{
1062+
setLoaderText(gettext('Downloading results(%s)...', p));
1063+
});
1064+
setLoaderText('');
1065+
});
1066+
10411067
eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_SET_LIMIT, async (limit)=>{
10421068
setLoaderText(gettext('Setting the limit on the result...'));
10431069
try {

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

Lines changed: 41 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,32 @@ 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:
262+
if isinstance(data, memoryview):
263+
data = bytes(data).decode()
264+
if data.startswith('\\x'):
265+
data = data[2:]
266+
try:
267+
return bytes.fromhex(data)
268+
except ValueError:
269+
# In case of error while converting hex to bytes, return
270+
# original data.
271+
return data
272+
else:
273+
return data
274+
return data if data is not None else None
275+
276+
277+
class ByteaBinaryDataLoader(Loader):
278+
format = _pq_Format.BINARY
279+
280+
def load(self, data):
281+
return data if data is not None else None
282+
283+
243284
class TextLoaderpgAdmin(TextLoader):
244285
def load(self, data):
245286
postgres_encoding, python_encoding = get_encoding(

0 commit comments

Comments
 (0)