NXL Forge.Pendant que le marché rédige — nous livrons
Technique Odoo··5 min de lecture

Odoo 20 supprime _table_query : vos rapports SQL à réécrire

Odoo 20 retire l'attribut _table_query de l'ORM et réécrit sale.report sur l'objet SQL et les chemins de champs. Un module qui garde l'ancien attribut ne plante pas au chargement : il échoue quand un utilisateur ouvre le rapport. Voici comment repérer et migrer vos rapports custom.

Odoo 20ORMMigrationReportingDéveloppement

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ôleOdoo 19.0Odoo 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

Footnotes

  1. Changelog ORM de la documentation Odoo 20.0, section « Odoo version 20.0 ». ↩

  2. Pull request odoo/odoo#281202, fusionnée par le bot d'intégration le 14 août 2026. ↩

  3. Fichier addons/account/models/account_code_mapping.py, branche 20.0. ↩

  4. Fichier odoo/addons/test_lint/tests/test_model_attributes.py, branche 20.0, liste DEPRECATED_MODEL_ATTRIBUTES. ↩

  5. Fichier odoo/orm/models.py, branche 19.0, propriété _table_sql. ↩

  6. 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. ↩

  7. Comparaison du fichier addons/project/report/project_report.py entre les branches 19.0 et 20.0. ↩

  8. Fichier addons/sale/report/sale_report.py, branche 19.0. ↩

  9. Fichier addons/sale/report/sale_report.py, branche 20.0, méthodes _table_sql, _select_dict, _groupby_list et _select_dict_to_list. ↩ ↩2

  10. Fichier addons/pos_sale/report/sale_report.py, branche 20.0. ↩

Sur le même thème

Votre idée mérite un vrai logiciel

Une question, un projet Odoo ?

Modules préconfigurés, intégration, sur-mesure — parlons-en sans engagement.