vial 0.0.0 → 0.2026.1.1.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.
- checksums.yaml +4 -4
- data/README.md +301 -10
- data/examples/admin_users.vial.rb +9 -0
- data/examples/base_users.vial.rb +11 -0
- data/examples/company__users.vial.rb +10 -0
- data/examples/users.vial.rb +23 -0
- data/lib/tasks/vial.rake +303 -0
- data/lib/vial/compiler.rb +154 -0
- data/lib/vial/config.rb +33 -0
- data/lib/vial/definition.rb +462 -0
- data/lib/vial/dsl.rb +9 -0
- data/lib/vial/erb.rb +5 -0
- data/lib/vial/explain_id.rb +27 -0
- data/lib/vial/explicit_id.rb +5 -0
- data/lib/vial/fixture_analyzer.rb +222 -0
- data/lib/vial/fixture_id_standardizer.rb +211 -0
- data/lib/vial/loader.rb +14 -0
- data/lib/vial/railtie.rb +13 -0
- data/lib/vial/registry.rb +26 -0
- data/lib/vial/sequence.rb +24 -0
- data/lib/vial/validator.rb +192 -0
- data/lib/vial/version.rb +5 -0
- data/lib/vial/yaml_emitter.rb +133 -0
- data/lib/vial.rb +102 -3
- metadata +58 -10
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module Vial
|
|
6
|
+
class FixtureAnalyzer
|
|
7
|
+
FixtureInfo = Data.define(:file, :fixture_name, :model_class, :model_name, :table_name, :detection_method)
|
|
8
|
+
|
|
9
|
+
attr_reader :fixture_paths, :model_mappings, :errors
|
|
10
|
+
|
|
11
|
+
def initialize(fixture_paths = nil)
|
|
12
|
+
@fixture_paths = Array(fixture_paths || default_fixture_paths)
|
|
13
|
+
@model_mappings = {}
|
|
14
|
+
@errors = []
|
|
15
|
+
@fixture_class_names = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def analyze
|
|
19
|
+
load_fixture_class_mappings
|
|
20
|
+
|
|
21
|
+
@fixture_paths.each do |path|
|
|
22
|
+
analyze_path(path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def summary
|
|
29
|
+
{
|
|
30
|
+
total_fixtures: @model_mappings.size,
|
|
31
|
+
mapped_fixtures: @model_mappings.count { |_, v| v.model_class.present? },
|
|
32
|
+
unmapped_fixtures: @model_mappings.count { |_, v| v.model_class.nil? },
|
|
33
|
+
errors: @errors
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def mapped_fixtures
|
|
38
|
+
@model_mappings.select { |_, v| v.model_class.present? }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def unmapped_fixtures
|
|
42
|
+
@model_mappings.select { |_, v| v.model_class.nil? }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fixture_for_model(model_name)
|
|
46
|
+
model_class = model_name.is_a?(Class) ? model_name : model_name.to_s.safe_constantize
|
|
47
|
+
return nil unless model_class
|
|
48
|
+
|
|
49
|
+
@model_mappings.select { |_, v| v.model_class == model_class }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def default_fixture_paths
|
|
55
|
+
fixture_paths = ActiveSupport::TestCase.fixture_paths
|
|
56
|
+
return fixture_paths if fixture_paths && !fixture_paths.empty?
|
|
57
|
+
|
|
58
|
+
[Rails.root.join('test/fixtures')]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load_fixture_class_mappings
|
|
62
|
+
if ActiveSupport::TestCase.respond_to?(:fixture_class_names)
|
|
63
|
+
@fixture_class_names = ActiveSupport::TestCase.fixture_class_names || {}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def analyze_path(path)
|
|
68
|
+
return unless File.directory?(path)
|
|
69
|
+
|
|
70
|
+
Dir[File.join(path, '**/*.yml')].each do |file|
|
|
71
|
+
analyze_fixture_file(file, path)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def analyze_fixture_file(file, base_path)
|
|
76
|
+
relative_path = file.sub(/^#{Regexp.escape(base_path.to_s)}\//, '').sub(/\.yml$/, '')
|
|
77
|
+
fixture_name = relative_path
|
|
78
|
+
|
|
79
|
+
# Skip special fixtures
|
|
80
|
+
return if fixture_name == 'DEFAULTS' || fixture_name.start_with?('_')
|
|
81
|
+
|
|
82
|
+
model_class = determine_model_class(file, fixture_name)
|
|
83
|
+
|
|
84
|
+
model_name = model_class.is_a?(Class) ? model_class.name : model_class
|
|
85
|
+
table_name = model_class.is_a?(Class) ? model_class.table_name : nil
|
|
86
|
+
|
|
87
|
+
@model_mappings[fixture_name] = FixtureInfo.new(
|
|
88
|
+
file: file,
|
|
89
|
+
fixture_name: fixture_name,
|
|
90
|
+
model_class: model_class,
|
|
91
|
+
model_name: model_name,
|
|
92
|
+
table_name: table_name,
|
|
93
|
+
detection_method: @detection_method
|
|
94
|
+
)
|
|
95
|
+
rescue => e
|
|
96
|
+
@errors << {
|
|
97
|
+
file: file,
|
|
98
|
+
error: e.message,
|
|
99
|
+
backtrace: e.backtrace.first(3)
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def determine_model_class(file, fixture_name)
|
|
104
|
+
# 1. Check for _fixture directive in YAML
|
|
105
|
+
if model_class = check_fixture_directive(file)
|
|
106
|
+
@detection_method = :fixture_directive
|
|
107
|
+
return model_class
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# 2. Check set_fixture_class mappings
|
|
111
|
+
if model_class = check_fixture_class_mapping(fixture_name)
|
|
112
|
+
@detection_method = :set_fixture_class
|
|
113
|
+
return model_class
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# 3. Infer from fixture path
|
|
117
|
+
if model_class = infer_from_path(fixture_name)
|
|
118
|
+
@detection_method = :path_inference
|
|
119
|
+
return model_class
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@detection_method = :none
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def check_fixture_directive(file)
|
|
127
|
+
yaml = load_fixture_yaml(file)
|
|
128
|
+
|
|
129
|
+
return nil unless yaml.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
if fixture_config = yaml['_fixture']
|
|
132
|
+
if model_class_name = fixture_config['model_class']
|
|
133
|
+
# Try to constantize, but if it fails, still return the string
|
|
134
|
+
# so we know the fixture has a directive
|
|
135
|
+
model_class = model_class_name.safe_constantize
|
|
136
|
+
return model_class || model_class_name
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
nil
|
|
141
|
+
rescue => e
|
|
142
|
+
@errors << {
|
|
143
|
+
file: file,
|
|
144
|
+
error: "Failed to parse fixture YAML: #{e.message}",
|
|
145
|
+
backtrace: e.backtrace.first(3)
|
|
146
|
+
}
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def load_fixture_yaml(file)
|
|
151
|
+
content = File.read(file)
|
|
152
|
+
content = ERB.new(content).result if content.include?('<%')
|
|
153
|
+
YAML.safe_load(
|
|
154
|
+
content,
|
|
155
|
+
permitted_classes: permitted_yaml_classes,
|
|
156
|
+
permitted_symbols: [],
|
|
157
|
+
aliases: true
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def permitted_yaml_classes
|
|
162
|
+
[Time, Date, DateTime, ActiveSupport::TimeWithZone]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def check_fixture_class_mapping(fixture_name)
|
|
166
|
+
# Check exact match
|
|
167
|
+
if class_name = @fixture_class_names[fixture_name]
|
|
168
|
+
return class_name.is_a?(Class) ? class_name : class_name.to_s.safe_constantize
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Check with slashes replaced by underscores (Rails convention)
|
|
172
|
+
underscore_name = fixture_name.tr('/', '_')
|
|
173
|
+
if class_name = @fixture_class_names[underscore_name]
|
|
174
|
+
return class_name.is_a?(Class) ? class_name : class_name.to_s.safe_constantize
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def infer_from_path(fixture_name)
|
|
181
|
+
# Handle namespaced fixtures (e.g., 'platform/countries')
|
|
182
|
+
parts = fixture_name.split('/')
|
|
183
|
+
|
|
184
|
+
# Try various naming conventions
|
|
185
|
+
candidates = [
|
|
186
|
+
# Exact path as namespace (platform/countries -> Platform::Country)
|
|
187
|
+
parts.map(&:singularize).map(&:camelize).join('::'),
|
|
188
|
+
# Last part only (platform/countries -> Country)
|
|
189
|
+
parts.last.singularize.camelize,
|
|
190
|
+
# With Model suffix (platform/countries -> Platform::CountryModel)
|
|
191
|
+
parts.map(&:singularize).map(&:camelize).join('::') + 'Model',
|
|
192
|
+
# Pluralized namespace (platforms/country -> Platforms::Country)
|
|
193
|
+
parts[0..-2].map(&:camelize).join('::') + '::' + parts.last.singularize.camelize
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
# Add table name prefix handling
|
|
197
|
+
prefix = ActiveRecord::Base.table_name_prefix
|
|
198
|
+
if prefix.present? && fixture_name.start_with?(prefix)
|
|
199
|
+
unprefixed = fixture_name.sub(/^#{Regexp.escape(prefix)}/, '')
|
|
200
|
+
candidates << unprefixed.singularize.camelize
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Try each candidate
|
|
204
|
+
candidates.each do |candidate|
|
|
205
|
+
next if candidate.blank?
|
|
206
|
+
|
|
207
|
+
if model_class = candidate.safe_constantize
|
|
208
|
+
# Verify it's an ActiveRecord model
|
|
209
|
+
if model_class < ActiveRecord::Base
|
|
210
|
+
# Additional verification: check if table name matches
|
|
211
|
+
expected_table = fixture_name.tr('/', '_')
|
|
212
|
+
if model_class.table_name == expected_table
|
|
213
|
+
return model_class
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vial
|
|
4
|
+
class FixtureIdStandardizer
|
|
5
|
+
Update = Data.define(:file, :model_class, :primary_key, :primary_key_type, :changes)
|
|
6
|
+
Change = Data.define(:label, :primary_key, :current_id, :new_id, :id_type)
|
|
7
|
+
attr_reader :fixture_paths, :updates_needed, :errors
|
|
8
|
+
|
|
9
|
+
def initialize(fixture_paths = nil)
|
|
10
|
+
@fixture_paths = Array(fixture_paths || default_fixture_paths)
|
|
11
|
+
@updates_needed = []
|
|
12
|
+
@errors = []
|
|
13
|
+
@analyzer = FixtureAnalyzer.new(fixture_paths)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def analyze
|
|
17
|
+
@analyzer.analyze
|
|
18
|
+
|
|
19
|
+
@analyzer.mapped_fixtures.each do |fixture_name, info|
|
|
20
|
+
analyze_fixture_file(info)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def standardize!(dry_run: false)
|
|
27
|
+
analyze if @updates_needed.empty?
|
|
28
|
+
|
|
29
|
+
@updates_needed.each do |update|
|
|
30
|
+
if dry_run
|
|
31
|
+
puts "Would update: #{update.file}"
|
|
32
|
+
update.changes.each do |change|
|
|
33
|
+
puts " #{change.label}: #{change.current_id} -> #{change.new_id}"
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
apply_updates(update)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def summary
|
|
42
|
+
pk_type_stats = @analyzer.mapped_fixtures.values.group_by do |info|
|
|
43
|
+
next :unmapped unless info.model_class
|
|
44
|
+
pk = info.model_class.primary_key
|
|
45
|
+
get_primary_key_type(info.model_class, pk) || :unknown
|
|
46
|
+
end.transform_values(&:size)
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
total_fixtures: @analyzer.mapped_fixtures.size,
|
|
50
|
+
fixtures_needing_updates: @updates_needed.size,
|
|
51
|
+
records_to_update: @updates_needed.sum { |u| u.changes.size },
|
|
52
|
+
primary_key_types: pk_type_stats,
|
|
53
|
+
errors: @errors
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def default_fixture_paths
|
|
60
|
+
fixture_paths = ActiveSupport::TestCase.fixture_paths
|
|
61
|
+
return fixture_paths if fixture_paths && !fixture_paths.empty?
|
|
62
|
+
|
|
63
|
+
[Rails.root.join('test/fixtures')]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def analyze_fixture_file(info)
|
|
67
|
+
return unless info.model_class
|
|
68
|
+
|
|
69
|
+
file = info.file
|
|
70
|
+
model_class = info.model_class
|
|
71
|
+
|
|
72
|
+
# Get primary key info
|
|
73
|
+
primary_key = model_class.primary_key
|
|
74
|
+
primary_key_type = get_primary_key_type(model_class, primary_key)
|
|
75
|
+
|
|
76
|
+
# Skip if no primary key
|
|
77
|
+
return unless primary_key && primary_key_type
|
|
78
|
+
|
|
79
|
+
# Read and parse fixture file
|
|
80
|
+
content = File.read(file)
|
|
81
|
+
if content.include?('<%')
|
|
82
|
+
@errors << {
|
|
83
|
+
file: file,
|
|
84
|
+
error: 'ERB detected in fixture file; skipping standardization to avoid rewriting dynamic content'
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
yaml = YAML.load(content)
|
|
90
|
+
|
|
91
|
+
return unless yaml.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
changes = []
|
|
94
|
+
|
|
95
|
+
yaml.each do |label, attributes|
|
|
96
|
+
next if label == '_fixture' # Skip meta configuration
|
|
97
|
+
next unless attributes.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
# Check if ID needs updating
|
|
100
|
+
current_id = attributes[primary_key] || attributes[primary_key.to_s]
|
|
101
|
+
expected_id = generate_fixture_id(label, primary_key_type)
|
|
102
|
+
|
|
103
|
+
if should_update_id?(current_id, expected_id, primary_key_type)
|
|
104
|
+
changes << Change.new(
|
|
105
|
+
label: label,
|
|
106
|
+
primary_key: primary_key,
|
|
107
|
+
current_id: current_id,
|
|
108
|
+
new_id: expected_id,
|
|
109
|
+
id_type: primary_key_type
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if changes.any?
|
|
115
|
+
@updates_needed << Update.new(
|
|
116
|
+
file: file,
|
|
117
|
+
model_class: model_class,
|
|
118
|
+
primary_key: primary_key,
|
|
119
|
+
primary_key_type: primary_key_type,
|
|
120
|
+
changes: changes
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
rescue => e
|
|
124
|
+
@errors << {
|
|
125
|
+
file: file,
|
|
126
|
+
error: e.message,
|
|
127
|
+
backtrace: e.backtrace.first(3)
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def get_primary_key_type(model_class, primary_key)
|
|
132
|
+
return nil unless model_class.connected?
|
|
133
|
+
|
|
134
|
+
column = model_class.columns_hash[primary_key.to_s]
|
|
135
|
+
return nil unless column
|
|
136
|
+
|
|
137
|
+
case column.type
|
|
138
|
+
when :uuid
|
|
139
|
+
:uuid
|
|
140
|
+
when :integer, :bigint
|
|
141
|
+
:integer
|
|
142
|
+
when :string
|
|
143
|
+
# String PKs are often manually managed, skip standardization
|
|
144
|
+
:string
|
|
145
|
+
else
|
|
146
|
+
column.type
|
|
147
|
+
end
|
|
148
|
+
rescue
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def generate_fixture_id(label, primary_key_type)
|
|
153
|
+
case primary_key_type
|
|
154
|
+
when :uuid
|
|
155
|
+
"<%= ActiveRecord::FixtureSet.identify(:#{label}, :uuid) %>"
|
|
156
|
+
when :integer
|
|
157
|
+
"<%= ActiveRecord::FixtureSet.identify(:#{label}) %>"
|
|
158
|
+
when :string
|
|
159
|
+
# Skip string primary keys - they're often manually managed (slugs, etc)
|
|
160
|
+
nil
|
|
161
|
+
else
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def should_update_id?(current_id, expected_id, primary_key_type)
|
|
167
|
+
return false if expected_id.nil?
|
|
168
|
+
|
|
169
|
+
# If no current ID, we need to add one
|
|
170
|
+
return true if current_id.nil?
|
|
171
|
+
|
|
172
|
+
# If current ID is already an ERB expression with identify, it's good
|
|
173
|
+
if current_id.is_a?(String) && current_id.include?('ActiveRecord::FixtureSet.identify')
|
|
174
|
+
# Check if it has the correct format
|
|
175
|
+
if primary_key_type == :uuid
|
|
176
|
+
return !current_id.include?(':uuid')
|
|
177
|
+
else
|
|
178
|
+
return current_id.include?(':uuid')
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# If it's a hardcoded value, we need to update it
|
|
183
|
+
true
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def apply_updates(update)
|
|
187
|
+
file = update.file
|
|
188
|
+
content = File.read(file)
|
|
189
|
+
yaml = YAML.load(content)
|
|
190
|
+
|
|
191
|
+
update.changes.each do |change|
|
|
192
|
+
label = change.label
|
|
193
|
+
if yaml[label]
|
|
194
|
+
# Update or add the primary key
|
|
195
|
+
yaml[label][change.primary_key.to_s] = change.new_id
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Write back to file, preserving YAML formatting as much as possible
|
|
200
|
+
File.write(file, yaml.to_yaml)
|
|
201
|
+
|
|
202
|
+
puts "Updated: #{file} (#{update.changes.size} records)"
|
|
203
|
+
rescue => e
|
|
204
|
+
@errors << {
|
|
205
|
+
file: file,
|
|
206
|
+
error: "Failed to update: #{e.message}",
|
|
207
|
+
backtrace: e.backtrace.first(3)
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
data/lib/vial/loader.rb
ADDED
data/lib/vial/railtie.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vial
|
|
4
|
+
class Registry
|
|
5
|
+
def initialize
|
|
6
|
+
@definitions = []
|
|
7
|
+
@by_name = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def define(name, **options, &block)
|
|
11
|
+
definition = Definition.new(name, **options)
|
|
12
|
+
definition.instance_eval(&block) if block
|
|
13
|
+
@definitions << definition
|
|
14
|
+
@by_name[definition.name] = definition
|
|
15
|
+
definition
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def definitions
|
|
19
|
+
@definitions.dup
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def [](name)
|
|
23
|
+
@by_name[name.to_s]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vial
|
|
4
|
+
class Sequence
|
|
5
|
+
def initialize(start: 1, &block)
|
|
6
|
+
@value = start
|
|
7
|
+
@block = block
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def next_value
|
|
11
|
+
current = @value
|
|
12
|
+
@value += 1
|
|
13
|
+
@block ? @block.call(current) : current
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class SequenceRef
|
|
18
|
+
attr_reader :name
|
|
19
|
+
|
|
20
|
+
def initialize(name)
|
|
21
|
+
@name = name.to_sym
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vial
|
|
4
|
+
class ValidationError < StandardError
|
|
5
|
+
attr_reader :type, :details
|
|
6
|
+
|
|
7
|
+
def initialize(type, details)
|
|
8
|
+
@type = type
|
|
9
|
+
@details = details
|
|
10
|
+
super(build_message)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def build_message
|
|
16
|
+
(["#{type}:"] + details.map { |detail| " #{detail}" }).join("\n")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Validator
|
|
21
|
+
MAX_DERIVED_SALTS = 3
|
|
22
|
+
|
|
23
|
+
def initialize(records_by_definition)
|
|
24
|
+
@records_by_definition = records_by_definition
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate!
|
|
28
|
+
validate_duplicate_labels!
|
|
29
|
+
assign_and_validate_ids!
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_duplicate_labels!
|
|
36
|
+
@records_by_definition.each do |entry|
|
|
37
|
+
definition = entry[:definition]
|
|
38
|
+
records = entry[:records]
|
|
39
|
+
label_map = {}
|
|
40
|
+
|
|
41
|
+
records.each do |record|
|
|
42
|
+
label = record.identity_label
|
|
43
|
+
variant_key = normalize_variant_key(record.variant_stack)
|
|
44
|
+
existing = label_map[label]
|
|
45
|
+
|
|
46
|
+
if existing && existing[:variant_key] != variant_key
|
|
47
|
+
raise ValidationError.new(
|
|
48
|
+
'DuplicateLabel',
|
|
49
|
+
[
|
|
50
|
+
"vial: #{definition.name}",
|
|
51
|
+
"record_type: #{definition.record_type}",
|
|
52
|
+
"label: #{label}",
|
|
53
|
+
"first: #{format_source(existing[:source_file], existing[:source_line])} (variant: #{existing[:variant_label]})",
|
|
54
|
+
"second: #{format_source(record.source_file, record.source_line)} (variant: #{format_variant(record.variant_stack)})"
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
label_map[label] ||= {
|
|
60
|
+
variant_key: variant_key,
|
|
61
|
+
variant_label: format_variant(record.variant_stack),
|
|
62
|
+
source_file: record.source_file,
|
|
63
|
+
source_line: record.source_line
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def assign_and_validate_ids!
|
|
70
|
+
used_ids = {}
|
|
71
|
+
all_records.each do |record|
|
|
72
|
+
explicit = explicit_id_for(record)
|
|
73
|
+
next unless explicit
|
|
74
|
+
|
|
75
|
+
id_value = explicit[:value]
|
|
76
|
+
if (existing = used_ids[id_value])
|
|
77
|
+
raise ValidationError.new(
|
|
78
|
+
'ExplicitIDCollision',
|
|
79
|
+
[
|
|
80
|
+
"ID: #{id_value.inspect}",
|
|
81
|
+
record_descriptor(existing[:record], existing[:source_file], existing[:source_line]),
|
|
82
|
+
record_descriptor(record, explicit[:source_file], explicit[:source_line])
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
record.attributes[record.definition.primary_key] = id_value
|
|
88
|
+
used_ids[id_value] = { explicit: true, record: record, source_file: explicit[:source_file], source_line: explicit[:source_line] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
derived_records = all_records.reject { |record| record.attributes.key?(record.definition.primary_key) }
|
|
92
|
+
derived_records.sort_by { |record| sort_key(record) }.each do |record|
|
|
93
|
+
assign_derived_id!(record, used_ids)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def assign_derived_id!(record, used_ids)
|
|
98
|
+
attempts = 0
|
|
99
|
+
last_collision = nil
|
|
100
|
+
|
|
101
|
+
while attempts < MAX_DERIVED_SALTS
|
|
102
|
+
id_value = record.definition.derive_id(
|
|
103
|
+
label: record.identity_label,
|
|
104
|
+
variant_stack: record.variant_stack,
|
|
105
|
+
index: record.index,
|
|
106
|
+
salt: attempts
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
existing = used_ids[id_value]
|
|
110
|
+
if existing.nil?
|
|
111
|
+
record.attributes[record.definition.primary_key] = id_value
|
|
112
|
+
used_ids[id_value] = { explicit: false, record: record }
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if existing[:explicit]
|
|
117
|
+
raise ValidationError.new(
|
|
118
|
+
'DerivedIDCollision',
|
|
119
|
+
[
|
|
120
|
+
"ID: #{id_value.inspect}",
|
|
121
|
+
"derived: #{record_descriptor(record, record.source_file, record.source_line)}",
|
|
122
|
+
"explicit: #{record_descriptor(existing[:record], existing[:source_file], existing[:source_line])}"
|
|
123
|
+
]
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
last_collision = { id: id_value, existing: existing }
|
|
128
|
+
attempts += 1
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
raise ValidationError.new(
|
|
132
|
+
'DerivedIDCollision',
|
|
133
|
+
[
|
|
134
|
+
"ID: #{last_collision ? last_collision[:id].inspect : 'unknown'}",
|
|
135
|
+
"derived: #{record_descriptor(record, record.source_file, record.source_line)}",
|
|
136
|
+
(last_collision ? "existing: #{record_descriptor(last_collision[:existing][:record], last_collision[:existing][:record].source_file, last_collision[:existing][:record].source_line)}" : nil),
|
|
137
|
+
"vial: #{record.definition.name}",
|
|
138
|
+
"record_type: #{record.definition.record_type}",
|
|
139
|
+
"source: #{format_source(record.source_file, record.source_line)}"
|
|
140
|
+
].compact
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def explicit_id_for(record)
|
|
145
|
+
primary_key = record.definition.primary_key
|
|
146
|
+
return nil unless record.attributes.key?(primary_key)
|
|
147
|
+
|
|
148
|
+
value = record.attributes[primary_key]
|
|
149
|
+
case value
|
|
150
|
+
in ExplicitId(value:, source_file:, source_line:)
|
|
151
|
+
{ value: value, source_file: source_file, source_line: source_line }
|
|
152
|
+
else
|
|
153
|
+
{ value: value, source_file: record.source_file, source_line: record.source_line }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def all_records
|
|
158
|
+
@all_records ||= @records_by_definition.flat_map { |entry| entry[:records] }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def sort_key(record)
|
|
162
|
+
[
|
|
163
|
+
record.definition.name.to_s.downcase,
|
|
164
|
+
record.definition.record_type.to_s.downcase,
|
|
165
|
+
record.identity_label.to_s.downcase,
|
|
166
|
+
normalize_variant_key(record.variant_stack),
|
|
167
|
+
record.index
|
|
168
|
+
]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def normalize_variant_key(variant_stack)
|
|
172
|
+
Array(variant_stack).map(&:to_s).join('::').downcase
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def format_variant(variant_stack)
|
|
176
|
+
stack = Array(variant_stack).map(&:to_s)
|
|
177
|
+
return 'base' if stack.empty?
|
|
178
|
+
stack.join('.')
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def record_descriptor(record, source_file, source_line)
|
|
182
|
+
variant = format_variant(record.variant_stack)
|
|
183
|
+
label = record.identity_label
|
|
184
|
+
"#{format_source(source_file, source_line)} (vial: #{record.definition.name}, record_type: #{record.definition.record_type}, label: #{label}, variant: #{variant}, index: #{record.index})"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def format_source(source_file, source_line)
|
|
188
|
+
return 'unknown' unless source_file
|
|
189
|
+
source_line ? "#{source_file}:#{source_line}" : source_file
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|