Skip to content

Commit bb82a12

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 b3ad3a4 commit bb82a12

File tree

4 files changed

+211
-18
lines changed

4 files changed

+211
-18
lines changed

src/base/tests/test_util.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,63 @@ def test_remove_field(self, domain, expected):
359359
self.assertEqual(altered_domain, expected)
360360

361361

362+
class TestIrExports(UnitTestCase):
363+
def setUp(self):
364+
super().setUp()
365+
self.export = self.env["ir.exports"].create(
366+
[
367+
{
368+
"name": "Test currency export",
369+
"resource": "res.currency",
370+
"export_fields": [
371+
(0, 0, {"name": "full_name"}),
372+
(0, 0, {"name": "rate_ids/company_id/user_ids/name"}),
373+
(0, 0, {"name": "rate_ids/company_id/user_ids/partner_id/user_ids/name"}),
374+
(0, 0, {"name": "rate_ids/name"}),
375+
],
376+
}
377+
]
378+
)
379+
util.flush(self.export)
380+
381+
def _invalidate(self):
382+
util.invalidate(self.export.export_fields)
383+
util.invalidate(self.export)
384+
385+
def test_rename_field(self):
386+
util.rename_field(self.cr, "res.partner", "user_ids", "renamed_user_ids")
387+
self._invalidate()
388+
self.assertEqual(
389+
self.export.export_fields[2].name, "rate_ids/company_id/user_ids/partner_id/renamed_user_ids/name"
390+
)
391+
392+
util.rename_field(self.cr, "res.users", "name", "new_name")
393+
self._invalidate()
394+
self.assertEqual(self.export.export_fields[1].name, "rate_ids/company_id/user_ids/new_name")
395+
396+
def test_remove_field(self):
397+
util.remove_field(self.cr, "res.currency.rate", "company_id")
398+
self._invalidate()
399+
self.assertEqual(len(self.export.export_fields), 2)
400+
self.assertEqual(self.export.export_fields[0].name, "full_name")
401+
self.assertEqual(self.export.export_fields[1].name, "rate_ids/name")
402+
403+
def test_rename_model(self):
404+
util.rename_model(self.cr, "res.currency", "res.currency2")
405+
self._invalidate()
406+
self.assertEqual(self.export.resource, "res.currency2")
407+
408+
def test_remove_model(self):
409+
util.remove_model(self.cr, "res.currency.rate")
410+
self._invalidate()
411+
self.assertEqual(len(self.export.export_fields), 1)
412+
self.assertEqual(self.export.export_fields[0].name, "full_name")
413+
414+
util.remove_model(self.cr, "res.currency")
415+
self.cr.execute("SELECT * FROM ir_exports WHERE id = %s", [self.export.id])
416+
self.assertFalse(self.cr.fetchall())
417+
418+
362419
class TestIterBrowse(UnitTestCase):
363420
def test_iter_browse_iter(self):
364421
cr = self.env.cr

src/util/fields.py

Lines changed: 149 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
import logging
1414
import re
1515
import warnings
16+
from collections import namedtuple
1617

1718
import psycopg2
1819
from psycopg2 import sql
19-
from psycopg2.extras import Json
20+
from psycopg2.extras import Json, execute_values
2021

2122
try:
2223
from odoo import release
@@ -79,6 +80,148 @@ def make_index_name(table_name, column_name):
7980
)
8081

8182

83+
ResolvedExportsLine = namedtuple(
84+
"ResolvedExportsLine",
85+
"export_id export_name export_model line_id path_parts part_index field_name field_model field_id relation_model",
86+
)
87+
88+
89+
def get_resolved_ir_exports(cr, models=None, fields=None, only_missing=False):
90+
"""
91+
Return a list of ir.exports.line records which models or fields match the given arguments.
92+
93+
Export lines can reference nested models through relationship field "paths"
94+
(e.g. "partner_id/country_id/name"), therefore these needs to be resolved properly.
95+
96+
Only one of ``models`` or ``fields`` arguments should be provided.
97+
98+
:param list[str] models: a list of model names to match in exports
99+
:param list[(str, str)] fields: a list of (model, field) tuples to match in exports
100+
:param bool only_missing: include only lines which contain missing models/fields
101+
:return: the matched resolved exports lines
102+
:rtype: list[ResolvedExportsLine]
103+
104+
:meta private: exclude from online docs
105+
"""
106+
assert bool(models) ^ bool(fields), "One of models or fields must be given, and not both."
107+
extra_where = ""
108+
query_params = {}
109+
if models:
110+
extra_where += " AND field_model = ANY(%(models)s)"
111+
query_params["models"] = list(models)
112+
if fields:
113+
extra_where += " AND (field_model, field_name) IN %(fields)s"
114+
query_params["fields"] = tuple((model, field) for model, field in fields)
115+
if only_missing:
116+
extra_where += " AND field_id IS NULL"
117+
# Resolve exports using a recursive CTE query
118+
cr.execute(
119+
"""
120+
WITH RECURSIVE resolved_exports_fields AS (
121+
-- non-recursive term
122+
SELECT e.id AS export_id,
123+
e.name AS export_name,
124+
e.resource AS export_model,
125+
el.id AS line_id,
126+
string_to_array(el.name, '/') AS path_parts,
127+
1 AS part_index,
128+
replace((string_to_array(el.name, '/'))[1], '.id', 'id') AS field_name,
129+
e.resource AS field_model,
130+
elf.id AS field_id,
131+
elf.relation AS relation_model
132+
FROM ir_exports_line el
133+
JOIN ir_exports e
134+
ON el.export_id = e.id
135+
LEFT JOIN ir_model_fields elf
136+
ON elf.model = e.resource AND elf.name = (string_to_array(el.name, '/'))[1]
137+
LEFT JOIN ir_model em
138+
ON em.model = e.resource
139+
140+
UNION ALL
141+
142+
-- recursive term
143+
SELECT ref.export_id,
144+
ref.export_name,
145+
ref.export_model,
146+
ref.line_id,
147+
ref.path_parts,
148+
ref.part_index + 1 AS part_index,
149+
replace(ref.path_parts[ref.part_index + 1], '.id', 'id') AS field_name,
150+
ref.relation_model AS field_model,
151+
refmf.id AS field_id,
152+
refmf.relation AS relation_model
153+
FROM resolved_exports_fields ref
154+
LEFT JOIN ir_model_fields refmf
155+
ON refmf.model = ref.relation_model AND refmf.name = ref.path_parts[ref.part_index + 1]
156+
WHERE cardinality(ref.path_parts) > ref.part_index AND ref.relation_model IS NOT NULL
157+
)
158+
SELECT *
159+
FROM resolved_exports_fields
160+
WHERE field_name != 'id' {extra_where}
161+
ORDER BY export_id, line_id, part_index
162+
""".format(extra_where=extra_where),
163+
query_params,
164+
)
165+
return [ResolvedExportsLine(**row) for row in cr.dictfetchall()]
166+
167+
168+
def rename_ir_exports_fields(cr, models_fields_map):
169+
"""
170+
Rename fields references in ir.exports.line records.
171+
172+
:param dict[str, dict[str, str]] models_fields_map: a dict of models to the fields rename dict,
173+
like: `{"model.name": {"old_field": "new_field", ...}, ...}`
174+
175+
:meta private: exclude from online docs
176+
"""
177+
matching_exports = get_resolved_ir_exports(
178+
cr,
179+
fields=[(model, field) for model, fields_map in models_fields_map.items() for field in fields_map],
180+
)
181+
if not matching_exports:
182+
return
183+
_logger.debug("Renaming %d export template lines with renamed fields", len(matching_exports))
184+
fixed_lines_paths = {}
185+
for row in matching_exports:
186+
assert row.field_model in models_fields_map
187+
fields_map = models_fields_map[row.field_model]
188+
assert row.field_name in fields_map
189+
assert row.path_parts[row.part_index - 1] == row.field_name
190+
new_field_name = fields_map[row.field_name]
191+
fixed_path = list(row.path_parts)
192+
fixed_path[row.part_index - 1] = new_field_name
193+
fixed_lines_paths[row.line_id] = fixed_path
194+
execute_values(
195+
cr,
196+
"""
197+
UPDATE ir_exports_line el
198+
SET name = v.name
199+
FROM (VALUES %s) AS v(id, name)
200+
WHERE el.id = v.id
201+
""",
202+
[(k, "/".join(v)) for k, v in fixed_lines_paths.items()],
203+
)
204+
205+
206+
def remove_ir_exports_lines(cr, models=None, fields=None):
207+
"""
208+
Delete ir.exports.line records that reference models or fields that are/will be removed.
209+
210+
Only one of ``models`` or ``fields`` arguments should be provided.
211+
212+
:param list[str] models: a list of model names to match in exports
213+
:param list[(str, str)] fields: a list of (model, field) tuples to match in exports
214+
215+
:meta private: exclude from online docs
216+
"""
217+
matching_exports = get_resolved_ir_exports(cr, models=models, fields=fields)
218+
if not matching_exports:
219+
return
220+
lines_ids = {row.line_id for row in matching_exports}
221+
_logger.debug("Deleting %d export template lines with removed models/fields", len(lines_ids))
222+
cr.execute("DELETE FROM ir_exports_line WHERE id IN %s", [tuple(lines_ids)])
223+
224+
82225
def ensure_m2o_func_field_data(cr, src_table, column, dst_table):
83226
"""
84227
Fix broken m2o relations.
@@ -202,6 +345,9 @@ def clean_context(context):
202345
[(fieldname, fieldname + " desc"), model, r"\y{}\y".format(fieldname)],
203346
)
204347

348+
# ir.exports
349+
remove_ir_exports_lines(cr, fields=[(model, fieldname)])
350+
205351
def adapter(leaf, is_or, negated):
206352
# replace by TRUE_LEAF, unless negated or in a OR operation but not negated
207353
if is_or ^ negated:
@@ -1065,22 +1211,9 @@ def _update_field_usage_multi(cr, models, old, new, domain_adapter=None, skip_in
10651211
"""
10661212
cr.execute(q.format(col_prefix=col_prefix), p)
10671213

1068-
# ir.exports.line
1069-
q = """
1070-
UPDATE ir_exports_line l
1071-
SET name = regexp_replace(l.name, %(old)s, %(new)s, 'g')
1072-
"""
1214+
# ir.exports
10731215
if only_models:
1074-
q += """
1075-
FROM ir_exports e
1076-
WHERE e.id = l.export_id
1077-
AND e.resource IN %(models)s
1078-
AND
1079-
"""
1080-
else:
1081-
q += "WHERE "
1082-
q += "l.name ~ %(old)s"
1083-
cr.execute(q, p)
1216+
rename_ir_exports_fields(cr, {model: {old: new} for model in only_models})
10841217

10851218
# mail.alias
10861219
if column_exists(cr, "mail_alias", "alias_defaults"):

src/util/indirect_references.py

Lines changed: 1 addition & 1 deletion
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

Lines changed: 4 additions & 1 deletion
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)