1.4.1

Migrating from Granite to Grant

Preview migration path: Grant is not included in the Amber 2.0.0-beta.2 core web template. Confirm a compatible official Grant release and its current API before beginning this migration; do not substitute a personal fork as an application default.

This guide is retained as evaluation material for teams considering a future Granite-to-Grant migration.

Why Grant?

| Feature | Granite | Grant | |---------|---------|-------| | Associations | Limited (has_many only) | Full (belongs_to, has_many, has_one, polymorphic) | | Validations | Basic | Comprehensive with custom validators | | Callbacks | Before/after save | Full lifecycle (create, update, destroy) | | Query Interface | Basic where/find | Chainable scopes, joins, includes | | Transactions | Manual | Built-in with savepoints | | Encryption | None | Attribute encryption | | Secure Tokens | None | has_secure_token, signed_id |

Coexistence Strategy

Grant and Granite can coexist during migration:

# shard.yml
dependencies:
  granite:
    github: amberframework/granite
    version: ~> 0.6.0
  grant:
    github: amberframework/grant
    version: ~> 0.3.0

Migrate models incrementally, starting with new features.

Basic Model Migration

Column Definitions

Granite:

class User < Granite::Base
  connection pg
  table users

  column id : Int64, primary: true
  column email : String
  column name : String?
  column admin : Bool = false
  column created_at : Time?
  column updated_at : Time?
end

Grant:

class User < Grant::Base
  column id : Int64, primary: true
  column email : String
  column name : String?
  column admin : Bool = false

  timestamps  # Automatically handles created_at and updated_at
end

Key Differences

  1. No connection declaration - Grant uses a global connection pool
  2. No table declaration - Inferred from class name (configurable)
  3. timestamps macro - Replaces manual timestamp columns
  4. Nullable by default - Use Type? for nullable columns

Connection Configuration

Granite:

Granite::Connections << Granite::Adapter::Pg.new(
  name: "pg",
  url: ENV["DATABASE_URL"]
)

Grant:

Grant::Connections.add(
  "primary",
  ENV["DATABASE_URL"]
)

# Or configure via environment
# Grant auto-detects DATABASE_URL

Associations Migration

has_many

Granite:

class User < Granite::Base
  has_many :posts

  # Manual setup often required
  def posts
    Post.all("WHERE user_id = ?", id)
  end
end

Grant:

class User < Grant::Base
  has_many :posts  # Just works!

  # Additional options available
  has_many :published_posts, Post, -> { where(published: true) }
  has_many :comments, through: :posts
end

belongs_to

Granite: (manual)

class Post < Granite::Base
  column user_id : Int64?

  def user
    User.find(user_id) if user_id
  end
end

Grant:

class Post < Grant::Base
  column user_id : Int64

  belongs_to :user
end

post = Post.find!(1)
post.user  # => User instance, lazy loaded

has_one

Granite: (manual)

class User < Granite::Base
  def profile
    Profile.first("WHERE user_id = ?", id)
  end
end

Grant:

class User < Grant::Base
  has_one :profile
end

user.profile  # => Profile or nil

Polymorphic Associations

Granite: Not supported

Grant:

class Comment < Grant::Base
  column commentable_id : Int64
  column commentable_type : String

  belongs_to :commentable, polymorphic: true
end

class Post < Grant::Base
  has_many :comments, as: :commentable
end

class Photo < Grant::Base
  has_many :comments, as: :commentable
end

Validations Migration

Basic Validations

Granite:

class User < Granite::Base
  validate :email, "can't be blank" do |user|
    !user.email.nil? && !user.email.not_nil!.empty?
  end
end

Grant:

class User < Grant::Base
  validates :email, presence: true
  validates :email, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  validates :email, uniqueness: true
end

Available Validators

| Granite | Grant | |---------|-------| | Manual blocks | presence, absence, format, length, inclusion, exclusion, uniqueness, numericality, confirmation |

Custom Validations

Granite:

class User < Granite::Base
  validate :custom_email_check do |user|
    # validation logic
  end
end

Grant:

class User < Grant::Base
  validate :custom_email_check

  private def custom_email_check
    if email && !email.ends_with?("@company.com")
      errors.add(:email, "must be a company email")
    end
  end
end

Callbacks Migration

Granite:

class Post < Granite::Base
  before_save :set_slug

  def set_slug
    self.slug ||= title.downcase.gsub(" ", "-")
  end
end

Grant:

class Post < Grant::Base
  before_save :set_slug
  before_create :set_published_at
  after_create :notify_subscribers
  after_destroy :cleanup_assets

  private def set_slug
    self.slug ||= title.downcase.gsub(" ", "-")
  end
end

Available Callbacks

| Lifecycle | Granite | Grant | |-----------|---------|-------| | Create | before_save, after_save | before_create, after_create, around_create | | Update | before_save, after_save | before_update, after_update, around_update | | Save | before_save, after_save | before_save, after_save, around_save | | Destroy | before_destroy, after_destroy | before_destroy, after_destroy, around_destroy | | Validation | - | before_validation, after_validation |

Query Interface Migration

Finding Records

Granite:

User.find(1)           # May return nil
User.find!(1)          # Raises on not found
User.first             # First record
User.all               # All records

Grant:

User.find(1)           # Returns User?
User.find!(1)          # Raises RecordNotFound
User.first             # First record
User.last              # Last record
User.all               # ActiveRecord::Relation

Where Clauses

Granite:

User.all("WHERE email = ? AND active = ?", ["[email protected]", true])

Grant:

User.where(email: "[email protected]", active: true)
User.where("email = ? AND active = ?", "[email protected]", true)
User.where(email: "[email protected]").where(active: true)  # Chainable

Ordering and Limiting

Granite:

User.all("ORDER BY created_at DESC LIMIT 10")

Grant:

User.order(created_at: :desc).limit(10)
User.order(:name).first(5)
User.recent.limit(10)  # Using scope

Scopes

Granite: Not supported natively

Grant:

class Post < Grant::Base
  scope :published, -> { where(published: true) }
  scope :recent, -> { order(created_at: :desc) }
  scope :by_author, ->(user : User) { where(user_id: user.id) }
end

Post.published.recent.limit(10)
Post.by_author(current_user).published

CRUD Operations

Create

Granite:

user = User.new
user.email = "[email protected]"
user.save

# Or
User.create!(email: "[email protected]")

Grant:

user = User.new(email: "[email protected]")
user.save

# Or
User.create!(email: "[email protected]")

# Build without save
user = User.build(email: "[email protected]")

Update

Granite:

user.email = "[email protected]"
user.save

Grant:

user.email = "[email protected]"
user.save

# Or
user.update!(email: "[email protected]")

# Update multiple
User.where(role: "guest").update_all(role: "member")

Destroy

Granite:

user.destroy

Grant:

user.destroy
user.destroy!  # Raises on failure

# Destroy multiple
User.where(inactive: true).destroy_all

Transactions

Granite: (manual)

Granite::Connections["pg"].transaction do |tx|
  user.save
  profile.save
end

Grant:

Grant::Base.transaction do
  user.save!
  profile.save!
  # Automatically rolls back on exception
end

# Nested transactions with savepoints
Grant::Base.transaction do
  user.save!

  Grant::Base.transaction(requires_new: true) do
    # Savepoint - can fail without rolling back outer transaction
    risky_operation.save!
  rescue
    # Inner transaction rolled back, outer continues
  end
end

Error Handling

Granite:

unless user.save
  user.errors.each do |error|
    puts error
  end
end

Grant:

unless user.save
  user.errors.full_messages.each do |message|
    puts message
  end

  # Access specific field errors
  user.errors[:email].each do |error|
    puts "Email #{error}"
  end
end

# Or use bang methods
begin
  user.save!
rescue Grant::RecordInvalid => e
  puts e.record.errors.full_messages
end

Migration Checklist

Per Model

  • [ ] Update class inheritance (Granite::BaseGrant::Base)
  • [ ] Remove connection and table declarations
  • [ ] Replace manual timestamp columns with timestamps
  • [ ] Convert associations to Grant syntax
  • [ ] Migrate validations to declarative style
  • [ ] Update callbacks to new lifecycle hooks
  • [ ] Convert raw SQL queries to chainable interface
  • [ ] Add scopes for common queries
  • [ ] Update error handling code
  • [ ] Run tests

Application-Wide

  • [ ] Update connection configuration
  • [ ] Review transaction usage
  • [ ] Update specs to use Grant factories/fixtures
  • [ ] Run full test suite
  • [ ] Performance test critical queries

Running Both ORMs

During migration, you may need models to interact:

# Grant model referencing Granite model
class Comment < Grant::Base
  column post_id : Int64

  def post
    # Manually fetch Granite model
    Post.find(post_id)
  end
end

# Or create a thin Grant wrapper
class PostGrant < Grant::Base
  self.table_name = "posts"

  column id : Int64, primary: true
  column title : String
  # ... mirror Granite columns
end

Troubleshooting

"undefined method" errors

Grant uses different method names. Common changes:

  • all("WHERE ...")where(...)
  • first("WHERE ...")find_by(...)
  • Manual association methods → has_many/belongs_to

Validation errors

Grant validations are more strict:

# May need to handle nil differently
validates :email, presence: true  # Fails on nil
validates :name, presence: true, allow_nil: true  # Passes on nil

Association loading

Grant associations are lazy-loaded by default:

# N+1 query issue
users.each { |u| puts u.posts.size }

# Use eager loading
users = User.includes(:posts)
users.each { |u| puts u.posts.size }  # No N+1