sqliter-py 0.4.0__tar.gz → 0.5.0__tar.gz

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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sqliter-py
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Interact with SQLite databases using Python and Pydantic
5
5
  Project-URL: Pull Requests, https://github.com/seapagan/sqliter-py/pulls
6
6
  Project-URL: Bug Tracker, https://github.com/seapagan/sqliter-py/issues
@@ -52,14 +52,16 @@ Website](https://sqliter.grantramsay.dev)
52
52
 
53
53
  > [!CAUTION]
54
54
  > This project is still in the early stages of development and is lacking some
55
- > planned functionality. Please use with caution.
55
+ > planned functionality. Please use with caution - Classes and methods may
56
+ > change until a stable release is made. I'll try to keep this to an absolute
57
+ > minimum and the releases and documentation will be very clear about any
58
+ > breaking changes.
56
59
  >
57
60
  > Also, structures like `list`, `dict`, `set` etc are not supported **at this
58
61
  > time** as field types, since SQLite does not have a native column type for
59
- > these. I will look at implementing these in the future, probably by
60
- > serializing them to JSON or pickling them and storing in a text field. For
61
- > now, you can actually do this manually when creating your Model (use `TEXT` or
62
- > `BLOB` fields), then serialize before saving after and retrieving data.
62
+ > these. This is the **next planned enhancement**. These will need to be
63
+ > `pickled` first then stored as a BLOB in the database . Also support `date`
64
+ > which can be stored as a Unix timestamp in an integer field.
63
65
  >
64
66
  > See the [TODO](TODO.md) for planned features and improvements.
65
67
 
@@ -74,10 +76,11 @@ Website](https://sqliter.grantramsay.dev)
74
76
 
75
77
  - Table creation based on Pydantic models
76
78
  - CRUD operations (Create, Read, Update, Delete)
77
- - Basic query building with filtering, ordering, and pagination
79
+ - Chained Query building with filtering, ordering, and pagination
78
80
  - Transaction support
79
81
  - Custom exceptions for better error handling
80
82
  - Full type hinting and type checking
83
+ - Detailed documentation and examples
81
84
  - No external dependencies other than Pydantic
82
85
  - Full test coverage
83
86
  - Can optionally output the raw SQL queries being executed for debugging
@@ -142,7 +145,7 @@ db.create_table(User)
142
145
 
143
146
  # Insert a record
144
147
  user = User(name="John Doe", age=30)
145
- db.insert(user)
148
+ new_user = db.insert(user)
146
149
 
147
150
  # Query records
148
151
  results = db.select(User).filter(name="John Doe").fetch_all()
@@ -150,11 +153,11 @@ for user in results:
150
153
  print(f"User: {user.name}, Age: {user.age}")
151
154
 
152
155
  # Update a record
153
- user.age = 31
154
- db.update(user)
156
+ new_user.age = 31
157
+ db.update(new_user)
155
158
 
156
159
  # Delete a record
157
- db.delete(User, "John Doe")
160
+ db.delete(User, new_user.pk)
158
161
  ```
159
162
 
160
163
  See the [Usage](https://sqliter.grantramsay.dev/usage) section of the documentation
@@ -24,14 +24,16 @@ Website](https://sqliter.grantramsay.dev)
24
24
 
25
25
  > [!CAUTION]
26
26
  > This project is still in the early stages of development and is lacking some
27
- > planned functionality. Please use with caution.
27
+ > planned functionality. Please use with caution - Classes and methods may
28
+ > change until a stable release is made. I'll try to keep this to an absolute
29
+ > minimum and the releases and documentation will be very clear about any
30
+ > breaking changes.
28
31
  >
29
32
  > Also, structures like `list`, `dict`, `set` etc are not supported **at this
30
33
  > time** as field types, since SQLite does not have a native column type for
31
- > these. I will look at implementing these in the future, probably by
32
- > serializing them to JSON or pickling them and storing in a text field. For
33
- > now, you can actually do this manually when creating your Model (use `TEXT` or
34
- > `BLOB` fields), then serialize before saving after and retrieving data.
34
+ > these. This is the **next planned enhancement**. These will need to be
35
+ > `pickled` first then stored as a BLOB in the database . Also support `date`
36
+ > which can be stored as a Unix timestamp in an integer field.
35
37
  >
36
38
  > See the [TODO](TODO.md) for planned features and improvements.
37
39
 
@@ -46,10 +48,11 @@ Website](https://sqliter.grantramsay.dev)
46
48
 
47
49
  - Table creation based on Pydantic models
48
50
  - CRUD operations (Create, Read, Update, Delete)
49
- - Basic query building with filtering, ordering, and pagination
51
+ - Chained Query building with filtering, ordering, and pagination
50
52
  - Transaction support
51
53
  - Custom exceptions for better error handling
52
54
  - Full type hinting and type checking
55
+ - Detailed documentation and examples
53
56
  - No external dependencies other than Pydantic
54
57
  - Full test coverage
55
58
  - Can optionally output the raw SQL queries being executed for debugging
@@ -114,7 +117,7 @@ db.create_table(User)
114
117
 
115
118
  # Insert a record
116
119
  user = User(name="John Doe", age=30)
117
- db.insert(user)
120
+ new_user = db.insert(user)
118
121
 
119
122
  # Query records
120
123
  results = db.select(User).filter(name="John Doe").fetch_all()
@@ -122,11 +125,11 @@ for user in results:
122
125
  print(f"User: {user.name}, Age: {user.age}")
123
126
 
124
127
  # Update a record
125
- user.age = 31
126
- db.update(user)
128
+ new_user.age = 31
129
+ db.update(new_user)
127
130
 
128
131
  # Delete a record
129
- db.delete(User, "John Doe")
132
+ db.delete(User, new_user.pk)
130
133
  ```
131
134
 
132
135
  See the [Usage](https://sqliter.grantramsay.dev/usage) section of the documentation
@@ -3,7 +3,7 @@
3
3
 
4
4
  [project]
5
5
  name = "sqliter-py"
6
- version = "0.4.0"
6
+ version = "0.5.0"
7
7
  description = "Interact with SQLite databases using Python and Pydantic"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.9"
@@ -144,9 +144,10 @@ known-first-party = ["sqliter"]
144
144
  keep-runtime-typing = true
145
145
 
146
146
  [tool.mypy]
147
+ plugins = ["pydantic.mypy"]
148
+
147
149
  python_version = "3.9"
148
150
  exclude = ["docs"]
149
-
150
151
  [[tool.mypy.overrides]]
151
152
  disable_error_code = ["method-assign", "no-untyped-def", "attr-defined"]
152
153
  module = "tests.*"
@@ -114,7 +114,7 @@ class RecordUpdateError(SqliterError):
114
114
  class RecordNotFoundError(SqliterError):
115
115
  """Exception raised when a requested record is not found in the database."""
116
116
 
117
- message_template = "Failed to find a record for key '{}' "
117
+ message_template = "Failed to find that record in the table (key '{}') "
118
118
 
119
119
 
120
120
  class RecordFetchError(SqliterError):
@@ -10,9 +10,9 @@ in SQLiter applications.
10
10
  from __future__ import annotations
11
11
 
12
12
  import re
13
- from typing import Any, Optional, TypeVar, Union, get_args, get_origin
13
+ from typing import Any, Optional, TypeVar, Union, cast, get_args, get_origin
14
14
 
15
- from pydantic import BaseModel, ConfigDict
15
+ from pydantic import BaseModel, ConfigDict, Field
16
16
 
17
17
  T = TypeVar("T", bound="BaseDBModel")
18
18
 
@@ -28,6 +28,8 @@ class BaseDBModel(BaseModel):
28
28
  representing database models.
29
29
  """
30
30
 
31
+ pk: int = Field(0, description="The mandatory primary key of the table.")
32
+
31
33
  model_config = ConfigDict(
32
34
  extra="ignore",
33
35
  populate_by_name=True,
@@ -44,10 +46,6 @@ class BaseDBModel(BaseModel):
44
46
  table_name (Optional[str]): The name of the database table.
45
47
  """
46
48
 
47
- create_pk: bool = (
48
- True # Whether to create an auto-increment primary key
49
- )
50
- primary_key: str = "id" # Default primary key name
51
49
  table_name: Optional[str] = (
52
50
  None # Table name, defaults to class name if not set
53
51
  )
@@ -89,7 +87,7 @@ class BaseDBModel(BaseModel):
89
87
  else:
90
88
  converted_obj[field_name] = field_type(value)
91
89
 
92
- return cls.model_construct(**converted_obj)
90
+ return cast(T, cls.model_construct(**converted_obj))
93
91
 
94
92
  @classmethod
95
93
  def get_table_name(cls) -> str:
@@ -127,18 +125,10 @@ class BaseDBModel(BaseModel):
127
125
 
128
126
  @classmethod
129
127
  def get_primary_key(cls) -> str:
130
- """Get the primary key field name for the model.
131
-
132
- Returns:
133
- The name of the primary key field.
134
- """
135
- return getattr(cls.Meta, "primary_key", "id")
128
+ """Returns the mandatory primary key, always 'pk'."""
129
+ return "pk"
136
130
 
137
131
  @classmethod
138
132
  def should_create_pk(cls) -> bool:
139
- """Determine if a primary key should be automatically created.
140
-
141
- Returns:
142
- True if a primary key should be created, False otherwise.
143
- """
144
- return getattr(cls.Meta, "create_pk", True)
133
+ """Returns True since the primary key is always created."""
134
+ return True
@@ -129,8 +129,11 @@ class QueryBuilder:
129
129
  field_name, operator = self._parse_field_operator(field)
130
130
  self._validate_field(field_name, valid_fields)
131
131
 
132
- handler = self._get_operator_handler(operator)
133
- handler(field_name, value, operator)
132
+ if operator in ["__isnull", "__notnull"]:
133
+ self._handle_null(field_name, value, operator)
134
+ else:
135
+ handler = self._get_operator_handler(operator)
136
+ handler(field_name, value, operator)
134
137
 
135
138
  return self
136
139
 
@@ -145,6 +148,8 @@ class QueryBuilder:
145
148
  The QueryBuilder instance for method chaining.
146
149
  """
147
150
  if fields:
151
+ if "pk" not in fields:
152
+ fields.append("pk")
148
153
  self._fields = fields
149
154
  self._validate_fields()
150
155
  return self
@@ -164,6 +169,9 @@ class QueryBuilder:
164
169
  invalid fields are specified.
165
170
  """
166
171
  if fields:
172
+ if "pk" in fields:
173
+ err = "The primary key 'pk' cannot be excluded."
174
+ raise ValueError(err)
167
175
  all_fields = set(self.model_class.model_fields.keys())
168
176
 
169
177
  # Check for invalid fields before subtraction
@@ -179,7 +187,7 @@ class QueryBuilder:
179
187
  self._fields = list(all_fields - set(fields))
180
188
 
181
189
  # Explicit check: raise an error if no fields remain
182
- if not self._fields:
190
+ if self._fields == ["pk"]:
183
191
  err = "Exclusion results in no fields being selected."
184
192
  raise ValueError(err)
185
193
 
@@ -208,7 +216,7 @@ class QueryBuilder:
208
216
  raise ValueError(err)
209
217
 
210
218
  # Set self._fields to just the single field
211
- self._fields = [field]
219
+ self._fields = [field, "pk"]
212
220
  return self
213
221
 
214
222
  def _get_operator_handler(
@@ -275,7 +283,7 @@ class QueryBuilder:
275
283
  self.filters.append((field_name, value, operator))
276
284
 
277
285
  def _handle_null(
278
- self, field_name: str, _: FilterValue, operator: str
286
+ self, field_name: str, value: Union[str, float, None], operator: str
279
287
  ) -> None:
280
288
  """Handle IS NULL and IS NOT NULL filter conditions.
281
289
 
@@ -283,15 +291,14 @@ class QueryBuilder:
283
291
  field_name: The name of the field to filter on. _: Placeholder for
284
292
  unused value parameter.
285
293
  operator: The operator string ('__isnull' or '__notnull').
294
+ value: The value to check for.
286
295
 
287
296
  This method adds an IS NULL or IS NOT NULL condition to the filters
288
297
  list.
289
298
  """
290
- condition = (
291
- f"{field_name} IS NOT NULL"
292
- if operator == "__notnull"
293
- else f"{field_name} IS NULL"
294
- )
299
+ is_null = operator == "__isnull"
300
+ check_null = bool(value) if is_null else not bool(value)
301
+ condition = f"{field_name} IS {'NOT ' if not check_null else ''}NULL"
295
302
  self.filters.append((condition, None, operator))
296
303
 
297
304
  def _handle_in(
@@ -527,6 +534,8 @@ class QueryBuilder:
527
534
  if count_only:
528
535
  fields = "COUNT(*)"
529
536
  elif self._fields:
537
+ if "pk" not in self._fields:
538
+ self._fields.append("pk")
530
539
  fields = ", ".join(f'"{field}"' for field in self._fields)
531
540
  else:
532
541
  fields = ", ".join(
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import logging
12
12
  import sqlite3
13
- from typing import TYPE_CHECKING, Any, Optional
13
+ from typing import TYPE_CHECKING, Any, Optional, TypeVar
14
14
 
15
15
  from typing_extensions import Self
16
16
 
@@ -33,6 +33,8 @@ if TYPE_CHECKING: # pragma: no cover
33
33
 
34
34
  from sqliter.model.model import BaseDBModel
35
35
 
36
+ T = TypeVar("T", bound="BaseDBModel")
37
+
36
38
 
37
39
  class SqliterDB:
38
40
  """Main class for interacting with SQLite databases.
@@ -223,28 +225,12 @@ class SqliterDB:
223
225
  """
224
226
  table_name = model_class.get_table_name()
225
227
  primary_key = model_class.get_primary_key()
226
- create_pk = model_class.should_create_pk()
227
228
 
228
229
  if force:
229
230
  drop_table_sql = f"DROP TABLE IF EXISTS {table_name}"
230
231
  self._execute_sql(drop_table_sql)
231
232
 
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")
237
- else:
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."
246
- )
247
- raise ValueError(err)
233
+ fields = [f'"{primary_key}" INTEGER PRIMARY KEY AUTOINCREMENT']
248
234
 
249
235
  # Add remaining fields
250
236
  for field_name, field_info in model_class.model_fields.items():
@@ -325,19 +311,28 @@ class SqliterDB:
325
311
  if self.auto_commit and self.conn:
326
312
  self.conn.commit()
327
313
 
328
- def insert(self, model_instance: BaseDBModel) -> None:
314
+ def insert(self, model_instance: T) -> T:
329
315
  """Insert a new record into the database.
330
316
 
331
317
  Args:
332
- model_instance: An instance of a Pydantic model to be inserted.
318
+ model_instance: The instance of the model class to insert.
319
+
320
+ Returns:
321
+ The updated model instance with the primary key (pk) set.
333
322
 
334
323
  Raises:
335
- RecordInsertionError: If there's an error inserting the record.
324
+ RecordInsertionError: If an error occurs during the insertion.
336
325
  """
337
326
  model_class = type(model_instance)
338
327
  table_name = model_class.get_table_name()
339
328
 
329
+ # Get the data from the model
340
330
  data = model_instance.model_dump()
331
+ # remove the primary key field if it exists, otherwise we'll get
332
+ # TypeErrors as multiple primary keys will exist
333
+ if data.get("pk", None) == 0:
334
+ data.pop("pk")
335
+
341
336
  fields = ", ".join(data.keys())
342
337
  placeholders = ", ".join(
343
338
  ["?" if value is not None else "NULL" for value in data.values()]
@@ -354,11 +349,15 @@ class SqliterDB:
354
349
  cursor = conn.cursor()
355
350
  cursor.execute(insert_sql, values)
356
351
  self._maybe_commit()
352
+
357
353
  except sqlite3.Error as exc:
358
354
  raise RecordInsertionError(table_name) from exc
355
+ else:
356
+ data.pop("pk", None)
357
+ return model_class(pk=cursor.lastrowid, **data)
359
358
 
360
359
  def get(
361
- self, model_class: type[BaseDBModel], primary_key_value: str
360
+ self, model_class: type[BaseDBModel], primary_key_value: int
362
361
  ) -> BaseDBModel | None:
363
362
  """Retrieve a single record from the database by its primary key.
364
363
 
@@ -405,11 +404,12 @@ class SqliterDB:
405
404
  model_instance: An instance of a Pydantic model to be updated.
406
405
 
407
406
  Raises:
408
- RecordUpdateError: If there's an error updating the record.
409
- RecordNotFoundError: If the record to update is not found.
407
+ RecordUpdateError: If there's an error updating the record or if it
408
+ is not found.
410
409
  """
411
410
  model_class = type(model_instance)
412
411
  table_name = model_class.get_table_name()
412
+
413
413
  primary_key = model_class.get_primary_key()
414
414
 
415
415
  fields = ", ".join(
File without changes
File without changes