Skip to content

Commit a4fc2be

Browse files
committed
[REF] util.domains: DRY model fields path code and helpers
The newly added ir.exports fixing code uses a recursive CTE query to resolve fields paths (from a root model). Similar code is already existing for handling domains, `ir.model.fields`.`depends`, and `ir.act.server`.`update_path`; but this older code is using a python for loop and issuing multiple queries to resolve the paths. In this commit, some of those helpers code is refactored to use the newer recursive CTE query instead. Additionally, the `resolve_model_fields_path` helper and related code has been moved to `util.helpers` module.
1 parent 1cc795c commit a4fc2be

File tree

5 files changed

+210
-80
lines changed

5 files changed

+210
-80
lines changed

src/base/tests/test_util.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from odoo.addons.base.maintenance.migrations import util
2020
from odoo.addons.base.maintenance.migrations.testing import UnitTestCase, parametrize
21-
from odoo.addons.base.maintenance.migrations.util.domains import _adapt_one_domain
21+
from odoo.addons.base.maintenance.migrations.util.domains import _adapt_one_domain, _model_of_path
2222
from odoo.addons.base.maintenance.migrations.util.exceptions import MigrationError
2323

2424

@@ -27,6 +27,22 @@ def setUp(self):
2727
super(TestAdaptOneDomain, self).setUp()
2828
self.mock_adapter = mock.Mock()
2929

30+
@parametrize(
31+
[
32+
("res.currency", [], "res.currency"),
33+
("res.currency", ["rate_ids"], "res.currency.rate"),
34+
("res.currency", ("rate_ids", "company_id"), "res.company"),
35+
("res.currency", ["rate_ids", "company_id", "user_ids"], "res.users"),
36+
("res.currency", ("rate_ids", "company_id", "user_ids", "partner_id"), "res.partner"),
37+
("res.users", ["partner_id"], "res.partner"),
38+
("res.users", ["nonexistent_field"], None),
39+
("res.users", ("partner_id", "removed_field"), None),
40+
]
41+
)
42+
def test_model_of_path(self, model, path, expected):
43+
cr = self.env.cr
44+
self.assertEqual(_model_of_path(cr, model, path), expected)
45+
3046
def test_change_no_leaf(self):
3147
# testing plan: updata path of a domain where the last element is not changed
3248

@@ -697,6 +713,43 @@ def test_model_table_convertion(self):
697713
self.assertEqual(table, self.env[model]._table)
698714
self.assertEqual(util.model_of_table(cr, table), model)
699715

716+
def test_resolve_model_fields_path(self):
717+
cr = self.env.cr
718+
RFPtuple = util.ResolvedFieldsPath
719+
720+
# test with provided paths
721+
model1, path1 = "res.currency", ["rate_ids", "company_id", "user_ids", "partner_id"]
722+
model2, path2 = "res.users", ("partner_id", "removed_field", "user_id")
723+
models_paths = [
724+
(model1, path1),
725+
(model2, path2),
726+
]
727+
expected_result = {
728+
(model1, tuple(path1)): [
729+
RFPtuple(model1, path1, 1, "res.currency", "rate_ids", "res.currency.rate"),
730+
RFPtuple(model1, path1, 2, "res.currency.rate", "company_id", "res.company"),
731+
RFPtuple(model1, path1, 3, "res.company", "user_ids", "res.users"),
732+
RFPtuple(model1, path1, 4, "res.users", "partner_id", "res.partner"),
733+
],
734+
(model2, tuple(path2)): [
735+
RFPtuple(model2, list(path2), 1, "res.users", "partner_id", "res.partner"),
736+
RFPtuple(model2, list(path2), 2, "res.partner", "removed_field", None),
737+
],
738+
}
739+
result = util.resolve_model_fields_path(cr, models_paths)
740+
self.assertEqual(result, expected_result)
741+
742+
# test with extra where clause + params
743+
extra_where = "(field_model, field_name) = (%(field_model)s, %(field_name)s)"
744+
extra_params = {"field_model": "res.company", "field_name": "user_ids"}
745+
expected_result = {
746+
(model1, tuple(path1)): [
747+
RFPtuple(model1, path1, 3, "res.company", "user_ids", "res.users"),
748+
]
749+
}
750+
result = util.resolve_model_fields_path(cr, models_paths, extra_where=extra_where, extra_params=extra_params)
751+
self.assertEqual(result, expected_result)
752+
700753

701754
@unittest.skipIf(
702755
util.version_gte("saas~17.1"),

src/util/domains.py

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from openerp.tools.safe_eval import safe_eval
3434

3535
from .const import NEARLYWARN
36-
from .helpers import _dashboard_actions, _validate_model
36+
from .helpers import _dashboard_actions, _validate_model, resolve_model_fields_path
3737
from .inherit import for_each_inherit
3838
from .misc import SelfPrintEvalContext
3939
from .pg import column_exists, get_value_or_en_translation, table_exists
@@ -160,21 +160,18 @@ def _get_domain_fields(cr):
160160

161161

162162
def _model_of_path(cr, model, path):
163-
for field in path:
164-
cr.execute(
165-
"""
166-
SELECT relation
167-
FROM ir_model_fields
168-
WHERE model = %s
169-
AND name = %s
170-
""",
171-
[model, field],
172-
)
173-
if not cr.rowcount:
174-
return None
175-
[model] = cr.fetchone()
176-
177-
return model
163+
if not path:
164+
return model
165+
path = tuple(path)
166+
models_paths = resolve_model_fields_path(cr, [(model, path)], extra_where="part_index = cardinality(path)")
167+
if not models_paths:
168+
return None
169+
assert len(models_paths) == 1
170+
resolved_paths = models_paths[(model, path)]
171+
assert len(resolved_paths) == 1
172+
[last_part] = resolved_paths
173+
assert last_part.part_index == len(last_part.path)
174+
return last_part.relation_model # could be None
178175

179176

180177
def _valid_path_to(cr, path, from_, to):

src/util/fields.py

Lines changed: 44 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def make_index_name(table_name, column_name):
4141
from .const import ENVIRON
4242
from .domains import _adapt_one_domain, _replace_path, _valid_path_to, adapt_domains
4343
from .exceptions import SleepyDeveloperError
44-
from .helpers import _dashboard_actions, _validate_model, table_of_model
44+
from .helpers import _dashboard_actions, _validate_model, resolve_model_fields_path, table_of_model
4545
from .inherit import for_each_inherit
4646
from .misc import SelfPrintEvalContext, log_progress, version_gte
4747
from .orm import env, invalidate
@@ -82,7 +82,7 @@ def make_index_name(table_name, column_name):
8282

8383
ResolvedExportsLine = namedtuple(
8484
"ResolvedExportsLine",
85-
"export_id export_name export_model line_id path_parts part_index field_name field_model field_id relation_model",
85+
"export_id export_name export_model line_id fields_path resolved_path",
8686
)
8787

8888

@@ -105,64 +105,47 @@ def get_resolved_ir_exports(cr, models=None, fields=None, only_missing=False):
105105
"""
106106
assert bool(models) ^ bool(fields), "One of models or fields must be given, and not both."
107107
extra_where = ""
108-
query_params = {}
108+
extra_params = {}
109109
if models:
110110
extra_where += " AND field_model = ANY(%(models)s)"
111-
query_params["models"] = list(models)
111+
extra_params["models"] = list(models)
112112
if fields:
113113
extra_where += " AND (field_model, field_name) IN %(fields)s"
114-
query_params["fields"] = tuple((model, field) for model, field in fields)
114+
extra_params["fields"] = tuple((model, field) for model, field in fields)
115115
if only_missing:
116116
extra_where += " AND field_id IS NULL"
117-
# Resolve exports using a recursive CTE query
117+
118+
input_paths_query = """
119+
SELECT e.resource AS model, string_to_array(el.name, '/') AS path
120+
FROM ir_exports_line el
121+
JOIN ir_exports e ON el.export_id = e.id
122+
"""
123+
resolved_paths = resolve_model_fields_path(
124+
cr,
125+
input_paths_query=input_paths_query,
126+
extra_where=extra_where,
127+
extra_params=extra_params,
128+
)
129+
if not resolved_paths:
130+
return []
118131
cr.execute(
119132
"""
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,
133+
SELECT e.id AS export_id,
134+
e.name AS export_name,
135+
e.resource AS export_model,
136+
el.id AS line_id,
137+
el.name AS fields_path
138+
FROM ir_exports_line el
139+
JOIN ir_exports e ON el.export_id = e.id
140+
WHERE (e.resource, el.name) IN %s
141+
""",
142+
[tuple((m, "/".join(p)) for m, p in resolved_paths)],
164143
)
165-
return [ResolvedExportsLine(**row) for row in cr.dictfetchall()]
144+
return [
145+
ResolvedExportsLine(resolved_path=resolved_path, **row)
146+
for row in cr.dictfetchall()
147+
for resolved_path in resolved_paths.get((row["export_model"], tuple(row["fields_path"].split("/"))), ())
148+
]
166149

167150

168151
def rename_ir_exports_fields(cr, models_fields_map):
@@ -182,15 +165,16 @@ def rename_ir_exports_fields(cr, models_fields_map):
182165
return
183166
_logger.debug("Renaming %d export template lines with renamed fields", len(matching_exports))
184167
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
168+
for export_line_row in matching_exports:
169+
resolved_path = export_line_row.resolved_path
170+
assert resolved_path.field_model in models_fields_map
171+
fields_map = models_fields_map[resolved_path.field_model]
172+
assert resolved_path.field_name in fields_map
173+
assert resolved_path.path[resolved_path.part_index - 1] == resolved_path.field_name
174+
new_field_name = fields_map[resolved_path.field_name]
175+
fixed_path = list(resolved_path.path)
176+
fixed_path[resolved_path.part_index - 1] = new_field_name
177+
fixed_lines_paths[export_line_row.line_id] = fixed_path
194178
execute_values(
195179
cr,
196180
"""

src/util/helpers.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# -*- coding: utf-8 -*-
22
import logging
33
import os
4+
import re
5+
from collections import defaultdict, namedtuple
46

57
import lxml
68

@@ -214,3 +216,96 @@ def _get_theme_models():
214216
"theme.website.menu": "website.menu",
215217
"theme.ir.attachment": "ir.attachment",
216218
}
219+
220+
221+
ResolvedFieldsPath = namedtuple(
222+
"ResolvedFieldsPath",
223+
"model path part_index field_model field_name relation_model",
224+
)
225+
226+
227+
def resolve_model_fields_path(cr, models_paths=None, input_paths_query=None, extra_where=None, extra_params=None):
228+
"""
229+
Resolve model fields paths (e.g. `hr.appraisal` `['employee_id', 'user_id', 'partner_id']`).
230+
231+
Will return all the intermediate results for all fields in the path parts.
232+
Callers must do additional filtering of the results to extract the data they need.
233+
234+
:param list[(str, typing.Sequence[str])] models_paths: a list of (model, path) tuples to resolve.
235+
It's mutually exclusive with `input_paths_query`.
236+
:param str input_paths_query: the subquery to execute to get the input paths.
237+
It must produce a table with at least 2 columns: `model` (text) and `path` (array of text).
238+
It's mutually exclusive with `models_paths`.
239+
:param sep: the separator used in the paths to split fields names
240+
:param extra_where: additional WHERE clauses to add to the query to filter the results
241+
:param extra_params: additional parameters to pass to the query for execution
242+
:return: a dict of the model fields paths to their resolved fields and models (optionally filtered)
243+
:rtype: dict[(str, typing.Sequence[str]), list[ResolvedFieldsPath]]
244+
245+
:meta private: exclude from online docs
246+
"""
247+
assert bool(models_paths) ^ bool(input_paths_query), "Only one of models_paths or input_paths_query must be given."
248+
249+
query_params = {}
250+
if models_paths:
251+
values_query = ""
252+
for i, (model, path) in enumerate(models_paths):
253+
params = {"m{}".format(i): model, "p{}".format(i): list(path)}
254+
values_query += (", " if values_query else "") + "({})".format(
255+
", ".join("%({})s".format(k) for k in params)
256+
)
257+
query_params.update(params)
258+
input_paths_query = "SELECT * FROM (VALUES %s) p(model, path)" % values_query
259+
260+
where_clause = ""
261+
extra_where = (extra_where or "").strip()
262+
if extra_where:
263+
where_clause = "WHERE "
264+
extra_where = re.sub(r"^(and|or)\s+", "", extra_where, flags=re.I)
265+
where_clause += extra_where
266+
267+
if extra_params:
268+
query_params.update(extra_params)
269+
270+
cr.execute(
271+
"""
272+
WITH RECURSIVE resolved_fields_path AS (
273+
-- non-recursive term
274+
SELECT p.model AS model,
275+
p.path AS path,
276+
1 AS part_index,
277+
p.model AS field_model,
278+
p.path[1] AS field_name,
279+
imf.relation AS relation_model
280+
FROM paths p
281+
LEFT JOIN ir_model_fields imf
282+
ON imf.model = p.model
283+
AND imf.name = p.path[1]
284+
285+
UNION ALL
286+
287+
-- recursive term
288+
SELECT rfp.model,
289+
rfp.path,
290+
rfp.part_index + 1 AS part_index,
291+
rfp.relation_model AS field_model,
292+
rfp.path[rfp.part_index + 1] AS field_name,
293+
rimf.relation AS relation_model
294+
FROM resolved_fields_path rfp
295+
LEFT JOIN ir_model_fields rimf
296+
ON rimf.model = rfp.relation_model
297+
AND rimf.name = rfp.path[rfp.part_index + 1]
298+
WHERE cardinality(rfp.path) > rfp.part_index
299+
AND rfp.relation_model IS NOT NULL
300+
),
301+
paths AS ({input_paths_query})
302+
SELECT * FROM resolved_fields_path
303+
{where_clause}
304+
ORDER BY model, path, part_index
305+
""".format(input_paths_query=input_paths_query, where_clause=where_clause),
306+
query_params,
307+
)
308+
result = defaultdict(list)
309+
for row in cr.dictfetchall():
310+
result[(row["model"], tuple(row["path"]))].append(ResolvedFieldsPath(**row))
311+
return dict(result)

src/util/report-migration.xml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,13 @@
5757
<t t-set="export_messages" t-value="grouped_exports[export_id]"/>
5858
<li>
5959
<strong><t t-raw="get_anchor_link_to_record('ir.exports', export_id, export_messages[0][0].export_name)"/></strong>
60-
(<t t-out="export_messages[0][0].export_model"/>)
60+
(<t t-esc="export_messages[0][0].export_model"/>)
6161
<ul>
6262
<t t-foreach="export_messages" t-as="message">
6363
<li>
64-
<t t-raw="'/'.join(message[0].path_parts)"/>:
65-
model <t t-raw="message[0].field_model"/> field <t t-raw="message[0].field_name"/> removed
64+
<kbd><t t-esc="message[0].fields_path"/></kbd>:
65+
field <kbd><t t-esc="message[0].resolved_path.field_model"/></kbd>
66+
<kbd><t t-esc="message[0].resolved_path.field_name"/></kbd> was removed
6667
</li>
6768
</t>
6869
</ul>

0 commit comments

Comments
 (0)