Skip to content

Commit

Permalink
Partial model .only(...) support (#350)
Browse files Browse the repository at this point in the history
 Populates model partially
raises AttributeError for unpopulated fields
raises IncompleteInstanceError when attempting to save field that isn't populated (meaning it works only when both PK and the update_fields are ALL available.
Document usage of .only() with caveats
  • Loading branch information
grigi committed Apr 12, 2020
1 parent 5d03e2f commit 945e24a
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 8 deletions.
21 changes: 21 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@ Changelog

0.16
====
0.16.6
------
* Added support for partial models:

To create a partial model, one can do a ``.only(<fieldnames-as-strings>)`` as part of the QuerySet.
This will create model instances that only have those values fetched.

Persisting changes on the model is allowed only when:

* All the fields you want to update is specified in ``<model>.save(update_fields=[...])``
* You included the Model primary key in the `.only(...)``

To protect against common mistakes we ensure that errors get raised:

* If you access a field that is not specified, you will get an ``AttributeError``.
* If you do a ``<model>.save()`` a ``IncompleteInstanceError`` will be raised as the model is, as requested, incomplete.
* If you do a ``<model>.save(update_fields=[...])`` and you didn't include the primary key in the ``.only(...)``,
then ``IncompleteInstanceError`` will be raised indicating that updates can't be done without the primary key being known.
* If you do a ``<model>.save(update_fields=[...])`` and one of the fields in ``update_fields`` was not in the ``.only(...)``,
then ``IncompleteInstanceError`` as that field is not available to be updated.

0.16.5
------
* Moved ``Tortoise.describe_model(<MODEL>, ...)`` to ``<MODEL>.describe(...)``
Expand Down
64 changes: 64 additions & 0 deletions tests/test_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from tests.testmodels import SourceFields, StraightFields
from tortoise.contrib import test
from tortoise.exceptions import IncompleteInstanceError


class TestOnlyStraight(test.TestCase):
async def setUp(self) -> None:
self.model = StraightFields
self.instance = await self.model.create(chars="Test")

async def test_get(self):
instance_part = await self.model.get(chars="Test").only("chars", "blip")

self.assertEqual(instance_part.chars, "Test")
with self.assertRaises(AttributeError):
_ = instance_part.nullable

async def test_filter(self):
instances = await self.model.filter(chars="Test").only("chars", "blip")

self.assertEqual(len(instances), 1)
self.assertEqual(instances[0].chars, "Test")
with self.assertRaises(AttributeError):
_ = instances[0].nullable

async def test_first(self):
instance_part = await self.model.filter(chars="Test").only("chars", "blip").first()

self.assertEqual(instance_part.chars, "Test")
with self.assertRaises(AttributeError):
_ = instance_part.nullable

async def test_save(self):
instance_part = await self.model.get(chars="Test").only("chars", "blip")

with self.assertRaisesRegex(IncompleteInstanceError, " is a partial model"):
await instance_part.save()

async def test_partial_save(self):
instance_part = await self.model.get(chars="Test").only("chars", "blip")

with self.assertRaisesRegex(IncompleteInstanceError, "Partial update not available"):
await instance_part.save(update_fields=["chars"])

async def test_partial_save_with_pk_wrong_field(self):
instance_part = await self.model.get(chars="Test").only("chars", "eyedee")

with self.assertRaisesRegex(IncompleteInstanceError, "field 'nullable' is not available"):
await instance_part.save(update_fields=["nullable"])

async def test_partial_save_with_pk(self):
instance_part = await self.model.get(chars="Test").only("chars", "eyedee")

instance_part.chars = "Test1"
await instance_part.save(update_fields=["chars"])

instance2 = await self.model.get(pk=self.instance.pk)
self.assertEqual(instance2.chars, "Test1")


class TestOnlySource(TestOnlyStraight):
async def setUp(self) -> None:
self.model = SourceFields # type: ignore
self.instance = await self.model.create(chars="Test")
6 changes: 6 additions & 0 deletions tortoise/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ class DoesNotExist(OperationalError):
"""


class IncompleteInstanceError(OperationalError):
"""
The IncompleteInstanceError exception is raised when a partial model is attempted to be persisted.
"""


class DBConnectionError(BaseORMException, ConnectionError):
"""
The DBConnectionError is raised when problems with connecting to db occurs
Expand Down
47 changes: 40 additions & 7 deletions tortoise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from tortoise.backends.base.client import BaseDBAsyncClient
from tortoise.exceptions import (
ConfigurationError,
IncompleteInstanceError,
IntegrityError,
OperationalError,
TransactionManagementError,
Expand Down Expand Up @@ -597,6 +598,7 @@ class Model(metaclass=ModelMeta):
def __init__(self, **kwargs: Any) -> None:
# self._meta is a very common attribute lookup, lets cache it.
meta = self._meta
self._partial = False
self._saved_in_db = False
self._custom_generated_pk = False

Expand Down Expand Up @@ -643,17 +645,31 @@ def __init__(self, **kwargs: Any) -> None:
@classmethod
def _init_from_db(cls: Type[MODEL], **kwargs: Any) -> MODEL:
self = cls.__new__(cls)
self._partial = False
self._saved_in_db = True

meta = self._meta

for key, model_field, field in meta.db_native_fields:
setattr(self, model_field, kwargs[key])
for key, model_field, field in meta.db_default_fields:
value = kwargs[key]
setattr(self, model_field, None if value is None else field.field_type(value))
for key, model_field, field in meta.db_complex_fields:
setattr(self, model_field, field.to_python_value(kwargs[key]))
try:
# This is like so for performance reasons.
# We want to avoid conditionals and calling .to_python_value()
# Native fields are fields that are already converted to/from python to DB type
# by the DB driver
for key, model_field, field in meta.db_native_fields:
setattr(self, model_field, kwargs[key])
# Fields that don't override .to_python_value() are converted without a call
# as we already know what we will be doing.
for key, model_field, field in meta.db_default_fields:
value = kwargs[key]
setattr(self, model_field, None if value is None else field.field_type(value))
# These fields need manual .to_python_value()
for key, model_field, field in meta.db_complex_fields:
setattr(self, model_field, field.to_python_value(kwargs[key]))
except KeyError:
self._partial = True
# TODO: Apply similar perf optimisation as above for partial
for key, value in kwargs.items():
setattr(self, key, meta.fields_map[key].to_python_value(value))

return self

Expand Down Expand Up @@ -698,9 +714,26 @@ async def save(
This is the subset of fields that should be updated.
If the object needs to be created ``update_fields`` will be ignored.
:param using_db: Specific DB connection to use instead of default bound
:raises IncompleteInstanceError: If the model is partial and the fields are not available for persistance.
"""
db = using_db or self._meta.db
executor = db.executor_class(model=self.__class__, db=db)
if self._partial:
if update_fields:
for field in update_fields:
if not hasattr(self, self._meta.pk_attr):
raise IncompleteInstanceError(
f"{self.__class__.__name__} is a partial model without primary key fetchd. Partial update not available"
)
if not hasattr(self, field):
raise IncompleteInstanceError(
f"{self.__class__.__name__} is a partial model, field '{field}' is not available"
)
else:
raise IncompleteInstanceError(
f"{self.__class__.__name__} is a partial model, can only be saved with the relevant update_field provided"
)
if self._saved_in_db:
await executor.execute_update(self, update_fields)
else:
Expand Down
38 changes: 37 additions & 1 deletion tortoise/queryset.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ def prefetch_related(self, *args: Union[str, Prefetch]) -> "QuerySetSingle[MODEL
def annotate(self, **kwargs: Function) -> "QuerySetSingle[MODEL]":
... # pragma: nocoverage

def only(self, *fields_for_select: str) -> "QuerySetSingle[MODEL]":
... # pragma: nocoverage

def values_list(self, *fields_: str, flat: bool = False) -> "ValuesListQuery":
... # pragma: nocoverage

Expand Down Expand Up @@ -232,6 +235,7 @@ class QuerySet(AwaitableQuery[MODEL]):
"_db",
"_limit",
"_offset",
"_fields_for_select",
"_filter_kwargs",
"_orderings",
"_q_objects",
Expand All @@ -255,6 +259,7 @@ def __init__(self, model: Type[MODEL]) -> None:
self._distinct: bool = False
self._having: Dict[str, Any] = {}
self._custom_filters: Dict[str, dict] = {}
self._fields_for_select: Tuple[str, ...] = ()

def _clone(self) -> "QuerySet[MODEL]":
queryset = QuerySet.__new__(QuerySet)
Expand All @@ -269,6 +274,7 @@ def _clone(self) -> "QuerySet[MODEL]":
queryset._db = self._db
queryset._limit = self._limit
queryset._offset = self._offset
queryset._fields_for_select = self._fields_for_select
queryset._filter_kwargs = copy(self._filter_kwargs)
queryset._orderings = copy(self._orderings)
queryset._joined_tables = copy(self._joined_tables)
Expand Down Expand Up @@ -550,6 +556,28 @@ def get_or_none(self, *args: Q, **kwargs: Any) -> QuerySetSingle[Optional[MODEL]
queryset._single = True
return queryset # type: ignore

def only(self, *fields_for_select: str) -> "QuerySet[MODEL]":
"""
Fetch ONLY the specified fields to create a partial model.
Persisting changes on the model is allowed only when:
* All the fields you want to update is specified in ``<model>.save(update_fields=[...])``
* You included the Model primary key in the `.only(...)``
To protect against common mistakes we ensure that errors get raised:
* If you access a field that is not specified, you will get an ``AttributeError``.
* If you do a ``<model>.save()`` a ``IncompleteInstanceError`` will be raised as the model is, as requested, incomplete.
* If you do a ``<model>.save(update_fields=[...])`` and you didn't include the primary key in the ``.only(...)``,
then ``IncompleteInstanceError`` will be raised indicating that updates can't be done without the primary key being known.
* If you do a ``<model>.save(update_fields=[...])`` and one of the fields in ``update_fields`` was not in the ``.only(...)``,
then ``IncompleteInstanceError`` as that field is not available to be updated.
"""
queryset = self._clone()
queryset._fields_for_select = fields_for_select
return queryset

def prefetch_related(self, *args: Union[str, Prefetch]) -> "QuerySet[MODEL]":
"""
Like ``.fetch_related()`` on instance, but works on all objects in QuerySet.
Expand Down Expand Up @@ -612,7 +640,15 @@ def using_db(self, _db: BaseDBAsyncClient) -> "QuerySet[MODEL]":
return queryset

def _make_query(self) -> None:
self.query = copy(self.model._meta.basequery_all_fields)
if self._fields_for_select:
table = self.model._meta.basetable
db_fields_for_select = [
table[self.model._meta.fields_db_projection[field]].as_(field)
for field in self._fields_for_select
]
self.query = copy(self.model._meta.basequery).select(*db_fields_for_select)
else:
self.query = copy(self.model._meta.basequery_all_fields)
self.resolve_filters(
model=self.model,
q_objects=self._q_objects,
Expand Down

0 comments on commit 945e24a

Please sign in to comment.