sqliter-py 0.6.0__tar.gz → 0.8.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.

@@ -220,3 +220,5 @@ pyrightconfig.json
220
220
  repopack-output.xml
221
221
  .envrc
222
222
  demo-db
223
+ .planning
224
+ .aider*
@@ -1,5 +1,5 @@
1
1
  The MIT License (MIT)
2
- Copyright (c) 2024 Grant Ramsay
2
+ Copyright (c) 2024-2025 Grant Ramsay
3
3
 
4
4
  Permission is hereby granted, free of charge, to any person obtaining a copy
5
5
  of this software and associated documentation files (the "Software"), to deal
@@ -1,7 +1,8 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: sqliter-py
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: Interact with SQLite databases using Python and Pydantic
5
+ Project-URL: Homepage, http://sqliter.grantramsay.dev
5
6
  Project-URL: Pull Requests, https://github.com/seapagan/sqliter-py/pulls
6
7
  Project-URL: Bug Tracker, https://github.com/seapagan/sqliter-py/issues
7
8
  Project-URL: Changelog, https://github.com/seapagan/sqliter-py/blob/main/CHANGELOG.md
@@ -18,6 +19,8 @@ Classifier: Programming Language :: Python :: 3.9
18
19
  Classifier: Programming Language :: Python :: 3.10
19
20
  Classifier: Programming Language :: Python :: 3.11
20
21
  Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Database :: Front-Ends
21
24
  Classifier: Topic :: Software Development
22
25
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
26
  Requires-Python: >=3.9
@@ -47,8 +50,7 @@ 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]
54
56
  > This project is still in the early stages of development and is lacking some
@@ -57,12 +59,6 @@ Website](https://sqliter.grantramsay.dev)
57
59
  > minimum and the releases and documentation will be very clear about any
58
60
  > breaking changes.
59
61
  >
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
62
  > See the [TODO](TODO.md) for planned features and improvements.
67
63
 
68
64
  - [Features](#features)
@@ -75,6 +71,8 @@ Website](https://sqliter.grantramsay.dev)
75
71
  ## Features
76
72
 
77
73
  - Table creation based on Pydantic models
74
+ - Supports `date` and `datetime` fields
75
+ - Support for complex data types (`list`, `dict`, `set`, `tuple`) stored as BLOBs
78
76
  - Automatic primary key generation
79
77
  - User defined indexes on any field
80
78
  - Set any field as UNIQUE
@@ -159,8 +157,11 @@ for user in results:
159
157
  new_user.age = 31
160
158
  db.update(new_user)
161
159
 
162
- # Delete a record
160
+ # Delete a record by primary key
163
161
  db.delete(User, new_user.pk)
162
+
163
+ # Delete all records returned from a query:
164
+ delete_count = db.select(User).filter(age__gt=30).delete()
164
165
  ```
165
166
 
166
167
  See the [Usage](https://sqliter.grantramsay.dev/usage) section of the documentation
@@ -180,7 +181,7 @@ which you can read in the [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) file.
180
181
  This project is licensed under the MIT License.
181
182
 
182
183
  ```pre
183
- Copyright (c) 2024 Grant Ramsay
184
+ Copyright (c) 2024-2025 Grant Ramsay
184
185
 
185
186
  Permission is hereby granted, free of charge, to any person obtaining a copy
186
187
  of this software and associated documentation files (the "Software"), to deal
@@ -19,8 +19,7 @@ time).
19
19
  The ideal use case is more for Python CLI tools that need to store data in a
20
20
  database-like format without needing to learn SQL or use a full ORM.
21
21
 
22
- Full documentation is available on the [Documentation
23
- Website](https://sqliter.grantramsay.dev)
22
+ Full documentation is available on the [Website](https://sqliter.grantramsay.dev)
24
23
 
25
24
  > [!CAUTION]
26
25
  > This project is still in the early stages of development and is lacking some
@@ -29,12 +28,6 @@ Website](https://sqliter.grantramsay.dev)
29
28
  > minimum and the releases and documentation will be very clear about any
30
29
  > breaking changes.
31
30
  >
32
- > Also, structures like `list`, `dict`, `set` etc are not supported **at this
33
- > time** as field types, since SQLite does not have a native column type for
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.
37
- >
38
31
  > See the [TODO](TODO.md) for planned features and improvements.
39
32
 
40
33
  - [Features](#features)
@@ -47,6 +40,8 @@ Website](https://sqliter.grantramsay.dev)
47
40
  ## Features
48
41
 
49
42
  - Table creation based on Pydantic models
43
+ - Supports `date` and `datetime` fields
44
+ - Support for complex data types (`list`, `dict`, `set`, `tuple`) stored as BLOBs
50
45
  - Automatic primary key generation
51
46
  - User defined indexes on any field
52
47
  - Set any field as UNIQUE
@@ -131,8 +126,11 @@ for user in results:
131
126
  new_user.age = 31
132
127
  db.update(new_user)
133
128
 
134
- # Delete a record
129
+ # Delete a record by primary key
135
130
  db.delete(User, new_user.pk)
131
+
132
+ # Delete all records returned from a query:
133
+ delete_count = db.select(User).filter(age__gt=30).delete()
136
134
  ```
137
135
 
138
136
  See the [Usage](https://sqliter.grantramsay.dev/usage) section of the documentation
@@ -152,7 +150,7 @@ which you can read in the [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) file.
152
150
  This project is licensed under the MIT License.
153
151
 
154
152
  ```pre
155
- Copyright (c) 2024 Grant Ramsay
153
+ Copyright (c) 2024-2025 Grant Ramsay
156
154
 
157
155
  Permission is hereby granted, free of charge, to any person obtaining a copy
158
156
  of this software and associated documentation files (the "Software"), to deal
@@ -3,7 +3,7 @@
3
3
 
4
4
  [project]
5
5
  name = "sqliter-py"
6
- version = "0.6.0"
6
+ version = "0.8.0"
7
7
  description = "Interact with SQLite databases using Python and Pydantic"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.9"
@@ -21,6 +21,8 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.10",
22
22
  "Programming Language :: Python :: 3.11",
23
23
  "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Database :: Front-Ends",
24
26
  "Topic :: Software Development",
25
27
  "Topic :: Software Development :: Libraries :: Python Modules",
26
28
  ]
@@ -29,7 +31,7 @@ classifiers = [
29
31
  extras = ["inflect==7.0.0"]
30
32
 
31
33
  [project.urls]
32
- # "HomeHage" = "https://xxxxxx"
34
+ "Homepage" = "http://sqliter.grantramsay.dev"
33
35
  "Pull Requests" = "https://github.com/seapagan/sqliter-py/pulls"
34
36
  "Bug Tracker" = "https://github.com/seapagan/sqliter-py/issues"
35
37
  "Changelog" = "https://github.com/seapagan/sqliter-py/blob/main/CHANGELOG.md"
@@ -100,12 +102,11 @@ changelog.help = "Generate a changelog"
100
102
  line-length = 80
101
103
  lint.select = ["ALL"] # we are being very strict!
102
104
  lint.ignore = [
103
- "ANN101",
104
- "ANN102",
105
105
  "PGH003",
106
106
  "FBT002",
107
107
  "FBT003",
108
108
  "B006",
109
+ "S301", # in this library we use 'pickle' for saving and loading list etc
109
110
  ] # These rules are too strict even for us 😝
110
111
  lint.extend-ignore = [
111
112
  "COM812",
@@ -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
  }
@@ -0,0 +1,100 @@
1
+ """Utility functions for SQLiter internal operations.
2
+
3
+ This module provides helper functions used across the SQLiter library,
4
+ primarily for type inference and mapping between Python and SQLite
5
+ data types. These utilities support the core functionality of model
6
+ to database schema translation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ from typing import Union
13
+
14
+ from sqliter.constants import SQLITE_TYPE_MAPPING
15
+
16
+
17
+ def infer_sqlite_type(field_type: Union[type, None]) -> str:
18
+ """Infer the SQLite column type based on the Python type.
19
+
20
+ This function maps Python types to their corresponding SQLite column
21
+ types. It's used when creating database tables to ensure that the
22
+ correct SQLite types are used for each field.
23
+
24
+ Args:
25
+ field_type: The Python type of the field, or None.
26
+
27
+ Returns:
28
+ A string representing the corresponding SQLite column type.
29
+
30
+ Note:
31
+ If the input type is None or not recognized, it defaults to 'TEXT'.
32
+ """
33
+ # If field_type is None, default to TEXT
34
+ if field_type is None:
35
+ return "TEXT"
36
+
37
+ # Map the simplified type to an SQLite type
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)
@@ -5,7 +5,7 @@ models in SQLiter applications, and the Unique class, which is used to
5
5
  define unique constraints on model fields.
6
6
  """
7
7
 
8
- from .model import BaseDBModel
8
+ from .model import BaseDBModel, SerializableField
9
9
  from .unique import Unique
10
10
 
11
- __all__ = ["BaseDBModel", "Unique"]
11
+ __all__ = ["BaseDBModel", "SerializableField", "Unique"]
@@ -9,11 +9,14 @@ in SQLiter applications.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import datetime
13
+ import pickle
12
14
  import re
13
15
  from typing import (
14
16
  Any,
15
17
  ClassVar,
16
18
  Optional,
19
+ Protocol,
17
20
  TypeVar,
18
21
  Union,
19
22
  cast,
@@ -23,9 +26,15 @@ from typing import (
23
26
 
24
27
  from pydantic import BaseModel, ConfigDict, Field
25
28
 
29
+ from sqliter.helpers import from_unix_timestamp, to_unix_timestamp
30
+
26
31
  T = TypeVar("T", bound="BaseDBModel")
27
32
 
28
33
 
34
+ class SerializableField(Protocol):
35
+ """Protocol for fields that can be serialized or deserialized."""
36
+
37
+
29
38
  class BaseDBModel(BaseModel):
30
39
  """Base model class for SQLiter database models.
31
40
 
@@ -38,11 +47,19 @@ class BaseDBModel(BaseModel):
38
47
  """
39
48
 
40
49
  pk: int = Field(0, description="The mandatory primary key of the table.")
50
+ created_at: int = Field(
51
+ default=0,
52
+ description="Unix timestamp when the record was created.",
53
+ )
54
+ updated_at: int = Field(
55
+ default=0,
56
+ description="Unix timestamp when the record was last updated.",
57
+ )
41
58
 
42
59
  model_config = ConfigDict(
43
60
  extra="ignore",
44
61
  populate_by_name=True,
45
- validate_assignment=False,
62
+ validate_assignment=True,
46
63
  from_attributes=True,
47
64
  )
48
65
 
@@ -151,3 +168,71 @@ class BaseDBModel(BaseModel):
151
168
  def should_create_pk(cls) -> bool:
152
169
  """Returns True since the primary key is always created."""
153
170
  return True
171
+
172
+ @classmethod
173
+ def serialize_field(cls, value: SerializableField) -> SerializableField:
174
+ """Serialize datetime or date fields to Unix timestamp.
175
+
176
+ Args:
177
+ field_name: The name of the field.
178
+ value: The value of the field.
179
+
180
+ Returns:
181
+ An integer Unix timestamp if the field is a datetime or date.
182
+ """
183
+ if isinstance(value, (datetime.datetime, datetime.date)):
184
+ return to_unix_timestamp(value)
185
+ if isinstance(value, (list, dict, set, tuple)):
186
+ return pickle.dumps(value)
187
+ return value # Return value as-is for other fields
188
+
189
+ # Deserialization after fetching from the database
190
+
191
+ @classmethod
192
+ def deserialize_field(
193
+ cls,
194
+ field_name: str,
195
+ value: SerializableField,
196
+ *,
197
+ return_local_time: bool,
198
+ ) -> object:
199
+ """Deserialize fields from Unix timestamp to datetime or date.
200
+
201
+ Args:
202
+ field_name: The name of the field being deserialized.
203
+ value: The Unix timestamp value fetched from the database.
204
+ return_local_time: Flag to control whether the datetime is localized
205
+ to the user's timezone.
206
+
207
+ Returns:
208
+ A datetime or date object if the field type is datetime or date,
209
+ otherwise returns the value as-is.
210
+ """
211
+ if value is None:
212
+ return None
213
+
214
+ # Get field type if it exists in model_fields
215
+ field_info = cls.model_fields.get(field_name)
216
+ if field_info is None:
217
+ # If field doesn't exist in model, return value as-is
218
+ return value
219
+
220
+ field_type = field_info.annotation
221
+
222
+ if (
223
+ isinstance(field_type, type)
224
+ and issubclass(field_type, (datetime.datetime, datetime.date))
225
+ and isinstance(value, int)
226
+ ):
227
+ return from_unix_timestamp(
228
+ value, field_type, localize=return_local_time
229
+ )
230
+
231
+ origin_type = get_origin(field_type) or field_type
232
+ if origin_type in (list, dict, set, tuple) and isinstance(value, bytes):
233
+ try:
234
+ return pickle.loads(value)
235
+ except pickle.UnpicklingError:
236
+ return value
237
+
238
+ return value
@@ -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
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import logging
12
12
  import sqlite3
13
+ import time
13
14
  from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
14
15
 
15
16
  from typing_extensions import Self
@@ -51,7 +52,9 @@ class SqliterDB:
51
52
  logger (Optional[logging.Logger]): Custom logger for debug output.
52
53
  """
53
54
 
54
- def __init__(
55
+ MEMORY_DB = ":memory:"
56
+
57
+ def __init__( # noqa: PLR0913
55
58
  self,
56
59
  db_filename: Optional[str] = None,
57
60
  *,
@@ -60,6 +63,7 @@ class SqliterDB:
60
63
  debug: bool = False,
61
64
  logger: Optional[logging.Logger] = None,
62
65
  reset: bool = False,
66
+ return_local_time: bool = True,
63
67
  ) -> None:
64
68
  """Initialize a new SqliterDB instance.
65
69
 
@@ -71,12 +75,13 @@ class SqliterDB:
71
75
  logger: Custom logger for debug output.
72
76
  reset: Whether to reset the database on initialization. This will
73
77
  basically drop all existing tables.
78
+ return_local_time: Whether to return local time for datetime fields.
74
79
 
75
80
  Raises:
76
81
  ValueError: If no filename is provided for a non-memory database.
77
82
  """
78
83
  if memory:
79
- self.db_filename = ":memory:"
84
+ self.db_filename = self.MEMORY_DB
80
85
  elif db_filename:
81
86
  self.db_filename = db_filename
82
87
  else:
@@ -90,6 +95,7 @@ class SqliterDB:
90
95
  self.logger = logger
91
96
  self.conn: Optional[sqlite3.Connection] = None
92
97
  self.reset = reset
98
+ self.return_local_time = return_local_time
93
99
 
94
100
  self._in_transaction = False
95
101
 
@@ -99,6 +105,54 @@ class SqliterDB:
99
105
  if self.reset:
100
106
  self._reset_database()
101
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
+
102
156
  def _reset_database(self) -> None:
103
157
  """Drop all user-created tables in the database."""
104
158
  with self.connect() as conn:
@@ -379,11 +433,18 @@ class SqliterDB:
379
433
  if not self._in_transaction and self.auto_commit and self.conn:
380
434
  self.conn.commit()
381
435
 
382
- def insert(self, model_instance: T) -> T:
436
+ def insert(
437
+ self, model_instance: T, *, timestamp_override: bool = False
438
+ ) -> T:
383
439
  """Insert a new record into the database.
384
440
 
385
441
  Args:
386
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.
387
448
 
388
449
  Returns:
389
450
  The updated model instance with the primary key (pk) set.
@@ -394,8 +455,28 @@ class SqliterDB:
394
455
  model_class = type(model_instance)
395
456
  table_name = model_class.get_table_name()
396
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
+
397
473
  # Get the data from the model
398
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
+
399
480
  # remove the primary key field if it exists, otherwise we'll get
400
481
  # TypeErrors as multiple primary keys will exist
401
482
  if data.get("pk", None) == 0:
@@ -422,7 +503,13 @@ class SqliterDB:
422
503
  raise RecordInsertionError(table_name) from exc
423
504
  else:
424
505
  data.pop("pk", None)
425
- 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)
426
513
 
427
514
  def get(
428
515
  self, model_class: type[BaseDBModel], primary_key_value: int
@@ -459,7 +546,17 @@ class SqliterDB:
459
546
  field: result[idx]
460
547
  for idx, field in enumerate(model_class.model_fields)
461
548
  }
462
- 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)
463
560
  except sqlite3.Error as exc:
464
561
  raise RecordFetchError(table_name) from exc
465
562
  else:
@@ -473,24 +570,27 @@ class SqliterDB:
473
570
 
474
571
  Raises:
475
572
  RecordUpdateError: If there's an error updating the record or if it
476
- is not found.
573
+ is not found.
477
574
  """
478
575
  model_class = type(model_instance)
479
576
  table_name = model_class.get_table_name()
480
-
481
577
  primary_key = model_class.get_primary_key()
482
578
 
483
- fields = ", ".join(
484
- f"{field} = ?"
485
- for field in model_class.model_fields
486
- if field != primary_key
487
- )
488
- values = tuple(
489
- getattr(model_instance, field)
490
- for field in model_class.model_fields
491
- if field != primary_key
492
- )
493
- 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())
494
594
 
495
595
  update_sql = f"""
496
596
  UPDATE {table_name}
@@ -1,35 +0,0 @@
1
- """Utility functions for SQLiter internal operations.
2
-
3
- This module provides helper functions used across the SQLiter library,
4
- primarily for type inference and mapping between Python and SQLite
5
- data types. These utilities support the core functionality of model
6
- to database schema translation.
7
- """
8
-
9
- from typing import Union
10
-
11
- from sqliter.constants import SQLITE_TYPE_MAPPING
12
-
13
-
14
- def infer_sqlite_type(field_type: Union[type, None]) -> str:
15
- """Infer the SQLite column type based on the Python type.
16
-
17
- This function maps Python types to their corresponding SQLite column
18
- types. It's used when creating database tables to ensure that the
19
- correct SQLite types are used for each field.
20
-
21
- Args:
22
- field_type: The Python type of the field, or None.
23
-
24
- Returns:
25
- A string representing the corresponding SQLite column type.
26
-
27
- Note:
28
- If the input type is None or not recognized, it defaults to 'TEXT'.
29
- """
30
- # If field_type is None, default to TEXT
31
- if field_type is None:
32
- return "TEXT"
33
-
34
- # Map the simplified type to an SQLite type
35
- return SQLITE_TYPE_MAPPING.get(field_type, "TEXT")