Skip to content

Comments

Added support to download binary data from result grid. #4011#9645

Open
pravesh-sharma wants to merge 3 commits intopgadmin-org:masterfrom
pravesh-sharma:GH-4011
Open

Added support to download binary data from result grid. #4011#9645
pravesh-sharma wants to merge 3 commits intopgadmin-org:masterfrom
pravesh-sharma:GH-4011

Conversation

@pravesh-sharma
Copy link
Contributor

@pravesh-sharma pravesh-sharma commented Feb 18, 2026

Summary by CodeRabbit

  • New Features

    • Download binary data directly from SQL result grids via a new download button, progress indication, and completion handling (requires enabling preference).
    • Client-side trigger and save flow to request binary cell downloads.
  • New Preferences

    • "Enable binary data download?" added (disabled by default) to limit memory use.
  • Server

    • New endpoint to stream binary cell data for downloads.
  • Documentation

    • Clarified download options are visible only in desktop mode and noted memory implications.

@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

Walkthrough

Adds optional binary-data download support to the Query Tool: new user preference, cursor-level bytea typecasters, a backend POST endpoint to stream cell binary data, and frontend UI/events to trigger and save binary cells from result grids.

Changes

Cohort / File(s) Summary
Docs & Preference
docs/en_US/preferences.rst, web/pgadmin/misc/__init__.py
Documented desktop-only visibility for some download options and added file_downloads.enable_binary_data_download preference (bool, default False) with memory-usage note.
Driver Typecasters
web/pgadmin/utils/driver/psycopg3/typecast.py
Added register_binary_data_typecasters(cur) plus ByteaDataLoader and ByteaBinaryDataLoader to surface raw bytea bytes at cursor level; adjusted InetLoader.load to handle memoryview.
Backend endpoint (SQL Editor)
web/pgadmin/tools/sqleditor/__init__.py
Added POST /download_binary_data/<trans_id> to validate transaction/cursor, register binary typecasters, read specified cell (rowpos/colpos) and stream it as an octet-stream attachment; endpoint exposed.
Frontend constants & events
web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js
Added TRIGGER_SAVE_BINARY_DATA to QUERY_TOOL_EVENTS.
Frontend UI & download flow
web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx, web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx, web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx
Formatter renders conditional download button (reads preference, uses rowKeyGetter via extras); button emits TRIGGER_SAVE_BINARY_DATA. ResultSet adds listener and saveBinaryResultsToFile() to POST to backend, show progress, and save file. Note: index.jsx exposes rowKeyGetter via extras for absolute row calculation.

Sequence Diagram

sequenceDiagram
    actor User
    participant Frontend as Browser / Query Tool
    participant EventBus as QueryTool EventBus
    participant Backend as Flask Endpoint
    participant Driver as psycopg3 Driver
    participant DB as PostgreSQL

    User->>Frontend: Click download button on binary cell
    Frontend->>EventBus: Emit TRIGGER_SAVE_BINARY_DATA(rowPos, colPos)
    EventBus->>Frontend: ResultSet handler starts download (show loader)
    Frontend->>Backend: POST /download_binary_data/<trans_id> (rowpos, colpos)
    Backend->>Backend: Validate transaction & cursor
    Backend->>Driver: register_binary_data_typecasters(cursor)
    Backend->>Driver: Fetch specified row/column
    Driver->>DB: Retrieve bytea column
    DB-->>Driver: bytea payload
    Driver->>Backend: Bytea loader returns bytes
    Backend-->>Frontend: response (application/octet-stream attachment)
    Frontend->>Frontend: Save file, clear loader
    Frontend->>User: Download complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • yogeshmahajan-1903
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main feature added: binary data download capability from the result grid. It is concise, clear, and directly reflects the core changes across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web/pgadmin/tools/sqleditor/__init__.py`:
- Around line 2200-2209: The code dereferences conn._Connection__async_cursor
before verifying the results of check_transaction_status, which can return a
Response or a None conn; update the logic in the block that calls
check_transaction_status(trans_id) to first check if the returned value is a
Response and return it directly, then verify conn, trans_obj and session_obj are
not None before accessing conn._Connection__async_cursor; only after those
guards call register_binary_data_typecasters(cur). Ensure you stop ignoring
error_msg by either logging/including it in the returned internal_server_error
call (using TRANSACTION_STATUS_CHECK_FAILED as context) so Ruff's
unused-variable warning is resolved and real transaction errors are surfaced.
- Around line 2220-2223: The code uses cursor.scroll in relative mode and
doesn't validate inputs, so fix by parsing and validating data['rowpos'] and
data['colpos'] (ensure they are integers >= 0), call cur.scroll(int_rowpos,
mode='absolute') to move to the exact row, call cur.fetchone() and check for
None (row out of range), then verify the returned row has enough columns before
accessing binary_data[col_pos']; on any invalid input or out-of-range access
raise/return a proper validation response instead of letting
IndexError/ValueError bubble up; wrap the parsing/validation and fetch in a
try/except to return a controlled error for bad inputs.

In
`@web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx`:
- Around line 76-85: BinaryFormatter is using the hard-coded row.__temp_PK which
can be undefined when the server shifts the key (e.g. __temp_PK1); instead
locate the actual client-PK key on the row object and pass its value to
eventBus.fireEvent for QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA. Change the
onClick handler in BinaryFormatter to compute the client PK key by scanning
Object.keys(row) for the property that startsWith('__temp_PK') (or otherwise
matches the server-generated client-PK name used elsewhere in ResultSet.jsx),
read row[thatKey], and use that value instead of row.__temp_PK when firing the
event; keep the rest of the component and props (row, column, eventBus,
downloadBinaryData) unchanged.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web/pgadmin/tools/sqleditor/__init__.py`:
- Around line 2253-2254: Remove the trailing whitespace at the end of the line
containing the return call to send_file in __init__.py (the line starting with
"return send_file(") to fix the pycodestyle W291; edit the return send_file(
call so there is no space before the newline (e.g., change "return send_file(␣"
to "return send_file(") and re-run linters to confirm the warning is resolved.
- Around line 2200-2212: The current handling after
check_transaction_status(trans_id) treats any error_msg as a 500 and may
double-wrap Response objects; instead, inspect the returned error_msg from
check_transaction_status (e.g., ERROR_MSG_TRANS_ID_NOT_FOUND) and handle it
explicitly: if error_msg equals the "not found" constant return a 404 Response
(do not call internal_server_error), if error_msg is already a Response return
it directly, otherwise fall back to internal_server_error(errormsg=error_msg);
keep the subsequent guard that returns
internal_server_error(TRANSACTION_STATUS_CHECK_FAILED) only for missing/invalid
status/conn/trans_obj/session_obj as before.

In
`@web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx`:
- Around line 76-86: BinaryFormatter currently passes a page-relative
props.rowIdx into eventBus.fireEvent for
QUERY_TOOL_EVENTS.TRIGGER_SAVE_BINARY_DATA, which breaks downloads when
pagination is present; update BinaryFormatter to get pagination base from
DataGridExtrasContext (or use dataGridExtras?.startRowNum) and compute an
absolute index as props.rowIdx + (startRowNum ?? 1) - 1, then pass that absolute
index to eventBus.fireEvent instead of props.rowIdx so the saved binary data
targets the correct row.

---

Duplicate comments:
In `@web/pgadmin/tools/sqleditor/__init__.py`:
- Around line 2230-2247: The code advances the DB cursor before validating
inputs and then scrolls again, which can skip rows; remove the premature
cur.scroll(int(data['rowpos'])) and the initial cur.fetchone() usage, validate
rowpos/colpos first (ensure they are ints and non-negative), then call
cur.scroll(row_pos, mode='absolute') to move to the absolute row and fetch with
cur.fetchone(), check that row is not None and col_pos < len(row) before
accessing row[col_pos], and keep the existing exception handling
(ValueError/IndexError/TypeError) with the internal_server_error/gettext path
unchanged.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web/pgadmin/tools/sqleditor/__init__.py`:
- Around line 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.
- Around line 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.
- Around line 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.
- Around line 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.

In
`@web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Formatters.jsx`:
- 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.
- Around line 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.

Comment on lines +2219 to +2224
cur = conn._Connection__async_cursor
if cur is None:
return internal_server_error(
errormsg=gettext('No active result cursor.')
)
register_binary_data_typecasters(cur)
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.

Comment on lines +2226 to +2234
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)."
)
)
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.

Comment on lines +2235 to +2238
col_pos = data['colpos']
cur.scroll(int(data['rowpos']))
binary_data = cur.fetchone()
binary_data = binary_data[col_pos]
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.

Comment on lines +2258 to +2263
return send_file(
BytesIO(binary_data),
as_attachment=True,
download_name='binary_data',
mimetype='application/octet-stream'
)
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.

Comment on lines +83 to +86
// 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);
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.

// 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);
console.log(absoluteRowPos)
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant