Class: TypedEAV::Field::Image

Inherits:
Base show all
Defined in:
app/models/typed_eav/field/image.rb

Overview

Active Storage-backed field type for image attachments. Stores the attached blob’s ‘signed_id` (a String) in `string_value`. The actual `:attachment` has_one_attached association lives on TypedEAV::Value (registered in lib/typed_eav/engine.rb’s config.after_initialize block when ActiveStorage::Blob is defined).

## Phase 05 Gating Decision 1: lazy soft-detect

The gem does NOT add ‘add_dependency ’activestorage’‘ to its gemspec. When Active Storage is absent at runtime, this class still LOADS (Zeitwerk autoloads it; constants are inspectable without forcing AS load). #cast and #validate_typed_value short-circuit / raise on invocation:

- #cast raises NotImplementedError with an actionable install
  message (the only cast contract that can fail-fast — every
  write path goes through cast).
- #validate_typed_value silently no-ops when AS is unloaded
  (validation runs against blob lookup, which can't happen).

Mirrors the acts_as_tenant precedent (Config::DEFAULT_SCOPE_RESOLVER in lib/typed_eav/config.rb lines 49-53) — ‘defined?(::ConstantName)` is the gem-wide soft-detect idiom.

## signed_id storage choice

signed_id is a stable, portable, message-verified handle that survives blob replacement and decouples the gem’s data shape from ActiveStorage’s internal blob-id format. Storing the bare integer blob_id would couple the gem’s persisted data to AS’s primary-key type and prevent migrations like UUID-typed blobs. signed_id is always a String → string_value is the natural typed column.

## Operators

Explicit narrowing to [:eq, :is_null, :is_not_null]. Numeric and string-pattern operators (:contains, :starts_with) don’t apply to signed_id strings (they’re message-signed opaque tokens). Presence checks via :is_null / :is_not_null are the canonical “does this entity have an attachment?” query.

## Options

  • allowed_content_types: Array<String> — content-type allowlist for validate_typed_value. Supports exact matches (“image/png”) and wildcard families (“image/*”).

  • max_size_bytes: Integer — maximum blob byte_size accepted by validate_typed_value. Pass as Integer or numeric String; nil disables the size cap.

## Attachment access

Read-side: ‘value.attachment.attached?`, `value.attachment.blob`, `value.attachment.url` (Rails standard helpers — typed_eav doesn’t wrap them). Write-side: ‘value.attachment.attach(io: …, filename: …, content_type: …)`, then `value.update!(string_value: value .attachment.blob.signed_id)`. The signed_id assignment is what the typed_eav read path serves; the attachment association is the AS-native handle.

Constant Summary

Constants inherited from Base

Base::RESERVED_NAMES

Constants included from ColumnMapping

ColumnMapping::DEFAULT_OPERATORS_BY_COLUMN, ColumnMapping::FALLBACK_OPERATORS

Instance Method Summary collapse

Methods inherited from Base

#allowed_option_values, #apply_default_to, #array_field?, #backfill_default!, #clear_option_cache!, #default_value, #default_value=, export_schema, #field_type_name, import_schema, #insert_at, #move_higher, #move_lower, #move_to_bottom, #move_to_top, #optionable?, #read_value, #storage_contract, storage_contract_class, #write_value

Instance Method Details

#cast(raw) ⇒ Object

Cast contract:

  • nil / blank → [nil, false]

  • String → treated as a signed_id; passthrough as [raw, false]

  • ActiveStorage::Blob → [blob.signed_id, false]

  • any other shape (File, Tempfile, IO, Hash) → [nil, true] (apps must call value.attachment.attach(io: …) directly, then assign the blob’s signed_id back through value=)

Raises NotImplementedError when ::ActiveStorage::Blob is undefined. The raise lives in cast (not in the class body) so the constant itself loads cleanly under Zeitwerk even when AS is absent —consumers inspecting ‘TypedEAV::Field::Image.value_column` are not forced to install AS.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'app/models/typed_eav/field/image.rb', line 85

def cast(raw)
  unless defined?(::ActiveStorage::Blob)
    raise NotImplementedError,
          "TypedEAV::Field::Image requires Active Storage. " \
          "Add `gem 'activestorage'` to your Gemfile (already " \
          "included via the `rails` meta-gem in Rails 7.1+) and " \
          "run `bin/rails active_storage:install`."
  end

  return [nil, false] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
  return [raw.signed_id, false] if raw.is_a?(::ActiveStorage::Blob)
  return [raw, false] if raw.is_a?(String)

  [nil, true]
end

#validate_typed_value(record, val) ⇒ Object

Validates a casted signed_id String. Looks up the blob via ‘ActiveStorage::Blob.find_signed` (returns nil for tampered/ expired tokens — flagged as :invalid). When allowed_content_types is set, asserts blob.content_type matches one entry (exact or `image/*` wildcard). When max_size_bytes is set, asserts blob.byte_size <= the limit.

Silently no-ops when Active Storage is unloaded — the raise happens in cast (the only path that reaches save) so this is defensive belt-and-suspenders. Without this guard, a soft- detect-aware host could call validate_typed_value directly via introspection and trigger NameError at runtime; the no-op preserves the lazy contract.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'app/models/typed_eav/field/image.rb', line 114

def validate_typed_value(record, val)
  return if val.nil?
  return unless defined?(::ActiveStorage::Blob)

  blob = ::ActiveStorage::Blob.find_signed(val)
  if blob.nil?
    record.errors.add(:value, :invalid)
    return
  end

  if allowed_content_types.present? && !content_type_matches?(blob.content_type)
    record.errors.add(
      :value,
      "must be one of #{Array(allowed_content_types).join(", ")}",
    )
  end

  return unless max_size_bytes.present? && blob.byte_size > max_size_bytes.to_i

  record.errors.add(:value, "exceeds max size #{max_size_bytes} bytes")
end