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,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