Skip to content

Commit

Permalink
[ADD] fields/make_field_non_stored
Browse files Browse the repository at this point in the history
opw-4502675

When a field becomes non-stored, the associated column must be removed during
the upgrade.

Unless the field remains selectable, such removal will cause `ir.filters`
relying on such field to fail. This commit introduces a new util to clean up
user-defined filters. In order to do that, the relevant part of the
`remove_field` util is factored out into a new private method.

---

Example of problem, with reproducer:

`account.account`'s `group_id` field has become non-stored[^1] in 18.0,
therefore:

1. Create db in 17.0, with `account` installed;
2. Create user-defined default filter on model `account.account`, with domain
   containing `group_id`. Example: `{"group_by", ["group_id"]}`;
3. Upgrade db to 18.0
4. Open chart of account (and select filter, if not automatically selected)

Error should look something like the following:

```
RPC_ERROR

Odoo Server Error

Occured on localhost:8069 on model account.account and id 6 on 2025-02-06 09:34:41 GMT

Traceback (most recent call last):
  File "/home/odoo/src/odoo/18.0/odoo/http.py", line 1957, in _transactioning
    return service_model.retrying(func, env=self.env)
  File "/home/odoo/src/odoo/18.0/odoo/service/model.py", line 137, in retrying
    result = func()
  File "/home/odoo/src/odoo/18.0/odoo/http.py", line 1924, in _serve_ir_http
    response = self.dispatcher.dispatch(rule.endpoint, args)
  File "/home/odoo/src/odoo/18.0/odoo/http.py", line 2172, in dispatch
    result = self.request.registry['ir.http']._dispatch(endpoint)
  File "/home/odoo/src/odoo/18.0/odoo/addons/base/models/ir_http.py", line 329, in _dispatch
    result = endpoint(**request.params)
  File "/home/odoo/src/odoo/18.0/odoo/http.py", line 727, in route_wrapper
    result = endpoint(self, *args, **params_ok)
  File "/home/odoo/src/odoo/18.0/addons/web/controllers/dataset.py", line 35, in call_kw
    return call_kw(request.env[model], method, args, kwargs)
  File "/home/odoo/src/odoo/18.0/odoo/api.py", line 517, in call_kw
    result = getattr(recs, name)(*args, **kwargs)
  File "/home/odoo/src/odoo/18.0/addons/web/models/models.py", line 243, in web_read_group
    groups = self._web_read_group(domain, fields, groupby, limit, offset, orderby, lazy)
  File "/home/odoo/src/odoo/18.0/addons/web/models/models.py", line 269, in _web_read_group
    groups = self.read_group(domain, fields, groupby, offset=offset, limit=limit,
  File "/home/odoo/src/odoo/18.0/odoo/models.py", line 2858, in read_group
    rows = self._read_group(domain, annotated_groupby.values(), annotated_aggregates.values(), offset=offset, limit=limit, order=orderby)
  File "/home/odoo/src/odoo/18.0/odoo/models.py", line 1973, in _read_group
    groupby_terms: dict[str, SQL] = {
  File "/home/odoo/src/odoo/18.0/odoo/models.py", line 1974, in <dictcomp>
    spec: self._read_group_groupby(spec, query)
  File "/home/odoo/src/odoo/18.0/odoo/models.py", line 2090, in _read_group_groupby
    sql_expr = self._field_to_sql(self._table, fname, query)
  File "/home/odoo/src/odoo/18.0/addons/account/models/account_account.py", line 184, in _field_to_sql
    return super()._field_to_sql(alias, fname, query, flush)
  File "/home/odoo/src/odoo/18.0/odoo/models.py", line 2946, in _field_to_sql
    raise ValueError(f"Cannot convert {field} to SQL because it is not stored")
ValueError: Cannot convert account.account.group_id to SQL because it is not stored

The above server error caused the following client error:
OwlError: An error occured in the owl lifecycle (see this Error's "cause" property)
    Error: An error occured in the owl lifecycle (see this Error's "cause" property)
        at handleError (http://localhost:8069/web/assets/fd2cc33/web.assets_web.min.js:959:101)
        at App.handleError (http://localhost:8069/web/assets/fd2cc33/web.assets_web.min.js:1610:29)
        at ComponentNode.initiateRender (http://localhost:8069/web/assets/fd2cc33/web.assets_web.min.js:1051:19)

Caused by: RPC_ERROR: Odoo Server Error
    RPC_ERROR
        at makeErrorFromResponse (http://localhost:8069/web/assets/fd2cc33/web.assets_web.min.js:3134:163)
        at XMLHttpRequest.<anonymous> (http://localhost:8069/web/assets/fd2cc33/web.assets_web.min.js:3139:13)
```

[^1]:
https://github.com/odoo/upgrade/blob/6197269809a7007fd7eadfc8fb6d2c6a83bc5ca4/migrations/account/saas~17.5.1.2/pre-migrate.py#L97
  • Loading branch information
Pirols committed Feb 13, 2025
1 parent 19d5972 commit 24d1103
Showing 1 changed file with 89 additions and 68 deletions.
157 changes: 89 additions & 68 deletions src/util/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,25 +110,42 @@ def ensure_m2o_func_field_data(cr, src_table, column, dst_table):
remove_column(cr, src_table, column, cascade=True)


def remove_field(cr, model, fieldname, cascade=False, drop_column=True, skip_inherit=()):
"""
Remove a field and its references from the database.
This function also removes the field from inheriting models, unless exceptions are
specified in `skip_inherit`. When the field is stored we can choose to not drop the
column.
def _remove_field_from_filters(cr, model, field):
cr.execute(
"SELECT id, name, context FROM ir_filters WHERE model_id = %s AND context ~ %s",
[model, r"\y{}\y".format(field)],
)
for id_, name, context_s in cr.fetchall():
context = safe_eval(context_s or "{}", SelfPrintEvalContext(), nocopy=True)
changed = _remove_field_from_context(context, field)
cr.execute("UPDATE ir_filters SET context = %s WHERE id = %s", [unicode(context), id_])
if changed:
add_to_migration_reports(("ir.filters", id_, name), "Filters/Dashboards")

:param str model: model name of the field to remove
:param str fieldname: name of the field to remove
:param bool cascade: whether the field column(s) are removed in `CASCADE` mode
:param bool drop_column: whether the field's column is dropped
:param list(str) or str skip_inherit: list of inheriting models to skip the removal
of the field, use `"*"` to skip all
"""
_validate_model(model)
if column_exists(cr, "ir_filters", "sort"):
cr.execute(
"""
WITH to_update AS (
SELECT f.id,
COALESCE(ARRAY_TO_JSON(ARRAY_AGG(s.sort_item ORDER BY rn) filter (WHERE s.sort_item not in %s)), '[]') AS sort
FROM ir_filters f
JOIN LATERAL JSONB_ARRAY_ELEMENTS_TEXT(f.sort::jsonb)
WITH ORDINALITY AS s(sort_item, rn)
ON true
WHERE f.model_id = %s
AND f.sort ~ %s
GROUP BY id
)
UPDATE ir_filters f
SET sort = t.sort
FROM to_update t
WHERE f.id = t.id
""",
[(field, field + " desc"), model, r"\y{}\y".format(field)],
)

ENVIRON["__renamed_fields"][model][fieldname] = None

def _remove_field_from_context(context, fieldname):
def filter_value(key, value):
if key == "orderedBy" and isinstance(value, dict):
res = {k: (filter_value(None, v) if k == "name" else v) for k, v in value.items()}
Expand All @@ -142,72 +159,59 @@ def filter_value(key, value):
return value
return None # value filtered out

def clean_context(context):
if not isinstance(context, dict):
return False
if not isinstance(context, dict):
return False

changed = False
for key in _CONTEXT_KEYS_TO_CLEAN:
if context.get(key):
context_part = [filter_value(key, e) for e in context[key]]
changed |= context_part != context[key]
context[key] = [e for e in context_part if e is not None]

for vt in ["pivot", "graph", "cohort"]:
key = "{}_measure".format(vt)
if key in context:
new_value = filter_value(key, context[key])
changed |= context[key] != new_value
context[key] = new_value if new_value is not None else "id"

if vt in context:
changed |= _remove_field_from_context(context[vt], fieldname)

changed = False
for key in _CONTEXT_KEYS_TO_CLEAN:
if context.get(key):
context_part = [filter_value(key, e) for e in context[key]]
changed |= context_part != context[key]
context[key] = [e for e in context_part if e is not None]
return changed


def remove_field(cr, model, fieldname, cascade=False, drop_column=True, skip_inherit=()):
"""
Remove a field and its references from the database.
for vt in ["pivot", "graph", "cohort"]:
key = "{}_measure".format(vt)
if key in context:
new_value = filter_value(key, context[key])
changed |= context[key] != new_value
context[key] = new_value if new_value is not None else "id"
This function also removes the field from inheriting models, unless exceptions are
specified in `skip_inherit`. When the field is stored we can choose to not drop the
column.
if vt in context:
changed |= clean_context(context[vt])
:param str model: model name of the field to remove
:param str fieldname: name of the field to remove
:param bool cascade: whether the field column(s) are removed in `CASCADE` mode
:param bool drop_column: whether the field's column is dropped
:param list(str) or str skip_inherit: list of inheriting models to skip the removal
of the field, use `"*"` to skip all
"""
_validate_model(model)

return changed
ENVIRON["__renamed_fields"][model][fieldname] = None

# clean dashboard's contexts
for id_, action in _dashboard_actions(cr, r"\y{}\y".format(fieldname), model):
context = safe_eval(action.get("context", "{}"), SelfPrintEvalContext(), nocopy=True)
changed = clean_context(context)
changed = _remove_field_from_context(context, fieldname)
action.set("context", unicode(context))
if changed:
add_to_migration_reports(
("ir.ui.view.custom", id_, action.get("string", "ir.ui.view.custom")), "Filters/Dashboards"
)

# clean filter's contexts
cr.execute(
"SELECT id, name, context FROM ir_filters WHERE model_id = %s AND context ~ %s",
[model, r"\y{}\y".format(fieldname)],
)
for id_, name, context_s in cr.fetchall():
context = safe_eval(context_s or "{}", SelfPrintEvalContext(), nocopy=True)
changed = clean_context(context)
cr.execute("UPDATE ir_filters SET context = %s WHERE id = %s", [unicode(context), id_])
if changed:
add_to_migration_reports(("ir.filters", id_, name), "Filters/Dashboards")

if column_exists(cr, "ir_filters", "sort"):
cr.execute(
"""
WITH to_update AS (
SELECT f.id,
COALESCE(ARRAY_TO_JSON(ARRAY_AGG(s.sort_item ORDER BY rn) filter (WHERE s.sort_item not in %s)), '[]') AS sort
FROM ir_filters f
JOIN LATERAL JSONB_ARRAY_ELEMENTS_TEXT(f.sort::jsonb)
WITH ORDINALITY AS s(sort_item, rn)
ON true
WHERE f.model_id = %s
AND f.sort ~ %s
GROUP BY id
)
UPDATE ir_filters f
SET sort = t.sort
FROM to_update t
WHERE f.id = t.id
""",
[(fieldname, fieldname + " desc"), model, r"\y{}\y".format(fieldname)],
)
_remove_field_from_filters(cr, model, fieldname)

_remove_import_export_paths(cr, model, fieldname)

Expand Down Expand Up @@ -372,6 +376,23 @@ def adapter(leaf, is_or, negated):
remove_field(cr, inh.model, fieldname, cascade=cascade, drop_column=drop_column, skip_inherit=skip_inherit)


def make_field_non_stored(cr, model, field, selectable=None):
"""
Convert field to non-stored.
:param str model: model name of the field to convert
:param str fieldname: name of the field to convert
:param bool selectable: whether the field is still selectable, if True custom `ir.filters` are not updated
"""
assert selectable is not None, "Please specify whether the field is selectable or not"

_validate_model(model)

remove_column(cr, table_of_model(cr, model), field)
if not selectable:
_remove_field_from_filters(cr, model, field)


def remove_field_metadata(cr, model, fieldname, skip_inherit=()):
"""
Remove metadata of a field.
Expand Down

0 comments on commit 24d1103

Please sign in to comment.