[FIX] migration 17→18 : retirer la vue morte account_root
Odoo 17 créait une vue SQL pour account.root ; en 18 le modèle porte _auto = False et _table_query = '0' : son nom n'entre plus dans aucune requête, et le contrôle des tables manquantes ignore ces modèles. Elle n'est pas seulement morte, elle est FAUSSE : bâtie sur la colonne code que l'ORM 18 n'écrit plus, elle rend trop peu de racines. Elle est aussi l'unique épingle des deux colonnes héritées où database_cleanup échoue, d'où ce retrait avant le chargement en 18 ; aucun DROP COLUMN, OpenUpgrade les lit encore. Sur base jetable, l'ordre est rejouable et laisse intactes une vue d'une autre forme, une vue dont dépend un objet, une table homonyme. --- EN --- Odoo 17 created a SQL view for account.root; in 18 the model carries _auto = False and _table_query = '0': its name no longer enters any query, and the missing-table check ignores such models. It is not merely dead, it is WRONG: built on the code column the 18 ORM no longer writes, it returns too few roots. It is also the sole pin holding the two legacy columns database_cleanup fails on, hence its removal before the load into 18; no DROP COLUMN, OpenUpgrade still reads them afterwards. On a throwaway database the statement replays cleanly and leaves alone a view of another shape, a view something depends on, a table of that name. Assisted-by: Claude Opus 5 (cherry picked from commit b50e2308cbdd1efdfa914497062919eab1bbd329)
This commit is contained in:
parent
809273eb34
commit
d2832f5c60
2 changed files with 324 additions and 0 deletions
|
|
@ -34,3 +34,85 @@ BEGIN
|
|||
RAISE NOTICE 'forum_tag_rel.forum_id renommee en forum_post_id';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- account_root : une VUE SQL qu'Odoo 17 créait pour le modèle
|
||||
-- `account.root` (son init() bâtissait la vue sur account_account.code),
|
||||
-- et que la 18 a orphelinée sans la retirer. En 18 le modèle porte
|
||||
-- `_auto = False` et `_table_query = '0'` : son nom n'entre plus dans
|
||||
-- aucune requête, et registry.py exclut ces modèles du contrôle des
|
||||
-- tables manquantes — Odoo ne la recréera donc jamais.
|
||||
--
|
||||
-- Elle n'est pas seulement morte, elle est FAUSSE : bâtie sur la colonne
|
||||
-- `code` que l'ORM 18 n'écrit plus, elle rendait 14 racines là où la
|
||||
-- donnée vivante (code_store) en portait 31. Un objet du schéma public
|
||||
-- qui répond faux, sans avertir, à qui l'interroge à la main ou par un
|
||||
-- outil décisionnel.
|
||||
--
|
||||
-- Elle est AUSSI l'unique épingle de deux colonnes héritées :
|
||||
-- database_cleanup a purgé 110 colonnes orphelines au palier 18 et n'a
|
||||
-- échoué que sur account_account.code et .company_id, dont elle dépend.
|
||||
-- On retire donc l'obstacle ICI, et l'outil déjà en place finit sa passe
|
||||
-- APRÈS le chargement en 18.
|
||||
--
|
||||
-- On ne supprime SURTOUT PAS les colonnes ici : le post-migration
|
||||
-- d'OpenUpgrade les lit encore pour remplir code_store et company_ids,
|
||||
-- et il tourne après ce fichier.
|
||||
DO $$
|
||||
DECLARE
|
||||
oid_vue oid;
|
||||
oid_table oid;
|
||||
nb_dependants int;
|
||||
BEGIN
|
||||
SELECT c.oid INTO oid_vue
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relname = 'account_root'
|
||||
AND c.relkind = 'v';
|
||||
IF oid_vue IS NULL THEN
|
||||
-- Ni vue, ni base sans le module account : rien à faire. C'est
|
||||
-- aussi ce qui rend l'ordre rejouable après un premier passage.
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
oid_table := to_regclass('public.account_account');
|
||||
IF oid_table IS NULL THEN
|
||||
RAISE NOTICE 'account_root sans account_account : laissee en place';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- La vue morte est CELLE QUI LIT account_account.code. Le contrôle
|
||||
-- est structurel et non textuel : si une version future d'Odoo
|
||||
-- recree un account_root d'une autre forme, on n'y touche pas.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_depend d
|
||||
JOIN pg_rewrite r ON r.oid = d.objid
|
||||
JOIN pg_attribute a ON a.attrelid = d.refobjid
|
||||
AND a.attnum = d.refobjsubid
|
||||
WHERE r.ev_class = oid_vue
|
||||
AND d.refobjid = oid_table
|
||||
AND a.attname = 'code'
|
||||
) THEN
|
||||
RAISE NOTICE 'account_root ne lit pas account_account.code : laissee en place';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Rien ne doit en dependre. Le jour ou quelque chose en dependrait,
|
||||
-- APPRENDRE plutot que detruire : on le dit et on passe. Jamais de
|
||||
-- CASCADE, et jamais d echec — ce fichier tourne sous ON_ERROR_STOP.
|
||||
SELECT count(DISTINCT ev.oid) INTO nb_dependants
|
||||
FROM pg_depend d
|
||||
JOIN pg_rewrite r ON r.oid = d.objid
|
||||
JOIN pg_class ev ON ev.oid = r.ev_class
|
||||
WHERE d.refobjid = oid_vue
|
||||
AND ev.oid <> oid_vue;
|
||||
IF nb_dependants > 0 THEN
|
||||
RAISE NOTICE 'account_root : % objet(s) en dependent, laissee en place',
|
||||
nb_dependants;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
DROP VIEW public.account_root;
|
||||
RAISE NOTICE 'vue morte account_root supprimee (Odoo 18 ne la recree pas)';
|
||||
END $$;
|
||||
|
|
|
|||
242
test/test_fix_account_root.py
Normal file
242
test/test_fix_account_root.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Retirer la vue morte account_root, et seulement quand c'est elle.
|
||||
|
||||
Odoo 17 créait une VUE SQL pour le modèle `account.root` ; la 18 l'a
|
||||
orphelinée sans la retirer — son modèle porte `_auto = False` et
|
||||
`_table_query = '0'`, donc son nom n'entre plus dans aucune requête, et
|
||||
registry.py exclut ces modèles du contrôle des tables manquantes.
|
||||
|
||||
Elle n'est pas seulement morte, elle est FAUSSE : bâtie sur la colonne
|
||||
`code` que l'ORM 18 n'écrit plus, elle rendait 14 racines là où la donnée
|
||||
vivante en portait 31. Mesuré sur une migration 12 → 18 réelle.
|
||||
|
||||
Et elle est l'unique épingle de deux colonnes héritées : database_cleanup
|
||||
a purgé 110 colonnes orphelines au palier 18 et n'a échoué que sur
|
||||
account_account.code et .company_id, dont la vue dépend.
|
||||
|
||||
Ce que ce fichier garde, c'est la PRUDENCE du geste. On supprime une vue
|
||||
sur une base client : les quatre refus comptent autant que la suppression.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIX = os.path.join(
|
||||
REPO, "script", "odoo", "migration", "fix_migration_odoo170_to_odoo180.sql"
|
||||
)
|
||||
BASE = "_erplibre_test_account_root"
|
||||
|
||||
|
||||
def psql_disponible():
|
||||
try:
|
||||
done = subprocess.run(
|
||||
["psql", "-X", "-w", "-l"], capture_output=True, timeout=20
|
||||
)
|
||||
return done.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
@unittest.skipUnless(psql_disponible(), "pas de PostgreSQL joignable")
|
||||
class Base(unittest.TestCase):
|
||||
"""Une base jetable, remontée à neuf pour chaque cas."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
subprocess.run(["dropdb", "--if-exists", BASE], capture_output=True)
|
||||
done = subprocess.run(["createdb", BASE], capture_output=True)
|
||||
if done.returncode:
|
||||
raise unittest.SkipTest(
|
||||
"createdb refusé : " + done.stderr.decode()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
subprocess.run(["dropdb", "--if-exists", BASE], capture_output=True)
|
||||
|
||||
def setUp(self):
|
||||
self.sql(
|
||||
"DROP VIEW IF EXISTS autre_vue",
|
||||
"DROP VIEW IF EXISTS account_root",
|
||||
"DROP TABLE IF EXISTS account_root",
|
||||
"DROP TABLE IF EXISTS account_account",
|
||||
)
|
||||
|
||||
def sql(self, *ordres):
|
||||
argv = ["psql", "-X", "-w", "-q", "-d", BASE]
|
||||
for ordre in ordres:
|
||||
argv += ["-c", ordre]
|
||||
done = subprocess.run(argv, capture_output=True, text=True)
|
||||
self.assertEqual(0, done.returncode, done.stderr)
|
||||
return done
|
||||
|
||||
def lire(self, requete):
|
||||
done = subprocess.run(
|
||||
["psql", "-X", "-w", "-tA", "-d", BASE, "-c", requete],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(0, done.returncode, done.stderr)
|
||||
return done.stdout.strip()
|
||||
|
||||
def vue_presente(self):
|
||||
return (
|
||||
self.lire(
|
||||
"SELECT (to_regclass('public.account_root') IS NOT NULL)::text"
|
||||
)
|
||||
== "true"
|
||||
)
|
||||
|
||||
def appliquer(self):
|
||||
"""Le fichier tel que la migration le lance : ON_ERROR_STOP=1."""
|
||||
done = subprocess.run(
|
||||
[
|
||||
"psql",
|
||||
"-X",
|
||||
"-w",
|
||||
"-q",
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-d",
|
||||
BASE,
|
||||
"-f",
|
||||
FIX,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return done.returncode, done.stderr
|
||||
|
||||
def poser_la_vue_morte(self):
|
||||
self.sql(
|
||||
"CREATE TABLE account_account ("
|
||||
" id serial PRIMARY KEY, code varchar, company_id int)",
|
||||
"CREATE VIEW account_root AS SELECT DISTINCT"
|
||||
" ascii(code::text) AS id, left(code::text, 2) AS name,"
|
||||
" company_id FROM account_account WHERE code::text <> ''",
|
||||
)
|
||||
|
||||
|
||||
class TestItRemovesTheDeadView(Base):
|
||||
def test_the_view_is_gone_and_psql_is_happy(self):
|
||||
self.poser_la_vue_morte()
|
||||
self.assertTrue(self.vue_presente())
|
||||
code, _err = self.appliquer()
|
||||
self.assertEqual(0, code)
|
||||
self.assertFalse(self.vue_presente())
|
||||
|
||||
def test_it_says_what_it_did(self):
|
||||
# Une suppression silencieuse sur une base client est une
|
||||
# suppression qu'on ne retrouve pas six mois plus tard.
|
||||
self.poser_la_vue_morte()
|
||||
_code, err = self.appliquer()
|
||||
self.assertIn("account_root supprimee", err)
|
||||
|
||||
def test_running_it_twice_changes_nothing(self):
|
||||
# Le fichier est rejoué à chaque reprise de migration.
|
||||
self.poser_la_vue_morte()
|
||||
self.appliquer()
|
||||
code, err = self.appliquer()
|
||||
self.assertEqual(0, code)
|
||||
self.assertNotIn("NOTICE", err)
|
||||
|
||||
|
||||
class TestWhatItRefusesToTouch(Base):
|
||||
"""Les refus comptent autant que la suppression."""
|
||||
|
||||
def test_a_view_that_does_not_read_code_is_left_alone(self):
|
||||
# Si une version future d'Odoo recrée un account_root d'une autre
|
||||
# forme, il ne nous appartient pas. Le contrôle est STRUCTUREL —
|
||||
# pg_depend — et non un examen du texte de la définition.
|
||||
self.sql(
|
||||
"CREATE TABLE account_account ("
|
||||
" id serial PRIMARY KEY, code varchar, code_store jsonb)",
|
||||
"CREATE VIEW account_root AS SELECT id, code_store"
|
||||
" FROM account_account",
|
||||
)
|
||||
code, err = self.appliquer()
|
||||
self.assertEqual(0, code)
|
||||
self.assertTrue(self.vue_presente())
|
||||
self.assertIn("ne lit pas account_account.code", err)
|
||||
|
||||
def test_a_view_something_depends_on_is_left_alone(self):
|
||||
# Apprendre plutôt que détruire : on le dit, et on passe. Jamais
|
||||
# de CASCADE.
|
||||
self.poser_la_vue_morte()
|
||||
self.sql("CREATE VIEW autre_vue AS SELECT * FROM account_root")
|
||||
code, err = self.appliquer()
|
||||
self.assertEqual(0, code)
|
||||
self.assertTrue(self.vue_presente())
|
||||
self.assertIn("1 objet(s) en dependent", err)
|
||||
|
||||
def test_a_table_named_account_root_is_never_dropped(self):
|
||||
# relkind = 'v' : une TABLE de ce nom porterait des données.
|
||||
self.sql(
|
||||
"CREATE TABLE account_account ("
|
||||
" id serial PRIMARY KEY, code varchar)",
|
||||
"CREATE TABLE account_root (id int, name varchar)",
|
||||
)
|
||||
code, _err = self.appliquer()
|
||||
self.assertEqual(0, code)
|
||||
self.assertEqual(
|
||||
"r",
|
||||
self.lire(
|
||||
"SELECT relkind FROM pg_class"
|
||||
" WHERE relname = 'account_root'"
|
||||
),
|
||||
)
|
||||
|
||||
def test_a_database_without_account_stays_silent(self):
|
||||
# Le module account n'est pas installé partout, et le fichier
|
||||
# tourne sur toutes les bases.
|
||||
code, err = self.appliquer()
|
||||
self.assertEqual(0, code)
|
||||
self.assertNotIn("account_root", err)
|
||||
|
||||
|
||||
class TestItNeverTouchesTheColumns(unittest.TestCase):
|
||||
"""Le post-migration d'OpenUpgrade les lit APRÈS ce fichier.
|
||||
|
||||
Il remplit code_store depuis `code` et company_ids depuis `company_id`.
|
||||
Les retirer ici casserait les deux. Leur suppression revient à
|
||||
database_cleanup, une fois la base chargée en 18.
|
||||
"""
|
||||
|
||||
def sans_commentaire(self):
|
||||
"""Le fichier dépouillé de sa prose.
|
||||
|
||||
Les gardes portent sur le CODE : les commentaires disent justement
|
||||
« on ne supprime pas les colonnes » et « jamais de CASCADE », et
|
||||
les lire suffisait à faire échouer les tests qui l'exigent.
|
||||
"""
|
||||
with io.open(FIX, encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
return "\n".join(
|
||||
ligne
|
||||
for ligne in source.split("\n")
|
||||
if not ligne.strip().startswith("--")
|
||||
)
|
||||
|
||||
def test_the_file_carries_no_drop_column(self):
|
||||
self.assertNotIn("DROP COLUMN", self.sans_commentaire().upper())
|
||||
|
||||
def test_the_only_drop_is_the_view(self):
|
||||
drops = [
|
||||
ligne.strip()
|
||||
for ligne in self.sans_commentaire().split("\n")
|
||||
if "DROP " in ligne.upper()
|
||||
]
|
||||
self.assertEqual(["DROP VIEW public.account_root;"], drops)
|
||||
|
||||
def test_it_never_uses_cascade(self):
|
||||
self.assertNotIn("CASCADE", self.sans_commentaire().upper())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue