diff --git a/docs/models.md b/docs/models.md index be91d12..a29fb70 100644 --- a/docs/models.md +++ b/docs/models.md @@ -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 diff --git a/docs/releases.md b/docs/releases.md index e931b13..f940a36 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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]). diff --git a/docs_src/models/docs014.py b/docs_src/models/docs014.py new file mode 100644 index 0000000..fc02ddb --- /dev/null +++ b/docs_src/models/docs014.py @@ -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) diff --git a/docs_src/models/docs015.py b/docs_src/models/docs015.py new file mode 100644 index 0000000..b67bff5 --- /dev/null +++ b/docs_src/models/docs015.py @@ -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 + diff --git a/ormar/__init__.py b/ormar/__init__.py index c970f7d..6e196db 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -31,7 +31,7 @@ class UndefinedType: # pragma no cover Undefined = UndefinedType() -__version__ = "0.6.1" +__version__ = "0.6.2" __all__ = [ "Integer", "BigInteger", diff --git a/ormar/models/modelproxy.py b/ormar/models/modelproxy.py index 3a82230..b545ecf 100644 --- a/ormar/models/modelproxy.py +++ b/ormar/models/modelproxy.py @@ -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): diff --git a/ormar/models/newbasemodel.py b/ormar/models/newbasemodel.py index 35a5d57..9105f7b 100644 --- a/ormar/models/newbasemodel.py +++ b/ormar/models/newbasemodel.py @@ -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), diff --git a/tests/test_excluding_fields_in_fastapi.py b/tests/test_excluding_fields_in_fastapi.py index b9398ca..a75d0e1 100644 --- a/tests/test_excluding_fields_in_fastapi.py +++ b/tests/test_excluding_fields_in_fastapi.py @@ -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