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,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
# In-process event-dispatch hub for Value and Field after_commit lifecycle
|
|
5
|
+
# events. Implements the contract that Phase 04 versioning and Phase 07
|
|
6
|
+
# materialized index both depend on.
|
|
7
|
+
#
|
|
8
|
+
# ## Contract surface
|
|
9
|
+
#
|
|
10
|
+
# - `Config.on_value_change` / `Config.on_field_change` are PUBLIC single
|
|
11
|
+
# proc slots (nil-default), backed by ActiveSupport::Configurable. Users
|
|
12
|
+
# set them via `TypedEAV.configure { |c| c.on_value_change = ->(...) }`.
|
|
13
|
+
# - `register_internal_value_change(callable)` / `register_internal_field_change(callable)`
|
|
14
|
+
# are FIRST-PARTY hooks for in-gem features (Phase 04 versioning, Phase 07
|
|
15
|
+
# matview DDL regen). They are not private_class_method because Phase 04
|
|
16
|
+
# lives in `TypedEAV::Versioning::*` and cannot reach a truly-private class
|
|
17
|
+
# method — the `register_internal_*` naming + this comment block signal
|
|
18
|
+
# first-party-only intent.
|
|
19
|
+
# - Internal subscribers fire FIRST, in registration order. User proc fires
|
|
20
|
+
# LAST. Phase 04 reserves slot 0 of `value_change_internals` by convention.
|
|
21
|
+
#
|
|
22
|
+
# ## Error policy (split, locked at 03-CONTEXT.md §User-callback error policy)
|
|
23
|
+
#
|
|
24
|
+
# - Internal subscribers: exceptions PROPAGATE (fail-closed). Versioning
|
|
25
|
+
# corruption must be loud — silent failure leaves typed_eav_value_versions
|
|
26
|
+
# inconsistent with the live row. Without propagation, Phase 04 bugs
|
|
27
|
+
# would be invisible until someone audited the version table.
|
|
28
|
+
# - User proc: rescued via `rescue StandardError`, logged via
|
|
29
|
+
# `Rails.logger.error`, and SWALLOWED. The Value/Field row is already
|
|
30
|
+
# committed by the time the after_commit fires, so re-raising here would
|
|
31
|
+
# surface a misleading "save failed" error to the caller — the save
|
|
32
|
+
# actually succeeded.
|
|
33
|
+
#
|
|
34
|
+
# ## Out of scope for this module
|
|
35
|
+
#
|
|
36
|
+
# - `:rename` detection happens in `Field`'s after_commit callback (the
|
|
37
|
+
# model has direct access to `saved_change_to_attribute?(:name)`).
|
|
38
|
+
# - Orphan-Value handling (`field.nil?` because the field row was destroyed
|
|
39
|
+
# in the same transaction) is filtered at the model layer, not here. The
|
|
40
|
+
# dispatcher receives a guaranteed-non-nil object.
|
|
41
|
+
#
|
|
42
|
+
# See `.vbw-planning/phases/03-event-system/03-CONTEXT.md` for the locked
|
|
43
|
+
# design decisions this module implements.
|
|
44
|
+
module EventDispatcher
|
|
45
|
+
class << self
|
|
46
|
+
# Internal subscribers for Value lifecycle events. Populated at engine
|
|
47
|
+
# boot by Phase 04 versioning (slot 0) and Phase 07 matview (subsequent
|
|
48
|
+
# slots). Exposed as a reader for test introspection — first-party
|
|
49
|
+
# registration goes through `register_internal_value_change`.
|
|
50
|
+
def value_change_internals
|
|
51
|
+
@value_change_internals ||= []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Internal subscribers for Field lifecycle events. Same registration
|
|
55
|
+
# protocol as `value_change_internals`.
|
|
56
|
+
def field_change_internals
|
|
57
|
+
@field_change_internals ||= []
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Register an in-gem value-change subscriber. Called at engine boot by
|
|
61
|
+
# Phase 04 versioning and Phase 07 matview. Subscribers are invoked in
|
|
62
|
+
# registration order with `(value, change_type, context)`. Exceptions
|
|
63
|
+
# raised here PROPAGATE — fail-closed because versioning corruption
|
|
64
|
+
# must be loud. See module-level comment §"Error policy".
|
|
65
|
+
#
|
|
66
|
+
# NOT private_class_method: Phase 04 lives in TypedEAV::Versioning::*
|
|
67
|
+
# and cannot call a truly-private class method. The `register_internal_*`
|
|
68
|
+
# naming + this comment signal first-party-only intent.
|
|
69
|
+
def register_internal_value_change(callable)
|
|
70
|
+
value_change_internals << callable
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Register an in-gem field-change subscriber. Same first-party-only
|
|
74
|
+
# contract as `register_internal_value_change`. Field subscribers are
|
|
75
|
+
# invoked with `(field, change_type)` — TWO args, no context. The
|
|
76
|
+
# asymmetry vs value-change is locked at 03-CONTEXT.md §Phase Boundary.
|
|
77
|
+
def register_internal_field_change(callable)
|
|
78
|
+
field_change_internals << callable
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Dispatch a value lifecycle event. Called from `Value#after_commit` in
|
|
82
|
+
# plan 03-02. Internals fire FIRST (raises propagate), then the user
|
|
83
|
+
# proc fires LAST (errors logged + swallowed).
|
|
84
|
+
#
|
|
85
|
+
# Signature: `(value, change_type, TypedEAV.current_context)` for both
|
|
86
|
+
# internals and user proc — context is injected here, not by callers.
|
|
87
|
+
# `change_type` is one of `:create | :update | :destroy`.
|
|
88
|
+
def dispatch_value_change(value, change_type)
|
|
89
|
+
context = TypedEAV.current_context
|
|
90
|
+
# Internals fire first, in registration order. Exceptions propagate —
|
|
91
|
+
# versioning failure (Phase 04) must surface, never be silent.
|
|
92
|
+
value_change_internals.each { |cb| cb.call(value, change_type, context) }
|
|
93
|
+
|
|
94
|
+
user = TypedEAV::Config.on_value_change
|
|
95
|
+
return unless user
|
|
96
|
+
|
|
97
|
+
# User proc fires last. Wrapped in rescue because the Value row is
|
|
98
|
+
# already committed — re-raising would surface a misleading "save
|
|
99
|
+
# failed" error to the caller. Internal-vs-user error policy split
|
|
100
|
+
# is locked at 03-CONTEXT.md §User-callback error policy.
|
|
101
|
+
begin
|
|
102
|
+
user.call(value, change_type, context)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Rails.logger.error(
|
|
105
|
+
"[TypedEAV] on_value_change raised: #{e.class}: #{e.message} " \
|
|
106
|
+
"(value_id=#{value.id} field_id=#{value.field_id} change_type=#{change_type})",
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Dispatch a field lifecycle event. Called from `Field#after_commit` in
|
|
112
|
+
# plan 03-02. Same internals-first / user-last ordering and same error
|
|
113
|
+
# policy split as `dispatch_value_change`.
|
|
114
|
+
#
|
|
115
|
+
# Signature: `(field, change_type)` — TWO args, no context. Field
|
|
116
|
+
# changes are CRUD-on-config (admin operations on field definitions),
|
|
117
|
+
# not per-entity user actions, so thread context is less relevant.
|
|
118
|
+
# Asymmetry vs `dispatch_value_change` is intentional and locked.
|
|
119
|
+
# `change_type` is one of `:create | :update | :destroy | :rename`.
|
|
120
|
+
def dispatch_field_change(field, change_type)
|
|
121
|
+
field_change_internals.each { |cb| cb.call(field, change_type) }
|
|
122
|
+
|
|
123
|
+
user = TypedEAV::Config.on_field_change
|
|
124
|
+
return unless user
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
user.call(field, change_type)
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
Rails.logger.error(
|
|
130
|
+
"[TypedEAV] on_field_change raised: #{e.class}: #{e.message} " \
|
|
131
|
+
"(field_id=#{field.id} field_name=#{field.name} change_type=#{change_type})",
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Clear ONLY the internal-subscribers arrays. Does NOT touch
|
|
137
|
+
# `Config.on_value_change` / `Config.on_field_change` — `Config.reset!`
|
|
138
|
+
# owns the user-proc state.
|
|
139
|
+
#
|
|
140
|
+
# Splitting reset is load-bearing: Phase 04 versioning registers on the
|
|
141
|
+
# internal list at engine load. Calling `EventDispatcher.reset!` must
|
|
142
|
+
# NOT require re-running engine load to restore versioning. Test
|
|
143
|
+
# teardown that needs to clear EVERYTHING calls Config.reset! AND
|
|
144
|
+
# EventDispatcher.reset! — see 03-CONTEXT.md §"Reset split".
|
|
145
|
+
def reset!
|
|
146
|
+
@value_change_internals = []
|
|
147
|
+
@field_change_internals = []
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
# One field-owned seam for native typed-column storage behavior.
|
|
5
|
+
#
|
|
6
|
+
# The contract intentionally delegates to the existing field type hooks so
|
|
7
|
+
# custom field authors keep the same public extension points while callers
|
|
8
|
+
# stop knowing which pieces belong together.
|
|
9
|
+
class FieldStorageContract
|
|
10
|
+
def initialize(field)
|
|
11
|
+
@field = field
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def value_columns
|
|
15
|
+
field.class.value_columns
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def query_column(operator)
|
|
19
|
+
field.class.operator_column(operator)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def read(value_record)
|
|
23
|
+
field.read_value(value_record)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def write(value_record, casted)
|
|
27
|
+
field.write_value(value_record, casted)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def apply_default(value_record)
|
|
31
|
+
field.apply_default_to(value_record)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def changed?(value_record)
|
|
35
|
+
value_columns.any? { |column| value_record.saved_change_to_attribute?(column) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def before_snapshot(value_record, change_type)
|
|
39
|
+
case change_type.to_sym
|
|
40
|
+
when :create
|
|
41
|
+
{}
|
|
42
|
+
when :update
|
|
43
|
+
value_columns.to_h do |column|
|
|
44
|
+
[column.to_s, value_record.attribute_before_last_save(column.to_s)]
|
|
45
|
+
end
|
|
46
|
+
when :destroy
|
|
47
|
+
value_columns.to_h { |column| [column.to_s, value_record[column]] }
|
|
48
|
+
else
|
|
49
|
+
raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def after_snapshot(value_record, change_type)
|
|
54
|
+
case change_type.to_sym
|
|
55
|
+
when :create, :update
|
|
56
|
+
value_columns.to_h { |column| [column.to_s, value_record[column]] }
|
|
57
|
+
when :destroy
|
|
58
|
+
{}
|
|
59
|
+
else
|
|
60
|
+
raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
attr_reader :field
|
|
67
|
+
end
|
|
68
|
+
end
|