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,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Partition-aware visibility for schema objects keyed by the canonical
5
+ # `(entity_type, scope, parent_scope)` tuple.
6
+ #
7
+ # This module is deliberately explicit: callers pass already-resolved scope
8
+ # values. Ambient resolution (`TypedEAV.current_scope`, `with_scope`,
9
+ # `unscoped`) stays with the adapters that know their calling context.
10
+ module Partition
11
+ class << self
12
+ # All field definitions visible from a tuple: pure global rows,
13
+ # scope-only rows, and full-tuple rows. Passing mode: :all_partitions is
14
+ # the deliberate admin bypass; it is distinct from `scope: nil`, which
15
+ # means the global partition only.
16
+ def visible_fields(entity_type:, scope: nil, parent_scope: nil, mode: :partition)
17
+ validate_mode!(mode)
18
+ return TypedEAV::Field::Base.where(entity_type: entity_type) if mode == :all_partitions
19
+
20
+ validate_tuple!(scope, parent_scope)
21
+ TypedEAV::Field::Base.for_entity(entity_type, scope: scope, parent_scope: parent_scope)
22
+ end
23
+
24
+ # One visible field per name after collision resolution. Most-specific
25
+ # wins: full tuple beats scope-only, scope-only beats global.
26
+ def effective_fields_by_name(entity_type:, scope: nil, parent_scope: nil, mode: :partition)
27
+ fields = visible_fields(entity_type: entity_type, scope: scope, parent_scope: parent_scope, mode: mode)
28
+ if mode == :all_partitions
29
+ TypedEAV::HasTypedEAV.definitions_multimap_by_name(fields)
30
+ else
31
+ TypedEAV::HasTypedEAV.definitions_by_name(fields)
32
+ end
33
+ end
34
+
35
+ # All sections visible from the same tuple as field definitions.
36
+ def visible_sections(entity_type:, scope: nil, parent_scope: nil, mode: :partition)
37
+ validate_mode!(mode)
38
+ return TypedEAV::Section.where(entity_type: entity_type) if mode == :all_partitions
39
+
40
+ validate_tuple!(scope, parent_scope)
41
+ TypedEAV::Section.for_entity(entity_type, scope: scope, parent_scope: parent_scope)
42
+ end
43
+
44
+ def find_visible_section!(id, entity_type:, scope: nil, parent_scope: nil, mode: :partition)
45
+ visible_sections(entity_type: entity_type, scope: scope, parent_scope: parent_scope, mode: mode).find(id)
46
+ end
47
+
48
+ private
49
+
50
+ def validate_mode!(mode)
51
+ return if %i[partition all_partitions].include?(mode)
52
+
53
+ raise ArgumentError, "Unknown partition mode: #{mode.inspect}. Expected :partition or :all_partitions."
54
+ end
55
+
56
+ def validate_tuple!(scope, parent_scope)
57
+ return if parent_scope.blank?
58
+ return if scope.present?
59
+
60
+ raise ArgumentError, "parent_scope cannot be set when scope is blank"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -30,10 +30,13 @@ module TypedEAV
30
30
  # Model.where(id: QueryBuilder.filter(field, :gt, 5).select(:entity_id))
31
31
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength -- one operator-dispatch case statement; flattening keeps the supported-operators list scannable in one place.
32
32
  def filter(field, operator, value)
33
- col = field.class.value_column
34
33
  operator = operator.to_sym
35
34
 
36
- # Validate operator is supported by this field type
35
+ # Validate operator is supported by this field type. The gate runs
36
+ # BEFORE column resolution so an unsupported operator raises a
37
+ # descriptive ArgumentError instead of silently dispatching to
38
+ # `operator_column`'s default (which would point at the wrong
39
+ # column for multi-cell types).
37
40
  supported = field.class.supported_operators
38
41
  unless supported.include?(operator)
39
42
  raise ArgumentError,
@@ -41,15 +44,48 @@ module TypedEAV
41
44
  "Supported operators: #{supported.map { |o| ":#{o}" }.join(", ")}"
42
45
  end
43
46
 
47
+ # Phase 05: route the operator to its physical column via field-side
48
+ # dispatch. Single-cell types (every built-in as of Phase 04) return
49
+ # `value_column` for every operator — BC-safe. Multi-cell types
50
+ # (Phase 05 Currency) route operators like `:eq` (amount) and
51
+ # `:currency_eq` (currency code) to different columns. See
52
+ # ColumnMapping#operator_column.
53
+ col = field.storage_contract.query_column(operator)
44
54
  arel_col = values_table[col]
45
55
 
46
56
  base = value_scope(field)
47
57
 
48
58
  case operator
49
- when :eq
59
+ when :eq, :currency_eq
60
+ # :currency_eq (Phase 5 Currency) is semantically equality on the
61
+ # routed column — Currency's operator_column override has already
62
+ # routed `col` to :string_value, so reusing the eq_predicate is
63
+ # the canonical implementation. Without this branch, the case
64
+ # falls through to the `else` raise even though the column
65
+ # dispatch resolved correctly. The operator-validation gate at
66
+ # the top of #filter still narrows :currency_eq to Field::Currency
67
+ # only — no other field type accepts it.
50
68
  eq_predicate(base, arel_col, col, value)
51
69
  when :not_eq
52
70
  not_eq_predicate(base, arel_col, col, value)
71
+ when :references
72
+ # Phase 5 Reference field. `value` may be an Integer FK OR an
73
+ # AR record instance — `field.cast` normalizes both to an
74
+ # integer FK (a class-mismatched record marks the cast invalid
75
+ # via the second tuple element). Empty-relation semantics on
76
+ # invalid cast: returning `base.where(col => nil)` would
77
+ # collapse to :is_null which has different semantics ("rows
78
+ # without an FK at all" rather than "rows referencing this
79
+ # missing target"); `base.none` is the unambiguous "no match".
80
+ # The :references operator is registered ONLY on Field::Reference
81
+ # (the operator-validation gate above keeps it from leaking to
82
+ # other types).
83
+ fk, invalid = field.cast(value)
84
+ if invalid || fk.nil?
85
+ base.none
86
+ else
87
+ base.where(arel_col.eq(fk))
88
+ end
53
89
  when :gt
54
90
  base.where(arel_col.gt(value))
55
91
  when :gteq
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/configurable"
4
-
5
3
  module TypedEAV
6
4
  # Registry of entity types (host ActiveRecord models) that have opted
7
5
  # into typed fields via `has_typed_eav`. Tracks optional field-type
@@ -10,15 +8,38 @@ module TypedEAV
10
8
  # Populated automatically when a host model calls `has_typed_eav`;
11
9
  # read by Field::Base#validate_type_allowed_for_entity to enforce
12
10
  # restrictions on field creation.
11
+ #
12
+ # Implementation note: see Config for why the class-level accessor is
13
+ # hand-rolled rather than provided by ActiveSupport::Configurable.
13
14
  class Registry
14
- include ActiveSupport::Configurable
15
-
16
- config_accessor(:entities) { {} }
17
-
18
15
  class << self
19
- # Register an entity type with optional type restrictions.
20
- def register(entity_type, types: nil)
21
- entities[entity_type] = { types: types }
16
+ # Mutable registry of entity_type => {types: [...]} entries. Lazy-init
17
+ # so first access seeds an empty Hash; reset! clears in place so the
18
+ # same Hash object is preserved across resets (callers that captured
19
+ # a reference don't end up with a stale snapshot).
20
+ def entities
21
+ @entities ||= {}
22
+ end
23
+ attr_writer :entities
24
+
25
+ # Register an entity type with optional type restrictions and optional
26
+ # versioning opt-in.
27
+ #
28
+ # `versioned:` is the per-entity Phase 04 opt-in flag. When true, AND
29
+ # `Config.versioning = true` at engine load (gem-level master switch),
30
+ # the Phase 04 subscriber writes a TypedEAV::ValueVersion row per
31
+ # Value mutation on this entity_type. Default false — apps not using
32
+ # versioning pay zero cost (one Hash#dig per write at most when
33
+ # `Config.versioning = true`, nothing when off).
34
+ #
35
+ # Backward compat: existing callers `register(name, types: types)`
36
+ # continue to work — the new kwarg defaults to false. The entry hash
37
+ # shape changes from `{ types: types }` to `{ types: types, versioned:
38
+ # versioned }`, but consumers (Registry.allowed_types_for,
39
+ # Registry.type_allowed?) only read the `:types` key, so they're
40
+ # unaffected.
41
+ def register(entity_type, types: nil, versioned: false)
42
+ entities[entity_type] = { types: types, versioned: versioned }
22
43
  end
23
44
 
24
45
  # All registered entity type names.
@@ -43,6 +64,24 @@ module TypedEAV
43
64
  allowed.include?(type_name)
44
65
  end
45
66
 
67
+ # Whether the entity type opted into Phase 04 versioning.
68
+ #
69
+ # Returns the stored boolean for opted-in entities; false for
70
+ # unregistered entities (defensive — callers might query before
71
+ # `has_typed_eav` runs in a particular load order). The Phase 04
72
+ # subscriber calls this on every Value write when `Config.versioning =
73
+ # true` — performance is one Hash#dig per write, negligible.
74
+ #
75
+ # `entities.dig(entity_type, :versioned)` returns nil when
76
+ # `entities[entity_type]` is missing (no register call) OR when the
77
+ # entry is `{ types: ..., versioned: nil }` (impossible by current
78
+ # register contract — kwarg default is false). The `|| false`
79
+ # normalizes to a strict boolean so callers can `if versioned?(...)`
80
+ # without three-way logic.
81
+ def versioned?(entity_type)
82
+ entities.dig(entity_type, :versioned) || false
83
+ end
84
+
46
85
  # Clear all registrations (test isolation).
47
86
  def reset!
48
87
  entities.clear
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Export and import field + section definitions for an exact partition
5
+ # tuple. Value rows are intentionally out of scope.
6
+ module SchemaPortability
7
+ class << self
8
+ def export_schema(entity_type:, scope: nil, parent_scope: nil)
9
+ fields = TypedEAV::Field::Base
10
+ .where(entity_type: entity_type, scope: scope, parent_scope: parent_scope)
11
+ .includes(:field_options)
12
+ .order(:sort_order)
13
+ .map { |field| export_field_entry(field) }
14
+
15
+ sections = TypedEAV::Section
16
+ .where(entity_type: entity_type, scope: scope, parent_scope: parent_scope)
17
+ .order(:sort_order)
18
+ .map { |section| export_section_entry(section) }
19
+
20
+ {
21
+ "schema_version" => 1,
22
+ "entity_type" => entity_type,
23
+ "scope" => scope,
24
+ "parent_scope" => parent_scope,
25
+ "fields" => fields,
26
+ "sections" => sections,
27
+ }
28
+ end
29
+
30
+ def import_schema(hash, on_conflict: :error)
31
+ validate_schema_version!(hash)
32
+ validate_conflict_policy!(on_conflict)
33
+
34
+ result = { "created" => 0, "updated" => 0, "skipped" => 0, "unchanged" => 0, "errors" => [] }
35
+
36
+ TypedEAV::Field::Base.transaction do
37
+ Array(hash["fields"]).each do |entry|
38
+ import_field_entry(entry, on_conflict, result)
39
+ end
40
+
41
+ Array(hash["sections"]).each do |entry|
42
+ import_section_entry(entry, on_conflict, result)
43
+ end
44
+ end
45
+
46
+ result
47
+ end
48
+
49
+ private
50
+
51
+ # rubocop:disable Metrics/AbcSize -- flat projection is the canonical field export shape.
52
+ def export_field_entry(field)
53
+ entry = {
54
+ "name" => field.name,
55
+ "type" => field.type,
56
+ "entity_type" => field.entity_type,
57
+ "scope" => field.scope,
58
+ "parent_scope" => field.parent_scope,
59
+ "required" => field.required,
60
+ "sort_order" => field.sort_order,
61
+ "field_dependent" => field.field_dependent,
62
+ "options" => field.options,
63
+ "default_value_meta" => field.default_value_meta,
64
+ }
65
+
66
+ if field.optionable?
67
+ options_rows = if field.field_options.loaded?
68
+ field.field_options.sort_by do |option|
69
+ [option.sort_order || 0, option.label.to_s, option.id]
70
+ end
71
+ else
72
+ field.field_options.sorted
73
+ end
74
+ entry["options_data"] = options_rows.map do |option|
75
+ { "label" => option.label, "value" => option.value, "sort_order" => option.sort_order }
76
+ end
77
+ end
78
+
79
+ entry
80
+ end
81
+ # rubocop:enable Metrics/AbcSize
82
+
83
+ def export_section_entry(section)
84
+ {
85
+ "name" => section.name,
86
+ "code" => section.code,
87
+ "entity_type" => section.entity_type,
88
+ "scope" => section.scope,
89
+ "parent_scope" => section.parent_scope,
90
+ "sort_order" => section.sort_order,
91
+ "active" => section.active,
92
+ }
93
+ end
94
+
95
+ def validate_schema_version!(hash)
96
+ return if hash["schema_version"] == 1
97
+
98
+ raise ArgumentError,
99
+ "Unsupported schema_version: #{hash["schema_version"].inspect}. " \
100
+ "Expected 1. Re-export from a current typed_eav version."
101
+ end
102
+
103
+ def validate_conflict_policy!(on_conflict)
104
+ valid_policies = %i[error skip overwrite]
105
+ return if valid_policies.include?(on_conflict)
106
+
107
+ raise ArgumentError,
108
+ "Unsupported on_conflict: #{on_conflict.inspect}. " \
109
+ "Supported: #{valid_policies.map { |policy| ":#{policy}" }.join(", ")}."
110
+ end
111
+
112
+ def import_field_entry(entry, on_conflict, result)
113
+ existing = TypedEAV::Field::Base.find_by(
114
+ name: entry["name"],
115
+ entity_type: entry["entity_type"],
116
+ scope: entry["scope"],
117
+ parent_scope: entry["parent_scope"],
118
+ )
119
+
120
+ if existing
121
+ reject_type_swap!(existing, entry)
122
+
123
+ if field_export_row_equal?(existing, entry)
124
+ result["unchanged"] += 1
125
+ return
126
+ end
127
+
128
+ case on_conflict
129
+ when :error
130
+ raise_divergent_field!(entry)
131
+ when :skip
132
+ result["skipped"] += 1
133
+ when :overwrite
134
+ overwrite_field!(existing, entry)
135
+ result["updated"] += 1
136
+ end
137
+ else
138
+ create_field!(entry)
139
+ result["created"] += 1
140
+ end
141
+ end
142
+
143
+ def reject_type_swap!(existing, entry)
144
+ return if existing.type == entry["type"]
145
+
146
+ raise ArgumentError,
147
+ "Cannot change field '#{entry["name"]}' from #{existing.type} to #{entry["type"]}: " \
148
+ "data-loss guard. The gem cannot infer a safe migration of existing typed values " \
149
+ "across *_value columns. Manually destroy and recreate the field if the type change " \
150
+ "is intentional."
151
+ end
152
+
153
+ def raise_divergent_field!(entry)
154
+ raise ArgumentError,
155
+ "Field '#{entry["name"]}' already exists for #{entry["entity_type"]} " \
156
+ "(scope=#{entry["scope"].inspect}, parent_scope=#{entry["parent_scope"].inspect}) " \
157
+ "and its attributes diverge from the incoming schema. " \
158
+ "Pass on_conflict: :skip or :overwrite to import over the existing field, " \
159
+ "or re-export from the source environment to confirm the divergence is intentional."
160
+ end
161
+
162
+ def overwrite_field!(existing, entry)
163
+ existing.assign_attributes(
164
+ required: entry["required"],
165
+ sort_order: entry["sort_order"],
166
+ field_dependent: entry["field_dependent"],
167
+ options: entry["options"],
168
+ )
169
+ existing.default_value_meta = entry["default_value_meta"]
170
+ existing.save!
171
+
172
+ return unless existing.optionable?
173
+
174
+ existing.field_options.destroy_all
175
+ Array(entry["options_data"]).each do |option|
176
+ existing.field_options.create!(
177
+ label: option["label"],
178
+ value: option["value"],
179
+ sort_order: option["sort_order"],
180
+ )
181
+ end
182
+ end
183
+
184
+ def create_field!(entry)
185
+ field = TypedEAV::Field::Base.create!(entry.except("options_data"))
186
+ return unless field.optionable?
187
+
188
+ Array(entry["options_data"]).each do |option|
189
+ field.field_options.create!(
190
+ label: option["label"],
191
+ value: option["value"],
192
+ sort_order: option["sort_order"],
193
+ )
194
+ end
195
+ end
196
+
197
+ # rubocop:disable Metrics/MethodLength -- mirrors field import for section rows without option replacement.
198
+ def import_section_entry(entry, on_conflict, result)
199
+ existing = TypedEAV::Section.find_by(
200
+ code: entry["code"],
201
+ entity_type: entry["entity_type"],
202
+ scope: entry["scope"],
203
+ parent_scope: entry["parent_scope"],
204
+ )
205
+
206
+ if existing
207
+ if section_export_row_equal?(existing, entry)
208
+ result["unchanged"] += 1
209
+ return
210
+ end
211
+
212
+ case on_conflict
213
+ when :error
214
+ raise_divergent_section!(entry)
215
+ when :skip
216
+ result["skipped"] += 1
217
+ when :overwrite
218
+ existing.update!(
219
+ name: entry["name"],
220
+ sort_order: entry["sort_order"],
221
+ active: entry["active"],
222
+ )
223
+ result["updated"] += 1
224
+ end
225
+ else
226
+ TypedEAV::Section.create!(entry)
227
+ result["created"] += 1
228
+ end
229
+ end
230
+ # rubocop:enable Metrics/MethodLength
231
+
232
+ def raise_divergent_section!(entry)
233
+ raise ArgumentError,
234
+ "Section '#{entry["code"]}' already exists for #{entry["entity_type"]} " \
235
+ "(scope=#{entry["scope"].inspect}, parent_scope=#{entry["parent_scope"].inspect}) " \
236
+ "and its attributes diverge from the incoming schema. " \
237
+ "Pass on_conflict: :skip or :overwrite to import over the existing section, " \
238
+ "or re-export from the source environment to confirm the divergence is intentional."
239
+ end
240
+
241
+ def field_export_row_equal?(existing, incoming)
242
+ export_field_entry(existing) == incoming
243
+ end
244
+
245
+ def section_export_row_equal?(existing, incoming)
246
+ export_section_entry(existing) == incoming
247
+ end
248
+ end
249
+ end
250
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypedEAV
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Mixin that opts a host entity into Phase 04 versioning AFTER it has
5
+ # already called `has_typed_eav`. Equivalent in effect to passing
6
+ # `versioned: true` directly to `has_typed_eav`, but useful for:
7
+ # - Codebases that group versioning concerns separately from the
8
+ # `has_typed_eav` macro (e.g., audited models in a `Auditable`
9
+ # mixin pattern).
10
+ # - Apps that conditionally include versioning via Rails initializers
11
+ # based on environment (`include TypedEAV::Versioned if Rails.env.production?`).
12
+ #
13
+ # ## Usage
14
+ #
15
+ # class Contact < ActiveRecord::Base
16
+ # has_typed_eav scope_method: :tenant_id, types: %i[text integer]
17
+ # include TypedEAV::Versioned
18
+ # end
19
+ #
20
+ # The order matters: `has_typed_eav` first, `include TypedEAV::Versioned`
21
+ # second. The concern's `included` hook re-registers the entity with
22
+ # `versioned: true`, preserving the existing `types:` restriction by
23
+ # reading the current Registry entry. If `has_typed_eav` was not called
24
+ # first, the included hook raises ArgumentError with a clear message.
25
+ #
26
+ # Why post-`has_typed_eav` (not standalone): `has_typed_eav` sets up
27
+ # the `has_many :typed_values` association, defines `typed_eav_scope` /
28
+ # `typed_eav_parent_scope` accessors, and includes the InstanceMethods
29
+ # mixin. Without that infrastructure, Phase 04 versioning has nothing
30
+ # to version — the host model can't even hold typed values. So
31
+ # `Versioned` is a *post*-step, not a replacement (Scout §2 confirmed
32
+ # this design).
33
+ #
34
+ # ## Equivalent to `has_typed_eav versioned: true`
35
+ #
36
+ # The two forms produce identical Registry state:
37
+ # has_typed_eav versioned: true
38
+ # # OR
39
+ # has_typed_eav
40
+ # include TypedEAV::Versioned
41
+ #
42
+ # The kwarg form is preferred for new code (one declaration, less to
43
+ # remember). The concern form is for codebases with established
44
+ # mixin-based feature wiring conventions.
45
+ module Versioned
46
+ extend ActiveSupport::Concern
47
+
48
+ included do
49
+ # Precondition: has_typed_eav must have run first.
50
+ # has_typed_eav sets `typed_eav_scope_method` as a class_attribute
51
+ # (lib/typed_eav/has_typed_eav.rb:115-116) — even when scope_method
52
+ # is nil, the class_attribute is defined. We test for the presence
53
+ # of the class_attribute reader as the canonical "did has_typed_eav
54
+ # run" check. `respond_to?` distinguishes "method defined" from
55
+ # "method missing" without false-positives from nil values.
56
+ unless respond_to?(:typed_eav_scope_method)
57
+ raise ArgumentError,
58
+ "include TypedEAV::Versioned requires `has_typed_eav` to have run first on #{name}. " \
59
+ "Add `has_typed_eav` (with any options you need) BEFORE `include TypedEAV::Versioned`. " \
60
+ "Alternatively, pass `versioned: true` directly to has_typed_eav."
61
+ end
62
+
63
+ # Re-register with versioned: true. Preserve the existing types:
64
+ # restriction by reading the current Registry entry.
65
+ # has_typed_eav already called register(name, types: types,
66
+ # versioned: false) — we overwrite with versioned: true while
67
+ # keeping the same types. If the entry doesn't exist (defensive
68
+ # — shouldn't happen post-has_typed_eav), default types to nil.
69
+ existing = TypedEAV.registry.entities[name] || {}
70
+ TypedEAV.registry.register(name, types: existing[:types], versioned: true)
71
+ end
72
+ end
73
+ end