sqliter-py 0.1.0__py3-none-any.whl → 0.2.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.
sqliter/constants.py ADDED
@@ -0,0 +1,20 @@
1
+ """Define constants used in the library."""
2
+
3
+ OPERATOR_MAPPING = {
4
+ "__lt": "<",
5
+ "__lte": "<=",
6
+ "__gt": ">",
7
+ "__gte": ">=",
8
+ "__eq": "=",
9
+ "__ne": "!=",
10
+ "__in": "IN",
11
+ "__not_in": "NOT IN",
12
+ "__isnull": "IS NULL",
13
+ "__notnull": "IS NOT NULL",
14
+ "__startswith": "LIKE",
15
+ "__endswith": "LIKE",
16
+ "__contains": "LIKE",
17
+ "__istartswith": "LIKE",
18
+ "__iendswith": "LIKE",
19
+ "__icontains": "LIKE",
20
+ }
sqliter/exceptions.py ADDED
@@ -0,0 +1,120 @@
1
+ """Define custom exceptions for the sqliter package."""
2
+
3
+ import os
4
+ import sys
5
+ import traceback
6
+
7
+
8
+ class SqliterError(Exception):
9
+ """Base class for all exceptions raised by the sqliter package."""
10
+
11
+ message_template: str = "An error occurred in the SQLiter package."
12
+
13
+ def __init__(self, *args: object) -> None:
14
+ """Format the message using the provided arguments.
15
+
16
+ We also capture (and display) the current exception context and chain
17
+ any previous exceptions.
18
+
19
+ :param args: Arguments to format into the message template
20
+ """
21
+ if args:
22
+ message = self.message_template.format(*args)
23
+ else:
24
+ message = (
25
+ self.message_template.replace("'{}'", "")
26
+ .replace(":", "")
27
+ .strip()
28
+ )
29
+
30
+ # Capture the current exception context
31
+ self.original_exception = sys.exc_info()[1]
32
+
33
+ # If there's an active exception, append its information to our message
34
+ if self.original_exception:
35
+ original_type = type(self.original_exception).__name__
36
+ original_module = type(self.original_exception).__module__
37
+
38
+ # Get the traceback of the original exception
39
+ tb = traceback.extract_tb(self.original_exception.__traceback__)
40
+ if tb:
41
+ last_frame = tb[-1]
42
+ file_path = os.path.relpath(last_frame.filename)
43
+ line_number = last_frame.lineno
44
+ location = f"{file_path}:{line_number}"
45
+ else:
46
+ location = "unknown location"
47
+
48
+ message += (
49
+ f"\n --> {original_module}.{original_type} "
50
+ f"from {location}: {self.original_exception}"
51
+ )
52
+
53
+ # Call the parent constructor with our formatted message
54
+ super().__init__(message)
55
+
56
+ # Explicitly chain exceptions if there's an active one
57
+ if self.original_exception:
58
+ self.__cause__ = self.original_exception
59
+
60
+
61
+ class DatabaseConnectionError(SqliterError):
62
+ """Raised when the SQLite database connection fails."""
63
+
64
+ message_template = "Failed to connect to the database: '{}'"
65
+
66
+
67
+ class InvalidOffsetError(SqliterError):
68
+ """Raised when an invalid offset value (0 or negative) is used."""
69
+
70
+ message_template = (
71
+ "Invalid offset value: '{}'. Offset must be a positive integer."
72
+ )
73
+
74
+
75
+ class InvalidOrderError(SqliterError):
76
+ """Raised when an invalid order value is used."""
77
+
78
+ message_template = "Invalid order value - {}"
79
+
80
+
81
+ class TableCreationError(SqliterError):
82
+ """Raised when a table cannot be created in the database."""
83
+
84
+ message_template = "Failed to create the table: '{}'"
85
+
86
+
87
+ class RecordInsertionError(SqliterError):
88
+ """Raised when an error occurs during record insertion."""
89
+
90
+ message_template = "Failed to insert record into table: '{}'"
91
+
92
+
93
+ class RecordUpdateError(SqliterError):
94
+ """Raised when an error occurs during record update."""
95
+
96
+ message_template = "Failed to update record in table: '{}'"
97
+
98
+
99
+ class RecordNotFoundError(SqliterError):
100
+ """Raised when a record with the specified primary key is not found."""
101
+
102
+ message_template = "Failed to find a record for key '{}' "
103
+
104
+
105
+ class RecordFetchError(SqliterError):
106
+ """Raised when an error occurs during record fetching."""
107
+
108
+ message_template = "Failed to fetch record from table: '{}'"
109
+
110
+
111
+ class RecordDeletionError(SqliterError):
112
+ """Raised when an error occurs during record deletion."""
113
+
114
+ message_template = "Failed to delete record from table: '{}'"
115
+
116
+
117
+ class InvalidFilterError(SqliterError):
118
+ """Raised when an invalid filter field is used in a query."""
119
+
120
+ message_template = "Failed to apply filter: invalid field '{}'"
sqliter/query/query.py CHANGED
@@ -2,14 +2,30 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any, Optional
5
+ import sqlite3
6
+ from typing import TYPE_CHECKING, Any, Callable, Optional, Union
6
7
 
7
- from typing_extensions import Self
8
+ from typing_extensions import LiteralString, Self
9
+
10
+ from sqliter.constants import OPERATOR_MAPPING
11
+ from sqliter.exceptions import (
12
+ InvalidFilterError,
13
+ InvalidOffsetError,
14
+ InvalidOrderError,
15
+ RecordFetchError,
16
+ )
17
+
18
+ if TYPE_CHECKING: # pragma: no cover
19
+ from pydantic.fields import FieldInfo
8
20
 
9
- if TYPE_CHECKING:
10
21
  from sqliter import SqliterDB
11
22
  from sqliter.model import BaseDBModel
12
23
 
24
+ # Define a type alias for the possible value types
25
+ FilterValue = Union[
26
+ str, int, float, bool, None, list[Union[str, int, float, bool]]
27
+ ]
28
+
13
29
 
14
30
  class QueryBuilder:
15
31
  """Functions to build and execute queries for a given model."""
@@ -19,53 +35,245 @@ class QueryBuilder:
19
35
  self.db = db
20
36
  self.model_class = model_class
21
37
  self.table_name = model_class.get_table_name() # Use model_class method
22
- self.filters: list[tuple[str, Any]] = []
38
+ self.filters: list[tuple[str, Any, str]] = []
39
+ self._limit: Optional[int] = None
40
+ self._offset: Optional[int] = None
41
+ self._order_by: Optional[str] = None
23
42
 
24
- def filter(self, **conditions: str | float | None) -> Self:
43
+ def filter(self, **conditions: str | float | None) -> QueryBuilder:
25
44
  """Add filter conditions to the query."""
45
+ valid_fields = self.model_class.model_fields
46
+
26
47
  for field, value in conditions.items():
27
- self.filters.append((field, value))
48
+ field_name, operator = self._parse_field_operator(field)
49
+ self._validate_field(field_name, valid_fields)
50
+
51
+ handler = self._get_operator_handler(operator)
52
+ handler(field_name, value, operator)
53
+
54
+ return self
55
+
56
+ def _get_operator_handler(
57
+ self, operator: str
58
+ ) -> Callable[[str, Any, str], None]:
59
+ handlers = {
60
+ "__isnull": self._handle_null,
61
+ "__notnull": self._handle_null,
62
+ "__in": self._handle_in,
63
+ "__not_in": self._handle_in,
64
+ "__startswith": self._handle_like,
65
+ "__endswith": self._handle_like,
66
+ "__contains": self._handle_like,
67
+ "__istartswith": self._handle_like,
68
+ "__iendswith": self._handle_like,
69
+ "__icontains": self._handle_like,
70
+ "__lt": self._handle_comparison,
71
+ "__lte": self._handle_comparison,
72
+ "__gt": self._handle_comparison,
73
+ "__gte": self._handle_comparison,
74
+ "__ne": self._handle_comparison,
75
+ }
76
+ return handlers.get(operator, self._handle_equality)
77
+
78
+ def _validate_field(
79
+ self, field_name: str, valid_fields: dict[str, FieldInfo]
80
+ ) -> None:
81
+ if field_name not in valid_fields:
82
+ raise InvalidFilterError(field_name)
83
+
84
+ def _handle_equality(
85
+ self, field_name: str, value: FilterValue, operator: str
86
+ ) -> None:
87
+ if value is None:
88
+ self.filters.append((f"{field_name} IS NULL", None, "__isnull"))
89
+ else:
90
+ self.filters.append((field_name, value, operator))
91
+
92
+ def _handle_null(
93
+ self, field_name: str, _: FilterValue, operator: str
94
+ ) -> None:
95
+ condition = (
96
+ f"{field_name} IS NOT NULL"
97
+ if operator == "__notnull"
98
+ else f"{field_name} IS NULL"
99
+ )
100
+ self.filters.append((condition, None, operator))
101
+
102
+ def _handle_in(
103
+ self, field_name: str, value: FilterValue, operator: str
104
+ ) -> None:
105
+ if not isinstance(value, list):
106
+ err = f"{field_name} requires a list for '{operator}'"
107
+ raise TypeError(err)
108
+ sql_operator = OPERATOR_MAPPING.get(operator, "IN")
109
+ placeholder_list = ", ".join(["?"] * len(value))
110
+ self.filters.append(
111
+ (
112
+ f"{field_name} {sql_operator} ({placeholder_list})",
113
+ value,
114
+ operator,
115
+ )
116
+ )
117
+
118
+ def _handle_like(
119
+ self, field_name: str, value: FilterValue, operator: str
120
+ ) -> None:
121
+ if not isinstance(value, str):
122
+ err = f"{field_name} requires a string value for '{operator}'"
123
+ raise TypeError(err)
124
+ formatted_value = self._format_string_for_operator(operator, value)
125
+ if operator in ["__startswith", "__endswith", "__contains"]:
126
+ self.filters.append(
127
+ (
128
+ f"{field_name} GLOB ?",
129
+ [formatted_value],
130
+ operator,
131
+ )
132
+ )
133
+ elif operator in ["__istartswith", "__iendswith", "__icontains"]:
134
+ self.filters.append(
135
+ (
136
+ f"{field_name} LIKE ?",
137
+ [formatted_value],
138
+ operator,
139
+ )
140
+ )
141
+
142
+ def _handle_comparison(
143
+ self, field_name: str, value: FilterValue, operator: str
144
+ ) -> None:
145
+ sql_operator = OPERATOR_MAPPING[operator]
146
+ self.filters.append((f"{field_name} {sql_operator} ?", value, operator))
147
+
148
+ # Helper method for parsing field and operator
149
+ def _parse_field_operator(self, field: str) -> tuple[str, str]:
150
+ for operator in OPERATOR_MAPPING:
151
+ if field.endswith(operator):
152
+ return field[: -len(operator)], operator
153
+ return field, "__eq" # Default to equality if no operator is found
154
+
155
+ # Helper method for formatting string operators (like startswith)
156
+ def _format_string_for_operator(self, operator: str, value: str) -> str:
157
+ # Mapping operators to their corresponding string format
158
+ format_map = {
159
+ "__startswith": f"{value}*",
160
+ "__endswith": f"*{value}",
161
+ "__contains": f"*{value}*",
162
+ "__istartswith": f"{value.lower()}%",
163
+ "__iendswith": f"%{value.lower()}",
164
+ "__icontains": f"%{value.lower()}%",
165
+ }
166
+
167
+ # Return the formatted string or the original value if no match
168
+ return format_map.get(operator, value)
169
+
170
+ def limit(self, limit_value: int) -> Self:
171
+ """Limit the number of results returned by the query."""
172
+ self._limit = limit_value
173
+ return self
174
+
175
+ def offset(self, offset_value: int) -> Self:
176
+ """Set an offset value for the query."""
177
+ if offset_value < 0:
178
+ raise InvalidOffsetError(offset_value)
179
+ self._offset = offset_value
180
+
181
+ if self._limit is None:
182
+ self._limit = -1
183
+ return self
184
+
185
+ def order(self, order_by_field: str, direction: str = "ASC") -> Self:
186
+ """Order the results by a specific field and optionally direction.
187
+
188
+ Currently only supports ordering by a single field, though this will be
189
+ expanded in the future. You can chain this method to order by multiple
190
+ fields.
191
+
192
+ Parameters:
193
+ order_by_field (str): The field to order by.
194
+ direction (str, optional): The sorting direction, either 'ASC' or
195
+ 'DESC'. Defaults to 'ASC'.
196
+
197
+ Returns:
198
+ Self: Returns the query object for chaining.
199
+
200
+ Raises:
201
+ InvalidOrderError: If the field or direction is invalid.
202
+ """
203
+ if order_by_field not in self.model_class.model_fields:
204
+ err = f"'{order_by_field}' does not exist in the model fields."
205
+ raise InvalidOrderError(err)
206
+
207
+ valid_directions = {"ASC", "DESC"}
208
+ if direction.upper() not in valid_directions:
209
+ err = f"'{direction}' is not a valid sorting direction."
210
+ raise InvalidOrderError(err)
211
+
212
+ self._order_by = f'"{order_by_field}" {direction.upper()}'
28
213
  return self
29
214
 
30
215
  def _execute_query(
31
216
  self,
32
- limit: Optional[int] = None,
33
- offset: Optional[int] = None,
34
- order_by: Optional[str] = None,
35
217
  *,
36
218
  fetch_one: bool = False,
219
+ count_only: bool = False,
37
220
  ) -> list[tuple[Any, ...]] | Optional[tuple[Any, ...]]:
38
221
  """Helper function to execute the query with filters."""
39
222
  fields = ", ".join(self.model_class.model_fields)
40
- where_clause = " AND ".join(
41
- [f"{field} = ?" for field, _ in self.filters]
42
- )
43
- sql = f"SELECT {fields} FROM {self.table_name}" # noqa: S608
223
+
224
+ # Build the WHERE clause with special handling for None (NULL in SQL)
225
+ values, where_clause = self._parse_filter()
226
+
227
+ select_fields = fields if not count_only else "COUNT(*)"
228
+
229
+ sql = f'SELECT {select_fields} FROM "{self.table_name}"' # noqa: S608 # nosec
44
230
 
45
231
  if self.filters:
46
232
  sql += f" WHERE {where_clause}"
47
233
 
48
- if order_by:
49
- sql += f" ORDER BY {order_by}"
234
+ if self._order_by:
235
+ sql += f" ORDER BY {self._order_by}"
236
+
237
+ if self._limit is not None:
238
+ sql += " LIMIT ?"
239
+ values.append(self._limit)
50
240
 
51
- if limit is not None:
52
- sql += f" LIMIT {limit}"
241
+ if self._offset is not None:
242
+ sql += " OFFSET ?"
243
+ values.append(self._offset)
53
244
 
54
- if offset is not None:
55
- sql += f" OFFSET {offset}"
245
+ try:
246
+ with self.db.connect() as conn:
247
+ cursor = conn.cursor()
248
+ cursor.execute(sql, values)
249
+ return cursor.fetchall() if not fetch_one else cursor.fetchone()
250
+ except sqlite3.Error as exc:
251
+ raise RecordFetchError(self.table_name) from exc
56
252
 
57
- values = [value for _, value in self.filters]
253
+ def _parse_filter(self) -> tuple[list[Any], LiteralString]:
254
+ """Actually parse the filters."""
255
+ where_clauses = []
256
+ values = []
257
+ for field, value, operator in self.filters:
258
+ if operator == "__eq":
259
+ where_clauses.append(f"{field} = ?")
260
+ values.append(value)
261
+ else:
262
+ where_clauses.append(field)
263
+ if operator not in ["__isnull", "__notnull"]:
264
+ if isinstance(value, list):
265
+ values.extend(value)
266
+ else:
267
+ values.append(value)
58
268
 
59
- with self.db.connect() as conn:
60
- cursor = conn.cursor()
61
- cursor.execute(sql, values)
62
- return cursor.fetchall() if not fetch_one else cursor.fetchone()
269
+ where_clause = " AND ".join(where_clauses)
270
+ return values, where_clause
63
271
 
64
272
  def fetch_all(self) -> list[BaseDBModel]:
65
273
  """Fetch all results matching the filters."""
66
274
  results = self._execute_query()
67
275
 
68
- if results is None:
276
+ if not results:
69
277
  return []
70
278
 
71
279
  return [
@@ -92,7 +300,8 @@ class QueryBuilder:
92
300
 
93
301
  def fetch_first(self) -> BaseDBModel | None:
94
302
  """Fetch the first result of the query."""
95
- result = self._execute_query(limit=1)
303
+ self._limit = 1
304
+ result = self._execute_query()
96
305
  if not result:
97
306
  return None
98
307
  return self.model_class(
@@ -103,9 +312,10 @@ class QueryBuilder:
103
312
  )
104
313
 
105
314
  def fetch_last(self) -> BaseDBModel | None:
106
- """Fetch the last result of the query (based on the primary key)."""
107
- primary_key = self.model_class.get_primary_key()
108
- result = self._execute_query(limit=1, order_by=f"{primary_key} DESC")
315
+ """Fetch the last result of the query (based on the insertion order)."""
316
+ self._limit = 1
317
+ self._order_by = "rowid DESC"
318
+ result = self._execute_query()
109
319
  if not result:
110
320
  return None
111
321
  return self.model_class(
@@ -117,22 +327,9 @@ class QueryBuilder:
117
327
 
118
328
  def count(self) -> int:
119
329
  """Return the count of records matching the filters."""
120
- where_clause = " AND ".join(
121
- [f"{field} = ?" for field, _ in self.filters]
122
- )
123
- sql = f"SELECT COUNT(*) FROM {self.table_name}" # noqa: S608
124
-
125
- if self.filters:
126
- sql += f" WHERE {where_clause}"
127
-
128
- values = [value for _, value in self.filters]
129
-
130
- with self.db.connect() as conn:
131
- cursor = conn.cursor()
132
- cursor.execute(sql, values)
133
- result = cursor.fetchone()
330
+ result = self._execute_query(count_only=True)
134
331
 
135
- return int(result[0]) if result else 0
332
+ return int(result[0][0]) if result else 0
136
333
 
137
334
  def exists(self) -> bool:
138
335
  """Return True if any record matches the filters."""
sqliter/sqliter.py CHANGED
@@ -7,9 +7,18 @@ from typing import TYPE_CHECKING, Optional
7
7
 
8
8
  from typing_extensions import Self
9
9
 
10
+ from sqliter.exceptions import (
11
+ DatabaseConnectionError,
12
+ RecordDeletionError,
13
+ RecordFetchError,
14
+ RecordInsertionError,
15
+ RecordNotFoundError,
16
+ RecordUpdateError,
17
+ TableCreationError,
18
+ )
10
19
  from sqliter.query.query import QueryBuilder
11
20
 
12
- if TYPE_CHECKING:
21
+ if TYPE_CHECKING: # pragma: no cover
13
22
  from types import TracebackType
14
23
 
15
24
  from sqliter.model.model import BaseDBModel
@@ -18,7 +27,7 @@ if TYPE_CHECKING:
18
27
  class SqliterDB:
19
28
  """Class to manage SQLite database interactions."""
20
29
 
21
- def __init__(self, db_filename: str, *, auto_commit: bool = False) -> None:
30
+ def __init__(self, db_filename: str, *, auto_commit: bool = True) -> None:
22
31
  """Initialize the class and options."""
23
32
  self.db_filename = db_filename
24
33
  self.auto_commit = auto_commit
@@ -27,9 +36,24 @@ class SqliterDB:
27
36
  def connect(self) -> sqlite3.Connection:
28
37
  """Create or return a connection to the SQLite database."""
29
38
  if not self.conn:
30
- self.conn = sqlite3.connect(self.db_filename)
39
+ try:
40
+ self.conn = sqlite3.connect(self.db_filename)
41
+ except sqlite3.Error as exc:
42
+ raise DatabaseConnectionError(self.db_filename) from exc
31
43
  return self.conn
32
44
 
45
+ def close(self) -> None:
46
+ """Close the connection to the SQLite database."""
47
+ if self.conn:
48
+ self._maybe_commit()
49
+ self.conn.close()
50
+ self.conn = None
51
+
52
+ def commit(self) -> None:
53
+ """Commit any pending transactions."""
54
+ if self.conn:
55
+ self.conn.commit()
56
+
33
57
  def create_table(self, model_class: type[BaseDBModel]) -> None:
34
58
  """Create a table based on the Pydantic model."""
35
59
  table_name = model_class.get_table_name()
@@ -43,7 +67,7 @@ class SqliterDB:
43
67
  if create_id:
44
68
  create_table_sql = f"""
45
69
  CREATE TABLE IF NOT EXISTS {table_name} (
46
- id INTEGER PRIMARY KEY AUTOINCREMENT,
70
+ {primary_key} INTEGER PRIMARY KEY AUTOINCREMENT,
47
71
  {fields}
48
72
  )
49
73
  """
@@ -55,15 +79,18 @@ class SqliterDB:
55
79
  )
56
80
  """
57
81
 
58
- with self.connect() as conn:
59
- cursor = conn.cursor()
60
- cursor.execute(create_table_sql)
61
- conn.commit()
82
+ try:
83
+ with self.connect() as conn:
84
+ cursor = conn.cursor()
85
+ cursor.execute(create_table_sql)
86
+ conn.commit()
87
+ except sqlite3.Error as exc:
88
+ raise TableCreationError(table_name) from exc
62
89
 
63
- def _maybe_commit(self, conn: sqlite3.Connection) -> None:
90
+ def _maybe_commit(self) -> None:
64
91
  """Commit changes if auto_commit is True."""
65
- if self.auto_commit:
66
- conn.commit()
92
+ if self.auto_commit and self.conn:
93
+ self.conn.commit()
67
94
 
68
95
  def insert(self, model_instance: BaseDBModel) -> None:
69
96
  """Insert a new record into the table defined by the Pydantic model."""
@@ -77,14 +104,17 @@ class SqliterDB:
77
104
  )
78
105
 
79
106
  insert_sql = f"""
80
- INSERT OR REPLACE INTO {table_name} ({fields})
107
+ INSERT INTO {table_name} ({fields})
81
108
  VALUES ({placeholders})
82
109
  """ # noqa: S608
83
110
 
84
- with self.connect() as conn:
85
- cursor = conn.cursor()
86
- cursor.execute(insert_sql, values)
87
- self._maybe_commit(conn)
111
+ try:
112
+ with self.connect() as conn:
113
+ cursor = conn.cursor()
114
+ cursor.execute(insert_sql, values)
115
+ self._maybe_commit()
116
+ except sqlite3.Error as exc:
117
+ raise RecordInsertionError(table_name) from exc
88
118
 
89
119
  def get(
90
120
  self, model_class: type[BaseDBModel], primary_key_value: str
@@ -99,18 +129,22 @@ class SqliterDB:
99
129
  SELECT {fields} FROM {table_name} WHERE {primary_key} = ?
100
130
  """ # noqa: S608
101
131
 
102
- with self.connect() as conn:
103
- cursor = conn.cursor()
104
- cursor.execute(select_sql, (primary_key_value,))
105
- result = cursor.fetchone()
106
-
107
- if result:
108
- result_dict = {
109
- field: result[idx]
110
- for idx, field in enumerate(model_class.model_fields)
111
- }
112
- return model_class(**result_dict)
113
- return None
132
+ try:
133
+ with self.connect() as conn:
134
+ cursor = conn.cursor()
135
+ cursor.execute(select_sql, (primary_key_value,))
136
+ result = cursor.fetchone()
137
+
138
+ if result:
139
+ result_dict = {
140
+ field: result[idx]
141
+ for idx, field in enumerate(model_class.model_fields)
142
+ }
143
+ return model_class(**result_dict)
144
+ except sqlite3.Error as exc:
145
+ raise RecordFetchError(table_name) from exc
146
+ else:
147
+ return None
114
148
 
115
149
  def update(self, model_instance: BaseDBModel) -> None:
116
150
  """Update an existing record using the Pydantic model."""
@@ -131,13 +165,24 @@ class SqliterDB:
131
165
  primary_key_value = getattr(model_instance, primary_key)
132
166
 
133
167
  update_sql = f"""
134
- UPDATE {table_name} SET {fields} WHERE {primary_key} = ?
168
+ UPDATE {table_name}
169
+ SET {fields}
170
+ WHERE {primary_key} = ?
135
171
  """ # noqa: S608
136
172
 
137
- with self.connect() as conn:
138
- cursor = conn.cursor()
139
- cursor.execute(update_sql, (*values, primary_key_value))
140
- self._maybe_commit(conn)
173
+ try:
174
+ with self.connect() as conn:
175
+ cursor = conn.cursor()
176
+ cursor.execute(update_sql, (*values, primary_key_value))
177
+
178
+ # Check if any rows were updated
179
+ if cursor.rowcount == 0:
180
+ raise RecordNotFoundError(primary_key_value)
181
+
182
+ self._maybe_commit()
183
+
184
+ except sqlite3.Error as exc:
185
+ raise RecordUpdateError(table_name) from exc
141
186
 
142
187
  def delete(
143
188
  self, model_class: type[BaseDBModel], primary_key_value: str
@@ -150,10 +195,16 @@ class SqliterDB:
150
195
  DELETE FROM {table_name} WHERE {primary_key} = ?
151
196
  """ # noqa: S608
152
197
 
153
- with self.connect() as conn:
154
- cursor = conn.cursor()
155
- cursor.execute(delete_sql, (primary_key_value,))
156
- self._maybe_commit(conn)
198
+ try:
199
+ with self.connect() as conn:
200
+ cursor = conn.cursor()
201
+ cursor.execute(delete_sql, (primary_key_value,))
202
+
203
+ if cursor.rowcount == 0:
204
+ raise RecordNotFoundError(primary_key_value)
205
+ self._maybe_commit()
206
+ except sqlite3.Error as exc:
207
+ raise RecordDeletionError(table_name) from exc
157
208
 
158
209
  def select(self, model_class: type[BaseDBModel]) -> QueryBuilder:
159
210
  """Start a query for the given model."""
@@ -173,7 +224,13 @@ class SqliterDB:
173
224
  ) -> None:
174
225
  """Exit the runtime context and close the connection."""
175
226
  if self.conn:
176
- if not self.auto_commit:
177
- self.conn.commit()
178
- self.conn.close()
179
- self.conn = None
227
+ try:
228
+ if exc_type:
229
+ # Roll back the transaction if there was an exception
230
+ self.conn.rollback()
231
+ else:
232
+ self.conn.commit()
233
+ finally:
234
+ # Close the connection and reset the instance variable
235
+ self.conn.close()
236
+ self.conn = None
@@ -0,0 +1,351 @@
1
+ Metadata-Version: 2.3
2
+ Name: sqliter-py
3
+ Version: 0.2.0
4
+ Summary: Interact with SQLite databases using Python and Pydantic
5
+ Project-URL: Pull Requests, https://github.com/seapagan/sqliter-py/pulls
6
+ Project-URL: Bug Tracker, https://github.com/seapagan/sqliter-py/issues
7
+ Project-URL: Changelog, https://github.com/seapagan/sqliter-py/blob/main/CHANGELOG.md
8
+ Project-URL: Repository, https://github.com/seapagan/sqliter-py
9
+ Author-email: Grant Ramsay <grant@gnramsay.com>
10
+ License-Expression: MIT
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: pydantic>=2.9.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # SQLiter <!-- omit in toc -->
27
+
28
+ ![PyPI - Version](https://img.shields.io/pypi/v/sqliter-py)
29
+ [![Test Suite](https://github.com/seapagan/sqliter-py/actions/workflows/testing.yml/badge.svg)](https://github.com/seapagan/sqliter-py/actions/workflows/testing.yml)
30
+ [![Linting](https://github.com/seapagan/sqliter-py/actions/workflows/linting.yml/badge.svg)](https://github.com/seapagan/sqliter-py/actions/workflows/linting.yml)
31
+ [![Type Checking](https://github.com/seapagan/sqliter-py/actions/workflows/mypy.yml/badge.svg)](https://github.com/seapagan/sqliter-py/actions/workflows/mypy.yml)
32
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/sqliter-py)
33
+
34
+ SQLiter is a lightweight Object-Relational Mapping (ORM) library for SQLite
35
+ databases in Python. It provides a simplified interface for interacting with
36
+ SQLite databases using Pydantic models. The only external run-time dependency
37
+ is Pydantic itself.
38
+
39
+ It does not aim to be a full-fledged ORM like SQLAlchemy, but rather a simple
40
+ and easy-to-use library for basic database operations, especially for small
41
+ projects. It is NOT asynchronous and does not support complex queries (at this
42
+ time).
43
+
44
+ The ideal use case is more for Python CLI tools that need to store data in a
45
+ database-like format without needing to learn SQL or use a full ORM.
46
+
47
+ > [!NOTE]
48
+ > This project is still in the early stages of development and is lacking some
49
+ > planned functionality. Please use with caution.
50
+ >
51
+ > See the [TODO](TODO.md) for planned features and improvements.
52
+
53
+ - [Features](#features)
54
+ - [Installation](#installation)
55
+ - [Quick Start](#quick-start)
56
+ - [Detailed Usage](#detailed-usage)
57
+ - [Defining Models](#defining-models)
58
+ - [Database Operations](#database-operations)
59
+ - [Creating a Connection](#creating-a-connection)
60
+ - [Creating Tables](#creating-tables)
61
+ - [Inserting Records](#inserting-records)
62
+ - [Querying Records](#querying-records)
63
+ - [Updating Records](#updating-records)
64
+ - [Deleting Records](#deleting-records)
65
+ - [Commit your changes](#commit-your-changes)
66
+ - [Close the Connection](#close-the-connection)
67
+ - [Transactions](#transactions)
68
+ - [Filter Options](#filter-options)
69
+ - [Basic Filters](#basic-filters)
70
+ - [Null Checks](#null-checks)
71
+ - [Comparison Operators](#comparison-operators)
72
+ - [List Operations](#list-operations)
73
+ - [String Operations (Case-Sensitive)](#string-operations-case-sensitive)
74
+ - [String Operations (Case-Insensitive)](#string-operations-case-insensitive)
75
+ - [Contributing](#contributing)
76
+ - [License](#license)
77
+ - [Acknowledgements](#acknowledgements)
78
+
79
+ ## Features
80
+
81
+ - Table creation based on Pydantic models
82
+ - CRUD operations (Create, Read, Update, Delete)
83
+ - Basic query building with filtering, ordering, and pagination
84
+ - Transaction support
85
+ - Custom exceptions for better error handling
86
+
87
+ ## Installation
88
+
89
+ You can install SQLiter using whichever method you prefer or is compatible with
90
+ your project setup.
91
+
92
+ With `pip`:
93
+
94
+ ```bash
95
+ pip install sqliter-py
96
+ ```
97
+
98
+ Or `Poetry`:
99
+
100
+ ```bash
101
+ poetry add sqliter-py
102
+ ```
103
+
104
+ Or `uv` which is rapidly becoming my favorite tool for managing projects and
105
+ virtual environments (`uv` is used for developing this project and in the CI):
106
+
107
+ ```bash
108
+ uv add sqliter-py
109
+ ```
110
+
111
+ ## Quick Start
112
+
113
+ Here's a quick example of how to use SQLiter:
114
+
115
+ ```python
116
+ from sqliter import SqliterDB
117
+ from sqliter.model import BaseDBModel
118
+
119
+ # Define your model
120
+ class User(BaseDBModel):
121
+ name: str
122
+ age: int
123
+
124
+ class Meta:
125
+ table_name = "users"
126
+
127
+ # Create a database connection
128
+ db = SqliterDB("example.db")
129
+
130
+ # Create the table
131
+ db.create_table(User)
132
+
133
+ # Insert a record
134
+ user = User(name="John Doe", age=30)
135
+ db.insert(user)
136
+
137
+ # Query records
138
+ results = db.select(User).filter(name="John Doe").fetch_all()
139
+ for user in results:
140
+ print(f"User: {user.name}, Age: {user.age}")
141
+
142
+ # Update a record
143
+ user.age = 31
144
+ db.update(user)
145
+
146
+ # Delete a record
147
+ db.delete(User, "John Doe")
148
+ ```
149
+
150
+ ## Detailed Usage
151
+
152
+ ### Defining Models
153
+
154
+ Models in SQLiter are based on Pydantic's `BaseModel`. You can define your
155
+ models like this:
156
+
157
+ ```python
158
+ from sqliter.model import BaseDBModel
159
+
160
+ class User(BaseDBModel):
161
+ name: str
162
+ age: int
163
+ email: str
164
+
165
+ class Meta:
166
+ table_name = "users"
167
+ primary_key = "name" # Default is "id"
168
+ create_id = False # Set to True to auto-create an ID field
169
+ ```
170
+
171
+ ### Database Operations
172
+
173
+ #### Creating a Connection
174
+
175
+ ```python
176
+ from sqliter import SqliterDB
177
+
178
+ db = SqliterDB("your_database.db")
179
+ ```
180
+
181
+ The default behavior is to automatically commit changes to the database after
182
+ each operation. If you want to disable this behavior, you can set `auto_commit=False`
183
+ when creating the database connection:
184
+
185
+ ```python
186
+ db = SqliterDB("your_database.db", auto_commit=False)
187
+ ```
188
+
189
+ It is then up to you to manually commit changes using the `commit()` method.
190
+ This can be useful when you want to perform multiple operations in a single
191
+ transaction without the overhead of committing after each operation.
192
+
193
+ #### Creating Tables
194
+
195
+ ```python
196
+ db.create_table(User)
197
+ ```
198
+
199
+ #### Inserting Records
200
+
201
+ ```python
202
+ user = User(name="Jane Doe", age=25, email="jane@example.com")
203
+ db.insert(user)
204
+ ```
205
+
206
+ #### Querying Records
207
+
208
+ ```python
209
+ # Fetch all users
210
+ all_users = db.select(User).fetch_all()
211
+
212
+ # Filter users
213
+ young_users = db.select(User).filter(age=25).fetch_all()
214
+
215
+ # Order users
216
+ ordered_users = db.select(User).order("age", direction="DESC").fetch_all()
217
+
218
+ # Limit and offset
219
+ paginated_users = db.select(User).limit(10).offset(20).fetch_all()
220
+ ```
221
+
222
+ See below for more advanced filtering options.
223
+
224
+ #### Updating Records
225
+
226
+ ```python
227
+ user.age = 26
228
+ db.update(user)
229
+ ```
230
+
231
+ #### Deleting Records
232
+
233
+ ```python
234
+ db.delete(User, "Jane Doe")
235
+ ```
236
+
237
+ #### Commit your changes
238
+
239
+ By default, SQLiter will automatically commit changes to the database after each
240
+ operation. If you want to disable this behavior, you can set `auto_commit=False`
241
+ when creating the database connection:
242
+
243
+ ```python
244
+ db = SqliterDB("your_database.db", auto_commit=False)
245
+ ```
246
+
247
+ You can then manually commit changes using the `commit()` method:
248
+
249
+ ```python
250
+ db.commit()
251
+ ```
252
+
253
+ #### Close the Connection
254
+
255
+ When you're done with the database connection, you should close it to release
256
+ resources:
257
+
258
+ ```python
259
+ db.close()
260
+ ```
261
+
262
+ Note that closing the connection will also commit any pending changes, unless
263
+ `auto_commit` is set to `False`.
264
+
265
+ ### Transactions
266
+
267
+ SQLiter supports transactions using Python's context manager:
268
+
269
+ ```python
270
+ with db:
271
+ db.insert(User(name="Alice", age=30, email="alice@example.com"))
272
+ db.insert(User(name="Bob", age=35, email="bob@example.com"))
273
+ # If an exception occurs, the transaction will be rolled back
274
+ ```
275
+
276
+ > [!WARNING]
277
+ > Using the context manager will automatically commit the transaction
278
+ > at the end (unless an exception occurs), regardless of the `auto_commit`
279
+ > setting.
280
+ >
281
+ > the 'close()' method will also be called when the context manager exits, so you
282
+ > do not need to call it manually.
283
+
284
+ ### Filter Options
285
+
286
+ The `filter()` method in SQLiter supports various filter options to query records.
287
+
288
+ #### Basic Filters
289
+
290
+ - `__eq`: Equal to (default if no operator is specified)
291
+ - Example: `name="John"` or `name__eq="John"`
292
+
293
+ #### Null Checks
294
+
295
+ - `__isnull`: Is NULL
296
+ - Example: `email__isnull=True`
297
+ - `__notnull`: Is NOT NULL
298
+ - Example: `email__notnull=True`
299
+
300
+ #### Comparison Operators
301
+
302
+ - `__lt`: Less than
303
+ - Example: `age__lt=30`
304
+ - `__lte`: Less than or equal to
305
+ - Example: `age__lte=30`
306
+ - `__gt`: Greater than
307
+ - Example: `age__gt=30`
308
+ - `__gte`: Greater than or equal to
309
+ - Example: `age__gte=30`
310
+ - `__ne`: Not equal to
311
+ - Example: `status__ne="inactive"`
312
+
313
+ #### List Operations
314
+
315
+ - `__in`: In a list of values
316
+ - Example: `status__in=["active", "pending"]`
317
+ - `__not_in`: Not in a list of values
318
+ - Example: `category__not_in=["archived", "deleted"]`
319
+
320
+ #### String Operations (Case-Sensitive)
321
+
322
+ - `__startswith`: Starts with
323
+ - Example: `name__startswith="A"`
324
+ - `__endswith`: Ends with
325
+ - Example: `email__endswith=".com"`
326
+ - `__contains`: Contains
327
+ - Example: `description__contains="important"`
328
+
329
+ #### String Operations (Case-Insensitive)
330
+
331
+ - `__istartswith`: Starts with (case-insensitive)
332
+ - Example: `name__istartswith="a"`
333
+ - `__iendswith`: Ends with (case-insensitive)
334
+ - Example: `email__iendswith=".COM"`
335
+ - `__icontains`: Contains (case-insensitive)
336
+ - Example: `description__icontains="IMPORTANT"`
337
+
338
+ ## Contributing
339
+
340
+ Contributions are welcome! Please feel free to submit a Pull Request.
341
+
342
+ ## License
343
+
344
+ This project is licensed under the MIT License.
345
+
346
+ ## Acknowledgements
347
+
348
+ SQLiter was initially developed as an experiment to see how helpful ChatGPT and
349
+ Claud AI can be to speed up the development process. The initial version of the
350
+ code was generated by ChatGPT, with subsequent manual/AI refinements and
351
+ improvements.
@@ -0,0 +1,11 @@
1
+ sqliter/__init__.py,sha256=L8R0uvCbbbACwaI5xtd3khtvpNhlPRgHJAaYZvqjzig,134
2
+ sqliter/constants.py,sha256=QEUC6kPkwYItgFRUmV6qfK9YV1PcUyUoBwj34yhAyik,441
3
+ sqliter/exceptions.py,sha256=RP1T67PkJMOgkT7yIjES1xil832_UmuAeABtNiv-RKE,3756
4
+ sqliter/sqliter.py,sha256=1MOa763OQdwkiAHHAitEKox5O9EV0FVMbMWC7pqyk9U,7823
5
+ sqliter/model/__init__.py,sha256=Ovpkbyx2-T6Oee0qFNgUBBc2M0uwK-cdG0pigG3mkd8,179
6
+ sqliter/model/model.py,sha256=t1w38om37gma1gRk01Z_9II0h4g-l734ijN_8M1SYoY,1247
7
+ sqliter/query/__init__.py,sha256=BluNMJpuoo2PsYN-bL7fXlEc02O_8LgOMsvCmyv04ao,125
8
+ sqliter/query/query.py,sha256=dRW-Y7X3qH4HrTw-oFbomnl4Kz7WOsImIfoKXZqkMKQ,11648
9
+ sqliter_py-0.2.0.dist-info/METADATA,sha256=OBYavQg-H3HipYh_mVkaJ4cL_EUpzqPk1MtypWUpwlg,9880
10
+ sqliter_py-0.2.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
11
+ sqliter_py-0.2.0.dist-info/RECORD,,
@@ -1,30 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: sqliter-py
3
- Version: 0.1.0
4
- Summary: Interact with SQLite databases using Python and Pydantic
5
- Project-URL: Pull Requests, https://github.com/seapagan/sqliter-py/pulls
6
- Project-URL: Bug Tracker, https://github.com/seapagan/sqliter-py/issues
7
- Project-URL: Repository, https://github.com/seapagan/sqliter-py
8
- Author-email: Grant Ramsay <grant@gnramsay.com>
9
- License-Expression: MIT
10
- Classifier: Development Status :: 4 - Beta
11
- Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: MIT License
13
- Classifier: Operating System :: OS Independent
14
- Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.9
16
- Classifier: Programming Language :: Python :: 3.10
17
- Classifier: Programming Language :: Python :: 3.11
18
- Classifier: Programming Language :: Python :: 3.12
19
- Classifier: Topic :: Software Development
20
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
- Requires-Python: >=3.9
22
- Requires-Dist: pydantic>=2.9.0
23
- Description-Content-Type: text/markdown
24
-
25
- # SQLiter
26
-
27
- An SQLite wrapper in Python using Pydantic and written primarily using ChatGPT,
28
- as an experiment in how viable it is to write working code using a LLM.
29
-
30
- The code was then cleaned up, typed and linted by hand.
@@ -1,9 +0,0 @@
1
- sqliter/__init__.py,sha256=L8R0uvCbbbACwaI5xtd3khtvpNhlPRgHJAaYZvqjzig,134
2
- sqliter/sqliter.py,sha256=FxWtYnjnfM2Tp1lc1b1AfrGia-G0IdSopZ2bSa-lPuo,5944
3
- sqliter/model/__init__.py,sha256=Ovpkbyx2-T6Oee0qFNgUBBc2M0uwK-cdG0pigG3mkd8,179
4
- sqliter/model/model.py,sha256=t1w38om37gma1gRk01Z_9II0h4g-l734ijN_8M1SYoY,1247
5
- sqliter/query/__init__.py,sha256=BluNMJpuoo2PsYN-bL7fXlEc02O_8LgOMsvCmyv04ao,125
6
- sqliter/query/query.py,sha256=8wV5GJwQVFesnW60Qe9XCiOwEZI3Wvk-8WNM0ANsT_M,4442
7
- sqliter_py-0.1.0.dist-info/METADATA,sha256=R_IogiSpgnGC54Mftva2w5EIa-JnGH-R8NuE5_LDhRo,1267
8
- sqliter_py-0.1.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
9
- sqliter_py-0.1.0.dist-info/RECORD,,