bump version, update docs

This commit is contained in:
collerek
2020-12-04 15:10:00 +01:00
parent 00ab8a6d1d
commit f071d4538e
8 changed files with 326 additions and 13 deletions

View File

@ -17,6 +17,8 @@ To build an ormar model you simply need to inherit a `ormar.Model` class.
Next assign one or more of the [Fields][fields] as a class level variables.
#### Basic Field Types
Each table **has to** have a primary key column, which you specify by setting `primary_key=True` on selected field.
Only one primary key column is allowed.
@ -37,7 +39,212 @@ You can disable by passing `autoincremant=False`.
id: int = ormar.Integer(primary_key=True, autoincrement=False)
```
### Fields names vs Column names
#### Non Database Fields
Note that if you need a normal pydantic field in your model (used to store value on model or pass around some value) you can define a
field with parameter `pydantic_only=True`.
Fields created like this are added to the `pydantic` model fields -> so are subject to validation according to `Field` type,
also appear in `dict()` and `json()` result.
The difference is that **those fields are not saved in the database**. So they won't be included in underlying sqlalchemy `columns`,
or `table` variables (check [Internals][Internals] section below to see how you can access those if you need).
Subsequently `pydantic_only` fields won't be included in migrations or any database operation (like `save`, `update` etc.)
Fields like those can be passed around into payload in `fastapi` request and will be returned in `fastapi` response
(of course only if you set their value somewhere in your code as the value is **not** fetched from the db.
If you pass a value in `fastapi` `request` and return the same instance that `fastapi` constructs for you in `request_model`
you should get back exactly same value in `response`.).
!!!warning
`pydantic_only=True` fields are always **Optional** and it cannot be changed (otherwise db load validation would fail)
!!!tip
`pydantic_only=True` fields are a good solution if you need to pass additional information from outside of your API
(i.e. frontend). They are not stored in db but you can access them in your `APIRoute` code and they also have `pydantic` validation.
```Python hl_lines="18"
--8<-- "../docs_src/models/docs014.py"
```
If you combine `pydantic_only=True` field with `default` parameter and do not pass actual value in request you will always get default value.
Since it can be a function you can set `default=datetime.datetime.now` and get current timestamp each time you call an endpoint etc.
!!!note
Note that both `pydantic_only` and `property_field` decorated field can be included/excluded in both `dict()` and `fastapi`
response with `include`/`exclude` and `response_model_include`/`response_model_exclude` accordingly.
```python
# <==part of code removed for clarity==>
class User(ormar.Model):
class Meta:
tablename: str = "users2"
metadata = metadata
database = database
id: int = ormar.Integer(primary_key=True)
email: str = ormar.String(max_length=255, nullable=False)
password: str = ormar.String(max_length=255)
first_name: str = ormar.String(max_length=255)
last_name: str = ormar.String(max_length=255)
category: str = ormar.String(max_length=255, nullable=True)
timestamp: datetime.datetime = ormar.DateTime(
pydantic_only=True, default=datetime.datetime.now
)
# <==part of code removed for clarity==>
app =FastAPI()
@app.post("/users/")
async def create_user(user: User):
return await user.save()
# <==part of code removed for clarity==>
def test_excluding_fields_in_endpoints():
client = TestClient(app)
with client as client:
timestamp = datetime.datetime.now()
user = {
"email": "test@domain.com",
"password": "^*^%A*DA*IAAA",
"first_name": "John",
"last_name": "Doe",
"timestamp": str(timestamp),
}
response = client.post("/users/", json=user)
assert list(response.json().keys()) == [
"id",
"email",
"first_name",
"last_name",
"category",
"timestamp",
]
# returned is the same timestamp
assert response.json().get("timestamp") == str(timestamp).replace(" ", "T")
# <==part of code removed for clarity==>
```
#### Property fields
Sometimes it's desirable to do some kind of calculation on the model instance. One of the most common examples can be concatenating
two or more fields. Imagine you have `first_name` and `last_name` fields on your model, but would like to have `full_name` in the result
of the `fastapi` query.
You can create a new `pydantic` model with a `method` that accepts only `self` (so like default python `@property`)
and populate it in your code.
But it's so common that `ormar` has you covered. You can "materialize" a `property_field` on you `Model`.
!!!warning
`property_field` fields are always **Optional** and it cannot be changed (otherwise db load validation would fail)
```Python hl_lines="20-22"
--8<-- "../docs_src/models/docs015.py"
```
!!!warning
The decorated function has to accept only one parameter, and that parameter have to be `self`.
If you try to decorate a function with more parameters `ormar` will raise `ModelDefinitionError`.
Sample:
```python
# will raise ModelDefinitionError
@property_field
def prefixed_name(self, prefix="prefix_"):
return 'custom_prefix__' + self.name
# will raise ModelDefinitionError
# (calling first param something else than 'self' is a bad practice anyway)
@property_field
def prefixed_name(instance):
return 'custom_prefix__' + self.name
```
Note that `property_field` decorated methods do not go through verification (but that might change in future) and are only available
in the response from `fastapi` and `dict()` and `json()` methods. You cannot pass a value for this field in the request
(or rather you can but it will be discarded by ormar so really no point but no Exception will be raised).
!!!note
Note that both `pydantic_only` and `property_field` decorated field can be included/excluded in both `dict()` and `fastapi`
response with `include`/`exclude` and `response_model_include`/`response_model_exclude` accordingly.
!!!tip
Note that `@property_field` decorator is designed to replace the python `@property` decorator, you do not have to combine them.
In theory you can cause `ormar` have a failsafe mechanism, but note that i.e. `mypy` will complain about re-decorating a property.
```python
# valid and working but unnecessary and mypy will complain
@property_field
@property
def prefixed_name(self):
return 'custom_prefix__' + self.name
```
```python
# <==part of code removed for clarity==>
def gen_pass(): # note: NOT production ready
choices = string.ascii_letters + string.digits + "!@#$%^&*()"
return "".join(random.choice(choices) for _ in range(20))
class RandomModel(ormar.Model):
class Meta:
tablename: str = "random_users"
metadata = metadata
database = database
include_props_in_dict = True
id: int = ormar.Integer(primary_key=True)
password: str = ormar.String(max_length=255, default=gen_pass)
first_name: str = ormar.String(max_length=255, default="John")
last_name: str = ormar.String(max_length=255)
created_date: datetime.datetime = ormar.DateTime(
server_default=sqlalchemy.func.now()
)
@property_field
def full_name(self) -> str:
return " ".join([self.first_name, self.last_name])
# <==part of code removed for clarity==>
app =FastAPI()
# explicitly exclude property_field in this endpoint
@app.post("/random/", response_model=RandomModel, response_model_exclude={"full_name"})
async def create_user(user: RandomModel):
return await user.save()
# <==part of code removed for clarity==>
def test_excluding_property_field_in_endpoints2():
client = TestClient(app)
with client as client:
RandomModel.Meta.include_props_in_dict = True
user3 = {"last_name": "Test"}
response = client.post("/random3/", json=user3)
assert list(response.json().keys()) == [
"id",
"password",
"first_name",
"last_name",
"created_date",
]
# despite being decorated with property_field if you explictly exclude it it will be gone
assert response.json().get("full_name") is None
# <==part of code removed for clarity==>
```
#### Fields names vs Column names
By default names of the fields will be used for both the underlying `pydantic` model and `sqlalchemy` table.
@ -330,7 +537,7 @@ You can set this parameter by providing `Meta` class `constraints` argument.
--8<-- "../docs_src/models/docs006.py"
```
## Initialization
## Model Initialization
There are two ways to create and persist the `Model` instance in the database.
@ -560,3 +767,4 @@ For example to list table model fields you can:
[sqlalchemy table creation]: https://docs.sqlalchemy.org/en/13/core/metadata.html#creating-and-dropping-database-tables
[alembic]: https://alembic.sqlalchemy.org/en/latest/tutorial.html
[save status]: ../models/#model-save-status
[Internals]: #internals

View File

@ -1,3 +1,11 @@
# 0.6.2
* Performance optimization
* Fix for bug with `pydantic_only` fields being required
* Add `property_field` decorator that registers a function as a property that will
be included in `Model.dict()` and in `fastapi` response
* Update docs
# 0.6.1
* Explicitly set None to excluded nullable fields to avoid pydantic setting a default value (fix [#60][#60]).

View File

@ -0,0 +1,18 @@
import databases
import sqlalchemy
import ormar
database = databases.Database("sqlite:///db.sqlite")
metadata = sqlalchemy.MetaData()
class Course(ormar.Model):
class Meta:
database = database
metadata = metadata
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=100)
completed: bool = ormar.Boolean(default=False)
non_db_field: str = ormar.String(max_length=100, pydantic_only=True)

View File

@ -0,0 +1,23 @@
import databases
import sqlalchemy
import ormar
from ormar import property_field
database = databases.Database("sqlite:///db.sqlite")
metadata = sqlalchemy.MetaData()
class Course(ormar.Model):
class Meta:
database = database
metadata = metadata
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=100)
completed: bool = ormar.Boolean(default=False)
@property_field
def prefixed_name(self):
return 'custom_prefix__' + self.name

View File

@ -31,7 +31,7 @@ class UndefinedType: # pragma no cover
Undefined = UndefinedType()
__version__ = "0.6.1"
__version__ = "0.6.2"
__all__ = [
"Integer",
"BigInteger",

View File

@ -177,7 +177,11 @@ class ModelTableProxy:
@classmethod
def populate_default_values(cls, new_kwargs: Dict) -> Dict:
for field_name, field in cls.Meta.model_fields.items():
if field_name not in new_kwargs and field.has_default(use_server=False):
if (
field_name not in new_kwargs
and field.has_default(use_server=False)
and not field.pydantic_only
):
new_kwargs[field_name] = field.get_default()
# clear fields with server_default set as None
if field.server_default is not None and not new_kwargs.get(field_name):

View File

@ -318,8 +318,6 @@ class NewBaseModel(
exclude_none: bool = False,
nested: bool = False,
) -> "DictStrAny": # noqa: A003'
# callable_name = inspect.currentframe().f_back.f_code.co_name
# print('dict', callable_name)
dict_instance = super().dict(
include=include,
exclude=self._update_excluded_with_related_not_required(exclude, nested),

View File

@ -87,10 +87,10 @@ class User(ormar.Model):
database = database
id: int = ormar.Integer(primary_key=True)
email: str = ormar.String(max_length=255, nullable=False)
email: str = ormar.String(max_length=255)
password: str = ormar.String(max_length=255, nullable=True)
first_name: str = ormar.String(max_length=255, nullable=False)
last_name: str = ormar.String(max_length=255, nullable=False)
first_name: str = ormar.String(max_length=255)
last_name: str = ormar.String(max_length=255)
category: str = ormar.String(max_length=255, nullable=True)
@ -102,10 +102,13 @@ class User2(ormar.Model):
id: int = ormar.Integer(primary_key=True)
email: str = ormar.String(max_length=255, nullable=False)
password: str = ormar.String(max_length=255, nullable=False)
first_name: str = ormar.String(max_length=255, nullable=False)
last_name: str = ormar.String(max_length=255, nullable=False)
password: str = ormar.String(max_length=255)
first_name: str = ormar.String(max_length=255)
last_name: str = ormar.String(max_length=255)
category: str = ormar.String(max_length=255, nullable=True)
timestamp: datetime.datetime = ormar.DateTime(
pydantic_only=True, default=datetime.datetime.now
)
@pytest.fixture(autouse=True, scope="module")
@ -150,6 +153,12 @@ async def create_user6(user: RandomModel):
return user.dict()
@app.post("/random3/", response_model=RandomModel, response_model_exclude={"full_name"})
async def create_user7(user: RandomModel):
user = await user.save()
return user.dict()
def test_excluding_fields_in_endpoints():
client = TestClient(app)
with client as client:
@ -184,6 +193,32 @@ def test_excluding_fields_in_endpoints():
response = client.post("/users3/", json=user)
assert list(response.json().keys()) == ["email", "first_name", "last_name"]
timestamp = datetime.datetime.now()
user3 = {
"email": "test@domain.com",
"password": "^*^%A*DA*IAAA",
"first_name": "John",
"last_name": "Doe",
"timestamp": str(timestamp),
}
response = client.post("/users4/", json=user3)
assert list(response.json().keys()) == [
"id",
"email",
"first_name",
"last_name",
"category",
"timestamp",
]
assert response.json().get("timestamp") == str(timestamp).replace(" ", "T")
resp_dict = response.json()
resp_dict.update({"password": "random"})
user_instance = User2(**resp_dict)
assert user_instance.timestamp is not None
assert isinstance(user_instance.timestamp, datetime.datetime)
assert user_instance.timestamp == timestamp
response = client.post("/users4/", json=user)
assert list(response.json().keys()) == [
"id",
@ -191,13 +226,16 @@ def test_excluding_fields_in_endpoints():
"first_name",
"last_name",
"category",
"timestamp",
]
assert response.json().get("timestamp") != str(timestamp).replace(" ", "T")
assert response.json().get("timestamp") is not None
def test_adding_fields_in_endpoints():
client = TestClient(app)
with client as client:
user3 = {"last_name": "Test"}
user3 = {"last_name": "Test", "full_name": "deleted"}
response = client.post("/random/", json=user3)
assert list(response.json().keys()) == [
"id",
@ -238,3 +276,19 @@ def test_adding_fields_in_endpoints2():
"full_name",
]
assert response.json().get("full_name") == "John Test"
def test_excluding_property_field_in_endpoints2():
client = TestClient(app)
with client as client:
RandomModel.Meta.include_props_in_dict = True
user3 = {"last_name": "Test"}
response = client.post("/random3/", json=user3)
assert list(response.json().keys()) == [
"id",
"password",
"first_name",
"last_name",
"created_date",
]
assert response.json().get("full_name") is None