diff --git a/docs/en_US/query_tool.rst b/docs/en_US/query_tool.rst index 0b0ba4890..9df372c34 100644 --- a/docs/en_US/query_tool.rst +++ b/docs/en_US/query_tool.rst @@ -179,6 +179,12 @@ The Query History tab displays information about recent commands: To erase the content of the *Query History* tab, select *Clear history* from the *Clear* drop-down menu. +Query History is maintained across sessions for each database on a per-user +basis when running in Query Tool mode. In View/Edit Data mode, history is not +retained. By default, the last 20 queries are stored for each database. This +can be adjusted in `config_local.py` by overriding the `MAX_QUERY_HIST_STORED` +value. See the :ref:`Deployment ` section for more information. + Use the *Connection status* feature to view the current connection and transaction status by clicking on the status icon in the Query Tool: diff --git a/docs/en_US/release_notes_4_4.rst b/docs/en_US/release_notes_4_4.rst index 4db3b1a59..0533f0d76 100644 --- a/docs/en_US/release_notes_4_4.rst +++ b/docs/en_US/release_notes_4_4.rst @@ -11,6 +11,7 @@ Features ******** | `Feature #2001 `_ - Add support for reverse proxied setups with Gunicorn, and document Gunicorn, uWSGI & NGINX configurations. +| `Feature #4017 `_ - Make the Query Tool history persistent across sessions. | `Feature #4018 `_ - Remove the large and unnecessary dependency on React and 87 other related libraries. Bug fixes diff --git a/web/config.py b/web/config.py index e08eda8da..07f5cd783 100644 --- a/web/config.py +++ b/web/config.py @@ -245,6 +245,9 @@ SQLITE_TIMEOUT = 500 # Set to False to disable password saving. ALLOW_SAVE_PASSWORD = True +# Maximum number of history queries stored per user/server/database +MAX_QUERY_HIST_STORED = 20 + ########################################################################## # Server-side session storage path # diff --git a/web/migrations/versions/ec1cac3399c9_.py b/web/migrations/versions/ec1cac3399c9_.py new file mode 100644 index 000000000..e6739dfcb --- /dev/null +++ b/web/migrations/versions/ec1cac3399c9_.py @@ -0,0 +1,42 @@ + +"""empty message + +Revision ID: ec1cac3399c9 +Revises: b5b87fdfcb30 +Create Date: 2019-03-07 16:05:28.874203 + +""" +from pgadmin.model import db + + +# revision identifiers, used by Alembic. +revision = 'ec1cac3399c9' +down_revision = 'b5b87fdfcb30' +branch_labels = None +depends_on = None + +srno = db.Column(db.Integer(), nullable=False, primary_key=True) +uid = db.Column( + db.Integer, db.ForeignKey('user.id'), nullable=False, primary_key=True +) +sid = db.Column(db.Integer(), nullable=False, primary_key=True) +did = db.Column(db.Integer(), nullable=False, primary_key=True) +query = db.Column(db.String(), nullable=False) + +def upgrade(): + db.engine.execute(""" + CREATE TABLE query_history ( + srno INTEGER NOT NULL, + uid INTEGER NOT NULL, + sid INTEGER NOT NULL, + dbname TEXT NOT NULL, + query_info TEXT NOT NULL, + last_updated_flag TEXT NOT NULL, + PRIMARY KEY (srno, uid, sid, dbname), + FOREIGN KEY(uid) REFERENCES user (id), + FOREIGN KEY(sid) REFERENCES server (id) + )""") + + +def downgrade(): + pass diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 41a332f1b..de0684033 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -20,6 +20,7 @@ from pgadmin.utils.ajax import make_json_response, bad_request, forbidden, \ make_response as ajax_response, internal_server_error, unauthorized, gone from pgadmin.utils.crypto import encrypt, decrypt, pqencryptpassword from pgadmin.utils.menu import MenuItem +from pgadmin.tools.sqleditor.utils.query_history import QueryHistory import config from config import PG_DEFAULT_DRIVER @@ -450,6 +451,9 @@ class ServerNode(PGChildNodeView): get_driver(PG_DEFAULT_DRIVER).delete_manager(s.id) db.session.delete(s) db.session.commit() + + QueryHistory.clear_history(current_user.id, sid) + except Exception as e: current_app.logger.exception(e) return make_json_response( diff --git a/web/pgadmin/browser/server_groups/servers/databases/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/__init__.py index d00db3b0e..731620592 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/databases/__init__.py @@ -15,6 +15,7 @@ from functools import wraps import simplejson as json from flask import render_template, current_app, request, jsonify from flask_babelex import gettext as _ +from flask_security import current_user import pgadmin.browser.server_groups.servers as servers from config import PG_DEFAULT_DRIVER @@ -28,6 +29,7 @@ from pgadmin.utils.ajax import gone from pgadmin.utils.ajax import make_json_response, \ make_response as ajax_response, internal_server_error, unauthorized from pgadmin.utils.driver import get_driver +from pgadmin.tools.sqleditor.utils.query_history import QueryHistory class DatabaseModule(CollectionNodeModule): @@ -675,6 +677,8 @@ class DatabaseView(PGChildNodeView): ) return internal_server_error(errormsg=msg) + QueryHistory.update_history_dbname( + current_user.id, sid, data['old_name'], data['name']) # Make connection for database again if self._db['datallowconn']: self.conn = self.manager.connection( diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index eda922b8b..a27929e2a 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy # ########################################################################## -SCHEMA_VERSION = 21 +SCHEMA_VERSION = 22 ########################################################################## # @@ -267,3 +267,18 @@ class Keys(db.Model): __tablename__ = 'keys' name = db.Column(db.String(), nullable=False, primary_key=True) value = db.Column(db.String(), nullable=False) + + +class QueryHistoryModel(db.Model): + """Define the history SQL table.""" + __tablename__ = 'query_history' + srno = db.Column(db.Integer(), nullable=False, primary_key=True) + uid = db.Column( + db.Integer, db.ForeignKey('user.id'), nullable=False, primary_key=True + ) + sid = db.Column( + db.Integer(), db.ForeignKey('server.id'), nullable=False, + primary_key=True) + dbname = db.Column(db.String(), nullable=False, primary_key=True) + query_info = db.Column(db.String(), nullable=False) + last_updated_flag = db.Column(db.String(), nullable=False) diff --git a/web/pgadmin/static/js/sqleditor/history/query_history.js b/web/pgadmin/static/js/sqleditor/history/query_history.js index b9668f9f4..d00915969 100644 --- a/web/pgadmin/static/js/sqleditor/history/query_history.js +++ b/web/pgadmin/static/js/sqleditor/history/query_history.js @@ -10,6 +10,7 @@ export default class QueryHistory { this.histCollection = histModel; this.editorPref = {}; + this.onCopyToEditorHandler = ()=>{}; this.histCollection.onAdd(this.onAddEntry.bind(this)); this.histCollection.onReset(this.onResetEntries.bind(this)); } @@ -35,8 +36,19 @@ export default class QueryHistory { this.render(); } + onCopyToEditorClick(onCopyToEditorHandler) { + this.onCopyToEditorHandler = onCopyToEditorHandler; + + if(this.queryHistDetails) { + this.queryHistDetails.onCopyToEditorClick(this.onCopyToEditorHandler); + } + } + setEditorPref(editorPref) { - this.editorPref = editorPref; + this.editorPref = { + ...this.editorPref, + ...editorPref, + }; if(this.queryHistDetails) { this.queryHistDetails.setEditorPref(this.editorPref); } @@ -63,6 +75,7 @@ export default class QueryHistory { this.queryHistDetails = new QueryHistoryDetails($histDetails); this.queryHistDetails.setEditorPref(this.editorPref); + this.queryHistDetails.onCopyToEditorClick(this.onCopyToEditorHandler); this.queryHistDetails.render(); this.queryHistEntries = new QueryHistoryEntries($histEntries); diff --git a/web/pgadmin/static/js/sqleditor/history/query_history_details.js b/web/pgadmin/static/js/sqleditor/history/query_history_details.js index 03c4a030f..e08387894 100644 --- a/web/pgadmin/static/js/sqleditor/history/query_history_details.js +++ b/web/pgadmin/static/js/sqleditor/history/query_history_details.js @@ -1,7 +1,8 @@ import CodeMirror from 'bundled_codemirror'; import clipboard from 'sources/selection/clipboard'; -import moment from 'moment'; +import gettext from 'sources/gettext'; import $ from 'jquery'; +import _ from 'underscore'; export default class QueryHistoryDetails { constructor(parentNode) { @@ -10,9 +11,11 @@ export default class QueryHistoryDetails { this.timeout = null; this.isRendered = false; this.sqlFontSize = null; + this.onCopyToEditorHandler = ()=>{}; this.editorPref = { 'sql_font_size': '1em', + 'copy_to_editor': true, }; } @@ -31,13 +34,21 @@ export default class QueryHistoryDetails { ...editorPref, }; - if(this.query_codemirror) { + if(this.query_codemirror && !_.isUndefined(editorPref.sql_font_size)) { $(this.query_codemirror.getWrapperElement()).css( 'font-size',this.editorPref.sql_font_size ); this.query_codemirror.refresh(); } + + if(this.$copyToEditor && !_.isUndefined(editorPref.copy_to_editor)) { + if(editorPref.copy_to_editor) { + this.$copyToEditor.removeClass('d-none'); + } else { + this.$copyToEditor.addClass('d-none'); + } + } } parseErrorMessage(message) { @@ -47,7 +58,7 @@ export default class QueryHistoryDetails { } formatDate(date) { - return moment(date).format('M-D-YY HH:mm:ss'); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString(); } copyAllHandler() { @@ -62,6 +73,10 @@ export default class QueryHistoryDetails { }, 1500); } + onCopyToEditorClick(onCopyToEditorHandler) { + this.onCopyToEditorHandler = onCopyToEditorHandler; + } + clearPreviousTimeout() { if (this.timeout) { clearTimeout(this.timeout); @@ -71,11 +86,11 @@ export default class QueryHistoryDetails { updateCopyButton(copied) { if (copied) { - this.$copyBtn.attr('class', 'was-copied'); + this.$copyBtn.addClass('was-copied').removeClass('copy-all'); this.$copyBtn.text('Copied!'); } else { - this.$copyBtn.attr('class', 'copy-all'); - this.$copyBtn.text('Copy All'); + this.$copyBtn.addClass('copy-all').removeClass('was-copied'); + this.$copyBtn.text('Copy'); } } @@ -137,7 +152,8 @@ export default class QueryHistoryDetails {
- + +
@@ -154,8 +170,13 @@ export default class QueryHistoryDetails { ); this.$errMsgBlock = this.parentNode.find('.error-message-block'); - this.$copyBtn = this.parentNode.find('#history-detail-query button'); + this.$copyBtn = this.parentNode.find('#history-detail-query .btn-copy'); this.$copyBtn.off('click').on('click', this.copyAllHandler.bind(this)); + this.$copyToEditor = this.parentNode.find('#history-detail-query .btn-copy-editor'); + this.$copyToEditor.off('click').on('click', () => { + this.onCopyToEditorHandler(this.entry.query); + }); + this.$copyToEditor.addClass(this.editorPref.copy_to_editor?'':'d-none'); this.$metaData = this.parentNode.find('.metadata-block'); this.query_codemirror = CodeMirror( this.parentNode.find('#history-detail-query div')[0], diff --git a/web/pgadmin/static/js/sqleditor/history/query_history_entries.js b/web/pgadmin/static/js/sqleditor/history/query_history_entries.js index eba2cb0b5..2529eaeaf 100644 --- a/web/pgadmin/static/js/sqleditor/history/query_history_entries.js +++ b/web/pgadmin/static/js/sqleditor/history/query_history_entries.js @@ -21,26 +21,20 @@ export class QueryHistoryEntryDateGroup { return prefix; } - getDateFormatted(momentToFormat) { - return momentToFormat.format(this.formatString); - } - - getDateMoment() { - return moment(this.date); + getDateFormatted(date) { + return date.toLocaleDateString(); } isDaysBefore(before) { return ( - this.getDateFormatted(this.getDateMoment()) === - this.getDateFormatted(moment().subtract(before, 'days')) + this.getDateFormatted(this.date) === + this.getDateFormatted(moment().subtract(before, 'days').toDate()) ); } render() { return $(`
-
${this.getDatePrefix()}${this.getDateFormatted( - this.getDateMoment() - )}
+
${this.getDatePrefix()}${this.getDateFormatted(this.date)}
    `); } @@ -66,9 +60,13 @@ export class QueryHistoryItem { return moment(date).format('HH:mm:ss'); } + dataKey() { + return this.formatDate(this.entry.start_time); + } + render() { this.$el = $( - `
  • + `
  • ${this.entry.query}
    @@ -98,15 +96,11 @@ export class QueryHistoryEntries { } focus() { - let self = this; - if (!this.$selectedItem) { this.setSelectedListItem(this.$el.find('.list-item').first()); } - - setTimeout(() => { - self.$selectedItem.trigger('click'); - }, 500); + this.$selectedItem.trigger('click'); + this.$el[0].focus(); } isArrowDown(event) { @@ -170,7 +164,8 @@ export class QueryHistoryEntries { } $listItem.addClass('selected'); this.$selectedItem = $listItem; - this.$selectedItem[0].scrollIntoView(false); + + this.$selectedItem[0].scrollIntoView({block: 'center'}); if (this.onSelectedChangeHandler) { this.onSelectedChangeHandler(this.$selectedItem.data('entrydata')); @@ -200,13 +195,20 @@ export class QueryHistoryEntries { entry.start_time, entryGroupKey ).render(); - if (groups[groupIdx]) { - $groupEl.insertBefore(groups[groupIdx]); - } else { - this.$el.prepend($groupEl); + + let i=0; + while(i groupsKeys[i]) { + $groupEl.insertBefore(groups[i]); + break; + } + i++; + } + if(i == groupsKeys.length) { + this.$el.append($groupEl); } } else if (groupIdx >= 0) { - /* if groups present, but this is a new one */ + /* if the group is present */ $groupEl = $(groups[groupIdx]); } diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index e2dd5a473..2067eb2e7 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -18,7 +18,7 @@ import simplejson as json from flask import Response, url_for, render_template, session, request, \ current_app from flask_babelex import gettext -from flask_security import login_required +from flask_security import login_required, current_user from config import PG_DEFAULT_DRIVER, ON_DEMAND_RECORD_COUNT from pgadmin.misc.file_manager import Filemanager @@ -42,6 +42,7 @@ from pgadmin.tools.sqleditor.utils.query_tool_preferences import \ from pgadmin.tools.sqleditor.utils.query_tool_fs_utils import \ read_file_generator from pgadmin.tools.sqleditor.utils.filter_dialog import FilterDialog +from pgadmin.tools.sqleditor.utils.query_history import QueryHistory MODULE_NAME = 'sqleditor' @@ -113,7 +114,10 @@ class SqlEditorModule(PgAdminModule): 'sqleditor.query_tool_download', 'sqleditor.connection_status', 'sqleditor.get_filter_data', - 'sqleditor.set_filter_data' + 'sqleditor.set_filter_data', + 'sqleditor.get_query_history', + 'sqleditor.add_query_history', + 'sqleditor.clear_query_history', ] def register_preferences(self): @@ -1504,3 +1508,64 @@ def set_filter_data(trans_id): request=request, trans_id=trans_id ) + + +@blueprint.route( + '/query_history/', + methods=["POST"], endpoint='add_query_history' +) +@login_required +def add_query_history(trans_id): + """ + This method adds to query history for user/server/database + + Args: + sid: server id + did: database id + """ + + status, error_msg, conn, trans_obj, session_ob = \ + check_transaction_status(trans_id) + + return QueryHistory.save(current_user.id, trans_obj.sid, conn.db, + request=request) + + +@blueprint.route( + '/query_history/', + methods=["DELETE"], endpoint='clear_query_history' +) +@login_required +def clear_query_history(trans_id): + """ + This method returns clears history for user/server/database + + Args: + sid: server id + did: database id + """ + + status, error_msg, conn, trans_obj, session_ob = \ + check_transaction_status(trans_id) + + return QueryHistory.clear(current_user.id, trans_obj.sid, conn.db) + + +@blueprint.route( + '/query_history/', + methods=["GET"], endpoint='get_query_history' +) +@login_required +def get_query_history(trans_id): + """ + This method returns query history for user/server/database + + Args: + sid: server id + did: database id + """ + + status, error_msg, conn, trans_obj, session_ob = \ + check_transaction_status(trans_id) + + return QueryHistory.get(current_user.id, trans_obj.sid, conn.db) diff --git a/web/pgadmin/tools/sqleditor/static/js/sqleditor.js b/web/pgadmin/tools/sqleditor/static/js/sqleditor.js index 8aad0e7f2..7668ab71f 100644 --- a/web/pgadmin/tools/sqleditor/static/js/sqleditor.js +++ b/web/pgadmin/tools/sqleditor/static/js/sqleditor.js @@ -193,11 +193,11 @@ define('tools.querytool', [ }); sql_panel.load(main_docker); - var sql_panel_obj = main_docker.addPanel('sql_panel', wcDocker.DOCK.TOP); + self.sql_panel_obj = main_docker.addPanel('sql_panel', wcDocker.DOCK.TOP); var text_container = $(''); var output_container = $('
    ').append(text_container); - sql_panel_obj.$container.find('.pg-panel-content').append(output_container); + self.sql_panel_obj.$container.find('.pg-panel-content').append(output_container); self.query_tool_obj = CodeMirror.fromTextArea(text_container.get(0), { tabindex: '0', @@ -222,7 +222,7 @@ define('tools.querytool', [ // Refresh Code mirror on SQL panel resize to // display its value properly - sql_panel_obj.on(wcDocker.EVENT.RESIZE_ENDED, function() { + self.sql_panel_obj.on(wcDocker.EVENT.RESIZE_ENDED, function() { setTimeout(function() { if (self && self.query_tool_obj) { self.query_tool_obj.refresh(); @@ -312,8 +312,8 @@ define('tools.querytool', [ geometry_viewer.load(main_docker); // Add all the panels to the docker - self.scratch_panel = main_docker.addPanel('scratch', wcDocker.DOCK.RIGHT, sql_panel_obj); - self.history_panel = main_docker.addPanel('history', wcDocker.DOCK.STACKED, sql_panel_obj); + self.scratch_panel = main_docker.addPanel('scratch', wcDocker.DOCK.RIGHT, self.sql_panel_obj); + self.history_panel = main_docker.addPanel('history', wcDocker.DOCK.STACKED, self.sql_panel_obj); self.data_output_panel = main_docker.addPanel('data_output', wcDocker.DOCK.BOTTOM); self.explain_panel = main_docker.addPanel('explain', wcDocker.DOCK.STACKED, self.data_output_panel); self.messages_panel = main_docker.addPanel('messages', wcDocker.DOCK.STACKED, self.data_output_panel); @@ -1309,13 +1309,51 @@ define('tools.querytool', [ if(!self.historyComponent) { self.historyComponent = new QueryHistory($('#history_grid'), self.history_collection); + + /* Copy query to query editor, set the focus to editor and move cursor to end */ + self.historyComponent.onCopyToEditorClick((query)=>{ + self.query_tool_obj.setValue(query); + self.sql_panel_obj.focus(); + setTimeout(() => { + self.query_tool_obj.focus(); + self.query_tool_obj.setCursor(self.query_tool_obj.lineCount(), 0); + }, 100); + }); + self.historyComponent.render(); + + self.history_panel.off(wcDocker.EVENT.VISIBILITY_CHANGED); + self.history_panel.on(wcDocker.EVENT.VISIBILITY_CHANGED, function() { + if (self.history_panel.isVisible()) { + setTimeout(()=>{ + self.historyComponent.focus(); + }, 100); + } + }); } - self.history_panel.off(wcDocker.EVENT.VISIBILITY_CHANGED); - self.history_panel.on(wcDocker.EVENT.VISIBILITY_CHANGED, function() { - self.historyComponent.focus(); - }); + // Make ajax call to get history data except view/edit data + if(self.handler.is_query_tool) { + $.ajax({ + url: url_for('sqleditor.get_query_history', { + 'trans_id': self.handler.transId, + }), + method: 'GET', + contentType: 'application/json', + }) + .done(function(res) { + res.data.result.map((entry) => { + let newEntry = JSON.parse(entry); + newEntry.start_time = new Date(newEntry.start_time); + self.history_collection.add(newEntry); + }); + }) + .fail(function() { + /* history fetch fail should not affect query tool */ + }); + } else { + self.historyComponent.setEditorPref({'copy_to_editor':false}); + } }, // Callback function for Add New Row button click. @@ -1637,11 +1675,26 @@ define('tools.querytool', [ } alertify.confirm(gettext('Clear history'), - gettext('Are you sure you wish to clear the history?'), + gettext('Are you sure you wish to clear the history?') + '
    ' + + gettext('This will remove all of your query history from this and other sessions for this database.'), function() { if (self.history_collection) { self.history_collection.reset(); } + + if(self.handler.is_query_tool) { + $.ajax({ + url: url_for('sqleditor.clear_query_history', { + 'trans_id': self.handler.transId, + }), + method: 'DELETE', + contentType: 'application/json', + }) + .done(function() {}) + .fail(function() { + /* history clear fail should not affect query tool */ + }); + } setTimeout(() => { self.query_tool_obj.focus(); }, 200); }, function() { @@ -2573,14 +2626,34 @@ define('tools.querytool', [ self.query_start_time, new Date()); } - self.gridView.history_collection.add({ + + let hist_entry = { 'status': status, 'start_time': self.query_start_time, 'query': self.query, 'row_affected': self.rows_affected, 'total_time': self.total_time, 'message': msg, - }); + }; + + /* Make ajax call to save the history data + * Do not bother query tool if failed to save + * Not applicable for view/edit data + */ + if(self.is_query_tool) { + $.ajax({ + url: url_for('sqleditor.add_query_history', { + 'trans_id': self.transId, + }), + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(hist_entry), + }) + .done(function() {}) + .fail(function() {}); + } + + self.gridView.history_collection.add(hist_entry); } }, diff --git a/web/pgadmin/tools/sqleditor/static/scss/_history.scss b/web/pgadmin/tools/sqleditor/static/scss/_history.scss index 9751da7c7..37ed8b0e8 100644 --- a/web/pgadmin/tools/sqleditor/static/scss/_history.scss +++ b/web/pgadmin/tools/sqleditor/static/scss/_history.scss @@ -143,7 +143,7 @@ height: 0; position: relative; - .copy-all, .was-copied { + .copy-all, .was-copied, .copy-to-editor { float: left; position: relative; z-index: 10; diff --git a/web/pgadmin/tools/sqleditor/tests/test_editor_history.py b/web/pgadmin/tools/sqleditor/tests/test_editor_history.py new file mode 100644 index 000000000..b43a20c56 --- /dev/null +++ b/web/pgadmin/tools/sqleditor/tests/test_editor_history.py @@ -0,0 +1,105 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2019, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import json + +from pgadmin.browser.server_groups.servers.databases.tests import utils as \ + database_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils + + +class TestEditorHistory(BaseTestGenerator): + """ This class will test the query tool polling. """ + scenarios = [ + ('When first query is hit', + dict( + entry="""{ + query: 'first sql statement', + start_time: '2017-05-03T14:03:15.150Z', + status: true, + row_affected: 12345, + total_time: '14 msec', + message: 'something important ERROR: message + from first sql query', + }""", + clear=False, + expected_len=1 + )), + ('When second query is hit', + dict( + entry="""{ + query: 'second sql statement', + start_time: '2016-04-03T14:03:15.99Z', + status: true, + row_affected: 12345, + total_time: '14 msec', + message: 'something important ERROR: message from + second sql query', + }""", + clear=False, + expected_len=2 + )), + ('When cleared', + dict( + clear=True, + expected_len=0 + )) + ] + + def setUp(self): + """ This function will check messages return by query tool polling. """ + database_info = parent_node_dict["database"][-1] + self.server_id = database_info["server_id"] + + self.db_id = database_info["db_id"] + db_con = database_utils.connect_database(self, + utils.SERVER_GROUP, + self.server_id, + self.db_id) + if not db_con["info"] == "Database connected.": + raise Exception("Could not connect to the database.") + + # Initialize query tool + url = '/datagrid/initialize/query_tool/{0}/{1}/{2}'.format( + utils.SERVER_GROUP, self.server_id, self.db_id) + response = self.tester.post(url) + self.assertEquals(response.status_code, 200) + + response_data = json.loads(response.data.decode('utf-8')) + self.trans_id = response_data['data']['gridTransId'] + + def runTest(self): + url = '/sqleditor/query_history/{0}'.format(self.trans_id) + + if not self.clear: + response = self.tester.post(url, data=self.entry) + self.assertEquals(response.status_code, 200) + + response = self.tester.get(url) + self.assertEquals(response.status_code, 200) + + response_data = json.loads(response.data.decode('utf-8')) + self.assertEquals(len(response_data['data']['result']), + self.expected_len) + else: + response = self.tester.delete(url) + self.assertEquals(response.status_code, 200) + + response = self.tester.get(url) + self.assertEquals(response.status_code, 200) + + response_data = json.loads(response.data.decode('utf-8')) + self.assertEquals(len(response_data['data']['result']), + self.expected_len) + + def tearDown(self): + # Disconnect the database + database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/tools/sqleditor/utils/query_history.py b/web/pgadmin/tools/sqleditor/utils/query_history.py new file mode 100644 index 000000000..6019aba3b --- /dev/null +++ b/web/pgadmin/tools/sqleditor/utils/query_history.py @@ -0,0 +1,137 @@ +from pgadmin.utils.ajax import make_json_response +from pgadmin.model import db, QueryHistoryModel +from config import MAX_QUERY_HIST_STORED + + +class QueryHistory: + @staticmethod + def get(uid, sid, dbname): + + result = db.session \ + .query(QueryHistoryModel.query_info) \ + .filter(QueryHistoryModel.uid == uid, + QueryHistoryModel.sid == sid, + QueryHistoryModel.dbname == dbname) \ + .all() + + return make_json_response( + data={ + 'status': True, + 'msg': '', + 'result': [rec.query_info for rec in result] + } + ) + + @staticmethod + def update_history_dbname(uid, sid, old_dbname, new_dbname): + try: + db.session \ + .query(QueryHistoryModel) \ + .filter(QueryHistoryModel.uid == uid, + QueryHistoryModel.sid == sid, + QueryHistoryModel.dbname == old_dbname) \ + .update({QueryHistoryModel.dbname: new_dbname}) + + db.session.commit() + except Exception: + db.session.rollback() + # do not affect query execution if history clear fails + + @staticmethod + def save(uid, sid, dbname, request): + try: + max_srno = db.session\ + .query(db.func.max(QueryHistoryModel.srno)) \ + .filter(QueryHistoryModel.uid == uid, + QueryHistoryModel.sid == sid, + QueryHistoryModel.dbname == dbname)\ + .scalar() + + # if no records present + if max_srno is None: + new_srno = 1 + else: + new_srno = max_srno + 1 + + # last updated flag is used to recognise the last + # inserted/updated record. + # It is helpful to cycle the records + last_updated_rec = db.session.query(QueryHistoryModel) \ + .filter(QueryHistoryModel.uid == uid, + QueryHistoryModel.sid == sid, + QueryHistoryModel.dbname == dbname, + QueryHistoryModel.last_updated_flag == 'Y') \ + .first() + + # there should be a last updated record + # if not present start from sr no 1 + if last_updated_rec is not None: + last_updated_rec.last_updated_flag = 'N' + + # if max limit reached then recycle + if new_srno > MAX_QUERY_HIST_STORED: + new_srno = ( + last_updated_rec.srno % MAX_QUERY_HIST_STORED) + 1 + else: + new_srno = 1 + + # if the limit is lowered and number of records present is + # more, then cleanup + if max_srno > MAX_QUERY_HIST_STORED: + db.session.query(QueryHistoryModel)\ + .filter(QueryHistoryModel.uid == uid, + QueryHistoryModel.sid == sid, + QueryHistoryModel.dbname == dbname, + QueryHistoryModel.srno > + MAX_QUERY_HIST_STORED)\ + .delete() + + history_entry = QueryHistoryModel( + srno=new_srno, uid=uid, sid=sid, dbname=dbname, + query_info=request.data, last_updated_flag='Y') + + db.session.merge(history_entry) + + db.session.commit() + except Exception: + db.session.rollback() + # do not affect query execution if history saving fails + + return make_json_response( + data={ + 'status': True, + 'msg': 'Success', + } + ) + + @staticmethod + def clear_history(uid, sid, dbname=None): + try: + if dbname is not None: + db.session.query(QueryHistoryModel) \ + .filter(QueryHistoryModel.uid == uid, + QueryHistoryModel.sid == sid, + QueryHistoryModel.dbname == dbname) \ + .delete() + + db.session.commit() + else: + db.session.query(QueryHistoryModel) \ + .filter(QueryHistoryModel.uid == uid, + QueryHistoryModel.sid == sid)\ + .delete() + + db.session.commit() + except Exception: + db.session.rollback() + # do not affect query execution if history clear fails + + @staticmethod + def clear(uid, sid, dbname=None): + QueryHistory.clear_history(uid, sid, dbname) + return make_json_response( + data={ + 'status': True, + 'msg': 'Success', + } + ) diff --git a/web/regression/javascript/history/query_history_spec.js b/web/regression/javascript/history/query_history_spec.js index 908aa2a9b..976026d8c 100644 --- a/web/regression/javascript/history/query_history_spec.js +++ b/web/regression/javascript/history/query_history_spec.js @@ -18,7 +18,6 @@ import moment from 'moment'; describe('QueryHistory', () => { let historyCollection; let historyWrapper; - let sqlEditorPref = {sql_font_size: '1.5em'}; let historyComponent; beforeEach(() => { @@ -70,6 +69,7 @@ describe('QueryHistory', () => { historyCollection = new HistoryCollection(historyObjects); historyComponent = new QueryHistory(historyWrapper, historyCollection); + historyComponent.onCopyToEditorClick(()=>{}); historyComponent.render(); queryEntries = historyWrapper.find('#query_list .list-item'); @@ -92,8 +92,9 @@ describe('QueryHistory', () => { expect($(queryEntries[1]).find('.timestamp').text()).toBe('01:33:05'); }); - it('renders the most recent query as selected', () => { + it('renders the most recent query as selected', (done) => { expect($(queryEntries[0]).hasClass('selected')).toBeTruthy(); + done(); }); it('renders the older query as not selected', () => { @@ -103,19 +104,26 @@ describe('QueryHistory', () => { }); describe('the historydetails panel', () => { - let copyAllButton; + let copyAllButton, copyEditorButton; beforeEach(() => { - copyAllButton = () => queryDetail.find('#history-detail-query > button'); + copyAllButton = () => queryDetail.find('#history-detail-query .btn-copy'); + copyEditorButton = () => queryDetail.find('#history-detail-query .btn-copy-editor'); }); - it('should change preferences', ()=>{ - historyComponent.setEditorPref(sqlEditorPref); + it('should change preference font size', ()=>{ + historyComponent.setEditorPref({sql_font_size: '1.5em'}); expect(queryDetail.find('#history-detail-query .CodeMirror').attr('style')).toBe('font-size: 1.5em;'); }); + it('should change preference copy to editor false', ()=>{ + historyComponent.setEditorPref({copy_to_editor: false}); + expect($(queryDetail.find('#history-detail-query .btn-copy-editor')).hasClass('d-none')).toBe(true); + }); + it('displays the formatted timestamp', () => { - expect(queryDetail.text()).toContain('6-3-17 14:03:15'); + let firstDate = new Date(2017, 5, 3, 14, 3, 15, 150); + expect(queryDetail.text()).toContain(firstDate.toLocaleDateString() + ' ' + firstDate.toLocaleTimeString()); }); it('displays the number of rows affected', () => { @@ -141,7 +149,7 @@ describe('QueryHistory', () => { }, 1000); }); - describe('when the "Copy All" button is clicked', () => { + describe('when the "Copy" button is clicked', () => { beforeEach(() => { spyOn(clipboard, 'copyTextToClipboard'); copyAllButton().trigger('click'); @@ -161,8 +169,8 @@ describe('QueryHistory', () => { jasmine.clock().uninstall(); }); - it('should have text \'Copy All\'', () => { - expect(copyAllButton().text()).toBe('Copy All'); + it('should have text \'Copy\'', () => { + expect(copyAllButton().text()).toBe('Copy'); }); it('should not have the class \'was-copied\'', () => { @@ -193,8 +201,8 @@ describe('QueryHistory', () => { jasmine.clock().tick(1501); }); - it('should change the button text back to \'Copy All\'', () => { - expect(copyAllButton().text()).toBe('Copy All'); + it('should change the button text back to \'Copy\'', () => { + expect(copyAllButton().text()).toBe('Copy'); }); }); @@ -224,14 +232,25 @@ describe('QueryHistory', () => { jasmine.clock().tick(1501); }); - it('should change the button text back to \'Copy All\'', () => { - expect(copyAllButton().text()).toBe('Copy All'); + it('should change the button text back to \'Copy\'', () => { + expect(copyAllButton().text()).toBe('Copy'); }); }); }); }); }); + describe('when the "Copy to query editor" button is clicked', () => { + beforeEach(() => { + spyOn(historyComponent.queryHistDetails, 'onCopyToEditorHandler').and.callThrough(); + copyEditorButton().trigger('click'); + }); + + it('sends the query to the onCopyToEditorHandler', () => { + expect(historyComponent.queryHistDetails.onCopyToEditorHandler).toHaveBeenCalledWith('first sql statement'); + }); + }); + describe('when the query failed', () => { let failedEntry; @@ -310,12 +329,17 @@ describe('QueryHistory', () => { describe('when several days of queries were executed', () => { let queryEntryDateGroups; + let dateToday, dateYest, dateBeforeYest; beforeEach(() => { jasmine.clock().install(); const mockedCurrentDate = moment('2017-07-01 13:30:00'); jasmine.clock().mockDate(mockedCurrentDate.toDate()); + dateToday = mockedCurrentDate.toDate(); + dateYest = mockedCurrentDate.clone().subtract(1, 'days').toDate(); + dateBeforeYest = mockedCurrentDate.clone().subtract(3, 'days').toDate(); + const historyObjects = [{ query: 'first today sql statement', start_time: mockedCurrentDate.toDate(), @@ -371,9 +395,9 @@ describe('QueryHistory', () => { }); it('has title above', () => { - expect($(queryEntryDateGroups[0]).find('.date-label').text()).toBe('Today - Jul 01 2017'); - expect($(queryEntryDateGroups[1]).find('.date-label').text()).toBe('Yesterday - Jun 30 2017'); - expect($(queryEntryDateGroups[2]).find('.date-label').text()).toBe('Jun 28 2017'); + expect($(queryEntryDateGroups[0]).find('.date-label').text()).toBe('Today - ' + dateToday.toLocaleDateString()); + expect($(queryEntryDateGroups[1]).find('.date-label').text()).toBe('Yesterday - ' + dateYest.toLocaleDateString()); + expect($(queryEntryDateGroups[2]).find('.date-label').text()).toBe(dateBeforeYest.toLocaleDateString()); }); }); });