From f8f7d5ac6fd1625ec8d27300b9a1c5319fc22446 Mon Sep 17 00:00:00 2001 From: Yosry Muhammad Date: Mon, 26 Aug 2019 14:17:40 +0530 Subject: [PATCH] Ensure editable and read-only columns in Query Tool should be identified by icons and tooltips in the column header. Fixes #4667 --- docs/en_US/editgrid.rst | 4 +- .../images/query_tool_editable_columns.png | Bin 0 -> 31676 bytes docs/en_US/query_tool.rst | 22 ++- docs/en_US/release_notes_4_13.rst | 3 +- .../feature_tests/query_tool_journey_test.py | 72 +++++++-- web/pgadmin/static/js/sqleditor_utils.js | 30 +++- web/pgadmin/tools/sqleditor/__init__.py | 43 ++---- web/pgadmin/tools/sqleditor/command.py | 20 ++- .../tools/sqleditor/static/css/sqleditor.css | 4 +- .../tools/sqleditor/static/js/sqleditor.js | 38 +++-- .../sqleditor/sql/default/get_columns.sql | 2 +- .../tools/sqleditor/utils/get_column_types.py | 57 +++++++ .../utils/is_query_resultset_updatable.py | 145 +++++++++++++----- .../test_is_query_resultset_updatable.py | 91 ++++++++--- web/regression/feature_utils/locators.py | 8 + 15 files changed, 410 insertions(+), 129 deletions(-) create mode 100644 docs/en_US/images/query_tool_editable_columns.png create mode 100644 web/pgadmin/tools/sqleditor/utils/get_column_types.py diff --git a/docs/en_US/editgrid.rst b/docs/en_US/editgrid.rst index b697449d6..c9f6d4723 100644 --- a/docs/en_US/editgrid.rst +++ b/docs/en_US/editgrid.rst @@ -35,6 +35,8 @@ disabled in either mode. Please see :ref:`The Query Tool Toolbar ` for a description of the available controls. +.. _data-grid: + The Data Grid ************* @@ -42,8 +44,6 @@ The top row of the data grid displays the name of each column, the data type, and if applicable, the number of characters allowed. A column that is part of the primary key will additionally be marked with [PK]. -.. _modifying-data-grid: - To modify the displayed data: * To change a numeric value within the grid, double-click the value to select diff --git a/docs/en_US/images/query_tool_editable_columns.png b/docs/en_US/images/query_tool_editable_columns.png new file mode 100644 index 0000000000000000000000000000000000000000..922c0280f9de576cb2201953d29f4a1ab9f87414 GIT binary patch literal 31676 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 literal 0 HcmV?d00001 diff --git a/docs/en_US/query_tool.rst b/docs/en_US/query_tool.rst index 48f2c88f9..b0ec056dd 100644 --- a/docs/en_US/query_tool.rst +++ b/docs/en_US/query_tool.rst @@ -130,15 +130,27 @@ You can: A result set is updatable if: -* All the columns belong to the same table. -* All the primary keys or OIDs of the table are explicitly selected. -* No columns are duplicated. +* All columns are either selected directly from a single table, or + are not table columns at all (e.g. concatenation of 2 columns). + Only columns that are selected directly from the table are + editable, other columns are read-only. +* All the primary key columns or OIDs of the table are selected in the + result set. + +Any columns that are renamed or selected more than once are also read-only. + +Editable and read-only columns are identified using pencil and lock icons +(respectively) in the column headers. + +.. image:: images/query_tool_editable_columns.png + :alt: Query tool editable and read-only columns + :align: center The psycopg2 driver version should be equal to or above 2.8 for updatable query result sets to work. -An updatable result set can be modified just like in -:ref:`View/Edit Data ` mode. +An updatable result set is identical to the :ref:`Data Grid ` in +View/Edit Data mode, and can be modified in the same way. If Auto-commit is off, the data changes are made as part of the ongoing transaction, if no transaction is ongoing a new one is initiated. The data diff --git a/docs/en_US/release_notes_4_13.rst b/docs/en_US/release_notes_4_13.rst index 4ac661577..88607ef75 100644 --- a/docs/en_US/release_notes_4_13.rst +++ b/docs/en_US/release_notes_4_13.rst @@ -9,8 +9,9 @@ This release contains a number of bug fixes and new features since the release o New features ************ -| `Issue #4453 `_ - Don't wait for the database connection before rendering the Query Tool UI, for improved UX. +| `Issue #4553 `_ - Don't wait for the database connection before rendering the Query Tool UI, for improved UX. | `Issue #4651 `_ - Allow configuration options to be set from the environment in the container distribution. +| `Issue #4667 `_ - Ensure editable and read-only columns in Query Tool should be identified by icons and tooltips in the column header. Housekeeping ************ diff --git a/web/pgadmin/feature_tests/query_tool_journey_test.py b/web/pgadmin/feature_tests/query_tool_journey_test.py index 8d8bfe34f..fbd0d28e3 100644 --- a/web/pgadmin/feature_tests/query_tool_journey_test.py +++ b/web/pgadmin/feature_tests/query_tool_journey_test.py @@ -74,10 +74,9 @@ class QueryToolJourneyTest(BaseFeatureTest): self._test_history_tab() print(" OK.", file=sys.stderr) - # Insert data into test editable table self._insert_data_into_test_editable_table() - print("History query sources and generated queries toggle...", + print("History query source icons and generated queries toggle...", file=sys.stderr, end="") self._test_query_sources_and_generated_queries() print(" OK.", file=sys.stderr) @@ -86,6 +85,10 @@ class QueryToolJourneyTest(BaseFeatureTest): self._test_updatable_resultset() print(" OK.", file=sys.stderr) + print("Is editable column header icons...", file=sys.stderr, end="") + self._test_is_editable_columns_icons() + print(" OK.", file=sys.stderr) + def _test_copies_rows(self): pyperclip.copy("old clipboard contents") self.page.driver.switch_to.default_content() @@ -237,16 +240,53 @@ class QueryToolJourneyTest(BaseFeatureTest): return self.page.click_tab("Query Editor") - # Select all data (contains the primary key -> should be editable) + # Select all data + # (contains the primary key -> all columns should be editable) self.page.clear_query_tool() query = "SELECT pk_column, normal_column FROM %s" \ % self.test_editable_table_name - self._check_query_results_editable(query, True) + self._check_query_results_editable(query, [True, True]) # Select data without primary keys -> should not be editable self.page.clear_query_tool() query = "SELECT normal_column FROM %s" % self.test_editable_table_name - self._check_query_results_editable(query, False) + self._check_query_results_editable(query, [False], + discard_changes_modal=True) + + # Select all data in addition to duplicate, renamed, and out-of-table + # columns + self.page.clear_query_tool() + query = """ + SELECT pk_column, normal_column, normal_column, + normal_column as pk_column, + (normal_column::text || normal_column::text)::int + FROM %s + """ % self.test_editable_table_name + self._check_query_results_editable(query, + [True, True, False, False, False]) + + def _test_is_editable_columns_icons(self): + if self.driver_version < 2.8: + return + self.page.click_tab("Query Editor") + + self.page.clear_query_tool() + query = "SELECT pk_column FROM %s" % self.test_editable_table_name + self.page.execute_query(query) + # Discard changes made by previous test to data grid + self.page.click_modal('Yes') + icon_exists = self.page.check_if_element_exist_by_xpath( + QueryToolLocators.editable_column_icon_xpath + ) + self.assertTrue(icon_exists) + + self.page.clear_query_tool() + query = "SELECT normal_column FROM %s" % self.test_editable_table_name + self.page.execute_query(query) + icon_exists = self.page.check_if_element_exist_by_xpath( + QueryToolLocators.read_only_column_icon_xpath + ) + self.assertTrue(icon_exists) def _execute_sources_test_queries(self): self.page.clear_query_tool() @@ -367,18 +407,24 @@ class QueryToolJourneyTest(BaseFeatureTest): def _assert_clickable(self, element): self.page.click_element(element) - def _check_query_results_editable(self, query, should_be_editable): + def _check_query_results_editable(self, query, cols_should_be_editable, + discard_changes_modal=False): self.page.execute_query(query) - # Check if the first cell in the first row is editable - is_editable = self._check_cell_editable(1) - self.assertEqual(is_editable, should_be_editable) + if discard_changes_modal: + self.page.click_modal('Yes') + enumerated_should_be_editable = enumerate(cols_should_be_editable, 1) + + import time + time.sleep(0.5) + for column_index, should_be_editable in enumerated_should_be_editable: + is_editable = self._check_cell_editable(column_index) + self.assertEqual(is_editable, should_be_editable) def _check_cell_editable(self, cell_index): """Checks if a cell in the first row of the resultset is editable""" - - self.page.check_if_element_exist_by_xpath( - "//div[contains(@style, 'top:0px')]//div[contains(@class, " - "'l{0} r{1}')]".format(cell_index, cell_index)) + # self.page.check_if_element_exist_by_xpath( + # "//div[contains(@style, 'top:0px')]//div[contains(@class, " + # "'l{0} r{1}')]".format(cell_index, cell_index)) cell_el = self.page.find_by_xpath( "//div[contains(@style, 'top:0px')]//div[contains(@class, " "'l{0} r{1}')]".format(cell_index, cell_index)) diff --git a/web/pgadmin/static/js/sqleditor_utils.js b/web/pgadmin/static/js/sqleditor_utils.js index bd4356c02..fde7283b0 100644 --- a/web/pgadmin/static/js/sqleditor_utils.js +++ b/web/pgadmin/static/js/sqleditor_utils.js @@ -8,8 +8,8 @@ ////////////////////////////////////////////////////////////////////////// // This file contains common utilities functions used in sqleditor modules -define(['jquery', 'sources/gettext', 'sources/url_for'], - function ($, gettext, url_for) { +define(['jquery', 'underscore', 'sources/gettext', 'sources/url_for'], + function ($, _, gettext, url_for) { var sqlEditorUtils = { /* Reference link http://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript * Modified as per requirement. @@ -198,6 +198,32 @@ define(['jquery', 'sources/gettext', 'sources/url_for'], } return '1em'; }, + + addEditableIcon: function(columnDefinition, is_editable) { + /* This uses Slickgrid.HeaderButtons plugin to add an icon to the + columns headers. Instead of a button, an icon is created */ + let content = null; + if(is_editable) { + content = ''; + } + else { + content = ''; + } + let button = { + cssClass: 'editable-column-header-icon', + content: content, + }; + // Check for existing buttons + if(!_.isUndefined(columnDefinition.header) && + !_.isUndefined(columnDefinition.header.buttons)) { + columnDefinition.header.buttons.push(button); + } + else { + columnDefinition.header = { + buttons: [button], + }; + } + }, }; return sqlEditorUtils; }); diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index b862f62cf..da612da1f 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -430,40 +430,17 @@ def poll(trans_id): oids = {'oid': 'oid'} if columns_info is not None: - # If it is a QueryToolCommand that has obj_id attribute - # then it should also be editable - if hasattr(trans_obj, 'obj_id') and \ - (not isinstance(trans_obj, QueryToolCommand) or - trans_obj.can_edit()): - # Get the template path for the column - template_path = 'columns/sql/#{0}#'.format( - conn.manager.version - ) + # Only QueryToolCommand or TableCommand can be editable + if hasattr(trans_obj, 'obj_id') and trans_obj.can_edit(): + columns = trans_obj.get_columns_types(conn) - SQL = render_template( - "/".join([template_path, 'nodes.sql']), - tid=trans_obj.obj_id, - has_oids=True - ) - # rows with attribute not_null - colst, rset = conn.execute_2darray(SQL) - if not colst: - return internal_server_error(errormsg=rset) - - for key, col in enumerate(columns_info): - col_type = dict() - col_type['type_code'] = col['type_code'] - col_type['type_name'] = None - col_type['internal_size'] = col['internal_size'] - columns[col['name']] = col_type - - if rset: - col_type['not_null'] = col['not_null'] = \ - rset['rows'][key]['not_null'] - - col_type['has_default_val'] = \ - col['has_default_val'] = \ - rset['rows'][key]['has_default_val'] + else: + for col in columns_info: + col_type = dict() + col_type['type_code'] = col['type_code'] + col_type['type_name'] = None + col_type['internal_size'] = col['internal_size'] + columns[col['name']] = col_type if columns: st, types = fetch_pg_types(columns, trans_obj) diff --git a/web/pgadmin/tools/sqleditor/command.py b/web/pgadmin/tools/sqleditor/command.py index 01815f8cf..05268f6d4 100644 --- a/web/pgadmin/tools/sqleditor/command.py +++ b/web/pgadmin/tools/sqleditor/command.py @@ -22,6 +22,7 @@ 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 pgadmin.tools.sqleditor.utils.get_column_types import get_columns_types from config import PG_DEFAULT_DRIVER @@ -677,6 +678,16 @@ class TableCommand(GridCommand): client_primary_key=client_primary_key, conn=conn) + def get_columns_types(self, conn): + columns_info = conn.get_column_info() + has_oids = self.has_oids() + table_oid = self.obj_id + return get_columns_types(conn=conn, + columns_info=columns_info, + has_oids=has_oids, + table_oid=table_oid, + is_query_tool=False) + class ViewCommand(GridCommand): """ @@ -864,6 +875,7 @@ class QueryToolCommand(BaseCommand, FetchedRowTracker): self.primary_keys = None self.pk_names = None self.table_has_oids = False + self.columns_types = None def get_sql(self, default_conn=None): return None @@ -874,6 +886,9 @@ class QueryToolCommand(BaseCommand, FetchedRowTracker): def get_primary_keys(self): return self.pk_names, self.primary_keys + def get_columns_types(self, conn=None): + return self.columns_types + def has_oids(self): return self.table_has_oids @@ -906,8 +921,9 @@ class QueryToolCommand(BaseCommand, FetchedRowTracker): # Get the path to the sql templates sql_path = 'sqleditor/sql/#{0}#'.format(manager.version) - self.is_updatable_resultset, self.table_has_oids, self.primary_keys, \ - pk_names, table_oid = is_query_resultset_updatable(conn, sql_path) + self.is_updatable_resultset, self.table_has_oids,\ + self.primary_keys, pk_names, table_oid,\ + self.columns_types = is_query_resultset_updatable(conn, sql_path) # Create pk_names attribute in the required format if pk_names is not None: diff --git a/web/pgadmin/tools/sqleditor/static/css/sqleditor.css b/web/pgadmin/tools/sqleditor/static/css/sqleditor.css index e078ea753..8831d5023 100644 --- a/web/pgadmin/tools/sqleditor/static/css/sqleditor.css +++ b/web/pgadmin/tools/sqleditor/static/css/sqleditor.css @@ -385,13 +385,13 @@ input.editor-checkbox:focus { /* For geometry column button */ -.div-view-geometry-column { +.div-view-geometry-column, .editable-column-header-icon { float: right; height: 100%; display: flex; display: -webkit-flex; align-items: center; - padding-right: 4px; + padding-right: 6px; } /* For leaflet popup */ diff --git a/web/pgadmin/tools/sqleditor/static/js/sqleditor.js b/web/pgadmin/tools/sqleditor/static/js/sqleditor.js index 4433019c3..7e368ffba 100644 --- a/web/pgadmin/tools/sqleditor/static/js/sqleditor.js +++ b/web/pgadmin/tools/sqleditor/static/js/sqleditor.js @@ -778,6 +778,7 @@ define('tools.querytool', [ not_null: c.not_null, has_default_val: c.has_default_val, is_array: c.is_array, + can_edit: c.can_edit, }; // Get the columns width based on longer string among data type or @@ -795,17 +796,17 @@ define('tools.querytool', [ if (c.cell == 'oid' && c.name == 'oid') { options['editor'] = null; } else if (c.cell == 'Json') { - options['editor'] = is_editable ? Slick.Editors.JsonText : + options['editor'] = c.can_edit ? Slick.Editors.JsonText : Slick.Editors.ReadOnlyJsonText; options['formatter'] = Slick.Formatters.JsonString; } else if (c.cell == 'number' || c.cell == 'oid' || $.inArray(c.type, ['xid', 'real']) !== -1 ) { - options['editor'] = is_editable ? Slick.Editors.CustomNumber : + options['editor'] = c.can_edit ? Slick.Editors.CustomNumber : Slick.Editors.ReadOnlyText; options['formatter'] = Slick.Formatters.Numbers; } else if (c.cell == 'boolean') { - options['editor'] = is_editable ? Slick.Editors.Checkbox : + options['editor'] = c.can_edit ? Slick.Editors.Checkbox : Slick.Editors.ReadOnlyCheckbox; options['formatter'] = Slick.Formatters.Checkmark; } else if (c.cell == 'binary') { @@ -814,23 +815,41 @@ define('tools.querytool', [ } else if (c.cell == 'geometry' || c.cell == 'geography') { // increase width to add 'view' button options['width'] += 28; + options['can_edit'] = false; } else { - options['editor'] = is_editable ? Slick.Editors.pgText : + options['editor'] = c.can_edit ? Slick.Editors.pgText : Slick.Editors.ReadOnlypgText; options['formatter'] = Slick.Formatters.Text; } + if(!_.isUndefined(c.can_edit)) { + // Increase width for editable/read-only icon + options['width'] += 12; + + let tooltip = ''; + if(c.can_edit) + tooltip = gettext('Editable column'); + else + tooltip = gettext('Read-only column'); + + options['toolTip'] = tooltip; + } + grid_columns.push(options); }); var gridSelector = new GridSelector(); grid_columns = self.grid_columns = gridSelector.getColumnDefinitions(grid_columns); - // add 'view' button in geometry and geography type column header _.each(grid_columns, function (c) { + // Add 'view' button in geometry and geography type column headers if (c.column_type_internal == 'geometry' || c.column_type_internal == 'geography') { GeometryViewer.add_header_button(c); } + // Add editable/read-only icon to columns + if (!_.isUndefined(c.can_edit)) { + SqlEditorUtils.addEditableIcon(c, c.can_edit); + } }); if (rows_affected) { @@ -2634,10 +2653,11 @@ define('tools.querytool', [ // Create columns required by slick grid to render _.each(colinfo, function(c) { - var is_primary_key = false; + var is_primary_key = false, + is_editable = self.can_edit && (!self.is_query_tool || c.is_editable); - // Check whether table have primary key - if (_.size(primary_keys) > 0) { + // Check whether this column is a primary key + if (is_editable && _.size(primary_keys) > 0) { _.each(primary_keys, function(value, key) { if (key === c.name) is_primary_key = true; @@ -2738,7 +2758,7 @@ define('tools.querytool', [ 'pos': c.pos, 'label': column_label, 'cell': col_cell, - 'can_edit': (c.name == 'oid') ? false : self.can_edit, + 'can_edit': (c.name == 'oid') ? false : is_editable, 'type': type, 'not_null': c.not_null, 'has_default_val': c.has_default_val, diff --git a/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/get_columns.sql b/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/get_columns.sql index 610747dfb..851b98523 100644 --- a/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/get_columns.sql +++ b/web/pgadmin/tools/sqleditor/templates/sqleditor/sql/default/get_columns.sql @@ -1,6 +1,6 @@ {# ============= Fetch the columns ============= #} {% if obj_id %} -SELECT at.attname, ty.typname +SELECT at.attname, ty.typname, at.attnum FROM pg_attribute at LEFT JOIN pg_type ty ON (ty.oid = at.atttypid) WHERE attrelid={{obj_id}}::oid diff --git a/web/pgadmin/tools/sqleditor/utils/get_column_types.py b/web/pgadmin/tools/sqleditor/utils/get_column_types.py new file mode 100644 index 000000000..99bcb9fa8 --- /dev/null +++ b/web/pgadmin/tools/sqleditor/utils/get_column_types.py @@ -0,0 +1,57 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2019, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## +""" + Get the column types for QueryToolCommand or TableCommand when + the result-set is editable. +""" + +from flask import render_template + + +def get_columns_types(is_query_tool, columns_info, table_oid, conn, has_oids): + nodes_sqlpath = 'columns/sql/#{0}#'.format(conn.manager.version) + query = render_template( + "/".join([nodes_sqlpath, 'nodes.sql']), + tid=table_oid, + has_oids=has_oids + ) + + colst, rset = conn.execute_2darray(query) + if not colst: + raise Exception(rset) + + column_types = dict() + for key, col in enumerate(columns_info): + col_type = dict() + col_type['type_code'] = col['type_code'] + col_type['type_name'] = None + col_type['internal_size'] = col['internal_size'] + column_types[col['name']] = col_type + + if not is_query_tool: + col_type['not_null'] = col['not_null'] = \ + rset['rows'][key]['not_null'] + + col_type['has_default_val'] = \ + col['has_default_val'] = \ + rset['rows'][key]['has_default_val'] + + else: + for row in rset['rows']: + if row['oid'] == col['table_column']: + col_type['not_null'] = col['not_null'] = row['not_null'] + + col_type['has_default_val'] = \ + col['has_default_val'] = row['has_default_val'] + + else: + col_type['not_null'] = col['not_null'] = None + col_type['has_default_val'] = col['has_default_val'] = None + + return column_types diff --git a/web/pgadmin/tools/sqleditor/utils/is_query_resultset_updatable.py b/web/pgadmin/tools/sqleditor/utils/is_query_resultset_updatable.py index 9c2bd09a6..0582ca3ab 100644 --- a/web/pgadmin/tools/sqleditor/utils/is_query_resultset_updatable.py +++ b/web/pgadmin/tools/sqleditor/utils/is_query_resultset_updatable.py @@ -8,11 +8,18 @@ ########################################################################## """ - Check if the result-set of a query is updatable, A resultset is - updatable (as of this version) if: - - All columns belong to the same table. - - All the primary key columns of the table are present in the resultset - - No duplicate columns + Check if the result-set of a query is editable, A result-set is + editable if: + - All columns are either selected directly from a single table, or + are not table columns at all (e.g. concatenation of 2 columns). + Only columns that are selected directly from a the table are + editable, other columns are read-only. + - All the primary key columns or oids (if applicable) of the table are + present in the result-set. + + Note: + - Duplicate columns (selected twice) or renamed columns are also + read-only. """ from flask import render_template from flask_babelex import gettext @@ -20,16 +27,18 @@ try: from collections import OrderedDict except ImportError: from ordereddict import OrderedDict +from pgadmin.tools.sqleditor.utils.get_column_types import get_columns_types def is_query_resultset_updatable(conn, sql_path): """ This function is used to check whether the last successful query - produced updatable results. + produced editable results. Args: conn: Connection object. - sql_path: the path to the sql templates. + sql_path: the path to the sql templates + primary_keys.sql & columns.sql. """ columns_info = conn.get_column_info() @@ -37,26 +46,43 @@ def is_query_resultset_updatable(conn, sql_path): return return_not_updatable() table_oid = _check_single_table(columns_info) - if not table_oid: - return return_not_updatable() - - if not _check_duplicate_columns(columns_info): + if table_oid is None: return return_not_updatable() if conn.connected(): - primary_keys, pk_names = _check_primary_keys(conn=conn, - columns_info=columns_info, - table_oid=table_oid, - sql_path=sql_path) + # Get all the table columns + table_columns = _get_table_columns(conn=conn, + table_oid=table_oid, + sql_path=sql_path) + + # Editable column: A column selected directly from a table, that is + # neither renamed nor is a duplicate of another selected column + _check_editable_columns(table_columns=table_columns, + results_columns=columns_info) + + primary_keys, pk_names = \ + _check_primary_keys(conn=conn, + columns_info=columns_info, + table_oid=table_oid, + sql_path=sql_path) has_oids = _check_oids(conn=conn, columns_info=columns_info, table_oid=table_oid, sql_path=sql_path) - if has_oids or primary_keys is not None: - return True, has_oids, primary_keys, pk_names, table_oid + is_resultset_updatable = has_oids or primary_keys is not None + + if is_resultset_updatable: + column_types = get_columns_types(columns_info=columns_info, + table_oid=table_oid, + conn=conn, + has_oids=has_oids, + is_query_tool=True) + return True, has_oids, primary_keys, \ + pk_names, table_oid, column_types else: + _set_all_columns_not_editable(columns_info=columns_info) return return_not_updatable() else: raise Exception( @@ -66,20 +92,34 @@ def is_query_resultset_updatable(conn, sql_path): def _check_single_table(columns_info): - table_oid = columns_info[0]['table_oid'] + table_oid = None for column in columns_info: - if column['table_oid'] != table_oid: + # Skip columns that are not directly from tables + if column['table_oid'] is None: + continue + # If we don't have a table_oid yet, store this one + if table_oid is None: + table_oid = column['table_oid'] + # If we already have one, check that all the columns have the same one + elif column['table_oid'] != table_oid: return None return table_oid -def _check_duplicate_columns(columns_info): - column_numbers = \ - [col['table_column'] for col in columns_info] - is_duplicate_columns = len(column_numbers) != len(set(column_numbers)) - if is_duplicate_columns: - return False - return True +def _check_editable_columns(table_columns, results_columns): + table_columns_numbers = set() + for results_column in results_columns: + table_column_number = results_column['table_column'] + if table_column_number is None: # Not a table column + results_column['is_editable'] = False + elif table_column_number in table_columns_numbers: # Duplicate + results_column['is_editable'] = False + elif results_column['display_name'] \ + != table_columns[table_column_number]: + results_column['is_editable'] = False + else: + results_column['is_editable'] = True + table_columns_numbers.add(table_column_number) def _check_oids(conn, sql_path, table_oid, columns_info): @@ -111,26 +151,34 @@ def _check_primary_keys(conn, columns_info, sql_path, table_oid): table_oid=table_oid, sql_path=sql_path) - if not _check_primary_keys_uniquely_exist(primary_keys_columns, - columns_info): + if not _check_all_primary_keys_exist(primary_keys_columns, + columns_info): primary_keys = None pk_names = None return primary_keys, pk_names -def _check_primary_keys_uniquely_exist(primary_keys_columns, columns_info): +def _check_all_primary_keys_exist(primary_keys_columns, columns_info): + """ + Check that all primary keys exist. + + If another column is selected with the same name as the primary key + before the primary key (e.g SELECT some_col as pk, pk from table) the + name of the actual primary key column gets changed to pk-2. + This is also reversed here. + """ for pk in primary_keys_columns: pk_exists = False for col in columns_info: - if col['table_column'] == pk['column_number']: + if col['is_editable'] and \ + col['table_column'] == pk['column_number']: pk_exists = True - # If the primary key column is renamed - if col['display_name'] != pk['name']: - return False - # If a normal column is renamed to a primary key column name - elif col['display_name'] == pk['name']: - return False - + # If the primary key is renamed, restore to its original name + if col['name'] != pk['name']: + col['name'], _ = col['name'].split('-') + # If another column is renamed to the primary key name, change it + elif col['name'] == pk['name']: + col['name'] += '-0' if not pk_exists: return False return True @@ -160,5 +208,26 @@ def _get_primary_keys(sql_path, table_oid, conn): return primary_keys, primary_keys_columns, pk_names +def _get_table_columns(sql_path, table_oid, conn): + query = render_template( + "/".join([sql_path, 'get_columns.sql']), + obj_id=table_oid + ) + status, result = conn.execute_dict(query) + if not status: + raise Exception(result) + + columns = {} + for row in result['rows']: + columns[row['attnum']] = row['attname'] + + return columns + + +def _set_all_columns_not_editable(columns_info): + for col in columns_info: + col['is_editable'] = False + + def return_not_updatable(): - return False, False, None, None, None + return False, False, None, None, None, None diff --git a/web/pgadmin/tools/sqleditor/utils/tests/test_is_query_resultset_updatable.py b/web/pgadmin/tools/sqleditor/utils/tests/test_is_query_resultset_updatable.py index 40ca4c8f7..8b451174a 100644 --- a/web/pgadmin/tools/sqleditor/utils/tests/test_is_query_resultset_updatable.py +++ b/web/pgadmin/tools/sqleditor/utils/tests/test_is_query_resultset_updatable.py @@ -25,67 +25,109 @@ class TestQueryUpdatableResultset(BaseTestGenerator): scenarios = [ ('When selecting all columns of the table', dict( sql='SELECT * FROM %s;', - primary_keys={ + expected_primary_keys={ 'pk_col1': 'int4', 'pk_col2': 'int4' }, expected_has_oids=False, - table_has_oids=False + table_has_oids=False, + expected_cols_is_editable=[True, True, True, True] )), ('When selecting all primary keys of the table', dict( sql='SELECT pk_col1, pk_col2 FROM %s;', - primary_keys={ + expected_primary_keys={ 'pk_col1': 'int4', 'pk_col2': 'int4' }, expected_has_oids=False, - table_has_oids=False + table_has_oids=False, + expected_cols_is_editable=[True, True] )), ('When selecting some of the primary keys of the table', dict( sql='SELECT pk_col2 FROM %s;', - primary_keys=None, + expected_primary_keys=None, expected_has_oids=False, - table_has_oids=False + table_has_oids=False, + expected_cols_is_editable=[False] )), ('When selecting none of the primary keys of the table', dict( sql='SELECT normal_col1 FROM %s;', - primary_keys=None, + expected_primary_keys=None, expected_has_oids=False, - table_has_oids=False + table_has_oids=False, + expected_cols_is_editable=[False] )), ('When renaming a primary key', dict( sql='SELECT pk_col1 as some_col, pk_col2 FROM "%s";', - primary_keys=None, + expected_primary_keys=None, expected_has_oids=False, - table_has_oids=False + table_has_oids=False, + expected_cols_is_editable=[False, False] )), - ('When renaming a column to a primary key name', dict( - sql='SELECT pk_col1, pk_col2, normal_col1 as pk_col1 FROM %s;', - primary_keys=None, + ('When renaming a normal column', dict( + sql='SELECT pk_col1, pk_col2, normal_col1 as some_col FROM "%s";', + expected_primary_keys={ + 'pk_col1': 'int4', + 'pk_col2': 'int4' + }, expected_has_oids=False, - table_has_oids=False + table_has_oids=False, + expected_cols_is_editable=[True, True, False] + )), + ('When renaming a normal column to a primary key name', dict( + sql='SELECT normal_col1 as pk_col1, pk_col1, pk_col2 FROM %s;', + expected_primary_keys={ + 'pk_col1': 'int4', + 'pk_col2': 'int4' + }, + expected_has_oids=False, + table_has_oids=False, + expected_cols_is_editable=[False, True, True] + )), + ('When selecting a normal column twice', dict( + sql='SELECT pk_col1, pk_col2, normal_col1, normal_col1 FROM %s;', + expected_primary_keys={ + 'pk_col1': 'int4', + 'pk_col2': 'int4' + }, + expected_has_oids=False, + table_has_oids=False, + expected_cols_is_editable=[True, True, True, False] + )), + ('When selecting a non-table column', dict( + sql='SELECT pk_col1, pk_col2, normal_col1 || normal_col2 FROM %s;', + expected_primary_keys={ + 'pk_col1': 'int4', + 'pk_col2': 'int4' + }, + expected_has_oids=False, + table_has_oids=False, + expected_cols_is_editable=[True, True, False] )), ('When selecting primary keys and oids (table with oids)', dict( sql='SELECT *, oid FROM %s;', - primary_keys={ + expected_primary_keys={ 'pk_col1': 'int4', 'pk_col2': 'int4' }, expected_has_oids=True, - table_has_oids=True + table_has_oids=True, + expected_cols_is_editable=[True, True, True, True, False] )), ('When selecting oids without primary keys (table with oids)', dict( sql='SELECT oid, normal_col1, normal_col2 FROM %s;', - primary_keys=None, + expected_primary_keys=None, expected_has_oids=True, - table_has_oids=True + table_has_oids=True, + expected_cols_is_editable=[False, True, True] )), ('When selecting none of the primary keys or oids (table with oids)', dict( sql='SELECT normal_col1, normal_col2 FROM %s;', - primary_keys=None, + expected_primary_keys=None, expected_has_oids=False, - table_has_oids=True + table_has_oids=True, + expected_cols_is_editable=[False, False] )) ] @@ -99,6 +141,7 @@ class TestQueryUpdatableResultset(BaseTestGenerator): response_data = self._execute_select_sql() self._check_primary_keys(response_data) self._check_oids(response_data) + self._check_editable_columns(response_data) def tearDown(self): # Disconnect the database @@ -116,12 +159,18 @@ class TestQueryUpdatableResultset(BaseTestGenerator): def _check_primary_keys(self, response_data): primary_keys = response_data['data']['primary_keys'] - self.assertEquals(primary_keys, self.primary_keys) + self.assertEquals(primary_keys, self.expected_primary_keys) def _check_oids(self, response_data): has_oids = response_data['data']['has_oids'] self.assertEquals(has_oids, self.expected_has_oids) + def _check_editable_columns(self, response_data): + columns_info = response_data['data']['colinfo'] + for col, expected_is_editable in \ + zip(columns_info, self.expected_cols_is_editable): + self.assertEquals(col['is_editable'], expected_is_editable) + def _initialize_database_connection(self): database_info = parent_node_dict["database"][-1] self.db_name = database_info["db_name"] diff --git a/web/regression/feature_utils/locators.py b/web/regression/feature_utils/locators.py index b2bcb6224..f37afddd5 100644 --- a/web/regression/feature_utils/locators.py +++ b/web/regression/feature_utils/locators.py @@ -219,3 +219,11 @@ class QueryToolLocators: "//div[label[normalize-space(" \ "text())='Show queries generated internally by pgAdmin?']]" \ "//div[contains(@class,'toggle btn')]" + + editable_column_icon_xpath = "//div[contains(@class," \ + " 'editable-column-header-icon')]" \ + "/i[contains(@class, 'fa-pencil')]" + + read_only_column_icon_xpath = "//div[contains(@class," \ + " 'editable-column-header-icon')]" \ + "/i[contains(@class, 'fa-lock')]"