Replace infinite scrolling with pagination in query tool data output for better UX and performance. #1780

pull/7990/head
Aditya Toshniwal 2024-10-01 14:47:12 +05:30 committed by GitHub
parent f8fb78be11
commit 6322674d98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 554 additions and 351 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 151 KiB

View File

@ -230,7 +230,7 @@ Use the fields on the *Options* panel to manage ERD preferences.
* Use *Cardinality Notation* to change the cardinality notation format
used to present relationship links.
used to present relationship links.
* When the *SQL With DROP Table* switch is set to *True*, the SQL
generated by the ERD Tool will add DROP table DDL before each CREATE
@ -398,7 +398,7 @@ Use the fields on the *Editor* panel to change settings of the query editor.
changed to text/plain. Keyword highlighting and code folding will be disabled.
This will improve editor performance with large files.
* When the *Highlight selection matches?* switch is set to *True*, the editor will
* When the *Highlight selection matches?* switch is set to *True*, the editor will
highlight matched selected text.
.. image:: images/preferences_sql_explain.png
@ -476,11 +476,11 @@ Use the fields on the *Options* panel to manage editor preferences.
* When the *Show View/Edit Data Promotion Warning?* switch is set to *True*
View/Edit Data tool will show promote to Query tool confirm dialog on query edit.
* When the *Underline query at cursor?* switch is set to *True*, query tool will
* When the *Underline query at cursor?* switch is set to *True*, query tool will
parse and underline the query at the cursor position.
* When the *Underlined query execute warning?* switch is set to *True*, query tool
will warn upon clicking the *Execute Query* button in the query tool. The warning
* When the *Underlined query execute warning?* switch is set to *True*, query tool
will warn upon clicking the *Execute Query* button in the query tool. The warning
will appear only if *Underline query at cursor?* is set to *False*.
.. image:: images/preferences_sql_results_grid.png
@ -497,9 +497,8 @@ preferences for copied data.
* Specify the maximum width of the column in pixels when 'Columns sized by' is
set to *Column data*. If 'Columns sized by' is set to *Column name* then this
setting won't have any effect.
* Specify the number of records to fetch in one batch in query tool when
query result set is large. Changing this value will override
ON_DEMAND_ROW_COUNT setting from config file.
* Specify the number of records to fetch in one batch. Changing this value will
override DATA_RESULT_ROWS_PER_PAGE setting from config file.
* Use the *Result copy field separator* drop-down listbox to select the field
separator for copied data.
* Use the *Result copy quote character* drop-down listbox to select the quote
@ -523,7 +522,7 @@ reformatting of SQL.
* Use the *Data type case* option to specify whether to change data types
into upper, lower, or preserve case.
* Use the *Expression width* option to specify maximum number of characters
* Use the *Expression width* option to specify maximum number of characters
in parenthesized expressions to be kept on single line.
* Use the *Function case* option to specify whether to change function
names into upper, lower, or preserve case.
@ -531,7 +530,7 @@ reformatting of SQL.
(object names) into upper, lower, or capitalized case.
* Use the *Keyword case* option to specify whether to change keywords into
upper, lower, or preserve case.
* Use *Lines between queries* to specify how many empty lines to leave
* Use *Lines between queries* to specify how many empty lines to leave
between SQL statements. If set to zero it puts no new line.
* Use *Logical operator new line* to specify newline placement before or
after logical operators (AND, OR, XOR).

View File

@ -74,8 +74,8 @@ key combination to select from a popup menu of autocomplete options.
After entering a query, select the *Execute script* icon from the toolbar. The
complete contents of the SQL editor panel will be sent to the database server
for execution. To execute only a section of the code that is displayed in the
SQL editor, highlight the text that you want the server to execute, and click the
for execution. To execute only a section of the code that is displayed in the
SQL editor, highlight the text that you want the server to execute, and click the
*Execute script* icon.
.. image:: images/query_execute_script.png
@ -177,6 +177,7 @@ You can:
* Use the *Save results to file* icon to save the content of the *Data Output*
tab as a comma-delimited file.
* Edit the data in the result set of a SELECT query if it is updatable.
* Move between pages of data result.
.. _updatable-result-set:

View File

@ -214,6 +214,48 @@ Data Editing Options
| SQL | Use the SQL button to check the current query that gave the data. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
Pagination Options
********************
.. image:: images/query_data_pagination.png
:alt: Query tool data pagination options
:align: center
.. table::
:class: longtable
:widths: 1 4 1
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| Icon | Behavior | Shortcut |
+======================+===================================================================================================+================+
| *Rows Range* | Show the current row numbers visible in the data grid. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| *Edit Range* | Click to open the from and to rows range inputs to allow setting them. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| *Page No* | Enter the page no you want to jump to out of total shown next to this input | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| *First Page* | Click to go to the first page. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| *Previous Page* | Click to go to the previous page. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| *Next Page* | Click to go to the next page. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| *Last Page* | Click to go to the last page. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
.. image:: images/query_data_pagination_edit.png
:alt: Query tool data pagination options
:align: center
One can click the edit range button to open rows range editor:
* From and to range should be between 1 and total rows.
* The range can be applied by clicking the *Apply* button or by pressing enter in the range inputs.
* Once the range is applied, pgAdmin will recalculate the rows per page. The pagination will then behave based on the new rows per page.
* It may be possible that on pressing next page button, the new rows range is not next to manually enterred range.
Status Bar
**********

View File

@ -513,10 +513,10 @@ THREADED_MODE = True
SQLALCHEMY_TRACK_MODIFICATIONS = False
##########################################################################
# Number of records to fetch in one batch in query tool when query result
# set is large.
# Number of records to fetch in one page in query tool when query result
# set is large and is divided in multiple pages
##########################################################################
ON_DEMAND_RECORD_COUNT = 1000
DATA_RESULT_ROWS_PER_PAGE = 1000
##########################################################################
# Allow users to display Gravatar image for their username in Server mode

View File

@ -15,6 +15,7 @@ import secrets
from urllib.parse import unquote
from threading import Lock
import threading
import math
import json
from config import PG_DEFAULT_DRIVER, ALLOW_SAVE_PASSWORD, SHARED_STORAGE
@ -106,8 +107,7 @@ class SqlEditorModule(PgAdminModule):
'sqleditor.view_data_start',
'sqleditor.query_tool_start',
'sqleditor.poll',
'sqleditor.fetch',
'sqleditor.fetch_all',
'sqleditor.fetch_window',
'sqleditor.fetch_all_from_start',
'sqleditor.save',
'sqleditor.inclusive_filter',
@ -470,7 +470,8 @@ def _init_sqleditor(trans_id, connect, sgid, sid, did, dbname=None, **kwargs):
"prompt_password": True,
"allow_save_password": True
if ALLOW_SAVE_PASSWORD and
session['allow_save_password'] else False,
session.get('allow_save_password', None)
else False,
}
), '', ''
else:
@ -925,7 +926,6 @@ def poll(trans_id):
rows_affected = 0
rows_fetched_from = 0
rows_fetched_to = 0
has_more_rows = False
columns = dict()
columns_info = None
primary_keys = None
@ -936,8 +936,8 @@ def poll(trans_id):
additional_messages = None
notifies = None
data_obj = {}
on_demand_record_count = Preferences.module(MODULE_NAME).\
preference('on_demand_record_count').get()
data_result_rows_per_page = Preferences.module(MODULE_NAME).\
preference('data_result_rows_per_page').get()
# Check the transaction and connection status
status, error_msg, conn, trans_obj, session_obj = \
check_transaction_status(trans_id)
@ -1004,7 +1004,8 @@ def poll(trans_id):
status = 'Success'
rows_affected = conn.rows_affected()
st, result = conn.async_fetchmany_2darray(on_demand_record_count)
st, result = \
conn.async_fetchmany_2darray(data_result_rows_per_page)
# There may be additional messages even if result is present
# eg: Function can provide result as well as RAISE messages
@ -1081,8 +1082,6 @@ def poll(trans_id):
# means nothing to fetch
if result and rows_affected > -1:
res_len = len(result)
if res_len == on_demand_record_count:
has_more_rows = True
if res_len > 0:
rows_fetched_from = trans_obj.get_fetched_row_cnt()
@ -1126,6 +1125,15 @@ def poll(trans_id):
data_obj['db_id'] = trans_obj.did \
if trans_obj is not None and hasattr(trans_obj, 'did') else 0
page_size = rows_fetched_to - rows_fetched_from + 1
pagination = {
'page_size': page_size,
'page_count': math.ceil(conn.total_rows / page_size),
'page_no': math.floor(rows_fetched_from / page_size) + 1,
'rows_from': rows_fetched_from,
'rows_to': rows_fetched_to
}
return make_json_response(
data={
'status': status, 'result': result,
@ -1134,7 +1142,6 @@ def poll(trans_id):
'rows_fetched_to': rows_fetched_to,
'additional_messages': additional_messages,
'notifies': notifies,
'has_more_rows': has_more_rows,
'colinfo': columns_info,
'primary_keys': primary_keys,
'types': types,
@ -1143,26 +1150,20 @@ def poll(trans_id):
'oids': oids,
'transaction_status': transaction_status,
'data_obj': data_obj,
'pagination': pagination,
}
)
@blueprint.route(
'/fetch/<int:trans_id>', methods=["GET"], endpoint='fetch'
)
@blueprint.route(
'/fetch/<int:trans_id>/<int:fetch_all>', methods=["GET"],
endpoint='fetch_all'
'/fetch_window/<int:trans_id>/<int:from_rownum>/<int:to_rownum>',
methods=["GET"], endpoint='fetch_window'
)
@pga_login_required
def fetch(trans_id, fetch_all=None):
def fetch_window(trans_id, from_rownum=0, to_rownum=0):
result = None
has_more_rows = False
rows_fetched_from = 0
rows_fetched_to = 0
on_demand_record_count = Preferences.module(MODULE_NAME).preference(
'on_demand_record_count').get()
fetch_row_cnt = -1 if fetch_all == 1 else on_demand_record_count
# Check the transaction and connection status
status, error_msg, conn, trans_obj, session_obj = \
@ -1174,33 +1175,39 @@ def fetch(trans_id, fetch_all=None):
status=404)
if status and conn is not None and session_obj is not None:
status, result = conn.async_fetchmany_2darray(fetch_row_cnt)
# rownums start from 0 but UI will ask from 1
status, result = conn.async_fetchmany_2darray(
records=None, from_rownum=from_rownum - 1, to_rownum=to_rownum - 1)
if not status:
status = 'Error'
else:
status = 'Success'
res_len = len(result) if result else 0
if fetch_row_cnt != -1 and res_len == on_demand_record_count:
has_more_rows = True
if res_len:
rows_fetched_from = trans_obj.get_fetched_row_cnt()
trans_obj.update_fetched_row_cnt(rows_fetched_from + res_len)
rows_fetched_from += 1
rows_fetched_to = trans_obj.get_fetched_row_cnt()
rows_fetched_from = from_rownum
rows_fetched_to = rows_fetched_from + res_len - 1
session_obj['command_obj'] = pickle.dumps(trans_obj, -1)
update_session_grid_transaction(trans_id, session_obj)
else:
status = 'NotConnected'
result = error_msg
page_size = to_rownum - from_rownum + 1
pagination = {
'page_size': page_size,
'page_count': math.ceil(conn.total_rows / page_size),
'page_no': math.floor(rows_fetched_from / page_size) + 1,
'rows_from': rows_fetched_from,
'rows_to': rows_fetched_to
}
return make_json_response(
data={
'status': status,
'result': result,
'has_more_rows': has_more_rows,
'rows_fetched_from': rows_fetched_from,
'rows_fetched_to': rows_fetched_to
'pagination': pagination,
'row_count': conn.row_count,
}
)

View File

@ -37,7 +37,7 @@ export const QUERY_TOOL_EVENTS = {
STOP_QUERY: 'STOP_QUERY',
CURSOR_ACTIVITY: 'CURSOR_ACTIVITY',
SET_MESSAGE: 'SET_MESSAGE',
ROWS_FETCHED: 'ROWS_FETCHED',
TOTAL_ROWS_COUNT: 'TOTAL_ROWS_COUNT',
SELECTED_ROWS_COLS_CELL_CHANGED: 'SELECTED_ROWS_COLS_CELL_CHANGED',
DATAGRID_CHANGED: 'DATAGRID_CHANGED',
HIGHLIGHT_ERROR: 'HIGHLIGHT_ERROR',
@ -56,8 +56,12 @@ export const QUERY_TOOL_EVENTS = {
PUSH_HISTORY: 'PUSH_HISTORY',
HANDLE_API_ERROR: 'HANDLE_API_ERROR',
SET_FILTER_INFO: 'SET_FILTER_INFO',
FETCH_MORE_ROWS: 'FETCH_MORE_ROWS',
REINIT_QT_CONNECTION:'REINIT_QT_CONNECTION',
FETCH_WINDOW: 'FETCH_WINDOW',
ALL_PAGE_ROWS_SELECTED: 'ALL_PAGE_ROWS_SELECTED',
ALL_ROWS_SELECTED: 'ALL_ROWS_SELECTED',
CLEAR_ROWS_SELECTED: 'CLEAR_ROWS_SELECTED',
ALL_ROWS_SELECTED_STATUS: 'ALL_ROWS_SELECTED_STATUS',
EDITOR_LAST_FOCUS: 'EDITOR_LAST_FOCUS',
EDITOR_FIND_REPLACE: 'EDITOR_FIND_REPLACE',
@ -109,4 +113,4 @@ export const PANELS = {
export const MAX_QUERY_LENGTH = 1000000;
export const OS_EOL = navigator.platform === 'win32' ? 'crlf' : 'lf';
export const OS_EOL = navigator.platform === 'win32' ? 'crlf' : 'lf';

View File

@ -139,9 +139,8 @@ function SelectAllHeaderRenderer({isCellSelected}) {
const eventBus = useContext(QueryToolEventsContext);
const dataGridExtras = useContext(DataGridExtrasContext);
const onClick = ()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.FETCH_MORE_ROWS, true, ()=>{
onRowSelectionChange({ type: 'HEADER', checked: !isRowSelected });
});
onRowSelectionChange({ type: 'HEADER', checked: !isRowSelected });
eventBus.fireEvent(QUERY_TOOL_EVENTS.ALL_PAGE_ROWS_SELECTED, !isRowSelected);
};
useLayoutEffect(() => {
@ -149,6 +148,15 @@ function SelectAllHeaderRenderer({isCellSelected}) {
cellRef.current?.focus({ preventScroll: true });
}, [isCellSelected]);
useEffect(()=>{
const unregClear = eventBus.registerListener(QUERY_TOOL_EVENTS.CLEAR_ROWS_SELECTED, ()=>{
onRowSelectionChange({ type: 'HEADER', checked: false });
});
return ()=>{
unregClear();
};
}, []);
return <div ref={cellRef} style={{width: '100%', height: '100%'}} onClick={onClick}
tabIndex="0" onKeyDown={getCopyShortcutHandler(dataGridExtras.handleCopy)}></div>;
}
@ -167,15 +175,13 @@ function SelectableHeaderRenderer({column, selectedColumns, onSelectedColumnsCha
}
const onClick = ()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.FETCH_MORE_ROWS, true, ()=>{
const newSelectedCols = new Set(selectedColumns);
if (newSelectedCols.has(column.idx)) {
newSelectedCols.delete(column.idx);
} else {
newSelectedCols.add(column.idx);
}
onSelectedColumnsChange(newSelectedCols);
});
const newSelectedCols = new Set(selectedColumns);
if (newSelectedCols.has(column.idx)) {
newSelectedCols.delete(column.idx);
} else {
newSelectedCols.add(column.idx);
}
onSelectedColumnsChange(newSelectedCols);
};
const isSelected = selectedColumns.has(column.idx);
@ -296,9 +302,10 @@ function initialiseColumns(columns, rows, totalRowCount, columnWidthBy) {
}
function RowNumColFormatter({row, rowKeyGetter, rowIdx, dataChangeStore, onSelectedColumnsChange}) {
const [isRowSelected, onRowSelectionChange] = useRowSelection();
const {startRowNum} = useContext(DataGridExtrasContext);
let rowKey = rowKeyGetter(row);
let rownum = rowIdx+1;
let rownum = rowIdx+(startRowNum??1);
if(rowKey in (dataChangeStore?.added || {})) {
rownum = rownum+'+';
} else if(rowKey in (dataChangeStore?.deleted || {})) {
@ -375,7 +382,7 @@ function getTextWidth(column, rows, canvas, columnWidthBy) {
}
export default function QueryToolDataGrid({columns, rows, totalRowCount, dataChangeStore,
onSelectedCellChange, selectedColumns, onSelectedColumnsChange, columnWidthBy, ...props}) {
onSelectedCellChange, selectedColumns, onSelectedColumnsChange, columnWidthBy, startRowNum, ...props}) {
const [readyColumns, setReadyColumns] = useState([]);
const eventBus = useContext(QueryToolEventsContext);
const onSelectedColumnsChangeWrapped = (arg)=>{
@ -392,7 +399,7 @@ export default function QueryToolDataGrid({columns, rows, totalRowCount, dataCha
}, []);
const dataGridExtras = useMemo(()=>({
onSelectedCellChange, handleCopy
onSelectedCellChange, handleCopy, startRowNum
}), [onSelectedCellChange]);
useEffect(()=>{

View File

@ -276,10 +276,10 @@ export function MainToolBar({containerRef, onFilterClick, onManageMacros, onAddT
}, [queryToolConnCtx.connectionStatus]);
const onCommitClick=()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, 'COMMIT;', null, '', true);
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, 'COMMIT;', {external: true});
};
const onRollbackClick=()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, 'ROLLBACK;', null, '', true);
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, 'ROLLBACK;', {external: true});
};
const executeMacro = (m)=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_EXECUTION, null, m.sql);

View File

@ -151,10 +151,10 @@ export default function Query({onTextSelect, handleEndOfLineChange}) {
query = query || editor.current?.getValue() || '';
}
if(query) {
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, query, explainObject, macroSQL, external, null, executeCursor);
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, query, {explainObject, macroSQL, external, executeCursor});
}
} else {
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, null, null, '');
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, null, {});
}
};

View File

@ -232,15 +232,20 @@ export class ResultSetUtils {
};
async startExecution(query, explainObject, macroSQL, onIncorrectSQL, flags={
isQueryTool: true, external: false, reconnect: false, executeCursor: false
isQueryTool: true, external: false, reconnect: false, executeCursor: false, refreshData: false,
}) {
let startTime = new Date();
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.SET_MESSAGE, '');
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TASK_START, gettext('Waiting for the query to complete...'), startTime);
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TASK_START,
flags.refreshData ? gettext('Refetching latest results...') : gettext('Waiting for the query to complete...'),
startTime
);
this.setStartTime(startTime);
this.query = query;
this.historyQuerySource = flags.isQueryTool ? QuerySources.EXECUTE : QuerySources.VIEW_DATA;
if(explainObject) {
if(flags.refreshData) {
this.historyQuerySource = null;
} else if(explainObject) {
if(explainObject.analyze) {
this.historyQuerySource = QuerySources.EXPLAIN_ANALYZE;
} else {
@ -301,7 +306,7 @@ export class ResultSetUtils {
e,
{
connectionLostCallback: ()=>{
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, query, explainObject, '', flags.external, true, flags.executeCursor);
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, query, {explainObject, external: flags.external, reconnect: true, executeCursor: flags.executeCursor});
},
checkTransaction: true,
}
@ -357,7 +362,7 @@ export class ResultSetUtils {
});
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.HANDLE_API_ERROR, error, {
connectionLostCallback: ()=>{
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, this.query, explainObject, '', flags.external, true, flags.executeCursor);
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, this.query, {explainObject, external: flags.external, reconnect: true, executeCursor: flags.executeCursor});
},
checkTransaction: true,
});
@ -396,7 +401,7 @@ export class ResultSetUtils {
if(this.qtPref?.query_success_notification) {
pgAdmin.Browser.notifier.success(msg);
}
if(!ResultSetUtils.isQueryStillRunning(httpMessage)) {
if(!ResultSetUtils.isQueryStillRunning(httpMessage) && this.historyQuerySource) {
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.PUSH_HISTORY, {
status: true,
start_time: this.startTime,
@ -414,16 +419,12 @@ export class ResultSetUtils {
}
}
getMoreRows(all=false) {
let url = url_for('sqleditor.fetch', {
getWindowRows(fromRownum, toRownum) {
let url = url_for('sqleditor.fetch_window', {
'trans_id': this.transId,
'from_rownum': fromRownum,
'to_rownum': toRownum,
});
if(all) {
url = url_for('sqleditor.fetch_all', {
'trans_id': this.transId,
'fetch_all': 1,
});
}
return this.api.get(url);
}
@ -791,6 +792,11 @@ function dataChangeReducer(state, action) {
...dataChange.deleted,
...action.add,
};
if(action.all) {
dataChange.delete_all = true;
} else {
dataChange.delete_all = false;
}
break;
case 'reset':
dataChange = {
@ -798,6 +804,7 @@ function dataChangeReducer(state, action) {
added: {},
added_index: {},
deleted: {},
delete_all: false,
};
break;
default:
@ -818,12 +825,14 @@ export function ResultSet() {
const [queryData, setQueryData] = useState(null);
const [rows, setRows] = useState([]);
const [columns, setColumns] = useState([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const api = getApiInstance();
const rsu = React.useRef(new ResultSetUtils(api, queryToolCtx, queryToolCtx.params.trans_id, queryToolCtx.params.is_query_tool));
const [dataChangeStore, dispatchDataChange] = React.useReducer(dataChangeReducer, {});
const [selectedRows, setSelectedRows] = useState(new Set());
const [selectedColumns, setSelectedColumns] = useState(new Set());
// NONE - no select, PAGE - show select all, ALL - select all.
const [allRowsSelect, setAllRowsSelect] = useState('NONE');
const selectedCell = useRef([]);
const selectedRange = useRef(null);
const setSelectedCell = (val)=>{
@ -855,7 +864,9 @@ export function ResultSet() {
eventBus.fireEvent(QUERY_TOOL_EVENTS.SELECTED_ROWS_COLS_CELL_CHANGED, selectedRows.size, selectedColumns.size, selectedRange.current, selectedCell.current?.length);
};
const executionStartCallback = async (query, explainObject, macroSQL, external=false, reconnect=false, executeCursor=false)=>{
const executionStartCallback = async (query, {
explainObject, macroSQL, external=false, reconnect=false, executeCursor=false, refreshData=false
})=>{
const yesCallback = async ()=>{
/* Reset */
eventBus.fireEvent(QUERY_TOOL_EVENTS.HIGHLIGHT_ERROR, null);
@ -871,7 +882,7 @@ export function ResultSet() {
setColumns([]);
setRows([]);
},
{isQueryTool: queryToolCtx.params.is_query_tool, external: external, reconnect: reconnect, executeCursor: executeCursor}
{isQueryTool: queryToolCtx.params.is_query_tool, external: external, reconnect: reconnect, executeCursor: executeCursor, refreshData: refreshData}
);
};
@ -916,7 +927,7 @@ export function ResultSet() {
});
};
if(isDataChanged()) {
if(isDataChanged() && !refreshData) {
queryToolCtx.modal.confirm(
gettext('Unsaved changes'),
gettext('The data has been modified, but not saved. Are you sure you wish to discard the changes?'),
@ -1010,19 +1021,41 @@ export function ResultSet() {
eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_INCLUDE_EXCLUDE_FILTER, triggerFilter);
eventBus.registerListener(QUERY_TOOL_EVENTS.GOTO_LAST_SCROLL, triggerResetScroll);
eventBus.registerListener(QUERY_TOOL_EVENTS.ALL_PAGE_ROWS_SELECTED, (selectAll)=>{
if(selectAll) {
setAllRowsSelect('PAGE');
} else {
setAllRowsSelect('NONE');
}
});
eventBus.registerListener(QUERY_TOOL_EVENTS.ALL_ROWS_SELECTED, ()=>{
setAllRowsSelect('ALL');
});
eventBus.registerListener(QUERY_TOOL_EVENTS.CLEAR_ROWS_SELECTED, ()=>{
setSelectedRows(new Set());
setAllRowsSelect('NONE');
});
}, []);
useEffect(()=>{
eventBus.registerListener(QUERY_TOOL_EVENTS.EXECUTION_START, executionStartCallback);
const deregExec = eventBus.registerListener(QUERY_TOOL_EVENTS.EXECUTION_START, executionStartCallback);
return ()=>{
eventBus.deregisterListener(QUERY_TOOL_EVENTS.EXECUTION_START, executionStartCallback);
deregExec();
};
}, [dataChangeStore]);
}, [dataChangeStore, dataOutputQuery]);
useEffect(()=>{
fireRowsColsCellChanged();
setAllRowsSelect('NONE');
}, [selectedRows.size, selectedColumns.size]);
useEffect(()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.ALL_ROWS_SELECTED_STATUS, allRowsSelect);
}, [allRowsSelect]);
useEffect(()=>{
rsu.current.transId = queryToolCtx.params.trans_id;
}, [queryToolCtx.params.trans_id]);
@ -1031,45 +1064,44 @@ export function ResultSet() {
eventBus.fireEvent(QUERY_TOOL_EVENTS.RESET_GRAPH_VISUALISER, columns);
}, [columns]);
const fetchMoreRows = async (all=false, callback=undefined)=>{
if(queryData.has_more_rows) {
let res = [];
setIsLoadingMore(true);
try {
res = await rsu.current.getMoreRows(all);
const newRows = rsu.current.processRows(res.data.data.result, columns);
setRows((prevRows)=>[...prevRows, ...newRows]);
setQueryData((prev)=>({
...prev,
has_more_rows: res.data.data.has_more_rows,
rows_fetched_to: res.data.data.rows_fetched_to!=0 ? res.data.data.rows_fetched_to : prev.rows_fetched_to,
}));
} catch (e) {
eventBus.fireEvent(QUERY_TOOL_EVENTS.HANDLE_API_ERROR,
e,
{
connectionLostCallback: ()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, rsu.current.query, null, '', false, true);
},
checkTransaction: true,
}
);
} finally {
setIsLoadingMore(false);
}
const fetchWindow = async (fromRownum, toRownum, callback)=>{
let res = [];
setLoaderText(gettext('Fetching rows...'));
try {
res = await rsu.current.getWindowRows(fromRownum, toRownum);
const newRows = rsu.current.processRows(res.data.data.result, columns);
setRows([...newRows]);
setQueryData((prev)=>({
...prev,
pagination: res.data.data.pagination,
rows_fetched_to: res.data.data.rows_fetched_to!=0 ? res.data.data.rows_fetched_to : prev.rows_fetched_to,
}));
} catch (e) {
eventBus.fireEvent(QUERY_TOOL_EVENTS.HANDLE_API_ERROR,
e,
{
connectionLostCallback: ()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, rsu.current.query, {external: false, reconnect: true});
},
checkTransaction: true,
}
);
} finally {
setLoaderText('');
}
callback?.();
};
useEffect(()=>{
eventBus.registerListener(QUERY_TOOL_EVENTS.FETCH_MORE_ROWS, fetchMoreRows);
return ()=>{
eventBus.deregisterListener(QUERY_TOOL_EVENTS.FETCH_MORE_ROWS, fetchMoreRows);
};
}, [queryData?.has_more_rows, columns]);
useEffect(()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.ROWS_FETCHED, queryData?.rows_fetched_to, queryData?.rows_affected);
}, [queryData?.rows_fetched_to, queryData?.rows_affected]);
eventBus.registerListener(QUERY_TOOL_EVENTS.FETCH_WINDOW, fetchWindow);
return ()=>{
eventBus.deregisterListener(QUERY_TOOL_EVENTS.FETCH_WINDOW, fetchWindow);
};
}, [columns]);
useEffect(()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.TOTAL_ROWS_COUNT, queryData?.rows_affected);
}, [queryData?.rows_affected]);
const warnSaveDataClose = ()=>{
// No changes.
@ -1116,6 +1148,7 @@ export function ResultSet() {
let {data: respData} = await rsu.current.saveData({
updated: dataChangeStore.updated,
deleted: dataChangeStore.deleted,
delete_all: dataChangeStore.delete_all,
added_index: dataChangeStore.added_index,
added: added,
columns: columns,
@ -1152,37 +1185,6 @@ export function ResultSet() {
}
eventBus.fireEvent(QUERY_TOOL_EVENTS.SAVE_DATA_DONE, true);
if(_.size(dataChangeStore.added)) {
// Update the rows in a grid after addition
respData.data.query_results.forEach((qr)=>{
if(!_.isNull(qr.row_added)) {
let rowClientPK = Object.keys(qr.row_added)[0];
setRows((prevRows)=>{
let rowIdx = prevRows.findIndex((r)=>rowKeyGetter(r)==rowClientPK);
return [
...prevRows.slice(0, rowIdx),
{
...prevRows[rowIdx],
...qr.row_added[rowClientPK],
},
...prevRows.slice(rowIdx+1),
];
});
}
});
}
let deletedKeys = Object.keys(dataChangeStore.deleted);
if(deletedKeys.length == rows.length) {
setRows([]);
}
else if(deletedKeys.length > 0) {
setRows((prevRows)=>{
return prevRows.filter((row)=>{
return deletedKeys.indexOf(row[rsu.current.clientPK]) == -1;
});
});
setColumns((prev)=>prev);
}
dispatchDataChange({type: 'reset'});
setSelectedRows(new Set());
setSelectedColumns(new Set());
@ -1192,6 +1194,8 @@ export function ResultSet() {
if(respData.data.transaction_status > CONNECTION_STATUS.TRANSACTION_STATUS_IDLE) {
pgAdmin.Browser.notifier.info(gettext('Auto-commit is off. You still need to commit changes to the database.'));
}
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, rsu.current.query, {refreshData: true});
} catch (error) {
eventBus.fireEvent(QUERY_TOOL_EVENTS.SAVE_DATA_DONE, false);
eventBus.fireEvent(QUERY_TOOL_EVENTS.HANDLE_API_ERROR, error, {
@ -1292,6 +1296,7 @@ export function ResultSet() {
type: 'deleted',
add: add,
remove: remove,
all: remove.length > 0 ? false : allRowsSelect == 'ALL',
});
};
@ -1307,7 +1312,7 @@ export function ResultSet() {
return ()=>{
eventBus.deregisterListener(QUERY_TOOL_EVENTS.TRIGGER_DELETE_ROWS, triggerDeleteRows);
};
}, [selectedRows, queryData, dataChangeStore, rows]);
}, [selectedRows, queryData, dataChangeStore, rows, allRowsSelect]);
useEffect(()=>{
const triggerAddRows = (_rows, fromClipboard, pasteSerials)=>{
@ -1317,7 +1322,7 @@ export function ResultSet() {
selectedRowsSorted.sort();
insPosn = _.findIndex(rows, (r)=>rowKeyGetter(r)==selectedRowsSorted[selectedRowsSorted.length-1])+1;
}
let byteaCellSelection = columns.filter(o=>o.type=='bytea');
let byteaCellSelection = columns.filter(o=>o.type=='bytea');
if (byteaCellSelection.length>0) {
_rows = _rows.map(x=>{
byteaCellSelection.forEach(r=>{
@ -1373,20 +1378,6 @@ export function ResultSet() {
return ()=>eventBus.deregisterListener(QUERY_TOOL_EVENTS.TRIGGER_RENDER_GEOMETRIES, renderGeometries);
}, [rows, columns, selectedRows.size, selectedColumns.size]);
const handleScroll = (e) => {
// Set scroll current position of RestSet.
if (!_.isNull(e.currentTarget) && isResettingScroll.current) {
lastScrollRef.current = {
ref: { ...e },
top: e.currentTarget.scrollTop,
left: e.currentTarget.scrollLeft
};
}
if (isLoadingMore || !rsu.current.isAtBottom(e)) return;
eventBus.fireEvent(QUERY_TOOL_EVENTS.FETCH_MORE_ROWS);
};
const triggerResetScroll = () => {
// Reset the scroll position to previously saved location.
if (lastScrollRef.current) {
@ -1463,17 +1454,20 @@ export function ResultSet() {
return (
<StyledBox ref={containerRef} tabIndex="0">
<Loader message={loaderText} />
<Loader data-label="loader-more-rows" message={isLoadingMore ? gettext('Loading more rows...') : null} style={{top: 'unset', right: 'unset', padding: '0.5rem 1rem'}}/>
{!queryData &&
<EmptyPanelMessage text={gettext('No data output. Execute a query to get output.')}/>
}
{queryData && <>
<ResultSetToolbar containerRef={containerRef} query={dataOutputQuery} canEdit={queryData.can_edit} totalRowCount={queryData?.rows_affected}/>
<ResultSetToolbar containerRef={containerRef} query={dataOutputQuery}
canEdit={queryData.can_edit} totalRowCount={queryData?.rows_affected}
pagination={queryData?.pagination ?? {}} allRowsSelect={allRowsSelect}
/>
<Box flexGrow="1" minHeight="0">
<QueryToolDataGrid
columns={columns}
rows={rows}
totalRowCount={queryData?.rows_affected}
startRowNum={queryData?.pagination?.rows_from}
columnWidthBy={
queryToolCtx.preferences?.sqleditor?.column_data_auto_resize == 'by_data' ?
queryToolCtx.preferences.sqleditor.column_data_max_width :
@ -1481,7 +1475,6 @@ export function ResultSet() {
}
key={rowsResetKey}
rowKeyGetter={rowKeyGetter}
onScroll={handleScroll}
onRowsChange={onRowsChange}
dataChangeStore={dataChangeStore}
selectedRows={selectedRows}

View File

@ -8,8 +8,8 @@
//////////////////////////////////////////////////////////////
import React, {useContext, useCallback, useEffect, useState} from 'react';
import { styled } from '@mui/material/styles';
import { Portal } from '@mui/material';
import { PgButtonGroup, PgIconButton } from '../../../../../../static/js/components/Buttons';
import { Box, Portal } from '@mui/material';
import { DefaultButton, PgButtonGroup, PgIconButton } from '../../../../../../static/js/components/Buttons';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import PlaylistAddRoundedIcon from '@mui/icons-material/PlaylistAddRounded';
import FileCopyRoundedIcon from '@mui/icons-material/FileCopyRounded';
@ -17,6 +17,14 @@ import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import TimelineRoundedIcon from '@mui/icons-material/TimelineRounded';
import { PasteIcon, SQLQueryIcon, SaveDataIcon } from '../../../../../../static/js/components/ExternalIcon';
import GetAppRoundedIcon from '@mui/icons-material/GetAppRounded';
import FastForwardRoundedIcon from '@mui/icons-material/FastForwardRounded';
import FastRewindRoundedIcon from '@mui/icons-material/FastRewindRounded';
import SkipNextRoundedIcon from '@mui/icons-material/SkipNextRounded';
import SkipPreviousRoundedIcon from '@mui/icons-material/SkipPreviousRounded';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import EditOffRoundedIcon from '@mui/icons-material/EditOffRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import {QUERY_TOOL_EVENTS} from '../QueryToolConstants';
import { QueryToolContext, QueryToolEventsContext } from '../QueryToolComponent';
import { PgMenu, PgMenuItem } from '../../../../../../static/js/components/Menu';
@ -26,14 +34,28 @@ import CopyData from '../QueryToolDataGrid/CopyData';
import PropTypes from 'prop-types';
import CodeMirror from '../../../../../../static/js/components/ReactCodeMirror';
import { setEditorPosition } from '../QueryToolDataGrid/Editors';
import { InputText } from '../../../../../../static/js/components/FormComponents';
import { minMaxValidator } from '../../../../../../static/js/validators';
const StyledDiv = styled('div')(({theme})=>({
padding: '2px',
display: 'flex',
alignItems: 'center',
gap: '4px',
flexWrap: 'wrap',
rowGap: '4px',
backgroundColor: theme.otherVars.editorToolbarBg,
justifyContent: 'space-between',
...theme.mixins.panelBorder.bottom,
'& .PaginationInputs': {
display: 'flex',
alignItems: 'center',
gap: '4px',
'& .PaginationInputs-divider': {
...theme.mixins.panelBorder.right,
}
}
}));
const StyledEditor = styled('div')(({theme})=>({
@ -76,7 +98,134 @@ ShowDataOutputQueryPopup.propTypes = {
query: PropTypes.string,
};
export function ResultSetToolbar({query,canEdit, totalRowCount}) {
function PaginationInputs({pagination, totalRowCount, clearSelection}) {
const eventBus = useContext(QueryToolEventsContext);
const [editPageRange, setEditPageRange] = useState(false);
const [errorInputs, setErrorInputs] = useState({
'from': false,
'to': false,
'pageNo': false
});
const [inputs, setInputs] = useState({
from: pagination.rows_from ?? 0,
to: pagination.rows_to ?? 0,
pageNo: pagination.page_no ?? 0,
});
const goToPage = (pageNo)=>{
const from = (pageNo-1) * pagination.page_size + 1;
const to = from + pagination.page_size - 1;
eventBus.fireEvent(QUERY_TOOL_EVENTS.FETCH_WINDOW, from, to);
clearSelection();
};
const onInputChange = (key, value)=>{
setInputs((prev)=>({...prev, [key]: value}));
};
const onInputKeydown = (e)=>{
if(e.code === 'Enter' && !errorInputs.from && !errorInputs.to) {
e.preventDefault();
eventBus.fireEvent(QUERY_TOOL_EVENTS.FETCH_WINDOW, inputs.from, inputs.to);
}
};
const onInputKeydownPageNo = (e)=>{
if(e.code === 'Enter' && !errorInputs.pageNo) {
e.preventDefault();
goToPage(inputs.pageNo);
}
};
useEffect(()=>{
// validate
setErrorInputs((prev)=>{
let errors = {...prev};
if(minMaxValidator('', inputs.pageNo, 1, pagination.page_count)) {
errors.pageNo = true;
} else {
errors.pageNo = false;
}
if(minMaxValidator('', inputs.from, 1, inputs.to)) {
errors.from = true;
} else {
errors.from = false;
}
if(minMaxValidator('', inputs.to, 1, totalRowCount)) {
errors.to = true;
} else {
errors.to = false;
}
return errors;
});
}, [inputs, pagination]);
return (
<Box className='PaginationInputs'>
{editPageRange ?
<Box display="flex" gap="2px" alignItems="center">
<div>{gettext('Showing rows:')}</div>
<InputText size="small"
controlProps={{maxLength: 7}}
style={{
maxWidth: '10ch'
}}
value={inputs.from}
onChange={(value)=>onInputChange('from', value)}
onKeyDown={onInputKeydown}
error={errorInputs['from']}
/>
<div>{gettext('to')}</div>
<InputText size="small"
controlProps={{maxLength: 7}}
style={{
maxWidth: '10ch'
}}
value={inputs.to}
onChange={(value)=>onInputChange('to', value)}
onKeyDown={onInputKeydown}
error={errorInputs['to']}
/>
</Box> : <span>{gettext('Showing rows: %s to %s', inputs.from, inputs.to)}</span>}
<PgButtonGroup>
{editPageRange && <PgIconButton size="xs"
title={editPageRange ? gettext('Apply (or press Enter on input)') : gettext('Edit range')}
onClick={()=>eventBus.fireEvent(QUERY_TOOL_EVENTS.FETCH_WINDOW, inputs.from, inputs.to)}
icon={<CheckRoundedIcon />}
/>}
<PgIconButton size="xs"
title={editPageRange ? gettext('Cancel edit') : gettext('Edit range')}
onClick={()=>setEditPageRange((prev)=>!prev)}
icon={editPageRange ? <EditOffRoundedIcon /> : <EditRoundedIcon />}
/>
</PgButtonGroup>
<div className='PaginationInputs-divider'>&nbsp;</div>
<span>{gettext('Page No:')}</span>
<InputText size="small"
controlProps={{maxLength: 7}}
style={{
maxWidth: '10ch'
}}
value={inputs.pageNo}
onChange={(value)=>onInputChange('pageNo', value)}
onKeyDown={onInputKeydownPageNo}
error={errorInputs['pageNo']}
/>
<span> {gettext('of')} {pagination.page_count}</span>
<div className='PaginationInputs-divider'>&nbsp;</div>
<PgButtonGroup size="small">
<PgIconButton title={gettext('First Page')} disabled={pagination.page_no == 1} onClick={()=>goToPage(1)} icon={<SkipPreviousRoundedIcon />}/>
<PgIconButton title={gettext('Previous Page')} disabled={pagination.page_no == 1} onClick={()=>goToPage(pagination.page_no-1)} icon={<FastRewindRoundedIcon />}/>
<PgIconButton title={gettext('Next Page')} disabled={pagination.page_no == pagination.page_count} onClick={()=>goToPage(pagination.page_no+1)} icon={<FastForwardRoundedIcon />}/>
<PgIconButton title={gettext('Last Page')} disabled={pagination.page_no == pagination.page_count} onClick={()=>goToPage(pagination.page_count)} icon={<SkipNextRoundedIcon />} />
</PgButtonGroup>
</Box>
);
}
export function ResultSetToolbar({query, canEdit, totalRowCount, pagination, allRowsSelect}) {
const eventBus = useContext(QueryToolEventsContext);
const queryToolCtx = useContext(QueryToolContext);
const [dataOutputQueryBtn,setDataOutputQueryBtn] = useState(false);
@ -208,45 +357,74 @@ export function ResultSetToolbar({query,canEdit, totalRowCount}) {
},
], queryToolCtx.mainContainerRef);
const clearSelection = ()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.CLEAR_ROWS_SELECTED);
};
return (
<>
<StyledDiv>
<PgButtonGroup size="small">
<PgIconButton title={gettext('Add row')} icon={<PlaylistAddRoundedIcon style={{height: 'unset'}}/>}
shortcut={queryToolPref.btn_add_row} disabled={!canEdit} onClick={addRow} />
<PgIconButton title={gettext('Copy')} icon={<FileCopyRoundedIcon />}
shortcut={FIXED_PREF.copy} disabled={buttonsDisabled['copy-rows']} onClick={copyData} />
<PgIconButton title={gettext('Copy options')} icon={<KeyboardArrowDownIcon />} splitButton
name="menu-copyheader" ref={copyMenuRef} onClick={openMenu} />
<PgIconButton title={gettext('Paste')} icon={<PasteIcon />}
shortcut={queryToolPref.btn_paste_row} disabled={!canEdit} onClick={pasteRows} />
<PgIconButton title={gettext('Paste options')} icon={<KeyboardArrowDownIcon />} splitButton
name="menu-pasteoptions" ref={pasetMenuRef} onClick={openMenu} />
<PgIconButton title={gettext('Delete')} icon={<DeleteRoundedIcon />}
shortcut={queryToolPref.btn_delete_row} disabled={buttonsDisabled['delete-rows'] || !canEdit} onClick={deleteRows} />
</PgButtonGroup>
<PgButtonGroup size="small">
<PgIconButton title={gettext('Save Data Changes')} icon={<SaveDataIcon />}
shortcut={queryToolPref.save_data} disabled={buttonsDisabled['save-data'] || !canEdit} onClick={saveData}/>
</PgButtonGroup>
<PgButtonGroup size="small">
<PgIconButton title={gettext('Save results to file')} icon={<GetAppRoundedIcon />}
onClick={downloadResult} shortcut={queryToolPref.download_results}
disabled={buttonsDisabled['save-result']} />
</PgButtonGroup>
<PgButtonGroup size="small">
<PgIconButton title={gettext('Graph Visualiser')} icon={<TimelineRoundedIcon />}
onClick={showGraphVisualiser} disabled={buttonsDisabled['save-result']} />
</PgButtonGroup>
{query &&
<>
<Box display="flex" alignItems="center" gap="4px">
<PgButtonGroup size="small">
<PgIconButton title={gettext('SQL query of data')} icon={<SQLQueryIcon />}
onClick={()=>{setDataOutputQueryBtn(prev=>!prev);}} onBlur={()=>{setDataOutputQueryBtn(false);}} disabled={!query} id='sql-query'/>
<PgIconButton title={gettext('Add row')} icon={<PlaylistAddRoundedIcon style={{height: 'unset'}}/>}
shortcut={queryToolPref.btn_add_row} disabled={!canEdit} onClick={addRow} />
<PgIconButton title={gettext('Copy')} icon={<FileCopyRoundedIcon />}
shortcut={FIXED_PREF.copy} disabled={buttonsDisabled['copy-rows']||allRowsSelect=='ALL'} onClick={copyData} />
<PgIconButton title={gettext('Copy options')} icon={<KeyboardArrowDownIcon />} splitButton
name="menu-copyheader" ref={copyMenuRef} onClick={openMenu} />
<PgIconButton title={gettext('Paste')} icon={<PasteIcon />}
shortcut={queryToolPref.btn_paste_row} disabled={!canEdit} onClick={pasteRows} />
<PgIconButton title={gettext('Paste options')} icon={<KeyboardArrowDownIcon />} splitButton
name="menu-pasteoptions" ref={pasetMenuRef} onClick={openMenu} />
<PgIconButton title={gettext('Delete')} icon={<DeleteRoundedIcon />}
shortcut={queryToolPref.btn_delete_row} disabled={buttonsDisabled['delete-rows'] || !canEdit} onClick={deleteRows} />
</PgButtonGroup>
{ dataOutputQueryBtn && <ShowDataOutputQueryPopup query={query} />}
</>
}
<PgButtonGroup size="small">
<PgIconButton title={gettext('Save Data Changes')} icon={<SaveDataIcon />}
shortcut={queryToolPref.save_data} disabled={buttonsDisabled['save-data'] || !canEdit} onClick={saveData}/>
</PgButtonGroup>
<PgButtonGroup size="small">
<PgIconButton title={gettext('Save results to file')} icon={<GetAppRoundedIcon />}
onClick={downloadResult} shortcut={queryToolPref.download_results}
disabled={buttonsDisabled['save-result']} />
</PgButtonGroup>
<PgButtonGroup size="small">
<PgIconButton title={gettext('Graph Visualiser')} icon={<TimelineRoundedIcon />}
onClick={showGraphVisualiser} disabled={buttonsDisabled['save-result']} />
</PgButtonGroup>
{query &&
<>
<PgButtonGroup size="small">
<PgIconButton title={gettext('SQL query of data')} icon={<SQLQueryIcon />}
onClick={()=>{setDataOutputQueryBtn(prev=>!prev);}} onBlur={()=>{setDataOutputQueryBtn(false);}} disabled={!query} id='sql-query'/>
</PgButtonGroup>
{ dataOutputQueryBtn && <ShowDataOutputQueryPopup query={query} />}
</>
}
{
allRowsSelect == 'PAGE' && (
<div>
<span>{gettext('All rows on this page are selected.')}</span>
<PgButtonGroup size="small">
<DefaultButton onClick={()=>eventBus.fireEvent(QUERY_TOOL_EVENTS.ALL_ROWS_SELECTED)}>Select All {totalRowCount} Rows</DefaultButton>
</PgButtonGroup>
</div>
)
}
{
allRowsSelect == 'ALL' && (
<div>
<span>{gettext('All %s rows are selected.', totalRowCount)}</span>
<PgButtonGroup size="small">
<DefaultButton onClick={clearSelection}>{gettext('Clear Selection')}</DefaultButton>
</PgButtonGroup>
</div>
)
}
</Box>
<Box>
<PaginationInputs key={JSON.stringify(pagination)} pagination={pagination} totalRowCount={totalRowCount} clearSelection={clearSelection} />
</Box>
</StyledDiv>
<PgMenu
anchorRef={copyMenuRef}

View File

@ -41,7 +41,7 @@ export function StatusBar({eol, handleEndOfLineChange}) {
const eventBus = useContext(QueryToolEventsContext);
const [position, setPosition] = useState([1, 1]);
const [lastTaskText, setLastTaskText] = useState(null);
const [rowsCount, setRowsCount] = useState([0, 0]);
const [rowsCount, setRowsCount] = useState(0);
const [selectedRowsCount, setSelectedRowsCount] = useState(0);
const [dataRowChangeCounts, setDataRowChangeCounts] = useState({
isDirty: false,
@ -52,6 +52,8 @@ export function StatusBar({eol, handleEndOfLineChange}) {
const {seconds, minutes, hours, msec, start:startTimer, pause:pauseTimer, reset:resetTimer} = useStopwatch({});
const eolMenuRef = React.useRef(null);
const {openMenuName, toggleMenu, onMenuClose} = usePgMenuGroup();
// NONE - no select, PAGE - show select all, ALL - select all.
const [allRowsSelect, setAllRowsSelect] = useState('NONE');
useEffect(()=>{
eventBus.registerListener(QUERY_TOOL_EVENTS.CURSOR_ACTIVITY, (newPos)=>{
@ -70,20 +72,30 @@ export function StatusBar({eol, handleEndOfLineChange}) {
pauseTimer(endTime);
setLastTaskText(taskText);
});
eventBus.registerListener(QUERY_TOOL_EVENTS.ROWS_FETCHED, (fetched, total)=>{
setRowsCount([fetched||0, total||0]);
eventBus.registerListener(QUERY_TOOL_EVENTS.TOTAL_ROWS_COUNT, (total)=>{
setRowsCount(total);
});
eventBus.registerListener(QUERY_TOOL_EVENTS.ALL_ROWS_SELECTED_STATUS, (v)=>{
setAllRowsSelect(v);
});
eventBus.registerListener(QUERY_TOOL_EVENTS.SELECTED_ROWS_COLS_CELL_CHANGED, (rows)=>{
setSelectedRowsCount(rows);
});
eventBus.registerListener(QUERY_TOOL_EVENTS.DATAGRID_CHANGED, (_isDirty, dataChangeStore)=>{
}, []);
useEffect(()=>{
const unregDataChange = eventBus.registerListener(QUERY_TOOL_EVENTS.DATAGRID_CHANGED, (_isDirty, dataChangeStore)=>{
setDataRowChangeCounts({
added: Object.keys(dataChangeStore.added||{}).length,
updated: Object.keys(dataChangeStore.updated||{}).length,
deleted: Object.keys(dataChangeStore.deleted||{}).length,
deleted: dataChangeStore.delete_all ? rowsCount : Object.keys(dataChangeStore.deleted||{}).length,
});
});
}, []);
return ()=>{
unregDataChange();
};
}, [rowsCount]);
let stagedText = '';
if(dataRowChangeCounts.added > 0) {
@ -98,7 +110,7 @@ export function StatusBar({eol, handleEndOfLineChange}) {
return (
<StyledBox>
<Box className='StatusBar-padding StatusBar-divider'>{gettext('Total rows: %s of %s', rowsCount[0], rowsCount[1])}</Box>
{rowsCount && <Box className='StatusBar-padding StatusBar-divider'>{gettext('Total rows: %s', rowsCount)}</Box>}
{lastTaskText &&
<Box className='StatusBar-padding StatusBar-divider'>{lastTaskText} {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}.{msec.toString().padStart(3, '0')}</Box>
}
@ -106,13 +118,13 @@ export function StatusBar({eol, handleEndOfLineChange}) {
<Box className='StatusBar-padding StatusBar-divider'>{lastTaskText} {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}.{msec.toString().padStart(3, '0')}</Box>
}
{Boolean(selectedRowsCount) &&
<Box className='StatusBar-padding StatusBar-divider'>{gettext('Rows selected: %s',selectedRowsCount)}</Box>}
<Box className='StatusBar-padding StatusBar-divider'>{gettext('Rows selected: %s', allRowsSelect == 'ALL' ? rowsCount : selectedRowsCount)}</Box>}
{stagedText &&
<Box className='StatusBar-padding StatusBar-divider'>
<span>{gettext('Changes staged: %s', stagedText)}</span>
</Box>
}
<Box className='StatusBar-padding StatusBar-mlAuto' style={{display:'flex'}}>
<Box className="StatusBar-padding StatusBar-divider">
<Tooltip title="Select EOL Sequence" disableInteractive enterDelay={2500}>

View File

@ -6,8 +6,8 @@ DELETE FROM {{ conn|qtIdent(nsp_name, object_name) }}
{% elif no_of_keys > 1 %}
WHERE ({% for each_label in primary_key_labels %}{{ conn|qtIdent(each_label) }}{% if not loop.last %}, {% endif %}{% endfor %}) IN
{% endif %}
{### Rows to delete ###}
{% if no_of_keys >= 1 %}{### Rows to delete ###}
({% for obj in data %}{% if no_of_keys == 1 %}{{ obj[primary_key_labels[0]]|qtLiteral(conn) }}{% elif no_of_keys > 1 %}
{### Here we need to make tuple for each row ###}
({% for each_label in primary_key_labels %}{{ obj[each_label]|qtLiteral(conn) }}{% if not loop.last %}, {% endif %}{% endfor %}){% endif %}{% if not loop.last %}, {% endif %}
{% endfor %});
{% endfor %}){% endif %};

View File

@ -14,7 +14,7 @@ from pgadmin.utils.constants import PREF_LABEL_DISPLAY,\
PREF_LABEL_EDITOR, PREF_LABEL_CSV_TXT, PREF_LABEL_RESULTS_GRID,\
PREF_LABEL_SQL_FORMATTING, PREF_LABEL_GRAPH_VISUALISER
from pgadmin.utils import SHORTCUT_FIELDS as shortcut_fields
from config import ON_DEMAND_RECORD_COUNT
from config import DATA_RESULT_ROWS_PER_PAGE
UPPER_CASE_STR = gettext('Upper case')
LOWER_CASE_STR = gettext('Lower case')
@ -346,15 +346,15 @@ def register_query_tool_preferences(self):
),
)
self.on_demand_record_count = self.preference.register(
'Results_grid', 'on_demand_record_count',
gettext("On demand record count"), 'integer', ON_DEMAND_RECORD_COUNT,
min_val=1,
self.data_result_rows_per_page = self.preference.register(
'Results_grid', 'data_result_rows_per_page',
gettext("Data result rows per page"), 'integer',
DATA_RESULT_ROWS_PER_PAGE, min_val=10,
category_label=PREF_LABEL_RESULTS_GRID,
help_str=gettext('Specify the number of records to fetch in one batch '
'in query tool when query result set is large. '
'Changing this value will override '
'ON_DEMAND_RECORD_COUNT setting from config file.')
help_str=gettext('Specify the number of records to fetch in one batch.'
' Changing this value will override'
' DATA_RESULT_ROWS_PER_PAGE setting from config '
' file.')
)
self.sql_font_size = self.preference.register(

View File

@ -188,35 +188,37 @@ def save_changed_data(changed_data, columns_info, conn, command_obj,
# For deleted rows
elif of_type == 'deleted':
delete_all = changed_data.get('delete_all', False)
list_of_sql[of_type] = []
is_first = True
rows_to_delete = []
keys = None
no_of_keys = None
for each_row in changed_data[of_type]:
rows_to_delete.append(changed_data[of_type][each_row])
# Fetch the keys for SQL generation
if is_first:
# We need to covert dict_keys to normal list in
# Python3
# In Python2, it's already a list & We will also
# fetch column names using index
keys = list(
changed_data[of_type][each_row].keys()
)
no_of_keys = len(keys)
is_first = False
# Map index with column name for each row
for row in rows_to_delete:
for k, v in row.items():
# Set primary key with label & delete index based
# mapped key
try:
row[changed_data['columns']
[int(k)]['name']] = v
except ValueError:
continue
del row[k]
keys = []
no_of_keys = 0
if not delete_all:
for each_row in changed_data[of_type]:
rows_to_delete.append(changed_data[of_type][each_row])
# Fetch the keys for SQL generation
if is_first:
# We need to covert dict_keys to normal list in
# Python3
# In Python2, it's already a list & We will also
# fetch column names using index
keys = list(
changed_data[of_type][each_row].keys()
)
no_of_keys = len(keys)
is_first = False
# Map index with column name for each row
for row in rows_to_delete:
for k, v in row.items():
# Set primary key with label & delete index based
# mapped key
try:
row[changed_data['columns']
[int(k)]['name']] = v
except ValueError:
continue
del row[k]
sql = render_template(
"/".join([command_obj.sql_path, 'delete.sql']),

View File

@ -1308,6 +1308,7 @@ WHERE db.datname = current_database()""")
return True, {'columns': columns, 'rows': rows}
def async_fetchmany_2darray(self, records=2000,
from_rownum=0, to_rownum=0,
formatted_exception_msg=False):
"""
User should poll and check if status is ASYNC_OK before calling this
@ -1342,6 +1343,10 @@ WHERE db.datname = current_database()""")
try:
if records == -1:
result = cur.fetchall(_tupples=True)
elif records is None:
result = cur.fetchwindow(from_rownum=from_rownum,
to_rownum=to_rownum,
_tupples=True)
else:
result = cur.fetchmany(records, _tupples=True)
except psycopg.ProgrammingError:
@ -1538,6 +1543,12 @@ Failed to reset the connection to the server due to following error:
return self.row_count
@property
def total_rows(self):
if self.__async_cursor is None:
return 0
return self.__async_cursor.rowcount
def get_column_info(self):
"""
This function will returns list of columns for last async sql command

View File

@ -364,6 +364,20 @@ class AsyncDictCursor(_async_cursor):
self.row_factory = dict_row
return res
def fetchwindow(self, from_rownum=0, to_rownum=0, _tupples=False):
"""
Fetch many tuples as ordered dictionary list.
"""
self._odt_desc = None
self.row_factory = tuple_row
asyncio.run(self._scrollcur(from_rownum, "absolute"))
res = asyncio.run(self._fetchmany(to_rownum - from_rownum + 1))
if not _tupples and res is not None:
res = [self._dict_tuple(t) for t in res]
self.row_factory = dict_row
return res
async def _scrollcur(self, position, mode):
"""
Fetch all tuples as ordered dictionary list.

View File

@ -47,10 +47,10 @@ class QueryToolFeatureTest(BaseFeatureTest):
def runTest(self):
self._reset_options()
# on demand result set on scrolling.
print("\nOn demand query result... ",
# pagination result on page change.
print("\nPagination query result... ",
file=sys.stderr, end="")
self._on_demand_result()
self._pagination_result()
self.page.clear_query_tool()
# explain query with verbose and cost
@ -129,18 +129,12 @@ class QueryToolFeatureTest(BaseFeatureTest):
# close menu
query_op.click()
def _on_demand_result(self):
ON_DEMAND_CHUNKS = 2
row_id_to_find = config.ON_DEMAND_RECORD_COUNT * ON_DEMAND_CHUNKS
query = """-- On demand query result on scroll
-- Grid select all
-- Column select all
def _pagination_result(self):
query = """-- Pagination result
SELECT generate_series(1, {}) as id1, 'dummy' as id2""".format(
config.ON_DEMAND_RECORD_COUNT * ON_DEMAND_CHUNKS)
config.DATA_RESULT_ROWS_PER_PAGE * 2.5)
print("\nOn demand result set on scrolling... ",
file=sys.stderr, end="")
print("\nPagination result... ", file=sys.stderr, end="")
self.page.execute_query(query)
# wait for header of the table to be visible
@ -151,94 +145,33 @@ SELECT generate_series(1, {}) as id1, 'dummy' as id2""".format(
(By.CSS_SELECTOR,
QueryToolLocators.query_output_cells)))
self.page.find_by_css_selector(
QueryToolLocators.query_output_canvas_css)
for i, page in enumerate([
{'page_info': '1 to 1000', 'cell_rownum': '1'},
{'page_info': '1001 to 2000', 'cell_rownum': '1001'},
{'page_info': '2001 to 2500', 'cell_rownum': '2001'}
]):
page_info = self.page.find_by_css_selector(
QueryToolLocators.pagination_inputs +
f' span:nth-of-type(1)')
self._check_ondemand_result(row_id_to_find)
print("OK.", file=sys.stderr)
self.assertEqual(page_info.text, f"Showing: {page['page_info']}")
print("On demand result set on grid select all... ",
file=sys.stderr, end="")
self.page.click_execute_query_button()
page_info = self.page.find_by_css_selector(
QueryToolLocators.pagination_inputs + ' span:nth-of-type(3)')
# wait for header of the table to be visible
self.page.find_by_css_selector(
QueryToolLocators.query_output_canvas_css)
self.assertEqual(page_info.text, "of 3")
# wait for the rows in the table to be displayed
self.wait.until(EC.presence_of_element_located(
(By.CSS_SELECTOR,
QueryToolLocators.query_output_cells))
)
cell_rownum = self.page.find_by_css_selector(
QueryToolLocators.query_output_cells + ':nth-of-type(1)')
# Select all rows in a table
multiple_check = True
while multiple_check:
try:
select_all = self.wait.until(EC.element_to_be_clickable(
(By.XPATH, QueryToolLocators.select_all_column)))
select_all.click()
multiple_check = False
except (StaleElementReferenceException,
ElementClickInterceptedException):
pass
self.assertEqual(cell_rownum.text, page['cell_rownum'])
self._check_ondemand_result(row_id_to_find)
print("OK.", file=sys.stderr)
print("On demand result set on column select all... ",
file=sys.stderr, end="")
self.page.click_execute_query_button()
self.page.wait_for_query_tool_loading_indicator_to_disappear()
# wait for header of the table to be visible
self.wait.until(EC.visibility_of_element_located(
(By.CSS_SELECTOR, QueryToolLocators.query_output_canvas_css)))
# wait for the rows in the table to be displayed
self.wait.until(EC.presence_of_element_located(
(By.CSS_SELECTOR,
QueryToolLocators.query_output_cells))
)
self.wait.until(EC.presence_of_element_located(
(By.CSS_SELECTOR, QueryToolLocators.query_output_canvas_css)))
self._check_ondemand_result(row_id_to_find)
print("OK.", file=sys.stderr)
def _check_ondemand_result(self, row_id_to_find):
# scroll to bottom to bring last row of next chunk in viewport.
scroll = 10
status = False
while scroll:
# click on first data column to select all column.
column_1 = \
if i < 2:
self.page.find_by_css_selector(
QueryToolLocators.output_column_header_css.format('id1'))
column_1.click()
grid = self.page.find_by_css_selector('.rdg')
scrolling_height = grid.size['height']
self.driver.execute_script(
"document.querySelector('.rdg').scrollTop="
"document.querySelector('.rdg').scrollHeight"
)
# Table height takes some time to update, for which their is no
# particular way
time.sleep(2)
if grid.size['height'] == scrolling_height and \
self.page.check_if_element_exist_by_xpath(
QueryToolLocators.output_column_data_xpath.format(
row_id_to_find)):
status = True
break
else:
scroll -= 1
QueryToolLocators.pagination_inputs +
' button[aria-label="Next Page"]').click()
self.assertTrue(
status, "Element is not loaded to the rows id: "
"{}".format(row_id_to_find))
self.page.wait_for_query_tool_loading_indicator_to_disappear()
def _query_tool_explain_with_verbose_and_cost(self):
query = """-- Explain query with verbose and cost

View File

@ -172,8 +172,6 @@ CREATE TABLE public.nonintpkey
self._copy_paste_row(config_data_local)
self._update_row(config_data_local)
self.page.click_tab("Messages")
self._verify_messsages("")
self.page.click_tab("Data Output")
updated_row_data = {
i: config_data_local['update'][i] if i in config_data_local[

View File

@ -232,6 +232,8 @@ class QueryToolLocators:
query_output_canvas_css = "#id-dataoutput .rdg"
pagination_inputs = "#id-dataoutput .PaginationInputs"
query_output_cells = ".rdg-cell[role='gridcell']"
sql_editor_message = "//div[@id='id-messages'][contains(string(), '{}')]"