more tests for excluding parent fields, finished docs

This commit is contained in:
collerek
2021-06-02 10:16:47 +02:00
parent af394de483
commit f52797fb06
9 changed files with 244 additions and 15 deletions

View File

@ -50,6 +50,26 @@ You can pass a static value or a Callable (function etc.)
Used both in sql and pydantic. Used both in sql and pydantic.
Sample usage:
```python
# note the distinction between passing a value and Callable pointer
# value
name: str = ormar.String(max_length=200, default="Name")
# note that when you call a function it's not a pointer to Callable
# a definition like this will call the function at startup and assign
# the result of the function to the default, so it will be constant value for all instances
created_date: datetime.datetime = ormar.DateTime(default=datetime.datetime.now())
# if you want to pass Callable reference (note that it cannot have arguments)
# note lack of the parenthesis -> ormar will call this function for you on each model
created_date: datetime.datetime = ormar.DateTime(default=datetime.datetime.now)
# Callable can be a function, builtin, class etc.
```
## server default ## server default
`server_default`: `Any` = `None` -> defaults to None. `server_default`: `Any` = `None` -> defaults to None.

View File

@ -461,3 +461,113 @@ abstract parent model you may lose your data on through table if not careful.
That means that each time you define a Child model you need to either manually create That means that each time you define a Child model you need to either manually create
the table in the database, or run a migration (with alembic). the table in the database, or run a migration (with alembic).
## exclude_parent_fields
Ormar allows you to skip certain fields in inherited model that are coming from a parent model.
!!!Note
Note that the same behaviour can be achieved by splitting the model into more abstract models and mixins - which is a preferred way in normal circumstances.
To skip certain fields from a child model, list all fields that you want to skip in `model.Meta.exclude_parent_fields` parameter like follows:
```python
metadata = sa.MetaData()
db = databases.Database(DATABASE_URL)
class AuditModel(ormar.Model):
class Meta:
abstract = True
created_by: str = ormar.String(max_length=100)
updated_by: str = ormar.String(max_length=100, default="Sam")
class DateFieldsModel(ormar.Model):
class Meta(ormar.ModelMeta):
abstract = True
metadata = metadata
database = db
created_date: datetime.datetime = ormar.DateTime(
default=datetime.datetime.now, name="creation_date"
)
updated_date: datetime.datetime = ormar.DateTime(
default=datetime.datetime.now, name="modification_date"
)
class Category(DateFieldsModel, AuditModel):
class Meta(ormar.ModelMeta):
tablename = "categories"
# set fields that should be skipped
exclude_parent_fields = ["updated_by", "updated_date"]
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=50, unique=True, index=True)
code: int = ormar.Integer()
# Note that now the update fields in Category are gone in all places -> ormar fields, pydantic fields and sqlachemy table columns
# so full list of available fileds in Category is: ["created_by", "created_date", "id", "name", "code"]
```
Note how you simply need to provide field names and it will exclude the parent field regardless of from which parent model the field is coming from.
!!!Note
Note that if you want to overwrite a field in child model you do not have to exclude it, simpy overwrite the field declaration in child model with same field name.
!!!Warning
Note that this kind of behavior can confuse mypy and static type checkers, yet accessing the non existing fields will fail at runtime. That's why splitting the base classes is preferred.
The same effect can be achieved by splitting base classes like:
```python
metadata = sa.MetaData()
db = databases.Database(DATABASE_URL)
class AuditCreateModel(ormar.Model):
class Meta:
abstract = True
created_by: str = ormar.String(max_length=100)
class AuditUpdateModel(ormar.Model):
class Meta:
abstract = True
updated_by: str = ormar.String(max_length=100, default="Sam")
class CreateDateFieldsModel(ormar.Model):
class Meta(ormar.ModelMeta):
abstract = True
metadata = metadata
database = db
created_date: datetime.datetime = ormar.DateTime(
default=datetime.datetime.now, name="creation_date"
)
class UpdateDateFieldsModel(ormar.Model):
class Meta(ormar.ModelMeta):
abstract = True
metadata = metadata
database = db
updated_date: datetime.datetime = ormar.DateTime(
default=datetime.datetime.now, name="modification_date"
)
class Category(CreateDateFieldsModel, AuditCreateModel):
class Meta(ormar.ModelMeta):
tablename = "categories"
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=50, unique=True, index=True)
code: int = ormar.Integer()
```
That way you can inherit from both create and update classes if needed, and only one of them otherwise.

View File

@ -2,11 +2,16 @@
## ✨ Features ## ✨ Features
* Add `get_pydantic` flag that allows you to auto generate equivalent pydantic models tree from ormar.Model. This newly generated model tree can be used in requests and responses to exclude fields you do not want to include in the data. * Add [`get_pydantic`](https://collerek.github.io/ormar/models/methods/#get_pydantic) flag that allows you to auto generate equivalent pydantic models tree from ormar.Model. This newly generated model tree can be used in requests and responses to exclude fields you do not want to include in the data.
* Add [`exclude_parent_fields`](https://collerek.github.io/ormar/models/inheritance/#exclude_parent_fields) parameter to model Meta that allows you to exclude fields from parent models during inheritance. Note that best practice is to combine models and mixins but if you have many similar models and just one that differs it might be useful tool to achieve that.
## 🐛 Fixes
* Fix is null filter with pagination and relations (by @erichaydel) [#214](https://github.com/collerek/ormar/issues/214)
## 💬 Other ## 💬 Other
* Expand fastapi part of the documentation to show samples of using ormar in requests and responses in fastapi. * Expand [fastapi](https://collerek.github.io/ormar/fastapi) part of the documentation to show samples of using ormar in requests and responses in fastapi.
# 0.10.9 # 0.10.9

View File

@ -119,7 +119,7 @@ def get_potential_fields(attrs: Dict) -> Dict:
} }
def remove_excluded_parent_fields(model: Type["Model"]): def remove_excluded_parent_fields(model: Type["Model"]) -> None:
""" """
Removes pydantic fields that should be excluded from parent models Removes pydantic fields that should be excluded from parent models

View File

@ -1,5 +1,5 @@
import uuid import uuid
from typing import Callable, Collection, Dict, Optional, Set, TYPE_CHECKING, cast from typing import Callable, Collection, Dict, List, Optional, Set, TYPE_CHECKING, cast
import ormar import ormar
from ormar.exceptions import ModelPersistenceError from ormar.exceptions import ModelPersistenceError
@ -275,9 +275,7 @@ class SavePrepareMixin(RelationMixin, AliasMixin):
:rtype: int :rtype: int
""" """
for field in fields_list: for field in fields_list:
values = getattr(self, field.name) or [] values = self._get_field_values(name=field.name)
if not isinstance(values, list):
values = [values]
for value in values: for value in values:
if follow: if follow:
update_count = await value.save_related( update_count = await value.save_related(
@ -299,3 +297,17 @@ class SavePrepareMixin(RelationMixin, AliasMixin):
update_count=update_count, update_count=update_count,
) )
return update_count return update_count
def _get_field_values(self, name: str) -> List:
"""
Extract field values and ensures it is a list.
:param name: name of the field
:type name: str
:return: list of values
:rtype: List
"""
values = getattr(self, name) or []
if not isinstance(values, list):
values = [values]
return values

View File

@ -107,7 +107,7 @@ class Model(ModelRow):
await self.signals.post_save.send(sender=self.__class__, instance=self) await self.signals.post_save.send(sender=self.__class__, instance=self)
return self return self
async def save_related( # noqa: CCR001 async def save_related( # noqa: CCR001, CFQ002
self, self,
follow: bool = False, follow: bool = False,
save_all: bool = False, save_all: bool = False,

View File

@ -552,7 +552,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
return items.get("__all__") return items.get("__all__")
return items return items
def _extract_nested_models( # noqa: CCR001 def _extract_nested_models( # noqa: CCR001, CFQ002
self, self,
relation_map: Dict, relation_map: Dict,
dict_instance: Dict, dict_instance: Dict,

View File

@ -1,5 +1,4 @@
import datetime import datetime
from typing import List, Optional
import databases import databases
import pytest import pytest
@ -7,8 +6,6 @@ import sqlalchemy as sa
from sqlalchemy import create_engine from sqlalchemy import create_engine
import ormar import ormar
from ormar import ModelDefinitionError, property_field
from ormar.exceptions import ModelError
from tests.settings import DATABASE_URL from tests.settings import DATABASE_URL
metadata = sa.MetaData() metadata = sa.MetaData()
@ -16,6 +13,24 @@ db = databases.Database(DATABASE_URL)
engine = create_engine(DATABASE_URL) engine = create_engine(DATABASE_URL)
class User(ormar.Model):
class Meta(ormar.ModelMeta):
tablename = "users"
metadata = metadata
database = db
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=50, unique=True, index=True)
class RelationalAuditModel(ormar.Model):
class Meta:
abstract = True
created_by: User = ormar.ForeignKey(User, nullable=False)
updated_by: User = ormar.ForeignKey(User, nullable=False)
class AuditModel(ormar.Model): class AuditModel(ormar.Model):
class Meta: class Meta:
abstract = True abstract = True
@ -48,6 +63,26 @@ class Category(DateFieldsModel, AuditModel):
code: int = ormar.Integer() code: int = ormar.Integer()
class Item(DateFieldsModel, AuditModel):
class Meta(ormar.ModelMeta):
tablename = "items"
exclude_parent_fields = ["updated_by", "updated_date"]
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=50, unique=True, index=True)
code: int = ormar.Integer()
updated_by: str = ormar.String(max_length=100, default="Bob")
class Gun(RelationalAuditModel, DateFieldsModel):
class Meta(ormar.ModelMeta):
tablename = "guns"
exclude_parent_fields = ["updated_by"]
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=50)
@pytest.fixture(autouse=True, scope="module") @pytest.fixture(autouse=True, scope="module")
def create_test_database(): def create_test_database():
metadata.create_all(engine) metadata.create_all(engine)
@ -65,3 +100,48 @@ def test_model_definition():
assert "updated_date" not in model_fields assert "updated_date" not in model_fields
assert "updated_date" not in sqlalchemy_columns assert "updated_date" not in sqlalchemy_columns
assert "updated_date" not in pydantic_columns assert "updated_date" not in pydantic_columns
assert "updated_by" not in Gun.Meta.model_fields
assert "updated_by" not in Gun.Meta.table.c
assert "updated_by" not in Gun.__fields__
@pytest.mark.asyncio
async def test_model_works_as_expected():
async with db:
async with db.transaction(force_rollback=True):
test = await Category(name="Cat", code=2, created_by="Joe").save()
assert test.created_date is not None
test2 = await Category.objects.get(pk=test.pk)
assert test2.name == "Cat"
assert test2.created_by == "Joe"
@pytest.mark.asyncio
async def test_exclude_with_redefinition():
async with db:
async with db.transaction(force_rollback=True):
test = await Item(name="Item", code=3, created_by="Anna").save()
assert test.created_date is not None
assert test.updated_by == "Bob"
test2 = await Item.objects.get(pk=test.pk)
assert test2.name == "Item"
assert test2.code == 3
@pytest.mark.asyncio
async def test_exclude_with_relation():
async with db:
async with db.transaction(force_rollback=True):
user = await User(name="Michaił Kałasznikow").save()
test = await Gun(name="AK47", created_by=user).save()
assert test.created_date is not None
with pytest.raises(AttributeError):
assert test.updated_by
test2 = await Gun.objects.select_related("created_by").get(pk=test.pk)
assert test2.name == "AK47"
assert test2.created_by.name == "Michaił Kałasznikow"

View File

@ -68,8 +68,10 @@ async def test_is_null():
assert tolkien.books[0].year is None assert tolkien.books[0].year is None
assert tolkien.books[0].title == "The Hobbit" assert tolkien.books[0].title == "The Hobbit"
tolkien = await Author.objects.select_related("books").paginate(1, 10).get( tolkien = (
books__year__isnull=True await Author.objects.select_related("books")
.paginate(1, 10)
.get(books__year__isnull=True)
) )
assert len(tolkien.books) == 1 assert len(tolkien.books) == 1
assert tolkien.books[0].year is None assert tolkien.books[0].year is None