Field types and validation
Field Types
| Type | Column | Ruby Type | Options |
|---|---|---|---|
Text |
string_value |
String | min_length, max_length, pattern |
LongText |
text_value |
String | min_length, max_length |
Integer |
integer_value |
Integer | min, max |
Decimal |
decimal_value |
BigDecimal | min, max, precision_scale |
Boolean |
boolean_value |
Boolean | |
Date |
date_value |
Date | min_date, max_date |
DateTime |
datetime_value |
Time | min_datetime, max_datetime |
Select |
string_value |
String | options via TypedEAV::Option |
MultiSelect |
json_value |
Array | options via TypedEAV::Option |
IntegerArray |
json_value |
Array | min_size, max_size, min, max |
DecimalArray |
json_value |
Array | min_size, max_size |
TextArray |
json_value |
Array | min_size, max_size |
DateArray |
json_value |
Array | min_size, max_size |
Email |
string_value |
String | auto-downcases, strips whitespace |
Url |
string_value |
String | strips whitespace |
Color |
string_value |
String | hex color values |
Json |
json_value |
Hash/Array | arbitrary JSON |
Currency |
decimal_value + string_value |
{amount: BigDecimal, currency: String} |
default_currency, allowed_currencies |
Percentage |
decimal_value |
BigDecimal (0..1 range) | decimal_places, display_as: :fraction \| :percent |
Image |
string_value (signed_id) + :attachment has_one_attached |
String (Active Storage signed_id) | allowed_content_types, max_size_bytes |
File |
string_value (signed_id) + :attachment has_one_attached |
String (Active Storage signed_id) | allowed_content_types, max_size_bytes |
Reference |
integer_value (FK) |
Integer (target record ID) | target_entity_type, target_scope |
Sections (Optional UI Grouping)
general = TypedEAV::Section.create!(
name: "General Info",
code: "general",
entity_type: "Contact",
sort_order: 1
)
social = TypedEAV::Section.create!(
name: "Social Media",
code: "social",
entity_type: "Contact",
sort_order: 2
)
TypedEAV::Field::Text.create!(
name: "twitter_handle",
entity_type: "Contact",
section: social
)
Custom Field Types
Override cast(raw) to return a [casted_value, invalid?] tuple.
invalid? tells Value#validate_value whether to surface :invalid
(vs :blank) when raw input can’t be coerced. For types that never
fail to coerce, always return [value, false].
# app/models/fields/phone.rb
module Fields
class Phone < TypedEAV::Field::Base
value_column :string_value
operators :eq, :contains, :starts_with, :is_null, :is_not_null
def cast(raw)
# Strip everything but digits and +; never rejects as invalid
[raw&.to_s&.gsub(/[^\d+]/, ""), false]
end
end
end
# Register it
TypedEAV.configure do |c|
c.register_field_type :phone, "Fields::Phone"
end
Family intermediate bases (extension points)
Field::Base is the universal parent, but three intermediate family
bases collapse the most common per-leaf duplication. Pick the right
parent and you inherit the family’s validation surface for free.
-
TypedEAV::Field::ValidatedString— subclass when your custom type stores instring_valueand wants a min/max-length + regex-pattern validation surface. Inheritsvalue_column :string_value,store_accessor :options, :min_length, :max_length, :pattern, numericality validators onmin_length/max_length, amax_gte_min_lengthguard that rejects inverted bounds at field-save, and avalidate_pattern_syntaxguard that rejects bad regexes at field-save. The defaultvalidate_typed_value(record, val)runsvalidate_lengthplusvalidate_pattern if pattern.present?. Override it and callsuperto layer on a format-specific check (the built-inField::Email/Field::Urlare the canonical pattern).class Fields::Slug < TypedEAV::Field::ValidatedString SLUG_FORMAT = /\A[a-z0-9-]+\z/ def cast(raw) [raw&.to_s&.strip&.downcase, false] end def validate_typed_value(record, val) super # length + pattern from the family base record.errors.add(:value, "is not a valid slug") unless SLUG_FORMAT.match?(val.to_s) end end -
TypedEAV::Field::RangeBounded— subclass when your custom type stores a single comparable value (numeric or temporal) constrained by a min/max bound. Each leaf still declares its ownvalue_columnand its ownstore_accessor(key names vary by family member::min/:maxfor numeric;:min_date/:max_datefor date;:min_datetime/:max_datetimefor datetime). The family base provides protectedvalidate_range/validate_date_range/validate_datetime_rangehelpers. Each leaf should pair itsstore_accessorwith the macrovalidates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min(or the analogous form for the leaf’s key names) so inverted bounds fail at field-save.class Fields::Score < TypedEAV::Field::RangeBounded value_column :integer_value store_accessor :options, :min, :max validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min def cast(raw) raw.nil? ? [nil, false] : [Integer(raw.to_s, exception: false), raw.to_s.empty? ? false : true] end def validate_typed_value(record, val) validate_range(record, val) end end -
TypedEAV::Field::Optionable—includethis concern when your custom type’s valid values are drawn from aField::Optionset. Providesoptionable? = true, a public-facing sortedallowed_valueshelper, and protectedvalidate_option_inclusion/validate_multi_option_inclusionhelpers. Mixin (not inheritance) because option-set field types may use differentvalue_columns — the built-inField::Selectstores instring_valuewhileField::MultiSelectstores injson_value, and both stay as direct children ofField::Base.class Fields::Tag < TypedEAV::Field::Base include TypedEAV::Field::Optionable value_column :string_value operators :eq, :not_eq, :is_null, :is_not_null def cast(raw) [raw&.to_s, false] end def validate_typed_value(record, val) validate_option_inclusion(record, val) end end
The rule of thumb: subclass an intermediate family base when the new
field type shares its storage and validation surface with the family;
include Optionable when it draws values from an option set; subclass
Field::Base directly (as the Phone example above does) when none of
the family surfaces fit. validate_array_size lives on Field::Base
itself — its callers span unrelated families.
Multi-cell field types
External field types may store their logical value across multiple typed
columns. The entire storage surface lives directly on Field::Base via
the Field::TypedStorage concern, so a custom multi-cell type is just a
Field::Base subclass that overrides three instance methods.
Class-level DSL (declared at class load time):
value_column :col– single-cell sugar; declares the primary cell.value_columns :a, :b, ...– plural form for multi-cell types. The primary cell isvalue_columns.first. Both forms share storage;value_columnandvalue_columnsare interchangeable getters/setters.operators :eq, :gt, ...– restrict the supported operator set.self.operator_column(op)– override to route different operators to different cells. Defaults tovalue_columns.first.
Override-point instance methods (the entire extension surface for multi-cell types):
read_value(record)– compose the logical value from the cells.write_value(record, casted)– unpack the casted value across cells.apply_default(record)– populate cells fromdefault_value.
The defaults target value_columns.first, so single-cell field types
keep working without overrides. The three methods are paired – override
all three or your reads will see a multi-cell shape that writes / defaults
cannot produce.
Concrete snapshot helpers (NOT overridable; derived from
value_columns):
value_changed?(record)– true iff any cell saw a saved change.before_snapshot(record, change_type)/after_snapshot(record, change_type)– per-cell hashes keyed by string column names; powers the versioning jsonb shape.
Custom multi-cell type example (matches the built-in Field::Currency):
class Fields::Money < TypedEAV::Field::Base
AMOUNT_COLUMN = :decimal_value
CURRENCY_COLUMN = :string_value
value_columns AMOUNT_COLUMN, CURRENCY_COLUMN
operators :eq, :gt, :lt, :gteq, :lteq, :between, :currency_eq, :is_null, :is_not_null
def self.operator_column(operator)
operator == :currency_eq ? CURRENCY_COLUMN : AMOUNT_COLUMN
end
def read_value(value_record)
amount = value_record[AMOUNT_COLUMN]
currency = value_record[CURRENCY_COLUMN]
return nil if amount.nil? && currency.nil?
{ amount: amount, currency: currency }
end
def write_value(value_record, casted)
if casted.nil?
value_record[AMOUNT_COLUMN] = nil
value_record[CURRENCY_COLUMN] = nil
else
value_record[AMOUNT_COLUMN] = casted[:amount]
value_record[CURRENCY_COLUMN] = casted[:currency]
end
end
def apply_default(value_record)
default = default_value
return unless default.is_a?(Hash)
value_record[AMOUNT_COLUMN] = default[:amount] || default["amount"]
value_record[CURRENCY_COLUMN] = default[:currency] || default["currency"]
end
end
The built-in Field::Currency is the canonical multi-cell consumer of
these extension points and reads as a normal Field::Base subclass with
exactly three method overrides.
Built-in field types
-
Currency: Stores{amount: BigDecimal, currency: String}across two typed columns (decimal_valuefor the amount;string_valuefor the ISO 4217 currency code). Multi-cell storage is declared viavalue_columns :decimal_value, :string_value; reads, writes, and default application overrideread_value,write_value, andapply_defaultdirectly onField::Currency. Operators::eq,:gt,:lt,:gteq,:lteq,:betweentarget the amount;:currency_eqtargets the currency code;:is_null/:is_not_nulltarget the amount column (a Currency value is null when its amount is null). Cast input MUST be a hash with:amountand/or:currencykeys — bare numeric/string values are rejected with:invalidto enforce explicit currency dimension at write time. Options:default_currency(String ISO code, applied as fallback only when an amount is given without an explicit currency),allowed_currencies(Array of ISO codes;validate_typed_valueenforces inclusion). Versioning snapshots automatically capture both columns because the snapshot helpers iteratevalue_columns. The:currency_eqoperator is registered ONLY onField::Currency; the QueryBuilder operator-validation gate rejects it with a clearArgumentErrorif invoked on any other field type.Contact.where_typed_eav(name: "price", op: :currency_eq, value: "USD") Contact.where_typed_eav(name: "price", op: :between, value: [50, 150]) -
Percentage: AField::Decimalsubclass storing the underlying fraction in 0..1 (inclusive). The:percentrepresentation is a format-time concern — callfield.format(value)withdisplay_as: :percentto render0.75as"75.0%". Options:decimal_places(Integer >= 0, default 2; format-time precision only — does NOT alter what’s stored indecimal_value),display_as(:fractiondefault, or:percent). Validation: out-of-range values (e.g.,1.5) fail with the message"must be between 0.0 and 1.0". Storage and operator semantics inherit fromField::Decimal.pf = TypedEAV::Field::Percentage.create!( name: "discount", entity_type: "Order", scope: tenant_id, options: { display_as: :percent, decimal_places: 1 }, ) pf.format(BigDecimal("0.755")) # => "75.5%" -
Image: Active Storage-backed field type. Stores the attached blob’ssigned_id(a String) instring_value. Operators::eq,:is_null,:is_not_null. Options:allowed_content_types(Array of strings; supports exact matches like"image/png"andimage/*family wildcards),max_size_bytes(Integer; nil disables the cap). The single:attachmenthas_one_attached association is declared onTypedEAV::Valueat engine boot when Active Storage is loaded; otherwiseField::Image#castraisesNotImplementedErrorwith an actionable install message. The:attachmentassociation is shared withField::File— Image vs File is a class-identity distinction (used by theon_image_attachedhook), not a separate association.field = TypedEAV::Field::Image.create!( name: "avatar", entity_type: "Contact", options: { allowed_content_types: %w[image/png image/jpeg image/webp], max_size_bytes: 5_000_000 }, ) value = TypedEAV::Value.create!(entity: contact, field: field) value.attachment.attach(io: file_io, filename: "avatar.png", content_type: "image/png") value.update!(string_value: value.attachment.blob.signed_id) value.value # => the signed_id String -
File: Same shape asField::Imagebut without image-specific semantics. Storessigned_idinstring_value; same operator set; same options (allowed_content_types,max_size_bytes). The Image vs File distinction is byvalue.field.classat runtime — apps that want strict image-only validation setallowed_content_types: ["image/*"]onField::Image;Field::Fileis a general-purpose attachment slot. -
Active Storage dependency: Lazy soft-detect via
defined?(::ActiveStorage::Blob). The gem does NOT add Active Storage as a hard dependency — apps that never use Image/File never need to install it. To use Image or File fields, addgem "activestorage"to your Gemfile (included in supported Rails versions via therailsmeta-gem) and runbin/rails active_storage:installto create theactive_storage_blobs/active_storage_attachments/active_storage_variant_recordstables. The mirror precedent isacts_as_tenant, which is also soft-detected (seeConfig::DEFAULT_SCOPE_RESOLVER). -
on_image_attachedhook: Fires fromafter_commitonTypedEAV::Valuewhen aField::Image-typed Value’s attachment is added or replaced. Receives(value, blob). Configure viaTypedEAV.configure { |c| c.on_image_attached = ->(v, b) { ... } }. Hook ordering: runs AFTER versioning (Phase 4) and AFTERon_value_change(Phase 3) so it sees the persisted version row and the user-callback context. File attachments do NOT fire this hook — the name is image-specific by design. Useon_value_changefor a generic value-mutation signal that covers File-typed Values too.TypedEAV.configure do |c| c.on_image_attached = ->(value, blob) { ProcessImageJob.perform_later(value.id, blob.id) } end -
Reference: Foreign-key field type. Stores the target record’s integer ID ininteger_value. Operators::eq,:is_null,:is_not_null,:references(explicit narrowing — does NOT inherit:integer_value’s:gt/:lt/:betweendefaults; arithmetic comparisons on FKs don’t carry useful semantics). The:referencesoperator accepts AR record instances OR Integer IDs at query time, normalizing viafield.cast(a class-mismatched record routes tobase.nonerather than:is_null). Options:target_entity_type(REQUIRED — String class name of the target model, validated to constantize at field save),target_scope(OPTIONAL — when set, the field is REJECTED at save time iftarget_entity_typeis not registered withhas_typed_eav scope_method:(Gating Decision 2); when set with a scoped target, value-time validation rejects writes whose target’styped_eav_scopedoes not matchtarget_scopevia atarget_partition_matches?helper structurally parallel to Phase 1’sentity_partition_axis_matches?but on the target axis). Cross-scope safety mirrors the existingValue#validate_field_scope_matches_entityguard pattern applied to the target rather than the source.rf = TypedEAV::Field::Reference.create!( name: "manager", entity_type: "Contact", scope: tenant_id, options: { target_entity_type: "Contact", target_scope: tenant_id }, ) TypedEAV::Value.create!(entity: alice, field: rf, value: bob) # accepts AR record TypedEAV::Value.create!(entity: alice, field: rf, value: bob.id) # accepts Integer FK Contact.where_typed_eav(name: "manager", op: :references, value: bob) # filter by record Contact.where_typed_eav(name: "manager", op: :references, value: 42) # filter by FK -
Summary: The built-in field types Image, File, Reference, Currency, Percentage all preserve the cast-tuple contract (
[casted, invalid?]), the operator-dispatch model (supported_operators+operator_columnfor multi-cell types), and the no-hardcoded-attribute-references foundational principle. The multi-cell extension surface (read_value,write_value,apply_default, andoperator_column) is the canonical way to build any future external multi-cell field type.
Validation Behavior
A few non-obvious contracts worth knowing about up front:
- Required + blank:
required: truefields reject empty strings, whitespace-only strings, and arrays whose every element is nil/blank/whitespace. - Array all-or-nothing cast: integer/decimal/date arrays mark the whole value invalid (stored as
nil) when any element fails to cast. There is no silent partial — a failed form re-renders with the original input intact so the user can correct the bad element. Integerarray rejects fractional input:"1.9"is rejected rather than truncated to1. Same rules as the scalarIntegerfield.Jsonparses string input: a JSON string posted from a form is parsed; parse failures surface as:invalidrather than being stored as the literal string.TextArraydoes not support:contains: it backs a jsonb column where SQLLIKEdoesn’t apply. Use:any_eqfor “array contains element”.- Orphaned values are skipped: if a field row is deleted while values remain,
typed_eav_valueandtyped_eav_hashsilently skip the orphans rather than raising. - Cross-scope writes are rejected: assigning a
Valueto a record whosetyped_eav_scopedoesn’t match the field’sscopeadds a validation error on:field. The same guard covers theparent_scopeaxis. - Orphan-parent rows rejected: a
FieldorSectionrow withparent_scopeset butscopeblank is invalid. TheValue-side guard rejects cross-(scope, parent_scope)writes too. - Event hooks fire from
after_commit: theon_value_changeandon_field_changecallbacks fire after the database write is durable; their exceptions never break a save. See Event hooks for the full contract. - Versioning is opt-in: When enabled (
TypedEAV.config.versioning = trueon the gem;versioned: trueper host), every:create/:update/:destroyevent on a Value writes an append-only audit row intyped_eav_value_versions. See Versioning for the full contract. labelis cosmetic,nameis the machine key: A field’s optionallabelis free-text human display, independent of the slugname. Render viadisplay_name, which returnslabelwhen present elsename.humanize.labelhas no uniqueness or format constraints (only a 255-char max) and never affects ordering, lookup, partitioning, or rename detection — editing onlylabelfireson_field_changewith:update, never:rename. Existing rows (labelNULL) render unchanged. Schema export round-trips the rawlabel(legacy payloads without alabelkey import as NULL); snapshot export carries the resolveddisplay_name.