fix: append and extend without touching attributes

This commit is contained in:
Ankush Menat 2025-06-11 10:07:48 +05:30
parent 10c3c9ea9b
commit 893a87df86
2 changed files with 96 additions and 72 deletions

View file

@ -21,7 +21,7 @@ from frappe.core.doctype.server_script.server_script_utils import run_server_scr
from frappe.desk.form.document_follow import follow_document
from frappe.integrations.doctype.webhook import run_webhooks
from frappe.model import optional_fields, table_fields
from frappe.model.base_document import BaseDocument, get_controller
from frappe.model.base_document import BaseDocument, D, get_controller
from frappe.model.docstatus import DocStatus
from frappe.model.naming import set_new_name, validate_name
from frappe.model.utils import is_virtual_doctype, simple_singledispatch
@ -1983,11 +1983,24 @@ class LazyDocument:
@override
def get(self: Document, key, filters=None, limit=None, default=None):
# Ensure that table descriptor is triggered at least once
if isinstance(key, str) and key in self._table_fieldnames:
# Trigger populating of __dict__
getattr(self, key, None)
return super().get(key, filters, limit, default)
@override
def extend(self: Document, key, value):
# Ensure that table descriptor is triggered at least once
if isinstance(key, str) and key in self._table_fieldnames:
getattr(self, key, None)
return super().extend(key, value)
@override
def append(self, key: str, value: D | dict | None = None, position: int = -1) -> D:
if isinstance(key, str) and key in self._table_fieldnames:
getattr(self, key, None)
return super().append(key, value, position)
@override
def db_update_all(self):
self.db_update()

View file

@ -527,76 +527,6 @@ class TestDocument(IntegrationTestCase):
changed_val = frappe.db.get_single_value(c.doctype, key)
self.assertEqual(val, changed_val)
def test_lazy_documents(self):
# Warmup meta etc
_ = frappe.get_lazy_doc("User", "Guest")
eager_guest: User = frappe.get_doc("User", "Guest")
# Only one query for parent document
with self.assertQueryCount(1):
guest: User = frappe.get_lazy_doc("User", "Guest")
self.assertEqual(guest.user_type, "Website User")
# Only one query for one table access
with self.assertQueryCount(1):
guest_role = guest.roles[0]
self.assertEqual(guest_role.role, "Guest")
self.assertIsInstance(guest_role, type(eager_guest.roles[0]))
# Only one query for one table access
with self.assertQueryCount(1):
_ = guest.role_profiles
# No queries for repeat access, same object
with self.assertQueryCount(0):
guest_role_repeat_access = guest.roles[0]
self.assertIs(guest_role, guest_role_repeat_access)
# Same object after first access
with self.assertQueryCount(0):
self.assertIs(guest.roles, guest.get("roles"))
# things accessing __dict__ by default should be updated too
self.assertTrue(frappe.get_lazy_doc("User", "Guest").get("roles"))
def test_lazy_doc_efficient_saves(self):
# Only touched tables and self should be updated
guest = frappe.get_lazy_doc("User", "Guest")
with self.assertQueryCount(1):
guest.db_update_all()
guest = frappe.get_lazy_doc("User", "Guest")
_ = guest.roles
with self.assertQueryCount(1 + len(guest.roles)):
guest.db_update_all()
# Save should works, it won't be efficient because internal code will just trigger fetching
# of child tables to resave them.
guest.save()
def test_lazy_magic(self):
self.assertIsNone(getattr(LazyChildTable, "__set__", None))
guest = frappe.get_lazy_doc("User", "Guest")
# table fields will be populated on first access
self.assertIsNone(guest.__dict__.get("roles"))
roles = guest.roles
self.assertIs(guest.__dict__.get("roles"), roles)
# Allow overriding from user code
roles_copy = deepcopy(roles)
guest.roles = roles_copy
self.assertIs(guest.__dict__.get("roles"), roles_copy)
with patch(f"{LazyChildTable.__module__}.{LazyChildTable.__name__}.__get__") as getter:
_ = guest.roles
self.assertFalse(getter.called)
guest = frappe.get_lazy_doc("User", "Guest")
with patch(f"{LazyChildTable.__module__}.{LazyChildTable.__name__}.__get__") as getter:
_ = guest.roles
self.assertTrue(getter.called)
class TestDocumentWebView(IntegrationTestCase):
def get(self, path, user="Guest"):
@ -734,3 +664,84 @@ class TestDocumentWebView(IntegrationTestCase):
)
self.assertEqual(sent_docs - all_docs, set(), "All docs should be inserted")
self.assertEqual(sent_child_docs - all_child_docs, set(), "All child docs should be inserted")
class TestLazyDocument(IntegrationTestCase):
def test_lazy_documents(self):
# Warmup meta etc
_ = frappe.get_lazy_doc("User", "Guest")
eager_guest: User = frappe.get_doc("User", "Guest")
# Only one query for parent document
with self.assertQueryCount(1):
guest: User = frappe.get_lazy_doc("User", "Guest")
self.assertEqual(guest.user_type, "Website User")
# Only one query for one table access
with self.assertQueryCount(1):
guest_role = guest.roles[0]
self.assertEqual(guest_role.role, "Guest")
self.assertIsInstance(guest_role, type(eager_guest.roles[0]))
# Only one query for one table access
with self.assertQueryCount(1):
_ = guest.role_profiles
# No queries for repeat access, same object
with self.assertQueryCount(0):
guest_role_repeat_access = guest.roles[0]
self.assertIs(guest_role, guest_role_repeat_access)
# Same object after first access
with self.assertQueryCount(0):
self.assertIs(guest.roles, guest.get("roles"))
# things accessing __dict__ by default should be updated too
self.assertTrue(frappe.get_lazy_doc("User", "Guest").get("roles"))
def test_lazy_doc_efficient_saves(self):
# Only touched tables and self should be updated
guest = frappe.get_lazy_doc("User", "Guest")
with self.assertQueryCount(1):
guest.db_update_all()
guest = frappe.get_lazy_doc("User", "Guest")
_ = guest.roles
with self.assertQueryCount(1 + len(guest.roles)):
guest.db_update_all()
# Save should works, it won't be efficient because internal code will just trigger fetching
# of child tables to resave them.
guest.save()
def test_lazy_magic(self):
self.assertIsNone(getattr(LazyChildTable, "__set__", None))
guest = frappe.get_lazy_doc("User", "Guest")
# table fields will be populated on first access
self.assertIsNone(guest.__dict__.get("roles"))
roles = guest.roles
self.assertIs(guest.__dict__.get("roles"), roles)
# Allow overriding from user code
roles_copy = deepcopy(roles)
guest.roles = roles_copy
self.assertIs(guest.__dict__.get("roles"), roles_copy)
with patch(f"{LazyChildTable.__module__}.{LazyChildTable.__name__}.__get__") as getter:
_ = guest.roles
self.assertFalse(getter.called)
guest = frappe.get_lazy_doc("User", "Guest")
with patch(f"{LazyChildTable.__module__}.{LazyChildTable.__name__}.__get__") as getter:
_ = guest.roles
self.assertTrue(getter.called)
def test_append_extend(self):
guest = frappe.get_lazy_doc("User", "Guest")
_ = guest.append("roles")
self.assertEqual(len(guest.roles), 2)
guest = frappe.get_lazy_doc("User", "Guest")
_ = guest.extend("roles", [{}])
self.assertEqual(len(guest.roles), 2)