Skip to content

Commit 045f760

Browse files
committed
[ADD] util.fields: handle ir.exports model/fields renames/removal
Implement proper fixing and/or removal of ir.exports and ir.exports.line records on model/fields renames/removal: - renamed fields: update affected ir.exports.line records - removed fields: remove affected ir.exports.line records - renamed models: update affected ir.exports records `resource` - removed models: remove affected ir.exports.line / ir.exports records Some of these cases were already handled but have been improved. Specifically: - for renamed fields, previously was done by doing a simple string replacement, which is not good enough because export lines can reference fields paths, and these in turn *might* contain paths to multiple fields with the same name on different models, only some of which are affected. Now the fields path get properly resolved for renaming, only the affected fields path parts are updated. - for removed fields, previously no action was taken, leaving a broken export template and UI traceback. Now the affected export lines are removed, using fields paths resolution. - for renamed and removed models, this was already handled by the indirect_references mechanism, but now export lines for the removed models are checked with the same mechanism for fields above. Additionally, unit tests have been added to make sure these cases are properly handled by the code.
1 parent 7d5536d commit 045f760

File tree

4 files changed

+189
-19
lines changed

4 files changed

+189
-19
lines changed

src/base/tests/test_util.py

+57
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,63 @@ def test_remove_field(self, domain, expected):
390390
self.assertEqual(altered_domain, expected)
391391

392392

393+
class TestIrExports(UnitTestCase):
394+
def setUp(self):
395+
super().setUp()
396+
self.export = self.env["ir.exports"].create(
397+
[
398+
{
399+
"name": "Test currency export",
400+
"resource": "res.currency",
401+
"export_fields": [
402+
(0, 0, {"name": "full_name"}),
403+
(0, 0, {"name": "rate_ids/company_id/user_ids/name"}),
404+
(0, 0, {"name": "rate_ids/company_id/user_ids/partner_id/user_ids/name"}),
405+
(0, 0, {"name": "rate_ids/name"}),
406+
],
407+
}
408+
]
409+
)
410+
util.flush(self.export)
411+
412+
def _invalidate(self):
413+
util.invalidate(self.export.export_fields)
414+
util.invalidate(self.export)
415+
416+
def test_rename_field(self):
417+
util.rename_field(self.cr, "res.partner", "user_ids", "renamed_user_ids")
418+
self._invalidate()
419+
self.assertEqual(
420+
self.export.export_fields[2].name, "rate_ids/company_id/user_ids/partner_id/renamed_user_ids/name"
421+
)
422+
423+
util.rename_field(self.cr, "res.users", "name", "new_name")
424+
self._invalidate()
425+
self.assertEqual(self.export.export_fields[1].name, "rate_ids/company_id/user_ids/new_name")
426+
427+
def test_remove_field(self):
428+
util.remove_field(self.cr, "res.currency.rate", "company_id")
429+
self._invalidate()
430+
self.assertEqual(len(self.export.export_fields), 2)
431+
self.assertEqual(self.export.export_fields[0].name, "full_name")
432+
self.assertEqual(self.export.export_fields[1].name, "rate_ids/name")
433+
434+
def test_rename_model(self):
435+
util.rename_model(self.cr, "res.currency", "res.currency2")
436+
self._invalidate()
437+
self.assertEqual(self.export.resource, "res.currency2")
438+
439+
def test_remove_model(self):
440+
util.remove_model(self.cr, "res.currency.rate")
441+
self._invalidate()
442+
self.assertEqual(len(self.export.export_fields), 1)
443+
self.assertEqual(self.export.export_fields[0].name, "full_name")
444+
445+
util.remove_model(self.cr, "res.currency")
446+
self.cr.execute("SELECT * FROM ir_exports WHERE id = %s", [self.export.id])
447+
self.assertFalse(self.cr.fetchall())
448+
449+
393450
class TestIterBrowse(UnitTestCase):
394451
def test_iter_browse_iter(self):
395452
cr = self.env.cr

src/util/fields.py

+127-17
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import psycopg2
1818
from psycopg2 import sql
19-
from psycopg2.extras import Json
19+
from psycopg2.extras import Json, execute_values
2020

2121
try:
2222
from odoo import release
@@ -40,7 +40,7 @@ def make_index_name(table_name, column_name):
4040
from .const import ENVIRON
4141
from .domains import _adapt_one_domain, _replace_path, _valid_path_to, adapt_domains
4242
from .exceptions import SleepyDeveloperError
43-
from .helpers import _dashboard_actions, _validate_model, table_of_model
43+
from .helpers import _dashboard_actions, _validate_model, resolve_model_fields_path, table_of_model
4444
from .inherit import for_each_inherit
4545
from .misc import SelfPrintEvalContext, log_progress, version_gte
4646
from .orm import env, invalidate
@@ -79,6 +79,126 @@ def make_index_name(table_name, column_name):
7979
)
8080

8181

82+
def _get_resolved_ir_exports(cr, models=None, fields=None):
83+
"""
84+
Return a list of ir.exports.line records which models or fields match the given arguments.
85+
86+
Export lines can reference nested models through relationship field "paths"
87+
(e.g. "partner_id/country_id/name"), therefore these needs to be resolved properly.
88+
89+
Only one of ``models`` or ``fields`` arguments should be provided.
90+
91+
:param list[str] models: a list of model names to match in exports
92+
:param list[(str, str)] fields: a list of (model, field) tuples to match in exports
93+
:return: the resolved field paths parts for each matched export line id
94+
:rtype: dict[int, list[FieldsPathPart]]
95+
96+
:meta private: exclude from online docs
97+
"""
98+
assert bool(models) ^ bool(fields), "One of models or fields must be given, and not both."
99+
100+
# Get the model fields paths for exports.
101+
# When matching fields we can already broadly filter on field names (will be double-checked later).
102+
# When matching models we can't exclude anything because we don't know intermediate models.
103+
where = ""
104+
params = {}
105+
if fields:
106+
fields = {(model, fields) for model, fields in fields} # noqa: C416 # make sure set[tuple]
107+
where = "WHERE el.name ~ ANY(%(field_names)s)"
108+
params["field_names"] = [f[1] for f in fields]
109+
cr.execute(
110+
"""
111+
SELECT el.id, e.resource AS model, string_to_array(el.name, '/') AS path
112+
FROM ir_exports e
113+
JOIN ir_exports_line el ON e.id = el.export_id
114+
{where}
115+
""".format(where=where),
116+
params,
117+
)
118+
paths_to_line_ids = {}
119+
for line_id, model, path in cr.fetchall():
120+
paths_to_line_ids.setdefault((model, tuple(path)), set()).add(line_id)
121+
122+
# Resolve intermediate models for all model fields paths, filter only matching paths parts
123+
matching_paths_parts = {}
124+
for model, path in paths_to_line_ids:
125+
resolved_paths = resolve_model_fields_path(cr, model, path)
126+
if fields:
127+
matching_parts = [p for p in resolved_paths if (p.field_model, p.field_name) in fields]
128+
else:
129+
matching_parts = [p for p in resolved_paths if p.field_model in models]
130+
if not matching_parts:
131+
continue
132+
matching_paths_parts[(model, path)] = matching_parts
133+
134+
# Return the matched parts for each export line id
135+
result = {}
136+
for (model, path), matching_parts in matching_paths_parts.items():
137+
line_ids = paths_to_line_ids.get((model, path))
138+
if not line_ids:
139+
continue # wut?
140+
for line_id in line_ids:
141+
result.setdefault(line_id, []).extend(matching_parts)
142+
return result
143+
144+
145+
def rename_ir_exports_fields(cr, models_fields_map):
146+
"""
147+
Rename fields references in ir.exports.line records.
148+
149+
:param dict[str, dict[str, str]] models_fields_map: a dict of models to the fields rename dict,
150+
like: `{"model.name": {"old_field": "new_field", ...}, ...}`
151+
152+
:meta private: exclude from online docs
153+
"""
154+
matching_exports = _get_resolved_ir_exports(
155+
cr,
156+
fields=[(model, field) for model, fields_map in models_fields_map.items() for field in fields_map],
157+
)
158+
if not matching_exports:
159+
return
160+
_logger.debug("Renaming %d export template lines with renamed fields", len(matching_exports))
161+
fixed_lines_paths = {}
162+
for line_id, resolved_paths in matching_exports.items():
163+
for path_part in resolved_paths:
164+
assert path_part.field_model in models_fields_map
165+
fields_map = models_fields_map[path_part.field_model]
166+
assert path_part.field_name in fields_map
167+
assert path_part.path[path_part.part_index - 1] == path_part.field_name
168+
new_field_name = fields_map[path_part.field_name]
169+
fixed_path = fixed_lines_paths.get(line_id, list(path_part.path))
170+
fixed_path[path_part.part_index - 1] = new_field_name
171+
fixed_lines_paths[line_id] = fixed_path
172+
execute_values(
173+
cr,
174+
"""
175+
UPDATE ir_exports_line el
176+
SET name = v.name
177+
FROM (VALUES %s) AS v(id, name)
178+
WHERE el.id = v.id
179+
""",
180+
[(k, "/".join(v)) for k, v in fixed_lines_paths.items()],
181+
)
182+
183+
184+
def remove_ir_exports_lines(cr, models=None, fields=None):
185+
"""
186+
Delete ir.exports.line records that reference models or fields that are/will be removed.
187+
188+
Only one of ``models`` or ``fields`` arguments should be provided.
189+
190+
:param list[str] models: a list of model names to match in exports
191+
:param list[(str, str)] fields: a list of (model, field) tuples to match in exports
192+
193+
:meta private: exclude from online docs
194+
"""
195+
matching_exports = _get_resolved_ir_exports(cr, models=models, fields=fields)
196+
if not matching_exports:
197+
return
198+
_logger.debug("Deleting %d export template lines with removed models/fields", len(matching_exports))
199+
cr.execute("DELETE FROM ir_exports_line WHERE id IN %s", [tuple(matching_exports.keys())])
200+
201+
82202
def ensure_m2o_func_field_data(cr, src_table, column, dst_table):
83203
"""
84204
Fix broken m2o relations.
@@ -202,6 +322,9 @@ def clean_context(context):
202322
[(fieldname, fieldname + " desc"), model, r"\y{}\y".format(fieldname)],
203323
)
204324

325+
# ir.exports
326+
remove_ir_exports_lines(cr, fields=[(model, fieldname)])
327+
205328
def adapter(leaf, is_or, negated):
206329
# replace by TRUE_LEAF, unless negated or in a OR operation but not negated
207330
if is_or ^ negated:
@@ -1070,22 +1193,9 @@ def _update_field_usage_multi(cr, models, old, new, domain_adapter=None, skip_in
10701193
"""
10711194
cr.execute(q.format(col_prefix=col_prefix), p)
10721195

1073-
# ir.exports.line
1074-
q = """
1075-
UPDATE ir_exports_line l
1076-
SET name = regexp_replace(l.name, %(old)s, %(new)s, 'g')
1077-
"""
1196+
# ir.exports
10781197
if only_models:
1079-
q += """
1080-
FROM ir_exports e
1081-
WHERE e.id = l.export_id
1082-
AND e.resource IN %(models)s
1083-
AND
1084-
"""
1085-
else:
1086-
q += "WHERE "
1087-
q += "l.name ~ %(old)s"
1088-
cr.execute(q, p)
1198+
rename_ir_exports_fields(cr, {model: {old: new} for model in only_models})
10891199

10901200
# mail.alias
10911201
if column_exists(cr, "mail_alias", "alias_defaults"):

src/util/indirect_references.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def indirect_references(cr, bound_only=False):
3939
IR("ir_model_fields", "relation", None), # destination of a relation field
4040
IR("ir_model_data", "model", "res_id"),
4141
IR("ir_filters", "model_id", None, set_unknown=True), # YUCK!, not an id
42-
IR("ir_exports", "resource", None, set_unknown=True),
42+
IR("ir_exports", "resource", None),
4343
IR("ir_ui_view", "model", None, set_unknown=True),
4444
IR("ir_values", "model", "res_id"),
4545
IR("wkf_transition", "trigger_model", None),

src/util/models.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import re
1111

1212
from .const import ENVIRON
13-
from .fields import IMD_FIELD_PATTERN, remove_field
13+
from .fields import IMD_FIELD_PATTERN, remove_field, remove_ir_exports_lines
1414
from .helpers import _ir_values_value, _validate_model, model_of_table, table_of_model
1515
from .indirect_references import indirect_references
1616
from .inherit import for_each_inherit, inherit_parents
@@ -129,6 +129,9 @@ def remove_model(cr, model, drop_table=True, ignore_m2m=()):
129129
cr.execute(query, args + (tuple(ids),))
130130
notify = notify or bool(cr.rowcount)
131131

132+
# for ir.exports.line we have to take care of "nested" references in fields "paths"
133+
remove_ir_exports_lines(cr, models=[model])
134+
132135
_rm_refs(cr, model)
133136

134137
cr.execute(

0 commit comments

Comments
 (0)