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

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 

26import os 

27import sys 

28import sqlalchemy as sa 

29from utils4 import utils 

30 

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

33 

34 

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. 

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 dblib.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 dblib.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