From 74826f635bb81bc5ff3c476f1352f53ff8a1fab6 Mon Sep 17 00:00:00 2001 From: Alvaro Fuentes Date: Mon, 26 May 2025 11:19:06 +0200 Subject: [PATCH] [IMP] util/{models,pg}: check m2m tables on model rename When renaming a model we need to check m2m tables that may need to be renamed as well. Otherwise the ORM will create a new table that would be empty. If the data is handled directly in the scripts the ignore parameter can be used to avoid warnings. Notes: * As this is a breaking change, this only done by default from Odoo saas~18.1 * From Odoo 9 the column relation_table exists in ir_model_fields * From Odoo 10 the name of m2m tables is given by the model names ordered alphabetically Related: odoo/upgrade#7752 --- src/base/tests/test_util.py | 2 + src/util/models.py | 21 ++++++++-- src/util/pg.py | 77 +++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/base/tests/test_util.py b/src/base/tests/test_util.py index b0a15b456a..e9bd65a56d 100644 --- a/src/base/tests/test_util.py +++ b/src/base/tests/test_util.py @@ -562,6 +562,7 @@ def test_remove_field(self): self.assertEqual(self.export.export_fields[0].name, "full_name") self.assertEqual(self.export.export_fields[1].name, "rate_ids/name") + @mute_logger(util.pg._logger.name) def test_rename_model(self): util.rename_model(self.cr, "res.currency", "res.currency2") self._invalidate() @@ -621,6 +622,7 @@ def test_remove_field(self): self.assertEqual(remaining_mappings[0].field_name, "full_name") self.assertEqual(remaining_mappings[1].field_name, "rate_ids/name") + @mute_logger(util.pg._logger.name) def test_rename_model(self): util.rename_model(self.cr, "res.currency", "res.currency2") util.invalidate(self.import_mapping) diff --git a/src/util/models.py b/src/util/models.py index 5ce231a39b..559dbc656b 100644 --- a/src/util/models.py +++ b/src/util/models.py @@ -15,7 +15,7 @@ from .helpers import _ir_values_value, _validate_model, model_of_table, table_of_model from .indirect_references import indirect_references from .inherit import for_each_inherit, inherit_parents -from .misc import _cached, chunks, log_progress +from .misc import _cached, chunks, log_progress, version_gte from .pg import ( _get_unique_indexes_with, column_exists, @@ -28,6 +28,7 @@ get_value_or_en_translation, parallel_execute, table_exists, + update_m2m_tables, view_exists, ) @@ -268,13 +269,21 @@ def _replace_model_in_computed_custom_fields(cr, source, target): ) -def rename_model(cr, old, new, rename_table=True): +def rename_model(cr, old, new, rename_table=True, ignored_m2ms="ALL_BEFORE_18_1"): """ Rename a model. - :param str old: current name of the model to rename - :param str new: new name of the model to rename + Updates all references to the model name in the DB. + + If table rename is requested, from saas~18.1+, m2m table are updated too, unless + ignored. In older versions, m2m tables are skipped unless an empty list is passed. + + :param str old: current model name + :param str new: new model name :param bool rename_table: whether to also rename the table of the model + :param ignored_m2ms: m2m tables to skip. Defaults to `"ALL_BEFORE_18_1"`, which skips + all in Odoo 18 or below, none in saa~18.1+. For all versions, if + the value is not the default, skip only the specified m2m tables. """ _validate_model(old) _validate_model(new) @@ -285,6 +294,10 @@ def rename_model(cr, old, new, rename_table=True): new_table = table_of_model(cr, new) if new_table != old_table: pg_rename_table(cr, old_table, new_table) + if ignored_m2ms != "ALL_BEFORE_18_1": # explicit value, run the the update + update_m2m_tables(cr, old_table, new_table, ignored_m2ms) + elif version_gte("saas~18.1"): # from 18.1 we update by default + update_m2m_tables(cr, old_table, new_table, ()) updates = [("wkf", "osv")] if table_exists(cr, "wkf") else [] updates += [(ir.table, ir.res_model) for ir in indirect_references(cr) if ir.res_model] diff --git a/src/util/pg.py b/src/util/pg.py index e7522a20ee..61fe53f018 100644 --- a/src/util/pg.py +++ b/src/util/pg.py @@ -1351,6 +1351,83 @@ def create_m2m(cr, m2m, fk1, fk2, col1=None, col2=None): ) +def update_m2m_tables(cr, old_table, new_table, ignored_m2ms=()): + """ + Update m2m table names and columns. + + This function renames m2m tables still referring to `old_table`. It also updates + column names and constraints of those tables. + + :param str old_table: former table name + :param str new_table: new table name + :param list(str) ignored_m2ms: explicit list of m2m tables to ignore + + :meta private: exclude from online docs + """ + assert isinstance(ignored_m2ms, (list, tuple)) + if old_table == new_table or not version_gte("10.0"): + return + ignored_m2ms = set(ignored_m2ms) + for orig_m2m_table in get_m2m_tables(cr, new_table): + if orig_m2m_table in ignored_m2ms: + continue + m = re.match(r"^(\w+)_{0}_rel|{0}_(\w+)_rel$".format(re.escape(old_table)), orig_m2m_table) + if m: + m2m_table = "{}_{}_rel".format(*sorted([m.group(1) or m.group(2), new_table])) + # Due to the 63 chars limit in generated constraint names, for long table names the FK + # constraint is dropped when renaming the table. We need the constraint to correctly + # identify the FK targets. The FK constraints will be dropped and recreated below. + rename_table(cr, orig_m2m_table, m2m_table, remove_constraints=False) + _logger.info("Renamed m2m table %s to %s", orig_m2m_table, m2m_table) + else: + m2m_table = orig_m2m_table + for m2m_col in get_columns(cr, m2m_table).iter_unquoted(): + col_info = target_of(cr, m2m_table, m2m_col) + if not col_info or col_info[0] != new_table or col_info[1] != "id": + continue + old_col, new_col = map("{}_id".format, [old_table, new_table]) + if m2m_col != old_col: + _logger.warning( + "Possibly missing rename: the column %s of m2m table %s references the table %s", + m2m_col, + m2m_table, + new_table, + ) + continue + old_constraint = col_info[2] + cr.execute( + """ + SELECT c.confdeltype + FROM pg_constraint c + JOIN pg_class t + ON c.conrelid = t.oid + WHERE t.relname = %s + AND c.conname = %s + """, + [m2m_table, old_constraint], + ) + on_delete = cr.fetchone()[0] + query = format_query( + cr, + """ + ALTER TABLE {m2m_table} + RENAME COLUMN {old_col} TO {new_col}; + + ALTER TABLE {m2m_table} + DROP CONSTRAINT {old_constraint}, + ADD FOREIGN KEY ({new_col}) REFERENCES {new_table} (id) ON DELETE {del_action} + """, + m2m_table=m2m_table, + old_col=old_col, + new_col=new_col, + old_constraint=old_constraint, + new_table=new_table, + del_action=SQLStr("RESTRICT") if on_delete == "r" else SQLStr("CASCADE"), + ) + cr.execute(query) + _logger.info("Renamed m2m column of table %s from %s to %s", m2m_table, old_col, new_col) + + def fixup_m2m(cr, m2m, fk1, fk2, col1=None, col2=None): if col1 is None: col1 = "%s_id" % fk1