Skip to content

Commit 546b5e7

Browse files
andreabakaj-fuentes
authored andcommitted
[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 9f458a6 commit 546b5e7

File tree

4 files changed

+168
-98
lines changed

4 files changed

+168
-98
lines changed

src/base/tests/test_util.py

Lines changed: 41 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

@@ -712,6 +728,30 @@ def test_model_table_convertion(self):
712728
self.assertEqual(table, self.env[model]._table)
713729
self.assertEqual(util.model_of_table(cr, table), model)
714730

731+
def test_resolve_model_fields_path(self):
732+
from odoo.addons.base.maintenance.migrations.util.helpers import FieldsPathPart, _resolve_model_fields_path
733+
734+
cr = self.env.cr
735+
736+
# test with provided paths
737+
model, path = "res.currency", ["rate_ids", "company_id", "user_ids", "partner_id"]
738+
expected_result = [
739+
FieldsPathPart(model, path, 1, "res.currency", "rate_ids", "res.currency.rate"),
740+
FieldsPathPart(model, path, 2, "res.currency.rate", "company_id", "res.company"),
741+
FieldsPathPart(model, path, 3, "res.company", "user_ids", "res.users"),
742+
FieldsPathPart(model, path, 4, "res.users", "partner_id", "res.partner"),
743+
]
744+
result = _resolve_model_fields_path(cr, model, path)
745+
self.assertEqual(result, expected_result)
746+
747+
model, path = "res.users", ("partner_id", "removed_field", "user_id")
748+
expected_result = [
749+
FieldsPathPart(model, list(path), 1, "res.users", "partner_id", "res.partner"),
750+
FieldsPathPart(model, list(path), 2, "res.partner", "removed_field", None),
751+
]
752+
result = _resolve_model_fields_path(cr, model, path)
753+
self.assertEqual(result, expected_result)
754+
715755

716756
@unittest.skipIf(
717757
util.version_gte("saas~17.1"),

src/util/domains.py

Lines changed: 11 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, _resolve_model_fields_path, _validate_model
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,16 @@ 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+
resolved_parts = _resolve_model_fields_path(cr, model, path)
167+
if not resolved_parts:
168+
return None
169+
last_part = resolved_parts[-1]
170+
if last_part.part_index != len(last_part.path):
171+
return None
172+
return last_part.relation_model # could be None
178173

179174

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

src/util/fields.py

Lines changed: 58 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import logging
1414
import re
1515
import warnings
16-
from collections import namedtuple
1716

1817
import psycopg2
1918
from psycopg2 import sql
@@ -41,7 +40,7 @@ def make_index_name(table_name, column_name):
4140
from .const import ENVIRON
4241
from .domains import _adapt_one_domain, _replace_path, _valid_path_to, adapt_domains
4342
from .exceptions import SleepyDeveloperError
44-
from .helpers import _dashboard_actions, _validate_model, table_of_model
43+
from .helpers import _dashboard_actions, _resolve_model_fields_path, _validate_model, table_of_model
4544
from .inherit import for_each_inherit
4645
from .misc import SelfPrintEvalContext, log_progress, version_gte
4746
from .orm import env, invalidate
@@ -80,13 +79,7 @@ def make_index_name(table_name, column_name):
8079
)
8180

8281

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):
82+
def _get_resolved_ir_exports(cr, models=None, fields=None):
9083
"""
9184
Return a list of ir.exports.line records which models or fields match the given arguments.
9285
@@ -97,72 +90,56 @@ def get_resolved_ir_exports(cr, models=None, fields=None, only_missing=False):
9790
9891
:param list[str] models: a list of model names to match in exports
9992
: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]
93+
:return: the resolved field paths parts for each matched export line id
94+
:rtype: dict[int, list[FieldsPathPart]]
10395
10496
:meta private: exclude from online docs
10597
"""
10698
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)
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 = {}
112105
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
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]
118109
cr.execute(
119110
"""
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,
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,
164117
)
165-
return [ResolvedExportsLine(**row) for row in cr.dictfetchall()]
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
166143

167144

168145
def rename_ir_exports_fields(cr, models_fields_map):
@@ -174,23 +151,24 @@ def rename_ir_exports_fields(cr, models_fields_map):
174151
175152
:meta private: exclude from online docs
176153
"""
177-
matching_exports = get_resolved_ir_exports(
154+
matching_exports = _get_resolved_ir_exports(
178155
cr,
179156
fields=[(model, field) for model, fields_map in models_fields_map.items() for field in fields_map],
180157
)
181158
if not matching_exports:
182159
return
183160
_logger.debug("Renaming %d export template lines with renamed fields", len(matching_exports))
184161
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
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
194172
execute_values(
195173
cr,
196174
"""
@@ -214,12 +192,11 @@ def remove_ir_exports_lines(cr, models=None, fields=None):
214192
215193
:meta private: exclude from online docs
216194
"""
217-
matching_exports = get_resolved_ir_exports(cr, models=models, fields=fields)
195+
matching_exports = _get_resolved_ir_exports(cr, models=models, fields=fields)
218196
if not matching_exports:
219197
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)])
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())])
223200

224201

225202
def ensure_m2o_func_field_data(cr, src_table, column, dst_table):

src/util/helpers.py

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

56
import lxml
67

@@ -214,3 +215,60 @@ def _get_theme_models():
214215
"theme.website.menu": "website.menu",
215216
"theme.ir.attachment": "ir.attachment",
216217
}
218+
219+
220+
FieldsPathPart = namedtuple(
221+
"FieldsPathPart",
222+
"model path part_index field_model field_name relation_model",
223+
)
224+
225+
226+
def _resolve_model_fields_path(cr, model, path):
227+
"""
228+
Resolve model fields paths (e.g. `hr.appraisal` `['employee_id', 'user_id', 'partner_id']`).
229+
230+
:param str model: the model to resolve the fields ``path`` from.
231+
:param typing.Sequence[str] path: the fields path starting from ``model``.
232+
:return: a list of the resolved fields path parts through their relation models.
233+
:rtype: list[FieldsPathPart]
234+
235+
:meta private: exclude from online docs
236+
"""
237+
path = list(path)
238+
cr.execute(
239+
"""
240+
WITH RECURSIVE resolved_fields_path AS (
241+
-- non-recursive term
242+
SELECT p.model AS model,
243+
p.path AS path,
244+
1 AS part_index,
245+
p.model AS field_model,
246+
p.path[1] AS field_name,
247+
imf.relation AS relation_model
248+
FROM (VALUES (%(model)s, %(path)s)) p(model, path)
249+
LEFT JOIN ir_model_fields imf
250+
ON imf.model = p.model
251+
AND imf.name = p.path[1]
252+
253+
UNION ALL
254+
255+
-- recursive term
256+
SELECT rfp.model,
257+
rfp.path,
258+
rfp.part_index + 1 AS part_index,
259+
rfp.relation_model AS field_model,
260+
rfp.path[rfp.part_index + 1] AS field_name,
261+
rimf.relation AS relation_model
262+
FROM resolved_fields_path rfp
263+
LEFT JOIN ir_model_fields rimf
264+
ON rimf.model = rfp.relation_model
265+
AND rimf.name = rfp.path[rfp.part_index + 1]
266+
WHERE cardinality(rfp.path) > rfp.part_index
267+
AND rfp.relation_model IS NOT NULL
268+
)
269+
SELECT * FROM resolved_fields_path
270+
ORDER BY model, path, part_index
271+
""",
272+
{"model": model, "path": list(path)},
273+
)
274+
return [FieldsPathPart(**row) for row in cr.dictfetchall()]

0 commit comments

Comments
 (0)