From ab2bd94cb910cb097558bed53b084135a1c517d1 Mon Sep 17 00:00:00 2001 From: Paul Clinton Date: Fri, 22 May 2026 15:08:18 +0530 Subject: [PATCH] fix: include child table metadata in get_doctype_info (#192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_doctype_info previously returned only top-level fields, so callers hitting create_document for a parent doctype (Sales Order, Purchase Order, etc.) had no way to discover the child DocType's own fields and ended up creating the parent with empty child tables. create_document's own description already instructs the model to call get_doctype_info first, so the gap left the workflow broken. Add a child_tables list to the response — one entry per Table / Table MultiSelect field — including each child DocType's full field metadata (same shape as the parent fields array). Callers now have everything they need in a single tool call. Also fix is_single, which was reading meta.istable (child-table flag) instead of meta.issingle, and add a separate is_child_table key so the two concepts stop being conflated. Tests: regression guard asserting `User` exposes its `roles` child table with recursive field metadata, plus a single-vs-child-table distinguishing test against `System Settings` and `Has Role`. --- .../plugins/core/tools/metadata_tools.py | 67 ++++++++++++------- .../tests/test_metadata_tools.py | 38 +++++++++++ 2 files changed, 81 insertions(+), 24 deletions(-) diff --git a/frappe_assistant_core/plugins/core/tools/metadata_tools.py b/frappe_assistant_core/plugins/core/tools/metadata_tools.py index 1873233..1226271 100644 --- a/frappe_assistant_core/plugins/core/tools/metadata_tools.py +++ b/frappe_assistant_core/plugins/core/tools/metadata_tools.py @@ -88,6 +88,21 @@ def execute_tool(tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: else: raise Exception(f"Unknown metadata tool: {tool_name}") + @staticmethod + def _serialize_field(field) -> Dict[str, Any]: + """Serialize a DocField row into the shape returned by get_doctype_metadata.""" + return { + "fieldname": field.fieldname, + "label": field.label, + "fieldtype": field.fieldtype, + "options": field.options, + "reqd": field.reqd, + "read_only": field.read_only, + "hidden": field.hidden, + "default": field.default, + "description": field.description, + } + @staticmethod def get_doctype_metadata(doctype: str) -> Dict[str, Any]: """Get DocType metadata and field information""" @@ -100,40 +115,44 @@ def get_doctype_metadata(doctype: str) -> Dict[str, Any]: meta = frappe.get_meta(doctype) - # Build field information - fields = [] - for field in meta.fields: - field_info = { - "fieldname": field.fieldname, - "label": field.label, - "fieldtype": field.fieldtype, - "options": field.options, - "reqd": field.reqd, - "read_only": field.read_only, - "hidden": field.hidden, - "default": field.default, - "description": field.description, + fields = [MetadataTools._serialize_field(field) for field in meta.fields] + + link_fields = [ + {"fieldname": field.fieldname, "label": field.label, "options": field.options} + for field in meta.get_link_fields() + ] + + # Child tables: include the child DocType's own field metadata so the + # caller can build nested row objects without a second tool call (#192). + child_tables = [] + for table_field in meta.get_table_fields(): + child_doctype = table_field.options + child_entry = { + "fieldname": table_field.fieldname, + "label": table_field.label, + "fieldtype": table_field.fieldtype, + "options": child_doctype, + "reqd": table_field.reqd, + "fields": [], } - fields.append(field_info) - - # Build link fields information - link_fields = [] - for field in meta.get_link_fields(): - link_fields.append( - {"fieldname": field.fieldname, "label": field.label, "options": field.options} - ) + if child_doctype and frappe.db.exists("DocType", child_doctype): + child_meta = frappe.get_meta(child_doctype) + child_entry["fields"] = [MetadataTools._serialize_field(f) for f in child_meta.fields] + child_tables.append(child_entry) return { "success": True, "doctype": doctype, "module": meta.module, - "is_submittable": meta.is_submittable, - "is_tree": meta.is_tree, - "is_single": meta.istable, + "is_submittable": bool(meta.is_submittable), + "is_tree": bool(meta.is_tree), + "is_single": bool(meta.issingle), + "is_child_table": bool(meta.istable), "naming_rule": meta.naming_rule, "title_field": meta.title_field, "fields": fields, "link_fields": link_fields, + "child_tables": child_tables, "permissions": [p.as_dict() for p in meta.permissions], } diff --git a/frappe_assistant_core/tests/test_metadata_tools.py b/frappe_assistant_core/tests/test_metadata_tools.py index 2290378..65f11aa 100644 --- a/frappe_assistant_core/tests/test_metadata_tools.py +++ b/frappe_assistant_core/tests/test_metadata_tools.py @@ -110,6 +110,44 @@ def test_list_doctypes_custom_only(self): def test_list_doctypes_with_module_filter(self): self.skipTest("Module filter test placeholder") + def test_get_doctype_metadata_includes_child_tables(self): + """Regression guard for #192: child tables must be surfaced with their own field metadata.""" + from frappe_assistant_core.plugins.core.tools.metadata_tools import MetadataTools + + # User ships with at least one Table field ("roles" -> "Has Role") in every Frappe install. + result = MetadataTools.get_doctype_metadata("User") + + self.assertTrue(result.get("success"), result) + self.assertIn("child_tables", result) + child_tables = result["child_tables"] + self.assertIsInstance(child_tables, list) + + roles_entry = next((c for c in child_tables if c["fieldname"] == "roles"), None) + self.assertIsNotNone(roles_entry, f"Expected 'roles' in child_tables, got {child_tables}") + self.assertEqual(roles_entry["options"], "Has Role") + self.assertIn(roles_entry["fieldtype"], ("Table", "Table MultiSelect")) + + # Recursive child field metadata must be present so create_document has everything it needs. + self.assertTrue(roles_entry["fields"], "Child table 'roles' should expose its own fields") + child_field_names = {f["fieldname"] for f in roles_entry["fields"]} + self.assertIn("role", child_field_names) + + def test_get_doctype_metadata_distinguishes_single_from_child_table(self): + """Regression guard for #192: is_single must use meta.issingle, not meta.istable.""" + from frappe_assistant_core.plugins.core.tools.metadata_tools import MetadataTools + + # System Settings is a Single doctype (issingle=1, istable=0). + single_result = MetadataTools.get_doctype_metadata("System Settings") + self.assertTrue(single_result.get("success"), single_result) + self.assertTrue(single_result["is_single"]) + self.assertFalse(single_result["is_child_table"]) + + # Has Role is a child table (issingle=0, istable=1). + child_result = MetadataTools.get_doctype_metadata("Has Role") + self.assertTrue(child_result.get("success"), child_result) + self.assertFalse(child_result["is_single"]) + self.assertTrue(child_result["is_child_table"]) + class TestMetadataToolsIntegration(BaseAssistantTest): """Integration tests for metadata tools"""