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:
parent
4292fc9005
commit
eb77ddab69
3 changed files with 96 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue