diff --git a/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql b/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql index 65993f4..e58f32e 100644 --- a/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql +++ b/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql @@ -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 $$; diff --git a/test/test_fix_account_root.py b/test/test_fix_account_root.py new file mode 100644 index 0000000..498e50b --- /dev/null +++ b/test/test_fix_account_root.py @@ -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()