Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain-models/plain/models/backends/sqlite3/base.py: 85%

123 statements  

« prev     ^ index     » next       coverage.py v7.6.9, created at 2024-12-23 11:16 -0600

1""" 

2SQLite backend for the sqlite3 module in the standard library. 

3""" 

4 

5import datetime 

6import decimal 

7import warnings 

8from collections.abc import Mapping 

9from itertools import chain, tee 

10from sqlite3 import dbapi2 as Database 

11 

12from plain.exceptions import ImproperlyConfigured 

13from plain.models.backends.base.base import BaseDatabaseWrapper 

14from plain.models.db import IntegrityError 

15from plain.utils.dateparse import parse_date, parse_datetime, parse_time 

16from plain.utils.regex_helper import _lazy_re_compile 

17 

18from ._functions import register as register_functions 

19from .client import DatabaseClient 

20from .creation import DatabaseCreation 

21from .features import DatabaseFeatures 

22from .introspection import DatabaseIntrospection 

23from .operations import DatabaseOperations 

24from .schema import DatabaseSchemaEditor 

25 

26 

27def decoder(conv_func): 

28 """ 

29 Convert bytestrings from Python's sqlite3 interface to a regular string. 

30 """ 

31 return lambda s: conv_func(s.decode()) 

32 

33 

34def adapt_date(val): 

35 return val.isoformat() 

36 

37 

38def adapt_datetime(val): 

39 return val.isoformat(" ") 

40 

41 

42Database.register_converter("bool", b"1".__eq__) 

43Database.register_converter("date", decoder(parse_date)) 

44Database.register_converter("time", decoder(parse_time)) 

45Database.register_converter("datetime", decoder(parse_datetime)) 

46Database.register_converter("timestamp", decoder(parse_datetime)) 

47 

48Database.register_adapter(decimal.Decimal, str) 

49Database.register_adapter(datetime.date, adapt_date) 

50Database.register_adapter(datetime.datetime, adapt_datetime) 

51 

52 

53class DatabaseWrapper(BaseDatabaseWrapper): 

54 vendor = "sqlite" 

55 display_name = "SQLite" 

56 # SQLite doesn't actually support most of these types, but it "does the right 

57 # thing" given more verbose field definitions, so leave them as is so that 

58 # schema inspection is more useful. 

59 data_types = { 

60 "AutoField": "integer", 

61 "BigAutoField": "integer", 

62 "BinaryField": "BLOB", 

63 "BooleanField": "bool", 

64 "CharField": "varchar(%(max_length)s)", 

65 "DateField": "date", 

66 "DateTimeField": "datetime", 

67 "DecimalField": "decimal", 

68 "DurationField": "bigint", 

69 "FloatField": "real", 

70 "IntegerField": "integer", 

71 "BigIntegerField": "bigint", 

72 "IPAddressField": "char(15)", 

73 "GenericIPAddressField": "char(39)", 

74 "JSONField": "text", 

75 "OneToOneField": "integer", 

76 "PositiveBigIntegerField": "bigint unsigned", 

77 "PositiveIntegerField": "integer unsigned", 

78 "PositiveSmallIntegerField": "smallint unsigned", 

79 "SlugField": "varchar(%(max_length)s)", 

80 "SmallAutoField": "integer", 

81 "SmallIntegerField": "smallint", 

82 "TextField": "text", 

83 "TimeField": "time", 

84 "UUIDField": "char(32)", 

85 } 

86 data_type_check_constraints = { 

87 "PositiveBigIntegerField": '"%(column)s" >= 0', 

88 "JSONField": '(JSON_VALID("%(column)s") OR "%(column)s" IS NULL)', 

89 "PositiveIntegerField": '"%(column)s" >= 0', 

90 "PositiveSmallIntegerField": '"%(column)s" >= 0', 

91 } 

92 data_types_suffix = { 

93 "AutoField": "AUTOINCREMENT", 

94 "BigAutoField": "AUTOINCREMENT", 

95 "SmallAutoField": "AUTOINCREMENT", 

96 } 

97 # SQLite requires LIKE statements to include an ESCAPE clause if the value 

98 # being escaped has a percent or underscore in it. 

99 # See https://www.sqlite.org/lang_expr.html for an explanation. 

100 operators = { 

101 "exact": "= %s", 

102 "iexact": "LIKE %s ESCAPE '\\'", 

103 "contains": "LIKE %s ESCAPE '\\'", 

104 "icontains": "LIKE %s ESCAPE '\\'", 

105 "regex": "REGEXP %s", 

106 "iregex": "REGEXP '(?i)' || %s", 

107 "gt": "> %s", 

108 "gte": ">= %s", 

109 "lt": "< %s", 

110 "lte": "<= %s", 

111 "startswith": "LIKE %s ESCAPE '\\'", 

112 "endswith": "LIKE %s ESCAPE '\\'", 

113 "istartswith": "LIKE %s ESCAPE '\\'", 

114 "iendswith": "LIKE %s ESCAPE '\\'", 

115 } 

116 

117 # The patterns below are used to generate SQL pattern lookup clauses when 

118 # the right-hand side of the lookup isn't a raw string (it might be an expression 

119 # or the result of a bilateral transformation). 

120 # In those cases, special characters for LIKE operators (e.g. \, *, _) should be 

121 # escaped on database side. 

122 # 

123 # Note: we use str.format() here for readability as '%' is used as a wildcard for 

124 # the LIKE operator. 

125 pattern_esc = r"REPLACE(REPLACE(REPLACE({}, '\', '\\'), '%%', '\%%'), '_', '\_')" 

126 pattern_ops = { 

127 "contains": r"LIKE '%%' || {} || '%%' ESCAPE '\'", 

128 "icontains": r"LIKE '%%' || UPPER({}) || '%%' ESCAPE '\'", 

129 "startswith": r"LIKE {} || '%%' ESCAPE '\'", 

130 "istartswith": r"LIKE UPPER({}) || '%%' ESCAPE '\'", 

131 "endswith": r"LIKE '%%' || {} ESCAPE '\'", 

132 "iendswith": r"LIKE '%%' || UPPER({}) ESCAPE '\'", 

133 } 

134 

135 Database = Database 

136 SchemaEditorClass = DatabaseSchemaEditor 

137 # Classes instantiated in __init__(). 

138 client_class = DatabaseClient 

139 creation_class = DatabaseCreation 

140 features_class = DatabaseFeatures 

141 introspection_class = DatabaseIntrospection 

142 ops_class = DatabaseOperations 

143 

144 def get_connection_params(self): 

145 settings_dict = self.settings_dict 

146 if not settings_dict["NAME"]: 

147 raise ImproperlyConfigured( 

148 "settings.DATABASES is improperly configured. " 

149 "Please supply the NAME value." 

150 ) 

151 kwargs = { 

152 "database": settings_dict["NAME"], 

153 "detect_types": Database.PARSE_DECLTYPES | Database.PARSE_COLNAMES, 

154 **settings_dict["OPTIONS"], 

155 } 

156 # Always allow the underlying SQLite connection to be shareable 

157 # between multiple threads. The safe-guarding will be handled at a 

158 # higher level by the `BaseDatabaseWrapper.allow_thread_sharing` 

159 # property. This is necessary as the shareability is disabled by 

160 # default in sqlite3 and it cannot be changed once a connection is 

161 # opened. 

162 if "check_same_thread" in kwargs and kwargs["check_same_thread"]: 

163 warnings.warn( 

164 "The `check_same_thread` option was provided and set to " 

165 "True. It will be overridden with False. Use the " 

166 "`DatabaseWrapper.allow_thread_sharing` property instead " 

167 "for controlling thread shareability.", 

168 RuntimeWarning, 

169 ) 

170 kwargs.update({"check_same_thread": False, "uri": True}) 

171 return kwargs 

172 

173 def get_database_version(self): 

174 return self.Database.sqlite_version_info 

175 

176 def get_new_connection(self, conn_params): 

177 conn = Database.connect(**conn_params) 

178 register_functions(conn) 

179 

180 conn.execute("PRAGMA foreign_keys = ON") 

181 # The macOS bundled SQLite defaults legacy_alter_table ON, which 

182 # prevents atomic table renames (feature supports_atomic_references_rename) 

183 conn.execute("PRAGMA legacy_alter_table = OFF") 

184 return conn 

185 

186 def create_cursor(self, name=None): 

187 return self.connection.cursor(factory=SQLiteCursorWrapper) 

188 

189 def close(self): 

190 self.validate_thread_sharing() 

191 # If database is in memory, closing the connection destroys the 

192 # database. To prevent accidental data loss, ignore close requests on 

193 # an in-memory db. 

194 if not self.is_in_memory_db(): 

195 BaseDatabaseWrapper.close(self) 

196 

197 def _savepoint_allowed(self): 

198 # When 'isolation_level' is not None, sqlite3 commits before each 

199 # savepoint; it's a bug. When it is None, savepoints don't make sense 

200 # because autocommit is enabled. The only exception is inside 'atomic' 

201 # blocks. To work around that bug, on SQLite, 'atomic' starts a 

202 # transaction explicitly rather than simply disable autocommit. 

203 return self.in_atomic_block 

204 

205 def _set_autocommit(self, autocommit): 

206 if autocommit: 

207 level = None 

208 else: 

209 # sqlite3's internal default is ''. It's different from None. 

210 # See Modules/_sqlite/connection.c. 

211 level = "" 

212 # 'isolation_level' is a misleading API. 

213 # SQLite always runs at the SERIALIZABLE isolation level. 

214 with self.wrap_database_errors: 

215 self.connection.isolation_level = level 

216 

217 def disable_constraint_checking(self): 

218 with self.cursor() as cursor: 

219 cursor.execute("PRAGMA foreign_keys = OFF") 

220 # Foreign key constraints cannot be turned off while in a multi- 

221 # statement transaction. Fetch the current state of the pragma 

222 # to determine if constraints are effectively disabled. 

223 enabled = cursor.execute("PRAGMA foreign_keys").fetchone()[0] 

224 return not bool(enabled) 

225 

226 def enable_constraint_checking(self): 

227 with self.cursor() as cursor: 

228 cursor.execute("PRAGMA foreign_keys = ON") 

229 

230 def check_constraints(self, table_names=None): 

231 """ 

232 Check each table name in `table_names` for rows with invalid foreign 

233 key references. This method is intended to be used in conjunction with 

234 `disable_constraint_checking()` and `enable_constraint_checking()`, to 

235 determine if rows with invalid references were entered while constraint 

236 checks were off. 

237 """ 

238 with self.cursor() as cursor: 

239 if table_names is None: 

240 violations = cursor.execute("PRAGMA foreign_key_check").fetchall() 

241 else: 

242 violations = chain.from_iterable( 

243 cursor.execute( 

244 f"PRAGMA foreign_key_check({self.ops.quote_name(table_name)})" 

245 ).fetchall() 

246 for table_name in table_names 

247 ) 

248 # See https://www.sqlite.org/pragma.html#pragma_foreign_key_check 

249 for ( 

250 table_name, 

251 rowid, 

252 referenced_table_name, 

253 foreign_key_index, 

254 ) in violations: 

255 foreign_key = cursor.execute( 

256 f"PRAGMA foreign_key_list({self.ops.quote_name(table_name)})" 

257 ).fetchall()[foreign_key_index] 

258 column_name, referenced_column_name = foreign_key[3:5] 

259 primary_key_column_name = self.introspection.get_primary_key_column( 

260 cursor, table_name 

261 ) 

262 primary_key_value, bad_value = cursor.execute( 

263 f"SELECT {self.ops.quote_name(primary_key_column_name)}, {self.ops.quote_name(column_name)} FROM {self.ops.quote_name(table_name)} WHERE rowid = %s", 

264 (rowid,), 

265 ).fetchone() 

266 raise IntegrityError( 

267 f"The row in table '{table_name}' with primary key '{primary_key_value}' has an " 

268 f"invalid foreign key: {table_name}.{column_name} contains a value '{bad_value}' that " 

269 f"does not have a corresponding value in {referenced_table_name}.{referenced_column_name}." 

270 ) 

271 

272 def is_usable(self): 

273 return True 

274 

275 def _start_transaction_under_autocommit(self): 

276 """ 

277 Start a transaction explicitly in autocommit mode. 

278 

279 Staying in autocommit mode works around a bug of sqlite3 that breaks 

280 savepoints when autocommit is disabled. 

281 """ 

282 self.cursor().execute("BEGIN") 

283 

284 def is_in_memory_db(self): 

285 return self.creation.is_in_memory_db(self.settings_dict["NAME"]) 

286 

287 

288FORMAT_QMARK_REGEX = _lazy_re_compile(r"(?<!%)%s") 

289 

290 

291class SQLiteCursorWrapper(Database.Cursor): 

292 """ 

293 Plain uses the "format" and "pyformat" styles, but Python's sqlite3 module 

294 supports neither of these styles. 

295 

296 This wrapper performs the following conversions: 

297 

298 - "format" style to "qmark" style 

299 - "pyformat" style to "named" style 

300 

301 In both cases, if you want to use a literal "%s", you'll need to use "%%s". 

302 """ 

303 

304 def execute(self, query, params=None): 

305 if params is None: 

306 return super().execute(query) 

307 # Extract names if params is a mapping, i.e. "pyformat" style is used. 

308 param_names = list(params) if isinstance(params, Mapping) else None 

309 query = self.convert_query(query, param_names=param_names) 

310 return super().execute(query, params) 

311 

312 def executemany(self, query, param_list): 

313 # Extract names if params is a mapping, i.e. "pyformat" style is used. 

314 # Peek carefully as a generator can be passed instead of a list/tuple. 

315 peekable, param_list = tee(iter(param_list)) 

316 if (params := next(peekable, None)) and isinstance(params, Mapping): 

317 param_names = list(params) 

318 else: 

319 param_names = None 

320 query = self.convert_query(query, param_names=param_names) 

321 return super().executemany(query, param_list) 

322 

323 def convert_query(self, query, *, param_names=None): 

324 if param_names is None: 

325 # Convert from "format" style to "qmark" style. 

326 return FORMAT_QMARK_REGEX.sub("?", query).replace("%%", "%") 

327 else: 

328 # Convert from "pyformat" style to "named" style. 

329 return query % {name: f":{name}" for name in param_names}