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.
@@ -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
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vial
4
+ class Loader
5
+ def initialize(path, box:)
6
+ @path = path
7
+ @box = box
8
+ end
9
+
10
+ def load
11
+ @box.module_eval(File.read(@path), @path, 1)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module Vial
6
+ class Railtie < Rails::Railtie
7
+ railtie_name :vial
8
+
9
+ rake_tasks do
10
+ load File.expand_path('../tasks/vial.rake', __dir__)
11
+ end
12
+ end
13
+ end
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vial
4
+ VERSION = "0.2026.1.1.0"
5
+ end