sqliter-py 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sqliter-py might be problematic. Click here for more details.

sqliter/sqliter.py CHANGED
@@ -1,9 +1,16 @@
1
- """This is the main module for the sqliter package."""
1
+ """Core module for SQLiter, providing the main database interaction class.
2
+
3
+ This module defines the SqliterDB class, which serves as the primary
4
+ interface for all database operations in SQLiter. It handles connection
5
+ management, table creation, and CRUD operations, bridging the gap between
6
+ Pydantic models and SQLite database interactions.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
11
+ import logging
5
12
  import sqlite3
6
- from typing import TYPE_CHECKING, Optional
13
+ from typing import TYPE_CHECKING, Any, Optional
7
14
 
8
15
  from typing_extensions import Self
9
16
 
@@ -14,8 +21,11 @@ from sqliter.exceptions import (
14
21
  RecordInsertionError,
15
22
  RecordNotFoundError,
16
23
  RecordUpdateError,
24
+ SqlExecutionError,
17
25
  TableCreationError,
26
+ TableDeletionError,
18
27
  )
28
+ from sqliter.helpers import infer_sqlite_type
19
29
  from sqliter.query.query import QueryBuilder
20
30
 
21
31
  if TYPE_CHECKING: # pragma: no cover
@@ -25,16 +35,145 @@ if TYPE_CHECKING: # pragma: no cover
25
35
 
26
36
 
27
37
  class SqliterDB:
28
- """Class to manage SQLite database interactions."""
38
+ """Main class for interacting with SQLite databases.
39
+
40
+ This class provides methods for connecting to a SQLite database,
41
+ creating tables, and performing CRUD operations.
42
+
43
+ Arguements:
44
+ db_filename (str): The filename of the SQLite database.
45
+ auto_commit (bool): Whether to automatically commit transactions.
46
+ debug (bool): Whether to enable debug logging.
47
+ logger (Optional[logging.Logger]): Custom logger for debug output.
48
+ """
29
49
 
30
- def __init__(self, db_filename: str, *, auto_commit: bool = True) -> None:
31
- """Initialize the class and options."""
32
- self.db_filename = db_filename
50
+ def __init__(
51
+ self,
52
+ db_filename: Optional[str] = None,
53
+ *,
54
+ memory: bool = False,
55
+ auto_commit: bool = True,
56
+ debug: bool = False,
57
+ logger: Optional[logging.Logger] = None,
58
+ reset: bool = False,
59
+ ) -> None:
60
+ """Initialize a new SqliterDB instance.
61
+
62
+ Args:
63
+ db_filename: The filename of the SQLite database.
64
+ memory: If True, create an in-memory database.
65
+ auto_commit: Whether to automatically commit transactions.
66
+ debug: Whether to enable debug logging.
67
+ logger: Custom logger for debug output.
68
+ reset: Whether to reset the database on initialization. This will
69
+ basically drop all existing tables.
70
+
71
+ Raises:
72
+ ValueError: If no filename is provided for a non-memory database.
73
+ """
74
+ if memory:
75
+ self.db_filename = ":memory:"
76
+ elif db_filename:
77
+ self.db_filename = db_filename
78
+ else:
79
+ err = (
80
+ "Database name must be provided if not using an in-memory "
81
+ "database."
82
+ )
83
+ raise ValueError(err)
33
84
  self.auto_commit = auto_commit
85
+ self.debug = debug
86
+ self.logger = logger
34
87
  self.conn: Optional[sqlite3.Connection] = None
88
+ self.reset = reset
89
+
90
+ if self.debug:
91
+ self._setup_logger()
92
+
93
+ if self.reset:
94
+ self._reset_database()
95
+
96
+ def _reset_database(self) -> None:
97
+ """Drop all user-created tables in the database."""
98
+ with self.connect() as conn:
99
+ cursor = conn.cursor()
100
+
101
+ # Get all table names, excluding SQLite system tables
102
+ cursor.execute(
103
+ "SELECT name FROM sqlite_master WHERE type='table' "
104
+ "AND name NOT LIKE 'sqlite_%';"
105
+ )
106
+ tables = cursor.fetchall()
107
+
108
+ # Drop each user-created table
109
+ for table in tables:
110
+ cursor.execute(f"DROP TABLE IF EXISTS {table[0]}")
111
+
112
+ conn.commit()
113
+
114
+ if self.debug and self.logger:
115
+ self.logger.debug(
116
+ "Database reset: %s user-created tables dropped.", len(tables)
117
+ )
118
+
119
+ def _setup_logger(self) -> None:
120
+ """Set up the logger for debug output.
121
+
122
+ This method configures a logger for the SqliterDB instance, either
123
+ using an existing logger or creating a new one specifically for
124
+ SQLiter.
125
+ """
126
+ # Check if the root logger is already configured
127
+ root_logger = logging.getLogger()
128
+
129
+ if root_logger.hasHandlers():
130
+ # If the root logger has handlers, use it without modifying the root
131
+ # configuration
132
+ self.logger = root_logger.getChild("sqliter")
133
+ else:
134
+ # If no root logger is configured, set up a new logger specific to
135
+ # SqliterDB
136
+ self.logger = logging.getLogger("sqliter")
137
+
138
+ handler = logging.StreamHandler() # Output to console
139
+ formatter = logging.Formatter(
140
+ "%(levelname)-8s%(message)s"
141
+ ) # Custom format
142
+ handler.setFormatter(formatter)
143
+ self.logger.addHandler(handler)
144
+
145
+ self.logger.setLevel(logging.DEBUG)
146
+ self.logger.propagate = False
147
+
148
+ def _log_sql(self, sql: str, values: list[Any]) -> None:
149
+ """Log the SQL query and its values if debug mode is enabled.
150
+
151
+ The values are inserted into the SQL query string to replace the
152
+ placeholders.
153
+
154
+ Args:
155
+ sql: The SQL query string.
156
+ values: The list of values to be inserted into the query.
157
+ """
158
+ if self.debug and self.logger:
159
+ formatted_sql = sql
160
+ for value in values:
161
+ if isinstance(value, str):
162
+ formatted_sql = formatted_sql.replace("?", f"'{value}'", 1)
163
+ else:
164
+ formatted_sql = formatted_sql.replace("?", str(value), 1)
165
+
166
+ self.logger.debug("Executing SQL: %s", formatted_sql)
35
167
 
36
168
  def connect(self) -> sqlite3.Connection:
37
- """Create or return a connection to the SQLite database."""
169
+ """Establish a connection to the SQLite database.
170
+
171
+ Returns:
172
+ The SQLite connection object.
173
+
174
+ Raises:
175
+ DatabaseConnectionError: If unable to connect to the database.
176
+ """
38
177
  if not self.conn:
39
178
  try:
40
179
  self.conn = sqlite3.connect(self.db_filename)
@@ -43,41 +182,88 @@ class SqliterDB:
43
182
  return self.conn
44
183
 
45
184
  def close(self) -> None:
46
- """Close the connection to the SQLite database."""
185
+ """Close the database connection.
186
+
187
+ This method commits any pending changes if auto_commit is True,
188
+ then closes the connection. If the connection is already closed or does
189
+ not exist, this method silently does nothing.
190
+ """
47
191
  if self.conn:
48
192
  self._maybe_commit()
49
193
  self.conn.close()
50
194
  self.conn = None
51
195
 
52
196
  def commit(self) -> None:
53
- """Commit any pending transactions."""
197
+ """Commit the current transaction.
198
+
199
+ This method explicitly commits any pending changes to the database.
200
+ """
54
201
  if self.conn:
55
202
  self.conn.commit()
56
203
 
57
- def create_table(self, model_class: type[BaseDBModel]) -> None:
58
- """Create a table based on the Pydantic model."""
204
+ def create_table(
205
+ self,
206
+ model_class: type[BaseDBModel],
207
+ *,
208
+ exists_ok: bool = True,
209
+ force: bool = False,
210
+ ) -> None:
211
+ """Create a table in the database based on the given model class.
212
+
213
+ Args:
214
+ model_class: The Pydantic model class representing the table.
215
+ exists_ok: If True, do not raise an error if the table already
216
+ exists. Default is True which is the original behavior.
217
+ force: If True, drop the table if it exists before creating.
218
+ Defaults to False.
219
+
220
+ Raises:
221
+ TableCreationError: If there's an error creating the table.
222
+ ValueError: If the primary key field is not found in the model.
223
+ """
59
224
  table_name = model_class.get_table_name()
60
225
  primary_key = model_class.get_primary_key()
61
- create_id = model_class.should_create_id()
226
+ create_pk = model_class.should_create_pk()
62
227
 
63
- fields = ", ".join(
64
- f"{field_name} TEXT" for field_name in model_class.model_fields
65
- )
228
+ if force:
229
+ drop_table_sql = f"DROP TABLE IF EXISTS {table_name}"
230
+ self._execute_sql(drop_table_sql)
66
231
 
67
- if create_id:
68
- create_table_sql = f"""
69
- CREATE TABLE IF NOT EXISTS {table_name} (
70
- {primary_key} INTEGER PRIMARY KEY AUTOINCREMENT,
71
- {fields}
72
- )
73
- """
232
+ fields = []
233
+
234
+ # Always add the primary key field first
235
+ if create_pk:
236
+ fields.append(f"{primary_key} INTEGER PRIMARY KEY AUTOINCREMENT")
74
237
  else:
75
- create_table_sql = f"""
76
- CREATE TABLE IF NOT EXISTS {table_name} (
77
- {fields},
78
- PRIMARY KEY ({primary_key})
238
+ field_info = model_class.model_fields.get(primary_key)
239
+ if field_info is not None:
240
+ sqlite_type = infer_sqlite_type(field_info.annotation)
241
+ fields.append(f"{primary_key} {sqlite_type} PRIMARY KEY")
242
+ else:
243
+ err = (
244
+ f"Primary key field '{primary_key}' not found in model "
245
+ "fields."
79
246
  )
80
- """
247
+ raise ValueError(err)
248
+
249
+ # Add remaining fields
250
+ for field_name, field_info in model_class.model_fields.items():
251
+ if field_name != primary_key:
252
+ sqlite_type = infer_sqlite_type(field_info.annotation)
253
+ fields.append(f"{field_name} {sqlite_type}")
254
+
255
+ create_str = (
256
+ "CREATE TABLE IF NOT EXISTS" if exists_ok else "CREATE TABLE"
257
+ )
258
+
259
+ create_table_sql = f"""
260
+ {create_str} {table_name} (
261
+ {", ".join(fields)}
262
+ )
263
+ """
264
+
265
+ if self.debug:
266
+ self._log_sql(create_table_sql, [])
81
267
 
82
268
  try:
83
269
  with self.connect() as conn:
@@ -87,26 +273,81 @@ class SqliterDB:
87
273
  except sqlite3.Error as exc:
88
274
  raise TableCreationError(table_name) from exc
89
275
 
276
+ def _execute_sql(self, sql: str) -> None:
277
+ """Execute an SQL statement.
278
+
279
+ Args:
280
+ sql: The SQL statement to execute.
281
+
282
+ Raises:
283
+ SqlExecutionError: If the SQL execution fails.
284
+ """
285
+ if self.debug:
286
+ self._log_sql(sql, [])
287
+
288
+ try:
289
+ with self.connect() as conn:
290
+ cursor = conn.cursor()
291
+ cursor.execute(sql)
292
+ conn.commit()
293
+ except (sqlite3.Error, sqlite3.Warning) as exc:
294
+ raise SqlExecutionError(sql) from exc
295
+
296
+ def drop_table(self, model_class: type[BaseDBModel]) -> None:
297
+ """Drop the table associated with the given model class.
298
+
299
+ Args:
300
+ model_class: The model class for which to drop the table.
301
+
302
+ Raises:
303
+ TableDeletionError: If there's an error dropping the table.
304
+ """
305
+ table_name = model_class.get_table_name()
306
+ drop_table_sql = f"DROP TABLE IF EXISTS {table_name}"
307
+
308
+ if self.debug:
309
+ self._log_sql(drop_table_sql, [])
310
+
311
+ try:
312
+ with self.connect() as conn:
313
+ cursor = conn.cursor()
314
+ cursor.execute(drop_table_sql)
315
+ self.commit()
316
+ except sqlite3.Error as exc:
317
+ raise TableDeletionError(table_name) from exc
318
+
90
319
  def _maybe_commit(self) -> None:
91
- """Commit changes if auto_commit is True."""
320
+ """Commit changes if auto_commit is enabled.
321
+
322
+ This method is called after operations that modify the database,
323
+ committing changes only if auto_commit is set to True.
324
+ """
92
325
  if self.auto_commit and self.conn:
93
326
  self.conn.commit()
94
327
 
95
328
  def insert(self, model_instance: BaseDBModel) -> None:
96
- """Insert a new record into the table defined by the Pydantic model."""
329
+ """Insert a new record into the database.
330
+
331
+ Args:
332
+ model_instance: An instance of a Pydantic model to be inserted.
333
+
334
+ Raises:
335
+ RecordInsertionError: If there's an error inserting the record.
336
+ """
97
337
  model_class = type(model_instance)
98
338
  table_name = model_class.get_table_name()
99
339
 
100
- fields = ", ".join(model_class.model_fields)
101
- placeholders = ", ".join(["?"] * len(model_class.model_fields))
102
- values = tuple(
103
- getattr(model_instance, field) for field in model_class.model_fields
340
+ data = model_instance.model_dump()
341
+ fields = ", ".join(data.keys())
342
+ placeholders = ", ".join(
343
+ ["?" if value is not None else "NULL" for value in data.values()]
104
344
  )
345
+ values = tuple(value for value in data.values() if value is not None)
105
346
 
106
347
  insert_sql = f"""
107
348
  INSERT INTO {table_name} ({fields})
108
349
  VALUES ({placeholders})
109
- """ # noqa: S608
350
+ """ # noqa: S608
110
351
 
111
352
  try:
112
353
  with self.connect() as conn:
@@ -119,7 +360,18 @@ class SqliterDB:
119
360
  def get(
120
361
  self, model_class: type[BaseDBModel], primary_key_value: str
121
362
  ) -> BaseDBModel | None:
122
- """Retrieve a record by its PK and return a Pydantic instance."""
363
+ """Retrieve a single record from the database by its primary key.
364
+
365
+ Args:
366
+ model_class: The Pydantic model class representing the table.
367
+ primary_key_value: The value of the primary key to look up.
368
+
369
+ Returns:
370
+ An instance of the model class if found, None otherwise.
371
+
372
+ Raises:
373
+ RecordFetchError: If there's an error fetching the record.
374
+ """
123
375
  table_name = model_class.get_table_name()
124
376
  primary_key = model_class.get_primary_key()
125
377
 
@@ -147,7 +399,15 @@ class SqliterDB:
147
399
  return None
148
400
 
149
401
  def update(self, model_instance: BaseDBModel) -> None:
150
- """Update an existing record using the Pydantic model."""
402
+ """Update an existing record in the database.
403
+
404
+ Args:
405
+ model_instance: An instance of a Pydantic model to be updated.
406
+
407
+ Raises:
408
+ RecordUpdateError: If there's an error updating the record.
409
+ RecordNotFoundError: If the record to update is not found.
410
+ """
151
411
  model_class = type(model_instance)
152
412
  table_name = model_class.get_table_name()
153
413
  primary_key = model_class.get_primary_key()
@@ -187,7 +447,17 @@ class SqliterDB:
187
447
  def delete(
188
448
  self, model_class: type[BaseDBModel], primary_key_value: str
189
449
  ) -> None:
190
- """Delete a record by its primary key."""
450
+ """Delete a record from the database by its primary key.
451
+
452
+ Args:
453
+ model_class: The Pydantic model class representing the table.
454
+ primary_key_value: The value of the primary key of the record to
455
+ delete.
456
+
457
+ Raises:
458
+ RecordDeletionError: If there's an error deleting the record.
459
+ RecordNotFoundError: If the record to delete is not found.
460
+ """
191
461
  table_name = model_class.get_table_name()
192
462
  primary_key = model_class.get_primary_key()
193
463
 
@@ -206,13 +476,44 @@ class SqliterDB:
206
476
  except sqlite3.Error as exc:
207
477
  raise RecordDeletionError(table_name) from exc
208
478
 
209
- def select(self, model_class: type[BaseDBModel]) -> QueryBuilder:
210
- """Start a query for the given model."""
211
- return QueryBuilder(self, model_class)
479
+ def select(
480
+ self,
481
+ model_class: type[BaseDBModel],
482
+ fields: Optional[list[str]] = None,
483
+ exclude: Optional[list[str]] = None,
484
+ ) -> QueryBuilder:
485
+ """Create a QueryBuilder instance for selecting records.
486
+
487
+ Args:
488
+ model_class: The Pydantic model class representing the table.
489
+ fields: Optional list of fields to include in the query.
490
+ exclude: Optional list of fields to exclude from the query.
491
+
492
+ Returns:
493
+ A QueryBuilder instance for further query construction.
494
+ """
495
+ query_builder = QueryBuilder(self, model_class, fields)
496
+
497
+ # If exclude is provided, apply the exclude method
498
+ if exclude:
499
+ query_builder.exclude(exclude)
500
+
501
+ return query_builder
212
502
 
213
503
  # --- Context manager methods ---
214
504
  def __enter__(self) -> Self:
215
- """Enter the runtime context for the 'with' statement."""
505
+ """Enter the runtime context for the SqliterDB instance.
506
+
507
+ This method is called when entering a 'with' statement. It ensures
508
+ that a database connection is established.
509
+
510
+ Note that this method should never be called explicitly, but will be
511
+ called by the 'with' statement when entering the context.
512
+
513
+ Returns:
514
+ The SqliterDB instance.
515
+
516
+ """
216
517
  self.connect()
217
518
  return self
218
519
 
@@ -222,7 +523,24 @@ class SqliterDB:
222
523
  exc_value: Optional[BaseException],
223
524
  traceback: Optional[TracebackType],
224
525
  ) -> None:
225
- """Exit the runtime context and close the connection."""
526
+ """Exit the runtime context for the SqliterDB instance.
527
+
528
+ This method is called when exiting a 'with' statement. It handles
529
+ committing or rolling back transactions based on whether an exception
530
+ occurred, and closes the database connection.
531
+
532
+ Args:
533
+ exc_type: The type of the exception that caused the context to be
534
+ exited, or None if no exception was raised.
535
+ exc_value: The instance of the exception that caused the context
536
+ to be exited, or None if no exception was raised.
537
+ traceback: A traceback object encoding the stack trace, or None
538
+ if no exception was raised.
539
+
540
+ Note that this method should never be called explicitly, but will be
541
+ called by the 'with' statement when exiting the context.
542
+
543
+ """
226
544
  if self.conn:
227
545
  try:
228
546
  if exc_type: