diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Editors.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Editors.jsx index 355bc009519..0d298bb84ac 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Editors.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Editors.jsx @@ -241,6 +241,15 @@ export function TextEditor({row, column, onRowChange, onClose}) { }, []); const onOK = ()=>{ + /* The editor also opens on read-only columns so that wide values can be + * inspected, but committing must be refused there: an expression/alias + * column has no counterpart in the base table and the generated UPDATE + * would fail with `column "..." does not exist` (#10103). The OK button + * is already hidden, this guards the Enter key path. */ + if(!column.can_edit) { + onClose(false); + return; + } if(column.is_array && !isValidArray(localVal)) { pgAdmin.Browser.notifier.error(gettext('Arrays must start with "{" and end with "}"')); } else { diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx index 639bd39598c..10687f4c8bf 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx @@ -1621,6 +1621,15 @@ export function ResultSet() { const onRowsChange = (newRows, otherInfo)=>{ + /* Never record, or even display, a change to a read-only column. The + * editors open on such columns so that wide values can be inspected, so a + * stray commit can still arrive here; letting it through would stage an + * expression/alias column that does not exist in the base table and the + * save would fail with `column "..." does not exist` (#10103). */ + if(otherInfo.column?.can_edit === false) { + return; + } + let row = newRows[otherInfo.indexes[0]]; let clientPK = rowKeyGetter(row); diff --git a/web/pgadmin/tools/sqleditor/utils/save_changed_data.py b/web/pgadmin/tools/sqleditor/utils/save_changed_data.py index 78776697ff3..adf8999c918 100644 --- a/web/pgadmin/tools/sqleditor/utils/save_changed_data.py +++ b/web/pgadmin/tools/sqleditor/utils/save_changed_data.py @@ -177,6 +177,37 @@ def save_changed_data(changed_data, columns_info, conn, command_obj, list_of_sql[of_type] = [] for each_row in changed_data[of_type]: data = changed_data[of_type][each_row]['data'] + # client_primary_key is a synthetic tracking key (e.g. + # '__temp_PK') chosen specifically to never match a real + # column name, so it must be read before it is popped/ + # filtered out below. + row_id = data.get(client_primary_key) + # Remove our unique tracking keys, mirroring the + # added-row path above. Today neither key is ever + # present on an updated-row payload (only the added-row + # path tags rows this way), so the columns_info filter + # below is already sufficient - but stripping them + # explicitly keeps this path from silently relying on + # that assumption if a future change starts tagging + # updated rows the same way (e.g. multi-row copy-paste + # into existing rows). + data.pop(client_primary_key, None) + data.pop('is_row_copied', None) + # Drop any column that isn't a real editable column of the + # underlying table (e.g. an expression/alias column such as + # `first_name || ' ' || last_name as the_name`). Such columns + # carry the read-only lock icon in the grid, but without this + # guard the rendered UPDATE references a non-existent column + # and Postgres rejects the change. Issue #10103. + data = { + k: v for k, v in data.items() + if k in columns_info and + columns_info[k].get('is_editable', True) + } + # Nothing editable left to persist for this row, skip it so we + # don't render an invalid `SET` clause. + if not data: + continue pk_escaped = { pk: pk_val.replace('%', '%%') if hasattr( pk_val, 'replace') else pk_val @@ -196,8 +227,7 @@ def save_changed_data(changed_data, columns_info, conn, command_obj, ) list_of_sql[of_type].append({'sql': sql, 'data': data, - 'row_id': - data.get(client_primary_key)}) + 'row_id': row_id}) # For deleted rows elif of_type == 'deleted': diff --git a/web/pgadmin/tools/sqleditor/utils/tests/test_save_changed_data.py b/web/pgadmin/tools/sqleditor/utils/tests/test_save_changed_data.py index b43d0773a9a..db8a4434245 100644 --- a/web/pgadmin/tools/sqleditor/utils/tests/test_save_changed_data.py +++ b/web/pgadmin/tools/sqleditor/utils/tests/test_save_changed_data.py @@ -1040,3 +1040,114 @@ def _create_test_table(self): "FROM {0};" ).format(self.test_table_name) utils.create_table_with_query(self.server, self.db_name, create_sql) + + +# The result set used by both alias regression classes below: three real +# columns of the base table plus an expression column that only exists in +# the query. ``can_edit`` mirrors what the client sends for the lock icon. +ALIAS_RESULT_COLUMNS = [ + {"name": "id", "pos": 0, "can_edit": True, + "type": "integer", "cell": "number", + "not_null": True, "has_default_val": False, + "is_array": False, "display_name": "id"}, + {"name": "first_name", "pos": 1, "can_edit": True, + "type": "text", "cell": "string", + "not_null": False, "has_default_val": False, + "is_array": False, "display_name": "first_name"}, + {"name": "last_name", "pos": 2, "can_edit": True, + "type": "text", "cell": "string", + "not_null": False, "has_default_val": False, + "is_array": False, "display_name": "last_name"}, + {"name": "the_name", "pos": 3, "can_edit": False, + "type": "text", "cell": "string", + "not_null": False, "has_default_val": False, + "is_array": False, "display_name": "the_name"}, +] + + +class TestSaveUpdatedRowSkipsNonEditableColumn(TestSaveChangedData): + """Regression test for issue #10103. + + The counterpart of :class:`TestSaveAddedRowSkipsNonEditableColumn` for + the UPDATE path. An edit staged against an expression or alias column + must be dropped before rendering the UPDATE, because the alias is not a + real column of the underlying table and PostgreSQL would reject the + statement with ``column "the_name" does not exist``. If nothing + editable is left once the alias has been dropped, no UPDATE should be + rendered at all. + """ + + scenarios = [ + ('Update carrying an alias alongside a real column', dict( + save_payload={ + "updated": { + "1": { + "err": False, + "data": { + "first_name": "Jane", + # The alias must be ignored rather than + # written to the base table. + "the_name": "Jane Doe" + }, + "primary_keys": {"id": 1} + } + }, + "added": {}, + "staged_rows": {}, + "deleted": {}, + "updated_index": {}, + "added_index": {}, + "columns": ALIAS_RESULT_COLUMNS + }, + save_status=True, + check_sql='SELECT id, first_name, last_name ' + 'FROM %s WHERE id = 1', + check_result=[[1, "Jane", "Doe"]] + )), + ('Update carrying nothing but an alias', dict( + save_payload={ + "updated": { + "1": { + "err": False, + "data": { + "the_name": "Jane Doe" + }, + "primary_keys": {"id": 1} + } + }, + "added": {}, + "staged_rows": {}, + "deleted": {}, + "updated_index": {}, + "added_index": {}, + "columns": ALIAS_RESULT_COLUMNS + }, + save_status=True, + # Nothing editable remains, so no UPDATE is rendered and the + # row is left exactly as it was. + check_sql='SELECT id, first_name, last_name ' + 'FROM %s WHERE id = 1', + check_result=[[1, "John", "Doe"]] + )), + ] + + def _create_test_table(self): + self.test_table_name = "test_for_save_data_alias_upd_" + \ + str(secrets.choice(range(1000, 9999))) + create_sql = """ + DROP TABLE IF EXISTS "{0}"; + + CREATE TABLE "{0}"( + id INT PRIMARY KEY, + first_name TEXT, + last_name TEXT + ); + + INSERT INTO "{0}" VALUES (1, 'John', 'Doe'); + """.format(self.test_table_name) + self.select_sql = ( + "SELECT id, first_name, last_name, " + "first_name || ' ' || last_name AS the_name " + "FROM {0};" + ).format(self.test_table_name) + utils.create_table_with_query(self.server, self.db_name, create_sql) diff --git a/web/regression/javascript/sqleditor/text_editor_readonly.spec.js b/web/regression/javascript/sqleditor/text_editor_readonly.spec.js new file mode 100644 index 00000000000..55f138445cf --- /dev/null +++ b/web/regression/javascript/sqleditor/text_editor_readonly.spec.js @@ -0,0 +1,80 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { render, screen, fireEvent } from '@testing-library/react'; + +// Stub the heavy JSON editor so importing Editors does not pull in CodeMirror. +jest.mock('../../../pgadmin/static/js/components/JsonEditor', () => ({ + __esModule: true, + default: () =>
, +})); + +// Mock the QueryToolDataGrid index so importing Editors does not pull in the +// whole data grid; Editors only needs RowInfoContext from it. +jest.mock('../../../pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid', () => { + const ReactActual = require('react'); + return { RowInfoContext: ReactActual.createContext() }; +}); + +import Theme from 'sources/Theme'; +import { TextEditor } from '../../../pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Editors'; +import { RowInfoContext } from '../../../pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid'; +import { PgAdminProvider } from '../../../pgadmin/static/js/PgAdminProvider'; + +describe('QueryToolDataGrid TextEditor read-only columns', () => { + const KEY = 'the_name'; + let onRowChange, onClose; + + const renderEditor = (canEdit) => { + const pgAdmin = { Browser: { notifier: { error: jest.fn() } } }; + return render( +