Intro

By default, new Lucky projects use the Int64 type for primary keys. In this post I will cover the process of changing the Lucky database primary keys type from an Int64 to a UUID .

Software Used

The following software versions are used in this post.

  • Crystal - 1.1.1
  • Lucky - 0.28.0
  • PostgreSQL - 13.4

Credits

This excellent post by Stephen Dolan outlines most of the process and without his guide, I would not known how to do this. There are a couple of extra steps that I needed to also complete which I assume was due to an earlier version of Lucky used in his post.

Warning

This post assumes you are working with a newly initialized app, it does not cover migrating existing data. At the end of this post I am completely resetting the database.

Create a migration file to enable the pgcrypto extension.

cmd
touch db/migrations/00000000000001_enable_pgcrypto.cr

Add the following contents to the db/migrations/00000000000001_enable_pgcrypto.cr file.

file
class EnablePgcrypto::V00000000000001 < Avram::Migrator::Migration::V1
  def migrate
    execute "CREATE EXTENSION IF NOT EXISTS pgcrypto"
  end

  def rollback
    execute "DROP EXTENSION pgcrypto"
  end
end

Rename the user migration file so the migration happens after the pgcrypto migration step.

cmd
mv db/migrations/00000000000001_create_users.cr db/migrations/00000000000002_create_users.cr

Update the db/migrations/00000000000002_create_users.cr file to use the UUID type and also rename the class.

file
# db/migrations/00000000000002_create_users.cr
# class CreateUsers::V00000000000001 < Avram::Migrator::Migration::V1
class CreateUsers::V00000000000002 < Avram::Migrator::Migration::V1
  def migrate
    enable_extension "citext"

    create table_for(User) do
      # primary_key id : Int64
      primary_key id : UUID
      add_timestamps
      add email : String, unique: true, case_sensitive: false
      add encrypted_password : String
    end
  end

  def rollback
    drop table_for(User)
    disable_extension "citext"
  end
end

Update the src/models/base_model.cr file to use the UUID type for the primary_key field on all tables. Other default parameters such as timestamps can also be set here.

file
# src/models/base_model.cr
abstract class BaseModel < Avram::Model
  # Add this macro
  macro default_columns
    primary_key id : UUID
    timestamps
  end
  def self.database : Avram::Database.class
    AppDatabase
  end
end

Update the src/pages/password_resets/new_page.cr file to use the UUID type for the user_id field.

file
# src/pages/password_resets/new_page.cr
class PasswordResets::NewPage < AuthLayout
  needs operation : ResetPassword
  # needs user_id : Int64
  needs user_id : UUID

  def content
    h1 "Reset your password"
    render_password_reset_form(@operation)
  end

  private def render_password_reset_form(op)
    form_for PasswordResets::Create.with(@user_id) do
      mount Shared::Field, attribute: op.password, label_text: "Password", &.password_input(autofocus: "true")
      mount Shared::Field, attribute: op.password_confirmation, label_text: "Confirm Password", &.password_input

      submit "Update Password", flow_id: "update-password-button"
    end
  end
end

Update the src/actions/password_resets/create.cr file to use the UUID type for the user_id field.

file
# src/actions/password_resets/create.cr
class PasswordResets::Create < BrowserAction
  include Auth::PasswordResets::Base
  include Auth::PasswordResets::TokenFromSession

  post "/password_resets/:user_id" do
    ResetPassword.update(user, params) do |operation, user|
      if operation.saved?
        session.delete(:password_reset_token)
        sign_in user
        flash.success = "Your password has been reset"
        redirect to: Home::Index
      else
        # html NewPage, operation: operation, user_id: user_id.to_i64
        html NewPage, operation: operation, user_id: UUID.new(user_id)
      end
    end
  end
end

Update the src/actions/password_resets/edit.cr file to use the UUID type for the user_id field.

file
# src/actions/password_resets/edit.cr
class PasswordResets::Edit < BrowserAction
  include Auth::PasswordResets::Base
  include Auth::PasswordResets::TokenFromSession

  get "/password_resets/:user_id/edit" do
    # html NewPage, operation: ResetPassword.new, user_id: user_id.to_i64
    html NewPage, operation: ResetPassword.new, user_id: UUID.new(user_id)
  end
end

Update the src/models/user_token.cr file to return a UUID from the user_id param. Also update the return type to UUID .

file
# src/models/user_token.cr
# Generates and decodes JSON Web Tokens for Authenticating users.
class UserToken
  Habitat.create { setting stubbed_token : String? }
  ALGORITHM = JWT::Algorithm::HS256

  def self.generate(user : User) : String
    payload = {"user_id" => user.id}

    settings.stubbed_token || create_token(payload)
  end

  def self.create_token(payload)
    JWT.encode(payload, Lucky::Server.settings.secret_key_base, ALGORITHM)
  end

  # def self.decode_user_id(token : String) : Int64?
  def self.decode_user_id(token : String) : UUID?
    payload, _header = JWT.decode(token, Lucky::Server.settings.secret_key_base, ALGORITHM)
    # payload["user_id"].to_s.to_i64
    UUID.new(payload["user_id"].to_s)
  rescue e : JWT::Error
    Lucky::Log.dexter.error { {jwt_decode_error: e.message} }
    nil
  end

  # Used in tests to return a fake token to test against.
  def self.stub_token(token : String)
    temp_config(stubbed_token: token) do
      yield
    end
  end
end

Once that is completed, setup the DB and run the migrations with the lucky db.reset cli command.

Outro

In this post we setup UUIDs for the primary key field on a postgres database for a Lucky webapp.