databased.databased

  1import logging
  2import sqlite3
  3from typing import Any
  4
  5from griddle import griddy
  6from pathier import Pathier, Pathish
  7
  8
  9def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict:
 10    fields = [column[0] for column in cursor.description]
 11    return {column: value for column, value in zip(fields, row)}
 12
 13
 14class Databased:
 15    """SQLite3 wrapper."""
 16
 17    def __init__(
 18        self,
 19        dbpath: Pathish = "db.sqlite3",
 20        connection_timeout: float = 10,
 21        detect_types: bool = True,
 22        enforce_foreign_keys: bool = True,
 23        commit_on_close: bool = True,
 24        logger_encoding: str = "utf-8",
 25        logger_message_format: str = "{levelname}|-|{asctime}|-|{message}",
 26    ):
 27        """ """
 28        self.path = dbpath
 29        self.connection_timeout = connection_timeout
 30        self.connection = None
 31        self._logger_init(logger_message_format, logger_encoding)
 32        self.detect_types = detect_types
 33        self.commit_on_close = commit_on_close
 34        self.enforce_foreign_keys = enforce_foreign_keys
 35
 36    def __enter__(self):
 37        self.connect()
 38        return self
 39
 40    def __exit__(self, *args, **kwargs):
 41        self.close()
 42
 43    @property
 44    def commit_on_close(self) -> bool:
 45        """Should commit database before closing connection when `self.close()` is called."""
 46        return self._commit_on_close
 47
 48    @commit_on_close.setter
 49    def commit_on_close(self, should_commit_on_close: bool):
 50        self._commit_on_close = should_commit_on_close
 51
 52    @property
 53    def connected(self) -> bool:
 54        """Whether this `Databased` instance is connected to the database file or not."""
 55        return self.connection is not None
 56
 57    @property
 58    def connection_timeout(self) -> float:
 59        """Changes to this property won't take effect until the current connection, if open, is closed and a new connection opened."""
 60        return self._connection_timeout
 61
 62    @connection_timeout.setter
 63    def connection_timeout(self, timeout: float):
 64        self._connection_timeout = timeout
 65
 66    @property
 67    def detect_types(self) -> bool:
 68        """Should use `detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES` when establishing a database connection.
 69
 70        Changes to this property won't take effect until the current connection, if open, is closed and a new connection opened.
 71        """
 72        return self._detect_types
 73
 74    @detect_types.setter
 75    def detect_types(self, should_detect: bool):
 76        self._detect_types = should_detect
 77
 78    @property
 79    def enforce_foreign_keys(self) -> bool:
 80        return self._enforce_foreign_keys
 81
 82    @enforce_foreign_keys.setter
 83    def enforce_foreign_keys(self, should_enforce: bool):
 84        self._enforce_foreign_keys = should_enforce
 85        self._set_foreign_key_enforcement()
 86
 87    @property
 88    def name(self) -> str:
 89        """The name of this database."""
 90        return self.path.stem
 91
 92    @property
 93    def path(self) -> Pathier:
 94        """The path to this database file."""
 95        return self._path
 96
 97    @path.setter
 98    def path(self, new_path: Pathish):
 99        """If `new_path` doesn't exist, it will be created (including parent folders)."""
100        self._path = Pathier(new_path)
101        if not self.path.exists():
102            self.path.touch()
103
104    @property
105    def tables(self) -> list[str]:
106        """List of table names for this database."""
107        return [
108            table["name"]
109            for table in self.query(
110                "SELECT name FROM sqlite_Schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%';"
111            )
112        ]
113
114    def _logger_init(self, message_format: str, encoding: str):
115        """:param: `message_format`: `{` style format string."""
116        self.logger = logging.getLogger(self.name)
117        if not self.logger.hasHandlers():
118            handler = logging.FileHandler(
119                str(self.path).replace(".", "") + ".log", encoding=encoding
120            )
121            handler.setFormatter(
122                logging.Formatter(
123                    message_format, style="{", datefmt="%m/%d/%Y %I:%M:%S %p"
124                )
125            )
126            self.logger.addHandler(handler)
127            self.logger.setLevel(logging.INFO)
128
129    def _set_foreign_key_enforcement(self):
130        if self.connection:
131            self.connection.execute(
132                f"pragma foreign_keys = {int(self.enforce_foreign_keys)};"
133            )
134
135    def add_column(self, table: str, column_def: str):
136        """Add a column to `table`.
137
138        `column_def` should be in the form `{column_name} {type_name} {constraint}`.
139
140        i.e.
141        >>> db = Databased()
142        >>> db.add_column("rides", "num_stops INTEGER NOT NULL DEFAULT 0")"""
143        self.query(f"ALTER TABLE {table} ADD {column_def};")
144
145    def close(self):
146        """Disconnect from the database.
147
148        Does not call `commit()` for you unless the `commit_on_close` property is set to `True`.
149        """
150        if self.connection:
151            if self.commit_on_close:
152                self.commit()
153            self.connection.close()
154            self.connection = None
155
156    def commit(self):
157        """Commit state of database."""
158        if self.connection:
159            self.connection.commit()
160            self.logger.info("Committed successfully.")
161        else:
162            raise RuntimeError(
163                "Databased.commit(): Can't commit db with no open connection."
164            )
165
166    def connect(self):
167        """Connect to the database."""
168        self.connection = sqlite3.connect(
169            self.path,
170            detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
171            if self.detect_types
172            else 0,
173            timeout=self.connection_timeout,
174        )
175        self._set_foreign_key_enforcement()
176        self.connection.row_factory = dict_factory
177
178    def count(
179        self,
180        table: str,
181        column: str = "*",
182        where: str | None = None,
183        distinct: bool = False,
184    ) -> int:
185        """Return number of matching rows in `table` table.
186
187        Equivalent to:
188        >>> SELECT COUNT({distinct} {column}) FROM {table} {where};"""
189        query = (
190            f"SELECT COUNT( {('DISTINCT' if distinct else '')} {column}) FROM {table}"
191        )
192        if where:
193            query += f" WHERE {where}"
194        query += ";"
195        return int(list(self.query(query)[0].values())[0])
196
197    def create_table(self, table: str, *column_defs: str):
198        """Create a table if it doesn't exist.
199
200        #### :params:
201
202        `table`: Name of the table to create.
203
204        `column_defs`: Any number of column names and their definitions in proper Sqlite3 sytax.
205        i.e. `"column_name TEXT UNIQUE"` or `"column_name INTEGER PRIMARY KEY"` etc."""
206        columns = ", ".join(column_defs)
207        result = self.query(f"CREATE TABLE IF NOT EXISTS {table} ({columns});")
208        self.logger.info(f"'{table}' table created.")
209
210    def delete(self, table: str, where: str | None = None) -> int:
211        """Delete rows from `table` that satisfy the given `where` clause.
212
213        If `where` is `None`, all rows will be deleted.
214
215        Returns the number of deleted rows.
216
217        e.g.
218        >>> db = Databased()
219        >>> db.delete("rides", "distance < 5 AND average_speed < 7")"""
220        try:
221            if where:
222                self.query(f"DELETE FROM {table} WHERE {where};")
223            else:
224                self.query(f"DELETE FROM {table};")
225            row_count = self.cursor.rowcount
226            self.logger.info(
227                f"Deleted {row_count} rows from '{table}' where '{where}'."
228            )
229            return row_count
230        except Exception as e:
231            self.logger.exception(
232                f"Error deleting rows from '{table}' where '{where}'."
233            )
234            raise e
235
236    def describe(self, table: str) -> list[dict]:
237        """Returns information about `table`."""
238        return self.query(f"pragma table_info('{table}');")
239
240    def drop_column(self, table: str, column: str):
241        """Drop `column` from `table`."""
242        self.query(f"ALTER TABLE {table} DROP {column};")
243
244    def drop_table(self, table: str) -> bool:
245        """Drop `table` from the database.
246
247        Returns `True` if successful, `False` if not."""
248        try:
249            self.query(f"DROP TABLE {table};")
250            self.logger.info(f"Dropped table '{table}'.")
251            return True
252        except Exception as e:
253            print(f"{type(e).__name__}: {e}")
254            self.logger.error(f"Failed to drop table '{table}'.")
255            return False
256
257    def execute_script(self, path: Pathish, encoding: str = "utf-8") -> sqlite3.Cursor:
258        """Execute sql script located at `path`."""
259        if not self.connected:
260            self.connect()
261        assert self.connection
262        return self.connection.executescript(Pathier(path).read_text(encoding))
263
264    def get_columns(self, table: str) -> list[str]:
265        """Returns a list of column names in `table`."""
266        return [
267            column["name"] for column in self.query(f"pragma table_info('{table}');")
268        ]
269
270    def insert(
271        self, table: str, columns: tuple[str, ...], values: list[tuple[Any, ...]]
272    ) -> int:
273        """Insert rows of `values` into `columns` of `table`.
274
275        Each `tuple` in `values` corresponds to an individual row that is to be inserted.
276        """
277        max_row_count = 900
278        column_list = "(" + ", ".join(columns) + ")"
279        row_count = 0
280        for i in range(0, len(values), max_row_count):
281            chunk = values[i : i + max_row_count]
282            placeholder = (
283                "(" + "),(".join((", ".join(("?" for _ in row)) for row in chunk)) + ")"
284            )
285            logger_values = "\n".join(
286                (
287                    "'(" + ", ".join((str(value) for value in row)) + ")'"
288                    for row in chunk
289                )
290            )
291            flattened_values = tuple((value for row in chunk for value in row))
292            try:
293                self.query(
294                    f"INSERT INTO {table} {column_list} VALUES {placeholder};",
295                    flattened_values,
296                )
297                self.logger.info(
298                    f"Inserted into '{column_list}' columns of '{table}' table values \n{logger_values}."
299                )
300                row_count += self.cursor.rowcount
301            except Exception as e:
302                self.logger.exception(
303                    f"Error inserting into '{column_list}' columns of '{table}' table values \n{logger_values}."
304                )
305                raise e
306        return row_count
307
308    def query(self, query_: str, parameters: tuple[Any, ...] = tuple()) -> list[dict]:
309        """Execute an SQL query and return the results.
310
311        Ensures that the database connection is opened before executing the command.
312
313        The cursor used to execute the query will be available through `self.cursor` until the next time `self.query()` is called.
314        """
315        if not self.connected:
316            self.connect()
317        assert self.connection
318        self.cursor = self.connection.cursor()
319        self.cursor.execute(query_, parameters)
320        return self.cursor.fetchall()
321
322    def rename_column(self, table: str, column_to_rename: str, new_column_name: str):
323        """Rename a column in `table`."""
324        self.query(
325            f"ALTER TABLE {table} RENAME {column_to_rename} TO {new_column_name};"
326        )
327
328    def rename_table(self, table_to_rename: str, new_table_name: str):
329        """Rename a table."""
330        self.query(f"ALTER TABLE {table_to_rename} RENAME TO {new_table_name};")
331
332    def select(
333        self,
334        table: str,
335        columns: list[str] = ["*"],
336        joins: list[str] | None = None,
337        where: str | None = None,
338        group_by: str | None = None,
339        having: str | None = None,
340        order_by: str | None = None,
341        limit: int | str | None = None,
342    ) -> list[dict]:
343        """Return rows for given criteria.
344
345        For complex queries, use the `databased.query()` method.
346
347        Parameters `where`, `group_by`, `having`, `order_by`, and `limit` should not have
348        their corresponding key word in their string, but should otherwise be valid SQL.
349
350        `joins` should contain their key word (`INNER JOIN`, `LEFT JOIN`) in addition to the rest of the sub-statement.
351
352        >>> Databased().select(
353            "bike_rides",
354            "id, date, distance, moving_time, AVG(distance/moving_time) as average_speed",
355            where="distance > 20",
356            order_by="distance",
357            desc=True,
358            limit=10
359            )
360        executes the query:
361        >>> SELECT
362                id, date, distance, moving_time, AVG(distance/moving_time) as average_speed
363            FROM
364                bike_rides
365            WHERE
366                distance > 20
367            ORDER BY
368                distance DESC
369            Limit 10;"""
370        query = f"SELECT {', '.join(columns)} FROM {table}"
371        if joins:
372            query += f" {' '.join(joins)}"
373        if where:
374            query += f" WHERE {where}"
375        if group_by:
376            query += f" GROUP BY {group_by}"
377        if having:
378            query += f" HAVING {having}"
379        if order_by:
380            query += f" ORDER BY {order_by}"
381        if limit:
382            query += f" LIMIT {limit}"
383        query += ";"
384        rows = self.query(query)
385        return rows
386
387    @staticmethod
388    def to_grid(data: list[dict], shrink_to_terminal: bool = True) -> str:
389        """Returns a tabular grid from `data`.
390
391        If `shrink_to_terminal` is `True`, the column widths of the grid will be reduced to fit within the current terminal.
392        """
393        return griddy(data, "keys", shrink_to_terminal)
394
395    def update(
396        self, table: str, column: str, value: Any, where: str | None = None
397    ) -> int:
398        """Update `column` of `table` to `value` for rows satisfying the conditions in `where`.
399
400        If `where` is `None` all rows will be updated.
401
402        Returns the number of updated rows.
403
404        e.g.
405        >>> db = Databased()
406        >>> db.update("rides", "elevation", 100, "elevation < 100")"""
407        try:
408            if where:
409                self.query(f"UPDATE {table} SET {column} = ? WHERE {where};", (value,))
410            else:
411                self.query(f"UPDATE {table} SET {column} = ?;", (value,))
412            row_count = self.cursor.rowcount
413            self.logger.info(
414                f"Updated {row_count} rows in '{table}' table to '{column}' = '{value}' where '{where}'."
415            )
416            return row_count
417        except Exception as e:
418            self.logger.exception(
419                f"Failed to update rows in '{table}' table to '{column}' = '{value}' where '{where}'."
420            )
421            raise e
422
423    def vacuum(self) -> int:
424        """Reduce disk size of database after row/table deletion.
425
426        Returns space freed up in bytes."""
427        size = self.path.size
428        self.query("VACUUM;")
429        return size - self.path.size
def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict:
10def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict:
11    fields = [column[0] for column in cursor.description]
12    return {column: value for column, value in zip(fields, row)}
class Databased:
 15class Databased:
 16    """SQLite3 wrapper."""
 17
 18    def __init__(
 19        self,
 20        dbpath: Pathish = "db.sqlite3",
 21        connection_timeout: float = 10,
 22        detect_types: bool = True,
 23        enforce_foreign_keys: bool = True,
 24        commit_on_close: bool = True,
 25        logger_encoding: str = "utf-8",
 26        logger_message_format: str = "{levelname}|-|{asctime}|-|{message}",
 27    ):
 28        """ """
 29        self.path = dbpath
 30        self.connection_timeout = connection_timeout
 31        self.connection = None
 32        self._logger_init(logger_message_format, logger_encoding)
 33        self.detect_types = detect_types
 34        self.commit_on_close = commit_on_close
 35        self.enforce_foreign_keys = enforce_foreign_keys
 36
 37    def __enter__(self):
 38        self.connect()
 39        return self
 40
 41    def __exit__(self, *args, **kwargs):
 42        self.close()
 43
 44    @property
 45    def commit_on_close(self) -> bool:
 46        """Should commit database before closing connection when `self.close()` is called."""
 47        return self._commit_on_close
 48
 49    @commit_on_close.setter
 50    def commit_on_close(self, should_commit_on_close: bool):
 51        self._commit_on_close = should_commit_on_close
 52
 53    @property
 54    def connected(self) -> bool:
 55        """Whether this `Databased` instance is connected to the database file or not."""
 56        return self.connection is not None
 57
 58    @property
 59    def connection_timeout(self) -> float:
 60        """Changes to this property won't take effect until the current connection, if open, is closed and a new connection opened."""
 61        return self._connection_timeout
 62
 63    @connection_timeout.setter
 64    def connection_timeout(self, timeout: float):
 65        self._connection_timeout = timeout
 66
 67    @property
 68    def detect_types(self) -> bool:
 69        """Should use `detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES` when establishing a database connection.
 70
 71        Changes to this property won't take effect until the current connection, if open, is closed and a new connection opened.
 72        """
 73        return self._detect_types
 74
 75    @detect_types.setter
 76    def detect_types(self, should_detect: bool):
 77        self._detect_types = should_detect
 78
 79    @property
 80    def enforce_foreign_keys(self) -> bool:
 81        return self._enforce_foreign_keys
 82
 83    @enforce_foreign_keys.setter
 84    def enforce_foreign_keys(self, should_enforce: bool):
 85        self._enforce_foreign_keys = should_enforce
 86        self._set_foreign_key_enforcement()
 87
 88    @property
 89    def name(self) -> str:
 90        """The name of this database."""
 91        return self.path.stem
 92
 93    @property
 94    def path(self) -> Pathier:
 95        """The path to this database file."""
 96        return self._path
 97
 98    @path.setter
 99    def path(self, new_path: Pathish):
100        """If `new_path` doesn't exist, it will be created (including parent folders)."""
101        self._path = Pathier(new_path)
102        if not self.path.exists():
103            self.path.touch()
104
105    @property
106    def tables(self) -> list[str]:
107        """List of table names for this database."""
108        return [
109            table["name"]
110            for table in self.query(
111                "SELECT name FROM sqlite_Schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%';"
112            )
113        ]
114
115    def _logger_init(self, message_format: str, encoding: str):
116        """:param: `message_format`: `{` style format string."""
117        self.logger = logging.getLogger(self.name)
118        if not self.logger.hasHandlers():
119            handler = logging.FileHandler(
120                str(self.path).replace(".", "") + ".log", encoding=encoding
121            )
122            handler.setFormatter(
123                logging.Formatter(
124                    message_format, style="{", datefmt="%m/%d/%Y %I:%M:%S %p"
125                )
126            )
127            self.logger.addHandler(handler)
128            self.logger.setLevel(logging.INFO)
129
130    def _set_foreign_key_enforcement(self):
131        if self.connection:
132            self.connection.execute(
133                f"pragma foreign_keys = {int(self.enforce_foreign_keys)};"
134            )
135
136    def add_column(self, table: str, column_def: str):
137        """Add a column to `table`.
138
139        `column_def` should be in the form `{column_name} {type_name} {constraint}`.
140
141        i.e.
142        >>> db = Databased()
143        >>> db.add_column("rides", "num_stops INTEGER NOT NULL DEFAULT 0")"""
144        self.query(f"ALTER TABLE {table} ADD {column_def};")
145
146    def close(self):
147        """Disconnect from the database.
148
149        Does not call `commit()` for you unless the `commit_on_close` property is set to `True`.
150        """
151        if self.connection:
152            if self.commit_on_close:
153                self.commit()
154            self.connection.close()
155            self.connection = None
156
157    def commit(self):
158        """Commit state of database."""
159        if self.connection:
160            self.connection.commit()
161            self.logger.info("Committed successfully.")
162        else:
163            raise RuntimeError(
164                "Databased.commit(): Can't commit db with no open connection."
165            )
166
167    def connect(self):
168        """Connect to the database."""
169        self.connection = sqlite3.connect(
170            self.path,
171            detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
172            if self.detect_types
173            else 0,
174            timeout=self.connection_timeout,
175        )
176        self._set_foreign_key_enforcement()
177        self.connection.row_factory = dict_factory
178
179    def count(
180        self,
181        table: str,
182        column: str = "*",
183        where: str | None = None,
184        distinct: bool = False,
185    ) -> int:
186        """Return number of matching rows in `table` table.
187
188        Equivalent to:
189        >>> SELECT COUNT({distinct} {column}) FROM {table} {where};"""
190        query = (
191            f"SELECT COUNT( {('DISTINCT' if distinct else '')} {column}) FROM {table}"
192        )
193        if where:
194            query += f" WHERE {where}"
195        query += ";"
196        return int(list(self.query(query)[0].values())[0])
197
198    def create_table(self, table: str, *column_defs: str):
199        """Create a table if it doesn't exist.
200
201        #### :params:
202
203        `table`: Name of the table to create.
204
205        `column_defs`: Any number of column names and their definitions in proper Sqlite3 sytax.
206        i.e. `"column_name TEXT UNIQUE"` or `"column_name INTEGER PRIMARY KEY"` etc."""
207        columns = ", ".join(column_defs)
208        result = self.query(f"CREATE TABLE IF NOT EXISTS {table} ({columns});")
209        self.logger.info(f"'{table}' table created.")
210
211    def delete(self, table: str, where: str | None = None) -> int:
212        """Delete rows from `table` that satisfy the given `where` clause.
213
214        If `where` is `None`, all rows will be deleted.
215
216        Returns the number of deleted rows.
217
218        e.g.
219        >>> db = Databased()
220        >>> db.delete("rides", "distance < 5 AND average_speed < 7")"""
221        try:
222            if where:
223                self.query(f"DELETE FROM {table} WHERE {where};")
224            else:
225                self.query(f"DELETE FROM {table};")
226            row_count = self.cursor.rowcount
227            self.logger.info(
228                f"Deleted {row_count} rows from '{table}' where '{where}'."
229            )
230            return row_count
231        except Exception as e:
232            self.logger.exception(
233                f"Error deleting rows from '{table}' where '{where}'."
234            )
235            raise e
236
237    def describe(self, table: str) -> list[dict]:
238        """Returns information about `table`."""
239        return self.query(f"pragma table_info('{table}');")
240
241    def drop_column(self, table: str, column: str):
242        """Drop `column` from `table`."""
243        self.query(f"ALTER TABLE {table} DROP {column};")
244
245    def drop_table(self, table: str) -> bool:
246        """Drop `table` from the database.
247
248        Returns `True` if successful, `False` if not."""
249        try:
250            self.query(f"DROP TABLE {table};")
251            self.logger.info(f"Dropped table '{table}'.")
252            return True
253        except Exception as e:
254            print(f"{type(e).__name__}: {e}")
255            self.logger.error(f"Failed to drop table '{table}'.")
256            return False
257
258    def execute_script(self, path: Pathish, encoding: str = "utf-8") -> sqlite3.Cursor:
259        """Execute sql script located at `path`."""
260        if not self.connected:
261            self.connect()
262        assert self.connection
263        return self.connection.executescript(Pathier(path).read_text(encoding))
264
265    def get_columns(self, table: str) -> list[str]:
266        """Returns a list of column names in `table`."""
267        return [
268            column["name"] for column in self.query(f"pragma table_info('{table}');")
269        ]
270
271    def insert(
272        self, table: str, columns: tuple[str, ...], values: list[tuple[Any, ...]]
273    ) -> int:
274        """Insert rows of `values` into `columns` of `table`.
275
276        Each `tuple` in `values` corresponds to an individual row that is to be inserted.
277        """
278        max_row_count = 900
279        column_list = "(" + ", ".join(columns) + ")"
280        row_count = 0
281        for i in range(0, len(values), max_row_count):
282            chunk = values[i : i + max_row_count]
283            placeholder = (
284                "(" + "),(".join((", ".join(("?" for _ in row)) for row in chunk)) + ")"
285            )
286            logger_values = "\n".join(
287                (
288                    "'(" + ", ".join((str(value) for value in row)) + ")'"
289                    for row in chunk
290                )
291            )
292            flattened_values = tuple((value for row in chunk for value in row))
293            try:
294                self.query(
295                    f"INSERT INTO {table} {column_list} VALUES {placeholder};",
296                    flattened_values,
297                )
298                self.logger.info(
299                    f"Inserted into '{column_list}' columns of '{table}' table values \n{logger_values}."
300                )
301                row_count += self.cursor.rowcount
302            except Exception as e:
303                self.logger.exception(
304                    f"Error inserting into '{column_list}' columns of '{table}' table values \n{logger_values}."
305                )
306                raise e
307        return row_count
308
309    def query(self, query_: str, parameters: tuple[Any, ...] = tuple()) -> list[dict]:
310        """Execute an SQL query and return the results.
311
312        Ensures that the database connection is opened before executing the command.
313
314        The cursor used to execute the query will be available through `self.cursor` until the next time `self.query()` is called.
315        """
316        if not self.connected:
317            self.connect()
318        assert self.connection
319        self.cursor = self.connection.cursor()
320        self.cursor.execute(query_, parameters)
321        return self.cursor.fetchall()
322
323    def rename_column(self, table: str, column_to_rename: str, new_column_name: str):
324        """Rename a column in `table`."""
325        self.query(
326            f"ALTER TABLE {table} RENAME {column_to_rename} TO {new_column_name};"
327        )
328
329    def rename_table(self, table_to_rename: str, new_table_name: str):
330        """Rename a table."""
331        self.query(f"ALTER TABLE {table_to_rename} RENAME TO {new_table_name};")
332
333    def select(
334        self,
335        table: str,
336        columns: list[str] = ["*"],
337        joins: list[str] | None = None,
338        where: str | None = None,
339        group_by: str | None = None,
340        having: str | None = None,
341        order_by: str | None = None,
342        limit: int | str | None = None,
343    ) -> list[dict]:
344        """Return rows for given criteria.
345
346        For complex queries, use the `databased.query()` method.
347
348        Parameters `where`, `group_by`, `having`, `order_by`, and `limit` should not have
349        their corresponding key word in their string, but should otherwise be valid SQL.
350
351        `joins` should contain their key word (`INNER JOIN`, `LEFT JOIN`) in addition to the rest of the sub-statement.
352
353        >>> Databased().select(
354            "bike_rides",
355            "id, date, distance, moving_time, AVG(distance/moving_time) as average_speed",
356            where="distance > 20",
357            order_by="distance",
358            desc=True,
359            limit=10
360            )
361        executes the query:
362        >>> SELECT
363                id, date, distance, moving_time, AVG(distance/moving_time) as average_speed
364            FROM
365                bike_rides
366            WHERE
367                distance > 20
368            ORDER BY
369                distance DESC
370            Limit 10;"""
371        query = f"SELECT {', '.join(columns)} FROM {table}"
372        if joins:
373            query += f" {' '.join(joins)}"
374        if where:
375            query += f" WHERE {where}"
376        if group_by:
377            query += f" GROUP BY {group_by}"
378        if having:
379            query += f" HAVING {having}"
380        if order_by:
381            query += f" ORDER BY {order_by}"
382        if limit:
383            query += f" LIMIT {limit}"
384        query += ";"
385        rows = self.query(query)
386        return rows
387
388    @staticmethod
389    def to_grid(data: list[dict], shrink_to_terminal: bool = True) -> str:
390        """Returns a tabular grid from `data`.
391
392        If `shrink_to_terminal` is `True`, the column widths of the grid will be reduced to fit within the current terminal.
393        """
394        return griddy(data, "keys", shrink_to_terminal)
395
396    def update(
397        self, table: str, column: str, value: Any, where: str | None = None
398    ) -> int:
399        """Update `column` of `table` to `value` for rows satisfying the conditions in `where`.
400
401        If `where` is `None` all rows will be updated.
402
403        Returns the number of updated rows.
404
405        e.g.
406        >>> db = Databased()
407        >>> db.update("rides", "elevation", 100, "elevation < 100")"""
408        try:
409            if where:
410                self.query(f"UPDATE {table} SET {column} = ? WHERE {where};", (value,))
411            else:
412                self.query(f"UPDATE {table} SET {column} = ?;", (value,))
413            row_count = self.cursor.rowcount
414            self.logger.info(
415                f"Updated {row_count} rows in '{table}' table to '{column}' = '{value}' where '{where}'."
416            )
417            return row_count
418        except Exception as e:
419            self.logger.exception(
420                f"Failed to update rows in '{table}' table to '{column}' = '{value}' where '{where}'."
421            )
422            raise e
423
424    def vacuum(self) -> int:
425        """Reduce disk size of database after row/table deletion.
426
427        Returns space freed up in bytes."""
428        size = self.path.size
429        self.query("VACUUM;")
430        return size - self.path.size

SQLite3 wrapper.

Databased( dbpath: pathier.pathier.Pathier | pathlib.Path | str = 'db.sqlite3', connection_timeout: float = 10, detect_types: bool = True, enforce_foreign_keys: bool = True, commit_on_close: bool = True, logger_encoding: str = 'utf-8', logger_message_format: str = '{levelname}|-|{asctime}|-|{message}')
18    def __init__(
19        self,
20        dbpath: Pathish = "db.sqlite3",
21        connection_timeout: float = 10,
22        detect_types: bool = True,
23        enforce_foreign_keys: bool = True,
24        commit_on_close: bool = True,
25        logger_encoding: str = "utf-8",
26        logger_message_format: str = "{levelname}|-|{asctime}|-|{message}",
27    ):
28        """ """
29        self.path = dbpath
30        self.connection_timeout = connection_timeout
31        self.connection = None
32        self._logger_init(logger_message_format, logger_encoding)
33        self.detect_types = detect_types
34        self.commit_on_close = commit_on_close
35        self.enforce_foreign_keys = enforce_foreign_keys
path: pathier.pathier.Pathier

If new_path doesn't exist, it will be created (including parent folders).

connection_timeout: float

Changes to this property won't take effect until the current connection, if open, is closed and a new connection opened.

detect_types: bool

Should use detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES when establishing a database connection.

Changes to this property won't take effect until the current connection, if open, is closed and a new connection opened.

commit_on_close: bool

Should commit database before closing connection when self.close() is called.

connected: bool

Whether this Databased instance is connected to the database file or not.

name: str

The name of this database.

tables: list[str]

List of table names for this database.

def add_column(self, table: str, column_def: str):
136    def add_column(self, table: str, column_def: str):
137        """Add a column to `table`.
138
139        `column_def` should be in the form `{column_name} {type_name} {constraint}`.
140
141        i.e.
142        >>> db = Databased()
143        >>> db.add_column("rides", "num_stops INTEGER NOT NULL DEFAULT 0")"""
144        self.query(f"ALTER TABLE {table} ADD {column_def};")

Add a column to table.

column_def should be in the form {column_name} {type_name} {constraint}.

i.e.

>>> db = Databased()
>>> db.add_column("rides", "num_stops INTEGER NOT NULL DEFAULT 0")
def close(self):
146    def close(self):
147        """Disconnect from the database.
148
149        Does not call `commit()` for you unless the `commit_on_close` property is set to `True`.
150        """
151        if self.connection:
152            if self.commit_on_close:
153                self.commit()
154            self.connection.close()
155            self.connection = None

Disconnect from the database.

Does not call commit() for you unless the commit_on_close property is set to True.

def commit(self):
157    def commit(self):
158        """Commit state of database."""
159        if self.connection:
160            self.connection.commit()
161            self.logger.info("Committed successfully.")
162        else:
163            raise RuntimeError(
164                "Databased.commit(): Can't commit db with no open connection."
165            )

Commit state of database.

def connect(self):
167    def connect(self):
168        """Connect to the database."""
169        self.connection = sqlite3.connect(
170            self.path,
171            detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
172            if self.detect_types
173            else 0,
174            timeout=self.connection_timeout,
175        )
176        self._set_foreign_key_enforcement()
177        self.connection.row_factory = dict_factory

Connect to the database.

def count( self, table: str, column: str = '*', where: str | None = None, distinct: bool = False) -> int:
179    def count(
180        self,
181        table: str,
182        column: str = "*",
183        where: str | None = None,
184        distinct: bool = False,
185    ) -> int:
186        """Return number of matching rows in `table` table.
187
188        Equivalent to:
189        >>> SELECT COUNT({distinct} {column}) FROM {table} {where};"""
190        query = (
191            f"SELECT COUNT( {('DISTINCT' if distinct else '')} {column}) FROM {table}"
192        )
193        if where:
194            query += f" WHERE {where}"
195        query += ";"
196        return int(list(self.query(query)[0].values())[0])

Return number of matching rows in table table.

Equivalent to:

>>> SELECT COUNT({distinct} {column}) FROM {table} {where};
def create_table(self, table: str, *column_defs: str):
198    def create_table(self, table: str, *column_defs: str):
199        """Create a table if it doesn't exist.
200
201        #### :params:
202
203        `table`: Name of the table to create.
204
205        `column_defs`: Any number of column names and their definitions in proper Sqlite3 sytax.
206        i.e. `"column_name TEXT UNIQUE"` or `"column_name INTEGER PRIMARY KEY"` etc."""
207        columns = ", ".join(column_defs)
208        result = self.query(f"CREATE TABLE IF NOT EXISTS {table} ({columns});")
209        self.logger.info(f"'{table}' table created.")

Create a table if it doesn't exist.

:params:

table: Name of the table to create.

column_defs: Any number of column names and their definitions in proper Sqlite3 sytax. i.e. "column_name TEXT UNIQUE" or "column_name INTEGER PRIMARY KEY" etc.

def delete(self, table: str, where: str | None = None) -> int:
211    def delete(self, table: str, where: str | None = None) -> int:
212        """Delete rows from `table` that satisfy the given `where` clause.
213
214        If `where` is `None`, all rows will be deleted.
215
216        Returns the number of deleted rows.
217
218        e.g.
219        >>> db = Databased()
220        >>> db.delete("rides", "distance < 5 AND average_speed < 7")"""
221        try:
222            if where:
223                self.query(f"DELETE FROM {table} WHERE {where};")
224            else:
225                self.query(f"DELETE FROM {table};")
226            row_count = self.cursor.rowcount
227            self.logger.info(
228                f"Deleted {row_count} rows from '{table}' where '{where}'."
229            )
230            return row_count
231        except Exception as e:
232            self.logger.exception(
233                f"Error deleting rows from '{table}' where '{where}'."
234            )
235            raise e

Delete rows from table that satisfy the given where clause.

If where is None, all rows will be deleted.

Returns the number of deleted rows.

e.g.

>>> db = Databased()
>>> db.delete("rides", "distance < 5 AND average_speed < 7")
def describe(self, table: str) -> list[dict]:
237    def describe(self, table: str) -> list[dict]:
238        """Returns information about `table`."""
239        return self.query(f"pragma table_info('{table}');")

Returns information about table.

def drop_column(self, table: str, column: str):
241    def drop_column(self, table: str, column: str):
242        """Drop `column` from `table`."""
243        self.query(f"ALTER TABLE {table} DROP {column};")

Drop column from table.

def drop_table(self, table: str) -> bool:
245    def drop_table(self, table: str) -> bool:
246        """Drop `table` from the database.
247
248        Returns `True` if successful, `False` if not."""
249        try:
250            self.query(f"DROP TABLE {table};")
251            self.logger.info(f"Dropped table '{table}'.")
252            return True
253        except Exception as e:
254            print(f"{type(e).__name__}: {e}")
255            self.logger.error(f"Failed to drop table '{table}'.")
256            return False

Drop table from the database.

Returns True if successful, False if not.

def execute_script( self, path: pathier.pathier.Pathier | pathlib.Path | str, encoding: str = 'utf-8') -> sqlite3.Cursor:
258    def execute_script(self, path: Pathish, encoding: str = "utf-8") -> sqlite3.Cursor:
259        """Execute sql script located at `path`."""
260        if not self.connected:
261            self.connect()
262        assert self.connection
263        return self.connection.executescript(Pathier(path).read_text(encoding))

Execute sql script located at path.

def get_columns(self, table: str) -> list[str]:
265    def get_columns(self, table: str) -> list[str]:
266        """Returns a list of column names in `table`."""
267        return [
268            column["name"] for column in self.query(f"pragma table_info('{table}');")
269        ]

Returns a list of column names in table.

def insert( self, table: str, columns: tuple[str, ...], values: list[tuple[typing.Any, ...]]) -> int:
271    def insert(
272        self, table: str, columns: tuple[str, ...], values: list[tuple[Any, ...]]
273    ) -> int:
274        """Insert rows of `values` into `columns` of `table`.
275
276        Each `tuple` in `values` corresponds to an individual row that is to be inserted.
277        """
278        max_row_count = 900
279        column_list = "(" + ", ".join(columns) + ")"
280        row_count = 0
281        for i in range(0, len(values), max_row_count):
282            chunk = values[i : i + max_row_count]
283            placeholder = (
284                "(" + "),(".join((", ".join(("?" for _ in row)) for row in chunk)) + ")"
285            )
286            logger_values = "\n".join(
287                (
288                    "'(" + ", ".join((str(value) for value in row)) + ")'"
289                    for row in chunk
290                )
291            )
292            flattened_values = tuple((value for row in chunk for value in row))
293            try:
294                self.query(
295                    f"INSERT INTO {table} {column_list} VALUES {placeholder};",
296                    flattened_values,
297                )
298                self.logger.info(
299                    f"Inserted into '{column_list}' columns of '{table}' table values \n{logger_values}."
300                )
301                row_count += self.cursor.rowcount
302            except Exception as e:
303                self.logger.exception(
304                    f"Error inserting into '{column_list}' columns of '{table}' table values \n{logger_values}."
305                )
306                raise e
307        return row_count

Insert rows of values into columns of table.

Each tuple in values corresponds to an individual row that is to be inserted.

def query(self, query_: str, parameters: tuple[typing.Any, ...] = ()) -> list[dict]:
309    def query(self, query_: str, parameters: tuple[Any, ...] = tuple()) -> list[dict]:
310        """Execute an SQL query and return the results.
311
312        Ensures that the database connection is opened before executing the command.
313
314        The cursor used to execute the query will be available through `self.cursor` until the next time `self.query()` is called.
315        """
316        if not self.connected:
317            self.connect()
318        assert self.connection
319        self.cursor = self.connection.cursor()
320        self.cursor.execute(query_, parameters)
321        return self.cursor.fetchall()

Execute an SQL query and return the results.

Ensures that the database connection is opened before executing the command.

The cursor used to execute the query will be available through self.cursor until the next time self.query() is called.

def rename_column(self, table: str, column_to_rename: str, new_column_name: str):
323    def rename_column(self, table: str, column_to_rename: str, new_column_name: str):
324        """Rename a column in `table`."""
325        self.query(
326            f"ALTER TABLE {table} RENAME {column_to_rename} TO {new_column_name};"
327        )

Rename a column in table.

def rename_table(self, table_to_rename: str, new_table_name: str):
329    def rename_table(self, table_to_rename: str, new_table_name: str):
330        """Rename a table."""
331        self.query(f"ALTER TABLE {table_to_rename} RENAME TO {new_table_name};")

Rename a table.

def select( self, table: str, columns: list[str] = ['*'], joins: list[str] | None = None, where: str | None = None, group_by: str | None = None, having: str | None = None, order_by: str | None = None, limit: int | str | None = None) -> list[dict]:
333    def select(
334        self,
335        table: str,
336        columns: list[str] = ["*"],
337        joins: list[str] | None = None,
338        where: str | None = None,
339        group_by: str | None = None,
340        having: str | None = None,
341        order_by: str | None = None,
342        limit: int | str | None = None,
343    ) -> list[dict]:
344        """Return rows for given criteria.
345
346        For complex queries, use the `databased.query()` method.
347
348        Parameters `where`, `group_by`, `having`, `order_by`, and `limit` should not have
349        their corresponding key word in their string, but should otherwise be valid SQL.
350
351        `joins` should contain their key word (`INNER JOIN`, `LEFT JOIN`) in addition to the rest of the sub-statement.
352
353        >>> Databased().select(
354            "bike_rides",
355            "id, date, distance, moving_time, AVG(distance/moving_time) as average_speed",
356            where="distance > 20",
357            order_by="distance",
358            desc=True,
359            limit=10
360            )
361        executes the query:
362        >>> SELECT
363                id, date, distance, moving_time, AVG(distance/moving_time) as average_speed
364            FROM
365                bike_rides
366            WHERE
367                distance > 20
368            ORDER BY
369                distance DESC
370            Limit 10;"""
371        query = f"SELECT {', '.join(columns)} FROM {table}"
372        if joins:
373            query += f" {' '.join(joins)}"
374        if where:
375            query += f" WHERE {where}"
376        if group_by:
377            query += f" GROUP BY {group_by}"
378        if having:
379            query += f" HAVING {having}"
380        if order_by:
381            query += f" ORDER BY {order_by}"
382        if limit:
383            query += f" LIMIT {limit}"
384        query += ";"
385        rows = self.query(query)
386        return rows

Return rows for given criteria.

For complex queries, use the databased.query() method.

Parameters where, group_by, having, order_by, and limit should not have their corresponding key word in their string, but should otherwise be valid SQL.

joins should contain their key word (INNER JOIN, LEFT JOIN) in addition to the rest of the sub-statement.

>>> Databased().select(
    "bike_rides",
    "id, date, distance, moving_time, AVG(distance/moving_time) as average_speed",
    where="distance > 20",
    order_by="distance",
    desc=True,
    limit=10
    )
executes the query:
>>> SELECT
        id, date, distance, moving_time, AVG(distance/moving_time) as average_speed
    FROM
        bike_rides
    WHERE
        distance > 20
    ORDER BY
        distance DESC
    Limit 10;
@staticmethod
def to_grid(data: list[dict], shrink_to_terminal: bool = True) -> str:
388    @staticmethod
389    def to_grid(data: list[dict], shrink_to_terminal: bool = True) -> str:
390        """Returns a tabular grid from `data`.
391
392        If `shrink_to_terminal` is `True`, the column widths of the grid will be reduced to fit within the current terminal.
393        """
394        return griddy(data, "keys", shrink_to_terminal)

Returns a tabular grid from data.

If shrink_to_terminal is True, the column widths of the grid will be reduced to fit within the current terminal.

def update( self, table: str, column: str, value: Any, where: str | None = None) -> int:
396    def update(
397        self, table: str, column: str, value: Any, where: str | None = None
398    ) -> int:
399        """Update `column` of `table` to `value` for rows satisfying the conditions in `where`.
400
401        If `where` is `None` all rows will be updated.
402
403        Returns the number of updated rows.
404
405        e.g.
406        >>> db = Databased()
407        >>> db.update("rides", "elevation", 100, "elevation < 100")"""
408        try:
409            if where:
410                self.query(f"UPDATE {table} SET {column} = ? WHERE {where};", (value,))
411            else:
412                self.query(f"UPDATE {table} SET {column} = ?;", (value,))
413            row_count = self.cursor.rowcount
414            self.logger.info(
415                f"Updated {row_count} rows in '{table}' table to '{column}' = '{value}' where '{where}'."
416            )
417            return row_count
418        except Exception as e:
419            self.logger.exception(
420                f"Failed to update rows in '{table}' table to '{column}' = '{value}' where '{where}'."
421            )
422            raise e

Update column of table to value for rows satisfying the conditions in where.

If where is None all rows will be updated.

Returns the number of updated rows.

e.g.

>>> db = Databased()
>>> db.update("rides", "elevation", 100, "elevation < 100")
def vacuum(self) -> int:
424    def vacuum(self) -> int:
425        """Reduce disk size of database after row/table deletion.
426
427        Returns space freed up in bytes."""
428        size = self.path.size
429        self.query("VACUUM;")
430        return size - self.path.size

Reduce disk size of database after row/table deletion.

Returns space freed up in bytes.