문자열 가정이 Ruby에서 뉴스레터 태그 필터링을 깨뜨린 경우

작성자

카테고리:

← 피드로
DEV Community · Yashika Vijayvargiya · 2026-08-14 개발(SW)

A bug doesn’t always need hundreds of lines of code to cause a real production problem.

Sometimes, one assumption is enough.

In this case, the assumption was simple:

“Every tag is a String.”

That assumption lived inside a Ruby filtering method in the open-source RubyEvents project.

It worked perfectly — until a tag wasn’t a String.

That small mismatch was enough to turn a normal user interaction into an error.

This is the story of how I tracked it down and fixed it.

The Bug

The issue was reported as:

“Cannot Click tags on Newsletter”

The behavior was straightforward:

  1. Open a newsletter.
  2. Click one of its tags.
  3. Instead of getting the filtered announcements, the application raises an error.

The issue was tracked as #1836.

At first glance, this sounds like a routing or controller problem.

It wasn’t.

The failure was deeper in the tag-filtering logic.

Finding the Assumption

The filtering logic lived in Announcement::Collection#by_tag.

The original implementation looked like this:

def by_tag(tag)
  Collection.new(
    select { |a| a.tags.map(&:downcase).include?(tag.downcase) }
  )
end

Enter fullscreen mode Exit fullscreen mode

The intent is easy to understand:

  • take all tags from an announcement
  • convert them to lowercase
  • compare them with the requested tag
  • return matching announcements

For normal String values, this works perfectly.

For example:

["Ruby", "Rails"].map(&:downcase)
# => ["ruby", "rails"]

Enter fullscreen mode Exit fullscreen mode

But map(&:downcase) contains an important assumption:

Every element must respond to downcase.

That’s not necessarily true.

Imagine the collection contains:

["Ruby", 123]

Enter fullscreen mode Exit fullscreen mode

Calling:

["Ruby", 123].map(&:downcase)

Enter fullscreen mode Exit fullscreen mode

tries to execute:

123.downcase

Enter fullscreen mode Exit fullscreen mode

And integers don’t have a downcase method.

The result is a NoMethodError.

So the real problem wasn’t the newsletter link itself.

The newsletter was simply the path that exposed an unsafe assumption in the tag-filtering code.

The Interesting Part: Fixing the Bug Without Changing the Behavior

There were several ways this could have been “fixed.”

For example, we could have assumed that all tags should always be Strings and modified the data at the source.

But that would change the scope of the fix.

The filtering method already had a clear responsibility:

Find announcements whose tags match the requested tag, case-insensitively.

The safer approach was to make that comparison resilient to the actual values it receives.

I changed the implementation to:

def by_tag(tag)
  Collection.new(
    select { |a| a.tags.any? { |tag_value| tag_value.to_s.casecmp?(tag) } }
  )
end

Enter fullscreen mode Exit fullscreen mode

There are two important changes here.

1. to_s creates a safe comparison boundary

Instead of assuming:

tag_value.downcase

Enter fullscreen mode Exit fullscreen mode

we explicitly convert the value:

tag_value.to_s

Enter fullscreen mode Exit fullscreen mode

Now a String remains a String:

"Ruby".to_s
# => "Ruby"

Enter fullscreen mode Exit fullscreen mode

And a non-String value becomes safely comparable:

123.to_s
# => "123"

Enter fullscreen mode Exit fullscreen mode

The filtering code no longer crashes simply because a tag value isn’t already a String.

2. casecmp? expresses the actual requirement

The original code lowercased both sides:

tag_value.downcase == tag.downcase

Enter fullscreen mode Exit fullscreen mode

But the actual requirement isn’t “convert everything to lowercase.”

The requirement is:

Compare these values without considering case.

Ruby provides exactly that operation:

casecmp?

Enter fullscreen mode Exit fullscreen mode

So the comparison becomes:

tag_value.to_s.casecmp?(tag)

Enter fullscreen mode Exit fullscreen mode

This makes the intent clearer.

We’re not transforming the data just to compare it.

We’re performing a case-insensitive comparison.

Why any? Instead of map?

This is another small but meaningful improvement.

The old implementation transformed every tag:

a.tags.map(&:downcase).include?(tag.downcase)

Enter fullscreen mode Exit fullscreen mode

The new implementation asks the question directly:

a.tags.any? { |tag_value| ... }

Enter fullscreen mode Exit fullscreen mode

We don’t actually need a transformed array.

We only need to know:

Does at least one tag match?

any? expresses that directly.

It also means we can stop looking as soon as a matching tag is found.

So the new implementation is not simply more defensive.

It is also closer to the intent of the operation.

Before vs After

Before

def by_tag(tag)
  Collection.new(
    select { |a| a.tags.map(&:downcase).include?(tag.downcase) }
  )
end

Enter fullscreen mode Exit fullscreen mode

The hidden assumption:

Every tag
   ↓
must respond to #downcase

Enter fullscreen mode Exit fullscreen mode

If one doesn’t:

NoMethodError
   ↓
request fails
   ↓
user cannot follow the newsletter tag

Enter fullscreen mode Exit fullscreen mode

After

def by_tag(tag)
  Collection.new(
    select { |a| a.tags.any? { |tag_value| tag_value.to_s.casecmp?(tag) } }
  )
end

Enter fullscreen mode Exit fullscreen mode

Now the comparison becomes:

Tag value
   ↓
convert safely to String
   ↓
case-insensitive comparison
   ↓
match / no match

Enter fullscreen mode Exit fullscreen mode

The existing filtering behavior remains intact while the unsafe type assumption is removed.

Why This Bug Is Easy to Miss

This is what I found most interesting about the issue.

The original code isn’t obviously bad.

For a dataset containing only Strings, it is perfectly reasonable Ruby code:

a.tags.map(&:downcase)

Enter fullscreen mode Exit fullscreen mode

The problem only appears when the runtime data doesn’t match the assumption made by the implementation.

That’s a common class of production bugs:

Code assumption
       ↓
"this value will always be a String"
       ↓
Works for normal data
       ↓
Unexpected value enters the system
       ↓
Runtime failure

Enter fullscreen mode Exit fullscreen mode

The lesson isn’t “never use downcase.”

The lesson is:

Know where your assumptions about data types are coming from.

If a method operates on data that can contain different types, the boundary where those values are consumed should be resilient.

The Final Change

The actual production change was intentionally small:

- Collection.new(select { |a| a.tags.map(&:downcase).include?(tag.downcase) })
+ Collection.new(select { |a| a.tags.any? { |tag_value| tag_value.to_s.casecmp?(tag) } })

Enter fullscreen mode Exit fullscreen mode

One line changed.

But that one line removed the assumption that every tag value is already a String.

I also fixed a linting issue in a follow-up commit.

The pull request was reviewed by the RubyEvents maintainer, passed all six checks, and was merged into the project’s main branch.

PR: #1847 — Fix tag filtering for non-string tags

Issue: #1836 — Cannot Click tags on Newsletter

What I Learned

1. Small bugs can expose bigger assumptions

The code wasn’t complicated.

The assumption behind the code was the real problem.

Whenever I see code such as:

items.map(&:some_method)

Enter fullscreen mode Exit fullscreen mode

I now ask myself:

Do I know for certain that every item responds to this method?

That question becomes particularly important at boundaries where data may come from different sources.

2. Fix the behavior, not just the exception

It would have been easy to focus only on preventing the NoMethodError.

But the goal should be broader:

  • preserve existing behavior
  • make the comparison safe
  • make the intent clearer
  • avoid unnecessary transformations

The final implementation does all four.

3. Ruby’s expressive methods can make intent clearer

Compare:

map(...).include?(...)

Enter fullscreen mode Exit fullscreen mode

with:

any? { ... }

Enter fullscreen mode Exit fullscreen mode

The second version tells the reader what we’re actually asking.

We’re not interested in producing another collection.

We’re asking whether any tag matches.

That distinction matters when maintaining code long after the original bug has been forgotten.

Open Source Contributions Are Full of These Bugs

One of the things I enjoy about contributing to open source is that you get to work with code outside the assumptions of your own applications.

You don’t know every historical decision behind a method.

You don’t know every shape of data that has passed through it.

And you don’t get to rewrite the whole system just because you found one imperfect assumption.

You have to understand the existing behavior, make the smallest responsible change, and prove that the fix doesn’t break what was already working.

That’s exactly what made this bug interesting to me.

The final diff was tiny.

The reasoning behind it was not.

The Takeaway

A production bug doesn’t always announce itself with a complicated stack trace or a thousand-line fix.

Sometimes it’s hidden inside a single assumption:

"Every tag is a String."

Enter fullscreen mode Exit fullscreen mode

When that assumption stopped being true, clicking a newsletter tag stopped working.

The fix was to make the comparison type-safe, preserve case-insensitive matching, and express the filtering intent more directly.

One line changed. One user-facing failure removed. One more reminder that robust software is often about handling the values we didn’t expect.

원문에서 계속 ↗