Class: TypedEAV::Field::Image
- Inherits:
-
Base
- Object
- ActiveRecord::Base
- ApplicationRecord
- Base
- TypedEAV::Field::Image
- 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
— 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
Constants included from TypedStorage
TypedStorage::DEFAULT_OPERATORS_BY_COLUMN, TypedStorage::FALLBACK_OPERATORS
Instance Method Summary collapse
-
#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=).
-
#validate_typed_value(record, val) ⇒ Object
Validates a casted signed_id String.
Methods inherited from Base
#allowed_option_values, #array_field?, #backfill_default!, #clear_option_cache!, #default_value, #default_value=, #display_name, export_schema, #field_type_name, import_schema, #insert_at, #move_higher, #move_lower, #move_to_bottom, #move_to_top, #optionable?
Methods included from TypedStorage
#after_snapshot, #apply_default, #before_snapshot, #read_value, #value_changed?, #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 |