diff --git a/frappe/model/document.py b/frappe/model/document.py index 1903d6dfe0..279cfcc66d 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -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() diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index 7aa5420ab0..970220bfd3 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -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)