From 627dea1aca2dd7d0ba9a169226438792cdf67540 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Sat, 28 Sep 2024 11:48:10 +0100 Subject: [PATCH 01/29] Refactor web docs layout and improve content (#36) --- README.md | 8 +- TODO.md | 18 +- docs/changelog.md | 1 - docs/changelog/index.md | 5 + docs/contributing.md | 1 - docs/contributing/index.md | 5 + docs/guide/connecting.md | 67 ++++++ docs/guide/data-ops.md | 100 +++++++++ docs/guide/debug.md | 13 ++ docs/{ => guide}/exceptions.md | 10 +- docs/guide/fields.md | 55 +++++ docs/guide/filtering.md | 59 +++++ docs/guide/guide.md | 183 ++++++++++++++++ docs/guide/models.md | 38 ++++ docs/guide/ordering.md | 25 +++ docs/guide/tables.md | 52 +++++ docs/guide/transactions.md | 18 ++ docs/index.md | 14 +- docs/license.md | 4 + docs/quickstart.md | 10 +- docs/todo.md | 1 - docs/todo/index.md | 5 + docs/usage.md | 386 --------------------------------- mkdocs.yml | 33 ++- 24 files changed, 697 insertions(+), 414 deletions(-) delete mode 100644 docs/changelog.md create mode 100644 docs/changelog/index.md delete mode 100644 docs/contributing.md create mode 100644 docs/contributing/index.md create mode 100644 docs/guide/connecting.md create mode 100644 docs/guide/data-ops.md create mode 100644 docs/guide/debug.md rename docs/{ => guide}/exceptions.md (86%) create mode 100644 docs/guide/fields.md create mode 100644 docs/guide/filtering.md create mode 100644 docs/guide/guide.md create mode 100644 docs/guide/models.md create mode 100644 docs/guide/ordering.md create mode 100644 docs/guide/tables.md create mode 100644 docs/guide/transactions.md delete mode 100644 docs/todo.md create mode 100644 docs/todo/index.md delete mode 100644 docs/usage.md diff --git a/README.md b/README.md index f9a64b0..1bf88e2 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,9 @@ Website](https://sqliter.grantramsay.dev) > > Also, structures like `list`, `dict`, `set` etc are not supported **at this > time** as field types, since SQLite does not have a native column type for -> these. I will look at implementing these in the future, probably by -> serializing them to JSON or pickling them and storing in a text field. For -> now, you can actually do this manually when creating your Model (use `TEXT` or -> `BLOB` fields), then serialize before saving after and retrieving data. +> these. This is the **next planned enhancement**. These will need to be +> `pickled` first then stored as a BLOB in the database . Also support `date` +> which can be stored as a Unix timestamp in an integer field. > > See the [TODO](TODO.md) for planned features and improvements. @@ -50,6 +49,7 @@ Website](https://sqliter.grantramsay.dev) - Transaction support - Custom exceptions for better error handling - Full type hinting and type checking +- Detailed documentation and examples - No external dependencies other than Pydantic - Full test coverage - Can optionally output the raw SQL queries being executed for debugging diff --git a/TODO.md b/TODO.md index d3ceec4..b579a93 100644 --- a/TODO.md +++ b/TODO.md @@ -14,16 +14,26 @@ data. - add more tests where 'auto_commit' is set to False to ensure that commit is not called automatically. -- support structures like, `list`, `dict`, `set` etc. in the model. This will - need to be stored as a JSON string or pickled in the database (the latter - would be more versatile). Also support `date` which can be either stored as a - string or more useful as a Unix timestamp in an integer field. +- support structures like, `list`, `dict`, `set` etc. in the model. These will + need to be `pickled` first then stored as a BLOB in the database . Also + support `date` which can be stored as a Unix timestamp in an integer field. + +## Bugs + +- The primary key is not available on the returned model if `create_pk` is set + to True. We need to add this to the returned model. This means that we cant + delete or update the record without the primary key. ## Housekeeping - Tidy up the test suite - remove any duplicates, sort them into logical files (many already are), try to reduce and centralize fixtures. +## Documentation + +- Ad examples using the Primary Key to update() and delete() - this will need + the above bug fixed first. + ## Potential Filter Additions - **Range filter** diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index 786b75d..0000000 --- a/docs/changelog.md +++ /dev/null @@ -1 +0,0 @@ ---8<-- "CHANGELOG.md" diff --git a/docs/changelog/index.md b/docs/changelog/index.md new file mode 100644 index 0000000..afa4fee --- /dev/null +++ b/docs/changelog/index.md @@ -0,0 +1,5 @@ +--- +hide: + - navigation +--- +--8<-- "CHANGELOG.md" diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index ea38c9b..0000000 --- a/docs/contributing.md +++ /dev/null @@ -1 +0,0 @@ ---8<-- "CONTRIBUTING.md" diff --git a/docs/contributing/index.md b/docs/contributing/index.md new file mode 100644 index 0000000..63093b2 --- /dev/null +++ b/docs/contributing/index.md @@ -0,0 +1,5 @@ +--- +hide: + - navigation +--- +--8<-- "CONTRIBUTING.md" diff --git a/docs/guide/connecting.md b/docs/guide/connecting.md new file mode 100644 index 0000000..142c822 --- /dev/null +++ b/docs/guide/connecting.md @@ -0,0 +1,67 @@ +# Connecting to the Database + +## Creating a Connection + +To connect to a database (and create the file if it does not already exist), you +create an instance of the `SqliterDB` class. This will automatically take care +of connecting to or creating the database file. + +```python +from sqliter import SqliterDB + +db = SqliterDB("your_database.db") +``` + +The default behavior is to automatically commit changes to the database after +each operation. If you want to disable this behavior, you can set `auto_commit=False` +when creating the database connection: + +```python +db = SqliterDB("your_database.db", auto_commit=False) +``` + +It is then up to you to manually commit changes using the `commit()` method. +This can be useful when you want to perform multiple operations in a single +transaction without the overhead of committing after each operation. + +### Using an In-Memory Database + +If you want to use an in-memory database, you can set `memory=True` when +creating the database connection: + +```python +db = SqliterDB(memory=True) +``` + +This will create an in-memory database that is not persisted to disk. If you +also specify a database name, it will be ignored. + +```python +db = SqliterDB("ignored.db", memory=True) +``` + +The `ignored.db` file will not be created, and the database will be in-memory. +If you do not specify a database name, and do NOT set `memory=True`, an +exception will be raised. + +> [!NOTE] +> +> You can also use `":memory:"` as the database name (same as normal with +> Sqlite) to create an in-memory database, this is just a cleaner and more +> descriptive way to do it. +> +> ```python +> db = SqliterDB(":memory:") +> ``` + +### Resetting the Database + +If you want to reset an existing database when you create the SqliterDB object, +you can pass `reset=True`: + +```python +db = SqliterDB("your_database.db", reset=True) +``` + +This will effectively drop all user tables from the database. The file itself is +not deleted, only the tables are dropped. diff --git a/docs/guide/data-ops.md b/docs/guide/data-ops.md new file mode 100644 index 0000000..a96f9fb --- /dev/null +++ b/docs/guide/data-ops.md @@ -0,0 +1,100 @@ +# Data Operations + +## Inserting Records + +The `insert()` method is used to add records to the database. You pass an +instance of your model class to the method, and SQLiter will insert the record +into the correct table: + +```python +user = User(name="Jane Doe", age=25, email="jane@example.com") +db.insert(user) +``` + +> [!IMPORTANT] +> +> The `insert()` method will raise a `RecordInsertionError` if you try to insert +> a record with a primary key that already exists in the table. Also, if the +> table does not exist, a `RecordInsertionError` will be raised. + +## Querying Records + +`SQLiter` provides a simple and intuitive API for querying records from the +database, Starting with the `select()` method and chaining other methods to +filter, order, limit, and offset the results: + +```python +# Fetch all users +all_users = db.select(User).fetch_all() + +# Filter users +young_users = db.select(User).filter(age=25).fetch_all() + +# Order users +ordered_users = db.select(User).order("age", reverse=True).fetch_all() + +# Limit and offset +paginated_users = db.select(User).limit(10).offset(20).fetch_all() +``` + +> [!IMPORTANT] +> +> The `select()` MUST come first, before any filtering, ordering, or pagination +> etc. This is the starting point for building your query. + +See [Filtering Results](filtering.md) for more advanced filtering options. + +## Updating Records + +```python +user.age = 26 +db.update(user) +``` + +## Deleting Records + +```python +db.delete(User, "Jane Doe") +``` + +## Commit your changes + +By default, SQLiter will automatically commit changes to the database after each +operation. If you want to disable this behavior, you can set `auto_commit=False` +when creating the database connection: + +```python +db = SqliterDB("your_database.db", auto_commit=False) +``` + +You can then manually commit changes using the `commit()` method: + +```python +db.commit() +``` + +> [!NOTE] +> +> If you are using the database connection as a context manager (see +> [tansactions](transactions.md)), you do not need to call `commit()` +> explicitly. The connection will be closed automatically when the context +> manager exits, and any changes **will be committed**. + +## Close the Connection + +When you're done with the database connection, you should close it to release +resources: + +```python +db.close() +``` + +Note that closing the connection will also commit any pending changes, unless +`auto_commit` is set to `False`. + +> [!NOTE] +> +> If you are using the database connection as a context manager (see +> [tansactions](transactions.md)), you do not need to call `close()` explicitly. +> The connection will be closed automatically when the context manager exits, +> and any changes **will be committed**. diff --git a/docs/guide/debug.md b/docs/guide/debug.md new file mode 100644 index 0000000..5cdea0e --- /dev/null +++ b/docs/guide/debug.md @@ -0,0 +1,13 @@ +# Debug Logging + +You can enable debug logging to see the SQL queries being executed by SQLiter. +This can be useful for debugging and understanding the behavior of your +application. It is disabled by default, and can be set on the `SqliterDB` class: + +```python +db = SqliterDB("your_database.db", debug=True) +``` + +This will print the SQL queries to the console as they are executed. If there is +an existing logger in your application then SQLiter will use that logger, +otherwise it will create and use a new logger named `sqliter`. diff --git a/docs/exceptions.md b/docs/guide/exceptions.md similarity index 86% rename from docs/exceptions.md rename to docs/guide/exceptions.md index dae6cc4..4dfe687 100644 --- a/docs/exceptions.md +++ b/docs/guide/exceptions.md @@ -21,7 +21,7 @@ class, `SqliterError`, to ensure consistency across error messages and behavior. - **`InvalidOrderError`**: - 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 - {}" + - **Message**: "Invalid order value - '{}'" - **`TableCreationError`**: - Raised when a table cannot be created in the database. @@ -50,3 +50,11 @@ class, `SqliterError`, to ensure consistency across error messages and behavior. - **`InvalidFilterError`**: - 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. + - **Message**: "Failed to delete the table: '{}'." + +- **SqlExecutionError** + - Raised when an error occurs during SQL query execution. + - **Message**: "Failed to execute SQL: '{}'." diff --git a/docs/guide/fields.md b/docs/guide/fields.md new file mode 100644 index 0000000..85d9d34 --- /dev/null +++ b/docs/guide/fields.md @@ -0,0 +1,55 @@ +# Field Control + +## Selecting Specific Fields + +By default, all commands query and return all fields in the table. If you want +to select only specific fields, you can pass them using the `fields()` +method: + +```python +results = db.select(User).fields(["name", "age"]).fetch_all() +``` + +This will return only the `name` and `age` fields for each record. + +You can also pass this as a parameter to the `select()` method: + +```python +results = db.select(User, fields=["name", "age"]).fetch_all() +``` + +Note that using the `fields()` method will override any fields specified in the +'select()' method. + +## Excluding Specific Fields + +If you want to exclude specific fields from the results, you can use the +`exclude()` method: + +```python +results = db.select(User).exclude(["email"]).fetch_all() +``` + +This will return all fields except the `email` field. + +You can also pass this as a parameter to the `select()` method: + +```python +results = db.select(User, exclude=["email"]).fetch_all() +``` + +## Returning exactly one explicit field only + +If you only want to return a single field from the results, you can use the +`only()` method: + +```python +result = db.select(User).only("name").fetch_first() +``` + +This will return only the `name` field for the first record. + +This is exactly the same as using the `fields()` method with a single field, but +very specific and obvious. **There is NO equivalent argument to this in the +`select()` method**. An exception **WILL** be raised if you try to use this method +with more than one field. diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md new file mode 100644 index 0000000..e42f50a --- /dev/null +++ b/docs/guide/filtering.md @@ -0,0 +1,59 @@ +# Filtering Results + +The `filter()` method in SQLiter supports various filter options to query +records, and can be combined with other methods like `order()`, `limit()`, and +`offset()` to build more complex queries: + +```python +result = db.select(User).filter(age__lte=30).limit(10).fetch_all() +``` + +## Basic Filters + +- `__eq`: Equal to (default if no operator is specified) + - Example: `name="John"` or `name__eq="John"` + +## Null Checks + +- `__isnull`: Is NULL + - Example: `email__isnull=True` +- `__notnull`: Is NOT NULL + - Example: `email__notnull=True` + +## Comparison Operators + +- `__lt`: Less than + - Example: `age__lt=30` +- `__lte`: Less than or equal to + - Example: `age__lte=30` +- `__gt`: Greater than + - Example: `age__gt=30` +- `__gte`: Greater than or equal to + - Example: `age__gte=30` +- `__ne`: Not equal to + - Example: `status__ne="inactive"` + +## List Operations + +- `__in`: In a list of values + - Example: `status__in=["active", "pending"]` +- `__not_in`: Not in a list of values + - Example: `category__not_in=["archived", "deleted"]` + +## String Operations (Case-Sensitive) + +- `__startswith`: Starts with + - Example: `name__startswith="A"` +- `__endswith`: Ends with + - Example: `email__endswith=".com"` +- `__contains`: Contains + - Example: `description__contains="important"` + +## String Operations (Case-Insensitive) + +- `__istartswith`: Starts with (case-insensitive) + - Example: `name__istartswith="a"` +- `__iendswith`: Ends with (case-insensitive) + - Example: `email__iendswith=".COM"` +- `__icontains`: Contains (case-insensitive) + - Example: `description__icontains="IMPORTANT"` diff --git a/docs/guide/guide.md b/docs/guide/guide.md new file mode 100644 index 0000000..47b4e87 --- /dev/null +++ b/docs/guide/guide.md @@ -0,0 +1,183 @@ + +# SQLiter Overview + +SQLiter is a lightweight Python library designed to simplify database operations +using Pydantic models. It provides a range of functionality including table +creation, CRUD operations, querying, filtering, and more. This overview briefly +introduces each feature. + +## Basic Setup + +To get started, import the necessary modules and define a Pydantic model for +your table: + +```python +from sqliter import SqliterDB +from sqliter.model import BaseDBModel + +class User(BaseDBModel): + name: str + age: int + email: str + +# Create a database connection +db = SqliterDB("example.db") +``` + +## Table Creation + +SQLiter allows you to create tables automatically based on your models: + +```python +db.create_table(User) +``` + +This creates a table for the `User` model, with fields based on the attributes +of the model. + +## Inserting Records + +Inserting records is straightforward with SQLiter: + +```python +user = User(name="John Doe", age=30, email="john@example.com") +db.insert(user) +``` + +## Basic Queries + +You can easily query all records from a table: + +```python +all_users = db.select(User).fetch_all() +``` + +### Filtering Results + +SQLiter allows filtering of results using various conditions: + +```python +young_users = db.select(User).filter(age__lt=30).fetch_all() +``` + + + +## Fetching Records + +SQLiter provides methods to fetch multiple, single, or the last record in a +table. + +### Fetching All Records + +The `fetch_all()` method retrieves all records from the table that match the +query or filter: + +```python +all_users = db.select(User).fetch_all() +``` + +This returns a list of all matching records. If no record matches, an empty list +is returned. + +### Fetching One Record + +The `fetch_one()` method retrieves a single record that matches the query or +filter: + +```python +result = db.select(User).filter(name="John Doe").fetch_one() +``` + +If no record is found, `None` is returned. + +### Fetching the Last Record + +The `fetch_last()` method retrieves the last record in the table, typically +based on the `rowid`: + +```python +last_user = db.select(User).fetch_last() +``` + +This fetches the most recently inserted record. If no record is found, `None` is +returned. + +## Updating Records + +Records can be updated seamlessly: + +```python +user.age = 31 +db.update(user) +``` + +## Deleting Records + +Deleting records is simple as well: + +```python +db.delete(User, "John Doe") +``` + +## Advanced Query Features + +### Ordering + +SQLiter supports ordering of results by specific fields: + +```python +ordered_users = db.select(User).order("age", reverse=True).fetch_all() +``` + +### Limiting and Offsetting + +Pagination is supported through `limit()` and `offset()`: + +```python +paginated_users = db.select(User).limit(10).offset(20).fetch_all() +``` + +## Transactions + +SQLiter supports transactions using Python's context manager. This ensures that +a group of operations are executed atomically, meaning either all of the +operations succeed or none of them are applied. + +To use transactions, simply wrap the operations within a `with` block: + +```python +with db: + db.insert(User(name="Alice", age=30, email="alice@example.com")) + db.insert(User(name="Bob", age=35, email="bob@example.com")) + # If an exception occurs here, both inserts will be rolled back +``` + +If an error occurs within the transaction block, all changes made inside the +block will be rolled back automatically. + +If no errors occur, the transaction will commit and changes will be saved. The +`close()` method will also be called when the context manager exits, so there is +no need to call it manually. + +## Closing the Database + +Always remember to close the connection when you're done: + +```python +db.close() +``` + +> [!NOTE] +> +> If you are using the database connection as a context manager (see above), you +> do not need to call `close()` explicitly. The connection will be closed +> automatically when the context manager exits, and any changes **will be +> committed**. + +This is a quick look at the core features of SQLiter. For more details on each +functionality, see the next section. diff --git a/docs/guide/models.md b/docs/guide/models.md new file mode 100644 index 0000000..4d30c06 --- /dev/null +++ b/docs/guide/models.md @@ -0,0 +1,38 @@ +# Defining Models + +Models in SQLiter use Pydantic to encapsulate the logic. All models should +inherit from SQLiter's `BaseDBModel`. You can define your +models like this: + +```python +from sqliter.model import BaseDBModel + +class User(BaseDBModel): + name: str + age: int + email: str + + class Meta: + table_name = "users" + primary_key = "name" # Default is "id" + create_pk = False # disable auto-creating an incrementing primary key - default is True +``` + +For a standard database with an auto-incrementing integer `id` primary key, you +do not need to specify the `primary_key` or `create_pk` fields. If you want to +specify a different primary key field name, you can do so using the +`primary_key` field in the `Meta` class. + +If `table_name` is not specified, the table name will be the same as the model +name, converted to 'snake_case' and pluralized (e.g., `User` -> `users`). Also, +any 'Model' suffix will be removed (e.g., `UserModel` -> `users`). To override +this behavior, you can specify the `table_name` in the `Meta` class manually as +above. + +> [!NOTE] +> +> The pluralization is pretty basic by default, and just consists of adding an +> 's' if not already there. This will fail on words like 'person' or 'child'. If +> you need more advanced pluralization, you can install the `extras` package as +> mentioned in the [installation](../installation.md#optional-dependencies). Of +> course, you can always specify the `table_name` manually in this case! diff --git a/docs/guide/ordering.md b/docs/guide/ordering.md new file mode 100644 index 0000000..feaf6bd --- /dev/null +++ b/docs/guide/ordering.md @@ -0,0 +1,25 @@ +# Ordering + +For now we only support ordering by the single field. You can specify the +field to order by and whether to reverse the order: + +```python +results = db.select(User).order("age", reverse=True).fetch_all() +``` + +This will order the results by the `age` field in descending order. + +If you do not specify a field, the default is to order by the primary key field: + +```python +results = db.select(User).order().fetch_all() +``` + +This will order the results by the primary key field in ascending order. + +> [!WARNING] +> +> Previously ordering was done using the `direction` parameter with `asc` or +> `desc`, but this has been deprecated in favor of using the `reverse` +> parameter. The `direction` parameter still works, but will raise a +> `DeprecationWarning` and will be removed in a future release. diff --git a/docs/guide/tables.md b/docs/guide/tables.md new file mode 100644 index 0000000..9dacbda --- /dev/null +++ b/docs/guide/tables.md @@ -0,0 +1,52 @@ +# Table Operations + +All table operations work on a Pydantic Model you have [defined](models.md) +based on `BaseDBModel`. You can have as many tables as you need, but each must +have it's own Model defined. + +## Creating Tables + +To create a table, you simply pass your Model class to the `create_table()` +method: + +```python +db.create_table(User) +``` + +> [!IMPORTANT] +> +> The Table is created **regardless** of the `auto_commit` setting. + +By default, if the table already exists, it will not be created again and no +error will be raised. If you want to raise an exception if the table already +exists, you can set `exists_ok=False`: + +```python +db.create_table(User, exists_ok=False) +``` + +This will raise a `TableCreationError` if the table already exists. + +There is a complementary flag `force=True` which will drop the table if it +exists and then recreate it. This may be useful if you are changing the table +structure: + +```python +db.create_table(User, force=True) +``` + +This defaults to `False`. + +## Dropping Tables + +To drop a table completely from the database use the `drop_table` method + +```python +db.drop_table(User) +``` + +> [!CAUTION] +> +> This is **non-reversible** and will you will lose **all data** in that table. +> +> The Table is dropped **regardless** of the `auto_commit` setting. diff --git a/docs/guide/transactions.md b/docs/guide/transactions.md new file mode 100644 index 0000000..6574858 --- /dev/null +++ b/docs/guide/transactions.md @@ -0,0 +1,18 @@ +# Transactions + +SQLiter supports transactions using Python's context manager: + +```python +with db: + db.insert(User(name="Alice", age=30, email="alice@example.com")) + db.insert(User(name="Bob", age=35, email="bob@example.com")) + # If an exception occurs, the transaction will be rolled back +``` + +> [!WARNING] +> Using the context manager will automatically commit the transaction +> at the end (unless an exception occurs), regardless of the `auto_commit` +> setting. +> +> the `close()` method will also be called when the context manager exits, so you +> do not need to call it manually. diff --git a/docs/index.md b/docs/index.md index 7d86e06..bf8bed0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,12 +26,11 @@ database-like format without needing to learn SQL or use a full ORM. > > Also, structures like `list`, `dict`, `set` etc are not supported **at this > time** as field types, since SQLite does not have a native column type for -> these. I will look at implementing these in the future, probably by -> serializing them to JSON or pickling them and storing in a text field. For -> now, you can actually do this manually when creating your Model (use `TEXT` or -> `BLOB` fields), then serialize before saving after and retrieving data. +> these. This is the **next planned enhancement**. These will need to be +> `pickled` first then stored as a BLOB in the database . Also support `date` +> which can be stored as a Unix timestamp in an integer field. > -> See the [TODO](todo.md) for planned features and improvements. +> See the [TODO](todo/index.md) for planned features and improvements. ## Features @@ -41,7 +40,12 @@ database-like format without needing to learn SQL or use a full ORM. - Transaction support - Custom exceptions for better error handling - Full type hinting and type checking +- Detailed documentation and examples - No external dependencies other than Pydantic - Full test coverage - Can optionally output the raw SQL queries being executed for debugging purposes. + +## License + +This project is licensed under the terms of the [MIT license](license.md). diff --git a/docs/license.md b/docs/license.md index 91a8d37..bbc34a8 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,3 +1,7 @@ +--- +hide: + - navigation +--- # License This project is licensed under the terms of the MIT license below: diff --git a/docs/quickstart.md b/docs/quickstart.md index 17e15ab..e5a6ead 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -3,6 +3,8 @@ Here's a quick example of how to use SQLiter: ```python +from typing import Optional + from sqliter import SqliterDB from sqliter.model import BaseDBModel @@ -10,9 +12,11 @@ from sqliter.model import BaseDBModel class User(BaseDBModel): name: str age: int + admin: Optional[bool] = False class Meta: - table_name = "users" + create_pk = False + primary_key = "name" # Create a database connection db = SqliterDB("example.db") @@ -27,7 +31,7 @@ db.insert(user) # Query records results = db.select(User).filter(name="John Doe").fetch_all() for user in results: - print(f"User: {user.name}, Age: {user.age}") + print(f"User: {user.name}, Age: {user.age}, Admin: {user.admin}") # Update a record user.age = 31 @@ -37,4 +41,4 @@ db.update(user) db.delete(User, "John Doe") ``` -Follow on the next pages for more detailed information on how to use `SQLiter`. +See the [Guide](guide/guide.md) for more detailed information on how to use `SQLiter`. diff --git a/docs/todo.md b/docs/todo.md deleted file mode 100644 index 7cf674f..0000000 --- a/docs/todo.md +++ /dev/null @@ -1 +0,0 @@ ---8<-- "TODO.md" diff --git a/docs/todo/index.md b/docs/todo/index.md new file mode 100644 index 0000000..e8013f1 --- /dev/null +++ b/docs/todo/index.md @@ -0,0 +1,5 @@ +--- +hide: + - navigation +--- +--8<-- "TODO.md" diff --git a/docs/usage.md b/docs/usage.md deleted file mode 100644 index 6dff821..0000000 --- a/docs/usage.md +++ /dev/null @@ -1,386 +0,0 @@ -# Detailed Usage - -## Defining Models - -Models in SQLiter use Pydantic to encapsulate the logic. All models should -inherit from SQLiter's `BaseDBModel`. You can define your -models like this: - -```python -from sqliter.model import BaseDBModel - -class User(BaseDBModel): - name: str - age: int - email: str - - class Meta: - table_name = "users" - primary_key = "name" # Default is "id" - create_pk = False # disable auto-creating an incrementing primary key - default is True -``` - -For a standard database with an auto-incrementing integer `id` primary key, you -do not need to specify the `primary_key` or `create_pk` fields. If you want to -specify a different primary key field name, you can do so using the -`primary_key` field in the `Meta` class. - -If `table_name` is not specified, the table name will be the same as the model -name, converted to 'snake_case' and pluralized (e.g., `User` -> `users`). Also, -any 'Model' suffix will be removed (e.g., `UserModel` -> `users`). To override -this behavior, you can specify the `table_name` in the `Meta` class manually as -above. - -> [!NOTE] -> -> The pluralization is pretty basic by default, and just consists of adding an -> 's' if not already there. This will fail on words like 'person' or 'child'. If -> you need more advanced pluralization, you can install the `extras` package as -> mentioned in the [installation](installation.md#optional-dependencies). Of -> course, you can always specify the `table_name` manually in this case! - -## Database Operations - -### Creating a Connection - -```python -from sqliter import SqliterDB - -db = SqliterDB("your_database.db") -``` - -The default behavior is to automatically commit changes to the database after -each operation. If you want to disable this behavior, you can set `auto_commit=False` -when creating the database connection: - -```python -db = SqliterDB("your_database.db", auto_commit=False) -``` - -It is then up to you to manually commit changes using the `commit()` method. -This can be useful when you want to perform multiple operations in a single -transaction without the overhead of committing after each operation. - -#### Using an In-Memory Database - -If you want to use an in-memory database, you can set `memory=True` when -creating the database connection: - -```python -db = SqliterDB(memory=True) -``` - -This will create an in-memory database that is not persisted to disk. If you -also specify a database name, it will be ignored. - -```python -db = SqliterDB("ignored.db", memory=True) -``` - -The `ignored.db` file will not be created, and the database will be in-memory. -If you do not specify a database name, and do NOT set `memory=True`, an -exception will be raised. - -> [!NOTE] -> -> You can also use `":memory:"` as the database name (same as normal with -> Sqlite) to create an in-memory database, this is just a cleaner and more -> descriptive way to do it. -> -> ```python -> db = SqliterDB(":memory:") -> ``` - -#### Resetting the Database - -If you want to reset the database when you create the SqliterDB object, you can -pass `reset=True`: - -```python -db = SqliterDB("your_database.db", reset=True) -``` - -This will effectively drop all user tables from the database. The file itself is -not deleted, only the tables are dropped. - -### Creating Tables - -```python -db.create_table(User) -``` - -> [!IMPORTANT] -> -> The Table is created **regardless** of the `auto_commit` setting. - -By default, if the table already exists, it will not be created again and no -error will be raised. If you want to raise an exception if the table already -exists, you can set `exists_ok=False`: - -```python -db.create_table(User, exists_ok=False) -``` - -This will raise a `TableCreationError` if the table already exists. - -There is a complementary flag `force=True` which will drop the table if it -exists and then recreate it. This may be useful if you are changing the table -structure: - -```python -db.create_table(User, force=True) -``` - -This defaults to `False`. - -### Dropping Tables - -```python -db.drop_table(User) -``` - -> [!IMPORTANT] -> -> The Table is dropped **regardless** of the `auto_commit` setting. - -## Data operations - -### Inserting Records - -```python -user = User(name="Jane Doe", age=25, email="jane@example.com") -db.insert(user) -``` - -### Querying Records - -```python -# Fetch all users -all_users = db.select(User).fetch_all() - -# Filter users -young_users = db.select(User).filter(age=25).fetch_all() - -# Order users -ordered_users = db.select(User).order("age", reverse=True).fetch_all() - -# Limit and offset -paginated_users = db.select(User).limit(10).offset(20).fetch_all() -``` - -> [!IMPORTANT] -> -> The `select()` MUST come first, before any filtering, ordering, or pagination -> etc. This is the starting point for building your query. - -See below for more advanced filtering options. - -### Updating Records - -```python -user.age = 26 -db.update(user) -``` - -### Deleting Records - -```python -db.delete(User, "Jane Doe") -``` - -### Commit your changes - -By default, SQLiter will automatically commit changes to the database after each -operation. If you want to disable this behavior, you can set `auto_commit=False` -when creating the database connection: - -```python -db = SqliterDB("your_database.db", auto_commit=False) -``` - -You can then manually commit changes using the `commit()` method: - -```python -db.commit() -``` - -### Close the Connection - -When you're done with the database connection, you should close it to release -resources: - -```python -db.close() -``` - -Note that closing the connection will also commit any pending changes, unless -`auto_commit` is set to `False`. - -## Transactions - -SQLiter supports transactions using Python's context manager: - -```python -with db: - db.insert(User(name="Alice", age=30, email="alice@example.com")) - db.insert(User(name="Bob", age=35, email="bob@example.com")) - # If an exception occurs, the transaction will be rolled back -``` - -> [!WARNING] -> Using the context manager will automatically commit the transaction -> at the end (unless an exception occurs), regardless of the `auto_commit` -> setting. -> -> the `close()` method will also be called when the context manager exits, so you -> do not need to call it manually. - -## Ordering - -For now we only support ordering by the single field. You can specify the -field to order by and whether to reverse the order: - -```python -results = db.select(User).order("age", reverse=True).fetch_all() -``` - -This will order the results by the `age` field in descending order. - -If you do not specify a field, the default is to order by the primary key field: - -```python -results = db.select(User).order().fetch_all() -``` - -This will order the results by the primary key field in ascending order. - -> [!WARNING] -> -> Previously ordering was done using the `direction` parameter with `asc` or -> `desc`, but this has been deprecated in favor of using the `reverse` -> parameter. The `direction` parameter still works, but will raise a -> `DeprecationWarning` and will be removed in a future release. - -## Field Control - -### Selecting Specific Fields - -By default, all commands query and return all fields in the table. If you want -to select only specific fields, you can pass them using the `fields()` -method: - -```python -results = db.select(User).fields(["name", "age"]).fetch_all() -``` - -This will return only the `name` and `age` fields for each record. - -You can also pass this as a parameter to the `select()` method: - -```python -results = db.select(User, fields=["name", "age"]).fetch_all() -``` - -Note that using the `fields()` method will override any fields specified in the -'select()' method. - -### Excluding Specific Fields - -If you want to exclude specific fields from the results, you can use the -`exclude()` method: - -```python -results = db.select(User).exclude(["email"]).fetch_all() -``` - -This will return all fields except the `email` field. - -You can also pass this as a parameter to the `select()` method: - -```python -results = db.select(User, exclude=["email"]).fetch_all() -``` - -### Returning exactly one explicit field only - -If you only want to return a single field from the results, you can use the -`only()` method: - -```python -result = db.select(User).only("name").fetch_first() -``` - -This will return only the `name` field for the first record. - -This is exactly the same as using the `fields()` method with a single field, but -very specific and obvious. **There is NO equivalent argument to this in the -`select()` method**. An exception **WILL** be raised if you try to use this method -with more than one field. - -## Filter Options - -The `filter()` method in SQLiter supports various filter options to query records. - -### Basic Filters - -- `__eq`: Equal to (default if no operator is specified) - - Example: `name="John"` or `name__eq="John"` - -### Null Checks - -- `__isnull`: Is NULL - - Example: `email__isnull=True` -- `__notnull`: Is NOT NULL - - Example: `email__notnull=True` - -### Comparison Operators - -- `__lt`: Less than - - Example: `age__lt=30` -- `__lte`: Less than or equal to - - Example: `age__lte=30` -- `__gt`: Greater than - - Example: `age__gt=30` -- `__gte`: Greater than or equal to - - Example: `age__gte=30` -- `__ne`: Not equal to - - Example: `status__ne="inactive"` - -### List Operations - -- `__in`: In a list of values - - Example: `status__in=["active", "pending"]` -- `__not_in`: Not in a list of values - - Example: `category__not_in=["archived", "deleted"]` - -### String Operations (Case-Sensitive) - -- `__startswith`: Starts with - - Example: `name__startswith="A"` -- `__endswith`: Ends with - - Example: `email__endswith=".com"` -- `__contains`: Contains - - Example: `description__contains="important"` - -### String Operations (Case-Insensitive) - -- `__istartswith`: Starts with (case-insensitive) - - Example: `name__istartswith="a"` -- `__iendswith`: Ends with (case-insensitive) - - Example: `email__iendswith=".COM"` -- `__icontains`: Contains (case-insensitive) - - Example: `description__icontains="IMPORTANT"` - -## Debug Logging - -You can enable debug logging to see the SQL queries being executed by SQLiter. -This can be useful for debugging and understanding the behavior of your -application. It is disabled by default, and can be set on the `SqliterDB` class: - -```python -db = SqliterDB("your_database.db", debug=True) -``` - -This will print the SQL queries to the console as they are executed. If there is -an existing logger in your application then SQLiter will use that logger, -otherwise it will create and use a new logger named `sqliter`. diff --git a/mkdocs.yml b/mkdocs.yml index b340e99..647e769 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,9 @@ theme: features: - navigation.footer - navigation.expand + - navigation.tabs + - navigation.sections + - navigation.indexes extra: social: @@ -60,12 +63,26 @@ extra_css: - css/extra.css nav: - - Introduction: index.md - - Installation: installation.md - - Quick Start: quickstart.md - - Usage: usage.md - - Exceptions: exceptions.md - - Future Plans: todo.md - - Contributing: contributing.md - - Changelog: changelog.md + - Home: + - Introduction: index.md + - Installation: installation.md + - Quick Start: quickstart.md + - Guide: + - Overview: guide/guide.md + - Models: guide/models.md + - Connect to a Database: guide/connecting.md + - Table Operations: guide/tables.md + - Data Operations: guide/data-ops.md + - Transactions: guide/transactions.md + - Filtering Results: guide/filtering.md + - Ordering: guide/ordering.md + - Field Control: guide/fields.md + - Exceptions: guide/exceptions.md + - Debug Logging: guide/debug.md + - Changelog: + - changelog/index.md + - Contributing: + - contributing/index.md + - TODO List: + - todo/index.md - License: license.md From 37b213f41715bea265dfed0fa9fe2d50d3e1ed8c Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 09:45:52 +0100 Subject: [PATCH 02/29] required modifications to make 'pk' non-optional Signed-off-by: Grant Ramsay --- demo.py | 8 +++++--- sqliter/model/model.py | 26 +++++++++----------------- sqliter/query/query.py | 9 ++++++++- sqliter/sqliter.py | 35 ++++++++++++++--------------------- 4 files changed, 36 insertions(+), 42 deletions(-) diff --git a/demo.py b/demo.py index d074658..fedc06e 100644 --- a/demo.py +++ b/demo.py @@ -41,7 +41,9 @@ def main() -> None: level=logging.DEBUG, format="%(levelname)-8s%(message)s" ) - db = SqliterDB(memory=True, auto_commit=True, debug=True) + db = SqliterDB( + "demo.db", memory=False, auto_commit=True, debug=True, reset=True + ) with db: db.create_table(UserModel) # Create the users table user1 = UserModel( @@ -62,7 +64,7 @@ def main() -> None: ) try: db.insert(user1) - db.insert(user2) + user2_id = db.insert(user2) db.insert(user3) except RecordInsertionError as exc: logging.error(exc) # noqa: TRY400 @@ -79,7 +81,7 @@ def main() -> None: ) logging.info(all_reversed) - fetched_user = db.get(UserModel, "jdoe2") + fetched_user = db.get(UserModel, user2_id) logging.info(fetched_user) count = db.select(UserModel).count() diff --git a/sqliter/model/model.py b/sqliter/model/model.py index 1ceca4f..9f3bd82 100644 --- a/sqliter/model/model.py +++ b/sqliter/model/model.py @@ -12,7 +12,7 @@ import re from typing import Any, Optional, TypeVar, Union, get_args, get_origin -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field T = TypeVar("T", bound="BaseDBModel") @@ -28,6 +28,10 @@ class BaseDBModel(BaseModel): representing database models. """ + pk: Optional[int] = Field( + None, description="The mandatory primary key of the table." + ) + model_config = ConfigDict( extra="ignore", populate_by_name=True, @@ -44,10 +48,6 @@ class Meta: table_name (Optional[str]): The name of the database table. """ - create_pk: bool = ( - True # Whether to create an auto-increment primary key - ) - primary_key: str = "id" # Default primary key name table_name: Optional[str] = ( None # Table name, defaults to class name if not set ) @@ -127,18 +127,10 @@ def get_table_name(cls) -> str: @classmethod def get_primary_key(cls) -> str: - """Get the primary key field name for the model. - - Returns: - The name of the primary key field. - """ - return getattr(cls.Meta, "primary_key", "id") + """Returns the mandatory primary key, always 'pk'.""" + return "pk" @classmethod def should_create_pk(cls) -> bool: - """Determine if a primary key should be automatically created. - - Returns: - True if a primary key should be created, False otherwise. - """ - return getattr(cls.Meta, "create_pk", True) + """Returns True since the primary key is always created.""" + return True diff --git a/sqliter/query/query.py b/sqliter/query/query.py index 704c30d..175a439 100644 --- a/sqliter/query/query.py +++ b/sqliter/query/query.py @@ -145,6 +145,8 @@ def fields(self, fields: Optional[list[str]] = None) -> QueryBuilder: The QueryBuilder instance for method chaining. """ if fields: + if "pk" not in fields: + fields.append("pk") self._fields = fields self._validate_fields() return self @@ -164,6 +166,9 @@ def exclude(self, fields: Optional[list[str]] = None) -> QueryBuilder: invalid fields are specified. """ if fields: + if "pk" in fields: + err = "The primary key 'pk' cannot be excluded." + raise ValueError(err) all_fields = set(self.model_class.model_fields.keys()) # Check for invalid fields before subtraction @@ -208,7 +213,7 @@ def only(self, field: str) -> QueryBuilder: raise ValueError(err) # Set self._fields to just the single field - self._fields = [field] + self._fields = [field, "pk"] return self def _get_operator_handler( @@ -527,6 +532,8 @@ def _execute_query( if count_only: fields = "COUNT(*)" elif self._fields: + if "pk" not in self._fields: + self._fields.append("pk") fields = ", ".join(f'"{field}"' for field in self._fields) else: fields = ", ".join( diff --git a/sqliter/sqliter.py b/sqliter/sqliter.py index 7e2a392..6b10468 100644 --- a/sqliter/sqliter.py +++ b/sqliter/sqliter.py @@ -223,28 +223,12 @@ def create_table( """ table_name = model_class.get_table_name() primary_key = model_class.get_primary_key() - create_pk = model_class.should_create_pk() if force: drop_table_sql = f"DROP TABLE IF EXISTS {table_name}" self._execute_sql(drop_table_sql) - fields = [] - - # Always add the primary key field first - if create_pk: - fields.append(f"{primary_key} INTEGER PRIMARY KEY AUTOINCREMENT") - else: - field_info = model_class.model_fields.get(primary_key) - if field_info is not None: - sqlite_type = infer_sqlite_type(field_info.annotation) - fields.append(f"{primary_key} {sqlite_type} PRIMARY KEY") - else: - err = ( - f"Primary key field '{primary_key}' not found in model " - "fields." - ) - raise ValueError(err) + fields = [f'"{primary_key}" INTEGER PRIMARY KEY AUTOINCREMENT'] # Add remaining fields for field_name, field_info in model_class.model_fields.items(): @@ -325,18 +309,22 @@ def _maybe_commit(self) -> None: if self.auto_commit and self.conn: self.conn.commit() - def insert(self, model_instance: BaseDBModel) -> None: + def insert(self, model_instance: BaseDBModel) -> int | None: """Insert a new record into the database. Args: - model_instance: An instance of a Pydantic model to be inserted. + model_instance: The instance of the model class to insert. + + Returns: + The primary key (pk) of the newly inserted record. Raises: - RecordInsertionError: If there's an error inserting the record. + RecordInsertionError: If an error occurs during the insertion. """ model_class = type(model_instance) table_name = model_class.get_table_name() + # Get the data from the model, excluding the 'pk' field data = model_instance.model_dump() fields = ", ".join(data.keys()) placeholders = ", ".join( @@ -354,11 +342,15 @@ def insert(self, model_instance: BaseDBModel) -> None: cursor = conn.cursor() cursor.execute(insert_sql, values) self._maybe_commit() + except sqlite3.Error as exc: raise RecordInsertionError(table_name) from exc + else: + # Return the primary key (pk) of the inserted row + return cursor.lastrowid def get( - self, model_class: type[BaseDBModel], primary_key_value: str + self, model_class: type[BaseDBModel], primary_key_value: int ) -> BaseDBModel | None: """Retrieve a single record from the database by its primary key. @@ -410,6 +402,7 @@ def update(self, model_instance: BaseDBModel) -> None: """ model_class = type(model_instance) table_name = model_class.get_table_name() + primary_key = model_class.get_primary_key() fields = ", ".join( From 3258799bbc29077792906fa2de02c3bd7656544b Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 09:57:45 +0100 Subject: [PATCH 03/29] use pydantic mypy plugin this is needed because we always create the pk, but it is optional and not specified when creating model instance Signed-off-by: Grant Ramsay --- demo.py | 7 +++++-- pyproject.toml | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/demo.py b/demo.py index fedc06e..c919c6b 100644 --- a/demo.py +++ b/demo.py @@ -81,8 +81,11 @@ def main() -> None: ) logging.info(all_reversed) - fetched_user = db.get(UserModel, user2_id) - logging.info(fetched_user) + if user2_id is None: + logging.error("User2 ID not found.") + else: + fetched_user = db.get(UserModel, user2_id) + logging.info(fetched_user) count = db.select(UserModel).count() logging.info("Total Users: %s", count) diff --git a/pyproject.toml b/pyproject.toml index 047e36f..9c28bad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,9 +144,10 @@ known-first-party = ["sqliter"] keep-runtime-typing = true [tool.mypy] +plugins = ["pydantic.mypy"] + python_version = "3.9" exclude = ["docs"] - [[tool.mypy.overrides]] disable_error_code = ["method-assign", "no-untyped-def", "attr-defined"] module = "tests.*" From 35c29699f16926605943a392c1c4f52b7da986d2 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 10:37:57 +0100 Subject: [PATCH 04/29] fix mypy in pre-commit needed the 'pydantic' dep added. Signed-off-by: Grant Ramsay --- .pre-commit-config.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2ee425..fda8b51 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,6 +30,8 @@ repos: hooks: - id: mypy name: "run mypy" + additional_dependencies: + - pydantic - repo: https://github.com/astral-sh/uv-pre-commit # uv version. From 9c99d6172f2b73a1650d45440cdca42d52b9d9ab Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 10:38:08 +0100 Subject: [PATCH 05/29] wip on fixing tests Signed-off-by: Grant Ramsay --- sqliter/exceptions.py | 2 +- sqliter/query/query.py | 2 +- sqliter/sqliter.py | 4 ++-- tests/conftest.py | 9 -------- tests/test_debug_logging.py | 25 +++++++++++---------- tests/test_execeptions.py | 5 ++++- tests/test_optional_fields_complex_model.py | 20 ++++++++--------- tests/test_query.py | 6 ++--- 8 files changed, 34 insertions(+), 39 deletions(-) diff --git a/sqliter/exceptions.py b/sqliter/exceptions.py index cf49652..f248a94 100644 --- a/sqliter/exceptions.py +++ b/sqliter/exceptions.py @@ -114,7 +114,7 @@ class RecordUpdateError(SqliterError): class RecordNotFoundError(SqliterError): """Exception raised when a requested record is not found in the database.""" - message_template = "Failed to find a record for key '{}' " + message_template = "Failed to find that record in the table (key '{}') " class RecordFetchError(SqliterError): diff --git a/sqliter/query/query.py b/sqliter/query/query.py index 175a439..a4ba9de 100644 --- a/sqliter/query/query.py +++ b/sqliter/query/query.py @@ -184,7 +184,7 @@ def exclude(self, fields: Optional[list[str]] = None) -> QueryBuilder: self._fields = list(all_fields - set(fields)) # Explicit check: raise an error if no fields remain - if not self._fields: + if self._fields == ["pk"]: err = "Exclusion results in no fields being selected." raise ValueError(err) diff --git a/sqliter/sqliter.py b/sqliter/sqliter.py index 6b10468..23f82c1 100644 --- a/sqliter/sqliter.py +++ b/sqliter/sqliter.py @@ -397,8 +397,8 @@ def update(self, model_instance: BaseDBModel) -> None: model_instance: An instance of a Pydantic model to be updated. Raises: - RecordUpdateError: If there's an error updating the record. - RecordNotFoundError: If the record to update is not found. + RecordUpdateError: If there's an error updating the record or if it + is not found. """ model_class = type(model_instance) table_name = model_class.get_table_name() diff --git a/tests/conftest.py b/tests/conftest.py index 4145c45..82719eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,8 +39,6 @@ class ExampleModel(BaseDBModel): class Meta: """Configuration for the model.""" - create_pk: bool = False - primary_key: str = "slug" table_name: str = "test_table" @@ -53,9 +51,7 @@ class PersonModel(BaseDBModel): class Meta: """Configuration for the model.""" - create_pk = False table_name = "person_table" - primary_key = "name" class DetailedPersonModel(BaseDBModel): @@ -72,14 +68,11 @@ class Meta: """Configuration for the model.""" table_name = "detailed_person_table" - primary_key = "name" - create_pk = False class ComplexModel(BaseDBModel): """Model to test complex field types.""" - id: int name: str age: float is_active: bool @@ -90,8 +83,6 @@ class Meta: """Configuration for the model.""" table_name = "complex_model" - primary_key = "id" - create_pk = False @pytest.fixture diff --git a/tests/test_debug_logging.py b/tests/test_debug_logging.py index 1176cfe..f4a7eb6 100644 --- a/tests/test_debug_logging.py +++ b/tests/test_debug_logging.py @@ -39,7 +39,7 @@ def test_debug_sql_output_basic_query( # Assert the SQL query was printed assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" WHERE age = 30.5' in caplog.text ) @@ -55,7 +55,7 @@ def test_debug_sql_output_string_values( # Assert the SQL query was printed with the string properly quoted assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" WHERE name = \'Alice\'' in caplog.text ) @@ -71,7 +71,7 @@ def test_debug_sql_output_multiple_conditions( # Assert the SQL query was printed with multiple conditions assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" WHERE name = \'Alice\' AND ' "age = 30.5" in caplog.text ) @@ -87,7 +87,7 @@ def test_debug_sql_output_order_and_limit( # Assert the SQL query was printed with ORDER and LIMIT assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" ORDER BY "age" DESC LIMIT 1' in caplog.text ) @@ -99,7 +99,7 @@ def test_debug_sql_output_with_null_value( with caplog.at_level(logging.DEBUG): db_mock_complex_debug.insert( ComplexModel( - id=4, + pk=4, name="David", age=40.0, is_active=True, @@ -114,7 +114,7 @@ def test_debug_sql_output_with_null_value( # Assert the SQL query was printed with IS NULL assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" WHERE age IS NULL' in caplog.text ) @@ -130,7 +130,8 @@ def test_debug_sql_output_with_fields_single( # Assert the SQL query only selects the 'name' field assert ( - 'Executing SQL: SELECT "name" FROM "complex_model"' in caplog.text + 'Executing SQL: SELECT "name", "pk" FROM "complex_model"' + in caplog.text ) def test_debug_sql_output_with_fields_multiple( @@ -144,7 +145,7 @@ def test_debug_sql_output_with_fields_multiple( # Assert the SQL query only selects the 'name' and 'age' fields assert ( - 'Executing SQL: SELECT "name", "age" FROM "complex_model"' + 'Executing SQL: SELECT "name", "age", "pk" FROM "complex_model"' in caplog.text ) @@ -159,7 +160,7 @@ def test_debug_sql_output_with_fields_and_filter( # Assert the SQL query selects 'name' and 'score' and applies the filter assert ( - 'Executing SQL: SELECT "name", "score" FROM "complex_model" ' + 'Executing SQL: SELECT "name", "score", "pk" FROM "complex_model" ' "WHERE score > 85" in caplog.text ) @@ -198,7 +199,7 @@ def test_manual_logger_respects_debug_flag(self, caplog) -> None: # Assert that log output was captured with the manually passed logger assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' in caplog.text ) @@ -227,7 +228,7 @@ def test_debug_sql_output_no_matching_records( # Assert that the SQL query was logged despite no matching records assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" WHERE age = 100' in caplog.text ) @@ -241,7 +242,7 @@ def test_debug_sql_output_empty_query( # Assert that the SQL query was logged for a full table scan assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model"' in caplog.text ) diff --git a/tests/test_execeptions.py b/tests/test_execeptions.py index cfd4eb5..69faf8c 100644 --- a/tests/test_execeptions.py +++ b/tests/test_execeptions.py @@ -55,6 +55,7 @@ def test_database_connection_error(self, mocker) -> None: exc_info.value ) + @pytest.mark.skip(reason="This is no longer a valid test case.") def test_insert_duplicate_primary_key(self, db_mock) -> None: """Test that exception raised when inserting duplicate primary key.""" # Create a model instance with a unique primary key @@ -98,7 +99,9 @@ def test_update_not_found_error(self, db_mock) -> None: db_mock.update(example_model) # Verify that the exception message contains the table name - assert "Failed to find a record for key 'test'" in str(exc_info.value) + assert "Failed to find that record in the table (key 'None')" in str( + exc_info.value + ) def test_update_exception_error(self, db_mock, mocker) -> None: """Test an exception is raised when updating a record with an error.""" diff --git a/tests/test_optional_fields_complex_model.py b/tests/test_optional_fields_complex_model.py index 3349390..260c0c7 100644 --- a/tests/test_optional_fields_complex_model.py +++ b/tests/test_optional_fields_complex_model.py @@ -8,11 +8,11 @@ @pytest.fixture def db_mock_complex(db_mock: SqliterDB) -> SqliterDB: - """Ficture for a mock database with a complex model.""" + """Fixture for a mock database with a complex model.""" db_mock.create_table(ComplexModel) db_mock.insert( ComplexModel( - id=1, + pk=1, name="Alice", age=30.5, is_active=True, @@ -22,7 +22,7 @@ def db_mock_complex(db_mock: SqliterDB) -> SqliterDB: ) db_mock.insert( ComplexModel( - id=2, + pk=2, name="Bob", age=25.0, is_active=False, @@ -41,7 +41,7 @@ def test_select_all_fields(self, db_mock_complex: SqliterDB) -> None: results = db_mock_complex.select(ComplexModel).fetch_all() assert len(results) == 2 for result in results: - assert isinstance(result.id, int) + assert isinstance(result.pk, int) assert isinstance(result.name, str) assert isinstance(result.age, float) assert isinstance(result.is_active, bool) @@ -53,13 +53,13 @@ def test_select_all_fields(self, db_mock_complex: SqliterDB) -> None: def test_select_subset_of_fields(self, db_mock_complex: SqliterDB) -> None: """Select a subset of fields and ensure their types are correct.""" - fields = ["id", "name", "age", "is_active", "score"] + fields = ["pk", "name", "age", "is_active", "score"] results = db_mock_complex.select( ComplexModel, fields=fields ).fetch_all() assert len(results) == 2 for result in results: - assert isinstance(result.id, int) + assert isinstance(result.pk, int) assert isinstance(result.name, str) assert isinstance(result.age, float) assert isinstance(result.is_active, bool) @@ -71,13 +71,13 @@ def test_select_with_type_conversion( self, db_mock_complex: SqliterDB ) -> None: """Select a subset of fields and ensure their types are correct.""" - fields = ["id", "age", "is_active", "score"] + fields = ["pk", "age", "is_active", "score"] results = db_mock_complex.select( ComplexModel, fields=fields ).fetch_all() assert len(results) == 2 for result in results: - assert isinstance(result.id, int) + assert isinstance(result.pk, int) assert isinstance(result.age, float) assert isinstance(result.is_active, bool) assert isinstance(result.score, (int, float)) @@ -107,7 +107,7 @@ def test_select_with_union_field(self, db_mock_complex: SqliterDB) -> None: def test_select_with_filtering(self, db_mock_complex: SqliterDB) -> None: """Select fields with a filter.""" - fields = ["id", "name", "age"] + fields = ["pk", "name", "age"] results = ( db_mock_complex.select(ComplexModel, fields=fields) .filter(age__gt=28) @@ -119,7 +119,7 @@ def test_select_with_filtering(self, db_mock_complex: SqliterDB) -> None: def test_select_with_ordering(self, db_mock_complex: SqliterDB) -> None: """Select fields with ordering.""" - fields = ["id", "name", "age"] + fields = ["pk", "name", "age"] results = ( db_mock_complex.select(ComplexModel, fields=fields) .order("age", direction="DESC") diff --git a/tests/test_query.py b/tests/test_query.py index 756c993..53a721a 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -565,8 +565,8 @@ def test_fetch_result_with_list_of_tuples(self, mocker) -> None: # Create some mock tuples (mimicking database rows) mock_result = [ - ("john", "John", "content"), - ("jane", "Jane", "content"), + ("1", "john", "John", "content"), + ("2", "jane", "Jane", "content"), ] # Mock the _execute_query method on the QueryBuilder instance @@ -581,5 +581,5 @@ def test_fetch_result_with_list_of_tuples(self, mocker) -> None: assert not isinstance(result, list) assert isinstance(result, ExampleModel) assert result == ExampleModel( - slug="john", name="John", content="content" + pk=1, slug="john", name="John", content="content" ) From 82b62088f70a051514963c149d80569f46e94678 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 12:05:10 +0100 Subject: [PATCH 06/29] make pk non-optional and default to 0 Signed-off-by: Grant Ramsay --- demo.py | 8 ++++---- sqliter/model/model.py | 4 +--- sqliter/sqliter.py | 17 +++++++++++------ tests/test_execeptions.py | 2 +- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/demo.py b/demo.py index c919c6b..b3c8a6c 100644 --- a/demo.py +++ b/demo.py @@ -64,7 +64,7 @@ def main() -> None: ) try: db.insert(user1) - user2_id = db.insert(user2) + user2_instance = db.insert(user2) db.insert(user3) except RecordInsertionError as exc: logging.error(exc) # noqa: TRY400 @@ -81,11 +81,11 @@ def main() -> None: ) logging.info(all_reversed) - if user2_id is None: + if user2_instance is None: logging.error("User2 ID not found.") else: - fetched_user = db.get(UserModel, user2_id) - logging.info(fetched_user) + fetched_user = db.get(UserModel, user2_instance.pk) + logging.info("Fetched (%s)", fetched_user) count = db.select(UserModel).count() logging.info("Total Users: %s", count) diff --git a/sqliter/model/model.py b/sqliter/model/model.py index 9f3bd82..6ea208a 100644 --- a/sqliter/model/model.py +++ b/sqliter/model/model.py @@ -28,9 +28,7 @@ class BaseDBModel(BaseModel): representing database models. """ - pk: Optional[int] = Field( - None, description="The mandatory primary key of the table." - ) + pk: int = Field(0, description="The mandatory primary key of the table.") model_config = ConfigDict( extra="ignore", diff --git a/sqliter/sqliter.py b/sqliter/sqliter.py index 23f82c1..1524d16 100644 --- a/sqliter/sqliter.py +++ b/sqliter/sqliter.py @@ -10,7 +10,7 @@ import logging import sqlite3 -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Optional, TypeVar from typing_extensions import Self @@ -33,6 +33,8 @@ from sqliter.model.model import BaseDBModel +T = TypeVar("T", bound="BaseDBModel") + class SqliterDB: """Main class for interacting with SQLite databases. @@ -309,14 +311,14 @@ def _maybe_commit(self) -> None: if self.auto_commit and self.conn: self.conn.commit() - def insert(self, model_instance: BaseDBModel) -> int | None: + def insert(self, model_instance: T) -> T: """Insert a new record into the database. Args: model_instance: The instance of the model class to insert. Returns: - The primary key (pk) of the newly inserted record. + The updated model instance with the primary key (pk) set. Raises: RecordInsertionError: If an error occurs during the insertion. @@ -324,8 +326,12 @@ def insert(self, model_instance: BaseDBModel) -> int | None: model_class = type(model_instance) table_name = model_class.get_table_name() - # Get the data from the model, excluding the 'pk' field + # Get the data from the model data = model_instance.model_dump() + # remove the primary key field if it exists, otherwise we'll get + # TypeErrors as multiple primary keys will exist + data.pop("pk") + fields = ", ".join(data.keys()) placeholders = ", ".join( ["?" if value is not None else "NULL" for value in data.values()] @@ -346,8 +352,7 @@ def insert(self, model_instance: BaseDBModel) -> int | None: except sqlite3.Error as exc: raise RecordInsertionError(table_name) from exc else: - # Return the primary key (pk) of the inserted row - return cursor.lastrowid + return model_class(pk=cursor.lastrowid, **data) def get( self, model_class: type[BaseDBModel], primary_key_value: int diff --git a/tests/test_execeptions.py b/tests/test_execeptions.py index 69faf8c..4bd9323 100644 --- a/tests/test_execeptions.py +++ b/tests/test_execeptions.py @@ -99,7 +99,7 @@ def test_update_not_found_error(self, db_mock) -> None: db_mock.update(example_model) # Verify that the exception message contains the table name - assert "Failed to find that record in the table (key 'None')" in str( + assert "Failed to find that record in the table (key '0')" in str( exc_info.value ) From 58858b939218202469ec55a90df784878d54552d Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 12:49:33 +0100 Subject: [PATCH 07/29] add pytest hook to clear screen before each test run Signed-off-by: Grant Ramsay --- tests/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 82719eb..2e48dcd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Optional, Union @@ -16,6 +17,12 @@ memory_db = ":memory:" +@pytest.hookimpl(tryfirst=True) +def pytest_configure(config) -> None: + """Clear the screen before running tests.""" + os.system("cls" if os.name == "nt" else "clear") # noqa: S605 + + @contextmanager def not_raises(exception) -> Generator[None, Any, None]: """Fake a pytest.raises context manager that does not raise an exception. From b4b7d703f552dfe2dc7390ebf942df7740c4807a Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 12:49:56 +0100 Subject: [PATCH 08/29] ensure insert fails on duplicate pk Signed-off-by: Grant Ramsay --- sqliter/sqliter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sqliter/sqliter.py b/sqliter/sqliter.py index 1524d16..62bf605 100644 --- a/sqliter/sqliter.py +++ b/sqliter/sqliter.py @@ -330,7 +330,8 @@ def insert(self, model_instance: T) -> T: data = model_instance.model_dump() # remove the primary key field if it exists, otherwise we'll get # TypeErrors as multiple primary keys will exist - data.pop("pk") + if data.get("pk", None) == 0: + data.pop("pk") fields = ", ".join(data.keys()) placeholders = ", ".join( @@ -352,6 +353,7 @@ def insert(self, model_instance: T) -> T: except sqlite3.Error as exc: raise RecordInsertionError(table_name) from exc else: + data.pop("pk", None) return model_class(pk=cursor.lastrowid, **data) def get( From ac41411ce847eab570d6ae90c4a3f72606d506d4 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 12:50:25 +0100 Subject: [PATCH 09/29] fix final existing tests Signed-off-by: Grant Ramsay --- tests/test_execeptions.py | 6 +- tests/test_sqliter.py | 164 +++++++------------------------------- 2 files changed, 33 insertions(+), 137 deletions(-) diff --git a/tests/test_execeptions.py b/tests/test_execeptions.py index 4bd9323..0bb3009 100644 --- a/tests/test_execeptions.py +++ b/tests/test_execeptions.py @@ -55,7 +55,7 @@ def test_database_connection_error(self, mocker) -> None: exc_info.value ) - @pytest.mark.skip(reason="This is no longer a valid test case.") + # @pytest.mark.skip(reason="This is no longer a valid test case.") def test_insert_duplicate_primary_key(self, db_mock) -> None: """Test that exception raised when inserting duplicate primary key.""" # Create a model instance with a unique primary key @@ -64,11 +64,11 @@ def test_insert_duplicate_primary_key(self, db_mock) -> None: ) # Insert the record for the first time, should succeed - db_mock.insert(example_model) + result = db_mock.insert(example_model) # Try inserting the same record again, which should raise our exception with pytest.raises(RecordInsertionError) as exc_info: - db_mock.insert(example_model) + db_mock.insert(result) # Verify that the exception message contains the table name assert "Failed to insert record into table: 'test_table'" in str( diff --git a/tests/test_sqliter.py b/tests/test_sqliter.py index 5a55e35..5447952 100644 --- a/tests/test_sqliter.py +++ b/tests/test_sqliter.py @@ -51,10 +51,10 @@ def test_data_lost_when_auto_commit_disabled(self) -> None: test_model = ExampleModel( slug="test", name="Test License", content="Test Content" ) - db.insert(test_model) + result = db.insert(test_model) # Ensure the record exists - fetched_license = db.get(ExampleModel, "test") + fetched_license = db.get(ExampleModel, result.pk) assert fetched_license is not None # Close the connection @@ -65,7 +65,7 @@ def test_data_lost_when_auto_commit_disabled(self) -> None: # Ensure the data is lost with pytest.raises(RecordFetchError): - db.get(ExampleModel, "test") + db.get(ExampleModel, result.pk) def test_create_table(self, db_mock) -> None: """Test table creation.""" @@ -73,8 +73,8 @@ def test_create_table(self, db_mock) -> None: cursor = conn.cursor() cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") tables = cursor.fetchall() - assert len(tables) == 1 - assert tables[0][0] == "test_table" + assert len(tables) == 2 + assert ("test_table",) in tables def test_close_connection(self, db_mock) -> None: """Test closing the connection.""" @@ -95,15 +95,13 @@ def test_commit_changes(self, mocker) -> None: assert mock_conn.commit.called - def test_create_table_with_auto_increment(self, db_mock) -> None: + def test_create_table_with_default_auto_increment(self, db_mock) -> None: """Test table creation with auto-incrementing primary key.""" class AutoIncrementModel(BaseDBModel): name: str class Meta: - create_pk: bool = True # Enable auto-increment ID - primary_key: str = "id" # Default primary key is 'id' table_name: str = "auto_increment_table" # Create the table @@ -116,113 +114,10 @@ class Meta: table_info = cursor.fetchall() # Check that the first column is 'id' and it's an auto-incrementing int - assert table_info[0][1] == "id" # Column name + assert table_info[0][1] == "pk" # Column name assert table_info[0][2] == "INTEGER" # Column type assert table_info[0][5] == 1 # Primary key flag - def test_create_table_with_custom_primary_key(self, db_mock) -> None: - """Test table creation with a custom primary key.""" - - class CustomPKModel(BaseDBModel): - code: str - description: str - - class Meta: - create_pk: bool = False # Disable auto-increment ID - primary_key: str = "code" # Use 'code' as the primary key - table_name: str = "custom_pk_table" - - # Create the table - db_mock.create_table(CustomPKModel) - - # Verify that the table was created with 'code' as the primary key - with db_mock.connect() as conn: - cursor = conn.cursor() - cursor.execute("PRAGMA table_info(custom_pk_table);") - table_info = cursor.fetchall() - - # Check that the primary key is the 'code' column - primary_key_column = next(col for col in table_info if col[1] == "code") - assert primary_key_column[1] == "code" # Column name - assert primary_key_column[5] == 1 # Primary key flag - - def test_create_table_with_custom_auto_increment_pk(self, db_mock) -> None: - """Test table creation with a custom auto-incrementing primary key.""" - - class CustomAutoIncrementPKModel(BaseDBModel): - name: str - - class Meta: - create_pk: bool = True # Enable auto-increment ID - primary_key: str = ( - "custom_id" # Use 'custom_id' as the primary key - ) - table_name: str = "custom_auto_increment_pk_table" - - # Create the table - db_mock.create_table(CustomAutoIncrementPKModel) - - # Check the table schema using PRAGMA - with db_mock.connect() as conn: - cursor = conn.cursor() - cursor.execute("PRAGMA table_info(custom_auto_increment_pk_table);") - table_info = cursor.fetchall() - - # Check that the 'custom_id' column is INTEGER and a primary key - primary_key_column = next( - col for col in table_info if col[1] == "custom_id" - ) - assert primary_key_column[1] == "custom_id" # Column name - assert primary_key_column[2] == "INTEGER" # Column type - assert primary_key_column[5] == 1 # Primary key flag - - # Insert rows to verify that the custom primary key auto-increments - model_instance1 = CustomAutoIncrementPKModel(name="First Entry") - model_instance2 = CustomAutoIncrementPKModel(name="Second Entry") - - db_mock.insert(model_instance1) - db_mock.insert(model_instance2) - - # Fetch the inserted rows and check the 'custom_id' values - with db_mock.connect() as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT custom_id, name FROM custom_auto_increment_pk_table;" - ) - results = cursor.fetchall() - - # Check that the custom_id column auto-incremented - assert results[0][0] == 1 - assert results[1][0] == 2 - assert results[0][1] == "First Entry" - assert results[1][1] == "Second Entry" - - def test_create_table_missing_primary_key(self) -> None: - """Test create_table raises ValueError when primary key is missing.""" - - # Define a model that doesn't have the expected primary key - class NoPKModel(BaseDBModel): - # Intentionally omitting the primary key field, e.g., 'id' or 'slug' - name: str - age: int - - class Meta: - create_pk = False - - # Initialize your SqliterDB instance (adjust if needed) - db = SqliterDB(memory=True) # Assuming memory=True uses an in-memory DB - - # Use pytest.raises to check if ValueError is raised - with pytest.raises( - ValueError, - match="Primary key field 'id' not found in model fields.", - ) as exc_info: - db.create_table(NoPKModel) - - # Check that the error message matches the expected output - assert "Primary key field" in str(exc_info.value) - assert "not found in model fields" in str(exc_info.value) - def test_default_table_name(self, db_mock) -> None: """Test the default table name generation. @@ -282,18 +177,19 @@ def test_insert_license(self, db_mock) -> None: cursor = conn.cursor() cursor.execute("SELECT * FROM test_table WHERE slug = ?", ("mit",)) result = cursor.fetchone() - assert result[0] == "mit" - assert result[1] == "MIT License" - assert result[2] == "MIT License Content" + assert result[0] == 1 + assert result[1] == "mit" + assert result[2] == "MIT License" + assert result[3] == "MIT License Content" def test_fetch_license(self, db_mock) -> None: """Test fetching a license by primary key.""" test_model = ExampleModel( slug="gpl", name="GPL License", content="GPL License Content" ) - db_mock.insert(test_model) + result = db_mock.insert(test_model) - fetched_license = db_mock.get(ExampleModel, "gpl") + fetched_license = db_mock.get(ExampleModel, result.pk) assert fetched_license is not None assert fetched_license.slug == "gpl" assert fetched_license.name == "GPL License" @@ -304,14 +200,14 @@ def test_update(self, db_mock) -> None: test_model = ExampleModel( slug="mit", name="MIT License", content="MIT License Content" ) - db_mock.insert(test_model) + result = db_mock.insert(test_model) # Update license content - test_model.content = "Updated MIT License Content" - db_mock.update(test_model) + result.content = "Updated MIT License Content" + db_mock.update(result) # Fetch and check if updated - fetched_license = db_mock.get(ExampleModel, "mit") + fetched_license = db_mock.get(ExampleModel, result.pk) assert fetched_license.content == "Updated MIT License Content" def test_delete(self, db_mock) -> None: @@ -319,13 +215,13 @@ def test_delete(self, db_mock) -> None: test_model = ExampleModel( slug="mit", name="MIT License", content="MIT License Content" ) - db_mock.insert(test_model) + result = db_mock.insert(test_model) # Delete the record - db_mock.delete(ExampleModel, "mit") + db_mock.delete(ExampleModel, result.pk) # Ensure it no longer exists - fetched_license = db_mock.get(ExampleModel, "mit") + fetched_license = db_mock.get(ExampleModel, result.pk) assert fetched_license is None def test_select_filter(self, db_mock) -> None: @@ -455,14 +351,14 @@ def test_update_existing_record(self, db_mock) -> None: example_model = ExampleModel( slug="test", name="Test License", content="Test Content" ) - db_mock.insert(example_model) + result = db_mock.insert(example_model) # Update the record's content - example_model.content = "Updated Content" - db_mock.update(example_model) + result.content = "Updated Content" + db_mock.update(result) # Fetch the updated record and verify the changes - updated_record = db_mock.get(ExampleModel, "test") + updated_record = db_mock.get(ExampleModel, result.pk) assert updated_record is not None assert updated_record.content == "Updated Content" @@ -480,7 +376,7 @@ def test_update_non_existing_record(self, db_mock) -> None: db_mock.update(example_model) # Check that the correct error message is raised - assert "Failed to find a record for key 'nonexistent'" in str( + assert "Failed to find that record in the table (key '0')" in str( exc_info.value ) @@ -510,13 +406,13 @@ def test_delete_existing_record(self, db_mock) -> None: test_model = ExampleModel( slug="test", name="Test License", content="Test Content" ) - db_mock.insert(test_model) + result = db_mock.insert(test_model) # Now delete the record - db_mock.delete(ExampleModel, "test") + db_mock.delete(ExampleModel, result.pk) # Fetch the deleted record to confirm it's gone - result = db_mock.get(ExampleModel, "test") + result = db_mock.get(ExampleModel, result.pk) assert result is None def test_transaction_commit_success(self, db_mock, mocker) -> None: @@ -685,7 +581,7 @@ def test_complex_model_field_types(self, db_mock) -> None: # Expected types in SQLite (INTEGER, REAL, TEXT, etc.) expected_types = { - "id": "INTEGER", + "pk": "INTEGER", "name": "TEXT", "age": "REAL", "price": "REAL", @@ -727,7 +623,7 @@ def test_complex_model_primary_key(self, db_mock) -> None: # Assert that the primary key is the 'id' field and is an INTEGER assert primary_key_column is not None, "Primary key not found" assert ( - primary_key_column[1] == "id" + primary_key_column[1] == "pk" ), f"Expected 'id' as primary key, but got {primary_key_column[1]}" assert primary_key_column[2] == "INTEGER", ( f"Expected 'INTEGER' type for primary key, but got " From 44cf95b3049f7ffa80c62c208538707f5f44ae20 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 13:31:21 +0100 Subject: [PATCH 10/29] add specific model unit tests Signed-off-by: Grant Ramsay --- tests/test_model.py | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/test_model.py diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..331f5e5 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,47 @@ +from typing import Optional + +import pytest + +from sqliter.model.model import BaseDBModel + +"""Test the Model and it's methods.""" + + +class TestBaseDBModel: + def test_should_create_pk(self) -> None: + """Test that 'should_create_pk' returns True.""" + assert BaseDBModel.should_create_pk() is True + + def test_get_primary_key(self) -> None: + """Test that 'get_primary_key' returns 'pk'.""" + assert BaseDBModel.get_primary_key() == "pk" + + def test_get_table_name_default(self) -> None: + """Test that 'get_table_name' returns the default table name.""" + + class TestModel(BaseDBModel): + pass + + assert TestModel.get_table_name() == "tests" + + def test_get_table_name_custom(self) -> None: + """Test that 'get_table_name' returns the custom table name.""" + + class TestModel(BaseDBModel): + class Meta: + table_name = "custom_table" + + assert TestModel.get_table_name() == "custom_table" + + def test_model_validate_partial(self) -> None: + """Test 'model_validate_partial' with partial data.""" + + class TestModel(BaseDBModel): + name: str + age: Optional[int] + + data = {"name": "John"} + model_instance = TestModel.model_validate_partial(data) + assert model_instance.name == "John" + with pytest.raises(AttributeError): + _ = model_instance.age From 9aa1c774d11d469a50b9cc68d1310a2e5803a73a Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 13:36:45 +0100 Subject: [PATCH 11/29] complete test coverage of changed code Signed-off-by: Grant Ramsay --- tests/test_query.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_query.py b/tests/test_query.py index 53a721a..84d67c2 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -583,3 +583,11 @@ def test_fetch_result_with_list_of_tuples(self, mocker) -> None: assert result == ExampleModel( pk=1, slug="john", name="John", content="content" ) + + def test_exclude_pk_raises_valueerror(self) -> None: + """Test that excluding the primary key raises a ValueError.""" + match_str = "The primary key 'pk' cannot be excluded." + + db = SqliterDB(memory=True) + with pytest.raises(ValueError, match=match_str): + db.select(ExampleModel).exclude(["pk"]) From a2f3861c6a70f87f2e349f532ef755444e623589 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 13:46:36 +0100 Subject: [PATCH 12/29] fix last mypy issue Signed-off-by: Grant Ramsay --- sqliter/model/model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sqliter/model/model.py b/sqliter/model/model.py index 6ea208a..130c3a8 100644 --- a/sqliter/model/model.py +++ b/sqliter/model/model.py @@ -10,7 +10,7 @@ from __future__ import annotations import re -from typing import Any, Optional, TypeVar, Union, get_args, get_origin +from typing import Any, Optional, TypeVar, Union, cast, get_args, get_origin from pydantic import BaseModel, ConfigDict, Field @@ -87,7 +87,7 @@ def model_validate_partial(cls: type[T], obj: dict[str, Any]) -> T: else: converted_obj[field_name] = field_type(value) - return cls.model_construct(**converted_obj) + return cast(T, cls.model_construct(**converted_obj)) @classmethod def get_table_name(cls) -> str: From 714e44b741ce296a5b15081709f5acd4af3702fc Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 13:48:31 +0100 Subject: [PATCH 13/29] add missing docstrings Signed-off-by: Grant Ramsay --- tests/test_model.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_model.py b/tests/test_model.py index 331f5e5..eec6245 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,13 +1,15 @@ +"""Specific tests for the Model and it's methods.""" + from typing import Optional import pytest from sqliter.model.model import BaseDBModel -"""Test the Model and it's methods.""" - class TestBaseDBModel: + """Test the Model and it's methods.""" + def test_should_create_pk(self) -> None: """Test that 'should_create_pk' returns True.""" assert BaseDBModel.should_create_pk() is True From b5f1262999950458427f4644d03200212b0f1901 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 14:50:38 +0100 Subject: [PATCH 14/29] update docs for pk changes Signed-off-by: Grant Ramsay --- README.md | 13 ++++--- docs/guide/data-ops.md | 22 ++++++++++-- docs/guide/filtering.md | 11 ++++++ docs/guide/guide.md | 26 +++++++++++--- docs/guide/models.md | 79 +++++++++++++++++++++++++++++++---------- docs/index.md | 7 ++-- docs/quickstart.md | 10 ++++-- mkdocs.yml | 1 + 8 files changed, 133 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 1bf88e2..14141ba 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,10 @@ Website](https://sqliter.grantramsay.dev) > [!CAUTION] > This project is still in the early stages of development and is lacking some -> planned functionality. Please use with caution. +> planned functionality. Please use with caution - Classes and methods may +> change until a stable release is made. I'll try to keep this to an absolute +> minimum and the releases and documentation will be very clear about any +> breaking changes. > > Also, structures like `list`, `dict`, `set` etc are not supported **at this > time** as field types, since SQLite does not have a native column type for @@ -45,7 +48,7 @@ Website](https://sqliter.grantramsay.dev) - Table creation based on Pydantic models - CRUD operations (Create, Read, Update, Delete) -- Basic query building with filtering, ordering, and pagination +- Chained Query building with filtering, ordering, and pagination - Transaction support - Custom exceptions for better error handling - Full type hinting and type checking @@ -114,7 +117,7 @@ db.create_table(User) # Insert a record user = User(name="John Doe", age=30) -db.insert(user) +new_user = db.insert(user) # Query records results = db.select(User).filter(name="John Doe").fetch_all() @@ -123,10 +126,10 @@ for user in results: # Update a record user.age = 31 -db.update(user) +db.update(User,user) # Delete a record -db.delete(User, "John Doe") +db.delete(User, new_user.pk) ``` See the [Usage](https://sqliter.grantramsay.dev/usage) section of the documentation diff --git a/docs/guide/data-ops.md b/docs/guide/data-ops.md index a96f9fb..a739f72 100644 --- a/docs/guide/data-ops.md +++ b/docs/guide/data-ops.md @@ -8,7 +8,16 @@ into the correct table: ```python user = User(name="Jane Doe", age=25, email="jane@example.com") -db.insert(user) +result = db.insert(user) +``` + +The `result` variable will contain a new instance of the model, with the primary +key value set to the newly-created primary key in the database. You should use +this instance to access the primary key value and other fields: + +```python +print(f"New record inserted with primary key: {result.pk}") +print(f"Name: {result.name}, Age: {result.age}, Email: {result.email}") ``` > [!IMPORTANT] @@ -46,15 +55,22 @@ See [Filtering Results](filtering.md) for more advanced filtering options. ## Updating Records +You can update records in the database by modifying the fields of the model +instance and then calling the `update()` method. You need to pass the model +class and the instance to the method: + ```python user.age = 26 -db.update(user) +db.update(User, user) ``` ## Deleting Records +To delete a record from the database, you need to pass the model class and the +primary key value of the record you want to delete: + ```python -db.delete(User, "Jane Doe") +db.delete(User, user.pk) ``` ## Commit your changes diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md index e42f50a..ec279d2 100644 --- a/docs/guide/filtering.md +++ b/docs/guide/filtering.md @@ -8,6 +8,17 @@ records, and can be combined with other methods like `order()`, `limit()`, and result = db.select(User).filter(age__lte=30).limit(10).fetch_all() ``` +It is possible to both add multiple filters in the same call, and to chain +multiple filter calls together: + +```python +result = db.select(User).filter(age__gte=20, age__lte=30).fetch_all() +``` + +```python +result = db.select(User).filter(age__gte=20).filter(age__lte=30).fetch_all() +``` + ## Basic Filters - `__eq`: Equal to (default if no operator is specified) diff --git a/docs/guide/guide.md b/docs/guide/guide.md index 47b4e87..1a69ac7 100644 --- a/docs/guide/guide.md +++ b/docs/guide/guide.md @@ -41,9 +41,12 @@ Inserting records is straightforward with SQLiter: ```python user = User(name="John Doe", age=30, email="john@example.com") -db.insert(user) +new_record = db.insert(user) ``` +If successful, `new_record` will contain a model the same as was passed to it, +but including the newly-created primary key value. + ## Basic Queries You can easily query all records from a table: @@ -109,21 +112,34 @@ returned. ## Updating Records -Records can be updated seamlessly: +Records can be updated seamlessly. Simply modify the fields of the model +instance and call the `update()` method, passing the model class and the updated +instance: ```python user.age = 31 -db.update(user) +db.update(User, user) ``` ## Deleting Records -Deleting records is simple as well: +Deleting records is simple as well. You just need to pass the Model that defines +your table and the primary key value of the record you want to delete: ```python -db.delete(User, "John Doe") +db.delete(User, 1) ``` +> [!NOTE] +> +> You can get the primary key value from the record or model instance itself, +> e.g., `new_record.pk` and pass that as the second argument to the `delete()` +> method: +> +> ```python +> db.delete(User, new_record.pk) +> ``` + ## Advanced Query Features ### Ordering diff --git a/docs/guide/models.md b/docs/guide/models.md index 4d30c06..e4c4145 100644 --- a/docs/guide/models.md +++ b/docs/guide/models.md @@ -1,8 +1,14 @@ -# Defining Models +# Models -Models in SQLiter use Pydantic to encapsulate the logic. All models should -inherit from SQLiter's `BaseDBModel`. You can define your -models like this: +Each individual table in your database should be represented by a model. This +model should inherit from `BaseDBModel` and define the fields that should be +stored in the table. Under the hood, the model is a Pydantic model, so you can +use all the features of Pydantic models, such as default values, type hints, and +validation. + +## Defining Models + +Models are defined like this: ```python from sqliter.model import BaseDBModel @@ -11,23 +17,36 @@ class User(BaseDBModel): name: str age: int email: str - - class Meta: - table_name = "users" - primary_key = "name" # Default is "id" - create_pk = False # disable auto-creating an incrementing primary key - default is True ``` -For a standard database with an auto-incrementing integer `id` primary key, you -do not need to specify the `primary_key` or `create_pk` fields. If you want to -specify a different primary key field name, you can do so using the -`primary_key` field in the `Meta` class. +You can create as many Models as you need, each representing a different table +in your database. The fields in the model will be used to create the columns in +the table. + +> [!IMPORTANT] +> +> - Type-hints are **REQUIRED** for each field in the model. +> - The Model **automatically** creates an **auto-incrementing integer primary +> key** for each table called `pk`, you do not need to define it yourself. + +### Custom Table Name + +By default, the table name will be the same as the model name, converted to +'snake_case' and pluralized (e.g., `User` -> `users`). Also, any 'Model' suffix +will be removed (e.g., `UserModel` -> `users`). To override this behavior, you +can specify the `table_name` in the `Meta` class manually as below: + +```python +from sqliter.model import BaseDBModel + +class User(BaseDBModel): + name: str + age: int + email: str -If `table_name` is not specified, the table name will be the same as the model -name, converted to 'snake_case' and pluralized (e.g., `User` -> `users`). Also, -any 'Model' suffix will be removed (e.g., `UserModel` -> `users`). To override -this behavior, you can specify the `table_name` in the `Meta` class manually as -above. + class Meta: + table_name = "people" +``` > [!NOTE] > @@ -36,3 +55,27 @@ above. > you need more advanced pluralization, you can install the `extras` package as > mentioned in the [installation](../installation.md#optional-dependencies). Of > course, you can always specify the `table_name` manually in this case! + +## Model Classmethods + +There are 2 useful methods you can call on your models. Note that they are +**Class Methods** so should be called on the Model class itself, not an +instance of the model: + +### `get_table_name()` + +This method returns the actual table name for the model either specified or +automatically generated. This is useful if you need to do any raw SQL queries. + +```python +table_name = User.get_table_name() +``` + +### `get_primary_key()` + +This simply returns the name of the primary key for that table. At the moment, +this will always return the string `pk` but this may change in the future. + +```python +primary_key = User.get_primary_key() +``` diff --git a/docs/index.md b/docs/index.md index bf8bed0..c45e465 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,10 @@ database-like format without needing to learn SQL or use a full ORM. > [!CAUTION] > This project is still in the early stages of development and is lacking some -> planned functionality. Please use with caution. +> planned functionality. Please use with caution - Classes and methods may +> change until a stable release is made. I'll try to keep this to an absolute +> minimum and the releases and documentation will be very clear about any +> breaking changes. > > Also, structures like `list`, `dict`, `set` etc are not supported **at this > time** as field types, since SQLite does not have a native column type for @@ -36,7 +39,7 @@ database-like format without needing to learn SQL or use a full ORM. - Table creation based on Pydantic models - CRUD operations (Create, Read, Update, Delete) -- Basic query building with filtering, ordering, and pagination +- Chained Query building with filtering, ordering, and pagination - Transaction support - Custom exceptions for better error handling - Full type hinting and type checking diff --git a/docs/quickstart.md b/docs/quickstart.md index e5a6ead..79b66e4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -26,7 +26,7 @@ db.create_table(User) # Insert a record user = User(name="John Doe", age=30) -db.insert(user) +new_record = db.insert(user) # Query records results = db.select(User).filter(name="John Doe").fetch_all() @@ -35,10 +35,14 @@ for user in results: # Update a record user.age = 31 -db.update(user) +db.update(User, user) + +results = db.select(User).filter(name="John Doe").fetch_one() + +print("Updated age:", results.age) # Delete a record -db.delete(User, "John Doe") +db.delete(User, new_record.pk) ``` See the [Guide](guide/guide.md) for more detailed information on how to use `SQLiter`. diff --git a/mkdocs.yml b/mkdocs.yml index 647e769..c0dd678 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,7 @@ theme: - navigation.tabs - navigation.sections - navigation.indexes + - content.code.copy extra: social: From e904bfce610026c511bee4364ec2ec4044e608f5 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 15:08:02 +0100 Subject: [PATCH 15/29] fix docs re update() method Signed-off-by: Grant Ramsay --- README.md | 4 ++-- docs/guide/data-ops.md | 6 +++--- docs/guide/guide.md | 5 ++--- docs/quickstart.md | 8 ++++---- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 14141ba..b09e4c4 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,8 @@ for user in results: print(f"User: {user.name}, Age: {user.age}") # Update a record -user.age = 31 -db.update(User,user) +new_user.age = 31 +db.update(new_user) # Delete a record db.delete(User, new_user.pk) diff --git a/docs/guide/data-ops.md b/docs/guide/data-ops.md index a739f72..f513447 100644 --- a/docs/guide/data-ops.md +++ b/docs/guide/data-ops.md @@ -56,12 +56,12 @@ See [Filtering Results](filtering.md) for more advanced filtering options. ## Updating Records You can update records in the database by modifying the fields of the model -instance and then calling the `update()` method. You need to pass the model -class and the instance to the method: +instance and then calling the `update()` method. You just pass the model +instance to the method: ```python user.age = 26 -db.update(User, user) +db.update(user) ``` ## Deleting Records diff --git a/docs/guide/guide.md b/docs/guide/guide.md index 1a69ac7..be1dde5 100644 --- a/docs/guide/guide.md +++ b/docs/guide/guide.md @@ -113,12 +113,11 @@ returned. ## Updating Records Records can be updated seamlessly. Simply modify the fields of the model -instance and call the `update()` method, passing the model class and the updated -instance: +instance and pass that to the `update()` method: ```python user.age = 31 -db.update(User, user) +db.update(user) ``` ## Deleting Records diff --git a/docs/quickstart.md b/docs/quickstart.md index 79b66e4..21f63b9 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -26,7 +26,7 @@ db.create_table(User) # Insert a record user = User(name="John Doe", age=30) -new_record = db.insert(user) +new_user = db.insert(user) # Query records results = db.select(User).filter(name="John Doe").fetch_all() @@ -34,15 +34,15 @@ for user in results: print(f"User: {user.name}, Age: {user.age}, Admin: {user.admin}") # Update a record -user.age = 31 -db.update(User, user) +new_user.age = 31 +db.update(new_user) results = db.select(User).filter(name="John Doe").fetch_one() print("Updated age:", results.age) # Delete a record -db.delete(User, new_record.pk) +db.delete(User, new_user.pk) ``` See the [Guide](guide/guide.md) for more detailed information on how to use `SQLiter`. From 12c23b251b80bd91237b22e8f966ca8670b0418d Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 09:45:52 +0100 Subject: [PATCH 16/29] required modifications to make 'pk' non-optional Signed-off-by: Grant Ramsay --- demo.py | 8 +++++--- sqliter/model/model.py | 26 +++++++++----------------- sqliter/query/query.py | 9 ++++++++- sqliter/sqliter.py | 35 ++++++++++++++--------------------- 4 files changed, 36 insertions(+), 42 deletions(-) diff --git a/demo.py b/demo.py index d074658..fedc06e 100644 --- a/demo.py +++ b/demo.py @@ -41,7 +41,9 @@ def main() -> None: level=logging.DEBUG, format="%(levelname)-8s%(message)s" ) - db = SqliterDB(memory=True, auto_commit=True, debug=True) + db = SqliterDB( + "demo.db", memory=False, auto_commit=True, debug=True, reset=True + ) with db: db.create_table(UserModel) # Create the users table user1 = UserModel( @@ -62,7 +64,7 @@ def main() -> None: ) try: db.insert(user1) - db.insert(user2) + user2_id = db.insert(user2) db.insert(user3) except RecordInsertionError as exc: logging.error(exc) # noqa: TRY400 @@ -79,7 +81,7 @@ def main() -> None: ) logging.info(all_reversed) - fetched_user = db.get(UserModel, "jdoe2") + fetched_user = db.get(UserModel, user2_id) logging.info(fetched_user) count = db.select(UserModel).count() diff --git a/sqliter/model/model.py b/sqliter/model/model.py index 1ceca4f..9f3bd82 100644 --- a/sqliter/model/model.py +++ b/sqliter/model/model.py @@ -12,7 +12,7 @@ import re from typing import Any, Optional, TypeVar, Union, get_args, get_origin -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field T = TypeVar("T", bound="BaseDBModel") @@ -28,6 +28,10 @@ class BaseDBModel(BaseModel): representing database models. """ + pk: Optional[int] = Field( + None, description="The mandatory primary key of the table." + ) + model_config = ConfigDict( extra="ignore", populate_by_name=True, @@ -44,10 +48,6 @@ class Meta: table_name (Optional[str]): The name of the database table. """ - create_pk: bool = ( - True # Whether to create an auto-increment primary key - ) - primary_key: str = "id" # Default primary key name table_name: Optional[str] = ( None # Table name, defaults to class name if not set ) @@ -127,18 +127,10 @@ def get_table_name(cls) -> str: @classmethod def get_primary_key(cls) -> str: - """Get the primary key field name for the model. - - Returns: - The name of the primary key field. - """ - return getattr(cls.Meta, "primary_key", "id") + """Returns the mandatory primary key, always 'pk'.""" + return "pk" @classmethod def should_create_pk(cls) -> bool: - """Determine if a primary key should be automatically created. - - Returns: - True if a primary key should be created, False otherwise. - """ - return getattr(cls.Meta, "create_pk", True) + """Returns True since the primary key is always created.""" + return True diff --git a/sqliter/query/query.py b/sqliter/query/query.py index 704c30d..175a439 100644 --- a/sqliter/query/query.py +++ b/sqliter/query/query.py @@ -145,6 +145,8 @@ def fields(self, fields: Optional[list[str]] = None) -> QueryBuilder: The QueryBuilder instance for method chaining. """ if fields: + if "pk" not in fields: + fields.append("pk") self._fields = fields self._validate_fields() return self @@ -164,6 +166,9 @@ def exclude(self, fields: Optional[list[str]] = None) -> QueryBuilder: invalid fields are specified. """ if fields: + if "pk" in fields: + err = "The primary key 'pk' cannot be excluded." + raise ValueError(err) all_fields = set(self.model_class.model_fields.keys()) # Check for invalid fields before subtraction @@ -208,7 +213,7 @@ def only(self, field: str) -> QueryBuilder: raise ValueError(err) # Set self._fields to just the single field - self._fields = [field] + self._fields = [field, "pk"] return self def _get_operator_handler( @@ -527,6 +532,8 @@ def _execute_query( if count_only: fields = "COUNT(*)" elif self._fields: + if "pk" not in self._fields: + self._fields.append("pk") fields = ", ".join(f'"{field}"' for field in self._fields) else: fields = ", ".join( diff --git a/sqliter/sqliter.py b/sqliter/sqliter.py index 7e2a392..6b10468 100644 --- a/sqliter/sqliter.py +++ b/sqliter/sqliter.py @@ -223,28 +223,12 @@ def create_table( """ table_name = model_class.get_table_name() primary_key = model_class.get_primary_key() - create_pk = model_class.should_create_pk() if force: drop_table_sql = f"DROP TABLE IF EXISTS {table_name}" self._execute_sql(drop_table_sql) - fields = [] - - # Always add the primary key field first - if create_pk: - fields.append(f"{primary_key} INTEGER PRIMARY KEY AUTOINCREMENT") - else: - field_info = model_class.model_fields.get(primary_key) - if field_info is not None: - sqlite_type = infer_sqlite_type(field_info.annotation) - fields.append(f"{primary_key} {sqlite_type} PRIMARY KEY") - else: - err = ( - f"Primary key field '{primary_key}' not found in model " - "fields." - ) - raise ValueError(err) + fields = [f'"{primary_key}" INTEGER PRIMARY KEY AUTOINCREMENT'] # Add remaining fields for field_name, field_info in model_class.model_fields.items(): @@ -325,18 +309,22 @@ def _maybe_commit(self) -> None: if self.auto_commit and self.conn: self.conn.commit() - def insert(self, model_instance: BaseDBModel) -> None: + def insert(self, model_instance: BaseDBModel) -> int | None: """Insert a new record into the database. Args: - model_instance: An instance of a Pydantic model to be inserted. + model_instance: The instance of the model class to insert. + + Returns: + The primary key (pk) of the newly inserted record. Raises: - RecordInsertionError: If there's an error inserting the record. + RecordInsertionError: If an error occurs during the insertion. """ model_class = type(model_instance) table_name = model_class.get_table_name() + # Get the data from the model, excluding the 'pk' field data = model_instance.model_dump() fields = ", ".join(data.keys()) placeholders = ", ".join( @@ -354,11 +342,15 @@ def insert(self, model_instance: BaseDBModel) -> None: cursor = conn.cursor() cursor.execute(insert_sql, values) self._maybe_commit() + except sqlite3.Error as exc: raise RecordInsertionError(table_name) from exc + else: + # Return the primary key (pk) of the inserted row + return cursor.lastrowid def get( - self, model_class: type[BaseDBModel], primary_key_value: str + self, model_class: type[BaseDBModel], primary_key_value: int ) -> BaseDBModel | None: """Retrieve a single record from the database by its primary key. @@ -410,6 +402,7 @@ def update(self, model_instance: BaseDBModel) -> None: """ model_class = type(model_instance) table_name = model_class.get_table_name() + primary_key = model_class.get_primary_key() fields = ", ".join( From 8078178ec0a7187e415dedf4290e141fad3596e5 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 09:57:45 +0100 Subject: [PATCH 17/29] use pydantic mypy plugin this is needed because we always create the pk, but it is optional and not specified when creating model instance Signed-off-by: Grant Ramsay --- demo.py | 7 +++++-- pyproject.toml | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/demo.py b/demo.py index fedc06e..c919c6b 100644 --- a/demo.py +++ b/demo.py @@ -81,8 +81,11 @@ def main() -> None: ) logging.info(all_reversed) - fetched_user = db.get(UserModel, user2_id) - logging.info(fetched_user) + if user2_id is None: + logging.error("User2 ID not found.") + else: + fetched_user = db.get(UserModel, user2_id) + logging.info(fetched_user) count = db.select(UserModel).count() logging.info("Total Users: %s", count) diff --git a/pyproject.toml b/pyproject.toml index 047e36f..9c28bad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,9 +144,10 @@ known-first-party = ["sqliter"] keep-runtime-typing = true [tool.mypy] +plugins = ["pydantic.mypy"] + python_version = "3.9" exclude = ["docs"] - [[tool.mypy.overrides]] disable_error_code = ["method-assign", "no-untyped-def", "attr-defined"] module = "tests.*" From d6c1d71b49859d6f5c640908c9c384d882fb261f Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 10:37:57 +0100 Subject: [PATCH 18/29] fix mypy in pre-commit needed the 'pydantic' dep added. Signed-off-by: Grant Ramsay --- .pre-commit-config.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2ee425..fda8b51 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,6 +30,8 @@ repos: hooks: - id: mypy name: "run mypy" + additional_dependencies: + - pydantic - repo: https://github.com/astral-sh/uv-pre-commit # uv version. From 03727dfa9431e7193616bc3090ae99d408f2a05b Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 10:38:08 +0100 Subject: [PATCH 19/29] wip on fixing tests Signed-off-by: Grant Ramsay --- sqliter/exceptions.py | 2 +- sqliter/query/query.py | 2 +- sqliter/sqliter.py | 4 ++-- tests/conftest.py | 9 -------- tests/test_debug_logging.py | 25 +++++++++++---------- tests/test_execeptions.py | 5 ++++- tests/test_optional_fields_complex_model.py | 20 ++++++++--------- tests/test_query.py | 6 ++--- 8 files changed, 34 insertions(+), 39 deletions(-) diff --git a/sqliter/exceptions.py b/sqliter/exceptions.py index cf49652..f248a94 100644 --- a/sqliter/exceptions.py +++ b/sqliter/exceptions.py @@ -114,7 +114,7 @@ class RecordUpdateError(SqliterError): class RecordNotFoundError(SqliterError): """Exception raised when a requested record is not found in the database.""" - message_template = "Failed to find a record for key '{}' " + message_template = "Failed to find that record in the table (key '{}') " class RecordFetchError(SqliterError): diff --git a/sqliter/query/query.py b/sqliter/query/query.py index 175a439..a4ba9de 100644 --- a/sqliter/query/query.py +++ b/sqliter/query/query.py @@ -184,7 +184,7 @@ def exclude(self, fields: Optional[list[str]] = None) -> QueryBuilder: self._fields = list(all_fields - set(fields)) # Explicit check: raise an error if no fields remain - if not self._fields: + if self._fields == ["pk"]: err = "Exclusion results in no fields being selected." raise ValueError(err) diff --git a/sqliter/sqliter.py b/sqliter/sqliter.py index 6b10468..23f82c1 100644 --- a/sqliter/sqliter.py +++ b/sqliter/sqliter.py @@ -397,8 +397,8 @@ def update(self, model_instance: BaseDBModel) -> None: model_instance: An instance of a Pydantic model to be updated. Raises: - RecordUpdateError: If there's an error updating the record. - RecordNotFoundError: If the record to update is not found. + RecordUpdateError: If there's an error updating the record or if it + is not found. """ model_class = type(model_instance) table_name = model_class.get_table_name() diff --git a/tests/conftest.py b/tests/conftest.py index 4145c45..82719eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,8 +39,6 @@ class ExampleModel(BaseDBModel): class Meta: """Configuration for the model.""" - create_pk: bool = False - primary_key: str = "slug" table_name: str = "test_table" @@ -53,9 +51,7 @@ class PersonModel(BaseDBModel): class Meta: """Configuration for the model.""" - create_pk = False table_name = "person_table" - primary_key = "name" class DetailedPersonModel(BaseDBModel): @@ -72,14 +68,11 @@ class Meta: """Configuration for the model.""" table_name = "detailed_person_table" - primary_key = "name" - create_pk = False class ComplexModel(BaseDBModel): """Model to test complex field types.""" - id: int name: str age: float is_active: bool @@ -90,8 +83,6 @@ class Meta: """Configuration for the model.""" table_name = "complex_model" - primary_key = "id" - create_pk = False @pytest.fixture diff --git a/tests/test_debug_logging.py b/tests/test_debug_logging.py index 1176cfe..f4a7eb6 100644 --- a/tests/test_debug_logging.py +++ b/tests/test_debug_logging.py @@ -39,7 +39,7 @@ def test_debug_sql_output_basic_query( # Assert the SQL query was printed assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" WHERE age = 30.5' in caplog.text ) @@ -55,7 +55,7 @@ def test_debug_sql_output_string_values( # Assert the SQL query was printed with the string properly quoted assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" WHERE name = \'Alice\'' in caplog.text ) @@ -71,7 +71,7 @@ def test_debug_sql_output_multiple_conditions( # Assert the SQL query was printed with multiple conditions assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" WHERE name = \'Alice\' AND ' "age = 30.5" in caplog.text ) @@ -87,7 +87,7 @@ def test_debug_sql_output_order_and_limit( # Assert the SQL query was printed with ORDER and LIMIT assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" ORDER BY "age" DESC LIMIT 1' in caplog.text ) @@ -99,7 +99,7 @@ def test_debug_sql_output_with_null_value( with caplog.at_level(logging.DEBUG): db_mock_complex_debug.insert( ComplexModel( - id=4, + pk=4, name="David", age=40.0, is_active=True, @@ -114,7 +114,7 @@ def test_debug_sql_output_with_null_value( # Assert the SQL query was printed with IS NULL assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" WHERE age IS NULL' in caplog.text ) @@ -130,7 +130,8 @@ def test_debug_sql_output_with_fields_single( # Assert the SQL query only selects the 'name' field assert ( - 'Executing SQL: SELECT "name" FROM "complex_model"' in caplog.text + 'Executing SQL: SELECT "name", "pk" FROM "complex_model"' + in caplog.text ) def test_debug_sql_output_with_fields_multiple( @@ -144,7 +145,7 @@ def test_debug_sql_output_with_fields_multiple( # Assert the SQL query only selects the 'name' and 'age' fields assert ( - 'Executing SQL: SELECT "name", "age" FROM "complex_model"' + 'Executing SQL: SELECT "name", "age", "pk" FROM "complex_model"' in caplog.text ) @@ -159,7 +160,7 @@ def test_debug_sql_output_with_fields_and_filter( # Assert the SQL query selects 'name' and 'score' and applies the filter assert ( - 'Executing SQL: SELECT "name", "score" FROM "complex_model" ' + 'Executing SQL: SELECT "name", "score", "pk" FROM "complex_model" ' "WHERE score > 85" in caplog.text ) @@ -198,7 +199,7 @@ def test_manual_logger_respects_debug_flag(self, caplog) -> None: # Assert that log output was captured with the manually passed logger assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' in caplog.text ) @@ -227,7 +228,7 @@ def test_debug_sql_output_no_matching_records( # Assert that the SQL query was logged despite no matching records assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model" WHERE age = 100' in caplog.text ) @@ -241,7 +242,7 @@ def test_debug_sql_output_empty_query( # Assert that the SQL query was logged for a full table scan assert ( - 'Executing SQL: SELECT "id", "name", "age", "is_active", "score", ' + 'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", ' '"nullable_field" FROM "complex_model"' in caplog.text ) diff --git a/tests/test_execeptions.py b/tests/test_execeptions.py index cfd4eb5..69faf8c 100644 --- a/tests/test_execeptions.py +++ b/tests/test_execeptions.py @@ -55,6 +55,7 @@ def test_database_connection_error(self, mocker) -> None: exc_info.value ) + @pytest.mark.skip(reason="This is no longer a valid test case.") def test_insert_duplicate_primary_key(self, db_mock) -> None: """Test that exception raised when inserting duplicate primary key.""" # Create a model instance with a unique primary key @@ -98,7 +99,9 @@ def test_update_not_found_error(self, db_mock) -> None: db_mock.update(example_model) # Verify that the exception message contains the table name - assert "Failed to find a record for key 'test'" in str(exc_info.value) + assert "Failed to find that record in the table (key 'None')" in str( + exc_info.value + ) def test_update_exception_error(self, db_mock, mocker) -> None: """Test an exception is raised when updating a record with an error.""" diff --git a/tests/test_optional_fields_complex_model.py b/tests/test_optional_fields_complex_model.py index 3349390..260c0c7 100644 --- a/tests/test_optional_fields_complex_model.py +++ b/tests/test_optional_fields_complex_model.py @@ -8,11 +8,11 @@ @pytest.fixture def db_mock_complex(db_mock: SqliterDB) -> SqliterDB: - """Ficture for a mock database with a complex model.""" + """Fixture for a mock database with a complex model.""" db_mock.create_table(ComplexModel) db_mock.insert( ComplexModel( - id=1, + pk=1, name="Alice", age=30.5, is_active=True, @@ -22,7 +22,7 @@ def db_mock_complex(db_mock: SqliterDB) -> SqliterDB: ) db_mock.insert( ComplexModel( - id=2, + pk=2, name="Bob", age=25.0, is_active=False, @@ -41,7 +41,7 @@ def test_select_all_fields(self, db_mock_complex: SqliterDB) -> None: results = db_mock_complex.select(ComplexModel).fetch_all() assert len(results) == 2 for result in results: - assert isinstance(result.id, int) + assert isinstance(result.pk, int) assert isinstance(result.name, str) assert isinstance(result.age, float) assert isinstance(result.is_active, bool) @@ -53,13 +53,13 @@ def test_select_all_fields(self, db_mock_complex: SqliterDB) -> None: def test_select_subset_of_fields(self, db_mock_complex: SqliterDB) -> None: """Select a subset of fields and ensure their types are correct.""" - fields = ["id", "name", "age", "is_active", "score"] + fields = ["pk", "name", "age", "is_active", "score"] results = db_mock_complex.select( ComplexModel, fields=fields ).fetch_all() assert len(results) == 2 for result in results: - assert isinstance(result.id, int) + assert isinstance(result.pk, int) assert isinstance(result.name, str) assert isinstance(result.age, float) assert isinstance(result.is_active, bool) @@ -71,13 +71,13 @@ def test_select_with_type_conversion( self, db_mock_complex: SqliterDB ) -> None: """Select a subset of fields and ensure their types are correct.""" - fields = ["id", "age", "is_active", "score"] + fields = ["pk", "age", "is_active", "score"] results = db_mock_complex.select( ComplexModel, fields=fields ).fetch_all() assert len(results) == 2 for result in results: - assert isinstance(result.id, int) + assert isinstance(result.pk, int) assert isinstance(result.age, float) assert isinstance(result.is_active, bool) assert isinstance(result.score, (int, float)) @@ -107,7 +107,7 @@ def test_select_with_union_field(self, db_mock_complex: SqliterDB) -> None: def test_select_with_filtering(self, db_mock_complex: SqliterDB) -> None: """Select fields with a filter.""" - fields = ["id", "name", "age"] + fields = ["pk", "name", "age"] results = ( db_mock_complex.select(ComplexModel, fields=fields) .filter(age__gt=28) @@ -119,7 +119,7 @@ def test_select_with_filtering(self, db_mock_complex: SqliterDB) -> None: def test_select_with_ordering(self, db_mock_complex: SqliterDB) -> None: """Select fields with ordering.""" - fields = ["id", "name", "age"] + fields = ["pk", "name", "age"] results = ( db_mock_complex.select(ComplexModel, fields=fields) .order("age", direction="DESC") diff --git a/tests/test_query.py b/tests/test_query.py index 756c993..53a721a 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -565,8 +565,8 @@ def test_fetch_result_with_list_of_tuples(self, mocker) -> None: # Create some mock tuples (mimicking database rows) mock_result = [ - ("john", "John", "content"), - ("jane", "Jane", "content"), + ("1", "john", "John", "content"), + ("2", "jane", "Jane", "content"), ] # Mock the _execute_query method on the QueryBuilder instance @@ -581,5 +581,5 @@ def test_fetch_result_with_list_of_tuples(self, mocker) -> None: assert not isinstance(result, list) assert isinstance(result, ExampleModel) assert result == ExampleModel( - slug="john", name="John", content="content" + pk=1, slug="john", name="John", content="content" ) From 04aa3ee312d6b9e4ccc809aedbb9175d957d7a91 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 12:05:10 +0100 Subject: [PATCH 20/29] make pk non-optional and default to 0 Signed-off-by: Grant Ramsay --- demo.py | 8 ++++---- sqliter/model/model.py | 4 +--- sqliter/sqliter.py | 17 +++++++++++------ tests/test_execeptions.py | 2 +- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/demo.py b/demo.py index c919c6b..b3c8a6c 100644 --- a/demo.py +++ b/demo.py @@ -64,7 +64,7 @@ def main() -> None: ) try: db.insert(user1) - user2_id = db.insert(user2) + user2_instance = db.insert(user2) db.insert(user3) except RecordInsertionError as exc: logging.error(exc) # noqa: TRY400 @@ -81,11 +81,11 @@ def main() -> None: ) logging.info(all_reversed) - if user2_id is None: + if user2_instance is None: logging.error("User2 ID not found.") else: - fetched_user = db.get(UserModel, user2_id) - logging.info(fetched_user) + fetched_user = db.get(UserModel, user2_instance.pk) + logging.info("Fetched (%s)", fetched_user) count = db.select(UserModel).count() logging.info("Total Users: %s", count) diff --git a/sqliter/model/model.py b/sqliter/model/model.py index 9f3bd82..6ea208a 100644 --- a/sqliter/model/model.py +++ b/sqliter/model/model.py @@ -28,9 +28,7 @@ class BaseDBModel(BaseModel): representing database models. """ - pk: Optional[int] = Field( - None, description="The mandatory primary key of the table." - ) + pk: int = Field(0, description="The mandatory primary key of the table.") model_config = ConfigDict( extra="ignore", diff --git a/sqliter/sqliter.py b/sqliter/sqliter.py index 23f82c1..1524d16 100644 --- a/sqliter/sqliter.py +++ b/sqliter/sqliter.py @@ -10,7 +10,7 @@ import logging import sqlite3 -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Optional, TypeVar from typing_extensions import Self @@ -33,6 +33,8 @@ from sqliter.model.model import BaseDBModel +T = TypeVar("T", bound="BaseDBModel") + class SqliterDB: """Main class for interacting with SQLite databases. @@ -309,14 +311,14 @@ def _maybe_commit(self) -> None: if self.auto_commit and self.conn: self.conn.commit() - def insert(self, model_instance: BaseDBModel) -> int | None: + def insert(self, model_instance: T) -> T: """Insert a new record into the database. Args: model_instance: The instance of the model class to insert. Returns: - The primary key (pk) of the newly inserted record. + The updated model instance with the primary key (pk) set. Raises: RecordInsertionError: If an error occurs during the insertion. @@ -324,8 +326,12 @@ def insert(self, model_instance: BaseDBModel) -> int | None: model_class = type(model_instance) table_name = model_class.get_table_name() - # Get the data from the model, excluding the 'pk' field + # Get the data from the model data = model_instance.model_dump() + # remove the primary key field if it exists, otherwise we'll get + # TypeErrors as multiple primary keys will exist + data.pop("pk") + fields = ", ".join(data.keys()) placeholders = ", ".join( ["?" if value is not None else "NULL" for value in data.values()] @@ -346,8 +352,7 @@ def insert(self, model_instance: BaseDBModel) -> int | None: except sqlite3.Error as exc: raise RecordInsertionError(table_name) from exc else: - # Return the primary key (pk) of the inserted row - return cursor.lastrowid + return model_class(pk=cursor.lastrowid, **data) def get( self, model_class: type[BaseDBModel], primary_key_value: int diff --git a/tests/test_execeptions.py b/tests/test_execeptions.py index 69faf8c..4bd9323 100644 --- a/tests/test_execeptions.py +++ b/tests/test_execeptions.py @@ -99,7 +99,7 @@ def test_update_not_found_error(self, db_mock) -> None: db_mock.update(example_model) # Verify that the exception message contains the table name - assert "Failed to find that record in the table (key 'None')" in str( + assert "Failed to find that record in the table (key '0')" in str( exc_info.value ) From 6164250547a6cb078a5fa4b94eb6172b5c4f9457 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 12:49:33 +0100 Subject: [PATCH 21/29] add pytest hook to clear screen before each test run Signed-off-by: Grant Ramsay --- tests/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 82719eb..2e48dcd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Optional, Union @@ -16,6 +17,12 @@ memory_db = ":memory:" +@pytest.hookimpl(tryfirst=True) +def pytest_configure(config) -> None: + """Clear the screen before running tests.""" + os.system("cls" if os.name == "nt" else "clear") # noqa: S605 + + @contextmanager def not_raises(exception) -> Generator[None, Any, None]: """Fake a pytest.raises context manager that does not raise an exception. From eef5abe364656c98caff2fa4a7ce055c4de38058 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 12:49:56 +0100 Subject: [PATCH 22/29] ensure insert fails on duplicate pk Signed-off-by: Grant Ramsay --- sqliter/sqliter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sqliter/sqliter.py b/sqliter/sqliter.py index 1524d16..62bf605 100644 --- a/sqliter/sqliter.py +++ b/sqliter/sqliter.py @@ -330,7 +330,8 @@ def insert(self, model_instance: T) -> T: data = model_instance.model_dump() # remove the primary key field if it exists, otherwise we'll get # TypeErrors as multiple primary keys will exist - data.pop("pk") + if data.get("pk", None) == 0: + data.pop("pk") fields = ", ".join(data.keys()) placeholders = ", ".join( @@ -352,6 +353,7 @@ def insert(self, model_instance: T) -> T: except sqlite3.Error as exc: raise RecordInsertionError(table_name) from exc else: + data.pop("pk", None) return model_class(pk=cursor.lastrowid, **data) def get( From 153fa2b6c0bdac34e2cefec87633a940c766534b Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 12:50:25 +0100 Subject: [PATCH 23/29] fix final existing tests Signed-off-by: Grant Ramsay --- tests/test_execeptions.py | 6 +- tests/test_sqliter.py | 164 +++++++------------------------------- 2 files changed, 33 insertions(+), 137 deletions(-) diff --git a/tests/test_execeptions.py b/tests/test_execeptions.py index 4bd9323..0bb3009 100644 --- a/tests/test_execeptions.py +++ b/tests/test_execeptions.py @@ -55,7 +55,7 @@ def test_database_connection_error(self, mocker) -> None: exc_info.value ) - @pytest.mark.skip(reason="This is no longer a valid test case.") + # @pytest.mark.skip(reason="This is no longer a valid test case.") def test_insert_duplicate_primary_key(self, db_mock) -> None: """Test that exception raised when inserting duplicate primary key.""" # Create a model instance with a unique primary key @@ -64,11 +64,11 @@ def test_insert_duplicate_primary_key(self, db_mock) -> None: ) # Insert the record for the first time, should succeed - db_mock.insert(example_model) + result = db_mock.insert(example_model) # Try inserting the same record again, which should raise our exception with pytest.raises(RecordInsertionError) as exc_info: - db_mock.insert(example_model) + db_mock.insert(result) # Verify that the exception message contains the table name assert "Failed to insert record into table: 'test_table'" in str( diff --git a/tests/test_sqliter.py b/tests/test_sqliter.py index 5a55e35..5447952 100644 --- a/tests/test_sqliter.py +++ b/tests/test_sqliter.py @@ -51,10 +51,10 @@ def test_data_lost_when_auto_commit_disabled(self) -> None: test_model = ExampleModel( slug="test", name="Test License", content="Test Content" ) - db.insert(test_model) + result = db.insert(test_model) # Ensure the record exists - fetched_license = db.get(ExampleModel, "test") + fetched_license = db.get(ExampleModel, result.pk) assert fetched_license is not None # Close the connection @@ -65,7 +65,7 @@ def test_data_lost_when_auto_commit_disabled(self) -> None: # Ensure the data is lost with pytest.raises(RecordFetchError): - db.get(ExampleModel, "test") + db.get(ExampleModel, result.pk) def test_create_table(self, db_mock) -> None: """Test table creation.""" @@ -73,8 +73,8 @@ def test_create_table(self, db_mock) -> None: cursor = conn.cursor() cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") tables = cursor.fetchall() - assert len(tables) == 1 - assert tables[0][0] == "test_table" + assert len(tables) == 2 + assert ("test_table",) in tables def test_close_connection(self, db_mock) -> None: """Test closing the connection.""" @@ -95,15 +95,13 @@ def test_commit_changes(self, mocker) -> None: assert mock_conn.commit.called - def test_create_table_with_auto_increment(self, db_mock) -> None: + def test_create_table_with_default_auto_increment(self, db_mock) -> None: """Test table creation with auto-incrementing primary key.""" class AutoIncrementModel(BaseDBModel): name: str class Meta: - create_pk: bool = True # Enable auto-increment ID - primary_key: str = "id" # Default primary key is 'id' table_name: str = "auto_increment_table" # Create the table @@ -116,113 +114,10 @@ class Meta: table_info = cursor.fetchall() # Check that the first column is 'id' and it's an auto-incrementing int - assert table_info[0][1] == "id" # Column name + assert table_info[0][1] == "pk" # Column name assert table_info[0][2] == "INTEGER" # Column type assert table_info[0][5] == 1 # Primary key flag - def test_create_table_with_custom_primary_key(self, db_mock) -> None: - """Test table creation with a custom primary key.""" - - class CustomPKModel(BaseDBModel): - code: str - description: str - - class Meta: - create_pk: bool = False # Disable auto-increment ID - primary_key: str = "code" # Use 'code' as the primary key - table_name: str = "custom_pk_table" - - # Create the table - db_mock.create_table(CustomPKModel) - - # Verify that the table was created with 'code' as the primary key - with db_mock.connect() as conn: - cursor = conn.cursor() - cursor.execute("PRAGMA table_info(custom_pk_table);") - table_info = cursor.fetchall() - - # Check that the primary key is the 'code' column - primary_key_column = next(col for col in table_info if col[1] == "code") - assert primary_key_column[1] == "code" # Column name - assert primary_key_column[5] == 1 # Primary key flag - - def test_create_table_with_custom_auto_increment_pk(self, db_mock) -> None: - """Test table creation with a custom auto-incrementing primary key.""" - - class CustomAutoIncrementPKModel(BaseDBModel): - name: str - - class Meta: - create_pk: bool = True # Enable auto-increment ID - primary_key: str = ( - "custom_id" # Use 'custom_id' as the primary key - ) - table_name: str = "custom_auto_increment_pk_table" - - # Create the table - db_mock.create_table(CustomAutoIncrementPKModel) - - # Check the table schema using PRAGMA - with db_mock.connect() as conn: - cursor = conn.cursor() - cursor.execute("PRAGMA table_info(custom_auto_increment_pk_table);") - table_info = cursor.fetchall() - - # Check that the 'custom_id' column is INTEGER and a primary key - primary_key_column = next( - col for col in table_info if col[1] == "custom_id" - ) - assert primary_key_column[1] == "custom_id" # Column name - assert primary_key_column[2] == "INTEGER" # Column type - assert primary_key_column[5] == 1 # Primary key flag - - # Insert rows to verify that the custom primary key auto-increments - model_instance1 = CustomAutoIncrementPKModel(name="First Entry") - model_instance2 = CustomAutoIncrementPKModel(name="Second Entry") - - db_mock.insert(model_instance1) - db_mock.insert(model_instance2) - - # Fetch the inserted rows and check the 'custom_id' values - with db_mock.connect() as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT custom_id, name FROM custom_auto_increment_pk_table;" - ) - results = cursor.fetchall() - - # Check that the custom_id column auto-incremented - assert results[0][0] == 1 - assert results[1][0] == 2 - assert results[0][1] == "First Entry" - assert results[1][1] == "Second Entry" - - def test_create_table_missing_primary_key(self) -> None: - """Test create_table raises ValueError when primary key is missing.""" - - # Define a model that doesn't have the expected primary key - class NoPKModel(BaseDBModel): - # Intentionally omitting the primary key field, e.g., 'id' or 'slug' - name: str - age: int - - class Meta: - create_pk = False - - # Initialize your SqliterDB instance (adjust if needed) - db = SqliterDB(memory=True) # Assuming memory=True uses an in-memory DB - - # Use pytest.raises to check if ValueError is raised - with pytest.raises( - ValueError, - match="Primary key field 'id' not found in model fields.", - ) as exc_info: - db.create_table(NoPKModel) - - # Check that the error message matches the expected output - assert "Primary key field" in str(exc_info.value) - assert "not found in model fields" in str(exc_info.value) - def test_default_table_name(self, db_mock) -> None: """Test the default table name generation. @@ -282,18 +177,19 @@ def test_insert_license(self, db_mock) -> None: cursor = conn.cursor() cursor.execute("SELECT * FROM test_table WHERE slug = ?", ("mit",)) result = cursor.fetchone() - assert result[0] == "mit" - assert result[1] == "MIT License" - assert result[2] == "MIT License Content" + assert result[0] == 1 + assert result[1] == "mit" + assert result[2] == "MIT License" + assert result[3] == "MIT License Content" def test_fetch_license(self, db_mock) -> None: """Test fetching a license by primary key.""" test_model = ExampleModel( slug="gpl", name="GPL License", content="GPL License Content" ) - db_mock.insert(test_model) + result = db_mock.insert(test_model) - fetched_license = db_mock.get(ExampleModel, "gpl") + fetched_license = db_mock.get(ExampleModel, result.pk) assert fetched_license is not None assert fetched_license.slug == "gpl" assert fetched_license.name == "GPL License" @@ -304,14 +200,14 @@ def test_update(self, db_mock) -> None: test_model = ExampleModel( slug="mit", name="MIT License", content="MIT License Content" ) - db_mock.insert(test_model) + result = db_mock.insert(test_model) # Update license content - test_model.content = "Updated MIT License Content" - db_mock.update(test_model) + result.content = "Updated MIT License Content" + db_mock.update(result) # Fetch and check if updated - fetched_license = db_mock.get(ExampleModel, "mit") + fetched_license = db_mock.get(ExampleModel, result.pk) assert fetched_license.content == "Updated MIT License Content" def test_delete(self, db_mock) -> None: @@ -319,13 +215,13 @@ def test_delete(self, db_mock) -> None: test_model = ExampleModel( slug="mit", name="MIT License", content="MIT License Content" ) - db_mock.insert(test_model) + result = db_mock.insert(test_model) # Delete the record - db_mock.delete(ExampleModel, "mit") + db_mock.delete(ExampleModel, result.pk) # Ensure it no longer exists - fetched_license = db_mock.get(ExampleModel, "mit") + fetched_license = db_mock.get(ExampleModel, result.pk) assert fetched_license is None def test_select_filter(self, db_mock) -> None: @@ -455,14 +351,14 @@ def test_update_existing_record(self, db_mock) -> None: example_model = ExampleModel( slug="test", name="Test License", content="Test Content" ) - db_mock.insert(example_model) + result = db_mock.insert(example_model) # Update the record's content - example_model.content = "Updated Content" - db_mock.update(example_model) + result.content = "Updated Content" + db_mock.update(result) # Fetch the updated record and verify the changes - updated_record = db_mock.get(ExampleModel, "test") + updated_record = db_mock.get(ExampleModel, result.pk) assert updated_record is not None assert updated_record.content == "Updated Content" @@ -480,7 +376,7 @@ def test_update_non_existing_record(self, db_mock) -> None: db_mock.update(example_model) # Check that the correct error message is raised - assert "Failed to find a record for key 'nonexistent'" in str( + assert "Failed to find that record in the table (key '0')" in str( exc_info.value ) @@ -510,13 +406,13 @@ def test_delete_existing_record(self, db_mock) -> None: test_model = ExampleModel( slug="test", name="Test License", content="Test Content" ) - db_mock.insert(test_model) + result = db_mock.insert(test_model) # Now delete the record - db_mock.delete(ExampleModel, "test") + db_mock.delete(ExampleModel, result.pk) # Fetch the deleted record to confirm it's gone - result = db_mock.get(ExampleModel, "test") + result = db_mock.get(ExampleModel, result.pk) assert result is None def test_transaction_commit_success(self, db_mock, mocker) -> None: @@ -685,7 +581,7 @@ def test_complex_model_field_types(self, db_mock) -> None: # Expected types in SQLite (INTEGER, REAL, TEXT, etc.) expected_types = { - "id": "INTEGER", + "pk": "INTEGER", "name": "TEXT", "age": "REAL", "price": "REAL", @@ -727,7 +623,7 @@ def test_complex_model_primary_key(self, db_mock) -> None: # Assert that the primary key is the 'id' field and is an INTEGER assert primary_key_column is not None, "Primary key not found" assert ( - primary_key_column[1] == "id" + primary_key_column[1] == "pk" ), f"Expected 'id' as primary key, but got {primary_key_column[1]}" assert primary_key_column[2] == "INTEGER", ( f"Expected 'INTEGER' type for primary key, but got " From 53f0f67205d2d3e53d9ed74e9e037887b53c417e Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 13:31:21 +0100 Subject: [PATCH 24/29] add specific model unit tests Signed-off-by: Grant Ramsay --- tests/test_model.py | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/test_model.py diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..331f5e5 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,47 @@ +from typing import Optional + +import pytest + +from sqliter.model.model import BaseDBModel + +"""Test the Model and it's methods.""" + + +class TestBaseDBModel: + def test_should_create_pk(self) -> None: + """Test that 'should_create_pk' returns True.""" + assert BaseDBModel.should_create_pk() is True + + def test_get_primary_key(self) -> None: + """Test that 'get_primary_key' returns 'pk'.""" + assert BaseDBModel.get_primary_key() == "pk" + + def test_get_table_name_default(self) -> None: + """Test that 'get_table_name' returns the default table name.""" + + class TestModel(BaseDBModel): + pass + + assert TestModel.get_table_name() == "tests" + + def test_get_table_name_custom(self) -> None: + """Test that 'get_table_name' returns the custom table name.""" + + class TestModel(BaseDBModel): + class Meta: + table_name = "custom_table" + + assert TestModel.get_table_name() == "custom_table" + + def test_model_validate_partial(self) -> None: + """Test 'model_validate_partial' with partial data.""" + + class TestModel(BaseDBModel): + name: str + age: Optional[int] + + data = {"name": "John"} + model_instance = TestModel.model_validate_partial(data) + assert model_instance.name == "John" + with pytest.raises(AttributeError): + _ = model_instance.age From 55e9fd8c34f33ab9932c7f92a635a471873c39bf Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 13:36:45 +0100 Subject: [PATCH 25/29] complete test coverage of changed code Signed-off-by: Grant Ramsay --- tests/test_query.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_query.py b/tests/test_query.py index 53a721a..84d67c2 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -583,3 +583,11 @@ def test_fetch_result_with_list_of_tuples(self, mocker) -> None: assert result == ExampleModel( pk=1, slug="john", name="John", content="content" ) + + def test_exclude_pk_raises_valueerror(self) -> None: + """Test that excluding the primary key raises a ValueError.""" + match_str = "The primary key 'pk' cannot be excluded." + + db = SqliterDB(memory=True) + with pytest.raises(ValueError, match=match_str): + db.select(ExampleModel).exclude(["pk"]) From 358d7053ecf33dd7dd19356c916f567ceaa8066f Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 13:46:36 +0100 Subject: [PATCH 26/29] fix last mypy issue Signed-off-by: Grant Ramsay --- sqliter/model/model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sqliter/model/model.py b/sqliter/model/model.py index 6ea208a..130c3a8 100644 --- a/sqliter/model/model.py +++ b/sqliter/model/model.py @@ -10,7 +10,7 @@ from __future__ import annotations import re -from typing import Any, Optional, TypeVar, Union, get_args, get_origin +from typing import Any, Optional, TypeVar, Union, cast, get_args, get_origin from pydantic import BaseModel, ConfigDict, Field @@ -87,7 +87,7 @@ def model_validate_partial(cls: type[T], obj: dict[str, Any]) -> T: else: converted_obj[field_name] = field_type(value) - return cls.model_construct(**converted_obj) + return cast(T, cls.model_construct(**converted_obj)) @classmethod def get_table_name(cls) -> str: From 607a65e8aab72a61fe845e05551e25d9b73ca07d Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 13:48:31 +0100 Subject: [PATCH 27/29] add missing docstrings Signed-off-by: Grant Ramsay --- tests/test_model.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_model.py b/tests/test_model.py index 331f5e5..eec6245 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,13 +1,15 @@ +"""Specific tests for the Model and it's methods.""" + from typing import Optional import pytest from sqliter.model.model import BaseDBModel -"""Test the Model and it's methods.""" - class TestBaseDBModel: + """Test the Model and it's methods.""" + def test_should_create_pk(self) -> None: """Test that 'should_create_pk' returns True.""" assert BaseDBModel.should_create_pk() is True From 9e18cbbc021baa41cded28daa1236f4221237681 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 14:50:38 +0100 Subject: [PATCH 28/29] update docs for pk changes Signed-off-by: Grant Ramsay --- README.md | 13 ++++--- docs/guide/data-ops.md | 22 ++++++++++-- docs/guide/filtering.md | 11 ++++++ docs/guide/guide.md | 26 +++++++++++--- docs/guide/models.md | 79 +++++++++++++++++++++++++++++++---------- docs/index.md | 7 ++-- docs/quickstart.md | 10 ++++-- mkdocs.yml | 1 + 8 files changed, 133 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index a91c431..ac192ac 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,10 @@ Website](https://sqliter.grantramsay.dev) > [!CAUTION] > This project is still in the early stages of development and is lacking some -> planned functionality. Please use with caution. +> planned functionality. Please use with caution - Classes and methods may +> change until a stable release is made. I'll try to keep this to an absolute +> minimum and the releases and documentation will be very clear about any +> breaking changes. > > Also, structures like `list`, `dict`, `set` etc are not supported **at this > time** as field types, since SQLite does not have a native column type for @@ -45,7 +48,7 @@ Website](https://sqliter.grantramsay.dev) - Table creation based on Pydantic models - CRUD operations (Create, Read, Update, Delete) -- Basic query building with filtering, ordering, and pagination +- Chained Query building with filtering, ordering, and pagination - Transaction support - Custom exceptions for better error handling - Full type hinting and type checking @@ -114,7 +117,7 @@ db.create_table(User) # Insert a record user = User(name="John Doe", age=30) -new_record = db.insert(user) +new_user = db.insert(user) # Query records results = db.select(User).filter(name="John Doe").fetch_all() @@ -123,10 +126,10 @@ for user in results: # Update a record user.age = 31 -db.update(User, new_record) +db.update(user) # Delete a record -db.delete(User, new_record.pk) +db.delete(User, new_user.pk) ``` See the [Usage](https://sqliter.grantramsay.dev/usage) section of the documentation diff --git a/docs/guide/data-ops.md b/docs/guide/data-ops.md index a96f9fb..a739f72 100644 --- a/docs/guide/data-ops.md +++ b/docs/guide/data-ops.md @@ -8,7 +8,16 @@ into the correct table: ```python user = User(name="Jane Doe", age=25, email="jane@example.com") -db.insert(user) +result = db.insert(user) +``` + +The `result` variable will contain a new instance of the model, with the primary +key value set to the newly-created primary key in the database. You should use +this instance to access the primary key value and other fields: + +```python +print(f"New record inserted with primary key: {result.pk}") +print(f"Name: {result.name}, Age: {result.age}, Email: {result.email}") ``` > [!IMPORTANT] @@ -46,15 +55,22 @@ See [Filtering Results](filtering.md) for more advanced filtering options. ## Updating Records +You can update records in the database by modifying the fields of the model +instance and then calling the `update()` method. You need to pass the model +class and the instance to the method: + ```python user.age = 26 -db.update(user) +db.update(User, user) ``` ## Deleting Records +To delete a record from the database, you need to pass the model class and the +primary key value of the record you want to delete: + ```python -db.delete(User, "Jane Doe") +db.delete(User, user.pk) ``` ## Commit your changes diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md index e42f50a..ec279d2 100644 --- a/docs/guide/filtering.md +++ b/docs/guide/filtering.md @@ -8,6 +8,17 @@ records, and can be combined with other methods like `order()`, `limit()`, and result = db.select(User).filter(age__lte=30).limit(10).fetch_all() ``` +It is possible to both add multiple filters in the same call, and to chain +multiple filter calls together: + +```python +result = db.select(User).filter(age__gte=20, age__lte=30).fetch_all() +``` + +```python +result = db.select(User).filter(age__gte=20).filter(age__lte=30).fetch_all() +``` + ## Basic Filters - `__eq`: Equal to (default if no operator is specified) diff --git a/docs/guide/guide.md b/docs/guide/guide.md index 47b4e87..1a69ac7 100644 --- a/docs/guide/guide.md +++ b/docs/guide/guide.md @@ -41,9 +41,12 @@ Inserting records is straightforward with SQLiter: ```python user = User(name="John Doe", age=30, email="john@example.com") -db.insert(user) +new_record = db.insert(user) ``` +If successful, `new_record` will contain a model the same as was passed to it, +but including the newly-created primary key value. + ## Basic Queries You can easily query all records from a table: @@ -109,21 +112,34 @@ returned. ## Updating Records -Records can be updated seamlessly: +Records can be updated seamlessly. Simply modify the fields of the model +instance and call the `update()` method, passing the model class and the updated +instance: ```python user.age = 31 -db.update(user) +db.update(User, user) ``` ## Deleting Records -Deleting records is simple as well: +Deleting records is simple as well. You just need to pass the Model that defines +your table and the primary key value of the record you want to delete: ```python -db.delete(User, "John Doe") +db.delete(User, 1) ``` +> [!NOTE] +> +> You can get the primary key value from the record or model instance itself, +> e.g., `new_record.pk` and pass that as the second argument to the `delete()` +> method: +> +> ```python +> db.delete(User, new_record.pk) +> ``` + ## Advanced Query Features ### Ordering diff --git a/docs/guide/models.md b/docs/guide/models.md index 4d30c06..e4c4145 100644 --- a/docs/guide/models.md +++ b/docs/guide/models.md @@ -1,8 +1,14 @@ -# Defining Models +# Models -Models in SQLiter use Pydantic to encapsulate the logic. All models should -inherit from SQLiter's `BaseDBModel`. You can define your -models like this: +Each individual table in your database should be represented by a model. This +model should inherit from `BaseDBModel` and define the fields that should be +stored in the table. Under the hood, the model is a Pydantic model, so you can +use all the features of Pydantic models, such as default values, type hints, and +validation. + +## Defining Models + +Models are defined like this: ```python from sqliter.model import BaseDBModel @@ -11,23 +17,36 @@ class User(BaseDBModel): name: str age: int email: str - - class Meta: - table_name = "users" - primary_key = "name" # Default is "id" - create_pk = False # disable auto-creating an incrementing primary key - default is True ``` -For a standard database with an auto-incrementing integer `id` primary key, you -do not need to specify the `primary_key` or `create_pk` fields. If you want to -specify a different primary key field name, you can do so using the -`primary_key` field in the `Meta` class. +You can create as many Models as you need, each representing a different table +in your database. The fields in the model will be used to create the columns in +the table. + +> [!IMPORTANT] +> +> - Type-hints are **REQUIRED** for each field in the model. +> - The Model **automatically** creates an **auto-incrementing integer primary +> key** for each table called `pk`, you do not need to define it yourself. + +### Custom Table Name + +By default, the table name will be the same as the model name, converted to +'snake_case' and pluralized (e.g., `User` -> `users`). Also, any 'Model' suffix +will be removed (e.g., `UserModel` -> `users`). To override this behavior, you +can specify the `table_name` in the `Meta` class manually as below: + +```python +from sqliter.model import BaseDBModel + +class User(BaseDBModel): + name: str + age: int + email: str -If `table_name` is not specified, the table name will be the same as the model -name, converted to 'snake_case' and pluralized (e.g., `User` -> `users`). Also, -any 'Model' suffix will be removed (e.g., `UserModel` -> `users`). To override -this behavior, you can specify the `table_name` in the `Meta` class manually as -above. + class Meta: + table_name = "people" +``` > [!NOTE] > @@ -36,3 +55,27 @@ above. > you need more advanced pluralization, you can install the `extras` package as > mentioned in the [installation](../installation.md#optional-dependencies). Of > course, you can always specify the `table_name` manually in this case! + +## Model Classmethods + +There are 2 useful methods you can call on your models. Note that they are +**Class Methods** so should be called on the Model class itself, not an +instance of the model: + +### `get_table_name()` + +This method returns the actual table name for the model either specified or +automatically generated. This is useful if you need to do any raw SQL queries. + +```python +table_name = User.get_table_name() +``` + +### `get_primary_key()` + +This simply returns the name of the primary key for that table. At the moment, +this will always return the string `pk` but this may change in the future. + +```python +primary_key = User.get_primary_key() +``` diff --git a/docs/index.md b/docs/index.md index bf8bed0..c45e465 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,10 @@ database-like format without needing to learn SQL or use a full ORM. > [!CAUTION] > This project is still in the early stages of development and is lacking some -> planned functionality. Please use with caution. +> planned functionality. Please use with caution - Classes and methods may +> change until a stable release is made. I'll try to keep this to an absolute +> minimum and the releases and documentation will be very clear about any +> breaking changes. > > Also, structures like `list`, `dict`, `set` etc are not supported **at this > time** as field types, since SQLite does not have a native column type for @@ -36,7 +39,7 @@ database-like format without needing to learn SQL or use a full ORM. - Table creation based on Pydantic models - CRUD operations (Create, Read, Update, Delete) -- Basic query building with filtering, ordering, and pagination +- Chained Query building with filtering, ordering, and pagination - Transaction support - Custom exceptions for better error handling - Full type hinting and type checking diff --git a/docs/quickstart.md b/docs/quickstart.md index e5a6ead..79b66e4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -26,7 +26,7 @@ db.create_table(User) # Insert a record user = User(name="John Doe", age=30) -db.insert(user) +new_record = db.insert(user) # Query records results = db.select(User).filter(name="John Doe").fetch_all() @@ -35,10 +35,14 @@ for user in results: # Update a record user.age = 31 -db.update(user) +db.update(User, user) + +results = db.select(User).filter(name="John Doe").fetch_one() + +print("Updated age:", results.age) # Delete a record -db.delete(User, "John Doe") +db.delete(User, new_record.pk) ``` See the [Guide](guide/guide.md) for more detailed information on how to use `SQLiter`. diff --git a/mkdocs.yml b/mkdocs.yml index 647e769..c0dd678 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,7 @@ theme: - navigation.tabs - navigation.sections - navigation.indexes + - content.code.copy extra: social: From 2315d01a0aee62ba1fb036fc8f912f92602c7552 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 30 Sep 2024 15:08:02 +0100 Subject: [PATCH 29/29] fix docs re update() method Signed-off-by: Grant Ramsay --- README.md | 4 ++-- docs/guide/data-ops.md | 6 +++--- docs/guide/guide.md | 5 ++--- docs/quickstart.md | 8 ++++---- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ac192ac..b09e4c4 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,8 @@ for user in results: print(f"User: {user.name}, Age: {user.age}") # Update a record -user.age = 31 -db.update(user) +new_user.age = 31 +db.update(new_user) # Delete a record db.delete(User, new_user.pk) diff --git a/docs/guide/data-ops.md b/docs/guide/data-ops.md index a739f72..f513447 100644 --- a/docs/guide/data-ops.md +++ b/docs/guide/data-ops.md @@ -56,12 +56,12 @@ See [Filtering Results](filtering.md) for more advanced filtering options. ## Updating Records You can update records in the database by modifying the fields of the model -instance and then calling the `update()` method. You need to pass the model -class and the instance to the method: +instance and then calling the `update()` method. You just pass the model +instance to the method: ```python user.age = 26 -db.update(User, user) +db.update(user) ``` ## Deleting Records diff --git a/docs/guide/guide.md b/docs/guide/guide.md index 1a69ac7..be1dde5 100644 --- a/docs/guide/guide.md +++ b/docs/guide/guide.md @@ -113,12 +113,11 @@ returned. ## Updating Records Records can be updated seamlessly. Simply modify the fields of the model -instance and call the `update()` method, passing the model class and the updated -instance: +instance and pass that to the `update()` method: ```python user.age = 31 -db.update(User, user) +db.update(user) ``` ## Deleting Records diff --git a/docs/quickstart.md b/docs/quickstart.md index 79b66e4..21f63b9 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -26,7 +26,7 @@ db.create_table(User) # Insert a record user = User(name="John Doe", age=30) -new_record = db.insert(user) +new_user = db.insert(user) # Query records results = db.select(User).filter(name="John Doe").fetch_all() @@ -34,15 +34,15 @@ for user in results: print(f"User: {user.name}, Age: {user.age}, Admin: {user.admin}") # Update a record -user.age = 31 -db.update(User, user) +new_user.age = 31 +db.update(new_user) results = db.select(User).filter(name="John Doe").fetch_one() print("Updated age:", results.age) # Delete a record -db.delete(User, new_record.pk) +db.delete(User, new_user.pk) ``` See the [Guide](guide/guide.md) for more detailed information on how to use `SQLiter`.