J'avais besoin de faire fonctionner Ransack au-dessus de Mongoid 9 sur Ruby 3.3. Difficulté immédiate : la gem ransack-mongoid n'avait pas reçu de commit depuis longtemps, et son gemspec déclarait encore une dépendance à Mongoid 3.2 — une version datant de 2013.
Première tentative, courant 2025. J'y ai consacré environ un mois, sur mes soirées et mes week-ends, à cartographier l'adapter et à tenter de le faire démarrer sur les versions récentes. Le code contenait trop d'ActiveRecord implicite, des références à Polyamorous, et des constantes pointant vers du vide dès lors que Mongoid était chargé seul. Chaque correctif faisait apparaître une nouvelle stack trace ailleurs. Au terme de ce mois, j'ai contourné le problème côté applicatif en réécrivant mes filtres manuellement, et j'ai mis le sujet de côté. Le portage est néanmoins resté sur ma liste de tâches pendant plusieurs mois.
Seconde tentative, 2026, cette fois avec Claude Code intégré à ma chaîne de développement. Huit commits plus tard, la gem fonctionne sur Ruby 3.3, Mongoid 9 et Ransack 4.4. Le diff est propre, la suite de tests est verte, et j'ai identifié au passage un bug de comportement présent depuis plusieurs versions.
Cet article rassemble les enseignements que je tire de cette seconde passe, ainsi que de ce qu'un agent modifie concrètement sur ce type de travail.
Méthode : procéder par étapes plutôt que tout réécrire
Le premier réflexe, face à du code à l'abandon, consiste à le réécrire intégralement. C'est un piège.
Ma règle de conduite : aucune refactorisation avant que la suite de tests ne passe sur les nouvelles versions. On se limite au strict nécessaire pour que le code démarre, compile et fasse passer les specs. Une fois au vert, on peut envisager le reste.
J'ai découpé le travail en cinq phases :
- Reproduction — cloner, lancer les tests, localiser les points de rupture.
- Démarrage — obtenir que
require 'ransack/mongoid'n'échoue plus. - Specs unitaires — les traiter une à une jusqu'à ce que la suite passe.
- Comportement — les cas où le code compile et passe les specs, mais produit des requêtes incorrectes.
- Confort de maintenance — Docker, RVM, exemples, README.
Chaque phase correspond à un ou deux commits. Cette granularité permet le git bisect en cas de régression et autorise un revert sans tout perdre. Le principe paraît évident énoncé ainsi, mais la tentation de tout modifier simultanément reste forte.
La répartition ingénieur / agent
Avant d'entrer dans la technique, un point sur la méthode, car c'est probablement ce qui distingue le plus 2025 de 2026.
Sur ce portage, Claude Code a rempli deux fonctions principales :
- Le travail mécanique de fond. Remplacer l'ensemble des
Bignum/Fixnumdans le visitor, ajouteroptional: truesur lesbelongs_todes fixtures, réécrire les blueprints Machinist encreate!directs, propager les changements de signature dans les specs. Un travail à grande échelle mais sans subtilité — précisément le domaine où un agent excelle. - L'exploration ciblée du code. « Liste tous les endroits où l'adapter référence ActiveRecord ou Arel », « identifie les méthodes de
Ransack::Contextque nous surchargeons déjà et celles que nous devrions surcharger », « établis un diff entre l'API des predicates de Ransack 2.x et 4.4 ». Ce type de recherche structurée me fait économiser plusieurs heures de lecture de code.
En revanche, l'agent n'a pas pris les décisions suivantes :
- Choisir de contourner le
superdeRansack::Context#initializeplutôt que de stubberPolyamorous. Il s'agit d'un arbitrage d'architecture — moins élégant, mais nettement plus robuste face aux futures montées de version de Ransack. Cet arbitrage se mesure à la main. - Identifier le bug
$not(détaillé plus bas). Le mettre au jour suppose d'exécuter de vraies requêtes contre la base et de juger l'erreur suspecte. Un agent qui constate une suite verte conclut que tout fonctionne et poursuit. - Trancher entre monkey-patch et
Ransack.configurepour les predicates. C'est une décision de maintenabilité à long terme, non un problème technique isolé : l'agent peut argumenter dans les deux sens, mais l'arbitrage ne lui revient pas.
La règle qui se dégage : l'agent traite le volume, l'ingénieur définit la direction. Sans cap clair, l'agent vous mène rapidement dans une impasse. Avec un cap clair, il réduit sensiblement le temps de réalisation sur tout ce qui relève de la tâche répétitive ou de l'exploration de code.
C'est exactement le levier qui m'a manqué en 2025 — non la compétence technique, mais l'outillage. Cartographier mentalement Ransack en 2025 signifiait ouvrir une quinzaine de fichiers en parallèle et maintenir le graphe de dépendances en tête. En 2026, c'est une requête à l'agent, et la cartographie arrive en quelques secondes. Le reste du travail — les décisions, le diagnostic des cas complexes — demeure identique.
Phase 1 : Ruby 3, la partie simple
Bignum et Fixnum ont disparu depuis Ruby 2.4. La gem les utilisait dans le visitor de l'adapter :
# Avant
when Bignum, Fixnum
value.to_i
# Après
when Integer
value.to_iCinq minutes de travail. Ce type de changement échoue à la moindre invocation, il faut donc le traiter en priorité avant de progresser. RuboCop, avec la règle Lint/UnifiedInteger, produit la liste complète immédiatement.
Phase 2 : les résidus d'ActiveRecord
C'est ici que le travail devient intéressant.
Ransack est conçu pour ActiveRecord. L'adapter Mongoid hérite de classes Ransack qui supposent AR chargé. Symptôme : au require, l'exécution échoue sur un Polyamorous::OuterJoin introuvable. polyamorous étant la bibliothèque de jointures d'AR, pourquoi se charge-t-elle dans un adapter Mongoid ? Parce que Ransack::Context#initialize appelle un super qui tire une large part de l'écosystème AR.
La solution retenue consiste à contourner le super et à reconstruire uniquement ce dont on a réellement besoin :
def initialize(object, options = {})
# On n'appelle pas super : il charge Polyamorous::OuterJoin
@object = object.is_a?(::Mongoid::Criteria) ? object : object.all
@klass = @object.klass
@base = @engine = @arel_visitor = nil
# ... le strict nécessaire
endMême logique pour visit_Ransack_Nodes_Sort : l'implémentation par défaut enveloppe les valeurs dans Arel::Nodes::Quoted, or Arel n'est pas chargé en l'absence d'ActiveRecord. On surcharge donc la méthode, on contourne Arel et on retourne le tri directement.
Le motif récurrent : partout où Ransack présuppose AR, on protège l'appel par defined?(ActiveRecord) ou l'on surcharge pour fournir l'équivalent natif Mongoid. L'approche n'est pas la plus élégante au premier abord, mais elle évite de subir une régression à chaque release de Ransack.
Phase 3 : reformuler les predicates
Ransack 4.4 fournit des predicates par défaut (cont, start, end, etc.) qui produisent du SQL LIKE — dénué de sens sur MongoDB, où il faut générer des expressions régulières.
L'ancienne version modifiait directement les predicates de Ransack par monkey-patch, une approche fragile qui casse à chaque montée de version. Je suis passé par l'API publique :
Ransack.configure do |config|
config.add_predicate 'cont',
arel_predicate: 'matches',
formatter: ->(v) { Regexp.new(Regexp.escape(v.to_s), Regexp::IGNORECASE) },
validator: ->(v) { v.present? },
type: :string
endAvantage : une éventuelle modification de l'implémentation interne de Ransack reste sans effet, puisque l'on s'appuie sur son API documentée. Regexp.escape remplace par ailleurs la détection d'adapter ActiveRecord — plus simple, et portable partout.
Phase 4 : le bug qui m'a coûté une heure
C'est la découverte dont je suis le plus satisfait.
Les tests passaient, la suite était verte. J'exécute Person.ransack(name_not_eq: "John").result.to_a dans un script ad hoc, et MongoDB renvoie :
unknown top level operator: $not
L'ancienne implémentation produisait :
{ '$not' => { name: /john/i } }Cette forme est invalide. $not n'est pas un opérateur de premier niveau sur MongoDB : il doit être rattaché à un champ. La documentation MongoDB l'indique clairement, mais le cas n'était pas testé, et l'erreur ne survient qu'à l'exécution réelle de la requête, pas à la construction du Hash.
La forme correcte :
{ name: { '$not' => /john/i } }Même correction pour not_in :
# Incorrect — $not de premier niveau
{ '$not' => { name: { '$in' => [...] } } }
# Correct — $nin rattaché au champ
{ name: { '$nin' => [...] } }Si les specs passaient malgré tout, c'est qu'elles vérifiaient la structure du Hash retourné, et non le résultat d'une requête réelle. Un cas classique de bibliothèque qui teste son AST sans jamais interroger le backend.
J'ai corrigé le problème et ajouté un dossier examples/ contenant des scripts qui interrogent effectivement MongoDB.
Enseignement : lorsqu'on porte un composant qui génère des requêtes pour un backend, il faut exécuter ces requêtes. Faute de quoi, on a seulement validé que le code produit du JSON, pas qu'il produit quelque chose de correct.
Phase 5 : nettoyer l'outillage de test
L'ancienne suite reposait sur Machinist, Sham et Faker — trois bibliothèques de génération de données de test, toutes plus ou moins abandonnées et porteuses de leurs propres ruptures de compatibilité accumulées.
J'ai tout supprimé au profit de create! directs :
# Avant — Machinist + Sham
Person.make!(name: Sham.name)
# Après — Mongoid natif
Person.create!(name: "John", age: 30)Plus de DSL spécifique à apprendre, plus de blueprint, plus de sham. La suite gagne quelques lignes et beaucoup en lisibilité.
Deux points de vigilance propres à Mongoid récent :
belongs_toest requis par défaut depuis Mongoid 7 : il faut ajouteroptional: truepartout où l'association peut être nulle dans les fixtures.Symbol#gt(ainsi que toute la familleSymbol#lt,Symbol#in, etc.) a disparu. On revient à la syntaxe hash explicite :{ 'age' => { '$gt' => 18 } }.
Rien d'insurmontable, mais le second point échoue silencieusement si on l'ignore : Symbol#gt retourne désormais un Symbol ordinaire au lieu d'un opérateur, si bien que la requête devient { :age.gt => 18 } — qui ne correspond à aucun document, sans lever d'erreur.
Ce que je retiens pour la prochaine migration
Lire l'historique avant de toucher au code. Le CHANGELOG, les issues, les PR ouvertes. On y trouve souvent quelqu'un ayant déjà tenté la même chose et abandonné ; ses notes sont précieuses.
Garder des commits courts et thématiques. Mes huit commits sont chacun relisibles indépendamment. Le commit « fix: invalid top-level $not » peut être cherry-pické sur une autre branche sans entraîner le reste. C'est un atout majeur pour maintenir un fork dans la durée.
Se méfier des suites vertes. Une suite qui passe ne garantit pas que le code est correct : elle indique seulement qu'il se comporte conformément aux specs. Si les specs vérifient la sérialisation d'un AST mais pas son exécution, le sentiment de sécurité est trompeur — précisément le piège dans lequel l'ancienne version était tombée.
Ne pas hésiter à contourner le super. Lorsqu'on hérite de classes qui présupposent un autre écosystème (AR ↔ Mongoid), contourner le super et reconstruire manuellement est une option légitime, et plus robuste face aux montées de version que l'empilement de correctifs.
S'imposer l'exécution d'exemples réels. Un dossier examples/ réunissant quelques scripts qui interrogent une vraie base en local représente une trentaine de minutes de travail et aurait évité le bug $not. C'est aussi la documentation la plus honnête qu'on puisse livrer : du code qui fonctionne réellement.
Déléguer le volume, conserver la direction. Sur cette migration, Claude Code a considérablement amélioré le rapport temps/résultat sur tout ce qui était mécanique : remplacement de symboles, réécriture de blueprints, identification des call sites. Mais les décisions d'architecture (contourner le super, privilégier Ransack.configure au monkey-patch) et la découverte du bug $not relèvent de l'ingénieur. L'agent suit une direction, il ne la définit pas. À l'inverse, se priver d'un agent sur ce type de tâche en 2026 revient à dépenser des heures sur du travail mécanique là où quelques minutes suffisent.
La PR est disponible ici pour qui souhaite consulter le détail. Les commits suivent la convention Conventional Commits, et chaque message explique le pourquoi autant que le quoi.
Plus largement, ce portage illustre bien ce que les agents IA changent au quotidien d'un développeur backend. La même tâche m'avait coûté un mois de soirées en 2025, avant abandon. En 2026, elle a tenu sur une après-midi. Les compétences requises — lire du Ruby métaprogrammé, comprendre les internals de Ransack, diagnostiquer un bug MongoDB — sont rigoureusement identiques. Ce qui a changé, c'est le coût de la cartographie initiale et celui du travail mécanique : tous deux ont chuté d'un ordre de grandeur. La part de jugement d'ingénieur, elle, reste intacte.