Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/en_US/preferences.rst
Original file line number Diff line number Diff line change
Expand Up @@ -444,11 +444,17 @@ Use the fields on the *File Downloads* panel to manage file downloads related pr

* When the *Automatically open downloaded files?* switch is set to *True*
the downloaded file will automatically open in the system's default
application associated with that file type.
application associated with that file type. **Note:** This option is applicable and
visible only in desktop mode.

* When the *Enable binary data download?* switch is set to *True*,
binary data can be downloaded from the result grid. Default is set to *False*
to prevent excessive memory usage on the server.

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

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

Expand Down
12 changes: 12 additions & 0 deletions web/pgadmin/misc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ def register_preferences(self):
)
)

self.preference.register(
'file_downloads', 'enable_binary_data_download',
gettext("Enable binary data download?"),
'boolean', False,
category_label=PREF_LABEL_FILE_DOWNLOADS,
help_str=gettext(
'If set to True, binary data can be downloaded '
'from the result grid. The default is False to '
'prevent excessive memory usage on the server.'
)
)

def get_exposed_url_endpoints(self):
"""
Returns:
Expand Down
83 changes: 82 additions & 1 deletion web/pgadmin/tools/sqleditor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import secrets
from urllib.parse import unquote
from threading import Lock
from io import BytesIO
import threading
import math

Expand All @@ -23,7 +24,8 @@

from config import PG_DEFAULT_DRIVER, ALLOW_SAVE_PASSWORD
from werkzeug.user_agent import UserAgent
from flask import Response, url_for, render_template, session, current_app
from flask import Response, url_for, render_template, session, current_app, \
send_file
from flask import request
from flask_babel import gettext
from pgadmin.tools.sqleditor.utils.query_tool_connection_check \
Expand Down Expand Up @@ -70,6 +72,8 @@
from pgadmin.browser.server_groups.servers.utils import \
convert_connection_parameter, get_db_disp_restriction
from pgadmin.misc.workspaces import check_and_delete_adhoc_server
from pgadmin.utils.driver.psycopg3.typecast import \
register_binary_data_typecasters

MODULE_NAME = 'sqleditor'
TRANSACTION_STATUS_CHECK_FAILED = gettext("Transaction status check failed.")
Expand Down Expand Up @@ -147,6 +151,7 @@ def get_exposed_url_endpoints(self):
'sqleditor.server_cursor',
'sqleditor.nlq_chat_stream',
'sqleditor.explain_analyze_stream',
'sqleditor.download_binary_data',
]

def on_logout(self):
Expand Down Expand Up @@ -2182,6 +2187,82 @@ def start_query_download_tool(trans_id):
return internal_server_error(errormsg=err_msg)


@blueprint.route(
'/download_binary_data/<int:trans_id>',
methods=["POST"], endpoint='download_binary_data'
)
@pga_login_required
def download_binary_data(trans_id):
"""
This method is used to download binary data.
"""

(status, error_msg, conn, trans_obj,
session_obj) = check_transaction_status(trans_id)

if isinstance(error_msg, Response):
return error_msg
if error_msg == ERROR_MSG_TRANS_ID_NOT_FOUND:
return make_json_response(
success=0,
errormsg=error_msg,
info='DATAGRID_TRANSACTION_REQUIRED',
status=404
)

if not status or conn is None or trans_obj is None or \
session_obj is None:
return internal_server_error(
errormsg=TRANSACTION_STATUS_CHECK_FAILED
)

cur = conn._Connection__async_cursor
if cur is None:
return internal_server_error(
errormsg=gettext('No active result cursor.')
)
register_binary_data_typecasters(cur)
Comment on lines +2219 to +2224
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Locate and examine register_binary_data_typecasters implementation
fd "typecast" web/pgadmin/ --type f | head -20

Repository: pgadmin-org/pgadmin4

Length of output: 109


🏁 Script executed:

# Search for the function definition and usage
rg -n "def register_binary_data_typecasters" web/pgadmin/ -A 15

Repository: pgadmin-org/pgadmin4

Length of output: 1378


🏁 Script executed:

# Check the typecast module in psycopg3 driver
find web/pgadmin -name "*.py" -path "*/psycopg3/*" | xargs grep -l "ByteaDataLoader\|register.*typecaster" | head -5

Repository: pgadmin-org/pgadmin4

Length of output: 157


🏁 Script executed:

# Check how async_cursor is used in fetch operations
rg -n "__async_cursor" web/pgadmin/tools/sqleditor/__init__.py -B 3 -A 3 | head -80

Repository: pgadmin-org/pgadmin4

Length of output: 739


🏁 Script executed:

# Check if there's cleanup or restoration of adapters after binary download
rg -n "register_binary_data_typecasters" web/pgadmin/tools/sqleditor/__init__.py -B 10 -A 10

Repository: pgadmin-org/pgadmin4

Length of output: 1740


🏁 Script executed:

# Get the full function context around line 2224
sed -n '2200,2280p' web/pgadmin/tools/sqleditor/__init__.py

Repository: pgadmin-org/pgadmin4

Length of output: 2469


🏁 Script executed:

# Check what function contains line 2224
rg -n "^def " web/pgadmin/tools/sqleditor/__init__.py | awk -F: '$1 <= 2224' | tail -1

Repository: pgadmin-org/pgadmin4

Length of output: 104


🏁 Script executed:

# Search for any adapter reset or restoration in the file
rg -n "adapters\|ByteaDataLoader\|ByteaBinaryDataLoader" web/pgadmin/tools/sqleditor/__init__.py

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Check the typecast module to understand the loaders
rg -n "class ByteaDataLoader\|class ByteaBinaryDataLoader" web/pgadmin/utils/driver/psycopg3/typecast.py -A 10

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Get the full typecast.py file to understand ByteaDataLoader and ByteaBinaryDataLoader
wc -l web/pgadmin/utils/driver/psycopg3/typecast.py

Repository: pgadmin-org/pgadmin4

Length of output: 113


🏁 Script executed:

# Check the loaders
sed -n '1,230p' web/pgadmin/utils/driver/psycopg3/typecast.py

Repository: pgadmin-org/pgadmin4

Length of output: 7820


🏁 Script executed:

# Search for any documentation or comments about ByteaBinaryDataLoader
rg -n "ByteaBinaryDataLoader" web/pgadmin/utils/driver/psycopg3/typecast.py -B 5 -A 10

Repository: pgadmin-org/pgadmin4

Length of output: 1118


🏁 Script executed:

# Check if there are any other calls to register_binary_data_typecasters or adapter cleanup
rg -n "register_binary_data_typecasters\|adapters\.register\|adapters\.deregister" web/pgadmin/ -i

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Check the full ByteaDataLoader to understand what it returns
sed -n '258,276p' web/pgadmin/utils/driver/psycopg3/typecast.py

Repository: pgadmin-org/pgadmin4

Length of output: 688


🏁 Script executed:

# Check if there's a separate cursor creation for binary download or cleanup after
rg -n "download_binary_data" web/pgadmin/tools/sqleditor/__init__.py -A 100 | grep -E "adapters|cursor|finally|cleanup" -i

Repository: pgadmin-org/pgadmin4

Length of output: 348


🏁 Script executed:

# Check if fetch_window or other grid operations might reuse the same cursor
rg -n "def fetch_window\|def async_fetchmany_2darray" web/pgadmin/tools/sqleditor/__init__.py -A 20 | head -80

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Get the complete download_binary_data function with context
sed -n '2195,2270p' web/pgadmin/tools/sqleditor/__init__.py

Repository: pgadmin-org/pgadmin4

Length of output: 2273


🏁 Script executed:

# Check if there's any adapter cleanup or cursor reset logic elsewhere
rg -n "def query_tool\|def fetch_window\|adapters\." web/pgadmin/tools/sqleditor/__init__.py | head -40

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Check what the grid display expects for bytea columns
rg -n "bytea\|binary" web/pgadmin/tools/sqleditor/ -i --type js | head -30

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Check if the async cursor is created fresh for each operation or persisted
rg -n "__async_cursor\s*=" web/pgadmin/utils/driver/psycopg3/ -B 3 -A 3

Repository: pgadmin-org/pgadmin4

Length of output: 1859


register_binary_data_typecasters permanently alters the shared cursor's type adapters, potentially breaking subsequent grid operations.

register_binary_data_typecasters(cur) modifies the adapter registry on conn._Connection__async_cursor by registering ByteaBinaryDataLoader for OIDs 17 and 1001 (bytea types). This cursor is reused for all subsequent fetch operations within the same query execution. After this call, if the user fetches additional rows or pages, bytea columns will return raw binary data instead of the hex-encoded strings the grid expects, breaking the display.

Consider either: (a) saving and restoring the original adapters after the binary fetch, or (b) using a separate cursor for the binary download.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/sqleditor/__init__.py` around lines 2219 - 2224, The call
to register_binary_data_typecasters(cur) mutates adapters on the shared cursor
conn._Connection__async_cursor (registering ByteaBinaryDataLoader for OIDs 17
and 1001) and breaks subsequent grid fetches; fix by creating and using a
dedicated cursor for the binary-download path instead of mutating the shared
cursor — e.g., obtain a new cursor (not conn._Connection__async_cursor), call
register_binary_data_typecasters(new_cur) and perform the binary fetch/streaming
on new_cur, then close it; alternatively, if creating a separate cursor is
infeasible, capture the original typecasters from the shared cursor, call
register_binary_data_typecasters(cur) for the binary read, then restore the
saved adapters immediately after the download completes.


data = request.values if request.values else request.get_json(silent=True)
if data is None:
return make_json_response(
status=410,
success=0,
errormsg=gettext(
"Could not find the required parameter (query)."
)
)
Comment on lines +2226 to +2234
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading error message — copy-pasted from another endpoint.

The error text "Could not find the required parameter (query)." was copied from the query_tool_download handler. For this endpoint, it should reference the expected binary data parameters.

✏️ Suggested fix
             errormsg=gettext(
-                "Could not find the required parameter (query)."
+                "Could not find the required parameters (rowpos, colpos)."
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/sqleditor/__init__.py` around lines 2226 - 2234, The error
message returned when request data is missing is incorrect/copy-pasted; update
the gettext string in the block that sets data = request.values if
request.values else request.get_json(silent=True) so the make_json_response call
(success=0, status=410) references the expected binary data parameters rather
than "query" — e.g. change the gettext text to mention the required binary
payload/parameter names (for example "file", "binary" or whatever this endpoint
expects) so callers know which binary parameters are missing; keep the existing
use of gettext and make_json_response.

col_pos = data['colpos']
cur.scroll(int(data['rowpos']))
binary_data = cur.fetchone()
binary_data = binary_data[col_pos]
Comment on lines +2235 to +2238
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Unvalidated duplicate code executes before the try block, corrupting cursor state.

Lines 2235–2238 scroll the cursor and fetch a row without any validation or error handling, then lines 2240–2256 repeat the same logic properly inside a try/except. This appears to be leftover code from before the validation fix was applied. Problems:

  1. data['colpos'] raises an unhandled KeyError if the key is missing.
  2. col_pos remains a string (not cast to int), so binary_data[col_pos] will fail or behave incorrectly.
  3. The cursor is scrolled and a row fetched for no reason — the validated block below repeats this.

Remove these four lines entirely.

🐛 Proposed fix
     register_binary_data_typecasters(cur)
 
     data = request.values if request.values else request.get_json(silent=True)
     if data is None:
         return make_json_response(
             status=410,
             success=0,
             errormsg=gettext(
                 "Could not find the required parameter (query)."
             )
         )
-    col_pos = data['colpos']
-    cur.scroll(int(data['rowpos']))
-    binary_data = cur.fetchone()
-    binary_data = binary_data[col_pos]
-
     try:
         row_pos = int(data['rowpos'])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/sqleditor/__init__.py` around lines 2235 - 2238, Remove the
unvalidated duplicate cursor operations: delete the four lines that set col_pos
= data['colpos'], call cur.scroll(int(data['rowpos'])), assign binary_data =
cur.fetchone(), and then binary_data = binary_data[col_pos]; these are run
before the existing try/except and can raise KeyError or use an uncast col_pos
and corrupt cursor state—keep only the validated logic already present in the
try/except that performs cur.scroll, cur.fetchone and integer casting of
col_pos.


try:
row_pos = int(data['rowpos'])
col_pos = int(data['colpos'])
if row_pos < 0 or col_pos < 0:
raise ValueError
cur.scroll(row_pos)
row = cur.fetchone()
if row is None or col_pos >= len(row):
return internal_server_error(
errormsg=gettext('Requested cell is out of range.')
)
binary_data = row[col_pos]
except (ValueError, IndexError, TypeError) as e:
current_app.logger.error(e)
return internal_server_error(
errormsg='Invalid row/column position.'
)

return send_file(
BytesIO(binary_data),
as_attachment=True,
download_name='binary_data',
mimetype='application/octet-stream'
)
Comment on lines +2258 to +2263
Copy link

@coderabbitai coderabbitai bot Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle NULL cell values — BytesIO(None) raises TypeError.

If the selected cell contains a SQL NULL, row[col_pos] is None, and BytesIO(None) will throw an unhandled TypeError (this line is outside the try/except block). Return a user-friendly error instead.

🛡️ Proposed fix
+    if binary_data is None:
+        return bad_request(
+            errormsg=gettext('The selected cell contains NULL.')
+        )
+
     return send_file(
         BytesIO(binary_data),
         as_attachment=True,
         download_name='binary_data',
         mimetype='application/octet-stream'
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return send_file(
BytesIO(binary_data),
as_attachment=True,
download_name='binary_data',
mimetype='application/octet-stream'
)
if binary_data is None:
return bad_request(
errormsg=gettext('The selected cell contains NULL.')
)
return send_file(
BytesIO(binary_data),
as_attachment=True,
download_name='binary_data',
mimetype='application/octet-stream'
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/sqleditor/__init__.py` around lines 2258 - 2263, The code
calls send_file(BytesIO(binary_data), ...) where binary_data comes from
row[col_pos]; if the cell is SQL NULL (binary_data is None) BytesIO(None) raises
TypeError outside the try/except, so before constructing BytesIO check whether
binary_data is None (or empty) and return a user-friendly error response (e.g.,
an HTTP error/flash/message) instead of attempting to call BytesIO; update the
branch around binary_data/row[col_pos] that calls send_file and ensure
send_file(BytesIO(...)) is only executed when binary_data is not None,
referencing the existing variables binary_data, row[col_pos], BytesIO and
send_file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For null value download is not allowed

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pravesh-sharma, thank you for clarifying that NULL value downloads are not allowed by design. However, the current code doesn't explicitly enforce this requirement with a user-friendly error message.

If binary_data is None (SQL NULL), the send_file(BytesIO(binary_data), ...) call will raise an unhandled TypeError because it's outside the try/except block. This would result in a generic 500 error rather than a clear message to the user.

To properly implement the "no NULL downloads" requirement, I recommend adding an explicit check before calling send_file, as CodeRabbit suggested:

if binary_data is None:
    return bad_request(
        errormsg=gettext('The selected cell contains NULL.')
    )

return send_file(
    BytesIO(binary_data),
    as_attachment=True,
    download_name='binary_data',
    mimetype='application/octet-stream'
)

This way, users will receive a clear, informative error message when they attempt to download a NULL value, which better reflects your stated intent.



@blueprint.route(
'/status/<int:trans_id>',
methods=["GET"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const QUERY_TOOL_EVENTS = {
TRIGGER_SELECT_ALL: 'TRIGGER_SELECT_ALL',
TRIGGER_SAVE_QUERY_TOOL_DATA: 'TRIGGER_SAVE_QUERY_TOOL_DATA',
TRIGGER_GET_QUERY_CONTENT: 'TRIGGER_GET_QUERY_CONTENT',
TRIGGER_SAVE_BINARY_DATA: 'TRIGGER_SAVE_BINARY_DATA',

COPY_DATA: 'COPY_DATA',
SET_LIMIT_VALUE: 'SET_LIMIT_VALUE',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { useContext } from 'react';
import { styled } from '@mui/material/styles';
import _ from 'lodash';
import PropTypes from 'prop-types';
import gettext from 'sources/gettext';
import CustomPropTypes from '../../../../../../static/js/custom_prop_types';
import usePreferences from '../../../../../../preferences/static/js/store';

import GetAppRoundedIcon from '@mui/icons-material/GetAppRounded';
import { PgIconButton } from '../../../../../../static/js/components/Buttons';
import { QUERY_TOOL_EVENTS } from '../QueryToolConstants';
import { QueryToolEventsContext } from '../QueryToolComponent';
import { DataGridExtrasContext } from './index';

const StyledNullAndDefaultFormatter = styled(NullAndDefaultFormatter)(({theme}) => ({
'& .Formatters-disabledCell': {
Expand Down Expand Up @@ -70,10 +76,22 @@

export function BinaryFormatter({row, column}) {
let value = row[column.key];
const eventBus = useContext(QueryToolEventsContext);
const dataGridExtras = useContext(DataGridExtrasContext);
const downloadBinaryData = usePreferences().getPreferences('misc', 'enable_binary_data_download').value;

// Use clientPK as the absolute row position
// rowKeyGetter returns the clientPK value which is a sequential counter (0, 1, 2, ...)
// that persists across pagination and represents the 0-based absolute position in the result set
const absoluteRowPos = parseInt(dataGridExtras?.rowKeyGetter?.(row) ?? 0);
Comment on lines +83 to +86
Copy link

@coderabbitai coderabbitai bot Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n "client_primary_key|clientPK|__temp_PK" web/pgadmin/tools/sqleditor/static/js/components -C 3 | head -120

Repository: pgadmin-org/pgadmin4

Length of output: 12195


🏁 Script executed:

# Find the rowKeyGetter function definition
rg -n "rowKeyGetter" web/pgadmin/tools/sqleditor/static/js -C 5 | head -100

Repository: pgadmin-org/pgadmin4

Length of output: 10843


🏁 Script executed:

# Check the ResultSet component to see how rows are initialized
fd -t f "ResultSet" web/pgadmin/tools/sqleditor/static/js/components -name "*.jsx" -o -name "*.js"

Repository: pgadmin-org/pgadmin4

Length of output: 294


🏁 Script executed:

rg -n "resetClientPKIndex" web/pgadmin/tools/sqleditor/static/js -C 4

Repository: pgadmin-org/pgadmin4

Length of output: 3108


🏁 Script executed:

# Check pagination or data fetch logic
rg -n "pagination|offset|limit" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx | head -30

Repository: pgadmin-org/pgadmin4

Length of output: 689


🏁 Script executed:

rg -n "offset|rows_from|rows_to|pagination" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx -B 2 -A 2 | grep -E "(getData|fetch|concat|append|setRows)" -A 3 -B 3

Repository: pgadmin-org/pgadmin4

Length of output: 392


🏁 Script executed:

# Look for how new pagination results are appended to existing data
rg -n "setRows|setDataOutputQuery" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx -A 5 | head -50

Repository: pgadmin-org/pgadmin4

Length of output: 2139


🏁 Script executed:

sed -n '1130,1160p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Repository: pgadmin-org/pgadmin4

Length of output: 1235


🏁 Script executed:

# Also check how getData is called and what newRows contains
rg -n "getData|newRows" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx -B 3 -A 3 | grep -E "getData|newRows" -A 5 -B 5 | head -60

Repository: pgadmin-org/pgadmin4

Length of output: 1605


🏁 Script executed:

# Check processRows implementation and its parameters
sed -n '705,725p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Repository: pgadmin-org/pgadmin4

Length of output: 931


🏁 Script executed:

# Check how getWindowRows API is called and what parameters it receives
rg -n "getWindowRows" web/pgadmin/tools/sqleditor/static/js -B 3 -A 8

Repository: pgadmin-org/pgadmin4

Length of output: 2642


🏁 Script executed:

# Look for the complete flow - check if clientPKLastIndex is ever reset during pagination
rg -n "clientPKLastIndex|resetClientPKIndex|fetchWindow" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx | head -20

Repository: pgadmin-org/pgadmin4

Length of output: 496


🏁 Script executed:

# Check the Formatters.jsx file to see the full context of how absoluteRowPos is used
cat -n web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx | sed -n '80,100p'

Repository: pgadmin-org/pgadmin4

Length of output: 1203


🏁 Script executed:

# Search for any reset or offset handling in processRows or pagination logic
rg -n "clientPKLastIndex|fromRownum|offset|pagination" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx | grep -E "fromRownum|offset|startRowNum" -A 2 -B 2

Repository: pgadmin-org/pgadmin4

Length of output: 662


🏁 Script executed:

# Check if there's any mechanism to account for row offset when assigning clientPK
sed -n '700,730p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Repository: pgadmin-org/pgadmin4

Length of output: 1425


🏁 Script executed:

# Search for TRIGGER_SAVE_BINARY_DATA event handler and backend cursor scroll usage
rg -n "TRIGGER_SAVE_BINARY_DATA" web/pgadmin/tools/sqleditor -C 5

Repository: pgadmin-org/pgadmin4

Length of output: 4033


🏁 Script executed:

# Check the Python backend for cur.scroll usage
find web/pgadmin/tools/sqleditor -name "*.py" -type f | head -10

Repository: pgadmin-org/pgadmin4

Length of output: 613


🏁 Script executed:

# Find the saveBinaryResultsToFile implementation in the backend
rg -n "saveBinaryResultsToFile|save_binary_results" web/pgadmin/tools/sqleditor -C 5

Repository: pgadmin-org/pgadmin4

Length of output: 2551


🏁 Script executed:

# Search for the backend API endpoint that handles binary data download
find web/pgadmin/tools/sqleditor -name "*.py" -exec rg -l "binary.*download|scroll" {} \;

Repository: pgadmin-org/pgadmin4

Length of output: 103


🏁 Script executed:

# Check the backend endpoint implementation
sed -n '1,100p' web/pgadmin/tools/sqleditor/__init__.py | grep -A 20 "download_binary_data"

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Search for the download_binary_data endpoint definition
rg -n "download_binary_data" web/pgadmin/tools/sqleditor/__init__.py -A 15

Repository: pgadmin-org/pgadmin4

Length of output: 1327


🏁 Script executed:

# Get more of the download_binary_data implementation
sed -n '2195,2250p' web/pgadmin/tools/sqleditor/__init__.py

Repository: pgadmin-org/pgadmin4

Length of output: 1775


🏁 Script executed:

# Check how fromRownum/toRownum are passed and if there's offset calculation
sed -n '496,510p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Repository: pgadmin-org/pgadmin4

Length of output: 742


🏁 Script executed:

# Check if there's any caching or prevention of re-fetching pages
rg -n "fetchWindow|getWindowRows" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx -B 2 -A 8 | head -50

Repository: pgadmin-org/pgadmin4

Length of output: 1757


🏁 Script executed:

# Verify the issue - check if processRows receives fromRownum parameter
rg -n "processRows" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx -B 1 -A 1 | grep -E "processRows|fromRownum"

Repository: pgadmin-org/pgadmin4

Length of output: 358


🏁 Script executed:

# Check the processRows method signature
sed -n '680,720p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Repository: pgadmin-org/pgadmin4

Length of output: 1599


Fix clientPK calculation to account for pagination offset, and add radix to parseInt.

The clientPKLastIndex is incremented sequentially without considering the row offset from pagination. When fetchWindow requests rows from the backend (e.g., rows 100–199), processRows doesn't receive this offset, causing it to continue incrementing from its previous value. If a user navigates back to an earlier page, the rows are re-fetched and assigned clientPK values that don't match their actual absolute positions in the result set. This breaks the binary data download feature, which relies on absoluteRowPos to call cur.scroll(row_pos) on the backend with the correct absolute position.

For example: after fetching page 2 (rows 100–199 with clientPK 100–199), re-fetching page 1 (rows 0–99) would assign clientPK 200–299, causing downloads from page 1 to fetch from the wrong rows.

Additionally, parseInt should include the radix parameter:

-  const absoluteRowPos = parseInt(dataGridExtras?.rowKeyGetter?.(row) ?? 0);
+  const absoluteRowPos = parseInt(dataGridExtras?.rowKeyGetter?.(row) ?? 0, 10);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx`
around lines 83 - 86, clientPK is being assigned sequentially in processRows
using clientPKLastIndex without accounting for the fetchWindow pagination
offset, so rows re-fetched on earlier pages get wrong absolute positions; update
processRows to compute clientPK from the window offset (e.g., clientPK =
(fetchWindowOffset || 0) + localIndex) or set clientPKLastIndex to the current
page start before incrementing, and when reading absoluteRowPos use
parseInt(dataGridExtras?.rowKeyGetter?.(row) ?? 0, 10) to include the radix;
locate and change the code in processRows and the absoluteRowPos calculation to
apply the pagination offset (use the fetchWindow/offset value available there or
from dataGridExtras) and add the radix argument to parseInt.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clientPK does not holds the actual value but the key which points to actual value

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@pravesh-sharma, thank you for the clarification! Let me investigate how clientPK is assigned and used to better understand the row positioning logic.


🏁 Script executed:

#!/bin/bash
# Check how clientPK is assigned in processRows
rg -n "clientPK.*=" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx -B 3 -A 3

Length of output: 2665


🏁 Script executed:

#!/bin/bash
# Check the row structure and what fields are available
rg -n "client_primary_key|__temp_PK" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx -B 2 -A 2

Length of output: 232


🏁 Script executed:

#!/bin/bash
# Check what the backend cur.scroll expects
sed -n '2190,2250p' web/pgadmin/tools/sqleditor/__init__.py

Length of output: 1849


🏁 Script executed:

#!/bin/bash
# Check if there's an absolute row position field stored in the row data
rg -n "row_pos|rowPos|absolute.*position" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx | head -20

Length of output: 355


🏁 Script executed:

#!/bin/bash
# Check how processRows is called and what parameters it receives
rg -n "processRows\(" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx -B 2 -A 5

Length of output: 1337


🏁 Script executed:

#!/bin/bash
# Check if there's logic to handle fromRownum or offset when processing rows
sed -n '705,725p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Length of output: 867


🏁 Script executed:

#!/bin/bash
# Check the getData method to see if it passes any offset information
sed -n '1130,1165p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Length of output: 1265


🏁 Script executed:

#!/bin/bash
# Check if clientPKLastIndex is ever reset or adjusted based on pagination
rg -n "resetClientPKIndex|clientPKLastIndex\s*=" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Length of output: 182


🏁 Script executed:

#!/bin/bash
# Check when resetClientPKIndex is called
rg -n "resetClientPKIndex" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx -B 5 -A 2

Length of output: 931


🏁 Script executed:

#!/bin/bash
# Check if fetchWindow is called after resetClientPKIndex
sed -n '235,245p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Length of output: 503


🏁 Script executed:

#!/bin/bash
# Check what happens on initial query execution
sed -n '910,925p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Length of output: 616


🏁 Script executed:

#!/bin/bash
# Look at the getWindowRows API call to understand what fromRownum/toRownum means
rg -n "getWindowRows" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx -B 2 -A 8

Length of output: 817


🏁 Script executed:

#!/bin/bash
# Check if the backend response includes row position information
rg -n "fetch_window|from_rownum|to_rownum" web/pgadmin/tools/sqleditor/__init__.py -B 3 -A 10 | head -50

Length of output: 1944


🏁 Script executed:

#!/bin/bash
# Check what data structure is returned from getWindowRows
rg -n "rows_fetched_from|rows_fetched_to|pagination" web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx | head -20

Length of output: 319


🏁 Script executed:

#!/bin/bash
# Check if there's any absolute row position tracking in the response
sed -n '1145,1160p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Length of output: 700


🏁 Script executed:

#!/bin/bash
# Check what pagination.rows_from contains and if it's available
rg -n "rows_from|pagination" web/pgadmin/tools/sqleditor/__init__.py -B 2 -A 5 | grep -A 5 -B 2 "rows_from\|'pagination'"

Length of output: 1092


🏁 Script executed:

#!/bin/bash
# Check if fromRownum is available in the fetchWindow context
sed -n '1140,1155p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Length of output: 645


🏁 Script executed:

#!/bin/bash
# Check how the DataGrid receives startRowNum and if it's propagated to rows
rg -n "startRowNum" web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid -B 2 -A 2

Length of output: 2973


🏁 Script executed:

#!/bin/bash
# Check if pagination.rows_from is used to calculate the absolute position
sed -n '1595,1610p' web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx

Length of output: 831


🏁 Script executed:

#!/bin/bash
# Verify the backend cur.scroll() usage - does it expect absolute position from start?
sed -n '2230,2250p' web/pgadmin/tools/sqleditor/__init__.py

Length of output: 660


🏁 Script executed:

#!/bin/bash
# Check the backend response structure to see what rows_from represents
sed -n '1325,1360p' web/pgadmin/tools/sqleditor/__init__.py

Length of output: 1185


@pravesh-sharma, thank you for the clarification! I understand you're saying clientPK is a reference key rather than the actual data value. However, I believe CodeRabbit has identified a valid concern about how this key is being used for row positioning in the binary download feature.

Let me explain the issue:

Current Behavior:

  1. clientPK is assigned sequentially using clientPKLastIndex (line 717 in ResultSet.jsx)
  2. clientPKLastIndex increments continuously and is only reset on new query execution (line 917)
  3. When fetching different pagination windows:
    • Fetch page 1 (rows 0-99): clientPK = 0-99, counter at 100
    • Fetch page 2 (rows 100-199): clientPK = 100-199, counter at 200
    • Fetch page 1 again: clientPK = 200-299 ❌ (should be 0-99)

The Problem:

  • absoluteRowPos in Formatters.jsx uses rowKeyGetter(row) which returns the clientPK value
  • This value is sent to the backend as rowpos for binary downloads
  • The backend's cur.scroll(row_pos) expects the actual absolute position from the start of the result set (0-based)
  • Re-fetching earlier pages assigns wrong clientPK values, causing downloads from wrong rows

The Solution:
The pagination offset (pagination.rows_from) is already available (line 1603 in ResultSet.jsx) but not used when assigning clientPK. You should either:

  1. Pass offset to processRows():
// In fetchWindow around line 1147
const newRows = rsu.current.processRows(res.data.data.result, columns, {
  startIndex: res.data.data.pagination.rows_from
});

// In processRows around line 717
rowObj[this.clientPK] = (options.startIndex || 0) + recIdx;
  1. Or use rowIdx + startRowNum in BinaryFormatter:
const absoluteRowPos = rowIdx + (dataGridExtras?.startRowNum ?? 0);

Also, please add the radix parameter to parseInt:

-const absoluteRowPos = parseInt(dataGridExtras?.rowKeyGetter?.(row) ?? 0);
+const absoluteRowPos = parseInt(dataGridExtras?.rowKeyGetter?.(row) ?? 0, 10);

Does this clarify the concern? Would you like me to create a plan to fix this issue?


🧬 Code Graph Analysis Results

[file: web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx]

Summary: BinaryFormatter uses clientPK-derived absolute row position via dataGridExtras.rowKeyGetter.

  • Description:

    • Function: BinaryFormatter({ row, column })
    • Key behavior:
      • Computes absoluteRowPos using parseInt(dataGridExtras?.rowKeyGetter?.(row) ?? 0)
      • The code comment explains: "Use clientPK as the absolute row position" and "rowKeyGetter returns the clientPK value which is a sequential counter (0, 1, 2, ...) that persists across pagination and represents the 0-based absolute position in the result set"
    • Rationale relevant to user comment: clarifies that the identifier used to reference a row is a key (clientPK) that maps to the actual row position/value, not the value itself. This aligns with understanding that clientPK is an index/key, not the data payload.
  • Additional context present in this snippet:

    • The absoluteRowPos is used to trigger actions (e.g., TRIGGER_SAVE_BINARY_DATA) for the given row
    • There is a DataGridExtrasContext that supplies rowKeyGetter

[file: web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx]

Summary: DataGridExtrasContext provides a rowKeyGetter function and is used to map a row to its absolute position (clientPK).

  • Description:

    • Context: DataGridExtrasContext
    • Usage patterns observed:
      • const dataGridExtras = useContext(DataGridExtrasContext);
      • absoluteRowPos = parseInt(dataGridExtras?.rowKeyGetter?.(row) ?? 0)
    • Note: The rowKeyGetter is described as returning the clientPK value for a given row, which is the 0-based absolute position in the full result set (across pagination). This is the key mechanism that the BinaryFormatter uses to identify a row, rather than using the actual data value directly.
  • Relevance to user comment:

    • Confirms that clientPK is a reference key pointing to the actual row/value, not the actual value itself. The code relies on rowKeyGetter(row) to obtain the positional key that identifies the row in the dataset.

console.log(absoluteRowPos)

Check failure on line 87 in web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx

View workflow job for this annotation

GitHub Actions / run-javascript-tests (macos-latest)

Missing semicolon

Check failure on line 87 in web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx

View workflow job for this annotation

GitHub Actions / run-javascript-tests (macos-latest)

Unexpected console statement. Only these console methods are allowed: warn, error, trace

Check failure on line 87 in web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx

View workflow job for this annotation

GitHub Actions / run-javascript-tests (ubuntu-22.04)

Missing semicolon

Check failure on line 87 in web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx

View workflow job for this annotation

GitHub Actions / run-javascript-tests (ubuntu-22.04)

Unexpected console statement. Only these console methods are allowed: warn, error, trace

Check failure on line 87 in web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx

View workflow job for this annotation

GitHub Actions / run-javascript-tests (windows-latest)

Missing semicolon

Check failure on line 87 in web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx

View workflow job for this annotation

GitHub Actions / run-javascript-tests (windows-latest)

Unexpected console statement. Only these console methods are allowed: warn, error, trace
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove debug console.log — breaks CI pipeline.

This leftover debug statement causes ESLint failures (no-console and semi) across all CI checks. Remove it.

🐛 Proposed fix
-  console.log(absoluteRowPos)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log(absoluteRowPos)
🧰 Tools
🪛 GitHub Actions: Check Javascript style

[error] 87-87: no-console: Unexpected console statement. Only these console methods are allowed: warn, error, trace


[error] 87-87: semi: Missing semicolon

🪛 GitHub Actions: Run feature tests on PostgreSQL

[error] 87-87: ESLint: Unexpected console statement. Only these console methods are allowed: warn, error, trace (no-console)


[error] 87-87: ESLint: Missing semicolon (semi)

🪛 GitHub Actions: Run Javascript tests

[error] 87-87: no-console: Unexpected console statement. Only these methods are allowed: warn, error, trace.

🪛 GitHub Check: run-javascript-tests (macos-latest)

[failure] 87-87:
Missing semicolon


[failure] 87-87:
Unexpected console statement. Only these console methods are allowed: warn, error, trace

🪛 GitHub Check: run-javascript-tests (ubuntu-22.04)

[failure] 87-87:
Missing semicolon


[failure] 87-87:
Unexpected console statement. Only these console methods are allowed: warn, error, trace

🪛 GitHub Check: run-javascript-tests (windows-latest)

[failure] 87-87:
Missing semicolon


[failure] 87-87:
Unexpected console statement. Only these console methods are allowed: warn, error, trace

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx`
at line 87, Remove the stray debug console.log call in Formatters.jsx: delete
the line "console.log(absoluteRowPos)" (it triggers ESLint no-console and semi
errors); if runtime debugging is required instead use the project's logger API
or wrap in a debug-only conditional, but the immediate fix is to remove that
console.log reference to absoluteRowPos so the file passes linting.


return (
<StyledNullAndDefaultFormatter value={value} column={column}>
<span className='Formatters-disabledCell'>[{value}]</span>
<span className='Formatters-disabledCell'>[{value}]</span>&nbsp;&nbsp;
{downloadBinaryData &&
<PgIconButton size="xs" title={gettext('Download binary data')} icon={<GetAppRoundedIcon />}
onClick={()=>eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, absoluteRowPos, column.pos)}/>}
</StyledNullAndDefaultFormatter>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,8 @@ export default function QueryToolDataGrid({columns, rows, totalRowCount, dataCha
}, []);

const dataGridExtras = useMemo(()=>({
onSelectedCellChange, handleShortcuts, startRowNum
}), [onSelectedCellChange]);
onSelectedCellChange, handleShortcuts, startRowNum, rowKeyGetter: props.rowKeyGetter
}), [onSelectedCellChange, props.rowKeyGetter]);

// Save column width to window object on resize
const handleColumnResize = (column, width) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,23 @@ export class ResultSetUtils {
}
}

async saveBinaryResultsToFile(fileName, rowPos, colPos, onProgress) {
try {
await DownloadUtils.downloadFileStream({
url: url_for('sqleditor.download_binary_data', {
'trans_id': this.transId,
}),
options: {
method: 'POST',
body: JSON.stringify({filename: fileName, rowpos: rowPos, colpos: colPos})
}}, fileName, 'application/octet-stream', onProgress);
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS_END);
} catch (error) {
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS_END);
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.HANDLE_API_ERROR, error);
}
}

includeFilter(reqData) {
return this.api.post(
url_for('sqleditor.inclusive_filter', {
Expand Down Expand Up @@ -1038,6 +1055,15 @@ export function ResultSet() {
setLoaderText('');
});

eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, async (rowPos, colPos)=>{
let fileName = 'data-' + new Date().getTime();
setLoaderText(gettext('Downloading results...'));
await rsu.current.saveBinaryResultsToFile(fileName, rowPos, colPos, (p)=>{
setLoaderText(gettext('Downloading results(%s)...', p));
});
setLoaderText('');
});

eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_SET_LIMIT, async (limit)=>{
setLoaderText(gettext('Setting the limit on the result...'));
try {
Expand Down
41 changes: 41 additions & 0 deletions web/pgadmin/utils/driver/psycopg3/typecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,21 @@ def register_array_to_string_typecasters(connection=None):
TextLoaderpgAdmin)


def register_binary_data_typecasters(cur):
# Register type caster to fetch original binary data for bytea type.
cur.adapters.register_loader(17,
ByteaDataLoader)

cur.adapters.register_loader(1001,
ByteaDataLoader)

cur.adapters.register_loader(17,
ByteaBinaryDataLoader)

cur.adapters.register_loader(1001,
ByteaBinaryDataLoader)


class InetLoader(InetLoader):
def load(self, data):
if isinstance(data, memoryview):
Expand Down Expand Up @@ -240,6 +255,32 @@ def load(self, data):
return 'binary data' if data is not None else None


class ByteaDataLoader(Loader):
# Loads the actual binary data.
def load(self, data):
if data:
if isinstance(data, memoryview):
data = bytes(data).decode()
if data.startswith('\\x'):
data = data[2:]
try:
return bytes.fromhex(data)
except ValueError:
# In case of error while converting hex to bytes, return
# original data.
return data
else:
return data
return data if data is not None else None


class ByteaBinaryDataLoader(Loader):
format = _pq_Format.BINARY

def load(self, data):
return data if data is not None else None


class TextLoaderpgAdmin(TextLoader):
def load(self, data):
postgres_encoding, python_encoding = get_encoding(
Expand Down
Loading