From eb8e683c267f826808fab6645acff73833daa4d9 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Tue, 21 Apr 2026 18:20:16 +0530 Subject: [PATCH 1/6] feat: add delete_custom_fields function to remove custom fields from doctypes --- .../doctype/custom_field/custom_field.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 41e86656e9..d72d26603f 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -443,3 +443,64 @@ def _update_fieldname_references(field: CustomField, old_fieldname: str, new_fie "insert_after", new_fieldname, ) + + +def delete_custom_fields(custom_fields: dict): + """ + Delete custom fields from the given doctypes. + + :param custom_fields: Dictionary of doctypes with fields to be deleted. + + --- + Structure of the `custom_fields` dictionary: + + ```py + # first structure + { + "DocType1": ["field1", "field2", ...], + "DocType2": ["field1", "field2", ...], + ... + } + + # second structure + { + "DocType1": [ + {"fieldname": "field1", ...}, + {"fieldname": "field2", ...}, + ... + ], + "DocType2": [ + {"fieldname": "field1", ...}, + {"fieldname": "field2", ...}, + ... + ], + ... + } + ``` + + """ + for doctype, fields in custom_fields.items(): + fieldnames = [] + + if isinstance(fields, (list, tuple, set)): + for field in fields: + if isinstance(field, str): + fieldnames.append(field) + elif isinstance(field, dict) and field.get("fieldname"): + fieldnames.append(field["fieldname"]) + + # avoid redundant values in SQL IN clause + fieldnames = list(set(fieldnames)) + + if not fieldnames: + continue + + frappe.db.delete( + "Custom Field", + { + "fieldname": ("in", fieldnames), + "dt": doctype, + }, + ) + + frappe.clear_cache(doctype=doctype) From 0ed3651767b41f36abb327f0ce270dfd67a650f8 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Tue, 21 Apr 2026 18:21:00 +0530 Subject: [PATCH 2/6] test: add test for delete_custom_fields function --- .../doctype/custom_field/test_custom_field.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/frappe/custom/doctype/custom_field/test_custom_field.py b/frappe/custom/doctype/custom_field/test_custom_field.py index 3f0aee90bf..daa95f11b0 100644 --- a/frappe/custom/doctype/custom_field/test_custom_field.py +++ b/frappe/custom/doctype/custom_field/test_custom_field.py @@ -5,6 +5,7 @@ import frappe from frappe.custom.doctype.custom_field.custom_field import ( create_custom_field, create_custom_fields, + delete_custom_fields, rename_fieldname, ) from frappe.tests import IntegrationTestCase @@ -183,3 +184,36 @@ class TestCustomField(IntegrationTestCase): self.assertFalse(doc.get(old)) field.delete() + + def test_delete_custom_fields(self): + doctype = "ToDo" + + field_1 = f"test_delete_cf_{frappe.generate_hash(length=6)}" + field_2 = f"test_delete_cf_{frappe.generate_hash(length=6)}" + field_3 = f"test_delete_cf_{frappe.generate_hash(length=6)}" + + create_custom_fields( + { + doctype: [ + {"fieldname": field_1, "fieldtype": "Data", "insert_after": "status"}, + {"fieldname": field_2, "fieldtype": "Data", "insert_after": "priority"}, + {"fieldname": field_3, "fieldtype": "Data", "insert_after": "color"}, + ] + } + ) + + def field_exists(fieldname): + return frappe.db.exists("Custom Field", {"fieldname": fieldname, "dt": doctype}) + + self.assertTrue(field_exists(field_1)) + self.assertTrue(field_exists(field_2)) + self.assertTrue(field_exists(field_3)) + + # delete using first supported structure (list of fieldname strings) + delete_custom_fields({doctype: [field_1, field_1]}) + self.assertFalse(field_exists(field_1)) + + # delete using second supported structure (list of field dicts) + delete_custom_fields({doctype: [{"fieldname": field_2}, {"fieldname": field_3}]}) + self.assertFalse(field_exists(field_2)) + self.assertFalse(field_exists(field_3)) From a0240a3d18a76a15a0c6d3ee1407c4d807629c49 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Tue, 21 Apr 2026 18:33:46 +0530 Subject: [PATCH 3/6] chore: minor fix --- frappe/custom/doctype/custom_field/custom_field.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index d72d26603f..7b05a7ea3b 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -489,16 +489,13 @@ def delete_custom_fields(custom_fields: dict): elif isinstance(field, dict) and field.get("fieldname"): fieldnames.append(field["fieldname"]) - # avoid redundant values in SQL IN clause - fieldnames = list(set(fieldnames)) - if not fieldnames: continue frappe.db.delete( "Custom Field", { - "fieldname": ("in", fieldnames), + "fieldname": ("in", set(fieldnames)), "dt": doctype, }, ) From 7fd4451e968f26be9e5967dcc83af855d89cefc4 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Thu, 23 Apr 2026 10:38:54 +0530 Subject: [PATCH 4/6] refactor: enhance delete_custom_fields function to support bypassing hooks --- .../doctype/custom_field/custom_field.py | 61 ++++++++----------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 7b05a7ea3b..ccc30fc3d2 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -445,39 +445,20 @@ def _update_fieldname_references(field: CustomField, old_fieldname: str, new_fie ) -def delete_custom_fields(custom_fields: dict): +def delete_custom_fields(custom_fields: dict, bypass_hooks: bool = False): """ - Delete custom fields from the given doctypes. + Delete custom fields from doctypes. - :param custom_fields: Dictionary of doctypes with fields to be deleted. + :param custom_fields: Dict mapping doctype to field names. + :param bypass_hooks: If `True`, fast raw delete (skips hooks (doc events like on_trash)). - --- - Structure of the `custom_fields` dictionary: + Example: - ```py - # first structure - { - "DocType1": ["field1", "field2", ...], - "DocType2": ["field1", "field2", ...], - ... - } - - # second structure - { - "DocType1": [ - {"fieldname": "field1", ...}, - {"fieldname": "field2", ...}, - ... - ], - "DocType2": [ - {"fieldname": "field1", ...}, - {"fieldname": "field2", ...}, - ... - ], - ... - } ``` + delete_custom_fields({"Address": ["custom_a", "custom_b"]}) + delete_custom_fields({"ToDo": [{"fieldname": "cf_1"}]}, bypass_hooks=True) + ```` """ for doctype, fields in custom_fields.items(): fieldnames = [] @@ -492,12 +473,24 @@ def delete_custom_fields(custom_fields: dict): if not fieldnames: continue - frappe.db.delete( - "Custom Field", - { - "fieldname": ("in", set(fieldnames)), - "dt": doctype, - }, - ) + fieldnames = tuple(set(fieldnames)) + + if bypass_hooks: + frappe.db.delete( + "Custom Field", + { + "fieldname": ("in", fieldnames), + "dt": doctype, + }, + ) + else: + custom_field_names = frappe.get_all( + "Custom Field", + filters={"fieldname": ("in", fieldnames), "dt": doctype}, + pluck="name", + ) + + for custom_field_name in custom_field_names: + frappe.get_doc("Custom Field", custom_field_name).delete(ignore_permissions=True, force=True) frappe.clear_cache(doctype=doctype) From 9a75ff6fd3f88fec51d67cab7f8bcd04b0868642 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Thu, 23 Apr 2026 11:03:38 +0530 Subject: [PATCH 5/6] test: enhance delete_custom_fields test to cover multiple deletion methods --- .../doctype/custom_field/test_custom_field.py | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/frappe/custom/doctype/custom_field/test_custom_field.py b/frappe/custom/doctype/custom_field/test_custom_field.py index daa95f11b0..e3eafe6bd2 100644 --- a/frappe/custom/doctype/custom_field/test_custom_field.py +++ b/frappe/custom/doctype/custom_field/test_custom_field.py @@ -8,6 +8,7 @@ from frappe.custom.doctype.custom_field.custom_field import ( delete_custom_fields, rename_fieldname, ) +from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.tests import IntegrationTestCase @@ -187,33 +188,47 @@ class TestCustomField(IntegrationTestCase): def test_delete_custom_fields(self): doctype = "ToDo" - - field_1 = f"test_delete_cf_{frappe.generate_hash(length=6)}" - field_2 = f"test_delete_cf_{frappe.generate_hash(length=6)}" - field_3 = f"test_delete_cf_{frappe.generate_hash(length=6)}" - - create_custom_fields( + fields = [ { - doctype: [ - {"fieldname": field_1, "fieldtype": "Data", "insert_after": "status"}, - {"fieldname": field_2, "fieldtype": "Data", "insert_after": "priority"}, - {"fieldname": field_3, "fieldtype": "Data", "insert_after": "color"}, - ] + "fieldname": f"test_delete_{frappe.generate_hash(length=5)}", + "fieldtype": "Data", + "insert_after": "status", } - ) + for _ in range(4) + ] + fieldnames = [f["fieldname"] for f in fields] + + create_custom_fields({doctype: fields}) + + # create property setters for fields deleted via safe path (hooks should clean these up) + for fieldname in fieldnames[:2]: + make_property_setter(doctype, fieldname, "hidden", "1", "Check") def field_exists(fieldname): return frappe.db.exists("Custom Field", {"fieldname": fieldname, "dt": doctype}) - self.assertTrue(field_exists(field_1)) - self.assertTrue(field_exists(field_2)) - self.assertTrue(field_exists(field_3)) + def property_setter_exists(fieldname): + return frappe.db.exists("Property Setter", {"doc_type": doctype, "field_name": fieldname}) - # delete using first supported structure (list of fieldname strings) - delete_custom_fields({doctype: [field_1, field_1]}) - self.assertFalse(field_exists(field_1)) + for fieldname in fieldnames: + self.assertTrue(field_exists(fieldname)) + for fieldname in fieldnames[:2]: + self.assertTrue(property_setter_exists(fieldname)) - # delete using second supported structure (list of field dicts) - delete_custom_fields({doctype: [{"fieldname": field_2}, {"fieldname": field_3}]}) - self.assertFalse(field_exists(field_2)) - self.assertFalse(field_exists(field_3)) + # 1 + delete_custom_fields({doctype: [fieldnames[0], fieldnames[0]]}) + self.assertFalse(field_exists(fieldnames[0])) + self.assertFalse(property_setter_exists(fieldnames[0])) + + # 2 + delete_custom_fields({doctype: [{"fieldname": fieldnames[1]}]}) + self.assertFalse(field_exists(fieldnames[1])) + self.assertFalse(property_setter_exists(fieldnames[1])) + + # 3 + delete_custom_fields({doctype: [fieldnames[2], fieldnames[2]]}, bypass_hooks=True) + self.assertFalse(field_exists(fieldnames[2])) + + # 4 + delete_custom_fields({doctype: [{"fieldname": fieldnames[3]}]}, bypass_hooks=True) + self.assertFalse(field_exists(fieldnames[3])) From 1d571827cf396f849a9ef02d72bcb7129b4cd8f1 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Fri, 24 Apr 2026 12:01:34 +0530 Subject: [PATCH 6/6] chore: clear cache after deleting custom fields --- frappe/custom/doctype/custom_field/custom_field.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index ccc30fc3d2..0f400d07a5 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -483,6 +483,7 @@ def delete_custom_fields(custom_fields: dict, bypass_hooks: bool = False): "dt": doctype, }, ) + frappe.clear_cache(doctype=doctype) else: custom_field_names = frappe.get_all( "Custom Field", @@ -492,5 +493,3 @@ def delete_custom_fields(custom_fields: dict, bypass_hooks: bool = False): for custom_field_name in custom_field_names: frappe.get_doc("Custom Field", custom_field_name).delete(ignore_permissions=True, force=True) - - frappe.clear_cache(doctype=doctype)