Back to blog
#ruby#rails#mongodb#mongoid#ransack#migration

Reviving ransack-mongoid: Ruby 3, Mongoid 9, Ransack 4.4

A first attempt abandoned in 2025, a second one carried through in 2026 with the help of an AI agent: eight commits, zero regressions. A field report on multi-version migrations and on how the work actually splits between engineer and agent.

10 min min read

I needed Ransack to work on top of Mongoid 9 on Ruby 3.3. The immediate obstacle: the ransack-mongoid gem hadn't seen a commit in a long time, and its gemspec still declared a dependency on Mongoid 3.2 — a version dating back to 2013.

First attempt, sometime in 2025. I spent roughly a month on it, across evenings and weekends, mapping the adapter and trying to get it to boot on recent versions. The code carried too much implicit ActiveRecord, references to Polyamorous, and constants pointing at nothing once Mongoid was loaded on its own. Every fix surfaced a new stack trace elsewhere. By the end of that month, I worked around the problem at the application level by rewriting my filters by hand, and set the matter aside. The port nonetheless stayed on my task list for several months.

Second attempt, 2026, this time with Claude Code integrated into my development pipeline. Eight commits later, the gem runs on Ruby 3.3, Mongoid 9, and Ransack 4.4. The diff is clean, the test suite is green, and along the way I identified a behavioral bug that had been present for several versions.

This article gathers the lessons I draw from that second pass, as well as from what an agent concretely changes in this kind of work.

Method: proceed in stages rather than rewriting everything

The first instinct, faced with abandoned code, is to rewrite it entirely. That's a trap.

My guiding rule: no refactoring before the test suite passes on the new versions. You limit yourself to the strict minimum needed for the code to boot, compile, and pass the specs. Once green, you can consider the rest.

I broke the work into five phases:

  1. Reproduction — clone, run the tests, locate the breakage points.
  2. Boot — get require 'ransack/mongoid' to stop failing.
  3. Unit specs — handle them one by one until the suite passes.
  4. Behavior — the cases where the code compiles and passes the specs, but produces incorrect queries.
  5. Quality of life — Docker, RVM, examples, README.

Each phase maps to one or two commits. That granularity allows git bisect in case of a regression and enables a revert without losing everything. The principle sounds obvious put this way, but the temptation to change everything at once remains strong.

The engineer / agent split

Before getting into the technical detail, a word on method, because this is probably what most distinguishes 2025 from 2026.

On this port, Claude Code filled two main functions:

  • The mechanical groundwork. Replacing every Bignum/Fixnum in the visitor, adding optional: true to the belongs_to associations in the fixtures, rewriting the Machinist blueprints as direct create! calls, propagating signature changes across the specs. Large-scale work with no subtlety to it — precisely where an agent excels.
  • Targeted code exploration. "List everywhere the adapter references ActiveRecord or Arel," "identify the methods of Ransack::Context we already override and the ones we should override," "produce a diff between the Ransack 2.x and 4.4 predicate APIs." This kind of structured search saves me several hours of reading code.

By contrast, the agent did not make the following decisions:

  • Choosing to skip the super in Ransack::Context#initialize rather than stubbing Polyamorous. This is an architectural trade-off — less elegant, but markedly more robust in the face of future Ransack upgrades. That trade-off is weighed by hand.
  • Finding the $not bug (detailed below). Surfacing it requires running real queries against the database and judging the error suspicious. An agent that observes a green suite concludes everything works and moves on.
  • Deciding between a monkey-patch and Ransack.configure for the predicates. This is a long-term maintainability decision, not an isolated technical problem: the agent can argue either way, but the call isn't its to make.

The rule that emerges: the agent handles volume, the engineer sets direction. Without a clear heading, the agent leads you into a dead end — fast. With a clear heading, it substantially cuts the time to completion on anything repetitive or exploratory.

That's exactly the leverage I lacked in 2025 — not the technical skill, but the tooling. Mentally mapping Ransack in 2025 meant opening fifteen files in parallel and holding the dependency graph in my head. In 2026, it's a query to the agent, and the map arrives within seconds. The rest of the work — the decisions, the diagnosis of the tricky cases — is unchanged.

Phase 1: Ruby 3, the easy part

Bignum and Fixnum have been gone since Ruby 2.4. The gem used them in the adapter's visitor:

# Before
when Bignum, Fixnum
  value.to_i
# After
when Integer
  value.to_i

Five minutes of work. This kind of change fails on the slightest invocation, so it has to be dealt with first before making progress. RuboCop, with the Lint/UnifiedInteger rule, produces the full list immediately.

Phase 2: the ActiveRecord remnants

This is where the work gets interesting.

Ransack is designed for ActiveRecord. The Mongoid adapter inherits from Ransack classes that assume AR is loaded. Symptom: at require, execution fails on a Polyamorous::OuterJoin that can't be found. Since polyamorous is AR's join library, why does it load inside a Mongoid adapter? Because Ransack::Context#initialize calls a super that pulls in a large part of the AR ecosystem.

The chosen solution is to skip the super and rebuild only what's actually needed:

def initialize(object, options = {})
  # We don't call super: it pulls in Polyamorous::OuterJoin
  @object = object.is_a?(::Mongoid::Criteria) ? object : object.all
  @klass  = @object.klass
  @base   = @engine = @arel_visitor = nil
  # ... the strict minimum
end

Same logic for visit_Ransack_Nodes_Sort: the default implementation wraps values in Arel::Nodes::Quoted, yet Arel isn't loaded in the absence of ActiveRecord. So we override the method, bypass Arel, and return the sort directly.

The recurring pattern: wherever Ransack presupposes AR, guard the call with defined?(ActiveRecord) or override it to provide the native Mongoid equivalent. The approach isn't the most elegant at first glance, but it avoids taking a regression with every Ransack release.

Phase 3: reformulating the predicates

Ransack 4.4 ships default predicates (cont, start, end, etc.) that produce SQL LIKE — meaningless on MongoDB, where you need to generate regular expressions.

The old version modified Ransack's predicates directly via monkey-patch, a fragile approach that breaks with every version bump. I went through the public API instead:

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
end

The advantage: any change to Ransack's internal implementation stays harmless, since we rely on its documented API. Regexp.escape also replaces the ActiveRecord adapter detection — simpler, and portable everywhere.

Phase 4: the bug that cost me an hour

This is the find I'm most pleased with.

The tests passed, the suite was green. I run Person.ransack(name_not_eq: "John").result.to_a in an ad hoc script, and MongoDB returns:

unknown top level operator: $not

The old implementation produced:

{ '$not' => { name: /john/i } }

That form is invalid. $not is not a top-level operator on MongoDB: it must be field-scoped. The MongoDB documentation states this clearly, but the case wasn't tested, and the error surfaces only at real query execution, not at Hash construction.

The correct form:

{ name: { '$not' => /john/i } }

Same correction for not_in:

# Incorrect — top-level $not
{ '$not' => { name: { '$in' => [...] } } }
# Correct — field-scoped $nin
{ name: { '$nin' => [...] } }

If the specs passed regardless, it's because they checked the structure of the returned Hash, not the result of a real query. A classic case of a library testing its AST without ever hitting the backend.

I fixed the problem and added an examples/ directory containing scripts that actually query MongoDB.

Lesson: when porting a component that generates queries for a backend, you have to run those queries. Otherwise you've only confirmed that the code produces JSON, not that it produces something correct.

Phase 5: cleaning up the test tooling

The old suite relied on Machinist, Sham, and Faker — three test-data generation libraries, all more or less abandoned and each carrying its own accumulated breaking changes.

I removed all of them in favor of direct create! calls:

# Before — Machinist + Sham
Person.make!(name: Sham.name)
 
# After — native Mongoid
Person.create!(name: "John", age: 30)

No more custom DSL to learn, no blueprints, no sham. The suite gains a few lines and a great deal of readability.

Two points of caution specific to recent Mongoid:

  • belongs_to is required by default since Mongoid 7: you have to add optional: true everywhere the association can be nil in the fixtures.
  • Symbol#gt (along with the whole Symbol#lt, Symbol#in, etc. family) is gone. You revert to the explicit hash syntax: { 'age' => { '$gt' => 18 } }.

Nothing insurmountable, but the second point fails silently if you overlook it: Symbol#gt now returns an ordinary Symbol instead of an operator, so the query becomes { :age.gt => 18 } — which matches no document, without raising an error.

What I'm taking away for the next migration

Read the history before touching the code. The CHANGELOG, the issues, the open PRs. You'll often find someone who already attempted the same thing and gave up; their notes are worth their weight in gold.

Keep commits short and thematic. My eight commits are each independently reviewable. The "fix: invalid top-level $not" commit can be cherry-picked onto another branch without dragging the rest along. That's a major asset for maintaining a fork over time.

Be wary of green suites. A passing suite doesn't guarantee the code is correct: it only indicates that the code behaves according to the specs. If the specs verify an AST's serialization but not its execution, the sense of safety is deceptive — exactly the trap the old version had fallen into.

Don't hesitate to skip the super. When inheriting from classes that presuppose a different ecosystem (AR ↔ Mongoid), skipping the super and rebuilding by hand is a legitimate option, and more robust against version bumps than stacking fixes on top.

Force yourself to run real examples. An examples/ directory holding a handful of scripts that query a real local database represents about thirty minutes of work and would have prevented the $not bug. It's also the most honest documentation you can ship: code that actually works.

Delegate volume, keep direction. On this migration, Claude Code considerably improved the time-to-result ratio on everything mechanical: symbol replacement, blueprint rewriting, call-site identification. But the architectural decisions (skipping the super, favoring Ransack.configure over the monkey-patch) and the discovery of the $not bug are the engineer's. The agent follows a direction; it doesn't set one. Conversely, forgoing an agent on this kind of task in 2026 means spending hours on mechanical work where a few minutes would do.


The PR is available here for anyone who wants the detail. The commits follow the Conventional Commits convention, and each message explains the why as much as the what.

More broadly, this port is a good illustration of what AI agents change in a backend developer's daily work. The same task cost me a month of evenings in 2025, before I gave up. In 2026, it fit into a single afternoon. The skills required — reading metaprogrammed Ruby, understanding Ransack internals, diagnosing a MongoDB bug — are rigorously identical. What changed is the cost of the initial mapping and that of the mechanical work: both dropped by an order of magnitude. The engineering-judgment part remains intact.

Next postSillage: privacy-first event tracking for Laravel