Class: TypedEAV::Config

Inherits:
Object
  • Object
show all
Defined in:
lib/typed_eav/config.rb

Overview

Gem-level configuration for field type registration.

TypedEAV.configure do |c|
c.register_field_type :phone, "MyApp::Fields::Phone"
end

Accessible from anywhere via TypedEAV.config (which returns this class; class-level field_types / register_field_type / field_class_for / type_names methods are defined below).

Implementation note: class-level accessors are hand-rolled (plain class instance variables behind reader/writer methods) rather than provided by ActiveSupport::Configurable. Configurable was deprecated without replacement in Rails 8.1 and will be removed in Rails 8.2; rolling our own keeps the public API stable across the migration. The defined?(@var) idiom on the readers preserves the "never set vs explicitly nil" distinction that callers rely on (e.g., spec_helper's snapshot/restore hook explicitly assigns nil and expects the reader to return nil, not silently fall through to a default).

Constant Summary collapse

DEFAULT_SCOPE_RESOLVER =

Default ambient-scope resolver. Auto-detects acts_as_tenant when loaded so AAT users get zero-config behavior. Apps using any other multi-tenancy primitive (Rails Current attributes, a subdomain lookup, a thread-local, etc.) override via TypedEAV.configure.

Return-value contract (Phase 1, breaking change from v0.1.x)

Returns either nil (no resolver / opt-out) or a 2-element Array [scope, parent_scope]. The acts_as_tenant gem has no parent_scope analog, so the parent slot is unconditionally nil. When AAT is not loaded we return nil (the sentinel: no resolver consulted). When AAT is loaded but current_tenant is itself nil we return [nil, nil] (the sentinel: AAT consulted, no tenant) — intentionally NOT auto-collapsed to nil, to preserve the distinction between "no resolver" and "resolver returned nothing".

Migration note for v0.1.x custom resolvers

Custom resolver lambdas configured via Config.scope_resolver = ->{ ... } MUST be updated to return a 2-element Array [scope, parent_scope] (or nil). A bare-scalar return — the v0.1.x shape — raises ArgumentError from TypedEAV.current_scope. The shim alternative (auto-coerce scalar to [scalar, nil]) was rejected during Phase 1 design; we want the breaking change to be loud, not silent. See the CHANGELOG and README migration section for the upgrade pattern.

lambda {
  next nil unless defined?(::ActsAsTenant)

  [::ActsAsTenant.current_tenant, nil]
}
BUILTIN_FIELD_TYPES =

Map of type names to their STI class names. Add custom types via TypedEAV.configure.

{
  text: "TypedEAV::Field::Text",
  long_text: "TypedEAV::Field::LongText",
  integer: "TypedEAV::Field::Integer",
  decimal: "TypedEAV::Field::Decimal",
  boolean: "TypedEAV::Field::Boolean",
  currency: "TypedEAV::Field::Currency",
  date: "TypedEAV::Field::Date",
  date_time: "TypedEAV::Field::DateTime",
  select: "TypedEAV::Field::Select",
  multi_select: "TypedEAV::Field::MultiSelect",
  percentage: "TypedEAV::Field::Percentage",
  reference: "TypedEAV::Field::Reference",
  integer_array: "TypedEAV::Field::IntegerArray",
  decimal_array: "TypedEAV::Field::DecimalArray",
  text_array: "TypedEAV::Field::TextArray",
  date_array: "TypedEAV::Field::DateArray",
  email: "TypedEAV::Field::Email",
  file: "TypedEAV::Field::File",
  image: "TypedEAV::Field::Image",
  url: "TypedEAV::Field::Url",
  color: "TypedEAV::Field::Color",
  json: "TypedEAV::Field::Json",
}.freeze

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.actor_resolverObject

Permissive actor resolver. Mirrors the scope_resolver callable shape (lib/typed_eav.rb:94: Config.scope_resolver&.call) but the return contract is permissive: any value the app chooses (AR object, integer, string, nil) is acceptable, and nil is the documented fail-permissive sentinel.

Called from TypedEAV::Versioning::Subscriber (plan 04-02) once per version row write: actor = TypedEAV.config.actor_resolver&.call. The return is coerced via normalize_one-style String coercion (gem's existing pattern at lib/typed_eav.rb:239-243) before storage in the typed_eav_value_versions.changed_by column. nil flows through as nil — the column is nullable (db/migrate/20260505000000).

Why permissive (vs. scope_resolver's strict return contract): missing scope is a tenant-isolation hazard (catastrophic, fail- closed). Missing actor is a degraded audit log (recoverable, sometimes legitimate — system writes, migrations, console). Forcing every Versioned write to have an actor would reject every console save, every migration backfill, every job that didn't set with_context(actor: ...) — hostile defaults for a gem. 04-CONTEXT.md §"actor_resolver returning nil" locks the permissive contract; apps that need strict enforcement do it inside their own resolver lambda (-> { Current.user || raise SomeAppError }).

Default nil (no resolver) means every version row's changed_by is nil. Apps wire this up by setting c.actor_resolver = -> { ... } in an initializer alongside c.versioning = true.



175
176
177
# File 'lib/typed_eav/config.rb', line 175

def actor_resolver
  defined?(@actor_resolver) ? @actor_resolver : nil
end

.field_typesObject

Mutable registry of type_name => class_name pairs. Seeded from BUILTIN_FIELD_TYPES on first access; extended via register_field_type.



85
86
87
# File 'lib/typed_eav/config.rb', line 85

def field_types
  @field_types ||= BUILTIN_FIELD_TYPES.dup
end

.on_field_changeObject

Public single-proc slot for field-change events. Signature: ->(field, change_type) { ... }

  • field: TypedEAV::Field::Base (or subclass)
  • change_type: :create | :update | :destroy | :rename

Note: TWO args, no context — asymmetric vs on_value_change by design. Field changes are CRUD-on-config (admin operations on field definitions), not per-entity user actions, so thread context is less relevant. The asymmetry is locked at 03-CONTEXT.md §Phase Boundary.

:rename fires when name is among Field#saved_changes, even combined with other attr changes (sort_order, options, etc.) — Registered consumers may use the rename signal to refresh their own name-to-field mappings. even when the rename was bundled with other edits.



212
213
214
# File 'lib/typed_eav/config.rb', line 212

def on_field_change
  @on_field_change
end

.on_image_attachedObject

Phase 05 hook: fires from after_commit on TypedEAV::Value when a Field::Image-typed Value gains (or replaces) an attachment. Receives (value, blob). Default nil — no-op when not configured.

Hook ordering: fires AFTER the transactional version write and AFTER on_value_change (Phase 03). The hook is informational ("an image was attached"), not mutational; running it last avoids polluting earlier hooks' snapshots / context with attachment-derived state.

Active Storage soft-detect (Gating Decision 1, Phase 05): when Active Storage is not loaded at engine boot, the after_commit dispatcher on TypedEAV::Value short-circuits via the defined?(::ActiveStorage::Blob) guard — this accessor exists regardless (set/get is a no-op if no dispatcher fires). Mirrors the on_value_change / on_field_change idiom (plain attr_accessor rather than the hand-rolled defined?(@var) reader because the hook contract is "nil means unset"; there is no "explicitly nil vs never set" distinction this hook needs to surface).

File-attached has no parallel hook in Phase 05 — the on_image_attached name is image-specific by ROADMAP design. Apps that want a generic file-attached signal use on_value_change (Phase 03) or subscribe to ActiveSupport::Notifications directly.



237
238
239
# File 'lib/typed_eav/config.rb', line 237

def on_image_attached
  @on_image_attached
end

.on_value_changeObject

Public single-proc slot for value-change events. Signature: ->(value, change_type, context) { ... }

  • value: TypedEAV::Value (the just-committed row)
  • change_type: :create | :update | :destroy
  • context: Hash (TypedEAV.current_context — frozen)

Errors raised inside this proc are rescued by EventDispatcher and logged via Rails.logger.error — they do NOT propagate to the user's save call (the row is already committed). Generic internal observers fire BEFORE this proc and their errors DO propagate. See 03-CONTEXT.md §User-callback error policy.

Reassigning this public callback does NOT disable transactional versioning callbacks; they are installed on Value at boot and are independent of this public slot.



195
196
197
# File 'lib/typed_eav/config.rb', line 195

def on_value_change
  @on_value_change
end

.require_scopeObject

When true, class-level queries on a model that declared has_typed_eav scope_method: ... raise TypedEAV::ScopeRequired if no scope can be resolved (explicit arg, active with_scope block, or configured resolver all returned nil). Bypass per-call via TypedEAV.unscoped { ... }.



120
121
122
# File 'lib/typed_eav/config.rb', line 120

def require_scope
  defined?(@require_scope) ? @require_scope : true
end

.scope_resolverObject

Callable returning the ambient scope (partition key) for class-level queries. Invoked by TypedEAV.current_scope when no explicit scope: kwarg is passed and no with_scope block is active.

Resolver contract (strict — Phase 1 breaking change)

The resolver MUST return either:

- `nil`                              — opt out / no scope to resolve
- `[scope, parent_scope]` 2-Array    — both elements may be `nil`

Any other shape — most importantly a bare scalar (the v0.1.x shape) — raises ArgumentError in TypedEAV.current_scope. There is no auto-coercion. parent_scope non-nil + scope nil (orphan parent) is rejected by model-level validators (plans 03 / 04), NOT here — this layer is a contract surface, not a validation surface.

Note: TypedEAV.with_scope(value) is a DIFFERENT surface — its block API is BC-permissive and accepts a scalar. The resolver-callable contract is strict; the with_scope block contract is not. Both surfaces, two contracts.



110
111
112
# File 'lib/typed_eav/config.rb', line 110

def scope_resolver
  defined?(@scope_resolver) ? @scope_resolver : DEFAULT_SCOPE_RESOLVER
end

.versioningObject

Boot-time switch for transactional versioning. When false (default), Value's version callbacks are not installed. When true, the callbacks install after the host configuration is loaded, but only write a row when value.entity_type belongs to a host model that opted in via has_typed_eav versioned: true (per-entity opt-in flows through Registry; both layers land in plan 04-02).

Decoupling the master switch from the per-entity decision: disabling for all is one toggle here; enabling for some is a per-host decision in has_typed_eav. Apps that want to A/B-test versioning across environments toggle this single flag.

Default false because the schema migration only matters for apps that opt in. A v0.1.x deployment that pulls in Phase 04 without changing any config or model declarations sees no behavior change — the callbacks don't register, no version rows are written, no perf impact at all. The migration is still copied (idempotent), but the table sits empty.



143
144
145
# File 'lib/typed_eav/config.rb', line 143

def versioning
  defined?(@versioning) ? @versioning : false
end

Class Method Details

.field_class_for(type_name) ⇒ Object

Resolve a type name to its STI class.

Raises:

  • (ArgumentError)


245
246
247
248
249
250
# File 'lib/typed_eav/config.rb', line 245

def field_class_for(type_name)
  class_name = field_types[type_name.to_sym]
  raise ArgumentError, "Unknown field type: #{type_name}" unless class_name

  class_name.constantize
end

.register_field_type(name, class_name) ⇒ Object

Register a custom field type.



240
241
242
# File 'lib/typed_eav/config.rb', line 240

def register_field_type(name, class_name)
  field_types[name.to_sym] = class_name
end

.reset!Object

Restore defaults (test isolation).



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/typed_eav/config.rb', line 258

def reset!
  self.field_types = BUILTIN_FIELD_TYPES.dup
  self.scope_resolver = DEFAULT_SCOPE_RESOLVER
  self.require_scope = true
  # Transactional versioning boot switch + actor resolver. Reset to defaults
  # (false / nil) so test isolation matches `Config.on_value_change` / etc.
  # Transactional Value callbacks are deliberately NOT cleared here —
  # callback installation is boot-latched and survives
  # Config.reset! by design (the snapshot/restore split is locked at
  # 03-CONTEXT.md §Reset split). Test teardown that needs to clear
  # generic observers too calls EventDispatcher.reset!.
  self.versioning = false
  self.actor_resolver = nil
  # Test isolation: scoping_spec/field_spec/etc. call Config.reset! in
  # `after` hooks — this ensures user procs set in earlier tests don't
  # leak across examples. Generic EventDispatcher observers
  # (value_change_internals/field_change_internals) are deliberately NOT
  # reset here — they persist across Config.reset!. Test teardown
  # that needs to clear EVERYTHING calls EventDispatcher.reset! too.
  self.on_value_change = nil
  self.on_field_change = nil
  # Phase 05 image-attached hook (parallel to on_value_change /
  # on_field_change reset for test isolation).
  self.on_image_attached = nil
end

.type_namesObject

All registered type names.



253
254
255
# File 'lib/typed_eav/config.rb', line 253

def type_names
  field_types.keys
end