diff --git a/docs/en_US/preferences.rst b/docs/en_US/preferences.rst
index 8342571ab5c..630d2070966 100644
--- a/docs/en_US/preferences.rst
+++ b/docs/en_US/preferences.rst
@@ -589,6 +589,13 @@ Use the fields on the *CSV/TXT Output* panel to control the CSV/TXT output.
quoted in the CSV/TXT output; select *Strings*, *All*, or *None*.
* Use the *Replace null values with* option to replace null values with
specified string in the output file. Default is set to 'NULL'.
+* Use the *Output file encoding* drop-down listbox to specify the character
+ encoding used when saving query results to a file. The default is utf-8; an
+ encoding that is not listed can also be typed in.
+* Use the *Add byte order mark (BOM)?* switch to add a byte order mark at the
+ start of the saved file when a UTF encoding is used. This helps applications
+ such as Microsoft Excel detect the encoding correctly. This applies to the
+ CSV/TXT output only.
.. image:: images/preferences_sql_display.png
:alt: Preferences sqleditor display options
@@ -754,6 +761,9 @@ preferences for copied data.
character for copied data.
* Use the *Result copy quoting* drop-down listbox to select which type of fields
require quoting; select *All*, *None*, or *Strings*.
+* When the *Copy with headers?* switch is set to true, the column headers are
+ included by default when copying data from the results grid. This can still
+ be toggled per-copy from the results grid copy options menu.
* When the *Striped rows?* switch is set to true, the result grid will display
rows with alternating background colors.
diff --git a/docs/en_US/query_tool_toolbar.rst b/docs/en_US/query_tool_toolbar.rst
index 9b03ca42ac9..848089a5659 100644
--- a/docs/en_US/query_tool_toolbar.rst
+++ b/docs/en_US/query_tool_toolbar.rst
@@ -210,10 +210,12 @@ Data Editing Options
| *Save Data Changes* | Click the *Save Data Changes* icon to save data changes (insert, update, or delete) in the Data | F6 |
| | Output Panel to the server. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
- | *Save results to* | Click the Save results to file icon to save the result set of the current query as a delimited | F8 |
- | *file* | text file (CSV, if the field separator is set to a comma). This button will only be enabled when | |
- | | a query has been executed and there are results in the data grid. You can specify the CSV/TXT | |
- | | settings in the Preference Dialogue under SQL Editor -> CSV/TXT output. | |
+ | *Save results to* | Click the Save results to file icon to save the result set of the current query. By | F8 |
+ | *file* | default it is saved as a delimited text file (CSV, if the field separator is set to a | |
+ | | comma). Use the adjacent drop-down list to instead save the results as JSON or XML. | |
+ | | This button is only enabled when a query has been executed and there are results in | |
+ | | the data grid. You can specify the CSV/TXT settings (including the output file encoding | |
+ | | and byte order mark) in the Preferences dialog under Query Tool -> CSV/TXT Output. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| Graph Visualiser | Use the Graph Visualiser button to generate graphs of the query results. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py
index 8080d220a54..864a55bec44 100644
--- a/web/pgadmin/tools/sqleditor/__init__.py
+++ b/web/pgadmin/tools/sqleditor/__init__.py
@@ -8,11 +8,12 @@
##########################################################################
"""A blueprint module implementing the sqleditor frame."""
+import codecs
import os
import pickle
import re
import secrets
-from urllib.parse import unquote
+from urllib.parse import quote as url_quote, unquote
from threading import Lock
from io import BytesIO
import threading
@@ -2179,35 +2180,106 @@ def start_query_download_tool(trans_id):
}
)
+ # Output format: csv (default), json or xml.
+ data_format = (data.get('format') or 'csv').lower()
+ if data_format not in ('csv', 'json', 'xml'):
+ data_format = 'csv'
+
+ # Encoding and BOM apply to the CSV/text output only; the structured
+ # formats are always emitted as UTF-8.
+ if data_format == 'csv':
+ output_encoding = blueprint.csv_output_encoding.get() or 'utf-8'
+ add_bom = blueprint.csv_add_bom.get()
+ else:
+ output_encoding = 'utf-8'
+ add_bom = False
+ # Validate the (free-text, user-configurable) encoding up front so
+ # an invalid codec returns a clean 400 here, rather than raising a
+ # LookupError mid-stream after the 200 Response has been returned.
+ try:
+ codecs.lookup(output_encoding)
+ except LookupError:
+ return make_json_response(
+ status=400,
+ success=0,
+ errormsg=gettext(
+ "Unknown output encoding '{0}'."
+ ).format(output_encoding)
+ )
+
+ normalized_encoding = output_encoding.lower().replace(
+ '-', '').replace('_', '')
+ is_utf = normalized_encoding.startswith('utf')
+ # The 'utf-16' and 'utf-32' codecs (without an explicit endianness
+ # suffix) emit their own BOM, so we must not hand-prepend one too;
+ # doing so would produce two BOMs and corrupt the output. The
+ # explicit-endian forms (utf-16-le/-be, utf-32-le/-be) and utf-8 do
+ # not self-emit a BOM, so for those we keep writing it ourselves.
+ codec_self_emits_bom = normalized_encoding in ('utf16', 'utf32')
+
+ str_gen = gen(conn_obj,
+ trans_obj,
+ quote=blueprint.csv_quoting.get(),
+ quote_char=blueprint.csv_quote_char.get(),
+ field_separator=blueprint.csv_field_separator.get(),
+ replace_nulls_with=blueprint.replace_nulls_with.get(),
+ data_format=data_format)
+
+ def encoded_gen(text_gen):
+ is_first_chunk = True
+ for chunk in text_gen:
+ if is_first_chunk:
+ is_first_chunk = False
+ # Only hand-prepend a BOM when the codec does not emit
+ # one itself, otherwise we'd end up with two BOMs.
+ if add_bom and is_utf and not codec_self_emits_bom:
+ chunk = '\ufeff' + chunk
+ yield chunk.encode(output_encoding, errors='replace')
+
+ if data_format == 'json':
+ base_mimetype = 'application/json'
+ elif data_format == 'xml':
+ base_mimetype = 'application/xml'
+ elif blueprint.csv_field_separator.get() == ',':
+ base_mimetype = 'text/csv'
+ else:
+ base_mimetype = 'text/plain'
+
r = Response(
- gen(conn_obj,
- trans_obj,
- quote=blueprint.csv_quoting.get(),
- quote_char=blueprint.csv_quote_char.get(),
- field_separator=blueprint.csv_field_separator.get(),
- replace_nulls_with=blueprint.replace_nulls_with.get()),
- mimetype='text/csv' if
- blueprint.csv_field_separator.get() == ','
- else 'text/plain'
+ encoded_gen(str_gen),
+ mimetype='{0}; charset={1}'.format(base_mimetype, output_encoding)
)
import time
- extn = 'csv' if blueprint.csv_field_separator.get() == ',' else 'txt'
+ if data_format == 'csv':
+ extn = 'csv' if blueprint.csv_field_separator.get() == ',' \
+ else 'txt'
+ else:
+ extn = data_format
filename = data['filename'] if data.get('filename', '') != "" else \
'{0}.{1}'.format(int(time.time()), extn)
- # We will try to encode report file name with latin-1
- # If it fails then we will fallback to default ascii file name
- # werkzeug only supports latin-1 encoding supported values
+ # Werkzeug will only put latin-1 in a header, so a name it cannot
+ # encode needs an ASCII stand-in. RFC 6266 lets us send the real name
+ # alongside it as filename*, so rather than losing the name entirely
+ # we offer both and let the client prefer the latter. The fallback
+ # follows the chosen format rather than always claiming to be a CSV.
+ ascii_filename = filename
try:
- tmp_file_name = filename
- tmp_file_name.encode('latin-1', 'strict')
+ filename.encode('latin-1', 'strict')
except UnicodeEncodeError:
- filename = "download.csv"
-
- r.headers[
- "Content-Disposition"
- ] = "attachment;filename={0}".format(filename)
+ ascii_filename = 'download.{0}'.format(extn)
+
+ # RFC 6266 requires the quoted form for anything with a space or a
+ # separator character in it, which a user supplied name can easily
+ # have.
+ disposition = 'attachment; filename="{0}"'.format(
+ ascii_filename.replace('\\', '\\\\').replace('"', '\\"'))
+ if ascii_filename != filename:
+ disposition += "; filename*=UTF-8''{0}".format(
+ url_quote(filename, safe=''))
+
+ r.headers["Content-Disposition"] = disposition
return r
except (ConnectionLost, SSHTunnelConnectionLost):
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 639bd39598c..16ae2bd5d57 100644
--- a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx
+++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx
@@ -476,7 +476,8 @@ export class ResultSetUtils {
});
}
- async saveResultsToFile(fileName, onProgress) {
+ async saveResultsToFile(fileName, onProgress, dataFormat='csv') {
+ const mimeTypes = {csv: 'text/csv', json: 'application/json', xml: 'application/xml'};
try {
await DownloadUtils.downloadFileStream({
url: url_for('sqleditor.query_tool_download', {
@@ -484,8 +485,8 @@ export class ResultSetUtils {
}),
options: {
method: 'POST',
- body: JSON.stringify({filename: fileName, query_commited: this.hasQueryCommitted})
- }}, fileName, 'text/csv', onProgress);
+ body: JSON.stringify({filename: fileName, query_commited: this.hasQueryCommitted, format: dataFormat})
+ }}, fileName, mimeTypes[dataFormat] ?? 'text/csv', onProgress);
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS_END);
} catch (error) {
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS_END);
@@ -1052,8 +1053,9 @@ export function ResultSet() {
setLoaderText(null);
});
- eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS, async ()=>{
- let extension = queryToolCtx.preferences?.sqleditor?.csv_field_separator === ',' ? '.csv': '.txt';
+ eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS, async (dataFormat='csv')=>{
+ const csvExtension = queryToolCtx.preferences?.sqleditor?.csv_field_separator === ',' ? '.csv': '.txt';
+ let extension = {csv: csvExtension, json: '.json', xml: '.xml'}[dataFormat] ?? csvExtension;
let fileName = 'data-' + new Date().getTime() + extension;
if(!queryToolCtx.params.is_query_tool) {
fileName = queryToolCtx.params.node_name + extension;
@@ -1061,7 +1063,7 @@ export function ResultSet() {
setLoaderText(gettext('Downloading results...'));
await rsu.current.saveResultsToFile(fileName, (p)=>{
setLoaderText(gettext('Downloading results(%s)...', p));
- });
+ }, dataFormat);
setLoaderText('');
});
diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSetToolbar.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSetToolbar.jsx
index 793ca45dcc4..36daa8b1131 100644
--- a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSetToolbar.jsx
+++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSetToolbar.jsx
@@ -282,6 +282,7 @@ export function ResultSetToolbar({query, canEdit, totalRowCount, pagination, all
/* Menu button refs */
const copyMenuRef = React.useRef(null);
const pasetMenuRef = React.useRef(null);
+ const downloadMenuRef = React.useRef(null);
const queryToolPref = queryToolCtx.preferences.sqleditor;
@@ -309,8 +310,8 @@ export function ResultSetToolbar({query, canEdit, totalRowCount, pagination, all
const addRow = useCallback(()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_ADD_ROWS, [[]], {isNewRow: true});
}, []);
- const downloadResult = useCallback(()=>{
- eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS);
+ const downloadResult = useCallback((fmt='csv')=>{
+ eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS, fmt);
}, []);
const showGraphVisualiser = useCallback(()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_GRAPH_VISUALISER);
@@ -348,6 +349,14 @@ export function ResultSetToolbar({query, canEdit, totalRowCount, pagination, all
setDisableButton('save-result', (totalRowCount||0) < 1);
}, [totalRowCount]);
+ useEffect(()=>{
+ // Seed the "Copy with headers" toggle default from the user preference.
+ setCheckedMenuItems((prev)=>({
+ ...prev,
+ copy_with_headers: queryToolPref.copy_column_headers,
+ }));
+ }, [queryToolPref.copy_column_headers]);
+
useEffect(()=>{
eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_COPY_DATA, copyData);
return ()=>eventBus.deregisterListener(QUERY_TOOL_EVENTS.TRIGGER_COPY_DATA, copyData);
@@ -432,7 +441,10 @@ export function ResultSetToolbar({query, canEdit, totalRowCount, pagination, all
}
- onClick={downloadResult} shortcut={queryToolPref.download_results}
+ onClick={()=>downloadResult('csv')} shortcut={queryToolPref.download_results}
+ disabled={buttonsDisabled['save-result']} />
+ } splitButton
+ name="menu-downloadoptions" ref={downloadMenuRef} onClick={openMenu}
disabled={buttonsDisabled['save-result']} />
@@ -490,6 +502,16 @@ export function ResultSetToolbar({query, canEdit, totalRowCount, pagination, all
>
{gettext('Paste with SERIAL/IDENTITY values?')}
+
+ downloadResult('csv')}>{gettext('Save as CSV/Text')}
+ downloadResult('json')}>{gettext('Save as JSON')}
+ downloadResult('xml')}>{gettext('Save as XML')}
+
>
);
}
diff --git a/web/pgadmin/tools/sqleditor/tests/test_download_csv_query_tool.py b/web/pgadmin/tools/sqleditor/tests/test_download_csv_query_tool.py
index f4157dbdc7f..b2bf87f0ad9 100644
--- a/web/pgadmin/tools/sqleditor/tests/test_download_csv_query_tool.py
+++ b/web/pgadmin/tools/sqleditor/tests/test_download_csv_query_tool.py
@@ -7,7 +7,10 @@
# This software is released under the PostgreSQL Licence
#
##########################################################################
+import codecs
from unittest.mock import patch
+from urllib.parse import quote as url_quote
+from xml.etree import ElementTree
from pgadmin.utils.route import BaseTestGenerator
from pgadmin.browser.server_groups.servers.databases.tests import utils as \
@@ -240,3 +243,390 @@ def tearDown(self):
self.server['sslmode']
)
test_utils.drop_database(main_conn, self._db_name)
+
+
+# A control character that XML 1.0 does not allow at all (not even as a
+# character reference), a bytea value, the two non-finite floats that JSON
+# has no syntax for, and a NULL.
+AWKWARD_SQL = (
+ 'SELECT E\'ctl\\x01char\' as "Ctl", '
+ '\'\\x48656c6c6f\'::bytea as "Bytes", '
+ '\'NaN\'::float8 as "NotANumber", '
+ '\'Infinity\'::float8 as "Inf", '
+ '\'-Infinity\'::float8 as "NegInf", '
+ 'NULL::text as "Nothing"'
+)
+
+
+class TestDownloadResultFormats(BaseTestGenerator):
+ """
+ Validates downloading query results as JSON and XML, the UTF BOM option
+ and the output file encoding option.
+ """
+ SQL = 'SELECT 1 as "A", 2 as "B", \'x\' as "C"'
+ INIT_URL = '/sqleditor/initialize/sqleditor/{0}/{1}/{2}/{3}'
+ DOWNLOAD_URL = '/sqleditor/query_tool/download/{0}'
+
+ scenarios = [
+ (
+ 'Download results as JSON',
+ dict(data_format='json', add_bom=False, encoding='utf-8',
+ expected_content_type='application/json',
+ expected_extension='.json')
+ ),
+ (
+ 'Download results as XML',
+ dict(data_format='xml', add_bom=False, encoding='utf-8',
+ expected_content_type='application/xml',
+ expected_extension='.xml')
+ ),
+ (
+ 'Download CSV with a UTF BOM',
+ dict(data_format='csv', add_bom=True, encoding='utf-8',
+ expected_content_type='text/csv',
+ expected_extension='.csv')
+ ),
+ (
+ # '€' (Euro sign) cannot be represented in Latin-1. The
+ # exporter's errors='replace' contract must survive the whole
+ # request/response round trip: an ASCII-only fixture would let a
+ # regression that silently drops or mis-encodes the character
+ # pass unnoticed.
+ 'Download CSV with a non-UTF output encoding',
+ dict(data_format='csv', add_bom=True, encoding='latin-1',
+ expected_content_type='text/csv',
+ expected_extension='.csv',
+ sql='SELECT 1 as "A", 2 as "B", \'€\' as "C"',
+ non_latin1_char='€')
+ ),
+ (
+ # utf-16 (without endianness) self-emits a BOM, so the result
+ # must contain exactly one BOM, not two (a hand-prepended one
+ # plus the codec's own).
+ 'Download CSV as utf-16 has exactly one BOM',
+ dict(data_format='csv', add_bom=True, encoding='utf-16',
+ expected_content_type='text/csv',
+ expected_extension='.csv')
+ ),
+ (
+ # A bogus, non-existent codec must be rejected up front with a
+ # clean 400, rather than blowing up mid-stream after a 200.
+ 'Download CSV with an invalid output encoding returns 400',
+ dict(data_format='csv', add_bom=False, encoding='not-a-codec',
+ expected_status=400, expected_content_type=None,
+ expected_extension='.csv')
+ ),
+ (
+ # RFC 6266 requires the quoted form once the name contains a
+ # space, or the client sees a truncated filename.
+ 'Download with a filename containing spaces',
+ dict(data_format='csv', add_bom=False, encoding='utf-8',
+ expected_content_type='text/csv',
+ expected_extension='.csv',
+ filename_override='my query results.csv')
+ ),
+ (
+ # A name werkzeug cannot put in a latin-1 header still has to
+ # reach the client, via the RFC 5987 filename* form, rather than
+ # being thrown away.
+ 'Download with a filename outside latin-1',
+ dict(data_format='csv', add_bom=False, encoding='utf-8',
+ expected_content_type='text/csv',
+ expected_extension='.csv',
+ filename_override='ohms-\u03a9.csv')
+ ),
+ (
+ # Data that the naive serialisers get wrong: a control character
+ # that XML 1.0 forbids outright, a bytea column, the non-finite
+ # floats that are not valid JSON, and a NULL.
+ 'Download awkward data as JSON stays valid JSON',
+ dict(data_format='json', add_bom=False, encoding='utf-8',
+ expected_content_type='application/json',
+ expected_extension='.json', sql=AWKWARD_SQL,
+ awkward_data=True)
+ ),
+ (
+ 'Download awkward data as XML stays well formed',
+ dict(data_format='xml', add_bom=False, encoding='utf-8',
+ expected_content_type='application/xml',
+ expected_extension='.xml', sql=AWKWARD_SQL,
+ awkward_data=True)
+ ),
+ (
+ # A genuine single-row, single-column result must be written as
+ # the bare value, not wrapped in the usual array/row structure,
+ # per #3205.
+ 'Download a single-value result as JSON',
+ dict(data_format='json', add_bom=False, encoding='utf-8',
+ expected_content_type='application/json',
+ expected_extension='.json',
+ sql='SELECT 42 as "Value"', single_value=True)
+ ),
+ (
+ 'Download a single-value result as XML',
+ dict(data_format='xml', add_bom=False, encoding='utf-8',
+ expected_content_type='application/xml',
+ expected_extension='.xml',
+ sql='SELECT 42 as "Value"', single_value=True)
+ ),
+ (
+ # A single-row, single-column NULL is still the direct-value
+ # shape, i.e. a bare JSON null / an empty element with
+ # null="true", not a row containing one null column.
+ 'Download a single-value NULL result as JSON',
+ dict(data_format='json', add_bom=False, encoding='utf-8',
+ expected_content_type='application/json',
+ expected_extension='.json',
+ sql='SELECT NULL::text as "Value"', single_value=True,
+ single_value_is_null=True)
+ ),
+ (
+ 'Download a single-value NULL result as XML',
+ dict(data_format='xml', add_bom=False, encoding='utf-8',
+ expected_content_type='application/xml',
+ expected_extension='.xml',
+ sql='SELECT NULL::text as "Value"', single_value=True,
+ single_value_is_null=True)
+ ),
+ (
+ # Zero rows must still come back as a (empty) document of the
+ # requested format, not the CSV-era plain-text message under an
+ # application/json or application/xml content type.
+ 'Download an empty result as JSON stays valid JSON',
+ dict(data_format='json', add_bom=False, encoding='utf-8',
+ expected_content_type='application/json',
+ expected_extension='.json',
+ sql='SELECT 1 as "A" WHERE false', empty_result=True)
+ ),
+ (
+ 'Download an empty result as XML stays well formed',
+ dict(data_format='xml', add_bom=False, encoding='utf-8',
+ expected_content_type='application/xml',
+ expected_extension='.xml',
+ sql='SELECT 1 as "A" WHERE false', empty_result=True)
+ ),
+ ]
+
+ # Set per scenario; the scenarios above override these as needed.
+ sql = None
+ awkward_data = False
+ single_value = False
+ single_value_is_null = False
+ empty_result = False
+ filename_override = None
+ non_latin1_char = None
+
+ def setUp(self):
+ self._db_name = 'download_results_fmt_' + 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)
+
+ def initiate_sql_query_tool(self, trans_id, sql_query):
+ 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 _assert_awkward_data(self, body):
+ """The output must be parseable, whatever the data contained.
+
+ A strict parser is the point here: XML 1.0 forbids most control
+ characters outright, and NaN/Infinity are not JSON tokens, so an
+ exporter that passes them straight through produces a file the
+ user's next tool refuses to open.
+ """
+ if self.data_format == 'json':
+ def reject_constant(constant):
+ # json.loads accepts NaN and Infinity by default even though
+ # they are not JSON; most other parsers do not, so treat them
+ # as the failure they are.
+ raise AssertionError(
+ '{0} is not a JSON token'.format(constant))
+
+ parsed = json.loads(body, parse_constant=reject_constant)
+ self.assertEqual(len(parsed), 1)
+ row = parsed[0]
+ # A NULL must survive as JSON null rather than a string.
+ self.assertIsNone(row['Nothing'])
+ # NaN and Infinity have to arrive as something a parser will
+ # accept, i.e. not as bare NaN/Infinity tokens.
+ self.assertEqual(row['NotANumber'], 'NaN')
+ self.assertEqual(row['Inf'], 'Infinity')
+ self.assertEqual(row['NegInf'], '-Infinity')
+ # bytea is deliberately reported as a placeholder rather than its
+ # contents, as it is in the grid and in CSV output, but it must
+ # never leak a Python repr such as ''.
+ self.assertNotIn('memory at', str(row['Bytes']))
+ return
+
+ root = ElementTree.fromstring(body)
+ self.assertEqual(root.tag, 'data_output')
+ columns = {c.get('name'): c for c in root.find('row')}
+ self.assertEqual(columns['Nothing'].get('null'), 'true')
+ self.assertNotIn('memory at', columns['Bytes'].text or '')
+ # The control character must not have been passed through verbatim.
+ self.assertNotIn('\x01', body)
+
+ def _assert_single_value(self, body):
+ """A genuine single-row, single-column result must be the bare
+ value, per #3205, not a one-element array / one-row document.
+ """
+ if self.data_format == 'json':
+ parsed = json.loads(body)
+ if self.single_value_is_null:
+ self.assertIsNone(parsed)
+ else:
+ self.assertEqual(parsed, 42)
+ return
+
+ root = ElementTree.fromstring(body)
+ self.assertEqual(root.tag, 'data_output')
+ # No row/column wrapper, and no column name anywhere in sight.
+ self.assertIsNone(root.find('row'))
+ self.assertIsNone(root.find('column'))
+ if self.single_value_is_null:
+ self.assertEqual(root.get('null'), 'true')
+ else:
+ self.assertEqual(root.text, '42')
+
+ def _assert_empty_result(self, body):
+ """Zero rows must still come back as an (empty) document of the
+ requested format, not the CSV-era plain-text message.
+ """
+ if self.data_format == 'json':
+ self.assertEqual(json.loads(body), [])
+ return
+
+ root = ElementTree.fromstring(body)
+ self.assertEqual(root.tag, 'data_output')
+ self.assertEqual(list(root), [])
+
+ def runTest(self):
+ db_con = database_utils.connect_database(self,
+ test_utils.SERVER_GROUP,
+ self._sid,
+ self._did)
+ 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 = self.INIT_URL.format(
+ self.trans_id, test_utils.SERVER_GROUP, self._sid, self._did)
+ response = self.tester.post(url, data=json.dumps({
+ "dbname": self._db_name
+ }))
+ self.assertEqual(response.status_code, 200)
+
+ sql = self.sql or self.SQL
+ self.initiate_sql_query_tool(self.trans_id, sql)
+
+ url = self.DOWNLOAD_URL.format(self.trans_id)
+ self.app.logger.disabled = True
+ filename = self.filename_override or \
+ 'test{0}'.format(self.expected_extension)
+ with patch('pgadmin.tools.sqleditor.blueprint.'
+ 'csv_add_bom.get', return_value=self.add_bom), \
+ patch('pgadmin.tools.sqleditor.blueprint.'
+ 'csv_output_encoding.get', return_value=self.encoding):
+ response = self.tester.post(url, data={
+ "query": sql,
+ "filename": filename,
+ "format": self.data_format,
+ "query_commited": True,
+ })
+ self.app.logger.disabled = False
+
+ headers = dict(response.headers)
+
+ # An invalid encoding must be rejected up front with a clean error
+ # status, before the streaming Response is constructed.
+ expected_status = getattr(self, 'expected_status', 200)
+ if expected_status != 200:
+ self.assertEqual(response.status_code, expected_status)
+ 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)
+ return
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(self.expected_content_type, headers['Content-Type'])
+ self.assertIn('charset={0}'.format(self.encoding),
+ headers['Content-Type'])
+ disposition = headers['Content-Disposition']
+ try:
+ filename.encode('latin-1', 'strict')
+ except UnicodeEncodeError:
+ # The stand-in must follow the format, and the real name must
+ # still be there in percent-encoded form.
+ self.assertIn('filename="download{0}"'.format(
+ self.expected_extension), disposition)
+ self.assertIn("filename*=UTF-8''", disposition)
+ self.assertIn(url_quote(filename, safe=''), disposition)
+ else:
+ self.assertIn('filename="{0}"'.format(filename), disposition)
+
+ raw = response.data
+ normalized = self.encoding.lower().replace('-', '').replace('_', '')
+ if self.add_bom and normalized.startswith('utf'):
+ # The output must carry exactly one BOM for the encoding, never
+ # two (which happened when a BOM was hand-prepended for codecs
+ # that already self-emit one, e.g. utf-16/utf-32).
+ bom = {
+ 'utf8': codecs.BOM_UTF8,
+ 'utf16': codecs.BOM_UTF16,
+ 'utf32': codecs.BOM_UTF32,
+ }[normalized]
+ self.assertTrue(raw.startswith(bom))
+ # No second, redundant BOM immediately after the first.
+ self.assertFalse(raw[len(bom):].startswith(bom))
+ else:
+ self.assertFalse(raw.startswith(b'\xef\xbb\xbf'))
+
+ body = raw.decode(self.encoding)
+
+ if self.awkward_data:
+ self._assert_awkward_data(body)
+ elif self.single_value:
+ self._assert_single_value(body)
+ elif self.empty_result:
+ self._assert_empty_result(body)
+ elif self.data_format == 'json':
+ parsed = json.loads(body)
+ self.assertIsInstance(parsed, list)
+ self.assertEqual(parsed[0]['A'], 1)
+ self.assertEqual(parsed[0]['B'], 2)
+ self.assertEqual(parsed[0]['C'], 'x')
+ elif self.data_format == 'xml':
+ self.assertIn('', body)
+ self.assertIn('1', body)
+ self.assertIn('x', body)
+ self.assertIn('', body)
+ else:
+ self.assertIn('"A","B","C"', body)
+ if self.non_latin1_char:
+ # errors='replace' must turn the character Latin-1 cannot
+ # encode into the codec's standard replacement rather than
+ # silently dropping it or corrupting the row.
+ self.assertNotIn(self.non_latin1_char, body)
+ self.assertIn('?', body)
+
+ 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 5b6e8941f24..a037b833707 100644
--- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py
+++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py
@@ -307,6 +307,34 @@ def register_query_tool_preferences(self):
allow_blanks=True
)
+ self.csv_output_encoding = self.preference.register(
+ 'CSV_output', 'csv_output_encoding',
+ gettext("Output file encoding"), 'options', 'utf-8',
+ category_label=PREF_LABEL_CSV_TXT,
+ options=[{'label': 'utf-8', 'value': 'utf-8'},
+ {'label': 'utf-16', 'value': 'utf-16'},
+ {'label': 'latin-1', 'value': 'latin-1'},
+ {'label': 'windows-1252', 'value': 'windows-1252'}],
+ control_props={
+ 'allowClear': False,
+ 'tags': False,
+ 'creatable': True
+ },
+ help_str=gettext('The character encoding used when saving query '
+ 'results to a file. Defaults to utf-8. A different '
+ 'encoding can be typed in if it is not listed.')
+ )
+
+ self.csv_add_bom = self.preference.register(
+ 'CSV_output', 'csv_add_bom',
+ gettext("Add byte order mark (BOM)?"), 'boolean',
+ False, category_label=PREF_LABEL_CSV_TXT,
+ help_str=gettext('If set to True, a byte order mark (BOM) is added at '
+ 'the start of the saved file when a UTF encoding is '
+ 'used. This helps applications such as Microsoft '
+ 'Excel detect the encoding correctly.')
+ )
+
self.results_grid_quoting = self.preference.register(
'Results_grid', 'results_grid_quoting',
gettext("Result copy quoting"), 'options', 'strings',
@@ -347,6 +375,16 @@ def register_query_tool_preferences(self):
}
)
+ self.copy_column_headers = self.preference.register(
+ 'Results_grid', 'copy_column_headers',
+ gettext("Copy with headers?"), 'boolean',
+ False, category_label=PREF_LABEL_RESULTS_GRID,
+ help_str=gettext('If set to True, the column headers are included by '
+ 'default when copying data from the results grid. '
+ 'This can still be toggled per-copy from the results '
+ 'grid copy menu.')
+ )
+
self.column_data_auto_resize = self.preference.register(
'Results_grid', 'column_data_auto_resize',
gettext("Columns sized by"), 'radioModern', 'by_data',
diff --git a/web/pgadmin/utils/driver/psycopg3/connection.py b/web/pgadmin/utils/driver/psycopg3/connection.py
index d07a16cefcd..a770e1c6955 100644
--- a/web/pgadmin/utils/driver/psycopg3/connection.py
+++ b/web/pgadmin/utils/driver/psycopg3/connection.py
@@ -17,7 +17,11 @@
import secrets
import datetime
import asyncio
+import json
+import re
from collections import deque
+from math import isfinite, isnan
+from xml.sax.saxutils import escape as xml_escape, quoteattr as xml_quoteattr
import psycopg
from flask import g, current_app
from flask_babel import gettext
@@ -54,6 +58,138 @@
_ = gettext
+
+def _json_default(value):
+ """Fallback serialiser for values that json cannot encode natively
+ (dates, Decimals, intervals, etc.)."""
+ return str(value)
+
+
+# XML 1.0 permits tab, newline, carriage return and nothing else below U+0020,
+# and forbids the surrogate range and U+FFFE/U+FFFF. Those characters cannot
+# even be written as character references, so a parser rejects the whole
+# document: a text column legally holding chr(1) would otherwise produce a
+# file nothing can open.
+_XML_ILLEGAL_CHARS = re.compile(
+ '[^\u0009\u000a\u000d\u0020-\ud7ff\ue000-\ufffd'
+ '\U00010000-\U0010ffff]'
+)
+
+# Substituted for anything XML cannot carry.
+_XML_REPLACEMENT = '\ufffd'
+
+
+def _to_text(value):
+ """Render a value as text for the structured output formats."""
+ if isinstance(value, (memoryview, bytes, bytearray)):
+ # Match the hex form PostgreSQL itself uses for bytea, rather than
+ # letting str() produce something like ''.
+ return '\\x' + bytes(value).hex()
+ if isinstance(value, float) and not isfinite(value):
+ return 'NaN' if isnan(value) else \
+ ('Infinity' if value > 0 else '-Infinity')
+ return str(value)
+
+
+def _xml_text(value):
+ """Escape a value for XML, dropping characters XML cannot represent."""
+ return xml_escape(
+ _XML_ILLEGAL_CHARS.sub(_XML_REPLACEMENT, _to_text(value)))
+
+
+def _xml_attr(value):
+ """Quote an attribute value for XML, with the same sanitising."""
+ return xml_quoteattr(_XML_ILLEGAL_CHARS.sub(_XML_REPLACEMENT, str(value)))
+
+
+def _json_safe(value):
+ """Convert a value into something json can encode, and validly.
+
+ NaN and Infinity are not JSON tokens: json.dumps emits them bare by
+ default, which Python itself will read back but most other parsers
+ reject, so they become the strings PostgreSQL uses for them. bytea is
+ rendered in the same hex form as elsewhere. Containers are walked
+ because a float8[] or a json column can hold either case nested.
+ """
+ if isinstance(value, (memoryview, bytes, bytearray)):
+ return _to_text(value)
+ if isinstance(value, float) and not isfinite(value):
+ return _to_text(value)
+ if isinstance(value, dict):
+ return {key: _json_safe(val) for key, val in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_json_safe(val) for val in value]
+ return value
+
+
+def _generate_json(cur, records, results):
+ """Stream the result set as a JSON array of row objects.
+
+ The first batch of rows (``results``) has already been fetched by the
+ caller; subsequent batches are pulled with ``fetchmany(records)``.
+
+ The 'Replace null values with' preference is deliberately not applied:
+ it exists because CSV has no way to distinguish an empty field from a
+ NULL, whereas JSON has null, and substituting the placeholder string
+ would turn every NULL into ordinary text.
+ """
+ yield '['
+ is_first_row = True
+ while results:
+ for row in results:
+ row_json = json.dumps(
+ {key: _json_safe(value) for key, value in dict(row).items()},
+ default=_json_default, allow_nan=False)
+ yield row_json if is_first_row else ',' + row_json
+ is_first_row = False
+ results = cur.fetchmany(records)
+ yield ']'
+
+
+def _generate_xml(cur, records, results, header):
+ """Stream the result set as XML.
+
+ Column names are emitted as escaped ``name`` attributes (rather than
+ element names) so that column names which are not valid XML element
+ names are handled safely. As with JSON, NULLs are reported natively,
+ via null="true", rather than through the CSV placeholder preference.
+ """
+ yield '\n'
+ while results:
+ for row in results:
+ row_io = ['']
+ for column in header:
+ value = row.get(column)
+ if value is None:
+ row_io.append(
+ ''.format(
+ _xml_attr(column)))
+ else:
+ row_io.append('{1}'.format(
+ _xml_attr(column), _xml_text(value)))
+ row_io.append('
')
+ yield ''.join(row_io)
+ results = cur.fetchmany(records)
+ yield ''
+
+
+def _generate_single_value(data_format, value):
+ """Render a genuine single-row, single-column result directly, per
+ issue #3205: no array wrapper for JSON, no / wrapper for
+ XML, just the value itself. NULL is reported the same way it is
+ elsewhere: JSON null, or an empty element with null="true".
+ """
+ if data_format == 'json':
+ return json.dumps(
+ _json_safe(value), default=_json_default, allow_nan=False)
+
+ if value is None:
+ return ('\n'
+ '')
+ return ('\n'
+ '{0}'.format(_xml_text(value)))
+
+
# Register global type caster which will be applicable to all connections.
register_global_typecasters()
configure_driver_encodings(encodings)
@@ -927,7 +1063,8 @@ def handle_null_values(results, replace_nulls_with):
return results
def gen(conn_obj, trans_obj, quote='strings', quote_char="'",
- field_separator=',', replace_nulls_with=None):
+ field_separator=',', replace_nulls_with=None,
+ data_format='csv'):
try:
cur.scroll(0, mode='absolute')
@@ -936,9 +1073,6 @@ def gen(conn_obj, trans_obj, quote='strings', quote_char="'",
# Make sure numeric values will be fetched without quoting
register_numeric_typecasters(cur)
results = cur.fetchmany(records)
- if not results:
- yield gettext('The query executed did not return any data.')
- return
header = []
json_columns = []
@@ -950,37 +1084,50 @@ def gen(conn_obj, trans_obj, quote='strings', quote_char="'",
if c.to_dict()['type_code'] in ALL_JSON_TYPES:
json_columns.append(column_name)
- res_io = StringIO()
+ if not results:
+ # An empty result must still come back in the requested
+ # format: JSON/XML consumers expect a (empty) document of
+ # that type, not the CSV-era plain-text message under an
+ # application/json or application/xml content type.
+ if data_format == 'json':
+ yield '[]'
+ elif data_format == 'xml':
+ yield ('\n'
+ '')
+ else:
+ yield gettext(
+ 'The query executed did not return any data.')
+ return
- if quote == 'strings':
- quote = csv.QUOTE_NONNUMERIC
- elif quote == 'all':
- quote = csv.QUOTE_ALL
+ if data_format in ('json', 'xml') and len(header) == 1 and \
+ len(results) == 1:
+ # A genuine single-row, single-column result is written as
+ # the bare value, without the usual array/row wrapper, per
+ # #3205. Confirm there really is only one row before
+ # committing to that shape: a batch boundary can make the
+ # first fetchmany() return exactly one row even though more
+ # follow.
+ more = cur.fetchmany(records)
+ if not more:
+ yield _generate_single_value(
+ data_format, results[0].get(header[0]))
+ return
+ results = results + more
+
+ if data_format == 'json':
+ yield from _generate_json(cur, records, results)
+ elif data_format == 'xml':
+ yield from _generate_xml(cur, records, results, header)
else:
- quote = csv.QUOTE_NONE
-
- csv_writer = csv.DictWriter(
- res_io, fieldnames=header, delimiter=field_separator,
- quoting=quote,
- quotechar=quote_char,
- replace_nulls_with=replace_nulls_with
- )
-
- csv_writer.writeheader()
- # Replace the null values with given string if configured.
- if replace_nulls_with is not None:
- results = handle_null_values(results, replace_nulls_with)
- csv_writer.writerows(results)
-
- yield res_io.getvalue()
-
- while True:
- results = cur.fetchmany(records)
-
- if not results:
- break
res_io = StringIO()
+ if quote == 'strings':
+ quote = csv.QUOTE_NONNUMERIC
+ elif quote == 'all':
+ quote = csv.QUOTE_ALL
+ else:
+ quote = csv.QUOTE_NONE
+
csv_writer = csv.DictWriter(
res_io, fieldnames=header, delimiter=field_separator,
quoting=quote,
@@ -988,12 +1135,35 @@ def gen(conn_obj, trans_obj, quote='strings', quote_char="'",
replace_nulls_with=replace_nulls_with
)
+ csv_writer.writeheader()
# Replace the null values with given string if configured.
if replace_nulls_with is not None:
results = handle_null_values(results, replace_nulls_with)
csv_writer.writerows(results)
+
yield res_io.getvalue()
+ while True:
+ results = cur.fetchmany(records)
+
+ if not results:
+ break
+ res_io = StringIO()
+
+ csv_writer = csv.DictWriter(
+ res_io, fieldnames=header, delimiter=field_separator,
+ quoting=quote,
+ quotechar=quote_char,
+ replace_nulls_with=replace_nulls_with
+ )
+
+ # Replace the null values with given string if configured.
+ if replace_nulls_with is not None:
+ results = handle_null_values(results,
+ replace_nulls_with)
+ csv_writer.writerows(results)
+ yield res_io.getvalue()
+
try:
# try to reset the cursor scroll back to where it was,
# bypass error, if cannot scroll back