Bug report to pull request in under 1 hour
Tags: programming, softwaredevelopment, opensource, rails
Published: 2020-04-14 11:56:23

---
title: Bug report to pull request in under 1 hour
published: true
date: 2018-02-12 21:53:14 UTC
tags: programming,software-development,open-source,ruby-on-rails
canonical_url: 
---

A user came in to our IRC channel today complaining about an issue with searching:

```
[16:31] <user> Why is it that when I search parent_hostgroup = “somerandom” in
the “Hosts” searchbox, where “somerandom” doesn’t match anything, it gives me a 
list of all hosts instead of none? I have some issues with this behavior when 
performing searches using the API
[16:40] <tbrisker> user: sounds like a bug, can you open an issue on 
projects.theforeman.org?
[16:49] <user> tbrisker, https://projects.theforeman.org/issues/22556
```

Luckily, this one was a quick fix. But let’s dive in together and understand why it failed in the first place.

We use a gem called [Scoped Search](https://github.com/wvanbergen/scoped_search/) to handle searches in our application. It allows us to provide our users with the ability to search the various resources in the application using a powerful query language and auto-completer. While for most cases scoped search Just Works™, sometimes more complex logic is needed for certain queries. This is possible by telling scoped search to use an external method to generate the query by passing an :ext_method parameter to the search definition.

The case of parent_hostgroup is one such case. A host in Foreman can belong to a host group, which in turn might be a child of one or more host groups. To allow searching by parent host group, i.e. any hosts that belong to a certain host group or any of its children, an external method is needed:

```ruby
def search_by_hostgroup_and_descendants(key, operator, value)
  conditions = sanitize_sql_for_conditions(["hostgroups.title #{operator} ?", value_to_sql(operator, value)])
  # Only one hostgroup (first) is used to determined descendants. Future TODO - alert if result results more than one hostgroup
  hostgroup     = Hostgroup.unscoped.with_taxonomy_scope.where(conditions).first
  hostgroup_ids = hostgroup.subtree_ids
  if hostgroup_ids.any?
    opts = "hosts.hostgroup_id IN (#{hostgroup_ids.join(',')})"
  else
    opts = "hosts.id < 0"
  end
  {:conditions => opts}
end
```

Can you spot the bug?

It’s on line 5. If no host groups match the search on line 4, `hostgroup` will be `nil`. That means that `hostgroup.subtree_ids` should raise an exception:

```
NoMethodError: undefined method `subtree_ids' for nil:NilClass
```

That will be easy to fix, we just need to make sure that the hostgroup is present before collecting its sub-tree ids:

```ruby
def search_by_hostgroup_and_descendants(key, operator, value)
  conditions = sanitize_sql_for_conditions(["hostgroups.title #{operator} ?", value_to_sql(operator, value)])
  # Only one hostgroup (first) is used to determined descendants. Future TODO - alert if result results more than one hostgroup
  hostgroup     = Hostgroup.unscoped.with_taxonomy_scope.where(conditions).first
  if hostgroup.present?
    hostgroup_ids = hostgroup.subtree_ids
    opts = "hosts.hostgroup_id IN (#{hostgroup_ids.join(',')})"
  else
    opts = "1 < 0"
  end
  {:conditions => opts}
end
```

And sure enough, less then an hour after the initial report, I had a pull request ready, which also included a unit test to ensure this function won’t break again in the future:

```
[17:23] <tbrisker> user: https://github.com/theforeman/foreman/pull/5249
[17:24] <user> tbrisker, thanks! :)
```

34 minutes of work and one happy user!

Since I try to follow the [Boy Scout Rule](http://programmer.97things.oreilly.com/wiki/index.php/The_Boy_Scout_Rule), I also fixed a minor issue on line 9: the idea here is to return a condition that is always evaluated to false, thereby returning no results for the search. However, when the DB sees a condition like host.id < 0, it checks if any of the IDs are in fact smaller then 0. When you have tens of thousands of objects, this condition combined with other conditions (like default scope, default order, STI type etc.), even when executed against the index, can still take some time:

```sql
foreman=# explain analyze SELECT “hosts”.* FROM “hosts” WHERE “hosts”.”type” IN (‘Host::Managed’) AND ((hosts.id < 0)) ORDER BY “hosts”.”name” ASC;
 QUERY PLAN 
— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — 
 Sort (cost=62.19..62.20 rows=4 width=6134) (actual time=17.702..17.702 rows=0 loops=1)
 Sort Key: name
 Sort Method: quicksort Memory: 25kB
 -> Bitmap Heap Scan on hosts (cost=16.50..62.15 rows=4 width=6134) (actual time=17.692..17.692 rows=0 loops=1)
 Recheck Cond: ((type)::text = ‘Host::Managed’::text)
 Filter: (id < 0)
 Rows Removed by Filter: 43835
 Heap Blocks: exact=1429
 -> Bitmap Index Scan on index_hosts_on_type (cost=0.00..16.50 rows=12 width=0) (actual time=9.268..9.268 rows=43835 loops=1)
 Index Cond: ((type)::text = ‘Host::Managed’::text)
 Planning time: 0.359 ms
 Execution time: 17.808 ms
(12 rows)
```

Ok, that’s not too bad 😄

But on the other hand, when the RDBMS sees a condition like 1 < 0 it immediately evaluates it to false and returns much faster (by about 3 orders of magnitude):

```sql
foreman=# explain analyze SELECT "hosts".* FROM "hosts" WHERE "hosts"."type" IN ('Host::Managed') AND ((1 < 0)) ORDER BY "hosts"."name" ASC;
 QUERY PLAN 
------------------------------------------------------------------
 Sort (cost=0.01..0.02 rows=0 width=6134) (actual time=0.011..0.011 rows=0 loops=1)
 Sort Key: name
 Sort Method: quicksort Memory: 25kB
 -> Result (cost=0.00..0.00 rows=0 width=6134) (actual time=0.001..0.001 rows=0 loops=1)
 One-Time Filter: false
 Planning time: 0.241 ms
 Execution time: 0.077 ms
(7 rows)
```

Not a huge gain, but doesn’t hurt either. Perhaps learning this trick may come in handy for other cases, where it may lead to a more significant gain.

But wait, the user wasn’t actually complaining about an error. They complained that when the parent host group doesn’t exist, all hosts are returned instead of none of them. Why didn’t they get the `NoMethodError`? That’s peculiar!

Good thing the Scoped Search gem is Open Source, I can take a look inside and try to figure out what happened.

Here is the code that handles the external method definitions inside Scoped Search:

```ruby
def to_ext_method_sql(key, operator, value, &block)
  raise ScopedSearch::QueryNotSupported, "'#{definition.klass}' doesn't respond to '#{ext_method}'" unless definition.klass.respond_to?(ext_method)
  conditions = definition.klass.send(ext_method.to_sym,key, operator, value) rescue {}
  raise ScopedSearch::QueryNotSupported, "external method '#{ext_method}' should return hash" unless conditions.kind_of?(Hash)
  sql = ''
  conditions.map do |notification, content|
    case notification
      when :include then yield(:include, content)
      when :joins then yield(:joins, content)
      when :conditions then sql = content
      when :parameter then content.map{|c| yield(:parameter, c)}
    end
  end
  return sql
end
```

Line 3 is the line that actually calls the external method when needed.

But what is that `rescue {}` doing there? That means that any error that occurs in the external method is swallowed silently, and the search will execute as if there was no condition at all! This is effectively suppressing any errors inside the external method, which is considered [bad practice](https://github.com/bbatsov/ruby-style-guide#dont-hide-exceptions) in Ruby and in programming in general. There are two reasons this is considered bad practice:

1. A user hitting an error (for example, by sending an invalid search query) will receive no indication that something went wrong. They may incorrectly assume the results they received were correct, which could lead them to take further actions that are mistaken. In our example, say a user wants to create a script that deletes all hosts in a certain host group and its children. If they mistype the host group name, instead of receiving an error or no results, they may end up deleting all of their hosts!
2. The developer creating the method who’s exceptions are being suppressed may think it is functioning correctly, and receive no indication that there is any issue in it. In this case, looking at the git history, this bug has existed for 3.5 years without being noticed!

So, thanks to the power of Open Source, I fixed the issue in Scoped Search and sent the maintainers a [Pull Request](https://github.com/wvanbergen/scoped_search/pull/173) as well:

```ruby
def to_ext_method_sql(key, operator, value, &block)
  raise ScopedSearch::QueryNotSupported, "'#{definition.klass}' doesn't respond to '#{ext_method}'" unless definition.klass.respond_to?(ext_method)
  begin
    conditions = definition.klass.send(ext_method.to_sym, key, operator, value)
  rescue StandardError => e
    raise ScopedSearch::QueryNotSupported, "external method '#{ext_method}' failed with error: #{e}"
  end
  raise ScopedSearch::QueryNotSupported, "external method '#{ext_method}' should return hash" unless conditions.kind_of?(Hash)
  sql = ''
  conditions.map do |notification, content|
    case notification
      when :include then yield(:include, content)
      when :joins then yield(:joins, content)
      when :conditions then sql = content
      when :parameter then content.map{|c| yield(:parameter, c)}
    end
  end
  return sql
end
```
Now let’s just hope they like it enough to merge it into the project! 😃

Thank you for reading, I would be happy to hear feedback in the comments below or [on twitter](https://twitter.com/tbrisker_pro).