diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 98d1a42..29d2de6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,6 +23,6 @@ jobs: - name: Build distribution run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@v1.8.10 + uses: pypa/gh-action-pypi-publish@v1.8.11 with: - password: ${{ secrets.FLIT_PASSWORD }} + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4a5b897..443ce32 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,4 +22,4 @@ jobs: - name: Test run: make test-cov - name: Coverage - uses: codecov/codecov-action@v3.1.4 + uses: codecov/codecov-action@v4.0.1 diff --git a/docs/index.md b/docs/index.md index 6c90093..7de8281 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,7 +15,7 @@ EllarSQL is an SQL database module, leveraging the robust capabilities of [SQLAlchemy](https://www.sqlalchemy.org/) to seamlessly interact with SQL databases through Python code and objects. -Engineered with precision, EllarSQL is meticulously designed to streamline the integration of **SQLAlchemy** within your +EllarSQL is meticulously designed to streamline the integration of **SQLAlchemy** within your Ellar application. It introduces discerning usage patterns around pivotal objects such as **model**, **session**, and **engine**, ensuring an efficient and coherent workflow. @@ -109,4 +109,8 @@ session.close() We have successfully seed `50` users to `User` table in `app.db`. -I know at this point you want to know more, so let's dive deep into the documents and [get started](https://githut.com/python-ellar/ellar-sql/models/). +You can find the source code +for this example +[here](https://github.com/python-ellar/ellar-sql/blob/master/examples/index-script/main.py){target="_blank"}. + +I know at this point you want to know more, so let's dive deep into the documents and [get started](./overview/index.md). diff --git a/docs/migrations/env.md b/docs/migrations/env.md index 1ec01b2..c437eb7 100644 --- a/docs/migrations/env.md +++ b/docs/migrations/env.md @@ -9,7 +9,7 @@ from logging.config import fileConfig from alembic import context from ellar.app import current_injector -from ellar.threading import execute_coroutine_with_sync_worker +from ellar.threading import run_as_async from ellar_sql.migrations import SingleDatabaseAlembicEnvMigration from ellar_sql.services import EllarSQLService @@ -28,7 +28,7 @@ fileConfig(config.config_file_name) # type:ignore[arg-type] # my_important_option = config.get_main_option("my_important_option") # ... etc. - +@run_as_async async def main() -> None: db_service: EllarSQLService = current_injector.get(EllarSQLService) @@ -41,7 +41,7 @@ async def main() -> None: await alembic_env_migration.run_migrations_online(context) # type:ignore[arg-type] -execute_coroutine_with_sync_worker(main()) +main() ``` The EllarSQL migration package provides two main migration classes: @@ -92,7 +92,7 @@ from alembic import context from ellar_sql.migrations import AlembicEnvMigrationBase from ellar_sql.model.database_binds import get_metadata from ellar.app import current_injector -from ellar.threading import execute_coroutine_with_sync_worker +from ellar.threading import run_as_async from ellar_sql.services import EllarSQLService # This is the Alembic Config object, which provides @@ -155,6 +155,8 @@ class MyCustomMigrationEnv(AlembicEnvMigrationBase): with context.begin_transaction(): context.run_migrations() + +@run_as_async async def main() -> None: db_service: EllarSQLService = current_injector.get(EllarSQLService) @@ -166,7 +168,7 @@ async def main() -> None: else: await alembic_env_migration.run_migrations_online(context) -execute_coroutine_with_sync_worker(main()) +main() ``` This migration environment class, `MyCustomMigrationEnv`, inherits from `AlembicEnvMigrationBase` diff --git a/docs/models/index.md b/docs/models/index.md index 960b64f..1ef7553 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -1,150 +1,330 @@ -# **Quick Start** -In this segment, we will walk through the process of configuring **EllarSQL** within your Ellar application, -ensuring that all essential services are registered, configurations are set, and everything is prepared for immediate use. +# **Models and Tables** +The `ellar_sql.model.Model` class acts as a factory for creating `SQLAlchemy` models, and +associating the generated models with the corresponding **Metadata** through their designated **`__database__`** key. -Before we delve into the setup instructions, it is assumed that you possess a comprehensive -understanding of how [Ellar Modules](https://python-ellar.github.io/ellar/basics/dynamic-modules/#module-dynamic-setup){target="_blank"} -operate. -## **Installation** -Let us install all the required packages, assuming that your Python environment has been properly configured: +This class can be configured through the `__base_config__` attribute, allowing you to specify how your `SQLAlchemy` model should be created. +The `__base_config__` attribute can be of type `ModelBaseConfig`, which is a dataclass, or a dictionary with keys that +match the attributes of `ModelBaseConfig`. -#### **For Existing Project:** -```shell -pip install ellar-sql +Attributes of `ModelBaseConfig`: + +- **as_base**: Indicates whether the class should be treated as a `Base` class for other model definitions, similar to creating a Base from a [DeclarativeBase](https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.DeclarativeBase){target="_blank"} or [DeclarativeBaseNoMeta](https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.DeclarativeBaseNoMeta){target="_blank"} class. *(Default: False)* +- **use_base**: Specifies the base classes that will be used to create the `SQLAlchemy` model. *(Default: [])* + +## **Creating a Base Class** +`Model` treats each model as a standalone entity. Each instance of `model.Model` creates a distinct **declarative** base for itself, using the `__database__` key as a reference to determine its associated **Metadata**. Consequently, models sharing the same `__database__` key will utilize the same **Metadata** object. + +Let's explore how we can create a `Base` model using `Model`, similar to the approach in traditional `SQLAlchemy`. + +```python +from ellar_sql import model, ModelBaseConfig + + +class Base(model.Model): + __base_config__ = ModelBaseConfig(as_base=True, use_bases=[model.DeclarativeBase]) + + +assert issubclass(Base, model.DeclarativeBase) ``` -#### **For New Project**: -```shell -pip install ellar ellar-cli ellar-sql +If you are interested in [SQLAlchemy’s native support for data classes](https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html#native-support-for-dataclasses-mapped-as-orm-models){target="_blank"}, +then you can add `MappedAsDataclass` to `use_bases` as shown below: + +```python +from ellar_sql import model, ModelBaseConfig + + +class Base(model.Model): + __base_config__ = ModelBaseConfig(as_base=True, use_bases=[model.DeclarativeBase, model.MappedAsDataclass]) + +assert issubclass(Base, model.MappedAsDataclass) ``` -After a successful package installation, we need to scaffold a new project using `ellar` cli tool -```shell -ellar new db-learning +In the examples above, `Base` classes are created, all subclassed from the `use_bases` provided, and with the `as_base` +option, the factory creates the `Base` class as a `Base`. + +## Create base with MetaData +You can also configure the SQLAlchemy object with a custom [`MetaData`](https://docs.sqlalchemy.org/en/20/core/metadata.html#sqlalchemy.schema.MetaData){target="_blank"} object. +For instance, you can define a specific **naming convention** for constraints, ensuring consistency and predictability in constraint names. +This can be particularly beneficial during migrations, as detailed by [Alembic](https://alembic.sqlalchemy.org/en/latest/naming.html){target="_blank"}. + +For example: + +```python +from ellar_sql import model, ModelBaseConfig + +class Base(model.Model): + __base_config__ = ModelBaseConfig(as_base=True, use_bases=[model.DeclarativeBase]) + + metadata = model.MetaData(naming_convention={ + "ix": 'ix_%(column_0_label)s', + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" + }) ``` -This will scaffold `db-learning` project with necessary file structure shown below. + +## **Abstract Models and Mixins** +If the desired behavior is only applicable to specific models rather than all models, +you can use an abstract model base class to customize only those models. +For example, if certain models need to track their creation or update **timestamps**, t +his approach allows for targeted customization. + +```python +from datetime import datetime, timezone +from ellar_sql import model +from sqlalchemy.orm import Mapped, mapped_column + +class TimestampModel(model.Model): + __abstract__ = True + created: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc)) + updated: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + +class BookAuthor(model.Model): + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(unique=True) + + +class Book(TimestampModel): + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] +``` + +This can also be done with a mixin class, inherited separately. +```python +from datetime import datetime, timezone +from ellar_sql import model +from sqlalchemy.orm import Mapped, mapped_column + +class TimestampModel: + created: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc)) + updated: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + +class Book(model.Model, TimestampModel): + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] +``` + +## **Defining Models** +Unlike plain SQLAlchemy, **EllarSQL** models will automatically generate a table name if the `__tablename__` attribute is not set, +provided a primary key column is defined. + +```python +from ellar_sql import model + + +class User(model.Model): + id: model.Mapped[int] = model.mapped_column(primary_key=True) + username: model.Mapped[str] = model.mapped_column(unique=True) + email: model.Mapped[str] + + +class UserAddress(model.Model): + __tablename__ = 'user-address' + id: model.Mapped[int] = model.mapped_column(primary_key=True) + address: model.Mapped[str] = model.mapped_column(unique=True) + +assert User.__tablename__ == 'user' +assert UserAddress.__tablename__ == 'user-address' ``` -path/to/db-learning/ -├─ db_learning/ -│ ├─ apps/ -│ │ ├─ __init__.py -│ ├─ core/ -│ ├─ config.py -│ ├─ domain -│ ├─ root_module.py -│ ├─ server.py -│ ├─ __init__.py -├─ tests/ -│ ├─ __init__.py -├─ pyproject.toml -├─ README.md +For a comprehensive guide on defining model classes declaratively, refer to +[SQLAlchemy’s declarative documentation](https://docs.sqlalchemy.org/en/20/orm/declarative_tables.html){target="_blank"}. +This resource provides detailed information and insights into the declarative approach for defining model classes. + +## **Defining Tables** +The table class is designed to receive a table name, followed by **columns** and other table **components** such as constraints. + +EllarSQL enhances the functionality of the SQLAlchemy Table +by facilitating the selection of **Metadata** based on the `__database__` argument. + +Directly creating a table proves particularly valuable when establishing **many-to-many** relationships. +In such cases, the association table doesn't need its dedicated model class; rather, it can be conveniently accessed +through the relevant relationship attributes on the associated models. + +```python +from ellar_sql import model + +author_book_m2m = model.Table( + "author_book", + model.Column("book_author_id", model.ForeignKey(BookAuthor.id), primary_key=True), + model.Column("book_id", model.ForeignKey(Book.id), primary_key=True), +) ``` -Next, in `db_learning/` directory, we need to create a `models.py`. It will hold all our SQLAlchemy ORM Models for now. -## **Creating a Model** -In `models.py`, we use `ellar_sql.model.Model` to create our SQLAlchemy ORM Models. +## **Quick Tutorial** +In this section, we'll delve into straightforward **CRUD** operations using the ORM objects. +However, if you're not well-acquainted with SQLAlchemy, +feel free to explore their tutorial +on [ORM](https://docs.sqlalchemy.org/tutorial/orm_data_manipulation.html){target="_blank"} +for a more comprehensive understanding. -```python title="db_learning/model.py" +Having understood, `Model` usage. Let's create a `User` model + +```python from ellar_sql import model class User(model.Model): - id: model.Mapped[int] = model.mapped_column(model.Integer, primary_key=True) - username: model.Mapped[str] = model.mapped_column(model.String, unique=True, nullable=False) - email: model.Mapped[str] = model.mapped_column(model.String) + id: model.Mapped[int] = model.mapped_column(primary_key=True) + username: model.Mapped[str] = model.mapped_column(unique=True) + full_name: model.Mapped[str] = model.mapped_column(model.String) ``` +We have created a `User` model but the data does not exist. Let's fix that + +```python +from ellar.app import current_injector +from ellar_sql import EllarSQLService -!!!info - `ellar_sql.model` also exposes `sqlalchemy`, `sqlalchemy.orm` and `sqlalchemy.event` imports just for ease of import reference +db_service = current_injector.get(EllarSQLService) +db_service.create_all() +``` -## **Create A UserController** -Let's create a controller that exposes our user data. +### **Insert** +To insert data, you need a session. Here's an example using EllarSQL to insert a user: -```python title="db_learning/controller.py" +```python import ellar.common as ecm -from ellar.pydantic import EmailStr -from ellar_sql import model, get_or_404 -from .models import User - - -@ecm.Controller -class UsersController(ecm.ControllerBase): - @ecm.post("/users") - def create_user(self, username: ecm.Body[str], email: ecm.Body[EmailStr], session: ecm.Inject[model.Session]): - user = User(username=username, email=email) - - session.add(user) - session.commit() - session.refresh(user) - - return user.dict() - - @ecm.get("/users/{user_id:int}") - def user_by_id(self, user_id: int): - user = get_or_404(User, user_id) - return user.dict() - - @ecm.get("/") - async def user_list(self, session: ecm.Inject[model.Session]): - stmt = model.select(User) - rows = session.execute(stmt.offset(0).limit(100)).scalars() - return [row.dict() for row in rows] +from .model import User + +@ecm.post('/create') +def create_user(): + session = User.get_db_session() + squidward = User(name="squidward", fullname="Squidward Tentacles") + session.add(squidward) + + session.commit() - @ecm.get("/{user_id:int}") - async def user_delete(self, user_id: int, session: ecm.Inject[model.Session]): - user = get_or_404(User, user_id) - session.delete(user) - return {'detail': f'User id={user_id} Deleted successfully'} -``` - -## **EllarSQLModule Setup** -In the `root_module.py` file, two main tasks need to be performed: - -1. Register the `UsersController` to make the `/users` endpoint available when starting the application. -2. Configure the `EllarSQLModule`, which will set up and register essential services such as `EllarSQLService`, `Session`, and `Engine`. - -```python title="db_learning/root_module.py" -from ellar.common import Module, exception_handler, IExecutionContext, JSONResponse, Response, IApplicationStartup -from ellar.app import App -from ellar.core import ModuleBase -from ellar_sql import EllarSQLModule, EllarSQLService -from .controller import UsersController - -@Module( - modules=[EllarSQLModule.setup( - databases={ - 'default': { - 'url': 'sqlite:///app.db', - 'echo': True - } - }, - migration_options={'directory': 'migrations'} - )], - controllers=[UsersController] -) -class ApplicationModule(ModuleBase, IApplicationStartup): - async def on_startup(self, app: App) -> None: - db_service = app.injector.get(EllarSQLService) - db_service.create_all() + return squidward.dict(exclude={'id'}) +``` + +In the above illustration, the `squidward` data is converted to a `dictionary` object by calling `.dict()` +and excluding the `id` field. The `User.get_db_session()` function is used to get the session +for the registered `EllarSQLService.session_factory`. The function depends on `ApplicationContext`. + + +### **Update** +To update, make changes to the ORM object and commit. Here's an example using EllarSQL to update a user: + +```python +import ellar.common as ecm +import sqlalchemy.orm as sa_orm +from .model import User + +@ecm.put('/update') +def update_user(session: ecm.Inject[sa_orm.Session]): + squidward = session.get(User, 1) + + squidward.fullname = 'EllarSQL' + session.commit() - @exception_handler(404) - def exception_404_handler(cls, ctx: IExecutionContext, exc: Exception) -> Response: - return JSONResponse(dict(detail="Resource not found."), status_code=404) + return squidward.dict() ``` -In the provided code snippet: +In the above illustration, `Inject[sa_orm.Session]` is used to inject the `session` into the `update_user` route handler. +This is another way to get the `session` through the `EllarSQLService` service. The changes made to the `squidward` +object are committed to the database using `session.commit()`. -- We registered `UserController` and `EllarSQLModule` with specific configurations for the database and migration options. For more details on [`EllarSQLModule` configurations](./configuration.md#ellarsqlmodule-config). +### **Delete** +To delete, pass the ORM object to `session.delete()`. +```python +import ellar.common as ecm +import sqlalchemy.orm as sa_orm +from .model import User -- In the `on_startup` method, we obtained the `EllarSQLService` from the Ellar Dependency Injection container using `EllarSQLModule`. Subsequently, we invoked the `create_all()` method to generate the necessary SQLAlchemy tables. -With these configurations, the application is now ready for testing. -```shell -ellar runserver --reload +@ecm.delete('/delete') +def delete_user(session: ecm.Inject[sa_orm.Session]): + squidward = session.get(User, 1) + + session.delete(squidward) + session.commit() + + return '' + ``` -Additionally, please remember to uncomment the configurations for the `OpenAPIModule` in the `server.py` -file to enable visualization and interaction with the `/users` endpoint. -Once done, -you can access the OpenAPI documentation at [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs){target="_blank"}. +After modifying data, you must call `session.commit()` to commit the changes to the database. +Otherwise, changes may not be persisted to the database. + +## **View Utilities** +EllarSQL provides some utility query functions to check missing entities and raise 404 Not found if not found. + +- **get_or_404**: It will raise a 404 error if the row with the given id does not exist; otherwise, it will return the corresponding instance. +- **first_or_404**: It will raise a 404 error if the query does not return any results; otherwise, it will return the first result. +- **one_or_404**(): It will raise a 404 error if the query does not return exactly one result; otherwise, it will return the result. + +```python +import ellar.common as ecm +from ellar_sql import get_or_404, one_or_404, model + +@ecm.get("/user-by-id/{user_id:int}") +def user_by_id(user_id: int): + user = get_or_404(User, user_id) + return user.dict() + +@ecm.get("/user-by-name/{name:str}") +def user_by_username(name: str): + user = one_or_404(model.select(User).filter_by(name=name), error_message=f"No user named '{name}'.") + return user.dict() +``` + +## **Accessing Metadata and Engines** + +In the process of `EllarSQLModule` setup, three services are registered to the Ellar IoC container. + +- `EllarSQLService`: Which manages models, metadata, engines and sessions +- `Engine`: SQLAlchemy Engine of the default database configuration +- `Session`SQLAlchemy Session of the default database configuration + +Although with `EllarSQLService` you can get the `engine` and `session`. It's there for easy of access. + +```python +import sqlalchemy as sa +import sqlalchemy.orm as sa_orm +from ellar.app import current_injector +from ellar_sql import EllarSQLService + +db_service = current_injector.get(EllarSQLService) + +assert isinstance(db_service.engine, sa.Engine) +assert isinstance(db_service.session_factory(), sa_orm.Session) +``` +#### **Important Constraints** +- EllarSQLModule `databases` options for `SQLAlchemy.ext.asyncio.AsyncEngine` will register `SQLAlchemy.ext.asyncio.AsyncEngine` + and `SQLAlchemy.ext.asyncio.AsyncSession` +- EllarSQLModule `databases` options for `SQLAlchemy.Engine` will register `SQLAlchemy.Engine` and `SQLAlchemy.orm.Session`. +- `EllarSQL.get_all_metadata()` retrieves all configured metadatas +- `EllarSQL.get_metadata()` retrieves metadata by `__database__` key or `default` is no parameter is passed. + +```python +import sqlalchemy as sa +import sqlalchemy.orm as sa_orm +from ellar.app import current_injector + +# get engine from DI +default_engine = current_injector.get(sa.Engine) +# get session from DI +session = current_injector.get(sa_orm.Session) + + +assert isinstance(default_engine, sa.Engine) +assert isinstance(session, sa_orm.Session) +``` +For Async Database options +```python +from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine +from ellar.app import current_injector + +# get engine from DI +default_engine = current_injector.get(AsyncEngine) +# get session from DI +session = current_injector.get(AsyncSession) + + +assert isinstance(default_engine, AsyncEngine) +assert isinstance(session, AsyncSession) +``` diff --git a/docs/models/models.md b/docs/models/models.md deleted file mode 100644 index f5edea2..0000000 --- a/docs/models/models.md +++ /dev/null @@ -1,323 +0,0 @@ -# **Models and Tables** -The `ellar_sql.model.Model` class acts as a factory for creating `SQLAlchemy` models, and -associating the generated models with the corresponding **Metadata** through their designated **`__database__`** key. - - -This class can be configured through the `__base_config__` attribute, allowing you to specify how your `SQLAlchemy` model should be created. -The `__base_config__` attribute can be of type `ModelBaseConfig`, which is a dataclass, or a dictionary with keys that -match the attributes of `ModelBaseConfig`. - -Attributes of `ModelBaseConfig`: - -- **as_base**: Indicates whether the class should be treated as a `Base` class for other model definitions, similar to creating a Base from a [DeclarativeBase](https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.DeclarativeBase){target="_blank"} or [DeclarativeBaseNoMeta](https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.DeclarativeBaseNoMeta){target="_blank"} class. *(Default: False)* -- **use_base**: Specifies the base classes that will be used to create the `SQLAlchemy` model. *(Default: [])* - -## **Creating a Base Class** -`Model` treats each model as a standalone entity. Each instance of `model.Model` creates a distinct **declarative** base for itself, using the `__database__` key as a reference to determine its associated **Metadata**. Consequently, models sharing the same `__database__` key will utilize the same **Metadata** object. - -Let's explore how we can create a `Base` model using `Model`, similar to the approach in traditional `SQLAlchemy`. - -```python -from ellar_sql import model, ModelBaseConfig - - -class Base(model.Model): - __base_config__ = ModelBaseConfig(as_base=True, use_bases=[model.DeclarativeBase]) - - -assert issubclass(Base, model.DeclarativeBase) -``` - -If you are interested in [SQLAlchemy’s native support for data classes](https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html#native-support-for-dataclasses-mapped-as-orm-models){target="_blank"}, -then you can add `MappedAsDataclass` to `use_bases` as shown below: - -```python -from ellar_sql import model, ModelBaseConfig - - -class Base(model.Model): - __base_config__ = ModelBaseConfig(as_base=True, use_bases=[model.DeclarativeBase, model.MappedAsDataclass]) - -assert issubclass(Base, model.MappedAsDataclass) -``` - -In the examples above, `Base` classes are created, all subclassed from the `use_bases` provided, and with the `as_base` -option, the factory creates the `Base` class as a `Base`. - -## Create base with MetaData -You can also configure the SQLAlchemy object with a custom [`MetaData`](https://docs.sqlalchemy.org/en/20/core/metadata.html#sqlalchemy.schema.MetaData){target="_blank"} object. -For instance, you can define a specific **naming convention** for constraints, ensuring consistency and predictability in constraint names. -This can be particularly beneficial during migrations, as detailed by [Alembic](https://alembic.sqlalchemy.org/en/latest/naming.html){target="_blank"}. - -For example: - -```python -from ellar_sql import model, ModelBaseConfig - -class Base(model.Model): - __base_config__ = ModelBaseConfig(as_base=True, use_bases=[model.DeclarativeBase]) - - metadata = model.MetaData(naming_convention={ - "ix": 'ix_%(column_0_label)s', - "uq": "uq_%(table_name)s_%(column_0_name)s", - "ck": "ck_%(table_name)s_%(constraint_name)s", - "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", - "pk": "pk_%(table_name)s" - }) -``` - -## **Abstract Models and Mixins** -If the desired behavior is only applicable to specific models rather than all models, -you can use an abstract model base class to customize only those models. -For example, if certain models need to track their creation or update **timestamps**, t -his approach allows for targeted customization. - -```python -from datetime import datetime, timezone -from ellar_sql import model -from sqlalchemy.orm import Mapped, mapped_column - -class TimestampModel(model.Model): - __abstract__ = True - created: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc)) - updated: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) - - -class BookAuthor(model.Model): - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(unique=True) - - -class Book(TimestampModel): - id: Mapped[int] = mapped_column(primary_key=True) - title: Mapped[str] -``` - -This can also be done with a mixin class, inherited separately. -```python -from datetime import datetime, timezone -from ellar_sql import model -from sqlalchemy.orm import Mapped, mapped_column - -class TimestampModel: - created: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc)) - updated: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) - - -class Book(model.Model, TimestampModel): - id: Mapped[int] = mapped_column(primary_key=True) - title: Mapped[str] -``` - -## **Defining Models** -Unlike plain SQLAlchemy, **EllarSQL** models will automatically generate a table name if the `__tablename__` attribute is not set, -provided a primary key column is defined. - -```python -from ellar_sql import model - - -class User(model.Model): - id: model.Mapped[int] = model.mapped_column(primary_key=True) - username: model.Mapped[str] = model.mapped_column(unique=True) - email: model.Mapped[str] - - -class UserAddress(model.Model): - __tablename__ = 'user-address' - id: model.Mapped[int] = model.mapped_column(primary_key=True) - address: model.Mapped[str] = model.mapped_column(unique=True) - -assert User.__tablename__ == 'user' -assert UserAddress.__tablename__ == 'user-address' -``` -For a comprehensive guide on defining model classes declaratively, refer to -[SQLAlchemy’s declarative documentation](https://docs.sqlalchemy.org/en/20/orm/declarative_tables.html){target="_blank"}. -This resource provides detailed information and insights into the declarative approach for defining model classes. - -## **Defining Tables** -The table class is designed to receive a table name, followed by **columns** and other table **components** such as constraints. - -EllarSQL enhances the functionality of the SQLAlchemy Table -by facilitating the selection of **Metadata** based on the `__database__` argument. - -Directly creating a table proves particularly valuable when establishing **many-to-many** relationships. -In such cases, the association table doesn't need its dedicated model class; rather, it can be conveniently accessed -through the relevant relationship attributes on the associated models. - -```python -from ellar_sql import model - -author_book_m2m = model.Table( - "author_book", - model.Column("book_author_id", model.ForeignKey(BookAuthor.id), primary_key=True), - model.Column("book_id", model.ForeignKey(Book.id), primary_key=True), -) -``` - -## **Quick Tutorial** -In this section, we'll delve into straightforward **CRUD** operations using the ORM objects. -However, if you're not well-acquainted with SQLAlchemy, -feel free to explore their tutorial -on [ORM](https://docs.sqlalchemy.org/tutorial/orm_data_manipulation.html){target="_blank"} -for a more comprehensive understanding. - -Having understood, `Model` usage. Let's create a `User` model - -```python -from ellar_sql import model - - -class User(model.Model): - id: model.Mapped[int] = model.mapped_column(primary_key=True) - username: model.Mapped[str] = model.mapped_column(unique=True) - full_name: model.Mapped[str] = model.mapped_column(model.String) -``` -We have created a `User` model but the data does not exist. Let's fix that - -```python -from ellar.app import current_injector -from ellar_sql import EllarSQLService - -db_service = current_injector.get(EllarSQLService) -db_service.create_all() -``` - -### **Insert** -To insert a data, you need a session -```python -import ellar.common as ecm -from .model import User - -@ecm.post('/create') -def create_user(): - session = User.get_db_session() - squidward = User(name="squidward", fullname="Squidward Tentacles") - session.add(squidward) - - session.commit() - - return squidward.dict(exclude={'id'}) -``` -In the above illustration, `squidward` -data was converted to `dictionary` object by calling `.dict()` and excluding the `id` as shown below. - -_It's important to note this functionality has not been extended to a relationship objects in an SQLAlchemy ORM object_. - -### **Update** -To update, make changes to the ORM object and commit. -```python -import ellar.common as ecm -from .model import User - - -@ecm.put('/update') -def update_user(): - session = User.get_db_session() - squidward = session.get(User, 1) - - squidward.fullname = 'EllarSQL' - session.commit() - - return squidward.dict() - -``` -### **Delete** -To delete, pass the ORM object to `session.delete()`. -```python -import ellar.common as ecm -from .model import User - - -@ecm.delete('/delete') -def delete_user(): - session = User.get_db_session() - squidward = session.get(User, 1) - - session.delete(squidward) - session.commit() - - return '' - -``` -After modifying data, you must call `session.commit()` to commit the changes to the database. -Otherwise, changes may not be persisted to the database. - -## **View Utilities** -EllarSQL provides some utility query functions to check missing entities and raise 404 Not found if not found. - -- **get_or_404**: It will raise a 404 error if the row with the given id does not exist; otherwise, it will return the corresponding instance. -- **first_or_404**: It will raise a 404 error if the query does not return any results; otherwise, it will return the first result. -- **one_or_404**(): It will raise a 404 error if the query does not return exactly one result; otherwise, it will return the result. - -```python -import ellar.common as ecm -from ellar_sql import get_or_404, one_or_404, model - -@ecm.get("/user-by-id/{user_id:int}") -def user_by_id(user_id: int): - user = get_or_404(User, user_id) - return user.dict() - -@ecm.get("/user-by-name/{name:str}") -def user_by_username(name: str): - user = one_or_404(model.select(User).filter_by(name=name), error_message=f"No user named '{name}'.") - return user.dict() -``` - -## **Accessing Metadata and Engines** - -In the process of `EllarSQLModule` setup, three services are registered to the Ellar IoC container. - -- `EllarSQLService`: Which manages models, metadata, engines and sessions -- `Engine`: SQLAlchemy Engine of the default database configuration -- `Session`SQLAlchemy Session of the default database configuration - -Although with `EllarSQLService` you can get the `engine` and `session`. It's there for easy of access. - -```python -import sqlalchemy as sa -import sqlalchemy.orm as sa_orm -from ellar.app import current_injector -from ellar_sql import EllarSQLService - -db_service = current_injector.get(EllarSQLService) - -assert isinstance(db_service.engine, sa.Engine) -assert isinstance(db_service.session_factory(), sa_orm.Session) -``` -#### **Important Constraints** -- EllarSQLModule `databases` options for `SQLAlchemy.ext.asyncio.AsyncEngine` will register `SQLAlchemy.ext.asyncio.AsyncEngine` - and `SQLAlchemy.ext.asyncio.AsyncSession` -- EllarSQLModule `databases` options for `SQLAlchemy.Engine` will register `SQLAlchemy.Engine` and `SQLAlchemy.orm.Session`. -- `EllarSQL.get_all_metadata()` retrieves all configured metadatas -- `EllarSQL.get_metadata()` retrieves metadata by `__database__` key or `default` is no parameter is passed. - -```python -import sqlalchemy as sa -import sqlalchemy.orm as sa_orm -from ellar.app import current_injector - -# get engine from DI -default_engine = current_injector.get(sa.Engine) -# get session from DI -session = current_injector.get(sa_orm.Session) - - -assert isinstance(default_engine, sa.Engine) -assert isinstance(session, sa_orm.Session) -``` -For Async Database options -```python -from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine -from ellar.app import current_injector - -# get engine from DI -default_engine = current_injector.get(AsyncEngine) -# get session from DI -session = current_injector.get(AsyncSession) - - -assert isinstance(default_engine, AsyncEngine) -assert isinstance(session, AsyncSession) -``` diff --git a/docs/models/configuration.md b/docs/overview/configuration.md similarity index 100% rename from docs/models/configuration.md rename to docs/overview/configuration.md diff --git a/docs/overview/index.md b/docs/overview/index.md new file mode 100644 index 0000000..6c4465c --- /dev/null +++ b/docs/overview/index.md @@ -0,0 +1,153 @@ +# **Quick Start** +In this segment, we will walk through the process of configuring **EllarSQL** within your Ellar application, +ensuring that all essential services are registered, configurations are set, and everything is prepared for immediate use. + +Before we delve into the setup instructions, it is assumed that you possess a comprehensive +understanding of how [Ellar Modules](https://python-ellar.github.io/ellar/basics/dynamic-modules/#module-dynamic-setup){target="_blank"} +operate. + +## **Installation** +Let us install all the required packages, assuming that your Python environment has been properly configured: + +#### **For Existing Project:** +```shell +pip install ellar-sql +``` + +#### **For New Project**: +```shell +pip install ellar ellar-cli ellar-sql +``` + +After a successful package installation, we need to scaffold a new project using `ellar` cli tool +```shell +ellar new db-learning +``` +This will scaffold `db-learning` project with necessary file structure shown below. +``` +path/to/db-learning/ +├─ db_learning/ +│ ├─ apps/ +│ │ ├─ __init__.py +│ ├─ core/ +│ ├─ config.py +│ ├─ domain +│ ├─ root_module.py +│ ├─ server.py +│ ├─ __init__.py +├─ tests/ +│ ├─ __init__.py +├─ pyproject.toml +├─ README.md +``` +Next, in `db_learning/` directory, we need to create a `models.py`. It will hold all our SQLAlchemy ORM Models for now. + +## **Creating a Model** +In `models.py`, we use `ellar_sql.model.Model` to create our SQLAlchemy ORM Models. + +```python title="db_learning/model.py" +from ellar_sql import model + + +class User(model.Model): + id: model.Mapped[int] = model.mapped_column(model.Integer, primary_key=True) + username: model.Mapped[str] = model.mapped_column(model.String, unique=True, nullable=False) + email: model.Mapped[str] = model.mapped_column(model.String) +``` + +!!!info + `ellar_sql.model` also exposes `sqlalchemy`, `sqlalchemy.orm` and `sqlalchemy.event` imports just for ease of import reference + +## **Create A UserController** +Let's create a controller that exposes our user data. + +```python title="db_learning/controller.py" +import ellar.common as ecm +from ellar.pydantic import EmailStr +from ellar_sql import model, get_or_404 +from .models import User + + +@ecm.Controller +class UsersController(ecm.ControllerBase): + @ecm.post("/users") + def create_user(self, username: ecm.Body[str], email: ecm.Body[EmailStr], session: ecm.Inject[model.Session]): + user = User(username=username, email=email) + + session.add(user) + session.commit() + session.refresh(user) + + return user.dict() + + @ecm.get("/users/{user_id:int}") + def user_by_id(self, user_id: int): + user = get_or_404(User, user_id) + return user.dict() + + @ecm.get("/") + async def user_list(self, session: ecm.Inject[model.Session]): + stmt = model.select(User) + rows = session.execute(stmt.offset(0).limit(100)).scalars() + return [row.dict() for row in rows] + + @ecm.get("/{user_id:int}") + async def user_delete(self, user_id: int, session: ecm.Inject[model.Session]): + user = get_or_404(User, user_id) + session.delete(user) + return {'detail': f'User id={user_id} Deleted successfully'} +``` + +## **EllarSQLModule Setup** +In the `root_module.py` file, two main tasks need to be performed: + +1. Register the `UsersController` to make the `/users` endpoint available when starting the application. +2. Configure the `EllarSQLModule`, which will set up and register essential services such as `EllarSQLService`, `Session`, and `Engine`. + +```python title="db_learning/root_module.py" +from ellar.common import Module, exception_handler, IExecutionContext, JSONResponse, Response, IApplicationStartup +from ellar.app import App +from ellar.core import ModuleBase +from ellar_sql import EllarSQLModule, EllarSQLService +from .controller import UsersController + +@Module( + modules=[EllarSQLModule.setup( + databases={ + 'default': { + 'url': 'sqlite:///app.db', + 'echo': True + } + }, + migration_options={'directory': 'migrations'} + )], + controllers=[UsersController] +) +class ApplicationModule(ModuleBase, IApplicationStartup): + async def on_startup(self, app: App) -> None: + db_service = app.injector.get(EllarSQLService) + db_service.create_all() + + @exception_handler(404) + def exception_404_handler(cls, ctx: IExecutionContext, exc: Exception) -> Response: + return JSONResponse(dict(detail="Resource not found."), status_code=404) +``` + +In the provided code snippet: + +- We registered `UserController` and `EllarSQLModule` with specific configurations for the database and migration options. For more details on [`EllarSQLModule` configurations](./configuration.md#ellarsqlmodule-config). + +- In the `on_startup` method, we obtained the `EllarSQLService` from the Ellar Dependency Injection container using `EllarSQLModule`. Subsequently, we invoked the `create_all()` method to generate the necessary SQLAlchemy tables. + +With these configurations, the application is now ready for testing. +```shell +ellar runserver --reload +``` +Additionally, please remember to uncomment the configurations for the `OpenAPIModule` in the `server.py` +file to enable visualization and interaction with the `/users` endpoint. + +Once done, +you can access the OpenAPI documentation at [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs){target="_blank"}. + +You can find the source code for this project +[here](https://github.com/python-ellar/ellar-sql/tree/master/examples/db-learning){target="_blank"}. diff --git a/ellar_sql/__init__.py b/ellar_sql/__init__.py index 517946c..200fb86 100644 --- a/ellar_sql/__init__.py +++ b/ellar_sql/__init__.py @@ -1,15 +1,11 @@ """EllarSQL Module adds support for SQLAlchemy and Alembic package to your Ellar application""" -__version__ = "0.0.1" +__version__ = "0.0.2" from .model.database_binds import get_all_metadata, get_metadata from .module import EllarSQLModule from .pagination import LimitOffsetPagination, PageNumberPagination, paginate -from .query import ( - first_or_404, - get_or_404, - one_or_404, -) +from .query import first_or_404, first_or_none, get_or_404, get_or_none, one_or_404 from .schemas import MigrationOption, ModelBaseConfig, SQLAlchemyConfig from .services import EllarSQLService @@ -21,6 +17,8 @@ "get_or_404", "first_or_404", "one_or_404", + "first_or_none", + "get_or_none", "paginate", "PageNumberPagination", "LimitOffsetPagination", diff --git a/ellar_sql/factory/base.py b/ellar_sql/factory/base.py index e34adde..86734ef 100644 --- a/ellar_sql/factory/base.py +++ b/ellar_sql/factory/base.py @@ -2,7 +2,7 @@ import sqlalchemy as sa import sqlalchemy.orm as sa_orm -from ellar.threading import execute_coroutine_with_sync_worker +from ellar.threading import run_as_async from factory.alchemy import ( SESSION_PERSISTENCE_COMMIT, SESSION_PERSISTENCE_FLUSH, @@ -36,12 +36,13 @@ class Meta: abstract = True @classmethod - def _session_execute( + @run_as_async + async def _session_execute( cls, session_func: t.Callable, *args: t.Any, **kwargs: t.Any ) -> t.Union[sa.Result, sa.CursorResult, t.Any]: res = session_func(*args, **kwargs) if isinstance(res, t.Coroutine): - res = execute_coroutine_with_sync_worker(res) + res = await res return res @classmethod diff --git a/ellar_sql/model/mixins.py b/ellar_sql/model/mixins.py index 158c010..2151d61 100644 --- a/ellar_sql/model/mixins.py +++ b/ellar_sql/model/mixins.py @@ -2,7 +2,6 @@ import sqlalchemy as sa import sqlalchemy.orm as sa_orm -from pydantic.v1 import BaseModel from ellar_sql.constant import ABSTRACT_KEY, DATABASE_KEY, DEFAULT_KEY, TABLE_KEY from ellar_sql.model.utils import ( @@ -12,13 +11,6 @@ ) from ellar_sql.schemas import ModelBaseConfig, ModelMetaStore - -class asss(BaseModel): - sd: str - - -IncEx = t.Union[t.Set[int], t.Set[str], t.Dict[int, t.Any], t.Dict[str, t.Any]] - if t.TYPE_CHECKING: from .base import ModelBase @@ -143,7 +135,6 @@ def dict( exclude: t.Optional[t.Set[str]] = None, exclude_none: bool = False, ) -> t.Dict[str, t.Any]: - # TODO: implement advance exclude and include that goes deep into relationships too return dict( self._iter(include=include, exclude_none=exclude_none, exclude=exclude) ) diff --git a/ellar_sql/model/table.py b/ellar_sql/model/table.py index 3f816ca..e1166cf 100644 --- a/ellar_sql/model/table.py +++ b/ellar_sql/model/table.py @@ -11,6 +11,8 @@ class Table(sa.Table): """ Custom SQLAlchemy Table class that supports database-binding E.g.: + ```python + from ellar_sql.model import Table user_book_m2m = Table( "user_book", @@ -18,6 +20,7 @@ class Table(sa.Table): sa.Column("book_id", sa.ForeignKey(Book.id), primary_key=True), __database__='default' ) + ``` """ @t.overload diff --git a/ellar_sql/module.py b/ellar_sql/module.py index 7fc8445..04361d6 100644 --- a/ellar_sql/module.py +++ b/ellar_sql/module.py @@ -3,10 +3,10 @@ import sqlalchemy as sa from ellar.common import IExecutionContext, IModuleSetup, Module, middleware -from ellar.common.utils.importer import get_main_directory_by_stack from ellar.core import Config, DynamicModule, ModuleBase, ModuleSetup from ellar.di import ProviderConfig, request_or_transient_scope -from ellar.events import app_context_teardown_events +from ellar.events import app_context_teardown +from ellar.utils.importer import get_main_directory_by_stack from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -155,7 +155,7 @@ def __setup_module(cls, sql_alchemy_config: SQLAlchemyConfig) -> DynamicModule: ) providers.append(ProviderConfig(EllarSQLService, use_value=db_service)) - app_context_teardown_events.connect( + app_context_teardown.connect( functools.partial(cls._on_application_tear_down, db_service=db_service) ) diff --git a/ellar_sql/pagination/base.py b/ellar_sql/pagination/base.py index 2a9bc1f..75ef012 100644 --- a/ellar_sql/pagination/base.py +++ b/ellar_sql/pagination/base.py @@ -6,7 +6,7 @@ import sqlalchemy as sa import sqlalchemy.orm as sa_orm from ellar.app import current_injector -from ellar.threading import execute_coroutine_with_sync_worker +from ellar.threading import run_as_async from sqlalchemy.ext.asyncio import AsyncSession from ellar_sql.model.base import ModelBase @@ -275,7 +275,13 @@ def __init__( ) if self._created_session: - self._session.close() # session usage is done but only if Paginator created the session + self._close_session() # session usage is done but only if Paginator created the session + + @run_as_async + async def _close_session(self) -> None: + res = self._session.close() + if isinstance(res, t.Coroutine): + await res def _get_session(self) -> t.Union[sa_orm.Session, AsyncSession, t.Any]: self._created_session = True @@ -284,7 +290,7 @@ def _get_session(self) -> t.Union[sa_orm.Session, AsyncSession, t.Any]: def _query_items(self) -> t.List[t.Any]: if self._is_async: - res = execute_coroutine_with_sync_worker(self._query_items_async()) + res = self._query_items_async() return list(res) return self._query_items_sync() @@ -292,6 +298,7 @@ def _query_items_sync(self) -> t.List[t.Any]: select = self._select.limit(self.per_page).offset(self._query_offset) return list(self._session.execute(select).unique().scalars()) + @run_as_async async def _query_items_async(self) -> t.List[t.Any]: session = t.cast(AsyncSession, self._session) @@ -302,7 +309,7 @@ async def _query_items_async(self) -> t.List[t.Any]: def _query_count(self) -> int: if self._is_async: - res = execute_coroutine_with_sync_worker(self._query_count_async()) + res = self._query_count_async() return int(res) return self._query_count_sync() @@ -313,6 +320,7 @@ def _query_count_sync(self) -> int: ).scalar() return out # type:ignore[return-value] + @run_as_async async def _query_count_async(self) -> int: session = t.cast(AsyncSession, self._session) diff --git a/ellar_sql/query/__init__.py b/ellar_sql/query/__init__.py index 3b95f4d..f0c4141 100644 --- a/ellar_sql/query/__init__.py +++ b/ellar_sql/query/__init__.py @@ -1,11 +1,9 @@ -from .utils import ( - first_or_404, - get_or_404, - one_or_404, -) +from .utils import first_or_404, first_or_none, get_or_404, get_or_none, one_or_404 __all__ = [ "get_or_404", "one_or_404", "first_or_404", + "first_or_none", + "get_or_none", ] diff --git a/ellar_sql/query/utils.py b/ellar_sql/query/utils.py index 9f131d9..b5e3ad6 100644 --- a/ellar_sql/query/utils.py +++ b/ellar_sql/query/utils.py @@ -32,6 +32,26 @@ async def get_or_404( return t.cast(_O, value) +async def get_or_none( + entity: t.Type[_O], + ident: t.Any, + **kwargs: t.Any, +) -> t.Optional[_O]: + """ """ + db_service = current_injector.get(EllarSQLService) + session = db_service.get_scoped_session()() + + value = session.get(entity, ident, **kwargs) + + if isinstance(value, t.Coroutine): + value = await value + + if value is None: + return None + + return t.cast(_O, value) + + async def first_or_404( statement: sa.sql.Select[t.Any], *, error_message: t.Optional[str] = None ) -> t.Any: @@ -51,6 +71,23 @@ async def first_or_404( return value +async def first_or_none(statement: sa.sql.Select[t.Any]) -> t.Any: + """ """ + db_service = current_injector.get(EllarSQLService) + session = db_service.session_factory() + + result = session.execute(statement) + if isinstance(result, t.Coroutine): + result = await result + + value = result.scalar() + + if value is None: + return None + + return value + + async def one_or_404( statement: sa.sql.Select[t.Any], *, error_message: t.Optional[str] = None ) -> t.Any: diff --git a/ellar_sql/services/base.py b/ellar_sql/services/base.py index 095e389..5791387 100644 --- a/ellar_sql/services/base.py +++ b/ellar_sql/services/base.py @@ -7,11 +7,11 @@ import sqlalchemy.exc as sa_exc import sqlalchemy.orm as sa_orm from ellar.common.exceptions import ImproperConfiguration -from ellar.common.utils.importer import ( +from ellar.threading.sync_worker import execute_coroutine +from ellar.utils.importer import ( get_main_directory_by_stack, module_import, ) -from ellar.threading import execute_coroutine_with_sync_worker from sqlalchemy.ext.asyncio import ( AsyncSession, async_scoped_session, @@ -151,7 +151,7 @@ def create_all(self, *databases: str) -> None: for metadata_engine in metadata_engines: if metadata_engine.is_async(): - execute_coroutine_with_sync_worker(metadata_engine.create_all_async()) + execute_coroutine(metadata_engine.create_all_async()) continue metadata_engine.create_all() @@ -162,7 +162,7 @@ def drop_all(self, *databases: str) -> None: for metadata_engine in metadata_engines: if metadata_engine.is_async(): - execute_coroutine_with_sync_worker(metadata_engine.drop_all_async()) + execute_coroutine(metadata_engine.drop_all_async()) continue metadata_engine.drop_all() @@ -173,7 +173,7 @@ def reflect(self, *databases: str) -> None: for metadata_engine in metadata_engines: if metadata_engine.is_async(): - execute_coroutine_with_sync_worker(metadata_engine.reflect_async()) + execute_coroutine(metadata_engine.reflect_async()) continue metadata_engine.reflect() diff --git a/ellar_sql/templates/multiple/env.py b/ellar_sql/templates/multiple/env.py index 00dc594..cf84f50 100644 --- a/ellar_sql/templates/multiple/env.py +++ b/ellar_sql/templates/multiple/env.py @@ -2,7 +2,7 @@ from alembic import context from ellar.app import current_injector -from ellar.threading import execute_coroutine_with_sync_worker +from ellar.threading import run_as_async from ellar_sql.migrations import MultipleDatabaseAlembicEnvMigration from ellar_sql.services import EllarSQLService @@ -22,6 +22,7 @@ # ... etc. +@run_as_async async def main() -> None: db_service: EllarSQLService = current_injector.get(EllarSQLService) @@ -34,4 +35,4 @@ async def main() -> None: await alembic_env_migration.run_migrations_online(context) # type:ignore[arg-type] -execute_coroutine_with_sync_worker(main()) +main() diff --git a/ellar_sql/templates/single/env.py b/ellar_sql/templates/single/env.py index 41c6f37..9d270b8 100644 --- a/ellar_sql/templates/single/env.py +++ b/ellar_sql/templates/single/env.py @@ -2,7 +2,7 @@ from alembic import context from ellar.app import current_injector -from ellar.threading import execute_coroutine_with_sync_worker +from ellar.threading import run_as_async from ellar_sql.migrations import SingleDatabaseAlembicEnvMigration from ellar_sql.services import EllarSQLService @@ -22,6 +22,7 @@ # ... etc. +@run_as_async async def main() -> None: db_service: EllarSQLService = current_injector.get(EllarSQLService) @@ -34,4 +35,4 @@ async def main() -> None: await alembic_env_migration.run_migrations_online(context) # type:ignore[arg-type] -execute_coroutine_with_sync_worker(main()) +main() diff --git a/examples/db-learning/README.md b/examples/db-learning/README.md index ee49c90..6722f8e 100644 --- a/examples/db-learning/README.md +++ b/examples/db-learning/README.md @@ -16,6 +16,7 @@ After environment setup, kindly follow instruction below ``` python manage.py runserver --reload ``` +Visit Swagger Docs: [http://localhost:8000/docs](http://localhost:8000/docs) ## Run Test diff --git a/examples/db-learning/db_learning/core/__init__.py b/examples/db-learning/db_learning/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/db-learning/db_learning/domain/__init__.py b/examples/db-learning/db_learning/domain/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/db-learning/db_learning/migrations/env.py b/examples/db-learning/db_learning/migrations/env.py index 41c6f37..9d270b8 100644 --- a/examples/db-learning/db_learning/migrations/env.py +++ b/examples/db-learning/db_learning/migrations/env.py @@ -2,7 +2,7 @@ from alembic import context from ellar.app import current_injector -from ellar.threading import execute_coroutine_with_sync_worker +from ellar.threading import run_as_async from ellar_sql.migrations import SingleDatabaseAlembicEnvMigration from ellar_sql.services import EllarSQLService @@ -22,6 +22,7 @@ # ... etc. +@run_as_async async def main() -> None: db_service: EllarSQLService = current_injector.get(EllarSQLService) @@ -34,4 +35,4 @@ async def main() -> None: await alembic_env_migration.run_migrations_online(context) # type:ignore[arg-type] -execute_coroutine_with_sync_worker(main()) +main() diff --git a/examples/db-learning/db_learning/pagination/api.py b/examples/db-learning/db_learning/pagination/api.py index da707ce..9972ae7 100644 --- a/examples/db-learning/db_learning/pagination/api.py +++ b/examples/db-learning/db_learning/pagination/api.py @@ -17,16 +17,14 @@ class UserSchema(ec.Serializer): # return User list_api_router = ec.ModuleRouter("/users-api") +# openapi tag +ApiTags( + name="API Pagination", + external_doc_url="https://python-ellar.github.io/ellar-sql/pagination/#api-pagination", +)(list_api_router) @list_api_router.get() @paginate(model=User, item_schema=UserSchema) def list_users_api(): pass - - -# openapi tag -ApiTags( - name="API Pagination", - external_doc_url="https://python-ellar.github.io/ellar-sql/pagination/#api-pagination", -)(list_api_router.get_control_type()) diff --git a/examples/db-learning/db_learning/pagination/template.py b/examples/db-learning/db_learning/pagination/template.py index b629e52..356fcd6 100644 --- a/examples/db-learning/db_learning/pagination/template.py +++ b/examples/db-learning/db_learning/pagination/template.py @@ -5,7 +5,11 @@ from ellar_sql import model, paginate list_template_router = ec.ModuleRouter("/users-template") - +# openapi tag +ApiTags( + name="Template Pagination", + external_doc_url="https://python-ellar.github.io/ellar-sql/pagination/#template-pagination", +)(list_template_router) ## CASE 1 # @list_template_router.get('/users') @@ -21,10 +25,3 @@ @paginate(model=model.select(User), as_template_context=True) def list_users_template(): return {"name": "Template Pagination"} - - -# openapi tag -ApiTags( - name="Template Pagination", - external_doc_url="https://python-ellar.github.io/ellar-sql/pagination/#template-pagination", -)(list_template_router.get_control_type()) diff --git a/examples/db-learning/db_learning/server.py b/examples/db-learning/db_learning/server.py index b0c2653..1bf24cc 100644 --- a/examples/db-learning/db_learning/server.py +++ b/examples/db-learning/db_learning/server.py @@ -25,8 +25,7 @@ def bootstrap() -> App: ).set_license("MIT Licence", url="https://www.google.com") document = document_builder.build_document(application) - module = OpenAPIDocumentModule.setup( - document=document, docs_ui=SwaggerUI(), guards=[] + OpenAPIDocumentModule.setup( + app=application, document=document, docs_ui=SwaggerUI(), guards=[] ) - application.install_module(module) return application diff --git a/examples/single-db/db/migrations/env.py b/examples/single-db/db/migrations/env.py index 83c4d43..159be24 100644 --- a/examples/single-db/db/migrations/env.py +++ b/examples/single-db/db/migrations/env.py @@ -1,9 +1,9 @@ -import asyncio import typing as t from logging.config import fileConfig from alembic import context from ellar.app import current_injector +from ellar.threading import run_as_async from ellar_sql.migrations import ( MultipleDatabaseAlembicEnvMigration, @@ -38,11 +38,14 @@ # ... etc. -alembic_env_migration = AlembicEnvMigrationKlass(db_service) +@run_as_async +async def main(): + alembic_env_migration = AlembicEnvMigrationKlass(db_service) -if context.is_offline_mode(): - alembic_env_migration.run_migrations_offline(context) # type:ignore[arg-type] -else: - asyncio.get_event_loop().run_until_complete( - alembic_env_migration.run_migrations_online(context) # type:ignore[arg-type] - ) + if context.is_offline_mode(): + alembic_env_migration.run_migrations_offline(context) # type:ignore[arg-type] + else: + await alembic_env_migration.run_migrations_online(context) # type:ignore[arg-type] + + +main() diff --git a/examples/single-db/pyproject.toml b/examples/single-db/pyproject.toml index dfeb147..028261d 100644 --- a/examples/single-db/pyproject.toml +++ b/examples/single-db/pyproject.toml @@ -11,6 +11,7 @@ packages = [{include = "single_db"}] python = "^3.8" ellar-cli = "^0.2.6" ellar = "^0.6.2" +ellar-sql = "^0.0.1" [build-system] diff --git a/examples/single-db/single_db/server.py b/examples/single-db/single_db/server.py index 822708d..635cb7a 100644 --- a/examples/single-db/single_db/server.py +++ b/examples/single-db/single_db/server.py @@ -25,9 +25,10 @@ def bootstrap(): ).set_license("MIT Licence", url="https://www.google.com") document = document_builder.build_document(application) - application.install_module( - OpenAPIDocumentModule.setup( - document=document, docs_ui=SwaggerUI(dark_theme=True), guards=[] - ) + OpenAPIDocumentModule.setup( + app=application, + document=document, + docs_ui=SwaggerUI(dark_theme=True), + guards=[], ) return application diff --git a/mkdocs.yml b/mkdocs.yml index 17105ac..3360e2d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: '' -site_description: Ellar-SQLAlchemy, designed for Ellar, enhances your application by integrating support for SQLAlchemy and Alembic. +site_description: EllarSQL, designed for Ellar, enhances your application by integrating support for SQLAlchemy and Alembic. site_url: https://github.com/python-ellar/ellar-sql repo_name: python-ellar/ellar-sql repo_url: https://github.com/python-ellar/ellar-sql @@ -65,18 +65,17 @@ plugins: nav: - Index: index.md - - Get Started: models/index.md - - Configuration: models/configuration.md + - Get Started: overview/index.md + - Configuration: overview/configuration.md - Models: - - index: models/models.md + - index: models/index.md - Extra Fields: models/extra-fields.md - Pagination: pagination/index.md - Multiple Database: multiple/index.md - Migrations: - index: migrations/index.md - customizing env: migrations/env.md - - Testing: - - index: testing/index.md + - Testing: testing/index.md markdown_extensions: - attr_list diff --git a/pyproject.toml b/pyproject.toml index 84ca615..3949ae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ ] dependencies = [ - "ellar >= 0.6.7", + "ellar >= 0.7.0", "sqlalchemy >= 2.0.23", "alembic >= 1.10.0" ] @@ -59,12 +59,12 @@ Homepage = "https://python-ellar.github.io/ellar-sql/" [project.optional-dependencies] test = [ - "pytest >= 7.1.3,<8.0.0", + "pytest >= 7.1.3,< 9.0.0", "pytest-cov >= 2.12.0,<5.0.0", "pytest-asyncio", "aiosqlite", "anyio[trio] >= 3.2.1", - "ruff ==0.1.9", + "ruff ==0.1.15", "mypy == 1.8.0", "autoflake", "ellar-cli >= 0.3.3", diff --git a/tests/test_migrations/samples/custom_directory.py b/tests/test_migrations/samples/custom_directory.py index 3c842d2..44173c8 100644 --- a/tests/test_migrations/samples/custom_directory.py +++ b/tests/test_migrations/samples/custom_directory.py @@ -2,7 +2,7 @@ import click from ellar.app import AppFactory, current_injector -from ellar.common.utils.importer import get_main_directory_by_stack +from ellar.utils.importer import get_main_directory_by_stack from ellar_cli.main import create_ellar_cli from models import User diff --git a/tests/test_migrations/samples/default.py b/tests/test_migrations/samples/default.py index 6686965..a95f544 100644 --- a/tests/test_migrations/samples/default.py +++ b/tests/test_migrations/samples/default.py @@ -1,7 +1,7 @@ #!/bin/env python import click from ellar.app import AppFactory, current_injector -from ellar.common.utils.importer import get_main_directory_by_stack +from ellar.utils.importer import get_main_directory_by_stack from ellar_cli.main import create_ellar_cli from models import User diff --git a/tests/test_migrations/samples/default_async.py b/tests/test_migrations/samples/default_async.py index b0f7b69..015c2c6 100644 --- a/tests/test_migrations/samples/default_async.py +++ b/tests/test_migrations/samples/default_async.py @@ -1,7 +1,7 @@ #!/bin/env python import ellar_cli.click as click from ellar.app import AppFactory, current_injector -from ellar.common.utils.importer import get_main_directory_by_stack +from ellar.utils.importer import get_main_directory_by_stack from ellar_cli.main import create_ellar_cli from models import User from sqlalchemy.ext.asyncio import AsyncSession diff --git a/tests/test_migrations/samples/multiple_database.py b/tests/test_migrations/samples/multiple_database.py index abca603..b6005ba 100644 --- a/tests/test_migrations/samples/multiple_database.py +++ b/tests/test_migrations/samples/multiple_database.py @@ -1,7 +1,7 @@ #!/bin/env python import click from ellar.app import AppFactory, current_injector -from ellar.common.utils.importer import get_main_directory_by_stack +from ellar.utils.importer import get_main_directory_by_stack from ellar_cli.main import create_ellar_cli from models import Group, User diff --git a/tests/test_migrations/samples/multiple_database_async.py b/tests/test_migrations/samples/multiple_database_async.py index 3d019d8..57f1e4a 100644 --- a/tests/test_migrations/samples/multiple_database_async.py +++ b/tests/test_migrations/samples/multiple_database_async.py @@ -1,7 +1,7 @@ #!/bin/env python import ellar_cli.click as click from ellar.app import AppFactory, current_injector -from ellar.common.utils.importer import get_main_directory_by_stack +from ellar.utils.importer import get_main_directory_by_stack from ellar_cli.main import create_ellar_cli from models import Group, User from sqlalchemy.ext.asyncio import AsyncSession diff --git a/tests/test_pagination/seed.py b/tests/test_pagination/seed.py index 115400c..f182549 100644 --- a/tests/test_pagination/seed.py +++ b/tests/test_pagination/seed.py @@ -1,7 +1,7 @@ import typing as t from ellar.app import App -from ellar.threading import execute_coroutine_with_sync_worker +from ellar.threading import run_as_async from ellar_sql import EllarSQLService, model @@ -14,7 +14,8 @@ class User(model.Model): return User -def seed_100_users(app: App): +@run_as_async +async def seed_100_users(app: App): user_model = create_model() db_service = app.injector.get(EllarSQLService) @@ -27,6 +28,6 @@ def seed_100_users(app: App): res = session.commit() if isinstance(res, t.Coroutine): - execute_coroutine_with_sync_worker(res) + await res return user_model diff --git a/tests/test_query/test_utils.py b/tests/test_query/test_utils.py index db65a20..5d047a5 100644 --- a/tests/test_query/test_utils.py +++ b/tests/test_query/test_utils.py @@ -3,12 +3,14 @@ import pytest from ellar.app import App from ellar.common import NotFound -from ellar.threading import execute_coroutine_with_sync_worker +from ellar.threading.sync_worker import execute_coroutine from ellar_sql import ( EllarSQLService, first_or_404, + first_or_none, get_or_404, + get_or_none, model, one_or_404, ) @@ -34,7 +36,7 @@ def _seed_model(app: App): res = session.commit() if isinstance(res, t.Coroutine): - execute_coroutine_with_sync_worker(res) + execute_coroutine(res) return user_model @@ -60,6 +62,16 @@ async def test_get_or_404_async_works(ignore_base, app_ctx_async, anyio_backend) await get_or_404(user_model, 2) +async def test_get_or_none_async_works(ignore_base, app_ctx_async, anyio_backend): + if anyio_backend == "asyncio": + user_model = _seed_model(app_ctx_async) + + user_instance = await get_or_none(user_model, 1) + assert user_instance.name == "First User" + + assert await get_or_none(user_model, 2) is None + + async def test_first_or_404_works(ignore_base, app_ctx, anyio_backend): user_model = _seed_model(app_ctx) @@ -72,6 +84,19 @@ async def test_first_or_404_works(ignore_base, app_ctx, anyio_backend): await first_or_404(model.select(user_model).where(user_model.id == 2)) +async def test_first_or_none_works(ignore_base, app_ctx, anyio_backend): + user_model = _seed_model(app_ctx) + + user_instance = await first_or_none( + model.select(user_model).where(user_model.id == 1) + ) + assert user_instance.name == "First User" + + assert ( + await first_or_none(model.select(user_model).where(user_model.id == 2)) is None + ) + + async def test_first_or_404_async_works(ignore_base, app_ctx_async, anyio_backend): if anyio_backend == "asyncio": user_model = _seed_model(app_ctx_async) diff --git a/tests/utils.py b/tests/utils.py index e69f8ff..80eb2b6 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,7 +5,7 @@ import shutil import subprocess -from ellar.common.utils.importer import get_main_directory_by_stack +from ellar.utils.importer import get_main_directory_by_stack SAMPLE_DUMB_DIRS = get_main_directory_by_stack("__main__/dumbs/", stack_level=1) SAMPLE_DIRS = get_main_directory_by_stack(
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: