Class: TypedEAV::Config
- Inherits:
-
Object
- Object
- TypedEAV::Config
- 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
-
.actor_resolver ⇒ Object
Permissive actor resolver.
-
.field_types ⇒ Object
Mutable registry of type_name => class_name pairs.
-
.on_field_change ⇒ Object
Public single-proc slot for field-change events.
-
.on_image_attached ⇒ Object
Phase 05 hook: fires from after_commit on TypedEAV::Value when a Field::Image-typed Value gains (or replaces) an attachment.
-
.on_value_change ⇒ Object
Public single-proc slot for value-change events.
-
.require_scope ⇒ Object
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).
-
.scope_resolver ⇒ Object
Callable returning the ambient scope (partition key) for class-level queries.
-
.versioning ⇒ Object
Master kill-switch for Phase 04 versioning.
Class Method Summary collapse
-
.field_class_for(type_name) ⇒ Object
Resolve a type name to its STI class.
-
.register_field_type(name, class_name) ⇒ Object
Register a custom field type.
-
.reset! ⇒ Object
Restore defaults (test isolation).
-
.type_names ⇒ Object
All registered type names.
Class Attribute Details
.actor_resolver ⇒ Object
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`.
176 177 178 |
# File 'lib/typed_eav/config.rb', line 176 def actor_resolver defined?(@actor_resolver) ? @actor_resolver : nil end |
.field_types ⇒ Object
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_change ⇒ Object
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.) —Phase 07 matview needs the rename signal to regenerate column names 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_attached ⇒ Object
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 versioning (Phase 04) 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_change ⇒ Object
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). Internal subscribers (Phase 04 versioning, Phase 07 matview) fire BEFORE this proc and their errors DO propagate. See 03-CONTEXT.md §User-callback error policy.
Reassignment after gem initialization does NOT disable internal subscribers — those live on EventDispatcher.value_change_internals, not here.
196 197 198 |
# File 'lib/typed_eav/config.rb', line 196 def on_value_change @on_value_change end |
.require_scope ⇒ Object
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_resolver ⇒ Object
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 |
.versioning ⇒ Object
Master kill-switch for Phase 04 versioning. When false (default), the Phase 04 internal subscriber is NOT registered with EventDispatcher at engine boot — zero overhead for apps that don’t use versioning. When true, the subscriber registers but only writes a version 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 subscriber doesn’t register, no version rows are written, no perf impact at all. The migration is still copied (idempotent), but the table sits empty.
144 145 146 |
# File 'lib/typed_eav/config.rb', line 144 def versioning defined?(@versioning) ? @versioning : false end |
Class Method Details
.field_class_for(type_name) ⇒ Object
Resolve a type name to its STI class.
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 283 284 |
# 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 # Phase 04 versioning master switch + actor resolver. Reset to defaults # (false / nil) so test isolation matches `Config.on_value_change` / etc. # Internal subscribers (TypedEAV::Versioning::Subscriber, registered # at engine load by plan 04-02) are deliberately NOT cleared here — # they live on EventDispatcher.value_change_internals and survive # Config.reset! by design (the snapshot/restore split is locked at # 03-CONTEXT.md §Reset split). Test teardown that needs to clear # subscribers 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. Internal subscribers # (EventDispatcher.value_change_internals/field_change_internals) are # deliberately NOT reset here — they're populated at engine load by # Phase 04+ and must 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_names ⇒ Object
All registered type names.
253 254 255 |
# File 'lib/typed_eav/config.rb', line 253 def type_names field_types.keys end |