Rails Built in Solution
According to the ActiveStorage Overview Guide there is already an existing solution image.file.analyze and image.file.analyze_later (docs ) which uses ActiveStorage::Analyzer::ImageAnalyzer
According to #analyze docs :
New blobs are automatically and asynchronously analyzed via analyze_later when they're attached for the first time.
That means you can access your image dimensions with
image.file.metadata
#=> {"identified"=>true, "width"=>2448, "height"=>3264, "analyzed"=>true}
image.file.metadata['width']
image.file.metadata['height']
So your model can look like:
class Image < ApplicationRecord
  has_one_attached :file
  def height
    file.metadata['height']
  end
  def width
    file.metadata['width']
  end
end
For 90% of regular cases you are good with this
BUT: the problem is this is "asynchronously analyzed" (#analyze_later) meaning you will not have the metadata stored right after upload
image.save!
image.file.metadata
#=> {"identified"=>true}
image.file.analyzed?
# => nil
# .... after ActiveJob for analyze_later finish
image.reload
image.file.analyzed?
# => true
#=> {"identified"=>true, "width"=>2448, "height"=>3264, "analyzed"=>true}
That means if you need to access width/height in real time (e.g. API response of dimensions of freshly uploaded file) you may need to do
class Image < ApplicationRecord
  has_one_attached :file
  after_commit :save_dimensions_now
  def height
    file.metadata['height']
  end
  def width
    file.metadata['width']
  end
  private
  def save_dimensions_now
    file.analyze if file.attached?
  end
end
Note: there is a good reason why this is done async in a Job. Responses of your request will be slightly slower due to this extra code execution needs to happen. So you need to have a good reason to "save dimensions now"
Mirror of this solution can be found at How to store Image Width Height in Rails ActiveStorage
DIY solution
recommendation: don't do it, rely on existing Vanilla Rails solution
Models that need to update attachment
Bogdan Balan's solution will work.  Here is a rewrite of same solution without the skip_set_dimensions attr_accessor
class Image < ApplicationRecord
  after_commit :set_dimensions
  has_one_attached :file
  private
  def set_dimensions
    if (file.attached?)
      meta = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata
      height = meta[:height]
      width  = meta[:width]
    else
      height = 0
      width  = 0
    end
    update_columns(width: width, height: height) # this will save to DB without Rails callbacks
  end
end
update_columns docs
Models that don't need to update attachment
Chances are that you may be creating model in which you want to store the file attachment and never update it again. (So if you ever need to update the attachment you just create new model record and delete the old one)
In that case the code is even slicker:
class Image < ApplicationRecord
  after_commit :set_dimensions, on: :create
  has_one_attached :file
  private
  def set_dimensions
    meta = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata
    self.height = meta[:height] || 0
    self.width  = meta[:width] || 0
    save!
  end
end
Chances are you want to validate if the attachment is present before saving. You can use active_storage_validations gem
class Image < ApplicationRecord
  after_commit :set_dimensions, on: :create
  has_one_attached :file
  # validations by active_storage_validations
  validates :file, attached: true,
    size: { less_than: 12.megabytes , message: 'image too large' },
    content_type: { in: ['image/png', 'image/jpg', 'image/jpeg'], message: 'needs to be an PNG or JPEG image' }
  private
  def set_dimensions
    meta = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata
    self.height = meta[:height] || 0
    self.width  = meta[:width] || 0
    save!
  end
end
test
require 'rails_helper'
RSpec.describe Image, type: :model do
  let(:image) { build :image, file: image_file }
  context 'when trying to upload jpg' do
    let(:image_file) { FilesTestHelper.jpg } # https://blog.eq8.eu/til/factory-bot-trait-for-active-storange-has_attached.html
    it do
      expect { image.save }.to change { image.height }.from(nil).to(35)
    end
    it do
      expect { image.save }.to change { image.width }.from(nil).to(37)
    end
    it 'on update it should not cause infinitte loop' do
      image.save! # creates
      image.rotation = 90 # whatever change, some random property on Image model
      image.save! # updates
      # no stack ofverflow happens => good
    end
  end
  context 'when trying to upload pdf' do
    let(:image_file) { FilesTestHelper.pdf } # https://blog.eq8.eu/til/factory-bot-trait-for-active-storange-has_attached.html
    it do
      expect { image.save }.not_to change { image.height }
    end
  end
end
How FilesTestHelper.jpg work is explained in article attaching Active Storange to Factory Bot