[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée

Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.

`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.

Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.

--- EN ---

The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.

`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.

Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 05:48:03 -04:00
parent 3d8e750af2
commit 81135f0963
3 changed files with 226 additions and 5 deletions

View file

@ -233,6 +233,18 @@ def inspect(database):
if len(ligne) >= 2:
dct_origine.setdefault(ligne[0], set()).add(ligne[1])
etat["field_module"] = {cle: sorted(v) for cle, v in dct_origine.items()}
# Lesquels PORTENT une donnée. Un champ `store=false` n'a jamais eu
# de colonne : sa disparition ne perd rien, et il pesait jusqu'à 90 %
# du seau « non déclarés par OpenUpgrade ». Mesuré au palier 16 → 17 :
# `__last_update` à lui seul comptait pour 397 des 565.
#
# `store` existe de la 12 à la 18 et n'est jamais NULL — vérifié sur
# les sept bases — donc le renseignement est fiable partout.
stockes = run_psql(
database,
"SELECT model || '.' || name FROM ir_model_fields WHERE store",
)
etat["field_stored"] = sorted(ligne[0] for ligne in stockes or [])
# Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et
# c'est par elle qu'on les retrouve d'une version à l'autre.
copies = run_psql(
@ -609,7 +621,11 @@ def declared_index(version):
def overlay_declared(
version, modeles_perdus, champs_perdus, origine_champ=None
version,
modeles_perdus,
champs_perdus,
origine_champ=None,
champs_stockes=None,
):
"""Le THÉORIQUE posé sur le PRATIQUE : qui avait été annoncé ?
@ -645,6 +661,7 @@ def overlay_declared(
"company_dependent": [],
"moved": [],
"model_gone": [],
"no_data": [],
"not_analysed": [],
"undeclared": [],
}
@ -664,6 +681,22 @@ def overlay_declared(
if partis and cle.startswith(partis):
champs["model_gone"].append(cle)
continue
# AVANT « non analysé » : « sans donnée propre » est une raison
# plus forte que « hors du champ d'OpenUpgrade ». Mesuré au
# palier 16 → 17, le placement avant fait tomber `not_analysed`
# de 181 à 47 sans changer `undeclared` — le seau résiduel se
# réduit alors au risque réel : des champs qui AVAIENT des
# données, dans des modules dont OpenUpgrade ne peut rien dire.
#
# La garde `is not None` compte : un instantané sans
# `field_stored` — une base lue par une version antérieure de cet
# outil — verserait TOUT ici et n'annoncerait plus rien.
if champs_stockes is not None and (
cle.rsplit(".", 1)[-1] in SANS_DONNEE_PROPRE
or cle not in champs_stockes
):
champs["no_data"].append(cle)
continue
origines = set((origine_champ or {}).get(cle) or [])
if origines and not (origines & analyses):
champs["not_analysed"].append(cle)
@ -743,6 +776,7 @@ def compare(avant, apres):
modeles_perdus,
champs_perdus,
avant.get("field_module"),
set(avant.get("field_stored") or []) or None,
),
"fields_lost": champs_perdus,
"fields_gained": sorted(champ_apres - champ_avant),
@ -851,6 +885,12 @@ DECLARE_MODELES = (
# L'ordre range du plus rassurant au plus inquiétant : ce qu'on doit
# regarder finit la liste, donc reste sous les yeux.
# `id` porte une donnée mais pas la SIENNE : elle appartient à la ligne.
# Il figure dans ir_model_fields de tout modèle, y compris abstrait, et
# Odoo 15 cesse de l'inscrire sur ceux-là — d'où 65 « pertes » d'un coup
# au palier 14 → 15, pour zéro octet.
SANS_DONNEE_PROPRE = ("id",)
DECLARE_CHAMPS = (
("del", "declared removed", "dim"),
("moved", "moved to another module", "dim"),
@ -860,6 +900,7 @@ DECLARE_CHAMPS = (
"computed now — the field remains, the column does not",
"ok",
),
("no_data", "held no data of their own — nothing to lose", "dim"),
("model_gone", "their model went away too", "dim"),
("not_analysed", "in a module OpenUpgrade does not analyse", "dim"),
("undeclared", "NOT declared by OpenUpgrade", "warn"),
@ -920,10 +961,12 @@ def render_declared(declare, colour, limit=8):
f" {paint(str(len(trouves)).rjust(5), teinte, colour)}"
f" {t(libelle)}"
)
if cle not in ("undeclared", "unstored"):
# Ces deux-là seuls méritent des noms : l'un parce qu'il
# faut aller voir, l'autre parce qu'on croirait à une
# perte. Nommer les autres noierait le rapport.
if cle not in ("undeclared", "unstored", "no_data"):
# Ces trois-là seuls méritent des noms : le premier
# parce qu'il faut aller voir, les deux autres parce
# qu'on croirait à une perte — et « __last_update ×
# 518 modèles » sur une ligne explique à lui seul le
# gros chiffre. Nommer le reste noierait le rapport.
continue
plats = [
element[0] if isinstance(element, tuple) else element

View file

@ -4085,6 +4085,18 @@ TRANSLATIONS = {
"fr": "Entrées ~/.ssh/config orphelines :",
"en": "Orphan ~/.ssh/config entries:",
},
"Kept (still leads somewhere):": {
"fr": "Conservées (mènent encore quelque part) :",
"en": "Kept (still leads somewhere):",
},
"reached through a jump host": {
"fr": "joignable par un rebond",
"en": "reached through a jump host",
},
"a VM of the Proxmox host": {
"fr": "une VM de l'hôte Proxmox",
"en": "a VM of the Proxmox host",
},
"Remove these ~/.ssh/config entries? (y/N): ": {
"fr": "Retirer ces entrées de ~/.ssh/config ? (o/N, défaut : non) : ",
"en": "Remove these ~/.ssh/config entries? (y/N, default: no): ",
@ -6777,6 +6789,10 @@ TRANSLATIONS = {
"fr": "Aucun index en double.",
"en": "No duplicate index.",
},
"held no data of their own — nothing to lose": {
"fr": "sans donnée propre — rien à perdre",
"en": "held no data of their own — nothing to lose",
},
"Configuration records a migration never recreates": {
"fr": "Réglages qu'une migration ne recrée jamais",
"en": "Configuration records a migration never recreates",

View file

@ -1330,5 +1330,167 @@ class TestGroupingByFieldName(unittest.TestCase):
self.assertEqual(groupes[0], ("seul", 1, "account.account.seul"))
class TestFieldsThatHeldNoData(TestTheOpenUpgradeOverlay):
"""Un champ sans colonne n'a rien perdu — et il noyait le rapport.
Mesuré sur une chaîne 12 → 18 : le seau « NON déclarés par
OpenUpgrade » comptait 565 champs au palier 16 → 17, dont 397
`__last_update` — un champ magique qu'Odoo 17 cesse d'inscrire et
qui n'a jamais eu de colonne. Un chiffre de tête qui fait peur pour
rien fait ignorer le rapport entier.
Après la règle : 565 → 57, et les 508 autres sont NOMMÉS sous
« sans donnée propre », en une ligne par nom de champ.
"""
def declare(self, perdus, stockes, origines=None):
# Le montage de la classe parente pose l'index déclaré en
# cache et restreint les modules analysés : sans lui
# `overlay_declared` sort tout de suite et rien n'est mesuré.
return quality.overlay_declared(18, [], perdus, origines, stockes)
def test_an_unstored_field_is_set_aside(self):
# `store=false` : le champ n'a jamais eu de colonne. Sa
# disparition ne coûte pas un octet.
res = self.declare(["res.partner.calcule"], set())
self.assertEqual(["res.partner.calcule"], res["fields"]["no_data"])
self.assertEqual([], res["fields"]["undeclared"])
def test_a_stored_field_stays_a_finding(self):
res = self.declare(["res.partner.vrai"], {"res.partner.vrai"})
self.assertEqual(["res.partner.vrai"], res["fields"]["undeclared"])
self.assertEqual([], res["fields"]["no_data"])
def test_id_is_set_aside_even_though_it_is_stored(self):
# `id` porte une donnée, mais pas la SIENNE : elle appartient à
# la ligne. Odoo 15 cesse de l'inscrire sur les modèles
# abstraits — 65 « pertes » d'un coup, zéro octet.
res = self.declare(["res.partner.id"], {"res.partner.id"})
self.assertEqual(["res.partner.id"], res["fields"]["no_data"])
def test_a_field_named_id_on_another_model_too(self):
res = self.declare(["mail.thread.id"], {"mail.thread.id"})
self.assertEqual(["mail.thread.id"], res["fields"]["no_data"])
def test_without_the_information_nothing_is_set_aside(self):
# Un instantané pris par une version antérieure de l'outil n'a
# pas `field_stored`. Tout verser dans le seau calme ferait
# taire le rapport au lieu de l'éclaircir.
res = quality.overlay_declared(18, [], ["res.partner.x"], None, None)
self.assertEqual([], res["fields"]["no_data"])
self.assertEqual(["res.partner.x"], res["fields"]["undeclared"])
def test_it_comes_before_the_not_analysed_bucket(self):
# « sans donnée propre » est une raison plus forte que « hors du
# champ d'OpenUpgrade ». Mesuré : le placement avant fait tomber
# `not_analysed` de 181 à 47 au palier 16 → 17, sans changer
# `undeclared` — le seau résiduel se réduit au risque réel.
res = self.declare(
["oca_module.model.champ"],
set(),
{"oca_module.model.champ": ["un_module_oca_inconnu"]},
)
self.assertEqual(["oca_module.model.champ"], res["fields"]["no_data"])
self.assertEqual([], res["fields"]["not_analysed"])
def test_the_model_gone_bucket_still_wins(self):
# Un champ dont le MODÈLE a disparu reste rangé là : c'est UNE
# trouvaille, pas une par champ.
res = quality.overlay_declared(
18, ["res.parti"], ["res.parti.champ"], None, set()
)
self.assertEqual(["res.parti.champ"], res["fields"]["model_gone"])
self.assertEqual([], res["fields"]["no_data"])
def test_inspect_reads_which_fields_hold_data(self):
# La règle ne vaut que si le renseignement est LU. En bouchonnant
# psql on éprouve la requête elle-même : c'est `store` qui
# décide, et lui seul.
vues = []
def faux_psql(database, sql, **kwargs):
vues.append(" ".join(sql.split()))
if "WHERE store" in " ".join(sql.split()):
return [["res.partner.vrai"]]
if "FROM ir_model_fields ORDER BY" in " ".join(sql.split()):
return [["res.partner.vrai"], ["res.partner.calcule"]]
return []
vrai = quality.run_psql
self.addCleanup(setattr, quality, "run_psql", vrai)
quality.run_psql = faux_psql
etat = quality.inspect("essai")
self.assertEqual(["res.partner.vrai"], etat["field_stored"])
self.assertTrue(
any("WHERE store" in v for v in vues),
"la requête doit trancher sur `store`, pas sur autre chose",
)
def test_the_names_are_shown_grouped_by_field(self):
# C'est la LIGNE « __last_update × 101 modèle(s) » qui explique le
# gros chiffre. Sans elle on remplace un nombre effrayant par un
# nombre opaque, et le lecteur reste sans réponse.
declare = {
"available": True,
"modules": 400,
"models": {"obsolete": [], "renamed": [], "undeclared": []},
"fields": {
"del": [],
"unstored": [],
"company_dependent": [],
"moved": [],
"model_gone": [],
"no_data": [
"a.__last_update",
"b.__last_update",
"c.__last_update",
],
"not_analysed": [],
"undeclared": [],
},
}
texte = "\n".join(quality.render_declared(declare, colour=False))
self.assertIn("__last_update", texte)
self.assertIn("3", texte)
def test_compare_passes_the_stored_fields_along(self):
# La règle vit dans `overlay_declared`, mais c'est `compare` qui
# lui donne de quoi trancher : sans la transmission, le seau
# reste vide et le rapport annonce toujours son gros chiffre.
commun = {
"exists": True,
"installed": [],
"model": ["res.partner"],
"table": {},
"view": 0,
"view_cow": 0,
"menu": 0,
"attachment": 0,
"cow": [],
"odoo": "18.0",
"database": "x",
}
avant = dict(
commun,
field=["res.partner.calcule", "res.partner.vrai"],
field_stored=["res.partner.vrai"],
)
apres = dict(commun, field=[])
diff = quality.compare(avant, apres)
champs = diff["declared"]["fields"]
self.assertEqual(["res.partner.calcule"], champs["no_data"])
self.assertEqual(["res.partner.vrai"], champs["undeclared"])
def test_the_bucket_has_a_label_and_a_quiet_tint(self):
table = dict(
(cle, (libelle, teinte))
for cle, libelle, teinte in quality.DECLARE_CHAMPS
)
self.assertIn("no_data", table)
libelle, teinte = table["no_data"]
self.assertEqual("dim", teinte, "il doit rassurer, pas attirer l'œil")
self.assertTrue(quality.t(libelle))
if __name__ == "__main__":
unittest.main()