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<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
Constants included from ColumnMapping
ColumnMapping::DEFAULT_OPERATORS_BY_COLUMN, ColumnMapping::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, #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 |