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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 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 `_. +* Use the *Service ID* field to specify the service name. For more information, see `Section 33.16 of the Postgres documentation `_. *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