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..a9d6fc557d0 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,6 @@ 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 complete row to get recalculated generated column values #} +{% if return_all_columns %} RETURNING *{% 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..8569fbfbb47 100644 --- a/web/pgadmin/tools/sqleditor/utils/save_changed_data.py +++ b/web/pgadmin/tools/sqleditor/utils/save_changed_data.py @@ -124,10 +124,13 @@ 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) } # Update columns value with columns having @@ -175,14 +178,33 @@ 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 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() + ) + 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. + 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( 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() } + + # 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, @@ -192,12 +214,26 @@ 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, + return_all_columns=has_generated_cols, conn=conn ) - list_of_sql[of_type].append({'sql': sql, - 'data': data, - 'row_id': - data.get(client_primary_key)}) + + # For tables with generated columns, use 'returning_all' + # flag to indicate RETURNING * is used (no separate SELECT). + if has_generated_cols: + list_of_sql[of_type].append({ + 'sql': sql, + 'data': data, + 'client_row': each_row, + 'returning_all': True, + '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': @@ -283,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: @@ -299,7 +341,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 + # 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 @@ -314,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'])