typed_eav 0.7.0 → 0.8.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.
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module HasTypedEAV
5
+ # Read-only, on-demand dirty tracking for typed Value rows that are part
6
+ # of a host association's in-memory target.
7
+ #
8
+ # Pending comparisons follow Active Record's own dirty state; saved
9
+ # snapshots use the host lifecycle, not a separate Value mutation registry.
10
+ # A typed Value
11
+ # retains its database snapshot through `attribute_in_database`, while
12
+ # autosave clears its ordinary dirty state after a successful host save.
13
+ # Consequently failed saves and outer transaction rollbacks retain the
14
+ # same correction-friendly state that Active Record exposes on the child.
15
+ #
16
+ # The association target is inspected without loading it. Named and
17
+ # nested assignment paths already place their touched Values in that
18
+ # target; callers using direct Value assignment should use a loaded
19
+ # `typed_values` association. A Value loaded independently from the host
20
+ # is intentionally outside this API because observing it would require an
21
+ # eager query of every Value row or a new global mutation registry.
22
+ module DirtyTracking
23
+ extend ActiveSupport::Concern
24
+
25
+ included do
26
+ # Register before has_typed_eav declares the autosave association.
27
+ # Active Model prepends after_* callbacks, so the later autosave
28
+ # callback executes after this capture. The public save wrappers
29
+ # restore prior state when a later save callback raises.
30
+ after_create :_typed_eav_capture_saved_changes
31
+ after_update :_typed_eav_capture_saved_changes
32
+ after_destroy :_typed_eav_clear_saved_changes
33
+ end
34
+
35
+ # Pending typed-value changes keyed by effective Field name.
36
+ #
37
+ # The returned hash and pairs are fresh objects. Before-values are
38
+ # reconstructed from the stored physical cells through the Field's
39
+ # public logical reader, so multi-cell fields retain the same shape as
40
+ # `typed_eav_value` rather than exposing storage-column details.
41
+ def typed_eav_changes
42
+ values = typed_eav_pending_values
43
+ return {} if values.empty?
44
+
45
+ names_by_field_id = typed_eav_effective_field_names
46
+ changes_by_field_id = typed_eav_change_pairs(values, names_by_field_id)
47
+
48
+ changes_by_field_id.each_with_object({}) do |(field_id, pair), changes|
49
+ name = names_by_field_id[field_id]
50
+ next unless name
51
+ next if pair[0] == pair[1]
52
+
53
+ changes[name] = pair.map(&:deep_dup)
54
+ end
55
+ end
56
+
57
+ # Logical changes from the most recent successful host save. The value
58
+ # is intentionally available in host `after_save` callbacks, where the
59
+ # child autosave has already consumed ordinary dirty state. This is
60
+ # successful-save state, not evidence that an outer transaction later
61
+ # committed and not a replacement for ValueVersion history.
62
+ def saved_typed_eav_changes
63
+ (@typed_eav_saved_changes || {}).deep_dup
64
+ end
65
+
66
+ # Bracket the complete public save call, including callbacks that run
67
+ # after the model's create/update callbacks. An around_save callback
68
+ # cannot rescue an after_save callback that is compiled outside its
69
+ # around sequence, but these wrappers can restore the previous snapshot
70
+ # for both false returns and raised errors.
71
+ def save(...)
72
+ _typed_eav_track_save { super }
73
+ end
74
+
75
+ def save!(...)
76
+ _typed_eav_track_save { super }
77
+ end
78
+
79
+ private
80
+
81
+ # Capture from the host lifecycle before autosave runs. Because this
82
+ # callback was registered before the association's autosave callback,
83
+ # before_save mutations and marked-for-destruction Values are still
84
+ # observable here.
85
+ def _typed_eav_capture_saved_changes
86
+ # A record-level rollback callback can be skipped after a later
87
+ # failed validation; use Rails' transaction-level callback instead.
88
+ self.class.current_transaction.after_rollback { _typed_eav_clear_saved_changes }
89
+ @typed_eav_saved_changes = typed_eav_changes.deep_dup
90
+ end
91
+
92
+ # Keep failed saves from replacing the last successful result. A normal
93
+ # return of false is handled here as well as exceptions raised by save
94
+ # callbacks or autosave. If the failed save opened a transaction that
95
+ # rolled back, Active Record may have cleared the snapshot before this
96
+ # wrapper resumes; restoring `previous` here preserves the failed-save
97
+ # contract. A successful save leaves the new snapshot in place until a
98
+ # later outer rollback callback clears it.
99
+ def _typed_eav_track_save
100
+ previous = @typed_eav_saved_changes
101
+ result = yield
102
+
103
+ @typed_eav_saved_changes = previous unless result
104
+ result
105
+ rescue StandardError
106
+ @typed_eav_saved_changes = previous
107
+ raise
108
+ end
109
+
110
+ def _typed_eav_clear_saved_changes
111
+ @typed_eav_saved_changes = {}
112
+ end
113
+
114
+ public
115
+
116
+ # Reload replaces the host's association target. Clear the separate
117
+ # saved snapshot at the same boundary so it cannot outlive the state it
118
+ # describes.
119
+ def reload(...)
120
+ super.tap { _typed_eav_clear_saved_changes }
121
+ end
122
+
123
+ private
124
+
125
+ # Reading `target` is the important lazy boundary: calling the public
126
+ # dirty API on an untouched host never issues a typed_values SELECT.
127
+ # `build` adds to target without marking the association loaded, so the
128
+ # direct-association path is covered as well.
129
+ def typed_eav_pending_values
130
+ typed_values.target.select do |value|
131
+ next false if value.destroyed?
132
+ next false if value.new_record? && value.marked_for_destruction?
133
+
134
+ typed_eav_value_pending?(value)
135
+ end
136
+ end
137
+
138
+ def typed_eav_value_pending?(value)
139
+ return true if value.marked_for_destruction? && value.persisted?
140
+ return false unless value.changed?
141
+
142
+ field = value.field
143
+ return false unless field
144
+
145
+ field.class.value_columns.any? do |column|
146
+ value.will_save_change_to_attribute?(column)
147
+ end
148
+ end
149
+
150
+ # Resolve names only after finding a changed Value. This keeps ordinary
151
+ # reads and saves free of definition queries while preserving the same
152
+ # collision precedence used by `typed_eav_value` and `typed_eav_hash`.
153
+ def typed_eav_effective_field_names
154
+ typed_eav_defs_by_name.each_with_object({}) do |(name, field), names|
155
+ names[field.id] = name
156
+ end
157
+ end
158
+
159
+ def typed_eav_change_pairs(values, names_by_field_id)
160
+ values.each_with_object({}) do |value, changes|
161
+ change = typed_eav_change_for(value, names_by_field_id)
162
+ next unless change
163
+
164
+ field_id, pair = change
165
+ merge_typed_eav_change(changes, field_id, pair)
166
+ end
167
+ end
168
+
169
+ def typed_eav_change_for(value, names_by_field_id)
170
+ field = value.field
171
+ field_id = value.field_id || field&.id
172
+ return unless field && field_id && names_by_field_id.key?(field_id)
173
+
174
+ before = typed_eav_before_value(value, field)
175
+ after = value.marked_for_destruction? ? nil : field.read_value(value)
176
+ return if before == after
177
+
178
+ [field_id, [before, after]]
179
+ end
180
+
181
+ # One Value row per field is the normal shape. Keep the first non-nil
182
+ # before-state when a target temporarily contains both an old marked row
183
+ # and a replacement build; this preserves the original persisted
184
+ # baseline while the final target row supplies the after-state.
185
+ def merge_typed_eav_change(changes, field_id, pair)
186
+ return changes[field_id] = pair unless changes.key?(field_id)
187
+
188
+ changes[field_id][0] = pair[0] if changes[field_id][0].nil? && !pair[0].nil?
189
+ changes[field_id][1] = pair[1]
190
+ end
191
+
192
+ # Use the original stored cells as a lightweight Value snapshot and
193
+ # invoke the Field's logical reader. This is intentionally not a second
194
+ # representation of multi-cell values; Field#read_value remains the
195
+ # single source of truth for both current and prior values.
196
+ def typed_eav_before_value(value, field)
197
+ return nil if value.new_record?
198
+
199
+ snapshot = value.dup
200
+ field.class.value_columns.each do |column|
201
+ snapshot[column] = value.attribute_in_database(column)
202
+ end
203
+ field.read_value(snapshot)
204
+ end
205
+ end
206
+ end
207
+ end
@@ -262,7 +262,7 @@ module TypedEAV
262
262
  # because each record's INSERT clears the cache — so cache-do alone
263
263
  # cannot keep field-definition reads N+1-free across the bulk loop.
264
264
  # The thread-local memo is the explicit fallback documented in plan
265
- # 06-05 §T3 notes; it pre-warms once per `[host_class, scope,
265
+ # 06-05 §T3 notes; it pre-warms once per `[polymorphic_name, scope,
266
266
  # parent_scope]` tuple and reuses across every record in that tuple.
267
267
  #
268
268
  # Outside a bulk operation the memo is nil and we fall through to
@@ -270,7 +270,7 @@ module TypedEAV
270
270
  def typed_eav_defs_by_name
271
271
  memo = Thread.current[:typed_eav_bulk_defs_memo]
272
272
  if memo
273
- key = [self.class.name, typed_eav_scope, typed_eav_parent_scope]
273
+ key = [self.class.polymorphic_name, typed_eav_scope, typed_eav_parent_scope]
274
274
  memo[key] ||= TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
275
275
  else
276
276
  TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
@@ -31,15 +31,18 @@ module TypedEAV
31
31
  # ## Architecture (ADR-0002, 0.3.0 refactor)
32
32
  #
33
33
  # This file holds the macro entry + macro-time guards. Per-record API
34
- # lives in `TypedEAV::HasTypedEAV::InstanceMethods`. Class-level query
34
+ # lives in `TypedEAV::HasTypedEAV::InstanceMethods`, with pending/saved
35
+ # change tracking in `TypedEAV::HasTypedEAV::DirtyTracking`. Class-level query
35
36
  # orchestration lives in `TypedEAV::EntityQuery` (extended onto the host
36
37
  # class), which delegates the heavy lifting to `TypedEAV::FilterQuery`
37
- # (where_typed_eav) and `TypedEAV::BulkRead` (typed_eav_hash_for).
38
+ # (where_typed_eav), `TypedEAV::ScalarQuery` (ordering/summaries), and
39
+ # `TypedEAV::BulkRead` (typed_eav_hash_for).
38
40
  # `bulk_set_typed_eav_values` continues to delegate to `TypedEAV::BulkWrite`.
39
41
  module HasTypedEAV
40
42
  extend ActiveSupport::Concern
41
43
 
42
44
  autoload :InstanceMethods, "typed_eav/has_typed_eav/instance_methods"
45
+ autoload :DirtyTracking, "typed_eav/has_typed_eav/dirty_tracking"
43
46
 
44
47
  class_methods do
45
48
  # Register this model as having typed fields.
@@ -82,6 +85,7 @@ module TypedEAV
82
85
  default: types && types.map(&:to_s).freeze
83
86
 
84
87
  include InstanceMethods
88
+ include DirtyTracking
85
89
  extend TypedEAV::EntityQuery
86
90
 
87
91
  has_many :typed_values,
@@ -93,7 +97,7 @@ module TypedEAV
93
97
 
94
98
  accepts_nested_attributes_for :typed_values, allow_destroy: true
95
99
 
96
- TypedEAV.registry.register(name, types: types, versioned: versioned)
100
+ TypedEAV.registry.register(polymorphic_name, types: types, versioned: versioned)
97
101
  end
98
102
 
99
103
  private
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Partition
5
+ # Internal batched resolver for field definitions visible to many exact
6
+ # partition tuples. It performs one definition SELECT, then applies the
7
+ # same global -> scope -> full-tuple precedence as `definitions_by_name`
8
+ # independently for every requested tuple.
9
+ class DefinitionBatch
10
+ class << self
11
+ def resolve(entity_type:, tuples:)
12
+ new(entity_type: entity_type, tuples: tuples).resolve
13
+ end
14
+ end
15
+
16
+ def initialize(entity_type:, tuples:)
17
+ @entity_type = entity_type
18
+ @tuples = tuples.uniq
19
+ end
20
+
21
+ def resolve
22
+ tuples.each { |scope, parent_scope| validate_tuple!(scope, parent_scope) }
23
+ return {} if tuples.empty?
24
+
25
+ definitions_by_tuple = load_definitions.group_by do |definition|
26
+ [definition.scope, definition.parent_scope]
27
+ end
28
+
29
+ tuples.to_h do |tuple|
30
+ visible = candidate_tuples(tuple).flat_map { |candidate| definitions_by_tuple.fetch(candidate, []) }
31
+ [tuple, TypedEAV::Partition.definitions_by_name(visible)]
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :entity_type, :tuples
38
+
39
+ def load_definitions
40
+ requested = tuples.map { |scope, parent_scope| { "scope" => scope, "parent_scope" => parent_scope } }
41
+ TypedEAV::Field::Base.where(entity_type: entity_type).where(<<~SQL.squish, requested.to_json).to_a
42
+ typed_eav_fields.scope IS NULL AND typed_eav_fields.parent_scope IS NULL
43
+ OR EXISTS (
44
+ SELECT 1
45
+ FROM jsonb_to_recordset(?::jsonb) AS requested(scope text, parent_scope text)
46
+ WHERE (
47
+ typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
48
+ AND typed_eav_fields.parent_scope IS NOT DISTINCT FROM requested.parent_scope
49
+ )
50
+ OR (
51
+ typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
52
+ AND typed_eav_fields.parent_scope IS NULL
53
+ )
54
+ )
55
+ SQL
56
+ end
57
+
58
+ def candidate_tuples(tuple)
59
+ scope, parent_scope = tuple
60
+ [[nil, nil], [scope, nil], [scope, parent_scope]].uniq
61
+ end
62
+
63
+ def validate_tuple!(scope, parent_scope)
64
+ return if TypedEAV::ScopeTuple.invariant_satisfied?(scope, parent_scope)
65
+
66
+ raise ArgumentError, "parent_scope cannot be set when scope is blank"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -8,6 +8,8 @@ module TypedEAV
8
8
  # values. Ambient resolution (`TypedEAV.current_scope`, `with_scope`,
9
9
  # `unscoped`) stays with the adapters that know their calling context.
10
10
  module Partition
11
+ autoload :DefinitionBatch, "typed_eav/partition/definition_batch"
12
+
11
13
  # Frozen orphan-parent ArgumentError message. Kept as a module constant
12
14
  # so both `visible_fields` and `visible_sections` raise the same string
13
15
  # without re-allocating per call. The string is the wire-stable BC error
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # SQL-backed operations over one scalar typed field.
5
+ #
6
+ # Entity-level wrappers resolve ambient/explicit scope and preserve Rails'
7
+ # current relation delegation. This object owns the field-definition lookup,
8
+ # scalar support gate, and correlated value expression so no host or Value
9
+ # records are hydrated merely to order a relation.
10
+ class ScalarQuery
11
+ SCALAR_COLUMNS = %i[
12
+ boolean_value
13
+ date_value
14
+ datetime_value
15
+ decimal_value
16
+ integer_value
17
+ string_value
18
+ text_value
19
+ ].freeze
20
+ DIRECTIONS = %i[asc desc].freeze
21
+ NULL_PLACEMENTS = %i[first last].freeze
22
+ AGGREGATE_OPERATIONS = %i[min max sum].freeze
23
+
24
+ # rubocop:disable Metrics/ParameterLists -- the query object receives the resolved public API inputs explicitly.
25
+ def initialize(model:, name:, scope:, parent_scope:, direction: :asc, nulls: :last)
26
+ @model = model
27
+ @name = normalize_name(name)
28
+ @direction = normalize_option(direction, DIRECTIONS, "direction")
29
+ @nulls = normalize_option(nulls, NULL_PLACEMENTS, "nulls")
30
+ @scope = scope
31
+ @parent_scope = parent_scope
32
+ end
33
+ # rubocop:enable Metrics/ParameterLists
34
+
35
+ def order_relation
36
+ raise_all_scopes!
37
+
38
+ field = field_for_name
39
+ column = scalar_column_for(field)
40
+ relation = @model.all
41
+ host_table = relation.arel_table
42
+ value_table = TypedEAV::Value.arel_table
43
+ value_query = value_subquery(value_table, host_table, column, field)
44
+
45
+ relation.reorder(
46
+ Arel.sql("(#{value_query.to_sql}) #{@direction.to_s.upcase} NULLS #{@nulls.to_s.upcase}"),
47
+ host_table[@model.primary_key].asc,
48
+ )
49
+ end
50
+
51
+ def distinct_values(limit:)
52
+ raise_all_scopes!
53
+
54
+ field = field_for_name
55
+ column = scalar_column_for(field)
56
+ relation = value_relation(field).select(column).distinct
57
+ relation = relation.order(Arel.sql("#{quoted_value_column(column)} ASC NULLS LAST"))
58
+
59
+ relation.limit(validate_limit(limit)).pluck(column)
60
+ end
61
+
62
+ def count_distinct_values
63
+ raise_all_scopes!
64
+
65
+ field = field_for_name
66
+ column = scalar_column_for(field)
67
+ distinct_relation = value_relation(field).select(column).distinct
68
+ aliased_relation = TypedEAV::Value.from(
69
+ "(#{distinct_relation.to_sql}) #{quoted_table_name("typed_eav_distinct_values")}",
70
+ )
71
+
72
+ aliased_relation.count
73
+ end
74
+
75
+ def value_counts(limit:)
76
+ raise_all_scopes!
77
+
78
+ field = field_for_name
79
+ column = scalar_column_for(field)
80
+ count_sql = Arel.sql("COUNT(DISTINCT #{quoted_value_column(:entity_id)})")
81
+
82
+ value_relation(field)
83
+ .group(column)
84
+ .order(Arel.sql("#{quoted_value_column(column)} ASC NULLS LAST"))
85
+ .limit(validate_limit(limit))
86
+ .pluck(column, count_sql)
87
+ .to_h
88
+ end
89
+
90
+ def aggregate(operation:)
91
+ raise_all_scopes!
92
+
93
+ operation = normalize_option(operation, AGGREGATE_OPERATIONS, "operation")
94
+ field = field_for_name
95
+ column = numeric_column_for(field)
96
+ relation = value_relation(field)
97
+
98
+ case operation
99
+ when :min then relation.minimum(column)
100
+ when :max then relation.maximum(column)
101
+ when :sum then relation.sum(column)
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def normalize_name(name)
108
+ unless name.is_a?(String) || name.is_a?(Symbol)
109
+ raise ArgumentError, "typed field name must be a non-empty String or Symbol"
110
+ end
111
+
112
+ normalized = name.to_s
113
+ return normalized if normalized.present?
114
+
115
+ raise ArgumentError, "typed field name must be a non-empty String or Symbol"
116
+ end
117
+
118
+ def normalize_option(value, allowed, label)
119
+ normalized = value.to_sym if value.is_a?(String) || value.is_a?(Symbol)
120
+ return normalized if allowed.include?(normalized)
121
+
122
+ formatted = allowed.map { |option| ":#{option}" }.join(", ")
123
+ raise ArgumentError, "typed field #{label} must be one of #{formatted}"
124
+ end
125
+
126
+ def raise_all_scopes!
127
+ return unless @scope.equal?(TypedEAV::EntityQuery::ALL_SCOPES)
128
+
129
+ raise ArgumentError,
130
+ "typed scalar queries across all partitions are ambiguous; leave `TypedEAV.unscoped` " \
131
+ "and select an explicit scope"
132
+ end
133
+
134
+ def field_for_name
135
+ fields = TypedEAV::Partition.visible_fields(
136
+ entity_type: @model.polymorphic_name,
137
+ scope: @scope,
138
+ parent_scope: @parent_scope,
139
+ )
140
+ field = TypedEAV::Partition.definitions_by_name(fields)[@name]
141
+ return field if field
142
+
143
+ raise ArgumentError,
144
+ "Unknown typed field '#{@name}' for #{@model.name}. " \
145
+ "Available fields: #{TypedEAV::Partition.definitions_by_name(fields).keys.join(", ")}"
146
+ end
147
+
148
+ def scalar_column_for(field)
149
+ columns = field.class.value_columns
150
+ if columns.length != 1 || SCALAR_COLUMNS.exclude?(columns.first)
151
+ raise ArgumentError,
152
+ "Typed field '#{field.name}' (#{field.field_type_name}) is not a supported scalar field for typed queries"
153
+ end
154
+
155
+ columns.first
156
+ rescue NotImplementedError
157
+ raise ArgumentError,
158
+ "Typed field '#{field.name}' (#{field.field_type_name}) is not a supported scalar field for typed queries"
159
+ end
160
+
161
+ def numeric_column_for(field)
162
+ unless field.is_a?(TypedEAV::Field::Integer) || field.is_a?(TypedEAV::Field::Decimal)
163
+ raise ArgumentError,
164
+ "Typed field '#{field.name}' (#{field.field_type_name}) is not a supported numeric field"
165
+ end
166
+
167
+ columns = field.class.value_columns
168
+ if columns.length != 1 || %i[decimal_value integer_value].exclude?(columns.first)
169
+ raise ArgumentError,
170
+ "Typed field '#{field.name}' (#{field.field_type_name}) is not a supported numeric field"
171
+ end
172
+
173
+ columns.first
174
+ rescue NotImplementedError
175
+ raise ArgumentError,
176
+ "Typed field '#{field.name}' (#{field.field_type_name}) is not a supported numeric field"
177
+ end
178
+
179
+ def value_relation(field)
180
+ TypedEAV::Value.where(
181
+ field_id: field.id,
182
+ entity_type: @model.polymorphic_name,
183
+ entity_id: host_id_relation,
184
+ )
185
+ end
186
+
187
+ def host_id_relation
188
+ # `all` is intentional: EntityQuery's relation delegation exposes the
189
+ # caller relation through the model's current scope at this boundary.
190
+ # rubocop:disable Rails/RedundantActiveRecordAllMethod
191
+ relation = @model.all.unscope(:select)
192
+ # rubocop:enable Rails/RedundantActiveRecordAllMethod
193
+ alias_name = "typed_eav_host_ids"
194
+ qualified_primary_key = "#{quoted_table_name(alias_name)}.#{quoted_column_name(@model.primary_key)}"
195
+ wrapped_sql = "(#{relation.to_sql}) #{quoted_table_name(alias_name)}"
196
+
197
+ @model.base_class.unscoped.from(wrapped_sql).select(Arel.sql(qualified_primary_key))
198
+ end
199
+
200
+ def quoted_value_column(column)
201
+ "#{quoted_table_name(TypedEAV::Value.table_name)}.#{quoted_column_name(column)}"
202
+ end
203
+
204
+ def quoted_table_name(table_name)
205
+ TypedEAV::Value.connection.quote_table_name(table_name)
206
+ end
207
+
208
+ def quoted_column_name(column_name)
209
+ TypedEAV::Value.connection.quote_column_name(column_name)
210
+ end
211
+
212
+ def validate_limit(limit)
213
+ unless limit.is_a?(Integer) && limit.positive? && limit <= 1_000
214
+ raise ArgumentError, "typed scalar query limit must be a positive Integer no greater than 1000"
215
+ end
216
+
217
+ limit
218
+ end
219
+
220
+ def value_subquery(value_table, host_table, column, field)
221
+ predicate = value_table[:field_id].eq(field.id)
222
+ .and(value_table[:entity_type].eq(@model.polymorphic_name))
223
+ .and(value_table[:entity_id].eq(host_table[@model.primary_key]))
224
+
225
+ value_table.project(value_table[column]).where(predicate)
226
+ end
227
+ end
228
+ end