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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/README.md +634 -2
- data/app/models/typed_eav/field/base.rb +552 -6
- data/app/models/typed_eav/field/currency.rb +125 -0
- data/app/models/typed_eav/field/file.rb +98 -0
- data/app/models/typed_eav/field/image.rb +152 -0
- data/app/models/typed_eav/field/percentage.rb +100 -0
- data/app/models/typed_eav/field/reference.rb +230 -0
- data/app/models/typed_eav/section.rb +114 -4
- data/app/models/typed_eav/value.rb +461 -11
- data/app/models/typed_eav/value_version.rb +96 -0
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
- data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
- data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
- data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
- data/lib/typed_eav/bulk_write.rb +147 -0
- data/lib/typed_eav/column_mapping.rb +46 -0
- data/lib/typed_eav/config.rb +215 -19
- data/lib/typed_eav/csv_mapper.rb +158 -0
- data/lib/typed_eav/currency_storage_contract.rb +46 -0
- data/lib/typed_eav/engine.rb +117 -0
- data/lib/typed_eav/event_dispatcher.rb +151 -0
- data/lib/typed_eav/field_storage_contract.rb +68 -0
- data/lib/typed_eav/has_typed_eav.rb +455 -58
- data/lib/typed_eav/partition.rb +64 -0
- data/lib/typed_eav/query_builder.rb +39 -3
- data/lib/typed_eav/registry.rb +48 -9
- data/lib/typed_eav/schema_portability.rb +250 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioned.rb +73 -0
- data/lib/typed_eav/versioning/subscriber.rb +161 -0
- data/lib/typed_eav/versioning.rb +94 -0
- data/lib/typed_eav.rb +180 -12
- metadata +36 -2
|
@@ -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
|
|
24
|
-
THREAD_UNSCOPED
|
|
25
|
-
|
|
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
|
|
57
|
+
# Current ambient scope tuple. Resolution order:
|
|
38
58
|
# 1. Inside `unscoped { }` → nil (hard bypass)
|
|
39
|
-
# 2. Innermost `with_scope(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
|
-
#
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
#
|
|
79
|
-
#
|
|
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,10 +1,10 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: typed_eav
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- dchuk
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
@@ -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
|