From 81135f09633f6fbbe4da41a1fdfeff249fbff03c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 24 Aug 2026 05:48:03 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20analyse:=20=C3=A9carter=20les=20champs?= =?UTF-8?q?=20qui=20n'ont=20jamais=20port=C3=A9=20de=20donn=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/analyse/check_migration_quality.py | 53 ++++++- script/todo/todo_i18n.py | 16 +++ test/test_check_migration_quality.py | 162 ++++++++++++++++++++++ 3 files changed, 226 insertions(+), 5 deletions(-) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 4b5123d..2bf8f76 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -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 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index ed41548..99c2844 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 169d825..adb5310 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -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()