diff --git a/Makefile b/Makefile index f0229e2..cd3a058 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ clean: ## Removing cached python compiled files find . -name \*pyo | xargs rm -fv find . -name \*~ | xargs rm -fv find . -name __pycache__ | xargs rm -rfv + find . -name .pytest_cache | xargs rm -rfv find . -name .ruff_cache | xargs rm -rfv install: ## Install dependencies @@ -23,14 +24,14 @@ lint:fmt ## Run code linters mypy ellar_sql fmt format:clean ## Run code formatters - ruff format ellar_sql tests - ruff check --fix ellar_sql tests + ruff format ellar_sql tests examples + ruff check --fix ellar_sql tests examples -test: ## Run tests - pytest tests +test:clean ## Run tests + pytest -test-cov: ## Run tests with coverage - pytest --cov=ellar_sql --cov-report term-missing tests +test-cov:clean ## Run tests with coverage + pytest --cov=ellar_sql --cov-report term-missing pre-commit-lint: ## Runs Requires commands during pre-commit make clean diff --git a/README.md b/README.md index 8cb0659..d9558ba 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,6 @@ [](https://pypi.python.org/pypi/ellar-sql) [](https://pypi.python.org/pypi/ellar-sql) -## Project Status -- [x] Production Ready -- [ ] SQLAlchemy Django Like Query ## Introduction EllarSQL Module adds support for `SQLAlchemy` and `Alembic` package to your Ellar application @@ -20,6 +17,8 @@ EllarSQL Module adds support for `SQLAlchemy` and `Alembic` package to your Ella $(venv) pip install ellar-sql ``` +This library was inspired by [Flask-SQLAlchemy](https://flask-sqlalchemy.palletsprojects.com/en/3.1.x/){target="_blank"} + ## Features - Migration diff --git a/docs/index.md b/docs/index.md index a1239f8..6c90093 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,12 +37,10 @@ EllarSQL comes packed with a set of awesome features designed: ## **Requirements** EllarSQL core dependencies includes: -- Python version >= 3.8 -- Ellar Framework >= 0.6.7 -- SQLAlchemy ORM >= 2.0.16 +- Python >= 3.8 +- Ellar >= 0.6.7 +- SQLAlchemy >= 2.0.16 - Alembic >= 1.10.0 -- Pillow >= 10.1.0 -- Python-Magic >= 0.4.27 ## **Installation** diff --git a/docs/migrations/env.md b/docs/migrations/env.md index 382baec..1ec01b2 100644 --- a/docs/migrations/env.md +++ b/docs/migrations/env.md @@ -3,6 +3,7 @@ In the generated migration template, EllarSQL adopts an async-first approach for handling migration file generation. This approach simplifies the execution of migrations for both `Session`, `Engine`, `AsyncSession`, and `AsyncEngine`, but it also introduces a certain level of complexity. + ```python from logging.config import fileConfig @@ -137,7 +138,7 @@ class MyCustomMigrationEnv(AlembicEnvMigrationBase): """ key, engine = self.db_service.engines.popitem() - metadata = get_metadata(key, certain=True) + metadata = get_metadata(key, certain=True).metadata conf_args = {} conf_args.setdefault( diff --git a/docs/migrations/index.md b/docs/migrations/index.md index 994f51b..397b4ba 100644 --- a/docs/migrations/index.md +++ b/docs/migrations/index.md @@ -1,15 +1,15 @@ # **Migrations** -EllarSQL also extends Alembic package -to add migration functionality to your application and make database operations available through EllarCLI commandline interface. +EllarSQL also extends **Alembic** package +to add migration functionality and make database operations accessible through **EllarCLI** commandline interface. -EllarSQL with Alembic does not override Alembic action rather provide Alembic all the configs/information -it needs to for a proper migration operation in your application. +**EllarSQL** with Alembic does not override Alembic action rather provide Alembic all the configs/information +it needs to for a proper migration/database operations. Its also still possible to use Alembic outside EllarSQL setup when necessary. This section is inspired by [`Flask Migrate`](https://flask-migrate.readthedocs.io/en/latest/#) ## **Quick Example** -We assume you have set up `EllarSQLModule` in your application and you have specified `migration_options`. +We assume you have set up `EllarSQLModule` in your application, and you have specified `migration_options`. Create a simple `User` model as shown below: diff --git a/docs/models/configuration.md b/docs/models/configuration.md index db61a2d..7865821 100644 --- a/docs/models/configuration.md +++ b/docs/models/configuration.md @@ -1,13 +1,12 @@ # **EllarSQLModule Config** +**`EllarSQLModule`** is an Ellar Dynamic Module that offers two ways of configuration: -**`EllarSQLModule`** is a versatile module, allowing configuration through the application `Config`. -This configuration can be done either using `EllarSQLModule.register_setup()` within your application setup or directly -during module registration with `EllarSQLModule.setup()`. +- `EllarSQLModule.register_setup()`: This method registers a `ModuleSetup` that depends on the application config. +- `EllarSQLModule.setup()`: This method immediately sets up the module with the provided options. -While examples have been provided for using `EllarSQLModule.setup()`, this section will shed light -on the usage of `EllarSQLModule.register_setup()`. Before diving into that, let's first explore -the setup options available for `EllarSQLModule`. +While we've explored many examples using `EllarSQLModule.setup()`, this section will focus on the usage of `EllarSQLModule.register_setup()`. +Before delving into that, let's first explore the setup options available for `EllarSQLModule`. ## **EllarSQLModule Configuration Parameters** - **databases**: _typing.Union[str, typing.Dict[str, typing.Any]]_: @@ -128,16 +127,17 @@ For more in-depth information on [dealing with disconnects](https://docs.sqlalchemy.org/core/pooling.html#dealing-with-disconnects){target="_blank"}, refer to SQLAlchemy's documentation on handling connection issues. -## **EllarSQLModule With App Config** -As stated above, **EllarSQLModule** can be configured from application through `EllarSQLModule.register_setup`. -This will register a [ModuleSetup](https://python-ellar.github.io/ellar/basics/dynamic-modules/#modulesetup){target="_blank"} factory that checks for `ELLAR_SQL` in application config. -The value of `ELLAR_SQL` read from the application config will be passed `EllarSQLModule` setup action -which validates and initializes the module. +## **EllarSQLModule RegisterSetup** +As mentioned earlier, **EllarSQLModule** can be configured from the application through `EllarSQLModule.register_setup`. +This process registers a [ModuleSetup](https://python-ellar.github.io/ellar/basics/dynamic-modules/#modulesetup){target="_blank"} factory +that depends on the Application Config object. +The factory retrieves the `ELLAR_SQL` attribute from the config and validates the data before passing it to `EllarSQLModule` for setup. -It's important to note -that `ELLAR_SQL` will be a dictionary object with the above [configuration parameters](#ellarsqlmodule-configuration-parameters) as keys. +It's essential to note +that `ELLAR_SQL` will be a dictionary object with the [configuration parameters](#ellarsqlmodule-configuration-parameters) +mentioned above as keys. -A Quick example: +Here's a quick example: ```python title="db_learning/root_module.py" from ellar.common import Module, exception_handler, IExecutionContext, JSONResponse, Response, IApplicationStartup from ellar.app import App @@ -180,8 +180,10 @@ class DevelopmentConfig(BaseConfig): 'models': [] } ``` -We have added config for **EllarSQLModule** through `ELLAR_SQL`. And from there, the rest of the actions -will be the same as to normal registration with `EllarSQLModule.setup()`. +The registered ModuleSetup factory reads the `ELLAR_SQL` value and configures the `EllarSQLModule` appropriately. -But with this approach, -we can seamlessly change **EllarSQLModule** configuration in any environment like CI, Development, Staging or Production. +This approach is particularly useful when dealing with multiple environments. +It allows for seamless modification of the **ELLAR_SQL** values in various environments such as +Continuous Integration (CI), Development, Staging, or Production. +You can easily change the settings for each environment +and export the configurations as a string to be imported into `ELLAR_CONFIG_MODULE`. diff --git a/docs/models/index.md b/docs/models/index.md index d128d84..960b64f 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -1,13 +1,13 @@ # **Quick Start** -In this section, -we shall go over how to set up **EllarSQL** in your ellar application -and have all necessary service registered and configuration set and ready to use. +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 proceed, we assume you have a clear understanding -of how [Ellar Modules](https://python-ellar.github.io/ellar/basics/dynamic-modules/#module-dynamic-setup){target="_blank"} work. +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's install all necessary packages. Also assuming your python environment has been configured: +Let us install all the required packages, assuming that your Python environment has been properly configured: #### **For Existing Project:** ```shell @@ -99,10 +99,10 @@ class UsersController(ecm.ControllerBase): ``` ## **EllarSQLModule Setup** -In `root_module.py`, we need to do two things: +In the `root_module.py` file, two main tasks need to be performed: -- Register `UsersController` to have `/users` available when we start the application -- configure `EllarSQLModule` to configure and register all necessary services such as `EllarSQLService`, `Session` and `Engine` +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 @@ -111,7 +111,6 @@ from ellar.core import ModuleBase from ellar_sql import EllarSQLModule, EllarSQLService from .controller import UsersController - @Module( modules=[EllarSQLModule.setup( databases={ @@ -133,19 +132,19 @@ class ApplicationModule(ModuleBase, IApplicationStartup): def exception_404_handler(cls, ctx: IExecutionContext, exc: Exception) -> Response: return JSONResponse(dict(detail="Resource not found."), status_code=404) ``` -In the above illustration, -we registered `UserController` and `EllarSQLModule` with some configurations on database and migration options. -See more on `EllarSQLModule` configurations. -Also, on the `on_startup` function, we retrieved `EllarSQLService` registered into the system through `EllarSQLModule`, -and call the `create_all()` function to create the SQLAlchemy tables. +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. -At this point, we are ready to test the application. +With these configurations, the application is now ready for testing. ```shell ellar runserver --reload ``` -Also, -remember to uncomment the `OpenAPIModule` configurations -in `server.py` to be able to visualize and interact with the `/users` the endpoint. +Additionally, please remember to uncomment the configurations for the `OpenAPIModule` in the `server.py` +file to enable visualization and interaction with the `/users` endpoint. -With that said, visit [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs){target="_blank"} +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"}. diff --git a/docs/models/models.md b/docs/models/models.md index 230d734..f5edea2 100644 --- a/docs/models/models.md +++ b/docs/models/models.md @@ -1,32 +1,35 @@ # **Models and Tables** -`Model` from `ellar_sql.model.Model` package is a factory class for creating `SQLAlchemy` model. -It also manages a model database key and associates it to its Metadata and engine. +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. -`Model` can be by defining `__base_config__` at the class level. This is necessary for a -case where we want to make a `Base` class that will be inherited through the application or change the declarative type -such as [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"} -`Model` configuration parameters: +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`. -- **as_base**: Indicates if the class should be treated as a Base class for other model definitions. `Default: True` -- **use_base**: Indicates base classes that will be used to create a model base. `Default=[]` +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: [])* -## **Base Class** -`Model` treats each model as a standalone model. This means each `model.Model` has a separate **declarative** base created for it -and the `__database__` key is used to determine its Metadata reference. +## **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`. -Let's create a class with **DeclarativeBase** ```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 desired, you can enable [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"} -by adding MappedAsDataclass as an additional parent class. + +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 @@ -34,12 +37,19 @@ 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) ``` -Optionally, you have the flexibility to construct the SQLAlchemy object with a custom [`MetaData`](https://docs.sqlalchemy.org/en/20/core/metadata.html#sqlalchemy.schema.MetaData){target="_blank"} object. -This customization enables you to define a specific **naming convention** for constraints. -This becomes particularly valuable as it ensures consistency and predictability in constraint names. -This predictability proves especially beneficial when utilizing migrations, as detailed by [Alembic](https://alembic.sqlalchemy.org/en/latest/naming.html){target="_blank"}. +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 @@ -55,10 +65,12 @@ class Base(model.Model): "pk": "pk_%(table_name)s" }) ``` + ## **Abstract Models and Mixins** -If the desired behavior is applicable only to specific models rather than all models, consider using an abstract model base class to customize only those models. -For instance, if certain models need to track their creation or update timestamps, -this approach allows for targeted customization. +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 @@ -98,9 +110,8 @@ class Book(model.Model, TimestampModel): ``` ## **Defining Models** -Unlike plain SQLAlchemy, EllarSQL model will automatically generate a table name -if `__tablename__` is not set and a primary key column is defined. -This can be **disabled** by setting a value for `__tablename__` or defining `__tablename__` as a **declarative_attr** +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 @@ -125,12 +136,12 @@ For a comprehensive guide on defining model classes declaratively, refer to 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. +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 creation or -selection of metadata based on the `__database__` argument. +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. +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. @@ -145,14 +156,13 @@ author_book_m2m = model.Table( ``` ## **Quick Tutorial** -In this section, we'll delve into straightforward CRUD operations using the ORM objects. +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. -### **Create a Model** -Having understood, `Model` usage. Let's create a User model +Having understood, `Model` usage. Let's create a `User` model ```python from ellar_sql import model @@ -264,6 +274,7 @@ In the process of `EllarSQLModule` setup, three services are registered to the E - `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 diff --git a/docs/multiple/index.md b/docs/multiple/index.md index 1a63ca6..3235689 100644 --- a/docs/multiple/index.md +++ b/docs/multiple/index.md @@ -2,12 +2,12 @@ SQLAlchemy has the capability to establish connections with multiple databases simultaneously, referring to these connections as "binds." -EllarSQL simplifies the management of binds by associating each engine with a short string identifier, "__database__." -Subsequently, each model and table is linked to a "__database__," and during a query, -the session selects the appropriate engine based on the "__database__" of the entity being queried. -In the absence of a specified "__database__," the default engine is employed. +EllarSQL simplifies the management of binds by associating each engine with a short string identifier, `__database__`. +Subsequently, each model and table is linked to a `__database__`, and during a query, +the session selects the appropriate engine based on the `__database__` of the entity being queried. +In the absence of a specified `__database__`, the default engine is employed. -## Configuring Multiple Databases +## **Configuring Multiple Databases** In EllarSQL, database configuration begins with the setup of the default database, followed by additional databases, as exemplified in the `EllarSQLModule` configurations: @@ -28,16 +28,16 @@ EllarSQLModule.setup( ) ``` -## Defining Models and Tables with Different Databases +## **Defining Models and Tables with Different Databases** -EllarSQL creates metadata and an engine for each configured database. -Models and tables associated with a specific database key are registered with the corresponding metadata. -During a session query, the session employs the related engine. +**EllarSQL** creates **Metadata** and an **Engine** for each configured database. +Models and tables associated with a specific `__database__` key are registered with the corresponding **Metadata**. +During a session query, the session employs the related `Engine`. -To designate the database for a model, set the "__database__" class attribute. -Not specifying a database key is equivalent to setting it to `default`: +To designate the database for a model, set the `__database__` class attribute. +Not specifying a `__database__` key is equivalent to setting it to `default`: -### In Models +### **In Models** ```python from ellar_sql import model @@ -51,7 +51,7 @@ Models inheriting from an already existing model will share the same `database` !!!info Its importance to not that `model.Model` has `__database__` value equals `default` -### In Tables +### **In Tables** To specify the database for a table, utilize the `__database__` keyword argument: ```python @@ -68,7 +68,7 @@ user_table = model.Table( Ultimately, the session references the database key associated with the metadata or table, an association established during creation. Consequently, changing the **database** key after creating a model or table has **no effect**. -## Creating and Dropping Tables +## **Creating and Dropping Tables** The `create_all()` and `drop_all()` methods operating are all part of the `EllarSQLService`. It also requires the `database` argument to target a specific database. diff --git a/docs/pagination/index.md b/docs/pagination/index.md index 0e43e91..2479666 100644 --- a/docs/pagination/index.md +++ b/docs/pagination/index.md @@ -1,6 +1,5 @@ # **Pagination** - Pagination is a common practice for large datasets, enhancing user experience by breaking content into manageable pages. It optimizes load times and navigation and allows users to explore extensive datasets with ease @@ -49,9 +48,9 @@ from .models import User class UserSchema(ec.Serializer): - id: str - name: str - fullname: str + id: int + username: str + email: str @ec.get('/users') diff --git a/docs/testing/index.md b/docs/testing/index.md new file mode 100644 index 0000000..ae470db --- /dev/null +++ b/docs/testing/index.md @@ -0,0 +1,311 @@ +# **Testing EllarSQL Models** +There are various approaches to testing SQLAlchemy models, but in this section, we will focus on setting +up a good testing environment for EllarSQL models using the +Ellar [Test](https://python-ellar.github.io/ellar/basics/testing/){target="_blank"} factory and pytest. + +For an effective testing environment, it is recommended to utilize the `EllarSQLModule.register_setup()` +approach to set up the **EllarSQLModule**. This allows you to add a new configuration for `ELLAR_SQL` +specific to your testing database, preventing interference with production or any other databases in use. + +### **Defining TestConfig** +There are various methods for configuring test settings in Ellar, +as outlined +[here](https://python-ellar.github.io/ellar/basics/testing/#overriding-application-conf-during-testing){target="_blank"}. +However, in this section, we will adopt the 'in a file' approach. + +Within the `db_learning/config.py` file, include the following code: + +```python title="db_learning/config.py" +import typing as t +... + +class DevelopmentConfig(BaseConfig): + DEBUG: bool = True + # Configuration through Config + ELLAR_SQL: t.Dict[str, t.Any] = { + 'databases': { + 'default': 'sqlite:///project.db', + }, + 'echo': True, + 'migration_options': { + 'directory': 'migrations' + }, + 'models': ['models'] + } + +class TestConfig(BaseConfig): + DEBUG = False + + ELLAR_SQL: t.Dict[str, t.Any] = { + **DevelopmentConfig.ELLAR_SQL, + 'databases': { + 'default': 'sqlite:///test.db', + }, + 'echo': False, + } +``` + +This snippet demonstrates the 'in a file' approach to setting up the `TestConfig` class within the same `db_learning/config.py` file. + +#### **Changes made:** +1. Updated the `databases` section to use `sqlite+aiosqlite:///test.db` for the testing database. +2. Set `echo` to `True` to enable SQLAlchemy output during testing for cleaner logs. +3. Preserved the `migration_options` and `models` configurations from `DevelopmentConfig`. + +Also, feel free to further adjust it based on your specific testing requirements! + +## **Test Fixtures** +After defining `TestConfig`, we need to add some pytest fixtures to set up **EllarSQLModule** and another one +that returns a `session` for testing purposes. Additionally, we need to export `ELLAR_CONFIG_MODULE` +to point to the newly defined **TestConfig**. + +```python title="tests/conftest.py" +import os +import pytest +from ellar.common.constants import ELLAR_CONFIG_MODULE +from ellar.testing import Test +from ellar_sql import EllarSQLService +from db_learning.root_module import ApplicationModule + +# Setting the ELLAR_CONFIG_MODULE environment variable to TestConfig +os.environ.setdefault(ELLAR_CONFIG_MODULE, "db_learning.config:TestConfig") + +# Fixture for creating a test module +@pytest.fixture(scope='session') +def tm(): + test_module = Test.create_test_module(modules=[ApplicationModule]) + yield test_module + +# Fixture for creating a database session for testing +@pytest.fixture(scope='session') +def db(tm): + db_service = tm.get(EllarSQLService) + + # Creating all tables + db_service.create_all() + + yield + + # Dropping all tables after the tests + db_service.drop_all() + +# Fixture for creating a database session for testing +@pytest.fixture(scope='session') +def db_session(db, tm): + db_service = tm.get(EllarSQLService) + + yield db_service.session_factory() + + # Removing the session factory + db_service.session_factory.remove() +``` + +The provided fixtures help in setting up a testing environment for EllarSQL models. +The `Test.create_test_module` method creates a **TestModule** for initializing your Ellar application, +and the `db_session` fixture initializes a database session for testing, creating and dropping tables as needed. + +If you are working with asynchronous database drivers, you can convert `db_session` +into an async function to handle coroutines seamlessly. + +## **Alembic Migration with Test Fixture** +In cases where there are already generated database migration files, and there is a need to apply migrations during testing, this can be achieved as shown in the example below: + +```python title="tests/conftest.py" +import os +import pytest +from ellar.common.constants import ELLAR_CONFIG_MODULE +from ellar.testing import Test +from ellar_sql import EllarSQLService +from ellar_sql.cli.handlers import CLICommandHandlers +from db_learning.root_module import ApplicationModule + +# Setting the ELLAR_CONFIG_MODULE environment variable to TestConfig +os.environ.setdefault(ELLAR_CONFIG_MODULE, "db_learning.config:TestConfig") + +# Fixture for creating a test module +@pytest.fixture(scope='session') +def tm(): + test_module = Test.create_test_module(modules=[ApplicationModule]) + yield test_module + + +# Fixture for creating a database session for testing +@pytest.fixture(scope='session') +async def db(tm): + db_service = tm.get(EllarSQLService) + + # Applying migrations using Alembic + async with tm.create_application().application_context(): + cli = CLICommandHandlers(db_service) + cli.migrate() + + yield + + # Downgrading migrations after testing + async with tm.create_application().application_context(): + cli = CLICommandHandlers(db_service) + cli.downgrade() + +# Fixture for creating an asynchronous database session for testing +@pytest.fixture(scope='session') +async def db_session(db, tm): + db_service = tm.get(EllarSQLService) + + yield db_service.session_factory() + + # Removing the session factory + db_service.session_factory.remove() +``` + +The `CLICommandHandlers` class wraps all `Alembic` functions executed through the Ellar command-line interface. +It can be used in conjunction with the application context to initialize all model tables during testing as shown in the illustration above. +`db_session` pytest fixture also ensures that migrations are applied and then downgraded after testing, +maintaining a clean and consistent test database state. + +## **Testing a Model** +After setting up the testing database and creating a session, let's test the insertion of a user model into the database. + +In `db_learning/models.py`, we have a user model: + +```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) +``` + +Now, create a file named `test_user_model.py`: + +```python title="tests/test_user_model.py" +import pytest +import sqlalchemy.exc as sa_exc +from db_learning.models import User + +def test_username_must_be_unique(db_session): + # Creating and adding the first user + user1 = User(username='ellarSQL', email='ellarsql@gmail.com') + db_session.add(user1) + db_session.commit() + + # Attempting to add a second user with the same username + user2 = User(username='ellarSQL', email='ellarsql2@gmail.com') + db_session.add(user2) + + # Expecting an IntegrityError due to unique constraint violation + with pytest.raises(sa_exc.IntegrityError): + db_session.commit() +``` + +In this test, we are checking whether the unique constraint on the `username` +field is enforced by attempting to insert two users with the same username. +The test expects an `IntegrityError` to be raised, indicating a violation of the unique constraint. +This ensures that the model behaves correctly and enforces the specified uniqueness requirement. + +## **Testing Factory Boy** +[factory-boy](https://pypi.org/project/factory-boy/){target="_blank"} provides a convenient and flexible way to create mock objects, supporting various ORMs like Django, MongoDB, and SQLAlchemy. EllarSQL extends `factory.alchemy.SQLAlchemy` to offer a Model factory solution compatible with both synchronous and asynchronous database drivers. + +To get started, you need to install `factory-boy`: + +```shell +pip install factory-boy +``` + +Now, let's create a factory for our user model in `tests/factories.py`: + +```python title="tests/factories.py" +import factory +from ellar_sql.factory import EllarSQLFactory, SESSION_PERSISTENCE_FLUSH +from db_learning.models import User +from . import common + +class UserFactory(EllarSQLFactory): + class Meta: + model = User + sqlalchemy_session_persistence = SESSION_PERSISTENCE_FLUSH + sqlalchemy_session_factory = lambda: common.Session() + + username = factory.Faker('username') + email = factory.Faker('email') +``` + +The `UserFactory` depends on a database session. Since the pytest fixture we created applies to it, +we also need a session factory in `tests/common.py`: + +```python title="tests/common.py" +from sqlalchemy import orm + +Session = orm.scoped_session(orm.sessionmaker()) +``` + +Additionally, we require a fixture responsible for configuring the Factory session in `tests/conftest.py`: + +```python title="tests/conftest.py" +import os +import pytest +import sqlalchemy as sa +from ellar.common.constants import ELLAR_CONFIG_MODULE +from ellar.testing import Test +from ellar_sql import EllarSQLService +from db_learning.root_module import ApplicationModule +from . import common + +os.environ.setdefault(ELLAR_CONFIG_MODULE, "db_learning.config:TestConfig") + +@pytest.fixture(scope='session') +def tm(): + test_module = Test.create_test_module(modules=[ApplicationModule]) + yield test_module + +# Fixture for creating a database session for testing +@pytest.fixture(scope='session') +def db(tm): + db_service = tm.get(EllarSQLService) + + # Creating all tables + db_service.create_all() + + yield + + # Dropping all tables after the tests + db_service.drop_all() + +# Fixture for creating a database session for testing +@pytest.fixture(scope='session') +def db_session(db, tm): + db_service = tm.get(EllarSQLService) + + yield db_service.session_factory() + + # Removing the session factory + db_service.session_factory.remove() + +@pytest.fixture +def factory_session(db, tm): + engine = tm.get(sa.Engine) + common.Session.configure(bind=engine) + yield + common.Session.remove() +``` + +In the `factory_session` fixture, we retrieve the `Engine` registered in the DI container by **EllarSQLModule**. +Using this engine, we configure the common `Session`. It's important to note that if you are using an +async database driver, **EllarSQLModule** will register `AsyncEngine`. + +With this setup, we can rewrite our `test_username_must_be_unique` test using `UserFactory` and `factory_session`: + +```python title="tests/test_user_model.py" +import pytest +import sqlalchemy.exc as sa_exc +from .factories import UserFactory + +def test_username_must_be_unique(factory_session): + user1 = UserFactory() + with pytest.raises(sa_exc.IntegrityError): + UserFactory(username=user1.username) +``` + +This test yields the same result as before. +Refer to the [factory-boy documentation](https://factoryboy.readthedocs.io/en/stable/orms.html#sqlalchemy) +for more features and tutorials. diff --git a/ellar_sql/exceptions.py b/ellar_sql/exceptions.py deleted file mode 100644 index 953e705..0000000 --- a/ellar_sql/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -# class NoMatch(Exception): -# pass -# -# -# class MultipleMatches(Exception): -# pass diff --git a/ellar_sql/factory/__init__.py b/ellar_sql/factory/__init__.py new file mode 100644 index 0000000..fed30b6 --- /dev/null +++ b/ellar_sql/factory/__init__.py @@ -0,0 +1,16 @@ +try: + import factory +except ImportError as im_ex: # pragma: no cover + raise RuntimeError( + "factory-boy not found. Please run `pip install factory-boy`" + ) from im_ex + +from factory.alchemy import SESSION_PERSISTENCE_COMMIT, SESSION_PERSISTENCE_FLUSH + +from .base import EllarSQLFactory + +__all__ = [ + "EllarSQLFactory", + "SESSION_PERSISTENCE_COMMIT", + "SESSION_PERSISTENCE_FLUSH", +] diff --git a/ellar_sql/factory/base.py b/ellar_sql/factory/base.py new file mode 100644 index 0000000..e34adde --- /dev/null +++ b/ellar_sql/factory/base.py @@ -0,0 +1,114 @@ +import typing as t + +import sqlalchemy as sa +import sqlalchemy.orm as sa_orm +from ellar.threading import execute_coroutine_with_sync_worker +from factory.alchemy import ( + SESSION_PERSISTENCE_COMMIT, + SESSION_PERSISTENCE_FLUSH, + SQLAlchemyModelFactory, + SQLAlchemyOptions, +) +from factory.errors import FactoryError +from sqlalchemy.exc import IntegrityError, NoResultFound +from sqlalchemy.ext.asyncio import AsyncSession + +from ellar_sql.model.base import ModelBase + +T = t.TypeVar("T", bound=ModelBase) + + +class EllarSQLOptions(SQLAlchemyOptions): + @staticmethod + def _check_has_sqlalchemy_session_set(meta, value): + if value and hasattr(meta, "sqlalchemy_session"): + raise RuntimeError( + "Provide either a sqlalchemy_session or a sqlalchemy_session_factory, not both" + ) + + +class EllarSQLFactory(SQLAlchemyModelFactory): + """Factory for EllarSQL models.""" + + _options_class = EllarSQLOptions + + class Meta: + abstract = True + + @classmethod + 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) + return res + + @classmethod + def _get_or_create( + cls, + model_class: t.Type[T], + session: t.Union[sa_orm.Session, AsyncSession], + args: t.Tuple[t.Any], + kwargs: t.Dict[str, t.Any], + ): + key_fields = {} + for field in cls._meta.sqlalchemy_get_or_create: + if field not in kwargs: + raise FactoryError( + "sqlalchemy_get_or_create - " + "Unable to find initialization value for '%s' in factory %s" + % (field, cls.__name__) + ) + key_fields[field] = kwargs.pop(field) + stmt = sa.select(model_class).filter_by(*args, **key_fields) # type:ignore[call-arg] + + res = cls._session_execute(session.execute, stmt) + obj = res.scalar() + + if not obj: + try: + obj = cls._save(model_class, session, args, {**key_fields, **kwargs}) + except IntegrityError as e: + cls._session_execute(session.rollback) + + if cls._original_params is None: + raise e + + get_or_create_params = { + lookup: value + for lookup, value in cls._original_params.items() + if lookup in cls._meta.sqlalchemy_get_or_create + } + if get_or_create_params: + try: + stmt = sa.select(model_class).filter_by(**get_or_create_params) + res = cls._session_execute(session.execute, stmt) + obj = res.scalar_one() + except NoResultFound: + # Original params are not a valid lookup and triggered a create(), + # that resulted in an IntegrityError. + raise e from None + else: + raise e + + return obj + + @classmethod + def _save( + cls, + model_class: t.Type[T], + session: t.Union[sa_orm.Session, AsyncSession], + args: t.Tuple[t.Any], + kwargs: t.Dict[str, t.Any], + ) -> T: + session_persistence = cls._meta.sqlalchemy_session_persistence + + obj = model_class(*args, **kwargs) # type:ignore[call-arg] + session.add(obj) + if session_persistence == SESSION_PERSISTENCE_FLUSH: + cls._session_execute(session.flush) + elif session_persistence == SESSION_PERSISTENCE_COMMIT: + cls._session_execute(session.commit) + cls._session_execute(session.refresh, obj) + return obj diff --git a/ellar_sql/migrations/multiple.py b/ellar_sql/migrations/multiple.py index b7e8316..2f396cf 100644 --- a/ellar_sql/migrations/multiple.py +++ b/ellar_sql/migrations/multiple.py @@ -104,7 +104,7 @@ def run_migrations_offline( logger.info("Migrating database %s" % key) url = str(engine.url).replace("%", "%%") - metadata = get_metadata(key, certain=True) + metadata = get_metadata(key, certain=True).metadata file_ = "%s.sql" % key logger.info("Writing output to %s" % file_) @@ -170,7 +170,7 @@ async def _compute_engine_info(self) -> t.List[DatabaseInfo]: res = [] for key, engine in self.db_service.engines.items(): - metadata = get_metadata(key, certain=True) + metadata = get_metadata(key, certain=True).metadata if engine.dialect.is_async: async_engine = AsyncEngine(engine) diff --git a/ellar_sql/migrations/single.py b/ellar_sql/migrations/single.py index e264215..4b87148 100644 --- a/ellar_sql/migrations/single.py +++ b/ellar_sql/migrations/single.py @@ -53,7 +53,7 @@ def run_migrations_offline( """ key, engine = self.db_service.engines.popitem() - metadata = get_metadata(key, certain=True) + metadata = get_metadata(key, certain=True).metadata conf_args = self.get_user_context_configurations() @@ -95,7 +95,7 @@ async def run_migrations_online(self, context: "EnvironmentContext") -> None: """ key, engine = self.db_service.engines.popitem() - metadata = get_metadata(key, certain=True) + metadata = get_metadata(key, certain=True).metadata migration_action_partial = functools.partial( self._migration_action, metadata=metadata, context=context diff --git a/ellar_sql/model/base.py b/ellar_sql/model/base.py index c34a2c1..4bb5d9f 100644 --- a/ellar_sql/model/base.py +++ b/ellar_sql/model/base.py @@ -26,7 +26,9 @@ def _update_metadata(namespace: t.Dict[str, t.Any]) -> None: if not has_metadata(database_key): # verify the key exist and store the metadata metadata.info[DATABASE_BIND_KEY] = database_key - update_database_metadata(database_key, metadata) + update_database_metadata( + database_key, metadata, sa_orm.registry(metadata=metadata) + ) # if we have saved metadata then, its save to remove it and allow # DatabaseBindKeyMixin to set it back when the class if fully created. namespace.pop("metadata") @@ -107,10 +109,12 @@ def __new__( lambda ns: ns.update(namespace), ) + # model = t.cast(t.Type[sa_orm.DeclarativeBase], model) + if not has_metadata(DEFAULT_KEY): # Use the model's metadata as the default metadata. model.metadata.info[DATABASE_BIND_KEY] = DEFAULT_KEY - update_database_metadata(DEFAULT_KEY, model.metadata) + update_database_metadata(DEFAULT_KEY, model.metadata, model.registry) elif not has_metadata(model.metadata.info.get(DATABASE_BIND_KEY)): # Use the passed in default metadata as the model's metadata. model.metadata = get_metadata(DEFAULT_KEY, certain=True) @@ -118,18 +122,17 @@ def __new__( return model # _update_metadata(namespace) + __base_config__ = ModelBaseConfig(use_bases=options.use_bases, as_base=True) base = ModelMeta( "ModelBase", bases, - { - "__base_config__": ModelBaseConfig( - use_bases=options.use_bases, as_base=True - ) - }, + {"__base_config__": __base_config__}, ) - return types.new_class(name, (base,), {}, lambda ns: ns.update(namespace)) + return types.new_class( + name, (base,), {"options": options}, lambda ns: ns.update(namespace) + ) class ModelBase(ModelDataExportMixin): @@ -157,9 +160,6 @@ class ModelBase(ModelDataExportMixin): def __init__(self, **kwargs: t.Any) -> None: ... - def dict(self, exclude: t.Optional[t.Set[str]] = None) -> t.Dict[str, t.Any]: - ... - def _sa_inspect_type(self) -> sa.Mapper["ModelBase"]: ... diff --git a/ellar_sql/model/database_binds.py b/ellar_sql/model/database_binds.py index 00cf795..02d6b63 100644 --- a/ellar_sql/model/database_binds.py +++ b/ellar_sql/model/database_binds.py @@ -1,27 +1,40 @@ import typing as t import sqlalchemy as sa - -__model_database_metadata__: t.Dict[str, sa.MetaData] = {} +import sqlalchemy.orm as sa_orm from ellar_sql.constant import DEFAULT_KEY -def update_database_metadata(database_key: str, value: sa.MetaData) -> None: +class DatabaseMetadata(t.NamedTuple): + metadata: sa.MetaData + registry: sa_orm.registry + + +__model_database_metadata__: t.Dict[str, DatabaseMetadata] = {} + + +def update_database_metadata( + database_key: str, value: sa.MetaData, registry: sa_orm.registry +) -> None: """ Update a metadata based on a database key """ - __model_database_metadata__[database_key] = value + __model_database_metadata__[database_key] = DatabaseMetadata( + metadata=value, registry=registry + ) -def get_all_metadata() -> t.Dict[str, sa.MetaData]: +def get_all_metadata() -> t.Dict[str, DatabaseMetadata]: """ Get all metadata available in your application """ return __model_database_metadata__.copy() -def get_metadata(database_key: str = DEFAULT_KEY, certain: bool = False) -> sa.MetaData: +def get_metadata( + database_key: str = DEFAULT_KEY, certain: bool = False +) -> DatabaseMetadata: """ Gets Metadata associated with a database key """ diff --git a/ellar_sql/model/mixins.py b/ellar_sql/model/mixins.py index 67866d9..158c010 100644 --- a/ellar_sql/model/mixins.py +++ b/ellar_sql/model/mixins.py @@ -1,10 +1,23 @@ import typing as t 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 ( + camel_to_snake_case, + make_metadata, + should_set_table_name, +) +from ellar_sql.schemas import ModelBaseConfig, ModelMetaStore -from .utils import camel_to_snake_case, make_metadata, should_set_table_name + +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 @@ -30,7 +43,6 @@ def __init_subclass__(cls, **kwargs: t.Dict[str, t.Any]) -> None: class DatabaseBindKeyMixin: metadata: sa.MetaData - __dnd__ = "Ellar" def __init_subclass__(cls, **kwargs: t.Dict[str, t.Any]) -> None: if not ("metadata" in cls.__dict__ or TABLE_KEY in cls.__dict__) and hasattr( @@ -38,25 +50,41 @@ def __init_subclass__(cls, **kwargs: t.Dict[str, t.Any]) -> None: ): database_bind_key = getattr(cls, DATABASE_KEY, DEFAULT_KEY) parent_metadata = getattr(cls, "metadata", None) - metadata = make_metadata(database_bind_key) + db_metadata = make_metadata(database_bind_key) - if metadata is not parent_metadata: - cls.metadata = metadata + if db_metadata.metadata is not parent_metadata: + cls.metadata = db_metadata.metadata + cls.registry = db_metadata.registry # type:ignore[attr-defined] super().__init_subclass__(**kwargs) class ModelTrackMixin: metadata: sa.MetaData + __mms__: ModelMetaStore + __table__: sa.Table def __init_subclass__(cls, **kwargs: t.Dict[str, t.Any]) -> None: + options: ModelBaseConfig = kwargs.pop( # type:ignore[assignment] + "options", + ModelBaseConfig(as_base=False, use_bases=[sa_orm.DeclarativeBase]), + ) + super().__init_subclass__(**kwargs) if TABLE_KEY in cls.__dict__ and ABSTRACT_KEY not in cls.__dict__: __ellar_sqlalchemy_models__[str(cls)] = cls # type:ignore[assignment] + cls.__mms__ = ModelMetaStore( + base_config=options, + pk_column=None, + columns=list(cls.__table__.columns), # type:ignore[arg-type] + ) + class ModelDataExportMixin: + __mms__: t.Optional[ModelMetaStore] = None + def __repr__(self) -> str: state = sa.inspect(self) assert state is not None @@ -70,13 +98,52 @@ def __repr__(self) -> str: return f"<{type(self).__name__} {pk}>" - def dict(self, exclude: t.Optional[t.Set[str]] = None) -> t.Dict[str, t.Any]: + def _calculate_keys( + self, + data: t.Dict[str, t.Any], + include: t.Optional[t.Set[str]], + exclude: t.Optional[t.Set[str]], + ) -> t.Set[str]: + keys: t.Set[str] = set(data.keys()) + + if include is None and exclude is None: + return keys + + if include is not None: + keys &= include + + if exclude: + keys -= exclude + + return keys + + def _iter( + self, + include: t.Optional[t.Set[str]], + exclude: t.Optional[t.Set[str]], + exclude_none: bool = False, + ) -> t.Generator[t.Tuple[str, t.Any], None, None]: + data = dict(self.__dict__) + + if len(data.keys()) != len(self.__mms__.columns): + data = {c.key: getattr(self, c.key, None) for c in self.__mms__.columns} + + allowed_keys = self._calculate_keys(include=include, exclude=exclude, data=data) + + for field_key, v in data.items(): + if (allowed_keys is not None and field_key not in allowed_keys) or ( + exclude_none and v is None + ): + continue + yield field_key, v + + def dict( + self, + include: t.Optional[t.Set[str]] = None, + 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 - _exclude: t.Set[str] = set() if not exclude else exclude - - tuple_generator = ( - (k, v) - for k, v in self.__dict__.items() - if k not in _exclude and not k.startswith("_sa") + return dict( + self._iter(include=include, exclude_none=exclude_none, exclude=exclude) ) - return dict(tuple_generator) diff --git a/ellar_sql/model/table.py b/ellar_sql/model/table.py index 5e0dd50..3f816ca 100644 --- a/ellar_sql/model/table.py +++ b/ellar_sql/model/table.py @@ -59,5 +59,7 @@ def __new__( if not args or (len(args) >= 2 and isinstance(args[1], sa.MetaData)): return super().__new__(cls, *args, **kwargs) - metadata = make_metadata(__database__ or DEFAULT_KEY) - return super().__new__(cls, *[args[0], metadata, *args[1:]], **kwargs) + db_metadata = make_metadata(__database__ or DEFAULT_KEY) + return super().__new__( + cls, *[args[0], db_metadata.metadata, *args[1:]], **kwargs + ) diff --git a/ellar_sql/model/utils.py b/ellar_sql/model/utils.py index 52e66e8..dbb836c 100644 --- a/ellar_sql/model/utils.py +++ b/ellar_sql/model/utils.py @@ -5,16 +5,21 @@ from ellar_sql.constant import DATABASE_BIND_KEY, DEFAULT_KEY, NAMING_CONVERSION -from .database_binds import get_metadata, has_metadata, update_database_metadata +from .database_binds import ( + DatabaseMetadata, + get_metadata, + has_metadata, + update_database_metadata, +) -def make_metadata(database_key: str) -> sa.MetaData: +def make_metadata(database_key: str) -> DatabaseMetadata: if has_metadata(database_key): return get_metadata(database_key, certain=True) if database_key != DEFAULT_KEY: # Copy the naming convention from the default metadata. - naming_convention = make_metadata(DEFAULT_KEY).naming_convention + naming_convention = make_metadata(DEFAULT_KEY).metadata.naming_convention else: naming_convention = NAMING_CONVERSION @@ -22,8 +27,10 @@ def make_metadata(database_key: str) -> sa.MetaData: metadata = sa.MetaData( naming_convention=naming_convention, info={DATABASE_BIND_KEY: database_key} ) - update_database_metadata(database_key, metadata) - return metadata + update_database_metadata( + database_key, metadata, registry=sa_orm.registry(metadata=metadata) + ) + return get_metadata(database_key, certain=True) def camel_to_snake_case(name: str) -> str: diff --git a/ellar_sql/module.py b/ellar_sql/module.py index e78c3ec..7fc8445 100644 --- a/ellar_sql/module.py +++ b/ellar_sql/module.py @@ -2,11 +2,11 @@ import typing as t import sqlalchemy as sa -from ellar.app import current_injector -from ellar.common import IApplicationShutdown, IModuleSetup, Module +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 sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -27,11 +27,29 @@ def _raise_exception(): @Module(commands=[DBCommands]) -class EllarSQLModule(ModuleBase, IModuleSetup, IApplicationShutdown): - async def on_shutdown(self) -> None: - db_service = current_injector.get(EllarSQLService) - res = db_service.session_factory.remove() +class EllarSQLModule(ModuleBase, IModuleSetup): + @middleware() + async def session_middleware( + cls, context: IExecutionContext, call_next: t.Callable[..., t.Coroutine] + ): + connection = context.switch_to_http_connection().get_client() + + db_session = connection.service_provider.get(EllarSQLService) + session = db_session.session_factory() + + connection.state.session = session + + try: + await call_next() + except Exception as ex: + res = session.rollback() + if isinstance(res, t.Coroutine): + await res + raise ex + @classmethod + async def _on_application_tear_down(cls, db_service: EllarSQLService) -> None: + res = db_service.session_factory.remove() if isinstance(res, t.Coroutine): await res @@ -137,6 +155,10 @@ def __setup_module(cls, sql_alchemy_config: SQLAlchemyConfig) -> DynamicModule: ) providers.append(ProviderConfig(EllarSQLService, use_value=db_service)) + app_context_teardown_events.connect( + functools.partial(cls._on_application_tear_down, db_service=db_service) + ) + return DynamicModule( cls, providers=providers, @@ -176,7 +198,7 @@ def __register_setup_factory( schema.migration_options.directory = get_main_directory_by_stack( schema.migration_options.directory, - stack_level=2, + stack_level=0, from_dir=defined_config["root_path"], ) diff --git a/ellar_sql/pagination/decorator.py b/ellar_sql/pagination/decorator.py index 97f85c5..fb06ab0 100644 --- a/ellar_sql/pagination/decorator.py +++ b/ellar_sql/pagination/decorator.py @@ -16,7 +16,7 @@ def paginate( pagination_class: t.Optional[t.Type[PaginationBase]] = None, - model: t.Optional[t.Type[ModelBase]] = None, + model: t.Optional[t.Union[t.Type[ModelBase], sa.sql.Select[t.Any]]] = None, as_template_context: bool = False, item_schema: t.Optional[t.Type[BaseModel]] = None, **paginator_options: t.Any, diff --git a/ellar_sql/pagination/view.py b/ellar_sql/pagination/view.py index d718212..3dbf250 100644 --- a/ellar_sql/pagination/view.py +++ b/ellar_sql/pagination/view.py @@ -34,11 +34,13 @@ def validate_model( model: t.Union[t.Type[ModelBase], sa.sql.Select[t.Any]], fallback: t.Optional[t.Union[t.Type[ModelBase], sa.sql.Select[t.Any]]], ) -> t.Union[t.Type[ModelBase], sa.sql.Select[t.Any]]: - if isinstance(model, sa.sql.Select): + if isinstance(model, sa.sql.Select) or ( + isinstance(model, type) and issubclass(model, ModelBase) + ): working_model = model else: - working_model = model or fallback # type:ignore[assignment] - assert working_model, "Model Can not be None" + working_model = fallback # type:ignore[assignment] + assert working_model is not None, "Model Can not be None" return working_model @abstractmethod diff --git a/ellar_sql/schemas.py b/ellar_sql/schemas.py index bec6d4c..cf6ac85 100644 --- a/ellar_sql/schemas.py +++ b/ellar_sql/schemas.py @@ -83,3 +83,20 @@ class SQLAlchemyConfig(ecm.Serializer): engine_options: t.Optional[t.Dict[str, t.Any]] = None models: t.Optional[t.List[str]] = None + + +@dataclass +class ModelMetaStore: + base_config: ModelBaseConfig + pk_column: t.Optional[sa_orm.ColumnProperty] = None + columns: t.List[sa_orm.ColumnProperty] = field(default_factory=lambda: []) + + def __post_init__(self) -> None: + if self.columns: + self.pk_column = next(c for c in self.columns if c.primary_key) + + @property + def pk_name(self) -> t.Optional[str]: + if self.pk_column is not None: + return self.pk_column.key + return None diff --git a/ellar_sql/services/base.py b/ellar_sql/services/base.py index 5d6fe5d..095e389 100644 --- a/ellar_sql/services/base.py +++ b/ellar_sql/services/base.py @@ -11,7 +11,6 @@ get_main_directory_by_stack, module_import, ) -from ellar.events import app_context_teardown_events from ellar.threading import execute_coroutine_with_sync_worker from sqlalchemy.ext.asyncio import ( AsyncSession, @@ -63,12 +62,6 @@ def __init__( self._setup(databases, models=models, echo=echo) self.session_factory = self.get_scoped_session() - app_context_teardown_events.connect(self._on_application_tear_down) - - async def _on_application_tear_down(self) -> None: - res = self.session_factory.remove() - if isinstance(res, t.Coroutine): - await res @property def has_async_engine_driver(self) -> bool: @@ -291,6 +284,6 @@ def _get_metadata_and_engine( message = f"Bind key '{key}' is not in 'Database' config." raise sa_exc.UnboundExecutionError(message) from None - metadata = get_metadata(key, certain=True) - result.append(MetaDataEngine(metadata=metadata, engine=engine)) + db_metadata = get_metadata(key, certain=True) + result.append(MetaDataEngine(metadata=db_metadata.metadata, engine=engine)) return result diff --git a/examples/db-learning/README.md b/examples/db-learning/README.md new file mode 100644 index 0000000..ee49c90 --- /dev/null +++ b/examples/db-learning/README.md @@ -0,0 +1,24 @@ +## Introduction +This is EllarSQL documentation `db_learning` project source code with all the illustrations + +## Project setup +``` +pip install -r requirements.txt +``` + +## Important Quick Steps +After environment setup, kindly follow instruction below + +- apply migration `python manage.py db upgrade` +- seed user data `python manage.py seed` + +## Development Server +``` +python manage.py runserver --reload +``` + + +## Run Test +``` +pytest +``` diff --git a/examples/db-learning/db_learning/__init__.py b/examples/db-learning/db_learning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/db-learning/db_learning/command.py b/examples/db-learning/db_learning/command.py new file mode 100644 index 0000000..adc7ae0 --- /dev/null +++ b/examples/db-learning/db_learning/command.py @@ -0,0 +1,18 @@ +import ellar_cli.click as click +from ellar.app import current_injector + +from db_learning.models import User +from ellar_sql import EllarSQLService + + +@click.command("seed") +@click.with_app_context +def seed_user(): + db_service = current_injector.get(EllarSQLService) + session = db_service.session_factory() + + for i in range(300): + session.add(User(username=f"username-{i+1}", email=f"user{i+1}doe@example.com")) + + session.commit() + db_service.session_factory.remove() diff --git a/examples/db-learning/db_learning/config.py b/examples/db-learning/db_learning/config.py new file mode 100644 index 0000000..716ab46 --- /dev/null +++ b/examples/db-learning/db_learning/config.py @@ -0,0 +1,93 @@ +""" +Application Configurations +Default Ellar Configurations are exposed here through `ConfigDefaultTypesMixin` +Make changes and define your own configurations specific to your application + +export ELLAR_CONFIG_MODULE=db_learning.config:DevelopmentConfig +""" + +import typing as t + +from ellar.common import IExceptionHandler, JSONResponse +from ellar.core import ConfigDefaultTypesMixin +from ellar.core.versioning import BaseAPIVersioning, DefaultAPIVersioning +from ellar.pydantic import ENCODERS_BY_TYPE as encoders_by_type +from starlette.middleware import Middleware + + +class BaseConfig(ConfigDefaultTypesMixin): + DEBUG: bool = False + + DEFAULT_JSON_CLASS: t.Type[JSONResponse] = JSONResponse + SECRET_KEY: str = "ellar_QdZwHTfLkZQWQtAot-V6gbTHONMn3ekrl5jdcb5AOC8" + + # injector auto_bind = True allows you to resolve types that are not registered on the container + # For more info, read: https://injector.readthedocs.io/en/latest/index.html + INJECTOR_AUTO_BIND = False + + # jinja Environment options + # https://jinja.palletsprojects.com/en/3.0.x/api/#high-level-api + JINJA_TEMPLATES_OPTIONS: t.Dict[str, t.Any] = {} + + # Application route versioning scheme + VERSIONING_SCHEME: BaseAPIVersioning = DefaultAPIVersioning() + + # Enable or Disable Application Router route searching by appending backslash + REDIRECT_SLASHES: bool = False + + # Define references to static folders in python packages. + # eg STATIC_FOLDER_PACKAGES = [('boostrap4', 'statics')] + STATIC_FOLDER_PACKAGES: t.Optional[t.List[t.Union[str, t.Tuple[str, str]]]] = [] + + # Define references to static folders defined within the project + STATIC_DIRECTORIES: t.Optional[t.List[t.Union[str, t.Any]]] = [] + + # static route path + STATIC_MOUNT_PATH: str = "/static" + + CORS_ALLOW_ORIGINS: t.List[str] = ["*"] + CORS_ALLOW_METHODS: t.List[str] = ["*"] + CORS_ALLOW_HEADERS: t.List[str] = ["*"] + ALLOWED_HOSTS: t.List[str] = ["*"] + + # Application middlewares + MIDDLEWARE: t.Sequence[Middleware] = [] + + # A dictionary mapping either integer status codes, + # or exception class types onto callables which handle the exceptions. + # Exception handler callables should be of the form + # `handler(context:IExecutionContext, exc: Exception) -> response` + # and may be either standard functions, or async functions. + EXCEPTION_HANDLERS: t.List[IExceptionHandler] = [] + + # Object Serializer custom encoders + SERIALIZER_CUSTOM_ENCODER: t.Dict[ + t.Any, t.Callable[[t.Any], t.Any] + ] = encoders_by_type + + +class DevelopmentConfig(BaseConfig): + DEBUG: bool = True + + ELLAR_SQL: t.Dict[str, t.Any] = { + "databases": { + "default": "sqlite:///app.db", + }, + "echo": True, + "migration_options": { + "directory": "migrations" # root directory will be determined based on where the module is instantiated. + }, + "models": ["db_learning.models"], + } + + +class TestConfig(BaseConfig): + DEBUG = False + + ELLAR_SQL: t.Dict[str, t.Any] = { + **DevelopmentConfig.ELLAR_SQL, + "databases": { + "default": "sqlite:///test.db", + }, + "echo": False, + } diff --git a/examples/db-learning/db_learning/controller.py b/examples/db-learning/db_learning/controller.py new file mode 100644 index 0000000..142a0c8 --- /dev/null +++ b/examples/db-learning/db_learning/controller.py @@ -0,0 +1,41 @@ +import ellar.common as ecm +from ellar.pydantic import EmailStr + +from ellar_sql import get_or_404, model + +from .models import User + + +@ecm.Controller +class UsersController(ecm.ControllerBase): + @ecm.post("/") + 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("/{user_id:int}") + async def user_by_id(self, user_id: int): + user: User = await 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"} diff --git a/examples/db-learning/db_learning/core/__init__.py b/examples/db-learning/db_learning/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/db-learning/db_learning/domain/__init__.py b/examples/db-learning/db_learning/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/db-learning/db_learning/migrations/README b/examples/db-learning/db_learning/migrations/README new file mode 100644 index 0000000..6e399f7 --- /dev/null +++ b/examples/db-learning/db_learning/migrations/README @@ -0,0 +1 @@ +Migration Setup for EllarSQL Models diff --git a/examples/db-learning/db_learning/migrations/alembic.ini b/examples/db-learning/db_learning/migrations/alembic.ini new file mode 100644 index 0000000..2cf6f90 --- /dev/null +++ b/examples/db-learning/db_learning/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,ellar_sqlalchemy_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_ellar_sqlalchemy_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/examples/db-learning/db_learning/migrations/env.py b/examples/db-learning/db_learning/migrations/env.py new file mode 100644 index 0000000..41c6f37 --- /dev/null +++ b/examples/db-learning/db_learning/migrations/env.py @@ -0,0 +1,37 @@ +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_sql.migrations import SingleDatabaseAlembicEnvMigration +from ellar_sql.services import EllarSQLService + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) # type:ignore[arg-type] + +# logger = logging.getLogger("alembic.env") +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +async def main() -> None: + db_service: EllarSQLService = current_injector.get(EllarSQLService) + + # initialize migration class + alembic_env_migration = SingleDatabaseAlembicEnvMigration(db_service) + + 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] + + +execute_coroutine_with_sync_worker(main()) diff --git a/examples/db-learning/db_learning/migrations/script.py.mako b/examples/db-learning/db_learning/migrations/script.py.mako new file mode 100644 index 0000000..e1b8246 --- /dev/null +++ b/examples/db-learning/db_learning/migrations/script.py.mako @@ -0,0 +1,27 @@ +<%! +import re + +%>"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/examples/db-learning/db_learning/migrations/versions/2024_01_27_1031-aa924ee1b88a_initial_migration.py b/examples/db-learning/db_learning/migrations/versions/2024_01_27_1031-aa924ee1b88a_initial_migration.py new file mode 100644 index 0000000..5da3b82 --- /dev/null +++ b/examples/db-learning/db_learning/migrations/versions/2024_01_27_1031-aa924ee1b88a_initial_migration.py @@ -0,0 +1,34 @@ +"""initial migration + +Revision ID: aa924ee1b88a +Revises: +Create Date: 2024-01-27 10:31:22.187308 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "aa924ee1b88a" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("username", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user")), + sa.UniqueConstraint("username", name=op.f("uq_user_username")), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("user") + # ### end Alembic commands ### diff --git a/examples/db-learning/db_learning/models.py b/examples/db-learning/db_learning/models.py new file mode 100644 index 0000000..3763d3b --- /dev/null +++ b/examples/db-learning/db_learning/models.py @@ -0,0 +1,9 @@ +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) diff --git a/examples/db-learning/db_learning/pagination/__init__.py b/examples/db-learning/db_learning/pagination/__init__.py new file mode 100644 index 0000000..23316bc --- /dev/null +++ b/examples/db-learning/db_learning/pagination/__init__.py @@ -0,0 +1,7 @@ +from .api import list_api_router +from .template import list_template_router + +__all__ = [ + "list_api_router", + "list_template_router", +] diff --git a/examples/db-learning/db_learning/pagination/api.py b/examples/db-learning/db_learning/pagination/api.py new file mode 100644 index 0000000..da707ce --- /dev/null +++ b/examples/db-learning/db_learning/pagination/api.py @@ -0,0 +1,32 @@ +import ellar.common as ec +from ellar.openapi import ApiTags + +from db_learning.models import User +from ellar_sql import paginate + + +class UserSchema(ec.Serializer): + id: int + username: str + email: str + + +# @ec.get('/users') +# @paginate(item_schema=UserSchema, per_page=100) +# def list_users(): +# return User + +list_api_router = ec.ModuleRouter("/users-api") + + +@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 new file mode 100644 index 0000000..b629e52 --- /dev/null +++ b/examples/db-learning/db_learning/pagination/template.py @@ -0,0 +1,30 @@ +import ellar.common as ec +from ellar.openapi import ApiTags + +from db_learning.models import User +from ellar_sql import model, paginate + +list_template_router = ec.ModuleRouter("/users-template") + + +## CASE 1 +# @list_template_router.get('/users') +# @ec.render('list.html') +# @paginate(as_template_context=True) +# def list_users(): +# return model.select(User), {'name': 'Template Pagination'} # pagination model, template context + + +## CASE 2 +@list_template_router.get() +@ec.render("list.html") +@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/root_module.py b/examples/db-learning/db_learning/root_module.py new file mode 100644 index 0000000..8c55cf1 --- /dev/null +++ b/examples/db-learning/db_learning/root_module.py @@ -0,0 +1,26 @@ +import ellar.common as ec +from ellar.app import App +from ellar.core import ModuleBase + +from db_learning.command import seed_user +from db_learning.controller import UsersController +from db_learning.pagination import list_api_router, list_template_router +from ellar_sql import EllarSQLModule, EllarSQLService + + +@ec.Module( + modules=[EllarSQLModule.register_setup()], + routers=[list_template_router, list_api_router], + controllers=[UsersController], + commands=[seed_user], +) +class ApplicationModule(ModuleBase, ec.IApplicationStartup): + async def on_startup(self, app: App) -> None: + db_service = app.injector.get(EllarSQLService) + db_service.create_all() + + @ec.exception_handler(404) + def exception_404_handler( + cls, ctx: ec.IExecutionContext, exc: Exception + ) -> ec.Response: + return ec.JSONResponse({"detail": "Resource not found."}, status_code=404) diff --git a/examples/db-learning/db_learning/server.py b/examples/db-learning/db_learning/server.py new file mode 100644 index 0000000..b0c2653 --- /dev/null +++ b/examples/db-learning/db_learning/server.py @@ -0,0 +1,32 @@ +import os + +from ellar.app import App, AppFactory +from ellar.common.constants import ELLAR_CONFIG_MODULE +from ellar.core import LazyModuleImport as lazyLoad +from ellar.openapi import OpenAPIDocumentBuilder, OpenAPIDocumentModule, SwaggerUI + + +def bootstrap() -> App: + application = AppFactory.create_from_app_module( + lazyLoad("db_learning.root_module:ApplicationModule"), + config_module=os.environ.get( + ELLAR_CONFIG_MODULE, "db_learning.config:DevelopmentConfig" + ), + global_guards=[], + ) + + # uncomment this section if you want API documentation + + document_builder = OpenAPIDocumentBuilder() + document_builder.set_title("Db_learning Title").set_version("1.0.2").set_contact( + name="Author Name", + url="https://www.author-name.com", + email="authorname@gmail.com", + ).set_license("MIT Licence", url="https://www.google.com") + + document = document_builder.build_document(application) + module = OpenAPIDocumentModule.setup( + document=document, docs_ui=SwaggerUI(), guards=[] + ) + application.install_module(module) + return application diff --git a/examples/db-learning/db_learning/templates/list.html b/examples/db-learning/db_learning/templates/list.html new file mode 100644 index 0000000..d252e5a --- /dev/null +++ b/examples/db-learning/db_learning/templates/list.html @@ -0,0 +1,29 @@ + + +