1 # SPDX-License-Identifier: GPL-2.0-only
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2022 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Specialised connection and cursor functions.
15 import psycopg2.extensions
16 import psycopg2.extras
17 from psycopg2 import sql as pysql
19 from nominatim.errors import UsageError
21 LOG = logging.getLogger()
23 class _Cursor(psycopg2.extras.DictCursor):
24 """ A cursor returning dict-like objects and providing specialised
28 def execute(self, query, args=None): # pylint: disable=W0221
29 """ Query execution that logs the SQL query when debugging is enabled.
31 LOG.debug(self.mogrify(query, args).decode('utf-8'))
33 super().execute(query, args)
36 def execute_values(self, sql, argslist, template=None):
37 """ Wrapper for the psycopg2 convenience function to execute
38 SQL for a list of values.
40 LOG.debug("SQL execute_values(%s, %s)", sql, argslist)
42 psycopg2.extras.execute_values(self, sql, argslist, template=template)
45 def scalar(self, sql, args=None):
46 """ Execute query that returns a single value. The value is returned.
47 If the query yields more than one row, a ValueError is raised.
49 self.execute(sql, args)
51 if self.rowcount != 1:
52 raise RuntimeError("Query did not return a single row.")
54 return self.fetchone()[0]
57 def drop_table(self, name, if_exists=True, cascade=False):
58 """ Drop the table with the given name.
59 Set `if_exists` to False if a non-existant table should raise
60 an exception instead of just being ignored. If 'cascade' is set
61 to True then all dependent tables are deleted as well.
70 self.execute(pysql.SQL(sql).format(pysql.Identifier(name)))
73 class _Connection(psycopg2.extensions.connection):
74 """ A connection that provides the specialised cursor by default and
75 adds convenience functions for administrating the database.
78 def cursor(self, cursor_factory=_Cursor, **kwargs):
79 """ Return a new cursor. By default the specialised cursor is returned.
81 return super().cursor(cursor_factory=cursor_factory, **kwargs)
84 def table_exists(self, table):
85 """ Check that a table with the given name exists in the database.
87 with self.cursor() as cur:
88 num = cur.scalar("""SELECT count(*) FROM pg_tables
89 WHERE tablename = %s and schemaname = 'public'""", (table, ))
93 def table_has_column(self, table, column):
94 """ Check if the table 'table' exists and has a column with name 'column'.
96 with self.cursor() as cur:
97 has_column = cur.scalar("""SELECT count(*) FROM information_schema.columns
99 and column_name = %s""",
101 return has_column > 0
104 def index_exists(self, index, table=None):
105 """ Check that an index with the given name exists in the database.
106 If table is not None then the index must relate to the given
109 with self.cursor() as cur:
110 cur.execute("""SELECT tablename FROM pg_indexes
111 WHERE indexname = %s and schemaname = 'public'""", (index, ))
112 if cur.rowcount == 0:
115 if table is not None:
117 return row[0] == table
122 def drop_table(self, name, if_exists=True, cascade=False):
123 """ Drop the table with the given name.
124 Set `if_exists` to False if a non-existant table should raise
125 an exception instead of just being ignored.
127 with self.cursor() as cur:
128 cur.drop_table(name, if_exists, cascade)
132 def server_version_tuple(self):
133 """ Return the server version as a tuple of (major, minor).
134 Converts correctly for pre-10 and post-10 PostgreSQL versions.
136 version = self.server_version
138 return (int(version / 10000), (version % 10000) / 100)
140 return (int(version / 10000), version % 10000)
143 def postgis_version_tuple(self):
144 """ Return the postgis version installed in the database as a
145 tuple of (major, minor). Assumes that the PostGIS extension
146 has been installed already.
148 with self.cursor() as cur:
149 version = cur.scalar('SELECT postgis_lib_version()')
151 return tuple((int(x) for x in version.split('.')[:2]))
155 """ Open a connection to the database using the specialised connection
156 factory. The returned object may be used in conjunction with 'with'.
157 When used outside a context manager, use the `connection` attribute
158 to get the connection.
161 conn = psycopg2.connect(dsn, connection_factory=_Connection)
162 ctxmgr = contextlib.closing(conn)
163 ctxmgr.connection = conn
165 except psycopg2.OperationalError as err:
166 raise UsageError(f"Cannot connect to database: {err}") from err
169 # Translation from PG connection string parameters to PG environment variables.
170 # Derived from https://www.postgresql.org/docs/current/libpq-envars.html.
171 _PG_CONNECTION_STRINGS = {
173 'hostaddr': 'PGHOSTADDR',
175 'dbname': 'PGDATABASE',
177 'password': 'PGPASSWORD',
178 'passfile': 'PGPASSFILE',
179 'channel_binding': 'PGCHANNELBINDING',
180 'service': 'PGSERVICE',
181 'options': 'PGOPTIONS',
182 'application_name': 'PGAPPNAME',
183 'sslmode': 'PGSSLMODE',
184 'requiressl': 'PGREQUIRESSL',
185 'sslcompression': 'PGSSLCOMPRESSION',
186 'sslcert': 'PGSSLCERT',
187 'sslkey': 'PGSSLKEY',
188 'sslrootcert': 'PGSSLROOTCERT',
189 'sslcrl': 'PGSSLCRL',
190 'requirepeer': 'PGREQUIREPEER',
191 'ssl_min_protocol_version': 'PGSSLMINPROTOCOLVERSION',
192 'ssl_max_protocol_version': 'PGSSLMAXPROTOCOLVERSION',
193 'gssencmode': 'PGGSSENCMODE',
194 'krbsrvname': 'PGKRBSRVNAME',
195 'gsslib': 'PGGSSLIB',
196 'connect_timeout': 'PGCONNECT_TIMEOUT',
197 'target_session_attrs': 'PGTARGETSESSIONATTRS',
201 def get_pg_env(dsn, base_env=None):
202 """ Return a copy of `base_env` with the environment variables for
203 PostgresSQL set up from the given database connection string.
204 If `base_env` is None, then the OS environment is used as a base
207 env = dict(base_env if base_env is not None else os.environ)
209 for param, value in psycopg2.extensions.parse_dsn(dsn).items():
210 if param in _PG_CONNECTION_STRINGS:
211 env[_PG_CONNECTION_STRINGS[param]] = value
213 LOG.error("Unknown connection parameter '%s' ignored.", param)