mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2026-09-03 20:52:57 -05:00
Add support for editing of resultsets in the Query Tool, if the data can be identified as updatable. Fixes #1760
When a query is run in the Query Tool, check if the source of the columns can be identified as being from a single table, and that we have all columns that make up the primary key. If so, consider the resultset to be editable and allow the user to edit data and add/remove rows in the grid. Changes to data are saved using SAVEPOINTs as part of any transaction that's in progress, and rolled back if there are integrity violations, without otherwise affecting the ongoing transaction. Implemented by Yosry Muhammad as a Google Summer of Code project.
This commit is contained in:
committed by
Dave Page
parent
beb06a4c76
commit
710d520631
@@ -19,6 +19,9 @@ from flask import render_template
|
||||
from flask_babelex import gettext
|
||||
from pgadmin.utils.ajax import forbidden
|
||||
from pgadmin.utils.driver import get_driver
|
||||
from pgadmin.tools.sqleditor.utils.is_query_resultset_updatable \
|
||||
import is_query_resultset_updatable
|
||||
from pgadmin.tools.sqleditor.utils.save_changed_data import save_changed_data
|
||||
|
||||
from config import PG_DEFAULT_DRIVER
|
||||
|
||||
@@ -668,269 +671,11 @@ class TableCommand(GridCommand):
|
||||
else:
|
||||
conn = default_conn
|
||||
|
||||
status = False
|
||||
res = None
|
||||
query_res = dict()
|
||||
count = 0
|
||||
list_of_rowid = []
|
||||
operations = ('added', 'updated', 'deleted')
|
||||
list_of_sql = {}
|
||||
_rowid = None
|
||||
|
||||
pgadmin_alias = {
|
||||
col_name: col_info['pgadmin_alias']
|
||||
for col_name, col_info in columns_info
|
||||
.items()
|
||||
}
|
||||
if conn.connected():
|
||||
|
||||
# Start the transaction
|
||||
conn.execute_void('BEGIN;')
|
||||
|
||||
# Iterate total number of records to be updated/inserted
|
||||
for of_type in changed_data:
|
||||
# No need to go further if its not add/update/delete operation
|
||||
if of_type not in operations:
|
||||
continue
|
||||
# if no data to be save then continue
|
||||
if len(changed_data[of_type]) < 1:
|
||||
continue
|
||||
|
||||
column_type = {}
|
||||
column_data = {}
|
||||
for each_col in columns_info:
|
||||
if (
|
||||
columns_info[each_col]['not_null'] and
|
||||
not columns_info[each_col]['has_default_val']
|
||||
):
|
||||
column_data[each_col] = None
|
||||
column_type[each_col] =\
|
||||
columns_info[each_col]['type_name']
|
||||
else:
|
||||
column_type[each_col] = \
|
||||
columns_info[each_col]['type_name']
|
||||
|
||||
# For newly added rows
|
||||
if of_type == 'added':
|
||||
# Python dict does not honour the inserted item order
|
||||
# So to insert data in the order, we need to make ordered
|
||||
# list of added index We don't need this mechanism in
|
||||
# updated/deleted rows as it does not matter in
|
||||
# those operations
|
||||
added_index = OrderedDict(
|
||||
sorted(
|
||||
changed_data['added_index'].items(),
|
||||
key=lambda x: int(x[0])
|
||||
)
|
||||
)
|
||||
list_of_sql[of_type] = []
|
||||
|
||||
# When new rows are added, only changed columns data is
|
||||
# sent from client side. But if column is not_null and has
|
||||
# no_default_value, set column to blank, instead
|
||||
# of not null which is set by default.
|
||||
column_data = {}
|
||||
pk_names, primary_keys = self.get_primary_keys()
|
||||
has_oids = 'oid' in column_type
|
||||
|
||||
for each_row in added_index:
|
||||
# Get the row index to match with the added rows
|
||||
# dict key
|
||||
tmp_row_index = added_index[each_row]
|
||||
data = changed_data[of_type][tmp_row_index]['data']
|
||||
# Remove our unique tracking key
|
||||
data.pop(client_primary_key, None)
|
||||
data.pop('is_row_copied', None)
|
||||
list_of_rowid.append(data.get(client_primary_key))
|
||||
|
||||
# Update columns value with columns having
|
||||
# not_null=False and has no default value
|
||||
column_data.update(data)
|
||||
|
||||
sql = render_template(
|
||||
"/".join([self.sql_path, 'insert.sql']),
|
||||
data_to_be_saved=column_data,
|
||||
pgadmin_alias=pgadmin_alias,
|
||||
primary_keys=None,
|
||||
object_name=self.object_name,
|
||||
nsp_name=self.nsp_name,
|
||||
data_type=column_type,
|
||||
pk_names=pk_names,
|
||||
has_oids=has_oids
|
||||
)
|
||||
|
||||
select_sql = render_template(
|
||||
"/".join([self.sql_path, 'select.sql']),
|
||||
object_name=self.object_name,
|
||||
nsp_name=self.nsp_name,
|
||||
primary_keys=primary_keys,
|
||||
has_oids=has_oids
|
||||
)
|
||||
|
||||
list_of_sql[of_type].append({
|
||||
'sql': sql, 'data': data,
|
||||
'client_row': tmp_row_index,
|
||||
'select_sql': select_sql
|
||||
})
|
||||
# Reset column data
|
||||
column_data = {}
|
||||
|
||||
# For updated rows
|
||||
elif of_type == 'updated':
|
||||
list_of_sql[of_type] = []
|
||||
for each_row in changed_data[of_type]:
|
||||
data = changed_data[of_type][each_row]['data']
|
||||
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()
|
||||
}
|
||||
sql = render_template(
|
||||
"/".join([self.sql_path, 'update.sql']),
|
||||
data_to_be_saved=data,
|
||||
pgadmin_alias=pgadmin_alias,
|
||||
primary_keys=pk_escaped,
|
||||
object_name=self.object_name,
|
||||
nsp_name=self.nsp_name,
|
||||
data_type=column_type
|
||||
)
|
||||
list_of_sql[of_type].append({'sql': sql, 'data': data})
|
||||
list_of_rowid.append(data.get(client_primary_key))
|
||||
|
||||
# For deleted rows
|
||||
elif of_type == 'deleted':
|
||||
list_of_sql[of_type] = []
|
||||
is_first = True
|
||||
rows_to_delete = []
|
||||
keys = None
|
||||
no_of_keys = None
|
||||
for each_row in changed_data[of_type]:
|
||||
rows_to_delete.append(changed_data[of_type][each_row])
|
||||
# Fetch the keys for SQL generation
|
||||
if is_first:
|
||||
# We need to covert dict_keys to normal list in
|
||||
# Python3
|
||||
# In Python2, it's already a list & We will also
|
||||
# fetch column names using index
|
||||
keys = list(
|
||||
changed_data[of_type][each_row].keys()
|
||||
)
|
||||
no_of_keys = len(keys)
|
||||
is_first = False
|
||||
# Map index with column name for each row
|
||||
for row in rows_to_delete:
|
||||
for k, v in row.items():
|
||||
# Set primary key with label & delete index based
|
||||
# mapped key
|
||||
try:
|
||||
row[changed_data['columns']
|
||||
[int(k)]['name']] = v
|
||||
except ValueError:
|
||||
continue
|
||||
del row[k]
|
||||
|
||||
sql = render_template(
|
||||
"/".join([self.sql_path, 'delete.sql']),
|
||||
data=rows_to_delete,
|
||||
primary_key_labels=keys,
|
||||
no_of_keys=no_of_keys,
|
||||
object_name=self.object_name,
|
||||
nsp_name=self.nsp_name
|
||||
)
|
||||
list_of_sql[of_type].append({'sql': sql, 'data': {}})
|
||||
|
||||
for opr, sqls in list_of_sql.items():
|
||||
for item in sqls:
|
||||
if item['sql']:
|
||||
item['data'] = {
|
||||
pgadmin_alias[k] if k in pgadmin_alias else k: v
|
||||
for k, v in item['data'].items()
|
||||
}
|
||||
|
||||
row_added = None
|
||||
|
||||
def failure_handle():
|
||||
conn.execute_void('ROLLBACK;')
|
||||
# If we roll backed every thing then update the
|
||||
# message for each sql query.
|
||||
for val in query_res:
|
||||
if query_res[val]['status']:
|
||||
query_res[val]['result'] = \
|
||||
'Transaction ROLLBACK'
|
||||
|
||||
# If list is empty set rowid to 1
|
||||
try:
|
||||
if list_of_rowid:
|
||||
_rowid = list_of_rowid[count]
|
||||
else:
|
||||
_rowid = 1
|
||||
except Exception:
|
||||
_rowid = 0
|
||||
|
||||
return status, res, query_res, _rowid
|
||||
|
||||
try:
|
||||
# Fetch oids/primary keys
|
||||
if 'select_sql' in item and item['select_sql']:
|
||||
status, res = conn.execute_dict(
|
||||
item['sql'], item['data'])
|
||||
else:
|
||||
status, res = conn.execute_void(
|
||||
item['sql'], item['data'])
|
||||
except Exception as _:
|
||||
failure_handle()
|
||||
raise
|
||||
|
||||
if not status:
|
||||
return failure_handle()
|
||||
|
||||
# Select added row from the table
|
||||
if 'select_sql' in item:
|
||||
status, sel_res = conn.execute_dict(
|
||||
item['select_sql'], res['rows'][0])
|
||||
|
||||
if not status:
|
||||
conn.execute_void('ROLLBACK;')
|
||||
# If we roll backed every thing then update
|
||||
# the message for each sql query.
|
||||
for val in query_res:
|
||||
if query_res[val]['status']:
|
||||
query_res[val]['result'] = \
|
||||
'Transaction ROLLBACK'
|
||||
|
||||
# If list is empty set rowid to 1
|
||||
try:
|
||||
if list_of_rowid:
|
||||
_rowid = list_of_rowid[count]
|
||||
else:
|
||||
_rowid = 1
|
||||
except Exception:
|
||||
_rowid = 0
|
||||
|
||||
return status, sel_res, query_res, _rowid
|
||||
|
||||
if 'rows' in sel_res and len(sel_res['rows']) > 0:
|
||||
row_added = {
|
||||
item['client_row']: sel_res['rows'][0]}
|
||||
|
||||
rows_affected = conn.rows_affected()
|
||||
|
||||
# store the result of each query in dictionary
|
||||
query_res[count] = {
|
||||
'status': status,
|
||||
'result': None if row_added else res,
|
||||
'sql': sql, 'rows_affected': rows_affected,
|
||||
'row_added': row_added
|
||||
}
|
||||
|
||||
count += 1
|
||||
|
||||
# Commit the transaction if there is no error found
|
||||
conn.execute_void('COMMIT;')
|
||||
|
||||
return status, res, query_res, _rowid
|
||||
return save_changed_data(changed_data=changed_data,
|
||||
columns_info=columns_info,
|
||||
command_obj=self,
|
||||
client_primary_key=client_primary_key,
|
||||
conn=conn)
|
||||
|
||||
|
||||
class ViewCommand(GridCommand):
|
||||
@@ -1114,18 +859,84 @@ class QueryToolCommand(BaseCommand, FetchedRowTracker):
|
||||
self.auto_rollback = False
|
||||
self.auto_commit = True
|
||||
|
||||
# Attributes needed to be able to edit updatable resultsets
|
||||
self.is_updatable_resultset = False
|
||||
self.primary_keys = None
|
||||
self.pk_names = None
|
||||
|
||||
def get_sql(self, default_conn=None):
|
||||
return None
|
||||
|
||||
def get_all_columns_with_order(self, default_conn=None):
|
||||
return None
|
||||
|
||||
def get_primary_keys(self):
|
||||
return self.pk_names, self.primary_keys
|
||||
|
||||
def can_edit(self):
|
||||
return False
|
||||
return self.is_updatable_resultset
|
||||
|
||||
def can_filter(self):
|
||||
return False
|
||||
|
||||
def check_updatable_results_pkeys(self):
|
||||
"""
|
||||
This function is used to check whether the last successful query
|
||||
produced updatable results and sets the necessary flags and
|
||||
attributes accordingly.
|
||||
Should be called after polling for the results is successful
|
||||
(results are ready)
|
||||
"""
|
||||
# Fetch the connection object
|
||||
driver = get_driver(PG_DEFAULT_DRIVER)
|
||||
manager = driver.connection_manager(self.sid)
|
||||
conn = manager.connection(did=self.did, conn_id=self.conn_id)
|
||||
|
||||
# Get the path to the sql templates
|
||||
sql_path = 'sqleditor/sql/#{0}#'.format(manager.version)
|
||||
|
||||
self.is_updatable_resultset, self.primary_keys, pk_names, table_oid = \
|
||||
is_query_resultset_updatable(conn, sql_path)
|
||||
|
||||
# Create pk_names attribute in the required format
|
||||
if pk_names is not None:
|
||||
self.pk_names = ''
|
||||
|
||||
for pk_name in pk_names:
|
||||
self.pk_names += driver.qtIdent(conn, pk_name) + ','
|
||||
|
||||
if self.pk_names != '':
|
||||
# Remove last character from the string
|
||||
self.pk_names = self.pk_names[:-1]
|
||||
|
||||
# Add attributes required to be able to update table data
|
||||
if self.is_updatable_resultset:
|
||||
self.__set_updatable_results_attrs(sql_path=sql_path,
|
||||
table_oid=table_oid,
|
||||
conn=conn)
|
||||
|
||||
def save(self,
|
||||
changed_data,
|
||||
columns_info,
|
||||
client_primary_key='__temp_PK',
|
||||
default_conn=None):
|
||||
if not self.is_updatable_resultset:
|
||||
return False, gettext('Resultset is not updatable.'), None, None
|
||||
else:
|
||||
driver = get_driver(PG_DEFAULT_DRIVER)
|
||||
if default_conn is None:
|
||||
manager = driver.connection_manager(self.sid)
|
||||
conn = manager.connection(did=self.did, conn_id=self.conn_id)
|
||||
else:
|
||||
conn = default_conn
|
||||
|
||||
return save_changed_data(changed_data=changed_data,
|
||||
columns_info=columns_info,
|
||||
conn=conn,
|
||||
command_obj=self,
|
||||
client_primary_key=client_primary_key,
|
||||
auto_commit=self.auto_commit)
|
||||
|
||||
def set_connection_id(self, conn_id):
|
||||
self.conn_id = conn_id
|
||||
|
||||
@@ -1134,3 +945,28 @@ class QueryToolCommand(BaseCommand, FetchedRowTracker):
|
||||
|
||||
def set_auto_commit(self, auto_commit):
|
||||
self.auto_commit = auto_commit
|
||||
|
||||
def __set_updatable_results_attrs(self, sql_path,
|
||||
table_oid, conn):
|
||||
# Set template path for sql scripts and the table object id
|
||||
self.sql_path = sql_path
|
||||
self.obj_id = table_oid
|
||||
|
||||
if conn.connected():
|
||||
# Fetch the Namespace Name and object Name
|
||||
query = render_template(
|
||||
"/".join([self.sql_path, 'objectname.sql']),
|
||||
obj_id=self.obj_id
|
||||
)
|
||||
|
||||
status, result = conn.execute_dict(query)
|
||||
if not status:
|
||||
raise Exception(result)
|
||||
|
||||
self.nsp_name = result['rows'][0]['nspname']
|
||||
self.object_name = result['rows'][0]['relname']
|
||||
else:
|
||||
raise Exception(gettext(
|
||||
'Not connected to server or connection with the server '
|
||||
'has been closed.')
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user