[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:
parent
3d8e750af2
commit
81135f0963
3 changed files with 226 additions and 5 deletions
|
|
@ -233,6 +233,18 @@ def inspect(database):
|
||||||
if len(ligne) >= 2:
|
if len(ligne) >= 2:
|
||||||
dct_origine.setdefault(ligne[0], set()).add(ligne[1])
|
dct_origine.setdefault(ligne[0], set()).add(ligne[1])
|
||||||
etat["field_module"] = {cle: sorted(v) for cle, v in dct_origine.items()}
|
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
|
# 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.
|
# c'est par elle qu'on les retrouve d'une version à l'autre.
|
||||||
copies = run_psql(
|
copies = run_psql(
|
||||||
|
|
@ -609,7 +621,11 @@ def declared_index(version):
|
||||||
|
|
||||||
|
|
||||||
def overlay_declared(
|
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é ?
|
"""Le THÉORIQUE posé sur le PRATIQUE : qui avait été annoncé ?
|
||||||
|
|
||||||
|
|
@ -645,6 +661,7 @@ def overlay_declared(
|
||||||
"company_dependent": [],
|
"company_dependent": [],
|
||||||
"moved": [],
|
"moved": [],
|
||||||
"model_gone": [],
|
"model_gone": [],
|
||||||
|
"no_data": [],
|
||||||
"not_analysed": [],
|
"not_analysed": [],
|
||||||
"undeclared": [],
|
"undeclared": [],
|
||||||
}
|
}
|
||||||
|
|
@ -664,6 +681,22 @@ def overlay_declared(
|
||||||
if partis and cle.startswith(partis):
|
if partis and cle.startswith(partis):
|
||||||
champs["model_gone"].append(cle)
|
champs["model_gone"].append(cle)
|
||||||
continue
|
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 [])
|
origines = set((origine_champ or {}).get(cle) or [])
|
||||||
if origines and not (origines & analyses):
|
if origines and not (origines & analyses):
|
||||||
champs["not_analysed"].append(cle)
|
champs["not_analysed"].append(cle)
|
||||||
|
|
@ -743,6 +776,7 @@ def compare(avant, apres):
|
||||||
modeles_perdus,
|
modeles_perdus,
|
||||||
champs_perdus,
|
champs_perdus,
|
||||||
avant.get("field_module"),
|
avant.get("field_module"),
|
||||||
|
set(avant.get("field_stored") or []) or None,
|
||||||
),
|
),
|
||||||
"fields_lost": champs_perdus,
|
"fields_lost": champs_perdus,
|
||||||
"fields_gained": sorted(champ_apres - champ_avant),
|
"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
|
# L'ordre range du plus rassurant au plus inquiétant : ce qu'on doit
|
||||||
# regarder finit la liste, donc reste sous les yeux.
|
# 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 = (
|
DECLARE_CHAMPS = (
|
||||||
("del", "declared removed", "dim"),
|
("del", "declared removed", "dim"),
|
||||||
("moved", "moved to another module", "dim"),
|
("moved", "moved to another module", "dim"),
|
||||||
|
|
@ -860,6 +900,7 @@ DECLARE_CHAMPS = (
|
||||||
"computed now — the field remains, the column does not",
|
"computed now — the field remains, the column does not",
|
||||||
"ok",
|
"ok",
|
||||||
),
|
),
|
||||||
|
("no_data", "held no data of their own — nothing to lose", "dim"),
|
||||||
("model_gone", "their model went away too", "dim"),
|
("model_gone", "their model went away too", "dim"),
|
||||||
("not_analysed", "in a module OpenUpgrade does not analyse", "dim"),
|
("not_analysed", "in a module OpenUpgrade does not analyse", "dim"),
|
||||||
("undeclared", "NOT declared by OpenUpgrade", "warn"),
|
("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" {paint(str(len(trouves)).rjust(5), teinte, colour)}"
|
||||||
f" {t(libelle)}"
|
f" {t(libelle)}"
|
||||||
)
|
)
|
||||||
if cle not in ("undeclared", "unstored"):
|
if cle not in ("undeclared", "unstored", "no_data"):
|
||||||
# Ces deux-là seuls méritent des noms : l'un parce qu'il
|
# Ces trois-là seuls méritent des noms : le premier
|
||||||
# faut aller voir, l'autre parce qu'on croirait à une
|
# parce qu'il faut aller voir, les deux autres parce
|
||||||
# perte. Nommer les autres noierait le rapport.
|
# 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
|
continue
|
||||||
plats = [
|
plats = [
|
||||||
element[0] if isinstance(element, tuple) else element
|
element[0] if isinstance(element, tuple) else element
|
||||||
|
|
|
||||||
|
|
@ -4085,6 +4085,18 @@ TRANSLATIONS = {
|
||||||
"fr": "Entrées ~/.ssh/config orphelines :",
|
"fr": "Entrées ~/.ssh/config orphelines :",
|
||||||
"en": "Orphan ~/.ssh/config entries:",
|
"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): ": {
|
"Remove these ~/.ssh/config entries? (y/N): ": {
|
||||||
"fr": "Retirer ces entrées de ~/.ssh/config ? (o/N, défaut : non) : ",
|
"fr": "Retirer ces entrées de ~/.ssh/config ? (o/N, défaut : non) : ",
|
||||||
"en": "Remove these ~/.ssh/config entries? (y/N, default: no): ",
|
"en": "Remove these ~/.ssh/config entries? (y/N, default: no): ",
|
||||||
|
|
@ -6777,6 +6789,10 @@ TRANSLATIONS = {
|
||||||
"fr": "Aucun index en double.",
|
"fr": "Aucun index en double.",
|
||||||
"en": "No duplicate index.",
|
"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": {
|
"Configuration records a migration never recreates": {
|
||||||
"fr": "Réglages qu'une migration ne recrée jamais",
|
"fr": "Réglages qu'une migration ne recrée jamais",
|
||||||
"en": "Configuration records a migration never recreates",
|
"en": "Configuration records a migration never recreates",
|
||||||
|
|
|
||||||
|
|
@ -1330,5 +1330,167 @@ class TestGroupingByFieldName(unittest.TestCase):
|
||||||
self.assertEqual(groupes[0], ("seul", 1, "account.account.seul"))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue