1.4.1

File Attachments

Preview ecosystem guide: Gemma is not part of the Amber 2.0.0-beta.2 core web-app release gate. Its package version, API, and platform support may change independently. Do not add a personal fork as a default dependency.

Gemma provides seamless integration with Grant ORM through the Attachable module. This guide covers single and multiple file attachments.

Setup

Include the Attachable module in your Grant model:

require "gemma/grant"

class User < Grant::Base
  include Gemma::Grant::Attachable

  column id : Int64, primary: true
  column name : String

  # Column to store attachment metadata (JSON)
  column avatar_data : JSON::Any?

  # Declare the attachment
  has_one_attached :avatar
end

Single File Attachments

Declaration

Use has_one_attached to attach a single file:

class User < Grant::Base
  include Gemma::Grant::Attachable

  column id : Int64, primary: true
  column profile_picture_data : JSON::Any?
  column resume_data : JSON::Any?

  has_one_attached :profile_picture
  has_one_attached :resume
end

The column name must be {attachment_name}_data with type JSON::Any?.

Attaching Files

# From IO object
user.avatar = File.open("avatar.jpg")

# From uploaded file in controller
user.avatar = params.files["avatar"].file

# Clear attachment
user.avatar = nil

Accessing Attachments

# Get the UploadedFile object
file = user.avatar

# Check if attached
if user.avatar
  puts "Avatar attached!"
end

# Get URL
url = user.avatar_url

# With URL options
url = user.avatar_url(host: "https://cdn.example.com")

# Check if changed (before save)
user.avatar_changed?  # => true/false

File Metadata

file = user.avatar

file.id                # Unique identifier
file.original_filename # Original upload name
file.extension         # File extension
file.size              # Size in bytes
file.mime_type         # MIME type
file.metadata          # All metadata hash

Working with File Content

# Open for reading
user.avatar.open do |io|
  content = io.gets_to_end
end

# Download to tempfile
user.avatar.download do |tempfile|
  # tempfile is a File object
  system("convert", tempfile.path, "thumbnail.jpg")
end

# Stream to destination
io = IO::Memory.new
user.avatar.stream(io)

Multiple File Attachments

Declaration

Use has_many_attached for multiple files:

class Post < Grant::Base
  include Gemma::Grant::Attachable

  column id : Int64, primary: true
  column title : String
  column images_data : JSON::Any?
  column attachments_data : JSON::Any?

  has_many_attached :images
  has_many_attached :attachments
end

Attaching Multiple Files

# Replace all files
post.images = [
  File.open("photo1.jpg"),
  File.open("photo2.jpg"),
  File.open("photo3.jpg")
]

# From controller with multiple file upload
post.images = params.files.select { |f| f.field == "images" }.map(&.file)

Managing Collections

# Get all files (Array of UploadedFile)
files = post.images

# Iterate
post.images.each do |image|
  puts image.url
end

# Count
post.images.size

# Add single file (singular form of attachment name)
post.add_image(File.open("new_photo.jpg"))

# Remove specific file
post.remove_image(post.images.first)

# Clear all files
post.clear_images

# Check if changed
post.images_changed?

Lifecycle Callbacks

Gemma automatically hooks into Grant's lifecycle:

class Document < Grant::Base
  include Gemma::Grant::Attachable

  column file_data : JSON::Any?
  has_one_attached :file

  # Gemma registers these automatically:
  # before_save  - promotes cached files to store
  # after_save   - persists attachment data
  # after_destroy - cleans up attached files
end

Custom Processing

Add your own callbacks for additional processing:

class Photo < Grant::Base
  include Gemma::Grant::Attachable

  column image_data : JSON::Any?
  column thumbnail_data : JSON::Any?

  has_one_attached :image
  has_one_attached :thumbnail

  after_save :generate_thumbnail

  private def generate_thumbnail
    return unless image && image_changed?

    image.download do |tempfile|
      # Generate thumbnail using ImageMagick
      thumb_path = "/tmp/thumb_#{id}.jpg"
      system("convert", tempfile.path, "-thumbnail", "100x100^", thumb_path)

      self.thumbnail = File.open(thumb_path)
      save! if thumbnail_changed?

      File.delete(thumb_path)
    end
  end
end

Custom Uploaders

Create custom uploaders for specialized behavior:

class AvatarUploader < Gemma
  # Custom file location
  def generate_location(io, metadata, context, **options)
    user = context[:model]
    filename = metadata["filename"]? || "avatar"
    extension = File.extname(filename)

    "users/#{user.id}/avatar#{extension}"
  end
end

class User < Grant::Base
  include Gemma::Grant::Attachable

  column avatar_data : JSON::Any?

  # Use custom uploader
  has_one_attached :avatar, uploader: AvatarUploader
end

Uploader with Plugins

require "gemma/plugins/determine_mime_type"
require "gemma/plugins/store_dimensions"

class ImageUploader < Gemma
  load_plugin(
    Gemma::Plugins::DetermineMimeType,
    analyzer: Gemma::Plugins::DetermineMimeType::Tools::File
  )

  load_plugin(
    Gemma::Plugins::StoreDimensions,
    analyzer: Gemma::Plugins::StoreDimensions::Tools::FastImage
  )

  finalize_plugins!
end

# Now metadata includes width/height
image.metadata["width"]   # => 1920
image.metadata["height"]  # => 1080
image.metadata["mime_type"]  # => "image/jpeg"

Form Integration

ECR Template

<form action="/users" method="post" enctype="multipart/form-data">
  <div class="form-group">
    <label for="avatar">Avatar</label>
    <input type="file" name="avatar" id="avatar" accept="image/*">
  </div>

  <% if @user.avatar %>
    <div class="current-avatar">
      <img src="<%= @user.avatar_url %>" alt="Current avatar">
      <label>
        <input type="checkbox" name="remove_avatar" value="1">
        Remove avatar
      </label>
    </div>
  <% end %>

  <button type="submit">Save</button>
</form>

Controller Handling

class UsersController < ApplicationController
  def update
    user = User.find!(params["id"])

    # Handle file upload
    if file = params.files["avatar"]?
      user.avatar = file.file
    end

    # Handle removal
    if params["remove_avatar"]? == "1"
      user.avatar = nil
    end

    if user.save
      redirect_to "/users/#{user.id}"
    else
      render "users/edit.ecr"
    end
  end
end

Direct Uploads

For large files, upload directly to storage:

# Controller
def presign
  # Generate presigned URL for direct S3 upload
  storage = Gemma.find_storage("cache").as(Gemma::Storage::S3)

  # Return presigned URL to client
  json({
    url:    storage.presigned_url(key),
    fields: storage.presigned_fields(key)
  })
end

def create
  user = User.new(user_params)

  # Accept cached file data from client
  if cached_data = params["avatar_data"]?
    user.avatar = JSON.parse(cached_data).as_h
  end

  user.save
end

Best Practices

1. Always Use JSON::Any? Column Type

# Correct
column avatar_data : JSON::Any?

# Wrong - will fail
column avatar_data : String?

2. Check for Attachment Before Accessing URL

# Safe
url = user.avatar_url if user.avatar

# Or use the helper that returns nil
url = user.avatar_url  # => nil if no attachment

3. Clean Up Orphaned Files

# Files are automatically deleted on destroy
user.destroy  # Avatar file is deleted

# For manual cleanup
user.avatar.try(&.delete)
user.update!(avatar_data: nil)

4. Use Appropriate Storage per Environment

Gemma.configure do |config|
  if ENV["AMBER_ENV"] == "production"
    config.storages["store"] = Gemma::Storage::S3.new(...)
  else
    config.storages["store"] = Gemma::Storage::FileSystem.new("uploads")
  end
end