Rails 7.1 handles long auto-generated index names with a limit

While adding a composite index that is made up of columns with long names, the index name auto-generated by Rails can grow too long. That leads to the annoying error

Index name index_table_name_on_column1_and_column2_and_column3 on table ‘table_name’ is too long

Before

After we see this error, the fix is to add a name option for adding index like so:

1
add_index :opportunities, %i(manager_id operational_countries_id hospital_id opportunity_type), name: "idx_opps_on_mid_ocid_hid_otype"

This works great, but could be better if Rails handled that auto-magically.

Rails 7.1

This PR added a limit of 62 bytes on the auto generated index names. This is safe for MySQL, Postgres and SQLite index name limits.

If the generated index name is over the limit, the naming would be done as per a shorter format.

Example

1
2
3
index_table_name_on_column1_and_column2_and_column3 => Long format

ix_on_column1_and_column2_and_column3_584cb5f07a => Shorter format

The shorter format includes a hash created from the long format name to ensure uniqueness across the database.

Primary format

index_{table_name}_on_#{columns.join('_and_')}

New fallback - shorter format

ix_on_#{columns.join('_')}_#{OpenSSL::Digest::SHA256.hexdigest(long_name).first(10)}

Digging into Mike’s PR

I’m trying something new here. I’ll go over some key aspects of the PR and how this OSS contribution fixes this problem. The aim is to simplify the solution.

Changing the index name

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb  
  
def index_name(table_name, options) # :nodoc:
  if Hash === options
    if options[:column]
-      "index_#{table_name}_on_#{Array(options[:column]) * '_and_'}"
+      generate_index_name(table_name, options[:column])
    elsif options[:name]
      options[:name]
    else
      raise ArgumentError, "You must specify the index name"
    end
  else
    index_name(table_name, index_name_options(options))
  end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def generate_index_name(table_name, column)
  name = "index_#{table_name}_on_#{Array(column) * '_and_'}"
  # Array(column) * '_and_' is similar to Array(column).join('_and_')
  # Example: index_students_on_name_and_age_and_city
  return name if name.bytesize <= max_index_name_size
  # If generated `name` is within limit, return that.
  
  hashed_identifier = "_" + OpenSSL::Digest::SHA256.hexdigest(name).first(10)
  name = "ix_on_#{Array(column) * '_'}"
  # Example: ix_on_name_age_city_0c06491783

  short_limit = max_index_name_size - hashed_identifier.bytesize
  # Calculates the size left after counting size of the hash that has to be added at the end of the name.
  # In this case 62 - 11 = 51
  short_name = name.mb_chars.limit(short_limit).to_s
  # Limit the new short format name to the limit we have left.

  "#{short_name}#{hashed_identifier}"
  # The final name is the calculated limited short_name with the hash identifier appended to it, which would always be within the limit.
end

def max_index_name_size
  62
end

Adding migration compatibility

Any modification to Rails migration functionality must maintain compatibility with older migrations, ensuring they run the same way, just as they did when originally created in their respective Rails versions.

In this case, the author has added the below override for the add_index method for the Rails version immediately preceding 7.1. That is 7.0.

1
2
3
4
def add_index(table_name, column_name, **options)
  options[:name] = legacy_index_name(table_name, column_name) if options[:name].nil?
  super
end

Simply put, if name option is provided, the change in Rails 7.1 doesn’t do anything differently, so we don’t do anything and call super.

If the name has to be generated, author has implemented a compatible method legacy_index_name.

Let’s dive in:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def legacy_index_name(table_name, options)
  if Hash === options
  # In this instance, think of it as, is `options` a Hash?
    if options[:column]
      # If column names are provided use them to create the name as before.
      "index_#{table_name}_on_#{Array(options[:column]) * '_and_'}"
    elsif options[:name]
      # If name was provided, use that instead.
      options[:name]
    else
      raise ArgumentError, "You must specify the index name"
    end
  else
    # If options isn't a Hash, call itself again with standardized options via index_name_options method
    legacy_index_name(table_name, index_name_options(options))
  end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def index_name_options(column_names)
  if expression_column_name?(column_names)
    # If column_names has any character except letters, numbers or underscores,
    # replace them with an underscore
    column_names = column_names.scan(/\w+/).join("_")
  end

  # Return the column_names in an expected format for legacy_index_name
  { column: column_names }
end

def expression_column_name?(column_name)  
  column_name.is_a?(String) && /\W/.match?(column_name)
end

Conclusion

This was a minor pain point from the beginning and I liked when this was fixed. The deep dive is something I haven’t done before in my blogs, I’d appreciate the feedback in the comments below.

Prateek Choudhary
Prateek Choudhary
Senior Software Developer