diff --git a/frappe/custom/doctype/custom_field/custom_field.js b/frappe/custom/doctype/custom_field/custom_field.js index be416cb49a..6e3ab9a249 100644 --- a/frappe/custom/doctype/custom_field/custom_field.js +++ b/frappe/custom/doctype/custom_field/custom_field.js @@ -25,6 +25,7 @@ frappe.ui.form.on("Custom Field", { frm.toggle_enable("dt", frm.doc.__islocal); frm.trigger("dt"); frm.toggle_reqd("label", !frm.doc.fieldname); + frm.trigger("add_rename_field"); if (frm.doc.is_system_generated) { frm.dashboard.add_comment( @@ -110,6 +111,29 @@ frappe.ui.form.on("Custom Field", { frm.fields_dict["options_help"].disp_area.innerHTML = ""; } }, + add_rename_field(frm) { + frm.add_custom_button(__("Rename Fieldname"), () => { + frappe.prompt( + { + fieldtype: "Data", + label: __("Fieldname"), + fieldname: "fieldname", + reqd: 1, + }, + function (data) { + frappe.call({ + method: "frappe.custom.doctype.custom_field.custom_field.rename_fieldname", + args: { + custom_field: frm.doc.name, + fieldname: data.fieldname, + }, + }); + }, + __("Rename Fieldname"), + __("Rename") + ); + }); + }, }); frappe.utils.has_special_chars = function (t) { diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 7342667668..a1aa4fd342 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -340,3 +340,52 @@ def create_custom_fields(custom_fields: dict, ignore_validate=False, update=True finally: frappe.flags.in_create_custom_fields = False + + +@frappe.whitelist() +def rename_fieldname(custom_field: str, fieldname: str): + frappe.only_for("System Manager") + + field: CustomField = frappe.get_doc("Custom Field", custom_field) + parent_doctype = field.dt + old_fieldname = field.fieldname + field.fieldname = fieldname + field.set_fieldname() + new_fieldname = field.fieldname + + if field.is_system_generated: + frappe.throw(_("System Generated Fields can not be renamed")) + if frappe.db.has_column(parent_doctype, fieldname): + frappe.throw(_("Can not rename as fieldname {0} is already present on DocType.")) + if old_fieldname == new_fieldname: + frappe.msgprint(_("Old and new fieldnames are same."), alert=True) + return + + frappe.db.rename_column(parent_doctype, old_fieldname, new_fieldname) + + # Update in DB after alter column is successful, alter column will implicitly commit, so it's + # best to commit change on field too to avoid any possible mismatch between two. + field.db_set("fieldname", field.fieldname, notify=True) + _update_fieldname_references(field, old_fieldname, new_fieldname) + + frappe.db.commit() + frappe.clear_cache() + + +def _update_fieldname_references( + field: CustomField, old_fieldname: str, new_fieldname: str +) -> None: + # Passwords are stored in auth table, so column name needs to be updated there. + if field.fieldtype == "Password": + Auth = frappe.qb.Table("__Auth") + frappe.qb.update(Auth).set(Auth.fieldname, new_fieldname).where( + (Auth.doctype == field.dt) & (Auth.fieldname == old_fieldname) + ).run() + + # Update ordering reference. + frappe.db.set_value( + "Custom Field", + {"insert_after": old_fieldname, "dt": field.dt}, + "insert_after", + new_fieldname, + ) diff --git a/frappe/custom/doctype/custom_field/test_custom_field.py b/frappe/custom/doctype/custom_field/test_custom_field.py index cf64e4495b..3f43588b5d 100644 --- a/frappe/custom/doctype/custom_field/test_custom_field.py +++ b/frappe/custom/doctype/custom_field/test_custom_field.py @@ -2,7 +2,11 @@ # License: MIT. See LICENSE import frappe -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from frappe.custom.doctype.custom_field.custom_field import ( + create_custom_field, + create_custom_fields, + rename_fieldname, +) from frappe.tests.utils import FrappeTestCase test_records = frappe.get_test_records("Custom Field") @@ -81,3 +85,23 @@ class TestCustomField(FrappeTestCase): # undo changes commited by DDL # nosemgrep frappe.db.commit() + + def test_custom_field_renaming(self): + def gen_fieldname(): + return "test_" + frappe.generate_hash() + + field = create_custom_field("ToDo", {"label": gen_fieldname()}, is_system_generated=False) + old = field.fieldname + new = gen_fieldname() + data = frappe.generate_hash() + doc = frappe.get_doc({"doctype": "ToDo", old: data, "description": "Something"}).insert() + + rename_fieldname(field.name, new) + field.reload() + self.assertEqual(field.fieldname, new) + + doc = frappe.get_doc("ToDo", doc.name) # doc.reload doesn't clear old fields. + self.assertEqual(doc.get(new), data) + self.assertFalse(doc.get(old)) + + field.delete() diff --git a/frappe/database/database.py b/frappe/database/database.py index 77fcfe73fe..9a6cb7772a 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -1285,6 +1285,9 @@ class Database: """Get estimated max row size of any table in bytes.""" raise NotImplementedError + def rename_column(self, doctype: str, old_column_name: str, new_column_name: str): + raise NotImplementedError + @contextmanager def savepoint(catch: type | tuple[type, ...] = Exception): diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 6a89966ee5..df64bdc86a 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -254,6 +254,20 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database): null_constraint = "NOT NULL" if not nullable else "" return self.sql_ddl(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} {null_constraint}") + def rename_column(self, doctype: str, old_column_name, new_column_name): + current_data_type = self.get_column_type(doctype, old_column_name) + + table_name = get_table_name(doctype) + + frappe.db.sql_ddl( + f"""ALTER TABLE `{table_name}` + CHANGE COLUMN `{old_column_name}` + `{new_column_name}` + {current_data_type}""" + # ^ Mariadb requires passing current data type again even if there's no change + # This requirement is gone from v10.5 + ) + def create_auth_table(self): self.sql_ddl( """create table if not exists `__Auth` ( diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 2d5b3a893f..edb2dc745a 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -264,6 +264,12 @@ class PostgresDatabase(PostgresExceptionUtil, Database): ALTER COLUMN "{column}" {null_constraint}""" ) + def rename_column(self, doctype: str, old_column_name: str, new_column_name: str): + table_name = get_table_name(doctype) + frappe.db.sql_ddl( + f"ALTER TABLE `{table_name}` RENAME COLUMN `{old_column_name}` TO `{new_column_name}`" + ) + def create_auth_table(self): self.sql_ddl( """create table if not exists "__Auth" (