diff --git a/.coverage b/.coverage index 07f5594..bf04874 100644 Binary files a/.coverage and b/.coverage differ diff --git a/README.md b/README.md index 0eca573..c144d4f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # ORMar
@@ -25,10 +25,12 @@ MySQL, and SQLite. Ormar is built with: Because ormar is built on SQLAlchemy core, you can use [`alembic`][alembic] to provide database migrations. -The goal was to create a simple ormar that can be used directly with [`fastapi`][fastapi] that bases it's data validation on pydantic. +The goal was to create a simple ORM that can be used directly with [`fastapi`][fastapi] that bases it's data validation on pydantic. Initial work was inspired by [`encode/orm`][encode/orm]. The encode package was too simple (i.e. no ability to join two times to the same table) and used typesystem for data checks. +To avoid too high coupling with pydantic and sqlalchemy ormar uses them by **composition** rather than by **inheritance**. + **ormar is still under development:** We recommend pinning any dependencies with `ormar~=0.1.1` **Note**: Use `ipython` to try this from the console, since it supports `await`. diff --git a/ormar/models/fakepydantic.py b/ormar/models/fakepydantic.py index c7a1bc8..d5109b4 100644 --- a/ormar/models/fakepydantic.py +++ b/ormar/models/fakepydantic.py @@ -117,15 +117,19 @@ class FakePydantic(list, metaclass=ModelMetaclass): def pk_type(cls) -> Any: return cls.__model_fields__[cls.__pkname__].__type__ - def dict(self) -> Dict: # noqa: A003 + def dict(self, nested=False) -> Dict: # noqa: A003 dict_instance = self.values.dict() for field in self._extract_related_names(): nested_model = getattr(self, field) - if isinstance(nested_model, list): - dict_instance[field] = [x.dict() for x in nested_model] + if self.__model_fields__[field].virtual and nested: + continue + if isinstance(nested_model, list) and not isinstance( + nested_model, ormar.Model + ): + dict_instance[field] = [x.dict(nested=True) for x in nested_model] else: dict_instance[field] = ( - nested_model.dict() if nested_model is not None else {} + nested_model.dict(nested=True) if nested_model is not None else {} ) return dict_instance diff --git a/ormar/models/model.py b/ormar/models/model.py index fed4224..b16d3e7 100644 --- a/ormar/models/model.py +++ b/ormar/models/model.py @@ -55,7 +55,7 @@ class Model(FakePydantic): def pk(self, value: Any) -> None: setattr(self.values, self.__pkname__, value) - async def save(self) -> int: + async def save(self) -> "Model": self_fields = self._extract_model_db_fields() if self.__model_fields__.get(self.__pkname__).autoincrement: self_fields.pop(self.__pkname__, None) @@ -63,7 +63,7 @@ class Model(FakePydantic): expr = expr.values(**self_fields) item_id = await self.__database__.execute(expr) self.pk = item_id - return item_id + return self async def update(self, **kwargs: Any) -> int: if kwargs: diff --git a/ormar/queryset/queryset.py b/ormar/queryset/queryset.py index 3190036..65192b8 100644 --- a/ormar/queryset/queryset.py +++ b/ormar/queryset/queryset.py @@ -160,10 +160,15 @@ class QuerySet: # substitute related models with their pk for field in self.model_cls._extract_related_names(): if field in new_kwargs and new_kwargs.get(field) is not None: - new_kwargs[field] = getattr( - new_kwargs.get(field), - self.model_cls.__model_fields__[field].to.__pkname__, - ) + if isinstance(new_kwargs.get(field), ormar.Model): + new_kwargs[field] = getattr( + new_kwargs.get(field), + self.model_cls.__model_fields__[field].to.__pkname__, + ) + else: + new_kwargs[field] = new_kwargs.get(field).get( + self.model_cls.__model_fields__[field].to.__pkname__ + ) # Build the insert expression. expr = self.table.insert() diff --git a/tests/test_foreign_keys.py b/tests/test_foreign_keys.py index 45f4359..bffa783 100644 --- a/tests/test_foreign_keys.py +++ b/tests/test_foreign_keys.py @@ -36,7 +36,7 @@ class Cover(ormar.Model): __database__ = database id = ormar.Integer(primary_key=True) - album = ormar.ForeignKey(Album, related_name='cover_pictures') + album = ormar.ForeignKey(Album, related_name="cover_pictures") title = ormar.String(length=100) @@ -171,8 +171,8 @@ async def test_fk_filter(): tracks = ( await Track.objects.select_related("album") - .filter(album__name="Fantasies") - .all() + .filter(album__name="Fantasies") + .all() ) assert len(tracks) == 3 for track in tracks: @@ -180,8 +180,8 @@ async def test_fk_filter(): tracks = ( await Track.objects.select_related("album") - .filter(album__name__icontains="fan") - .all() + .filter(album__name__icontains="fan") + .all() ) assert len(tracks) == 3 for track in tracks: @@ -223,8 +223,8 @@ async def test_multiple_fk(): members = ( await Member.objects.select_related("team__org") - .filter(team__org__ident="ACME Ltd") - .all() + .filter(team__org__ident="ACME Ltd") + .all() ) assert len(members) == 4 for member in members: @@ -243,8 +243,8 @@ async def test_pk_filter(): tracks = ( await Track.objects.select_related("album") - .filter(position=2, album__name="Test") - .all() + .filter(position=2, album__name="Test") + .all() ) assert len(tracks) == 1 diff --git a/tests/test_model_definition.py b/tests/test_model_definition.py index 253ae4f..f6dc722 100644 --- a/tests/test_model_definition.py +++ b/tests/test_model_definition.py @@ -95,13 +95,16 @@ def test_sqlalchemy_table_is_created(example): def test_no_pk_in_model_definition(): with pytest.raises(ModelDefinitionError): + class ExampleModel2(Model): __tablename__ = "example3" __metadata__ = metadata test_string = fields.String(length=250) + def test_two_pks_in_model_definition(): with pytest.raises(ModelDefinitionError): + class ExampleModel2(Model): __tablename__ = "example3" __metadata__ = metadata @@ -111,6 +114,7 @@ def test_two_pks_in_model_definition(): def test_setting_pk_column_as_pydantic_only_in_model_definition(): with pytest.raises(ModelDefinitionError): + class ExampleModel2(Model): __tablename__ = "example4" __metadata__ = metadata @@ -119,6 +123,7 @@ def test_setting_pk_column_as_pydantic_only_in_model_definition(): def test_decimal_error_in_model_definition(): with pytest.raises(ModelDefinitionError): + class ExampleModel2(Model): __tablename__ = "example4" __metadata__ = metadata @@ -127,6 +132,7 @@ def test_decimal_error_in_model_definition(): def test_string_error_in_model_definition(): with pytest.raises(ModelDefinitionError): + class ExampleModel2(Model): __tablename__ = "example4" __metadata__ = metadata diff --git a/tests/test_more_reallife_fastapi.py b/tests/test_more_reallife_fastapi.py new file mode 100644 index 0000000..d7670cc --- /dev/null +++ b/tests/test_more_reallife_fastapi.py @@ -0,0 +1,116 @@ +from typing import List + +import databases +import pytest +import sqlalchemy +from fastapi import FastAPI +from starlette.testclient import TestClient + +import ormar +from tests.settings import DATABASE_URL + +app = FastAPI() +metadata = sqlalchemy.MetaData() +database = databases.Database(DATABASE_URL, force_rollback=True) +app.state.database = database + + +@app.on_event("startup") +async def startup() -> None: + database_ = app.state.database + if not database_.is_connected: + await database_.connect() + + +@app.on_event("shutdown") +async def shutdown() -> None: + database_ = app.state.database + if database_.is_connected: + await database_.disconnect() + + +class Category(ormar.Model): + __tablename__ = "categories" + __metadata__ = metadata + __database__ = database + + id = ormar.Integer(primary_key=True) + name = ormar.String(length=100) + + +class Item(ormar.Model): + __tablename__ = "items" + __metadata__ = metadata + __database__ = database + + id = ormar.Integer(primary_key=True) + name = ormar.String(length=100) + category = ormar.ForeignKey(Category, nullable=True) + + +@pytest.fixture(autouse=True, scope="module") +def create_test_database(): + engine = sqlalchemy.create_engine(DATABASE_URL) + metadata.create_all(engine) + yield + metadata.drop_all(engine) + + +@app.get("/items/", response_model=List[Item]) +async def get_items(): + items = await Item.objects.select_related("category").all() + return [item.dict() for item in items] + + +@app.post("/items/", response_model=Item) +async def create_item(item: Item): + item = await Item.objects.create(**item.dict()) + return item.dict() + + +@app.post("/categories/", response_model=Category) +async def create_category(category: Category): + category = await Category.objects.create(**category.dict()) + return category.dict() + + +@app.put("/items/{item_id}") +async def get_item(item_id: int, item: Item): + item_db = await Item.objects.get(pk=item_id) + return {"updated_rows": await item_db.update(**item.dict())} + + +@app.delete("/items/{item_id}") +async def delete_item(item_id: int, item: Item): + item_db = await Item.objects.get(pk=item_id) + return {"deleted_rows": await item_db.delete()} + + +def test_all_endpoints(): + client = TestClient(app) + with client as client: + response = client.post("/categories/", json={"name": "test cat"}) + category = response.json() + response = client.post( + "/items/", json={"name": "test", "id": 1, "category": category} + ) + item = Item(**response.json()) + assert item.pk is not None + + response = client.get("/items/") + items = [Item(**item) for item in response.json()] + assert items[0] == item + + item.name = "New name" + response = client.put(f"/items/{item.pk}", json=item.dict()) + assert response.json().get("updated_rows") == 1 + + response = client.get("/items/") + items = [Item(**item) for item in response.json()] + assert items[0].name == "New name" + + response = client.delete(f"/items/{item.pk}", json=item.dict()) + assert response.json().get("deleted_rows") == 1 + response = client.get("/items/") + items = response.json() + assert len(items) == 0 diff --git a/tests/test_same_table_joins.py b/tests/test_same_table_joins.py index 69f5dd2..60ddddc 100644 --- a/tests/test_same_table_joins.py +++ b/tests/test_same_table_joins.py @@ -61,7 +61,7 @@ class Teacher(ormar.Model): category = ormar.ForeignKey(Category, nullable=True) -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def event_loop(): loop = asyncio.get_event_loop() yield loop @@ -92,6 +92,8 @@ async def test_model_multiple_instances_of_same_table_in_schema(): assert classes[0].name == "Math" assert classes[0].students[0].name == "Jane" + assert len(classes[0].dict().get("students")) == 2 + # related fields of main model are only populated by pk # unless there is a required foreign key somewhere along the way # since department is required for schoolclass it was pre loaded (again)