diff --git a/web/migrations/versions/398697dc9550_.py b/web/migrations/versions/398697dc9550_.py new file mode 100644 index 000000000..c9651f546 --- /dev/null +++ b/web/migrations/versions/398697dc9550_.py @@ -0,0 +1,56 @@ + +"""empty message + +Revision ID: 398697dc9550 +Revises: a091c9611d20 +Create Date: 2020-09-07 15:17:59.473879 + +""" +from pgadmin.model import db + +# revision identifiers, used by Alembic. +revision = '398697dc9550' +down_revision = 'a091c9611d20' +branch_labels = None +depends_on = None + + +def upgrade(): + db.engine.execute(""" + CREATE TABLE macros ( + id INTEGER NOT NULL, + alt BOOLEAN NOT NULL, + control BOOLEAN NOT NULL, + key VARCHAR(128) NOT NULL, + key_code INTEGER NOT NULL, + PRIMARY KEY(id) + ); + """) + + db.engine.execute(""" + CREATE TABLE user_macros ( + mid INTEGER NOT NULL, + uid INTEGER NOT NULL, + name VARCHAR(1024) NOT NULL, + sql TEXT NOT NULL, + PRIMARY KEY(mid, uid), + FOREIGN KEY(mid) REFERENCES macros (id), + FOREIGN KEY(uid) REFERENCES user (id) + ); + """) + + db.engine.execute(""" + INSERT INTO macros (id, alt, control, key, key_code) VALUES (1, false, true, '1', 49), + (2, false, true, '2', 50), (3, false, true, '3', 51), (4, false, true, '4', 52), + (5, false, true, '5', 53), (6, false, true, '6', 54), (7, false, true, '7', 55), + (8, false, true, '8', 56), (9, false, true, '9', 57), (10, false, true, '0', 48), + (11, true, false, 'F1', 112), (12, true, false, 'F2', 113), (13, true, false, 'F3', 114), + (14, true, false, 'F4', 115), (15, true, false, 'F5', 116), (16, true, false, 'F6', 117), + (17, true, false, 'F7', 118), (18, true, false, 'F8', 119), (19, true, false, 'F9', 120), + (20, true, false, 'F10', 121), (21, true, false, 'F11', 122), (22, true, false, 'F12', 123); + """) + + +def downgrade(): + # pgAdmin only upgrades, downgrade not implemented. + pass diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index b33adc062..425c9e649 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -29,7 +29,7 @@ from flask_sqlalchemy import SQLAlchemy # ########################################################################## -SCHEMA_VERSION = 26 +SCHEMA_VERSION = 27 ########################################################################## # @@ -391,3 +391,26 @@ class SharedServer(db.Model): tunnel_identity_file = db.Column(db.String(64), nullable=True) tunnel_password = db.Column(db.String(64), nullable=True) shared = db.Column(db.Boolean(), nullable=False) + + +class Macros(db.Model): + """Define a particular macro.""" + __tablename__ = 'macros' + id = db.Column(db.Integer, primary_key=True) + alt = db.Column(db.Boolean(), nullable=False) + control = db.Column(db.Boolean(), nullable=False) + key = db.Column(db.String(32), nullable=False) + key_code = db.Column(db.Integer, nullable=False) + + +class UserMacros(db.Model): + """Define the macro for a particular user.""" + __tablename__ = 'user_macros' + mid = db.Column( + db.Integer, db.ForeignKey('macros.id'), primary_key=True + ) + uid = db.Column( + db.Integer, db.ForeignKey('user.id'), primary_key=True + ) + name = db.Column(db.String(1024), nullable=False) + sql = db.Column(db.Text(), nullable=False) diff --git a/web/pgadmin/static/js/backgrid.pgadmin.js b/web/pgadmin/static/js/backgrid.pgadmin.js index e417dc78d..6394c1794 100644 --- a/web/pgadmin/static/js/backgrid.pgadmin.js +++ b/web/pgadmin/static/js/backgrid.pgadmin.js @@ -9,10 +9,10 @@ define([ 'sources/gettext', 'underscore', 'jquery', 'backbone', 'backform', 'backgrid', 'alertify', - 'moment', 'bignumber', 'sources/utils', 'sources/keyboard_shortcuts', 'sources/select2/configure_show_on_scroll', + 'moment', 'bignumber', 'codemirror', 'sources/utils', 'sources/keyboard_shortcuts', 'sources/select2/configure_show_on_scroll', 'bootstrap.datetimepicker', 'backgrid.filter', 'bootstrap.toggle', ], function( - gettext, _, $, Backbone, Backform, Backgrid, Alertify, moment, BigNumber, + gettext, _, $, Backbone, Backform, Backgrid, Alertify, moment, BigNumber, CodeMirror, commonUtils, keyboardShortcuts, configure_show_on_scroll ) { /* @@ -44,7 +44,7 @@ define([ _.extend(Backgrid.InputCellEditor.prototype.events, { 'keydown': function(e) { let preferences = pgBrowser.get_preferences_for_module('browser'); - if(keyboardShortcuts.validateShortcutKeys(preferences.add_grid_row,e)) { + if(preferences && keyboardShortcuts.validateShortcutKeys(preferences.add_grid_row,e)) { pgBrowser.keyboardNavigation.bindAddGridRow(); } else { Backgrid.InputCellEditor.prototype.saveOrCancel.apply(this, arguments); @@ -324,7 +324,7 @@ define([ events: { 'keydown': function (event) { let preferences = pgBrowser.get_preferences_for_module('browser'); - if(keyboardShortcuts.validateShortcutKeys(preferences.add_grid_row,event)) { + if(preferences && keyboardShortcuts.validateShortcutKeys(preferences.add_grid_row,event)) { pgBrowser.keyboardNavigation.bindAddGridRow(); } }, @@ -606,6 +606,95 @@ define([ }, }); + + Backgrid.Extension.ClearCell = Backgrid.Cell.extend({ + defaults: _.defaults({ + defaultClearMsg: gettext('Are you sure you wish to clear this row?'), + defaultClearTitle: gettext('Clear Row'), + }, Backgrid.Cell.prototype.defaults), + + /** @property */ + className: 'clear-cell', + events: { + 'click': 'clearRow', + }, + clearRow: function(e) { + e.preventDefault(); + var that = this; + // We will check if row is deletable or not + + var clear_msg = !_.isUndefined(this.column.get('customClearMsg')) ? + this.column.get('customClearMsg') : that.defaults.defaultClearMsg; + var clear_title = !_.isUndefined(this.column.get('customClearTitle')) ? + this.column.get('customClearTitle') : that.defaults.defaultClearTitle; + Alertify.confirm( + clear_title, + clear_msg, + function() { + that.model.set('name', null); + that.model.set('sql', null); + }, + function() { + return true; + } + ); + + }, + exitEditMode: function() { + this.$el.removeClass('editor'); + }, + initialize: function() { + Backgrid.Cell.prototype.initialize.apply(this, arguments); + }, + render: function() { + var self = this; + this.$el.empty(); + $(this.$el).attr('tabindex', 0); + if (this.model.get('name') !== null && this.model.get('sql') !== null) + this.$el.html(''); + // Listen for Tab/Shift-Tab key + this.$el.on('keydown', function(e) { + // with keyboard navigation on space key, mark row for deletion + if (e.keyCode == 32) { + self.$el.click(); + } + var gotoCell; + if (e.keyCode == 9 || e.keyCode == 16) { + // go to Next Cell & if Shift is also pressed go to Previous Cell + gotoCell = e.shiftKey ? self.$el.prev() : self.$el.next(); + } + + if (gotoCell) { + let command = new Backgrid.Command({ + key: 'Tab', + keyCode: 9, + which: 9, + shiftKey: e.shiftKey, + }); + setTimeout(function() { + // When we have Editable Cell + if (gotoCell.hasClass('editable')) { + e.preventDefault(); + e.stopPropagation(); + self.model.trigger('backgrid:edited', self.model, + self.column, command); + } + else { + // When we have Non-Editable Cell + self.model.trigger('backgrid:edited', self.model, + self.column, command); + } + }, 20); + } + }); + + + this.delegateEvents(); + return this; + }, + }); + + Backgrid.Extension.CustomHeaderCell = Backgrid.HeaderCell.extend({ initialize: function() { // Here, we will add custom classes to header cell @@ -2081,6 +2170,84 @@ define([ }, }); + Backgrid.Extension.SqlCell = Backgrid.Extension.TextareaCell.extend({ + className: 'sql-cell', + defaults: { + lineWrapping: true, + }, + template: _.template([ + '
', + ].join('\n')), + + render: function() { + let self = this, + col = _.defaults(this.column.toJSON(), this.defaults), + model = this.model, + column = this.column, + columnName = this.column.get('name'), + editable = Backgrid.callByNeed(col.editable, column, model); + + if (this.sqlCell) { + this.sqlCell.toTextArea(); + delete this.sqlCell; + this.sqlCell = null; + } + + this.$el.empty(); + this.$el.append(this.template({ + val:this.formatter.fromRaw(model.get(columnName), model), + }) + ); + this.$el.addClass(columnName); + this.updateStateClassesMaybe(); + this.delegateEvents(); + + setTimeout(function() { + self.sqlCell = CodeMirror.fromTextArea( + (self.$el.find('textarea')[0]), { + mode: 'text/x-pgsql', + readOnly: !editable, + lineWrapping: col.lineWrapping, + screenReaderLabel: columnName, + }); + }); + + return this; + }, + enterEditMode: function () { + if (!this.$el.hasClass('editor')) this.$el.addClass('editor'); + this.sqlCell.focus(); + this.sqlCell.on('blur', this.exitEditMode.bind(this)); + }, + exitEditMode: function () { + this.$el.removeClass('editor'); + this.saveOrCancel.apply(this, arguments); + }, + saveOrCancel: function() { + var model = this.model; + var column = this.column; + if (this.sqlCell) { + var val = this.sqlCell.getTextArea().value; + var newValue = this.sqlCell.getValue(); + if (_.isUndefined(newValue)) { + model.trigger('backgrid:error', model, column, val); + } + else { + model.set(column.get('name'), newValue); + } + } + }, + remove: function() { + if (this.sqlCell) { + $(this.$el.find('[data-toggle="tooltip"]')).tooltip('dispose'); + this.sqlCell.toTextArea(); + delete this.sqlCell; + this.sqlCell = null; + } + return Backgrid.Extension.TextareaCell.prototype.remove.apply(this, arguments); + }, + }); + return Backgrid; }); diff --git a/web/pgadmin/static/js/keyboard_shortcuts.js b/web/pgadmin/static/js/keyboard_shortcuts.js index f02851720..abda8d8f2 100644 --- a/web/pgadmin/static/js/keyboard_shortcuts.js +++ b/web/pgadmin/static/js/keyboard_shortcuts.js @@ -324,15 +324,38 @@ function keyboardShortcutsQueryTool( } } } + } else { + // Macros + let macroId = this.validateMacros(sqlEditorController, event); + + if (macroId !== false) { + this._stopEventPropagation(event); + queryToolActions.executeMacro(sqlEditorController, macroId); + } } return panel_type; } +function validateMacros(sqlEditorController, event) { + let keyCode = event.which || event.keyCode; + + let macro = sqlEditorController.macros.filter(mc => + mc.alt == event.altKey && + mc.control == event.ctrlKey && + mc.key_code == keyCode); + + if (macro.length == 1) { + return macro[0].id; + } + + return false; +} + export { keyboardShortcutsDebugger as processEventDebugger, keyboardShortcutsQueryTool as processEventQueryTool, - focusDockerPanel, validateShortcutKeys, + focusDockerPanel, validateShortcutKeys, validateMacros, _stopEventPropagation, isMac, isKeyCtrlAlt, isKeyAltShift, isKeyCtrlShift, isKeyCtrlAltShift, isAltShiftBoth, isCtrlShiftBoth, isCtrlAltBoth, shortcut_key, shortcut_title, shortcut_accesskey_title, diff --git a/web/pgadmin/static/js/sqleditor/macro.js b/web/pgadmin/static/js/sqleditor/macro.js new file mode 100644 index 000000000..b761002fc --- /dev/null +++ b/web/pgadmin/static/js/sqleditor/macro.js @@ -0,0 +1,325 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2020, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import url_for from 'sources/url_for'; +import $ from 'jquery'; +import Alertify from 'pgadmin.alertifyjs'; +import pgAdmin from 'sources/pgadmin'; +import Backform from 'pgadmin.backform'; +import macroModel from 'sources/sqleditor/macro_model'; +import axios from 'axios'; + +let MacroDialog = { + 'dialog': function(handler) { + let title = gettext('Manage Macros'); + + // Check the alertify dialog already loaded then delete it to clear + // the cache + if (Alertify.macroDialog) { + delete Alertify.macroDialog; + } + + // Create Dialog + Alertify.dialog('macroDialog', function factory() { + let $container = $('
'); + return { + main: function() { + this.set('title', ' ' + gettext('Manage Macros')); + }, + build: function() { + this.elements.content.appendChild($container.get(0)); + Alertify.pgDialogBuild.apply(this); + }, + setup: function() { + return { + buttons: [{ + text: '', + key: 112, + className: 'btn btn-primary-icon pull-left fa fa-question pg-alertify-icon-button', + attrs: { + name: 'dialog_help', + type: 'button', + label: gettext('Help'), + 'aria-label': gettext('Help'), + url: url_for('help.static', { + 'filename': 'querytool.html', + }), + }, + }, { + text: gettext('Cancel'), + key: 27, + className: 'btn btn-secondary fa fa-times pg-alertify-button', + 'data-btn-name': 'cancel', + }, { + text: gettext('Save'), + className: 'btn btn-primary fa fa-save pg-alertify-button', + 'data-btn-name': 'ok', + }], + // Set options for dialog + options: { + title: title, + //disable both padding and overflow control. + padding: !1, + overflow: !1, + model: 0, + resizable: true, + maximizable: true, + pinnable: false, + closableByDimmer: false, + modal: false, + autoReset: false, + }, + }; + }, + hooks: { + // triggered when the dialog is closed + onclose: function() { + if (this.view) { + this.macroCollectionModel.stopSession(); + this.view.model.stopSession(); + this.view.remove({ + data: true, + internal: true, + silent: true, + }); + } + }, + }, + prepare: function() { + let self = this; + $container.html(''); + // Status bar + this.statusBar = $( + '
' + + ' ' + + '
').appendTo($container); + + // To show progress on filter Saving/Updating on AJAX + this.showFilterProgress = $( + `
+
+
+
` + gettext('Loading data...') + `
+
+
` + ).appendTo($container); + + self.macroCollectionModel = macroModel(handler.transId); + + let fields = Backform.generateViewSchema( + null, self.macroCollectionModel, 'edit', null, null, true + ); + + let ManageMacroDialog = Backform.Dialog.extend({ + template: { + 'panel': _.template( + '
' + ), + }, + render: function() { + this.cleanup(); + + var m = this.model, + controls = this.controls, + tmpls = this.template, + dialog_obj = this, + idx = (this.tabIndex * 100), + evalF = function(f, d, model) { + return (_.isFunction(f) ? !!f.apply(d, [model]) : !!f); + }; + + this.$el + .empty() + .attr('role', 'tabpanel') + .attr('class', _.result(this, 'tabPanelClassName')); + m.panelEl = this.$el; + + var tabContent = $('
') + .appendTo(this.$el); + + _.each(this.schema, function(o) { + idx++; + if (!o.version_compatible || !evalF(o.visible, o, m)) { + return; + } + var el = $((tmpls['panel'])(_.extend(o, { + 'tabIndex': idx, + 'tabPanelCodeClass': o.tabPanelCodeClass ? o.tabPanelCodeClass : '', + }))) + .appendTo(tabContent) + .removeClass('collapse').addClass('collapse'); + + o.fields.each(function(f) { + var cntr = new(f.get('control'))({ + field: f, + model: m, + dialog: dialog_obj, + tabIndex: idx, + }); + el.append(cntr.render().$el); + controls.push(cntr); + }); + }); + + tabContent.find('.tab-pane').first().addClass('active show'); + + return this; + }, + }); + + let view = self.view = new ManageMacroDialog({ + el: '
', + model: self.macroCollectionModel, + schema: fields, + }); + + self.macroCollectionModel.fetch({ + success: function() { + + // We got the latest attributes of the object. Render the view + // now. + $container.append(self.view.render().$el); + + // Enable/disable save button and show/hide statusbar based on session + self.view.listenTo(self.view.model, 'pgadmin-session:start', function() { + self.view.listenTo(self.view.model, 'pgadmin-session:invalid', function(msg) { + self.statusBar.removeClass('d-none'); + $(self.statusBar.find('.alert-text')).html(msg); + // Disable Okay button + self.__internal.buttons[2].element.disabled = true; + }); + + view.listenTo(self.view.model, 'pgadmin-session:valid', function() { + self.statusBar.addClass('d-none'); + $(self.statusBar.find('.alert-text')).html(''); + // Enable Okay button + self.__internal.buttons[2].element.disabled = false; + }); + }); + + view.listenTo(self.view.model, 'pgadmin-session:stop', function() { + view.stopListening(self.view.model, 'pgadmin-session:invalid'); + view.stopListening(self.view.model, 'pgadmin-session:valid'); + }); + + // Starts monitoring changes to model + self.view.model.startNewSession(); + + }}); + + $(this.elements.body.childNodes[0]).addClass( + 'alertify_tools_dialog_properties obj_properties' + ); + + + + }, + // Callback functions when click on the buttons of the Alertify dialogs + callback: function(e) { + let self = this; + + if (e.button.element.name == 'dialog_help') { + e.cancel = true; + pgAdmin.Browser.showHelp(e.button.element.name, e.button.element.getAttribute('url'), + null, null); + return; + } else if (e.button['data-btn-name'] === 'ok') { + e.cancel = true; // Do not close dialog + + let data = self.view.model.get('macro').toJSON(true); + + if (data == undefined || data == null) { + self.close(); + return; + } + + axios.put( + url_for('sqleditor.set_macros', { + 'trans_id': handler.transId, + }), + data + ).then(function (result) { + // Hide Progress ... + $( + self.showFilterProgress[0] + ).addClass('d-none'); + + let macroResponse = result; + + if (macroResponse.status) { + setTimeout( + function() { + // Update Macro Menu + let macros = self.view.model.get('macro').toJSON().filter(m => m.name !== undefined || m.name !== null); + handler.macros = macros; + var str = ` +
  • + + Manage Macros... + +
  • + `; + _.each(macros, function(m) { + if (m.name) { + str += `
  • + + ` + m.name + ` + (` + m.key_label + `) + +
  • `; + } + }); + + $($.find('div.btn-group.mr-1.user_macros ul.dropdown-menu')).html($(str)); + + self.close(); // Close the dialog now + Alertify.success(gettext('Macro updated successfully')); + }, 10 + ); + } else { + Alertify.alert( + gettext('Validation Error'), + macroResponse.result + ); + } + + }).catch(function (error) { + // Hide Progress ... + $( + self.showFilterProgress[0] + ).addClass('d-none'); + + setTimeout( + function() { + Alertify.error(error.response.data.errormsg); + }, 10 + ); + }); + + } else { + self.close(); + } + }, + }; + }); + + Alertify.macroDialog(title).resizeTo(pgAdmin.Browser.stdW.calc(pgAdmin.Browser.stdW.lg), + pgAdmin.Browser.stdH.calc(pgAdmin.Browser.stdH.lg)); + }, +}; + +module.exports = MacroDialog; diff --git a/web/pgadmin/static/js/sqleditor/macro_model.js b/web/pgadmin/static/js/sqleditor/macro_model.js new file mode 100644 index 000000000..c66d85c6e --- /dev/null +++ b/web/pgadmin/static/js/sqleditor/macro_model.js @@ -0,0 +1,213 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2020, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import pgAdmin from 'sources/pgadmin'; +import Backform from 'pgadmin.backform'; +import Backgrid from 'pgadmin.backgrid'; +import url_for from 'sources/url_for'; +import $ from 'jquery'; +import _ from 'underscore'; +import Alertify from 'pgadmin.alertifyjs'; + +export default function macroModel(transId) { + + let MacroModel = pgAdmin.Browser.DataModel.extend({ + idAttribute: 'id', + defaults: { + id: undefined, + key: undefined, + name: undefined, + sql: undefined, + key_label: undefined, + }, + schema: [{ + id: 'key_label', + name: 'key_label', + label: gettext('Key'), + type: 'text', + cell: 'string', + editable: false, + cellHeaderClasses: 'width_percent_10', + headerCell: Backgrid.Extension.CustomHeaderCell, + disabled: false, + }, { + id: 'name', + name: 'name', + label: gettext('Name'), + cell: 'string', + type: 'text', + editable: true, + cellHeaderClasses: 'width_percent_20', + headerCell: Backgrid.Extension.CustomHeaderCell, + disabled: false, + }, { + id: 'sql', + name: 'sql', + label: gettext('SQL'), + cell: Backgrid.Extension.SqlCell, + type: 'multiline', + control: Backform.SqlCodeControl, + editable: true, + cellHeaderClasses: 'width_percent_70', + headerCell: Backgrid.Extension.CustomHeaderCell, + disabled: false, + }, + ], + validate: function() { + return null; + }, + }); + + let MacroCollectionModel = pgAdmin.Browser.DataModel.extend({ + defaults: { + macro: undefined, + }, + urlRoot: url_for('sqleditor.get_macros', {'trans_id': transId}), + schema: [{ + id: 'macro', + name: 'macro', + label: gettext('Macros'), + model: MacroModel, + editable: true, + type: 'collection', + control: Backform.SubNodeCollectionControl.extend({ + showGridControl: function(data) { + var self = this, + gridBody = $('
    '); + + var subnode = data.subnode.schema ? data.subnode : data.subnode.prototype, + gridSchema = Backform.generateGridColumnsFromModel( + data.node_info, subnode, this.field.get('mode'), data.columns, data.schema_node + ); + + // Clean up existing grid if any (in case of re-render) + if (self.grid) { + self.grid.remove(); + } + + // Set visibility of Add button + if (data.disabled || data.canAdd == false) { + $(gridBody).find('button.add').remove(); + } + + // Insert Clear Cell into Grid + gridSchema.columns.unshift({ + name: 'pg-backform-clear', + label: '', + cell: Backgrid.Extension.ClearCell, + editable: false, + cell_priority: -1, + sortable: false, + headerCell: Backgrid.Extension.CustomHeaderCell.extend({ + className: 'header-icon-cell', + events: { + 'click': 'clearrAll', + }, + clearrAll: function(e) { + e.preventDefault(); + var that = this; + // We will check if row is deletable or not + + Alertify.confirm( + gettext('Clear All Rows'), + gettext('Are you sure you wish to clear all rows?'), + function() { + _.each(that.collection.toJSON(), function(m) { + that.collection.get(m.id).set({'name': null, 'sql': null}); + }); + }, + function() { + return true; + } + ); + }, + render: function() { + this.$el.empty(); + var column = this.column; + var label = $('
    +
    + + +
    @@ -459,7 +484,8 @@ require(['sources/generated/browser_nodes', 'sources/generated/codemirror', 'sou sqlEditorController.start( {{ uniqueId }}, {{ url_params|safe}}, - '{{ layout|safe }}' + '{{ layout|safe }}', + {{ macros|safe }} ); // If opening from schema diff, set the generated script to the SQL Editor diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index c62bc7cbc..9d0903b25 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -32,7 +32,7 @@ from pgadmin.tools.sqleditor.utils.update_session_grid_transaction import \ from pgadmin.utils import PgAdminModule from pgadmin.utils import get_storage_directory from pgadmin.utils.ajax import make_json_response, bad_request, \ - success_return, internal_server_error + success_return, internal_server_error, make_response as ajax_response from pgadmin.utils.driver import get_driver from pgadmin.utils.menu import MenuItem from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost,\ @@ -46,6 +46,8 @@ from pgadmin.tools.sqleditor.utils.filter_dialog import FilterDialog from pgadmin.tools.sqleditor.utils.query_history import QueryHistory from pgadmin.utils.constants import MIMETYPE_APP_JS, SERVER_CONNECTION_CLOSED,\ ERROR_MSG_TRANS_ID_NOT_FOUND +from pgadmin.tools.sqleditor.utils.macros import get_macros,\ + get_user_macros, set_macros MODULE_NAME = 'sqleditor' @@ -109,6 +111,9 @@ class SqlEditorModule(PgAdminModule): 'sqleditor.get_query_history', 'sqleditor.add_query_history', 'sqleditor.clear_query_history', + 'sqleditor.get_macro', + 'sqleditor.get_macros', + 'sqleditor.set_macros' ] def register_preferences(self): @@ -1547,3 +1552,46 @@ def get_query_history(trans_id): check_transaction_status(trans_id) return QueryHistory.get(current_user.id, trans_obj.sid, conn.db) + + +@blueprint.route( + '/get_macros/', + methods=["GET"], endpoint='get_macros' +) +@blueprint.route( + '/get_macros//', + methods=["GET"], endpoint='get_macro' +) +@login_required +def macros(trans_id, macro_id=None, json_resp=True): + """ + This method is used to get all the columns for data sorting dialog. + + Args: + trans_id: unique transaction id + macro_id: Macro id + """ + + status, error_msg, conn, trans_obj, session_ob = \ + check_transaction_status(trans_id) + + return get_macros(macro_id, json_resp) + + +@blueprint.route( + '/set_macros/', + methods=["PUT"], endpoint='set_macros' +) +@login_required +def update_macros(trans_id): + """ + This method is used to get all the columns for data sorting dialog. + + Args: + trans_id: unique transaction id + """ + + status, error_msg, conn, trans_obj, session_ob = \ + check_transaction_status(trans_id) + + return set_macros() diff --git a/web/pgadmin/tools/sqleditor/static/css/sqleditor.css b/web/pgadmin/tools/sqleditor/static/css/sqleditor.css index 00543ff69..5f673b04d 100644 --- a/web/pgadmin/tools/sqleditor/static/css/sqleditor.css +++ b/web/pgadmin/tools/sqleditor/static/css/sqleditor.css @@ -395,3 +395,24 @@ input.editor-checkbox:focus { .hide-vertical-scrollbar { overflow-y: hidden; } + + +/* Macros */ + +.macro-tab { + top: 0px !important; +} + +.macro-tab .tab-pane { + padding: 0px !important; +} + +.CodeMirror { + overflow-y: auto; + resize: vertical; +} +.sql-cell > div { + overflow-y: auto; + resize: vertical; +} + diff --git a/web/pgadmin/tools/sqleditor/static/js/sqleditor.js b/web/pgadmin/tools/sqleditor/static/js/sqleditor.js index ddd9ad701..203e3a573 100644 --- a/web/pgadmin/tools/sqleditor/static/js/sqleditor.js +++ b/web/pgadmin/tools/sqleditor/static/js/sqleditor.js @@ -43,6 +43,7 @@ define('tools.querytool', [ 'tools/datagrid/static/js/datagrid_panel_title', 'sources/window', 'sources/is_native', + 'sources/sqleditor/macro', 'sources/../bundle/slickgrid', 'pgadmin.file_manager', 'slick.pgadmin.formatters', @@ -57,7 +58,7 @@ define('tools.querytool', [ GeometryViewer, historyColl, queryHist, querySources, keyboardShortcuts, queryToolActions, queryToolNotifications, Datagrid, modifyAnimation, calculateQueryRunTime, callRenderAfterPoll, queryToolPref, queryTxnStatus, csrfToken, panelTitleFunc, - pgWindow, isNative) { + pgWindow, isNative, MacroHandler) { /* Return back, this has been called more than once */ if (pgAdmin.SqlEditor) return pgAdmin.SqlEditor; @@ -149,6 +150,9 @@ define('tools.querytool', [ // Transaction control 'click #btn-commit': 'on_commit_transaction', 'click #btn-rollback': 'on_rollback_transaction', + // Manage Macros + 'click #btn-manage-macros': 'on_manage_macros', + 'click .btn-macro': 'on_execute_macro', }, reflectPreferences: function() { @@ -2038,8 +2042,30 @@ define('tools.querytool', [ queryToolActions.executeRollback(this.handler); }, + + // Callback function for manage macros button click. + on_manage_macros: function() { + var self = this; + + // Trigger the show_filter signal to the SqlEditorController class + self.handler.trigger( + 'pgadmin-sqleditor:button:manage_macros', + self, + self.handler + ); + }, + + // Callback function for manage macros button click. + on_execute_macro: function(e) { + let macroId = $(e.currentTarget).data('macro-id'); + this.handler.history_query_source = QuerySources.EXECUTE; + queryToolActions.executeMacro(this.handler, macroId); + }, + }); + + /* Defining controller class for data grid, which actually * perform the operations like executing the sql query, poll the result, * render the data in the grid, Save/Refresh the data etc... @@ -2308,7 +2334,7 @@ define('tools.querytool', [ * call the render method of the grid view to render the slickgrid * header and loading icon and start execution of the sql query. */ - start: function(transId, url_params, layout) { + start: function(transId, url_params, layout, macros) { var self = this; self.is_query_tool = url_params.is_query_tool==='true'?true:false; @@ -2333,6 +2359,7 @@ define('tools.querytool', [ layout: layout, }); self.transId = self.gridView.transId = transId; + self.macros = self.gridView.macros = macros; self.gridView.current_file = undefined; @@ -2474,12 +2501,14 @@ define('tools.querytool', [ self.on('pgadmin-sqleditor:unindent_selected_code', self._unindent_selected_code, self); // Format self.on('pgadmin-sqleditor:format_sql', self._format_sql, self); + self.on('pgadmin-sqleditor:button:manage_macros', self._manage_macros, self); + self.on('pgadmin-sqleditor:button:execute_macro', self._execute_macro, self); window.parent.$(window.parent.document).on('pgadmin-sqleditor:rows-copied', self._copied_in_other_session); }, // Checks if there is any dirty data in the grid before executing a query - check_data_changes_to_execute_query: function(explain_prefix=null, shouldReconnect=false) { + check_data_changes_to_execute_query: function(explain_prefix=null, shouldReconnect=false, macroId=undefined) { var self = this; // Check if the data grid has any changes before running query @@ -2492,7 +2521,10 @@ define('tools.querytool', [ gettext('The data has been modified, but not saved. Are you sure you wish to discard the changes?'), function() { // The user does not want to save, just continue - if(self.is_query_tool) { + if (macroId !== undefined) { + self._execute_macro_query(explain_prefix, shouldReconnect, macroId); + } + else if(self.is_query_tool) { self._execute_sql_query(explain_prefix, shouldReconnect); } else { @@ -2508,7 +2540,10 @@ define('tools.querytool', [ cancel: gettext('No'), }); } else { - if(self.is_query_tool) { + if (macroId !== undefined) { + self._execute_macro_query(explain_prefix, shouldReconnect, macroId); + } + else if(self.is_query_tool) { self._execute_sql_query(explain_prefix, shouldReconnect); } else { @@ -2602,6 +2637,37 @@ define('tools.querytool', [ }); }, + // Executes sql query for macroin the editor in Query Tool mode + _execute_macro_query: function(explain_prefix, shouldReconnect, macroId) { + var self = this; + + self.has_more_rows = false; + self.fetching_rows = false; + + $.ajax({ + url: url_for('sqleditor.get_macro', {'macro_id': macroId, 'trans_id': self.transId}), + method: 'GET', + contentType: 'application/json', + dataType: 'json', + }) + .done(function(res) { + if (res) { + // Replace the place holder + let query = res.sql.replaceAll('$SELECTION$', self.gridView.query_tool_obj.getSelection()); + + const executeQuery = new ExecuteQuery.ExecuteQuery(self, pgAdmin.Browser.UserManagement); + executeQuery.poll = pgBrowser.override_activity_event_decorator(executeQuery.poll).bind(executeQuery); + executeQuery.execute(query, explain_prefix, shouldReconnect); + } else { + // Let it be for now + } + }) + .fail(function() { + /* failure should not be ignored */ + }); + + }, + // Executes sql query in the editor in Query Tool mode _execute_sql_query: function(explain_prefix, shouldReconnect) { var self = this, sql = ''; @@ -3968,6 +4034,7 @@ define('tools.querytool', [ $('#btn-file-menu-dropdown').prop('disabled', mode_disabled); $('#btn-find').prop('disabled', mode_disabled); $('#btn-find-menu-dropdown').prop('disabled', mode_disabled); + $('#btn-macro-dropdown').prop('disabled', mode_disabled); if (this.is_query_tool) { @@ -4375,6 +4442,24 @@ define('tools.querytool', [ }); }, + // This function will open the manage macro dialog + _manage_macros: function() { + let self = this; + + /* When server is disconnected and connected, connection is lost, + * To reconnect pass true + */ + MacroHandler.dialog(self); + }, + + // This function will open the manage macro dialog + _execute_macro: function() { + + queryToolActions.executeMacro(this.handler); + + }, + + isQueryRunning: function() { return is_query_running; }, diff --git a/web/pgadmin/tools/sqleditor/tests/test_macros.py b/web/pgadmin/tools/sqleditor/tests/test_macros.py new file mode 100644 index 000000000..0375f01a2 --- /dev/null +++ b/web/pgadmin/tools/sqleditor/tests/test_macros.py @@ -0,0 +1,121 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2020, 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 +import random + + +class TestMacros(BaseTestGenerator): + """ This class will test the query tool polling. """ + scenarios = [ + ('Get all macros', + dict( + url='get_macros', + method='get' + )), + ('Set Macros', + dict( + url='set_macros', + method='put', + operation='update', + data={ + 'changed': [ + {'id': 1, + 'name': 'Test Macro 1', + 'sql': 'SELECT 1;' + }, + {'id': 2, + 'name': 'Test Macro 2', + 'sql': 'SELECT 2;' + }, + {'id': 3, + 'name': 'Test Macro 3', + 'sql': 'SELECT 3;' + }, + ] + } + )), + ('Clear Macros', + dict( + url='set_macros', + method='put', + operation='clear', + data={ + 'changed': [ + {'id': 1, + 'name': '', + 'sql': '' + }, + {'id': 3, + 'name': '', + 'sql': '' + }, + ] + } + )) + ] + + 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 + self.trans_id = str(random.randint(1, 9999999)) + url = '/datagrid/initialize/query_tool/{0}/{1}/{2}/{3}'.format( + self.trans_id, utils.SERVER_GROUP, self.server_id, self.db_id) + response = self.tester.post(url) + self.assertEqual(response.status_code, 200) + + def runTest(self): + url = '/sqleditor/{0}/{1}'.format(self.url, self.trans_id) + + if self.method == 'get': + response = self.tester.get(url) + self.assertEqual(response.status_code, 200) + + response_data = json.loads(response.data.decode('utf-8')) + self.assertEqual(len(response_data['macro']), 22) + else: + response = self.tester.put(url, + data=json.dumps(self.data), + follow_redirects=True) + self.assertEqual(response.status_code, 200) + + for m in self.data['changed']: + url = '/sqleditor/get_macros/{0}/{1}'.format(m['id'], + self.trans_id) + response = self.tester.get(url) + + if self.operation == 'clear': + self.assertEqual(response.status_code, 410) + else: + self.assertEqual(response.status_code, 200) + + response_data = json.loads(response.data.decode('utf-8')) + self.assertEqual(response_data['name'], m['name']) + self.assertEqual(response_data['sql'], m['sql']) + + 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/macros.py b/web/pgadmin/tools/sqleditor/utils/macros.py new file mode 100644 index 000000000..5dd183c30 --- /dev/null +++ b/web/pgadmin/tools/sqleditor/utils/macros.py @@ -0,0 +1,165 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2020, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Handle Macros for SQL Editor.""" + +import simplejson as json +from flask_babelex import gettext +from flask import current_app, request +from flask_security import login_required, current_user +from pgadmin.utils.ajax import make_response as ajax_response,\ + make_json_response +from pgadmin.model import db, Macros, UserMacros + + +def get_macros(macro_id, json_resp): + """ + This method is used to get all the macros/specific macro. + :param macro_id: Macro ID + :param json_resp: Set True to return json response + """ + if macro_id: + macro = UserMacros.query.filter_by(mid=macro_id, + uid=current_user.id).first() + if macro is None: + return make_json_response( + status=410, + success=0, + errormsg=gettext("Macro not found.") + ) + else: + return ajax_response( + response={'id': macro.mid, + 'name': macro.name, + 'sql': macro.sql}, + status=200 + ) + else: + macros = db.session.query(Macros.id, Macros.alt, Macros.control, + Macros.key, Macros.key_code, + UserMacros.name, UserMacros.sql + ).outerjoin( + UserMacros, Macros.id == UserMacros.mid).all() + + data = [] + + for m in macros: + key_label = 'Ctrl + ' + m[3] if m[2] is True else 'Alt + ' + m[3] + data.append({'id': m[0], 'alt': m[1], + 'control': m[2], 'key': m[3], + 'key_code': m[4], 'name': m[5], + 'sql': m[6], + 'key_label': key_label}) + + if not json_resp: + return data + + return ajax_response( + response={'macro': data}, + status=200 + ) + + +def get_user_macros(): + """ + This method is used to get all the user macros. + """ + + macros = db.session.query(UserMacros.name, Macros.id, + Macros.alt, Macros.control, + Macros.key, Macros.key_code + ).outerjoin( + Macros, UserMacros.mid == Macros.id).all() + + data = [] + + for m in macros: + key_label = 'Ctrl + ' + m[4] if m[3] is True else 'Alt + ' + m[4] + data.append({'name': m[0], 'id': m[1], 'key': m[4], + 'key_label': key_label, 'alt': 1 if m[2] else 0, + 'control': 1 if m[3] else 0, 'key_code': m[5]}) + + return data + + +def set_macros(): + """ + This method is used to update the user defined macros. + """ + + data = request.form if request.form else json.loads( + request.data, encoding='utf-8' + ) + + if 'changed' not in data: + return make_json_response( + success=1, + info=gettext('Nothing to update.') + ) + + for m in data['changed']: + if m['id']: + macro = UserMacros.query.filter_by( + uid=current_user.id, + mid=m['id']).first() + if macro: + status, msg = update_macro(m, macro) + else: + status, msg = create_macro(m) + + if not status: + return make_json_response( + status=410, success=0, errormsg=msg + ) + return ajax_response(status=200) + + +def create_macro(macro): + """ + This method is used to create the user defined macros. + :param macro: macro + """ + try: + new_macro = UserMacros( + uid=current_user.id, + mid=macro['id'], + name=macro['name'], + sql=macro['sql'] + ) + db.session.add(new_macro) + db.session.commit() + except Exception as e: + db.session.rollback() + return False, str(e) + + return True, None + + +def update_macro(data, macro): + """ + This method is used to clear/update the user defined macros. + :param data: updated macro data + :param macro: macro + """ + name = data['name'] if 'name' in data else None + sql = data['sql'] if 'sql' in data else None + try: + if (name is None and sql is None) or (name == '' and sql == ''): + db.session.delete(macro) + else: + if name: + macro.name = name + if sql: + macro.sql = sql + db.session.commit() + except Exception as e: + db.session.rollback() + return False, str(e) + + return True, None diff --git a/web/regression/javascript/sqleditor/keyboard_shortcuts_spec.js b/web/regression/javascript/sqleditor/keyboard_shortcuts_spec.js index c8415a089..04365bf48 100644 --- a/web/regression/javascript/sqleditor/keyboard_shortcuts_spec.js +++ b/web/regression/javascript/sqleditor/keyboard_shortcuts_spec.js @@ -18,7 +18,8 @@ describe('the keyboard shortcuts', () => { F7_KEY = 118, F8_KEY = 119, PERIOD_KEY = 190, - FWD_SLASH_KEY = 191; + FWD_SLASH_KEY = 191, + C1_KEY = 49; let sqlEditorControllerSpy, event, queryToolActionsSpy; beforeEach(() => { @@ -120,6 +121,19 @@ describe('the keyboard shortcuts', () => { }, }; + sqlEditorControllerSpy.macros = [ + { + alt: false, + control: true, + id: 1, + key: '1', + key_code: C1_KEY, + key_label: 'Ctrl + 1', + name: 'C1', + sql: 'Select 1;', + }, + ]; + queryToolActionsSpy = jasmine.createSpyObj(queryToolActions, [ 'explainAnalyze', 'explain', @@ -131,6 +145,7 @@ describe('the keyboard shortcuts', () => { 'executeCommit', 'executeRollback', 'saveDataChanges', + 'executeMacro', ]); }); @@ -667,6 +682,47 @@ describe('the keyboard shortcuts', () => { }); }); + describe('Macro Ctrl + 1', () => { + describe('when there is not a query already running', () => { + beforeEach(() => { + event.which = C1_KEY; + event.altKey = false; + event.shiftKey = false; + event.ctrlKey = true; + + keyboardShortcuts.processEventQueryTool( + sqlEditorControllerSpy, queryToolActionsSpy, event + ); + }); + + it('should execute the macro', () => { + expect(queryToolActionsSpy.executeMacro).toHaveBeenCalledWith(sqlEditorControllerSpy, + sqlEditorControllerSpy.macros[0].id); + }); + + it('should stop event propagation', () => { + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('when the query is already running', () => { + it('does nothing', () => { + event.keyCode = C1_KEY; + event.altKey = false; + event.shiftKey = false; + event.ctrlKey = true; + + sqlEditorControllerSpy.isQueryRunning.and.returnValue(true); + + keyboardShortcuts.processEventQueryTool( + sqlEditorControllerSpy, queryToolActionsSpy, event + ); + + expect(queryToolActionsSpy.executeMacro).not.toHaveBeenCalled(); + }); + }); + }); + function expectEventPropagationToStop() { describe('stops all event propogation', () => {