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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sqliter-py might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sqliter-py
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -75,6 +75,9 @@ Website](https://sqliter.grantramsay.dev)
75
75
  ## Features
76
76
 
77
77
  - Table creation based on Pydantic models
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 `pip`:
104
+ With `Poetry`:
102
105
 
103
106
  ```bash
104
- pip install sqliter-py
107
+ poetry add sqliter-py
105
108
  ```
106
109
 
107
- Or with `Poetry`:
110
+ Or with `pip`:
108
111
 
109
112
  ```bash
110
- poetry add sqliter-py
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 specified). This just offers a
120
- more-advanced pluralization than the default method used. In most cases you
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)
@@ -47,6 +47,9 @@ Website](https://sqliter.grantramsay.dev)
47
47
  ## Features
48
48
 
49
49
  - Table creation based on Pydantic models
50
+ - Automatic primary key generation
51
+ - User defined indexes on any field
52
+ - Set any field as UNIQUE
50
53
  - CRUD operations (Create, Read, Update, Delete)
51
54
  - Chained Query building with filtering, ordering, and pagination
52
55
  - Transaction support
@@ -70,16 +73,16 @@ virtual environments (`uv` is used for developing this project and in the CI):
70
73
  uv add sqliter-py
71
74
  ```
72
75
 
73
- With `pip`:
76
+ With `Poetry`:
74
77
 
75
78
  ```bash
76
- pip install sqliter-py
79
+ poetry add sqliter-py
77
80
  ```
78
81
 
79
- Or with `Poetry`:
82
+ Or with `pip`:
80
83
 
81
84
  ```bash
82
- poetry add sqliter-py
85
+ pip install sqliter-py
83
86
  ```
84
87
 
85
88
  ### Optional Dependencies
@@ -88,9 +91,9 @@ Currently by default, the only external dependency is Pydantic. However, there
88
91
  are some optional dependencies that can be installed to enable additional
89
92
  features:
90
93
 
91
- - `inflect`: For pluralizing table names (if not specified). This just offers a
92
- more-advanced pluralization than the default method used. In most cases you
93
- will not need this.
94
+ - `inflect`: For pluralizing the auto-generated table names (if not explicitly
95
+ set in the Model) This just offers a more-advanced pluralization than the
96
+ default method used. In most cases you will not need this.
94
97
 
95
98
  See [Installing Optional
96
99
  Dependencies](https://sqliter.grantramsay.dev/installation#optional-dependencies)
@@ -3,7 +3,7 @@
3
3
 
4
4
  [project]
5
5
  name = "sqliter-py"
6
- version = "0.5.0"
6
+ version = "0.6.0"
7
7
  description = "Interact with SQLite databases using Python and Pydantic"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.9"
@@ -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)
@@ -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
8
  from .model import BaseDBModel
9
+ from .unique import Unique
8
10
 
9
- __all__ = ["BaseDBModel"]
11
+ __all__ = ["BaseDBModel", "Unique"]
@@ -10,7 +10,16 @@ in SQLiter applications.
10
10
  from __future__ import annotations
11
11
 
12
12
  import re
13
- from typing import Any, Optional, TypeVar, Union, cast, get_args, get_origin
13
+ from typing import (
14
+ Any,
15
+ ClassVar,
16
+ Optional,
17
+ TypeVar,
18
+ Union,
19
+ cast,
20
+ get_args,
21
+ get_origin,
22
+ )
14
23
 
15
24
  from pydantic import BaseModel, ConfigDict, Field
16
25
 
@@ -41,14 +50,24 @@ class BaseDBModel(BaseModel):
41
50
  """Metadata class for configuring database-specific attributes.
42
51
 
43
52
  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.
53
+ table_name (Optional[str]): The name of the database table. If not
54
+ specified, the table name will be inferred from the model class
55
+ name and converted to snake_case.
56
+ indexes (ClassVar[list[Union[str, tuple[str]]]]): A list of fields
57
+ or tuples of fields for which regular (non-unique) indexes
58
+ should be created. Indexes improve query performance on these
59
+ fields.
60
+ unique_indexes (ClassVar[list[Union[str, tuple[str]]]]): A list of
61
+ fields or tuples of fields for which unique indexes should be
62
+ created. Unique indexes enforce that all values in these fields
63
+ are distinct across the table.
47
64
  """
48
65
 
49
66
  table_name: Optional[str] = (
50
67
  None # Table name, defaults to class name if not set
51
68
  )
69
+ indexes: ClassVar[list[Union[str, tuple[str]]]] = []
70
+ unique_indexes: ClassVar[list[Union[str, tuple[str]]]] = []
52
71
 
53
72
  @classmethod
54
73
  def model_validate_partial(cls: type[T], obj: dict[str, Any]) -> T:
@@ -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
@@ -10,12 +10,13 @@ from __future__ import annotations
10
10
 
11
11
  import logging
12
12
  import sqlite3
13
- from typing import TYPE_CHECKING, Any, Optional, TypeVar
13
+ from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
14
14
 
15
15
  from typing_extensions import Self
16
16
 
17
17
  from sqliter.exceptions import (
18
18
  DatabaseConnectionError,
19
+ InvalidIndexError,
19
20
  RecordDeletionError,
20
21
  RecordFetchError,
21
22
  RecordInsertionError,
@@ -26,6 +27,7 @@ from sqliter.exceptions import (
26
27
  TableDeletionError,
27
28
  )
28
29
  from sqliter.helpers import infer_sqlite_type
30
+ from sqliter.model.unique import Unique
29
31
  from sqliter.query.query import QueryBuilder
30
32
 
31
33
  if TYPE_CHECKING: # pragma: no cover
@@ -89,6 +91,8 @@ class SqliterDB:
89
91
  self.conn: Optional[sqlite3.Connection] = None
90
92
  self.reset = reset
91
93
 
94
+ self._in_transaction = False
95
+
92
96
  if self.debug:
93
97
  self._setup_logger()
94
98
 
@@ -236,7 +240,12 @@ class SqliterDB:
236
240
  for field_name, field_info in model_class.model_fields.items():
237
241
  if field_name != primary_key:
238
242
  sqlite_type = infer_sqlite_type(field_info.annotation)
239
- fields.append(f"{field_name} {sqlite_type}")
243
+ unique_constraint = (
244
+ "UNIQUE" if isinstance(field_info, Unique) else ""
245
+ )
246
+ fields.append(
247
+ f"{field_name} {sqlite_type} {unique_constraint}".strip()
248
+ )
240
249
 
241
250
  create_str = (
242
251
  "CREATE TABLE IF NOT EXISTS" if exists_ok else "CREATE TABLE"
@@ -259,6 +268,65 @@ class SqliterDB:
259
268
  except sqlite3.Error as exc:
260
269
  raise TableCreationError(table_name) from exc
261
270
 
271
+ # Create regular indexes
272
+ if hasattr(model_class.Meta, "indexes"):
273
+ self._create_indexes(
274
+ model_class, model_class.Meta.indexes, unique=False
275
+ )
276
+
277
+ # Create unique indexes
278
+ if hasattr(model_class.Meta, "unique_indexes"):
279
+ self._create_indexes(
280
+ model_class, model_class.Meta.unique_indexes, unique=True
281
+ )
282
+
283
+ def _create_indexes(
284
+ self,
285
+ model_class: type[BaseDBModel],
286
+ indexes: list[Union[str, tuple[str]]],
287
+ *,
288
+ unique: bool = False,
289
+ ) -> None:
290
+ """Helper method to create regular or unique indexes.
291
+
292
+ Args:
293
+ model_class: The model class defining the table.
294
+ indexes: List of fields or tuples of fields to create indexes for.
295
+ unique: If True, creates UNIQUE indexes; otherwise, creates regular
296
+ indexes.
297
+
298
+ Raises:
299
+ InvalidIndexError: If any fields specified for indexing do not exist
300
+ in the model.
301
+ """
302
+ valid_fields = set(
303
+ model_class.model_fields.keys()
304
+ ) # Get valid fields from the model
305
+
306
+ for index in indexes:
307
+ # Handle multiple fields in tuple form
308
+ fields = list(index) if isinstance(index, tuple) else [index]
309
+
310
+ # Check if all fields exist in the model
311
+ invalid_fields = [
312
+ field for field in fields if field not in valid_fields
313
+ ]
314
+ if invalid_fields:
315
+ raise InvalidIndexError(invalid_fields, model_class.__name__)
316
+
317
+ # Build the SQL string
318
+ index_name = "_".join(fields)
319
+ index_postfix = "_unique" if unique else ""
320
+ index_type = " UNIQUE " if unique else " "
321
+
322
+ create_index_sql = (
323
+ f"CREATE{index_type}INDEX IF NOT EXISTS "
324
+ f"idx_{model_class.get_table_name()}"
325
+ f"_{index_name}{index_postfix} "
326
+ f"ON {model_class.get_table_name()} ({', '.join(fields)})"
327
+ )
328
+ self._execute_sql(create_index_sql)
329
+
262
330
  def _execute_sql(self, sql: str) -> None:
263
331
  """Execute an SQL statement.
264
332
 
@@ -308,7 +376,7 @@ class SqliterDB:
308
376
  This method is called after operations that modify the database,
309
377
  committing changes only if auto_commit is set to True.
310
378
  """
311
- if self.auto_commit and self.conn:
379
+ if not self._in_transaction and self.auto_commit and self.conn:
312
380
  self.conn.commit()
313
381
 
314
382
  def insert(self, model_instance: T) -> T:
@@ -515,6 +583,7 @@ class SqliterDB:
515
583
 
516
584
  """
517
585
  self.connect()
586
+ self._in_transaction = True
518
587
  return self
519
588
 
520
589
  def __exit__(
@@ -552,3 +621,4 @@ class SqliterDB:
552
621
  # Close the connection and reset the instance variable
553
622
  self.conn.close()
554
623
  self.conn = None
624
+ self._in_transaction = False
File without changes
File without changes