typed_eav 0.1.0 → 0.2.0

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 +80 -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 +35 -1
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Versioning
5
+ # The Phase 04 internal subscriber. Conditionally registered with
6
+ # EventDispatcher.register_internal_value_change at engine boot via
7
+ # `TypedEAV::Versioning.register_if_enabled`. When registered, runs
8
+ # at slot 0 of the value-change subscriber chain.
9
+ #
10
+ # ## Contract
11
+ #
12
+ # `call(value, change_type, context)` — called by EventDispatcher.
13
+ # Returns nil (return value is ignored by EventDispatcher; the
14
+ # method's job is the side effect of writing a ValueVersion row).
15
+ #
16
+ # Two-gate short-circuit (the master switch is enforced at
17
+ # registration time, NOT here — when off, this callable is never
18
+ # registered):
19
+ # 1. `value.field` is nil → return nil (orphan guard).
20
+ # 2. `TypedEAV.registry.versioned?(value.entity_type) != true` →
21
+ # return nil.
22
+ #
23
+ # ## Why a class method (not a class-with-state)
24
+ #
25
+ # The subscriber holds NO instance state — it's a stateless function
26
+ # of (value, change_type, context, gem state). A module method is
27
+ # cheaper to register (single proc reference, no allocation per call)
28
+ # and easier to mock in specs (`allow(Subscriber).to receive(:call)`).
29
+ # If future versions need per-call state (e.g., batching), the call
30
+ # body can construct an instance internally without API change.
31
+ #
32
+ # ## Snapshot logic
33
+ #
34
+ # The before_value / after_value hashes are keyed by typed-column
35
+ # name (locked in 04-CONTEXT.md). For each column in
36
+ # `field.class.value_columns`:
37
+ # - :create → after = value[col]; before key absent (empty hash).
38
+ # - :update → before = value.attribute_before_last_save(col);
39
+ # after = value[col].
40
+ # - :destroy → before = value[col] (still in-memory on the
41
+ # destroyed record per Phase 03 P04 live-validation);
42
+ # after key absent.
43
+ # Column names are stringified for jsonb storage so query patterns
44
+ # like `WHERE before_value->>'integer_value' = '42'` work uniformly
45
+ # regardless of how the subscriber wrote them.
46
+ #
47
+ # ## Actor resolution
48
+ #
49
+ # `TypedEAV.config.actor_resolver&.call` returns an AR record,
50
+ # scalar, or nil. We coerce via the same `respond_to?(:id) ? .id.to_s
51
+ # : .to_s` pattern as lib/typed_eav.rb:239-243 (normalize_one). nil
52
+ # flows through as nil (the typed_eav_value_versions.changed_by
53
+ # column is nullable per 04-CONTEXT.md §"actor_resolver returning
54
+ # nil").
55
+ module Subscriber
56
+ class << self
57
+ # Public entry point. EventDispatcher calls this with the locked
58
+ # 3-arg signature `(value, change_type, context)`.
59
+ #
60
+ # NOTE: there is NO `Config.versioning` gate here. The subscriber
61
+ # is only registered with EventDispatcher when `Config.versioning`
62
+ # was true at engine `config.after_initialize` time (see
63
+ # `TypedEAV::Versioning.register_if_enabled`, invoked from
64
+ # lib/typed_eav/engine.rb's `config.after_initialize` block). If
65
+ # versioning is off, the subscriber is never registered and never
66
+ # reached. The remaining gates are:
67
+ # 1. field-presence (orphan guard — Value's field_id may have
68
+ # been NULLed by Phase 02's ON DELETE SET NULL cascade).
69
+ # 2. per-entity opt-in (Registry.versioned?).
70
+ def call(value, change_type, context)
71
+ return unless value.field
72
+ return unless TypedEAV.registry.versioned?(value.entity_type)
73
+
74
+ write_version_row(value, change_type, context)
75
+ end
76
+
77
+ private
78
+
79
+ def write_version_row(value, change_type, context)
80
+ # Build before_value / after_value snapshots through the field
81
+ # storage contract. Single-cell types produce one-key snapshots;
82
+ # multi-cell types like Currency produce one key per typed cell.
83
+ storage = value.field.storage_contract
84
+ before_value = storage.before_snapshot(value, change_type)
85
+ after_value = storage.after_snapshot(value, change_type)
86
+
87
+ # CRITICAL: for :destroy events, write `value_id: nil`.
88
+ # By the time `after_commit on: :destroy` fires, the parent row
89
+ # in `typed_eav_values` has already been deleted (Postgres
90
+ # commits the DELETE before invoking after_commit callbacks).
91
+ # The FK is `ON DELETE SET NULL`, but at the moment we INSERT
92
+ # the version row, Postgres validates the FK against the
93
+ # current state of typed_eav_values — which no longer contains
94
+ # the parent. Writing `value.id` (still readable in-memory on
95
+ # the destroyed AR record) would FK-fail at INSERT.
96
+ #
97
+ # The audit trail for destroy events stays queryable via:
98
+ # - entity_type + entity_id (host record identity)
99
+ # - field_id (Field is NOT destroyed by Value destruction)
100
+ # - before_value (snapshot of the columns at destroy time)
101
+ # `field_id` remains populated because destroying a Value does
102
+ # not destroy its Field — `value.field_id` is a live reference.
103
+ #
104
+ # For :create and :update events, `value_id: value.id` is
105
+ # correct (parent row exists at after_commit time).
106
+ version_value_id = change_type == :destroy ? nil : value.id
107
+
108
+ # Phase 06 bulk-operations correlation tag. Two delivery paths:
109
+ #
110
+ # 1. PER-VALUE SNAPSHOT (preferred). Plan 06-05's
111
+ # `bulk_set_typed_eav_values` stamps `value.pending_version_group_id`
112
+ # on each affected Value object BEFORE `record.save`, INSIDE
113
+ # the per-record `with_context` block. Reading the snapshot
114
+ # here (not `current_context`) guarantees the UUID survives
115
+ # the outer-transaction `after_commit` boundary — by the time
116
+ # we run, the lexical `with_context` block has unwound and
117
+ # `current_context` would be empty, but the per-Value ivar
118
+ # persists. Mirrors the existing in-memory ivar pattern at
119
+ # `app/models/typed_eav/value.rb:92-123` (`@cast_was_invalid`).
120
+ # Locked at 06-CONTEXT.md line 26 — savepoints per record
121
+ # inside an outer transaction, no relaxation of the structure.
122
+ #
123
+ # 2. CONTEXT FALLBACK. Non-bulk callers may still wrap a single
124
+ # write in `TypedEAV.with_context(version_group_id: uuid) { ... }`
125
+ # (e.g., a script correlating one update with one external
126
+ # request id). For those paths the Value ivar is unset; the
127
+ # `||` falls through to `context[:version_group_id]`. Also
128
+ # used as the belt-and-suspenders fallback for any future
129
+ # after_commit-inside-savepoint dispatch path that bulk writes
130
+ # might leverage.
131
+ #
132
+ # When neither path supplies a UUID the column (added in
133
+ # `db/migrate/20260506000001`) stays NULL — backward-compatible:
134
+ # unchanged subscribers and unchanged callers continue to work.
135
+ TypedEAV::ValueVersion.create!(
136
+ value_id: version_value_id,
137
+ field_id: value.field_id,
138
+ entity_type: value.entity_type,
139
+ entity_id: value.entity_id,
140
+ changed_by: resolve_actor,
141
+ before_value: before_value,
142
+ after_value: after_value,
143
+ context: context.to_h, # frozen → unfrozen jsonb-serializable hash
144
+ version_group_id: value.pending_version_group_id || context[:version_group_id],
145
+ change_type: change_type.to_s,
146
+ changed_at: Time.current,
147
+ )
148
+ end
149
+
150
+ def resolve_actor
151
+ actor = TypedEAV.config.actor_resolver&.call
152
+ return nil if actor.nil?
153
+
154
+ # Same coercion as lib/typed_eav.rb:239-243 (normalize_one):
155
+ # AR record → id.to_s; scalar → to_s.
156
+ actor.respond_to?(:id) ? actor.id.to_s : actor.to_s
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Phase 04 versioning namespace. Houses the Subscriber that writes
5
+ # TypedEAV::ValueVersion rows in response to Value lifecycle events
6
+ # dispatched by EventDispatcher.
7
+ #
8
+ # ## Architecture
9
+ #
10
+ # - TypedEAV::Versioning::Subscriber.call(value, change_type, context)
11
+ # is conditionally registered with
12
+ # EventDispatcher.register_internal_value_change at engine boot via
13
+ # `TypedEAV::Versioning.register_if_enabled`, which is invoked from
14
+ # the `config.after_initialize` block in lib/typed_eav/engine.rb.
15
+ # When TypedEAV.config.versioning is false (default), the helper
16
+ # returns early — no callable is added to the dispatcher chain.
17
+ # When true, the subscriber registers and runs FIRST in the value-
18
+ # change subscriber chain (slot 0 by `after_initialize` block
19
+ # declaration order — Phase 07 will declare its matview block LATER
20
+ # in the same engine to keep matview at slot ≥ 1).
21
+ #
22
+ # - The subscriber is gated by TWO checks at call time (both must
23
+ # pass for a version row to be written):
24
+ # 1. value.field is non-nil (orphan guard — Value's field_id may
25
+ # have been NULLed by Phase 02's ON DELETE SET NULL cascade).
26
+ # 2. TypedEAV.registry.versioned?(value.entity_type) == true
27
+ # (per-entity opt-in via has_typed_eav versioned: true or
28
+ # include TypedEAV::Versioned).
29
+ # The `Config.versioning` master switch is NOT re-checked inside
30
+ # the callable — when false, the subscriber is never registered in
31
+ # the first place.
32
+ #
33
+ # - Errors raised by Subscriber.call PROPAGATE per the EventDispatcher
34
+ # internal-vs-user error policy (03-CONTEXT.md §User-callback error
35
+ # policy). Versioning corruption must be loud — silent failure
36
+ # leaves the audit log inconsistent with the live row.
37
+ #
38
+ # ## Public API surface
39
+ #
40
+ # The subscriber itself is gem-internal — apps do not call it directly.
41
+ # The public API is:
42
+ # - `TypedEAV.config.versioning = true` — master switch.
43
+ # - `has_typed_eav versioned: true` (or `include TypedEAV::Versioned`) —
44
+ # per-entity opt-in.
45
+ # - `TypedEAV.config.actor_resolver = -> { ... }` — actor identification.
46
+ # - `TypedEAV.with_context(actor: ..., source: ...) { ... }` — request-
47
+ # scoped audit context.
48
+ # - `Value#history` and `Value#revert_to(version)` (plan 04-03).
49
+ module Versioning
50
+ # CRITICAL: declare nested autoload for Subscriber explicitly.
51
+ # The top-level `autoload :Versioning` in lib/typed_eav.rb only resolves
52
+ # this namespace shell — it does NOT recursively autoload nested
53
+ # constants. Without the explicit declaration below, the engine's
54
+ # config.after_initialize block (which references
55
+ # `TypedEAV::Versioning::Subscriber.method(:call)`) raises
56
+ # `NameError: uninitialized constant TypedEAV::Versioning::Subscriber`
57
+ # at boot time, breaking every host that enables versioning.
58
+ autoload :Subscriber, "typed_eav/versioning/subscriber"
59
+
60
+ # Conditionally register the Subscriber with EventDispatcher's internal
61
+ # value-change subscriber chain. Called by the engine's
62
+ # `config.after_initialize` block (lib/typed_eav/engine.rb).
63
+ #
64
+ # Extracted into a class method (not inlined inside the after_initialize
65
+ # block) for testability: specs can call this against a freshly-cleared
66
+ # `EventDispatcher.value_change_internals` to exercise both branches
67
+ # (versioning on/off) in-process, without booting the engine. The
68
+ # slot-0 regression spec (plan 04-03 P03) and the zero-overhead
69
+ # verification spec (this plan, subscriber_spec engine-boot block) both
70
+ # rely on this seam.
71
+ #
72
+ # Idempotent — safe to call multiple times. Calling twice with
73
+ # versioning on results in exactly ONE entry in
74
+ # `EventDispatcher.value_change_internals`. The idempotency check uses
75
+ # `Array#include?` against `Subscriber.method(:call)`; `Method#==`
76
+ # compares receiver+name (semantic equality), so two fresh
77
+ # `Subscriber.method(:call)` instances compare equal even though they
78
+ # are different Method objects. The engine block runs this exactly
79
+ # once per boot in production; the idempotency guard protects future
80
+ # code paths that might re-invoke for any reason.
81
+ #
82
+ # When `TypedEAV.config.versioning` is false (default), this method is
83
+ # a no-op: zero callable in `value_change_internals`, zero per-write
84
+ # dispatch cost. That is the locked CONTEXT line 17 contract.
85
+ def self.register_if_enabled
86
+ return unless TypedEAV.config.versioning
87
+
88
+ method_ref = TypedEAV::Versioning::Subscriber.method(:call)
89
+ return if TypedEAV::EventDispatcher.value_change_internals.include?(method_ref)
90
+
91
+ TypedEAV::EventDispatcher.register_internal_value_change(method_ref)
92
+ end
93
+ end
94
+ end
data/lib/typed_eav.rb CHANGED
@@ -14,15 +14,35 @@ module TypedEAV
14
14
  autoload :Config
15
15
  autoload :Registry
16
16
  autoload :HasTypedEAV
17
+ autoload :BulkWrite
18
+ autoload :Partition
17
19
  autoload :QueryBuilder
20
+ autoload :SchemaPortability
21
+ autoload :EventDispatcher
22
+ autoload :FieldStorageContract
23
+ autoload :CurrencyStorageContract
24
+ autoload :CSVMapper
25
+ autoload :ValueVersion
26
+ autoload :Versioned
27
+ autoload :Versioning
18
28
 
19
29
  # Raised when a model declared `has_typed_eav scope_method: ...` but no
20
30
  # scope can be resolved at query time and `config.require_scope` is truthy.
21
31
  class ScopeRequired < StandardError; end
22
32
 
23
- THREAD_SCOPE_STACK = :typed_eav_scope_stack
24
- THREAD_UNSCOPED = :typed_eav_unscoped
25
- private_constant :THREAD_SCOPE_STACK, :THREAD_UNSCOPED
33
+ THREAD_SCOPE_STACK = :typed_eav_scope_stack
34
+ THREAD_UNSCOPED = :typed_eav_unscoped
35
+ THREAD_CONTEXT_STACK = :typed_eav_context_stack
36
+ private_constant :THREAD_SCOPE_STACK, :THREAD_UNSCOPED, :THREAD_CONTEXT_STACK
37
+
38
+ # Shared frozen Hash returned by `current_context` when no `with_context`
39
+ # block is active. Using a single shared instance avoids allocating a new
40
+ # Hash on every empty-context dispatch (the hot path inside
41
+ # `EventDispatcher.dispatch_value_change` when no with_context block is
42
+ # active). Per locked decision (03-CONTEXT.md, pre-Lead resolution):
43
+ # always-frozen, never bare {}.
44
+ EMPTY_FROZEN_CONTEXT = {}.freeze
45
+ private_constant :EMPTY_FROZEN_CONTEXT
26
46
 
27
47
  class << self
28
48
  def config
@@ -34,27 +54,96 @@ module TypedEAV
34
54
 
35
55
  def registry = Registry
36
56
 
37
- # Current ambient scope value. Resolution order:
57
+ # Current ambient scope tuple. Resolution order:
38
58
  # 1. Inside `unscoped { }` → nil (hard bypass)
39
- # 2. Innermost `with_scope(v)` → v
59
+ # 2. Innermost `with_scope(v)` → tuple stored on the stack
40
60
  # 3. Configured `scope_resolver` callable
41
61
  # 4. nil
42
62
  #
43
- # Returns a string (via normalize), or nil when nothing resolves.
63
+ # ## Return-value contract (Phase 1, breaking change from v0.1.x)
64
+ #
65
+ # Returns either `nil` (no ambient scope) or a 2-element Array
66
+ # `[scope, parent_scope]` where each element is a String or nil.
67
+ # Never returns a bare scalar.
68
+ #
69
+ # ## scope_resolver contract (strict)
70
+ #
71
+ # The resolver lambda configured via `Config.scope_resolver = ->{ ... }`
72
+ # MUST return either `nil` or a 2-element Array. Both elements may be
73
+ # nil. Any other shape — most importantly a bare scalar (the v0.1.x
74
+ # shape) — raises `ArgumentError` directly inside `current_scope`,
75
+ # BEFORE any normalization is applied. We deliberately do NOT auto-coerce
76
+ # a bare-scalar return into `[scalar, nil]`; the BC-shim path was
77
+ # rejected during Phase 1 design (see `.vbw-planning/phases/01-*/01-CONTEXT.md`
78
+ # § "Deferred Ideas"). The strict raise is the chokepoint that makes
79
+ # the breaking change visible — silent coercion here would hide a
80
+ # contract violation in user-supplied resolver code.
81
+ #
82
+ # `parent_scope` non-nil + `scope` nil (orphan parent) is invalid; the
83
+ # check belongs to model-level validators added by plans 03/04, NOT
84
+ # to this resolver layer. The resolver is a contract surface, not a
85
+ # validation surface.
86
+ #
87
+ # `with_scope(scalar)` block API remains BC-permissive and is a
88
+ # DIFFERENT surface from the resolver-callable contract — see
89
+ # `with_scope` doc and `normalize_scope` doc.
44
90
  def current_scope
45
91
  return nil if Thread.current[THREAD_UNSCOPED]
46
92
 
47
93
  stack = Thread.current[THREAD_SCOPE_STACK]
48
- return normalize_scope(stack.last) if stack.present?
49
-
50
- normalize_scope(Config.scope_resolver&.call)
94
+ # The stack stores tuples already (with_scope normalized on push), so
95
+ # reads bypass normalize_scope entirely — no risk of double-coercion.
96
+ return stack.last if stack.present?
97
+
98
+ # Resolver-callable strict-contract path. We deliberately do NOT pass
99
+ # the raw return value through `normalize_scope`, because that helper
100
+ # is permissive (`scalar` → `[scalar, nil]`) for `with_scope` block BC.
101
+ # Routing the resolver through it would silently swallow a contract
102
+ # violation by a custom resolver returning a bare scalar.
103
+ raw = Config.scope_resolver&.call
104
+ return nil if raw.nil?
105
+
106
+ unless raw.is_a?(Array) && raw.size == 2
107
+ raise ArgumentError,
108
+ "TypedEAV.config.scope_resolver must return a 2-element " \
109
+ "[scope, parent_scope] Array (or nil). Got: #{raw.inspect}. " \
110
+ "v0.1.x resolvers returning a bare scalar must be updated — " \
111
+ "see CHANGELOG and the README migration note."
112
+ end
113
+
114
+ # Tuple shape verified — normalize each slot through the same scalar
115
+ # coercion that `normalize_scope` uses on the with_scope path. We pass
116
+ # the verified 2-element Array through normalize_scope (which is one
117
+ # of its accepted input shapes) to produce the canonical
118
+ # `[String|nil, String|nil]` tuple.
119
+ normalize_scope(raw)
51
120
  end
52
121
 
53
122
  # Run the block with `value` as the ambient scope, restoring the prior
54
123
  # stack on exit (exception-safe). Nests cleanly.
124
+ #
125
+ # ## Accepted input shapes (BC-permissive — public block API)
126
+ #
127
+ # - `with_scope("t1")` — single-arg BC: pushes `["t1", nil]`.
128
+ # - `with_scope(ar_record)` — pushes `[ar_record.id.to_s, nil]`.
129
+ # - `with_scope(["t1", "ps1"])` — Phase 1 tuple form: pushes the tuple.
130
+ # - `with_scope(nil)` — pushes nil (sentinel: no scope).
131
+ #
132
+ # The single-arg signature `with_scope(value)` keeps its v0.1.x meaning:
133
+ # `scope = value`, `parent_scope = nil`. Apps that have only ever passed
134
+ # a scalar do not need to update on upgrade.
135
+ #
136
+ # The internal stack stores normalized tuples (or nil), NOT raw values,
137
+ # so `current_scope` can return `stack.last` directly without further
138
+ # coercion.
139
+ #
140
+ # NOTE: this is the BC-permissive surface. The strict-contract surface
141
+ # is `Config.scope_resolver` — see `current_scope` doc. Two surfaces,
142
+ # two contracts: `with_scope`'s scalar-OK behavior is BC-preserving;
143
+ # the resolver-callable contract rejects bare scalars.
55
144
  def with_scope(value)
56
145
  stack = (Thread.current[THREAD_SCOPE_STACK] ||= [])
57
- stack.push(value)
146
+ stack.push(normalize_scope(value))
58
147
  yield
59
148
  ensure
60
149
  stack&.pop
@@ -75,10 +164,89 @@ module TypedEAV
75
164
  !!Thread.current[THREAD_UNSCOPED]
76
165
  end
77
166
 
78
- # Coerce resolver/with_scope/explicit-kwarg inputs into the string shape
79
- # stored in the `scope` column. Accepts raw scalars or AR records.
167
+ # Run the block with `kwargs` merged into the ambient event context,
168
+ # restoring the prior stack on exit (exception-safe). Nests cleanly with
169
+ # shallow per-key merge — outer keys remain visible inside nested blocks
170
+ # unless overridden by name; deep-merge of nested Hash values is NOT
171
+ # promised.
172
+ #
173
+ # The pre-merged hash is FROZEN before being pushed so callbacks invoked
174
+ # downstream (`Config.on_value_change` user proc, internal subscribers)
175
+ # cannot mutate context for the current or outer blocks. Without freeze,
176
+ # a callback that did `ctx[:added] = true` would corrupt the stack for
177
+ # every wrapping block on the same thread.
178
+ #
179
+ # ## Why **kwargs and not positional Hash
180
+ #
181
+ # `def with_context(**kwargs)` enforces the keyword-syntax call form.
182
+ # Per Ruby 3.0+ kwargs/Hash separation, `TypedEAV.with_context({ foo: 1 })`
183
+ # raises ArgumentError ("wrong number of arguments") — the only accepted
184
+ # form is `TypedEAV.with_context(foo: 1)`. Without **kwargs, callers
185
+ # could push arbitrary Hash shapes (including nested Arrays or non-symbol
186
+ # keys) that wouldn't merge cleanly across nesting and wouldn't match
187
+ # the documented context shape that hooks read.
188
+ #
189
+ # See `with_scope` (above) for the parallel ensure-pop pattern. Mirrors
190
+ # `with_scope`'s shape exactly except for: (a) **kwargs vs positional
191
+ # value, (b) merge-into-outer-on-push vs replace-on-push.
192
+ def with_context(**kwargs)
193
+ stack = (Thread.current[THREAD_CONTEXT_STACK] ||= [])
194
+ merged = (stack.last || EMPTY_FROZEN_CONTEXT).merge(kwargs).freeze
195
+ stack.push(merged)
196
+ yield
197
+ ensure
198
+ stack&.pop
199
+ end
200
+
201
+ # Returns the current thread's top-of-stack context Hash, or a shared
202
+ # frozen empty Hash when no `with_context` block is active. The return
203
+ # value is ALWAYS frozen — callers can rely on read-only semantics
204
+ # regardless of whether a block is active. NEVER returns nil.
205
+ def current_context
206
+ Thread.current[THREAD_CONTEXT_STACK]&.last || EMPTY_FROZEN_CONTEXT
207
+ end
208
+
209
+ # BC-permissive normalizer for `with_scope` block input and explicit
210
+ # tuple inputs. Always returns either `nil` or a 2-element tuple
211
+ # `[scope, parent_scope]` where each element is a `String` or `nil`.
212
+ #
213
+ # Accepted inputs:
214
+ #
215
+ # - `nil` → `nil` (sentinel: nothing resolved).
216
+ # - `[a, b]` (2-element Array) → `[normalize_one(a), normalize_one(b)]`.
217
+ # This is the canonical Phase-1 input shape; callers that already have
218
+ # a tuple (a custom resolver, a future `with_scope([s, ps])`) pass it
219
+ # through unchanged. `[scope, nil]` is the canonical "scope-only" tuple.
220
+ # `[nil, "ps1"]` (orphan-parent) is intentionally accepted at this
221
+ # layer — orphan-parent rejection happens in the model validator
222
+ # added by plans 03/04, NOT here. Keeping normalize permissive lets
223
+ # tests construct invalid states intentionally.
224
+ # - any other value (scalar / AR record) → `[normalize_one(value), nil]`.
225
+ # This is the BC path for `with_scope(scalar)` — single-arg block usage
226
+ # continues to mean "scope=scalar, parent_scope=nil".
227
+ #
228
+ # ## NOT a contract chokepoint for resolver returns
229
+ #
230
+ # `current_scope` deliberately does NOT route a custom-resolver return
231
+ # value through this helper, because the bare-scalar passthrough above
232
+ # would silently coerce a contract violation. Resolver shape is checked
233
+ # in `current_scope` BEFORE this helper is called. This split — strict
234
+ # on the resolver-callable surface, permissive on the with_scope block
235
+ # surface — is the Phase 1 design.
80
236
  def normalize_scope(value)
81
237
  return nil if value.nil?
238
+ return [normalize_one(value[0]), normalize_one(value[1])] if value.is_a?(Array) && value.size == 2
239
+
240
+ [normalize_one(value), nil]
241
+ end
242
+
243
+ private
244
+
245
+ # Coerce a single scope slot (scalar or AR record) into a String or nil.
246
+ # The previous v0.1.x scalar-coercion lives here unchanged — we just
247
+ # apply it per-slot now that scope is a tuple.
248
+ def normalize_one(value)
249
+ return nil if value.nil?
82
250
 
83
251
  value.respond_to?(:id) ? value.id.to_s : value.to_s
84
252
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typed_eav
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Darrin Chuk
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: csv
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.3'
26
40
  description: Add dynamic custom fields to ActiveRecord models at runtime using native
27
41
  database typed columns instead of jsonb blobs. Hybrid EAV with real indexes, real
28
42
  types, real query performance.
@@ -40,17 +54,22 @@ files:
40
54
  - app/models/typed_eav/field/base.rb
41
55
  - app/models/typed_eav/field/boolean.rb
42
56
  - app/models/typed_eav/field/color.rb
57
+ - app/models/typed_eav/field/currency.rb
43
58
  - app/models/typed_eav/field/date.rb
44
59
  - app/models/typed_eav/field/date_array.rb
45
60
  - app/models/typed_eav/field/date_time.rb
46
61
  - app/models/typed_eav/field/decimal.rb
47
62
  - app/models/typed_eav/field/decimal_array.rb
48
63
  - app/models/typed_eav/field/email.rb
64
+ - app/models/typed_eav/field/file.rb
65
+ - app/models/typed_eav/field/image.rb
49
66
  - app/models/typed_eav/field/integer.rb
50
67
  - app/models/typed_eav/field/integer_array.rb
51
68
  - app/models/typed_eav/field/json.rb
52
69
  - app/models/typed_eav/field/long_text.rb
53
70
  - app/models/typed_eav/field/multi_select.rb
71
+ - app/models/typed_eav/field/percentage.rb
72
+ - app/models/typed_eav/field/reference.rb
54
73
  - app/models/typed_eav/field/select.rb
55
74
  - app/models/typed_eav/field/text.rb
56
75
  - app/models/typed_eav/field/text_array.rb
@@ -58,7 +77,12 @@ files:
58
77
  - app/models/typed_eav/option.rb
59
78
  - app/models/typed_eav/section.rb
60
79
  - app/models/typed_eav/value.rb
80
+ - app/models/typed_eav/value_version.rb
61
81
  - db/migrate/20260330000000_create_typed_eav_tables.rb
82
+ - db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb
83
+ - db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb
84
+ - db/migrate/20260505000000_create_typed_eav_value_versions.rb
85
+ - db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb
62
86
  - lib/generators/typed_eav/install/install_generator.rb
63
87
  - lib/generators/typed_eav/scaffold/scaffold_generator.rb
64
88
  - lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb
@@ -109,13 +133,23 @@ files:
109
133
  - lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text_array.html.erb
110
134
  - lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_url.html.erb
111
135
  - lib/typed_eav.rb
136
+ - lib/typed_eav/bulk_write.rb
112
137
  - lib/typed_eav/column_mapping.rb
113
138
  - lib/typed_eav/config.rb
139
+ - lib/typed_eav/csv_mapper.rb
140
+ - lib/typed_eav/currency_storage_contract.rb
114
141
  - lib/typed_eav/engine.rb
142
+ - lib/typed_eav/event_dispatcher.rb
143
+ - lib/typed_eav/field_storage_contract.rb
115
144
  - lib/typed_eav/has_typed_eav.rb
145
+ - lib/typed_eav/partition.rb
116
146
  - lib/typed_eav/query_builder.rb
117
147
  - lib/typed_eav/registry.rb
148
+ - lib/typed_eav/schema_portability.rb
118
149
  - lib/typed_eav/version.rb
150
+ - lib/typed_eav/versioned.rb
151
+ - lib/typed_eav/versioning.rb
152
+ - lib/typed_eav/versioning/subscriber.rb
119
153
  homepage: https://github.com/dchuk/typed_eav
120
154
  licenses:
121
155
  - MIT