typed_eav 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +634 -2
  4. data/app/models/typed_eav/field/base.rb +552 -6
  5. data/app/models/typed_eav/field/currency.rb +125 -0
  6. data/app/models/typed_eav/field/file.rb +98 -0
  7. data/app/models/typed_eav/field/image.rb +152 -0
  8. data/app/models/typed_eav/field/percentage.rb +100 -0
  9. data/app/models/typed_eav/field/reference.rb +230 -0
  10. data/app/models/typed_eav/section.rb +114 -4
  11. data/app/models/typed_eav/value.rb +461 -11
  12. data/app/models/typed_eav/value_version.rb +96 -0
  13. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
  14. data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
  15. data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
  16. data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
  17. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
  18. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
  19. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
  20. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
  21. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
  22. data/lib/typed_eav/bulk_write.rb +147 -0
  23. data/lib/typed_eav/column_mapping.rb +46 -0
  24. data/lib/typed_eav/config.rb +215 -19
  25. data/lib/typed_eav/csv_mapper.rb +158 -0
  26. data/lib/typed_eav/currency_storage_contract.rb +46 -0
  27. data/lib/typed_eav/engine.rb +117 -0
  28. data/lib/typed_eav/event_dispatcher.rb +151 -0
  29. data/lib/typed_eav/field_storage_contract.rb +68 -0
  30. data/lib/typed_eav/has_typed_eav.rb +455 -58
  31. data/lib/typed_eav/partition.rb +64 -0
  32. data/lib/typed_eav/query_builder.rb +39 -3
  33. data/lib/typed_eav/registry.rb +48 -9
  34. data/lib/typed_eav/schema_portability.rb +250 -0
  35. data/lib/typed_eav/version.rb +1 -1
  36. data/lib/typed_eav/versioned.rb +73 -0
  37. data/lib/typed_eav/versioning/subscriber.rb +161 -0
  38. data/lib/typed_eav/versioning.rb +94 -0
  39. data/lib/typed_eav.rb +180 -12
  40. metadata +36 -2
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/configurable"
4
-
5
3
  module TypedEAV
6
4
  # Gem-level configuration for field type registration.
7
5
  #
@@ -12,15 +10,46 @@ module TypedEAV
12
10
  # Accessible from anywhere via `TypedEAV.config` (which returns this
13
11
  # class; class-level `field_types` / `register_field_type` / `field_class_for`
14
12
  # / `type_names` methods are defined below).
13
+ #
14
+ # Implementation note: class-level accessors are hand-rolled (plain class
15
+ # instance variables behind reader/writer methods) rather than provided by
16
+ # ActiveSupport::Configurable. Configurable was deprecated without
17
+ # replacement in Rails 8.1 and will be removed in Rails 8.2; rolling our
18
+ # own keeps the public API stable across the migration. The `defined?(@var)`
19
+ # idiom on the readers preserves the "never set vs explicitly nil"
20
+ # distinction that callers rely on (e.g., spec_helper's snapshot/restore
21
+ # hook explicitly assigns `nil` and expects the reader to return `nil`,
22
+ # not silently fall through to a default).
15
23
  class Config
16
- include ActiveSupport::Configurable
17
-
18
24
  # Default ambient-scope resolver. Auto-detects `acts_as_tenant` when
19
25
  # loaded so AAT users get zero-config behavior. Apps using any other
20
26
  # multi-tenancy primitive (Rails `Current` attributes, a subdomain
21
27
  # lookup, a thread-local, etc.) override via `TypedEAV.configure`.
28
+ #
29
+ # ## Return-value contract (Phase 1, breaking change from v0.1.x)
30
+ #
31
+ # Returns either `nil` (no resolver / opt-out) or a 2-element Array
32
+ # `[scope, parent_scope]`. The `acts_as_tenant` gem has no
33
+ # `parent_scope` analog, so the parent slot is unconditionally `nil`.
34
+ # When AAT is not loaded we return `nil` (the sentinel: no resolver
35
+ # consulted). When AAT is loaded but `current_tenant` is itself nil
36
+ # we return `[nil, nil]` (the sentinel: AAT consulted, no tenant) —
37
+ # intentionally NOT auto-collapsed to nil, to preserve the distinction
38
+ # between "no resolver" and "resolver returned nothing".
39
+ #
40
+ # ## Migration note for v0.1.x custom resolvers
41
+ #
42
+ # Custom resolver lambdas configured via `Config.scope_resolver = ->{ ... }`
43
+ # MUST be updated to return a 2-element Array `[scope, parent_scope]`
44
+ # (or `nil`). A bare-scalar return — the v0.1.x shape — raises
45
+ # `ArgumentError` from `TypedEAV.current_scope`. The shim alternative
46
+ # (auto-coerce scalar to `[scalar, nil]`) was rejected during Phase 1
47
+ # design; we want the breaking change to be loud, not silent. See the
48
+ # CHANGELOG and README migration section for the upgrade pattern.
22
49
  DEFAULT_SCOPE_RESOLVER = lambda {
23
- ::ActsAsTenant.current_tenant if defined?(::ActsAsTenant)
50
+ next nil unless defined?(::ActsAsTenant)
51
+
52
+ [::ActsAsTenant.current_tenant, nil]
24
53
  }
25
54
 
26
55
  # Map of type names to their STI class names.
@@ -31,37 +60,182 @@ module TypedEAV
31
60
  integer: "TypedEAV::Field::Integer",
32
61
  decimal: "TypedEAV::Field::Decimal",
33
62
  boolean: "TypedEAV::Field::Boolean",
63
+ currency: "TypedEAV::Field::Currency",
34
64
  date: "TypedEAV::Field::Date",
35
65
  date_time: "TypedEAV::Field::DateTime",
36
66
  select: "TypedEAV::Field::Select",
37
67
  multi_select: "TypedEAV::Field::MultiSelect",
68
+ percentage: "TypedEAV::Field::Percentage",
69
+ reference: "TypedEAV::Field::Reference",
38
70
  integer_array: "TypedEAV::Field::IntegerArray",
39
71
  decimal_array: "TypedEAV::Field::DecimalArray",
40
72
  text_array: "TypedEAV::Field::TextArray",
41
73
  date_array: "TypedEAV::Field::DateArray",
42
74
  email: "TypedEAV::Field::Email",
75
+ file: "TypedEAV::Field::File",
76
+ image: "TypedEAV::Field::Image",
43
77
  url: "TypedEAV::Field::Url",
44
78
  color: "TypedEAV::Field::Color",
45
79
  json: "TypedEAV::Field::Json",
46
80
  }.freeze
47
81
 
48
- # Mutable registry of type_name => class_name pairs. Seeded from
49
- # BUILTIN_FIELD_TYPES on first access; extended via register_field_type.
50
- config_accessor(:field_types) { BUILTIN_FIELD_TYPES.dup }
82
+ class << self
83
+ # Mutable registry of type_name => class_name pairs. Seeded from
84
+ # BUILTIN_FIELD_TYPES on first access; extended via register_field_type.
85
+ def field_types
86
+ @field_types ||= BUILTIN_FIELD_TYPES.dup
87
+ end
88
+ attr_writer :field_types # rubocop:disable Style/AccessorGrouping
89
+
90
+ # Callable returning the ambient scope (partition key) for class-level
91
+ # queries. Invoked by `TypedEAV.current_scope` when no explicit
92
+ # `scope:` kwarg is passed and no `with_scope` block is active.
93
+ #
94
+ # ## Resolver contract (strict — Phase 1 breaking change)
95
+ #
96
+ # The resolver MUST return either:
97
+ # - `nil` — opt out / no scope to resolve
98
+ # - `[scope, parent_scope]` 2-Array — both elements may be `nil`
99
+ #
100
+ # Any other shape — most importantly a bare scalar (the v0.1.x shape) —
101
+ # raises `ArgumentError` in `TypedEAV.current_scope`. There is no
102
+ # auto-coercion. `parent_scope` non-nil + `scope` nil (orphan parent)
103
+ # is rejected by model-level validators (plans 03 / 04), NOT here —
104
+ # this layer is a contract surface, not a validation surface.
105
+ #
106
+ # Note: `TypedEAV.with_scope(value)` is a DIFFERENT surface — its block
107
+ # API is BC-permissive and accepts a scalar. The resolver-callable
108
+ # contract is strict; the `with_scope` block contract is not. Both
109
+ # surfaces, two contracts.
110
+ def scope_resolver
111
+ defined?(@scope_resolver) ? @scope_resolver : DEFAULT_SCOPE_RESOLVER
112
+ end
113
+ attr_writer :scope_resolver # rubocop:disable Style/AccessorGrouping
114
+
115
+ # When true, class-level queries on a model that declared
116
+ # `has_typed_eav scope_method: ...` raise `TypedEAV::ScopeRequired`
117
+ # if no scope can be resolved (explicit arg, active `with_scope` block,
118
+ # or configured resolver all returned nil). Bypass per-call via
119
+ # `TypedEAV.unscoped { ... }`.
120
+ def require_scope
121
+ defined?(@require_scope) ? @require_scope : true
122
+ end
123
+ attr_writer :require_scope # rubocop:disable Style/AccessorGrouping
124
+
125
+ # Master kill-switch for Phase 04 versioning. When false (default), the
126
+ # Phase 04 internal subscriber is NOT registered with EventDispatcher
127
+ # at engine boot — zero overhead for apps that don't use versioning.
128
+ # When true, the subscriber registers but only writes a version row
129
+ # when value.entity_type belongs to a host model that opted in via
130
+ # `has_typed_eav versioned: true` (per-entity opt-in flows through
131
+ # Registry; both layers land in plan 04-02).
132
+ #
133
+ # Decoupling the master switch from the per-entity decision: disabling
134
+ # for all is one toggle here; enabling for some is a per-host decision
135
+ # in `has_typed_eav`. Apps that want to A/B-test versioning across
136
+ # environments toggle this single flag.
137
+ #
138
+ # Default false because the schema migration only matters for apps that
139
+ # opt in. A v0.1.x deployment that pulls in Phase 04 without changing
140
+ # any config or model declarations sees no behavior change — the
141
+ # subscriber doesn't register, no version rows are written, no perf
142
+ # impact at all. The migration is still copied (idempotent), but the
143
+ # table sits empty.
144
+ def versioning
145
+ defined?(@versioning) ? @versioning : false
146
+ end
147
+ attr_writer :versioning # rubocop:disable Style/AccessorGrouping
148
+
149
+ # Permissive actor resolver. Mirrors the `scope_resolver` callable
150
+ # shape (lib/typed_eav.rb:94: `Config.scope_resolver&.call`) but the
151
+ # return contract is permissive: any value the app chooses (AR object,
152
+ # integer, string, nil) is acceptable, and nil is the documented
153
+ # fail-permissive sentinel.
154
+ #
155
+ # Called from TypedEAV::Versioning::Subscriber (plan 04-02) once per
156
+ # version row write: `actor = TypedEAV.config.actor_resolver&.call`.
157
+ # The return is coerced via `normalize_one`-style String coercion
158
+ # (gem's existing pattern at lib/typed_eav.rb:239-243) before storage
159
+ # in the typed_eav_value_versions.changed_by column. nil flows through
160
+ # as nil — the column is nullable (db/migrate/20260505000000).
161
+ #
162
+ # Why permissive (vs. scope_resolver's strict return contract):
163
+ # missing scope is a tenant-isolation hazard (catastrophic, fail-
164
+ # closed). Missing actor is a degraded audit log (recoverable,
165
+ # sometimes legitimate — system writes, migrations, console).
166
+ # Forcing every Versioned write to have an actor would reject every
167
+ # console save, every migration backfill, every job that didn't set
168
+ # `with_context(actor: ...)` — hostile defaults for a gem.
169
+ # 04-CONTEXT.md §"actor_resolver returning nil" locks the permissive
170
+ # contract; apps that need strict enforcement do it inside their own
171
+ # resolver lambda (`-> { Current.user || raise SomeAppError }`).
172
+ #
173
+ # Default nil (no resolver) means every version row's changed_by is
174
+ # nil. Apps wire this up by setting `c.actor_resolver = -> { ... }`
175
+ # in an initializer alongside `c.versioning = true`.
176
+ def actor_resolver
177
+ defined?(@actor_resolver) ? @actor_resolver : nil
178
+ end
179
+ attr_writer :actor_resolver # rubocop:disable Style/AccessorGrouping
180
+
181
+ # Public single-proc slot for value-change events.
182
+ # Signature: ->(value, change_type, context) { ... }
183
+ # - value: TypedEAV::Value (the just-committed row)
184
+ # - change_type: :create | :update | :destroy
185
+ # - context: Hash (TypedEAV.current_context — frozen)
186
+ #
187
+ # Errors raised inside this proc are rescued by EventDispatcher and
188
+ # logged via Rails.logger.error — they do NOT propagate to the
189
+ # user's save call (the row is already committed). Internal subscribers
190
+ # (Phase 04 versioning, Phase 07 matview) fire BEFORE this proc and
191
+ # their errors DO propagate. See 03-CONTEXT.md §User-callback error policy.
192
+ #
193
+ # Reassignment after gem initialization does NOT disable internal
194
+ # subscribers — those live on EventDispatcher.value_change_internals,
195
+ # not here.
196
+ attr_accessor :on_value_change
51
197
 
52
- # Callable returning the ambient scope (partition key) for class-level
53
- # queries. Invoked by `TypedEAV.current_scope` when no explicit
54
- # `scope:` kwarg is passed and no `with_scope` block is active.
55
- config_accessor :scope_resolver, default: DEFAULT_SCOPE_RESOLVER
198
+ # Public single-proc slot for field-change events.
199
+ # Signature: ->(field, change_type) { ... }
200
+ # - field: TypedEAV::Field::Base (or subclass)
201
+ # - change_type: :create | :update | :destroy | :rename
202
+ #
203
+ # Note: TWO args, no context — asymmetric vs on_value_change by design.
204
+ # Field changes are CRUD-on-config (admin operations on field
205
+ # definitions), not per-entity user actions, so thread context is
206
+ # less relevant. The asymmetry is locked at 03-CONTEXT.md §Phase Boundary.
207
+ #
208
+ # :rename fires when `name` is among Field#saved_changes, even
209
+ # combined with other attr changes (sort_order, options, etc.) —
210
+ # Phase 07 matview needs the rename signal to regenerate column names
211
+ # even when the rename was bundled with other edits.
212
+ attr_accessor :on_field_change
56
213
 
57
- # When true, class-level queries on a model that declared
58
- # `has_typed_eav scope_method: ...` raise `TypedEAV::ScopeRequired`
59
- # if no scope can be resolved (explicit arg, active `with_scope` block,
60
- # or configured resolver all returned nil). Bypass per-call via
61
- # `TypedEAV.unscoped { ... }`.
62
- config_accessor :require_scope, default: true
214
+ # Phase 05 hook: fires from after_commit on TypedEAV::Value when a
215
+ # Field::Image-typed Value gains (or replaces) an attachment. Receives
216
+ # `(value, blob)`. Default nil no-op when not configured.
217
+ #
218
+ # Hook ordering: fires AFTER versioning (Phase 04) and AFTER
219
+ # on_value_change (Phase 03). The hook is informational ("an image
220
+ # was attached"), not mutational; running it last avoids polluting
221
+ # earlier hooks' snapshots / context with attachment-derived state.
222
+ #
223
+ # Active Storage soft-detect (Gating Decision 1, Phase 05): when
224
+ # Active Storage is not loaded at engine boot, the after_commit
225
+ # dispatcher on TypedEAV::Value short-circuits via the
226
+ # `defined?(::ActiveStorage::Blob)` guard — this accessor exists
227
+ # regardless (set/get is a no-op if no dispatcher fires). Mirrors
228
+ # the on_value_change / on_field_change idiom (plain attr_accessor
229
+ # rather than the hand-rolled `defined?(@var)` reader because the
230
+ # hook contract is "nil means unset"; there is no "explicitly nil
231
+ # vs never set" distinction this hook needs to surface).
232
+ #
233
+ # File-attached has no parallel hook in Phase 05 — the on_image_attached
234
+ # name is image-specific by ROADMAP design. Apps that want a generic
235
+ # file-attached signal use on_value_change (Phase 03) or subscribe to
236
+ # ActiveSupport::Notifications directly.
237
+ attr_accessor :on_image_attached
63
238
 
64
- class << self
65
239
  # Register a custom field type.
66
240
  def register_field_type(name, class_name)
67
241
  field_types[name.to_sym] = class_name
@@ -85,6 +259,28 @@ module TypedEAV
85
259
  self.field_types = BUILTIN_FIELD_TYPES.dup
86
260
  self.scope_resolver = DEFAULT_SCOPE_RESOLVER
87
261
  self.require_scope = true
262
+ # Phase 04 versioning master switch + actor resolver. Reset to defaults
263
+ # (false / nil) so test isolation matches `Config.on_value_change` / etc.
264
+ # Internal subscribers (TypedEAV::Versioning::Subscriber, registered
265
+ # at engine load by plan 04-02) are deliberately NOT cleared here —
266
+ # they live on EventDispatcher.value_change_internals and survive
267
+ # Config.reset! by design (the snapshot/restore split is locked at
268
+ # 03-CONTEXT.md §Reset split). Test teardown that needs to clear
269
+ # subscribers too calls EventDispatcher.reset!.
270
+ self.versioning = false
271
+ self.actor_resolver = nil
272
+ # Test isolation: scoping_spec/field_spec/etc. call Config.reset! in
273
+ # `after` hooks — this ensures user procs set in earlier tests don't
274
+ # leak across examples. Internal subscribers
275
+ # (EventDispatcher.value_change_internals/field_change_internals) are
276
+ # deliberately NOT reset here — they're populated at engine load by
277
+ # Phase 04+ and must persist across Config.reset!. Test teardown
278
+ # that needs to clear EVERYTHING calls EventDispatcher.reset! too.
279
+ self.on_value_change = nil
280
+ self.on_field_change = nil
281
+ # Phase 05 image-attached hook (parallel to on_value_change /
282
+ # on_field_change reset for test isolation).
283
+ self.on_image_attached = nil
88
284
  end
89
285
  end
90
286
  end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module TypedEAV
6
+ # Pure stateless CSV-to-attributes transform.
7
+ #
8
+ # `TypedEAV::CSVMapper.row_to_attributes(row, mapping, fields_by_name: nil)`
9
+ # turns a single CSV row (`CSV::Row` for header-mapped files, or a plain
10
+ # `Array` for index-mapped headerless files) into a `Result` value object
11
+ # with `.attributes`, `.errors`, and `.success?` / `.failure?` predicates.
12
+ # Never raises on per-row content errors — cast failures land in `errors`,
13
+ # NOT in exceptions. The only ArgumentError path is mapping-shape
14
+ # validation, which fires before any row processing.
15
+ #
16
+ # ## Operating modes
17
+ #
18
+ # The 2-arg public form `row_to_attributes(row, mapping)` is the
19
+ # **passthrough mode**: raw cell values flow through unchanged keyed by
20
+ # the mapped field name. No coercion is attempted, no errors are possible.
21
+ # This honors the public 2-arg surface in CONTEXT line 13 + ROADMAP §Phase
22
+ # 6 success criterion exactly. Use this when the caller only needs CSV
23
+ # mapping (header → field-name) without typed coercion — e.g., when
24
+ # building a preview before the host record's partition is known.
25
+ #
26
+ # The 3-arg form `row_to_attributes(row, mapping, fields_by_name:
27
+ # defs_by_name)` is the **typed mode**: per-cell coercion runs through
28
+ # `field.cast(raw)` (the existing tuple contract documented on
29
+ # `TypedEAV::Field::Base#cast`). Cast failures (`invalid? == true`) land
30
+ # in `Result#errors` keyed by the field name, with the AR-symmetric
31
+ # message `"is invalid"`. Empty cells (nil / empty string) cast to nil
32
+ # per the `field.cast` contract and produce `attributes[name] = nil` with
33
+ # NO error. The caller is expected to pass the result of
34
+ # `record.class.typed_eav_definitions(scope:, parent_scope:).index_by(&:name)`
35
+ # (or equivalent) — the mapper has no record context and does not resolve
36
+ # fields itself.
37
+ #
38
+ # ## Mapping shape
39
+ #
40
+ # Single Hash. Keys are EITHER all `String` (CSV header names) OR all
41
+ # `Integer` (column indexes for headerless files). Mixed-key mappings
42
+ # raise `ArgumentError` immediately, before any row is touched, with a
43
+ # remediation message that tells the caller how to fix it.
44
+ #
45
+ # Mapping VALUES are field names — accepted as Symbol or String; the
46
+ # mapper coerces to String before lookup in `fields_by_name`. This
47
+ # matches the codebase convention where `field.name` is always a String.
48
+ #
49
+ # ## Unknown field in mapping (typed mode)
50
+ #
51
+ # When a mapping value (e.g. `:foo`) does NOT appear in `fields_by_name`,
52
+ # the cell is silently SKIPPED — it does NOT produce an error and does
53
+ # NOT appear in `Result#attributes`. Rationale: the mapper is a pure
54
+ # transform and has no record context. Mapping misconfiguration is a
55
+ # caller concern; callers that want to detect it can compare
56
+ # `result.attributes.keys` against the expected set. In passthrough mode
57
+ # there is no `fields_by_name` to look up against, so every mapped cell
58
+ # flows through unconditionally.
59
+ #
60
+ # ## Foundational principle
61
+ #
62
+ # NO HARDCODED ATTRIBUTE REFERENCES. The mapper resolves field metadata
63
+ # via the `fields_by_name:` keyword argument supplied by the caller —
64
+ # the mapper itself never inspects record attributes or partition state.
65
+ # Every field touch goes through `field.cast(raw)` which dispatches via
66
+ # the existing per-type cast contract.
67
+ module CSVMapper
68
+ # Plain value object — NOT an ActiveRecord model. No callbacks, no
69
+ # validations, no DB interaction. Two frozen Hashes; `success?` is just
70
+ # `errors.empty?`. Callers that need to combine multiple row Results
71
+ # into a batch view do so by composing the immutable Hashes in their
72
+ # own code (e.g., `results.flat_map(&:errors).reduce({}, :merge)`); the
73
+ # mapper does not provide a "merge" helper in v0.6.0.
74
+ class Result
75
+ attr_reader :attributes, :errors
76
+
77
+ def initialize(attributes:, errors:)
78
+ @attributes = attributes.freeze
79
+ @errors = errors.freeze
80
+ end
81
+
82
+ def success?
83
+ @errors.empty?
84
+ end
85
+
86
+ def failure?
87
+ !success?
88
+ end
89
+ end
90
+
91
+ class << self
92
+ # Transform a single row into a `Result`. See module-level docs for
93
+ # the full contract. Returns a `Result`; only raises on mapping-shape
94
+ # errors (mixed String + Integer keys).
95
+ def row_to_attributes(row, mapping, fields_by_name: nil)
96
+ validate_mapping_keys!(mapping)
97
+
98
+ attributes = {}
99
+ errors = {}
100
+
101
+ mapping.each do |source_key, raw_field_name|
102
+ # Unified cell read: both `CSV::Row#[String]` and `Array#[Integer]`
103
+ # work via `[]` — homogeneous key validation above ensures
104
+ # `source_key` matches the row representation (header name vs
105
+ # index).
106
+ raw_cell = row[source_key]
107
+
108
+ # Codebase convention: field names are always Strings on the AR
109
+ # side. Mapping values may be Symbol or String — coerce here so
110
+ # the lookup against `fields_by_name` and the keys in
111
+ # `attributes` / `errors` are consistent regardless of caller
112
+ # input style.
113
+ name = raw_field_name.to_s
114
+
115
+ if fields_by_name.nil?
116
+ # Passthrough mode — no coercion, no errors possible. Honors
117
+ # the 2-arg public surface in CONTEXT line 13 + ROADMAP §Phase
118
+ # 6. Cell flows through unchanged.
119
+ attributes[name] = raw_cell
120
+ else
121
+ # Typed mode — silently skip unknown fields (see module docs).
122
+ field = fields_by_name[name]
123
+ next if field.nil?
124
+
125
+ casted, invalid = field.cast(raw_cell)
126
+ if invalid
127
+ # AR-symmetric message; matches `errors_by_record` in the
128
+ # bulk-write surface and `errors.add(:value, :invalid)` in
129
+ # `Value#validate_value`. Plain Hash with String keys per
130
+ # RESEARCH §Open-Question Resolutions §errors_hash shape.
131
+ (errors[name] ||= []) << "is invalid"
132
+ else
133
+ attributes[name] = casted
134
+ end
135
+ end
136
+ end
137
+
138
+ Result.new(attributes: attributes, errors: errors)
139
+ end
140
+
141
+ private
142
+
143
+ # Mapping-shape validation: keys must be all-String OR all-Integer.
144
+ # Mixed keys raise immediately, BEFORE any row processing — fail
145
+ # fast on configuration errors so the caller catches them on the
146
+ # first invocation rather than silently producing partial Results.
147
+ def validate_mapping_keys!(mapping)
148
+ key_classes = mapping.keys.map(&:class).uniq
149
+ return if key_classes == [String] || key_classes == [Integer] || key_classes.empty?
150
+
151
+ raise ArgumentError,
152
+ "CSVMapper mapping must use either all String keys (CSV headers) " \
153
+ "or all Integer keys (column indexes), not both. " \
154
+ "Got: #{mapping.inspect}"
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Storage contract for Field::Currency's two-cell value shape.
5
+ class CurrencyStorageContract < FieldStorageContract
6
+ VALUE_COLUMNS = %i[decimal_value string_value].freeze
7
+ AMOUNT_COLUMN = :decimal_value
8
+ CURRENCY_COLUMN = :string_value
9
+
10
+ def self.value_columns
11
+ VALUE_COLUMNS
12
+ end
13
+
14
+ def self.query_column(operator)
15
+ operator == :currency_eq ? CURRENCY_COLUMN : AMOUNT_COLUMN
16
+ end
17
+
18
+ delegate :value_columns, :query_column, to: :class
19
+
20
+ def read(value_record)
21
+ amount = value_record[AMOUNT_COLUMN]
22
+ currency = value_record[CURRENCY_COLUMN]
23
+ return nil if amount.nil? && currency.nil?
24
+
25
+ { amount: amount, currency: currency }
26
+ end
27
+
28
+ def write(value_record, casted)
29
+ if casted.nil?
30
+ value_record[AMOUNT_COLUMN] = nil
31
+ value_record[CURRENCY_COLUMN] = nil
32
+ else
33
+ value_record[AMOUNT_COLUMN] = casted[:amount]
34
+ value_record[CURRENCY_COLUMN] = casted[:currency]
35
+ end
36
+ end
37
+
38
+ def apply_default(value_record)
39
+ default = field.default_value
40
+ return unless default.is_a?(Hash)
41
+
42
+ value_record[AMOUNT_COLUMN] = default[:amount] || default["amount"]
43
+ value_record[CURRENCY_COLUMN] = default[:currency] || default["currency"]
44
+ end
45
+ end
46
+ end
@@ -8,6 +8,13 @@ module TypedEAV
8
8
  require_relative "column_mapping"
9
9
  require_relative "config"
10
10
  require_relative "registry"
11
+ # Eager-loaded (not autoloaded) — Phase 04 versioning will register on
12
+ # EventDispatcher at engine boot, before any model reference triggers
13
+ # autoload. Without this require_relative, Phase 04's engine-time
14
+ # `register_internal_value_change` call would const-resolve the module
15
+ # for the first time and run a fresh `@value_change_internals = []`
16
+ # AFTER versioning had already pushed onto a different instance.
17
+ require_relative "event_dispatcher"
11
18
  end
12
19
 
13
20
  # Make `has_typed_eav` available on all ActiveRecord models
@@ -16,5 +23,115 @@ module TypedEAV
16
23
  include TypedEAV::HasTypedEAV
17
24
  end
18
25
  end
26
+
27
+ # Phase 04 versioning subscriber registration.
28
+ #
29
+ # CONDITIONAL on TypedEAV.config.versioning. When false (the default
30
+ # for apps that don't enable versioning), no subscriber is registered:
31
+ # zero callable in EventDispatcher.value_change_internals, zero per-write
32
+ # dispatch overhead, zero config reads on the hot path. This is the
33
+ # locked CONTEXT contract — line 17 says "zero overhead for apps that
34
+ # don't use versioning", which means literally no callable, not "callable
35
+ # that early-returns".
36
+ #
37
+ # We use `config.after_initialize` rather than a Rails `initializer` block
38
+ # because we need to consult host-set config values. The host's
39
+ # `config/initializers/typed_eav.rb` runs AFTER all engine initializers
40
+ # but BEFORE `config.after_initialize`. By the time this block fires,
41
+ # `TypedEAV.config.versioning` reflects the host's chosen value (or the
42
+ # default `false` if the host never touched it).
43
+ #
44
+ # Trade-off (documented in 04-02-PLAN §Plan-time decisions §6): apps
45
+ # that toggle `c.versioning = true` at runtime AFTER `after_initialize`
46
+ # has fired (e.g., a Rails console session that monkey-patches Config,
47
+ # or a feature-flag flip mid-process) will NOT get versioning until
48
+ # process restart. Runtime toggle is not a documented use case — adding
49
+ # a register/deregister API is out of scope for Phase 04. The Risk §1
50
+ # late-toggle concern from RESEARCH is acceptably narrowed by this
51
+ # trade-off.
52
+ #
53
+ # Slot 0 ordering: Phase 07 (future matview) will register its
54
+ # subscriber via its own `config.after_initialize` block declared LATER
55
+ # in this same engine file. Rails runs `after_initialize` blocks in
56
+ # declaration order within a single Engine class, so versioning's block
57
+ # fires first → slot 0. The regression spec (plan 04-03 P03) is the
58
+ # ongoing guard.
59
+ #
60
+ # Why a one-line callable to a class method (not inline registration):
61
+ # `TypedEAV::Versioning.register_if_enabled` is the testable seam. The
62
+ # slot-0 regression spec (plan 04-03 P03) and the zero-overhead
63
+ # verification spec (this plan, subscriber_spec) cannot reboot the Rails
64
+ # process inside RSpec — but they CAN call the helper directly against
65
+ # a fresh internals array to exercise both branches (versioning on/off)
66
+ # in-process. Inlining the `if` here would force tests to either reboot
67
+ # the engine or use brittle private-block extraction.
68
+ config.after_initialize do
69
+ TypedEAV::Versioning.register_if_enabled
70
+ end
71
+
72
+ # Phase 05 Active Storage soft-detect (Gating Decision 1).
73
+ #
74
+ # Mirrors the acts_as_tenant precedent (Config::DEFAULT_SCOPE_RESOLVER
75
+ # in lib/typed_eav/config.rb lines 49-53): the gem detects without
76
+ # requiring. When ActiveStorage::Blob is not defined at this point in
77
+ # boot, has_one_attached is NOT registered on TypedEAV::Value — apps
78
+ # that don't use Image/File field types pay zero overhead, AND the
79
+ # gemspec stays free of an activestorage hard-dependency.
80
+ #
81
+ # When AS IS loaded (Rails 7.1+ with the rails meta-gem, or an
82
+ # explicit `gem 'activestorage'` line), TypedEAV::Value gains a
83
+ # single :attachment has_one_attached association that covers BOTH
84
+ # Field::Image and Field::File typed Values. The Image vs File
85
+ # distinction at runtime is `value.field.is_a?(Field::Image)` (used
86
+ # by the on_image_attached dispatcher on TypedEAV::Value); the blob's
87
+ # content_type is the source of truth for image-vs-other-file at
88
+ # render time.
89
+ #
90
+ # Why a single shared association (not :image_attachment +
91
+ # :file_attachment): TypedEAV::Value is a monolithic table — every
92
+ # Value row gets every association declared on the class. Two
93
+ # associations would double the AR association overhead on every
94
+ # Value row (Text, Integer, etc.), even when no attachment is in
95
+ # play. RESEARCH §Risk 3 documents this rationale.
96
+ #
97
+ # Second after_initialize block (versioning's is the first): Rails
98
+ # runs after_initialize blocks in declaration order within a single
99
+ # Engine class. Versioning's slot-0 dispatcher position at the
100
+ # EventDispatcher level is preserved (dispatcher slots are an
101
+ # EventDispatcher-internal concern; the engine's after_initialize
102
+ # ordering is independent). Phase 07 matview will append its own
103
+ # block after this one.
104
+ #
105
+ # Why a one-line callable to a class method (testable seam): the
106
+ # active_storage_soft_detect_spec cannot reboot Rails inside RSpec
107
+ # to exercise both branches. By extracting the body into
108
+ # `Engine.register_attachment_associations!`, specs call the helper
109
+ # directly with whatever ::ActiveStorage state they need to test.
110
+ # Pattern matches Phase 04's `Versioning.register_if_enabled`.
111
+ config.after_initialize do
112
+ TypedEAV::Engine.register_attachment_associations!
113
+ end
114
+
115
+ # Conditionally register the :attachment has_one_attached association
116
+ # on TypedEAV::Value. Idempotent — safe to call multiple times. The
117
+ # idempotency guard (`@attachment_registered`) prevents double-
118
+ # registration when specs invoke the seam in addition to the engine
119
+ # boot path. Without the guard, AR's has_one_attached macro would
120
+ # redefine the association methods (technically harmless but
121
+ # generates RuntimeError noise on duplicate declaration in newer AS
122
+ # versions).
123
+ #
124
+ # Returns truthy on first successful registration, falsy when AS is
125
+ # unloaded or the association is already registered. The return is
126
+ # not part of the public contract — specs that care about the
127
+ # registration outcome inspect TypedEAV::Value.reflect_on_attachment
128
+ # directly.
129
+ def self.register_attachment_associations!
130
+ return false unless defined?(::ActiveStorage::Blob)
131
+ return false if @attachment_registered
132
+
133
+ TypedEAV::Value.has_one_attached :attachment
134
+ @attachment_registered = true
135
+ end
19
136
  end
20
137
  end