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,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module SchemaPortability
5
+ # Read-only comparator for the portable schema wire format. This class is
6
+ # intentionally separate from import_schema: a preview must not run
7
+ # validations, callbacks, jobs, conversions, or a dry-run transaction.
8
+ # Each incoming entry must carry the exact entity_type/scope/parent_scope
9
+ # identity declared by the envelope; preview does not retarget entries.
10
+ # rubocop:disable Metrics/ClassLength -- the validator and comparator form
11
+ # one small, private implementation behind the single preview API.
12
+ class Preview
13
+ FIELD_IDENTITY_KEYS = %w[name entity_type scope parent_scope].freeze
14
+ SECTION_IDENTITY_KEYS = %w[code entity_type scope parent_scope].freeze
15
+ OPTION_KEYS = %w[label value sort_order].freeze
16
+ CONFLICT_STATUSES = %w[changed conflict].freeze
17
+ def initialize(hash, on_conflict:)
18
+ @hash = hash
19
+ @on_conflict = on_conflict
20
+ end
21
+
22
+ def call
23
+ validate_input!
24
+
25
+ fields = @hash["fields"]
26
+ sections = @hash["sections"]
27
+ import_index = ImportIndex.new(fields, sections)
28
+ field_previews = preview_fields(fields, import_index)
29
+ section_previews = preview_sections(sections, import_index)
30
+ entries = field_previews + section_previews
31
+
32
+ {
33
+ "schema_version" => @hash["schema_version"],
34
+ "entity_type" => @hash["entity_type"],
35
+ "scope" => @hash["scope"],
36
+ "parent_scope" => @hash["parent_scope"],
37
+ "on_conflict" => @on_conflict.to_s,
38
+ "importable" => entries.none? { |entry| entry["action"] == "error" },
39
+ "summary" => summary_for(entries),
40
+ "fields" => field_previews,
41
+ "sections" => section_previews,
42
+ "risks" => entries.flat_map { |entry| entry["risks"] }.uniq.sort,
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ def validate_input!
49
+ raise ArgumentError, "schema preview expects a Hash export" unless @hash.is_a?(Hash)
50
+
51
+ SchemaPortability.send(:validate_schema_version!, @hash)
52
+ SchemaPortability.send(:validate_conflict_policy!, @on_conflict)
53
+
54
+ required_keys = %w[entity_type scope parent_scope fields sections]
55
+ missing_keys = required_keys.reject { |key| @hash.key?(key) }
56
+ unless missing_keys.empty?
57
+ raise ArgumentError, "Schema preview is missing required keys: #{missing_keys.join(", ")}"
58
+ end
59
+
60
+ validate_target!
61
+ validate_entries!(@hash["fields"], FIELD_IDENTITY_KEYS, "field") do |entry|
62
+ validate_field_entry!(entry)
63
+ end
64
+ validate_entries!(@hash["sections"], SECTION_IDENTITY_KEYS, "section") do |entry|
65
+ validate_section_entry!(entry)
66
+ end
67
+ end
68
+
69
+ def validate_target!
70
+ unless @hash["entity_type"].is_a?(String) && @hash["entity_type"].present?
71
+ raise ArgumentError, "Schema preview entity_type must be a non-empty String"
72
+ end
73
+
74
+ validate_scope_slot!(@hash["scope"], "scope")
75
+ validate_scope_slot!(@hash["parent_scope"], "parent_scope")
76
+ return if TypedEAV::ScopeTuple.invariant_satisfied?(@hash["scope"], @hash["parent_scope"])
77
+
78
+ raise ArgumentError, "Schema preview target parent_scope requires a non-blank scope"
79
+ end
80
+
81
+ def validate_scope_slot!(value, name)
82
+ return if value.nil? || value.is_a?(String)
83
+
84
+ raise ArgumentError, "Schema preview #{name} must be a String or nil"
85
+ end
86
+
87
+ def validate_entries!(entries, identity_keys, kind)
88
+ raise ArgumentError, "Schema preview #{kind}s must be an Array" unless entries.is_a?(Array)
89
+
90
+ seen = {}
91
+ entries.each do |entry|
92
+ validate_entry_identity!(entry, identity_keys, kind, seen)
93
+ yield(entry) if block_given?
94
+ end
95
+ end
96
+
97
+ def validate_entry_identity!(entry, identity_keys, kind, seen)
98
+ raise ArgumentError, "Schema preview #{kind} entries must be Hashes" unless entry.is_a?(Hash)
99
+
100
+ validate_identity_keys!(entry, identity_keys, kind)
101
+ validate_identity_values!(entry, identity_keys, kind)
102
+ identity = entry.values_at(*identity_keys)
103
+ raise ArgumentError, "Duplicate #{kind} identity in schema preview: #{identity.inspect}" if seen.key?(identity)
104
+
105
+ seen[identity] = true
106
+ end
107
+
108
+ def validate_identity_keys!(entry, identity_keys, kind)
109
+ missing_keys = identity_keys.reject { |key| entry.key?(key) }
110
+ return if missing_keys.empty?
111
+
112
+ raise ArgumentError,
113
+ "Schema preview #{kind} entry is missing identity keys: #{missing_keys.join(", ")}"
114
+ end
115
+
116
+ def validate_identity_values!(entry, identity_keys, kind)
117
+ name_key = identity_keys.first
118
+ unless entry[name_key].is_a?(String) && entry[name_key].present?
119
+ raise ArgumentError, "Schema preview #{kind} #{name_key} must be a non-empty String"
120
+ end
121
+ return if entry.values_at("entity_type", "scope", "parent_scope") ==
122
+ @hash.values_at("entity_type", "scope", "parent_scope") &&
123
+ TypedEAV::ScopeTuple.invariant_satisfied?(entry["scope"], entry["parent_scope"])
124
+
125
+ raise ArgumentError,
126
+ "Schema preview #{kind} entry #{entry[name_key].inspect} identity must match " \
127
+ "the declared entity_type/scope/parent_scope target"
128
+ end
129
+
130
+ def validate_field_entry!(entry)
131
+ validate_field_type!(entry)
132
+ validate_option_rows!(entry) if entry.key?("options_data")
133
+ end
134
+
135
+ def validate_section_entry!(entry)
136
+ return if entry["name"].is_a?(String) && entry["name"].present?
137
+
138
+ raise ArgumentError, "Schema preview section #{entry["code"].inspect} name must be a non-empty String"
139
+ end
140
+
141
+ def validate_field_type!(entry)
142
+ type = entry["type"]
143
+ unless type.is_a?(String) && type.present?
144
+ raise ArgumentError, "Schema preview field #{entry["name"].inspect} type must be a non-empty String"
145
+ end
146
+
147
+ type_class = type.safe_constantize
148
+ return if type_class.is_a?(Class) && type_class <= TypedEAV::Field::Base
149
+
150
+ raise ArgumentError,
151
+ "Schema preview field #{entry["name"].inspect} type #{type.inspect} " \
152
+ "must be a TypedEAV::Field::Base subclass"
153
+ end
154
+
155
+ def validate_option_rows!(entry)
156
+ options = entry["options_data"]
157
+ unless options.is_a?(Array)
158
+ raise ArgumentError,
159
+ "Schema preview field #{entry["name"].inspect} options_data must be an Array"
160
+ end
161
+
162
+ seen_values = {}
163
+ options.each do |option|
164
+ validate_option_row!(entry, option)
165
+
166
+ value = option["value"]
167
+ if seen_values.key?(value)
168
+ raise ArgumentError,
169
+ "Duplicate option value #{value.inspect} for field #{entry["name"].inspect} in schema preview"
170
+ end
171
+
172
+ seen_values[value] = true
173
+ end
174
+ end
175
+
176
+ # rubocop:disable Metrics/AbcSize -- validation errors stay adjacent to
177
+ # the wire-format checks so malformed payloads fail before any query.
178
+ def validate_option_row!(entry, option)
179
+ unless option.is_a?(Hash)
180
+ raise ArgumentError, "Schema preview field #{entry["name"].inspect} option rows must be Hashes"
181
+ end
182
+
183
+ missing_keys = OPTION_KEYS.reject { |key| option.key?(key) }
184
+ unless missing_keys.empty?
185
+ raise ArgumentError,
186
+ "Schema preview field #{entry["name"].inspect} option is missing key #{missing_keys.first.inspect}"
187
+ end
188
+ unless option["label"].is_a?(String) && option["label"].present?
189
+ raise ArgumentError,
190
+ "Schema preview field #{entry["name"].inspect} option label must be a non-empty String"
191
+ end
192
+ unless option["value"].is_a?(String) && option["value"].present?
193
+ raise ArgumentError,
194
+ "Schema preview field #{entry["name"].inspect} option value must be a non-empty String"
195
+ end
196
+ return if option["sort_order"].nil? || option["sort_order"].is_a?(Integer)
197
+
198
+ raise ArgumentError,
199
+ "Schema preview field #{entry["name"].inspect} option sort_order must be an Integer or nil"
200
+ end
201
+ # rubocop:enable Metrics/AbcSize
202
+
203
+ def preview_fields(entries, import_index)
204
+ entries.map { |entry| preview_field(entry, import_index.fields[field_identity(entry)]) }
205
+ end
206
+
207
+ def preview_sections(entries, import_index)
208
+ entries.map { |entry| preview_section(entry, import_index.sections[section_identity(entry)]) }
209
+ end
210
+
211
+ def preview_field(entry, existing)
212
+ identity = identity_hash(entry, "name")
213
+ options = existing ? option_diff(existing_entry(existing), entry) : option_diff({}, entry)
214
+ return added_preview(identity, options) unless existing
215
+
216
+ current = existing_entry(existing)
217
+ changes = field_changes(current, entry)
218
+ risks = field_risks(current, entry, options)
219
+ status = field_status(current, entry)
220
+ action = field_action(status)
221
+
222
+ entry_preview(
223
+ identity,
224
+ status: status,
225
+ action: action,
226
+ changes: changes,
227
+ options: options,
228
+ risks: risks,
229
+ )
230
+ end
231
+
232
+ def field_status(current, incoming)
233
+ return "conflict" if current["type"] != incoming["type"]
234
+ return "unchanged" if current == incoming
235
+
236
+ "changed"
237
+ end
238
+
239
+ def field_action(status)
240
+ return "error" if status == "conflict"
241
+ return "unchanged" if status == "unchanged"
242
+
243
+ conflict_action
244
+ end
245
+
246
+ def field_changes(current, incoming)
247
+ changes = attribute_changes(current, incoming, comparable_keys(current, incoming, FIELD_IDENTITY_KEYS))
248
+ same_options = current["options_data"] == incoming["options_data"] &&
249
+ current.key?("options_data") == incoming.key?("options_data")
250
+ return changes if same_options
251
+
252
+ changes["options_data"] = presence_change(current, incoming, "options_data")
253
+ changes
254
+ end
255
+
256
+ def preview_section(entry, existing)
257
+ identity = identity_hash(entry, "code")
258
+ return entry_preview(identity, status: "added", action: "create", changes: {}, risks: []) unless existing
259
+
260
+ current = SchemaPortability.send(:export_section_entry, existing)
261
+ changes = attribute_changes(current, entry, comparable_keys(current, entry, SECTION_IDENTITY_KEYS))
262
+ if current == entry
263
+ entry_preview(identity, status: "unchanged", action: "unchanged", changes: changes, risks: [])
264
+ else
265
+ entry_preview(identity, status: "changed", action: conflict_action, changes: changes, risks: [])
266
+ end
267
+ end
268
+
269
+ def existing_entry(existing)
270
+ SchemaPortability.send(:export_field_entry, existing)
271
+ end
272
+
273
+ def identity_hash(entry, key)
274
+ {
275
+ key => entry[key],
276
+ "entity_type" => entry["entity_type"],
277
+ "scope" => entry["scope"],
278
+ "parent_scope" => entry["parent_scope"],
279
+ }
280
+ end
281
+
282
+ def field_identity(entry)
283
+ entry.values_at(*FIELD_IDENTITY_KEYS)
284
+ end
285
+
286
+ def section_identity(entry)
287
+ entry.values_at(*SECTION_IDENTITY_KEYS)
288
+ end
289
+
290
+ def added_preview(identity, options)
291
+ entry_preview(identity, status: "added", action: "create", changes: {}, options: options, risks: [])
292
+ end
293
+
294
+ # rubocop:disable Metrics/ParameterLists -- these are the explicit
295
+ # dimensions of one stable result entry.
296
+ def entry_preview(identity, status:, action:, changes:, risks:, options: nil)
297
+ result = {
298
+ "identity" => identity.deep_dup,
299
+ "status" => status,
300
+ "action" => action,
301
+ "changes" => changes.deep_dup,
302
+ "risks" => risks,
303
+ }
304
+ result["options"] = options.deep_dup if options
305
+ result
306
+ end
307
+ # rubocop:enable Metrics/ParameterLists
308
+
309
+ def attribute_changes(current, incoming, keys)
310
+ keys.each_with_object({}) do |key, changes|
311
+ next if current[key] == incoming[key] && current.key?(key) == incoming.key?(key)
312
+
313
+ changes[key] = { "from" => current[key], "to" => incoming[key] }
314
+ end
315
+ end
316
+
317
+ def option_diff(current, incoming)
318
+ current_by_value = options_by_value(current)
319
+ incoming_by_value = options_by_value(incoming)
320
+
321
+ {
322
+ "added" => option_rows(incoming_by_value, incoming_by_value.keys - current_by_value.keys),
323
+ "removed" => option_rows(current_by_value, current_by_value.keys - incoming_by_value.keys),
324
+ "changed" => changed_option_rows(current_by_value, incoming_by_value),
325
+ }
326
+ end
327
+
328
+ def options_by_value(entry)
329
+ Array(entry["options_data"]).index_by { |row| row["value"] }
330
+ end
331
+
332
+ def option_rows(rows_by_value, values)
333
+ values.sort.map { |value| rows_by_value[value] }
334
+ end
335
+
336
+ def changed_option_rows(current_by_value, incoming_by_value)
337
+ (current_by_value.keys & incoming_by_value.keys).sort.filter_map do |value|
338
+ next if current_by_value[value] == incoming_by_value[value]
339
+
340
+ { "from" => current_by_value[value], "to" => incoming_by_value[value] }
341
+ end
342
+ end
343
+
344
+ def presence_change(current, incoming, key)
345
+ { "from" => current[key], "to" => incoming[key] }
346
+ end
347
+
348
+ def comparable_keys(current, incoming, identity_keys)
349
+ (current.keys | incoming.keys) - identity_keys - ["options_data"]
350
+ end
351
+
352
+ def field_risks(current, incoming, options)
353
+ risks = []
354
+ risks << "type_change" if current["type"] != incoming["type"]
355
+ risks << "option_removal" unless options["removed"].empty?
356
+ risks << "required_false_to_true" if current["required"] == false && incoming["required"] == true
357
+ risks << "potentially_breaking_options" if current["options"] != incoming["options"]
358
+ risks << "default_value_change" if current["default_value_meta"] != incoming["default_value_meta"]
359
+ risks << "field_dependent_change" if current["field_dependent"] != incoming["field_dependent"]
360
+ risks
361
+ end
362
+
363
+ def conflict_action
364
+ @on_conflict == :error ? "error" : @on_conflict.to_s
365
+ end
366
+
367
+ def summary_for(entries)
368
+ {
369
+ "unchanged" => entries.count { |entry| entry["status"] == "unchanged" },
370
+ "added" => entries.count { |entry| entry["status"] == "added" },
371
+ "changed" => entries.count { |entry| entry["status"] == "changed" },
372
+ "conflicts" => entries.count { |entry| CONFLICT_STATUSES.include?(entry["status"]) },
373
+ "risks" => entries.sum { |entry| entry["risks"].size },
374
+ }
375
+ end
376
+ end
377
+ # rubocop:enable Metrics/ClassLength
378
+ end
379
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "schema_portability/import_index"
4
+ require_relative "schema_portability/preview"
4
5
 
5
6
  module TypedEAV
6
7
  # Export and import field + section definitions for an exact partition
@@ -86,6 +87,25 @@ module TypedEAV
86
87
  }
87
88
  end
88
89
 
90
+ # Compare an exported schema with the exact target partition without
91
+ # invoking the import pipeline. The result is a JSON-safe, read-only
92
+ # snapshot of the definitions currently in the database and the
93
+ # conditional action the requested conflict policy would take.
94
+ #
95
+ # `:error` marks divergent definitions as blocked, `:skip` predicts
96
+ # leaving them unchanged, and `:overwrite` predicts replacement. Type
97
+ # changes are always blocked because the importer refuses unsafe typed
98
+ # value conversions under every policy. Omitted target definitions are
99
+ # intentionally absent from the result: import_schema never deletes
100
+ # them. A preview is advisory and does not lock or reserve the target;
101
+ # custom validations and concurrent changes can still affect a later
102
+ # import. Every field/section entry must also repeat the exact
103
+ # entity_type/scope/parent_scope envelope identity; inconsistent
104
+ # payloads are rejected instead of being silently retargeted.
105
+ def preview_schema(hash, on_conflict: :error)
106
+ TypedEAV::SchemaPortability::Preview.new(hash, on_conflict: on_conflict).call
107
+ end
108
+
89
109
  def import_schema(hash, on_conflict: :error)
90
110
  validate_schema_version!(hash)
91
111
  validate_conflict_policy!(on_conflict)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypedEAV
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -62,12 +62,14 @@ module TypedEAV
62
62
 
63
63
  # Re-register with versioned: true. Preserve the existing types:
64
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)
65
+ # has_typed_eav already registered the canonical Rails polymorphic
66
+ # name. Reuse it here so including this concern on an STI subclass
67
+ # enables versioning for the base entity_type actually stored on Value.
68
+ # If the entry doesn't exist (defensive — shouldn't happen post-
69
+ # has_typed_eav), default types to nil.
70
+ entity_type = polymorphic_name
71
+ existing = TypedEAV.registry.entities[entity_type] || {}
72
+ TypedEAV.registry.register(entity_type, types: existing[:types], versioned: true)
71
73
  end
72
74
  end
73
75
  end
data/lib/typed_eav.rb CHANGED
@@ -15,6 +15,7 @@ module TypedEAV
15
15
  autoload :Registry
16
16
  autoload :HasTypedEAV
17
17
  autoload :EntityQuery
18
+ autoload :ScalarQuery
18
19
  autoload :FilterQuery
19
20
  autoload :BulkRead
20
21
  autoload :BulkWrite
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typed_eav
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dchuk
@@ -157,12 +157,16 @@ files:
157
157
  - lib/typed_eav/field_deletion.rb
158
158
  - lib/typed_eav/filter_query.rb
159
159
  - lib/typed_eav/has_typed_eav.rb
160
+ - lib/typed_eav/has_typed_eav/dirty_tracking.rb
160
161
  - lib/typed_eav/has_typed_eav/instance_methods.rb
161
162
  - lib/typed_eav/partition.rb
163
+ - lib/typed_eav/partition/definition_batch.rb
162
164
  - lib/typed_eav/query_builder.rb
163
165
  - lib/typed_eav/registry.rb
166
+ - lib/typed_eav/scalar_query.rb
164
167
  - lib/typed_eav/schema_portability.rb
165
168
  - lib/typed_eav/schema_portability/import_index.rb
169
+ - lib/typed_eav/schema_portability/preview.rb
166
170
  - lib/typed_eav/scope_tuple.rb
167
171
  - lib/typed_eav/version.rb
168
172
  - lib/typed_eav/versioned.rb