{"title":"File Attachments","description":"Attaching single and multiple files to Grant models","section":"guides/uploads","version":"v2","path":"guides/uploads/attachments","canonical_url":"https://amberframework.org/docs/v2/guides/uploads/attachments","markdown_url":"https://amberframework.org/docs/v2/guides/uploads/attachments.md","inherited":false,"content_markdown":"# File Attachments\n\n> **Preview ecosystem guide:** Gemma is not part of the Amber 2.0.0-beta.5\n> core web-app release gate. Its package version, API, and platform support may\n> change independently. Confirm a compatible official release before adding it\n> to an application.\n\n## Where the examples go\n\nAttachment declarations, lifecycle callbacks, and uploader selection belong in\nthe matching Grant model under `src/models/`. Upload assignment and direct\nupload handling belong in the receiving controller under `src/controllers/`.\nForm and display markup belongs in the matching ECR files under `src/views/`.\nReusable uploader classes belong under `src/uploaders/`; direct storage work\nbelongs in a job or service with focused specs.\n\nGemma's `Attachable` module adds single- and multiple-file attachment declarations\nto Grant models.\n\n## Setup\n\nInclude the `Attachable` module in your Grant model:\n\n```crystal\nrequire \"gemma/grant\"\n\nclass User < Grant::Base\n  include Gemma::Grant::Attachable\n\n  column id : Int64, primary: true\n  column name : String\n\n  # Column to store attachment metadata (JSON)\n  column avatar_data : JSON::Any?\n\n  # Declare the attachment\n  has_one_attached :avatar\nend\n```\n\n## Single File Attachments\n\n### Declaration\n\nUse `has_one_attached` to attach a single file:\n\n```crystal\nclass User < Grant::Base\n  include Gemma::Grant::Attachable\n\n  column id : Int64, primary: true\n  column profile_picture_data : JSON::Any?\n  column resume_data : JSON::Any?\n\n  has_one_attached :profile_picture\n  has_one_attached :resume\nend\n```\n\nThe column name must be `{attachment_name}_data` with type `JSON::Any?`.\n\n### Attaching Files\n\n```crystal\n# From IO object\nuser.avatar = File.open(\"avatar.jpg\")\n\n# From uploaded file in controller\nuser.avatar = params.files[\"avatar\"].file\n\n# Clear attachment\nuser.avatar = nil\n```\n\n### Accessing Attachments\n\n```crystal\n# Get the UploadedFile object\nfile = user.avatar\n\n# Check if attached\nif user.avatar\n  puts \"Avatar attached!\"\nend\n\n# Get URL\nurl = user.avatar_url\n\n# With URL options\nurl = user.avatar_url(host: \"https://cdn.example.com\")\n\n# Check if changed (before save)\nuser.avatar_changed?  # => true/false\n```\n\n### File Metadata\n\n```crystal\nfile = user.avatar\n\nfile.id                # Unique identifier\nfile.original_filename # Original upload name\nfile.extension         # File extension\nfile.size              # Size in bytes\nfile.mime_type         # MIME type\nfile.metadata          # All metadata hash\n```\n\n### Working with File Content\n\n```crystal\n# Open for reading\nuser.avatar.open do |io|\n  content = io.gets_to_end\nend\n\n# Download to tempfile\nuser.avatar.download do |tempfile|\n  # tempfile is a File object\n  system(\"convert\", tempfile.path, \"thumbnail.jpg\")\nend\n\n# Stream to destination\nio = IO::Memory.new\nuser.avatar.stream(io)\n```\n\n## Multiple File Attachments\n\n### Declaration\n\nUse `has_many_attached` for multiple files:\n\n```crystal\nclass Post < Grant::Base\n  include Gemma::Grant::Attachable\n\n  column id : Int64, primary: true\n  column title : String\n  column images_data : JSON::Any?\n  column attachments_data : JSON::Any?\n\n  has_many_attached :images\n  has_many_attached :attachments\nend\n```\n\n### Attaching Multiple Files\n\n```crystal\n# Replace all files\npost.images = [\n  File.open(\"photo1.jpg\"),\n  File.open(\"photo2.jpg\"),\n  File.open(\"photo3.jpg\")\n]\n\n# From controller with multiple file upload\npost.images = params.files.select { |f| f.field == \"images\" }.map(&.file)\n```\n\n### Managing Collections\n\n```crystal\n# Get all files (Array of UploadedFile)\nfiles = post.images\n\n# Iterate\npost.images.each do |image|\n  puts image.url\nend\n\n# Count\npost.images.size\n\n# Add single file (singular form of attachment name)\npost.add_image(File.open(\"new_photo.jpg\"))\n\n# Remove specific file\npost.remove_image(post.images.first)\n\n# Clear all files\npost.clear_images\n\n# Check if changed\npost.images_changed?\n```\n\n## Lifecycle Callbacks\n\nGemma automatically hooks into Grant's lifecycle:\n\n```crystal\nclass Document < Grant::Base\n  include Gemma::Grant::Attachable\n\n  column file_data : JSON::Any?\n  has_one_attached :file\n\n  # Gemma registers these automatically:\n  # before_save  - promotes cached files to store\n  # after_save   - persists attachment data\n  # after_destroy - cleans up attached files\nend\n```\n\n### Custom Processing\n\nAdd your own callbacks for additional processing:\n\n```crystal\nclass Photo < Grant::Base\n  include Gemma::Grant::Attachable\n\n  column image_data : JSON::Any?\n  column thumbnail_data : JSON::Any?\n\n  has_one_attached :image\n  has_one_attached :thumbnail\n\n  after_save :generate_thumbnail\n\n  private def generate_thumbnail\n    return unless image && image_changed?\n\n    image.download do |tempfile|\n      # Generate thumbnail using ImageMagick\n      thumb_path = \"/tmp/thumb_#{id}.jpg\"\n      system(\"convert\", tempfile.path, \"-thumbnail\", \"100x100^\", thumb_path)\n\n      self.thumbnail = File.open(thumb_path)\n      save! if thumbnail_changed?\n\n      File.delete(thumb_path)\n    end\n  end\nend\n```\n\n## Custom Uploaders\n\nCreate custom uploaders for specialized behavior:\n\n```crystal\nclass AvatarUploader < Gemma\n  # Custom file location\n  def generate_location(io, metadata, context, **options)\n    user = context[:model]\n    filename = metadata[\"filename\"]? || \"avatar\"\n    extension = File.extname(filename)\n\n    \"users/#{user.id}/avatar#{extension}\"\n  end\nend\n\nclass User < Grant::Base\n  include Gemma::Grant::Attachable\n\n  column avatar_data : JSON::Any?\n\n  # Use custom uploader\n  has_one_attached :avatar, uploader: AvatarUploader\nend\n```\n\n### Uploader with Plugins\n\n```crystal\nrequire \"gemma/plugins/determine_mime_type\"\nrequire \"gemma/plugins/store_dimensions\"\n\nclass ImageUploader < Gemma\n  load_plugin(\n    Gemma::Plugins::DetermineMimeType,\n    analyzer: Gemma::Plugins::DetermineMimeType::Tools::File\n  )\n\n  load_plugin(\n    Gemma::Plugins::StoreDimensions,\n    analyzer: Gemma::Plugins::StoreDimensions::Tools::FastImage\n  )\n\n  finalize_plugins!\nend\n\n# Now metadata includes width/height\nimage.metadata[\"width\"]   # => 1920\nimage.metadata[\"height\"]  # => 1080\nimage.metadata[\"mime_type\"]  # => \"image/jpeg\"\n```\n\n## Form Integration\n\n### ECR Template\n\n```erb\n<form action=\"/users\" method=\"post\" enctype=\"multipart/form-data\">\n  <div class=\"form-group\">\n    <label for=\"avatar\">Avatar</label>\n    <input type=\"file\" name=\"avatar\" id=\"avatar\" accept=\"image/*\">\n  </div>\n\n  <% if @user.avatar %>\n    <div class=\"current-avatar\">\n      <img src=\"<%= @user.avatar_url %>\" alt=\"Current avatar\">\n      <label>\n        <input type=\"checkbox\" name=\"remove_avatar\" value=\"1\">\n        Remove avatar\n      </label>\n    </div>\n  <% end %>\n\n  <button type=\"submit\">Save</button>\n</form>\n```\n\n### Controller Handling\n\n```crystal\nclass UsersController < ApplicationController\n  def update\n    user = User.find!(params[\"id\"])\n\n    # Handle file upload\n    if file = params.files[\"avatar\"]?\n      user.avatar = file.file\n    end\n\n    # Handle removal\n    if params[\"remove_avatar\"]? == \"1\"\n      user.avatar = nil\n    end\n\n    if user.save\n      redirect_to \"/users/#{user.id}\"\n    else\n      render \"users/edit.ecr\"\n    end\n  end\nend\n```\n\n## Direct Uploads\n\nFor large files, upload directly to storage:\n\n```crystal\n# Controller\ndef presign\n  # Generate presigned URL for direct S3 upload\n  storage = Gemma.find_storage(\"cache\").as(Gemma::Storage::S3)\n\n  # Return presigned URL to client\n  json({\n    url:    storage.presigned_url(key),\n    fields: storage.presigned_fields(key)\n  })\nend\n\ndef create\n  user = User.new(user_params)\n\n  # Accept cached file data from client\n  if cached_data = params[\"avatar_data\"]?\n    user.avatar = JSON.parse(cached_data).as_h\n  end\n\n  user.save\nend\n```\n\n## Best Practices\n\n### 1. Always Use `JSON::Any?` Column Type\n\n```crystal\n# Correct\ncolumn avatar_data : JSON::Any?\n\n# Wrong - will fail\ncolumn avatar_data : String?\n```\n\n### 2. Check for Attachment Before Accessing URL\n\n```crystal\n# Safe\nurl = user.avatar_url if user.avatar\n\n# Or use the helper that returns nil\nurl = user.avatar_url  # => nil if no attachment\n```\n\n### 3. Clean Up Orphaned Files\n\n```crystal\n# Files are automatically deleted on destroy\nuser.destroy  # Avatar file is deleted\n\n# For manual cleanup\nuser.avatar.try(&.delete)\nuser.update!(avatar_data: nil)\n```\n\n### 4. Use Appropriate Storage per Environment\n\n```crystal\nGemma.configure do |config|\n  if ENV[\"AMBER_ENV\"] == \"production\"\n    config.storages[\"store\"] = Gemma::Storage::S3.new(...)\n  else\n    config.storages[\"store\"] = Gemma::Storage::FileSystem.new(\"uploads\")\n  end\nend\n```"}