sqliter-py 0.12.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/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """SQLiter: A lightweight ORM-like library for SQLite databases in Python.
2
+
3
+ This module provides the main SqliterDB class for interacting with
4
+ SQLite databases using Pydantic models.
5
+ """
6
+
7
+ from .sqliter import SqliterDB
8
+
9
+ __all__ = ["SqliterDB"]
sqliter/constants.py ADDED
@@ -0,0 +1,45 @@
1
+ """Constant values and mappings used throughout SQLiter.
2
+
3
+ This module defines constant dictionaries that map SQLiter-specific
4
+ concepts to their SQLite equivalents. It includes mappings for query
5
+ operators and data types, which are crucial for translating between
6
+ Pydantic models and SQLite database operations.
7
+ """
8
+
9
+ import datetime
10
+
11
+ # A dictionary mapping SQLiter filter operators to their corresponding SQL
12
+ # operators.
13
+ OPERATOR_MAPPING = {
14
+ "__lt": "<",
15
+ "__lte": "<=",
16
+ "__gt": ">",
17
+ "__gte": ">=",
18
+ "__eq": "=",
19
+ "__ne": "!=",
20
+ "__in": "IN",
21
+ "__not_in": "NOT IN",
22
+ "__isnull": "IS NULL",
23
+ "__notnull": "IS NOT NULL",
24
+ "__startswith": "LIKE",
25
+ "__endswith": "LIKE",
26
+ "__contains": "LIKE",
27
+ "__istartswith": "LIKE",
28
+ "__iendswith": "LIKE",
29
+ "__icontains": "LIKE",
30
+ }
31
+
32
+ # A dictionary mapping Python types to their corresponding SQLite column types.
33
+ SQLITE_TYPE_MAPPING = {
34
+ int: "INTEGER",
35
+ float: "REAL",
36
+ str: "TEXT",
37
+ bool: "INTEGER", # SQLite stores booleans as integers (0 or 1)
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",
45
+ }
sqliter/exceptions.py ADDED
@@ -0,0 +1,198 @@
1
+ """Custom exception classes for SQLiter error handling.
2
+
3
+ This module defines a hierarchy of exception classes specific to
4
+ SQLiter operations. These exceptions provide detailed error information
5
+ for various scenarios such as connection issues, invalid queries,
6
+ and CRUD operation failures, enabling more precise error handling
7
+ in applications using SQLiter.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import traceback
13
+
14
+
15
+ class SqliterError(Exception):
16
+ """Base exception class for all SQLiter-specific errors.
17
+
18
+ This class serves as the parent for all custom exceptions in SQLiter,
19
+ providing a consistent interface and message formatting.
20
+
21
+ Attributes:
22
+ message_template (str): A template string for the error message.
23
+ original_exception (Exception): The original exception that was caught.
24
+ """
25
+
26
+ message_template: str = "An error occurred in the SQLiter package."
27
+
28
+ def __init__(self, *args: object) -> None:
29
+ """Format the message using the provided arguments.
30
+
31
+ We also capture (and display) the current exception context and chain
32
+ any previous exceptions.
33
+
34
+ :param args: Arguments to format into the message template
35
+ """
36
+ if args:
37
+ message = self.message_template.format(*args)
38
+ else:
39
+ message = (
40
+ self.message_template.replace("'{}'", "")
41
+ .replace(":", "")
42
+ .strip()
43
+ )
44
+
45
+ # Capture the current exception context
46
+ self.original_exception = sys.exc_info()[1]
47
+
48
+ # If there's an active exception, append its information to our message
49
+ if self.original_exception:
50
+ original_type = type(self.original_exception).__name__
51
+ original_module = type(self.original_exception).__module__
52
+
53
+ # Get the traceback of the original exception
54
+ tb = traceback.extract_tb(self.original_exception.__traceback__)
55
+ if tb:
56
+ last_frame = tb[-1]
57
+ file_path = os.path.relpath(last_frame.filename)
58
+ line_number = last_frame.lineno
59
+ location = f"{file_path}:{line_number}"
60
+ else:
61
+ location = "unknown location"
62
+
63
+ message += (
64
+ f"\n --> {original_module}.{original_type} "
65
+ f"from {location}: {self.original_exception}"
66
+ )
67
+
68
+ # Call the parent constructor with our formatted message
69
+ super().__init__(message)
70
+
71
+ # Explicitly chain exceptions if there's an active one
72
+ if self.original_exception:
73
+ self.__cause__ = self.original_exception
74
+
75
+
76
+ class DatabaseConnectionError(SqliterError):
77
+ """Exception raised when a database connection cannot be established."""
78
+
79
+ message_template = "Failed to connect to the database: '{}'"
80
+
81
+
82
+ class InvalidOffsetError(SqliterError):
83
+ """Exception raised when an invalid offset value is provided."""
84
+
85
+ message_template = (
86
+ "Invalid offset value: '{}'. Offset must be a positive integer."
87
+ )
88
+
89
+
90
+ class InvalidOrderError(SqliterError):
91
+ """Exception raised when an invalid order specification is provided."""
92
+
93
+ message_template = "Invalid order value - {}"
94
+
95
+
96
+ class TableCreationError(SqliterError):
97
+ """Exception raised when a table cannot be created in the database."""
98
+
99
+ message_template = "Failed to create the table: '{}'"
100
+
101
+
102
+ class RecordInsertionError(SqliterError):
103
+ """Exception raised when a record cannot be inserted into the database."""
104
+
105
+ message_template = "Failed to insert record into table: '{}'"
106
+
107
+
108
+ class RecordUpdateError(SqliterError):
109
+ """Exception raised when a record cannot be updated in the database."""
110
+
111
+ message_template = "Failed to update record in table: '{}'"
112
+
113
+
114
+ class RecordNotFoundError(SqliterError):
115
+ """Exception raised when a requested record is not found in the database."""
116
+
117
+ message_template = "Failed to find that record in the table (key '{}') "
118
+
119
+
120
+ class RecordFetchError(SqliterError):
121
+ """Exception raised on an error fetching records from the database."""
122
+
123
+ message_template = "Failed to fetch record from table: '{}'"
124
+
125
+
126
+ class RecordDeletionError(SqliterError):
127
+ """Exception raised when a record cannot be deleted from the database."""
128
+
129
+ message_template = "Failed to delete record from table: '{}'"
130
+
131
+
132
+ class InvalidFilterError(SqliterError):
133
+ """Exception raised when an invalid filter is applied to a query."""
134
+
135
+ message_template = "Failed to apply filter: invalid field '{}'"
136
+
137
+
138
+ class TableDeletionError(SqliterError):
139
+ """Raised when a table cannot be deleted from the database."""
140
+
141
+ message_template = "Failed to delete the table: '{}'"
142
+
143
+
144
+ class SqlExecutionError(SqliterError):
145
+ """Raised when an SQL execution fails."""
146
+
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)
169
+
170
+
171
+ class ForeignKeyError(SqliterError):
172
+ """Base exception for foreign key related errors."""
173
+
174
+ message_template = "Foreign key error: {}"
175
+
176
+
177
+ class ForeignKeyConstraintError(ForeignKeyError):
178
+ """Raised when a foreign key constraint is violated.
179
+
180
+ This error occurs when attempting to insert/update a record with a
181
+ foreign key value that doesn't exist in the referenced table, or
182
+ when attempting to delete a record that is still referenced.
183
+ """
184
+
185
+ message_template = (
186
+ "Foreign key constraint violation: Cannot {} record - "
187
+ "referenced record {}"
188
+ )
189
+
190
+
191
+ class InvalidForeignKeyError(ForeignKeyError):
192
+ """Raised when an invalid foreign key configuration is detected.
193
+
194
+ This error occurs when defining a foreign key with invalid parameters,
195
+ such as using SET NULL without null=True.
196
+ """
197
+
198
+ message_template = "Invalid foreign key configuration: {}"
sqliter/helpers.py ADDED
@@ -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)
@@ -0,0 +1,46 @@
1
+ """This module provides the base model class for SQLiter database models.
2
+
3
+ It exports the BaseDBModel class, which is used to define database
4
+ models in SQLiter applications, and the unique function, which is used to
5
+ define unique constraints on model fields.
6
+ """
7
+
8
+ import warnings
9
+ from typing import Any
10
+
11
+ from typing_extensions import deprecated
12
+
13
+ from .foreign_key import ForeignKey, ForeignKeyInfo, get_foreign_key_info
14
+ from .model import BaseDBModel, SerializableField
15
+ from .unique import unique
16
+
17
+
18
+ @deprecated("Use 'unique' instead. Will be removed in a future version.")
19
+ def Unique(default: Any = ..., **kwargs: Any) -> Any: # noqa: ANN401, N802
20
+ """Deprecated: Use 'unique' instead. Will be removed in a future version.
21
+
22
+ Args:
23
+ default: The default value for the field.
24
+ **kwargs: Additional keyword arguments to pass to Field.
25
+
26
+ Returns:
27
+ A Field with unique metadata attached.
28
+ """
29
+ warnings.warn(
30
+ "Unique is deprecated and will be removed in a future version. "
31
+ "Use 'unique' instead.",
32
+ DeprecationWarning,
33
+ stacklevel=2,
34
+ )
35
+ return unique(default=default, **kwargs)
36
+
37
+
38
+ __all__ = [
39
+ "BaseDBModel",
40
+ "ForeignKey",
41
+ "ForeignKeyInfo",
42
+ "SerializableField",
43
+ "Unique",
44
+ "get_foreign_key_info",
45
+ "unique",
46
+ ]
@@ -0,0 +1,153 @@
1
+ """Foreign key support for SQLiter ORM.
2
+
3
+ This module provides the ForeignKey factory function and ForeignKeyInfo
4
+ dataclass for defining foreign key relationships between models.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, Any, Literal, Optional
11
+
12
+ from pydantic import Field
13
+
14
+ from sqliter.exceptions import InvalidForeignKeyError
15
+
16
+ if TYPE_CHECKING: # pragma: no cover
17
+ from pydantic.fields import FieldInfo
18
+
19
+ from sqliter.model.model import BaseDBModel
20
+
21
+ # Type alias for foreign key actions
22
+ FKAction = Literal["CASCADE", "SET NULL", "RESTRICT", "NO ACTION"]
23
+
24
+
25
+ @dataclass
26
+ class ForeignKeyInfo:
27
+ """Metadata about a foreign key relationship.
28
+
29
+ Attributes:
30
+ to_model: The target model class that this foreign key references.
31
+ on_delete: Action to take when the referenced record is deleted.
32
+ on_update: Action to take when the referenced record's PK is updated.
33
+ null: Whether the foreign key field can be NULL.
34
+ unique: Whether the foreign key field must be unique (one-to-one).
35
+ related_name: Optional name for the reverse relationship (Phase 2).
36
+ db_column: Optional custom column name in the database.
37
+ """
38
+
39
+ to_model: type[BaseDBModel]
40
+ on_delete: FKAction
41
+ on_update: FKAction
42
+ null: bool
43
+ unique: bool
44
+ related_name: Optional[str]
45
+ db_column: Optional[str]
46
+
47
+
48
+ def ForeignKey( # noqa: N802, PLR0913
49
+ to: type[BaseDBModel],
50
+ *,
51
+ on_delete: FKAction = "RESTRICT",
52
+ on_update: FKAction = "RESTRICT",
53
+ null: bool = False,
54
+ unique: bool = False,
55
+ related_name: Optional[str] = None,
56
+ db_column: Optional[str] = None,
57
+ default: Any = ..., # noqa: ANN401
58
+ **kwargs: Any, # noqa: ANN401
59
+ ) -> Any: # noqa: ANN401
60
+ """Create a foreign key field.
61
+
62
+ This function creates a Pydantic Field with foreign key metadata stored
63
+ in json_schema_extra. In Phase 1, this provides FK constraint support.
64
+ Phase 2 will add descriptor support for `book.author` style access.
65
+
66
+ Args:
67
+ to: The target model class that this foreign key references.
68
+ on_delete: Action when referenced record is deleted.
69
+ - CASCADE: Delete this record too.
70
+ - SET NULL: Set this field to NULL (requires null=True).
71
+ - RESTRICT: Prevent deletion if references exist (default).
72
+ - NO ACTION: Similar to RESTRICT in SQLite.
73
+ on_update: Action when referenced record's PK is updated.
74
+ - CASCADE: Update this field to the new PK value.
75
+ - SET NULL: Set this field to NULL (requires null=True).
76
+ - RESTRICT: Prevent update if references exist (default).
77
+ - NO ACTION: Similar to RESTRICT in SQLite.
78
+ null: Whether the foreign key field can be NULL. Default is False.
79
+ unique: Whether the field must be unique (creates one-to-one
80
+ relationship). Default is False.
81
+ related_name: Optional name for the reverse relationship. Reserved
82
+ for Phase 2 implementation.
83
+ db_column: Optional custom column name. If not specified, defaults
84
+ to `{field_name}_id`.
85
+ default: Default value for the field. If null=True and no default
86
+ is provided, defaults to None.
87
+ **kwargs: Additional keyword arguments passed to Pydantic Field.
88
+
89
+ Returns:
90
+ A Pydantic Field with foreign key metadata.
91
+
92
+ Raises:
93
+ InvalidForeignKeyError: If SET NULL action is used without null=True.
94
+
95
+ Example:
96
+ >>> class Book(BaseDBModel):
97
+ ... title: str
98
+ ... author_id: int = ForeignKey(Author, on_delete="CASCADE")
99
+ """
100
+ # Validate SET NULL requires null=True
101
+ if on_delete == "SET NULL" and not null:
102
+ msg = "on_delete='SET NULL' requires null=True"
103
+ raise InvalidForeignKeyError(msg)
104
+ if on_update == "SET NULL" and not null:
105
+ msg = "on_update='SET NULL' requires null=True"
106
+ raise InvalidForeignKeyError(msg)
107
+
108
+ # Handle existing json_schema_extra
109
+ existing_extra = kwargs.pop("json_schema_extra", {})
110
+ if not isinstance(existing_extra, dict):
111
+ existing_extra = {}
112
+
113
+ # Create ForeignKeyInfo metadata
114
+ fk_info = ForeignKeyInfo(
115
+ to_model=to,
116
+ on_delete=on_delete,
117
+ on_update=on_update,
118
+ null=null,
119
+ unique=unique,
120
+ related_name=related_name,
121
+ db_column=db_column,
122
+ )
123
+
124
+ # Store FK metadata in json_schema_extra
125
+ existing_extra["foreign_key"] = fk_info
126
+ if unique:
127
+ existing_extra["unique"] = True
128
+
129
+ # Set default value
130
+ if default is ... and "default_factory" not in kwargs:
131
+ default = None if null else ...
132
+
133
+ return Field(default=default, json_schema_extra=existing_extra, **kwargs)
134
+
135
+
136
+ def get_foreign_key_info(field_info: FieldInfo) -> Optional[ForeignKeyInfo]:
137
+ """Extract ForeignKeyInfo from a field if it's a foreign key.
138
+
139
+ Args:
140
+ field_info: The Pydantic FieldInfo to examine.
141
+
142
+ Returns:
143
+ The ForeignKeyInfo if the field is a foreign key, None otherwise.
144
+ """
145
+ if not hasattr(field_info, "json_schema_extra"):
146
+ return None
147
+ extra = field_info.json_schema_extra
148
+ if not isinstance(extra, dict):
149
+ return None
150
+ fk_info = extra.get("foreign_key")
151
+ if isinstance(fk_info, ForeignKeyInfo):
152
+ return fk_info
153
+ return None