From 3ad961e8dda8a824dc6aa0c797470c3536e07a08 Mon Sep 17 00:00:00 2001 From: Sumit Bhanushali Date: Fri, 19 Sep 2025 10:54:57 +0530 Subject: [PATCH 01/10] feat: support expand links in read_doc, document_list and client.get_list --- frappe/api/v1.py | 23 ++++++++++++++++++- frappe/client.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/frappe/api/v1.py b/frappe/api/v1.py index 0006e5e5d5..ef5f80e1c0 100644 --- a/frappe/api/v1.py +++ b/frappe/api/v1.py @@ -4,6 +4,10 @@ from werkzeug.routing import Rule import frappe from frappe import _ +from frappe.desk.form.load import ( + get_title_values_for_link_and_dynamic_link_fields, + get_title_values_for_table_and_multiselect_fields, +) from frappe.utils.data import sbool @@ -11,6 +15,9 @@ def document_list(doctype: str): if frappe.form_dict.get("fields"): frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"]) + if frappe.form_dict.get("expand"): + frappe.form_dict["expand"] = json.loads(frappe.form_dict["expand"]) + # set limit of records for frappe.get_list frappe.form_dict.setdefault( "limit_page_length", @@ -74,7 +81,21 @@ def read_doc(doctype: str, name: str): doc = frappe.get_doc(doctype, name) doc.check_permission("read") doc.apply_fieldlevel_read_permissions() - return doc + if frappe.form_dict.get("expand_links") and json.loads(frappe.form_dict["expand_links"]): + meta = frappe.get_meta(doctype) + link_titles = {} + link_fields = meta.get_link_fields() + meta.get_dynamic_link_fields() + + if link_fields: + link_titles.update(get_title_values_for_link_and_dynamic_link_fields(doc, link_fields)) + + table_fields = meta.get_table_fields() + if table_fields: + link_titles.update(get_title_values_for_table_and_multiselect_fields(doc, table_fields)) + + return doc.as_dict().update({"_link_titles": link_titles}) + else: + return doc.as_dict() def execute_doc_method(doctype: str, name: str, method: str | None = None): diff --git a/frappe/client.py b/frappe/client.py index 8c893b5ce7..090d28a21b 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE import json import os +from collections import defaultdict from typing import TYPE_CHECKING import frappe @@ -37,6 +38,7 @@ def get_list( debug: bool = False, as_dict: bool = True, or_filters=None, + expand=None, ): """Return a list of records by filters, fields, ordering and limit. @@ -64,7 +66,60 @@ def get_list( ) validate_args(args) - return frappe.get_list(**args) + _list = frappe.get_list(**args) + + if not expand: + return _list + + if fields and not fields[0] == "*": + expand = [f for f in expand if f in fields] + + meta = frappe.get_meta(doctype) + + link_fields = {f.fieldname: f for f in meta.get_link_fields() + meta.get_dynamic_link_fields()} + + doctype_values = defaultdict(set) + field_to_doctype = {} + + for fieldname in expand: + if fieldname not in link_fields: + continue + e = link_fields[fieldname] + link_doctype = e.options + field_to_doctype[fieldname] = link_doctype + + for li in _list: + val = li.get(fieldname) + if val: + doctype_values[link_doctype].add(val) + + doctype_title_maps = {} + + for link_doctype, values in doctype_values.items(): + link_meta = frappe.get_meta(link_doctype) + if not link_meta.title_field or not link_meta.show_title_field_in_link: + continue + + title_field = link_meta.title_field + + records = frappe.get_all( + link_doctype, + filters={"name": ["in", list(values)]}, + fields=["name", title_field], + ) + doctype_title_maps[link_doctype] = {r["name"]: r[title_field] for r in records} + + for li in _list: + for fieldname in expand: + if fieldname not in field_to_doctype: + continue + link_doctype = field_to_doctype[fieldname] + val = li.get(fieldname) + val_title = doctype_title_maps.get(link_doctype, {}).get(val) + if val and val_title: + li["_" + fieldname + "_title"] = val_title + + return _list @frappe.whitelist() From 36012487f9f1ef376e95eb293b67f5a02008e4c7 Mon Sep 17 00:00:00 2001 From: Sumit Bhanushali Date: Fri, 3 Oct 2025 15:31:53 +0530 Subject: [PATCH 02/10] fix: updated keys and link response to return full object instead of just title --- frappe/api/v1.py | 39 ++++++++++++++++++++++++++++----------- frappe/client.py | 12 +++--------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/frappe/api/v1.py b/frappe/api/v1.py index ef5f80e1c0..5f958be8e1 100644 --- a/frappe/api/v1.py +++ b/frappe/api/v1.py @@ -81,21 +81,38 @@ def read_doc(doctype: str, name: str): doc = frappe.get_doc(doctype, name) doc.check_permission("read") doc.apply_fieldlevel_read_permissions() + doc_dict = doc.as_dict() if frappe.form_dict.get("expand_links") and json.loads(frappe.form_dict["expand_links"]): - meta = frappe.get_meta(doctype) - link_titles = {} - link_fields = meta.get_link_fields() + meta.get_dynamic_link_fields() + get_values_for_link_and_dynamic_link_fields(doc_dict) + get_values_for_table_and_multiselect_fields(doc_dict) - if link_fields: - link_titles.update(get_title_values_for_link_and_dynamic_link_fields(doc, link_fields)) + return doc_dict - table_fields = meta.get_table_fields() - if table_fields: - link_titles.update(get_title_values_for_table_and_multiselect_fields(doc, table_fields)) - return doc.as_dict().update({"_link_titles": link_titles}) - else: - return doc.as_dict() +def get_values_for_link_and_dynamic_link_fields(doc_dict): + meta = frappe.get_meta(doc_dict.doctype) + link_fields = meta.get_link_fields() + meta.get_dynamic_link_fields() + + for field in link_fields: + if not (doc_fieldvalue := getattr(doc_dict, field.fieldname, None)): + continue + + doctype = field.options if field.fieldtype == "Link" else doc_dict.get(field.options) + + link_doc = frappe.get_doc(doctype, doc_fieldvalue) + doc_dict.update({"_expanded_" + field.fieldname: link_doc}) + + +def get_values_for_table_and_multiselect_fields(doc_dict): + meta = frappe.get_meta(doc_dict.doctype) + table_fields = meta.get_table_fields() + + for field in table_fields: + if not doc_dict.get(field.fieldname): + continue + + for value in doc_dict.get(field.fieldname): + value.update(get_values_for_link_and_dynamic_link_fields(value)) def execute_doc_method(doctype: str, name: str, method: str | None = None): diff --git a/frappe/client.py b/frappe/client.py index 090d28a21b..e6a78ab29e 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -96,18 +96,12 @@ def get_list( doctype_title_maps = {} for link_doctype, values in doctype_values.items(): - link_meta = frappe.get_meta(link_doctype) - if not link_meta.title_field or not link_meta.show_title_field_in_link: - continue - - title_field = link_meta.title_field - records = frappe.get_all( link_doctype, filters={"name": ["in", list(values)]}, - fields=["name", title_field], + fields=["*"], ) - doctype_title_maps[link_doctype] = {r["name"]: r[title_field] for r in records} + doctype_title_maps[link_doctype] = {r["name"]: r for r in records} for li in _list: for fieldname in expand: @@ -117,7 +111,7 @@ def get_list( val = li.get(fieldname) val_title = doctype_title_maps.get(link_doctype, {}).get(val) if val and val_title: - li["_" + fieldname + "_title"] = val_title + li["_expanded_" + fieldname] = val_title return _list From f8c73cb25489cbe2e8bcfdedcde19cf20efa6f05 Mon Sep 17 00:00:00 2001 From: Sumit Bhanushali Date: Wed, 15 Oct 2025 12:56:23 +0530 Subject: [PATCH 03/10] fix: replace key with object when expand is given in query param --- frappe/api/v1.py | 8 ++------ frappe/client.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/frappe/api/v1.py b/frappe/api/v1.py index 5f958be8e1..9c62e884ff 100644 --- a/frappe/api/v1.py +++ b/frappe/api/v1.py @@ -4,10 +4,6 @@ from werkzeug.routing import Rule import frappe from frappe import _ -from frappe.desk.form.load import ( - get_title_values_for_link_and_dynamic_link_fields, - get_title_values_for_table_and_multiselect_fields, -) from frappe.utils.data import sbool @@ -82,7 +78,7 @@ def read_doc(doctype: str, name: str): doc.check_permission("read") doc.apply_fieldlevel_read_permissions() doc_dict = doc.as_dict() - if frappe.form_dict.get("expand_links") and json.loads(frappe.form_dict["expand_links"]): + if frappe.form_dict.get("expand_links") and frappe.form_dict["expand_links"]: get_values_for_link_and_dynamic_link_fields(doc_dict) get_values_for_table_and_multiselect_fields(doc_dict) @@ -100,7 +96,7 @@ def get_values_for_link_and_dynamic_link_fields(doc_dict): doctype = field.options if field.fieldtype == "Link" else doc_dict.get(field.options) link_doc = frappe.get_doc(doctype, doc_fieldvalue) - doc_dict.update({"_expanded_" + field.fieldname: link_doc}) + doc_dict.update({field.fieldname: link_doc}) def get_values_for_table_and_multiselect_fields(doc_dict): diff --git a/frappe/client.py b/frappe/client.py index e6a78ab29e..a2dab393bf 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -111,7 +111,7 @@ def get_list( val = li.get(fieldname) val_title = doctype_title_maps.get(link_doctype, {}).get(val) if val and val_title: - li["_expanded_" + fieldname] = val_title + li[fieldname] = val_title return _list From ed13cf252749a0b602961cb04b4943e6c7ca2841 Mon Sep 17 00:00:00 2001 From: Sumit Bhanushali Date: Thu, 16 Oct 2025 18:00:27 +0530 Subject: [PATCH 04/10] test: added test for expand getdoc and getlist --- frappe/tests/test_api.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py index aa40817bc4..0c0753c565 100644 --- a/frappe/tests/test_api.py +++ b/frappe/tests/test_api.py @@ -143,7 +143,13 @@ class TestResourceAPI(FrappeAPITestCase): def setUpClass(cls): super().setUpClass() for _ in range(20): - doc = frappe.get_doc({"doctype": "ToDo", "description": frappe.mock("paragraph")}).insert() + doc = frappe.get_doc( + { + "doctype": "ToDo", + "description": frappe.mock("paragraph"), + "allocated_to": "test@example.com", + } + ).insert() cls.GENERATED_DOCUMENTS = [] cls.GENERATED_DOCUMENTS.append(doc.name) frappe.db.commit() @@ -167,6 +173,35 @@ class TestResourceAPI(FrappeAPITestCase): self.assertIsInstance(response.json, dict) self.assertIn("data", response.json) + def test_get_list_expand(self): + response = self.get( + self.resource(self.DOCTYPE), + { + "sid": self.sid, + "filters": json.dumps([["name", "in", self.GENERATED_DOCUMENTS]]), + "fields": json.dumps(["allocated_to", "name"]), + "expand": json.dumps(["allocated_to"]), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.json, dict) + self.assertIn("data", response.json) + self.assertIn("allocated_to", response.json["data"][0]) + self.assertIsInstance(response.json["data"][0]["allocated_to"], dict) + self.assertIn("name", response.json["data"][0]["allocated_to"]) + + def test_get_doc_expand(self): + response = self.get( + self.resource(self.DOCTYPE, self.GENERATED_DOCUMENTS[0]), + { + "expand_links": json.dumps(True), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.json, dict) + self.assertIn("data", response.json) + self.assertIsInstance(response.json["data"]["allocated_to"], dict) + def test_get_list_limit(self): # test 3: fetch data with limit response = self.get(self.resource(self.DOCTYPE), {"sid": self.sid, "limit": 2}) From 8e4cf29e2c6751c3a897d15f1b23911ec9084930 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Wed, 15 Oct 2025 14:02:00 +0530 Subject: [PATCH 05/10] fix: use `sbool` to cast parameter to bool Signed-off-by: Akhil Narang --- frappe/api/v1.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/api/v1.py b/frappe/api/v1.py index 9c62e884ff..962dbc5af6 100644 --- a/frappe/api/v1.py +++ b/frappe/api/v1.py @@ -78,7 +78,7 @@ def read_doc(doctype: str, name: str): doc.check_permission("read") doc.apply_fieldlevel_read_permissions() doc_dict = doc.as_dict() - if frappe.form_dict.get("expand_links") and frappe.form_dict["expand_links"]: + if sbool(frappe.form_dict.get("expand_links")): get_values_for_link_and_dynamic_link_fields(doc_dict) get_values_for_table_and_multiselect_fields(doc_dict) From fad80b892ebb6e0cd81c5382ec12d68e18a6da15 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Fri, 17 Oct 2025 16:59:09 +0530 Subject: [PATCH 06/10] fix: reset list outside loop Signed-off-by: Akhil Narang --- frappe/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py index 0c0753c565..2bb3c872f4 100644 --- a/frappe/tests/test_api.py +++ b/frappe/tests/test_api.py @@ -142,6 +142,7 @@ class TestResourceAPI(FrappeAPITestCase): @classmethod def setUpClass(cls): super().setUpClass() + cls.GENERATED_DOCUMENTS = [] for _ in range(20): doc = frappe.get_doc( { @@ -150,7 +151,6 @@ class TestResourceAPI(FrappeAPITestCase): "allocated_to": "test@example.com", } ).insert() - cls.GENERATED_DOCUMENTS = [] cls.GENERATED_DOCUMENTS.append(doc.name) frappe.db.commit() From 4cfc0c6b55d92b68f70e1ba606b98fdf14c60c7f Mon Sep 17 00:00:00 2001 From: Sumit Bhanushali Date: Wed, 22 Oct 2025 12:12:36 +0530 Subject: [PATCH 07/10] test: added rest api test user to avoid past user creation inconsistency --- frappe/tests/test_api.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py index 2bb3c872f4..8b3dfd13c6 100644 --- a/frappe/tests/test_api.py +++ b/frappe/tests/test_api.py @@ -138,17 +138,22 @@ class FrappeAPITestCase(IntegrationTestCase): class TestResourceAPI(FrappeAPITestCase): DOCTYPE = "ToDo" GENERATED_DOCUMENTS: typing.ClassVar[list] = [] + TEST_USER = "test@restapi.com" @classmethod def setUpClass(cls): super().setUpClass() cls.GENERATED_DOCUMENTS = [] + user = frappe.get_doc( + {"doctype": "User", "email": cls.TEST_USER, "first_name": "Test User", "send_welcome_email": 0} + ).insert(ignore_permissions=True) + for _ in range(20): doc = frappe.get_doc( { "doctype": "ToDo", "description": frappe.mock("paragraph"), - "allocated_to": "test@example.com", + "allocated_to": user.name, } ).insert() cls.GENERATED_DOCUMENTS.append(doc.name) @@ -159,6 +164,7 @@ class TestResourceAPI(FrappeAPITestCase): frappe.db.commit() for name in cls.GENERATED_DOCUMENTS: frappe.delete_doc_if_exists(cls.DOCTYPE, name) + frappe.delete_doc_if_exists("User", cls.TEST_USER) frappe.db.commit() def test_unauthorized_call(self): @@ -178,7 +184,7 @@ class TestResourceAPI(FrappeAPITestCase): self.resource(self.DOCTYPE), { "sid": self.sid, - "filters": json.dumps([["name", "in", self.GENERATED_DOCUMENTS]]), + "filters": json.dumps([["name", "=", self.GENERATED_DOCUMENTS[0]]]), "fields": json.dumps(["allocated_to", "name"]), "expand": json.dumps(["allocated_to"]), }, From f5fed5b4be71f896208af441c6689eca37e7efa3 Mon Sep 17 00:00:00 2001 From: Sumit Bhanushali Date: Mon, 27 Oct 2025 13:00:43 +0530 Subject: [PATCH 08/10] perf: optimize expand link for table fields --- frappe/api/v1.py | 8 +++----- frappe/client.py | 42 ++---------------------------------------- frappe/utils/data.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 45 deletions(-) diff --git a/frappe/api/v1.py b/frappe/api/v1.py index 962dbc5af6..744ad89cdb 100644 --- a/frappe/api/v1.py +++ b/frappe/api/v1.py @@ -4,6 +4,7 @@ from werkzeug.routing import Rule import frappe from frappe import _ +from frappe.utils import attach_expanded_links from frappe.utils.data import sbool @@ -104,11 +105,8 @@ def get_values_for_table_and_multiselect_fields(doc_dict): table_fields = meta.get_table_fields() for field in table_fields: - if not doc_dict.get(field.fieldname): - continue - - for value in doc_dict.get(field.fieldname): - value.update(get_values_for_link_and_dynamic_link_fields(value)) + table_link_fieldnames = [f.fieldname for f in frappe.get_meta(field.options).get_link_fields()] + attach_expanded_links(field.options, doc_dict.get(field.fieldname), table_link_fieldnames) def execute_doc_method(doctype: str, name: str, method: str | None = None): diff --git a/frappe/client.py b/frappe/client.py index a2dab393bf..13755531cc 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -2,7 +2,6 @@ # License: MIT. See LICENSE import json import os -from collections import defaultdict from typing import TYPE_CHECKING import frappe @@ -12,7 +11,7 @@ from frappe import _ from frappe.desk.reportview import validate_args from frappe.model.db_query import check_parent_permission from frappe.model.utils import is_virtual_doctype -from frappe.utils import get_safe_filters +from frappe.utils import attach_expanded_links, get_safe_filters from frappe.utils.caching import http_cache if TYPE_CHECKING: @@ -74,44 +73,7 @@ def get_list( if fields and not fields[0] == "*": expand = [f for f in expand if f in fields] - meta = frappe.get_meta(doctype) - - link_fields = {f.fieldname: f for f in meta.get_link_fields() + meta.get_dynamic_link_fields()} - - doctype_values = defaultdict(set) - field_to_doctype = {} - - for fieldname in expand: - if fieldname not in link_fields: - continue - e = link_fields[fieldname] - link_doctype = e.options - field_to_doctype[fieldname] = link_doctype - - for li in _list: - val = li.get(fieldname) - if val: - doctype_values[link_doctype].add(val) - - doctype_title_maps = {} - - for link_doctype, values in doctype_values.items(): - records = frappe.get_all( - link_doctype, - filters={"name": ["in", list(values)]}, - fields=["*"], - ) - doctype_title_maps[link_doctype] = {r["name"]: r for r in records} - - for li in _list: - for fieldname in expand: - if fieldname not in field_to_doctype: - continue - link_doctype = field_to_doctype[fieldname] - val = li.get(fieldname) - val_title = doctype_title_maps.get(link_doctype, {}).get(val) - if val and val_title: - li[fieldname] = val_title + attach_expanded_links(doctype, _list, expand) return _list diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 5ff2105214..e37e4e0c5e 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -2776,3 +2776,47 @@ def map_trackers(url_trackers: dict, create: bool = False): frappe_trackers["utm_content"] = url_content return frappe_trackers + + +def attach_expanded_links(doctype: str, docs: list, fields_to_expand: list): + if not fields_to_expand: + return + + meta = frappe.get_meta(doctype) + + link_fields = {f.fieldname: f for f in meta.get_link_fields() + meta.get_dynamic_link_fields()} + + doctype_values = defaultdict(set) + field_to_doctype = {} + + for fieldname in fields_to_expand: + if fieldname not in link_fields: + continue + e = link_fields[fieldname] + link_doctype = e.options + field_to_doctype[fieldname] = link_doctype + + for li in docs: + val = li.get(fieldname) + if val: + doctype_values[link_doctype].add(val) + + doctype_title_maps = {} + + for link_doctype, values in doctype_values.items(): + records = frappe.get_all( + link_doctype, + filters={"name": ["in", list(values)]}, + fields=["*"], + ) + doctype_title_maps[link_doctype] = {r["name"]: r for r in records} + + for li in docs: + for fieldname in fields_to_expand: + if fieldname not in field_to_doctype: + continue + link_doctype = field_to_doctype[fieldname] + val = li.get(fieldname) + val_title = doctype_title_maps.get(link_doctype, {}).get(val) + if val and val_title: + li[fieldname] = val_title From 8955f079a1917a4dd38f00db231196eb2e23feb5 Mon Sep 17 00:00:00 2001 From: Sumit Bhanushali Date: Mon, 27 Oct 2025 13:27:13 +0530 Subject: [PATCH 09/10] fix: use get_list to only fetch permitted records --- frappe/utils/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index e37e4e0c5e..86629a4b3d 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -2804,7 +2804,7 @@ def attach_expanded_links(doctype: str, docs: list, fields_to_expand: list): doctype_title_maps = {} for link_doctype, values in doctype_values.items(): - records = frappe.get_all( + records = frappe.get_list( link_doctype, filters={"name": ["in", list(values)]}, fields=["*"], From 6f0242db6aec9fd76b0b8ba7fa6676375f356a33 Mon Sep 17 00:00:00 2001 From: Sumit Bhanushali Date: Mon, 27 Oct 2025 15:21:41 +0530 Subject: [PATCH 10/10] chore: added docstring for attach_expanded_links --- frappe/utils/data.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 86629a4b3d..03a783112d 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -2779,6 +2779,37 @@ def map_trackers(url_trackers: dict, create: bool = False): def attach_expanded_links(doctype: str, docs: list, fields_to_expand: list): + """ + Expands specified link or dynamic link fields in a list of documents by replacing + their linked values (names) with full document records. + + This function takes a list of documents and a list of link fieldnames that should be + expanded. For each specified field, it retrieves all referenced linked records from + the corresponding doctypes and replaces the link value in each document with the + full linked record (as a dict). + + Args: + doctype (str): The parent doctype of the provided documents. + docs (list[dict]): A list of document dictionaries whose link fields are to be expanded. + fields_to_expand (list[str]): A list of fieldnames corresponding to link or dynamic + link fields that should be expanded. + + Returns: + None: The function modifies the `docs` list in place. + + Example: + >>> docs = [{"customer": "CUST-001"}, {"customer": "CUST-002"}] + >>> attach_expanded_links("Sales Invoice", docs, ["customer"]) + >>> docs[0]["customer"] + { + "name": "CUST-001", + "customer_name": "John Doe", + "customer_group": "Retail", + ... + } + + """ + if not fields_to_expand: return