#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
:Purpose: This module provides the library's primary entry-point for
accessing the database methods and attributes.
:Platform: Linux/Windows | Python 3.10+
:Developer: J Berendt
:Email: support@s3dev.uk
:Comments: n/a
:Example:
For class-specific usage examples, please refer to the docstring
for the following classes:
- :class:`DBInterface`
"""
# This enables a single module installed test, rather than two.
# pylint: disable=import-outside-toplevel
# Silence the spurious IDE-based error.
# pylint: disable=import-error
import os
import sys
import sqlalchemy as sa
from utils4 import utils
# Set syspath to enable the private modules to import their db-specific class.
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
[docs]
class DBInterface:
"""This class holds the methods and properties which are used across
all databases. This class is the primary entry-point for the database
interface.
Database-specific functionality is provided by this class'
:meth:`__new__` method, which returns the appropriate instance of
the lower-level database-specific class, depending on the connection
string provided. Or, more specifically, the ``sqlalchemy.Engine``
object created from the provided connection string.
Note:
Due to the way this class is created - for specific design
reasons - the inheriting class' ``__init__`` method **will not
be called**. Therefore, specialisation is not as simple as
inheritance and calling the ``super()`` function.
See the examples below for a use-case in how to specialise this
class with your own methods.
Args:
connstr (str): The database-specific SQLAlchemy connection
string.
:Example Use:
This low-level generalised class is designed to be instantiated
by local program or database module, as::
>>> from dblib.database import DBInterface
>>> dbi = DBInterface(connstr=('mysql+mysqlconnector://'
'<user>:<pwd>@<host>:<port>/'
'<db_name>'))
>>> dbi.engine
Engine(mysql+mysqlconnector://<user>:***@<host>:<port>/db_name)
For example, the ``dbi`` instance can be used to execute a
query, as::
>>> result = dbi.execute_query('SELECT COUNT(*) FROM mytable');
>>> result
[(14,)]
Additionally, the ``dbi.engine`` object can be supplied to the
:func:`pandas.read_sql` function's ``con`` parameter, as the
database connection object, as::
>>> import pandas as pd
>>> sql = 'select count(*) from mytable'
>>> df = pd.read_sql(sql, con=dbi.engine)
>>> df
count(*)
0 14
:Subclass Specialisation:
To *specialise the subclass*, a bit of 'pseudo-inheritance' is
required due to the way the :class:`DBInterface` class is
created. A 'standard inheritance' with a call to ``super()``
does not work, as the subclass' ``__init__`` method is **not**
called. Therefore, the subclass must add the parent's attributes
into its class, manually.
This can be done as follows::
from dblib.database import DBInterface
class MyDBI:
def __init__(self, connstr: str):
#
# Pseudo-inherit the DBInterface class by 'copying'
# the attributes into this subclass.
#
# There are many ways to do this. This is the most
# general, as functions, methods and properties are
# captured.
#
self._dbi = DBInterface(connstr=connstr)
fns = [fn for fn in dir(self._dbi) if not fn.startswith('__')]
for fn in fns:
setattr(self, fn, self._dbi.__getattribute__(fn))
@property
def spam(self):
return 'spam'
# Continuation of class definition ...
>>> db = MyDBI(connstr=('mysql+mysqlconnector://'
'<user>:<pwd>@<host>:<port>/'
'<db_name>'))
# List the database interface class' attributes.
# Notice that 'spam' is included in the list, along with the
# methods from the :class:`DBInterface` class, thus
# *simulating* inheritance.
>>> dir(db)
['_PREFIX',
'__class__',
'__delattr__',
...,
'__str__',
'__subclasshook__',
'__weakref__',
'_connstr',
'_create_engine',
'_dbi',
'_engine',
'_report_sa_error',
'_result_to_df__cursor',
'_result_to_df__stored',
'call_procedure',
'call_procedure_update',
'call_procedure_update_many',
'call_procedure_update_raw',
'database_name',
'engine',
'execute_query',
'spam', # <---
'table_exists']]
"""
_SUPPORTED_DBS = ['mysql', 'oracle', 'sqlite']
[docs]
def __new__(cls, connstr: str, *args, **kwargs):
"""Provide a database interface based on the connection string.
Using the provided connection string, a
``sqlalchemy.engine.base.Engine`` object is created. Using the
``.name`` attribute, an instance of the associated database
interface class is returned.
For example, if the ``.name`` attribute is ``'mysql'``, an
instance of the :class:`_dbi_mysql._DBIMySQL` private interface
class is returned. Likewise, if the ``.name`` attribute is
``'oracle'``, an instance of the :class:`_dbi_oracle._DBIOracle`
private interface class is returned, etc.
Args:
connstr (str): The SQLAlchemy-syle connection string, from
which the ``sqlalchemy.engine.base.Engine`` is created
for the database interface instance.
"""
# Enable the use of *args and **kwargs for class parameters.
# pylint: disable=unused-argument
name = cls._create_engine__internal_only(connstr=connstr)
if name not in cls._SUPPORTED_DBS:
raise NotImplementedError('The only databases supported at this time are: '
f'{cls._SUPPORTED_DBS}.')
# These are intentionally verbose as a ModuleNotFoundError will
# be raised during the test if operating on an environment without
# that driver installed.
if name == 'mysql':
if utils.testimport('mysql.connector', verbose=False):
from _dbi_mysql import _DBIMySQL
return _DBIMySQL(connstr=connstr, *args, **kwargs)
if name == 'oracle': # pragma: nocover
if utils.testimport('cx_Oracle', verbose=False):
from _dbi_oracle import _DBIOracle
return _DBIOracle(connstr=connstr, *args, **kwargs)
if name == 'sqlite':
if utils.testimport('sqlite3', verbose=False):
from _dbi_sqlite import _DBISQLite
return _DBISQLite(connstr=connstr, *args, **kwargs)
# Fallback if a module is not installed.
# This is actually caught by the _create_engine__internal_only method.
raise RuntimeError('An error occurred while creating an instance of the database '
'accessor class. Perhaps the appropriate database driver is not '
'installed?') # pragma: nocover (never reached)
[docs]
@staticmethod
def _create_engine__internal_only(connstr: str) -> str:
"""Create a database engine using the provided connection string.
Warning:
This method is *not* to be called independently as:
- The engine itself is not returned.
- The connect is disposed immediately after creation.
- The ``pool_*`` objects are not set.
The engine created here is *only* meant to providing
database-class routing for this class' :meth:`__new__`
method.
Args:
connstr (str): The SQLAlchemy connection string.
Returns:
str: The value of the ``.name`` attribute from the database
engine.
"""
_engine = sa.create_engine(url=connstr)
name = _engine.name.lower()
_engine.dispose(close=True)
return name