Coverage for /var/devmt/py/dbilib_0.0.1.dev1/dbilib/database.py: 100%
25 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-07 23:49 +0100
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-07 23:49 +0100
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3"""
4:Purpose: This module provides the library's primary entry-point for
5 accessing the database methods and attributes.
7:Platform: Linux/Windows | Python 3.10+
8:Developer: J Berendt
9:Email: support@s3dev.uk
11:Comments: n/a
13:Example:
15 For class-specific usage examples, please refer to the docstring
16 for the following classes:
18 - :class:`DBInterface`
20"""
21# This enables a single module installed test, rather than two.
22# pylint: disable=import-outside-toplevel
23# Silence the spurious IDE-based error.
24# pylint: disable=import-error
26import os
27import sys
28import sqlalchemy as sa
29from utils4 import utils
31# Set syspath to enable the private modules to import their db-specific class.
32sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
35class DBInterface:
36 """This class holds the methods and properties which are used across
37 all databases. This class is the primary entry-point for the database
38 interface.
40 Database-specific functionality is provided by this class'
41 :meth:`__new__` method, which returns the appropriate instance of
42 the lower-level database-specific class, depending on the connection
43 string provided. Or, more specifically, the ``sqlalchemy.Engine``
44 object created from the provided connection string.
46 Note:
47 Due to the way this class is created - for specific design
48 reasons - the inheriting class' ``__init__`` method **will not
49 be called**. Therefore, specialisation is not as simple as
50 inheritance and calling the ``super()`` function.
52 See the examples below for a use-case in how to specialise this
53 class with your own methods.
55 Args:
56 connstr (str): The database-specific SQLAlchemy connection
57 string.
59 :Example Use:
61 This low-level generalised class is designed to be instantiated
62 by local program or database module, as::
64 >>> from dblib.database import DBInterface
66 >>> dbi = DBInterface(connstr=('mysql+mysqlconnector://'
67 '<user>:<pwd>@<host>:<port>/'
68 '<db_name>'))
69 >>> dbi.engine
70 Engine(mysql+mysqlconnector://<user>:***@<host>:<port>/db_name)
73 For example, the ``dbi`` instance can be used to execute a
74 query, as::
76 >>> result = dbi.execute_query('SELECT COUNT(*) FROM mytable');
77 >>> result
78 [(14,)]
81 Additionally, the ``dbi.engine`` object can be supplied to the
82 :func:`pandas.read_sql` function's ``con`` parameter, as the
83 database connection object, as::
85 >>> import pandas as pd
87 >>> sql = 'select count(*) from mytable'
88 >>> df = pd.read_sql(sql, con=dbi.engine)
90 >>> df
91 count(*)
92 0 14
94 :Subclass Specialisation:
96 To *specialise the subclass*, a bit of 'pseudo-inheritance' is
97 required due to the way the :class:`DBInterface` class is
98 created. A 'standard inheritance' with a call to ``super()``
99 does not work, as the subclass' ``__init__`` method is **not**
100 called. Therefore, the subclass must add the parent's attributes
101 into its class, manually.
103 This can be done as follows::
105 from dblib.database import DBInterface
107 class MyDBI:
109 def __init__(self, connstr: str):
110 #
111 # Pseudo-inherit the DBInterface class by 'copying'
112 # the attributes into this subclass.
113 #
114 # There are many ways to do this. This is the most
115 # general, as functions, methods and properties are
116 # captured.
117 #
118 self._dbi = DBInterface(connstr=connstr)
119 fns = [fn for fn in dir(self._dbi) if not fn.startswith('__')]
120 for fn in fns:
121 setattr(self, fn, self._dbi.__getattribute__(fn))
123 @property
124 def spam(self):
125 return 'spam'
127 # Continuation of class definition ...
129 >>> db = MyDBI(connstr=('mysql+mysqlconnector://'
130 '<user>:<pwd>@<host>:<port>/'
131 '<db_name>'))
132 # List the database interface class' attributes.
133 # Notice that 'spam' is included in the list, along with the
134 # methods from the :class:`DBInterface` class, thus
135 # *simulating* inheritance.
136 >>> dir(db)
137 ['_PREFIX',
138 '__class__',
139 '__delattr__',
140 ...,
141 '__str__',
142 '__subclasshook__',
143 '__weakref__',
144 '_connstr',
145 '_create_engine',
146 '_dbi',
147 '_engine',
148 '_report_sa_error',
149 '_result_to_df__cursor',
150 '_result_to_df__stored',
151 'call_procedure',
152 'call_procedure_update',
153 'call_procedure_update_many',
154 'call_procedure_update_raw',
155 'database_name',
156 'engine',
157 'execute_query',
158 'spam', # <---
159 'table_exists']]
161 """
163 _SUPPORTED_DBS = ['mysql', 'oracle', 'sqlite']
165 def __new__(cls, connstr: str, *args, **kwargs):
166 """Provide a database interface based on the connection string.
168 Using the provided connection string, a
169 ``sqlalchemy.engine.base.Engine`` object is created. Using the
170 ``.name`` attribute, an instance of the associated database
171 interface class is returned.
173 For example, if the ``.name`` attribute is ``'mysql'``, an
174 instance of the :class:`_dbi_mysql._DBIMySQL` private interface
175 class is returned. Likewise, if the ``.name`` attribute is
176 ``'oracle'``, an instance of the :class:`_dbi_oracle._DBIOracle`
177 private interface class is returned, etc.
179 Args:
180 connstr (str): The SQLAlchemy-syle connection string, from
181 which the ``sqlalchemy.engine.base.Engine`` is created
182 for the database interface instance.
184 """
185 # Enable the use of *args and **kwargs for class parameters.
186 # pylint: disable=unused-argument
187 name = cls._create_engine__internal_only(connstr=connstr)
188 if name not in cls._SUPPORTED_DBS:
189 raise NotImplementedError('The only databases supported at this time are: '
190 f'{cls._SUPPORTED_DBS}.')
191 # These are intentionally verbose as a ModuleNotFoundError will
192 # be raised during the test if operating on an environment without
193 # that driver installed.
194 if name == 'mysql':
195 if utils.testimport('mysql.connector', verbose=False):
196 from _dbi_mysql import _DBIMySQL
197 return _DBIMySQL(connstr=connstr, *args, **kwargs)
198 if name == 'oracle': # pragma: nocover
199 if utils.testimport('cx_Oracle', verbose=False):
200 from _dbi_oracle import _DBIOracle
201 return _DBIOracle(connstr=connstr, *args, **kwargs)
202 if name == 'sqlite':
203 if utils.testimport('sqlite3', verbose=False):
204 from _dbi_sqlite import _DBISQLite
205 return _DBISQLite(connstr=connstr, *args, **kwargs)
206 # Fallback if a module is not installed.
207 # This is actually caught by the _create_engine__internal_only method.
208 raise RuntimeError('An error occurred while creating an instance of the database '
209 'accessor class. Perhaps the appropriate database driver is not '
210 'installed?') # pragma: nocover (never reached)
212 @staticmethod
213 def _create_engine__internal_only(connstr: str) -> str:
214 """Create a database engine using the provided connection string.
216 Warning:
217 This method is *not* to be called independently as:
219 - The engine itself is not returned.
220 - The connect is disposed immediately after creation.
221 - The ``pool_*`` objects are not set.
223 The engine created here is *only* meant to providing
224 database-class routing for this class' :meth:`__new__`
225 method.
227 Args:
228 connstr (str): The SQLAlchemy connection string.
230 Returns:
231 str: The value of the ``.name`` attribute from the database
232 engine.
234 """
235 _engine = sa.create_engine(url=connstr)
236 name = _engine.name.lower()
237 _engine.dispose(close=True)
238 return name