Matt Privman
Matt Privman

Reputation: 6856

Best way to implement multiple both UUID and ID on each model in Rails

Transitioning a large legacy codebase from UUIDs to IDs. This needs to be done in stages to maintain backwards compatibility among many devices.

Current solution is to maintain both a UUID and ID field until we can transition over completely.

What's the best way to do this so that all belongs_to models update both the ID and UUID on each create/update?

Example: Comment model belongs to BlogPost and needs to set both blogpost_id & blogpost_uuid on create/update.

Upvotes: 8

Views: 3720

Answers (4)

bliof
bliof

Reputation: 2987

Just do it through the database:

Let's say you have such legacy tables

class CreateLegacy < ActiveRecord::Migration
  def change
    enable_extension 'uuid-ossp'

    create_table :legacies, id: :uuid do |t|
      t.timestamps
    end

    create_table :another_legacies, id: false do |t|
      t.uuid :uuid, default: 'uuid_generate_v4()', primary_key: true
      t.timestamps
    end
  end
end

class Legacy < ActiveRecord::Base
end

class AnotherLegacy < ActiveRecord::Base
  self.primary_key = 'uuid'
end

With the above code you have:

Legacy.create.id        # => "fb360410-0403-4388-9eac-c35f676f8368"
AnotherLegacy.create.id # => "dd45b2db-13c2-4ff1-bcad-3718cd119440"

Now to add the new id column

class AddIds < ActiveRecord::Migration
  def up
    add_column :legacies, :new_id, :bigint
    add_index :legacies, :new_id, unique: true
    add_column :another_legacies, :id, :bigint
    add_index :another_legacies, :id, unique: true

    execute <<-SQL
      CREATE SEQUENCE legacies_new_id_seq;
      ALTER SEQUENCE legacies_new_id_seq OWNED BY legacies.new_id;
      ALTER TABLE legacies ALTER new_id SET DEFAULT nextval('legacies_new_id_seq');

      CREATE SEQUENCE another_legacies_id_seq;
      ALTER SEQUENCE another_legacies_id_seq OWNED BY another_legacies.id;
      ALTER TABLE another_legacies ALTER id SET DEFAULT nextval('another_legacies_id_seq');
    SQL
  end

  def down
    remove_column :legacies, :new_id
    remove_column :another_legacies, :id
  end
end

The default value is added after you create the new column as this prevents the db to try to update all the records. => the default will be default just for new records.

The old one you can backfill as you wish.

e.g. One by one

Legacy.where(new_id: nil).find_each { |l| l.update_column(:new_id, ActiveRecord::Base.connection.execute("SELECT nextval('legacies_new_id_seq')")[0]['nextval'].to_i) }

AnotherLegacy.where(id: nil).find_each { |l| l.update_column(:id, ActiveRecord::Base.connection.execute("SELECT nextval('another_legacies_id_seq')")[0]['nextval'].to_i) }

If you want you can first backfill and then add the defaults and then backfill again.

When you are happy with the values just change the primary key:

class Legacy < ActiveRecord::Base
  self.primary_key = 'new_id'

  def uuid
    attributes['id']
  end
end

class AnotherLegacy < ActiveRecord::Base
  self.primary_key = 'id' # needed as we have not switched the PK in the db
end
Legacy.first.id   # => 1
Legacy.first.uuid # => "fb360410-0403-4388-9eac-c35f676f8368"

AnotherLegacy.first.id   # => 1
AnotherLegacy.first.uuid # => "dd45b2db-13c2-4ff1-bcad-3718cd119440"

Finally you need one more migration to change the primary key to the new id.

Most importantly to avoid downtime:

  • create a column
  • ensure new records fill by default somehow (default or trigger)
  • backfill old record
  • add constraints
  • switch to the new column
  • then you can drop the old one (if you are sure it is not in use)

ps. not sure why you want to switch completely from the uuids, they are better if you want to reference the records from external applications

ps.2.0. if you need to be able to do Legacy.find("fb360410-0403-4388-9eac-c35f676f8368") and Legacy.find(123) maybe try https://github.com/norman/friendly_id

friendly_id :uuid, use: [:slugged, :finders]

Upvotes: 7

Kien Le
Kien Le

Reputation: 191

You can define multiple keys for primary key using this gem: https://github.com/composite-primary-keys/composite_primary_keys

class Blogpost
  self.primary_keys = :uuid, :id

  has_many :comments, foreign_key: [:uuid, :id]
end

class Comment
  belongs_to :blogpost, foreign_key: [:blogpost_uuid, :blogpost_id]
end

It would work if you already generated UUID and ID for BlogPost and synchronized with Comment's blogpost_uuid, blogpost_id

In case you haven't synchronized blogpost_uuid and blogpost_id, I recommend you do the following to migrate:

  • Put your system to maintenance mode
  • Copy uuid from Blogpost to Comment's blogpost_uuid, you could do:
Comment.preload(:blogpost).find_each do |comment|
  comment.update_column(blogpost_uuid: blogpost.uuid)
end
  • Release new update with composite primary key gem and code change
  • Turn off maintenance mode

Hope it help you have a smooth transition. Let me know if something is not clear.

Upvotes: 1

Clemens Kofler
Clemens Kofler

Reputation: 1968

First of all: The answer to your question might heavily depend on the DBMS you're using because some DBMS have better facilities for this kind of stuff than others. For my answer, I'll assume you're using Postgres.

So let's get started.

Conceptually speaking, you're dealing with foreign keys here. Postgres (like lots of other DBMS) offers built-in foreign keys and it allows you to do pretty much everything – including putting in place multiple foreign key relations between the same tables. So step 1, if you haven't done it already, would be to set up foreign key relations between the affected tables for both your integer as well as UUID columns. The result should be that you have foreign keys between comments.blogpost_id and blogposts.id as well as comments.blogpost_uuid and blogposts.uuid. This setup will, down the line, ensure that your database content stays consistent once you drop the integer columns.

Step 2 is making sure that both values are always written when one is set. You could do this in Rails in a way similar to bwalshy's comment with a small adjustment:

self.blogpost_id ||= BlogPost.find_by(uuid: blogpost_uuid)&.id if blogpost_uuid.present?
self.blogpost_uuid ||= BlogPost.find_by(id: blogpost_id)&.uuid if blogpost_id.present?

Or you could, again, let your DBMS do its job and set up triggers that handle these things on INSERT/UPDATE. This is the way I would take because it, again, increases consistency and keeps an incidental temporary complexity out of the app code (you can still write unit tests for this if you want to).

Step 3 is to backfill any existing data and set NOT NULL constraints on all the columns involved in foreign key relations to ensure full consistency.

I hope this makes sense. Let me know if you have any follow-up questions.

Upvotes: 0

bwalshy
bwalshy

Reputation: 1135

On your Comment model, for example, you can add a before_save callback, which gets called on model creation and update. In the callback method, you can reference the association and make sure the necessary fields are updated on the Comment.

# app/models/comment.rb

belongs_to :blogpost

# Add callback, gets called before create and update
before_save :save_blogpost_id_and_uuid

# At the bottom of your model
private

def save_blogpost_id_and_uuid
  # You usually don't have to explicitly set the blogpost_id
  # because Rails usually handles it. But you might have to 
  # depending on your app's implementation of UUIDs. Although it's
  # probably safer to explicitly set them just in case.

  self.blogpost_uuid = blogpost.uuid
  self.blogpost_id = blogpost.id
end

And then repeat the above method for other models and their associations.

If desired, you can add some conditional logic that only updates the blogpost_id and blogpost_uuid if the blogpost ID or UUID changed.

Upvotes: 1

Related Questions