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,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
|
data/lib/typed_eav/registry.rb
CHANGED
|
@@ -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
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
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
|
data/lib/typed_eav/version.rb
CHANGED
|
@@ -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
|