Skip to content

Commit

Permalink
Merge pull request #45 from seapagan/indexes
Browse files Browse the repository at this point in the history
  • Loading branch information
seapagan authored Oct 12, 2024
2 parents cf8797f + 61b44ef commit 06f9e0b
Show file tree
Hide file tree
Showing 11 changed files with 521 additions and 37 deletions.
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Website](https://sqliter.grantramsay.dev)
## Features

- Table creation based on Pydantic models
- Automatic primary key generation
- User defined indexes on any field
- CRUD operations (Create, Read, Update, Delete)
- Chained Query building with filtering, ordering, and pagination
- Transaction support
Expand All @@ -70,16 +72,16 @@ virtual environments (`uv` is used for developing this project and in the CI):
uv add sqliter-py
```

With `pip`:
With `Poetry`:

```bash
pip install sqliter-py
poetry add sqliter-py
```

Or with `Poetry`:
Or with `pip`:

```bash
poetry add sqliter-py
pip install sqliter-py
```

### Optional Dependencies
Expand All @@ -88,9 +90,9 @@ Currently by default, the only external dependency is Pydantic. However, there
are some optional dependencies that can be installed to enable additional
features:

- `inflect`: For pluralizing table names (if not specified). This just offers a
more-advanced pluralization than the default method used. In most cases you
will not need this.
- `inflect`: For pluralizing the auto-generated table names (if not explicitly
set in the Model) This just offers a more-advanced pluralization than the
default method used. In most cases you will not need this.

See [Installing Optional
Dependencies](https://sqliter.grantramsay.dev/installation#optional-dependencies)
Expand Down
1 change: 0 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
addition to the `delete` method in the main class which deletes a single
record based on the primary key.
- add a `rollback` method to the main class to allow manual rollbacks.
- allow adding multiple indexes to each table as well as the primary key.
- allow adding foreign keys and relationships to each table.
- add a migration system to allow updating the database schema without losing
data.
Expand Down
28 changes: 16 additions & 12 deletions docs/guide/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,51 +10,55 @@ class, `SqliterError`, to ensure consistency across error messages and behavior.
- **Message**: "An error occurred in the SQLiter package."

- **`DatabaseConnectionError`**:
- Raised when the SQLite database connection fails.
- **Raised** when the SQLite database connection fails.
- **Message**: "Failed to connect to the database: '{}'."

- **`InvalidOffsetError`**:
- Raised when an invalid offset value (0 or negative) is used in queries.
- **Raised** when an invalid offset value (0 or negative) is used in queries.
- **Message**: "Invalid offset value: '{}'. Offset must be a positive
integer."

- **`InvalidOrderError`**:
- Raised when an invalid order value is used in queries, such as a
- **Raised** when an invalid order value is used in queries, such as a
non-existent field or an incorrect sorting direction.
- **Message**: "Invalid order value - '{}'"

- **`TableCreationError`**:
- Raised when a table cannot be created in the database.
- **Raised** when a table cannot be created in the database.
- **Message**: "Failed to create the table: '{}'."

- **`RecordInsertionError`**:
- Raised when an error occurs during record insertion.
- **Raised** when an error occurs during record insertion.
- **Message**: "Failed to insert record into table: '{}'."

- **`RecordUpdateError`**:
- Raised when an error occurs during record update.
- **Raised** when an error occurs during record update.
- **Message**: "Failed to update record in table: '{}'."

- **`RecordNotFoundError`**:
- Raised when a record with the specified primary key is not found.
- **Raised** when a record with the specified primary key is not found.
- **Message**: "Failed to find a record for key '{}'".

- **`RecordFetchError`**:
- Raised when an error occurs while fetching records from the database.
- **Raised** when an error occurs while fetching records from the database.
- **Message**: "Failed to fetch record from table: '{}'."

- **`RecordDeletionError`**:
- Raised when an error occurs during record deletion.
- **Raised** when an error occurs during record deletion.
- **Message**: "Failed to delete record from table: '{}'."

- **`InvalidFilterError`**:
- Raised when an invalid filter field is used in a query.
- **Raised** when an invalid filter field is used in a query.
- **Message**: "Failed to apply filter: invalid field '{}'".

- **`TableDeletionError`**:
- Raised when a table cannot be deleted from the database.
- **Raised** when a table cannot be deleted from the database.
- **Message**: "Failed to delete the table: '{}'."

- **SqlExecutionError**
- Raised when an error occurs during SQL query execution.
- **Raised** when an error occurs during SQL query execution.
- **Message**: "Failed to execute SQL: '{}'."

- **InvalidIndexError**
- **Raised** when an invalid index is specified for a model.
- **Message**: "Invalid fields for indexing in model '{}': {}"
47 changes: 47 additions & 0 deletions docs/guide/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,53 @@ the table.
> - The Model **automatically** creates an **auto-incrementing integer primary
> key** for each table called `pk`, you do not need to define it yourself.
### Adding Indexes

You can add indexes to your table by specifying the `indexes` attribute in the
`Meta` class. This should be a list of strings, each string being the name of an
existing field in the model that should be indexed.

```python
from sqliter.model import BaseDBModel

class User(BaseDBModel):
name: str
age: int
email: str

class Meta:
indexes = ["name", "email"]
```

This is in addition to the primary key index (`pk`) that is automatically
created.

### Adding Unique Indexes

You can add unique indexes to your table by specifying the `unique_indexes`
attribute in the `Meta` class. This should be a list of strings, each string
being the name of an existing field in the model that should be indexed.

```python
from sqliter.model import BaseDBModel

class User(BaseDBModel):
name: str
age: int
email: str

class Meta:
unique_indexes = ["email"]
```

These will ensure that all values in this field are unique. This is in addition
to the primary key index (`pk`) that is automatically created.

> [!TIP]
>
> You can specify both `indexes` and `unique_indexes` in the `Meta` class if you
> need to.
### Custom Table Name

By default, the table name will be the same as the model name, converted to
Expand Down
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ database-like format without needing to learn SQL or use a full ORM.
## Features

- Table creation based on Pydantic models
- Automatic primary key generation
- User defined indexes on any field
- CRUD operations (Create, Read, Update, Delete)
- Chained Query building with filtering, ordering, and pagination
- Transaction support
Expand Down
16 changes: 8 additions & 8 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ virtual environments (`uv` is used for developing this project and in the CI):
uv add sqliter-py
```

With `pip`:
With `Poetry`:

```bash
pip install sqliter-py
poetry add sqliter-py
```

Or with `Poetry`:
Or with `pip`:

```bash
poetry add sqliter-py
pip install sqliter-py
```

## Optional Dependencies
Expand All @@ -38,14 +38,14 @@ These can be installed using `uv`:
uv add 'sqliter-py[extras]'
```

Or with `pip`:
With `Poetry`:

```bash
pip install 'sqliter-py[extras]'
poetry add 'sqliter-py[extras]'
```

Or with `Poetry`:
Or with `pip`:

```bash
poetry add 'sqliter-py[extras]'
pip install 'sqliter-py[extras]'
```
4 changes: 0 additions & 4 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ class User(BaseDBModel):
age: int
admin: Optional[bool] = False

class Meta:
create_pk = False
primary_key = "name"

# Create a database connection
db = SqliterDB("example.db")

Expand Down
21 changes: 21 additions & 0 deletions sqliter/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,24 @@ class SqlExecutionError(SqliterError):
"""Raised when an SQL execution fails."""

message_template = "Failed to execute SQL: '{}'"


class InvalidIndexError(SqliterError):
"""Exception raised when an invalid index field is specified.
This error is triggered if one or more fields specified for an index
do not exist in the model's fields.
Attributes:
invalid_fields (list[str]): The list of fields that were invalid.
model_class (str): The name of the model where the error occurred.
"""

message_template = "Invalid fields for indexing in model '{}': {}"

def __init__(self, invalid_fields: list[str], model_class: str) -> None:
"""Tidy up the error message by joining the invalid fields."""
# Join invalid fields into a comma-separated string
invalid_fields_str = ", ".join(invalid_fields)
# Pass the formatted message to the parent class
super().__init__(model_class, invalid_fields_str)
27 changes: 23 additions & 4 deletions sqliter/model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@
from __future__ import annotations

import re
from typing import Any, Optional, TypeVar, Union, cast, get_args, get_origin
from typing import (
Any,
ClassVar,
Optional,
TypeVar,
Union,
cast,
get_args,
get_origin,
)

from pydantic import BaseModel, ConfigDict, Field

Expand Down Expand Up @@ -41,14 +50,24 @@ class Meta:
"""Metadata class for configuring database-specific attributes.
Attributes:
create_pk (bool): Whether to create a primary key field.
primary_key (str): The name of the primary key field.
table_name (Optional[str]): The name of the database table.
table_name (Optional[str]): The name of the database table. If not
specified, the table name will be inferred from the model class
name and converted to snake_case.
indexes (ClassVar[list[Union[str, tuple[str]]]]): A list of fields
or tuples of fields for which regular (non-unique) indexes
should be created. Indexes improve query performance on these
fields.
unique_indexes (ClassVar[list[Union[str, tuple[str]]]]): A list of
fields or tuples of fields for which unique indexes should be
created. Unique indexes enforce that all values in these fields
are distinct across the table.
"""

table_name: Optional[str] = (
None # Table name, defaults to class name if not set
)
indexes: ClassVar[list[Union[str, tuple[str]]]] = []
unique_indexes: ClassVar[list[Union[str, tuple[str]]]] = []

@classmethod
def model_validate_partial(cls: type[T], obj: dict[str, Any]) -> T:
Expand Down
62 changes: 61 additions & 1 deletion sqliter/sqliter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@

import logging
import sqlite3
from typing import TYPE_CHECKING, Any, Optional, TypeVar
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union

from typing_extensions import Self

from sqliter.exceptions import (
DatabaseConnectionError,
InvalidIndexError,
RecordDeletionError,
RecordFetchError,
RecordInsertionError,
Expand Down Expand Up @@ -261,6 +262,65 @@ def create_table(
except sqlite3.Error as exc:
raise TableCreationError(table_name) from exc

# Create regular indexes
if hasattr(model_class.Meta, "indexes"):
self._create_indexes(
model_class, model_class.Meta.indexes, unique=False
)

# Create unique indexes
if hasattr(model_class.Meta, "unique_indexes"):
self._create_indexes(
model_class, model_class.Meta.unique_indexes, unique=True
)

def _create_indexes(
self,
model_class: type[BaseDBModel],
indexes: list[Union[str, tuple[str]]],
*,
unique: bool = False,
) -> None:
"""Helper method to create regular or unique indexes.
Args:
model_class: The model class defining the table.
indexes: List of fields or tuples of fields to create indexes for.
unique: If True, creates UNIQUE indexes; otherwise, creates regular
indexes.
Raises:
InvalidIndexError: If any fields specified for indexing do not exist
in the model.
"""
valid_fields = set(
model_class.model_fields.keys()
) # Get valid fields from the model

for index in indexes:
# Handle multiple fields in tuple form
fields = list(index) if isinstance(index, tuple) else [index]

# Check if all fields exist in the model
invalid_fields = [
field for field in fields if field not in valid_fields
]
if invalid_fields:
raise InvalidIndexError(invalid_fields, model_class.__name__)

# Build the SQL string
index_name = "_".join(fields)
index_postfix = "_unique" if unique else ""
index_type = " UNIQUE " if unique else " "

create_index_sql = (
f"CREATE{index_type}INDEX IF NOT EXISTS "
f"idx_{model_class.get_table_name()}"
f"_{index_name}{index_postfix} "
f"ON {model_class.get_table_name()} ({', '.join(fields)})"
)
self._execute_sql(create_index_sql)

def _execute_sql(self, sql: str) -> None:
"""Execute an SQL statement.
Expand Down
Loading

0 comments on commit 06f9e0b

Please sign in to comment.