From 03b772bf642d2364c3c51226da4b33439107e883 Mon Sep 17 00:00:00 2001 From: Murtuza Zabuawala Date: Mon, 12 Mar 2018 16:45:56 -0400 Subject: [PATCH] Add support for connecting using pg_service.conf files. Fixes #3140 --- docs/en_US/images/server_connection.png | Bin 39678 -> 66243 bytes docs/en_US/server_dialog.rst | 1 + web/migrations/versions/50aad68f99c2_.py | 82 + .../browser/server_groups/servers/__init__.py | 61 +- .../server_groups/servers/static/js/server.js | 48 +- .../tests/test_add_server_with_service_id.py | 47 + web/pgadmin/model/__init__.py | 5 +- web/pgadmin/utils/driver/psycopg2/__init__.py | 1985 +---------------- .../utils/driver/psycopg2/connection.py | 1682 ++++++++++++++ .../utils/driver/psycopg2/server_manager.py | 333 +++ 10 files changed, 2227 insertions(+), 2017 deletions(-) create mode 100644 web/migrations/versions/50aad68f99c2_.py create mode 100644 web/pgadmin/browser/server_groups/servers/tests/test_add_server_with_service_id.py create mode 100644 web/pgadmin/utils/driver/psycopg2/connection.py create mode 100644 web/pgadmin/utils/driver/psycopg2/server_manager.py diff --git a/docs/en_US/images/server_connection.png b/docs/en_US/images/server_connection.png index 7ebf6b58b7bd260bcb3c1223907c3a22375fcd94..dd18376e5884effefabf45fc1bf6332f5522a73e 100644 GIT binary patch literal 66243 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 -|aIZ;>ir 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 literal 39678 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 j?p`AvQ8Kx+hB@RQfPb&V<-|%v^?m;rYGjaT diff --git a/docs/en_US/server_dialog.rst b/docs/en_US/server_dialog.rst index 24a726baf..b1159c598 100644 --- a/docs/en_US/server_dialog.rst +++ b/docs/en_US/server_dialog.rst @@ -32,6 +32,7 @@ Use the fields in the *Connection* tab to configure a connection: * Use the *Password* field to provide a password that will be supplied when authenticating with the server. * Check the box next to *Save password?* to instruct pgAdmin to save the password for future use. * Use the *Role* field to specify the name of a role that has privileges that will be conveyed to the client after authentication with the server. This selection allows you to connect as one role, and then assume the permissions of this specified role after the connection is established. Note that the connecting role must be a member of the role specified. +* Use the *Service* field to specify the service name. For more information, see `Section 33.16 of the Postgres documentation `_. Click the *SSL* tab to continue. diff --git a/web/migrations/versions/50aad68f99c2_.py b/web/migrations/versions/50aad68f99c2_.py new file mode 100644 index 000000000..4cda56bdb --- /dev/null +++ b/web/migrations/versions/50aad68f99c2_.py @@ -0,0 +1,82 @@ + +"""Added service field option in server table (RM#3140) + +Revision ID: 50aad68f99c2 +Revises: 02b9dccdcfcb +Create Date: 2018-03-07 11:53:57.584280 + +""" +from pgadmin.model import db + + +# revision identifiers, used by Alembic. +revision = '50aad68f99c2' +down_revision = '02b9dccdcfcb' +branch_labels = None +depends_on = None + + +def upgrade(): + # To Save previous data + db.engine.execute("ALTER TABLE server RENAME TO server_old") + + # With service file some fields won't be mandatory as user can provide + # them using service file. Removed NOT NULL constraint from few columns + db.engine.execute(""" + CREATE TABLE server ( + id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + servergroup_id INTEGER NOT NULL, + name VARCHAR(128) NOT NULL, + host VARCHAR(128), + port INTEGER NOT NULL CHECK(port >= 1024 AND port <= 65534), + maintenance_db VARCHAR(64), + username VARCHAR(64) NOT NULL, + password VARCHAR(64), + role VARCHAR(64), + ssl_mode VARCHAR(16) NOT NULL CHECK(ssl_mode IN + ( 'allow' , 'prefer' , 'require' , 'disable' , + 'verify-ca' , 'verify-full' ) + ), + comment VARCHAR(1024), + discovery_id VARCHAR(128), + hostaddr TEXT(1024), + db_res TEXT, + passfile TEXT, + sslcert TEXT, + sslkey TEXT, + sslrootcert TEXT, + sslcrl TEXT, + sslcompression INTEGER DEFAULT 0, + bgcolor TEXT(10), + fgcolor TEXT(10), + PRIMARY KEY(id), + FOREIGN KEY(user_id) REFERENCES user(id), + FOREIGN KEY(servergroup_id) REFERENCES servergroup(id) + ) + """) + + # Copy old data again into table + db.engine.execute(""" + INSERT INTO server ( + id,user_id, servergroup_id, name, host, port, maintenance_db, + username, ssl_mode, comment, password, role, discovery_id, + hostaddr, db_res, passfile, sslcert, sslkey, sslrootcert, sslcrl, + bgcolor, fgcolor + ) SELECT + id,user_id, servergroup_id, name, host, port, maintenance_db, + username, ssl_mode, comment, password, role, discovery_id, + hostaddr, db_res, passfile, sslcert, sslkey, sslrootcert, sslcrl, + bgcolor, fgcolor + FROM server_old""") + + # Remove old data + db.engine.execute("DROP TABLE server_old") + + # Add column for Service + db.engine.execute( + 'ALTER TABLE server ADD COLUMN service TEXT' + ) + +def downgrade(): + pass diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index dfa9d62a7..fecb66d11 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 993280832..36908733b 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -665,6 +665,7 @@ define('pgadmin.node.server', [ sslkey: undefined, sslrootcert: undefined, sslcrl: undefined, + service: undefined, }, // Default values! initialize: function(attrs, args) { @@ -841,12 +842,18 @@ define('pgadmin.node.server', [ var passfile = m.get('passfile'); return !_.isUndefined(passfile) && !_.isNull(passfile); }, + },{ + id: 'service', label: gettext('Service'), type: 'text', + mode: ['properties', 'edit', 'create'], disabled: 'isConnected', + group: gettext('Connection'), }], validate: function() { var err = {}, errmsg, self = this; + var service_id = this.get('service'); + var check_for_empty = function(id, msg) { var v = self.get(id); if ( @@ -903,26 +910,41 @@ define('pgadmin.node.server', [ } check_for_empty('name', gettext('Name must be specified.')); - if (check_for_empty( - 'host', gettext('Either Host name or Host address must be specified.') - ) && check_for_empty('hostaddr', gettext('Either Host name or Host address must be specified.'))){ - errmsg = errmsg || gettext('Either Host name or Host address must be specified'); + // If no service id then only check + if ( + _.isUndefined(service_id) || _.isNull(service_id) || + String(service_id).replace(/^\s+|\s+$/g, '') == '' + ) { + if (check_for_empty( + 'host', gettext('Either Host name, Address or Service must be specified.') + ) && check_for_empty('hostaddr', gettext('Either Host name, Address or Service must be specified.'))){ + errmsg = errmsg || gettext('Either Host name, Address or Service must be specified.'); + } else { + errmsg = undefined; + delete err['host']; + delete err['hostaddr']; + } + + check_for_empty( + 'db', gettext('Maintenance database must be specified.') + ); + check_for_valid_ip( + 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') + ); + check_for_valid_ip( + 'hostaddr', gettext('Host address must be valid IPv4 or IPv6 address.') + ); } else { - 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 000000000..3b03d4946 --- /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 674f94510..11bc9f05e 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 941a69429..95a49fb46 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 000000000..d4c9573c7 --- /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 000000000..2299e285e --- /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