Skip to main content

Command Palette

Search for a command to run...

How to update multiple different values at the same time in one query

How to create a raw SQL to update in Rails different values for each record in one single query

Published
3 min read
How to update multiple different values at the same time in one query
L

Senior Product Engineer, working in Ruby and Rails. Passionate about idea generation, creativity, and programming.

I curate the Short Ruby Newsletter.

Context

You want to update the same column for multiple records in one query and set different values for each record.

Example:

class Car < ActiveRecord::Base
# has attributes: id, license_plate
end

Let's say you want to update license plates and you have a list of car ids and associated license plates:

updates = {
  1 => "EU-DE-123245"
  2 => "EU-NL-12456"
  3 => "EU-FR-12455"
  4 => "EU-RO-87613"
}

Problem

You can do this in multiple queries. Here is an example:

updates.each do |car_id, license_place|
  Car.find(car_id).update_column(:license_place, license_place)
end

The problem with this solution is that it will do multiple queries and sometimes this could come with a performance penalty.

So you want to find a way to update all records with different values in one single query.

Solution

# @param column [String] The name of the column to be updated with new values
# @param updates [Array<Hash>] The hash key is the id of the record and hash value is the new value to be set
# @example Update amount for records with id 1 and 2 
#     update_column_for_all_records("amount", [ {
#       1 => "EU-DE-123245"
#       2 => "EU-NL-12456"
#       3 => "EU-FR-12455"
#       4 => "EU-RO-87613"
#     }])

def update_column_for_all_records(column, updates)
  cases = []
  ids = updates.keys.map(&:to_i)

  Car.where(id: ids).each do 
    id = _1.id
    new_value = updates[id]
    cases << sanitize_case(id, new_value) 
  end

  update_query = "#{column} = CASE id #{cases.join(" ")} END"

  Car.where(id: ids).update_all(update_query)
end

def sanitize_case(id, new_value)
  Car.sanitize_sql [ "WHEN :id THEN :value", { id: id, value: new_value} ]
end

Explanation of the solution

We are using a kind of raw SQL to update all records in one query.

What we want to execute in SQL is something like this:

UPDATE
    "cars"
SET
    "cars"."license_plate" = CASE "cars"."id"
    WHEN 1 THEN
        'EU-DE-123245'
    WHEN 2 THEN
        'EU-NL-12456'
    END
WHERE
    "cars"."id" IN(1, 2)

Things to consider

If you use this in #Rails please take into consideration:

update_all will not execute callbacks nor validations

As specified in the Rails docs, update_all:

It does not instantiate the involved models and it does not trigger Active Record callbacks or validations.

Thus it will also not update the updated_at column.

You might need to cast your values in the WHEN THEN query

For example, if you will need to update a column that has the format timestamp then you might need to change the method to support casting and add to the cases array something like:

cases << "WHEN #{id} THEN CAST('#{new_value.to_fs(:db)}' as timestamp)"

Notice in this line the expectation is that new_value is a Rails DateTime object and I am using to_fs(:db) (see more here) to make sure I am converting the proper value to timestamp.

Here is a more full example with casting:

# @param column [String] The name of the column to be updated with new values
# @param updates [Array<Hash>] The hash key is the id of the record and hash value is the new value to be set
# @example Update amount for records with id 1 and 2 
#     update_column_for_all_records("amount", [{ id: 1, value: "EU-DE-123245", type: "string" }, {id: 2, value: "EU-NL-12456", type: "text"}])

def update_column_for_all_records(column, updates)
  cases = []
  ids = updates.map { _1[:id] }

  Car.where(id: ids).each do 
    id = _1[:id]
    new_value = _1[:value] 
    cases << sanitize_case(id, new_value, _1[:type]) 
  end

  update_query = "#{column} = CASE id #{cases.join(" ")} END"

  Car.where(id: ids).update_all(update_query)
end

def sanitize_case(id, new_value, type)
  native_type = adapter_type(type)
  Car.sanitize_sql [ "WHEN :id THEN CAST(:value as #{native_type})", { id: id, value: new_value} ]
end

# This works for PostgreSQL for example
def adapter_type(type)
  ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES.dig(type.to_s, :name) || "text"
end

More from this blog

All about code - Ruby and Rails technical content written by Lucian Ghinda

102 posts

I write here quick thoughts, ideas, tips, and learnings about programming, programmers, and building software. Most of my focus is on Ruby, Rails, Hotwire, and everything about web applications.