From eb77ddab69c0eb80adca9bff5cb310289d7b4704 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 10 Jun 2025 15:02:37 +0530 Subject: [PATCH] feat: Lazy loaded documents https://docs.python.org/3/howto/descriptor.html#invocation-from-an-instance After first invokation, object's __dict__ will be used... then we can assume rest of the code works as expected (?) --- frappe/__init__.py | 2 +- frappe/model/document.py | 67 +++++++++++++++++++++++++++++++++++ frappe/tests/test_document.py | 28 +++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index e26e82deac..2ea28ccb35 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1993,7 +1993,7 @@ import frappe._optimizations from frappe.cache_manager import clear_cache, reset_metadata_version from frappe.config import get_common_site_config, get_conf, get_site_config from frappe.core.doctype.system_settings.system_settings import get_system_settings -from frappe.model.document import get_doc +from frappe.model.document import get_doc, get_lazy_doc from frappe.model.meta import get_meta from frappe.realtime import publish_progress, publish_realtime from frappe.utils import get_traceback, mock, parse_json, safe_eval diff --git a/frappe/model/document.py b/frappe/model/document.py index 81793d50d9..a6b8ca3a16 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -10,6 +10,7 @@ from functools import wraps from types import MappingProxyType from typing import TYPE_CHECKING, Any, Literal, Optional, TypeAlias, Union, overload +from typing_extensions import Self, override from werkzeug.exceptions import NotFound import frappe @@ -138,6 +139,13 @@ def get_doc_from_dict(data: dict[str, Any], **kwargs) -> "Document": raise ImportError(data["doctype"]) +def get_lazy_doc(doctype: str, name: str): + controller = get_lazy_controller(doctype) + if controller: + return controller(doctype, name) + raise ImportError(doctype) + + class Document(BaseDocument): """All controllers inherit from `Document`.""" @@ -1933,3 +1941,62 @@ def _document_values_generator( def unlock_document(doctype: str, name: str): frappe.get_doc(doctype, name).unlock() frappe.msgprint(frappe._("Document Unlocked"), alert=True) + + +def get_lazy_controller(doctype): + from frappe.model.document import LazyDocument + + lazy_controllers = frappe.controllers.setdefault(f"lazy|{frappe.local.site}", {}) + if doctype not in lazy_controllers: + original_controller = get_controller(doctype) + + lazy_controller = type(f"Lazy{original_controller.__name__}", (LazyDocument, original_controller), {}) + meta = frappe.get_meta(doctype) + for fieldname, child_doctype in meta._table_doctypes.items(): + setattr(lazy_controller, fieldname, LazyChildTable(fieldname, child_doctype)) + + lazy_controllers[doctype] = lazy_controller + return lazy_controllers[doctype] + + +class LazyDocument: + """Mixin for Document class that implments lazy loading for child tables.""" + + @override + def load_children_from_db(self: Document): + """Override Document which eagerly loads child tables""" + # This is a map of loaded children, it should get erased whenever load_children_from_db is + # called to allow reloading lazily again. + for fieldname in self._table_fieldnames: + self.__dict__.pop(fieldname, None) + + @override + def get(self: Document, key, *args, **kwags): + _ = getattr(self, key, None) + return super().get(key, *args, **kwags) + + +class LazyChildTable: + __slots__ = ("doctype", "fieldname") + + def __init__(self, fieldname: str, doctype: str) -> None: + self.fieldname = fieldname + self.doctype = doctype + + def __get__(self, doc, objtype=None): + # TODO: review cached_property magic + children = frappe.db.sql( + """SELECT * FROM {table_name} + WHERE `parent`= %(parent)s + AND `parenttype`= %(parenttype)s + AND `parentfield`= %(parentfield)s + ORDER BY `idx` ASC""".format( + table_name=get_table_name(self.doctype, wrap_in_backticks=True), + ), + {"parent": str(doc.name), "parenttype": doc.doctype, "parentfield": self.fieldname}, + as_dict=True, + ) + + # Update __dict__ and convert to Document objects + doc.extend(self.fieldname, children or []) + return doc.__dict__[self.fieldname] # Note: avoid any high level access here diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index a00da96df7..890af417b6 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -7,6 +7,7 @@ from unittest.mock import Mock, patch import frappe from frappe.app import make_form_dict from frappe.core.doctype.doctype.test_doctype import new_doctype +from frappe.core.doctype.user.user import User from frappe.desk.doctype.note.note import Note from frappe.model.naming import make_autoname, parse_naming_series, revert_series_if_last from frappe.tests import IntegrationTestCase @@ -524,6 +525,33 @@ 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])) + + # 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) + + 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")) + class TestDocumentWebView(IntegrationTestCase): def get(self, path, user="Guest"):