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:
Yosry Muhammad
2019-07-17 11:45:20 +01:00
committed by Dave Page
parent beb06a4c76
commit 710d520631
38 changed files with 1868 additions and 605 deletions
+100 -264
View File
@@ -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.')
)