fix(sqleditor): skip read-only columns when saving inserted rows (#10015)

This commit is contained in:
Ashesh Vashi authored and Dave Page committed 2026-06-09 14:12:11 +01:00
1 parent 39684cdf48
commit f9b9bc9d40
5 files changed
+108 -1

No files matched your search

+1
View File
@@ -35,3 +35,4 @@ Bug fixes
| `Issue #9896 <https://github.com/pgadmin-org/pgadmin4/issues/9896>`_ - Fix invalid DDL reconstruction for SERIAL columns in Schema Diff and the generated SQL/CREATE Script so the output round-trips on a clean target.
| `Issue #9935 <https://github.com/pgadmin-org/pgadmin4/issues/9935>`_ - Fix "Illegal instruction" crash on startup of the Linux DEB and RPM packages on older x86_64 CPUs by pinning the psycopg C extension build to the x86-64 baseline.
| `Issue #9936 <https://github.com/pgadmin-org/pgadmin4/issues/9936>`_ - Fix the AI panel silently falling back to the default provider when a custom LLM API URL or key file was set, and allow self-hosted LLM endpoints on any loopback port.
| `Issue #9939 <https://github.com/pgadmin-org/pgadmin4/issues/9939>`_ - Fix saving a newly-added row in the Query Tool failing when the result set includes expression or alias columns that are not real columns of the underlying table.
@@ -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,
@@ -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']:
@@ -118,6 +118,18 @@ 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`). Keys not
# known to the result set are dropped too. 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 k in columns_info and
columns_info[k].get('is_editable', True)
}
# Update columns value with columns having
# not_null=False and has no default value
column_data.update(data)
@@ -960,3 +960,83 @@ class TestSaveChangedData(BaseTestGenerator):
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)