From b7b02c9a5b032cf0676a3d00896836576dff07a5 Mon Sep 17 00:00:00 2001 From: Rohit Bhati Date: Tue, 17 Mar 2026 18:46:34 +0530 Subject: [PATCH 1/2] Fixed View/Edit Data not handling generated columns properly. #9672 --- .../templates/columns/sql/12_plus/nodes.sql | 40 +++++++++++ .../js/components/sections/ResultSet.jsx | 6 +- .../sqleditor/sql/default/update.sql | 5 +- .../tools/sqleditor/utils/get_column_types.py | 10 +++ .../sqleditor/utils/save_changed_data.py | 67 +++++++++++++++++-- 5 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/12_plus/nodes.sql diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/12_plus/nodes.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/12_plus/nodes.sql new file mode 100644 index 00000000000..5b6d100bc63 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/12_plus/nodes.sql @@ -0,0 +1,40 @@ +SELECT DISTINCT att.attname as name, att.attnum as OID, pg_catalog.format_type(ty.oid,NULL) AS datatype, +pg_catalog.format_type(ty.oid,att.atttypmod) AS displaytypname, +att.attnotnull as not_null, +CASE WHEN att.atthasdef OR att.attidentity != '' OR ty.typdefault IS NOT NULL THEN True +ELSE False END as has_default_val, des.description, seq.seqtypid, +{# Detect generated columns to exclude from INSERT/UPDATE in View/Edit Data #} +CASE WHEN att.attgenerated = 's' THEN true ELSE false END as is_generated +FROM pg_catalog.pg_attribute att + JOIN pg_catalog.pg_type ty ON ty.oid=atttypid + JOIN pg_catalog.pg_namespace tn ON tn.oid=ty.typnamespace + JOIN pg_catalog.pg_class cl ON cl.oid=att.attrelid + JOIN pg_catalog.pg_namespace na ON na.oid=cl.relnamespace + LEFT OUTER JOIN pg_catalog.pg_type et ON et.oid=ty.typelem + LEFT OUTER JOIN pg_catalog.pg_attrdef def ON adrelid=att.attrelid AND adnum=att.attnum + LEFT OUTER JOIN (pg_catalog.pg_depend JOIN pg_catalog.pg_class cs ON classid='pg_class'::regclass AND objid=cs.oid AND cs.relkind='S') ON refobjid=att.attrelid AND refobjsubid=att.attnum + LEFT OUTER JOIN pg_catalog.pg_namespace ns ON ns.oid=cs.relnamespace + LEFT OUTER JOIN pg_catalog.pg_index pi ON pi.indrelid=att.attrelid AND indisprimary + LEFT OUTER JOIN pg_catalog.pg_description des ON (des.objoid=att.attrelid AND des.objsubid=att.attnum AND des.classoid='pg_class'::regclass) + LEFT OUTER JOIN pg_catalog.pg_sequence seq ON cs.oid=seq.seqrelid +WHERE + +{% if tid %} + att.attrelid = {{ tid|qtLiteral(conn) }}::oid +{% endif %} +{% if table_name and table_nspname %} + cl.relname= {{table_name |qtLiteral(conn)}} and na.nspname={{table_nspname|qtLiteral(conn)}} +{% endif %} +{% if clid %} + AND att.attnum = {{ clid|qtLiteral(conn) }} +{% endif %} +{### To show system objects ###} +{% if not show_sys_objects and not has_oids %} + AND att.attnum > 0 +{% endif %} +{### To show oids in view data ###} +{% if has_oids %} + AND (att.attnum > 0 OR (att.attname = 'oid' AND att.attnum < 0)) +{% endif %} + AND att.attisdropped IS FALSE +ORDER BY att.attnum 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 fc6700bee43..8cf8cd6d670 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx @@ -1309,8 +1309,10 @@ export function ResultSet() { } pageDataOutOfSync.current = true; - if(_.size(dataChangeStore.added)) { - // Update the rows in a grid after addition + // Update the rows in a grid after addition/update. + // row_added contains refetched row data with recalculated + // generated column values (for both INSERT and UPDATE). + if(_.size(dataChangeStore.added) || _.size(dataChangeStore.updated)) { respData.data.query_results.forEach((qr)=>{ if(!_.isNull(qr.row_added)) { let rowClientPK = Object.keys(qr.row_added)[0]; diff --git a/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/update.sql b/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/update.sql index 08ac1c5f428..ee8ebb0a9ec 100644 --- a/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/update.sql +++ b/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/update.sql @@ -4,4 +4,7 @@ UPDATE {{ conn|qtIdent(nsp_name, object_name) | replace("%", "%%") }} SET {% if not loop.first %}, {% endif %}{{ conn|qtIdent(col) | replace("%", "%%") }} = %({{ pgadmin_alias[col] }})s{% if type_cast_required[col] %}::{{ data_type[col] }}{% endif %}{% endfor %} WHERE {% for pk in primary_keys %} -{% if not loop.first %} AND {% endif %}{{ conn|qtIdent(pk) | replace("%", "%%") }} = {{ primary_keys[pk]|qtLiteral(conn) }}{% endfor %}; +{% if not loop.first %} AND {% endif %}{{ conn|qtIdent(pk) | replace("%", "%%") }} = {{ primary_keys[pk]|qtLiteral(conn) }}{% endfor %} +{# Return primary keys to refetch row with recalculated generated column values #} +{% if pk_names and not has_oids %} RETURNING {{pk_names | replace("%", "%%")}}{% endif %} +{% if has_oids %} RETURNING oid{% endif %}; diff --git a/web/pgadmin/tools/sqleditor/utils/get_column_types.py b/web/pgadmin/tools/sqleditor/utils/get_column_types.py index 81c26281ba2..9399be7bd76 100644 --- a/web/pgadmin/tools/sqleditor/utils/get_column_types.py +++ b/web/pgadmin/tools/sqleditor/utils/get_column_types.py @@ -69,6 +69,11 @@ def get_columns_types(is_query_tool, columns_info, table_oid, conn, has_oids, col_type['seqtypid'] = col['seqtypid'] = \ rset['rows'][key]['seqtypid'] + # Check if column is a generated column (PostgreSQL 12+). + # Generated columns must be excluded from INSERT/UPDATE. + col_type['is_generated'] = col['is_generated'] = \ + rset['rows'][key].get('is_generated', False) + else: for row in rset['rows']: if row['oid'] == col['table_column']: @@ -81,6 +86,10 @@ def get_columns_types(is_query_tool, columns_info, table_oid, conn, has_oids, col_type['seqtypid'] = col['seqtypid'] = \ row['seqtypid'] + + # Check if column is a generated column (PG 12+). + col_type['is_generated'] = col['is_generated'] = \ + row.get('is_generated', False) break else: @@ -88,5 +97,6 @@ def get_columns_types(is_query_tool, columns_info, table_oid, conn, has_oids, col_type['has_default_val'] = \ col['has_default_val'] = None col_type['seqtypid'] = col['seqtypid'] = None + col_type['is_generated'] = col['is_generated'] = False return column_types diff --git a/web/pgadmin/tools/sqleditor/utils/save_changed_data.py b/web/pgadmin/tools/sqleditor/utils/save_changed_data.py index 78776697ff3..06db819a824 100644 --- a/web/pgadmin/tools/sqleditor/utils/save_changed_data.py +++ b/web/pgadmin/tools/sqleditor/utils/save_changed_data.py @@ -130,6 +130,12 @@ def save_changed_data(changed_data, columns_info, conn, command_obj, columns_info[k].get('is_editable', True) } + # Remove generated columns (GENERATED ALWAYS AS) as they + # cannot be inserted - PostgreSQL auto-computes their values. + for col_name, col_info in columns_info.items(): + if col_info.get('is_generated', False): + data.pop(col_name, None) + # Update columns value with columns having # not_null=False and has no default value column_data.update(data) @@ -175,14 +181,38 @@ def save_changed_data(changed_data, columns_info, conn, command_obj, # For updated rows elif of_type == 'updated': list_of_sql[of_type] = [] + + # Check if table has generated columns. If yes, we need to + # refetch row after UPDATE to get recalculated values for UI. + has_generated_cols = any( + col_info.get('is_generated', False) + for col_info in columns_info.values() + ) + + # Get primary keys info (same as INSERT) - needed for RETURNING + # clause and SELECT query to refetch updated row. + pk_names, primary_keys = command_obj.get_primary_keys() + for each_row in changed_data[of_type]: data = changed_data[of_type][each_row]['data'] + row_primary_keys = changed_data[of_type][each_row][ + 'primary_keys'] + + # Remove generated columns (GENERATED ALWAYS AS) as they + # cannot be updated - PostgreSQL auto-computes their values. + for col_name, col_info in columns_info.items(): + if col_info.get('is_generated', False): + data.pop(col_name, None) + pk_escaped = { pk: pk_val.replace('%', '%%') if hasattr( pk_val, 'replace') else pk_val - for pk, pk_val in - changed_data[of_type][each_row]['primary_keys'].items() + for pk, pk_val in row_primary_keys.items() } + + # Pass pk_names and has_oids for RETURNING clause in + # UPDATE statement. + # This will help to fetch the updated row's. sql = render_template( "/".join([command_obj.sql_path, 'update.sql']), data_to_be_saved=data, @@ -192,12 +222,35 @@ def save_changed_data(changed_data, columns_info, conn, command_obj, nsp_name=command_obj.nsp_name, data_type=column_type, type_cast_required=type_cast_required, + pk_names=pk_names if has_generated_cols else None, + has_oids=command_obj.has_oids(), conn=conn ) - list_of_sql[of_type].append({'sql': sql, - 'data': data, - 'row_id': - data.get(client_primary_key)}) + + # For tables with generated columns, add select_sql to + # refetch updated row. + if has_generated_cols: + select_sql = render_template( + "/".join([command_obj.sql_path, 'select.sql']), + object_name=command_obj.object_name, + nsp_name=command_obj.nsp_name, + pgadmin_alias=pgadmin_alias, + primary_keys=primary_keys, + has_oids=command_obj.has_oids() + ) + list_of_sql[of_type].append({ + 'sql': sql, + 'data': data, + 'client_row': each_row, + 'select_sql': select_sql, + 'row_id': data.get(client_primary_key) + }) + else: + list_of_sql[of_type].append({ + 'sql': sql, + 'data': data, + 'row_id': data.get(client_primary_key) + }) # For deleted rows elif of_type == 'deleted': @@ -299,7 +352,7 @@ def failure_handle(res, row_id): if not status: return failure_handle(res, item.get('row_id', 0)) - # Select added row from the table + # Select added/updated row from the table if 'select_sql' in item: params = { pgadmin_alias[k] if k in pgadmin_alias else k: v From dfedcbcee1694718b3b9bffeb791e304dc257410 Mon Sep 17 00:00:00 2001 From: Rohit Bhati Date: Thu, 11 Jun 2026 17:50:00 +0530 Subject: [PATCH 2/2] Addressed review comments for generated columns fix. --- .../sqleditor/sql/default/update.sql | 5 +- .../sqleditor/utils/save_changed_data.py | 64 +++++++++---------- 2 files changed, 31 insertions(+), 38 deletions(-) diff --git a/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/update.sql b/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/update.sql index ee8ebb0a9ec..a9d6fc557d0 100644 --- a/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/update.sql +++ b/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/update.sql @@ -5,6 +5,5 @@ UPDATE {{ conn|qtIdent(nsp_name, object_name) | replace("%", "%%") }} SET WHERE {% for pk in primary_keys %} {% if not loop.first %} AND {% endif %}{{ conn|qtIdent(pk) | replace("%", "%%") }} = {{ primary_keys[pk]|qtLiteral(conn) }}{% endfor %} -{# Return primary keys to refetch row with recalculated generated column values #} -{% if pk_names and not has_oids %} RETURNING {{pk_names | replace("%", "%%")}}{% endif %} -{% if has_oids %} RETURNING oid{% endif %}; +{# Return complete row to get recalculated generated column values #} +{% if return_all_columns %} RETURNING *{% endif %}; diff --git a/web/pgadmin/tools/sqleditor/utils/save_changed_data.py b/web/pgadmin/tools/sqleditor/utils/save_changed_data.py index 06db819a824..8569fbfbb47 100644 --- a/web/pgadmin/tools/sqleditor/utils/save_changed_data.py +++ b/web/pgadmin/tools/sqleditor/utils/save_changed_data.py @@ -124,18 +124,15 @@ def save_changed_data(changed_data, columns_info, conn, command_obj, # 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. + # Also remove generated columns (GENERATED ALWAYS AS) as they + # cannot be inserted - PostgreSQL auto-computes their values. data = { k: v for k, v in data.items() if k in columns_info and - columns_info[k].get('is_editable', True) + columns_info[k].get('is_editable', True) and + not columns_info[k].get('is_generated', False) } - # Remove generated columns (GENERATED ALWAYS AS) as they - # cannot be inserted - PostgreSQL auto-computes their values. - for col_name, col_info in columns_info.items(): - if col_info.get('is_generated', False): - data.pop(col_name, None) - # Update columns value with columns having # not_null=False and has no default value column_data.update(data) @@ -182,17 +179,13 @@ def save_changed_data(changed_data, columns_info, conn, command_obj, elif of_type == 'updated': list_of_sql[of_type] = [] - # Check if table has generated columns. If yes, we need to - # refetch row after UPDATE to get recalculated values for UI. + # Check if table has generated columns. If yes, we use + # RETURNING * to get recalculated values directly from UPDATE. has_generated_cols = any( col_info.get('is_generated', False) for col_info in columns_info.values() ) - # Get primary keys info (same as INSERT) - needed for RETURNING - # clause and SELECT query to refetch updated row. - pk_names, primary_keys = command_obj.get_primary_keys() - for each_row in changed_data[of_type]: data = changed_data[of_type][each_row]['data'] row_primary_keys = changed_data[of_type][each_row][ @@ -200,9 +193,9 @@ def save_changed_data(changed_data, columns_info, conn, command_obj, # Remove generated columns (GENERATED ALWAYS AS) as they # cannot be updated - PostgreSQL auto-computes their values. - for col_name, col_info in columns_info.items(): - if col_info.get('is_generated', False): - data.pop(col_name, None) + data = {k: v for k, v in data.items() + if not columns_info.get(k, {}).get('is_generated', + False)} pk_escaped = { pk: pk_val.replace('%', '%%') if hasattr( @@ -210,9 +203,8 @@ def save_changed_data(changed_data, columns_info, conn, command_obj, for pk, pk_val in row_primary_keys.items() } - # Pass pk_names and has_oids for RETURNING clause in - # UPDATE statement. - # This will help to fetch the updated row's. + # Use RETURNING * when table has generated columns to get + # the complete updated row with recalculated values. sql = render_template( "/".join([command_obj.sql_path, 'update.sql']), data_to_be_saved=data, @@ -222,27 +214,18 @@ def save_changed_data(changed_data, columns_info, conn, command_obj, nsp_name=command_obj.nsp_name, data_type=column_type, type_cast_required=type_cast_required, - pk_names=pk_names if has_generated_cols else None, - has_oids=command_obj.has_oids(), + return_all_columns=has_generated_cols, conn=conn ) - # For tables with generated columns, add select_sql to - # refetch updated row. + # For tables with generated columns, use 'returning_all' + # flag to indicate RETURNING * is used (no separate SELECT). if has_generated_cols: - select_sql = render_template( - "/".join([command_obj.sql_path, 'select.sql']), - object_name=command_obj.object_name, - nsp_name=command_obj.nsp_name, - pgadmin_alias=pgadmin_alias, - primary_keys=primary_keys, - has_oids=command_obj.has_oids() - ) list_of_sql[of_type].append({ 'sql': sql, 'data': data, 'client_row': each_row, - 'select_sql': select_sql, + 'returning_all': True, 'row_id': data.get(client_primary_key) }) else: @@ -336,10 +319,16 @@ def failure_handle(res, row_id): } row_added = None + # Check if we need result data (INSERT with select_sql or + # UPDATE with RETURNING *) + needs_result = ( + ('select_sql' in item and item['select_sql']) or + item.get('returning_all', False) + ) try: - # Fetch oids/primary keys - if 'select_sql' in item and item['select_sql']: + # Fetch oids/primary keys or complete row + if needs_result: status, res = conn.execute_dict( item['sql'], item['data']) else: @@ -352,7 +341,7 @@ def failure_handle(res, row_id): if not status: return failure_handle(res, item.get('row_id', 0)) - # Select added/updated row from the table + # For INSERT: use RETURNING to get PKs, then SELECT full row if 'select_sql' in item: params = { pgadmin_alias[k] if k in pgadmin_alias else k: v @@ -367,6 +356,11 @@ def failure_handle(res, row_id): if 'rows' in sel_res and len(sel_res['rows']) > 0: row_added = { item['client_row']: sel_res['rows'][0]} + # For UPDATE with RETURNING *: use result directly + elif item.get('returning_all', False): + if 'rows' in res and len(res['rows']) > 0: + row_added = { + item['client_row']: res['rows'][0]} rows_affected = conn.rows_affected() mogrified_sql = conn.mogrify(item['sql'], item['data'])