sqliter-py 0.5.0__py3-none-any.whl → 0.9.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 CHANGED
@@ -6,6 +6,8 @@ operators and data types, which are crucial for translating between
6
6
  Pydantic models and SQLite database operations.
7
7
  """
8
8
 
9
+ import datetime
10
+
9
11
  # A dictionary mapping SQLiter filter operators to their corresponding SQL
10
12
  # operators.
11
13
  OPERATOR_MAPPING = {
@@ -34,4 +36,10 @@ SQLITE_TYPE_MAPPING = {
34
36
  str: "TEXT",
35
37
  bool: "INTEGER", # SQLite stores booleans as integers (0 or 1)
36
38
  bytes: "BLOB",
39
+ datetime.datetime: "INTEGER", # Store as Unix timestamp
40
+ datetime.date: "INTEGER", # Store as Unix timestamp
41
+ list: "BLOB",
42
+ dict: "BLOB",
43
+ set: "BLOB",
44
+ tuple: "BLOB",
37
45
  }
sqliter/exceptions.py CHANGED
@@ -145,3 +145,24 @@ class SqlExecutionError(SqliterError):
145
145
  """Raised when an SQL execution fails."""
146
146
 
147
147
  message_template = "Failed to execute SQL: '{}'"
148
+
149
+
150
+ class InvalidIndexError(SqliterError):
151
+ """Exception raised when an invalid index field is specified.
152
+
153
+ This error is triggered if one or more fields specified for an index
154
+ do not exist in the model's fields.
155
+
156
+ Attributes:
157
+ invalid_fields (list[str]): The list of fields that were invalid.
158
+ model_class (str): The name of the model where the error occurred.
159
+ """
160
+
161
+ message_template = "Invalid fields for indexing in model '{}': {}"
162
+
163
+ def __init__(self, invalid_fields: list[str], model_class: str) -> None:
164
+ """Tidy up the error message by joining the invalid fields."""
165
+ # Join invalid fields into a comma-separated string
166
+ invalid_fields_str = ", ".join(invalid_fields)
167
+ # Pass the formatted message to the parent class
168
+ super().__init__(model_class, invalid_fields_str)
sqliter/helpers.py CHANGED
@@ -6,6 +6,9 @@ data types. These utilities support the core functionality of model
6
6
  to database schema translation.
7
7
  """
8
8
 
9
+ from __future__ import annotations
10
+
11
+ import datetime
9
12
  from typing import Union
10
13
 
11
14
  from sqliter.constants import SQLITE_TYPE_MAPPING
@@ -33,3 +36,65 @@ def infer_sqlite_type(field_type: Union[type, None]) -> str:
33
36
 
34
37
  # Map the simplified type to an SQLite type
35
38
  return SQLITE_TYPE_MAPPING.get(field_type, "TEXT")
39
+
40
+
41
+ def to_unix_timestamp(value: datetime.date | datetime.datetime) -> int:
42
+ """Convert datetime or date to a Unix timestamp in UTC.
43
+
44
+ Args:
45
+ value: The datetime or date object to convert.
46
+
47
+ Returns:
48
+ An integer Unix timestamp.
49
+
50
+ Raises:
51
+ TypeError: If the value is not a datetime or date object.
52
+ """
53
+ if isinstance(value, datetime.datetime):
54
+ # If no timezone is provided, assume local time and convert to UTC
55
+ if value.tzinfo is None:
56
+ value = value.astimezone() # Convert to user's local timezone
57
+ # Convert to UTC before storing
58
+ value = value.astimezone(datetime.timezone.utc)
59
+ return int(value.timestamp())
60
+ if isinstance(value, datetime.date):
61
+ # Convert date to datetime at midnight in UTC
62
+ dt = datetime.datetime.combine(
63
+ value, datetime.time(0, 0), tzinfo=datetime.timezone.utc
64
+ )
65
+ return int(dt.timestamp())
66
+
67
+ err_msg = "Expected datetime or date object."
68
+ raise TypeError(err_msg)
69
+
70
+
71
+ def from_unix_timestamp(
72
+ value: int, to_type: type, *, localize: bool = True
73
+ ) -> datetime.date | datetime.datetime:
74
+ """Convert a Unix timestamp to datetime or date, optionally to local time.
75
+
76
+ Args:
77
+ value: The Unix timestamp as an integer.
78
+ to_type: The expected output type, either datetime or date.
79
+ localize: If True, convert the datetime to the user's local timezone.
80
+
81
+ Returns:
82
+ The corresponding datetime or date object.
83
+
84
+ Raises:
85
+ TypeError: If to_type is not datetime or date.
86
+ """
87
+ if to_type is datetime.datetime:
88
+ # Convert the Unix timestamp to UTC datetime
89
+ dt = datetime.datetime.fromtimestamp(value, tz=datetime.timezone.utc)
90
+ # Convert to local time if requested
91
+ return dt.astimezone() if localize else dt
92
+ if to_type is datetime.date:
93
+ # Convert to UTC datetime first
94
+ dt = datetime.datetime.fromtimestamp(value, tz=datetime.timezone.utc)
95
+ # Convert to local time if requested, then return the date part
96
+ dt_local = dt.astimezone() if localize else dt
97
+ return dt_local.date() # Extract the date part
98
+
99
+ err_msg = "Expected datetime or date type."
100
+ raise TypeError(err_msg)
sqliter/model/__init__.py CHANGED
@@ -1,9 +1,11 @@
1
1
  """This module provides the base model class for SQLiter database models.
2
2
 
3
3
  It exports the BaseDBModel class, which is used to define database
4
- models in SQLiter applications.
4
+ models in SQLiter applications, and the Unique class, which is used to
5
+ define unique constraints on model fields.
5
6
  """
6
7
 
7
- from .model import BaseDBModel
8
+ from .model import BaseDBModel, SerializableField
9
+ from .unique import Unique
8
10
 
9
- __all__ = ["BaseDBModel"]
11
+ __all__ = ["BaseDBModel", "SerializableField", "Unique"]
sqliter/model/model.py CHANGED
@@ -9,12 +9,28 @@ in SQLiter applications.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import datetime
13
+ import pickle
12
14
  import re
13
- from typing import Any, Optional, TypeVar, Union, cast, get_args, get_origin
15
+ from typing import (
16
+ Any,
17
+ ClassVar,
18
+ Optional,
19
+ Protocol,
20
+ Union,
21
+ cast,
22
+ get_args,
23
+ get_origin,
24
+ )
14
25
 
15
26
  from pydantic import BaseModel, ConfigDict, Field
27
+ from typing_extensions import Self
16
28
 
17
- T = TypeVar("T", bound="BaseDBModel")
29
+ from sqliter.helpers import from_unix_timestamp, to_unix_timestamp
30
+
31
+
32
+ class SerializableField(Protocol):
33
+ """Protocol for fields that can be serialized or deserialized."""
18
34
 
19
35
 
20
36
  class BaseDBModel(BaseModel):
@@ -29,11 +45,19 @@ class BaseDBModel(BaseModel):
29
45
  """
30
46
 
31
47
  pk: int = Field(0, description="The mandatory primary key of the table.")
48
+ created_at: int = Field(
49
+ default=0,
50
+ description="Unix timestamp when the record was created.",
51
+ )
52
+ updated_at: int = Field(
53
+ default=0,
54
+ description="Unix timestamp when the record was last updated.",
55
+ )
32
56
 
33
57
  model_config = ConfigDict(
34
58
  extra="ignore",
35
59
  populate_by_name=True,
36
- validate_assignment=False,
60
+ validate_assignment=True,
37
61
  from_attributes=True,
38
62
  )
39
63
 
@@ -41,17 +65,27 @@ class BaseDBModel(BaseModel):
41
65
  """Metadata class for configuring database-specific attributes.
42
66
 
43
67
  Attributes:
44
- create_pk (bool): Whether to create a primary key field.
45
- primary_key (str): The name of the primary key field.
46
- table_name (Optional[str]): The name of the database table.
68
+ table_name (Optional[str]): The name of the database table. If not
69
+ specified, the table name will be inferred from the model class
70
+ name and converted to snake_case.
71
+ indexes (ClassVar[list[Union[str, tuple[str]]]]): A list of fields
72
+ or tuples of fields for which regular (non-unique) indexes
73
+ should be created. Indexes improve query performance on these
74
+ fields.
75
+ unique_indexes (ClassVar[list[Union[str, tuple[str]]]]): A list of
76
+ fields or tuples of fields for which unique indexes should be
77
+ created. Unique indexes enforce that all values in these fields
78
+ are distinct across the table.
47
79
  """
48
80
 
49
81
  table_name: Optional[str] = (
50
82
  None # Table name, defaults to class name if not set
51
83
  )
84
+ indexes: ClassVar[list[Union[str, tuple[str]]]] = []
85
+ unique_indexes: ClassVar[list[Union[str, tuple[str]]]] = []
52
86
 
53
87
  @classmethod
54
- def model_validate_partial(cls: type[T], obj: dict[str, Any]) -> T:
88
+ def model_validate_partial(cls, obj: dict[str, Any]) -> Self:
55
89
  """Validate and create a model instance from partial data.
56
90
 
57
91
  This method allows for the creation of a model instance even when
@@ -87,7 +121,7 @@ class BaseDBModel(BaseModel):
87
121
  else:
88
122
  converted_obj[field_name] = field_type(value)
89
123
 
90
- return cast(T, cls.model_construct(**converted_obj))
124
+ return cast("Self", cls.model_construct(**converted_obj))
91
125
 
92
126
  @classmethod
93
127
  def get_table_name(cls) -> str:
@@ -111,7 +145,7 @@ class BaseDBModel(BaseModel):
111
145
 
112
146
  # Pluralize the table name
113
147
  try:
114
- import inflect
148
+ import inflect # noqa: PLC0415
115
149
 
116
150
  p = inflect.engine()
117
151
  return p.plural(snake_case_name)
@@ -132,3 +166,71 @@ class BaseDBModel(BaseModel):
132
166
  def should_create_pk(cls) -> bool:
133
167
  """Returns True since the primary key is always created."""
134
168
  return True
169
+
170
+ @classmethod
171
+ def serialize_field(cls, value: SerializableField) -> SerializableField:
172
+ """Serialize datetime or date fields to Unix timestamp.
173
+
174
+ Args:
175
+ field_name: The name of the field.
176
+ value: The value of the field.
177
+
178
+ Returns:
179
+ An integer Unix timestamp if the field is a datetime or date.
180
+ """
181
+ if isinstance(value, (datetime.datetime, datetime.date)):
182
+ return to_unix_timestamp(value)
183
+ if isinstance(value, (list, dict, set, tuple)):
184
+ return pickle.dumps(value)
185
+ return value # Return value as-is for other fields
186
+
187
+ # Deserialization after fetching from the database
188
+
189
+ @classmethod
190
+ def deserialize_field(
191
+ cls,
192
+ field_name: str,
193
+ value: SerializableField,
194
+ *,
195
+ return_local_time: bool,
196
+ ) -> object:
197
+ """Deserialize fields from Unix timestamp to datetime or date.
198
+
199
+ Args:
200
+ field_name: The name of the field being deserialized.
201
+ value: The Unix timestamp value fetched from the database.
202
+ return_local_time: Flag to control whether the datetime is localized
203
+ to the user's timezone.
204
+
205
+ Returns:
206
+ A datetime or date object if the field type is datetime or date,
207
+ otherwise returns the value as-is.
208
+ """
209
+ if value is None:
210
+ return None
211
+
212
+ # Get field type if it exists in model_fields
213
+ field_info = cls.model_fields.get(field_name)
214
+ if field_info is None:
215
+ # If field doesn't exist in model, return value as-is
216
+ return value
217
+
218
+ field_type = field_info.annotation
219
+
220
+ if (
221
+ isinstance(field_type, type)
222
+ and issubclass(field_type, (datetime.datetime, datetime.date))
223
+ and isinstance(value, int)
224
+ ):
225
+ return from_unix_timestamp(
226
+ value, field_type, localize=return_local_time
227
+ )
228
+
229
+ origin_type = get_origin(field_type) or field_type
230
+ if origin_type in (list, dict, set, tuple) and isinstance(value, bytes):
231
+ try:
232
+ return pickle.loads(value)
233
+ except pickle.UnpicklingError:
234
+ return value
235
+
236
+ return value
@@ -0,0 +1,19 @@
1
+ """Define a custom field type for unique constraints in SQLiter."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic.fields import FieldInfo
6
+
7
+
8
+ class Unique(FieldInfo):
9
+ """A custom field type for unique constraints in SQLiter."""
10
+
11
+ def __init__(self, default: Any = ..., **kwargs: Any) -> None: # noqa: ANN401
12
+ """Initialize a Unique field.
13
+
14
+ Args:
15
+ default: The default value for the field.
16
+ **kwargs: Additional keyword arguments to pass to FieldInfo.
17
+ """
18
+ super().__init__(default=default, **kwargs)
19
+ self.unique = True
sqliter/py.typed ADDED
File without changes
sqliter/query/query.py CHANGED
@@ -28,6 +28,7 @@ from sqliter.exceptions import (
28
28
  InvalidFilterError,
29
29
  InvalidOffsetError,
30
30
  InvalidOrderError,
31
+ RecordDeletionError,
31
32
  RecordFetchError,
32
33
  )
33
34
 
@@ -35,7 +36,7 @@ if TYPE_CHECKING: # pragma: no cover
35
36
  from pydantic.fields import FieldInfo
36
37
 
37
38
  from sqliter import SqliterDB
38
- from sqliter.model import BaseDBModel
39
+ from sqliter.model import BaseDBModel, SerializableField
39
40
 
40
41
  # Define a type alias for the possible value types
41
42
  FilterValue = Union[
@@ -609,14 +610,32 @@ class QueryBuilder:
609
610
  An instance of the model class populated with the row data.
610
611
  """
611
612
  if self._fields:
612
- return self.model_class.model_validate_partial(
613
- {field: row[idx] for idx, field in enumerate(self._fields)}
614
- )
615
- return self.model_class(
616
- **{
617
- field: row[idx]
618
- for idx, field in enumerate(self.model_class.model_fields)
613
+ data = {
614
+ field: self._deserialize(field, row[idx])
615
+ for idx, field in enumerate(self._fields)
619
616
  }
617
+ return self.model_class.model_validate_partial(data)
618
+
619
+ data = {
620
+ field: self._deserialize(field, row[idx])
621
+ for idx, field in enumerate(self.model_class.model_fields)
622
+ }
623
+ return self.model_class(**data)
624
+
625
+ def _deserialize(
626
+ self, field_name: str, value: SerializableField
627
+ ) -> SerializableField:
628
+ """Deserialize a field value if needed.
629
+
630
+ Args:
631
+ field_name: Name of the field being deserialized.
632
+ value: Value from the database.
633
+
634
+ Returns:
635
+ The deserialized value.
636
+ """
637
+ return self.model_class.deserialize_field(
638
+ field_name, value, return_local_time=self.db.return_local_time
620
639
  )
621
640
 
622
641
  @overload
@@ -710,3 +729,34 @@ class QueryBuilder:
710
729
  True if at least one result exists, False otherwise.
711
730
  """
712
731
  return self.count() > 0
732
+
733
+ def delete(self) -> int:
734
+ """Delete records that match the current query conditions.
735
+
736
+ Returns:
737
+ The number of records deleted.
738
+
739
+ Raises:
740
+ RecordDeletionError: If there's an error deleting the records.
741
+ """
742
+ sql = f'DELETE FROM "{self.table_name}"' # noqa: S608 # nosec
743
+
744
+ # Build the WHERE clause with special handling for None (NULL in SQL)
745
+ values, where_clause = self._parse_filter()
746
+
747
+ if self.filters:
748
+ sql += f" WHERE {where_clause}"
749
+
750
+ # Print the raw SQL and values if debug is enabled
751
+ if self.db.debug:
752
+ self.db._log_sql(sql, values) # noqa: SLF001
753
+
754
+ try:
755
+ with self.db.connect() as conn:
756
+ cursor = conn.cursor()
757
+ cursor.execute(sql, values)
758
+ deleted_count = cursor.rowcount
759
+ self.db._maybe_commit() # noqa: SLF001
760
+ return deleted_count
761
+ except sqlite3.Error as exc:
762
+ raise RecordDeletionError(self.table_name) from exc
sqliter/sqliter.py CHANGED
@@ -10,12 +10,14 @@ from __future__ import annotations
10
10
 
11
11
  import logging
12
12
  import sqlite3
13
- from typing import TYPE_CHECKING, Any, Optional, TypeVar
13
+ import time
14
+ from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
14
15
 
15
16
  from typing_extensions import Self
16
17
 
17
18
  from sqliter.exceptions import (
18
19
  DatabaseConnectionError,
20
+ InvalidIndexError,
19
21
  RecordDeletionError,
20
22
  RecordFetchError,
21
23
  RecordInsertionError,
@@ -26,6 +28,7 @@ from sqliter.exceptions import (
26
28
  TableDeletionError,
27
29
  )
28
30
  from sqliter.helpers import infer_sqlite_type
31
+ from sqliter.model.unique import Unique
29
32
  from sqliter.query.query import QueryBuilder
30
33
 
31
34
  if TYPE_CHECKING: # pragma: no cover
@@ -49,7 +52,9 @@ class SqliterDB:
49
52
  logger (Optional[logging.Logger]): Custom logger for debug output.
50
53
  """
51
54
 
52
- def __init__(
55
+ MEMORY_DB = ":memory:"
56
+
57
+ def __init__( # noqa: PLR0913
53
58
  self,
54
59
  db_filename: Optional[str] = None,
55
60
  *,
@@ -58,6 +63,7 @@ class SqliterDB:
58
63
  debug: bool = False,
59
64
  logger: Optional[logging.Logger] = None,
60
65
  reset: bool = False,
66
+ return_local_time: bool = True,
61
67
  ) -> None:
62
68
  """Initialize a new SqliterDB instance.
63
69
 
@@ -69,12 +75,13 @@ class SqliterDB:
69
75
  logger: Custom logger for debug output.
70
76
  reset: Whether to reset the database on initialization. This will
71
77
  basically drop all existing tables.
78
+ return_local_time: Whether to return local time for datetime fields.
72
79
 
73
80
  Raises:
74
81
  ValueError: If no filename is provided for a non-memory database.
75
82
  """
76
83
  if memory:
77
- self.db_filename = ":memory:"
84
+ self.db_filename = self.MEMORY_DB
78
85
  elif db_filename:
79
86
  self.db_filename = db_filename
80
87
  else:
@@ -88,6 +95,9 @@ class SqliterDB:
88
95
  self.logger = logger
89
96
  self.conn: Optional[sqlite3.Connection] = None
90
97
  self.reset = reset
98
+ self.return_local_time = return_local_time
99
+
100
+ self._in_transaction = False
91
101
 
92
102
  if self.debug:
93
103
  self._setup_logger()
@@ -95,6 +105,54 @@ class SqliterDB:
95
105
  if self.reset:
96
106
  self._reset_database()
97
107
 
108
+ @property
109
+ def filename(self) -> Optional[str]:
110
+ """Returns the filename of the current database or None if in-memory."""
111
+ return None if self.db_filename == self.MEMORY_DB else self.db_filename
112
+
113
+ @property
114
+ def is_memory(self) -> bool:
115
+ """Returns True if the database is in-memory."""
116
+ return self.db_filename == self.MEMORY_DB
117
+
118
+ @property
119
+ def is_autocommit(self) -> bool:
120
+ """Returns True if auto-commit is enabled."""
121
+ return self.auto_commit
122
+
123
+ @property
124
+ def is_connected(self) -> bool:
125
+ """Returns True if the database is connected, False otherwise."""
126
+ return self.conn is not None
127
+
128
+ @property
129
+ def table_names(self) -> list[str]:
130
+ """Returns a list of all table names in the database.
131
+
132
+ Temporarily connects to the database if not connected and restores
133
+ the connection state afterward.
134
+ """
135
+ was_connected = self.is_connected
136
+ if not was_connected:
137
+ self.connect()
138
+
139
+ if self.conn is None:
140
+ err_msg = "Failed to establish a database connection."
141
+ raise DatabaseConnectionError(err_msg)
142
+
143
+ cursor = self.conn.cursor()
144
+ cursor.execute(
145
+ "SELECT name FROM sqlite_master WHERE type='table' "
146
+ "AND name NOT LIKE 'sqlite_%';"
147
+ )
148
+ tables = [row[0] for row in cursor.fetchall()]
149
+
150
+ # Restore the connection state
151
+ if not was_connected:
152
+ self.close()
153
+
154
+ return tables
155
+
98
156
  def _reset_database(self) -> None:
99
157
  """Drop all user-created tables in the database."""
100
158
  with self.connect() as conn:
@@ -236,7 +294,12 @@ class SqliterDB:
236
294
  for field_name, field_info in model_class.model_fields.items():
237
295
  if field_name != primary_key:
238
296
  sqlite_type = infer_sqlite_type(field_info.annotation)
239
- fields.append(f"{field_name} {sqlite_type}")
297
+ unique_constraint = (
298
+ "UNIQUE" if isinstance(field_info, Unique) else ""
299
+ )
300
+ fields.append(
301
+ f"{field_name} {sqlite_type} {unique_constraint}".strip()
302
+ )
240
303
 
241
304
  create_str = (
242
305
  "CREATE TABLE IF NOT EXISTS" if exists_ok else "CREATE TABLE"
@@ -259,6 +322,65 @@ class SqliterDB:
259
322
  except sqlite3.Error as exc:
260
323
  raise TableCreationError(table_name) from exc
261
324
 
325
+ # Create regular indexes
326
+ if hasattr(model_class.Meta, "indexes"):
327
+ self._create_indexes(
328
+ model_class, model_class.Meta.indexes, unique=False
329
+ )
330
+
331
+ # Create unique indexes
332
+ if hasattr(model_class.Meta, "unique_indexes"):
333
+ self._create_indexes(
334
+ model_class, model_class.Meta.unique_indexes, unique=True
335
+ )
336
+
337
+ def _create_indexes(
338
+ self,
339
+ model_class: type[BaseDBModel],
340
+ indexes: list[Union[str, tuple[str]]],
341
+ *,
342
+ unique: bool = False,
343
+ ) -> None:
344
+ """Helper method to create regular or unique indexes.
345
+
346
+ Args:
347
+ model_class: The model class defining the table.
348
+ indexes: List of fields or tuples of fields to create indexes for.
349
+ unique: If True, creates UNIQUE indexes; otherwise, creates regular
350
+ indexes.
351
+
352
+ Raises:
353
+ InvalidIndexError: If any fields specified for indexing do not exist
354
+ in the model.
355
+ """
356
+ valid_fields = set(
357
+ model_class.model_fields.keys()
358
+ ) # Get valid fields from the model
359
+
360
+ for index in indexes:
361
+ # Handle multiple fields in tuple form
362
+ fields = list(index) if isinstance(index, tuple) else [index]
363
+
364
+ # Check if all fields exist in the model
365
+ invalid_fields = [
366
+ field for field in fields if field not in valid_fields
367
+ ]
368
+ if invalid_fields:
369
+ raise InvalidIndexError(invalid_fields, model_class.__name__)
370
+
371
+ # Build the SQL string
372
+ index_name = "_".join(fields)
373
+ index_postfix = "_unique" if unique else ""
374
+ index_type = " UNIQUE " if unique else " "
375
+
376
+ create_index_sql = (
377
+ f"CREATE{index_type}INDEX IF NOT EXISTS "
378
+ f"idx_{model_class.get_table_name()}"
379
+ f"_{index_name}{index_postfix} "
380
+ f"ON {model_class.get_table_name()} ({', '.join(fields)})"
381
+ )
382
+ self._execute_sql(create_index_sql)
383
+
262
384
  def _execute_sql(self, sql: str) -> None:
263
385
  """Execute an SQL statement.
264
386
 
@@ -308,14 +430,21 @@ class SqliterDB:
308
430
  This method is called after operations that modify the database,
309
431
  committing changes only if auto_commit is set to True.
310
432
  """
311
- if self.auto_commit and self.conn:
433
+ if not self._in_transaction and self.auto_commit and self.conn:
312
434
  self.conn.commit()
313
435
 
314
- def insert(self, model_instance: T) -> T:
436
+ def insert(
437
+ self, model_instance: T, *, timestamp_override: bool = False
438
+ ) -> T:
315
439
  """Insert a new record into the database.
316
440
 
317
441
  Args:
318
442
  model_instance: The instance of the model class to insert.
443
+ timestamp_override: If True, override the created_at and updated_at
444
+ timestamps with provided values. Default is False. If the values
445
+ are not provided, they will be set to the current time as
446
+ normal. Without this flag, the timestamps will always be set to
447
+ the current time, even if provided.
319
448
 
320
449
  Returns:
321
450
  The updated model instance with the primary key (pk) set.
@@ -326,8 +455,28 @@ class SqliterDB:
326
455
  model_class = type(model_instance)
327
456
  table_name = model_class.get_table_name()
328
457
 
458
+ # Always set created_at and updated_at timestamps
459
+ current_timestamp = int(time.time())
460
+
461
+ # Handle the case where timestamp_override is False
462
+ if not timestamp_override:
463
+ # Always override both timestamps with the current time
464
+ model_instance.created_at = current_timestamp
465
+ model_instance.updated_at = current_timestamp
466
+ else:
467
+ # Respect provided values, but set to current time if they are 0
468
+ if model_instance.created_at == 0:
469
+ model_instance.created_at = current_timestamp
470
+ if model_instance.updated_at == 0:
471
+ model_instance.updated_at = current_timestamp
472
+
329
473
  # Get the data from the model
330
474
  data = model_instance.model_dump()
475
+
476
+ # Serialize the data
477
+ for field_name, value in list(data.items()):
478
+ data[field_name] = model_instance.serialize_field(value)
479
+
331
480
  # remove the primary key field if it exists, otherwise we'll get
332
481
  # TypeErrors as multiple primary keys will exist
333
482
  if data.get("pk", None) == 0:
@@ -354,7 +503,13 @@ class SqliterDB:
354
503
  raise RecordInsertionError(table_name) from exc
355
504
  else:
356
505
  data.pop("pk", None)
357
- return model_class(pk=cursor.lastrowid, **data)
506
+ # Deserialize each field before creating the model instance
507
+ deserialized_data = {}
508
+ for field_name, value in data.items():
509
+ deserialized_data[field_name] = model_class.deserialize_field(
510
+ field_name, value, return_local_time=self.return_local_time
511
+ )
512
+ return model_class(pk=cursor.lastrowid, **deserialized_data)
358
513
 
359
514
  def get(
360
515
  self, model_class: type[BaseDBModel], primary_key_value: int
@@ -391,7 +546,17 @@ class SqliterDB:
391
546
  field: result[idx]
392
547
  for idx, field in enumerate(model_class.model_fields)
393
548
  }
394
- return model_class(**result_dict)
549
+ # Deserialize each field before creating the model instance
550
+ deserialized_data = {}
551
+ for field_name, value in result_dict.items():
552
+ deserialized_data[field_name] = (
553
+ model_class.deserialize_field(
554
+ field_name,
555
+ value,
556
+ return_local_time=self.return_local_time,
557
+ )
558
+ )
559
+ return model_class(**deserialized_data)
395
560
  except sqlite3.Error as exc:
396
561
  raise RecordFetchError(table_name) from exc
397
562
  else:
@@ -405,24 +570,27 @@ class SqliterDB:
405
570
 
406
571
  Raises:
407
572
  RecordUpdateError: If there's an error updating the record or if it
408
- is not found.
573
+ is not found.
409
574
  """
410
575
  model_class = type(model_instance)
411
576
  table_name = model_class.get_table_name()
412
-
413
577
  primary_key = model_class.get_primary_key()
414
578
 
415
- fields = ", ".join(
416
- f"{field} = ?"
417
- for field in model_class.model_fields
418
- if field != primary_key
419
- )
420
- values = tuple(
421
- getattr(model_instance, field)
422
- for field in model_class.model_fields
423
- if field != primary_key
424
- )
425
- primary_key_value = getattr(model_instance, primary_key)
579
+ # Set updated_at timestamp
580
+ current_timestamp = int(time.time())
581
+ model_instance.updated_at = current_timestamp
582
+
583
+ # Get the data and serialize any datetime/date fields
584
+ data = model_instance.model_dump()
585
+ for field_name, value in list(data.items()):
586
+ data[field_name] = model_instance.serialize_field(value)
587
+
588
+ # Remove the primary key from the update data
589
+ primary_key_value = data.pop(primary_key)
590
+
591
+ # Create the SQL using the processed data
592
+ fields = ", ".join(f"{field} = ?" for field in data)
593
+ values = tuple(data.values())
426
594
 
427
595
  update_sql = f"""
428
596
  UPDATE {table_name}
@@ -515,6 +683,7 @@ class SqliterDB:
515
683
 
516
684
  """
517
685
  self.connect()
686
+ self._in_transaction = True
518
687
  return self
519
688
 
520
689
  def __exit__(
@@ -552,3 +721,4 @@ class SqliterDB:
552
721
  # Close the connection and reset the instance variable
553
722
  self.conn.close()
554
723
  self.conn = None
724
+ self._in_transaction = False
@@ -1,14 +1,10 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: sqliter-py
3
- Version: 0.5.0
3
+ Version: 0.9.0
4
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
5
+ Author: Grant Ramsay
9
6
  Author-email: Grant Ramsay <grant@gnramsay.com>
10
7
  License-Expression: MIT
11
- License-File: LICENSE.txt
12
8
  Classifier: Development Status :: 4 - Beta
13
9
  Classifier: Intended Audience :: Developers
14
10
  Classifier: License :: OSI Approved :: MIT License
@@ -18,12 +14,19 @@ Classifier: Programming Language :: Python :: 3.9
18
14
  Classifier: Programming Language :: Python :: 3.10
19
15
  Classifier: Programming Language :: Python :: 3.11
20
16
  Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Database :: Front-Ends
21
19
  Classifier: Topic :: Software Development
22
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Dist: pydantic==2.11.5
22
+ Requires-Dist: inflect==7.0.0 ; extra == 'extras'
23
23
  Requires-Python: >=3.9
24
- Requires-Dist: pydantic>=2.9.0
24
+ Project-URL: Bug Tracker, https://github.com/seapagan/sqliter-py/issues
25
+ Project-URL: Changelog, https://github.com/seapagan/sqliter-py/blob/main/CHANGELOG.md
26
+ Project-URL: Homepage, http://sqliter.grantramsay.dev
27
+ Project-URL: Pull Requests, https://github.com/seapagan/sqliter-py/pulls
28
+ Project-URL: Repository, https://github.com/seapagan/sqliter-py
25
29
  Provides-Extra: extras
26
- Requires-Dist: inflect==7.0.0; extra == 'extras'
27
30
  Description-Content-Type: text/markdown
28
31
 
29
32
  # SQLiter <!-- omit in toc -->
@@ -47,22 +50,19 @@ time).
47
50
  The ideal use case is more for Python CLI tools that need to store data in a
48
51
  database-like format without needing to learn SQL or use a full ORM.
49
52
 
50
- Full documentation is available on the [Documentation
51
- Website](https://sqliter.grantramsay.dev)
53
+ Full documentation is available on the [Website](https://sqliter.grantramsay.dev)
52
54
 
53
55
  > [!CAUTION]
56
+ >
57
+ > Currently NOT compatible with Python 3.14 (I need to refactor some code d/t
58
+ > changes in the latest Pydantic versions, this is a priority.)
59
+ >
54
60
  > This project is still in the early stages of development and is lacking some
55
61
  > planned functionality. Please use with caution - Classes and methods may
56
62
  > change until a stable release is made. I'll try to keep this to an absolute
57
63
  > minimum and the releases and documentation will be very clear about any
58
64
  > breaking changes.
59
65
  >
60
- > Also, structures like `list`, `dict`, `set` etc are not supported **at this
61
- > time** as field types, since SQLite does not have a native column type for
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.
65
- >
66
66
  > See the [TODO](TODO.md) for planned features and improvements.
67
67
 
68
68
  - [Features](#features)
@@ -75,6 +75,12 @@ Website](https://sqliter.grantramsay.dev)
75
75
  ## Features
76
76
 
77
77
  - Table creation based on Pydantic models
78
+ - Supports `date` and `datetime` fields
79
+ - Support for complex data types (`list`, `dict`, `set`, `tuple`) stored as
80
+ BLOBs
81
+ - Automatic primary key generation
82
+ - User defined indexes on any field
83
+ - Set any field as UNIQUE
78
84
  - CRUD operations (Create, Read, Update, Delete)
79
85
  - Chained Query building with filtering, ordering, and pagination
80
86
  - Transaction support
@@ -98,16 +104,16 @@ virtual environments (`uv` is used for developing this project and in the CI):
98
104
  uv add sqliter-py
99
105
  ```
100
106
 
101
- With `pip`:
107
+ With `Poetry`:
102
108
 
103
109
  ```bash
104
- pip install sqliter-py
110
+ poetry add sqliter-py
105
111
  ```
106
112
 
107
- Or with `Poetry`:
113
+ Or with `pip`:
108
114
 
109
115
  ```bash
110
- poetry add sqliter-py
116
+ pip install sqliter-py
111
117
  ```
112
118
 
113
119
  ### Optional Dependencies
@@ -116,9 +122,9 @@ Currently by default, the only external dependency is Pydantic. However, there
116
122
  are some optional dependencies that can be installed to enable additional
117
123
  features:
118
124
 
119
- - `inflect`: For pluralizing table names (if not specified). This just offers a
120
- more-advanced pluralization than the default method used. In most cases you
121
- will not need this.
125
+ - `inflect`: For pluralizing the auto-generated table names (if not explicitly
126
+ set in the Model) This just offers a more-advanced pluralization than the
127
+ default method used. In most cases you will not need this.
122
128
 
123
129
  See [Installing Optional
124
130
  Dependencies](https://sqliter.grantramsay.dev/installation#optional-dependencies)
@@ -156,12 +162,16 @@ for user in results:
156
162
  new_user.age = 31
157
163
  db.update(new_user)
158
164
 
159
- # Delete a record
165
+ # Delete a record by primary key
160
166
  db.delete(User, new_user.pk)
167
+
168
+ # Delete all records returned from a query:
169
+ delete_count = db.select(User).filter(age__gt=30).delete()
161
170
  ```
162
171
 
163
- See the [Usage](https://sqliter.grantramsay.dev/usage) section of the documentation
164
- for more detailed information on how to use SQLiter, and advanced features.
172
+ See the [Guide](https://sqliter.grantramsay.dev/guide/guide/) section of the
173
+ documentation for more detailed information on how to use SQLiter, and advanced
174
+ features.
165
175
 
166
176
  ## Contributing
167
177
 
@@ -177,7 +187,7 @@ which you can read in the [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) file.
177
187
  This project is licensed under the MIT License.
178
188
 
179
189
  ```pre
180
- Copyright (c) 2024 Grant Ramsay
190
+ Copyright (c) 2024-2025 Grant Ramsay
181
191
 
182
192
  Permission is hereby granted, free of charge, to any person obtaining a copy
183
193
  of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,14 @@
1
+ sqliter/__init__.py,sha256=ECfn02OPmiMCQvRYbfizKFhVDk00xV-HV1s2cH9037I,244
2
+ sqliter/constants.py,sha256=pNKalajchG6YvXw0-lTMKOJ_OL2xPSs_3YuOuVsqkVk,1257
3
+ sqliter/exceptions.py,sha256=g-YZaPazBxlYxQ0lVP_MCWC-mQlX_qgyWip5LJCVLV4,5654
4
+ sqliter/helpers.py,sha256=d6k4oWl43Se_c8rAmX8Zj0_sf2O-bpaaeu33VN2IcsI,3442
5
+ sqliter/model/__init__.py,sha256=sBp6HcDZdespSYmA4sCXq9OikCFTNdFFkT-wM3JJ2Mw,396
6
+ sqliter/model/model.py,sha256=UOD5yO5CizHFWye_BlG28keSK6qWRdHQVI79Y7ovQ9o,8058
7
+ sqliter/model/unique.py,sha256=r4CXrW1GXB6OgNoOVDTpTEVZl8_zta3mYbbT5quMc-c,578
8
+ sqliter/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ sqliter/query/__init__.py,sha256=MRajhjTPJqjbmmrwndVKj8vqMbK5-XufpwoIswQf5z4,239
10
+ sqliter/query/query.py,sha256=yQjYtfSWxgEvbxQgQgwpjS6Z9tSFTQ2aISSKoQ9kaGM,25743
11
+ sqliter/sqliter.py,sha256=lPSvBrFrszCGT47CNPssmp4G7NiFccpdas4kpKZQmO0,25173
12
+ sqliter_py-0.9.0.dist-info/WHEEL,sha256=93kfTGt3a0Dykt_T-gsjtyS5_p8F_d6CE1NwmBOirzo,79
13
+ sqliter_py-0.9.0.dist-info/METADATA,sha256=2Rd3ABHO8IG82IZQOlryAOMoSzxYB-D_pPFmrR3SiwA,7566
14
+ sqliter_py-0.9.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.16
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -1,13 +0,0 @@
1
- sqliter/__init__.py,sha256=ECfn02OPmiMCQvRYbfizKFhVDk00xV-HV1s2cH9037I,244
2
- sqliter/constants.py,sha256=j0lE2cB1Uj8cNo40GGtCPdOR5asm-dHRDmGG0oyindA,1050
3
- sqliter/exceptions.py,sha256=g6jjcBnU2wnd_Sz90PCARl8PRayhU-D88W0tIUh548A,4808
4
- sqliter/helpers.py,sha256=75r4zMmGztVPm9_Bz3L1cSvBdx17uPEAnaggVhD70Pg,1138
5
- sqliter/sqliter.py,sha256=XLMIZZfMdRFv7iyGLH6N9uHE_0bAIxgvxgLxADYAjv8,18545
6
- sqliter/model/__init__.py,sha256=GgULmKRn0Dq0Jz6LbHGPndS6GP3vd1uxI1KrEevofLs,237
7
- sqliter/model/model.py,sha256=1aLe0QX5HEovOb9U6F9i_-fs4JqpPpewafd8KWINAkQ,4595
8
- sqliter/query/__init__.py,sha256=MRajhjTPJqjbmmrwndVKj8vqMbK5-XufpwoIswQf5z4,239
9
- sqliter/query/query.py,sha256=g9MbF3AGqNNha7QStxNv1qtIXv0A9FUgerur_0qhu8U,24081
10
- sqliter_py-0.5.0.dist-info/METADATA,sha256=-kRDsXK1GrxUoL3B9uNEMwnsNUwhGsUjYGuNsjSxZ2g,7271
11
- sqliter_py-0.5.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
12
- sqliter_py-0.5.0.dist-info/licenses/LICENSE.txt,sha256=-r4mvgoEWzkl1hPO5k8I_iMwJate7zDj8p_Fmn7dhVg,1078
13
- sqliter_py-0.5.0.dist-info/RECORD,,
@@ -1,4 +0,0 @@
1
- Wheel-Version: 1.0
2
- Generator: hatchling 1.25.0
3
- Root-Is-Purelib: true
4
- Tag: py3-none-any
@@ -1,20 +0,0 @@
1
- The MIT License (MIT)
2
- Copyright (c) 2024 Grant Ramsay
3
-
4
- Permission is hereby granted, free of charge, to any person obtaining a copy
5
- of this software and associated documentation files (the "Software"), to deal
6
- in the Software without restriction, including without limitation the rights
7
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
- copies of the Software, and to permit persons to whom the Software is
9
- furnished to do so, subject to the following conditions:
10
-
11
- The above copyright notice and this permission notice shall be included in all
12
- copies or substantial portions of the Software.
13
-
14
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
- IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
18
- DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
19
- OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
20
- OR OTHER DEALINGS IN THE SOFTWARE.