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 (?)
This commit is contained in:
Ankush Menat 2025-06-10 15:02:37 +05:30
parent 4292fc9005
commit eb77ddab69
3 changed files with 96 additions and 1 deletions

View file

@ -1993,7 +1993,7 @@ import frappe._optimizations
from frappe.cache_manager import clear_cache, reset_metadata_version 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.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.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.model.meta import get_meta
from frappe.realtime import publish_progress, publish_realtime from frappe.realtime import publish_progress, publish_realtime
from frappe.utils import get_traceback, mock, parse_json, safe_eval from frappe.utils import get_traceback, mock, parse_json, safe_eval

View file

@ -10,6 +10,7 @@ from functools import wraps
from types import MappingProxyType from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeAlias, Union, overload from typing import TYPE_CHECKING, Any, Literal, Optional, TypeAlias, Union, overload
from typing_extensions import Self, override
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
import frappe import frappe
@ -138,6 +139,13 @@ def get_doc_from_dict(data: dict[str, Any], **kwargs) -> "Document":
raise ImportError(data["doctype"]) 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): class Document(BaseDocument):
"""All controllers inherit from `Document`.""" """All controllers inherit from `Document`."""
@ -1933,3 +1941,62 @@ def _document_values_generator(
def unlock_document(doctype: str, name: str): def unlock_document(doctype: str, name: str):
frappe.get_doc(doctype, name).unlock() frappe.get_doc(doctype, name).unlock()
frappe.msgprint(frappe._("Document Unlocked"), alert=True) 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

View file

@ -7,6 +7,7 @@ from unittest.mock import Mock, patch
import frappe import frappe
from frappe.app import make_form_dict from frappe.app import make_form_dict
from frappe.core.doctype.doctype.test_doctype import new_doctype 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.desk.doctype.note.note import Note
from frappe.model.naming import make_autoname, parse_naming_series, revert_series_if_last from frappe.model.naming import make_autoname, parse_naming_series, revert_series_if_last
from frappe.tests import IntegrationTestCase from frappe.tests import IntegrationTestCase
@ -524,6 +525,33 @@ class TestDocument(IntegrationTestCase):
changed_val = frappe.db.get_single_value(c.doctype, key) changed_val = frappe.db.get_single_value(c.doctype, key)
self.assertEqual(val, changed_val) 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): class TestDocumentWebView(IntegrationTestCase):
def get(self, path, user="Guest"): def get(self, path, user="Guest"):