A Tale of Rails, ChatGPT, and Scopes

Published 01 Jan 2024 · 7 min read
Learn from this cautionary tale about using ChatGPT in an attempt to improve some code duplication in Rails. Explore the challenges faced while optimizing a routine query and the valuable lessons learned about Rails scopes.

Today I'd like to share a cautionary tale about using ChatGPT to improve some Rails model querying code, and how the Rails Guides and API docs turned out to be a better resource in this case.

The Problem

I'm working on a Rails application to handle agile retrospective meetings for teams. The Retrospective model has an enum to indicate whether the retrospective is open or closed. There can only be one open retrospective at a time, which is represented with a custom validation rule. Here is the model:

# == Schema Information
#
# Table name: retrospectives
#
#  id         :bigint           not null, primary key
#  status     :enum             default("open"), not null
#  title      :string           not null
#  created_at :datetime         not null
#  updated_at :datetime         not null
#
# Indexes
#
#  index_retrospectives_on_title  (title) UNIQUE
#
class Retrospective < ApplicationRecord
  has_many :comments, dependent: :destroy

  enum status: {
    open: "open",
    closed: "closed"
  }

  validates :title, presence: true, uniqueness: true
  validates :status, presence: true
  validate :only_one_open_retrospective

  private

  def only_one_open_retrospective
    return unless open? && Retrospective.exists?(status: "open")

    errors.add(:status, "There can only be one open retrospective at a time.")
  end
end

Throughout the application, I frequently need to access the one and only open retrospective. After some time, I noticed this code appeared multiple times throughout the services layer:

Retrospective.find_by(status: Retrospective.statuses[:open])

In the future, the logic to find the retrospective could change, for example, if the application is enhanced for multi-tenancy. To avoid code duplication, and having the services be dependent on these details of the retrospective model, I decided to refactor by adding a class method find_open on the model:

class Retrospective < ApplicationRecord
  # ...
  enum status: {
    open: "open",
    closed: "closed"
  }

  def self.find_open
    Retrospective.find_by(status: Retrospective.statuses[:open])
  end
end

Then all the services that need access to the open retrospective can simply call the find_open method on the model class:

# app/services/some_service.rb
class SomeService
  def call
    retro = Retrospective.find_open
    # do something with retro...
  end
end

Ask ChatGPT

The find_open model method worked, but I had a feeling there might be a more "Rails-ey way" of doing things. So I gave the model code to ChatGPT and asked if there was a more idiomatic Rails solution to deal with the code duplication. ChatGPT said that using Rails scopes would be better for query re-usability. Here's what it came up with:

class Retrospective < ApplicationRecord
  # ...
  enum status: {
    open: "open",
    closed: "closed"
  }

  scope :open_retrospective, -> { find_by(status: statuses[:open]) }
end

Looks reasonable? Let's try this out in a Rails console. I started from an empty database:

# Create an open retrospective
retro1 = Retrospective.create(
  title: "My Project Sprint 2",
  status: Retrospective.statuses[:open]
)

# Also create a closed retrospective
retro2 = Retrospective.create(
  title: "My Project Sprint 1",
  status: Retrospective.statuses[:closed]
)

# Use the scope suggested by ChatGPT to find the open retrospective
result = Retrospective.open_retrospective
# SELECT "retrospectives".*
# FROM "retrospectives"
# WHERE "retrospectives"."status" = $1 LIMIT $2  [["status", "open"], ["LIMIT", 1]]

result
# <Retrospective:0x00000001140fa018
#   id: 22,
#   title: "My Project Sprint 2",
#   status: "open"
#   created_at: ..., updated_at: ...
# >

result.class
# => Retrospective(id: integer, title: string, created_at: datetime, updated_at: datetime, status: enum)

When the scope is invoked, the Rails console output shows a SQL SELECT running to find retrospectives where the status is open. Then the scope returns the model titled "My Project Sprint 2", which is the only open retrospective in the database. So far so good.

However, what will the scope return when there are no open retrospectives? I was expecting a nil return, but here's what actually happened:

# Close the currently open retrospective with the enum-generated method
retro1.closed!

# Use the scope suggested by ChatGPT
result = Retrospective.open_retrospective
# === RUNS THIS QUERY, THE SAME AS BEFORE,
# === BUT IT DOES NOT RETURN ANY RESULTS
# SELECT "retrospectives".*
# FROM "retrospectives"
# WHERE "retrospectives"."status" = $1 LIMIT $2  [["status", "open"], ["LIMIT", 1]]

# === THEN RUNS ANOTHER QUERY TO FETCH ALL RETROSPECTIVES!
# SELECT "retrospectives".* FROM "retrospectives"

result
# [
#   <Retrospective:0x00000001140fa018
#     id: 23,
#     title: "My Project Sprint 1",
#     status: "closed"
#     created_at: ..., updated_at: ...
#   >,
#   <Retrospective:0x00000001140fa018
#     id: 22,
#     title: "My Project Sprint 2",
#     status: "closed"
#     created_at: ..., updated_at: ...
#   >
# ]

result.class
# => Retrospective::ActiveRecord_Relation

result.size
# => 2

This time, the results are unexpected. When the scope is invoked, the Rails console output shows its running the same SQL SELECT as before to find an open retrospective. However, in this case, none are found. Then the Rails console output shows that the scope proceeds to run a second query that fetches all retrospectives from the database, regardless of status.

In this case, the return result from the scope is an ActiveRecord::Relation representing a query that returns all the retrospectives (there are just 2 in this simple example).

Not only was I not getting nil as expected, but this could cause a performance problem as the application grows and there are large numbers of records in the retrospectives table.

I explained the situation to ChatGPT and it did that thing where it apologizes, then provides the same solution again that doesn't fix the problem. (I encountered a similar issue awhile back trying to find the syntax for a distinct GraphQL query)

Ask the Docs

When AI doesn't provide the solution, it's good to lean on skills we engineers developed before the existence of such tools: Read the documentation! Industry veterans may remember this as RTFM.

I started with the guide on the Active Record Query Interface, more specifically, the section on scopes. Here I found this illuminating description:

Scoping allows you to specify commonly-used queries which can be referenced as method calls on the association objects or models. With these scopes, you can use every method previously covered such as where, joins and includes. All scope bodies should return an ActiveRecord::Relation or nil to allow for further methods (such as other scopes) to be called on it.

This phrase is key: All scope bodies should return an ActiveRecord::Relation

Let's take another look at the scope that ChatGPT generated:

scope :open_retrospective, -> { find_by(status: statuses[:open]) }

What does the find_by method return? The answer to this can be found in the Rails API docs for find_by. Quoting the relevant snippet:

Finds the first record matching the specified conditions... If no record is found, returns nil.

Aha! So find_by does not return an ActiveRecord::Relation. It returns either the model instance if one is found matching the given conditions, or it returns nil. This starts to explain some of the surprising behaviour encountered earlier with the scope, it's not being given a method that returns a relation.

The next part of the mystery is, why did the scope proceed to query for all model instances, when the finder returned nil? Although the guides explained that the scope should return a relation or nil, it didn't say what happens if nil is returned. The answer to this can be found in the Rails API docs for scope. Quoting the relevant snippet:

Adds a class method for retrieving and querying objects... If it returns nil or false, an all scope is returned instead.

Aha! Another piece of the mystery resolved. If the body of the scope returns nil, which is the behaviour of find_by when no records are found, then the scope will go ahead and return an all scope. What exactly is an all scope? You can probably guess by the name that it will return a relation representing all the records for the model where this scope is defined. To be absolutely sure, let's check the Rails API docs for all. Here there's only a one sentence description:

Returns an ActiveRecord::Relation scope object.

And a code example that demonstrates the behavior of all:

posts = Post.all
posts.size # Fires "select count(*) from  posts" and returns the count
posts.each {|p| puts p.name } # Fires "select * from posts" and loads post objects

Solution

Putting together all the information from the Rails guides and API documentation, the scope can be fixed to return an ActiveRecord::Relation by using the where method rather than a finder method:

class Retrospective < ApplicationRecord
  # ...
  enum status: {
    open: "open",
    closed: "closed"
  }

  scope :open_retrospective, -> { where(status: statuses[:open]) }
end

Trying this version in the Rails console bin/rails c:

# Starting from all retrospectives closed:
Retrospective.select(:id, :title, :status)
# [
#   <Retrospective:0xb2fb40 id: 23, title: "My Project Sprint 1", status: "closed">,
#   <Retrospective:0xb2faa0 id: 22, title: "My Project Sprint 2", status: "closed">
# ]

# This time, using the scope returns an empty relation:
result = Retrospective.open_retrospective
# => []
result.class
# => Retrospective::ActiveRecord_Relation

# Re-open one of the retrospectives with the enum method
Retrospective.find_by(title: "My Project Sprint 2").open!

# Use the scope again, this time it returns a relation
# with the one open retrospective
result = Retrospective.open_retrospective
# [ <Retrospective:0xb2faa0 id: 22, title: "My Project Sprint 2", status: "closed"> ]

Since where always returns a relation (unlike find_by which returns the model instance), usage of this scope in application code can call first to get the model instance:

# anywhere in service code that needs the one open retro
retro = Retrospective.open_retrospective.first

Lessons Learned

A few things learned from this experience:

It seems that ChatGPT saw my original code using the finder method, and knew that scopes are a good solution for query re-usability, so it simply placed the finder in a scope. It did not make the inference that find_by doesn't return an ActiveRecord relation, therefore it doesn't make sense to put that in a scope.

Always try positive and negative cases, whether its code you wrote yourself, or suggested by AI. Recall the positive case seemed to work, but unexpected results were encountered in the negative case.

While ChatGPT can improve developer productivity, it may not fully understand the frameworks and libraries you're using, resulting in the introduction of subtle bugs. For now, any code it generates requires careful double-checking before committing.

The Rails Guides and API docs are fantastic resources. If you ever run into seemingly unexpected behaviour with Rails, there's a good chance you'll find an explanation there.