Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: repository enhancements and MySQL SQLAlchemy support #1345

Merged
merged 78 commits into from Apr 20, 2023
Merged
Show file tree
Hide file tree
Changes from 72 commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
232a800
feat: introduces mysql tests using `asyncmy`
Mar 18, 2023
01c048a
fix: linting
Mar 18, 2023
b4f6eab
fix: set host as wildcard
Mar 18, 2023
c7ed2e2
feat: bump version
Mar 18, 2023
c7d52cc
fix: extend the startup wait
Mar 18, 2023
6171901
Merge branch 'main' of github.com:cofin/starlite into mysql-tests
Apr 7, 2023
ceedd90
feat: add doc stubs
Apr 8, 2023
5918864
fix: updated example doc for pagination
Apr 8, 2023
3b6d5bf
enhancements:
Apr 8, 2023
6455a26
feat: adds additional test coverage
Apr 8, 2023
bdb7e76
chore: version bump and removed unused ignore
Apr 8, 2023
7de42fa
feat: ignore asyncmy tests cases by default
Apr 8, 2023
94fa4c4
feat: add markers
Apr 8, 2023
46ab41b
feat: adds basic example app for using the new declarative models
Apr 8, 2023
18dcff5
fix: updated URLs to 2.0 document links
Apr 9, 2023
af2ce8b
fix: typo correction
Apr 9, 2023
e622f5b
feat: wip on docs
Apr 9, 2023
849d591
fix: rename to `AsyncSQLAlchemyRepository`
Apr 9, 2023
429dc4b
fix: updated basic implementation links
Apr 9, 2023
541702d
feat: updated basic repository features example
Apr 10, 2023
3944096
fix: add `selectin` to codespell ignore
Apr 10, 2023
6720a2b
fix: updated documentation
Apr 10, 2023
74bd3a6
fix: updated reference
Apr 10, 2023
2b2a547
fix: address lint warnings
Apr 10, 2023
b8eaf84
fix: adds sqlalchemy to toc tree
Apr 10, 2023
e87d2fe
Merge branch 'main' into mysql-tests
cofin Apr 10, 2023
f1ab74f
fix: name cleanup
Apr 10, 2023
1a4c3c5
Merge branch 'main' into mysql-tests
cofin Apr 10, 2023
9779d87
fix: linting updates
Apr 10, 2023
fdc11b1
Merge branch 'mysql-tests' of github.com:cofin/starlite into mysql-tests
Apr 10, 2023
3570a52
fix: reference correction
Apr 10, 2023
3358ca2
Merge branch 'main' into mysql-tests
cofin Apr 10, 2023
01b9865
fix: adjust comments
Apr 10, 2023
3ec70fb
fix: adjust naming to align with existing convention
Apr 10, 2023
201f65e
Merge branch 'main' into mysql-tests
cofin Apr 10, 2023
fc4cdb4
Merge branch 'main' into mysql-tests
cofin Apr 10, 2023
4b7b3bb
fix: use the ID column parameter
Apr 11, 2023
c581e14
Merge branch 'mysql-tests' of github.com:cofin/starlite into mysql-tests
Apr 11, 2023
c9f7ac8
fix: adjust the bound model
Apr 11, 2023
5f5eaeb
Merge branch 'main' into mysql-tests
cofin Apr 11, 2023
f6ecd16
Merge branch 'main' into mysql-tests
cofin Apr 13, 2023
c29ad79
Merge branch 'main' into mysql-tests
cofin Apr 16, 2023
145e4ae
Merge branch 'main' into mysql-tests
cofin Apr 16, 2023
1cb5114
Update docs/usage/contrib/sqlalchemy.rst
cofin Apr 17, 2023
08469f7
Update docs/usage/contrib/sqlalchemy.rst
cofin Apr 17, 2023
9a2fe40
Update docs/usage/contrib/sqlalchemy.rst
cofin Apr 17, 2023
55aaca6
Update docs/usage/contrib/sqlalchemy.rst
cofin Apr 17, 2023
3e85902
Update litestar/contrib/repository/testing/generic_mock_repository.py
cofin Apr 17, 2023
5eafeef
Merge branch 'main' into mysql-tests
cofin Apr 17, 2023
b7807e2
feat: implement a dialect independent JSON type.
Apr 17, 2023
86b121d
fix: typo correction
Apr 17, 2023
1fb1ccc
feat: add base for testing JSON/JSONB type. more tweaks to broken my…
Apr 18, 2023
a21747d
fix: revert fixture modification
Apr 18, 2023
2c82051
fix: correctly await operational error
Apr 18, 2023
982a703
feat: adds GUID type with stringified UUID coercion
Apr 18, 2023
edc6021
Merge branch 'main' into mysql-tests
cofin Apr 18, 2023
b261abb
fix: documentation updates
Apr 18, 2023
6acf353
Merge branch 'mysql-tests' of github.com:cofin/starlite into mysql-tests
Apr 18, 2023
64af916
fix: removed `__future__` from examples
Apr 18, 2023
9853ba9
fix: another attempt at setting up testing
Apr 18, 2023
1ea3ffd
fix: updated reference
Apr 18, 2023
e12b27c
fix: adds additional test hook
Apr 18, 2023
247fee6
Merge branch 'main' into mysql-tests
cofin Apr 18, 2023
17e8d3c
Merge branch 'mysql-tests' of github.com:cofin/starlite into mysql-tests
Apr 18, 2023
f64e1ea
feat: another attempt at documentation
Apr 18, 2023
63a705b
fix: corrected ignore
Apr 18, 2023
f039338
chore: linting and pre-commit upgrade
Apr 18, 2023
1992aa0
chore: bump deps
Apr 18, 2023
6890446
fix: revert dep update due to polyfactory release PR
Apr 18, 2023
eb18398
fix: pin polyfactory version until Bug: FieldMeta unexpected keyword …
Apr 19, 2023
84928b9
fix: doc update
Apr 19, 2023
8e1d871
fix: pin version until Bug: FieldMeta unexpected keyword argument 'co…
Apr 19, 2023
ec98fc4
Merge branch 'main' into mysql-tests
cofin Apr 19, 2023
d35272d
fix: revert polyfactory pin
Apr 19, 2023
adee8b3
fix: adjust nitpick config
Apr 19, 2023
7129630
Merge branch 'main' into mysql-tests
cofin Apr 19, 2023
323b748
fix: only disable running database tests when running `make test` loc…
Apr 19, 2023
d18b2c2
Merge branch 'main' into mysql-tests
cofin Apr 19, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/test.yaml
Expand Up @@ -58,6 +58,11 @@ jobs:
run: |
source $VENV
pytest
- name: Test Database Backends
if: ${{ !inputs.coverage }}
provinzkraut marked this conversation as resolved.
Show resolved Hide resolved
run: |
source $VENV
pytest -m='sqlalchemy_asyncmy or sqlalchemy_asyncpg'
- name: Test with coverage
if: inputs.coverage
run: poetry run pytest docs/examples tests --cov=litestar --cov-report=xml
Expand Down
6 changes: 4 additions & 2 deletions .pre-commit-config.yaml
Expand Up @@ -54,6 +54,7 @@ repos:
[
aiosqlite,
async_timeout,
asyncmy,
asyncpg,
attrs,
beanie,
Expand All @@ -77,7 +78,7 @@ repos:
piccolo,
picologging,
pydantic,
polyfactory,
polyfactory==2.0.0a1,
pytest,
pytest-lazy-fixture,
pytest-mock,
Expand Down Expand Up @@ -107,6 +108,7 @@ repos:
[
aiosqlite,
async_timeout,
asyncmy,
asyncpg,
attrs,
beanie,
Expand All @@ -130,7 +132,7 @@ repos:
piccolo,
picologging,
pydantic,
polyfactory,
polyfactory==2.0.0a1,
cofin marked this conversation as resolved.
Show resolved Hide resolved
pytest,
pytest-lazy-fixture,
pytest-mock,
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Expand Up @@ -16,10 +16,16 @@ docs-test:
test-examples:
pytest docs/examples

test-sqlalchemy-asyncpg:
pytest tests -m='sqlalchemy_asyncpg'

test-sqlalchemy-asyncmy:
pytest tests -m='sqlalchemy_asyncmy'

test:
pytest tests

test-all: test test-examples
test-all: test test-sqlalchemy-asyncpg test-sqlalchemy-asyncmy test-examples

coverage:
pytest tests --cov=litestar
Expand Down
19 changes: 19 additions & 0 deletions docs/conf.py
Expand Up @@ -70,12 +70,31 @@
("py:class", "sqlalchemy.orm.decl_api.DeclarativeMeta"),
("py:class", "sqlalchemy.sql.sqltypes.TupleType"),
("py:class", "sqlalchemy.dialects.postgresql.named_types.ENUM"),
("py:class", "_orm.Mapper"),
("py:class", "_orm.registry"),
("py:class", "_schema.MetaData"),
("py:class", "_schema.Table"),
("py:class", "_RegistryType"),
("py:class", "abc.Collection"),
("py:class", "TypeEngine"),
("py:class", "ExternalType"),
("py:class", "UserDefinedType"),
provinzkraut marked this conversation as resolved.
Show resolved Hide resolved
("py:class", "_types.TypeDecorator"),
("py:meth", "_types.TypeDecorator.process_bind_param"),
("py:meth", "_types.TypeDecorator.process_result_value"),
("py:meth", "type_engine"),
# type vars and aliases / intentionally undocumented
("py:class", "RouteHandlerType"),
("py:obj", "litestar.security.base.AuthType"),
("py:class", "ControllerRouterHandler"),
("py:class", "PathParameterDefinition"),
("py:class", "BaseSessionBackendT"),
("py:class", "litestar.contrib.repository.abc.CollectionT"),
("py:obj", "ModelT"),
("py:obj", "litestar.contrib.sqlalchemy.repository.ModelT"),
("py:class", "litestar.contrib.sqlalchemy.repository.ModelT"),
("py:class", "ModelT"),
cofin marked this conversation as resolved.
Show resolved Hide resolved
("py:class", "litestar.contrib.sqlalchemy.repository.SelectT"),
("py:class", "AnyIOBackend"),
("py:class", "T"),
("py:class", "C"),
Expand Down
Empty file.
204 changes: 204 additions & 0 deletions docs/examples/contrib/sqlalchemy/sqlalchemy_async_repository.py
@@ -0,0 +1,204 @@
from datetime import date
from typing import TYPE_CHECKING
from uuid import UUID

from pydantic import BaseModel as _BaseModel
from pydantic import parse_obj_as
from sqlalchemy import ForeignKey, select
from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload

from litestar import Litestar, get
from litestar.contrib.repository.filters import LimitOffset
from litestar.contrib.sqlalchemy.base import AuditBase, Base
from litestar.contrib.sqlalchemy.init_plugin import SQLAlchemyAsyncConfig, SQLAlchemyInitPlugin
from litestar.contrib.sqlalchemy.repository import SQLAlchemyAsyncRepository
from litestar.controller import Controller
from litestar.di import Provide
from litestar.handlers.http_handlers.decorators import delete, patch, post
from litestar.pagination import OffsetPagination
from litestar.params import Parameter

if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession


class BaseModel(_BaseModel):
"""Extend Pydantic's BaseModel to enable ORM mode"""

class Config:
orm_mode = True


# the SQLAlchemy base includes a declarative model for you to use in your models.
# The `Base` class includes a `UUID` based primary key (`id`)
class AuthorModel(Base):
# we can optionally provide the table name instead of auto-generating it
__tablename__ = "author"
name: Mapped[str]
dob: Mapped[date | None]
books: Mapped[list["BookModel"]] = relationship(back_populates="author", lazy="noload")


# The `AuditBase` class includes the same UUID` based primary key (`id`) and 2 additional columns: `created` and `updated`.
# `created` is a timestamp of when the record created, and `updated` is the last time the record was modified.
class BookModel(AuditBase):
__tablename__ = "book"
title: Mapped[str]
author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id"))
author: Mapped["AuthorModel"] = relationship(lazy="joined", innerjoin=True, viewonly=True)


# we will explicitly define the schema instead of using DTO objects for clarity.


class Author(BaseModel):
id: UUID | None
name: str
dob: date | None = None


class AuthorCreate(BaseModel):
name: str
dob: date | None = None


class AuthorUpdate(BaseModel):
name: str | None = None
dob: date | None = None


class AuthorRepository(SQLAlchemyAsyncRepository[AuthorModel]):
"""Author repository."""

model_type = AuthorModel


async def provide_authors_repo(db_session: "AsyncSession") -> AuthorRepository:
"""This provides the default Authors repository."""
return AuthorRepository(session=db_session)


# we can optionally override the default `select` used for the repository to pass in specific SQL options such as join details
async def provide_author_details_repo(db_session: "AsyncSession") -> AuthorRepository:
"""This provides a simple example demonstrating how to override the join options for the repository."""
return AuthorRepository(statement=select(AuthorModel).options(selectinload(AuthorModel.books)), session=db_session)


def provide_limit_offset_pagination(
current_page: int = Parameter(ge=1, query="currentPage", default=1, required=False),
page_size: int = Parameter(
query="pageSize",
ge=1,
default=10,
required=False,
),
) -> LimitOffset:
"""Add offset/limit pagination.

Return type consumed by `Repository.apply_limit_offset_pagination()`.

Parameters
----------
current_page : int
LIMIT to apply to select.
page_size : int
OFFSET to apply to select.
"""
return LimitOffset(page_size, page_size * (current_page - 1))


class AuthorController(Controller):
"""Author CRUD"""

dependencies = {"authors_repo": Provide(provide_authors_repo)}

@get(path="/authors")
async def list_authors(
self,
authors_repo: AuthorRepository,
limit_offset: LimitOffset,
) -> OffsetPagination[Author]:
"""List authors."""
results, total = await authors_repo.list_and_count(limit_offset)
return OffsetPagination[Author](
items=parse_obj_as(list[Author], results),
total=total,
limit=limit_offset.limit,
offset=limit_offset.offset,
)

@post(path="/authors")
async def create_author(
self,
authors_repo: AuthorRepository,
data: AuthorCreate,
) -> Author:
"""Create a new author."""
obj = await authors_repo.add(AuthorModel(**data.dict(exclude_unset=True, by_alias=False, exclude_none=True)))
await authors_repo.session.commit()
return Author.from_orm(obj)

# we override the authors_repo to use the version that joins the Books in
@get(path="/authors/{author_id:uuid}", dependencies={"authors_repo": Provide(provide_author_details_repo)})
async def get_author(
self,
authors_repo: AuthorRepository,
author_id: UUID = Parameter(
title="Author ID",
description="The author to retrieve.",
),
) -> Author:
"""Get an existing author."""
obj = await authors_repo.get(author_id)
return Author.from_orm(obj)

@patch(path="/authors/{author_id:uuid}", dependencies={"authors_repo": Provide(provide_author_details_repo)})
async def update_author(
self,
authors_repo: AuthorRepository,
data: AuthorUpdate,
author_id: UUID = Parameter(
title="Author ID",
description="The author to update.",
),
) -> Author:
"""Update an author."""
raw_obj = data.dict(exclude_unset=True, by_alias=False, exclude_none=True)
raw_obj.update({"id": author_id})
obj = await authors_repo.update(AuthorModel(**raw_obj))
await authors_repo.session.commit()
return Author.from_orm(obj)

@delete(path="/authors/{author_id:uuid}")
async def delete_author(
self,
authors_repo: AuthorRepository,
author_id: UUID = Parameter(
title="Author ID",
description="The author to delete.",
),
) -> None:
"""Delete a author from the system."""
_ = await authors_repo.delete(author_id)
await authors_repo.session.commit()


sqlalchemy_config = SQLAlchemyAsyncConfig(
connection_string="sqlite+aiosqlite:///test.sqlite", session_dependency_key="db_session"
) # Create 'db_session' dependency.
sqlalchemy_plugin = SQLAlchemyInitPlugin(config=sqlalchemy_config)


async def on_startup() -> None:
"""Initializes the database."""
async with sqlalchemy_config.create_engine().begin() as conn:
await conn.run_sync(Base.metadata.create_all)


app = Litestar(
route_handlers=[AuthorController],
on_startup=[on_startup],
plugins=[SQLAlchemyInitPlugin(config=sqlalchemy_config)],
dependencies={"limit_offset": Provide(provide_limit_offset_pagination)},
debug=True,
)
@@ -0,0 +1,54 @@
from datetime import date
from typing import TYPE_CHECKING
from uuid import UUID

from sqlalchemy import ForeignKey, select
from sqlalchemy.orm import Mapped, mapped_column, relationship

from litestar import Litestar, get
from litestar.contrib.sqlalchemy.base import AuditBase, Base
from litestar.contrib.sqlalchemy.init_plugin import SQLAlchemyAsyncConfig, SQLAlchemyInitPlugin

if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession


# the SQLAlchemy base includes a declarative model for you to use in your models.
# The `Base` class includes a `UUID` based primary key (`id`)
class Author(Base):
name: Mapped[str]
dob: Mapped[date]
books: Mapped[list["Book"]] = relationship(back_populates="author", lazy="selectin")


# The `AuditBase` class includes the same UUID` based primary key (`id`) and 2 additional columns: `created` and `updated`.
# `created` is a timestamp of when the record created, and `updated` is the last time the record was modified.
class Book(AuditBase):
title: Mapped[str]
author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id"))
author: Mapped[Author] = relationship(lazy="joined", innerjoin=True, viewonly=True)


@get(path="/sqlalchemy-app")
async def async_sqlalchemy_init(db_session: "AsyncSession", db_engine: "AsyncEngine") -> list[Author]:
"""Interact with SQLAlchemy engine and session."""
return await db_session.scalars(select(Author))


sqlalchemy_config = SQLAlchemyAsyncConfig(
connection_string="sqlite+aiosqlite:///test.sqlite", session_dependency_key="db_session"
) # Create 'async_session' dependency.
sqlalchemy_plugin = SQLAlchemyInitPlugin(config=sqlalchemy_config)


async def on_startup() -> None:
"""Initializes the database."""
async with sqlalchemy_config.create_engine().begin() as conn:
await conn.run_sync(Base.metadata.create_all)


app = Litestar(
route_handlers=[async_sqlalchemy_init],
on_startup=[on_startup],
plugins=[SQLAlchemyInitPlugin(config=sqlalchemy_config)],
)