Migrating from Granite to Grant
Preview migration path: Grant is not included in the Amber
2.0.0-beta.2core 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
- No
connectiondeclaration - Grant uses a global connection pool - No
tabledeclaration - Inferred from class name (configurable) timestampsmacro - Replaces manual timestamp columns- 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::Base→Grant::Base) - [ ] Remove
connectionandtabledeclarations - [ ] 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