sqliter-py 0.5.0__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of sqliter-py might be problematic. Click here for more details.
- sqliter/constants.py +5 -0
- sqliter/exceptions.py +21 -0
- sqliter/helpers.py +65 -0
- sqliter/model/__init__.py +5 -3
- sqliter/model/model.py +86 -4
- sqliter/model/unique.py +19 -0
- sqliter/query/query.py +26 -8
- sqliter/sqliter.py +173 -19
- {sqliter_py-0.5.0.dist-info → sqliter_py-0.7.0.dist-info}/METADATA +13 -10
- sqliter_py-0.7.0.dist-info/RECORD +14 -0
- sqliter_py-0.5.0.dist-info/RECORD +0 -13
- {sqliter_py-0.5.0.dist-info → sqliter_py-0.7.0.dist-info}/WHEEL +0 -0
- {sqliter_py-0.5.0.dist-info → sqliter_py-0.7.0.dist-info}/licenses/LICENSE.txt +0 -0
sqliter/constants.py
CHANGED
|
@@ -6,8 +6,11 @@ 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.
|
|
13
|
+
|
|
11
14
|
OPERATOR_MAPPING = {
|
|
12
15
|
"__lt": "<",
|
|
13
16
|
"__lte": "<=",
|
|
@@ -34,4 +37,6 @@ SQLITE_TYPE_MAPPING = {
|
|
|
34
37
|
str: "TEXT",
|
|
35
38
|
bool: "INTEGER", # SQLite stores booleans as integers (0 or 1)
|
|
36
39
|
bytes: "BLOB",
|
|
40
|
+
datetime.datetime: "INTEGER", # Store as Unix timestamp
|
|
41
|
+
datetime.date: "INTEGER", # Store as Unix timestamp
|
|
37
42
|
}
|
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", "Unique", "SerializableField"]
|
sqliter/model/model.py
CHANGED
|
@@ -9,14 +9,31 @@ in SQLiter applications.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import datetime
|
|
12
13
|
import re
|
|
13
|
-
from typing import
|
|
14
|
+
from typing import (
|
|
15
|
+
Any,
|
|
16
|
+
ClassVar,
|
|
17
|
+
Optional,
|
|
18
|
+
Protocol,
|
|
19
|
+
TypeVar,
|
|
20
|
+
Union,
|
|
21
|
+
cast,
|
|
22
|
+
get_args,
|
|
23
|
+
get_origin,
|
|
24
|
+
)
|
|
14
25
|
|
|
15
26
|
from pydantic import BaseModel, ConfigDict, Field
|
|
16
27
|
|
|
28
|
+
from sqliter.helpers import from_unix_timestamp, to_unix_timestamp
|
|
29
|
+
|
|
17
30
|
T = TypeVar("T", bound="BaseDBModel")
|
|
18
31
|
|
|
19
32
|
|
|
33
|
+
class SerializableField(Protocol):
|
|
34
|
+
"""Protocol for fields that can be serialized or deserialized."""
|
|
35
|
+
|
|
36
|
+
|
|
20
37
|
class BaseDBModel(BaseModel):
|
|
21
38
|
"""Base model class for SQLiter database models.
|
|
22
39
|
|
|
@@ -29,6 +46,14 @@ class BaseDBModel(BaseModel):
|
|
|
29
46
|
"""
|
|
30
47
|
|
|
31
48
|
pk: int = Field(0, description="The mandatory primary key of the table.")
|
|
49
|
+
created_at: int = Field(
|
|
50
|
+
default=0,
|
|
51
|
+
description="Unix timestamp when the record was created.",
|
|
52
|
+
)
|
|
53
|
+
updated_at: int = Field(
|
|
54
|
+
default=0,
|
|
55
|
+
description="Unix timestamp when the record was last updated.",
|
|
56
|
+
)
|
|
32
57
|
|
|
33
58
|
model_config = ConfigDict(
|
|
34
59
|
extra="ignore",
|
|
@@ -41,14 +66,24 @@ class BaseDBModel(BaseModel):
|
|
|
41
66
|
"""Metadata class for configuring database-specific attributes.
|
|
42
67
|
|
|
43
68
|
Attributes:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
69
|
+
table_name (Optional[str]): The name of the database table. If not
|
|
70
|
+
specified, the table name will be inferred from the model class
|
|
71
|
+
name and converted to snake_case.
|
|
72
|
+
indexes (ClassVar[list[Union[str, tuple[str]]]]): A list of fields
|
|
73
|
+
or tuples of fields for which regular (non-unique) indexes
|
|
74
|
+
should be created. Indexes improve query performance on these
|
|
75
|
+
fields.
|
|
76
|
+
unique_indexes (ClassVar[list[Union[str, tuple[str]]]]): A list of
|
|
77
|
+
fields or tuples of fields for which unique indexes should be
|
|
78
|
+
created. Unique indexes enforce that all values in these fields
|
|
79
|
+
are distinct across the table.
|
|
47
80
|
"""
|
|
48
81
|
|
|
49
82
|
table_name: Optional[str] = (
|
|
50
83
|
None # Table name, defaults to class name if not set
|
|
51
84
|
)
|
|
85
|
+
indexes: ClassVar[list[Union[str, tuple[str]]]] = []
|
|
86
|
+
unique_indexes: ClassVar[list[Union[str, tuple[str]]]] = []
|
|
52
87
|
|
|
53
88
|
@classmethod
|
|
54
89
|
def model_validate_partial(cls: type[T], obj: dict[str, Any]) -> T:
|
|
@@ -132,3 +167,50 @@ class BaseDBModel(BaseModel):
|
|
|
132
167
|
def should_create_pk(cls) -> bool:
|
|
133
168
|
"""Returns True since the primary key is always created."""
|
|
134
169
|
return True
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def serialize_field(cls, value: SerializableField) -> SerializableField:
|
|
173
|
+
"""Serialize datetime or date fields to Unix timestamp.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
field_name: The name of the field.
|
|
177
|
+
value: The value of the field.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
An integer Unix timestamp if the field is a datetime or date.
|
|
181
|
+
"""
|
|
182
|
+
if isinstance(value, (datetime.datetime, datetime.date)):
|
|
183
|
+
return to_unix_timestamp(value)
|
|
184
|
+
return value # Return value as-is for non-datetime fields
|
|
185
|
+
|
|
186
|
+
# Deserialization after fetching from the database
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def deserialize_field(
|
|
190
|
+
cls,
|
|
191
|
+
field_name: str,
|
|
192
|
+
value: SerializableField,
|
|
193
|
+
*,
|
|
194
|
+
return_local_time: bool,
|
|
195
|
+
) -> object:
|
|
196
|
+
"""Deserialize fields from Unix timestamp to datetime or date.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
field_name: The name of the field being deserialized.
|
|
200
|
+
value: The Unix timestamp value fetched from the database.
|
|
201
|
+
return_local_time: Flag to control whether the datetime is localized
|
|
202
|
+
to the user's timezone.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
A datetime or date object if the field type is datetime or date,
|
|
206
|
+
otherwise returns the value as-is.
|
|
207
|
+
"""
|
|
208
|
+
field_type = cls.__annotations__.get(field_name)
|
|
209
|
+
|
|
210
|
+
if field_type in (datetime.datetime, datetime.date) and isinstance(
|
|
211
|
+
value, int
|
|
212
|
+
):
|
|
213
|
+
return from_unix_timestamp(
|
|
214
|
+
value, field_type, localize=return_local_time
|
|
215
|
+
)
|
|
216
|
+
return value # Return value as-is for non-datetime fields
|
sqliter/model/unique.py
ADDED
|
@@ -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/query/query.py
CHANGED
|
@@ -35,7 +35,7 @@ if TYPE_CHECKING: # pragma: no cover
|
|
|
35
35
|
from pydantic.fields import FieldInfo
|
|
36
36
|
|
|
37
37
|
from sqliter import SqliterDB
|
|
38
|
-
from sqliter.model import BaseDBModel
|
|
38
|
+
from sqliter.model import BaseDBModel, SerializableField
|
|
39
39
|
|
|
40
40
|
# Define a type alias for the possible value types
|
|
41
41
|
FilterValue = Union[
|
|
@@ -609,14 +609,32 @@ class QueryBuilder:
|
|
|
609
609
|
An instance of the model class populated with the row data.
|
|
610
610
|
"""
|
|
611
611
|
if self._fields:
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
return self.model_class(
|
|
616
|
-
**{
|
|
617
|
-
field: row[idx]
|
|
618
|
-
for idx, field in enumerate(self.model_class.model_fields)
|
|
612
|
+
data = {
|
|
613
|
+
field: self._deserialize(field, row[idx])
|
|
614
|
+
for idx, field in enumerate(self._fields)
|
|
619
615
|
}
|
|
616
|
+
return self.model_class.model_validate_partial(data)
|
|
617
|
+
|
|
618
|
+
data = {
|
|
619
|
+
field: self._deserialize(field, row[idx])
|
|
620
|
+
for idx, field in enumerate(self.model_class.model_fields)
|
|
621
|
+
}
|
|
622
|
+
return self.model_class(**data)
|
|
623
|
+
|
|
624
|
+
def _deserialize(
|
|
625
|
+
self, field_name: str, value: SerializableField
|
|
626
|
+
) -> SerializableField:
|
|
627
|
+
"""Deserialize a field value if needed.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
field_name: Name of the field being deserialized.
|
|
631
|
+
value: Value from the database.
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
The deserialized value.
|
|
635
|
+
"""
|
|
636
|
+
return self.model_class.deserialize_field(
|
|
637
|
+
field_name, value, return_local_time=self.db.return_local_time
|
|
620
638
|
)
|
|
621
639
|
|
|
622
640
|
@overload
|
sqliter/sqliter.py
CHANGED
|
@@ -10,12 +10,14 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import logging
|
|
12
12
|
import sqlite3
|
|
13
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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:
|
|
@@ -405,24 +554,27 @@ class SqliterDB:
|
|
|
405
554
|
|
|
406
555
|
Raises:
|
|
407
556
|
RecordUpdateError: If there's an error updating the record or if it
|
|
408
|
-
|
|
557
|
+
is not found.
|
|
409
558
|
"""
|
|
410
559
|
model_class = type(model_instance)
|
|
411
560
|
table_name = model_class.get_table_name()
|
|
412
|
-
|
|
413
561
|
primary_key = model_class.get_primary_key()
|
|
414
562
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
primary_key_value =
|
|
563
|
+
# Set updated_at timestamp
|
|
564
|
+
current_timestamp = int(time.time())
|
|
565
|
+
model_instance.updated_at = current_timestamp
|
|
566
|
+
|
|
567
|
+
# Get the data and serialize any datetime/date fields
|
|
568
|
+
data = model_instance.model_dump()
|
|
569
|
+
for field_name, value in list(data.items()):
|
|
570
|
+
data[field_name] = model_instance.serialize_field(value)
|
|
571
|
+
|
|
572
|
+
# Remove the primary key from the update data
|
|
573
|
+
primary_key_value = data.pop(primary_key)
|
|
574
|
+
|
|
575
|
+
# Create the SQL using the processed data
|
|
576
|
+
fields = ", ".join(f"{field} = ?" for field in data)
|
|
577
|
+
values = tuple(data.values())
|
|
426
578
|
|
|
427
579
|
update_sql = f"""
|
|
428
580
|
UPDATE {table_name}
|
|
@@ -515,6 +667,7 @@ class SqliterDB:
|
|
|
515
667
|
|
|
516
668
|
"""
|
|
517
669
|
self.connect()
|
|
670
|
+
self._in_transaction = True
|
|
518
671
|
return self
|
|
519
672
|
|
|
520
673
|
def __exit__(
|
|
@@ -552,3 +705,4 @@ class SqliterDB:
|
|
|
552
705
|
# Close the connection and reset the instance variable
|
|
553
706
|
self.conn.close()
|
|
554
707
|
self.conn = None
|
|
708
|
+
self._in_transaction = False
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: sqliter-py
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -60,8 +60,7 @@ Website](https://sqliter.grantramsay.dev)
|
|
|
60
60
|
> Also, structures like `list`, `dict`, `set` etc are not supported **at this
|
|
61
61
|
> time** as field types, since SQLite does not have a native column type for
|
|
62
62
|
> these. This is the **next planned enhancement**. These will need to be
|
|
63
|
-
> `pickled` first then stored as a BLOB in the database
|
|
64
|
-
> which can be stored as a Unix timestamp in an integer field.
|
|
63
|
+
> `pickled` first then stored as a BLOB in the database.
|
|
65
64
|
>
|
|
66
65
|
> See the [TODO](TODO.md) for planned features and improvements.
|
|
67
66
|
|
|
@@ -75,6 +74,10 @@ Website](https://sqliter.grantramsay.dev)
|
|
|
75
74
|
## Features
|
|
76
75
|
|
|
77
76
|
- Table creation based on Pydantic models
|
|
77
|
+
- Supports `date` and `datetime` fields. List/Dict/Set fields are planned.
|
|
78
|
+
- Automatic primary key generation
|
|
79
|
+
- User defined indexes on any field
|
|
80
|
+
- Set any field as UNIQUE
|
|
78
81
|
- CRUD operations (Create, Read, Update, Delete)
|
|
79
82
|
- Chained Query building with filtering, ordering, and pagination
|
|
80
83
|
- Transaction support
|
|
@@ -98,16 +101,16 @@ virtual environments (`uv` is used for developing this project and in the CI):
|
|
|
98
101
|
uv add sqliter-py
|
|
99
102
|
```
|
|
100
103
|
|
|
101
|
-
With `
|
|
104
|
+
With `Poetry`:
|
|
102
105
|
|
|
103
106
|
```bash
|
|
104
|
-
|
|
107
|
+
poetry add sqliter-py
|
|
105
108
|
```
|
|
106
109
|
|
|
107
|
-
Or with `
|
|
110
|
+
Or with `pip`:
|
|
108
111
|
|
|
109
112
|
```bash
|
|
110
|
-
|
|
113
|
+
pip install sqliter-py
|
|
111
114
|
```
|
|
112
115
|
|
|
113
116
|
### Optional Dependencies
|
|
@@ -116,9 +119,9 @@ Currently by default, the only external dependency is Pydantic. However, there
|
|
|
116
119
|
are some optional dependencies that can be installed to enable additional
|
|
117
120
|
features:
|
|
118
121
|
|
|
119
|
-
- `inflect`: For pluralizing table names (if not
|
|
120
|
-
|
|
121
|
-
will not need this.
|
|
122
|
+
- `inflect`: For pluralizing the auto-generated table names (if not explicitly
|
|
123
|
+
set in the Model) This just offers a more-advanced pluralization than the
|
|
124
|
+
default method used. In most cases you will not need this.
|
|
122
125
|
|
|
123
126
|
See [Installing Optional
|
|
124
127
|
Dependencies](https://sqliter.grantramsay.dev/installation#optional-dependencies)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
sqliter/__init__.py,sha256=ECfn02OPmiMCQvRYbfizKFhVDk00xV-HV1s2cH9037I,244
|
|
2
|
+
sqliter/constants.py,sha256=clai8tiF6g9raTKUvEcBA5srmLePa6GFX7zC4YQ_6Zo,1186
|
|
3
|
+
sqliter/exceptions.py,sha256=g-YZaPazBxlYxQ0lVP_MCWC-mQlX_qgyWip5LJCVLV4,5654
|
|
4
|
+
sqliter/helpers.py,sha256=d6k4oWl43Se_c8rAmX8Zj0_sf2O-bpaaeu33VN2IcsI,3442
|
|
5
|
+
sqliter/sqliter.py,sha256=Cvko-UqmdXbZqkVTglBlVAKUyPcDyiuiOjeNY6-_2EI,24340
|
|
6
|
+
sqliter/model/__init__.py,sha256=MblCLIdoUwvRQmTALxXTadW54vH8SyOShonkzktbH5A,396
|
|
7
|
+
sqliter/model/model.py,sha256=yc-fbiDa2swNd5yl1x-QP0vqeRTCPMpfZbIA6yG8k7M,7393
|
|
8
|
+
sqliter/model/unique.py,sha256=r4CXrW1GXB6OgNoOVDTpTEVZl8_zta3mYbbT5quMc-c,578
|
|
9
|
+
sqliter/query/__init__.py,sha256=MRajhjTPJqjbmmrwndVKj8vqMbK5-XufpwoIswQf5z4,239
|
|
10
|
+
sqliter/query/query.py,sha256=GL3Wh7jQY5n-X1oacVoHIFBTUa-zQJwzx8TM_ZEeuVE,24657
|
|
11
|
+
sqliter_py-0.7.0.dist-info/METADATA,sha256=ApjziCgXFt-N3KC_ogS1KuRV7_YRjoV2Plxv1HBBPzA,7395
|
|
12
|
+
sqliter_py-0.7.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
|
|
13
|
+
sqliter_py-0.7.0.dist-info/licenses/LICENSE.txt,sha256=-r4mvgoEWzkl1hPO5k8I_iMwJate7zDj8p_Fmn7dhVg,1078
|
|
14
|
+
sqliter_py-0.7.0.dist-info/RECORD,,
|
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|