Coverage for gws-app/gws/lib/sqlitex/__init__.py: 99%

70 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""Convenience wrapper for the SQLite driver. 

2 

3The ``Object`` class accepts a database path and optionally an "init" DDL script. 

4It executes queries given in a text form, with named parameters (``:name``). 

5 

6Each query runs on its own connection in autocommit mode, which is closed immediately afterwards. 

7 

8If a query fails with "no such table", the wrapper runs the "init" script and repeats the query once. 

9The script can contain multiple statements. 

10 

11A query that fails with a recoverable error (e.g. the database is locked) is repeated on a new connection, 

12up to ``MAX_ATTEMPTS`` times. Other errors are raised as ``Error``. 

13 

14Example:: 

15 

16 import gws.lib.sqlitex 

17 

18 db = gws.lib.sqlitex.Object( 

19 '/data/jobs.sqlite', 

20 init_ddl='CREATE TABLE jobs (uid TEXT PRIMARY KEY, state TEXT)', 

21 ) 

22 db.insert('jobs', {'uid': 'a1', 'state': 'open'}) 

23 db.update('jobs', {'state': 'done'}, 'a1') 

24 rows = db.select('SELECT * FROM jobs WHERE state=:state', state='done') 

25""" 

26 

27import sqlite3 

28 

29import gws 

30 

31BUSY_TIMEOUT = 5.0 

32"""Time in seconds to wait for a lock before giving up.""" 

33 

34MAX_ATTEMPTS = 3 

35"""How many times to repeat a query that failed with a recoverable error.""" 

36 

37SLEEP_TIME = 0.1 

38"""Time in seconds to wait between the attempts.""" 

39 

40_RECOVERABLE_ERRORS = { 

41 'SQLITE_BUSY', 

42 'SQLITE_CANTOPEN', 

43 'SQLITE_LOCKED', 

44 'SQLITE_PROTOCOL', 

45} 

46"""Errors worth repeating, matched against the leading part of the sqlite error name.""" 

47 

48 

49class Error(gws.Error): 

50 """Raised when a query fails.""" 

51 

52 pass 

53 

54 

55class Object: 

56 """SQLite database wrapper.""" 

57 

58 def __init__(self, db_path: str, init_ddl: str = '', uid_column: str = 'uid'): 

59 """Create a database wrapper. 

60 

61 Args: 

62 db_path: Path to the database file. 

63 init_ddl: DDL script to run when a table does not exist. 

64 uid_column: Name of the primary key column, used by ``update`` and ``delete``. 

65 """ 

66 self.dbPath = db_path 

67 self.initDDL = init_ddl 

68 self.uidName = uid_column 

69 

70 def execute(self, stmt: str, **params): 

71 """Execute a text statement that does not return rows. 

72 

73 Args: 

74 stmt: SQL statement. 

75 **params: Values for the named parameters of the statement. 

76 

77 Raises: 

78 ``Error``: If the statement fails. 

79 """ 

80 

81 self._exec2(False, stmt, params) 

82 

83 def select(self, stmt: str, **params) -> list[dict]: 

84 """Execute a text select statement. 

85 

86 Args: 

87 stmt: SQL statement. 

88 **params: Values for the named parameters of the statement. 

89 

90 Returns: 

91 Result rows as dicts. 

92 

93 Raises: 

94 ``Error``: If the statement fails. 

95 """ 

96 

97 return self._exec2(True, stmt, params) 

98 

99 def insert(self, table_name: str, rec: dict, on_conflict: str = ''): 

100 """Insert a record into a table. 

101 

102 Args: 

103 table_name: Table name. 

104 rec: Record as a dict of column names and values. 

105 on_conflict: What to do if the record conflicts with an existing primary or unique key: 

106 empty to fail, ``ignore`` to skip the record, ``update`` to update the existing record 

107 with the given values (the conflict is detected on the primary key column). 

108 

109 Raises: 

110 ``Error``: If the statement fails, or ``on_conflict`` is invalid. 

111 """ 

112 

113 keys = ','.join(rec) 

114 vals = ','.join(':' + k for k in rec) 

115 

116 if not on_conflict: 

117 clause = '' 

118 elif on_conflict == 'ignore': 

119 clause = ' ON CONFLICT DO NOTHING' 

120 elif on_conflict == 'update': 

121 sets = ','.join(f'{k}=excluded.{k}' for k in rec if k != self.uidName) 

122 clause = f' ON CONFLICT({self.uidName}) DO UPDATE SET {sets}' 

123 else: 

124 raise Error(f'sqlitex: invalid {on_conflict=}') 

125 

126 self._exec2(False, f'INSERT INTO {table_name} ({keys}) VALUES({vals}){clause}', rec) 

127 

128 def update(self, table_name: str, rec: dict, uid): 

129 """Update a record in a table. 

130 

131 Args: 

132 table_name: Table name. 

133 rec: Columns to update, as a dict of column names and values. 

134 uid: Value of the primary key column of the record. 

135 

136 Raises: 

137 ``Error``: If the statement fails. 

138 """ 

139 

140 vals = ','.join(f'{k}=:{k}' for k in rec) 

141 self._exec2( 

142 False, 

143 f'UPDATE {table_name} SET {vals} WHERE {self.uidName}=:__uid', 

144 {'__uid': uid, **rec}, 

145 ) 

146 

147 def delete(self, table_name: str, uid): 

148 """Delete a record from a table. 

149 

150 Args: 

151 table_name: Table name. 

152 uid: Value of the primary key column of the record. 

153 

154 Raises: 

155 ``Error``: If the statement fails. 

156 """ 

157 

158 self._exec2( 

159 False, 

160 f'DELETE FROM {table_name} WHERE {self.uidName}=:__uid', 

161 {'__uid': uid}, 

162 ) 

163 

164 ## 

165 

166 def _exec2(self, is_select, stmt, params): 

167 attempt = 0 

168 

169 while True: 

170 attempt += 1 

171 try: 

172 return self._exec3(is_select, stmt, params) 

173 except sqlite3.Error as exc: 

174 gws.log.warning(f'sqlitex: {self.dbPath}: {exc}, sql={" ".join(stmt.split())}') 

175 name = getattr(exc, 'sqlite_errorname', '') 

176 if not any(name.startswith(e) for e in _RECOVERABLE_ERRORS) or attempt >= MAX_ATTEMPTS: 

177 raise Error(f'sqlitex: {self.dbPath}: {exc}') from exc 

178 gws.u.sleep(SLEEP_TIME) 

179 

180 def _exec3(self, is_select, stmt, params): 

181 conn = None 

182 

183 try: 

184 conn = sqlite3.connect(self.dbPath, timeout=BUSY_TIMEOUT, isolation_level=None) 

185 conn.row_factory = sqlite3.Row 

186 try: 

187 return self._exec4(conn, is_select, stmt, params) 

188 except sqlite3.OperationalError as exc: 

189 if not self.initDDL or 'no such table' not in str(exc): 

190 raise 

191 gws.log.warning(f'sqlitex: {self.dbPath}: {exc}, running init...') 

192 conn.executescript(self.initDDL) 

193 return self._exec4(conn, is_select, stmt, params) 

194 finally: 

195 if conn: 

196 conn.close() 

197 

198 def _exec4(self, conn, is_select, stmt, params): 

199 cur = conn.execute(stmt, params) 

200 if is_select: 

201 return [dict(r) for r in cur] 

202 return []