From 9b5ed58c512eb0b261ffb7b805fa4258fc361a60 Mon Sep 17 00:00:00 2001 From: Dairo Mosquera Date: Mon, 30 Dec 2024 20:11:53 -0500 Subject: [PATCH 1/6] fix ci workflow --- .github/workflows/ci.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b8171f..cbfd3a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,9 @@ name: ci on: - release: - types: [published] + push: + tags: + - 'v*' permissions: contents: write @@ -26,9 +27,5 @@ jobs: path: .cache restore-keys: | mkdocs-material- - - name: Install dependencies - run: pip install mkdocs-material mike - - name: Build site - run: mike deploy --push --update-aliases ${{ github.event.release.tag_name }} latest - - name: Set latest as default version - run: mike set-default --push latest + - run: pip install mkdocs-material + - run: mkdocs gh-deploy --force From 2ec0a5d4fdbc2d721535efe30cc9ad6abdfa387c Mon Sep 17 00:00:00 2001 From: Dairo Mosquera Date: Mon, 30 Dec 2024 20:18:24 -0500 Subject: [PATCH 2/6] docs: add more prefixes --- docs/contributing.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 630f80c..99dce29 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -34,10 +34,15 @@ a smooth collaboration process. - Write **NumPy-style docstrings** for all public functions, classes, attributes, and properties. - Commit messages and pull requests must follow specific prefixes: - - `fix:` for bug fixes. - - `feat:` for new features. - - `docs:` for documentation changes. - `ci:` for CI/CD changes. + - `test:` Update tests/* files. + - `dist:` Changes to dependencies, e.g. `requirements.txt`. + - `minor:` Small changes. + - `docs:` Updates to documentation. `doc` is also a valid prefix. + - `fix:` Bug fixes. + - `refactor:` Refactor of existing code. + - `nit:` Small code review changes mainly around style or syntax. + - `feat:` New features. ## Your First Pull Request @@ -146,10 +151,15 @@ To suggest a feature: ### Commit Message Format - Use a prefix to categorize your commit: - - `fix: ` for bug fixes. - - `feat: ` for new features. - - `docs: ` for documentation changes. - - `ci: ` for CI/CD changes. + - `ci:` for CI/CD changes. + - `test:` Update tests/* files. + - `dist:` Changes to dependencies, e.g. `requirements.txt`. + - `minor:` Small changes. + - `docs:` Updates to documentation. `doc` is also a valid prefix. + - `fix:` Bug fixes. + - `refactor:` Refactor of existing code. + - `nit:` Small code review changes mainly around style or syntax. + - `feat:` New features. - Example: ``` feat: add support for PostgreSQL database connections From 4a8ba995152ae0d4a301434a84466778902cd02f Mon Sep 17 00:00:00 2001 From: Dairo Mosquera Date: Tue, 31 Dec 2024 00:00:52 -0500 Subject: [PATCH 3/6] feat: add find unique methods, refactor code and update docs --- .gitignore | 1 + README.md | 78 ++++++- docs/contributing.md | 2 +- docs/index.md | 42 +++- docs/pages/SERIALIZATION_MIXIN.md | 155 ++++++++++++++ .../active_record_mixin/api_reference.md | 172 +++++++++++++-- docs/pages/active_record_mixin/overview.md | 10 +- docs/pages/inspection_mixin.md | 57 ++++- docs/pages/smart_query_mixin.md | 5 +- mkdocs.yml | 17 +- sqlactive/__init__.py | 73 +++---- sqlactive/active_record.py | 196 ++++++++++++++++++ sqlactive/base_model.py | 104 ++-------- sqlactive/inspection.py | 35 ++++ sqlactive/serialization.py | 186 +++++++++++++++++ tests/test_active_record.py | 59 ++++++ tests/test_base_model.py | 85 -------- tests/test_inspection.py | 7 + tests/test_serialization.py | 169 +++++++++++++++ 19 files changed, 1173 insertions(+), 280 deletions(-) create mode 100644 docs/pages/SERIALIZATION_MIXIN.md create mode 100644 sqlactive/serialization.py delete mode 100644 tests/test_base_model.py create mode 100644 tests/test_serialization.py diff --git a/.gitignore b/.gitignore index adadf44..12fdb60 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +htmlcov/ # Environments .env diff --git a/README.md b/README.md index dd19bf8..e1494b4 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ Documentation: https://daireto.github.io/sqlactive/ - [5. Perform Queries](#5-perform-queries) - [6. Manage Timestamps](#6-manage-timestamps) - [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Coverage](#coverage) + - [Lint](#lint) - [Documentation](#documentation) - [Contributing](#contributing) - [License](#license) @@ -71,9 +74,18 @@ pip install sqlactive ### 1. Define the Models -The `ActiveRecordBaseModel` class provides a base class for your models. It inherits from -[`ActiveRecordMixin`](https://daireto.github.io/sqlactive/latest/pages/active_record_mixin/overview/) -and [`TimestampMixin`](https://daireto.github.io/sqlactive/latest/pages/timestamp_mixin/). +The `ActiveRecordBaseModel` class provides a base class for your models. + +It inherits from: + +* [`ActiveRecordMixin`](https://daireto.github.io/sqlactive/latest/pages/active_record_mixin/overview/): Provides a set of ActiveRecord-like + helper methods for interacting with the database. +* [`TimestampMixin`](https://daireto.github.io/sqlactive/latest/pages/timestamp_mixin/): Adds the `created_at` and `updated_at` timestamp columns. +* [`SerializationMixin`](https://daireto.github.io/sqlactive/latest/pages/serialization_mixin/): Provides serialization and deserialization methods. + +It is recommended to define a `BaseModel` class that inherits from +`ActiveRecordBaseModel` and use it as the base class for all models +as shown in the following example: ```python from sqlalchemy import String, ForeignKey @@ -121,6 +133,10 @@ class Comment(BaseModel): user: Mapped['User'] = relationship(back_populates='comments') ``` +> [!WARNING] +> When defining a `BaseModel` class, don't forget to set `__abstract__` to `True` +> in the base class to avoid creating tables for the base class. + > [!NOTE] > The models can directly inherit from the `ActiveRecordBaseModel` class: > ```python @@ -130,14 +146,18 @@ class Comment(BaseModel): > __tablename__ = 'users' > # ... > ``` +> However, it is recommended to create a base class for your models and +> inherit from it. > [!TIP] -> If you don't want to implement automatic timestamps, your base model can inherit -> from `ActiveRecordMixin` directly: +> Your `BaseModel` class can also inherit directly from the mixins. +> For example, if you don't want to implement automatic timestamps don't inherit +> from `ActiveRecordBaseModel` class. Instead, inherit from `ActiveRecordMixin` +> and/or `SerializationMixin`: > ```python -> from sqlactive import ActiveRecordMixin +> from sqlactive import ActiveRecordMixin, SerializationMixin > -> class BaseModel(ActiveRecordMixin): +> class BaseModel(ActiveRecordMixin, SerializationMixin): > __abstract__ = True > ``` @@ -290,6 +310,8 @@ print(users) Perform simple and complex queries, eager loading, and dictionary serialization: ```python +from sqlactive import JOINED, SUBQUERY + user = await User.filter(name='John Doe').first() print(user) # @@ -389,6 +411,8 @@ print(user.updated_at) ## Testing +### Unit Tests + To run the tests, simply run the following command from the root directory: ```bash @@ -401,12 +425,48 @@ To run a specific test, use the following command: python -m unittest tests. ``` -Available tests: +**Available tests:** - `test_active_record` - `test_inspection` -- `test_base_model` +- `test_serialization` - `test_smart_query` +### Coverage + +First, install the `coverage` package: + +```bash +pip install coverage +``` + +To check the coverage, run the following command: + +```bash +python -m coverage run -m unittest discover -s tests -t . +python -m coverage report -m +``` + +To generate the coverage report, run the following command: + +```bash +python -m coverage run -m unittest discover -s tests -t . +python -m coverage html -d htmlcov +``` + +### Lint + +First, install the `ruff` package: + +```bash +pip install ruff +``` + +To check the code style, run the following command: + +```bash +python -m ruff check . +``` + ## Documentation Find the complete documentation [here](https://daireto.github.io/sqlactive/). diff --git a/docs/contributing.md b/docs/contributing.md index 99dce29..d02941f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -68,7 +68,7 @@ If this is your first pull request: ```bash pip install -r requirements.txt ``` -5. Install `Ruff`: +5. Install `ruff`: ```bash pip install ruff ``` diff --git a/docs/index.md b/docs/index.md index d518ec7..ab6d122 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,7 +66,17 @@ pip install sqlactive ### 1. Define the Models The `ActiveRecordBaseModel` class provides a base class for your models. -It inherits from [`ActiveRecordMixin`](pages/active_record_mixin/overview.md) and [`TimestampMixin`](pages/timestamp_mixin.md). + +It inherits from: + +* [`ActiveRecordMixin`](https://daireto.github.io/sqlactive/latest/pages/active_record_mixin/overview/): Provides a set of ActiveRecord-like + helper methods for interacting with the database. +* [`TimestampMixin`](https://daireto.github.io/sqlactive/latest/pages/timestamp_mixin/): Adds the `created_at` and `updated_at` timestamp columns. +* [`SerializationMixin`](https://daireto.github.io/sqlactive/latest/pages/serialization_mixin/): Provides serialization and deserialization methods. + +It is recommended to define a `BaseModel` class that inherits from +`ActiveRecordBaseModel` and use it as the base class for all models +as shown in the following example: ```python from sqlalchemy import String, ForeignKey @@ -114,6 +124,11 @@ class Comment(BaseModel): user: Mapped['User'] = relationship(back_populates='comments') ``` +!!! warning + + When defining a `BaseModel` class, don't forget to set `__abstract__` to `True` + in the base class to avoid creating tables for the base class. + !!! note The models can directly inherit from the `ActiveRecordBaseModel` class: @@ -125,14 +140,19 @@ class Comment(BaseModel): # ... ``` + However, it is recommended to create a base class for your models and + inherit from it. + !!! tip - If you don't want to implement automatic timestamps, your base model can inherit - from [`ActiveRecordMixin`](pages/active_record_mixin/overview.md) directly: + Your `BaseModel` class can also inherit directly from the mixins. + For example, if you don't want to implement automatic timestamps don't inherit + from `ActiveRecordBaseModel` class. Instead, inherit from `ActiveRecordMixin` + and/or `SerializationMixin`: ```python - from sqlactive import ActiveRecordMixin - class BaseModel(ActiveRecordMixin): + from sqlactive import ActiveRecordMixin, SerializationMixin + class BaseModel(ActiveRecordMixin, SerializationMixin): __abstract__ = True ``` @@ -214,7 +234,7 @@ await user.delete() !!! tip - Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/api_reference.md) class to see all the available methods. + Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/API_REFERENCE.md) class to see all the available methods. ### 4. Perform Bulk Operations @@ -287,13 +307,15 @@ print(users) !!! tip - Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/api_reference.md) class to see all the available methods. + Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/API_REFERENCE.md) class to see all the available methods. ### 5. Perform Queries Perform simple and complex queries, eager loading, and dictionary serialization: ```python +from sqlactive import JOINED, SUBQUERY + user = await User.filter(name='John Doe').first() print(user) # @@ -342,7 +364,7 @@ session.query(Post).filter(*Post.filter_expr(rating__gt=2, body='text')) It's like [filter_by in SQLALchemy](https://docs.sqlalchemy.org/en/20/orm/queryguide/query.html#sqlalchemy.orm.Query.filter), but also allows magic operators like `rating__gt`. -See the [low-level SmartQueryMixin methods](pages/smart_query_mixin.md#api-reference) for more details. +See the [low-level SmartQueryMixin methods](pages/SMART_QUERY_MIXIN.md#api-reference) for more details. !!! note @@ -368,7 +390,7 @@ See the [low-level SmartQueryMixin methods](pages/smart_query_mixin.md#api-refer !!! tip - Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/api_reference.md) class to see all the available methods. + Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/API_REFERENCE.md) class to see all the available methods. ### 6. Manage Timestamps @@ -390,7 +412,7 @@ print(user.updated_at) !!! tip - Check the [`TimestampMixin`](pages/timestamp_mixin.md) class to know how to customize the timestamps behavior. + Check the [`TimestampMixin`](pages/TIMESTAMP_MIXIN.md) class to know how to customize the timestamps behavior. ## License diff --git a/docs/pages/SERIALIZATION_MIXIN.md b/docs/pages/SERIALIZATION_MIXIN.md new file mode 100644 index 0000000..b7b4c36 --- /dev/null +++ b/docs/pages/SERIALIZATION_MIXIN.md @@ -0,0 +1,155 @@ +# SerializationMixin + +The `SerializationMixin` class provides methods for serializing and deserializing +SQLAlchemy models. + +It uses the [`InspectionMixin`](INSPECTION_MIXIN.md) class functionality. + +**Table of Contents** + +- [SerializationMixin](#serializationmixin) + - [Serialization](#serialization) + - [to\_dict](#to_dict) + - [to\_json](#to_json) + - [Deserialization](#deserialization) + - [from\_dict](#from_dict) + - [from\_json](#from_json) + +## Serialization + +### to_dict +```python +def to_dict(nested: bool = False, hybrid_attributes: bool = False, exclude: list[str] | None = None) +``` + +> Serializes the model to a dictionary. + +> **Parameters:** + +> - `nested`: Set to `True` to include nested relationships' data, by default False. +> - `hybrid_attributes`: Set to `True` to include hybrid attributes, by default False. +> - `exclude`: Exclude specific attributes from the result, by default None. + +> **Returns:** + +> - `dict`: Serialized model. + +> **Example:** + +> ```python +> user = await User.get(id=1) +> user.to_dict() +> # {'id': 1, 'username': 'user1', 'name': 'John', 'age': 30, ...} +> user.to_dict(nested=True) +> # {'id': 1, 'username': 'user1', 'name': 'John', 'age': 30, 'posts': [...], ...} +> user.to_dict(hybrid_attributes=True) +> # {'id': 1, 'username': 'user1', 'name': 'John', 'age': 30, 'posts_count': 3, ...} +> user.to_dict(exclude=['id', 'username']) +> # {'name': 'John', 'age': 30, ...} +> ``` + +### to_json +```python +def to_json(nested: bool = False, hybrid_attributes: bool = False, exclude: list[str] | None = None, ensure_ascii: bool = False, indent: int | str | None = None, sort_keys: bool = False) +``` + +> Serializes the model to JSON. + +> Calls the `Self.to_dict` method and dumps it with `json.dumps`. + +> **Parameters:** + +> - `nested`: Set to `True` to include nested relationships' data, by default False. +> - `hybrid_attributes`: Set to `True` to include hybrid attributes, by default False. +> - `exclude`: Exclude specific attributes from the result, by default None. +> - `ensure_ascii`: If False, then the return value can contain non-ASCII characters if they appear in strings contained in obj. Otherwise, all such characters are escaped in JSON strings, by default False. +> - `indent`: If indent is a non-negative integer, then JSON array elements and object members will be pretty-printed with that indent level. An indent level of 0 will only insert newlines. `None` is the most compact representation, by default None. +> - `sort_keys`: Sort dictionary keys, by default False. + +> **Returns:** + +> - `str`: Serialized model. + +> **Example:** + +> ```python +> user = await User.get(id=1) +> user.to_json() +> # {"id": 1, "username": "user1", "name": "John", "age": 30, ...} +> user.to_json(nested=True) +> # {"id": 1, "username": "user1", "name": "John", "age": 30, "posts": [...], ...} +> user.to_json(hybrid_attributes=True) +> # {"id": 1, "username": "user1", "name": "John", "age": 30, "posts_count": 3, ...} +> user.to_json(exclude=['id', 'username']) +> # {"name": "John", "age": 30, ...} +> ``` + +## Deserialization + +### from_dict +```python +def from_dict(data: dict | list, exclude: list[str] | None = None) +``` + +> Deserializes a dictionary to the model. + +> Sets the attributes of the model with the values of the dictionary. + +> **Parameters:** + +> - `data`: Data to deserialize. +> - `exclude`: Exclude specific keys from the dictionary, by default None. + +> **Returns:** + +> - `Self | list[Self]`: Deserialized model or models. + +> **Raises:** + +> - `TypeError`: If the data is not a dictionary or list of dictionaries. +> - `KeyError`: If attribute doesn't exist. + +> **Example:** + +> ```python +> user = await User.from_dict({'name': 'John', 'age': 30}) +> user.to_dict() +> # {'name': 'John', 'age': 30, ...} +> users = await User.from_dict([{'name': 'John', 'age': 30}, {'name': 'Jane', 'age': 25}]) +> users[0].to_dict() +> # {'name': 'John', 'age': 30, ...} +> users[1].to_dict() +> # {'name': 'Jane', 'age': 25, ...} +> ``` + +### from_json +```python +def from_json(json_string: str, exclude: list[str] | None = None) +``` + +> Deserializes a JSON string to the model. + +> Calls the `json.loads` method and sets the attributes of the model +> with the values of the JSON object using the `from_dict` method. + +> **Parameters:** + +> - `json_string`: JSON string. +> - `exclude`: Exclude specific keys from the dictionary, by default None. + +> **Returns:** + +> - `Self | list[Self]`: Deserialized model or models. + +> **Example:** + +> ```python +> user = await User.from_json('{"name": "John", "age": 30}') +> user.to_dict() +> # {'name': 'John', 'age': 30, ...} +> users = await User.from_json('[{"name": "John", "age": 30}, {"name": "Jane", "age": 25}]') +> users[0].to_dict() +> # {'name': 'John', 'age': 30, ...} +> users[1].to_dict() +> # {'name': 'Jane', 'age': 25, ...} +> ``` diff --git a/docs/pages/active_record_mixin/api_reference.md b/docs/pages/active_record_mixin/api_reference.md index 2d7964f..e388489 100644 --- a/docs/pages/active_record_mixin/api_reference.md +++ b/docs/pages/active_record_mixin/api_reference.md @@ -30,6 +30,12 @@ This is the API reference for the `ActiveRecordMixin` class. - [find\_one](#find_one) - [find\_one\_or\_none](#find_one_or_none) - [find\_all](#find_all) + - [find\_first](#find_first) + - [find\_unique](#find_unique) + - [find\_unique\_all](#find_unique_all) + - [find\_unique\_first](#find_unique_first) + - [find\_unique\_one](#find_unique_one) + - [find\_unique\_one\_or\_none](#find_unique_one_or_none) - [order\_by](#order_by) - [sort](#sort) - [offset](#offset) @@ -70,7 +76,7 @@ def fill(**kwargs) > **Returns:** -> - `self`: The instance itself for method chaining. +> - `Self`: The instance itself for method chaining. > **Raises:** @@ -91,7 +97,7 @@ async def save() > **Returns:** -> - `self`: The saved instance for method chaining. +> - `Self`: The saved instance for method chaining. > **Raises:** Any database errors are caught and will trigger a rollback. @@ -115,7 +121,7 @@ async def update(**kwargs) > **Returns:** -> - `self`: The updated instance for method chaining. +> - `Self`: The updated instance for method chaining. > **Raises:** Any database errors are caught and will trigger a rollback. @@ -169,7 +175,7 @@ async def create(**kwargs) > **Returns:** -> - `self`: The created instance for method chaining. +> - `Self`: The created instance for method chaining. > **Raises:** Any database errors are caught and will trigger a rollback. @@ -280,7 +286,7 @@ async def get(pk: object) > **Returns:** -> - `self | None`: Instance for method chaining or `None` if not found. +> - `Self | None`: Instance for method chaining or `None` if not found. > **Raises:** @@ -305,7 +311,7 @@ async def get_or_fail(pk: object) > **Returns:** -> - `self`: Instance for method chaining. +> - `Self`: Instance for method chaining. > **Raises:** @@ -415,7 +421,7 @@ async def find_one(*criterion: ColumnElement[Any], **filters: Any) > **Returns:** -> - `self`: Instance for method chaining. +> - `Self`: Instance for method chaining. > **Raises:** @@ -439,7 +445,7 @@ async def find_one_or_none(*criterion: ColumnElement[Any], **filters: Any) > **Returns:** -> - `self | None`: Instance for method chaining or `None`. +> - `Self | None`: Instance for method chaining or `None`. > **Raises:** @@ -462,7 +468,7 @@ async def find_all(*criterion: ColumnElement[Any], **filters: Any) > **Returns:** -> - `list[self]`: List of instances for method chaining. +> - `list[Self]`: List of instances for method chaining. > **Example:** @@ -470,6 +476,131 @@ async def find_all(*criterion: ColumnElement[Any], **filters: Any) > users = await User.find_all(age__gte=18) > ``` +### find_first +```python +async def find_first(*criterion: ColumnElement[Any], **filters: Any) +``` + +> Finds a single row matching the criteria or `None`. + +> This is same as calling `await cls.find(*criterion, **filters).first()`. + +> **Returns:** + +> - `Self | None`: Instance for method chaining or `None`. + +> **Example:** + +> ```python +> user = await User.find_first(name='Bob') +> ``` + +### find_unique +```python +async def find_unique(*criterion: ColumnElement[Any], **filters: Any) +``` + +> Finds all unique rows matching the criteria and +> returns an `ScalarResult` object with them. + +> This is same as calling `await cls.find(*criterion, **filters).unique()`. + +> **Returns:** + +> - `sqlalchemy.engine.ScalarResult`: Scalars. + +> **Example:** + +> ```python +> users_scalars = await User.find_unique(name__like='%John%') +> users = users_scalars.all() +> ``` + +### find_unique_all +```python +async def find_unique_all(*criterion: ColumnElement[Any], **filters: Any) +``` + +> Finds all unique rows matching the criteria and returns a list. + +> This is same as calling `await cls.find(*criterion, **filters).unique_all()`. + +> **Returns:** + +> - `list[Self]`: List of instances. + +> **Example:** + +> ```python +> users = await User.find_unique_all(name__like='%John%') +> ``` + +### find_unique_first +```python +async def find_unique_first(*criterion: ColumnElement[Any], **filters: Any) +``` + +> Finds a single unique row matching the criteria or `None`. + +> This is same as calling `await cls.find(*criterion, **filters).unique_first()`. + +> **Returns:** + +> - `Self | None`: Instance for method chaining or `None`. + +> **Example:** + +> ```python +> user = await User.find_unique_first(name__like='%John%', age=30) +> ``` + +### find_unique_one +```python +async def find_unique_one(*criterion: ColumnElement[Any], **filters: Any) +``` + +> Finds a single unique row matching the criteria. + +> This is same as calling `await cls.find(*criterion, **filters).unique_one()`. + +> **Returns:** + +> - `Self`: Instance for method chaining. + +> **Raises:** + +> - `NoResultFound`: If no row is found. +> - `MultipleResultsFound`: If multiple rows match. + +> **Example:** + +> ```python +> user = await User.find_unique_one(name__like='%John%', age=30) +> ``` + +### find_unique_one_or_none +```python +async def find_unique_one_or_none(*criterion: ColumnElement[Any], **filters: Any) +``` + +> Finds a single unique row matching the criteria or `None`. + +> This is same as calling `await cls.find(*criterion, **filters).unique_one_or_none()`. + +> **Returns:** + +> - `Self | None`: Instance for method chaining or `None`. + +> **Raises:** + +> - `MultipleResultsFound`: If multiple rows match. + +> **Example:** + +> ```python +> user = await User.find_unique_one_or_none(name__like='%John%', age=30) +> ``` + ### order_by ```python def order_by(*columns: str | InstrumentedAttribute | UnaryExpression) @@ -664,10 +795,11 @@ def with_schema(schema: dict) > - `AsyncQuery`: Async query instance for chaining. > ```python +> from sqlactive import JOINED, SUBQUERY > schema = { -> User.posts: 'joined', -> User.comments: ('subquery', { -> Comment.user: 'joined' +> User.posts: JOINED, +> User.comments: (SUBQUERY, { +> Comment.user: JOINED > }) > } > users = await User.with_schema(schema).all() @@ -700,7 +832,7 @@ async def first() > **Returns:** -> - `self | None`: Instance for method chaining or `None` if no matches. +> - `Self | None`: Instance for method chaining or `None` if no matches. > **Example:** @@ -717,7 +849,7 @@ async def one() > **Returns:** -> - `self`: Instance for method chaining. +> - `Self`: Instance for method chaining. > **Raises:** @@ -739,7 +871,7 @@ async def one_or_none() > **Returns:** -> - `self | None`: Instance for method chaining or `None`. +> - `Self | None`: Instance for method chaining or `None`. > **Raises:** @@ -774,7 +906,7 @@ async def all() > **Returns:** -> - `list[self]`: List of instances. +> - `list[Self]`: List of instances. > **Example:** @@ -823,7 +955,7 @@ async def unique_all() > **Returns:** -> - `list[self]`: List of instances. +> - `list[Self]`: List of instances. > **Example:** @@ -840,7 +972,7 @@ async def unique_first() > **Returns:** -> - `self | None`: Instance for method chaining or `None`. +> - `Self | None`: Instance for method chaining or `None`. > **Example:** @@ -857,7 +989,7 @@ async def unique_one() > **Returns:** -> - `self`: Instance for method chaining. +> - `Self`: Instance for method chaining. > **Raises:** @@ -879,7 +1011,7 @@ async def unique_one_or_none() > **Returns:** -> - `self | None`: Instance for method chaining or `None`. +> - `Self | None`: Instance for method chaining or `None`. > **Raises:** diff --git a/docs/pages/active_record_mixin/overview.md b/docs/pages/active_record_mixin/overview.md index effcbfc..14f9df4 100644 --- a/docs/pages/active_record_mixin/overview.md +++ b/docs/pages/active_record_mixin/overview.md @@ -1,17 +1,17 @@ -# Overview +# ActiveRecord Mixin The `ActiveRecordMixin` class provides ActiveRecord-style functionality for SQLAlchemy models, allowing for more intuitive and chainable database operations with async/await support. -It uses the [`SmartQueryMixin`](../smart_query_mixin.md) class functionality. +It uses the [`SmartQueryMixin`](../SMART_QUERY_MIXIN.md) class functionality. -Check the [API Reference](api_reference.md) for the full list of +Check the [API Reference](API_REFERENCE.md) for the full list of available methods. **Table of Contents** -- [Overview](#overview) +- [ActiveRecord Mixin](#activerecord-mixin) - [Usage](#usage) - [Core Features](#core-features) - [Creation, Updating, and Deletion](#creation-updating-and-deletion) @@ -253,5 +253,5 @@ on failure. ## API Reference -Check the [API Reference](api_reference.md) for the full list of +Check the [API Reference](API_REFERENCE.md) for the full list of available methods. diff --git a/docs/pages/inspection_mixin.md b/docs/pages/inspection_mixin.md index 473c1d9..2d6d116 100644 --- a/docs/pages/inspection_mixin.md +++ b/docs/pages/inspection_mixin.md @@ -4,12 +4,17 @@ The `InspectionMixin` class provides attributes and properties inspection functi !!! info - This mixin is intended to extend the functionality of the [`SmartQueryMixin`](smart_query_mixin.md). + This mixin is intended to extend the functionality of the + [`SmartQueryMixin`](SMART_QUERY_MIXIN.md). It is not intended to be used on its own. **Table of Contents** - [InspectionMixin](#inspectionmixin) + - [Instance Methods](#instance-methods) + - [__repr__](#repr) + - [Class Methods](#class-methods) + - [get\_class\_of\_relation](#get_class_of_relation) - [Properties](#properties) - [id\_str](#id_str) - [columns](#columns) @@ -24,6 +29,56 @@ The `InspectionMixin` class provides attributes and properties inspection functi - [sortable\_attributes](#sortable_attributes) - [settable\_attributes](#settable_attributes) +## Instance Methods + +### __repr__ +```python +def __repr__(self) +``` + +> Print the model in a readable format including the primary key. + +> Format: +> ``` +> +> ``` + +> **Example:** + +> ```python +> user = await User.get(id=1) +> print(user) +> # User #1 +> users = await User.find(username__endswith='Doe').all() +> print(users) +> # [, ] +> ``` + +## Class Methods + +### get_class_of_relation +```python +def get_class_of_relation(relation_name: str) +``` + +> Gets the class of a relationship by its name. + +> **Parameters:** + +> - `relation_name`: The name of the relationship. + +> **Returns:** + +> - `type`: The class of the relationship. + +> **Example:** + +> ```python +> user = await User.get(id=1) +> user.get_class_of_relation('posts') +> # +> ``` + ## Properties ### id_str diff --git a/docs/pages/smart_query_mixin.md b/docs/pages/smart_query_mixin.md index 0593fb7..bc317b2 100644 --- a/docs/pages/smart_query_mixin.md +++ b/docs/pages/smart_query_mixin.md @@ -4,11 +4,12 @@ The `SmartQueryMixin` class provides advanced query functionality for SQLAlchemy models, allowing you to filter, sort, and eager load data in a single query, making it easier to retrieve specific data from the database. -It uses the [`InspectionMixin`](inspection_mixin.md) class functionality. +It uses the [`InspectionMixin`](INSPECTION_MIXIN.md) class functionality. !!! info - This mixin is intended to extend the functionality of the [`ActiveRecordMixin`](active_record_mixin/overview.md) + This mixin is intended to extend the functionality of the + [`ActiveRecordMixin`](active_record_mixin/OVERVIEW.md) on which the examples below are based. It is not intended to be used on its own. **Table of Contents** diff --git a/mkdocs.yml b/mkdocs.yml index e416328..3daf8c3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,16 +12,17 @@ theme: - search.highlight - search.share nav: - - Home: index.md + - Home: INDEX.md - Docs: - - ActiveRecordMixin: - - Overview: pages/active_record_mixin/overview.md - - API Reference: pages/active_record_mixin/api_reference.md - - TImestampMixin: pages/timestamp_mixin.md - - SmartQueryMixin: pages/smart_query_mixin.md - - InspectionMixin: pages/inspection_mixin.md + - ActiveRecord Mixin: + - Overview: pages/active_record_mixin/OVERVIEW.md + - API Reference: pages/active_record_mixin/API_REFERENCE.md + - TImestamp Mixin: pages/TIMESTAMP_MIXIN.md + - Serialization Mixin: pages/SERIALIZATION_MIXIN.md + - Smart Queries Mixin: pages/SMART_QUERY_MIXIN.md + - Inspection Mixin: pages/INSPECTION_MIXIN.md - Development: - - Contributing: contributing.md + - Contributing: CONTRIBUTING.md extra: version: provider: mike diff --git a/sqlactive/__init__.py b/sqlactive/__init__.py index d5e0393..3c9b4dc 100644 --- a/sqlactive/__init__.py +++ b/sqlactive/__init__.py @@ -1,5 +1,13 @@ """ -Inspired by https://github.com/absent1706/sqlalchemy-mixins +# SQLActive + +A sleek, powerful and asynchronous ActiveRecord-style wrapper for SQLAlchemy. +Bring Django-like queries, automatic timestamps, nested eager loading, +and dictionary serialization for SQLAlchemy models. + +Heavily inspired by [sqlalchemy-mixins](https://github.com/absent1706/sqlalchemy-mixins/). + +Documentation: https://daireto.github.io/sqlactive/ This package provides a set of mixins for SQLAlchemy models and a base class for all models. @@ -15,9 +23,10 @@ The `ActiveRecordBaseModel` class is a base class for all models that inherits from `ActiveRecordMixin` class which provides the set -of ActiveRecord-like helper methods for interacting with the database. -It also inherits from `TimestampMixin` class which adds the `created_at` -and `updated_at` timestamp columns. +of ActiveRecord-like helper methods for interacting with the database, +`TimestampMixin` class which adds the `created_at` and `updated_at` +timestamp columns, and `SerializationMixin` class which provides +serialization and deserialization methods. It is recommended to define a `BaseModel` class that inherits from `ActiveRecordBaseModel` and use it as the base class for all models @@ -49,53 +58,21 @@ class User(BaseModel): ``` `TimestampMixin` class defines the `created_at` and `updated_at` columns -with default values and onupdate behavior. To customize the column names, -override the `__created_at_name__` and `__updated_at_name__` class -variables as shown in the following example: - -```python - class MyModel(ActiveRecordBaseModel): - __created_at_name__ = 'created_at' - __updated_at_name__ = 'updated_at' -``` - -The `__datetime_func__` class variable can be used to override the default -datetime function as shown in the following example: - -```python - from sqlalchemy.sql import func - - class MyModel(ActiveRecordBaseModel): - __datetime_func__ = func.current_timestamp() -``` - -To avoid adding the `created_at` and `updated_at` timestamp columns and -use only the ActiveRecord-like helper methods, don't inherit from -`ActiveRecordBaseModel` class. Instead, inherit from `ActiveRecordMixin` -as shown in the following example: - -```python - from sqlalchemy import Mapped, mapped_column - from sqlactive import ActiveRecordMixin - - class BaseModel(ActiveRecordMixin): - __abstract__ = True - - class User(BaseModel): - __tablename__ = 'users' - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String(100)) -``` +with default values and onupdate behavior. To know how to customize the +timestamps behavior, check the `TimestampMixin` class documentation in +`sqlactive.timestamp.TimestampMixin` or in the following link: +https://daireto.github.io/sqlactive/latest/pages/timestamp_mixin/ -To only add the `created_at` and `updated_at` timestamp columns, don't -inherit from `ActiveRecordMixin` class. Instead, inherit from -`TimestampMixin` as shown in the following example: +Your `BaseModel` class can also inherit directly from the mixins. For +example, if you don't want to implement automatic timestamps don't inherit +from `ActiveRecordBaseModel` class. Instead, inherit from `ActiveRecordMixin` +and/or `SerializationMixin` as shown in the following example: ```python from sqlalchemy import Mapped, mapped_column - from sqlactive import TimestampMixin + from sqlactive import ActiveRecordMixin, SerializationMixin - class BaseModel(TimestampMixin): + class BaseModel(ActiveRecordMixin, SerializationMixin): __abstract__ = True class User(BaseModel): @@ -152,6 +129,7 @@ class BaseModel(ActiveRecordBaseModel): from .base_model import ActiveRecordBaseModel from .active_record import ActiveRecordMixin +from .serialization import SerializationMixin from .timestamp import TimestampMixin from .definitions import JOINED, SUBQUERY, SELECT_IN from .conn import DBConnection @@ -160,6 +138,7 @@ class BaseModel(ActiveRecordBaseModel): __all__ = [ 'ActiveRecordBaseModel', 'ActiveRecordMixin', + 'SerializationMixin', 'TimestampMixin', 'JOINED', 'SUBQUERY', @@ -168,4 +147,4 @@ class BaseModel(ActiveRecordBaseModel): ] -__version__ = '0.0.3' +__version__ = '0.0.4' diff --git a/sqlactive/active_record.py b/sqlactive/active_record.py index 34b8ef9..0ec1fa9 100644 --- a/sqlactive/active_record.py +++ b/sqlactive/active_record.py @@ -126,6 +126,18 @@ class User(BaseModel): `None` if no results are found. `find_all(*criterion, **filters)` A synonym for `filter` but returns all results. + `find_first(*criterion, **filters)` + Finds a single row matching the criteria or `None`. + `find_unique(*criterion, **filters)` + Finds all unique rows matching the criteria and + `find_unique_all(*criterion, **filters)` + Finds all unique rows matching the criteria and returns a list. + `find_unique_first(*criterion, **filters)` + Finds a single unique row matching the criteria or `None`. + `find_unique_one(*criterion, **filters)` + Finds a single unique row matching the criteria. + `find_unique_one_or_none(*criterion, **filters)` + Finds a single unique row matching the criteria or `None`. `scalars()` Returns an `ScalarResult` object with all rows. `first()` @@ -613,6 +625,190 @@ async def find_all(cls, *criterion: ColumnElement[Any], **filters: Any): return await cls.find(*criterion, **filters).all() + @classmethod + async def find_first(cls, *criterion: ColumnElement[Any], **filters: Any): + """Finds a single row matching the criteria or `None`. + + This is same as calling `await cls.find(*criterion, **filters).first()`. + + Example using Django-like syntax: + >>> user = await User.find_first(name__like='%John%', age=30) + >>> user + # + >>> user = await User.find_first(name__like='%Jane%') # Does not exist + >>> user + # None + + Example using SQLAlchemy syntax: + >>> user = await User.find_first(User.name == 'John Doe') + >>> user + # + + Example using both: + >>> user = await User.find_first(User.age == 30, name__like='%John%') + >>> user + # + """ + + return await cls.find(*criterion, **filters).first() + + @classmethod + async def find_unique(cls, *criterion: ColumnElement[Any], **filters: Any): + """Finds all unique rows matching the criteria and + returns an `ScalarResult` object with them. + + This is same as calling `await cls.find(*criterion, **filters).unique()`. + + Example using Django-like syntax: + >>> users_scalars = await User.find_unique(name__like='%John%') + >>> users = users_scalars.all() + >>> users + # [, , ...] + >>> users = await User.find_unique(name__like='%John%', age=30) + >>> users + # [] + + Example using SQLAlchemy syntax: + >>> users_scalars = await User.find_unique(User.name == 'John Doe') + >>> users = users_scalars.all() + >>> users + # [] + + Example using both: + >>> users_scalars = await User.find_unique(User.age == 30, name__like='%John%') + >>> users = users_scalars.all() + >>> users + # [] + """ + + return await cls.find(*criterion, **filters).unique() + + @classmethod + async def find_unique_all(cls, *criterion: ColumnElement[Any], **filters: Any): + """Finds all unique rows matching the criteria and returns a list. + + This is same as calling `await cls.find(*criterion, **filters).unique_all()`. + + Example using Django-like syntax: + >>> users = await User.find_unique_all(name__like='%John%') + >>> users + # [, , ...] + >>> users = await User.find_unique_all(name__like='%John%', age=30) + >>> users + # [] + + Example using SQLAlchemy syntax: + >>> users = await User.find_unique_all(User.name == 'John Doe') + >>> users + # [] + + Example using both: + >>> users = await User.find_unique_all(User.age == 30, name__like='%John%') + >>> users + # [] + """ + + return await cls.find(*criterion, **filters).unique_all() + + @classmethod + async def find_unique_first(cls, *criterion: ColumnElement[Any], **filters: Any): + """Finds a single unique row matching the criteria or `None`. + + This is same as calling `await cls.find(*criterion, **filters).unique_first()`. + + Example using Django-like syntax: + >>> user = await User.find_unique_first(name__like='%John%', age=30) + >>> user + # + >>> user = await User.find_unique_first(name__like='%Jane%') # Does not exist + >>> user + # None + + Example using SQLAlchemy syntax: + >>> user = await User.find_unique_first(User.name == 'John Doe') + >>> user + # + + Example using both: + >>> user = await User.find_unique_first(User.age == 30, name__like='%John%') + >>> user + # + """ + + return await cls.find(*criterion, **filters).unique_first() + + @classmethod + async def find_unique_one(cls, *criterion: ColumnElement[Any], **filters: Any): + """Finds a single unique row matching the criteria. + + If multiple results are found, raises MultipleResultsFound. + + This is same as calling `await cls.find(*criterion, **filters).unique_one()`. + + Example using Django-like syntax: + >>> user = await User.find_unique_one(name__like='%John%', age=30) + >>> user + # + >>> user = await User.find_unique_one(name__like='%Jane%') # Does not exist + >>> user + # Traceback (most recent call last): + # ... + # NoResultFound: 'No result found.' + + Example using SQLAlchemy syntax: + >>> user = await User.find_unique_one(User.name == 'John Doe').all() + >>> user + # + + Example using both: + >>> user = await User.find_unique_one(User.age == 30, name__like='%John%').all() + >>> user + # + + Raises + ------ + NoResultFound + If no result is found. + MultipleResultsFound + If multiple results are found. + """ + + return await cls.find(*criterion, **filters).unique_one() + + @classmethod + async def find_unique_one_or_none(cls, *criterion: ColumnElement[Any], **filters: Any): + """Finds a single unique row matching the criteria or `None`. + + If multiple results are found, raises MultipleResultsFound. + + This is same as calling `await cls.find(*criterion, **filters).unique_one_or_none()`. + + Example using Django-like syntax: + >>> user = await User.find_unique_one_or_none(name__like='%John%', age=30) + >>> user + # + >>> user = await User.find_unique_one_or_none(name__like='%Jane%') # Does not exist + >>> user + # None + + Example using SQLAlchemy syntax: + >>> user = await User.find_unique_one_or_none(User.name == 'John Doe').all() + >>> user + # + + Example using both: + >>> user = await User.find_unique_one_or_none(User.age == 30, name__like='%John%').all() + >>> user + # + + Raises + ------ + MultipleResultsFound + If multiple results are found. + """ + + return await cls.find(*criterion, **filters).unique_one_or_none() + @classmethod def order_by(cls, *columns: str | InstrumentedAttribute | UnaryExpression): """Creates a query with ORDER BY clause. diff --git a/sqlactive/base_model.py b/sqlactive/base_model.py index 821ddec..b8abaf9 100644 --- a/sqlactive/base_model.py +++ b/sqlactive/base_model.py @@ -1,39 +1,21 @@ """This module defines `ActiveRecordBaseModel` class.""" -from typing import Iterable -from sqlalchemy.orm.exc import DetachedInstanceError - from .active_record import ActiveRecordMixin +from .serialization import SerializationMixin from .timestamp import TimestampMixin -class ActiveRecordBaseModel(ActiveRecordMixin, TimestampMixin): +class ActiveRecordBaseModel(ActiveRecordMixin, SerializationMixin, TimestampMixin): """This is intended to be a base class for all models. - Inherits from `ActiveRecordMixin` class to provide a set of - ActiveRecord-like helper methods for interacting with the database. - - It also inherits from `TimestampMixin` class which adds - the `created_at` and `updated_at` timestamp columns. - To customize the column names, override the `__created_at_name__` - and `__updated_at_name__` class variables as shown in the following - example: - - ```python - class MyModel(ActiveRecordBaseModel): - __created_at_name__ = 'created_at' - __updated_at_name__ = 'updated_at' - ``` - - The `__datetime_func__` class variable can be used to override the default - datetime function as shown in the following example: + Inherits from: - ```python - from sqlalchemy.sql import func - - class MyModel(ActiveRecordBaseModel): - __datetime_func__ = func.current_timestamp() - ``` + - `ActiveRecordMixin`: Provides a set of ActiveRecord-like + helper methods for interacting with the database. + - `TimestampMixin`: Adds the `created_at` and `updated_at` + timestamp columns. + - `SerializationMixin`: Provides serialization + and deserialization methods. It is recommended to define a `BaseModel` class that inherits from `ActiveRecordBaseModel` and use it as the base class for all models @@ -53,10 +35,6 @@ class User(BaseModel): name: Mapped[str] = mapped_column(String(100)) ``` - This class provides a `to_dict` method which returns a dictionary - representation of the model's data and a custom `__repr__` definition - to print the model in a readable format including the primary key. - Example: >>> bob = User.create(name='Bob') >>> bob @@ -70,8 +48,10 @@ class User(BaseModel): >>> bob.update(name='Bob2') >>> bob.name # Bob2 - >>> bobo.to_dict() + >>> bob.to_dict() # {'id': 2, 'name': 'Bob2'} + >>> bob.to_json() + # '{"id": 2, "name": "Bob2"}' >>> bob.delete() >>> User.all() # [] @@ -81,63 +61,3 @@ class User(BaseModel): """ __abstract__ = True - - def __repr__(self) -> str: - """Print the model in a readable format including the primary key. - - Format: - - - Example: - >>> bob = User.create(name='Bob') - >>> bob - # - """ - - id_str = ('#' + self.id_str) if self.id_str else '' - return f'<{self.__class__.__name__} {id_str}>' - - def to_dict(self, nested: bool = False, hybrid_attributes: bool = False, exclude: list[str] | None = None) -> dict: - """Serializes the model to a dictionary. - - Parameters - ---------- - nested : bool, optional - Set to `True` to include nested relationships' data, by default False. - hybrid_attributes : bool, optional - Set to `True` to include hybrid attributes, by default False. - exclude : list[str] | None, optional - Exclude specific attributes from the result, by default None. - """ - - result = dict() - - if exclude is None: - view_cols = self.columns - else: - view_cols = filter(lambda e: e not in exclude, self.columns) - - for key in view_cols: - result[key] = getattr(self, key, None) - - if hybrid_attributes: - for key in self.hybrid_properties: - result[key] = getattr(self, key, None) - - if nested: - for key in self.relations: - try: - obj = getattr(self, key) - - if isinstance(obj, ActiveRecordBaseModel): - result[key] = obj.to_dict(hybrid_attributes=hybrid_attributes) - elif isinstance(obj, Iterable): - result[key] = [ - o.to_dict(hybrid_attributes=hybrid_attributes) - for o in obj - if isinstance(o, ActiveRecordBaseModel) - ] - except DetachedInstanceError: - continue - - return result diff --git a/sqlactive/inspection.py b/sqlactive/inspection.py index 257f857..6d89ea1 100644 --- a/sqlactive/inspection.py +++ b/sqlactive/inspection.py @@ -14,6 +14,41 @@ class InspectionMixin(DeclarativeBase): __abstract__ = True + def __repr__(self) -> str: + """Print the model in a readable format including the primary key. + + Format: + + + Example: + >>> bob = User.create(name='Bob') + >>> bob + # + >>> users = await User.find(name__like='%John%') + >>> users + # [, , ...] + """ + + id_str = ('#' + self.id_str) if self.id_str else '' + return f'<{self.__class__.__name__} {id_str}>' + + @classmethod + def get_class_of_relation(cls, relation_name: str) -> type: + """Gets the class of a relationship by its name. + + Parameters + ---------- + relation_name : str + The name of the relationship + + Example: + >>> bob = User.create(name='Bob') + >>> bob.get_class_of_relation('posts') + # + """ + + return cls.__mapper__.relationships[relation_name].mapper.class_ + @property def id_str(self) -> str: """Returns primary key as string. diff --git a/sqlactive/serialization.py b/sqlactive/serialization.py new file mode 100644 index 0000000..fbc9181 --- /dev/null +++ b/sqlactive/serialization.py @@ -0,0 +1,186 @@ +"""This module defines `SerializationMixin` class.""" + +import json + +from typing import Iterable, Self, overload +from sqlalchemy.orm.exc import DetachedInstanceError + +from .inspection import InspectionMixin + + +class SerializationMixin(InspectionMixin): + """Mixin for SQLAlchemy models to provide serialization methods.""" + + __abstract__ = True + + def to_dict(self, nested: bool = False, hybrid_attributes: bool = False, exclude: list[str] | None = None) -> dict: + """Serializes the model to a dictionary. + + Parameters + ---------- + nested : bool, optional + Set to `True` to include nested relationships' data, by default False. + hybrid_attributes : bool, optional + Set to `True` to include hybrid attributes, by default False. + exclude : list[str] | None, optional + Exclude specific attributes from the result, by default None. + + Returns + ------- + dict + Serialized model. + """ + + result = dict() + + if exclude is None: + view_cols = self.columns + else: + view_cols = filter(lambda e: e not in exclude, self.columns) + + for key in view_cols: + result[key] = getattr(self, key, None) + + if hybrid_attributes: + for key in self.hybrid_properties: + result[key] = getattr(self, key, None) + + if nested: + for key in self.relations: + try: + obj = getattr(self, key) + + if isinstance(obj, SerializationMixin): + result[key] = obj.to_dict(hybrid_attributes=hybrid_attributes) + elif isinstance(obj, Iterable): + result[key] = [ + o.to_dict(hybrid_attributes=hybrid_attributes) + for o in obj + if isinstance(o, SerializationMixin) + ] + except DetachedInstanceError: + continue + + return result + + def to_json( + self, + nested: bool = False, + hybrid_attributes: bool = False, + exclude: list[str] | None = None, + ensure_ascii: bool = False, + indent: int | str | None = None, + sort_keys: bool = False, + ) -> str: + """Serializes the model to JSON. + + Calls the `Self.to_dict` method and dumps it with `json.dumps`. + + Parameters + ---------- + nested : bool, optional + Set to `True` to include nested relationships' data, by default False. + hybrid_attributes : bool, optional + Set to `True` to include hybrid attributes, by default False. + exclude : list[str] | None, optional + Exclude specific attributes from the result, by default None. + ensure_ascii : bool, optional + If False, then the return value can contain non-ASCII characters + if they appear in strings contained in obj. Otherwise, all such + characters are escaped in JSON strings, by default False. + indent : int | str | None, optional + If indent is a non-negative integer, then JSON array elements and object + members will be pretty-printed with that indent level. + An indent level of 0 will only insert newlines. + `None` is the most compact representation, by default None. + sort_keys : bool, optional + Sort dictionary keys, by default False. + + Returns + ------- + str + Serialized model. + """ + + dumped_model = self.to_dict(nested=nested, hybrid_attributes=hybrid_attributes, exclude=exclude) + return json.dumps(obj=dumped_model, ensure_ascii=ensure_ascii, indent=indent, sort_keys=sort_keys, default=str) + + @overload + @classmethod + def from_dict(cls, data: dict, exclude: list[str] | None = None) -> Self: ... + + @overload + @classmethod + def from_dict(cls, data: list, exclude: list[str] | None = None) -> list[Self]: ... + + @classmethod + def from_dict(cls, data: dict | list, exclude: list[str] | None = None): + """Deserializes a dictionary to the model. + + Sets the attributes of the model with the values of the dictionary. + + Parameters + ---------- + data : dict | list + Data to deserialize. + exclude : list[str] | None, optional + Exclude specific keys from the dictionary, by default None. + + Returns + ------- + Self | list[Self] + Deserialized model or models. + + Raises + ------ + TypeError + If loaded JSON is not a dictionary or a list. + KeyError + If attribute doesn't exist. + """ + + if not isinstance(data, (dict, list)): + raise TypeError(f'Expected dict or list, got {type(data)}.') + + if isinstance(data, list): + return [cls.from_dict(d, exclude) for d in data] + + obj = cls() + for name in data.keys(): + if exclude is not None and name in exclude: + continue + if name in obj.hybrid_properties: + continue + if name in obj.relations: + relation_class = cls.get_class_of_relation(name) + setattr(obj, name, relation_class.from_dict(data[name])) + continue + if name in obj.columns: + setattr(obj, name, data[name]) + else: + raise KeyError(f'Attribute `{name}` does not exist.') + + return obj + + @classmethod + def from_json(cls, json_string: str, exclude: list[str] | None = None): + """Deserializes a JSON string to the model. + + Calls the `json.loads` method and sets the attributes of the model + with the values of the JSON object using the `from_dict` method. + + Parameters + ---------- + json_string : str + JSON string. + exclude : list[str] | None, optional + Exclude specific keys from the dictionary, by default None. + + Returns + ------- + Self | list[Self] + Deserialized model or models. + """ + + data = json.loads(json_string) + return cls.from_dict(data, exclude) diff --git a/tests/test_active_record.py b/tests/test_active_record.py index 6af976d..e7855cc 100644 --- a/tests/test_active_record.py +++ b/tests/test_active_record.py @@ -270,6 +270,65 @@ async def test_find_all(self): users = await User.find_all(username__like='Ji%') self.assertEqual(3, len(users)) + async def test_find_first(self): + """Test for `find_first` function.""" + + logger.info('Testing `find_first` function...') + user = await User.find_first(username='Joe156') + self.assertIsNotNone(user) + if user: + self.assertEqual('Joe Smith', user.name) + user = await User.find_first(username='Unknown') + self.assertIsNone(user) + + async def test_find_unique(self): + """Test for `find_unique` function.""" + + logger.info('Testing `find_unique` function...') + unique_user_scalars = await User.find_unique(username__like='Ji%') + users = unique_user_scalars.all() + self.assertEqual(3, len(users)) + + async def test_find_unique_all(self): + """Test for `find_unique_all` function.""" + + logger.info('Testing `find_unique_all` function...') + users = await User.find_unique_all(username__like='Ji%') + self.assertEqual(3, len(users)) + + async def test_find_unique_first(self): + """Test for `find_unique_first` function.""" + + logger.info('Testing `find_unique_first` function...') + user = await User.find_unique_first(username='Joe156') + self.assertIsNotNone(user) + if user: + self.assertEqual('Joe Smith', user.name) + + async def test_find_unique_one(self): + """Test for `find_unique_one` function.""" + + logger.info('Testing `find_unique_one` function...') + with self.assertRaises(MultipleResultsFound) as context: + await User.find_unique_one(username__like='Ji%') + self.assertEqual('Multiple rows were found when exactly one was required', str(context.exception)) + user = await User.find_unique_one(username='Joe156') + self.assertEqual('Joe Smith', user.name) + + async def test_find_unique_one_or_none(self): + """Test for `find_unique_one_or_none` function.""" + + logger.info('Testing `find_unique_one_or_none` function...') + with self.assertRaises(MultipleResultsFound) as context: + await User.find_unique_one_or_none(username__like='Ji%') + self.assertEqual('Multiple rows were found when one or none was required', str(context.exception)) + user = await User.find_unique_one_or_none(username='Joe156') + self.assertIsNotNone(user) + if user: + self.assertEqual('Joe Smith', user.name) + user = await User.find_unique_one_or_none(username='Unknown') + self.assertIsNone(user) + async def test_order_by(self): """Test for `order_by`, `sort` functions.""" diff --git a/tests/test_base_model.py b/tests/test_base_model.py deleted file mode 100644 index cbe51e9..0000000 --- a/tests/test_base_model.py +++ /dev/null @@ -1,85 +0,0 @@ -import asyncio -import unittest - -from sqlactive.conn import DBConnection - -from ._logger import logger -from ._models import User -from ._seed import Seed - - -class TestBaseModel(unittest.IsolatedAsyncioTestCase): - """Tests for `sqlactive.base_model.ActiveRecordBaseModel`.""" - - DB_URL = 'sqlite+aiosqlite://' - - @classmethod - def setUpClass(cls): - logger.info('ActiveRecordBaseModel tests...') - logger.info('Creating DB connection...') - cls.conn = DBConnection(cls.DB_URL, echo=False) - seed = Seed(cls.conn) - asyncio.run(seed.run()) - - @classmethod - def tearDownClass(cls): - if hasattr(cls, 'conn'): - logger.info('Closing DB connection...') - asyncio.run(cls.conn.close()) - - async def test_repr(self): - """Test for `__repr__` function.""" - - logger.info('Testing `__repr__` function...') - user = await User.get_or_fail(1) - self.assertEqual('', str(user)) - - async def test_to_dict(self): - """Test for `to_dict` function.""" - - logger.info('Testing `to_dict` function...') - user = await User.with_subquery(User.posts).filter(id=1).one() - self.assertDictEqual( - { - 'id': user.id, - 'username': user.username, - 'name': user.name, - 'age': user.age, - 'created_at': user.created_at, - 'updated_at': user.updated_at, - }, - user.to_dict(), - ) - self.assertDictEqual( - { - 'id': user.id, - 'username': user.username, - 'name': user.name, - 'age': user.age, - 'created_at': user.created_at, - 'updated_at': user.updated_at, - 'is_adult': user.is_adult, - }, - user.to_dict(hybrid_attributes=True), - ) - self.assertDictEqual( - { - 'id': user.id, - 'username': user.username, - 'name': user.name, - 'age': user.age, - 'is_adult': user.is_adult, - }, - user.to_dict(hybrid_attributes=True, exclude=['created_at', 'updated_at']), - ) - self.assertDictEqual( - { - 'id': user.id, - 'username': user.username, - 'name': user.name, - 'age': user.age, - 'posts': [post.to_dict() for post in user.posts], - 'is_adult': user.is_adult, - }, - user.to_dict(nested=True, hybrid_attributes=True, exclude=['created_at', 'updated_at']), - ) diff --git a/tests/test_inspection.py b/tests/test_inspection.py index 010fe25..beaeacc 100644 --- a/tests/test_inspection.py +++ b/tests/test_inspection.py @@ -27,6 +27,13 @@ def tearDownClass(cls): logger.info('Closing DB connection...') asyncio.run(cls.conn.close()) + async def test_repr(self): + """Test for `__repr__` function.""" + + logger.info('Testing `__repr__` function...') + user = await User.get_or_fail(1) + self.assertEqual('', str(user)) + async def test_id_str(self): """Test for `id_str` property.""" diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..7b2b8b0 --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,169 @@ +import asyncio +import json +import unittest + +from sqlactive.conn import DBConnection + +from ._logger import logger +from ._models import User, Post +from ._seed import Seed + + +class TestSerializationMixin(unittest.IsolatedAsyncioTestCase): + """Tests for `sqlactive.serialization.SerializationMixin`.""" + + DB_URL = 'sqlite+aiosqlite://' + + @classmethod + def setUpClass(cls): + logger.info('ActiveRecordBaseModel tests...') + logger.info('Creating DB connection...') + cls.conn = DBConnection(cls.DB_URL, echo=False) + seed = Seed(cls.conn) + asyncio.run(seed.run()) + + @classmethod + def tearDownClass(cls): + if hasattr(cls, 'conn'): + logger.info('Closing DB connection...') + asyncio.run(cls.conn.close()) + + async def test_to_dict(self): + """Test for `to_dict` function.""" + + logger.info('Testing `to_dict` function...') + user = await User.with_subquery(User.posts).filter(id=1).one() + self.assertDictEqual( + { + 'id': user.id, + 'username': user.username, + 'name': user.name, + 'age': user.age, + 'created_at': user.created_at, + 'updated_at': user.updated_at, + }, + user.to_dict(), + ) + self.assertDictEqual( + { + 'id': user.id, + 'username': user.username, + 'name': user.name, + 'age': user.age, + 'created_at': user.created_at, + 'updated_at': user.updated_at, + 'is_adult': user.is_adult, + }, + user.to_dict(hybrid_attributes=True), + ) + self.assertDictEqual( + { + 'id': user.id, + 'username': user.username, + 'name': user.name, + 'age': user.age, + 'is_adult': user.is_adult, + }, + user.to_dict(hybrid_attributes=True, exclude=['created_at', 'updated_at']), + ) + self.assertDictEqual( + { + 'id': user.id, + 'username': user.username, + 'name': user.name, + 'age': user.age, + 'posts': [post.to_dict() for post in user.posts], + 'is_adult': user.is_adult, + }, + user.to_dict(nested=True, hybrid_attributes=True, exclude=['created_at', 'updated_at']), + ) + + async def test_to_json(self): + """Test for `to_json` function.""" + + logger.info('Testing `to_json` function...') + user = await User.with_subquery(User.posts).filter(id=1).one() + self.assertEqual(json.dumps(user.to_dict(), ensure_ascii=False, default=str), user.to_json()) + self.assertEqual( + json.dumps(user.to_dict(hybrid_attributes=True), ensure_ascii=False, default=str), + user.to_json(hybrid_attributes=True), + ) + self.assertEqual( + json.dumps( + user.to_dict(hybrid_attributes=True, exclude=['created_at', 'updated_at']), + ensure_ascii=False, + default=str, + ), + user.to_json(hybrid_attributes=True, exclude=['created_at', 'updated_at']), + ) + self.assertEqual( + json.dumps( + user.to_dict(nested=True, hybrid_attributes=True, exclude=['created_at', 'updated_at']), + ensure_ascii=False, + default=str, + ), + user.to_json(nested=True, hybrid_attributes=True, exclude=['created_at', 'updated_at']), + ) + + def test_from_dict(self): + """Test for `from_dict` function.""" + + logger.info('Testing `from_dict` function...') + user = User.from_dict({'id': 1, 'username': 'username', 'name': 'name', 'age': 0, 'is_adult': False}) + self.assertEqual(user.id, 1) + self.assertEqual(user.username, 'username') + self.assertEqual(user.name, 'name') + self.assertEqual(user.age, 0) + self.assertEqual(user.is_adult, False) + + user = User.from_dict( + { + 'id': 1, + 'username': 'username', + 'name': 'name', + 'age': 0, + 'is_adult': False, + 'posts': [ + {'id': 1, 'title': 'title', 'body': 'body', 'rating': 0, 'user_id': 1}, + {'id': 2, 'title': 'title', 'body': 'body', 'rating': 0, 'user_id': 1}, + ], + } + ) + self.assertEqual(user.id, 1) + self.assertEqual(user.username, 'username') + self.assertEqual(user.name, 'name') + self.assertEqual(user.age, 0) + self.assertEqual(user.is_adult, False) + EXPECTED_POSTS = Post.from_dict( + [ + {'id': 1, 'title': 'title', 'body': 'body', 'rating': 0, 'user_id': 1}, + {'id': 2, 'title': 'title', 'body': 'body', 'rating': 0, 'user_id': 1}, + ] + ) + for i in range(len(user.posts)): + self.assertDictEqual(EXPECTED_POSTS[i].to_dict(), user.posts[i].to_dict()) + + def test_from_json(self): + """Test for `from_json` function.""" + + logger.info('Testing `from_json` function...') + user = User.from_json('{"id": 1, "username": "username", "name": "name", "age": 0, "is_adult": false}') + self.assertEqual(user.id, 1) + self.assertEqual(user.username, 'username') + self.assertEqual(user.name, 'name') + self.assertEqual(user.age, 0) + self.assertEqual(user.is_adult, False) + + user = User.from_json( + '{"id": 1, "username": "username", "name": "name", "age": 0, "is_adult": false, "posts": [{"id": 1, "title": "title", "body": "body", "rating": 0, "user_id": 1}, {"id": 2, "title": "title", "body": "body", "rating": 0, "user_id": 1}]}' + ) + self.assertEqual(user.id, 1) + self.assertEqual(user.username, 'username') + self.assertEqual(user.name, 'name') + self.assertEqual(user.age, 0) + self.assertEqual(user.is_adult, False) + EXPECTED_POSTS = Post.from_json( + '[{"id": 1, "title": "title", "body": "body", "rating": 0, "user_id": 1}, {"id": 2, "title": "title", "body": "body", "rating": 0, "user_id": 1}]' + ) + for i in range(len(user.posts)): + self.assertDictEqual(EXPECTED_POSTS[i].to_dict(), user.posts[i].to_dict()) From 82f616af6a5ec93ed64e7767c04a8f7c6921194c Mon Sep 17 00:00:00 2001 From: Dairo Mosquera Date: Tue, 31 Dec 2024 00:22:44 -0500 Subject: [PATCH 4/6] rename file to lowercase --- docs/pages/{SERIALIZATION_MIXIN.md => serialization_mixin.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/pages/{SERIALIZATION_MIXIN.md => serialization_mixin.md} (100%) diff --git a/docs/pages/SERIALIZATION_MIXIN.md b/docs/pages/serialization_mixin.md similarity index 100% rename from docs/pages/SERIALIZATION_MIXIN.md rename to docs/pages/serialization_mixin.md From 0f949e681a4f199c07f24c0943ea690bb142163d Mon Sep 17 00:00:00 2001 From: Dairo Mosquera Date: Tue, 31 Dec 2024 00:22:46 -0500 Subject: [PATCH 5/6] fix: lowercase pages and add light/dark theme toggle button --- docs/index.md | 10 ++++---- docs/pages/active_record_mixin/overview.md | 6 ++--- docs/pages/inspection_mixin.md | 2 +- docs/pages/serialization_mixin.md | 2 +- docs/pages/smart_query_mixin.md | 4 ++-- mkdocs.yml | 27 +++++++++++++++------- 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/docs/index.md b/docs/index.md index ab6d122..5127c77 100644 --- a/docs/index.md +++ b/docs/index.md @@ -234,7 +234,7 @@ await user.delete() !!! tip - Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/API_REFERENCE.md) class to see all the available methods. + Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/api_reference.md) class to see all the available methods. ### 4. Perform Bulk Operations @@ -307,7 +307,7 @@ print(users) !!! tip - Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/API_REFERENCE.md) class to see all the available methods. + Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/api_reference.md) class to see all the available methods. ### 5. Perform Queries @@ -364,7 +364,7 @@ session.query(Post).filter(*Post.filter_expr(rating__gt=2, body='text')) It's like [filter_by in SQLALchemy](https://docs.sqlalchemy.org/en/20/orm/queryguide/query.html#sqlalchemy.orm.Query.filter), but also allows magic operators like `rating__gt`. -See the [low-level SmartQueryMixin methods](pages/SMART_QUERY_MIXIN.md#api-reference) for more details. +See the [low-level SmartQueryMixin methods](pages/smart_query_mixin.md#api-reference) for more details. !!! note @@ -390,7 +390,7 @@ See the [low-level SmartQueryMixin methods](pages/SMART_QUERY_MIXIN.md#api-refer !!! tip - Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/API_REFERENCE.md) class to see all the available methods. + Check the [`ActiveRecordMixin` API Reference](pages/active_record_mixin/api_reference.md) class to see all the available methods. ### 6. Manage Timestamps @@ -412,7 +412,7 @@ print(user.updated_at) !!! tip - Check the [`TimestampMixin`](pages/TIMESTAMP_MIXIN.md) class to know how to customize the timestamps behavior. + Check the [`TimestampMixin`](pages/timestamp_mixin.md) class to know how to customize the timestamps behavior. ## License diff --git a/docs/pages/active_record_mixin/overview.md b/docs/pages/active_record_mixin/overview.md index 14f9df4..a91edaf 100644 --- a/docs/pages/active_record_mixin/overview.md +++ b/docs/pages/active_record_mixin/overview.md @@ -4,9 +4,9 @@ The `ActiveRecordMixin` class provides ActiveRecord-style functionality for SQLAlchemy models, allowing for more intuitive and chainable database operations with async/await support. -It uses the [`SmartQueryMixin`](../SMART_QUERY_MIXIN.md) class functionality. +It uses the [`SmartQueryMixin`](../smart_query_mixin.md) class functionality. -Check the [API Reference](API_REFERENCE.md) for the full list of +Check the [API Reference](api_reference.md) for the full list of available methods. **Table of Contents** @@ -253,5 +253,5 @@ on failure. ## API Reference -Check the [API Reference](API_REFERENCE.md) for the full list of +Check the [API Reference](api_reference.md) for the full list of available methods. diff --git a/docs/pages/inspection_mixin.md b/docs/pages/inspection_mixin.md index 2d6d116..faf643a 100644 --- a/docs/pages/inspection_mixin.md +++ b/docs/pages/inspection_mixin.md @@ -5,7 +5,7 @@ The `InspectionMixin` class provides attributes and properties inspection functi !!! info This mixin is intended to extend the functionality of the - [`SmartQueryMixin`](SMART_QUERY_MIXIN.md). + [`SmartQueryMixin`](smart_query_mixin.md). It is not intended to be used on its own. **Table of Contents** diff --git a/docs/pages/serialization_mixin.md b/docs/pages/serialization_mixin.md index b7b4c36..982386c 100644 --- a/docs/pages/serialization_mixin.md +++ b/docs/pages/serialization_mixin.md @@ -3,7 +3,7 @@ The `SerializationMixin` class provides methods for serializing and deserializing SQLAlchemy models. -It uses the [`InspectionMixin`](INSPECTION_MIXIN.md) class functionality. +It uses the [`InspectionMixin`](inspection_mixin.md) class functionality. **Table of Contents** diff --git a/docs/pages/smart_query_mixin.md b/docs/pages/smart_query_mixin.md index bc317b2..5746c01 100644 --- a/docs/pages/smart_query_mixin.md +++ b/docs/pages/smart_query_mixin.md @@ -4,12 +4,12 @@ The `SmartQueryMixin` class provides advanced query functionality for SQLAlchemy models, allowing you to filter, sort, and eager load data in a single query, making it easier to retrieve specific data from the database. -It uses the [`InspectionMixin`](INSPECTION_MIXIN.md) class functionality. +It uses the [`InspectionMixin`](inspection_mixin.md) class functionality. !!! info This mixin is intended to extend the functionality of the - [`ActiveRecordMixin`](active_record_mixin/OVERVIEW.md) + [`ActiveRecordMixin`](active_record_mixin/overview.md) on which the examples below are based. It is not intended to be used on its own. **Table of Contents** diff --git a/mkdocs.yml b/mkdocs.yml index 3daf8c3..bafdc7a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,18 +11,29 @@ theme: - search.suggest - search.highlight - search.share + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode nav: - - Home: INDEX.md + - Home: index.md - Docs: - ActiveRecord Mixin: - - Overview: pages/active_record_mixin/OVERVIEW.md - - API Reference: pages/active_record_mixin/API_REFERENCE.md - - TImestamp Mixin: pages/TIMESTAMP_MIXIN.md - - Serialization Mixin: pages/SERIALIZATION_MIXIN.md - - Smart Queries Mixin: pages/SMART_QUERY_MIXIN.md - - Inspection Mixin: pages/INSPECTION_MIXIN.md + - Overview: pages/active_record_mixin/overview.md + - API Reference: pages/active_record_mixin/api_reference.md + - TImestamp Mixin: pages/timestamp_mixin.md + - Serialization Mixin: pages/serialization_mixin.md + - Smart Queries Mixin: pages/smart_query_mixin.md + - Inspection Mixin: pages/inspection_mixin.md - Development: - - Contributing: CONTRIBUTING.md + - Contributing: contributing.md extra: version: provider: mike From 9c7bfea6a1c72a8a5f04f686fdfc2effacb46a45 Mon Sep 17 00:00:00 2001 From: Dairo Mosquera Date: Tue, 31 Dec 2024 00:27:43 -0500 Subject: [PATCH 6/6] default theme to dark --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index bafdc7a..60380ff 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,7 @@ theme: - search.highlight - search.share palette: + - scheme: slate - media: "(prefers-color-scheme: light)" scheme: default toggle: