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 c79c40df027..fc6700bee43 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx @@ -1257,8 +1257,17 @@ export function ResultSet() { try { /* Convert the added info to actual rows */ let added = {...dataChangeStore.added}; + /* Strip read-only columns (expression/alias columns in Query Tool) + * from the new-row payload so the backend doesn't try to INSERT + * them into the base table. Issue #9939. */ + let nonEditableKeys = new Set( + columns.filter((c)=>c.can_edit === false).map((c)=>c.key) + ); Object.keys(added).forEach((clientPK)=>{ - added[clientPK].data = _.find(rows, (r)=>rowKeyGetter(r)==clientPK); + let rowData = _.find(rows, (r)=>rowKeyGetter(r)==clientPK); + added[clientPK].data = _.omitBy( + rowData, (_v, k)=>nonEditableKeys.has(k) + ); }); let {data: respData} = await rsu.current.saveData({ updated: dataChangeStore.updated, diff --git a/web/pgadmin/tools/sqleditor/utils/get_column_types.py b/web/pgadmin/tools/sqleditor/utils/get_column_types.py index 251ff422560..81c26281ba2 100644 --- a/web/pgadmin/tools/sqleditor/utils/get_column_types.py +++ b/web/pgadmin/tools/sqleditor/utils/get_column_types.py @@ -50,6 +50,11 @@ def get_columns_types(is_query_tool, columns_info, table_oid, conn, has_oids, col_type['type_name'] = None col_type['internal_size'] = col['internal_size'] col_type['display_size'] = col['display_size'] + # Propagate per-column editability so the save path can refuse to + # write expression/alias columns that don't exist in the base table. + # View Data Tool columns are always real table columns: default True. + col_type['is_editable'] = col.get('is_editable', True) \ + if is_query_tool else True column_types[col['name']] = col_type if rset['rows']: diff --git a/web/pgadmin/tools/sqleditor/utils/save_changed_data.py b/web/pgadmin/tools/sqleditor/utils/save_changed_data.py index e2c521bc257..40148cff72b 100644 --- a/web/pgadmin/tools/sqleditor/utils/save_changed_data.py +++ b/web/pgadmin/tools/sqleditor/utils/save_changed_data.py @@ -118,6 +118,16 @@ def save_changed_data(changed_data, columns_info, conn, command_obj, if command_obj.has_oids(): data.pop('oid', None) + # Drop any column the client included that isn't a real + # editable column of the underlying table (e.g. + # `first_name || ' ' || last_name as the_name`). Without + # this guard the rendered INSERT references a non-existent + # column and Postgres rejects the row. Issue #9939. + data = { + k: v for k, v in data.items() + if columns_info.get(k, {}).get('is_editable', True) + } + # Update columns value with columns having # not_null=False and has no default value column_data.update(data) 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 91bff5830b3..b43d0773a9a 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 @@ -960,3 +960,83 @@ def _close_query_tool(self): url = '/sqleditor/close/{0}'.format(self.trans_id) response = self.tester.delete(url) self.assertEqual(response.status_code, 200) + + +class TestSaveAddedRowSkipsNonEditableColumn(TestSaveChangedData): + """Regression test for issue #9939. + + When a Query Tool result includes an expression or alias column + (e.g. ``first_name || ' ' || last_name AS the_name``), the alias is + not a real column of the underlying table. The new-row save flow + must drop those keys before rendering INSERT; otherwise PostgreSQL + rejects the row with ``column "the_name" does not exist``. + """ + + scenarios = [ + ('Insert via SELECT that aliases a concatenation', dict( + save_payload={ + "updated": {}, + "added": { + "2": { + "err": False, + "data": { + "id": "1", + "__temp_PK": "2", + "first_name": "John", + "last_name": "Doe", + # The client populates every column when + # building a new row. ``the_name`` is the + # aliased expression — sending it must not + # break the INSERT. + "the_name": None + } + } + }, + "staged_rows": {}, + "deleted": {}, + "updated_index": {}, + "added_index": {"2": "2"}, + "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"}, + ] + }, + save_status=True, + 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_" + \ + 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 + ); + """.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)