Dans un projet Odoo de PME, les rapports SQL sur mesure sont souvent le code le plus ancien et le moins relu. Une vue d'analyse des ventes par région, un tableau de marge par famille de produits, un reporting logistique branché sur stock.move : ces modèles _auto = False ont souvent été écrits il y a plusieurs versions, puis recopiés de migration en migration. Odoo 20 touche précisément à leur fondation, puisque l'attribut Model._table_query disparaît de l'ORM.
Ce que dit le changelog officiel
Le changelog ORM de la documentation Odoo 20.0 mentionne en une ligne la suppression de Model._table_query, avec un renvoi à la pull request #2812021. Cette PR, intitulée « [IMP] orm: remove Model._table_query », a été fusionnée le 14 août 20262. Sa description justifie le choix ainsi : l'attribut ne devrait jamais être utilisé directement, puisque _table_sql existe déjà et que _table_query construit du SQL par concaténation de chaînes.
Le diff est cohérent avec ce discours. Dans le module account, le modèle account.code.mapping passe de _table_query = '0' à _table_sql = SQL('(0)')3. Le module de lint test_lint ajoute aussi _table_query à sa liste d'attributs dépréciés, avec une consigne explicite : « overwrite _table_sql »4.
Pourquoi l'erreur ne sautera pas aux yeux
En 19.0, la propriété _table_sql de la classe de base lisait _table_query. Si l'attribut était renseigné, elle l'enveloppait entre parenthèses et s'en servait comme sous-requête. Sinon, elle renvoyait le nom de la table du modèle5.
En 20.0, cette lecture a disparu. La propriété renvoie directement l'identifiant de self._table, puis ajoute les dépendances déclarées dans _depends6. Un module custom qui définit encore _table_query ne déclenche donc aucune erreur au chargement de la classe. L'ORM ignore simplement l'attribut et interroge une table portant le nom du modèle. Pour un modèle _auto = False sans vue créée dans init(), cette table n'existe pas, et l'erreur PostgreSQL n'apparaît qu'à la première lecture, typiquement quand un utilisateur ouvre le rapport. Seule la suite de tests de lint signale le problème en amont, à condition que vous la fassiez tourner sur vos modules.
Les rapports qui créent une vue PostgreSQL dans init(), comme report.project.task.user, ne sont pas concernés par ce mécanisme. Leur code a pourtant bougé lui aussi : les fragments _select(), _from() et _where() renvoient désormais des objets SQL au lieu de chaînes, et la valeur RATING_LIMIT_MIN passe en paramètre au lieu d'être injectée par f-string7. Un héritage qui concatène une chaîne au résultat de super()._select() mérite donc une relecture.
sale.report, le cas qui concerne presque tout le monde
Dans les projets que nous reprenons, le rapport d'analyse des ventes figure parmi les modèles les plus souvent étendus. En 19.0, il reposait sur _table_query et sur une série de hooks textuels : _select_additional_fields(), _from_sale(), _where_sale(), _group_by_sale() et _query()8.
Dans la branche 20.0, aucun de ces hooks ne subsiste. Le fichier surcharge _table_sql et construit la requête à partir de l'ORM : un _search() sur sale.order.line produit l'objet requête, puis trois méthodes prennent le relais9.
| Rôle | Odoo 19.0 | Odoo 20.0 |
|---|---|---|
| Colonnes ajoutées | _select_additional_fields() (dict de chaînes) | _select_dict(table) (dict d'expressions SQL) |
| Jointures | _from_sale() (FROM écrit à la main) | Chemins de champs : table.order_id.partner_id |
| Filtre | _where_sale() | _order_line_domain() (un Domain) |
| Regroupement | _group_by_sale() (chaîne) | _groupby_list(table) (liste) |
Concrètement, un module qui ajoutait une région commerciale portée par la commande ressemblait à ceci en 19.0 :
def _select_additional_fields(self):
res = super()._select_additional_fields()
res['x_region_id'] = 's.x_region_id'
return res
def _group_by_sale(self):
return super()._group_by_sale() + ', s.x_region_id'
En 20.0, la même extension s'écrit avec les chemins de champs de l'objet TableSQL :
def _select_dict(self, table):
res = super()._select_dict(table)
res['x_region_id'] = table.order_id.x_region_id
return res
def _groupby_list(self, table):
return super()._groupby_list(table) + [table.order_id.x_region_id]
Un détail du nouveau code mérite votre attention. La méthode _select_dict_to_list() parcourt tous les champs stockés du modèle et remplace par NULL ceux qui n'ont pas d'entrée dans le dictionnaire9. Si vous déclarez un champ sur sale.report sans l'ajouter à _select_dict(), le rapport s'affiche donc sans erreur, avec une colonne vide. Le module pos_sale montre le modèle à suivre pour les extensions plus lourdes : il surcharge _table_sql et combine sa propre sous-requête avec super()._table_sql par un UNION ALL10.
Comment préparer la migration
Avant de lancer une montée de version, un inventaire rapide suffit à mesurer l'exposition. Un grep sur _table_query, _select_additional_fields, _from_sale et _group_by_sale dans vos dépôts custom donne la liste des fichiers à réécrire. Il faut ensuite distinguer deux familles. Les modèles qui définissaient leur propre _table_query passent à une propriété _table_sql qui renvoie un objet SQL entre parenthèses. Les héritages de rapports standard doivent suivre la nouvelle API du rapport parent, ce qui demande de relire le fichier source 20.0 module par module.
Nous recommandons aussi d'ajouter à votre CI un test qui lit chaque rapport custom avec search_read sur une base de démonstration. Ce test coûte quelques lignes et attrape à la fois la table introuvable et la colonne restée à NULL. Le sujet rejoint d'autres ruptures de l'ORM 20 déjà évoquées sur ce blog, comme la fin du base64 dans les champs Binary.
Notre lecture
Odoo poursuit un mouvement engagé depuis plusieurs versions : retirer les chaînes SQL brutes au profit de l'objet SQL et des chemins de champs, qui gèrent paramètres, jointures et dépendances de flush. Le gain en sécurité et en maintenabilité est réel, car les injections par f-string disparaissent du code standard. La facture tombe pourtant sur les intégrateurs, parce que la suppression de _table_query reste silencieuse à l'exécution et que la refonte de sale.report casse des hooks que beaucoup de modules spécifiques surchargent. Sur nos chiffrages de migration vers Odoo 20, nous comptons désormais chaque rapport SQL custom comme un développement à part entière, et non comme une simple adaptation de syntaxe.
Sources
- Odoo, Changelog ORM (documentation 20.0) : https://www.odoo.com/documentation/20.0/developer/reference/backend/orm/changelog.html
- GitHub, odoo/odoo PR #281202 « [IMP] orm: remove Model._table_query » : https://github.com/odoo/odoo/pull/281202
- GitHub, odoo/odoo, odoo/orm/models.py (20.0) : https://github.com/odoo/odoo/blob/20.0/odoo/orm/models.py
- GitHub, odoo/odoo, odoo/orm/models.py (19.0) : https://github.com/odoo/odoo/blob/19.0/odoo/orm/models.py
- GitHub, odoo/odoo, test_model_attributes.py (20.0) : https://github.com/odoo/odoo/blob/20.0/odoo/addons/test_lint/tests/test_model_attributes.py
- GitHub, odoo/odoo, account_code_mapping.py (20.0) : https://github.com/odoo/odoo/blob/20.0/addons/account/models/account_code_mapping.py
- GitHub, odoo/odoo, sale_report.py (20.0) : https://github.com/odoo/odoo/blob/20.0/addons/sale/report/sale_report.py
- GitHub, odoo/odoo, sale_report.py (19.0) : https://github.com/odoo/odoo/blob/19.0/addons/sale/report/sale_report.py
- GitHub, odoo/odoo, pos_sale sale_report.py (20.0) : https://github.com/odoo/odoo/blob/20.0/addons/pos_sale/report/sale_report.py
- GitHub, odoo/odoo, project_report.py (20.0) : https://github.com/odoo/odoo/blob/20.0/addons/project/report/project_report.py
Footnotes
-
Changelog ORM de la documentation Odoo 20.0, section « Odoo version 20.0 ». ↩
-
Pull request odoo/odoo#281202, fusionnée par le bot d'intégration le 14 août 2026. ↩
-
Fichier
addons/account/models/account_code_mapping.py, branche 20.0. ↩ -
Fichier
odoo/addons/test_lint/tests/test_model_attributes.py, branche 20.0, listeDEPRECATED_MODEL_ATTRIBUTES. ↩ -
Fichier
odoo/orm/models.py, branche 19.0, propriété_table_sql. ↩ -
Fichier
odoo/orm/models.py, branche 20.0, propriété_table_sql. Notre lecture du code ne montre aucun avertissement d'exécution lié à_table_query. ↩ -
Comparaison du fichier
addons/project/report/project_report.pyentre les branches 19.0 et 20.0. ↩ -
Fichier
addons/sale/report/sale_report.py, branche 19.0. ↩ -
Fichier
addons/sale/report/sale_report.py, branche 20.0, méthodes_table_sql,_select_dict,_groupby_listet_select_dict_to_list. ↩ ↩2 -
Fichier
addons/pos_sale/report/sale_report.py, branche 20.0. ↩