Coverage for /var/devmt/py/dbilib_0.4.1/dbilib/database.py: 100%

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.

6

7:Platform: Linux/Windows | Python 3.10+

8:Developer: J Berendt

9:Email: support@s3dev.uk

10

11:Comments: n/a

12

13:Example:

14

15 For class-specific usage examples, please refer to the docstring

16 for the following classes:

17

18 - :class:`DBInterface`

19

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

25

26 import os

27 import sys

28 import sqlalchemy as sa

29 from utils4 import utils

30

31# Set syspath to enable the private modules to import their db-specific class.

32 sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))

33

34

35 class 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.

39

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.

45

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.

51

52 See the examples below for a use-case in how to specialise this

53 class with your own methods.

54

55 Args:

56 connstr (str): The database-specific SQLAlchemy connection

57 string.

58

59 :Example Use:

60

61 This low-level generalised class is designed to be instantiated

62 by local program or database module, as::

63

64 >>> from dbilib.database import DBInterface

65

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)

71

72

73 For example, the ``dbi`` instance can be used to execute a

74 query, as::

75

76 >>> result = dbi.execute_query('SELECT COUNT(*) FROM mytable');

77 >>> result

78 [(14,)]

79

80

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::

84

85 >>> import pandas as pd

86

87 >>> sql = 'select count(*) from mytable'

88 >>> df = pd.read_sql(sql, con=dbi.engine)

89

90 >>> df

91 count(*)

92 0 14

93

94 :Subclass Specialisation:

95

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.

102

103 This can be done as follows::

104

105 from dbilib.database import DBInterface

106

107 class MyDBI:

108

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))

122

123 @property

124 def spam(self):

125 return 'spam'

126

127 # Continuation of class definition ...

128

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']]

160

161 """

162

163 _SUPPORTED_DBS = ['mysql', 'oracle', 'sqlite']

164

165 def __new__(cls, connstr: str, *args, **kwargs):

166 """Provide a database interface based on the connection string.

167

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.

172

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.

178

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.

183

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)

211

212 @staticmethod

213 def _create_engine__internal_only(connstr: str) -> str:

214 """Create a database engine using the provided connection string.

215

216 Warning:

217 This method is *not* to be called independently as:

218

219 - The engine itself is not returned.

220 - The connect is disposed immediately after creation.

221 - The ``pool_*`` objects are not set.

222

223 The engine created here is *only* meant to providing

224 database-class routing for this class' :meth:`__new__`

225 method.

226

227 Args:

228 connstr (str): The SQLAlchemy connection string.

229

230 Returns:

231 str: The value of the ``.name`` attribute from the database

232 engine.

233

234 """

235 _engine = sa.create_engine(url=connstr)

236 name = _engine.name.lower()

237 _engine.dispose(close=True)

238 return name