public inbox for [email protected]help / color / mirror / Atom feed
[pgAdmin4][RM#3140] Add service parameter 14+ messages / 5 participants [nested] [flat]
* [pgAdmin4][RM#3140] Add service parameter @ 2018-03-09 11:47 Murtuza Zabuawala <[email protected]> 0 siblings, 1 reply; 14+ messages in thread From: Murtuza Zabuawala @ 2018-03-09 11:47 UTC (permalink / raw) To: pgadmin-hackers Hi, PFA patch to add service parameter in server dialog. - Docs updated - Test case added for Service ID parameter Please note, I have extracted Connection class and Server manager class from our own custom Psycopg2 driver module. Patch also covers RM#3120 Please review. -- Regards, Murtuza Zabuawala EnterpriseDB: http://www.enterprisedb.com The Enterprise PostgreSQL Company Attachments: [application/octet-stream] RM_3140.diff (257.4K, 3-RM_3140.diff) download | inline diff: diff --git a/docs/en_US/images/server_advanced.png b/docs/en_US/images/server_advanced.png index 10f1809a364579a39b2fe1911391568260b379b8..2291d95f4378a21554a8a668a6f52ea0a485f485 100644 GIT binary patch literal 55652 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 Q0g%5h5(?rKqI&-SA3I!p=l}o! literal 34067 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 diff --git a/docs/en_US/server_dialog.rst b/docs/en_US/server_dialog.rst index 24a726b..6ac72a6 100644 --- a/docs/en_US/server_dialog.rst +++ b/docs/en_US/server_dialog.rst @@ -62,6 +62,7 @@ Use the fields in the *Advanced* tab to configure a connection: * Specify the IP address of the server host in the *Host address* field. Using this field to specify the host IP address may save time by avoiding a DNS lookup on connection, but it may be useful to specify both a host name and address when using Kerberos, GSSAPI, or SSPI authentication methods, as well as for verify-full SSL certificate verification. * Use the *DB restriction* field to provide a SQL restriction that will be used against the pg_database table to limit the databases that you see. For example, you might enter: *live_db test_db* so that only live_db and test_db are shown in the pgAdmin browser. Separate entries with a comma or tab as you type. * Use the *Password File* field to specify the location of a password file (.pgpass). A .pgpass file allows a user to login without providing a password when they connect. For more information, see `Section 33.15 of the Postgres documentation <http://www.postgresql.org/docs/current/static/libpq-pgpass.html>`_. +* Use the *Service ID* field to specify the service name. For more information, see `Section 33.16 of the Postgres documentation <https://www.postgresql.org/docs/10/static/libpq-pgservice.html>`_. *NOTE:* The password file option is only supported when pgAdmin is using libpq v10.0 or later to connect to the server. diff --git a/web/migrations/versions/50aad68f99c2_.py b/web/migrations/versions/50aad68f99c2_.py new file mode 100644 index 0000000..f8c97f2 --- /dev/null +++ b/web/migrations/versions/50aad68f99c2_.py @@ -0,0 +1,82 @@ + +"""Added service field option in server table (RM#3140) + +Revision ID: 50aad68f99c2 +Revises: 02b9dccdcfcb +Create Date: 2018-03-07 11:53:57.584280 + +""" +from pgadmin.model import db + + +# revision identifiers, used by Alembic. +revision = '50aad68f99c2' +down_revision = '02b9dccdcfcb' +branch_labels = None +depends_on = None + + +def upgrade(): + # To Save previous data + db.engine.execute("ALTER TABLE server RENAME TO server_old") + + # With service file some fields won't be mandatory as user can provide + # them using service file. Removed NOT NULL constraint from few columns + db.engine.execute(""" + CREATE TABLE server ( + id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + servergroup_id INTEGER NOT NULL, + name VARCHAR(128) NOT NULL, + host VARCHAR(128), + port INTEGER NOT NULL CHECK(port >= 1024 AND port <= 65534), + maintenance_db VARCHAR(64), + username VARCHAR(64) NOT NULL, + password VARCHAR(64), + role VARCHAR(64), + ssl_mode VARCHAR(16) NOT NULL CHECK(ssl_mode IN + ( 'allow' , 'prefer' , 'require' , 'disable' , + 'verify-ca' , 'verify-full' ) + ), + comment VARCHAR(1024), + discovery_id VARCHAR(128), + hostaddr TEXT(1024), + db_res TEXT, + passfile TEXT, + sslcert TEXT, + sslkey TEXT, + sslrootcert TEXT, + sslcrl TEXT, + sslcompression INTEGER DEFAULT 0, + bgcolor TEXT(10), + fgcolor TEXT(10), + PRIMARY KEY(id), + FOREIGN KEY(user_id) REFERENCES user(id), + FOREIGN KEY(servergroup_id) REFERENCES servergroup(id) + ) + """) + + # Copy old data again into table + db.engine.execute(""" + INSERT INTO server ( + id,user_id, servergroup_id, name, host, port, maintenance_db, + username, ssl_mode, comment, password, role, discovery_id, + hostaddr, db_res, passfile, sslcert, sslkey, sslrootcert, sslcrl, + bgcolor, fgcolor + ) SELECT + id,user_id, servergroup_id, name, host, port, maintenance_db, + username, ssl_mode, comment, password, role, discovery_id, + hostaddr, db_res, passfile, sslcert, sslkey, sslrootcert, sslcrl, + bgcolor, fgcolor + FROM server_old""") + + # Remove old data + db.engine.execute("DROP TABLE server_old") + + # Add column for Service ID + db.engine.execute( + 'ALTER TABLE server ADD COLUMN service TEXT' + ) + +def downgrade(): + pass diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index dfa9d62..fecb66d 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -478,7 +478,8 @@ class ServerNode(PGChildNodeView): 'sslcrl': 'sslcrl', 'sslcompression': 'sslcompression', 'bgcolor': 'bgcolor', - 'fgcolor': 'fgcolor' + 'fgcolor': 'fgcolor', + 'service': 'service' } disp_lbl = { @@ -515,7 +516,7 @@ class ServerNode(PGChildNodeView): if connected: for arg in ( 'host', 'hostaddr', 'port', 'db', 'username', 'sslmode', - 'role' + 'role', 'service' ): if arg in data: return forbidden( @@ -663,7 +664,8 @@ class ServerNode(PGChildNodeView): 'sslrootcert': server.sslrootcert if is_ssl else None, 'sslcrl': server.sslcrl if is_ssl else None, 'sslcompression': True if is_ssl and server.sslcompression - else False + else False, + 'service': server.service if server.service else None } ) @@ -672,18 +674,22 @@ class ServerNode(PGChildNodeView): """Add a server node to the settings database""" required_args = [ u'name', - u'host', u'port', - u'db', - u'username', u'sslmode', - u'role' + u'username' ] data = request.form if request.form else json.loads( request.data, encoding='utf-8' ) + # Some fields can be provided with service file so they are optional + if 'service' in data and not data['service']: + required_args.extend([ + u'host', + u'db', + u'role' + ]) for arg in required_args: if arg not in data: return make_json_response( @@ -711,29 +717,26 @@ class ServerNode(PGChildNodeView): try: server = Server( user_id=current_user.id, - servergroup_id=data[u'gid'] if u'gid' in data else gid, - name=data[u'name'], - host=data[u'host'], - hostaddr=data[u'hostaddr'] if u'hostaddr' in data else None, - port=data[u'port'], - maintenance_db=data[u'db'], - username=data[u'username'], - ssl_mode=data[u'sslmode'], - comment=data[u'comment'] if u'comment' in data else None, - role=data[u'role'] if u'role' in data else None, + servergroup_id=data.get('gid', gid), + name=data.get('name'), + host=data.get('host', None), + hostaddr=data.get('hostaddr', None), + port=data.get('port'), + maintenance_db=data.get('db', None), + username=data.get('username'), + ssl_mode=data.get('sslmode'), + comment=data.get('comment', None), + role=data.get('role', None), db_res=','.join(data[u'db_res']) - if u'db_res' in data - else None, - sslcert=data['sslcert'] if is_ssl else None, - sslkey=data['sslkey'] if is_ssl else None, - sslrootcert=data['sslrootcert'] if is_ssl else None, - sslcrl=data['sslcrl'] if is_ssl else None, + if u'db_res' in data else None, + sslcert=data.get('sslcert', None), + sslkey=data.get('sslkey', None), + sslrootcert=data.get('sslrootcert', None), + sslcrl=data.get('sslcrl', None), sslcompression=1 if is_ssl and data['sslcompression'] else 0, - bgcolor=data['bgcolor'] if u'bgcolor' in data - else None, - fgcolor=data['fgcolor'] if u'fgcolor' in data - else None - + bgcolor=data.get('bgcolor', None), + fgcolor=data.get('fgcolor', None), + service=data.get('service', None) ) db.session.add(server) db.session.commit() @@ -930,7 +933,7 @@ class ServerNode(PGChildNodeView): if 'password' not in data: conn_passwd = getattr(conn, 'password', None) if conn_passwd is None and server.password is None and \ - server.passfile is None: + server.passfile is None and server.service is None: # Return the password template in case password is not # provided, or password has not been saved earlier. return make_json_response( diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.js b/web/pgadmin/browser/server_groups/servers/static/js/server.js index 9932808..bedb38d 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -665,6 +665,7 @@ define('pgadmin.node.server', [ sslkey: undefined, sslrootcert: undefined, sslcrl: undefined, + service: undefined, }, // Default values! initialize: function(attrs, args) { @@ -841,12 +842,18 @@ define('pgadmin.node.server', [ var passfile = m.get('passfile'); return !_.isUndefined(passfile) && !_.isNull(passfile); }, + },{ + id: 'service', label: gettext('Service ID'), type: 'text', + mode: ['properties', 'edit', 'create'], disabled: 'isConnected', + group: gettext('Advanced'), }], validate: function() { var err = {}, errmsg, self = this; + var service_id = this.get('service'); + var check_for_empty = function(id, msg) { var v = self.get(id); if ( @@ -903,26 +910,41 @@ define('pgadmin.node.server', [ } check_for_empty('name', gettext('Name must be specified.')); - if (check_for_empty( - 'host', gettext('Either Host name or Host address must be specified.') - ) && check_for_empty('hostaddr', gettext('Either Host name or Host address must be specified.'))){ - errmsg = errmsg || gettext('Either Host name or Host address must be specified'); + // If no service id then only check + if ( + _.isUndefined(service_id) || _.isNull(service_id) || + String(service_id).replace(/^\s+|\s+$/g, '') == '' + ) { + if (check_for_empty( + 'host', gettext('Either Host name or Host address must be specified.') + ) && check_for_empty('hostaddr', gettext('Either Host name or Host address must be specified.'))){ + errmsg = errmsg || gettext('Either Host name or Host address must be specified'); + } else { + errmsg = undefined; + delete err['host']; + delete err['hostaddr']; + } + + check_for_empty( + 'db', gettext('Maintenance database must be specified.') + ); + check_for_valid_ip( + 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') + ); + check_for_valid_ip( + 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') + ); } else { - errmsg = undefined; - delete err['host']; - delete err['hostaddr']; + _.each(['host', 'hostaddr', 'db'], (item) => { + self.errorModel.unset(item); + }); } check_for_empty( - 'db', gettext('Maintenance database must be specified.') - ); - check_for_empty( 'username', gettext('Username must be specified.') ); check_for_empty('port', gettext('Port must be specified.')); - check_for_valid_ip( - 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') - ); + this.errorModel.set(err); if (_.size(err)) { diff --git a/web/pgadmin/browser/server_groups/servers/tests/test_add_server_with_service_id.py b/web/pgadmin/browser/server_groups/servers/tests/test_add_server_with_service_id.py new file mode 100644 index 0000000..3b03d49 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/tests/test_add_server_with_service_id.py @@ -0,0 +1,47 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2018, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import json + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils + + +class ServersWithServiceIDAddTestCase(BaseTestGenerator): + """ This class will add the servers under default server group. """ + + scenarios = [ + # Fetch the default url for server object + ( + 'Default Server Node url', dict( + url='/browser/server/obj/' + ) + ) + ] + + def setUp(self): + pass + + def runTest(self): + """ This function will add the server under default server group.""" + url = "{0}{1}/".format(self.url, utils.SERVER_GROUP) + # Add service name in the config + self.server['service'] = "TestDB" + response = self.tester.post( + url, + data=json.dumps(self.server), + content_type='html/json' + ) + self.assertEquals(response.status_code, 200) + response_data = json.loads(response.data.decode('utf-8')) + self.server_id = response_data['node']['_id'] + + def tearDown(self): + """This function delete the server from SQLite """ + utils.delete_server_with_api(self.tester, self.server_id) diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index 674f945..11bc9f0 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -107,13 +107,13 @@ class Server(db.Model): nullable=False ) name = db.Column(db.String(128), nullable=False) - host = db.Column(db.String(128), nullable=False) + host = db.Column(db.String(128), nullable=True) hostaddr = db.Column(db.String(128), nullable=True) port = db.Column( db.Integer(), db.CheckConstraint('port >= 1024 AND port <= 65534'), nullable=False) - maintenance_db = db.Column(db.String(64), nullable=False) + maintenance_db = db.Column(db.String(64), nullable=True) username = db.Column(db.String(64), nullable=False) password = db.Column(db.String(64), nullable=True) role = db.Column(db.String(64), nullable=True) @@ -144,6 +144,7 @@ class Server(db.Model): ) bgcolor = db.Column(db.Text(10), nullable=True) fgcolor = db.Column(db.Text(10), nullable=True) + service = db.Column(db.Text(), nullable=True) class ModulePreference(db.Model): diff --git a/web/pgadmin/utils/driver/psycopg2/__init__.py b/web/pgadmin/utils/driver/psycopg2/__init__.py index 941a694..95a49fb 100644 --- a/web/pgadmin/utils/driver/psycopg2/__init__.py +++ b/web/pgadmin/utils/driver/psycopg2/__init__.py @@ -8,1985 +8,23 @@ ########################################################################## """ -Implementation of Connection, ServerManager and Driver classes using the -psycopg2. It is a wrapper around the actual psycopg2 driver, and connection +Implementation of Driver class +It is a wrapper around the actual psycopg2 driver, and connection object. -""" +""" import datetime -import os -import random -import select -import sys - -import simplejson as json -import psycopg2 -from flask import g, current_app, session +from flask import session from flask_babel import gettext -from flask_security import current_user -from pgadmin.utils.crypto import decrypt -from psycopg2.extensions import adapt, encodings +import psycopg2 +from psycopg2.extensions import adapt import config from pgadmin.model import Server, User -from pgadmin.utils.exception import ConnectionLost -from pgadmin.utils import get_complete_file_path from .keywords import ScanKeyword -from ..abstract import BaseDriver, BaseConnection -from .cursor import DictCursor -from .typecast import register_global_typecasters, \ - register_string_typecasters, register_binary_typecasters, \ - register_array_to_string_typecasters, ALL_JSON_TYPES -from collections import deque - - -if sys.version_info < (3,): - # Python2 in-built csv module do not handle unicode - # backports.csv module ported from PY3 csv module for unicode handling - from backports import csv - from StringIO import StringIO - IS_PY2 = True -else: - from io import StringIO - import csv - IS_PY2 = False - -_ = gettext - - -# Register global type caster which will be applicable to all connections. -register_global_typecasters() - - -class Connection(BaseConnection): - """ - class Connection(object) - - A wrapper class, which wraps the psycopg2 connection object, and - delegate the execution to the actual connection object, when required. - - Methods: - ------- - * connect(**kwargs) - - Connect the PostgreSQL/EDB Postgres Advanced Server using the psycopg2 - driver - - * execute_scalar(query, params, formatted_exception_msg) - - Execute the given query and returns single datum result - - * execute_async(query, params, formatted_exception_msg) - - Execute the given query asynchronously and returns result. - - * execute_void(query, params, formatted_exception_msg) - - Execute the given query with no result. - - * execute_2darray(query, params, formatted_exception_msg) - - Execute the given query and returns the result as a 2 dimensional - array. - - * execute_dict(query, params, formatted_exception_msg) - - Execute the given query and returns the result as an array of dict - (column name -> value) format. - - * connected() - - Get the status of the connection. - Returns True if connected, otherwise False. - - * reset() - - Reconnect the database server (if possible) - - * transaction_status() - - Transaction Status - - * ping() - - Ping the server. - - * _release() - - Release the connection object of psycopg2 - - * _reconnect() - - Attempt to reconnect to the database - - * _wait(conn) - - This method is used to wait for asynchronous connection. This is a - blocking call. - - * _wait_timeout(conn) - - This method is used to wait for asynchronous connection with timeout. - This is a non blocking call. - - * poll(formatted_exception_msg) - - This method is used to poll the data of query running on asynchronous - connection. - - * status_message() - - Returns the status message returned by the last command executed on - the server. - - * rows_affected() - - Returns the no of rows affected by the last command executed on - the server. - - * cancel_transaction(conn_id, did=None) - - This method is used to cancel the transaction for the - specified connection id and database id. - - * messages() - - Returns the list of messages/notices sends from the PostgreSQL database - server. - - * _formatted_exception_msg(exception_obj, formatted_msg) - - This method is used to parse the psycopg2.Error object and returns the - formatted error message if flag is set to true else return - normal error message. - - """ - - def __init__(self, manager, conn_id, db, auto_reconnect=True, async=0, - use_binary_placeholder=False, array_to_string=False): - assert (manager is not None) - assert (conn_id is not None) - - self.conn_id = conn_id - self.manager = manager - self.db = db if db is not None else manager.db - self.conn = None - self.auto_reconnect = auto_reconnect - self.async = async - self.__async_cursor = None - self.__async_query_id = None - self.__backend_pid = None - self.execution_aborted = False - self.row_count = 0 - self.__notices = None - self.password = None - # This flag indicates the connection status (connected/disconnected). - self.wasConnected = False - # This flag indicates the connection reconnecting status. - self.reconnecting = False - self.use_binary_placeholder = use_binary_placeholder - self.array_to_string = array_to_string - - super(Connection, self).__init__() - - def as_dict(self): - """ - Returns the dictionary object representing this object. - """ - # In case, it cannot be auto reconnectable, or already been released, - # then we will return None. - if not self.auto_reconnect and not self.conn: - return None - - res = dict() - res['conn_id'] = self.conn_id - res['database'] = self.db - res['async'] = self.async - res['wasConnected'] = self.wasConnected - res['auto_reconnect'] = self.auto_reconnect - res['use_binary_placeholder'] = self.use_binary_placeholder - res['array_to_string'] = self.array_to_string - - return res - - def __repr__(self): - return "PG Connection: {0} ({1}) -> {2} (ajax:{3})".format( - self.conn_id, self.db, - 'Connected' if self.conn and not self.conn.closed else - "Disconnected", - self.async - ) - - def __str__(self): - return "PG Connection: {0} ({1}) -> {2} (ajax:{3})".format( - self.conn_id, self.db, - 'Connected' if self.conn and not self.conn.closed else - "Disconnected", - self.async - ) - - def connect(self, **kwargs): - if self.conn: - if self.conn.closed: - self.conn = None - else: - return True, None - - pg_conn = None - password = None - passfile = None - mgr = self.manager - - encpass = kwargs['password'] if 'password' in kwargs else None - passfile = kwargs['passfile'] if 'passfile' in kwargs else None - - if encpass is None: - encpass = self.password or getattr(mgr, 'password', None) - - # Reset the existing connection password - if self.reconnecting is not False: - self.password = None - - if encpass: - # Fetch Logged in User Details. - user = User.query.filter_by(id=current_user.id).first() - - if user is None: - return False, gettext("Unauthorized request.") - - try: - password = decrypt(encpass, user.password) - # Handling of non ascii password (Python2) - if hasattr(str, 'decode'): - password = password.decode('utf-8').encode('utf-8') - # password is in bytes, for python3 we need it in string - elif isinstance(password, bytes): - password = password.decode() - - except Exception as e: - current_app.logger.exception(e) - return False, \ - _( - "Failed to decrypt the saved password.\nError: {0}" - ).format(str(e)) - - # If no password credential is found then connect request might - # come from Query tool, ViewData grid, debugger etc tools. - # we will check for pgpass file availability from connection manager - # if it's present then we will use it - if not password and not encpass and not passfile: - passfile = mgr.passfile if mgr.passfile else None - - try: - if hasattr(str, 'decode'): - database = self.db.encode('utf-8') - user = mgr.user.encode('utf-8') - conn_id = self.conn_id.encode('utf-8') - else: - database = self.db - user = mgr.user - conn_id = self.conn_id - - import os - os.environ['PGAPPNAME'] = '{0} - {1}'.format( - config.APP_NAME, conn_id) - - pg_conn = psycopg2.connect( - host=mgr.host, - hostaddr=mgr.hostaddr, - port=mgr.port, - database=database, - user=user, - password=password, - async=self.async, - passfile=get_complete_file_path(passfile), - sslmode=mgr.ssl_mode, - sslcert=get_complete_file_path(mgr.sslcert), - sslkey=get_complete_file_path(mgr.sslkey), - sslrootcert=get_complete_file_path(mgr.sslrootcert), - sslcrl=get_complete_file_path(mgr.sslcrl), - sslcompression=True if mgr.sslcompression else False - ) - - # If connection is asynchronous then we will have to wait - # until the connection is ready to use. - if self.async == 1: - self._wait(pg_conn) - - except psycopg2.Error as e: - if e.pgerror: - msg = e.pgerror - elif e.diag.message_detail: - msg = e.diag.message_detail - else: - msg = str(e) - current_app.logger.info( - u"Failed to connect to the database server(#{server_id}) for " - u"connection ({conn_id}) with error message as below" - u":{msg}".format( - server_id=self.manager.sid, - conn_id=conn_id, - msg=msg.decode('utf-8') if hasattr(str, 'decode') else msg - ) - ) - return False, msg - - # Overwrite connection notice attr to support - # more than 50 notices at a time - pg_conn.notices = deque([], self.ASYNC_NOTICE_MAXLENGTH) - self.conn = pg_conn - self.wasConnected = True - try: - status, msg = self._initialize(conn_id, **kwargs) - except Exception as e: - current_app.logger.exception(e) - self.conn = None - if not self.reconnecting: - self.wasConnected = False - raise e - - if status: - mgr._update_password(encpass) - else: - if not self.reconnecting: - self.wasConnected = False - - return status, msg - - def _initialize(self, conn_id, **kwargs): - self.execution_aborted = False - self.__backend_pid = self.conn.get_backend_pid() - - setattr(g, "{0}#{1}".format( - self.manager.sid, - self.conn_id.encode('utf-8') - ), None) - - status, cur = self.__cursor() - formatted_exception_msg = self._formatted_exception_msg - mgr = self.manager - - def _execute(cur, query, params=None): - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - return formatted_exception_msg(pe, False) - return None - - # autocommit flag does not work with asynchronous connections. - # By default asynchronous connection runs in autocommit mode. - if self.async == 0: - if 'autocommit' in kwargs and kwargs['autocommit'] is False: - self.conn.autocommit = False - else: - self.conn.autocommit = True - - register_string_typecasters(self.conn) - - if self.array_to_string: - register_array_to_string_typecasters(self.conn) - - # Register type casters for binary data only after registering array to - # string type casters. - if self.use_binary_placeholder: - register_binary_typecasters(self.conn) - - status = _execute(cur, "SET DateStyle=ISO;" - "SET client_min_messages=notice;" - "SET bytea_output=escape;" - "SET client_encoding='UNICODE';") - - if status is not None: - self.conn.close() - self.conn = None - - return False, status - - if mgr.role: - status = _execute(cur, u"SET ROLE TO %s", [mgr.role]) - - if status is not None: - self.conn.close() - self.conn = None - current_app.logger.error( - "Connect to the database server (#{server_id}) for " - "connection ({conn_id}), but - failed to setup the role " - "with error message as below:{msg}".format( - server_id=self.manager.sid, - conn_id=conn_id, - msg=status - ) - ) - return False, \ - _( - "Failed to setup the role with error message:\n{0}" - ).format(status) - - if mgr.ver is None: - status = _execute(cur, "SELECT version()") - - if status is not None: - self.conn.close() - self.conn = None - self.wasConnected = False - current_app.logger.error( - "Failed to fetch the version information on the " - "established connection to the database server " - "(#{server_id}) for '{conn_id}' with below error " - "message:{msg}".format( - server_id=self.manager.sid, - conn_id=conn_id, - msg=status) - ) - return False, status - - if cur.rowcount > 0: - row = cur.fetchmany(1)[0] - mgr.ver = row['version'] - mgr.sversion = self.conn.server_version - - status = _execute(cur, """ -SELECT - db.oid as did, db.datname, db.datallowconn, - pg_encoding_to_char(db.encoding) AS serverencoding, - has_database_privilege(db.oid, 'CREATE') as cancreate, datlastsysoid -FROM - pg_database db -WHERE db.datname = current_database()""") - - if status is None: - mgr.db_info = mgr.db_info or dict() - if cur.rowcount > 0: - res = cur.fetchmany(1)[0] - mgr.db_info[res['did']] = res.copy() - - # We do not have database oid for the maintenance database. - if len(mgr.db_info) == 1: - mgr.did = res['did'] - - status = _execute(cur, """ -SELECT - oid as id, rolname as name, rolsuper as is_superuser, - rolcreaterole as can_create_role, rolcreatedb as can_create_db -FROM - pg_catalog.pg_roles -WHERE - rolname = current_user""") - - if status is None: - mgr.user_info = dict() - if cur.rowcount > 0: - mgr.user_info = cur.fetchmany(1)[0] - - if 'password' in kwargs: - mgr.password = kwargs['password'] - - server_types = None - if 'server_types' in kwargs and isinstance( - kwargs['server_types'], list): - server_types = mgr.server_types = kwargs['server_types'] - - if server_types is None: - from pgadmin.browser.server_groups.servers.types import ServerType - server_types = ServerType.types() - - for st in server_types: - if st.instanceOf(mgr.ver): - mgr.server_type = st.stype - mgr.server_cls = st - break - - mgr.update_session() - - return True, None - - def __cursor(self, server_cursor=False): - if self.wasConnected is False: - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - cur = getattr(g, "{0}#{1}".format( - self.manager.sid, - self.conn_id.encode('utf-8') - ), None) - - if self.connected() and cur and not cur.closed: - if not server_cursor or (server_cursor and cur.name): - return True, cur - - if not self.connected(): - errmsg = "" - - current_app.logger.warning( - "Connection to database server (#{server_id}) for the " - "connection - '{conn_id}' has been lost.".format( - server_id=self.manager.sid, - conn_id=self.conn_id - ) - ) - - if self.auto_reconnect and not self.reconnecting: - self.__attempt_execution_reconnect(None) - else: - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - - try: - if server_cursor: - # Providing name to cursor will create server side cursor. - cursor_name = "CURSOR:{0}".format(self.conn_id) - cur = self.conn.cursor( - name=cursor_name, cursor_factory=DictCursor - ) - else: - cur = self.conn.cursor(cursor_factory=DictCursor) - except psycopg2.Error as pe: - current_app.logger.exception(pe) - errmsg = gettext( - "Failed to create cursor for psycopg2 connection with error " - "message for the server#{1}:{2}:\n{0}" - ).format( - str(pe), self.manager.sid, self.db - ) - - current_app.logger.error(errmsg) - if self.conn.closed: - self.conn = None - if self.auto_reconnect and not self.reconnecting: - current_app.logger.info( - gettext( - "Attempting to reconnect to the database server " - "(#{server_id}) for the connection - '{conn_id}'." - ).format( - server_id=self.manager.sid, - conn_id=self.conn_id - ) - ) - return self.__attempt_execution_reconnect( - self.__cursor, server_cursor - ) - else: - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' - else self.conn_id[5:] - ) - - setattr( - g, "{0}#{1}".format( - self.manager.sid, self.conn_id.encode('utf-8') - ), cur - ) - - return True, cur - - def __internal_blocking_execute(self, cur, query, params): - """ - This function executes the query using cursor's execute function, - but in case of asynchronous connection we need to wait for the - transaction to be completed. If self.async is 1 then it is a - blocking call. - - Args: - cur: Cursor object - query: SQL query to run. - params: Extra parameters - """ - - if sys.version_info < (3,): - if type(query) == unicode: - query = query.encode('utf-8') - else: - query = query.encode('utf-8') - - cur.execute(query, params) - if self.async == 1: - self._wait(cur.connection) - - def execute_on_server_as_csv(self, - query, params=None, - formatted_exception_msg=False, - records=2000): - """ - To fetch query result and generate CSV output - - Args: - query: SQL - params: Additional parameters - formatted_exception_msg: For exception - records: Number of initial records - Returns: - Generator response - """ - status, cur = self.__cursor(server_cursor=True) - self.row_count = 0 - - if not status: - return False, str(cur) - query_id = random.randint(1, 9999999) - - if IS_PY2 and type(query) == unicode: - query = query.encode('utf-8') - - current_app.logger.log( - 25, - u"Execute (with server cursor) for server #{server_id} - " - u"{conn_id} (Query-id: {query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query.decode('utf-8') if - sys.version_info < (3,) else query, - query_id=query_id - ) - ) - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"failed to execute query ((with server cursor) " - u"for the server #{server_id} - {conn_id} " - u"(query-id: {query_id}):\nerror message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - errmsg=errmsg, - query_id=query_id - ) - ) - return False, errmsg - - def handle_json_data(json_columns, results): - """ - [ This is only for Python2.x] - This function will be useful to handle json data types. - We will dump json data as proper json instead of unicode values - - Args: - json_columns: Columns which contains json data - results: Query result - - Returns: - results - """ - # Only if Python2 and there are columns with JSON type - if IS_PY2 and len(json_columns) > 0: - temp_results = [] - for row in results: - res = dict() - for k, v in row.items(): - if k in json_columns: - res[k] = json.dumps(v) - else: - res[k] = v - temp_results.append(res) - results = temp_results - return results - - def convert_keys_to_unicode(results, conn_encoding): - """ - [ This is only for Python2.x] - We need to convert all keys to unicode as psycopg2 - sends them as string - - Args: - res: Query result set from psycopg2 - conn_encoding: Connection encoding - - Returns: - Result set (With all the keys converted to unicode) - """ - new_results = [] - for row in results: - new_results.append( - dict([(k.decode(conn_encoding), v) - for k, v in row.items()]) - ) - return new_results - - def gen(quote='strings', quote_char="'", field_separator=','): - - results = cur.fetchmany(records) - if not results: - if not cur.closed: - cur.close() - yield gettext('The query executed did not return any data.') - return - - header = [] - json_columns = [] - conn_encoding = cur.connection.encoding - - for c in cur.ordered_description(): - # This is to handle the case in which column name is non-ascii - column_name = c.to_dict()['name'] - if IS_PY2: - column_name = column_name.decode(conn_encoding) - header.append(column_name) - if c.to_dict()['type_code'] in ALL_JSON_TYPES: - json_columns.append(column_name) - - if IS_PY2: - results = convert_keys_to_unicode(results, conn_encoding) - - res_io = StringIO() - - if quote == 'strings': - quote = csv.QUOTE_NONNUMERIC - elif quote == 'all': - quote = csv.QUOTE_ALL - else: - quote = csv.QUOTE_NONE - - if hasattr(str, 'decode'): - # Decode the field_separator - try: - field_separator = field_separator.decode('utf-8') - except Exception as e: - current_app.logger.error(e) - - # Decode the quote_char - try: - quote_char = quote_char.decode('utf-8') - except Exception as e: - current_app.logger.error(e) - - csv_writer = csv.DictWriter( - res_io, fieldnames=header, delimiter=field_separator, - quoting=quote, - quotechar=quote_char - ) - - csv_writer.writeheader() - results = handle_json_data(json_columns, results) - csv_writer.writerows(results) - - yield res_io.getvalue() - - while True: - results = cur.fetchmany(records) - - if not results: - if not cur.closed: - cur.close() - break - res_io = StringIO() - - csv_writer = csv.DictWriter( - res_io, fieldnames=header, delimiter=field_separator, - quoting=quote, - quotechar=quote_char - ) - - if IS_PY2: - results = convert_keys_to_unicode(results, conn_encoding) - - results = handle_json_data(json_columns, results) - csv_writer.writerows(results) - yield res_io.getvalue() - - return True, gen - - def execute_scalar(self, query, params=None, - formatted_exception_msg=False): - status, cur = self.__cursor() - self.row_count = 0 - - if not status: - return False, str(cur) - query_id = random.randint(1, 9999999) - - current_app.logger.log( - 25, - u"Execute (scalar) for server #{server_id} - {conn_id} (Query-id: " - u"{query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - query_id=query_id - ) - ) - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - if not self.connected(): - if self.auto_reconnect and not self.reconnecting: - return self.__attempt_execution_reconnect( - self.execute_dict, query, params, - formatted_exception_msg - ) - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"Failed to execute query (execute_scalar) for the server " - u"#{server_id} - {conn_id} (Query-id: {query_id}):\n" - u"Error Message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - errmsg=errmsg, - query_id=query_id - ) - ) - return False, errmsg - - self.row_count = cur.rowcount - if cur.rowcount > 0: - res = cur.fetchone() - if len(res) > 0: - return True, res[0] - - return True, None - - def execute_async(self, query, params=None, formatted_exception_msg=True): - """ - This function executes the given query asynchronously and returns - result. - - Args: - query: SQL query to run. - params: extra parameters to the function - formatted_exception_msg: if True then function return the - formatted exception message - """ - - if sys.version_info < (3,): - if type(query) == unicode: - query = query.encode('utf-8') - else: - query = query.encode('utf-8') - - self.__async_cursor = None - status, cur = self.__cursor() - - if not status: - return False, str(cur) - query_id = random.randint(1, 9999999) - - current_app.logger.log( - 25, - u"Execute (async) for server #{server_id} - {conn_id} (Query-id: " - u"{query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query.decode('utf-8'), - query_id=query_id - ) - ) - - try: - self.__notices = [] - self.execution_aborted = False - cur.execute(query, params) - res = self._wait_timeout(cur.connection) - except psycopg2.Error as pe: - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"Failed to execute query (execute_async) for the server " - u"#{server_id} - {conn_id}(Query-id: {query_id}):\n" - u"Error Message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query.decode('utf-8'), - errmsg=errmsg, - query_id=query_id - ) - ) - - if self.is_disconnected(pe): - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - return False, errmsg - - self.__async_cursor = cur - self.__async_query_id = query_id - - return True, res - - def execute_void(self, query, params=None, formatted_exception_msg=False): - """ - This function executes the given query with no result. - - Args: - query: SQL query to run. - params: extra parameters to the function - formatted_exception_msg: if True then function return the - formatted exception message - """ - status, cur = self.__cursor() - self.row_count = 0 - - if not status: - return False, str(cur) - query_id = random.randint(1, 9999999) - - current_app.logger.log( - 25, - u"Execute (void) for server #{server_id} - {conn_id} (Query-id: " - u"{query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - query_id=query_id - ) - ) - - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - if not self.connected(): - if self.auto_reconnect and not self.reconnecting: - return self.__attempt_execution_reconnect( - self.execute_void, query, params, - formatted_exception_msg - ) - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"Failed to execute query (execute_void) for the server " - u"#{server_id} - {conn_id}(Query-id: {query_id}):\n" - u"Error Message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - errmsg=errmsg, - query_id=query_id - ) - ) - return False, errmsg - - self.row_count = cur.rowcount - - return True, None - - def __attempt_execution_reconnect(self, fn, *args, **kwargs): - self.reconnecting = True - setattr(g, "{0}#{1}".format( - self.manager.sid, - self.conn_id.encode('utf-8') - ), None) - try: - status, res = self.connect() - if status: - if fn: - status, res = fn(*args, **kwargs) - self.reconnecting = False - return status, res - except Exception as e: - current_app.logger.exception(e) - self.reconnecting = False - - current_app.warning( - "Failed to reconnect the database server " - "(#{server_id})".format( - server_id=self.manager.sid, - conn_id=self.conn_id - ) - ) - self.reconnecting = False - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - - def execute_2darray(self, query, params=None, - formatted_exception_msg=False): - status, cur = self.__cursor() - self.row_count = 0 - - if not status: - return False, str(cur) - - query_id = random.randint(1, 9999999) - current_app.logger.log( - 25, - u"Execute (2darray) for server #{server_id} - {conn_id} " - u"(Query-id: {query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - query_id=query_id - ) - ) - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - if not self.connected(): - if self.auto_reconnect and \ - not self.reconnecting: - return self.__attempt_execution_reconnect( - self.execute_2darray, query, params, - formatted_exception_msg - ) - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"Failed to execute query (execute_2darray) for the server " - u"#{server_id} - {conn_id} (Query-id: {query_id}):\n" - u"Error Message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - errmsg=errmsg, - query_id=query_id - ) - ) - return False, errmsg - - # Get Resultset Column Name, Type and size - columns = cur.description and [ - desc.to_dict() for desc in cur.ordered_description() - ] or [] - - rows = [] - self.row_count = cur.rowcount - if cur.rowcount > 0: - for row in cur: - rows.append(row) - - return True, {'columns': columns, 'rows': rows} - - def execute_dict(self, query, params=None, formatted_exception_msg=False): - status, cur = self.__cursor() - self.row_count = 0 - - if not status: - return False, str(cur) - query_id = random.randint(1, 9999999) - current_app.logger.log( - 25, - u"Execute (dict) for server #{server_id} - {conn_id} (Query-id: " - u"{query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - query_id=query_id - ) - ) - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - if not self.connected(): - if self.auto_reconnect and not self.reconnecting: - return self.__attempt_execution_reconnect( - self.execute_dict, query, params, - formatted_exception_msg - ) - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"Failed to execute query (execute_dict) for the server " - u"#{server_id}- {conn_id} (Query-id: {query_id}):\n" - u"Error Message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query_id=query_id, - errmsg=errmsg - ) - ) - return False, errmsg - - # Get Resultset Column Name, Type and size - columns = cur.description and [ - desc.to_dict() for desc in cur.ordered_description() - ] or [] - - rows = [] - self.row_count = cur.rowcount - if cur.rowcount > 0: - for row in cur: - rows.append(dict(row)) - - return True, {'columns': columns, 'rows': rows} - - def async_fetchmany_2darray(self, records=2000, - formatted_exception_msg=False): - """ - User should poll and check if status is ASYNC_OK before calling this - function - Args: - records: no of records to fetch. use -1 to fetchall. - formatted_exception_msg: - - Returns: - - """ - cur = self.__async_cursor - if not cur: - return False, gettext( - "Cursor could not be found for the async connection." - ) - - if self.conn.isexecuting(): - return False, gettext( - "Asynchronous query execution/operation underway." - ) - - if self.row_count > 0: - result = [] - # For DDL operation, we may not have result. - # - # Because - there is not direct way to differentiate DML and - # DDL operations, we need to rely on exception to figure - # that out at the moment. - try: - if records == -1: - res = cur.fetchall() - else: - res = cur.fetchmany(records) - for row in res: - new_row = [] - for col in self.column_info: - new_row.append(row[col['name']]) - result.append(new_row) - except psycopg2.ProgrammingError as e: - result = None - else: - # User performed operation which dose not produce record/s as - # result. - # for eg. DDL operations. - return True, None - - return True, result - - def connected(self): - if self.conn: - if not self.conn.closed: - return True - self.conn = None - return False - - def reset(self): - if self.conn: - if self.conn.closed: - self.conn = None - pg_conn = None - mgr = self.manager - - password = getattr(mgr, 'password', None) - - if password: - # Fetch Logged in User Details. - user = User.query.filter_by(id=current_user.id).first() - - if user is None: - return False, gettext("Unauthorized request.") - - password = decrypt(password, user.password).decode() - - try: - pg_conn = psycopg2.connect( - host=mgr.host, - hostaddr=mgr.hostaddr, - port=mgr.port, - database=self.db, - user=mgr.user, - password=password, - passfile=get_complete_file_path(mgr.passfile), - sslmode=mgr.ssl_mode, - sslcert=get_complete_file_path(mgr.sslcert), - sslkey=get_complete_file_path(mgr.sslkey), - sslrootcert=get_complete_file_path(mgr.sslrootcert), - sslcrl=get_complete_file_path(mgr.sslcrl), - sslcompression=True if mgr.sslcompression else False - ) - - except psycopg2.Error as e: - msg = e.pgerror if e.pgerror else e.message \ - if e.message else e.diag.message_detail \ - if e.diag.message_detail else str(e) - - current_app.logger.error( - gettext( - """ -Failed to reset the connection to the server due to following error: -{0}""" - ).Format(msg) - ) - return False, msg - - pg_conn.notices = deque([], self.ASYNC_NOTICE_MAXLENGTH) - self.conn = pg_conn - self.__backend_pid = pg_conn.get_backend_pid() - - return True, None - - def transaction_status(self): - if self.conn: - return self.conn.get_transaction_status() - return None - - def ping(self): - return self.execute_scalar('SELECT 1') - - def _release(self): - if self.wasConnected: - if self.conn: - self.conn.close() - self.conn = None - self.password = None - self.wasConnected = False - - def _wait(self, conn): - """ - This function is used for the asynchronous connection, - it will call poll method in a infinite loop till poll - returns psycopg2.extensions.POLL_OK. This is a blocking - call. - - Args: - conn: connection object - """ - - while 1: - state = conn.poll() - if state == psycopg2.extensions.POLL_OK: - break - elif state == psycopg2.extensions.POLL_WRITE: - select.select([], [conn.fileno()], []) - elif state == psycopg2.extensions.POLL_READ: - select.select([conn.fileno()], [], []) - else: - raise psycopg2.OperationalError( - "poll() returned %s from _wait function" % state) - - def _wait_timeout(self, conn): - """ - This function is used for the asynchronous connection, - it will call poll method and return the status. If state is - psycopg2.extensions.POLL_WRITE and psycopg2.extensions.POLL_READ - function will wait for the given timeout.This is not a blocking call. - - Args: - conn: connection object - """ - while 1: - state = conn.poll() - if state == psycopg2.extensions.POLL_OK: - return self.ASYNC_OK - elif state == psycopg2.extensions.POLL_WRITE: - # Wait for the given time and then check the return status - # If three empty lists are returned then the time-out is - # reached. - timeout_status = select.select([], [conn.fileno()], [], - self.ASYNC_TIMEOUT) - if timeout_status == ([], [], []): - return self.ASYNC_WRITE_TIMEOUT - elif state == psycopg2.extensions.POLL_READ: - # Wait for the given time and then check the return status - # If three empty lists are returned then the time-out is - # reached. - timeout_status = select.select([conn.fileno()], [], [], - self.ASYNC_TIMEOUT) - if timeout_status == ([], [], []): - return self.ASYNC_READ_TIMEOUT - else: - raise psycopg2.OperationalError( - "poll() returned %s from _wait_timeout function" % state - ) - - def poll(self, formatted_exception_msg=False, no_result=False): - """ - This function is a wrapper around connection's poll function. - It internally uses the _wait_timeout method to poll the - result on the connection object. In case of success it - returns the result of the query. - - Args: - formatted_exception_msg: if True then function return the formatted - exception message, otherwise error string. - no_result: If True then only poll status will be returned. - """ - - cur = self.__async_cursor - if not cur: - return False, gettext( - "Cursor could not be found for the async connection." - ) - - current_app.logger.log( - 25, - "Polling result for (Query-id: {query_id})".format( - query_id=self.__async_query_id - ) - ) - - is_error = False - try: - status = self._wait_timeout(self.conn) - except psycopg2.Error as pe: - if self.conn.closed: - raise ConnectionLost( - self.manager.sid, - self.db, - self.conn_id[5:] - ) - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - is_error = True - - if self.conn.notices and self.__notices is not None: - self.__notices.extend(self.conn.notices) - self.conn.notices.clear() - - # We also need to fetch notices before we return from function in case - # of any Exception, To avoid code duplication we will return after - # fetching the notices in case of any Exception - if is_error: - return False, errmsg - - result = None - self.row_count = 0 - self.column_info = None - - if status == self.ASYNC_OK: - - # if user has cancelled the transaction then changed the status - if self.execution_aborted: - status = self.ASYNC_EXECUTION_ABORTED - self.execution_aborted = False - return status, result - - # Fetch the column information - if cur.description is not None: - self.column_info = [ - desc.to_dict() for desc in cur.ordered_description() - ] - - pos = 0 - for col in self.column_info: - col['pos'] = pos - pos += 1 - - self.row_count = cur.rowcount - if not no_result: - if cur.rowcount > 0: - result = [] - # For DDL operation, we may not have result. - # - # Because - there is not direct way to differentiate DML - # and DDL operations, we need to rely on exception to - # figure that out at the moment. - try: - for row in cur: - new_row = [] - for col in self.column_info: - new_row.append(row[col['name']]) - result.append(new_row) - - except psycopg2.ProgrammingError: - result = None - - return status, result - - def status_message(self): - """ - This function will return the status message returned by the last - command executed on the server. - """ - cur = self.__async_cursor - if not cur: - return gettext( - "Cursor could not be found for the async connection." - ) - - current_app.logger.log( - 25, - "Status message for (Query-id: {query_id})".format( - query_id=self.__async_query_id - ) - ) - - return cur.statusmessage - - def rows_affected(self): - """ - This function will return the no of rows affected by the last command - executed on the server. - """ - - return self.row_count - - def get_column_info(self): - """ - This function will returns list of columns for last async sql command - executed on the server. - """ - - return self.column_info - - def cancel_transaction(self, conn_id, did=None): - """ - This function is used to cancel the running transaction - of the given connection id and database id using - PostgreSQL's pg_cancel_backend. - - Args: - conn_id: Connection id - did: Database id (optional) - """ - cancel_conn = self.manager.connection(did=did, conn_id=conn_id) - query = """SELECT pg_cancel_backend({0});""".format( - cancel_conn.__backend_pid) - - status = True - msg = '' - - # if backend pid is same then create a new connection - # to cancel the query and release it. - if cancel_conn.__backend_pid == self.__backend_pid: - password = getattr(self.manager, 'password', None) - if password: - # Fetch Logged in User Details. - user = User.query.filter_by(id=current_user.id).first() - if user is None: - return False, gettext("Unauthorized request.") - - password = decrypt(password, user.password).decode() - - try: - pg_conn = psycopg2.connect( - host=self.manager.host, - hostaddr=self.manager.hostaddr, - port=self.manager.port, - database=self.db, - user=self.manager.user, - password=password, - passfile=get_complete_file_path(self.manager.passfile), - sslmode=self.manager.ssl_mode, - sslcert=get_complete_file_path(self.manager.sslcert), - sslkey=get_complete_file_path(self.manager.sslkey), - sslrootcert=get_complete_file_path( - self.manager.sslrootcert - ), - sslcrl=get_complete_file_path(self.manager.sslcrl), - sslcompression=True if self.manager.sslcompression - else False - ) - - # Get the cursor and run the query - cur = pg_conn.cursor() - cur.execute(query) - - # Close the connection - pg_conn.close() - pg_conn = None - - except psycopg2.Error as e: - status = False - if e.pgerror: - msg = e.pgerror - elif e.diag.message_detail: - msg = e.diag.message_detail - else: - msg = str(e) - return status, msg - else: - if self.connected(): - status, msg = self.execute_void(query) - - if status: - cancel_conn.execution_aborted = True - else: - status = False - msg = gettext("Not connected to the database server.") - - return status, msg - - def messages(self): - """ - Returns the list of the messages/notices send from the database server. - """ - resp = [] - while self.__notices: - resp.append(self.__notices.pop(0)) - return resp - - def decode_to_utf8(self, value): - """ - This method will decode values to utf-8 - Args: - value: String to be decode - - Returns: - Decoded string - """ - is_error = False - if hasattr(str, 'decode'): - try: - value = value.decode('utf-8') - except UnicodeDecodeError: - # Let's try with python's preferred encoding - # On Windows lc_messages mostly has environment dependent - # encoding like 'French_France.1252' - try: - import locale - pref_encoding = locale.getpreferredencoding() - value = value.decode(pref_encoding)\ - .encode('utf-8')\ - .decode('utf-8') - except Exception: - is_error = True - except Exception: - is_error = True - - # If still not able to decode then - if is_error: - value = value.decode('ascii', 'ignore') - - return value - - def _formatted_exception_msg(self, exception_obj, formatted_msg): - """ - This method is used to parse the psycopg2.Error object and returns the - formatted error message if flag is set to true else return - normal error message. - - Args: - exception_obj: exception object - formatted_msg: if True then function return the formatted exception - message - - """ - if exception_obj.pgerror: - errmsg = exception_obj.pgerror - elif exception_obj.diag.message_detail: - errmsg = exception_obj.diag.message_detail - else: - errmsg = str(exception_obj) - # errmsg might contains encoded value, lets decode it - errmsg = self.decode_to_utf8(errmsg) - - # if formatted_msg is false then return from the function - if not formatted_msg: - return errmsg - - # Do not append if error starts with `ERROR:` as most pg related - # error starts with `ERROR:` - if not errmsg.startswith(u'ERROR:'): - errmsg = u'ERROR: ' + errmsg + u'\n\n' - - if exception_obj.diag.severity is not None \ - and exception_obj.diag.message_primary is not None: - ex_diag_message = u"{0}: {1}".format( - exception_obj.diag.severity, - self.decode_to_utf8(exception_obj.diag.message_primary) - ) - # If both errors are different then only append it - if errmsg and ex_diag_message and \ - ex_diag_message.strip().strip('\n').lower() not in \ - errmsg.strip().strip('\n').lower(): - errmsg += ex_diag_message - elif exception_obj.diag.message_primary is not None: - message_primary = self.decode_to_utf8( - exception_obj.diag.message_primary - ) - if message_primary.lower() not in errmsg.lower(): - errmsg += message_primary - - if exception_obj.diag.sqlstate is not None: - if not errmsg.endswith('\n'): - errmsg += '\n' - errmsg += gettext('SQL state: ') - errmsg += self.decode_to_utf8(exception_obj.diag.sqlstate) - - if exception_obj.diag.message_detail is not None: - if 'Detail:'.lower() not in errmsg.lower(): - if not errmsg.endswith('\n'): - errmsg += '\n' - errmsg += gettext('Detail: ') - errmsg += self.decode_to_utf8( - exception_obj.diag.message_detail - ) - - if exception_obj.diag.message_hint is not None: - if 'Hint:'.lower() not in errmsg.lower(): - if not errmsg.endswith('\n'): - errmsg += '\n' - errmsg += gettext('Hint: ') - errmsg += self.decode_to_utf8(exception_obj.diag.message_hint) - - if exception_obj.diag.statement_position is not None: - if 'Character:'.lower() not in errmsg.lower(): - if not errmsg.endswith('\n'): - errmsg += '\n' - errmsg += gettext('Character: ') - errmsg += self.decode_to_utf8( - exception_obj.diag.statement_position - ) - - if exception_obj.diag.context is not None: - if 'Context:'.lower() not in errmsg.lower(): - if not errmsg.endswith('\n'): - errmsg += '\n' - errmsg += gettext('Context: ') - errmsg += self.decode_to_utf8(exception_obj.diag.context) - - return errmsg - - ##### - # As per issue reported on pgsycopg2 github repository link is shared below - # conn.closed is not reliable enough to identify the disconnection from the - # database server for some unknown reasons. - # - # (https://github.com/psycopg/psycopg2/issues/263) - # - # In order to resolve the issue, sqlalchamey follows the below logic to - # identify the disconnection. It relies on exception message to identify - # the error. - # - # Reference (MIT license): - # https://github.com/zzzeek/sqlalchemy/blob/master/lib/sqlalchemy/dialects/postgresql/psycopg2.py - # - def is_disconnected(self, err): - if not self.conn.closed: - # checks based on strings. in the case that .closed - # didn't cut it, fall back onto these. - str_e = str(err).partition("\n")[0] - for msg in [ - # these error messages from libpq: interfaces/libpq/fe-misc.c - # and interfaces/libpq/fe-secure.c. - 'terminating connection', - 'closed the connection', - 'connection not open', - 'could not receive data from server', - 'could not send data to server', - # psycopg2 client errors, psycopg2/conenction.h, - # psycopg2/cursor.h - 'connection already closed', - 'cursor already closed', - # not sure where this path is originally from, it may - # be obsolete. It really says "losed", not "closed". - 'losed the connection unexpectedly', - # these can occur in newer SSL - 'connection has been closed unexpectedly', - 'SSL SYSCALL error: Bad file descriptor', - 'SSL SYSCALL error: EOF detected', - ]: - idx = str_e.find(msg) - if idx >= 0 and '"' not in str_e[:idx]: - return True - - return False - return True - - -class ServerManager(object): - """ - class ServerManager - - This class contains the information about the given server. - And, acts as connection manager for that particular session. - """ - - def __init__(self, server): - self.connections = dict() - - self.update(server) - - def update(self, server): - assert (server is not None) - assert (isinstance(server, Server)) - - self.ver = None - self.sversion = None - self.server_type = None - self.server_cls = None - self.password = None - - self.sid = server.id - self.host = server.host - self.hostaddr = server.hostaddr - self.port = server.port - self.db = server.maintenance_db - self.did = None - self.user = server.username - self.password = server.password - self.role = server.role - self.ssl_mode = server.ssl_mode - self.pinged = datetime.datetime.now() - self.db_info = dict() - self.server_types = None - self.db_res = server.db_res - self.passfile = server.passfile - self.sslcert = server.sslcert - self.sslkey = server.sslkey - self.sslrootcert = server.sslrootcert - self.sslcrl = server.sslcrl - self.sslcompression = True if server.sslcompression else False - - for con in self.connections: - self.connections[con]._release() - - self.update_session() - - self.connections = dict() - - def as_dict(self): - """ - Returns a dictionary object representing the server manager. - """ - if self.ver is None or len(self.connections) == 0: - return None - - res = dict() - res['sid'] = self.sid - res['ver'] = self.ver - res['sversion'] = self.sversion - if hasattr(self, 'password') and self.password: - # If running under PY2 - if hasattr(self.password, 'decode'): - res['password'] = self.password.decode('utf-8') - else: - res['password'] = str(self.password) - else: - res['password'] = self.password - - connections = res['connections'] = dict() - - for conn_id in self.connections: - conn = self.connections[conn_id].as_dict() - - if conn is not None: - connections[conn_id] = conn - - return res - - def ServerVersion(self): - return self.ver - - @property - def version(self): - return self.sversion - - def MajorVersion(self): - if self.sversion is not None: - return int(self.sversion / 10000) - raise Exception("Information is not available.") - - def MinorVersion(self): - if self.sversion: - return int(int(self.sversion / 100) % 100) - raise Exception("Information is not available.") - - def PatchVersion(self): - if self.sversion: - return int(int(self.sversion / 100) / 100) - raise Exception("Information is not available.") - - def connection( - self, database=None, conn_id=None, auto_reconnect=True, did=None, - async=None, use_binary_placeholder=False, array_to_string=False - ): - if database is not None: - if hasattr(str, 'decode') and \ - not isinstance(database, unicode): - database = database.decode('utf-8') - if did is not None: - if did in self.db_info: - self.db_info[did]['datname'] = database - else: - if did is None: - database = self.db - elif did in self.db_info: - database = self.db_info[did]['datname'] - else: - maintenance_db_id = u'DB:{0}'.format(self.db) - if maintenance_db_id in self.connections: - conn = self.connections[maintenance_db_id] - if conn.connected(): - status, res = conn.execute_dict(u""" -SELECT - db.oid as did, db.datname, db.datallowconn, - pg_encoding_to_char(db.encoding) AS serverencoding, - has_database_privilege(db.oid, 'CREATE') as cancreate, datlastsysoid -FROM - pg_database db -WHERE db.oid = {0}""".format(did)) - - if status and len(res['rows']) > 0: - for row in res['rows']: - self.db_info[did] = row - database = self.db_info[did]['datname'] - - if did not in self.db_info: - raise Exception(gettext( - "Could not find the specified database." - )) - - if database is None: - raise ConnectionLost(self.sid, None, None) - - my_id = (u'CONN:{0}'.format(conn_id)) if conn_id is not None else \ - (u'DB:{0}'.format(database)) - - self.pinged = datetime.datetime.now() - - if my_id in self.connections: - return self.connections[my_id] - else: - if async is None: - async = 1 if conn_id is not None else 0 - else: - async = 1 if async is True else 0 - self.connections[my_id] = Connection( - self, my_id, database, auto_reconnect, async, - use_binary_placeholder=use_binary_placeholder, - array_to_string=array_to_string - ) - - return self.connections[my_id] - - def _restore(self, data): - """ - Helps restoring to reconnect the auto-connect connections smoothly on - reload/restart of the app server.. - """ - # restore server version from flask session if flask server was - # restarted. As we need server version to resolve sql template paths. - - self.ver = data.get('ver', None) - self.sversion = data.get('sversion', None) - - if self.ver and not self.server_type: - from pgadmin.browser.server_groups.servers.types import ServerType - for st in ServerType.types(): - if st.instanceOf(self.ver): - self.server_type = st.stype - self.server_cls = st - break - - # Hmm.. we will not honour this request, when I already have - # connections - if len(self.connections) != 0: - return - - # We need to know about the existing server variant supports during - # first connection for identifications. - from pgadmin.browser.server_groups.servers.types import ServerType - self.pinged = datetime.datetime.now() - try: - if 'password' in data and data['password']: - data['password'] = data['password'].encode('utf-8') - except Exception as e: - current_app.logger.exception(e) - - connections = data['connections'] - for conn_id in connections: - conn_info = connections[conn_id] - conn = self.connections[conn_info['conn_id']] = Connection( - self, conn_info['conn_id'], conn_info['database'], - conn_info['auto_reconnect'], conn_info['async'], - use_binary_placeholder=conn_info['use_binary_placeholder'], - array_to_string=conn_info['array_to_string'] - ) - - # only try to reconnect if connection was connected previously and - # auto_reconnect is true. - if conn_info['wasConnected'] and conn_info['auto_reconnect']: - try: - conn.connect( - password=data['password'], - server_types=ServerType.types() - ) - # This will also update wasConnected flag in connection so - # no need to update the flag manually. - except Exception as e: - current_app.logger.exception(e) - self.connections.pop(conn_info['conn_id']) - - def release(self, database=None, conn_id=None, did=None): - if did is not None: - if did in self.db_info and 'datname' in self.db_info[did]: - database = self.db_info[did]['datname'] - if hasattr(str, 'decode') and \ - not isinstance(database, unicode): - database = database.decode('utf-8') - if database is None: - return False - else: - return False - - my_id = (u'CONN:{0}'.format(conn_id)) if conn_id is not None else \ - (u'DB:{0}'.format(database)) if database is not None else None - - if my_id is not None: - if my_id in self.connections: - self.connections[my_id]._release() - del self.connections[my_id] - if did is not None: - del self.db_info[did] - - if len(self.connections) == 0: - self.ver = None - self.sversion = None - self.server_type = None - self.server_cls = None - self.password = None - - self.update_session() - - return True - else: - return False - - for con in self.connections: - self.connections[con]._release() - - self.connections = dict() - self.ver = None - self.sversion = None - self.server_type = None - self.server_cls = None - self.password = None - - self.update_session() - - return True - - def _update_password(self, passwd): - self.password = passwd - for conn_id in self.connections: - conn = self.connections[conn_id] - if conn.conn is not None or conn.wasConnected is True: - conn.password = passwd - - def update_session(self): - managers = session['__pgsql_server_managers'] \ - if '__pgsql_server_managers' in session else dict() - updated_mgr = self.as_dict() - - if not updated_mgr: - if self.sid in managers: - managers.pop(self.sid) - else: - managers[self.sid] = updated_mgr - session['__pgsql_server_managers'] = managers - session.force_write = True - - def utility(self, operation): - """ - utility(operation) - - Returns: name of the utility which used for the operation - """ - if self.server_cls is not None: - return self.server_cls.utility(operation, self.sversion) - - return None - - def export_password_env(self, env): - if self.password: - password = decrypt( - self.password, current_user.password - ).decode() - os.environ[str(env)] = password +from ..abstract import BaseDriver +from .connection import Connection +from .server_manager import ServerManager class Driver(BaseDriver): @@ -2164,8 +202,9 @@ class Driver(BaseDriver): continue if curr_time - sess_mgr['pinged'] >= session_idle_timeout: - for mgr in [m for m in sess_mgr if isinstance(m, - ServerManager)]: + for mgr in [ + m for m in sess_mgr if isinstance(m, ServerManager) + ]: mgr.release() @staticmethod diff --git a/web/pgadmin/utils/driver/psycopg2/connection.py b/web/pgadmin/utils/driver/psycopg2/connection.py new file mode 100644 index 0000000..d4c9573 --- /dev/null +++ b/web/pgadmin/utils/driver/psycopg2/connection.py @@ -0,0 +1,1682 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2018, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +""" +Implementation of Connection. +It is a wrapper around the actual psycopg2 driver, and connection +object. +""" + +import random +import select +import sys +from collections import deque +import simplejson as json +import psycopg2 +from flask import g, current_app +from flask_babel import gettext +from flask_security import current_user +from pgadmin.utils.crypto import decrypt +from psycopg2.extensions import adapt, encodings + +import config +from pgadmin.model import Server, User +from pgadmin.utils.exception import ConnectionLost +from pgadmin.utils import get_complete_file_path +from ..abstract import BaseDriver, BaseConnection +from .cursor import DictCursor +from .typecast import register_global_typecasters, \ + register_string_typecasters, register_binary_typecasters, \ + register_array_to_string_typecasters, ALL_JSON_TYPES + + +if sys.version_info < (3,): + # Python2 in-built csv module do not handle unicode + # backports.csv module ported from PY3 csv module for unicode handling + from backports import csv + from StringIO import StringIO + IS_PY2 = True +else: + from io import StringIO + import csv + IS_PY2 = False + +_ = gettext + + +# Register global type caster which will be applicable to all connections. +register_global_typecasters() + + +class Connection(BaseConnection): + """ + class Connection(object) + + A wrapper class, which wraps the psycopg2 connection object, and + delegate the execution to the actual connection object, when required. + + Methods: + ------- + * connect(**kwargs) + - Connect the PostgreSQL/EDB Postgres Advanced Server using the psycopg2 + driver + + * execute_scalar(query, params, formatted_exception_msg) + - Execute the given query and returns single datum result + + * execute_async(query, params, formatted_exception_msg) + - Execute the given query asynchronously and returns result. + + * execute_void(query, params, formatted_exception_msg) + - Execute the given query with no result. + + * execute_2darray(query, params, formatted_exception_msg) + - Execute the given query and returns the result as a 2 dimensional + array. + + * execute_dict(query, params, formatted_exception_msg) + - Execute the given query and returns the result as an array of dict + (column name -> value) format. + + * connected() + - Get the status of the connection. + Returns True if connected, otherwise False. + + * reset() + - Reconnect the database server (if possible) + + * transaction_status() + - Transaction Status + + * ping() + - Ping the server. + + * _release() + - Release the connection object of psycopg2 + + * _reconnect() + - Attempt to reconnect to the database + + * _wait(conn) + - This method is used to wait for asynchronous connection. This is a + blocking call. + + * _wait_timeout(conn) + - This method is used to wait for asynchronous connection with timeout. + This is a non blocking call. + + * poll(formatted_exception_msg) + - This method is used to poll the data of query running on asynchronous + connection. + + * status_message() + - Returns the status message returned by the last command executed on + the server. + + * rows_affected() + - Returns the no of rows affected by the last command executed on + the server. + + * cancel_transaction(conn_id, did=None) + - This method is used to cancel the transaction for the + specified connection id and database id. + + * messages() + - Returns the list of messages/notices sends from the PostgreSQL database + server. + + * _formatted_exception_msg(exception_obj, formatted_msg) + - This method is used to parse the psycopg2.Error object and returns the + formatted error message if flag is set to true else return + normal error message. + + """ + + def __init__(self, manager, conn_id, db, auto_reconnect=True, async=0, + use_binary_placeholder=False, array_to_string=False): + assert (manager is not None) + assert (conn_id is not None) + + self.conn_id = conn_id + self.manager = manager + self.db = db if db is not None else manager.db + self.conn = None + self.auto_reconnect = auto_reconnect + self.async = async + self.__async_cursor = None + self.__async_query_id = None + self.__backend_pid = None + self.execution_aborted = False + self.row_count = 0 + self.__notices = None + self.password = None + # This flag indicates the connection status (connected/disconnected). + self.wasConnected = False + # This flag indicates the connection reconnecting status. + self.reconnecting = False + self.use_binary_placeholder = use_binary_placeholder + self.array_to_string = array_to_string + + super(Connection, self).__init__() + + def as_dict(self): + """ + Returns the dictionary object representing this object. + """ + # In case, it cannot be auto reconnectable, or already been released, + # then we will return None. + if not self.auto_reconnect and not self.conn: + return None + + res = dict() + res['conn_id'] = self.conn_id + res['database'] = self.db + res['async'] = self.async + res['wasConnected'] = self.wasConnected + res['auto_reconnect'] = self.auto_reconnect + res['use_binary_placeholder'] = self.use_binary_placeholder + res['array_to_string'] = self.array_to_string + + return res + + def __repr__(self): + return "PG Connection: {0} ({1}) -> {2} (ajax:{3})".format( + self.conn_id, self.db, + 'Connected' if self.conn and not self.conn.closed else + "Disconnected", + self.async + ) + + def __str__(self): + return "PG Connection: {0} ({1}) -> {2} (ajax:{3})".format( + self.conn_id, self.db, + 'Connected' if self.conn and not self.conn.closed else + "Disconnected", + self.async + ) + + def connect(self, **kwargs): + if self.conn: + if self.conn.closed: + self.conn = None + else: + return True, None + + pg_conn = None + password = None + passfile = None + mgr = self.manager + + encpass = kwargs['password'] if 'password' in kwargs else None + passfile = kwargs['passfile'] if 'passfile' in kwargs else None + + if encpass is None: + encpass = self.password or getattr(mgr, 'password', None) + + # Reset the existing connection password + if self.reconnecting is not False: + self.password = None + + if encpass: + # Fetch Logged in User Details. + user = User.query.filter_by(id=current_user.id).first() + + if user is None: + return False, gettext("Unauthorized request.") + + try: + password = decrypt(encpass, user.password) + # Handling of non ascii password (Python2) + if hasattr(str, 'decode'): + password = password.decode('utf-8').encode('utf-8') + # password is in bytes, for python3 we need it in string + elif isinstance(password, bytes): + password = password.decode() + + except Exception as e: + current_app.logger.exception(e) + return False, \ + _( + "Failed to decrypt the saved password.\nError: {0}" + ).format(str(e)) + + # If no password credential is found then connect request might + # come from Query tool, ViewData grid, debugger etc tools. + # we will check for pgpass file availability from connection manager + # if it's present then we will use it + if not password and not encpass and not passfile: + passfile = mgr.passfile if mgr.passfile else None + + try: + if hasattr(str, 'decode'): + database = self.db.encode('utf-8') + user = mgr.user.encode('utf-8') + conn_id = self.conn_id.encode('utf-8') + else: + database = self.db + user = mgr.user + conn_id = self.conn_id + + import os + os.environ['PGAPPNAME'] = '{0} - {1}'.format( + config.APP_NAME, conn_id) + + pg_conn = psycopg2.connect( + host=mgr.host, + hostaddr=mgr.hostaddr, + port=mgr.port, + database=database, + user=user, + password=password, + async=self.async, + passfile=get_complete_file_path(passfile), + sslmode=mgr.ssl_mode, + sslcert=get_complete_file_path(mgr.sslcert), + sslkey=get_complete_file_path(mgr.sslkey), + sslrootcert=get_complete_file_path(mgr.sslrootcert), + sslcrl=get_complete_file_path(mgr.sslcrl), + sslcompression=True if mgr.sslcompression else False, + service=mgr.service + ) + + # If connection is asynchronous then we will have to wait + # until the connection is ready to use. + if self.async == 1: + self._wait(pg_conn) + + except psycopg2.Error as e: + if e.pgerror: + msg = e.pgerror + elif e.diag.message_detail: + msg = e.diag.message_detail + else: + msg = str(e) + current_app.logger.info( + u"Failed to connect to the database server(#{server_id}) for " + u"connection ({conn_id}) with error message as below" + u":{msg}".format( + server_id=self.manager.sid, + conn_id=conn_id, + msg=msg.decode('utf-8') if hasattr(str, 'decode') else msg + ) + ) + return False, msg + + # Overwrite connection notice attr to support + # more than 50 notices at a time + pg_conn.notices = deque([], self.ASYNC_NOTICE_MAXLENGTH) + + self.conn = pg_conn + self.wasConnected = True + try: + status, msg = self._initialize(conn_id, **kwargs) + except Exception as e: + current_app.logger.exception(e) + self.conn = None + if not self.reconnecting: + self.wasConnected = False + raise e + + if status: + mgr._update_password(encpass) + else: + if not self.reconnecting: + self.wasConnected = False + + return status, msg + + def _initialize(self, conn_id, **kwargs): + self.execution_aborted = False + self.__backend_pid = self.conn.get_backend_pid() + + setattr(g, "{0}#{1}".format( + self.manager.sid, + self.conn_id.encode('utf-8') + ), None) + + status, cur = self.__cursor() + formatted_exception_msg = self._formatted_exception_msg + mgr = self.manager + + def _execute(cur, query, params=None): + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + return formatted_exception_msg(pe, False) + return None + + # autocommit flag does not work with asynchronous connections. + # By default asynchronous connection runs in autocommit mode. + if self.async == 0: + if 'autocommit' in kwargs and kwargs['autocommit'] is False: + self.conn.autocommit = False + else: + self.conn.autocommit = True + + register_string_typecasters(self.conn) + + if self.array_to_string: + register_array_to_string_typecasters(self.conn) + + # Register type casters for binary data only after registering array to + # string type casters. + if self.use_binary_placeholder: + register_binary_typecasters(self.conn) + + status = _execute(cur, "SET DateStyle=ISO;" + "SET client_min_messages=notice;" + "SET bytea_output=escape;" + "SET client_encoding='UNICODE';") + + if status is not None: + self.conn.close() + self.conn = None + + return False, status + + if mgr.role: + status = _execute(cur, u"SET ROLE TO %s", [mgr.role]) + + if status is not None: + self.conn.close() + self.conn = None + current_app.logger.error( + "Connect to the database server (#{server_id}) for " + "connection ({conn_id}), but - failed to setup the role " + "with error message as below:{msg}".format( + server_id=self.manager.sid, + conn_id=conn_id, + msg=status + ) + ) + return False, \ + _( + "Failed to setup the role with error message:\n{0}" + ).format(status) + + if mgr.ver is None: + status = _execute(cur, "SELECT version()") + + if status is not None: + self.conn.close() + self.conn = None + self.wasConnected = False + current_app.logger.error( + "Failed to fetch the version information on the " + "established connection to the database server " + "(#{server_id}) for '{conn_id}' with below error " + "message:{msg}".format( + server_id=self.manager.sid, + conn_id=conn_id, + msg=status) + ) + return False, status + + if cur.rowcount > 0: + row = cur.fetchmany(1)[0] + mgr.ver = row['version'] + mgr.sversion = self.conn.server_version + + status = _execute(cur, """ +SELECT + db.oid as did, db.datname, db.datallowconn, + pg_encoding_to_char(db.encoding) AS serverencoding, + has_database_privilege(db.oid, 'CREATE') as cancreate, datlastsysoid +FROM + pg_database db +WHERE db.datname = current_database()""") + + if status is None: + mgr.db_info = mgr.db_info or dict() + if cur.rowcount > 0: + res = cur.fetchmany(1)[0] + mgr.db_info[res['did']] = res.copy() + + # We do not have database oid for the maintenance database. + if len(mgr.db_info) == 1: + mgr.did = res['did'] + + status = _execute(cur, """ +SELECT + oid as id, rolname as name, rolsuper as is_superuser, + rolcreaterole as can_create_role, rolcreatedb as can_create_db +FROM + pg_catalog.pg_roles +WHERE + rolname = current_user""") + + if status is None: + mgr.user_info = dict() + if cur.rowcount > 0: + mgr.user_info = cur.fetchmany(1)[0] + + if 'password' in kwargs: + mgr.password = kwargs['password'] + + server_types = None + if 'server_types' in kwargs and isinstance( + kwargs['server_types'], list): + server_types = mgr.server_types = kwargs['server_types'] + + if server_types is None: + from pgadmin.browser.server_groups.servers.types import ServerType + server_types = ServerType.types() + + for st in server_types: + if st.instanceOf(mgr.ver): + mgr.server_type = st.stype + mgr.server_cls = st + break + + mgr.update_session() + + return True, None + + def __cursor(self, server_cursor=False): + if self.wasConnected is False: + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + cur = getattr(g, "{0}#{1}".format( + self.manager.sid, + self.conn_id.encode('utf-8') + ), None) + + if self.connected() and cur and not cur.closed: + if not server_cursor or (server_cursor and cur.name): + return True, cur + + if not self.connected(): + errmsg = "" + + current_app.logger.warning( + "Connection to database server (#{server_id}) for the " + "connection - '{conn_id}' has been lost.".format( + server_id=self.manager.sid, + conn_id=self.conn_id + ) + ) + + if self.auto_reconnect and not self.reconnecting: + self.__attempt_execution_reconnect(None) + else: + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + + try: + if server_cursor: + # Providing name to cursor will create server side cursor. + cursor_name = "CURSOR:{0}".format(self.conn_id) + cur = self.conn.cursor( + name=cursor_name, cursor_factory=DictCursor + ) + else: + cur = self.conn.cursor(cursor_factory=DictCursor) + except psycopg2.Error as pe: + current_app.logger.exception(pe) + errmsg = gettext( + "Failed to create cursor for psycopg2 connection with error " + "message for the server#{1}:{2}:\n{0}" + ).format( + str(pe), self.manager.sid, self.db + ) + + current_app.logger.error(errmsg) + if self.conn.closed: + self.conn = None + if self.auto_reconnect and not self.reconnecting: + current_app.logger.info( + gettext( + "Attempting to reconnect to the database server " + "(#{server_id}) for the connection - '{conn_id}'." + ).format( + server_id=self.manager.sid, + conn_id=self.conn_id + ) + ) + return self.__attempt_execution_reconnect( + self.__cursor, server_cursor + ) + else: + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' + else self.conn_id[5:] + ) + + setattr( + g, "{0}#{1}".format( + self.manager.sid, self.conn_id.encode('utf-8') + ), cur + ) + + return True, cur + + def __internal_blocking_execute(self, cur, query, params): + """ + This function executes the query using cursor's execute function, + but in case of asynchronous connection we need to wait for the + transaction to be completed. If self.async is 1 then it is a + blocking call. + + Args: + cur: Cursor object + query: SQL query to run. + params: Extra parameters + """ + + if sys.version_info < (3,): + if type(query) == unicode: + query = query.encode('utf-8') + else: + query = query.encode('utf-8') + + cur.execute(query, params) + if self.async == 1: + self._wait(cur.connection) + + def execute_on_server_as_csv(self, + query, params=None, + formatted_exception_msg=False, + records=2000): + """ + To fetch query result and generate CSV output + + Args: + query: SQL + params: Additional parameters + formatted_exception_msg: For exception + records: Number of initial records + Returns: + Generator response + """ + status, cur = self.__cursor(server_cursor=True) + self.row_count = 0 + + if not status: + return False, str(cur) + query_id = random.randint(1, 9999999) + + if IS_PY2 and type(query) == unicode: + query = query.encode('utf-8') + + current_app.logger.log( + 25, + u"Execute (with server cursor) for server #{server_id} - " + u"{conn_id} (Query-id: {query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query.decode('utf-8') if + sys.version_info < (3,) else query, + query_id=query_id + ) + ) + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"failed to execute query ((with server cursor) " + u"for the server #{server_id} - {conn_id} " + u"(query-id: {query_id}):\nerror message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + errmsg=errmsg, + query_id=query_id + ) + ) + return False, errmsg + + def handle_json_data(json_columns, results): + """ + [ This is only for Python2.x] + This function will be useful to handle json data types. + We will dump json data as proper json instead of unicode values + + Args: + json_columns: Columns which contains json data + results: Query result + + Returns: + results + """ + # Only if Python2 and there are columns with JSON type + if IS_PY2 and len(json_columns) > 0: + temp_results = [] + for row in results: + res = dict() + for k, v in row.items(): + if k in json_columns: + res[k] = json.dumps(v) + else: + res[k] = v + temp_results.append(res) + results = temp_results + return results + + def convert_keys_to_unicode(results, conn_encoding): + """ + [ This is only for Python2.x] + We need to convert all keys to unicode as psycopg2 + sends them as string + + Args: + res: Query result set from psycopg2 + conn_encoding: Connection encoding + + Returns: + Result set (With all the keys converted to unicode) + """ + new_results = [] + for row in results: + new_results.append( + dict([(k.decode(conn_encoding), v) + for k, v in row.items()]) + ) + return new_results + + def gen(quote='strings', quote_char="'", field_separator=','): + + results = cur.fetchmany(records) + if not results: + if not cur.closed: + cur.close() + yield gettext('The query executed did not return any data.') + return + + header = [] + json_columns = [] + conn_encoding = cur.connection.encoding + + for c in cur.ordered_description(): + # This is to handle the case in which column name is non-ascii + column_name = c.to_dict()['name'] + if IS_PY2: + column_name = column_name.decode(conn_encoding) + header.append(column_name) + if c.to_dict()['type_code'] in ALL_JSON_TYPES: + json_columns.append(column_name) + + if IS_PY2: + results = convert_keys_to_unicode(results, conn_encoding) + + res_io = StringIO() + + if quote == 'strings': + quote = csv.QUOTE_NONNUMERIC + elif quote == 'all': + quote = csv.QUOTE_ALL + else: + quote = csv.QUOTE_NONE + + if hasattr(str, 'decode'): + # Decode the field_separator + try: + field_separator = field_separator.decode('utf-8') + except Exception as e: + current_app.logger.error(e) + + # Decode the quote_char + try: + quote_char = quote_char.decode('utf-8') + except Exception as e: + current_app.logger.error(e) + + csv_writer = csv.DictWriter( + res_io, fieldnames=header, delimiter=field_separator, + quoting=quote, + quotechar=quote_char + ) + + csv_writer.writeheader() + results = handle_json_data(json_columns, results) + csv_writer.writerows(results) + + yield res_io.getvalue() + + while True: + results = cur.fetchmany(records) + + if not results: + if not cur.closed: + cur.close() + break + res_io = StringIO() + + csv_writer = csv.DictWriter( + res_io, fieldnames=header, delimiter=field_separator, + quoting=quote, + quotechar=quote_char + ) + + if IS_PY2: + results = convert_keys_to_unicode(results, conn_encoding) + + results = handle_json_data(json_columns, results) + csv_writer.writerows(results) + yield res_io.getvalue() + + return True, gen + + def execute_scalar(self, query, params=None, + formatted_exception_msg=False): + status, cur = self.__cursor() + self.row_count = 0 + + if not status: + return False, str(cur) + query_id = random.randint(1, 9999999) + + current_app.logger.log( + 25, + u"Execute (scalar) for server #{server_id} - {conn_id} (Query-id: " + u"{query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + query_id=query_id + ) + ) + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + if not self.connected(): + if self.auto_reconnect and not self.reconnecting: + return self.__attempt_execution_reconnect( + self.execute_dict, query, params, + formatted_exception_msg + ) + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"Failed to execute query (execute_scalar) for the server " + u"#{server_id} - {conn_id} (Query-id: {query_id}):\n" + u"Error Message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + errmsg=errmsg, + query_id=query_id + ) + ) + return False, errmsg + + self.row_count = cur.rowcount + if cur.rowcount > 0: + res = cur.fetchone() + if len(res) > 0: + return True, res[0] + + return True, None + + def execute_async(self, query, params=None, formatted_exception_msg=True): + """ + This function executes the given query asynchronously and returns + result. + + Args: + query: SQL query to run. + params: extra parameters to the function + formatted_exception_msg: if True then function return the + formatted exception message + """ + + if sys.version_info < (3,): + if type(query) == unicode: + query = query.encode('utf-8') + else: + query = query.encode('utf-8') + + self.__async_cursor = None + status, cur = self.__cursor() + + if not status: + return False, str(cur) + query_id = random.randint(1, 9999999) + + current_app.logger.log( + 25, + u"Execute (async) for server #{server_id} - {conn_id} (Query-id: " + u"{query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query.decode('utf-8'), + query_id=query_id + ) + ) + + try: + self.__notices = [] + self.execution_aborted = False + cur.execute(query, params) + res = self._wait_timeout(cur.connection) + except psycopg2.Error as pe: + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"Failed to execute query (execute_async) for the server " + u"#{server_id} - {conn_id}(Query-id: {query_id}):\n" + u"Error Message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query.decode('utf-8'), + errmsg=errmsg, + query_id=query_id + ) + ) + + if self.is_disconnected(pe): + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + return False, errmsg + + self.__async_cursor = cur + self.__async_query_id = query_id + + return True, res + + def execute_void(self, query, params=None, formatted_exception_msg=False): + """ + This function executes the given query with no result. + + Args: + query: SQL query to run. + params: extra parameters to the function + formatted_exception_msg: if True then function return the + formatted exception message + """ + status, cur = self.__cursor() + self.row_count = 0 + + if not status: + return False, str(cur) + query_id = random.randint(1, 9999999) + + current_app.logger.log( + 25, + u"Execute (void) for server #{server_id} - {conn_id} (Query-id: " + u"{query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + query_id=query_id + ) + ) + + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + if not self.connected(): + if self.auto_reconnect and not self.reconnecting: + return self.__attempt_execution_reconnect( + self.execute_void, query, params, + formatted_exception_msg + ) + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"Failed to execute query (execute_void) for the server " + u"#{server_id} - {conn_id}(Query-id: {query_id}):\n" + u"Error Message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + errmsg=errmsg, + query_id=query_id + ) + ) + return False, errmsg + + self.row_count = cur.rowcount + + return True, None + + def __attempt_execution_reconnect(self, fn, *args, **kwargs): + self.reconnecting = True + setattr(g, "{0}#{1}".format( + self.manager.sid, + self.conn_id.encode('utf-8') + ), None) + try: + status, res = self.connect() + if status: + if fn: + status, res = fn(*args, **kwargs) + self.reconnecting = False + return status, res + except Exception as e: + current_app.logger.exception(e) + self.reconnecting = False + + current_app.warning( + "Failed to reconnect the database server " + "(#{server_id})".format( + server_id=self.manager.sid, + conn_id=self.conn_id + ) + ) + self.reconnecting = False + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + + def execute_2darray(self, query, params=None, + formatted_exception_msg=False): + status, cur = self.__cursor() + self.row_count = 0 + + if not status: + return False, str(cur) + + query_id = random.randint(1, 9999999) + current_app.logger.log( + 25, + u"Execute (2darray) for server #{server_id} - {conn_id} " + u"(Query-id: {query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + query_id=query_id + ) + ) + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + if not self.connected(): + if self.auto_reconnect and \ + not self.reconnecting: + return self.__attempt_execution_reconnect( + self.execute_2darray, query, params, + formatted_exception_msg + ) + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"Failed to execute query (execute_2darray) for the server " + u"#{server_id} - {conn_id} (Query-id: {query_id}):\n" + u"Error Message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + errmsg=errmsg, + query_id=query_id + ) + ) + return False, errmsg + + # Get Resultset Column Name, Type and size + columns = cur.description and [ + desc.to_dict() for desc in cur.ordered_description() + ] or [] + + rows = [] + self.row_count = cur.rowcount + if cur.rowcount > 0: + for row in cur: + rows.append(row) + + return True, {'columns': columns, 'rows': rows} + + def execute_dict(self, query, params=None, formatted_exception_msg=False): + status, cur = self.__cursor() + self.row_count = 0 + + if not status: + return False, str(cur) + query_id = random.randint(1, 9999999) + current_app.logger.log( + 25, + u"Execute (dict) for server #{server_id} - {conn_id} (Query-id: " + u"{query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + query_id=query_id + ) + ) + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + if not self.connected(): + if self.auto_reconnect and not self.reconnecting: + return self.__attempt_execution_reconnect( + self.execute_dict, query, params, + formatted_exception_msg + ) + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"Failed to execute query (execute_dict) for the server " + u"#{server_id}- {conn_id} (Query-id: {query_id}):\n" + u"Error Message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query_id=query_id, + errmsg=errmsg + ) + ) + return False, errmsg + + # Get Resultset Column Name, Type and size + columns = cur.description and [ + desc.to_dict() for desc in cur.ordered_description() + ] or [] + + rows = [] + self.row_count = cur.rowcount + if cur.rowcount > 0: + for row in cur: + rows.append(dict(row)) + + return True, {'columns': columns, 'rows': rows} + + def async_fetchmany_2darray(self, records=2000, + formatted_exception_msg=False): + """ + User should poll and check if status is ASYNC_OK before calling this + function + Args: + records: no of records to fetch. use -1 to fetchall. + formatted_exception_msg: + + Returns: + + """ + cur = self.__async_cursor + if not cur: + return False, gettext( + "Cursor could not be found for the async connection." + ) + + if self.conn.isexecuting(): + return False, gettext( + "Asynchronous query execution/operation underway." + ) + + if self.row_count > 0: + result = [] + # For DDL operation, we may not have result. + # + # Because - there is not direct way to differentiate DML and + # DDL operations, we need to rely on exception to figure + # that out at the moment. + try: + if records == -1: + res = cur.fetchall() + else: + res = cur.fetchmany(records) + for row in res: + new_row = [] + for col in self.column_info: + new_row.append(row[col['name']]) + result.append(new_row) + except psycopg2.ProgrammingError as e: + result = None + else: + # User performed operation which dose not produce record/s as + # result. + # for eg. DDL operations. + return True, None + + return True, result + + def connected(self): + if self.conn: + if not self.conn.closed: + return True + self.conn = None + return False + + def reset(self): + if self.conn: + if self.conn.closed: + self.conn = None + pg_conn = None + mgr = self.manager + + password = getattr(mgr, 'password', None) + + if password: + # Fetch Logged in User Details. + user = User.query.filter_by(id=current_user.id).first() + + if user is None: + return False, gettext("Unauthorized request.") + + password = decrypt(password, user.password).decode() + + try: + pg_conn = psycopg2.connect( + host=mgr.host, + hostaddr=mgr.hostaddr, + port=mgr.port, + database=self.db, + user=mgr.user, + password=password, + passfile=get_complete_file_path(mgr.passfile), + sslmode=mgr.ssl_mode, + sslcert=get_complete_file_path(mgr.sslcert), + sslkey=get_complete_file_path(mgr.sslkey), + sslrootcert=get_complete_file_path(mgr.sslrootcert), + sslcrl=get_complete_file_path(mgr.sslcrl), + sslcompression=True if mgr.sslcompression else False, + service=mgr.service + ) + + except psycopg2.Error as e: + msg = e.pgerror if e.pgerror else e.message \ + if e.message else e.diag.message_detail \ + if e.diag.message_detail else str(e) + + current_app.logger.error( + gettext( + """ +Failed to reset the connection to the server due to following error: +{0}""" + ).Format(msg) + ) + return False, msg + + pg_conn.notices = deque([], self.ASYNC_NOTICE_MAXLENGTH) + self.conn = pg_conn + self.__backend_pid = pg_conn.get_backend_pid() + + return True, None + + def transaction_status(self): + if self.conn: + return self.conn.get_transaction_status() + return None + + def ping(self): + return self.execute_scalar('SELECT 1') + + def _release(self): + if self.wasConnected: + if self.conn: + self.conn.close() + self.conn = None + self.password = None + self.wasConnected = False + + def _wait(self, conn): + """ + This function is used for the asynchronous connection, + it will call poll method in a infinite loop till poll + returns psycopg2.extensions.POLL_OK. This is a blocking + call. + + Args: + conn: connection object + """ + + while 1: + state = conn.poll() + if state == psycopg2.extensions.POLL_OK: + break + elif state == psycopg2.extensions.POLL_WRITE: + select.select([], [conn.fileno()], []) + elif state == psycopg2.extensions.POLL_READ: + select.select([conn.fileno()], [], []) + else: + raise psycopg2.OperationalError( + "poll() returned %s from _wait function" % state) + + def _wait_timeout(self, conn): + """ + This function is used for the asynchronous connection, + it will call poll method and return the status. If state is + psycopg2.extensions.POLL_WRITE and psycopg2.extensions.POLL_READ + function will wait for the given timeout.This is not a blocking call. + + Args: + conn: connection object + time: wait time + """ + + while 1: + state = conn.poll() + + if state == psycopg2.extensions.POLL_OK: + return self.ASYNC_OK + elif state == psycopg2.extensions.POLL_WRITE: + # Wait for the given time and then check the return status + # If three empty lists are returned then the time-out is + # reached. + timeout_status = select.select( + [], [conn.fileno()], [], self.ASYNC_TIMEOUT + ) + if timeout_status == ([], [], []): + return self.ASYNC_WRITE_TIMEOUT + elif state == psycopg2.extensions.POLL_READ: + # Wait for the given time and then check the return status + # If three empty lists are returned then the time-out is + # reached. + timeout_status = select.select( + [conn.fileno()], [], [], self.ASYNC_TIMEOUT + ) + if timeout_status == ([], [], []): + return self.ASYNC_READ_TIMEOUT + else: + raise psycopg2.OperationalError( + "poll() returned %s from _wait_timeout function" % state + ) + + def poll(self, formatted_exception_msg=False, no_result=False): + """ + This function is a wrapper around connection's poll function. + It internally uses the _wait_timeout method to poll the + result on the connection object. In case of success it + returns the result of the query. + + Args: + formatted_exception_msg: if True then function return the formatted + exception message, otherwise error string. + no_result: If True then only poll status will be returned. + """ + + cur = self.__async_cursor + if not cur: + return False, gettext( + "Cursor could not be found for the async connection." + ) + + current_app.logger.log( + 25, + "Polling result for (Query-id: {query_id})".format( + query_id=self.__async_query_id + ) + ) + + is_error = False + try: + status = self._wait_timeout(self.conn) + except psycopg2.Error as pe: + if self.conn.closed: + raise ConnectionLost( + self.manager.sid, + self.db, + self.conn_id[5:] + ) + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + is_error = True + + if self.conn.notices and self.__notices is not None: + self.__notices.extend(self.conn.notices) + self.conn.notices.clear() + + # We also need to fetch notices before we return from function in case + # of any Exception, To avoid code duplication we will return after + # fetching the notices in case of any Exception + if is_error: + return False, errmsg + + result = None + self.row_count = 0 + self.column_info = None + + if status == self.ASYNC_OK: + + # if user has cancelled the transaction then changed the status + if self.execution_aborted: + status = self.ASYNC_EXECUTION_ABORTED + self.execution_aborted = False + return status, result + + # Fetch the column information + if cur.description is not None: + self.column_info = [ + desc.to_dict() for desc in cur.ordered_description() + ] + + pos = 0 + for col in self.column_info: + col['pos'] = pos + pos += 1 + + self.row_count = cur.rowcount + if not no_result: + if cur.rowcount > 0: + result = [] + # For DDL operation, we may not have result. + # + # Because - there is not direct way to differentiate DML + # and DDL operations, we need to rely on exception to + # figure that out at the moment. + try: + for row in cur: + new_row = [] + for col in self.column_info: + new_row.append(row[col['name']]) + result.append(new_row) + + except psycopg2.ProgrammingError: + result = None + + return status, result + + def status_message(self): + """ + This function will return the status message returned by the last + command executed on the server. + """ + cur = self.__async_cursor + if not cur: + return gettext( + "Cursor could not be found for the async connection." + ) + + current_app.logger.log( + 25, + "Status message for (Query-id: {query_id})".format( + query_id=self.__async_query_id + ) + ) + + return cur.statusmessage + + def rows_affected(self): + """ + This function will return the no of rows affected by the last command + executed on the server. + """ + + return self.row_count + + def get_column_info(self): + """ + This function will returns list of columns for last async sql command + executed on the server. + """ + + return self.column_info + + def cancel_transaction(self, conn_id, did=None): + """ + This function is used to cancel the running transaction + of the given connection id and database id using + PostgreSQL's pg_cancel_backend. + + Args: + conn_id: Connection id + did: Database id (optional) + """ + cancel_conn = self.manager.connection(did=did, conn_id=conn_id) + query = """SELECT pg_cancel_backend({0});""".format( + cancel_conn.__backend_pid) + + status = True + msg = '' + + # if backend pid is same then create a new connection + # to cancel the query and release it. + if cancel_conn.__backend_pid == self.__backend_pid: + password = getattr(self.manager, 'password', None) + if password: + # Fetch Logged in User Details. + user = User.query.filter_by(id=current_user.id).first() + if user is None: + return False, gettext("Unauthorized request.") + + password = decrypt(password, user.password).decode() + + try: + pg_conn = psycopg2.connect( + host=self.manager.host, + hostaddr=self.manager.hostaddr, + port=self.manager.port, + database=self.db, + user=self.manager.user, + password=password, + passfile=get_complete_file_path(self.manager.passfile), + sslmode=self.manager.ssl_mode, + sslcert=get_complete_file_path(self.manager.sslcert), + sslkey=get_complete_file_path(self.manager.sslkey), + sslrootcert=get_complete_file_path( + self.manager.sslrootcert + ), + sslcrl=get_complete_file_path(self.manager.sslcrl), + sslcompression=True if self.manager.sslcompression + else False, + service=self.manager.service + ) + + # Get the cursor and run the query + cur = pg_conn.cursor() + cur.execute(query) + + # Close the connection + pg_conn.close() + pg_conn = None + + except psycopg2.Error as e: + status = False + if e.pgerror: + msg = e.pgerror + elif e.diag.message_detail: + msg = e.diag.message_detail + else: + msg = str(e) + return status, msg + else: + if self.connected(): + status, msg = self.execute_void(query) + + if status: + cancel_conn.execution_aborted = True + else: + status = False + msg = gettext("Not connected to the database server.") + + return status, msg + + def messages(self): + """ + Returns the list of the messages/notices send from the database server. + """ + resp = [] + while self.__notices: + resp.append(self.__notices.pop(0)) + return resp + + def decode_to_utf8(self, value): + """ + This method will decode values to utf-8 + Args: + value: String to be decode + + Returns: + Decoded string + """ + is_error = False + if hasattr(str, 'decode'): + try: + value = value.decode('utf-8') + except UnicodeDecodeError: + # Let's try with python's preferred encoding + # On Windows lc_messages mostly has environment dependent + # encoding like 'French_France.1252' + try: + import locale + pref_encoding = locale.getpreferredencoding() + value = value.decode(pref_encoding)\ + .encode('utf-8')\ + .decode('utf-8') + except Exception: + is_error = True + except Exception: + is_error = True + + # If still not able to decode then + if is_error: + value = value.decode('ascii', 'ignore') + + return value + + def _formatted_exception_msg(self, exception_obj, formatted_msg): + """ + This method is used to parse the psycopg2.Error object and returns the + formatted error message if flag is set to true else return + normal error message. + + Args: + exception_obj: exception object + formatted_msg: if True then function return the formatted exception + message + + """ + if exception_obj.pgerror: + errmsg = exception_obj.pgerror + elif exception_obj.diag.message_detail: + errmsg = exception_obj.diag.message_detail + else: + errmsg = str(exception_obj) + # errmsg might contains encoded value, lets decode it + errmsg = self.decode_to_utf8(errmsg) + + # if formatted_msg is false then return from the function + if not formatted_msg: + return errmsg + + # Do not append if error starts with `ERROR:` as most pg related + # error starts with `ERROR:` + if not errmsg.startswith(u'ERROR:'): + errmsg = u'ERROR: ' + errmsg + u'\n\n' + + if exception_obj.diag.severity is not None \ + and exception_obj.diag.message_primary is not None: + ex_diag_message = u"{0}: {1}".format( + exception_obj.diag.severity, + self.decode_to_utf8(exception_obj.diag.message_primary) + ) + # If both errors are different then only append it + if errmsg and ex_diag_message and \ + ex_diag_message.strip().strip('\n').lower() not in \ + errmsg.strip().strip('\n').lower(): + errmsg += ex_diag_message + elif exception_obj.diag.message_primary is not None: + message_primary = self.decode_to_utf8( + exception_obj.diag.message_primary + ) + if message_primary.lower() not in errmsg.lower(): + errmsg += message_primary + + if exception_obj.diag.sqlstate is not None: + if not errmsg.endswith('\n'): + errmsg += '\n' + errmsg += gettext('SQL state: ') + errmsg += self.decode_to_utf8(exception_obj.diag.sqlstate) + + if exception_obj.diag.message_detail is not None: + if 'Detail:'.lower() not in errmsg.lower(): + if not errmsg.endswith('\n'): + errmsg += '\n' + errmsg += gettext('Detail: ') + errmsg += self.decode_to_utf8( + exception_obj.diag.message_detail + ) + + if exception_obj.diag.message_hint is not None: + if 'Hint:'.lower() not in errmsg.lower(): + if not errmsg.endswith('\n'): + errmsg += '\n' + errmsg += gettext('Hint: ') + errmsg += self.decode_to_utf8(exception_obj.diag.message_hint) + + if exception_obj.diag.statement_position is not None: + if 'Character:'.lower() not in errmsg.lower(): + if not errmsg.endswith('\n'): + errmsg += '\n' + errmsg += gettext('Character: ') + errmsg += self.decode_to_utf8( + exception_obj.diag.statement_position + ) + + if exception_obj.diag.context is not None: + if 'Context:'.lower() not in errmsg.lower(): + if not errmsg.endswith('\n'): + errmsg += '\n' + errmsg += gettext('Context: ') + errmsg += self.decode_to_utf8(exception_obj.diag.context) + + return errmsg + + ##### + # As per issue reported on pgsycopg2 github repository link is shared below + # conn.closed is not reliable enough to identify the disconnection from the + # database server for some unknown reasons. + # + # (https://github.com/psycopg/psycopg2/issues/263) + # + # In order to resolve the issue, sqlalchamey follows the below logic to + # identify the disconnection. It relies on exception message to identify + # the error. + # + # Reference (MIT license): + # https://github.com/zzzeek/sqlalchemy/blob/master/lib/sqlalchemy/dialects/postgresql/psycopg2.py + # + def is_disconnected(self, err): + if not self.conn.closed: + # checks based on strings. in the case that .closed + # didn't cut it, fall back onto these. + str_e = str(err).partition("\n")[0] + for msg in [ + # these error messages from libpq: interfaces/libpq/fe-misc.c + # and interfaces/libpq/fe-secure.c. + 'terminating connection', + 'closed the connection', + 'connection not open', + 'could not receive data from server', + 'could not send data to server', + # psycopg2 client errors, psycopg2/conenction.h, + # psycopg2/cursor.h + 'connection already closed', + 'cursor already closed', + # not sure where this path is originally from, it may + # be obsolete. It really says "losed", not "closed". + 'losed the connection unexpectedly', + # these can occur in newer SSL + 'connection has been closed unexpectedly', + 'SSL SYSCALL error: Bad file descriptor', + 'SSL SYSCALL error: EOF detected', + ]: + idx = str_e.find(msg) + if idx >= 0 and '"' not in str_e[:idx]: + return True + + return False + return True diff --git a/web/pgadmin/utils/driver/psycopg2/server_manager.py b/web/pgadmin/utils/driver/psycopg2/server_manager.py new file mode 100644 index 0000000..2299e28 --- /dev/null +++ b/web/pgadmin/utils/driver/psycopg2/server_manager.py @@ -0,0 +1,333 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2018, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +""" +Implementation of ServerManager +""" +import os +import datetime +from flask import current_app, session +from flask_security import current_user +from flask_babel import gettext + +from pgadmin.utils.crypto import decrypt +from .connection import Connection +from pgadmin.model import Server + + +class ServerManager(object): + """ + class ServerManager + + This class contains the information about the given server. + And, acts as connection manager for that particular session. + """ + + def __init__(self, server): + self.connections = dict() + + self.update(server) + + def update(self, server): + assert (server is not None) + assert (isinstance(server, Server)) + + self.ver = None + self.sversion = None + self.server_type = None + self.server_cls = None + self.password = None + + self.sid = server.id + self.host = server.host + self.hostaddr = server.hostaddr + self.port = server.port + self.db = server.maintenance_db + self.did = None + self.user = server.username + self.password = server.password + self.role = server.role + self.ssl_mode = server.ssl_mode + self.pinged = datetime.datetime.now() + self.db_info = dict() + self.server_types = None + self.db_res = server.db_res + self.passfile = server.passfile + self.sslcert = server.sslcert + self.sslkey = server.sslkey + self.sslrootcert = server.sslrootcert + self.sslcrl = server.sslcrl + self.sslcompression = True if server.sslcompression else False + self.service = server.service + + for con in self.connections: + self.connections[con]._release() + + self.update_session() + + self.connections = dict() + + def as_dict(self): + """ + Returns a dictionary object representing the server manager. + """ + if self.ver is None or len(self.connections) == 0: + return None + + res = dict() + res['sid'] = self.sid + res['ver'] = self.ver + res['sversion'] = self.sversion + if hasattr(self, 'password') and self.password: + # If running under PY2 + if hasattr(self.password, 'decode'): + res['password'] = self.password.decode('utf-8') + else: + res['password'] = str(self.password) + else: + res['password'] = self.password + + connections = res['connections'] = dict() + + for conn_id in self.connections: + conn = self.connections[conn_id].as_dict() + + if conn is not None: + connections[conn_id] = conn + + return res + + def ServerVersion(self): + return self.ver + + @property + def version(self): + return self.sversion + + def MajorVersion(self): + if self.sversion is not None: + return int(self.sversion / 10000) + raise Exception("Information is not available.") + + def MinorVersion(self): + if self.sversion: + return int(int(self.sversion / 100) % 100) + raise Exception("Information is not available.") + + def PatchVersion(self): + if self.sversion: + return int(int(self.sversion / 100) / 100) + raise Exception("Information is not available.") + + def connection( + self, database=None, conn_id=None, auto_reconnect=True, did=None, + async=None, use_binary_placeholder=False, array_to_string=False + ): + if database is not None: + if hasattr(str, 'decode') and \ + not isinstance(database, unicode): + database = database.decode('utf-8') + if did is not None: + if did in self.db_info: + self.db_info[did]['datname'] = database + else: + if did is None: + database = self.db + elif did in self.db_info: + database = self.db_info[did]['datname'] + else: + maintenance_db_id = u'DB:{0}'.format(self.db) + if maintenance_db_id in self.connections: + conn = self.connections[maintenance_db_id] + if conn.connected(): + status, res = conn.execute_dict(u""" +SELECT + db.oid as did, db.datname, db.datallowconn, + pg_encoding_to_char(db.encoding) AS serverencoding, + has_database_privilege(db.oid, 'CREATE') as cancreate, datlastsysoid +FROM + pg_database db +WHERE db.oid = {0}""".format(did)) + + if status and len(res['rows']) > 0: + for row in res['rows']: + self.db_info[did] = row + database = self.db_info[did]['datname'] + + if did not in self.db_info: + raise Exception(gettext( + "Could not find the specified database." + )) + + if database is None: + raise ConnectionLost(self.sid, None, None) + + my_id = (u'CONN:{0}'.format(conn_id)) if conn_id is not None else \ + (u'DB:{0}'.format(database)) + + self.pinged = datetime.datetime.now() + + if my_id in self.connections: + return self.connections[my_id] + else: + if async is None: + async = 1 if conn_id is not None else 0 + else: + async = 1 if async is True else 0 + self.connections[my_id] = Connection( + self, my_id, database, auto_reconnect, async, + use_binary_placeholder=use_binary_placeholder, + array_to_string=array_to_string + ) + + return self.connections[my_id] + + def _restore(self, data): + """ + Helps restoring to reconnect the auto-connect connections smoothly on + reload/restart of the app server.. + """ + # restore server version from flask session if flask server was + # restarted. As we need server version to resolve sql template paths. + from pgadmin.browser.server_groups.servers.types import ServerType + + self.ver = data.get('ver', None) + self.sversion = data.get('sversion', None) + + if self.ver and not self.server_type: + for st in ServerType.types(): + if st.instanceOf(self.ver): + self.server_type = st.stype + self.server_cls = st + break + + # Hmm.. we will not honour this request, when I already have + # connections + if len(self.connections) != 0: + return + + # We need to know about the existing server variant supports during + # first connection for identifications. + self.pinged = datetime.datetime.now() + try: + if 'password' in data and data['password']: + data['password'] = data['password'].encode('utf-8') + except Exception as e: + current_app.logger.exception(e) + + connections = data['connections'] + for conn_id in connections: + conn_info = connections[conn_id] + conn = self.connections[conn_info['conn_id']] = Connection( + self, conn_info['conn_id'], conn_info['database'], + conn_info['auto_reconnect'], conn_info['async'], + use_binary_placeholder=conn_info['use_binary_placeholder'], + array_to_string=conn_info['array_to_string'] + ) + + # only try to reconnect if connection was connected previously and + # auto_reconnect is true. + if conn_info['wasConnected'] and conn_info['auto_reconnect']: + try: + conn.connect( + password=data['password'], + server_types=ServerType.types() + ) + # This will also update wasConnected flag in connection so + # no need to update the flag manually. + except Exception as e: + current_app.logger.exception(e) + self.connections.pop(conn_info['conn_id']) + + def release(self, database=None, conn_id=None, did=None): + if did is not None: + if did in self.db_info and 'datname' in self.db_info[did]: + database = self.db_info[did]['datname'] + if hasattr(str, 'decode') and \ + not isinstance(database, unicode): + database = database.decode('utf-8') + if database is None: + return False + else: + return False + + my_id = (u'CONN:{0}'.format(conn_id)) if conn_id is not None else \ + (u'DB:{0}'.format(database)) if database is not None else None + + if my_id is not None: + if my_id in self.connections: + self.connections[my_id]._release() + del self.connections[my_id] + if did is not None: + del self.db_info[did] + + if len(self.connections) == 0: + self.ver = None + self.sversion = None + self.server_type = None + self.server_cls = None + self.password = None + + self.update_session() + + return True + else: + return False + + for con in self.connections: + self.connections[con]._release() + + self.connections = dict() + self.ver = None + self.sversion = None + self.server_type = None + self.server_cls = None + self.password = None + + self.update_session() + + return True + + def _update_password(self, passwd): + self.password = passwd + for conn_id in self.connections: + conn = self.connections[conn_id] + if conn.conn is not None or conn.wasConnected is True: + conn.password = passwd + + def update_session(self): + managers = session['__pgsql_server_managers'] \ + if '__pgsql_server_managers' in session else dict() + updated_mgr = self.as_dict() + + if not updated_mgr: + if self.sid in managers: + managers.pop(self.sid) + else: + managers[self.sid] = updated_mgr + session['__pgsql_server_managers'] = managers + session.force_write = True + + def utility(self, operation): + """ + utility(operation) + + Returns: name of the utility which used for the operation + """ + if self.server_cls is not None: + return self.server_cls.utility(operation, self.sversion) + + return None + + def export_password_env(self, env): + if self.password: + password = decrypt( + self.password, current_user.password + ).decode() + os.environ[str(env)] = password ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-09 15:55 Dave Page <[email protected]> parent: Murtuza Zabuawala <[email protected]> 0 siblings, 1 reply; 14+ messages in thread From: Dave Page @ 2018-03-09 15:55 UTC (permalink / raw) To: Murtuza Zabuawala <[email protected]>; +Cc: pgadmin-hackers HI On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala < [email protected]> wrote: > Hi, > > PFA patch to add service parameter in server dialog. > - Docs updated > - Test case added for Service ID parameter > > Please note, > I have extracted Connection class and Server manager class from our own > custom Psycopg2 driver module. > > Patch also covers RM#3120 > This patch seems a little confused. The "Service" and "Service ID" fields from pgAdmin 3 are very different things. The Redmine ticket seems to be asking for the Service field (the pg_service.conf service name), *not* Service ID (the operating system's service ID, used to start/stop the database server service). -- Dave Page Blog: http://pgsnake.blogspot.com Twitter: @pgsnake EnterpriseDB UK: http://www.enterprisedb.com The Enterprise PostgreSQL Company ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-09 15:59 Murtuza Zabuawala <[email protected]> parent: Dave Page <[email protected]> 0 siblings, 1 reply; 14+ messages in thread From: Murtuza Zabuawala @ 2018-03-09 15:59 UTC (permalink / raw) To: Dave Page <[email protected]>; +Cc: pgadmin-hackers Hi Dave, I'll change the name and send you updated patch. On Fri, Mar 9, 2018 at 9:25 PM, Dave Page <[email protected]> wrote: > HI > > On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala <murtuza.zabuawala@ > enterprisedb.com> wrote: > >> Hi, >> >> PFA patch to add service parameter in server dialog. >> - Docs updated >> - Test case added for Service ID parameter >> >> Please note, >> I have extracted Connection class and Server manager class from our own >> custom Psycopg2 driver module. >> >> Patch also covers RM#3120 >> > > This patch seems a little confused. The "Service" and "Service ID" fields > from pgAdmin 3 are very different things. The Redmine ticket seems to be > asking for the Service field (the pg_service.conf service name), *not* > Service ID (the operating system's service ID, used to start/stop the > database server service). > > -- > Dave Page > Blog: http://pgsnake.blogspot.com > Twitter: @pgsnake > > EnterpriseDB UK: http://www.enterprisedb.com > The Enterprise PostgreSQL Company > ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-12 07:31 Murtuza Zabuawala <[email protected]> parent: Murtuza Zabuawala <[email protected]> 0 siblings, 1 reply; 14+ messages in thread From: Murtuza Zabuawala @ 2018-03-12 07:31 UTC (permalink / raw) To: Dave Page <[email protected]>; +Cc: pgadmin-hackers Hi Dave, PFA updated patch. -- Regards, Murtuza Zabuawala EnterpriseDB: http://www.enterprisedb.com The Enterprise PostgreSQL Company On Fri, Mar 9, 2018 at 9:29 PM, Murtuza Zabuawala < [email protected]> wrote: > Hi Dave, > > I'll change the name and send you updated patch. > > > On Fri, Mar 9, 2018 at 9:25 PM, Dave Page <[email protected]> wrote: > >> HI >> >> On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala < >> [email protected]> wrote: >> >>> Hi, >>> >>> PFA patch to add service parameter in server dialog. >>> - Docs updated >>> - Test case added for Service ID parameter >>> >>> Please note, >>> I have extracted Connection class and Server manager class from our own >>> custom Psycopg2 driver module. >>> >>> Patch also covers RM#3120 >>> >> >> This patch seems a little confused. The "Service" and "Service ID" >> fields from pgAdmin 3 are very different things. The Redmine ticket seems >> to be asking for the Service field (the pg_service.conf service name), >> *not* Service ID (the operating system's service ID, used to start/stop the >> database server service). >> >> -- >> Dave Page >> Blog: http://pgsnake.blogspot.com >> Twitter: @pgsnake >> >> EnterpriseDB UK: http://www.enterprisedb.com >> The Enterprise PostgreSQL Company >> > > Attachments: [application/octet-stream] RM_3140_v1.diff (280.4K, 3-RM_3140_v1.diff) download | inline diff: diff --git a/docs/en_US/images/server_connection.png b/docs/en_US/images/server_connection.png index 7ebf6b58b7bd260bcb3c1223907c3a22375fcd94..dd18376e5884effefabf45fc1bf6332f5522a73e 100644 GIT binary patch literal 66243 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	-|aIZ;>ir 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 literal 39678 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 diff --git a/docs/en_US/server_dialog.rst b/docs/en_US/server_dialog.rst index 24a726b..b1159c5 100644 --- a/docs/en_US/server_dialog.rst +++ b/docs/en_US/server_dialog.rst @@ -32,6 +32,7 @@ Use the fields in the *Connection* tab to configure a connection: * Use the *Password* field to provide a password that will be supplied when authenticating with the server. * Check the box next to *Save password?* to instruct pgAdmin to save the password for future use. * Use the *Role* field to specify the name of a role that has privileges that will be conveyed to the client after authentication with the server. This selection allows you to connect as one role, and then assume the permissions of this specified role after the connection is established. Note that the connecting role must be a member of the role specified. +* Use the *Service* field to specify the service name. For more information, see `Section 33.16 of the Postgres documentation <https://www.postgresql.org/docs/10/static/libpq-pgservice.html>`_. Click the *SSL* tab to continue. diff --git a/web/migrations/versions/50aad68f99c2_.py b/web/migrations/versions/50aad68f99c2_.py new file mode 100644 index 0000000..4cda56b --- /dev/null +++ b/web/migrations/versions/50aad68f99c2_.py @@ -0,0 +1,82 @@ + +"""Added service field option in server table (RM#3140) + +Revision ID: 50aad68f99c2 +Revises: 02b9dccdcfcb +Create Date: 2018-03-07 11:53:57.584280 + +""" +from pgadmin.model import db + + +# revision identifiers, used by Alembic. +revision = '50aad68f99c2' +down_revision = '02b9dccdcfcb' +branch_labels = None +depends_on = None + + +def upgrade(): + # To Save previous data + db.engine.execute("ALTER TABLE server RENAME TO server_old") + + # With service file some fields won't be mandatory as user can provide + # them using service file. Removed NOT NULL constraint from few columns + db.engine.execute(""" + CREATE TABLE server ( + id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + servergroup_id INTEGER NOT NULL, + name VARCHAR(128) NOT NULL, + host VARCHAR(128), + port INTEGER NOT NULL CHECK(port >= 1024 AND port <= 65534), + maintenance_db VARCHAR(64), + username VARCHAR(64) NOT NULL, + password VARCHAR(64), + role VARCHAR(64), + ssl_mode VARCHAR(16) NOT NULL CHECK(ssl_mode IN + ( 'allow' , 'prefer' , 'require' , 'disable' , + 'verify-ca' , 'verify-full' ) + ), + comment VARCHAR(1024), + discovery_id VARCHAR(128), + hostaddr TEXT(1024), + db_res TEXT, + passfile TEXT, + sslcert TEXT, + sslkey TEXT, + sslrootcert TEXT, + sslcrl TEXT, + sslcompression INTEGER DEFAULT 0, + bgcolor TEXT(10), + fgcolor TEXT(10), + PRIMARY KEY(id), + FOREIGN KEY(user_id) REFERENCES user(id), + FOREIGN KEY(servergroup_id) REFERENCES servergroup(id) + ) + """) + + # Copy old data again into table + db.engine.execute(""" + INSERT INTO server ( + id,user_id, servergroup_id, name, host, port, maintenance_db, + username, ssl_mode, comment, password, role, discovery_id, + hostaddr, db_res, passfile, sslcert, sslkey, sslrootcert, sslcrl, + bgcolor, fgcolor + ) SELECT + id,user_id, servergroup_id, name, host, port, maintenance_db, + username, ssl_mode, comment, password, role, discovery_id, + hostaddr, db_res, passfile, sslcert, sslkey, sslrootcert, sslcrl, + bgcolor, fgcolor + FROM server_old""") + + # Remove old data + db.engine.execute("DROP TABLE server_old") + + # Add column for Service + db.engine.execute( + 'ALTER TABLE server ADD COLUMN service TEXT' + ) + +def downgrade(): + pass diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index dfa9d62..fecb66d 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -478,7 +478,8 @@ class ServerNode(PGChildNodeView): 'sslcrl': 'sslcrl', 'sslcompression': 'sslcompression', 'bgcolor': 'bgcolor', - 'fgcolor': 'fgcolor' + 'fgcolor': 'fgcolor', + 'service': 'service' } disp_lbl = { @@ -515,7 +516,7 @@ class ServerNode(PGChildNodeView): if connected: for arg in ( 'host', 'hostaddr', 'port', 'db', 'username', 'sslmode', - 'role' + 'role', 'service' ): if arg in data: return forbidden( @@ -663,7 +664,8 @@ class ServerNode(PGChildNodeView): 'sslrootcert': server.sslrootcert if is_ssl else None, 'sslcrl': server.sslcrl if is_ssl else None, 'sslcompression': True if is_ssl and server.sslcompression - else False + else False, + 'service': server.service if server.service else None } ) @@ -672,18 +674,22 @@ class ServerNode(PGChildNodeView): """Add a server node to the settings database""" required_args = [ u'name', - u'host', u'port', - u'db', - u'username', u'sslmode', - u'role' + u'username' ] data = request.form if request.form else json.loads( request.data, encoding='utf-8' ) + # Some fields can be provided with service file so they are optional + if 'service' in data and not data['service']: + required_args.extend([ + u'host', + u'db', + u'role' + ]) for arg in required_args: if arg not in data: return make_json_response( @@ -711,29 +717,26 @@ class ServerNode(PGChildNodeView): try: server = Server( user_id=current_user.id, - servergroup_id=data[u'gid'] if u'gid' in data else gid, - name=data[u'name'], - host=data[u'host'], - hostaddr=data[u'hostaddr'] if u'hostaddr' in data else None, - port=data[u'port'], - maintenance_db=data[u'db'], - username=data[u'username'], - ssl_mode=data[u'sslmode'], - comment=data[u'comment'] if u'comment' in data else None, - role=data[u'role'] if u'role' in data else None, + servergroup_id=data.get('gid', gid), + name=data.get('name'), + host=data.get('host', None), + hostaddr=data.get('hostaddr', None), + port=data.get('port'), + maintenance_db=data.get('db', None), + username=data.get('username'), + ssl_mode=data.get('sslmode'), + comment=data.get('comment', None), + role=data.get('role', None), db_res=','.join(data[u'db_res']) - if u'db_res' in data - else None, - sslcert=data['sslcert'] if is_ssl else None, - sslkey=data['sslkey'] if is_ssl else None, - sslrootcert=data['sslrootcert'] if is_ssl else None, - sslcrl=data['sslcrl'] if is_ssl else None, + if u'db_res' in data else None, + sslcert=data.get('sslcert', None), + sslkey=data.get('sslkey', None), + sslrootcert=data.get('sslrootcert', None), + sslcrl=data.get('sslcrl', None), sslcompression=1 if is_ssl and data['sslcompression'] else 0, - bgcolor=data['bgcolor'] if u'bgcolor' in data - else None, - fgcolor=data['fgcolor'] if u'fgcolor' in data - else None - + bgcolor=data.get('bgcolor', None), + fgcolor=data.get('fgcolor', None), + service=data.get('service', None) ) db.session.add(server) db.session.commit() @@ -930,7 +933,7 @@ class ServerNode(PGChildNodeView): if 'password' not in data: conn_passwd = getattr(conn, 'password', None) if conn_passwd is None and server.password is None and \ - server.passfile is None: + server.passfile is None and server.service is None: # Return the password template in case password is not # provided, or password has not been saved earlier. return make_json_response( diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.js b/web/pgadmin/browser/server_groups/servers/static/js/server.js index 9932808..53be225 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -665,6 +665,7 @@ define('pgadmin.node.server', [ sslkey: undefined, sslrootcert: undefined, sslcrl: undefined, + service: undefined, }, // Default values! initialize: function(attrs, args) { @@ -841,12 +842,18 @@ define('pgadmin.node.server', [ var passfile = m.get('passfile'); return !_.isUndefined(passfile) && !_.isNull(passfile); }, + },{ + id: 'service', label: gettext('Service'), type: 'text', + mode: ['properties', 'edit', 'create'], disabled: 'isConnected', + group: gettext('Connection'), }], validate: function() { var err = {}, errmsg, self = this; + var service_id = this.get('service'); + var check_for_empty = function(id, msg) { var v = self.get(id); if ( @@ -903,26 +910,41 @@ define('pgadmin.node.server', [ } check_for_empty('name', gettext('Name must be specified.')); - if (check_for_empty( - 'host', gettext('Either Host name or Host address must be specified.') - ) && check_for_empty('hostaddr', gettext('Either Host name or Host address must be specified.'))){ - errmsg = errmsg || gettext('Either Host name or Host address must be specified'); + // If no service id then only check + if ( + _.isUndefined(service_id) || _.isNull(service_id) || + String(service_id).replace(/^\s+|\s+$/g, '') == '' + ) { + if (check_for_empty( + 'host', gettext('Either Host name or Host address must be specified.') + ) && check_for_empty('hostaddr', gettext('Either Host name or Host address must be specified.'))){ + errmsg = errmsg || gettext('Either Host name or Host address must be specified'); + } else { + errmsg = undefined; + delete err['host']; + delete err['hostaddr']; + } + + check_for_empty( + 'db', gettext('Maintenance database must be specified.') + ); + check_for_valid_ip( + 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') + ); + check_for_valid_ip( + 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') + ); } else { - errmsg = undefined; - delete err['host']; - delete err['hostaddr']; + _.each(['host', 'hostaddr', 'db'], (item) => { + self.errorModel.unset(item); + }); } check_for_empty( - 'db', gettext('Maintenance database must be specified.') - ); - check_for_empty( 'username', gettext('Username must be specified.') ); check_for_empty('port', gettext('Port must be specified.')); - check_for_valid_ip( - 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') - ); + this.errorModel.set(err); if (_.size(err)) { diff --git a/web/pgadmin/browser/server_groups/servers/tests/test_add_server_with_service_id.py b/web/pgadmin/browser/server_groups/servers/tests/test_add_server_with_service_id.py new file mode 100644 index 0000000..3b03d49 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/tests/test_add_server_with_service_id.py @@ -0,0 +1,47 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2018, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import json + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils + + +class ServersWithServiceIDAddTestCase(BaseTestGenerator): + """ This class will add the servers under default server group. """ + + scenarios = [ + # Fetch the default url for server object + ( + 'Default Server Node url', dict( + url='/browser/server/obj/' + ) + ) + ] + + def setUp(self): + pass + + def runTest(self): + """ This function will add the server under default server group.""" + url = "{0}{1}/".format(self.url, utils.SERVER_GROUP) + # Add service name in the config + self.server['service'] = "TestDB" + response = self.tester.post( + url, + data=json.dumps(self.server), + content_type='html/json' + ) + self.assertEquals(response.status_code, 200) + response_data = json.loads(response.data.decode('utf-8')) + self.server_id = response_data['node']['_id'] + + def tearDown(self): + """This function delete the server from SQLite """ + utils.delete_server_with_api(self.tester, self.server_id) diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index 674f945..11bc9f0 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -107,13 +107,13 @@ class Server(db.Model): nullable=False ) name = db.Column(db.String(128), nullable=False) - host = db.Column(db.String(128), nullable=False) + host = db.Column(db.String(128), nullable=True) hostaddr = db.Column(db.String(128), nullable=True) port = db.Column( db.Integer(), db.CheckConstraint('port >= 1024 AND port <= 65534'), nullable=False) - maintenance_db = db.Column(db.String(64), nullable=False) + maintenance_db = db.Column(db.String(64), nullable=True) username = db.Column(db.String(64), nullable=False) password = db.Column(db.String(64), nullable=True) role = db.Column(db.String(64), nullable=True) @@ -144,6 +144,7 @@ class Server(db.Model): ) bgcolor = db.Column(db.Text(10), nullable=True) fgcolor = db.Column(db.Text(10), nullable=True) + service = db.Column(db.Text(), nullable=True) class ModulePreference(db.Model): diff --git a/web/pgadmin/utils/driver/psycopg2/__init__.py b/web/pgadmin/utils/driver/psycopg2/__init__.py index 941a694..95a49fb 100644 --- a/web/pgadmin/utils/driver/psycopg2/__init__.py +++ b/web/pgadmin/utils/driver/psycopg2/__init__.py @@ -8,1985 +8,23 @@ ########################################################################## """ -Implementation of Connection, ServerManager and Driver classes using the -psycopg2. It is a wrapper around the actual psycopg2 driver, and connection +Implementation of Driver class +It is a wrapper around the actual psycopg2 driver, and connection object. -""" +""" import datetime -import os -import random -import select -import sys - -import simplejson as json -import psycopg2 -from flask import g, current_app, session +from flask import session from flask_babel import gettext -from flask_security import current_user -from pgadmin.utils.crypto import decrypt -from psycopg2.extensions import adapt, encodings +import psycopg2 +from psycopg2.extensions import adapt import config from pgadmin.model import Server, User -from pgadmin.utils.exception import ConnectionLost -from pgadmin.utils import get_complete_file_path from .keywords import ScanKeyword -from ..abstract import BaseDriver, BaseConnection -from .cursor import DictCursor -from .typecast import register_global_typecasters, \ - register_string_typecasters, register_binary_typecasters, \ - register_array_to_string_typecasters, ALL_JSON_TYPES -from collections import deque - - -if sys.version_info < (3,): - # Python2 in-built csv module do not handle unicode - # backports.csv module ported from PY3 csv module for unicode handling - from backports import csv - from StringIO import StringIO - IS_PY2 = True -else: - from io import StringIO - import csv - IS_PY2 = False - -_ = gettext - - -# Register global type caster which will be applicable to all connections. -register_global_typecasters() - - -class Connection(BaseConnection): - """ - class Connection(object) - - A wrapper class, which wraps the psycopg2 connection object, and - delegate the execution to the actual connection object, when required. - - Methods: - ------- - * connect(**kwargs) - - Connect the PostgreSQL/EDB Postgres Advanced Server using the psycopg2 - driver - - * execute_scalar(query, params, formatted_exception_msg) - - Execute the given query and returns single datum result - - * execute_async(query, params, formatted_exception_msg) - - Execute the given query asynchronously and returns result. - - * execute_void(query, params, formatted_exception_msg) - - Execute the given query with no result. - - * execute_2darray(query, params, formatted_exception_msg) - - Execute the given query and returns the result as a 2 dimensional - array. - - * execute_dict(query, params, formatted_exception_msg) - - Execute the given query and returns the result as an array of dict - (column name -> value) format. - - * connected() - - Get the status of the connection. - Returns True if connected, otherwise False. - - * reset() - - Reconnect the database server (if possible) - - * transaction_status() - - Transaction Status - - * ping() - - Ping the server. - - * _release() - - Release the connection object of psycopg2 - - * _reconnect() - - Attempt to reconnect to the database - - * _wait(conn) - - This method is used to wait for asynchronous connection. This is a - blocking call. - - * _wait_timeout(conn) - - This method is used to wait for asynchronous connection with timeout. - This is a non blocking call. - - * poll(formatted_exception_msg) - - This method is used to poll the data of query running on asynchronous - connection. - - * status_message() - - Returns the status message returned by the last command executed on - the server. - - * rows_affected() - - Returns the no of rows affected by the last command executed on - the server. - - * cancel_transaction(conn_id, did=None) - - This method is used to cancel the transaction for the - specified connection id and database id. - - * messages() - - Returns the list of messages/notices sends from the PostgreSQL database - server. - - * _formatted_exception_msg(exception_obj, formatted_msg) - - This method is used to parse the psycopg2.Error object and returns the - formatted error message if flag is set to true else return - normal error message. - - """ - - def __init__(self, manager, conn_id, db, auto_reconnect=True, async=0, - use_binary_placeholder=False, array_to_string=False): - assert (manager is not None) - assert (conn_id is not None) - - self.conn_id = conn_id - self.manager = manager - self.db = db if db is not None else manager.db - self.conn = None - self.auto_reconnect = auto_reconnect - self.async = async - self.__async_cursor = None - self.__async_query_id = None - self.__backend_pid = None - self.execution_aborted = False - self.row_count = 0 - self.__notices = None - self.password = None - # This flag indicates the connection status (connected/disconnected). - self.wasConnected = False - # This flag indicates the connection reconnecting status. - self.reconnecting = False - self.use_binary_placeholder = use_binary_placeholder - self.array_to_string = array_to_string - - super(Connection, self).__init__() - - def as_dict(self): - """ - Returns the dictionary object representing this object. - """ - # In case, it cannot be auto reconnectable, or already been released, - # then we will return None. - if not self.auto_reconnect and not self.conn: - return None - - res = dict() - res['conn_id'] = self.conn_id - res['database'] = self.db - res['async'] = self.async - res['wasConnected'] = self.wasConnected - res['auto_reconnect'] = self.auto_reconnect - res['use_binary_placeholder'] = self.use_binary_placeholder - res['array_to_string'] = self.array_to_string - - return res - - def __repr__(self): - return "PG Connection: {0} ({1}) -> {2} (ajax:{3})".format( - self.conn_id, self.db, - 'Connected' if self.conn and not self.conn.closed else - "Disconnected", - self.async - ) - - def __str__(self): - return "PG Connection: {0} ({1}) -> {2} (ajax:{3})".format( - self.conn_id, self.db, - 'Connected' if self.conn and not self.conn.closed else - "Disconnected", - self.async - ) - - def connect(self, **kwargs): - if self.conn: - if self.conn.closed: - self.conn = None - else: - return True, None - - pg_conn = None - password = None - passfile = None - mgr = self.manager - - encpass = kwargs['password'] if 'password' in kwargs else None - passfile = kwargs['passfile'] if 'passfile' in kwargs else None - - if encpass is None: - encpass = self.password or getattr(mgr, 'password', None) - - # Reset the existing connection password - if self.reconnecting is not False: - self.password = None - - if encpass: - # Fetch Logged in User Details. - user = User.query.filter_by(id=current_user.id).first() - - if user is None: - return False, gettext("Unauthorized request.") - - try: - password = decrypt(encpass, user.password) - # Handling of non ascii password (Python2) - if hasattr(str, 'decode'): - password = password.decode('utf-8').encode('utf-8') - # password is in bytes, for python3 we need it in string - elif isinstance(password, bytes): - password = password.decode() - - except Exception as e: - current_app.logger.exception(e) - return False, \ - _( - "Failed to decrypt the saved password.\nError: {0}" - ).format(str(e)) - - # If no password credential is found then connect request might - # come from Query tool, ViewData grid, debugger etc tools. - # we will check for pgpass file availability from connection manager - # if it's present then we will use it - if not password and not encpass and not passfile: - passfile = mgr.passfile if mgr.passfile else None - - try: - if hasattr(str, 'decode'): - database = self.db.encode('utf-8') - user = mgr.user.encode('utf-8') - conn_id = self.conn_id.encode('utf-8') - else: - database = self.db - user = mgr.user - conn_id = self.conn_id - - import os - os.environ['PGAPPNAME'] = '{0} - {1}'.format( - config.APP_NAME, conn_id) - - pg_conn = psycopg2.connect( - host=mgr.host, - hostaddr=mgr.hostaddr, - port=mgr.port, - database=database, - user=user, - password=password, - async=self.async, - passfile=get_complete_file_path(passfile), - sslmode=mgr.ssl_mode, - sslcert=get_complete_file_path(mgr.sslcert), - sslkey=get_complete_file_path(mgr.sslkey), - sslrootcert=get_complete_file_path(mgr.sslrootcert), - sslcrl=get_complete_file_path(mgr.sslcrl), - sslcompression=True if mgr.sslcompression else False - ) - - # If connection is asynchronous then we will have to wait - # until the connection is ready to use. - if self.async == 1: - self._wait(pg_conn) - - except psycopg2.Error as e: - if e.pgerror: - msg = e.pgerror - elif e.diag.message_detail: - msg = e.diag.message_detail - else: - msg = str(e) - current_app.logger.info( - u"Failed to connect to the database server(#{server_id}) for " - u"connection ({conn_id}) with error message as below" - u":{msg}".format( - server_id=self.manager.sid, - conn_id=conn_id, - msg=msg.decode('utf-8') if hasattr(str, 'decode') else msg - ) - ) - return False, msg - - # Overwrite connection notice attr to support - # more than 50 notices at a time - pg_conn.notices = deque([], self.ASYNC_NOTICE_MAXLENGTH) - self.conn = pg_conn - self.wasConnected = True - try: - status, msg = self._initialize(conn_id, **kwargs) - except Exception as e: - current_app.logger.exception(e) - self.conn = None - if not self.reconnecting: - self.wasConnected = False - raise e - - if status: - mgr._update_password(encpass) - else: - if not self.reconnecting: - self.wasConnected = False - - return status, msg - - def _initialize(self, conn_id, **kwargs): - self.execution_aborted = False - self.__backend_pid = self.conn.get_backend_pid() - - setattr(g, "{0}#{1}".format( - self.manager.sid, - self.conn_id.encode('utf-8') - ), None) - - status, cur = self.__cursor() - formatted_exception_msg = self._formatted_exception_msg - mgr = self.manager - - def _execute(cur, query, params=None): - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - return formatted_exception_msg(pe, False) - return None - - # autocommit flag does not work with asynchronous connections. - # By default asynchronous connection runs in autocommit mode. - if self.async == 0: - if 'autocommit' in kwargs and kwargs['autocommit'] is False: - self.conn.autocommit = False - else: - self.conn.autocommit = True - - register_string_typecasters(self.conn) - - if self.array_to_string: - register_array_to_string_typecasters(self.conn) - - # Register type casters for binary data only after registering array to - # string type casters. - if self.use_binary_placeholder: - register_binary_typecasters(self.conn) - - status = _execute(cur, "SET DateStyle=ISO;" - "SET client_min_messages=notice;" - "SET bytea_output=escape;" - "SET client_encoding='UNICODE';") - - if status is not None: - self.conn.close() - self.conn = None - - return False, status - - if mgr.role: - status = _execute(cur, u"SET ROLE TO %s", [mgr.role]) - - if status is not None: - self.conn.close() - self.conn = None - current_app.logger.error( - "Connect to the database server (#{server_id}) for " - "connection ({conn_id}), but - failed to setup the role " - "with error message as below:{msg}".format( - server_id=self.manager.sid, - conn_id=conn_id, - msg=status - ) - ) - return False, \ - _( - "Failed to setup the role with error message:\n{0}" - ).format(status) - - if mgr.ver is None: - status = _execute(cur, "SELECT version()") - - if status is not None: - self.conn.close() - self.conn = None - self.wasConnected = False - current_app.logger.error( - "Failed to fetch the version information on the " - "established connection to the database server " - "(#{server_id}) for '{conn_id}' with below error " - "message:{msg}".format( - server_id=self.manager.sid, - conn_id=conn_id, - msg=status) - ) - return False, status - - if cur.rowcount > 0: - row = cur.fetchmany(1)[0] - mgr.ver = row['version'] - mgr.sversion = self.conn.server_version - - status = _execute(cur, """ -SELECT - db.oid as did, db.datname, db.datallowconn, - pg_encoding_to_char(db.encoding) AS serverencoding, - has_database_privilege(db.oid, 'CREATE') as cancreate, datlastsysoid -FROM - pg_database db -WHERE db.datname = current_database()""") - - if status is None: - mgr.db_info = mgr.db_info or dict() - if cur.rowcount > 0: - res = cur.fetchmany(1)[0] - mgr.db_info[res['did']] = res.copy() - - # We do not have database oid for the maintenance database. - if len(mgr.db_info) == 1: - mgr.did = res['did'] - - status = _execute(cur, """ -SELECT - oid as id, rolname as name, rolsuper as is_superuser, - rolcreaterole as can_create_role, rolcreatedb as can_create_db -FROM - pg_catalog.pg_roles -WHERE - rolname = current_user""") - - if status is None: - mgr.user_info = dict() - if cur.rowcount > 0: - mgr.user_info = cur.fetchmany(1)[0] - - if 'password' in kwargs: - mgr.password = kwargs['password'] - - server_types = None - if 'server_types' in kwargs and isinstance( - kwargs['server_types'], list): - server_types = mgr.server_types = kwargs['server_types'] - - if server_types is None: - from pgadmin.browser.server_groups.servers.types import ServerType - server_types = ServerType.types() - - for st in server_types: - if st.instanceOf(mgr.ver): - mgr.server_type = st.stype - mgr.server_cls = st - break - - mgr.update_session() - - return True, None - - def __cursor(self, server_cursor=False): - if self.wasConnected is False: - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - cur = getattr(g, "{0}#{1}".format( - self.manager.sid, - self.conn_id.encode('utf-8') - ), None) - - if self.connected() and cur and not cur.closed: - if not server_cursor or (server_cursor and cur.name): - return True, cur - - if not self.connected(): - errmsg = "" - - current_app.logger.warning( - "Connection to database server (#{server_id}) for the " - "connection - '{conn_id}' has been lost.".format( - server_id=self.manager.sid, - conn_id=self.conn_id - ) - ) - - if self.auto_reconnect and not self.reconnecting: - self.__attempt_execution_reconnect(None) - else: - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - - try: - if server_cursor: - # Providing name to cursor will create server side cursor. - cursor_name = "CURSOR:{0}".format(self.conn_id) - cur = self.conn.cursor( - name=cursor_name, cursor_factory=DictCursor - ) - else: - cur = self.conn.cursor(cursor_factory=DictCursor) - except psycopg2.Error as pe: - current_app.logger.exception(pe) - errmsg = gettext( - "Failed to create cursor for psycopg2 connection with error " - "message for the server#{1}:{2}:\n{0}" - ).format( - str(pe), self.manager.sid, self.db - ) - - current_app.logger.error(errmsg) - if self.conn.closed: - self.conn = None - if self.auto_reconnect and not self.reconnecting: - current_app.logger.info( - gettext( - "Attempting to reconnect to the database server " - "(#{server_id}) for the connection - '{conn_id}'." - ).format( - server_id=self.manager.sid, - conn_id=self.conn_id - ) - ) - return self.__attempt_execution_reconnect( - self.__cursor, server_cursor - ) - else: - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' - else self.conn_id[5:] - ) - - setattr( - g, "{0}#{1}".format( - self.manager.sid, self.conn_id.encode('utf-8') - ), cur - ) - - return True, cur - - def __internal_blocking_execute(self, cur, query, params): - """ - This function executes the query using cursor's execute function, - but in case of asynchronous connection we need to wait for the - transaction to be completed. If self.async is 1 then it is a - blocking call. - - Args: - cur: Cursor object - query: SQL query to run. - params: Extra parameters - """ - - if sys.version_info < (3,): - if type(query) == unicode: - query = query.encode('utf-8') - else: - query = query.encode('utf-8') - - cur.execute(query, params) - if self.async == 1: - self._wait(cur.connection) - - def execute_on_server_as_csv(self, - query, params=None, - formatted_exception_msg=False, - records=2000): - """ - To fetch query result and generate CSV output - - Args: - query: SQL - params: Additional parameters - formatted_exception_msg: For exception - records: Number of initial records - Returns: - Generator response - """ - status, cur = self.__cursor(server_cursor=True) - self.row_count = 0 - - if not status: - return False, str(cur) - query_id = random.randint(1, 9999999) - - if IS_PY2 and type(query) == unicode: - query = query.encode('utf-8') - - current_app.logger.log( - 25, - u"Execute (with server cursor) for server #{server_id} - " - u"{conn_id} (Query-id: {query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query.decode('utf-8') if - sys.version_info < (3,) else query, - query_id=query_id - ) - ) - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"failed to execute query ((with server cursor) " - u"for the server #{server_id} - {conn_id} " - u"(query-id: {query_id}):\nerror message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - errmsg=errmsg, - query_id=query_id - ) - ) - return False, errmsg - - def handle_json_data(json_columns, results): - """ - [ This is only for Python2.x] - This function will be useful to handle json data types. - We will dump json data as proper json instead of unicode values - - Args: - json_columns: Columns which contains json data - results: Query result - - Returns: - results - """ - # Only if Python2 and there are columns with JSON type - if IS_PY2 and len(json_columns) > 0: - temp_results = [] - for row in results: - res = dict() - for k, v in row.items(): - if k in json_columns: - res[k] = json.dumps(v) - else: - res[k] = v - temp_results.append(res) - results = temp_results - return results - - def convert_keys_to_unicode(results, conn_encoding): - """ - [ This is only for Python2.x] - We need to convert all keys to unicode as psycopg2 - sends them as string - - Args: - res: Query result set from psycopg2 - conn_encoding: Connection encoding - - Returns: - Result set (With all the keys converted to unicode) - """ - new_results = [] - for row in results: - new_results.append( - dict([(k.decode(conn_encoding), v) - for k, v in row.items()]) - ) - return new_results - - def gen(quote='strings', quote_char="'", field_separator=','): - - results = cur.fetchmany(records) - if not results: - if not cur.closed: - cur.close() - yield gettext('The query executed did not return any data.') - return - - header = [] - json_columns = [] - conn_encoding = cur.connection.encoding - - for c in cur.ordered_description(): - # This is to handle the case in which column name is non-ascii - column_name = c.to_dict()['name'] - if IS_PY2: - column_name = column_name.decode(conn_encoding) - header.append(column_name) - if c.to_dict()['type_code'] in ALL_JSON_TYPES: - json_columns.append(column_name) - - if IS_PY2: - results = convert_keys_to_unicode(results, conn_encoding) - - res_io = StringIO() - - if quote == 'strings': - quote = csv.QUOTE_NONNUMERIC - elif quote == 'all': - quote = csv.QUOTE_ALL - else: - quote = csv.QUOTE_NONE - - if hasattr(str, 'decode'): - # Decode the field_separator - try: - field_separator = field_separator.decode('utf-8') - except Exception as e: - current_app.logger.error(e) - - # Decode the quote_char - try: - quote_char = quote_char.decode('utf-8') - except Exception as e: - current_app.logger.error(e) - - csv_writer = csv.DictWriter( - res_io, fieldnames=header, delimiter=field_separator, - quoting=quote, - quotechar=quote_char - ) - - csv_writer.writeheader() - results = handle_json_data(json_columns, results) - csv_writer.writerows(results) - - yield res_io.getvalue() - - while True: - results = cur.fetchmany(records) - - if not results: - if not cur.closed: - cur.close() - break - res_io = StringIO() - - csv_writer = csv.DictWriter( - res_io, fieldnames=header, delimiter=field_separator, - quoting=quote, - quotechar=quote_char - ) - - if IS_PY2: - results = convert_keys_to_unicode(results, conn_encoding) - - results = handle_json_data(json_columns, results) - csv_writer.writerows(results) - yield res_io.getvalue() - - return True, gen - - def execute_scalar(self, query, params=None, - formatted_exception_msg=False): - status, cur = self.__cursor() - self.row_count = 0 - - if not status: - return False, str(cur) - query_id = random.randint(1, 9999999) - - current_app.logger.log( - 25, - u"Execute (scalar) for server #{server_id} - {conn_id} (Query-id: " - u"{query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - query_id=query_id - ) - ) - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - if not self.connected(): - if self.auto_reconnect and not self.reconnecting: - return self.__attempt_execution_reconnect( - self.execute_dict, query, params, - formatted_exception_msg - ) - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"Failed to execute query (execute_scalar) for the server " - u"#{server_id} - {conn_id} (Query-id: {query_id}):\n" - u"Error Message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - errmsg=errmsg, - query_id=query_id - ) - ) - return False, errmsg - - self.row_count = cur.rowcount - if cur.rowcount > 0: - res = cur.fetchone() - if len(res) > 0: - return True, res[0] - - return True, None - - def execute_async(self, query, params=None, formatted_exception_msg=True): - """ - This function executes the given query asynchronously and returns - result. - - Args: - query: SQL query to run. - params: extra parameters to the function - formatted_exception_msg: if True then function return the - formatted exception message - """ - - if sys.version_info < (3,): - if type(query) == unicode: - query = query.encode('utf-8') - else: - query = query.encode('utf-8') - - self.__async_cursor = None - status, cur = self.__cursor() - - if not status: - return False, str(cur) - query_id = random.randint(1, 9999999) - - current_app.logger.log( - 25, - u"Execute (async) for server #{server_id} - {conn_id} (Query-id: " - u"{query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query.decode('utf-8'), - query_id=query_id - ) - ) - - try: - self.__notices = [] - self.execution_aborted = False - cur.execute(query, params) - res = self._wait_timeout(cur.connection) - except psycopg2.Error as pe: - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"Failed to execute query (execute_async) for the server " - u"#{server_id} - {conn_id}(Query-id: {query_id}):\n" - u"Error Message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query.decode('utf-8'), - errmsg=errmsg, - query_id=query_id - ) - ) - - if self.is_disconnected(pe): - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - return False, errmsg - - self.__async_cursor = cur - self.__async_query_id = query_id - - return True, res - - def execute_void(self, query, params=None, formatted_exception_msg=False): - """ - This function executes the given query with no result. - - Args: - query: SQL query to run. - params: extra parameters to the function - formatted_exception_msg: if True then function return the - formatted exception message - """ - status, cur = self.__cursor() - self.row_count = 0 - - if not status: - return False, str(cur) - query_id = random.randint(1, 9999999) - - current_app.logger.log( - 25, - u"Execute (void) for server #{server_id} - {conn_id} (Query-id: " - u"{query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - query_id=query_id - ) - ) - - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - if not self.connected(): - if self.auto_reconnect and not self.reconnecting: - return self.__attempt_execution_reconnect( - self.execute_void, query, params, - formatted_exception_msg - ) - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"Failed to execute query (execute_void) for the server " - u"#{server_id} - {conn_id}(Query-id: {query_id}):\n" - u"Error Message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - errmsg=errmsg, - query_id=query_id - ) - ) - return False, errmsg - - self.row_count = cur.rowcount - - return True, None - - def __attempt_execution_reconnect(self, fn, *args, **kwargs): - self.reconnecting = True - setattr(g, "{0}#{1}".format( - self.manager.sid, - self.conn_id.encode('utf-8') - ), None) - try: - status, res = self.connect() - if status: - if fn: - status, res = fn(*args, **kwargs) - self.reconnecting = False - return status, res - except Exception as e: - current_app.logger.exception(e) - self.reconnecting = False - - current_app.warning( - "Failed to reconnect the database server " - "(#{server_id})".format( - server_id=self.manager.sid, - conn_id=self.conn_id - ) - ) - self.reconnecting = False - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - - def execute_2darray(self, query, params=None, - formatted_exception_msg=False): - status, cur = self.__cursor() - self.row_count = 0 - - if not status: - return False, str(cur) - - query_id = random.randint(1, 9999999) - current_app.logger.log( - 25, - u"Execute (2darray) for server #{server_id} - {conn_id} " - u"(Query-id: {query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - query_id=query_id - ) - ) - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - if not self.connected(): - if self.auto_reconnect and \ - not self.reconnecting: - return self.__attempt_execution_reconnect( - self.execute_2darray, query, params, - formatted_exception_msg - ) - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"Failed to execute query (execute_2darray) for the server " - u"#{server_id} - {conn_id} (Query-id: {query_id}):\n" - u"Error Message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - errmsg=errmsg, - query_id=query_id - ) - ) - return False, errmsg - - # Get Resultset Column Name, Type and size - columns = cur.description and [ - desc.to_dict() for desc in cur.ordered_description() - ] or [] - - rows = [] - self.row_count = cur.rowcount - if cur.rowcount > 0: - for row in cur: - rows.append(row) - - return True, {'columns': columns, 'rows': rows} - - def execute_dict(self, query, params=None, formatted_exception_msg=False): - status, cur = self.__cursor() - self.row_count = 0 - - if not status: - return False, str(cur) - query_id = random.randint(1, 9999999) - current_app.logger.log( - 25, - u"Execute (dict) for server #{server_id} - {conn_id} (Query-id: " - u"{query_id}):\n{query}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query=query, - query_id=query_id - ) - ) - try: - self.__internal_blocking_execute(cur, query, params) - except psycopg2.Error as pe: - cur.close() - if not self.connected(): - if self.auto_reconnect and not self.reconnecting: - return self.__attempt_execution_reconnect( - self.execute_dict, query, params, - formatted_exception_msg - ) - raise ConnectionLost( - self.manager.sid, - self.db, - None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] - ) - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - current_app.logger.error( - u"Failed to execute query (execute_dict) for the server " - u"#{server_id}- {conn_id} (Query-id: {query_id}):\n" - u"Error Message:{errmsg}".format( - server_id=self.manager.sid, - conn_id=self.conn_id, - query_id=query_id, - errmsg=errmsg - ) - ) - return False, errmsg - - # Get Resultset Column Name, Type and size - columns = cur.description and [ - desc.to_dict() for desc in cur.ordered_description() - ] or [] - - rows = [] - self.row_count = cur.rowcount - if cur.rowcount > 0: - for row in cur: - rows.append(dict(row)) - - return True, {'columns': columns, 'rows': rows} - - def async_fetchmany_2darray(self, records=2000, - formatted_exception_msg=False): - """ - User should poll and check if status is ASYNC_OK before calling this - function - Args: - records: no of records to fetch. use -1 to fetchall. - formatted_exception_msg: - - Returns: - - """ - cur = self.__async_cursor - if not cur: - return False, gettext( - "Cursor could not be found for the async connection." - ) - - if self.conn.isexecuting(): - return False, gettext( - "Asynchronous query execution/operation underway." - ) - - if self.row_count > 0: - result = [] - # For DDL operation, we may not have result. - # - # Because - there is not direct way to differentiate DML and - # DDL operations, we need to rely on exception to figure - # that out at the moment. - try: - if records == -1: - res = cur.fetchall() - else: - res = cur.fetchmany(records) - for row in res: - new_row = [] - for col in self.column_info: - new_row.append(row[col['name']]) - result.append(new_row) - except psycopg2.ProgrammingError as e: - result = None - else: - # User performed operation which dose not produce record/s as - # result. - # for eg. DDL operations. - return True, None - - return True, result - - def connected(self): - if self.conn: - if not self.conn.closed: - return True - self.conn = None - return False - - def reset(self): - if self.conn: - if self.conn.closed: - self.conn = None - pg_conn = None - mgr = self.manager - - password = getattr(mgr, 'password', None) - - if password: - # Fetch Logged in User Details. - user = User.query.filter_by(id=current_user.id).first() - - if user is None: - return False, gettext("Unauthorized request.") - - password = decrypt(password, user.password).decode() - - try: - pg_conn = psycopg2.connect( - host=mgr.host, - hostaddr=mgr.hostaddr, - port=mgr.port, - database=self.db, - user=mgr.user, - password=password, - passfile=get_complete_file_path(mgr.passfile), - sslmode=mgr.ssl_mode, - sslcert=get_complete_file_path(mgr.sslcert), - sslkey=get_complete_file_path(mgr.sslkey), - sslrootcert=get_complete_file_path(mgr.sslrootcert), - sslcrl=get_complete_file_path(mgr.sslcrl), - sslcompression=True if mgr.sslcompression else False - ) - - except psycopg2.Error as e: - msg = e.pgerror if e.pgerror else e.message \ - if e.message else e.diag.message_detail \ - if e.diag.message_detail else str(e) - - current_app.logger.error( - gettext( - """ -Failed to reset the connection to the server due to following error: -{0}""" - ).Format(msg) - ) - return False, msg - - pg_conn.notices = deque([], self.ASYNC_NOTICE_MAXLENGTH) - self.conn = pg_conn - self.__backend_pid = pg_conn.get_backend_pid() - - return True, None - - def transaction_status(self): - if self.conn: - return self.conn.get_transaction_status() - return None - - def ping(self): - return self.execute_scalar('SELECT 1') - - def _release(self): - if self.wasConnected: - if self.conn: - self.conn.close() - self.conn = None - self.password = None - self.wasConnected = False - - def _wait(self, conn): - """ - This function is used for the asynchronous connection, - it will call poll method in a infinite loop till poll - returns psycopg2.extensions.POLL_OK. This is a blocking - call. - - Args: - conn: connection object - """ - - while 1: - state = conn.poll() - if state == psycopg2.extensions.POLL_OK: - break - elif state == psycopg2.extensions.POLL_WRITE: - select.select([], [conn.fileno()], []) - elif state == psycopg2.extensions.POLL_READ: - select.select([conn.fileno()], [], []) - else: - raise psycopg2.OperationalError( - "poll() returned %s from _wait function" % state) - - def _wait_timeout(self, conn): - """ - This function is used for the asynchronous connection, - it will call poll method and return the status. If state is - psycopg2.extensions.POLL_WRITE and psycopg2.extensions.POLL_READ - function will wait for the given timeout.This is not a blocking call. - - Args: - conn: connection object - """ - while 1: - state = conn.poll() - if state == psycopg2.extensions.POLL_OK: - return self.ASYNC_OK - elif state == psycopg2.extensions.POLL_WRITE: - # Wait for the given time and then check the return status - # If three empty lists are returned then the time-out is - # reached. - timeout_status = select.select([], [conn.fileno()], [], - self.ASYNC_TIMEOUT) - if timeout_status == ([], [], []): - return self.ASYNC_WRITE_TIMEOUT - elif state == psycopg2.extensions.POLL_READ: - # Wait for the given time and then check the return status - # If three empty lists are returned then the time-out is - # reached. - timeout_status = select.select([conn.fileno()], [], [], - self.ASYNC_TIMEOUT) - if timeout_status == ([], [], []): - return self.ASYNC_READ_TIMEOUT - else: - raise psycopg2.OperationalError( - "poll() returned %s from _wait_timeout function" % state - ) - - def poll(self, formatted_exception_msg=False, no_result=False): - """ - This function is a wrapper around connection's poll function. - It internally uses the _wait_timeout method to poll the - result on the connection object. In case of success it - returns the result of the query. - - Args: - formatted_exception_msg: if True then function return the formatted - exception message, otherwise error string. - no_result: If True then only poll status will be returned. - """ - - cur = self.__async_cursor - if not cur: - return False, gettext( - "Cursor could not be found for the async connection." - ) - - current_app.logger.log( - 25, - "Polling result for (Query-id: {query_id})".format( - query_id=self.__async_query_id - ) - ) - - is_error = False - try: - status = self._wait_timeout(self.conn) - except psycopg2.Error as pe: - if self.conn.closed: - raise ConnectionLost( - self.manager.sid, - self.db, - self.conn_id[5:] - ) - errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) - is_error = True - - if self.conn.notices and self.__notices is not None: - self.__notices.extend(self.conn.notices) - self.conn.notices.clear() - - # We also need to fetch notices before we return from function in case - # of any Exception, To avoid code duplication we will return after - # fetching the notices in case of any Exception - if is_error: - return False, errmsg - - result = None - self.row_count = 0 - self.column_info = None - - if status == self.ASYNC_OK: - - # if user has cancelled the transaction then changed the status - if self.execution_aborted: - status = self.ASYNC_EXECUTION_ABORTED - self.execution_aborted = False - return status, result - - # Fetch the column information - if cur.description is not None: - self.column_info = [ - desc.to_dict() for desc in cur.ordered_description() - ] - - pos = 0 - for col in self.column_info: - col['pos'] = pos - pos += 1 - - self.row_count = cur.rowcount - if not no_result: - if cur.rowcount > 0: - result = [] - # For DDL operation, we may not have result. - # - # Because - there is not direct way to differentiate DML - # and DDL operations, we need to rely on exception to - # figure that out at the moment. - try: - for row in cur: - new_row = [] - for col in self.column_info: - new_row.append(row[col['name']]) - result.append(new_row) - - except psycopg2.ProgrammingError: - result = None - - return status, result - - def status_message(self): - """ - This function will return the status message returned by the last - command executed on the server. - """ - cur = self.__async_cursor - if not cur: - return gettext( - "Cursor could not be found for the async connection." - ) - - current_app.logger.log( - 25, - "Status message for (Query-id: {query_id})".format( - query_id=self.__async_query_id - ) - ) - - return cur.statusmessage - - def rows_affected(self): - """ - This function will return the no of rows affected by the last command - executed on the server. - """ - - return self.row_count - - def get_column_info(self): - """ - This function will returns list of columns for last async sql command - executed on the server. - """ - - return self.column_info - - def cancel_transaction(self, conn_id, did=None): - """ - This function is used to cancel the running transaction - of the given connection id and database id using - PostgreSQL's pg_cancel_backend. - - Args: - conn_id: Connection id - did: Database id (optional) - """ - cancel_conn = self.manager.connection(did=did, conn_id=conn_id) - query = """SELECT pg_cancel_backend({0});""".format( - cancel_conn.__backend_pid) - - status = True - msg = '' - - # if backend pid is same then create a new connection - # to cancel the query and release it. - if cancel_conn.__backend_pid == self.__backend_pid: - password = getattr(self.manager, 'password', None) - if password: - # Fetch Logged in User Details. - user = User.query.filter_by(id=current_user.id).first() - if user is None: - return False, gettext("Unauthorized request.") - - password = decrypt(password, user.password).decode() - - try: - pg_conn = psycopg2.connect( - host=self.manager.host, - hostaddr=self.manager.hostaddr, - port=self.manager.port, - database=self.db, - user=self.manager.user, - password=password, - passfile=get_complete_file_path(self.manager.passfile), - sslmode=self.manager.ssl_mode, - sslcert=get_complete_file_path(self.manager.sslcert), - sslkey=get_complete_file_path(self.manager.sslkey), - sslrootcert=get_complete_file_path( - self.manager.sslrootcert - ), - sslcrl=get_complete_file_path(self.manager.sslcrl), - sslcompression=True if self.manager.sslcompression - else False - ) - - # Get the cursor and run the query - cur = pg_conn.cursor() - cur.execute(query) - - # Close the connection - pg_conn.close() - pg_conn = None - - except psycopg2.Error as e: - status = False - if e.pgerror: - msg = e.pgerror - elif e.diag.message_detail: - msg = e.diag.message_detail - else: - msg = str(e) - return status, msg - else: - if self.connected(): - status, msg = self.execute_void(query) - - if status: - cancel_conn.execution_aborted = True - else: - status = False - msg = gettext("Not connected to the database server.") - - return status, msg - - def messages(self): - """ - Returns the list of the messages/notices send from the database server. - """ - resp = [] - while self.__notices: - resp.append(self.__notices.pop(0)) - return resp - - def decode_to_utf8(self, value): - """ - This method will decode values to utf-8 - Args: - value: String to be decode - - Returns: - Decoded string - """ - is_error = False - if hasattr(str, 'decode'): - try: - value = value.decode('utf-8') - except UnicodeDecodeError: - # Let's try with python's preferred encoding - # On Windows lc_messages mostly has environment dependent - # encoding like 'French_France.1252' - try: - import locale - pref_encoding = locale.getpreferredencoding() - value = value.decode(pref_encoding)\ - .encode('utf-8')\ - .decode('utf-8') - except Exception: - is_error = True - except Exception: - is_error = True - - # If still not able to decode then - if is_error: - value = value.decode('ascii', 'ignore') - - return value - - def _formatted_exception_msg(self, exception_obj, formatted_msg): - """ - This method is used to parse the psycopg2.Error object and returns the - formatted error message if flag is set to true else return - normal error message. - - Args: - exception_obj: exception object - formatted_msg: if True then function return the formatted exception - message - - """ - if exception_obj.pgerror: - errmsg = exception_obj.pgerror - elif exception_obj.diag.message_detail: - errmsg = exception_obj.diag.message_detail - else: - errmsg = str(exception_obj) - # errmsg might contains encoded value, lets decode it - errmsg = self.decode_to_utf8(errmsg) - - # if formatted_msg is false then return from the function - if not formatted_msg: - return errmsg - - # Do not append if error starts with `ERROR:` as most pg related - # error starts with `ERROR:` - if not errmsg.startswith(u'ERROR:'): - errmsg = u'ERROR: ' + errmsg + u'\n\n' - - if exception_obj.diag.severity is not None \ - and exception_obj.diag.message_primary is not None: - ex_diag_message = u"{0}: {1}".format( - exception_obj.diag.severity, - self.decode_to_utf8(exception_obj.diag.message_primary) - ) - # If both errors are different then only append it - if errmsg and ex_diag_message and \ - ex_diag_message.strip().strip('\n').lower() not in \ - errmsg.strip().strip('\n').lower(): - errmsg += ex_diag_message - elif exception_obj.diag.message_primary is not None: - message_primary = self.decode_to_utf8( - exception_obj.diag.message_primary - ) - if message_primary.lower() not in errmsg.lower(): - errmsg += message_primary - - if exception_obj.diag.sqlstate is not None: - if not errmsg.endswith('\n'): - errmsg += '\n' - errmsg += gettext('SQL state: ') - errmsg += self.decode_to_utf8(exception_obj.diag.sqlstate) - - if exception_obj.diag.message_detail is not None: - if 'Detail:'.lower() not in errmsg.lower(): - if not errmsg.endswith('\n'): - errmsg += '\n' - errmsg += gettext('Detail: ') - errmsg += self.decode_to_utf8( - exception_obj.diag.message_detail - ) - - if exception_obj.diag.message_hint is not None: - if 'Hint:'.lower() not in errmsg.lower(): - if not errmsg.endswith('\n'): - errmsg += '\n' - errmsg += gettext('Hint: ') - errmsg += self.decode_to_utf8(exception_obj.diag.message_hint) - - if exception_obj.diag.statement_position is not None: - if 'Character:'.lower() not in errmsg.lower(): - if not errmsg.endswith('\n'): - errmsg += '\n' - errmsg += gettext('Character: ') - errmsg += self.decode_to_utf8( - exception_obj.diag.statement_position - ) - - if exception_obj.diag.context is not None: - if 'Context:'.lower() not in errmsg.lower(): - if not errmsg.endswith('\n'): - errmsg += '\n' - errmsg += gettext('Context: ') - errmsg += self.decode_to_utf8(exception_obj.diag.context) - - return errmsg - - ##### - # As per issue reported on pgsycopg2 github repository link is shared below - # conn.closed is not reliable enough to identify the disconnection from the - # database server for some unknown reasons. - # - # (https://github.com/psycopg/psycopg2/issues/263) - # - # In order to resolve the issue, sqlalchamey follows the below logic to - # identify the disconnection. It relies on exception message to identify - # the error. - # - # Reference (MIT license): - # https://github.com/zzzeek/sqlalchemy/blob/master/lib/sqlalchemy/dialects/postgresql/psycopg2.py - # - def is_disconnected(self, err): - if not self.conn.closed: - # checks based on strings. in the case that .closed - # didn't cut it, fall back onto these. - str_e = str(err).partition("\n")[0] - for msg in [ - # these error messages from libpq: interfaces/libpq/fe-misc.c - # and interfaces/libpq/fe-secure.c. - 'terminating connection', - 'closed the connection', - 'connection not open', - 'could not receive data from server', - 'could not send data to server', - # psycopg2 client errors, psycopg2/conenction.h, - # psycopg2/cursor.h - 'connection already closed', - 'cursor already closed', - # not sure where this path is originally from, it may - # be obsolete. It really says "losed", not "closed". - 'losed the connection unexpectedly', - # these can occur in newer SSL - 'connection has been closed unexpectedly', - 'SSL SYSCALL error: Bad file descriptor', - 'SSL SYSCALL error: EOF detected', - ]: - idx = str_e.find(msg) - if idx >= 0 and '"' not in str_e[:idx]: - return True - - return False - return True - - -class ServerManager(object): - """ - class ServerManager - - This class contains the information about the given server. - And, acts as connection manager for that particular session. - """ - - def __init__(self, server): - self.connections = dict() - - self.update(server) - - def update(self, server): - assert (server is not None) - assert (isinstance(server, Server)) - - self.ver = None - self.sversion = None - self.server_type = None - self.server_cls = None - self.password = None - - self.sid = server.id - self.host = server.host - self.hostaddr = server.hostaddr - self.port = server.port - self.db = server.maintenance_db - self.did = None - self.user = server.username - self.password = server.password - self.role = server.role - self.ssl_mode = server.ssl_mode - self.pinged = datetime.datetime.now() - self.db_info = dict() - self.server_types = None - self.db_res = server.db_res - self.passfile = server.passfile - self.sslcert = server.sslcert - self.sslkey = server.sslkey - self.sslrootcert = server.sslrootcert - self.sslcrl = server.sslcrl - self.sslcompression = True if server.sslcompression else False - - for con in self.connections: - self.connections[con]._release() - - self.update_session() - - self.connections = dict() - - def as_dict(self): - """ - Returns a dictionary object representing the server manager. - """ - if self.ver is None or len(self.connections) == 0: - return None - - res = dict() - res['sid'] = self.sid - res['ver'] = self.ver - res['sversion'] = self.sversion - if hasattr(self, 'password') and self.password: - # If running under PY2 - if hasattr(self.password, 'decode'): - res['password'] = self.password.decode('utf-8') - else: - res['password'] = str(self.password) - else: - res['password'] = self.password - - connections = res['connections'] = dict() - - for conn_id in self.connections: - conn = self.connections[conn_id].as_dict() - - if conn is not None: - connections[conn_id] = conn - - return res - - def ServerVersion(self): - return self.ver - - @property - def version(self): - return self.sversion - - def MajorVersion(self): - if self.sversion is not None: - return int(self.sversion / 10000) - raise Exception("Information is not available.") - - def MinorVersion(self): - if self.sversion: - return int(int(self.sversion / 100) % 100) - raise Exception("Information is not available.") - - def PatchVersion(self): - if self.sversion: - return int(int(self.sversion / 100) / 100) - raise Exception("Information is not available.") - - def connection( - self, database=None, conn_id=None, auto_reconnect=True, did=None, - async=None, use_binary_placeholder=False, array_to_string=False - ): - if database is not None: - if hasattr(str, 'decode') and \ - not isinstance(database, unicode): - database = database.decode('utf-8') - if did is not None: - if did in self.db_info: - self.db_info[did]['datname'] = database - else: - if did is None: - database = self.db - elif did in self.db_info: - database = self.db_info[did]['datname'] - else: - maintenance_db_id = u'DB:{0}'.format(self.db) - if maintenance_db_id in self.connections: - conn = self.connections[maintenance_db_id] - if conn.connected(): - status, res = conn.execute_dict(u""" -SELECT - db.oid as did, db.datname, db.datallowconn, - pg_encoding_to_char(db.encoding) AS serverencoding, - has_database_privilege(db.oid, 'CREATE') as cancreate, datlastsysoid -FROM - pg_database db -WHERE db.oid = {0}""".format(did)) - - if status and len(res['rows']) > 0: - for row in res['rows']: - self.db_info[did] = row - database = self.db_info[did]['datname'] - - if did not in self.db_info: - raise Exception(gettext( - "Could not find the specified database." - )) - - if database is None: - raise ConnectionLost(self.sid, None, None) - - my_id = (u'CONN:{0}'.format(conn_id)) if conn_id is not None else \ - (u'DB:{0}'.format(database)) - - self.pinged = datetime.datetime.now() - - if my_id in self.connections: - return self.connections[my_id] - else: - if async is None: - async = 1 if conn_id is not None else 0 - else: - async = 1 if async is True else 0 - self.connections[my_id] = Connection( - self, my_id, database, auto_reconnect, async, - use_binary_placeholder=use_binary_placeholder, - array_to_string=array_to_string - ) - - return self.connections[my_id] - - def _restore(self, data): - """ - Helps restoring to reconnect the auto-connect connections smoothly on - reload/restart of the app server.. - """ - # restore server version from flask session if flask server was - # restarted. As we need server version to resolve sql template paths. - - self.ver = data.get('ver', None) - self.sversion = data.get('sversion', None) - - if self.ver and not self.server_type: - from pgadmin.browser.server_groups.servers.types import ServerType - for st in ServerType.types(): - if st.instanceOf(self.ver): - self.server_type = st.stype - self.server_cls = st - break - - # Hmm.. we will not honour this request, when I already have - # connections - if len(self.connections) != 0: - return - - # We need to know about the existing server variant supports during - # first connection for identifications. - from pgadmin.browser.server_groups.servers.types import ServerType - self.pinged = datetime.datetime.now() - try: - if 'password' in data and data['password']: - data['password'] = data['password'].encode('utf-8') - except Exception as e: - current_app.logger.exception(e) - - connections = data['connections'] - for conn_id in connections: - conn_info = connections[conn_id] - conn = self.connections[conn_info['conn_id']] = Connection( - self, conn_info['conn_id'], conn_info['database'], - conn_info['auto_reconnect'], conn_info['async'], - use_binary_placeholder=conn_info['use_binary_placeholder'], - array_to_string=conn_info['array_to_string'] - ) - - # only try to reconnect if connection was connected previously and - # auto_reconnect is true. - if conn_info['wasConnected'] and conn_info['auto_reconnect']: - try: - conn.connect( - password=data['password'], - server_types=ServerType.types() - ) - # This will also update wasConnected flag in connection so - # no need to update the flag manually. - except Exception as e: - current_app.logger.exception(e) - self.connections.pop(conn_info['conn_id']) - - def release(self, database=None, conn_id=None, did=None): - if did is not None: - if did in self.db_info and 'datname' in self.db_info[did]: - database = self.db_info[did]['datname'] - if hasattr(str, 'decode') and \ - not isinstance(database, unicode): - database = database.decode('utf-8') - if database is None: - return False - else: - return False - - my_id = (u'CONN:{0}'.format(conn_id)) if conn_id is not None else \ - (u'DB:{0}'.format(database)) if database is not None else None - - if my_id is not None: - if my_id in self.connections: - self.connections[my_id]._release() - del self.connections[my_id] - if did is not None: - del self.db_info[did] - - if len(self.connections) == 0: - self.ver = None - self.sversion = None - self.server_type = None - self.server_cls = None - self.password = None - - self.update_session() - - return True - else: - return False - - for con in self.connections: - self.connections[con]._release() - - self.connections = dict() - self.ver = None - self.sversion = None - self.server_type = None - self.server_cls = None - self.password = None - - self.update_session() - - return True - - def _update_password(self, passwd): - self.password = passwd - for conn_id in self.connections: - conn = self.connections[conn_id] - if conn.conn is not None or conn.wasConnected is True: - conn.password = passwd - - def update_session(self): - managers = session['__pgsql_server_managers'] \ - if '__pgsql_server_managers' in session else dict() - updated_mgr = self.as_dict() - - if not updated_mgr: - if self.sid in managers: - managers.pop(self.sid) - else: - managers[self.sid] = updated_mgr - session['__pgsql_server_managers'] = managers - session.force_write = True - - def utility(self, operation): - """ - utility(operation) - - Returns: name of the utility which used for the operation - """ - if self.server_cls is not None: - return self.server_cls.utility(operation, self.sversion) - - return None - - def export_password_env(self, env): - if self.password: - password = decrypt( - self.password, current_user.password - ).decode() - os.environ[str(env)] = password +from ..abstract import BaseDriver +from .connection import Connection +from .server_manager import ServerManager class Driver(BaseDriver): @@ -2164,8 +202,9 @@ class Driver(BaseDriver): continue if curr_time - sess_mgr['pinged'] >= session_idle_timeout: - for mgr in [m for m in sess_mgr if isinstance(m, - ServerManager)]: + for mgr in [ + m for m in sess_mgr if isinstance(m, ServerManager) + ]: mgr.release() @staticmethod diff --git a/web/pgadmin/utils/driver/psycopg2/connection.py b/web/pgadmin/utils/driver/psycopg2/connection.py new file mode 100644 index 0000000..d4c9573 --- /dev/null +++ b/web/pgadmin/utils/driver/psycopg2/connection.py @@ -0,0 +1,1682 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2018, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +""" +Implementation of Connection. +It is a wrapper around the actual psycopg2 driver, and connection +object. +""" + +import random +import select +import sys +from collections import deque +import simplejson as json +import psycopg2 +from flask import g, current_app +from flask_babel import gettext +from flask_security import current_user +from pgadmin.utils.crypto import decrypt +from psycopg2.extensions import adapt, encodings + +import config +from pgadmin.model import Server, User +from pgadmin.utils.exception import ConnectionLost +from pgadmin.utils import get_complete_file_path +from ..abstract import BaseDriver, BaseConnection +from .cursor import DictCursor +from .typecast import register_global_typecasters, \ + register_string_typecasters, register_binary_typecasters, \ + register_array_to_string_typecasters, ALL_JSON_TYPES + + +if sys.version_info < (3,): + # Python2 in-built csv module do not handle unicode + # backports.csv module ported from PY3 csv module for unicode handling + from backports import csv + from StringIO import StringIO + IS_PY2 = True +else: + from io import StringIO + import csv + IS_PY2 = False + +_ = gettext + + +# Register global type caster which will be applicable to all connections. +register_global_typecasters() + + +class Connection(BaseConnection): + """ + class Connection(object) + + A wrapper class, which wraps the psycopg2 connection object, and + delegate the execution to the actual connection object, when required. + + Methods: + ------- + * connect(**kwargs) + - Connect the PostgreSQL/EDB Postgres Advanced Server using the psycopg2 + driver + + * execute_scalar(query, params, formatted_exception_msg) + - Execute the given query and returns single datum result + + * execute_async(query, params, formatted_exception_msg) + - Execute the given query asynchronously and returns result. + + * execute_void(query, params, formatted_exception_msg) + - Execute the given query with no result. + + * execute_2darray(query, params, formatted_exception_msg) + - Execute the given query and returns the result as a 2 dimensional + array. + + * execute_dict(query, params, formatted_exception_msg) + - Execute the given query and returns the result as an array of dict + (column name -> value) format. + + * connected() + - Get the status of the connection. + Returns True if connected, otherwise False. + + * reset() + - Reconnect the database server (if possible) + + * transaction_status() + - Transaction Status + + * ping() + - Ping the server. + + * _release() + - Release the connection object of psycopg2 + + * _reconnect() + - Attempt to reconnect to the database + + * _wait(conn) + - This method is used to wait for asynchronous connection. This is a + blocking call. + + * _wait_timeout(conn) + - This method is used to wait for asynchronous connection with timeout. + This is a non blocking call. + + * poll(formatted_exception_msg) + - This method is used to poll the data of query running on asynchronous + connection. + + * status_message() + - Returns the status message returned by the last command executed on + the server. + + * rows_affected() + - Returns the no of rows affected by the last command executed on + the server. + + * cancel_transaction(conn_id, did=None) + - This method is used to cancel the transaction for the + specified connection id and database id. + + * messages() + - Returns the list of messages/notices sends from the PostgreSQL database + server. + + * _formatted_exception_msg(exception_obj, formatted_msg) + - This method is used to parse the psycopg2.Error object and returns the + formatted error message if flag is set to true else return + normal error message. + + """ + + def __init__(self, manager, conn_id, db, auto_reconnect=True, async=0, + use_binary_placeholder=False, array_to_string=False): + assert (manager is not None) + assert (conn_id is not None) + + self.conn_id = conn_id + self.manager = manager + self.db = db if db is not None else manager.db + self.conn = None + self.auto_reconnect = auto_reconnect + self.async = async + self.__async_cursor = None + self.__async_query_id = None + self.__backend_pid = None + self.execution_aborted = False + self.row_count = 0 + self.__notices = None + self.password = None + # This flag indicates the connection status (connected/disconnected). + self.wasConnected = False + # This flag indicates the connection reconnecting status. + self.reconnecting = False + self.use_binary_placeholder = use_binary_placeholder + self.array_to_string = array_to_string + + super(Connection, self).__init__() + + def as_dict(self): + """ + Returns the dictionary object representing this object. + """ + # In case, it cannot be auto reconnectable, or already been released, + # then we will return None. + if not self.auto_reconnect and not self.conn: + return None + + res = dict() + res['conn_id'] = self.conn_id + res['database'] = self.db + res['async'] = self.async + res['wasConnected'] = self.wasConnected + res['auto_reconnect'] = self.auto_reconnect + res['use_binary_placeholder'] = self.use_binary_placeholder + res['array_to_string'] = self.array_to_string + + return res + + def __repr__(self): + return "PG Connection: {0} ({1}) -> {2} (ajax:{3})".format( + self.conn_id, self.db, + 'Connected' if self.conn and not self.conn.closed else + "Disconnected", + self.async + ) + + def __str__(self): + return "PG Connection: {0} ({1}) -> {2} (ajax:{3})".format( + self.conn_id, self.db, + 'Connected' if self.conn and not self.conn.closed else + "Disconnected", + self.async + ) + + def connect(self, **kwargs): + if self.conn: + if self.conn.closed: + self.conn = None + else: + return True, None + + pg_conn = None + password = None + passfile = None + mgr = self.manager + + encpass = kwargs['password'] if 'password' in kwargs else None + passfile = kwargs['passfile'] if 'passfile' in kwargs else None + + if encpass is None: + encpass = self.password or getattr(mgr, 'password', None) + + # Reset the existing connection password + if self.reconnecting is not False: + self.password = None + + if encpass: + # Fetch Logged in User Details. + user = User.query.filter_by(id=current_user.id).first() + + if user is None: + return False, gettext("Unauthorized request.") + + try: + password = decrypt(encpass, user.password) + # Handling of non ascii password (Python2) + if hasattr(str, 'decode'): + password = password.decode('utf-8').encode('utf-8') + # password is in bytes, for python3 we need it in string + elif isinstance(password, bytes): + password = password.decode() + + except Exception as e: + current_app.logger.exception(e) + return False, \ + _( + "Failed to decrypt the saved password.\nError: {0}" + ).format(str(e)) + + # If no password credential is found then connect request might + # come from Query tool, ViewData grid, debugger etc tools. + # we will check for pgpass file availability from connection manager + # if it's present then we will use it + if not password and not encpass and not passfile: + passfile = mgr.passfile if mgr.passfile else None + + try: + if hasattr(str, 'decode'): + database = self.db.encode('utf-8') + user = mgr.user.encode('utf-8') + conn_id = self.conn_id.encode('utf-8') + else: + database = self.db + user = mgr.user + conn_id = self.conn_id + + import os + os.environ['PGAPPNAME'] = '{0} - {1}'.format( + config.APP_NAME, conn_id) + + pg_conn = psycopg2.connect( + host=mgr.host, + hostaddr=mgr.hostaddr, + port=mgr.port, + database=database, + user=user, + password=password, + async=self.async, + passfile=get_complete_file_path(passfile), + sslmode=mgr.ssl_mode, + sslcert=get_complete_file_path(mgr.sslcert), + sslkey=get_complete_file_path(mgr.sslkey), + sslrootcert=get_complete_file_path(mgr.sslrootcert), + sslcrl=get_complete_file_path(mgr.sslcrl), + sslcompression=True if mgr.sslcompression else False, + service=mgr.service + ) + + # If connection is asynchronous then we will have to wait + # until the connection is ready to use. + if self.async == 1: + self._wait(pg_conn) + + except psycopg2.Error as e: + if e.pgerror: + msg = e.pgerror + elif e.diag.message_detail: + msg = e.diag.message_detail + else: + msg = str(e) + current_app.logger.info( + u"Failed to connect to the database server(#{server_id}) for " + u"connection ({conn_id}) with error message as below" + u":{msg}".format( + server_id=self.manager.sid, + conn_id=conn_id, + msg=msg.decode('utf-8') if hasattr(str, 'decode') else msg + ) + ) + return False, msg + + # Overwrite connection notice attr to support + # more than 50 notices at a time + pg_conn.notices = deque([], self.ASYNC_NOTICE_MAXLENGTH) + + self.conn = pg_conn + self.wasConnected = True + try: + status, msg = self._initialize(conn_id, **kwargs) + except Exception as e: + current_app.logger.exception(e) + self.conn = None + if not self.reconnecting: + self.wasConnected = False + raise e + + if status: + mgr._update_password(encpass) + else: + if not self.reconnecting: + self.wasConnected = False + + return status, msg + + def _initialize(self, conn_id, **kwargs): + self.execution_aborted = False + self.__backend_pid = self.conn.get_backend_pid() + + setattr(g, "{0}#{1}".format( + self.manager.sid, + self.conn_id.encode('utf-8') + ), None) + + status, cur = self.__cursor() + formatted_exception_msg = self._formatted_exception_msg + mgr = self.manager + + def _execute(cur, query, params=None): + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + return formatted_exception_msg(pe, False) + return None + + # autocommit flag does not work with asynchronous connections. + # By default asynchronous connection runs in autocommit mode. + if self.async == 0: + if 'autocommit' in kwargs and kwargs['autocommit'] is False: + self.conn.autocommit = False + else: + self.conn.autocommit = True + + register_string_typecasters(self.conn) + + if self.array_to_string: + register_array_to_string_typecasters(self.conn) + + # Register type casters for binary data only after registering array to + # string type casters. + if self.use_binary_placeholder: + register_binary_typecasters(self.conn) + + status = _execute(cur, "SET DateStyle=ISO;" + "SET client_min_messages=notice;" + "SET bytea_output=escape;" + "SET client_encoding='UNICODE';") + + if status is not None: + self.conn.close() + self.conn = None + + return False, status + + if mgr.role: + status = _execute(cur, u"SET ROLE TO %s", [mgr.role]) + + if status is not None: + self.conn.close() + self.conn = None + current_app.logger.error( + "Connect to the database server (#{server_id}) for " + "connection ({conn_id}), but - failed to setup the role " + "with error message as below:{msg}".format( + server_id=self.manager.sid, + conn_id=conn_id, + msg=status + ) + ) + return False, \ + _( + "Failed to setup the role with error message:\n{0}" + ).format(status) + + if mgr.ver is None: + status = _execute(cur, "SELECT version()") + + if status is not None: + self.conn.close() + self.conn = None + self.wasConnected = False + current_app.logger.error( + "Failed to fetch the version information on the " + "established connection to the database server " + "(#{server_id}) for '{conn_id}' with below error " + "message:{msg}".format( + server_id=self.manager.sid, + conn_id=conn_id, + msg=status) + ) + return False, status + + if cur.rowcount > 0: + row = cur.fetchmany(1)[0] + mgr.ver = row['version'] + mgr.sversion = self.conn.server_version + + status = _execute(cur, """ +SELECT + db.oid as did, db.datname, db.datallowconn, + pg_encoding_to_char(db.encoding) AS serverencoding, + has_database_privilege(db.oid, 'CREATE') as cancreate, datlastsysoid +FROM + pg_database db +WHERE db.datname = current_database()""") + + if status is None: + mgr.db_info = mgr.db_info or dict() + if cur.rowcount > 0: + res = cur.fetchmany(1)[0] + mgr.db_info[res['did']] = res.copy() + + # We do not have database oid for the maintenance database. + if len(mgr.db_info) == 1: + mgr.did = res['did'] + + status = _execute(cur, """ +SELECT + oid as id, rolname as name, rolsuper as is_superuser, + rolcreaterole as can_create_role, rolcreatedb as can_create_db +FROM + pg_catalog.pg_roles +WHERE + rolname = current_user""") + + if status is None: + mgr.user_info = dict() + if cur.rowcount > 0: + mgr.user_info = cur.fetchmany(1)[0] + + if 'password' in kwargs: + mgr.password = kwargs['password'] + + server_types = None + if 'server_types' in kwargs and isinstance( + kwargs['server_types'], list): + server_types = mgr.server_types = kwargs['server_types'] + + if server_types is None: + from pgadmin.browser.server_groups.servers.types import ServerType + server_types = ServerType.types() + + for st in server_types: + if st.instanceOf(mgr.ver): + mgr.server_type = st.stype + mgr.server_cls = st + break + + mgr.update_session() + + return True, None + + def __cursor(self, server_cursor=False): + if self.wasConnected is False: + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + cur = getattr(g, "{0}#{1}".format( + self.manager.sid, + self.conn_id.encode('utf-8') + ), None) + + if self.connected() and cur and not cur.closed: + if not server_cursor or (server_cursor and cur.name): + return True, cur + + if not self.connected(): + errmsg = "" + + current_app.logger.warning( + "Connection to database server (#{server_id}) for the " + "connection - '{conn_id}' has been lost.".format( + server_id=self.manager.sid, + conn_id=self.conn_id + ) + ) + + if self.auto_reconnect and not self.reconnecting: + self.__attempt_execution_reconnect(None) + else: + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + + try: + if server_cursor: + # Providing name to cursor will create server side cursor. + cursor_name = "CURSOR:{0}".format(self.conn_id) + cur = self.conn.cursor( + name=cursor_name, cursor_factory=DictCursor + ) + else: + cur = self.conn.cursor(cursor_factory=DictCursor) + except psycopg2.Error as pe: + current_app.logger.exception(pe) + errmsg = gettext( + "Failed to create cursor for psycopg2 connection with error " + "message for the server#{1}:{2}:\n{0}" + ).format( + str(pe), self.manager.sid, self.db + ) + + current_app.logger.error(errmsg) + if self.conn.closed: + self.conn = None + if self.auto_reconnect and not self.reconnecting: + current_app.logger.info( + gettext( + "Attempting to reconnect to the database server " + "(#{server_id}) for the connection - '{conn_id}'." + ).format( + server_id=self.manager.sid, + conn_id=self.conn_id + ) + ) + return self.__attempt_execution_reconnect( + self.__cursor, server_cursor + ) + else: + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' + else self.conn_id[5:] + ) + + setattr( + g, "{0}#{1}".format( + self.manager.sid, self.conn_id.encode('utf-8') + ), cur + ) + + return True, cur + + def __internal_blocking_execute(self, cur, query, params): + """ + This function executes the query using cursor's execute function, + but in case of asynchronous connection we need to wait for the + transaction to be completed. If self.async is 1 then it is a + blocking call. + + Args: + cur: Cursor object + query: SQL query to run. + params: Extra parameters + """ + + if sys.version_info < (3,): + if type(query) == unicode: + query = query.encode('utf-8') + else: + query = query.encode('utf-8') + + cur.execute(query, params) + if self.async == 1: + self._wait(cur.connection) + + def execute_on_server_as_csv(self, + query, params=None, + formatted_exception_msg=False, + records=2000): + """ + To fetch query result and generate CSV output + + Args: + query: SQL + params: Additional parameters + formatted_exception_msg: For exception + records: Number of initial records + Returns: + Generator response + """ + status, cur = self.__cursor(server_cursor=True) + self.row_count = 0 + + if not status: + return False, str(cur) + query_id = random.randint(1, 9999999) + + if IS_PY2 and type(query) == unicode: + query = query.encode('utf-8') + + current_app.logger.log( + 25, + u"Execute (with server cursor) for server #{server_id} - " + u"{conn_id} (Query-id: {query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query.decode('utf-8') if + sys.version_info < (3,) else query, + query_id=query_id + ) + ) + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"failed to execute query ((with server cursor) " + u"for the server #{server_id} - {conn_id} " + u"(query-id: {query_id}):\nerror message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + errmsg=errmsg, + query_id=query_id + ) + ) + return False, errmsg + + def handle_json_data(json_columns, results): + """ + [ This is only for Python2.x] + This function will be useful to handle json data types. + We will dump json data as proper json instead of unicode values + + Args: + json_columns: Columns which contains json data + results: Query result + + Returns: + results + """ + # Only if Python2 and there are columns with JSON type + if IS_PY2 and len(json_columns) > 0: + temp_results = [] + for row in results: + res = dict() + for k, v in row.items(): + if k in json_columns: + res[k] = json.dumps(v) + else: + res[k] = v + temp_results.append(res) + results = temp_results + return results + + def convert_keys_to_unicode(results, conn_encoding): + """ + [ This is only for Python2.x] + We need to convert all keys to unicode as psycopg2 + sends them as string + + Args: + res: Query result set from psycopg2 + conn_encoding: Connection encoding + + Returns: + Result set (With all the keys converted to unicode) + """ + new_results = [] + for row in results: + new_results.append( + dict([(k.decode(conn_encoding), v) + for k, v in row.items()]) + ) + return new_results + + def gen(quote='strings', quote_char="'", field_separator=','): + + results = cur.fetchmany(records) + if not results: + if not cur.closed: + cur.close() + yield gettext('The query executed did not return any data.') + return + + header = [] + json_columns = [] + conn_encoding = cur.connection.encoding + + for c in cur.ordered_description(): + # This is to handle the case in which column name is non-ascii + column_name = c.to_dict()['name'] + if IS_PY2: + column_name = column_name.decode(conn_encoding) + header.append(column_name) + if c.to_dict()['type_code'] in ALL_JSON_TYPES: + json_columns.append(column_name) + + if IS_PY2: + results = convert_keys_to_unicode(results, conn_encoding) + + res_io = StringIO() + + if quote == 'strings': + quote = csv.QUOTE_NONNUMERIC + elif quote == 'all': + quote = csv.QUOTE_ALL + else: + quote = csv.QUOTE_NONE + + if hasattr(str, 'decode'): + # Decode the field_separator + try: + field_separator = field_separator.decode('utf-8') + except Exception as e: + current_app.logger.error(e) + + # Decode the quote_char + try: + quote_char = quote_char.decode('utf-8') + except Exception as e: + current_app.logger.error(e) + + csv_writer = csv.DictWriter( + res_io, fieldnames=header, delimiter=field_separator, + quoting=quote, + quotechar=quote_char + ) + + csv_writer.writeheader() + results = handle_json_data(json_columns, results) + csv_writer.writerows(results) + + yield res_io.getvalue() + + while True: + results = cur.fetchmany(records) + + if not results: + if not cur.closed: + cur.close() + break + res_io = StringIO() + + csv_writer = csv.DictWriter( + res_io, fieldnames=header, delimiter=field_separator, + quoting=quote, + quotechar=quote_char + ) + + if IS_PY2: + results = convert_keys_to_unicode(results, conn_encoding) + + results = handle_json_data(json_columns, results) + csv_writer.writerows(results) + yield res_io.getvalue() + + return True, gen + + def execute_scalar(self, query, params=None, + formatted_exception_msg=False): + status, cur = self.__cursor() + self.row_count = 0 + + if not status: + return False, str(cur) + query_id = random.randint(1, 9999999) + + current_app.logger.log( + 25, + u"Execute (scalar) for server #{server_id} - {conn_id} (Query-id: " + u"{query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + query_id=query_id + ) + ) + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + if not self.connected(): + if self.auto_reconnect and not self.reconnecting: + return self.__attempt_execution_reconnect( + self.execute_dict, query, params, + formatted_exception_msg + ) + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"Failed to execute query (execute_scalar) for the server " + u"#{server_id} - {conn_id} (Query-id: {query_id}):\n" + u"Error Message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + errmsg=errmsg, + query_id=query_id + ) + ) + return False, errmsg + + self.row_count = cur.rowcount + if cur.rowcount > 0: + res = cur.fetchone() + if len(res) > 0: + return True, res[0] + + return True, None + + def execute_async(self, query, params=None, formatted_exception_msg=True): + """ + This function executes the given query asynchronously and returns + result. + + Args: + query: SQL query to run. + params: extra parameters to the function + formatted_exception_msg: if True then function return the + formatted exception message + """ + + if sys.version_info < (3,): + if type(query) == unicode: + query = query.encode('utf-8') + else: + query = query.encode('utf-8') + + self.__async_cursor = None + status, cur = self.__cursor() + + if not status: + return False, str(cur) + query_id = random.randint(1, 9999999) + + current_app.logger.log( + 25, + u"Execute (async) for server #{server_id} - {conn_id} (Query-id: " + u"{query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query.decode('utf-8'), + query_id=query_id + ) + ) + + try: + self.__notices = [] + self.execution_aborted = False + cur.execute(query, params) + res = self._wait_timeout(cur.connection) + except psycopg2.Error as pe: + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"Failed to execute query (execute_async) for the server " + u"#{server_id} - {conn_id}(Query-id: {query_id}):\n" + u"Error Message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query.decode('utf-8'), + errmsg=errmsg, + query_id=query_id + ) + ) + + if self.is_disconnected(pe): + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + return False, errmsg + + self.__async_cursor = cur + self.__async_query_id = query_id + + return True, res + + def execute_void(self, query, params=None, formatted_exception_msg=False): + """ + This function executes the given query with no result. + + Args: + query: SQL query to run. + params: extra parameters to the function + formatted_exception_msg: if True then function return the + formatted exception message + """ + status, cur = self.__cursor() + self.row_count = 0 + + if not status: + return False, str(cur) + query_id = random.randint(1, 9999999) + + current_app.logger.log( + 25, + u"Execute (void) for server #{server_id} - {conn_id} (Query-id: " + u"{query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + query_id=query_id + ) + ) + + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + if not self.connected(): + if self.auto_reconnect and not self.reconnecting: + return self.__attempt_execution_reconnect( + self.execute_void, query, params, + formatted_exception_msg + ) + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"Failed to execute query (execute_void) for the server " + u"#{server_id} - {conn_id}(Query-id: {query_id}):\n" + u"Error Message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + errmsg=errmsg, + query_id=query_id + ) + ) + return False, errmsg + + self.row_count = cur.rowcount + + return True, None + + def __attempt_execution_reconnect(self, fn, *args, **kwargs): + self.reconnecting = True + setattr(g, "{0}#{1}".format( + self.manager.sid, + self.conn_id.encode('utf-8') + ), None) + try: + status, res = self.connect() + if status: + if fn: + status, res = fn(*args, **kwargs) + self.reconnecting = False + return status, res + except Exception as e: + current_app.logger.exception(e) + self.reconnecting = False + + current_app.warning( + "Failed to reconnect the database server " + "(#{server_id})".format( + server_id=self.manager.sid, + conn_id=self.conn_id + ) + ) + self.reconnecting = False + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + + def execute_2darray(self, query, params=None, + formatted_exception_msg=False): + status, cur = self.__cursor() + self.row_count = 0 + + if not status: + return False, str(cur) + + query_id = random.randint(1, 9999999) + current_app.logger.log( + 25, + u"Execute (2darray) for server #{server_id} - {conn_id} " + u"(Query-id: {query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + query_id=query_id + ) + ) + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + if not self.connected(): + if self.auto_reconnect and \ + not self.reconnecting: + return self.__attempt_execution_reconnect( + self.execute_2darray, query, params, + formatted_exception_msg + ) + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"Failed to execute query (execute_2darray) for the server " + u"#{server_id} - {conn_id} (Query-id: {query_id}):\n" + u"Error Message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + errmsg=errmsg, + query_id=query_id + ) + ) + return False, errmsg + + # Get Resultset Column Name, Type and size + columns = cur.description and [ + desc.to_dict() for desc in cur.ordered_description() + ] or [] + + rows = [] + self.row_count = cur.rowcount + if cur.rowcount > 0: + for row in cur: + rows.append(row) + + return True, {'columns': columns, 'rows': rows} + + def execute_dict(self, query, params=None, formatted_exception_msg=False): + status, cur = self.__cursor() + self.row_count = 0 + + if not status: + return False, str(cur) + query_id = random.randint(1, 9999999) + current_app.logger.log( + 25, + u"Execute (dict) for server #{server_id} - {conn_id} (Query-id: " + u"{query_id}):\n{query}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query=query, + query_id=query_id + ) + ) + try: + self.__internal_blocking_execute(cur, query, params) + except psycopg2.Error as pe: + cur.close() + if not self.connected(): + if self.auto_reconnect and not self.reconnecting: + return self.__attempt_execution_reconnect( + self.execute_dict, query, params, + formatted_exception_msg + ) + raise ConnectionLost( + self.manager.sid, + self.db, + None if self.conn_id[0:3] == u'DB:' else self.conn_id[5:] + ) + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + current_app.logger.error( + u"Failed to execute query (execute_dict) for the server " + u"#{server_id}- {conn_id} (Query-id: {query_id}):\n" + u"Error Message:{errmsg}".format( + server_id=self.manager.sid, + conn_id=self.conn_id, + query_id=query_id, + errmsg=errmsg + ) + ) + return False, errmsg + + # Get Resultset Column Name, Type and size + columns = cur.description and [ + desc.to_dict() for desc in cur.ordered_description() + ] or [] + + rows = [] + self.row_count = cur.rowcount + if cur.rowcount > 0: + for row in cur: + rows.append(dict(row)) + + return True, {'columns': columns, 'rows': rows} + + def async_fetchmany_2darray(self, records=2000, + formatted_exception_msg=False): + """ + User should poll and check if status is ASYNC_OK before calling this + function + Args: + records: no of records to fetch. use -1 to fetchall. + formatted_exception_msg: + + Returns: + + """ + cur = self.__async_cursor + if not cur: + return False, gettext( + "Cursor could not be found for the async connection." + ) + + if self.conn.isexecuting(): + return False, gettext( + "Asynchronous query execution/operation underway." + ) + + if self.row_count > 0: + result = [] + # For DDL operation, we may not have result. + # + # Because - there is not direct way to differentiate DML and + # DDL operations, we need to rely on exception to figure + # that out at the moment. + try: + if records == -1: + res = cur.fetchall() + else: + res = cur.fetchmany(records) + for row in res: + new_row = [] + for col in self.column_info: + new_row.append(row[col['name']]) + result.append(new_row) + except psycopg2.ProgrammingError as e: + result = None + else: + # User performed operation which dose not produce record/s as + # result. + # for eg. DDL operations. + return True, None + + return True, result + + def connected(self): + if self.conn: + if not self.conn.closed: + return True + self.conn = None + return False + + def reset(self): + if self.conn: + if self.conn.closed: + self.conn = None + pg_conn = None + mgr = self.manager + + password = getattr(mgr, 'password', None) + + if password: + # Fetch Logged in User Details. + user = User.query.filter_by(id=current_user.id).first() + + if user is None: + return False, gettext("Unauthorized request.") + + password = decrypt(password, user.password).decode() + + try: + pg_conn = psycopg2.connect( + host=mgr.host, + hostaddr=mgr.hostaddr, + port=mgr.port, + database=self.db, + user=mgr.user, + password=password, + passfile=get_complete_file_path(mgr.passfile), + sslmode=mgr.ssl_mode, + sslcert=get_complete_file_path(mgr.sslcert), + sslkey=get_complete_file_path(mgr.sslkey), + sslrootcert=get_complete_file_path(mgr.sslrootcert), + sslcrl=get_complete_file_path(mgr.sslcrl), + sslcompression=True if mgr.sslcompression else False, + service=mgr.service + ) + + except psycopg2.Error as e: + msg = e.pgerror if e.pgerror else e.message \ + if e.message else e.diag.message_detail \ + if e.diag.message_detail else str(e) + + current_app.logger.error( + gettext( + """ +Failed to reset the connection to the server due to following error: +{0}""" + ).Format(msg) + ) + return False, msg + + pg_conn.notices = deque([], self.ASYNC_NOTICE_MAXLENGTH) + self.conn = pg_conn + self.__backend_pid = pg_conn.get_backend_pid() + + return True, None + + def transaction_status(self): + if self.conn: + return self.conn.get_transaction_status() + return None + + def ping(self): + return self.execute_scalar('SELECT 1') + + def _release(self): + if self.wasConnected: + if self.conn: + self.conn.close() + self.conn = None + self.password = None + self.wasConnected = False + + def _wait(self, conn): + """ + This function is used for the asynchronous connection, + it will call poll method in a infinite loop till poll + returns psycopg2.extensions.POLL_OK. This is a blocking + call. + + Args: + conn: connection object + """ + + while 1: + state = conn.poll() + if state == psycopg2.extensions.POLL_OK: + break + elif state == psycopg2.extensions.POLL_WRITE: + select.select([], [conn.fileno()], []) + elif state == psycopg2.extensions.POLL_READ: + select.select([conn.fileno()], [], []) + else: + raise psycopg2.OperationalError( + "poll() returned %s from _wait function" % state) + + def _wait_timeout(self, conn): + """ + This function is used for the asynchronous connection, + it will call poll method and return the status. If state is + psycopg2.extensions.POLL_WRITE and psycopg2.extensions.POLL_READ + function will wait for the given timeout.This is not a blocking call. + + Args: + conn: connection object + time: wait time + """ + + while 1: + state = conn.poll() + + if state == psycopg2.extensions.POLL_OK: + return self.ASYNC_OK + elif state == psycopg2.extensions.POLL_WRITE: + # Wait for the given time and then check the return status + # If three empty lists are returned then the time-out is + # reached. + timeout_status = select.select( + [], [conn.fileno()], [], self.ASYNC_TIMEOUT + ) + if timeout_status == ([], [], []): + return self.ASYNC_WRITE_TIMEOUT + elif state == psycopg2.extensions.POLL_READ: + # Wait for the given time and then check the return status + # If three empty lists are returned then the time-out is + # reached. + timeout_status = select.select( + [conn.fileno()], [], [], self.ASYNC_TIMEOUT + ) + if timeout_status == ([], [], []): + return self.ASYNC_READ_TIMEOUT + else: + raise psycopg2.OperationalError( + "poll() returned %s from _wait_timeout function" % state + ) + + def poll(self, formatted_exception_msg=False, no_result=False): + """ + This function is a wrapper around connection's poll function. + It internally uses the _wait_timeout method to poll the + result on the connection object. In case of success it + returns the result of the query. + + Args: + formatted_exception_msg: if True then function return the formatted + exception message, otherwise error string. + no_result: If True then only poll status will be returned. + """ + + cur = self.__async_cursor + if not cur: + return False, gettext( + "Cursor could not be found for the async connection." + ) + + current_app.logger.log( + 25, + "Polling result for (Query-id: {query_id})".format( + query_id=self.__async_query_id + ) + ) + + is_error = False + try: + status = self._wait_timeout(self.conn) + except psycopg2.Error as pe: + if self.conn.closed: + raise ConnectionLost( + self.manager.sid, + self.db, + self.conn_id[5:] + ) + errmsg = self._formatted_exception_msg(pe, formatted_exception_msg) + is_error = True + + if self.conn.notices and self.__notices is not None: + self.__notices.extend(self.conn.notices) + self.conn.notices.clear() + + # We also need to fetch notices before we return from function in case + # of any Exception, To avoid code duplication we will return after + # fetching the notices in case of any Exception + if is_error: + return False, errmsg + + result = None + self.row_count = 0 + self.column_info = None + + if status == self.ASYNC_OK: + + # if user has cancelled the transaction then changed the status + if self.execution_aborted: + status = self.ASYNC_EXECUTION_ABORTED + self.execution_aborted = False + return status, result + + # Fetch the column information + if cur.description is not None: + self.column_info = [ + desc.to_dict() for desc in cur.ordered_description() + ] + + pos = 0 + for col in self.column_info: + col['pos'] = pos + pos += 1 + + self.row_count = cur.rowcount + if not no_result: + if cur.rowcount > 0: + result = [] + # For DDL operation, we may not have result. + # + # Because - there is not direct way to differentiate DML + # and DDL operations, we need to rely on exception to + # figure that out at the moment. + try: + for row in cur: + new_row = [] + for col in self.column_info: + new_row.append(row[col['name']]) + result.append(new_row) + + except psycopg2.ProgrammingError: + result = None + + return status, result + + def status_message(self): + """ + This function will return the status message returned by the last + command executed on the server. + """ + cur = self.__async_cursor + if not cur: + return gettext( + "Cursor could not be found for the async connection." + ) + + current_app.logger.log( + 25, + "Status message for (Query-id: {query_id})".format( + query_id=self.__async_query_id + ) + ) + + return cur.statusmessage + + def rows_affected(self): + """ + This function will return the no of rows affected by the last command + executed on the server. + """ + + return self.row_count + + def get_column_info(self): + """ + This function will returns list of columns for last async sql command + executed on the server. + """ + + return self.column_info + + def cancel_transaction(self, conn_id, did=None): + """ + This function is used to cancel the running transaction + of the given connection id and database id using + PostgreSQL's pg_cancel_backend. + + Args: + conn_id: Connection id + did: Database id (optional) + """ + cancel_conn = self.manager.connection(did=did, conn_id=conn_id) + query = """SELECT pg_cancel_backend({0});""".format( + cancel_conn.__backend_pid) + + status = True + msg = '' + + # if backend pid is same then create a new connection + # to cancel the query and release it. + if cancel_conn.__backend_pid == self.__backend_pid: + password = getattr(self.manager, 'password', None) + if password: + # Fetch Logged in User Details. + user = User.query.filter_by(id=current_user.id).first() + if user is None: + return False, gettext("Unauthorized request.") + + password = decrypt(password, user.password).decode() + + try: + pg_conn = psycopg2.connect( + host=self.manager.host, + hostaddr=self.manager.hostaddr, + port=self.manager.port, + database=self.db, + user=self.manager.user, + password=password, + passfile=get_complete_file_path(self.manager.passfile), + sslmode=self.manager.ssl_mode, + sslcert=get_complete_file_path(self.manager.sslcert), + sslkey=get_complete_file_path(self.manager.sslkey), + sslrootcert=get_complete_file_path( + self.manager.sslrootcert + ), + sslcrl=get_complete_file_path(self.manager.sslcrl), + sslcompression=True if self.manager.sslcompression + else False, + service=self.manager.service + ) + + # Get the cursor and run the query + cur = pg_conn.cursor() + cur.execute(query) + + # Close the connection + pg_conn.close() + pg_conn = None + + except psycopg2.Error as e: + status = False + if e.pgerror: + msg = e.pgerror + elif e.diag.message_detail: + msg = e.diag.message_detail + else: + msg = str(e) + return status, msg + else: + if self.connected(): + status, msg = self.execute_void(query) + + if status: + cancel_conn.execution_aborted = True + else: + status = False + msg = gettext("Not connected to the database server.") + + return status, msg + + def messages(self): + """ + Returns the list of the messages/notices send from the database server. + """ + resp = [] + while self.__notices: + resp.append(self.__notices.pop(0)) + return resp + + def decode_to_utf8(self, value): + """ + This method will decode values to utf-8 + Args: + value: String to be decode + + Returns: + Decoded string + """ + is_error = False + if hasattr(str, 'decode'): + try: + value = value.decode('utf-8') + except UnicodeDecodeError: + # Let's try with python's preferred encoding + # On Windows lc_messages mostly has environment dependent + # encoding like 'French_France.1252' + try: + import locale + pref_encoding = locale.getpreferredencoding() + value = value.decode(pref_encoding)\ + .encode('utf-8')\ + .decode('utf-8') + except Exception: + is_error = True + except Exception: + is_error = True + + # If still not able to decode then + if is_error: + value = value.decode('ascii', 'ignore') + + return value + + def _formatted_exception_msg(self, exception_obj, formatted_msg): + """ + This method is used to parse the psycopg2.Error object and returns the + formatted error message if flag is set to true else return + normal error message. + + Args: + exception_obj: exception object + formatted_msg: if True then function return the formatted exception + message + + """ + if exception_obj.pgerror: + errmsg = exception_obj.pgerror + elif exception_obj.diag.message_detail: + errmsg = exception_obj.diag.message_detail + else: + errmsg = str(exception_obj) + # errmsg might contains encoded value, lets decode it + errmsg = self.decode_to_utf8(errmsg) + + # if formatted_msg is false then return from the function + if not formatted_msg: + return errmsg + + # Do not append if error starts with `ERROR:` as most pg related + # error starts with `ERROR:` + if not errmsg.startswith(u'ERROR:'): + errmsg = u'ERROR: ' + errmsg + u'\n\n' + + if exception_obj.diag.severity is not None \ + and exception_obj.diag.message_primary is not None: + ex_diag_message = u"{0}: {1}".format( + exception_obj.diag.severity, + self.decode_to_utf8(exception_obj.diag.message_primary) + ) + # If both errors are different then only append it + if errmsg and ex_diag_message and \ + ex_diag_message.strip().strip('\n').lower() not in \ + errmsg.strip().strip('\n').lower(): + errmsg += ex_diag_message + elif exception_obj.diag.message_primary is not None: + message_primary = self.decode_to_utf8( + exception_obj.diag.message_primary + ) + if message_primary.lower() not in errmsg.lower(): + errmsg += message_primary + + if exception_obj.diag.sqlstate is not None: + if not errmsg.endswith('\n'): + errmsg += '\n' + errmsg += gettext('SQL state: ') + errmsg += self.decode_to_utf8(exception_obj.diag.sqlstate) + + if exception_obj.diag.message_detail is not None: + if 'Detail:'.lower() not in errmsg.lower(): + if not errmsg.endswith('\n'): + errmsg += '\n' + errmsg += gettext('Detail: ') + errmsg += self.decode_to_utf8( + exception_obj.diag.message_detail + ) + + if exception_obj.diag.message_hint is not None: + if 'Hint:'.lower() not in errmsg.lower(): + if not errmsg.endswith('\n'): + errmsg += '\n' + errmsg += gettext('Hint: ') + errmsg += self.decode_to_utf8(exception_obj.diag.message_hint) + + if exception_obj.diag.statement_position is not None: + if 'Character:'.lower() not in errmsg.lower(): + if not errmsg.endswith('\n'): + errmsg += '\n' + errmsg += gettext('Character: ') + errmsg += self.decode_to_utf8( + exception_obj.diag.statement_position + ) + + if exception_obj.diag.context is not None: + if 'Context:'.lower() not in errmsg.lower(): + if not errmsg.endswith('\n'): + errmsg += '\n' + errmsg += gettext('Context: ') + errmsg += self.decode_to_utf8(exception_obj.diag.context) + + return errmsg + + ##### + # As per issue reported on pgsycopg2 github repository link is shared below + # conn.closed is not reliable enough to identify the disconnection from the + # database server for some unknown reasons. + # + # (https://github.com/psycopg/psycopg2/issues/263) + # + # In order to resolve the issue, sqlalchamey follows the below logic to + # identify the disconnection. It relies on exception message to identify + # the error. + # + # Reference (MIT license): + # https://github.com/zzzeek/sqlalchemy/blob/master/lib/sqlalchemy/dialects/postgresql/psycopg2.py + # + def is_disconnected(self, err): + if not self.conn.closed: + # checks based on strings. in the case that .closed + # didn't cut it, fall back onto these. + str_e = str(err).partition("\n")[0] + for msg in [ + # these error messages from libpq: interfaces/libpq/fe-misc.c + # and interfaces/libpq/fe-secure.c. + 'terminating connection', + 'closed the connection', + 'connection not open', + 'could not receive data from server', + 'could not send data to server', + # psycopg2 client errors, psycopg2/conenction.h, + # psycopg2/cursor.h + 'connection already closed', + 'cursor already closed', + # not sure where this path is originally from, it may + # be obsolete. It really says "losed", not "closed". + 'losed the connection unexpectedly', + # these can occur in newer SSL + 'connection has been closed unexpectedly', + 'SSL SYSCALL error: Bad file descriptor', + 'SSL SYSCALL error: EOF detected', + ]: + idx = str_e.find(msg) + if idx >= 0 and '"' not in str_e[:idx]: + return True + + return False + return True diff --git a/web/pgadmin/utils/driver/psycopg2/server_manager.py b/web/pgadmin/utils/driver/psycopg2/server_manager.py new file mode 100644 index 0000000..2299e28 --- /dev/null +++ b/web/pgadmin/utils/driver/psycopg2/server_manager.py @@ -0,0 +1,333 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2018, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +""" +Implementation of ServerManager +""" +import os +import datetime +from flask import current_app, session +from flask_security import current_user +from flask_babel import gettext + +from pgadmin.utils.crypto import decrypt +from .connection import Connection +from pgadmin.model import Server + + +class ServerManager(object): + """ + class ServerManager + + This class contains the information about the given server. + And, acts as connection manager for that particular session. + """ + + def __init__(self, server): + self.connections = dict() + + self.update(server) + + def update(self, server): + assert (server is not None) + assert (isinstance(server, Server)) + + self.ver = None + self.sversion = None + self.server_type = None + self.server_cls = None + self.password = None + + self.sid = server.id + self.host = server.host + self.hostaddr = server.hostaddr + self.port = server.port + self.db = server.maintenance_db + self.did = None + self.user = server.username + self.password = server.password + self.role = server.role + self.ssl_mode = server.ssl_mode + self.pinged = datetime.datetime.now() + self.db_info = dict() + self.server_types = None + self.db_res = server.db_res + self.passfile = server.passfile + self.sslcert = server.sslcert + self.sslkey = server.sslkey + self.sslrootcert = server.sslrootcert + self.sslcrl = server.sslcrl + self.sslcompression = True if server.sslcompression else False + self.service = server.service + + for con in self.connections: + self.connections[con]._release() + + self.update_session() + + self.connections = dict() + + def as_dict(self): + """ + Returns a dictionary object representing the server manager. + """ + if self.ver is None or len(self.connections) == 0: + return None + + res = dict() + res['sid'] = self.sid + res['ver'] = self.ver + res['sversion'] = self.sversion + if hasattr(self, 'password') and self.password: + # If running under PY2 + if hasattr(self.password, 'decode'): + res['password'] = self.password.decode('utf-8') + else: + res['password'] = str(self.password) + else: + res['password'] = self.password + + connections = res['connections'] = dict() + + for conn_id in self.connections: + conn = self.connections[conn_id].as_dict() + + if conn is not None: + connections[conn_id] = conn + + return res + + def ServerVersion(self): + return self.ver + + @property + def version(self): + return self.sversion + + def MajorVersion(self): + if self.sversion is not None: + return int(self.sversion / 10000) + raise Exception("Information is not available.") + + def MinorVersion(self): + if self.sversion: + return int(int(self.sversion / 100) % 100) + raise Exception("Information is not available.") + + def PatchVersion(self): + if self.sversion: + return int(int(self.sversion / 100) / 100) + raise Exception("Information is not available.") + + def connection( + self, database=None, conn_id=None, auto_reconnect=True, did=None, + async=None, use_binary_placeholder=False, array_to_string=False + ): + if database is not None: + if hasattr(str, 'decode') and \ + not isinstance(database, unicode): + database = database.decode('utf-8') + if did is not None: + if did in self.db_info: + self.db_info[did]['datname'] = database + else: + if did is None: + database = self.db + elif did in self.db_info: + database = self.db_info[did]['datname'] + else: + maintenance_db_id = u'DB:{0}'.format(self.db) + if maintenance_db_id in self.connections: + conn = self.connections[maintenance_db_id] + if conn.connected(): + status, res = conn.execute_dict(u""" +SELECT + db.oid as did, db.datname, db.datallowconn, + pg_encoding_to_char(db.encoding) AS serverencoding, + has_database_privilege(db.oid, 'CREATE') as cancreate, datlastsysoid +FROM + pg_database db +WHERE db.oid = {0}""".format(did)) + + if status and len(res['rows']) > 0: + for row in res['rows']: + self.db_info[did] = row + database = self.db_info[did]['datname'] + + if did not in self.db_info: + raise Exception(gettext( + "Could not find the specified database." + )) + + if database is None: + raise ConnectionLost(self.sid, None, None) + + my_id = (u'CONN:{0}'.format(conn_id)) if conn_id is not None else \ + (u'DB:{0}'.format(database)) + + self.pinged = datetime.datetime.now() + + if my_id in self.connections: + return self.connections[my_id] + else: + if async is None: + async = 1 if conn_id is not None else 0 + else: + async = 1 if async is True else 0 + self.connections[my_id] = Connection( + self, my_id, database, auto_reconnect, async, + use_binary_placeholder=use_binary_placeholder, + array_to_string=array_to_string + ) + + return self.connections[my_id] + + def _restore(self, data): + """ + Helps restoring to reconnect the auto-connect connections smoothly on + reload/restart of the app server.. + """ + # restore server version from flask session if flask server was + # restarted. As we need server version to resolve sql template paths. + from pgadmin.browser.server_groups.servers.types import ServerType + + self.ver = data.get('ver', None) + self.sversion = data.get('sversion', None) + + if self.ver and not self.server_type: + for st in ServerType.types(): + if st.instanceOf(self.ver): + self.server_type = st.stype + self.server_cls = st + break + + # Hmm.. we will not honour this request, when I already have + # connections + if len(self.connections) != 0: + return + + # We need to know about the existing server variant supports during + # first connection for identifications. + self.pinged = datetime.datetime.now() + try: + if 'password' in data and data['password']: + data['password'] = data['password'].encode('utf-8') + except Exception as e: + current_app.logger.exception(e) + + connections = data['connections'] + for conn_id in connections: + conn_info = connections[conn_id] + conn = self.connections[conn_info['conn_id']] = Connection( + self, conn_info['conn_id'], conn_info['database'], + conn_info['auto_reconnect'], conn_info['async'], + use_binary_placeholder=conn_info['use_binary_placeholder'], + array_to_string=conn_info['array_to_string'] + ) + + # only try to reconnect if connection was connected previously and + # auto_reconnect is true. + if conn_info['wasConnected'] and conn_info['auto_reconnect']: + try: + conn.connect( + password=data['password'], + server_types=ServerType.types() + ) + # This will also update wasConnected flag in connection so + # no need to update the flag manually. + except Exception as e: + current_app.logger.exception(e) + self.connections.pop(conn_info['conn_id']) + + def release(self, database=None, conn_id=None, did=None): + if did is not None: + if did in self.db_info and 'datname' in self.db_info[did]: + database = self.db_info[did]['datname'] + if hasattr(str, 'decode') and \ + not isinstance(database, unicode): + database = database.decode('utf-8') + if database is None: + return False + else: + return False + + my_id = (u'CONN:{0}'.format(conn_id)) if conn_id is not None else \ + (u'DB:{0}'.format(database)) if database is not None else None + + if my_id is not None: + if my_id in self.connections: + self.connections[my_id]._release() + del self.connections[my_id] + if did is not None: + del self.db_info[did] + + if len(self.connections) == 0: + self.ver = None + self.sversion = None + self.server_type = None + self.server_cls = None + self.password = None + + self.update_session() + + return True + else: + return False + + for con in self.connections: + self.connections[con]._release() + + self.connections = dict() + self.ver = None + self.sversion = None + self.server_type = None + self.server_cls = None + self.password = None + + self.update_session() + + return True + + def _update_password(self, passwd): + self.password = passwd + for conn_id in self.connections: + conn = self.connections[conn_id] + if conn.conn is not None or conn.wasConnected is True: + conn.password = passwd + + def update_session(self): + managers = session['__pgsql_server_managers'] \ + if '__pgsql_server_managers' in session else dict() + updated_mgr = self.as_dict() + + if not updated_mgr: + if self.sid in managers: + managers.pop(self.sid) + else: + managers[self.sid] = updated_mgr + session['__pgsql_server_managers'] = managers + session.force_write = True + + def utility(self, operation): + """ + utility(operation) + + Returns: name of the utility which used for the operation + """ + if self.server_cls is not None: + return self.server_cls.utility(operation, self.sversion) + + return None + + def export_password_env(self, env): + if self.password: + password = decrypt( + self.password, current_user.password + ).decode() + os.environ[str(env)] = password ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-12 20:46 Dave Page <[email protected]> parent: Murtuza Zabuawala <[email protected]> 0 siblings, 1 reply; 14+ messages in thread From: Dave Page @ 2018-03-12 20:46 UTC (permalink / raw) To: Murtuza Zabuawala <[email protected]>; +Cc: pgadmin-hackers Thanks, patch applied! On Mon, Mar 12, 2018 at 3:31 AM, Murtuza Zabuawala < [email protected]> wrote: > Hi Dave, > > PFA updated patch. > > -- > Regards, > Murtuza Zabuawala > EnterpriseDB: http://www.enterprisedb.com > The Enterprise PostgreSQL Company > > > On Fri, Mar 9, 2018 at 9:29 PM, Murtuza Zabuawala <murtuza.zabuawala@ > enterprisedb.com> wrote: > >> Hi Dave, >> >> I'll change the name and send you updated patch. >> >> >> On Fri, Mar 9, 2018 at 9:25 PM, Dave Page <[email protected]> wrote: >> >>> HI >>> >>> On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala < >>> [email protected]> wrote: >>> >>>> Hi, >>>> >>>> PFA patch to add service parameter in server dialog. >>>> - Docs updated >>>> - Test case added for Service ID parameter >>>> >>>> Please note, >>>> I have extracted Connection class and Server manager class from our own >>>> custom Psycopg2 driver module. >>>> >>>> Patch also covers RM#3120 >>>> >>> >>> This patch seems a little confused. The "Service" and "Service ID" >>> fields from pgAdmin 3 are very different things. The Redmine ticket seems >>> to be asking for the Service field (the pg_service.conf service name), >>> *not* Service ID (the operating system's service ID, used to start/stop the >>> database server service). >>> >>> -- >>> Dave Page >>> Blog: http://pgsnake.blogspot.com >>> Twitter: @pgsnake >>> >>> EnterpriseDB UK: http://www.enterprisedb.com >>> The Enterprise PostgreSQL Company >>> >> >> > -- Dave Page Blog: http://pgsnake.blogspot.com Twitter: @pgsnake EnterpriseDB UK: http://www.enterprisedb.com The Enterprise PostgreSQL Company ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-12 21:18 Joao De Almeida Pereira <[email protected]> parent: Dave Page <[email protected]> 0 siblings, 1 reply; 14+ messages in thread From: Joao De Almeida Pereira @ 2018-03-12 21:18 UTC (permalink / raw) To: Dave Page <[email protected]>; +Cc: Murtuza Zabuawala <[email protected]>; pgadmin-hackers Hi Dave and Murtuza, Regarding this patch we refactored the Javascript code so that is lives in a different file and added some tests. Also we found an issue with karma-jasmine that does not allow us to use jasmine 3.1 yet. You can find attached a patch that reverts that commit. Thanks Victoria && Joao On Mon, Mar 12, 2018 at 4:46 PM Dave Page <[email protected]> wrote: > Thanks, patch applied! > > On Mon, Mar 12, 2018 at 3:31 AM, Murtuza Zabuawala < > [email protected]> wrote: > >> Hi Dave, >> >> PFA updated patch. >> >> -- >> Regards, >> Murtuza Zabuawala >> EnterpriseDB: http://www.enterprisedb.com >> The Enterprise PostgreSQL Company >> >> >> On Fri, Mar 9, 2018 at 9:29 PM, Murtuza Zabuawala < >> [email protected]> wrote: >> >>> Hi Dave, >>> >>> I'll change the name and send you updated patch. >>> >>> >>> On Fri, Mar 9, 2018 at 9:25 PM, Dave Page <[email protected]> wrote: >>> >>>> HI >>>> >>>> On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala < >>>> [email protected]> wrote: >>>> >>>>> Hi, >>>>> >>>>> PFA patch to add service parameter in server dialog. >>>>> - Docs updated >>>>> - Test case added for Service ID parameter >>>>> >>>>> Please note, >>>>> I have extracted Connection class and Server manager class from our >>>>> own custom Psycopg2 driver module. >>>>> >>>>> Patch also covers RM#3120 >>>>> >>>> >>>> This patch seems a little confused. The "Service" and "Service ID" >>>> fields from pgAdmin 3 are very different things. The Redmine ticket seems >>>> to be asking for the Service field (the pg_service.conf service name), >>>> *not* Service ID (the operating system's service ID, used to start/stop the >>>> database server service). >>>> >>>> -- >>>> Dave Page >>>> Blog: http://pgsnake.blogspot.com >>>> Twitter: @pgsnake >>>> >>>> EnterpriseDB UK: http://www.enterprisedb.com >>>> The Enterprise PostgreSQL Company >>>> >>> >>> >> > > > -- > Dave Page > Blog: http://pgsnake.blogspot.com > Twitter: @pgsnake > > EnterpriseDB UK: http://www.enterprisedb.com > The Enterprise PostgreSQL Company > Attachments: [text/x-patch] refactor-javascript.diff (19.9K, 3-refactor-javascript.diff) download | inline diff: diff --git a/web/package.json b/web/package.json index ad0f3e16..66684fc6 100644 --- a/web/package.json +++ b/web/package.json @@ -69,6 +69,7 @@ "hard-source-webpack-plugin": "^0.4.9", "immutability-helper": "^2.2.0", "imports-loader": "^0.7.1", + "ip-address": "^5.8.9", "jquery": "1.11.2", "jquery-contextmenu": "^2.5.0", "jquery-ui": "^1.12.1", diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.js b/web/pgadmin/browser/server_groups/servers/static/js/server.js index 53be2255..302fe458 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -2,10 +2,13 @@ define('pgadmin.node.server', [ 'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'backbone', 'underscore.string', 'sources/pgadmin', 'pgadmin.browser', 'pgadmin.server.supported_servers', 'pgadmin.user_management.current_user', - 'pgadmin.alertifyjs', 'pgadmin.backform', 'pgadmin.browser.server.privilege', + 'pgadmin.alertifyjs', 'pgadmin.backform', + 'sources/browser/server_groups/servers/model_validation', + 'pgadmin.browser.server.privilege', ], function( gettext, url_for, $, _, Backbone, S, pgAdmin, pgBrowser, - supported_servers, current_user, Alertify, Backform + supported_servers, current_user, Alertify, Backform, + modelValidation ) { if (!pgBrowser.Nodes['server']) { @@ -848,110 +851,8 @@ define('pgadmin.node.server', [ group: gettext('Connection'), }], validate: function() { - var err = {}, - errmsg, - self = this; - - var service_id = this.get('service'); - - var check_for_empty = function(id, msg) { - var v = self.get(id); - if ( - _.isUndefined(v) || v === null || String(v).replace(/^\s+|\s+$/g, '') == '' - ) { - err[id] = msg; - errmsg = errmsg || msg; - return true; - } else { - self.errorModel.unset(id); - return false; - } - }; - var check_for_valid_ipv6 = function(val){ - // Regular expression for validating IPv6 address formats - var exps = ['^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|', - '(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|', - '2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|', - '(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|', - ':((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|', - '(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|', - '2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|', - '(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|', - '[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|', - '((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|', - '(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|', - '1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|', - '((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$']; - - var exp = new RegExp(exps.join('')); - return exp.test(val.trim()); - }; - var check_for_valid_ip = function(id, msg) { - var v4exps = '(^\\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\\s*$)'; - var v4exp = new RegExp(v4exps); - var v = self.get(id); - if ( - v && !(v4exp.test(v.trim())) - ) { - if(!check_for_valid_ipv6(v)){ - err[id] = msg; - errmsg = msg; - } - } else { - self.errorModel.unset(id); - } - }; - - if (!self.isNew() && 'id' in self.sessAttrs) { - err['id'] = gettext('The ID cannot be changed.'); - errmsg = err['id']; - } else { - self.errorModel.unset('id'); - } - check_for_empty('name', gettext('Name must be specified.')); - - // If no service id then only check - if ( - _.isUndefined(service_id) || _.isNull(service_id) || - String(service_id).replace(/^\s+|\s+$/g, '') == '' - ) { - if (check_for_empty( - 'host', gettext('Either Host name or Host address must be specified.') - ) && check_for_empty('hostaddr', gettext('Either Host name or Host address must be specified.'))){ - errmsg = errmsg || gettext('Either Host name or Host address must be specified'); - } else { - errmsg = undefined; - delete err['host']; - delete err['hostaddr']; - } - - check_for_empty( - 'db', gettext('Maintenance database must be specified.') - ); - check_for_valid_ip( - 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') - ); - check_for_valid_ip( - 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') - ); - } else { - _.each(['host', 'hostaddr', 'db'], (item) => { - self.errorModel.unset(item); - }); - } - - check_for_empty( - 'username', gettext('Username must be specified.') - ); - check_for_empty('port', gettext('Port must be specified.')); - - this.errorModel.set(err); - - if (_.size(err)) { - return errmsg; - } - - return null; + const validateModel = new modelValidation.ModelValidation(this); + return validateModel.validate(); }, isConnected: function(model) { return model.get('connected'); diff --git a/web/pgadmin/static/bundle/browser.js b/web/pgadmin/static/bundle/browser.js index 3fcc69d8..83b2ad8b 100644 --- a/web/pgadmin/static/bundle/browser.js +++ b/web/pgadmin/static/bundle/browser.js @@ -1,6 +1,6 @@ define('bundled_browser',[ 'pgadmin.browser', - 'sources/browser/server_groups/servers/databases/external_tables/index', + 'sources/browser/index', ], function(pgBrowser) { pgBrowser.init(); }); diff --git a/web/pgadmin/static/js/browser/index.js b/web/pgadmin/static/js/browser/index.js new file mode 100644 index 00000000..297e8bf9 --- /dev/null +++ b/web/pgadmin/static/js/browser/index.js @@ -0,0 +1,10 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import 'server_groups'; diff --git a/web/pgadmin/static/js/browser/server_groups/index.js b/web/pgadmin/static/js/browser/server_groups/index.js new file mode 100644 index 00000000..b151b6f6 --- /dev/null +++ b/web/pgadmin/static/js/browser/server_groups/index.js @@ -0,0 +1,10 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import 'servers'; diff --git a/web/pgadmin/static/js/browser/server_groups/servers/databases/index.js b/web/pgadmin/static/js/browser/server_groups/servers/databases/index.js new file mode 100644 index 00000000..ef17c0ad --- /dev/null +++ b/web/pgadmin/static/js/browser/server_groups/servers/databases/index.js @@ -0,0 +1,10 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import 'external_tables'; diff --git a/web/pgadmin/static/js/browser/server_groups/servers/index.js b/web/pgadmin/static/js/browser/server_groups/servers/index.js new file mode 100644 index 00000000..242a1919 --- /dev/null +++ b/web/pgadmin/static/js/browser/server_groups/servers/index.js @@ -0,0 +1,11 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import 'databases'; +import 'model_validation'; diff --git a/web/pgadmin/static/js/browser/server_groups/servers/model_validation.js b/web/pgadmin/static/js/browser/server_groups/servers/model_validation.js new file mode 100644 index 00000000..feb4e6bd --- /dev/null +++ b/web/pgadmin/static/js/browser/server_groups/servers/model_validation.js @@ -0,0 +1,104 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import _ from 'underscore'; +import {Address4, Address6} from 'ip-address'; + +export class ModelValidation { + constructor(model) { + this.err = {}; + this.errmsg = ''; + this.model = model; + } + + validate() { + const serviceId = this.model.get('service'); + + if (!this.model.isNew() && 'id' in this.model.sessAttrs) { + this.err['id'] = gettext('The ID cannot be changed.'); + this.errmsg = this.err['id']; + } else { + this.model.errorModel.unset('id'); + } + + this.checkForEmpty('name', gettext('Name must be specified.')); + + if (ModelValidation.isEmptyString(serviceId)) { + this.checkHostAndHostAddress(); + + this.checkForEmpty('db', gettext('Maintenance database must be specified.')); + } else { + this.clearHostAddressAndDbErrors(); + } + + this.checkForEmpty('username', gettext('Username must be specified.')); + this.checkForEmpty('port', gettext('Port must be specified.')); + + this.model.errorModel.set(this.err); + + if (_.size(this.err)) { + return this.errmsg; + } + + return null; + } + + clearHostAddressAndDbErrors() { + _.each(['host', 'hostaddr', 'db'], (item) => { + this.model.errorModel.unset(item); + }); + } + + checkHostAndHostAddress() { + const translatedStr = gettext('Either Host name or Host address must be' + + ' specified.'); + if (this.checkForEmpty('host', translatedStr) && + this.checkForEmpty('hostaddr', translatedStr)) { + this.errmsg = this.errmsg || translatedStr; + } else { + this.errmsg = undefined; + delete this.err['host']; + delete this.err['hostaddr']; + } + + this.checkForValidIp(this.model.get('hostaddr'), + gettext('Host address must be valid IPv4 or IPv6 address.')); + } + + checkForValidIp(ipAddress, msg) { + if (ipAddress) { + const isIpv6Address = new Address6(ipAddress).isValid(); + const isIpv4Address = new Address4(ipAddress).isValid(); + if (!isIpv4Address && !isIpv6Address) { + this.err['hostaddr'] = msg; + this.errmsg = msg; + } + } else { + this.model.errorModel.unset('hostaddr'); + } + } + + checkForEmpty(id, msg) { + const value = this.model.get(id); + + if (ModelValidation.isEmptyString(value)) { + this.err[id] = msg; + this.errmsg = this.errmsg || msg; + return true; + } else { + this.model.errorModel.unset(id); + return false; + } + } + + static isEmptyString(string) { + return _.isUndefined(string) || _.isNull(string) || string.trim() === ''; + } +} diff --git a/web/regression/javascript/browser/server_groups/servers/model_validation_spec.js b/web/regression/javascript/browser/server_groups/servers/model_validation_spec.js new file mode 100644 index 00000000..f0b13a8d --- /dev/null +++ b/web/regression/javascript/browser/server_groups/servers/model_validation_spec.js @@ -0,0 +1,101 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import {ModelValidation} from 'sources/browser/server_groups/servers/model_validation'; + +describe('Server#ModelValidation', () => { + describe('When validating a server parameters', () => { + let model; + let modelValidation; + beforeEach(() => { + model = { + errorModel: jasmine.createSpyObj('errorModel', ['set', 'unset']), + allValues: {}, + get: function (key) { + return this.allValues[key]; + }, + sessAttrs: {}, + }; + model.isNew = jasmine.createSpy('isNew'); + modelValidation = new ModelValidation(model); + }); + + describe('When all parameters are valid', () => { + beforeEach(() => { + model.isNew.and.returnValue(true); + model.allValues['name'] = 'some name'; + model.allValues['username'] = 'some username'; + model.allValues['port'] = 'some port'; + }); + + describe('No service id', () => { + it('does not set any error in the model', () => { + model.allValues['host'] = 'some host'; + model.allValues['db'] = 'some db'; + model.allValues['hostaddr'] = '1.1.1.1'; + expect(modelValidation.validate()).toBeNull(); + expect(model.errorModel.set).toHaveBeenCalledWith({}); + }); + }); + + describe('Service id present', () => { + it('does not set any error in the model', () => { + model.allValues['service'] = 'asdfg'; + expect(modelValidation.validate()).toBeNull(); + expect(model.errorModel.set).toHaveBeenCalledWith({}); + }); + }); + }); + + describe('When no parameters are valid', () => { + describe('Service id not present', () => { + it('does not set any error in the model', () => { + expect(modelValidation.validate()).toBe('Name must be specified.'); + expect(model.errorModel.set).toHaveBeenCalledTimes(1); + expect(model.errorModel.set).toHaveBeenCalledWith({ + name: 'Name must be specified.', + host: 'Either Host name or Host address must be specified.', + hostaddr: 'Either Host name or Host address must be specified.', + db: 'Maintenance database must be specified.', + username: 'Username must be specified.', + port: 'Port must be specified.' + }); + }); + }); + + describe('Host address is not valid', () => { + it('sets the "Host address must be a valid IPv4 or IPv6 address" error', () => { + model.allValues['hostaddr'] = 'something that is not an ip address'; + expect(modelValidation.validate()).toBe('Host address must be valid IPv4 or IPv6 address.'); + expect(model.errorModel.set).toHaveBeenCalledTimes(1); + expect(model.errorModel.set).toHaveBeenCalledWith({ + name: 'Name must be specified.', + hostaddr: 'Host address must be valid IPv4 or IPv6 address.', + db: 'Maintenance database must be specified.', + username: 'Username must be specified.', + port: 'Port must be specified.' + }); + }); + }); + + describe('Service id present', () => { + it('does not set any error in the model', () => { + model.allValues['service'] = 'asdfg'; + expect(modelValidation.validate()).toBe('Name must be specified.'); + expect(model.errorModel.set).toHaveBeenCalledTimes(1); + expect(model.errorModel.set).toHaveBeenCalledWith({ + name: 'Name must be specified.', + username: 'Username must be specified.', + port: 'Port must be specified.' + }); + }); + }); + }); + }); +}); diff --git a/web/yarn.lock b/web/yarn.lock index 85ccbc8b..2dc4c5c2 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -3938,6 +3938,18 @@ invert-kv@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" +ip-address@^5.8.9: + version "5.8.9" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-5.8.9.tgz#6379277c23fc5adb20511e4d23ec2c1bde105dfd" + dependencies: + jsbn "1.1.0" + lodash.find "^4.6.0" + lodash.max "^4.0.1" + lodash.merge "^4.6.0" + lodash.padstart "^4.6.1" + lodash.repeat "^4.1.0" + sprintf-js "1.1.0" + ip-regex@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-1.0.3.tgz#dc589076f659f419c222039a33316f1c7387effd" @@ -4329,6 +4341,10 @@ js-yaml@~3.7.0: argparse "^1.0.7" esprima "^2.6.0" [email protected]: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -4738,6 +4754,10 @@ lodash.escape@^3.0.0: dependencies: lodash._root "^3.0.0" +lodash.find@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.find/-/lodash.find-4.6.0.tgz#cb0704d47ab71789ffa0de8b97dd926fb88b13b1" + lodash.flattendeep@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" @@ -4777,6 +4797,10 @@ lodash.keys@^3.0.0: lodash.isarguments "^3.0.0" lodash.isarray "^3.0.0" +lodash.max@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.max/-/lodash.max-4.0.1.tgz#8735566c618b35a9f760520b487ae79658af136a" + lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -4785,10 +4809,22 @@ lodash.memoize@~3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f" +lodash.merge@^4.6.0: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.1.tgz#adc25d9cb99b9391c59624f379fbba60d7111d54" + lodash.mergewith@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.0.tgz#150cf0a16791f5903b8891eab154609274bdea55" +lodash.padstart@^4.6.1: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.padstart/-/lodash.padstart-4.6.1.tgz#d2e3eebff0d9d39ad50f5cbd1b52a7bce6bb611b" + +lodash.repeat@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/lodash.repeat/-/lodash.repeat-4.1.0.tgz#fc7de8131d8c8ac07e4b49f74ffe829d1f2bec44" + lodash.restparam@^3.0.0: version "3.6.1" resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" @@ -6863,6 +6899,10 @@ spectrum-colorpicker@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/spectrum-colorpicker/-/spectrum-colorpicker-1.8.0.tgz#b926cf5002c0a77860b5f8351e1c093c65200107" [email protected]: + version "1.1.0" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.0.tgz#cffcaf702daf65ea39bb4e0fa2b299cec1a1be46" + sprintf-js@^1.0.3: version "1.1.1" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.1.tgz#36be78320afe5801f6cea3ee78b6e5aab940ea0c" [text/x-patch] revert-upgrade-of-jasmine.diff (998B, 4-revert-upgrade-of-jasmine.diff) download | inline diff: diff --git a/web/package.json b/web/package.json index ad0f3e16..a4cb58d5 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,7 @@ "file-loader": "^0.11.2", "image-webpack-loader": "^3.3.1", "is-docker": "^1.1.0", - "jasmine-core": "~2.99.0", + "jasmine-core": "~3.1.0", "jasmine-enzyme": "~4.1.1", "karma": "~1.5.0", "karma-babel-preprocessor": "^6.0.1", diff --git a/web/yarn.lock b/web/yarn.lock index 85ccbc8b..5e310d79 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -4267,9 +4267,9 @@ isurl@^1.0.0-alpha5: has-to-string-tag-x "^1.2.0" is-object "^1.0.1" -jasmine-core@~2.99.0: - version "2.99.1" - resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.99.1.tgz#e6400df1e6b56e130b61c4bcd093daa7f6e8ca15" +jasmine-core@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-3.1.0.tgz#a4785e135d5df65024dfc9224953df585bd2766c" jasmine-enzyme@~4.1.1: version "4.1.1" ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-13 00:48 Dave Page <[email protected]> parent: Joao De Almeida Pereira <[email protected]> 0 siblings, 2 replies; 14+ messages in thread From: Dave Page @ 2018-03-13 00:48 UTC (permalink / raw) To: Joao De Almeida Pereira <[email protected]>; +Cc: Murtuza Zabuawala <[email protected]>; pgadmin-hackers Hi On Mon, Mar 12, 2018 at 5:18 PM, Joao De Almeida Pereira < [email protected]> wrote: > Hi Dave and Murtuza, > > Regarding this patch we refactored the Javascript code so that is lives in > a different file and added some tests. > > Also we found an issue with karma-jasmine that does not allow us to use > jasmine 3.1 yet. You can find attached a patch that reverts that commit. > Sounds good, but neither patch will apply (in fact, the Jasmine one looks entirely backwards). One of the error messages was changed in Murtuza's patch, and wasn't reflected in your update for example. Can you rebase please? Thanks. > > Thanks > Victoria && Joao > > On Mon, Mar 12, 2018 at 4:46 PM Dave Page <[email protected]> wrote: > >> Thanks, patch applied! >> >> On Mon, Mar 12, 2018 at 3:31 AM, Murtuza Zabuawala <murtuza.zabuawala@ >> enterprisedb.com> wrote: >> >>> Hi Dave, >>> >>> PFA updated patch. >>> >>> -- >>> Regards, >>> Murtuza Zabuawala >>> EnterpriseDB: http://www.enterprisedb.com >>> The Enterprise PostgreSQL Company >>> >>> >>> On Fri, Mar 9, 2018 at 9:29 PM, Murtuza Zabuawala <murtuza.zabuawala@ >>> enterprisedb.com> wrote: >>> >>>> Hi Dave, >>>> >>>> I'll change the name and send you updated patch. >>>> >>>> >>>> On Fri, Mar 9, 2018 at 9:25 PM, Dave Page <[email protected]> wrote: >>>> >>>>> HI >>>>> >>>>> On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala <murtuza.zabuawala@ >>>>> enterprisedb.com> wrote: >>>>> >>>>>> Hi, >>>>>> >>>>>> PFA patch to add service parameter in server dialog. >>>>>> - Docs updated >>>>>> - Test case added for Service ID parameter >>>>>> >>>>>> Please note, >>>>>> I have extracted Connection class and Server manager class from our >>>>>> own custom Psycopg2 driver module. >>>>>> >>>>>> Patch also covers RM#3120 >>>>>> >>>>> >>>>> This patch seems a little confused. The "Service" and "Service ID" >>>>> fields from pgAdmin 3 are very different things. The Redmine ticket seems >>>>> to be asking for the Service field (the pg_service.conf service name), >>>>> *not* Service ID (the operating system's service ID, used to start/stop the >>>>> database server service). >>>>> >>>>> -- >>>>> Dave Page >>>>> Blog: http://pgsnake.blogspot.com >>>>> Twitter: @pgsnake >>>>> >>>>> EnterpriseDB UK: http://www.enterprisedb.com >>>>> The Enterprise PostgreSQL Company >>>>> >>>> >>>> >>> >> >> >> -- >> Dave Page >> Blog: http://pgsnake.blogspot.com >> Twitter: @pgsnake >> >> EnterpriseDB UK: http://www.enterprisedb.com >> The Enterprise PostgreSQL Company >> > -- Dave Page Blog: http://pgsnake.blogspot.com Twitter: @pgsnake EnterpriseDB UK: http://www.enterprisedb.com The Enterprise PostgreSQL Company ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-13 03:31 Ashesh Vashi <[email protected]> parent: Dave Page <[email protected]> 1 sibling, 1 reply; 14+ messages in thread From: Ashesh Vashi @ 2018-03-13 03:31 UTC (permalink / raw) To: Murtuza Zabuawala <[email protected]>; Dave Page <[email protected]>; +Cc: Joao De Almeida Pereira <[email protected]>; pgadmin-hackers Murtuza/Dave, I have to reviewed/seen the patch yet. But - I have a question. Have we used the service file in the external tools for backup, restore, and import/export functionalities? If not - we should fix that asap. We had missed that during SSL support, and now - we're fixing that. -- Thanks & Regards, Ashesh Vashi EnterpriseDB INDIA: Enterprise PostgreSQL Company <http://www.enterprisedb.com; *http://www.linkedin.com/in/asheshvashi* <http://www.linkedin.com/in/asheshvashi; On Tue, Mar 13, 2018 at 6:18 AM, Dave Page <[email protected]> wrote: > Hi > > On Mon, Mar 12, 2018 at 5:18 PM, Joao De Almeida Pereira < > [email protected]> wrote: > >> Hi Dave and Murtuza, >> >> Regarding this patch we refactored the Javascript code so that is lives >> in a different file and added some tests. >> >> Also we found an issue with karma-jasmine that does not allow us to use >> jasmine 3.1 yet. You can find attached a patch that reverts that commit. >> > > Sounds good, but neither patch will apply (in fact, the Jasmine one looks > entirely backwards). One of the error messages was changed in Murtuza's > patch, and wasn't reflected in your update for example. > > Can you rebase please? > > Thanks. > > >> >> Thanks >> Victoria && Joao >> >> On Mon, Mar 12, 2018 at 4:46 PM Dave Page <[email protected]> wrote: >> >>> Thanks, patch applied! >>> >>> On Mon, Mar 12, 2018 at 3:31 AM, Murtuza Zabuawala < >>> [email protected]> wrote: >>> >>>> Hi Dave, >>>> >>>> PFA updated patch. >>>> >>>> -- >>>> Regards, >>>> Murtuza Zabuawala >>>> EnterpriseDB: http://www.enterprisedb.com >>>> The Enterprise PostgreSQL Company >>>> >>>> >>>> On Fri, Mar 9, 2018 at 9:29 PM, Murtuza Zabuawala < >>>> [email protected]> wrote: >>>> >>>>> Hi Dave, >>>>> >>>>> I'll change the name and send you updated patch. >>>>> >>>>> >>>>> On Fri, Mar 9, 2018 at 9:25 PM, Dave Page <[email protected]> wrote: >>>>> >>>>>> HI >>>>>> >>>>>> On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala < >>>>>> [email protected]> wrote: >>>>>> >>>>>>> Hi, >>>>>>> >>>>>>> PFA patch to add service parameter in server dialog. >>>>>>> - Docs updated >>>>>>> - Test case added for Service ID parameter >>>>>>> >>>>>>> Please note, >>>>>>> I have extracted Connection class and Server manager class from our >>>>>>> own custom Psycopg2 driver module. >>>>>>> >>>>>>> Patch also covers RM#3120 >>>>>>> >>>>>> >>>>>> This patch seems a little confused. The "Service" and "Service ID" >>>>>> fields from pgAdmin 3 are very different things. The Redmine ticket seems >>>>>> to be asking for the Service field (the pg_service.conf service name), >>>>>> *not* Service ID (the operating system's service ID, used to start/stop the >>>>>> database server service). >>>>>> >>>>>> -- >>>>>> Dave Page >>>>>> Blog: http://pgsnake.blogspot.com >>>>>> Twitter: @pgsnake >>>>>> >>>>>> EnterpriseDB UK: http://www.enterprisedb.com >>>>>> The Enterprise PostgreSQL Company >>>>>> >>>>> >>>>> >>>> >>> >>> >>> -- >>> Dave Page >>> Blog: http://pgsnake.blogspot.com >>> Twitter: @pgsnake >>> >>> EnterpriseDB UK: http://www.enterprisedb.com >>> The Enterprise PostgreSQL Company >>> >> > > > -- > Dave Page > Blog: http://pgsnake.blogspot.com > Twitter: @pgsnake > > EnterpriseDB UK: http://www.enterprisedb.com > The Enterprise PostgreSQL Company > ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-13 04:12 Murtuza Zabuawala <[email protected]> parent: Ashesh Vashi <[email protected]> 0 siblings, 2 replies; 14+ messages in thread From: Murtuza Zabuawala @ 2018-03-13 04:12 UTC (permalink / raw) To: Ashesh Vashi <[email protected]>; +Cc: Dave Page <[email protected]>; Joao De Almeida Pereira <[email protected]>; pgadmin-hackers Hi Ashesh, I haven't implemented that intentionally because Khushboo is working on the same for SSL and our code will conflict, So once Khushboo's patch gets committed, I'll make changes for Service file as well. -- Regards, Murtuza Zabuawala EnterpriseDB: http://www.enterprisedb.com The Enterprise PostgreSQL Company On Tue, Mar 13, 2018 at 9:01 AM, Ashesh Vashi <[email protected] > wrote: > Murtuza/Dave, > > I have to reviewed/seen the patch yet. > But - I have a question. > Have we used the service file in the external tools for backup, restore, > and import/export functionalities? > If not - we should fix that asap. > > We had missed that during SSL support, and now - we're fixing that. > > -- > > Thanks & Regards, > > Ashesh Vashi > EnterpriseDB INDIA: Enterprise PostgreSQL Company > <http://www.enterprisedb.com; > > > *http://www.linkedin.com/in/asheshvashi* > <http://www.linkedin.com/in/asheshvashi; > > On Tue, Mar 13, 2018 at 6:18 AM, Dave Page <[email protected]> wrote: > >> Hi >> >> On Mon, Mar 12, 2018 at 5:18 PM, Joao De Almeida Pereira < >> [email protected]> wrote: >> >>> Hi Dave and Murtuza, >>> >>> Regarding this patch we refactored the Javascript code so that is lives >>> in a different file and added some tests. >>> >>> Also we found an issue with karma-jasmine that does not allow us to use >>> jasmine 3.1 yet. You can find attached a patch that reverts that commit. >>> >> >> Sounds good, but neither patch will apply (in fact, the Jasmine one looks >> entirely backwards). One of the error messages was changed in Murtuza's >> patch, and wasn't reflected in your update for example. >> >> Can you rebase please? >> >> Thanks. >> >> >>> >>> Thanks >>> Victoria && Joao >>> >>> On Mon, Mar 12, 2018 at 4:46 PM Dave Page <[email protected]> wrote: >>> >>>> Thanks, patch applied! >>>> >>>> On Mon, Mar 12, 2018 at 3:31 AM, Murtuza Zabuawala < >>>> [email protected]> wrote: >>>> >>>>> Hi Dave, >>>>> >>>>> PFA updated patch. >>>>> >>>>> -- >>>>> Regards, >>>>> Murtuza Zabuawala >>>>> EnterpriseDB: http://www.enterprisedb.com >>>>> The Enterprise PostgreSQL Company >>>>> >>>>> >>>>> On Fri, Mar 9, 2018 at 9:29 PM, Murtuza Zabuawala < >>>>> [email protected]> wrote: >>>>> >>>>>> Hi Dave, >>>>>> >>>>>> I'll change the name and send you updated patch. >>>>>> >>>>>> >>>>>> On Fri, Mar 9, 2018 at 9:25 PM, Dave Page <[email protected]> wrote: >>>>>> >>>>>>> HI >>>>>>> >>>>>>> On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala < >>>>>>> [email protected]> wrote: >>>>>>> >>>>>>>> Hi, >>>>>>>> >>>>>>>> PFA patch to add service parameter in server dialog. >>>>>>>> - Docs updated >>>>>>>> - Test case added for Service ID parameter >>>>>>>> >>>>>>>> Please note, >>>>>>>> I have extracted Connection class and Server manager class from our >>>>>>>> own custom Psycopg2 driver module. >>>>>>>> >>>>>>>> Patch also covers RM#3120 >>>>>>>> >>>>>>> >>>>>>> This patch seems a little confused. The "Service" and "Service ID" >>>>>>> fields from pgAdmin 3 are very different things. The Redmine ticket seems >>>>>>> to be asking for the Service field (the pg_service.conf service name), >>>>>>> *not* Service ID (the operating system's service ID, used to start/stop the >>>>>>> database server service). >>>>>>> >>>>>>> -- >>>>>>> Dave Page >>>>>>> Blog: http://pgsnake.blogspot.com >>>>>>> Twitter: @pgsnake >>>>>>> >>>>>>> EnterpriseDB UK: http://www.enterprisedb.com >>>>>>> The Enterprise PostgreSQL Company >>>>>>> >>>>>> >>>>>> >>>>> >>>> >>>> >>>> -- >>>> Dave Page >>>> Blog: http://pgsnake.blogspot.com >>>> Twitter: @pgsnake >>>> >>>> EnterpriseDB UK: http://www.enterprisedb.com >>>> The Enterprise PostgreSQL Company >>>> >>> >> >> >> -- >> Dave Page >> Blog: http://pgsnake.blogspot.com >> Twitter: @pgsnake >> >> EnterpriseDB UK: http://www.enterprisedb.com >> The Enterprise PostgreSQL Company >> > > ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-13 04:15 Ashesh Vashi <[email protected]> parent: Murtuza Zabuawala <[email protected]> 1 sibling, 1 reply; 14+ messages in thread From: Ashesh Vashi @ 2018-03-13 04:15 UTC (permalink / raw) To: Murtuza Zabuawala <[email protected]>; +Cc: Dave Page <[email protected]>; Joao De Almeida Pereira <[email protected]>; pgadmin-hackers On Tue, Mar 13, 2018 at 9:42 AM, Murtuza Zabuawala < [email protected]> wrote: > Hi Ashesh, > > I haven't implemented that intentionally because Khushboo is working on > the same for SSL and our code will conflict, So once Khushboo's patch gets > committed, I'll make changes for Service file as well. > No - that's a bad practice. You need to work on full feature set, not partial. If you have intentionally skipped that, you should have informed the list. -- Thanks, Ashesh > > > -- > Regards, > Murtuza Zabuawala > EnterpriseDB: http://www.enterprisedb.com > The Enterprise PostgreSQL Company > > > On Tue, Mar 13, 2018 at 9:01 AM, Ashesh Vashi < > [email protected]> wrote: > >> Murtuza/Dave, >> >> I have to reviewed/seen the patch yet. >> But - I have a question. >> Have we used the service file in the external tools for backup, restore, >> and import/export functionalities? >> If not - we should fix that asap. >> >> We had missed that during SSL support, and now - we're fixing that. >> >> -- >> >> Thanks & Regards, >> >> Ashesh Vashi >> EnterpriseDB INDIA: Enterprise PostgreSQL Company >> <http://www.enterprisedb.com; >> >> >> *http://www.linkedin.com/in/asheshvashi* >> <http://www.linkedin.com/in/asheshvashi; >> >> On Tue, Mar 13, 2018 at 6:18 AM, Dave Page <[email protected]> wrote: >> >>> Hi >>> >>> On Mon, Mar 12, 2018 at 5:18 PM, Joao De Almeida Pereira < >>> [email protected]> wrote: >>> >>>> Hi Dave and Murtuza, >>>> >>>> Regarding this patch we refactored the Javascript code so that is lives >>>> in a different file and added some tests. >>>> >>>> Also we found an issue with karma-jasmine that does not allow us to use >>>> jasmine 3.1 yet. You can find attached a patch that reverts that commit. >>>> >>> >>> Sounds good, but neither patch will apply (in fact, the Jasmine one >>> looks entirely backwards). One of the error messages was changed in >>> Murtuza's patch, and wasn't reflected in your update for example. >>> >>> Can you rebase please? >>> >>> Thanks. >>> >>> >>>> >>>> Thanks >>>> Victoria && Joao >>>> >>>> On Mon, Mar 12, 2018 at 4:46 PM Dave Page <[email protected]> wrote: >>>> >>>>> Thanks, patch applied! >>>>> >>>>> On Mon, Mar 12, 2018 at 3:31 AM, Murtuza Zabuawala < >>>>> [email protected]> wrote: >>>>> >>>>>> Hi Dave, >>>>>> >>>>>> PFA updated patch. >>>>>> >>>>>> -- >>>>>> Regards, >>>>>> Murtuza Zabuawala >>>>>> EnterpriseDB: http://www.enterprisedb.com >>>>>> The Enterprise PostgreSQL Company >>>>>> >>>>>> >>>>>> On Fri, Mar 9, 2018 at 9:29 PM, Murtuza Zabuawala < >>>>>> [email protected]> wrote: >>>>>> >>>>>>> Hi Dave, >>>>>>> >>>>>>> I'll change the name and send you updated patch. >>>>>>> >>>>>>> >>>>>>> On Fri, Mar 9, 2018 at 9:25 PM, Dave Page <[email protected]> wrote: >>>>>>> >>>>>>>> HI >>>>>>>> >>>>>>>> On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala < >>>>>>>> [email protected]> wrote: >>>>>>>> >>>>>>>>> Hi, >>>>>>>>> >>>>>>>>> PFA patch to add service parameter in server dialog. >>>>>>>>> - Docs updated >>>>>>>>> - Test case added for Service ID parameter >>>>>>>>> >>>>>>>>> Please note, >>>>>>>>> I have extracted Connection class and Server manager class from >>>>>>>>> our own custom Psycopg2 driver module. >>>>>>>>> >>>>>>>>> Patch also covers RM#3120 >>>>>>>>> >>>>>>>> >>>>>>>> This patch seems a little confused. The "Service" and "Service ID" >>>>>>>> fields from pgAdmin 3 are very different things. The Redmine ticket seems >>>>>>>> to be asking for the Service field (the pg_service.conf service name), >>>>>>>> *not* Service ID (the operating system's service ID, used to start/stop the >>>>>>>> database server service). >>>>>>>> >>>>>>>> -- >>>>>>>> Dave Page >>>>>>>> Blog: http://pgsnake.blogspot.com >>>>>>>> Twitter: @pgsnake >>>>>>>> >>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com >>>>>>>> The Enterprise PostgreSQL Company >>>>>>>> >>>>>>> >>>>>>> >>>>>> >>>>> >>>>> >>>>> -- >>>>> Dave Page >>>>> Blog: http://pgsnake.blogspot.com >>>>> Twitter: @pgsnake >>>>> >>>>> EnterpriseDB UK: http://www.enterprisedb.com >>>>> The Enterprise PostgreSQL Company >>>>> >>>> >>> >>> >>> -- >>> Dave Page >>> Blog: http://pgsnake.blogspot.com >>> Twitter: @pgsnake >>> >>> EnterpriseDB UK: http://www.enterprisedb.com >>> The Enterprise PostgreSQL Company >>> >> >> > ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-13 04:35 Murtuza Zabuawala <[email protected]> parent: Ashesh Vashi <[email protected]> 0 siblings, 0 replies; 14+ messages in thread From: Murtuza Zabuawala @ 2018-03-13 04:35 UTC (permalink / raw) To: Ashesh Vashi <[email protected]>; +Cc: Dave Page <[email protected]>; Joao De Almeida Pereira <[email protected]>; pgadmin-hackers On Tue, Mar 13, 2018 at 9:45 AM, Ashesh Vashi <[email protected] > wrote: > > On Tue, Mar 13, 2018 at 9:42 AM, Murtuza Zabuawala <murtuza.zabuawala@ > enterprisedb.com> wrote: > >> Hi Ashesh, >> >> I haven't implemented that intentionally because Khushboo is working on >> the same for SSL and our code will conflict, So once Khushboo's patch gets >> committed, I'll make changes for Service file as well. >> > No - that's a bad practice. > You need to work on full feature set, not partial. > > If you have intentionally skipped that, you should have informed the list. > ​I forgot to create sub-task, will keep that in mind next time onwards.​ https://redmine.postgresql.org/issues/3195 > > -- Thanks, Ashesh > >> >> >> -- >> Regards, >> Murtuza Zabuawala >> EnterpriseDB: http://www.enterprisedb.com >> The Enterprise PostgreSQL Company >> >> >> On Tue, Mar 13, 2018 at 9:01 AM, Ashesh Vashi < >> [email protected]> wrote: >> >>> Murtuza/Dave, >>> >>> I have to reviewed/seen the patch yet. >>> But - I have a question. >>> Have we used the service file in the external tools for backup, restore, >>> and import/export functionalities? >>> If not - we should fix that asap. >>> >>> We had missed that during SSL support, and now - we're fixing that. >>> >>> -- >>> >>> Thanks & Regards, >>> >>> Ashesh Vashi >>> EnterpriseDB INDIA: Enterprise PostgreSQL Company >>> <http://www.enterprisedb.com; >>> >>> >>> *http://www.linkedin.com/in/asheshvashi* >>> <http://www.linkedin.com/in/asheshvashi; >>> >>> On Tue, Mar 13, 2018 at 6:18 AM, Dave Page <[email protected]> wrote: >>> >>>> Hi >>>> >>>> On Mon, Mar 12, 2018 at 5:18 PM, Joao De Almeida Pereira < >>>> [email protected]> wrote: >>>> >>>>> Hi Dave and Murtuza, >>>>> >>>>> Regarding this patch we refactored the Javascript code so that is >>>>> lives in a different file and added some tests. >>>>> >>>>> Also we found an issue with karma-jasmine that does not allow us to >>>>> use jasmine 3.1 yet. You can find attached a patch that reverts that commit. >>>>> >>>> >>>> Sounds good, but neither patch will apply (in fact, the Jasmine one >>>> looks entirely backwards). One of the error messages was changed in >>>> Murtuza's patch, and wasn't reflected in your update for example. >>>> >>>> Can you rebase please? >>>> >>>> Thanks. >>>> >>>> >>>>> >>>>> Thanks >>>>> Victoria && Joao >>>>> >>>>> On Mon, Mar 12, 2018 at 4:46 PM Dave Page <[email protected]> wrote: >>>>> >>>>>> Thanks, patch applied! >>>>>> >>>>>> On Mon, Mar 12, 2018 at 3:31 AM, Murtuza Zabuawala < >>>>>> [email protected]> wrote: >>>>>> >>>>>>> Hi Dave, >>>>>>> >>>>>>> PFA updated patch. >>>>>>> >>>>>>> -- >>>>>>> Regards, >>>>>>> Murtuza Zabuawala >>>>>>> EnterpriseDB: http://www.enterprisedb.com >>>>>>> The Enterprise PostgreSQL Company >>>>>>> >>>>>>> >>>>>>> On Fri, Mar 9, 2018 at 9:29 PM, Murtuza Zabuawala < >>>>>>> [email protected]> wrote: >>>>>>> >>>>>>>> Hi Dave, >>>>>>>> >>>>>>>> I'll change the name and send you updated patch. >>>>>>>> >>>>>>>> >>>>>>>> On Fri, Mar 9, 2018 at 9:25 PM, Dave Page <[email protected]> >>>>>>>> wrote: >>>>>>>> >>>>>>>>> HI >>>>>>>>> >>>>>>>>> On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala < >>>>>>>>> [email protected]> wrote: >>>>>>>>> >>>>>>>>>> Hi, >>>>>>>>>> >>>>>>>>>> PFA patch to add service parameter in server dialog. >>>>>>>>>> - Docs updated >>>>>>>>>> - Test case added for Service ID parameter >>>>>>>>>> >>>>>>>>>> Please note, >>>>>>>>>> I have extracted Connection class and Server manager class from >>>>>>>>>> our own custom Psycopg2 driver module. >>>>>>>>>> >>>>>>>>>> Patch also covers RM#3120 >>>>>>>>>> >>>>>>>>> >>>>>>>>> This patch seems a little confused. The "Service" and "Service >>>>>>>>> ID" fields from pgAdmin 3 are very different things. The Redmine ticket >>>>>>>>> seems to be asking for the Service field (the pg_service.conf service >>>>>>>>> name), *not* Service ID (the operating system's service ID, used to >>>>>>>>> start/stop the database server service). >>>>>>>>> >>>>>>>>> -- >>>>>>>>> Dave Page >>>>>>>>> Blog: http://pgsnake.blogspot.com >>>>>>>>> Twitter: @pgsnake >>>>>>>>> >>>>>>>>> EnterpriseDB UK: http://www.enterprisedb.com >>>>>>>>> The Enterprise PostgreSQL Company >>>>>>>>> >>>>>>>> >>>>>>>> >>>>>>> >>>>>> >>>>>> >>>>>> -- >>>>>> Dave Page >>>>>> Blog: http://pgsnake.blogspot.com >>>>>> Twitter: @pgsnake >>>>>> >>>>>> EnterpriseDB UK: http://www.enterprisedb.com >>>>>> The Enterprise PostgreSQL Company >>>>>> >>>>> >>>> >>>> >>>> -- >>>> Dave Page >>>> Blog: http://pgsnake.blogspot.com >>>> Twitter: @pgsnake >>>> >>>> EnterpriseDB UK: http://www.enterprisedb.com >>>> The Enterprise PostgreSQL Company >>>> >>> >>> >> > ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-13 11:45 Dave Page <[email protected]> parent: Murtuza Zabuawala <[email protected]> 1 sibling, 0 replies; 14+ messages in thread From: Dave Page @ 2018-03-13 11:45 UTC (permalink / raw) To: Murtuza Zabuawala <[email protected]>; +Cc: Ashesh Vashi <[email protected]>; Joao De Almeida Pereira <[email protected]>; pgadmin-hackers On Tue, Mar 13, 2018 at 12:12 AM, Murtuza Zabuawala < [email protected]> wrote: > Hi Ashesh, > > I haven't implemented that intentionally because Khushboo is working on > the same for SSL and our code will conflict, So once Khushboo's patch gets > committed, I'll make changes for Service file as well. > Ashesh is right - this should have been finished (and I should have thought about this issue). Please fix ASAP. Thanks. -- Dave Page Blog: http://pgsnake.blogspot.com Twitter: @pgsnake EnterpriseDB UK: http://www.enterprisedb.com The Enterprise PostgreSQL Company ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-13 13:53 Victoria Henry <[email protected]> parent: Dave Page <[email protected]> 1 sibling, 1 reply; 14+ messages in thread From: Victoria Henry @ 2018-03-13 13:53 UTC (permalink / raw) To: Dave Page <[email protected]>; +Cc: Joao De Almeida Pereira <[email protected]>; Murtuza Zabuawala <[email protected]>; pgadmin-hackers Hi Dave, We've made updated our previous patch to fix the error messages. Attached are our updated patches. On Mon, Mar 12, 2018 at 8:48 PM, Dave Page <[email protected]> wrote: > Hi > > On Mon, Mar 12, 2018 at 5:18 PM, Joao De Almeida Pereira < > [email protected]> wrote: > >> Hi Dave and Murtuza, >> >> Regarding this patch we refactored the Javascript code so that is lives >> in a different file and added some tests. >> >> Also we found an issue with karma-jasmine that does not allow us to use >> jasmine 3.1 yet. You can find attached a patch that reverts that commit. >> > > Sounds good, but neither patch will apply (in fact, the Jasmine one looks > entirely backwards). One of the error messages was changed in Murtuza's > patch, and wasn't reflected in your update for example. > > Can you rebase please? > > Thanks. > > >> >> Thanks >> Victoria && Joao >> >> On Mon, Mar 12, 2018 at 4:46 PM Dave Page <[email protected]> wrote: >> >>> Thanks, patch applied! >>> >>> On Mon, Mar 12, 2018 at 3:31 AM, Murtuza Zabuawala < >>> [email protected]> wrote: >>> >>>> Hi Dave, >>>> >>>> PFA updated patch. >>>> >>>> -- >>>> Regards, >>>> Murtuza Zabuawala >>>> EnterpriseDB: http://www.enterprisedb.com >>>> The Enterprise PostgreSQL Company >>>> >>>> >>>> On Fri, Mar 9, 2018 at 9:29 PM, Murtuza Zabuawala < >>>> [email protected]> wrote: >>>> >>>>> Hi Dave, >>>>> >>>>> I'll change the name and send you updated patch. >>>>> >>>>> >>>>> On Fri, Mar 9, 2018 at 9:25 PM, Dave Page <[email protected]> wrote: >>>>> >>>>>> HI >>>>>> >>>>>> On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala < >>>>>> [email protected]> wrote: >>>>>> >>>>>>> Hi, >>>>>>> >>>>>>> PFA patch to add service parameter in server dialog. >>>>>>> - Docs updated >>>>>>> - Test case added for Service ID parameter >>>>>>> >>>>>>> Please note, >>>>>>> I have extracted Connection class and Server manager class from our >>>>>>> own custom Psycopg2 driver module. >>>>>>> >>>>>>> Patch also covers RM#3120 >>>>>>> >>>>>> >>>>>> This patch seems a little confused. The "Service" and "Service ID" >>>>>> fields from pgAdmin 3 are very different things. The Redmine ticket seems >>>>>> to be asking for the Service field (the pg_service.conf service name), >>>>>> *not* Service ID (the operating system's service ID, used to start/stop the >>>>>> database server service). >>>>>> >>>>>> -- >>>>>> Dave Page >>>>>> Blog: http://pgsnake.blogspot.com >>>>>> Twitter: @pgsnake >>>>>> >>>>>> EnterpriseDB UK: http://www.enterprisedb.com >>>>>> The Enterprise PostgreSQL Company >>>>>> >>>>> >>>>> >>>> >>> >>> >>> -- >>> Dave Page >>> Blog: http://pgsnake.blogspot.com >>> Twitter: @pgsnake >>> >>> EnterpriseDB UK: http://www.enterprisedb.com >>> The Enterprise PostgreSQL Company >>> >> > > > -- > Dave Page > Blog: http://pgsnake.blogspot.com > Twitter: @pgsnake > > EnterpriseDB UK: http://www.enterprisedb.com > The Enterprise PostgreSQL Company > Attachments: [text/x-patch] refactor-javascript.diff (19.9K, 3-refactor-javascript.diff) download | inline diff: diff --git a/web/package.json b/web/package.json index ad0f3e16..66684fc6 100644 --- a/web/package.json +++ b/web/package.json @@ -69,6 +69,7 @@ "hard-source-webpack-plugin": "^0.4.9", "immutability-helper": "^2.2.0", "imports-loader": "^0.7.1", + "ip-address": "^5.8.9", "jquery": "1.11.2", "jquery-contextmenu": "^2.5.0", "jquery-ui": "^1.12.1", diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.js b/web/pgadmin/browser/server_groups/servers/static/js/server.js index 36908733..302fe458 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -2,10 +2,13 @@ define('pgadmin.node.server', [ 'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'backbone', 'underscore.string', 'sources/pgadmin', 'pgadmin.browser', 'pgadmin.server.supported_servers', 'pgadmin.user_management.current_user', - 'pgadmin.alertifyjs', 'pgadmin.backform', 'pgadmin.browser.server.privilege', + 'pgadmin.alertifyjs', 'pgadmin.backform', + 'sources/browser/server_groups/servers/model_validation', + 'pgadmin.browser.server.privilege', ], function( gettext, url_for, $, _, Backbone, S, pgAdmin, pgBrowser, - supported_servers, current_user, Alertify, Backform + supported_servers, current_user, Alertify, Backform, + modelValidation ) { if (!pgBrowser.Nodes['server']) { @@ -848,110 +851,8 @@ define('pgadmin.node.server', [ group: gettext('Connection'), }], validate: function() { - var err = {}, - errmsg, - self = this; - - var service_id = this.get('service'); - - var check_for_empty = function(id, msg) { - var v = self.get(id); - if ( - _.isUndefined(v) || v === null || String(v).replace(/^\s+|\s+$/g, '') == '' - ) { - err[id] = msg; - errmsg = errmsg || msg; - return true; - } else { - self.errorModel.unset(id); - return false; - } - }; - var check_for_valid_ipv6 = function(val){ - // Regular expression for validating IPv6 address formats - var exps = ['^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|', - '(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|', - '2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|', - '(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|', - ':((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|', - '(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|', - '2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|', - '(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|', - '[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|', - '((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|', - '(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|', - '1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|', - '((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$']; - - var exp = new RegExp(exps.join('')); - return exp.test(val.trim()); - }; - var check_for_valid_ip = function(id, msg) { - var v4exps = '(^\\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\\s*$)'; - var v4exp = new RegExp(v4exps); - var v = self.get(id); - if ( - v && !(v4exp.test(v.trim())) - ) { - if(!check_for_valid_ipv6(v)){ - err[id] = msg; - errmsg = msg; - } - } else { - self.errorModel.unset(id); - } - }; - - if (!self.isNew() && 'id' in self.sessAttrs) { - err['id'] = gettext('The ID cannot be changed.'); - errmsg = err['id']; - } else { - self.errorModel.unset('id'); - } - check_for_empty('name', gettext('Name must be specified.')); - - // If no service id then only check - if ( - _.isUndefined(service_id) || _.isNull(service_id) || - String(service_id).replace(/^\s+|\s+$/g, '') == '' - ) { - if (check_for_empty( - 'host', gettext('Either Host name, Address or Service must be specified.') - ) && check_for_empty('hostaddr', gettext('Either Host name, Address or Service must be specified.'))){ - errmsg = errmsg || gettext('Either Host name, Address or Service must be specified.'); - } else { - errmsg = undefined; - delete err['host']; - delete err['hostaddr']; - } - - check_for_empty( - 'db', gettext('Maintenance database must be specified.') - ); - check_for_valid_ip( - 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') - ); - check_for_valid_ip( - 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') - ); - } else { - _.each(['host', 'hostaddr', 'db'], (item) => { - self.errorModel.unset(item); - }); - } - - check_for_empty( - 'username', gettext('Username must be specified.') - ); - check_for_empty('port', gettext('Port must be specified.')); - - this.errorModel.set(err); - - if (_.size(err)) { - return errmsg; - } - - return null; + const validateModel = new modelValidation.ModelValidation(this); + return validateModel.validate(); }, isConnected: function(model) { return model.get('connected'); diff --git a/web/pgadmin/static/bundle/browser.js b/web/pgadmin/static/bundle/browser.js index 3fcc69d8..83b2ad8b 100644 --- a/web/pgadmin/static/bundle/browser.js +++ b/web/pgadmin/static/bundle/browser.js @@ -1,6 +1,6 @@ define('bundled_browser',[ 'pgadmin.browser', - 'sources/browser/server_groups/servers/databases/external_tables/index', + 'sources/browser/index', ], function(pgBrowser) { pgBrowser.init(); }); diff --git a/web/pgadmin/static/js/browser/index.js b/web/pgadmin/static/js/browser/index.js new file mode 100644 index 00000000..297e8bf9 --- /dev/null +++ b/web/pgadmin/static/js/browser/index.js @@ -0,0 +1,10 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import 'server_groups'; diff --git a/web/pgadmin/static/js/browser/server_groups/index.js b/web/pgadmin/static/js/browser/server_groups/index.js new file mode 100644 index 00000000..b151b6f6 --- /dev/null +++ b/web/pgadmin/static/js/browser/server_groups/index.js @@ -0,0 +1,10 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import 'servers'; diff --git a/web/pgadmin/static/js/browser/server_groups/servers/databases/index.js b/web/pgadmin/static/js/browser/server_groups/servers/databases/index.js new file mode 100644 index 00000000..ef17c0ad --- /dev/null +++ b/web/pgadmin/static/js/browser/server_groups/servers/databases/index.js @@ -0,0 +1,10 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import 'external_tables'; diff --git a/web/pgadmin/static/js/browser/server_groups/servers/index.js b/web/pgadmin/static/js/browser/server_groups/servers/index.js new file mode 100644 index 00000000..242a1919 --- /dev/null +++ b/web/pgadmin/static/js/browser/server_groups/servers/index.js @@ -0,0 +1,11 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import 'databases'; +import 'model_validation'; diff --git a/web/pgadmin/static/js/browser/server_groups/servers/model_validation.js b/web/pgadmin/static/js/browser/server_groups/servers/model_validation.js new file mode 100644 index 00000000..7ab129ba --- /dev/null +++ b/web/pgadmin/static/js/browser/server_groups/servers/model_validation.js @@ -0,0 +1,104 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import _ from 'underscore'; +import {Address4, Address6} from 'ip-address'; + +export class ModelValidation { + constructor(model) { + this.err = {}; + this.errmsg = ''; + this.model = model; + } + + validate() { + const serviceId = this.model.get('service'); + + if (!this.model.isNew() && 'id' in this.model.sessAttrs) { + this.err['id'] = gettext('The ID cannot be changed.'); + this.errmsg = this.err['id']; + } else { + this.model.errorModel.unset('id'); + } + + this.checkForEmpty('name', gettext('Name must be specified.')); + + if (ModelValidation.isEmptyString(serviceId)) { + this.checkHostAndHostAddress(); + + this.checkForEmpty('db', gettext('Maintenance database must be specified.')); + } else { + this.clearHostAddressAndDbErrors(); + } + + this.checkForEmpty('username', gettext('Username must be specified.')); + this.checkForEmpty('port', gettext('Port must be specified.')); + + this.model.errorModel.set(this.err); + + if (_.size(this.err)) { + return this.errmsg; + } + + return null; + } + + clearHostAddressAndDbErrors() { + _.each(['host', 'hostaddr', 'db'], (item) => { + this.model.errorModel.unset(item); + }); + } + + checkHostAndHostAddress() { + const translatedStr = gettext('Either Host name, Address or Service must ' + + 'be specified.'); + if (this.checkForEmpty('host', translatedStr) && + this.checkForEmpty('hostaddr', translatedStr)) { + this.errmsg = this.errmsg || translatedStr; + } else { + this.errmsg = undefined; + delete this.err['host']; + delete this.err['hostaddr']; + } + + this.checkForValidIp(this.model.get('hostaddr'), + gettext('Host address must be valid IPv4 or IPv6 address.')); + } + + checkForValidIp(ipAddress, msg) { + if (ipAddress) { + const isIpv6Address = new Address6(ipAddress).isValid(); + const isIpv4Address = new Address4(ipAddress).isValid(); + if (!isIpv4Address && !isIpv6Address) { + this.err['hostaddr'] = msg; + this.errmsg = msg; + } + } else { + this.model.errorModel.unset('hostaddr'); + } + } + + checkForEmpty(id, msg) { + const value = this.model.get(id); + + if (ModelValidation.isEmptyString(value)) { + this.err[id] = msg; + this.errmsg = this.errmsg || msg; + return true; + } else { + this.model.errorModel.unset(id); + return false; + } + } + + static isEmptyString(string) { + return _.isUndefined(string) || _.isNull(string) || string.trim() === ''; + } +} diff --git a/web/regression/javascript/browser/server_groups/servers/model_validation_spec.js b/web/regression/javascript/browser/server_groups/servers/model_validation_spec.js new file mode 100644 index 00000000..a05cd455 --- /dev/null +++ b/web/regression/javascript/browser/server_groups/servers/model_validation_spec.js @@ -0,0 +1,101 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import {ModelValidation} from 'sources/browser/server_groups/servers/model_validation'; + +describe('Server#ModelValidation', () => { + describe('When validating a server parameters', () => { + let model; + let modelValidation; + beforeEach(() => { + model = { + errorModel: jasmine.createSpyObj('errorModel', ['set', 'unset']), + allValues: {}, + get: function (key) { + return this.allValues[key]; + }, + sessAttrs: {}, + }; + model.isNew = jasmine.createSpy('isNew'); + modelValidation = new ModelValidation(model); + }); + + describe('When all parameters are valid', () => { + beforeEach(() => { + model.isNew.and.returnValue(true); + model.allValues['name'] = 'some name'; + model.allValues['username'] = 'some username'; + model.allValues['port'] = 'some port'; + }); + + describe('No service id', () => { + it('does not set any error in the model', () => { + model.allValues['host'] = 'some host'; + model.allValues['db'] = 'some db'; + model.allValues['hostaddr'] = '1.1.1.1'; + expect(modelValidation.validate()).toBeNull(); + expect(model.errorModel.set).toHaveBeenCalledWith({}); + }); + }); + + describe('Service id present', () => { + it('does not set any error in the model', () => { + model.allValues['service'] = 'asdfg'; + expect(modelValidation.validate()).toBeNull(); + expect(model.errorModel.set).toHaveBeenCalledWith({}); + }); + }); + }); + + describe('When no parameters are valid', () => { + describe('Service id not present', () => { + it('does not set any error in the model', () => { + expect(modelValidation.validate()).toBe('Name must be specified.'); + expect(model.errorModel.set).toHaveBeenCalledTimes(1); + expect(model.errorModel.set).toHaveBeenCalledWith({ + name: 'Name must be specified.', + host: 'Either Host name, Address or Service must be specified.', + hostaddr: 'Either Host name, Address or Service must be specified.', + db: 'Maintenance database must be specified.', + username: 'Username must be specified.', + port: 'Port must be specified.' + }); + }); + }); + + describe('Host address is not valid', () => { + it('sets the "Host address must be a valid IPv4 or IPv6 address" error', () => { + model.allValues['hostaddr'] = 'something that is not an ip address'; + expect(modelValidation.validate()).toBe('Host address must be valid IPv4 or IPv6 address.'); + expect(model.errorModel.set).toHaveBeenCalledTimes(1); + expect(model.errorModel.set).toHaveBeenCalledWith({ + name: 'Name must be specified.', + hostaddr: 'Host address must be valid IPv4 or IPv6 address.', + db: 'Maintenance database must be specified.', + username: 'Username must be specified.', + port: 'Port must be specified.' + }); + }); + }); + + describe('Service id present', () => { + it('does not set any error in the model', () => { + model.allValues['service'] = 'asdfg'; + expect(modelValidation.validate()).toBe('Name must be specified.'); + expect(model.errorModel.set).toHaveBeenCalledTimes(1); + expect(model.errorModel.set).toHaveBeenCalledWith({ + name: 'Name must be specified.', + username: 'Username must be specified.', + port: 'Port must be specified.' + }); + }); + }); + }); + }); +}); diff --git a/web/yarn.lock b/web/yarn.lock index 85ccbc8b..2dc4c5c2 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -3938,6 +3938,18 @@ invert-kv@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" +ip-address@^5.8.9: + version "5.8.9" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-5.8.9.tgz#6379277c23fc5adb20511e4d23ec2c1bde105dfd" + dependencies: + jsbn "1.1.0" + lodash.find "^4.6.0" + lodash.max "^4.0.1" + lodash.merge "^4.6.0" + lodash.padstart "^4.6.1" + lodash.repeat "^4.1.0" + sprintf-js "1.1.0" + ip-regex@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-1.0.3.tgz#dc589076f659f419c222039a33316f1c7387effd" @@ -4329,6 +4341,10 @@ js-yaml@~3.7.0: argparse "^1.0.7" esprima "^2.6.0" [email protected]: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -4738,6 +4754,10 @@ lodash.escape@^3.0.0: dependencies: lodash._root "^3.0.0" +lodash.find@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.find/-/lodash.find-4.6.0.tgz#cb0704d47ab71789ffa0de8b97dd926fb88b13b1" + lodash.flattendeep@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" @@ -4777,6 +4797,10 @@ lodash.keys@^3.0.0: lodash.isarguments "^3.0.0" lodash.isarray "^3.0.0" +lodash.max@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.max/-/lodash.max-4.0.1.tgz#8735566c618b35a9f760520b487ae79658af136a" + lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -4785,10 +4809,22 @@ lodash.memoize@~3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f" +lodash.merge@^4.6.0: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.1.tgz#adc25d9cb99b9391c59624f379fbba60d7111d54" + lodash.mergewith@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.0.tgz#150cf0a16791f5903b8891eab154609274bdea55" +lodash.padstart@^4.6.1: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.padstart/-/lodash.padstart-4.6.1.tgz#d2e3eebff0d9d39ad50f5cbd1b52a7bce6bb611b" + +lodash.repeat@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/lodash.repeat/-/lodash.repeat-4.1.0.tgz#fc7de8131d8c8ac07e4b49f74ffe829d1f2bec44" + lodash.restparam@^3.0.0: version "3.6.1" resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" @@ -6863,6 +6899,10 @@ spectrum-colorpicker@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/spectrum-colorpicker/-/spectrum-colorpicker-1.8.0.tgz#b926cf5002c0a77860b5f8351e1c093c65200107" [email protected]: + version "1.1.0" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.0.tgz#cffcaf702daf65ea39bb4e0fa2b299cec1a1be46" + sprintf-js@^1.0.3: version "1.1.1" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.1.tgz#36be78320afe5801f6cea3ee78b6e5aab940ea0c" [text/x-patch] revert-upgrade-of-jasmine.diff (998B, 4-revert-upgrade-of-jasmine.diff) download | inline diff: diff --git a/web/package.json b/web/package.json index a4cb58d5..ad0f3e16 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,7 @@ "file-loader": "^0.11.2", "image-webpack-loader": "^3.3.1", "is-docker": "^1.1.0", - "jasmine-core": "~3.1.0", + "jasmine-core": "~2.99.0", "jasmine-enzyme": "~4.1.1", "karma": "~1.5.0", "karma-babel-preprocessor": "^6.0.1", diff --git a/web/yarn.lock b/web/yarn.lock index 5e310d79..85ccbc8b 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -4267,9 +4267,9 @@ isurl@^1.0.0-alpha5: has-to-string-tag-x "^1.2.0" is-object "^1.0.1" -jasmine-core@~3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-3.1.0.tgz#a4785e135d5df65024dfc9224953df585bd2766c" +jasmine-core@~2.99.0: + version "2.99.1" + resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.99.1.tgz#e6400df1e6b56e130b61c4bcd093daa7f6e8ca15" jasmine-enzyme@~4.1.1: version "4.1.1" ^ permalink raw reply [nested|flat] 14+ messages in thread
* Re: [pgAdmin4][RM#3140] Add service parameter @ 2018-03-13 18:47 Dave Page <[email protected]> parent: Victoria Henry <[email protected]> 0 siblings, 0 replies; 14+ messages in thread From: Dave Page @ 2018-03-13 18:47 UTC (permalink / raw) To: Victoria Henry <[email protected]>; +Cc: Joao De Almeida Pereira <[email protected]>; Murtuza Zabuawala <[email protected]>; pgadmin-hackers Thanks, patches applied! On Tue, Mar 13, 2018 at 9:53 AM, Victoria Henry <[email protected]> wrote: > Hi Dave, > > We've made updated our previous patch to fix the error messages. Attached > are our updated patches. > > On Mon, Mar 12, 2018 at 8:48 PM, Dave Page <[email protected]> wrote: > >> Hi >> >> On Mon, Mar 12, 2018 at 5:18 PM, Joao De Almeida Pereira < >> [email protected]> wrote: >> >>> Hi Dave and Murtuza, >>> >>> Regarding this patch we refactored the Javascript code so that is lives >>> in a different file and added some tests. >>> >>> Also we found an issue with karma-jasmine that does not allow us to use >>> jasmine 3.1 yet. You can find attached a patch that reverts that commit. >>> >> >> Sounds good, but neither patch will apply (in fact, the Jasmine one looks >> entirely backwards). One of the error messages was changed in Murtuza's >> patch, and wasn't reflected in your update for example. >> >> Can you rebase please? >> >> Thanks. >> >> >>> >>> Thanks >>> Victoria && Joao >>> >>> On Mon, Mar 12, 2018 at 4:46 PM Dave Page <[email protected]> wrote: >>> >>>> Thanks, patch applied! >>>> >>>> On Mon, Mar 12, 2018 at 3:31 AM, Murtuza Zabuawala < >>>> [email protected]> wrote: >>>> >>>>> Hi Dave, >>>>> >>>>> PFA updated patch. >>>>> >>>>> -- >>>>> Regards, >>>>> Murtuza Zabuawala >>>>> EnterpriseDB: http://www.enterprisedb.com >>>>> The Enterprise PostgreSQL Company >>>>> >>>>> >>>>> On Fri, Mar 9, 2018 at 9:29 PM, Murtuza Zabuawala < >>>>> [email protected]> wrote: >>>>> >>>>>> Hi Dave, >>>>>> >>>>>> I'll change the name and send you updated patch. >>>>>> >>>>>> >>>>>> On Fri, Mar 9, 2018 at 9:25 PM, Dave Page <[email protected]> wrote: >>>>>> >>>>>>> HI >>>>>>> >>>>>>> On Fri, Mar 9, 2018 at 11:47 AM, Murtuza Zabuawala < >>>>>>> [email protected]> wrote: >>>>>>> >>>>>>>> Hi, >>>>>>>> >>>>>>>> PFA patch to add service parameter in server dialog. >>>>>>>> - Docs updated >>>>>>>> - Test case added for Service ID parameter >>>>>>>> >>>>>>>> Please note, >>>>>>>> I have extracted Connection class and Server manager class from our >>>>>>>> own custom Psycopg2 driver module. >>>>>>>> >>>>>>>> Patch also covers RM#3120 >>>>>>>> >>>>>>> >>>>>>> This patch seems a little confused. The "Service" and "Service ID" >>>>>>> fields from pgAdmin 3 are very different things. The Redmine ticket seems >>>>>>> to be asking for the Service field (the pg_service.conf service name), >>>>>>> *not* Service ID (the operating system's service ID, used to start/stop the >>>>>>> database server service). >>>>>>> >>>>>>> -- >>>>>>> Dave Page >>>>>>> Blog: http://pgsnake.blogspot.com >>>>>>> Twitter: @pgsnake >>>>>>> >>>>>>> EnterpriseDB UK: http://www.enterprisedb.com >>>>>>> The Enterprise PostgreSQL Company >>>>>>> >>>>>> >>>>>> >>>>> >>>> >>>> >>>> -- >>>> Dave Page >>>> Blog: http://pgsnake.blogspot.com >>>> Twitter: @pgsnake >>>> >>>> EnterpriseDB UK: http://www.enterprisedb.com >>>> The Enterprise PostgreSQL Company >>>> >>> >> >> >> -- >> Dave Page >> Blog: http://pgsnake.blogspot.com >> Twitter: @pgsnake >> >> EnterpriseDB UK: http://www.enterprisedb.com >> The Enterprise PostgreSQL Company >> > > -- Dave Page Blog: http://pgsnake.blogspot.com Twitter: @pgsnake EnterpriseDB UK: http://www.enterprisedb.com The Enterprise PostgreSQL Company ^ permalink raw reply [nested|flat] 14+ messages in thread
end of thread, other threads:[~2018-03-13 18:47 UTC | newest] Thread overview: 14+ messages (download: mbox mbox.gz follow: Atom feed) -- links below jump to the message on this page -- 2018-03-09 11:47 [pgAdmin4][RM#3140] Add service parameter Murtuza Zabuawala <[email protected]> 2018-03-09 15:55 ` Dave Page <[email protected]> 2018-03-09 15:59 ` Murtuza Zabuawala <[email protected]> 2018-03-12 07:31 ` Murtuza Zabuawala <[email protected]> 2018-03-12 20:46 ` Dave Page <[email protected]> 2018-03-12 21:18 ` Joao De Almeida Pereira <[email protected]> 2018-03-13 00:48 ` Dave Page <[email protected]> 2018-03-13 03:31 ` Ashesh Vashi <[email protected]> 2018-03-13 04:12 ` Murtuza Zabuawala <[email protected]> 2018-03-13 04:15 ` Ashesh Vashi <[email protected]> 2018-03-13 04:35 ` Murtuza Zabuawala <[email protected]> 2018-03-13 11:45 ` Dave Page <[email protected]> 2018-03-13 13:53 ` Victoria Henry <[email protected]> 2018-03-13 18:47 ` Dave Page <[email protected]>
This inbox is served by agora; see mirroring instructions for how to clone and mirror all data and code used for this inbox