diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml new file mode 100644 index 0000000..c3441f2 --- /dev/null +++ b/.github/workflows/flake8.yml @@ -0,0 +1,23 @@ +name: Flake8 + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 + - name: Checking the code with flake8 + run: | + flake8 $(git ls-files '*.py') diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..a794463 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,41 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Check token + run: echo ${{ secrets.PYPI_API_TOKEN }} + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d22b6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,150 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject +.vscode + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# brew artefacts +Brewfile.lock.json + +.DS_Store +.bash_history +coverage.* +.devcontainer +data/image_batch + +# Redis +*.rdb + +# Experiments +experiments +experiment + +# pycharm +.idea + +# Decrypted files +.decrypted* +.aider* diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..71ec9ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5622954 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# 🚀 SQLModelRepo - A simple CRUD util for SQLModel + +`SQLModelRepo` is a simple and powerful repository pattern implementation for managing database interactions using the SQLAlchemy-backed SQLModel ORM. It abstracts common database tasks (CRUD - Create, Read, Update, Delete) 🛠️, making data manipulation and access easier, with support for filtering, pagination, and session management 🎯. + +## 🎯 Features + +- 🏷️ **Generic Repository**: Perform Create, Read (single or multiple), Update, and Delete (CRUD) operations effortlessly. +- 🔄 **Session Management**: Automate session reuse or creation, ensuring efficient transaction handling. +- 🔍 **Filtering & Pagination**: Easily filter and paginate results. +- ♻️ **Partial Updates**: Update records partially using SQL. + +## 📦 Installation + +```bash +pip install sqlmodel_repo +``` + +Ensure you have `sqlmodel` and other dependencies installed. + +## 🚀 Usage + +### Define your SQLModel Repository 🏗️ + +Create an SQLModel class representing your database table. + +```python +... +from sqlmodel_repo import SQLModelRepo + +class User(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + username: str + email: str + extra_metadata: dict = Field(sa_column=Column(JSON)) + +users_repo = SQLModelRepo(model=User, db_engine=engine) +``` + +### Basic Operations 🛠️ + +#### Create a Record ✍️ + +You can easily create a new record using `create`. + +```python +new_user = users_repo.create(username="john_doe", email="john@example.com") +``` + +#### Retrieve by ID 🆔 + +Fetch a record by its ID using `get_by_id`. + +```python +user = users_repo.get_by_id(new_user.id) +``` + +#### Update a Record 🔄 + +Modify a record and call `save` to persist your changes. + +```python +user.email = "john_new@example.com" +users_repo.save(user) +``` + +Or, perform partial updates directly with `update(id, **kwargs)`: + +```python +users_repo.update(user.id, email="updated_email@example.com") +``` + +#### Delete a Record 🗑️ + +Easily delete a record by passing the instance to the `delete` method. + +```python +users_repo.delete(user) +``` + +#### Reuse session 🔄 +```python +with Session(engine) as session: + assert users_repo(session).all() +``` + +### Advanced Querying 🔥 + +#### Filtering Records 🔎 + +Use the `filter` method to retrieve records meeting specific criteria. + +```python +# Filter by username 'john_doe' +users = users_repo.filter(username="john_doe").all() + +# Find usernames starting with 'jo' +users = users_repo.filter(User.username.startswith('jo')).all() +``` + +#### Querying Inside JSON Fields 🗂️ + +```python +from sqlalchemy import cast, String + +users = users_repo.filter(cast(User.extra_metadata['some_num'], String) == '99').all() +``` + +#### Paginated Results 📄 + +Fetch paginated results using `paginate` or `paginate_with_total` to retrieve ordered subsets of data. + +```python +# Paginate results, sorted by username in descending order +users_paginated, total_count = users_repo.filter().paginate_with_total( + offset=0, limit=4, order_by="username", desc=True +) +``` + + +## ⚖️ License + +This project is licensed under the MIT License. + +--- + +Enjoy coding and happy querying with `SQLModelRepo`! 🎉 + +Author is a lazy person, so this README.md was generated by AI. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ff440f1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "sqlmodel_repo" +version = "0.0.1" +description = "Active record mixin for SQLModel" +readme = "README.md" +keywords = ["sqlmodel", "sqlalchemy", "orm", "active record"] +dependencies = [ + "sqlmodel", +] +requires-python = ">3.11.0" diff --git a/sqlmodel_repo.py b/sqlmodel_repo.py new file mode 100644 index 0000000..a6f2699 --- /dev/null +++ b/sqlmodel_repo.py @@ -0,0 +1,192 @@ +from contextlib import contextmanager +from typing import Optional + +from sqlmodel import Session, SQLModel, select, func, text + + +@contextmanager +def reuse_session_or_new(db_engine=None, session: Optional[Session] = None): + """ + Context manager to wrap session reuse or creation logic. + + :param session: An existing session to reuse. If None, + a new session is created. + :param db_engine: The database engine to use if creating a new session. + """ + should_close = False + try: + # If session is None, create a new session using the provided db_engine + if session is None: + if db_engine is None: + raise ValueError( + "No session and no db_engine provided to create a session." + ) + session = Session(db_engine) + should_close = True + + # Yield the session for use in the context block + yield session + finally: + # Close the session if it was created inside this context manager + if should_close: + session.close() + + +class CollectionResult: + def __init__(self, stmt, model, db_engine, session=None): + self.stmt = stmt + self.model = model + self.db_engine = db_engine + self.session = session + + def paginate( + self, + offset: int, + limit: int, + order_by: str, + desc: bool = False + ) -> list: + """Paginate results""" + with reuse_session_or_new(self.db_engine, self.session) as session: + return self._paginate(session, offset, limit, order_by, desc) + + def paginate_with_total( + self, + offset: int, + limit: int, + order_by: str, + desc: bool = False + ) -> (list, int): + """Paginate results and fetch total count + + Returns: + tuple(list, int) - Items and total count. + """ + with reuse_session_or_new(self.db_engine, self.session) as session: + count_stmt = select(func.count()).select_from(self.stmt.subquery()) + count = session.execute(count_stmt).scalar() + results = self._paginate(session, offset, limit, order_by, desc) + return results, count + + def _paginate( + self, + session, + offset: int, + limit: int, + order_by: str, + desc: bool = False + ) -> list: + order_by = getattr(self.model, order_by) + if desc: + order_by = getattr(order_by, 'desc')() + return session.exec( + self.stmt.order_by(order_by).offset(offset).limit(limit) + ).all() + + def all(self) -> list: + """Get all results""" + with reuse_session_or_new(self.db_engine, self.session) as session: + return session.exec(self.stmt).all() + + def count(self): + """Get total results count""" + with reuse_session_or_new(self.db_engine, self.session) as session: + count_stmt = select(func.count()).select_from(self.stmt.subquery()) + return session.execute(count_stmt).scalar() + + +class SQLModelRepo: + def __init__(self, model: SQLModel, db_engine): + """ Generic repository for SQLModel. + + Args: + model (SQLModel): The SQLModel class (table) for which + the repo is instantiated. + db_engine: The SQLAlchemy engine linked to the database. + + Usage: + users_repo = SQLModelRepo(model=User, db_engine=engine) + users_repo.get_by_id(1) + """ + self.model = model + self.db_engine = db_engine + self._session = None + + def __call__(self, session): + new_repo = SQLModelRepo(model=self.model, db_engine=self.db_engine) + new_repo._session = session + return new_repo + + def create(self, **kwargs): + """Create a new record and save to the database.""" + instance = self.model(**kwargs) + with reuse_session_or_new(self.db_engine, self._session) as session: + session.add(instance) + session.commit() + session.refresh(instance) + return instance + + def get_by_id(self, id, *fields): + """Fetch an object by its primary key.""" + select_obj = self._get_select_obj(fields) + with reuse_session_or_new(self.db_engine, self._session) as session: + return session.exec( + select(*select_obj).where( + getattr(self.model, 'id') == id + ) + ).first() + + def save(self, instance): + """Save the current object (instance) to the database.""" + with reuse_session_or_new(self.db_engine, self._session) as session: + session.add(instance) + session.commit() + session.refresh(instance) + + def update(self, id, **kwargs): + """Record partial update.""" + with reuse_session_or_new(self.db_engine, self._session) as session: + set_statements = ", ".join( + f"{field} = :{field}" for field in kwargs.keys() + ) + kwargs['id'] = id + query = f""" + UPDATE {self.model.__tablename__} + SET {set_statements} + WHERE id = :id + """ + session.execute(text(query), kwargs) + session.commit() + + def delete(self, instance): + """Delete an object from the database.""" + with reuse_session_or_new(self.db_engine, self._session) as session: + session.delete(instance) + session.commit() + + def all(self, *fields) -> list: + """Return all records.""" + select_obj = self._get_select_obj(fields) + with reuse_session_or_new(self.db_engine, self._session) as session: + return session.exec(select(*select_obj)).all() + + def filter(self, *filters, _fields=(), **kwargs) -> CollectionResult: + """Filter records based on provided conditions.""" + select_obj = self._get_select_obj(_fields) + stmt = select(*select_obj).where( + *filters, + *[ + getattr(self.model, k) == v + if isinstance(k, str) else k == v + for k, v in kwargs.items() + ] + ) + return CollectionResult( + stmt=stmt, + model=self.model, + db_engine=self.db_engine, + session=self._session + ) + + def _get_select_obj(self, fields=None): + return [self.model] if not fields else [getattr(self.model, f) for f in fields] diff --git a/test.py b/test.py new file mode 100644 index 0000000..5028c5f --- /dev/null +++ b/test.py @@ -0,0 +1,102 @@ +from sqlalchemy import Column, JSON, cast, String +from sqlmodel import SQLModel, Field, create_engine, Session + +from sqlmodel_repo import SQLModelRepo + + +class User(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + username: str + email: str + extra_metadata: dict = Field(sa_column=Column(JSON)) + + +# Setup in-memory SQLite engine and metadata +engine = create_engine("sqlite:///:memory:", echo=True) +SQLModel.metadata.create_all(engine) + +# Instantiate the repository with the User model and engine +users_repo = SQLModelRepo(model=User, db_engine=engine) + + +def test_all(): + # Create a new user + user1 = users_repo.create(username="john_doe", email="john@example.com") + + # Get user by ID + fetched_user = users_repo.get_by_id(user1.id) + + # Ensure a user that doesn't exist returns None + assert users_repo.get_by_id(123) is None + + # Get all users + all_users = users_repo.all() + assert 1 == len(all_users) + + # Update fetched_user and save changes + fetched_user.email = "new_email@example.com" + users_repo.save(fetched_user) + + # Create another user + users_repo.create(username="joe", email="joe@example.com") + + # Filter users by username + users = users_repo.filter(username="joe").all() + assert len(users) == 1 + assert users[0].username == "joe" + + # Filter users where username starts with 'jo' + users = users_repo.filter(User.username.startswith('jo')).all() + assert len(users) == 2 + + # Delete a user + users_repo.delete(fetched_user) + + assert len(users_repo.all()) == 1 + + # Create a new user with metadata + users_repo.create( + username="bob", + email="bob@example.com", + extra_metadata={'some_num': 99} + ) + + # Filter users by casting metadata + users = users_repo.filter( + cast(User.extra_metadata['some_num'], String) == '99' + ).all() + assert users, "cannot find by metadata" + + # Create 10 more users + for i in range(10): + users_repo.create( + username=f'user{i}', + email=f'user{i}@example.com', + extra_metadata={'i': i} + ) + + # Verify the total number of users + assert len(users_repo.all()) == 12 + + # Paginate the results (order by username in descending order) + users, total_count = ( + users_repo.filter() + .paginate_with_total(0, 4, order_by='username', desc=True) + ) + assert len(users) == 4 + assert total_count == 12 + assert users[0].username == 'user9' + + # Paginate the results (order by username in ascending order) + users, total_count = ( + users_repo.filter() + .paginate_with_total(0, 4, order_by='username', desc=False) + ) + assert users[0].username == 'bob' + + with Session(engine) as session: + assert users_repo(session).all() + + +if __name__ == '__main__': + test_all()