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,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vial
4
+ class Compiler
5
+ CompileResult = Data.define(:status, :files, :output_path)
6
+ CleanResult = Data.define(:status, :removed)
7
+ attr_reader :config, :source_paths, :output_path, :seed
8
+
9
+ def initialize(config:, source_paths: nil, output_path: nil, seed: nil)
10
+ @config = config
11
+ @source_paths = Array(source_paths || config.source_paths)
12
+ @output_path = output_path || config.output_path
13
+ @seed = seed || config.seed
14
+ end
15
+
16
+ def compile!(dry_run: false, only: nil)
17
+ previous_seed = srand(@seed)
18
+ Vial.load_sources!(source_paths: @source_paths)
19
+
20
+ definitions = Vial.registry.definitions
21
+ if definitions.empty?
22
+ warn "Vial: no vial definitions found in #{@source_paths.join(', ')}"
23
+ return CompileResult.new(status: :no_definitions, files: [], output_path: @output_path.to_s)
24
+ end
25
+
26
+ selected_definitions = filter_definitions(definitions, only)
27
+ if selected_definitions.empty?
28
+ warn "Vial: no matching vials to compile"
29
+ return CompileResult.new(status: :no_definitions, files: [], output_path: @output_path.to_s)
30
+ end
31
+
32
+ records_by_definition = definitions.each_with_object([]) do |definition, entries|
33
+ next if definition.abstract?
34
+ entries << { definition: definition, records: definition.build_records }
35
+ end
36
+
37
+ total_records = records_by_definition.sum { |entry| entry[:records].size }
38
+ if total_records == 0
39
+ warn "Vial: no records generated (all definitions are abstract or missing generate calls)"
40
+ return CompileResult.new(status: :no_records, files: [], output_path: @output_path.to_s)
41
+ end
42
+
43
+ Validator.new(records_by_definition).validate!
44
+
45
+ warn_if_output_path_unconfigured
46
+
47
+ selected_names = selected_definitions.map(&:name).to_set
48
+ selected_records = records_by_definition.select { |entry| selected_names.include?(entry[:definition].name) }
49
+ files_to_write = selected_records.map { |entry| fixture_path_for(entry[:definition]) }
50
+
51
+ if dry_run
52
+ return CompileResult.new(status: :dry_run, files: files_to_write, output_path: @output_path.to_s)
53
+ end
54
+
55
+ selected_records.each do |entry|
56
+ write_fixture(entry[:definition], entry[:records])
57
+ end
58
+
59
+ write_manifest(expected_fixture_paths(definitions))
60
+
61
+ CompileResult.new(status: :compiled, files: files_to_write, output_path: @output_path.to_s)
62
+ ensure
63
+ srand(previous_seed) unless previous_seed.nil?
64
+ end
65
+
66
+ def clean!
67
+ Vial.load_sources!(source_paths: @source_paths)
68
+ definitions = Vial.registry.definitions
69
+ expected = expected_fixture_paths(definitions)
70
+ manifest = read_manifest
71
+
72
+ if manifest.empty?
73
+ warn "Vial: no manifest found; nothing to clean"
74
+ return CleanResult.new(status: :no_manifest, removed: [])
75
+ end
76
+
77
+ removed = []
78
+ manifest.each do |relative_path|
79
+ next if expected.include?(relative_path)
80
+
81
+ file_path = File.join(@output_path.to_s, relative_path)
82
+ next unless File.exist?(file_path)
83
+
84
+ File.delete(file_path)
85
+ removed << file_path
86
+ end
87
+
88
+ write_manifest(expected)
89
+ CleanResult.new(status: :cleaned, removed: removed)
90
+ end
91
+
92
+ private
93
+
94
+ def write_fixture(definition, records)
95
+ file_path = File.join(@output_path.to_s, "#{definition.name}.yml")
96
+ FileUtils.mkdir_p(File.dirname(file_path))
97
+
98
+ emitter = YamlEmitter.new(records)
99
+ File.open(file_path, 'w') do |file|
100
+ emitter.write_to(file)
101
+ end
102
+ end
103
+
104
+ def fixture_path_for(definition)
105
+ File.join(@output_path.to_s, "#{definition.name}.yml")
106
+ end
107
+
108
+ def expected_fixture_paths(definitions)
109
+ base = Pathname.new(@output_path.to_s)
110
+ definitions.reject(&:abstract?).map do |definition|
111
+ Pathname.new(fixture_path_for(definition)).relative_path_from(base).to_s
112
+ end
113
+ end
114
+
115
+ def manifest_path
116
+ File.join(@output_path.to_s, '.vial_manifest.yml')
117
+ end
118
+
119
+ def write_manifest(expected_paths)
120
+ FileUtils.mkdir_p(@output_path.to_s)
121
+ File.write(manifest_path, YAML.dump(expected_paths))
122
+ end
123
+
124
+ def read_manifest
125
+ return [] unless File.exist?(manifest_path)
126
+
127
+ data = YAML.load_file(manifest_path)
128
+ data.is_a?(Array) ? data : []
129
+ rescue
130
+ []
131
+ end
132
+
133
+ def filter_definitions(definitions, only)
134
+ filtered = definitions.reject(&:abstract?)
135
+ return filtered if only.nil?
136
+
137
+ names = Array(only).flat_map { |name| name.to_s.split(',') }
138
+ names = names.map(&:strip).reject(&:empty?).map { |name| name.tr('__', '/') }
139
+
140
+ filtered.select { |definition| names.include?(definition.name) }
141
+ end
142
+
143
+ def warn_if_output_path_unconfigured
144
+ fixture_paths = Array(ActiveSupport::TestCase.fixture_paths).compact.map { |path| File.expand_path(path.to_s) }
145
+ return if fixture_paths.empty?
146
+
147
+ output = File.expand_path(@output_path.to_s)
148
+
149
+ return if fixture_paths.any? { |path| output == path || output.start_with?(path + File::SEPARATOR) }
150
+
151
+ warn "Vial: output_path (#{@output_path}) is not inside fixture_paths"
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vial
4
+ class Config
5
+ attr_accessor :source_paths, :seed, :id_base, :id_range
6
+ attr_writer :output_path
7
+
8
+ def initialize
9
+ @source_paths = default_source_paths
10
+ @output_path = nil
11
+ @seed = 1
12
+ @id_base = 0
13
+ @id_range = 90_000
14
+ end
15
+
16
+ def output_path
17
+ @output_path || default_output_path
18
+ end
19
+
20
+ private
21
+
22
+ def default_source_paths
23
+ [Rails.root.join('test/vials')]
24
+ end
25
+
26
+ def default_output_path
27
+ fixture_paths = Array(ActiveSupport::TestCase.fixture_paths).compact
28
+ return fixture_paths.first if fixture_paths.any?
29
+
30
+ Rails.root.join('test/fixtures')
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,462 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vial
4
+ class Definition
5
+ Generation = Data.define(:count, :variant, :label_prefix, :source_file, :source_line)
6
+ Record = Data.define(:definition, :fixture_label, :identity_label, :variant_stack, :index, :attributes, :source_file, :source_line)
7
+ SequenceDefinition = Data.define(:start, :block)
8
+ Include = Data.define(:name, :overrides)
9
+
10
+ attr_reader :name, :primary_key, :record_type, :id_base, :id_range, :base_attributes, :variant_attributes
11
+
12
+ def initialize(name, primary_key: :id, record_type: nil, id_base: nil, id_range: nil, id_type: nil, abstract: false)
13
+ @name = normalize_name(name)
14
+ @primary_key = primary_key.to_s
15
+ @record_type = (record_type ? record_type.to_s : default_record_type(@name))
16
+ @id_base = id_base.nil? ? Vial.config.id_base : id_base
17
+ @id_range = id_range.nil? ? Vial.config.id_range : id_range
18
+ @abstract = !!abstract
19
+ raise ArgumentError, "id_type is not supported; Vial derives deterministic integer IDs" unless id_type.nil?
20
+ raise ArgumentError, "id_range must be positive" unless @id_range.to_i > 0
21
+ @base_attributes = {}
22
+ @variant_attributes = {}
23
+ @generations = []
24
+ @sequence_defs = {}
25
+ @includes = []
26
+ @sequences = nil
27
+ end
28
+
29
+ def base(&block)
30
+ @base_attributes = build_attributes(&block)
31
+ end
32
+
33
+ def variant(name, &block)
34
+ @variant_attributes[name.to_sym] = build_attributes(&block)
35
+ end
36
+
37
+ def include_vial(name, &block)
38
+ overrides = {}
39
+ if block
40
+ builder = IncludeOverrideBuilder.new(self)
41
+ builder.instance_eval(&block)
42
+ overrides = builder.attributes
43
+ end
44
+
45
+ @includes << Include.new(name: normalize_name(name), overrides: overrides)
46
+ end
47
+
48
+ def generate(*args, **kwargs)
49
+ count, variant, label_prefix = parse_generation_args(*args, **kwargs)
50
+ loc = caller_locations(1, 1).first
51
+ source_file = loc&.absolute_path || loc&.path
52
+ @generations << Generation.new(
53
+ count: count,
54
+ variant: variant,
55
+ label_prefix: label_prefix,
56
+ source_file: source_file,
57
+ source_line: loc&.lineno
58
+ )
59
+ end
60
+
61
+ def sequence(name, start: 1, &block)
62
+ if block
63
+ @sequence_defs[name.to_sym] = SequenceDefinition.new(start: start, block: block)
64
+ return
65
+ end
66
+
67
+ SequenceRef.new(name)
68
+ end
69
+
70
+ def build_records
71
+ @sequences = build_sequences
72
+ base_attrs = resolved_base_attributes
73
+ records = []
74
+
75
+ grouped_generations.each do |group|
76
+ variant = group[:variant]
77
+ label = group[:label]
78
+ count = group[:count]
79
+ variant_attrs = variant ? (@variant_attributes[variant] || {}) : {}
80
+ merged_attrs = merge_attributes(base_attrs, variant_attrs)
81
+ static_attrs, dynamic_entries = split_attributes(merged_attrs)
82
+ variant_stack = variant ? [variant] : []
83
+ source_file = group[:source_file]
84
+ source_line = group[:source_line]
85
+
86
+ count.times do |offset|
87
+ index = offset + 1
88
+ fixture_label = fixture_label_for(label, index, count)
89
+
90
+ attrs = static_attrs.dup
91
+ if dynamic_entries.any?
92
+ context = RecordContext.new(self, label: label, variant: variant, index: index)
93
+ dynamic_entries.each do |key, value|
94
+ attrs[key] = resolve_value(value, context)
95
+ end
96
+ end
97
+
98
+ records << Record.new(
99
+ definition: self,
100
+ fixture_label: fixture_label,
101
+ identity_label: label,
102
+ variant_stack: variant_stack,
103
+ index: index,
104
+ attributes: attrs,
105
+ source_file: source_file,
106
+ source_line: source_line
107
+ )
108
+ end
109
+ end
110
+
111
+ records
112
+ ensure
113
+ @sequences = nil
114
+ end
115
+
116
+ def abstract?
117
+ @abstract
118
+ end
119
+
120
+ def explain_id(label:, variant_stack:, index:)
121
+ variants = Array(variant_stack)
122
+ parts = [@name, @record_type, label] + variants + [index]
123
+ normalized = parts.map { |part| normalize_identity_part(part) }.join('::')
124
+ hash_value = Zlib.crc32(normalized)
125
+ {
126
+ tuple: parts,
127
+ normalized: normalized,
128
+ hash: hash_value,
129
+ base: @id_base,
130
+ range: @id_range,
131
+ final: @id_base + (hash_value % @id_range)
132
+ }
133
+ end
134
+
135
+ def derive_id(label:, variant_stack:, index:, salt: 0)
136
+ derived_id_for(label: label, variant_stack: variant_stack, index: index, salt: salt)
137
+ end
138
+
139
+ def sequence_definitions
140
+ @sequence_defs
141
+ end
142
+
143
+ private
144
+
145
+ def parse_generation_args(*args, **kwargs)
146
+ if args.length == 0
147
+ raise ArgumentError, 'generate requires a count or variant'
148
+ end
149
+
150
+ if args.length == 1
151
+ if args.first.is_a?(Integer)
152
+ count = args.first
153
+ variant = nil
154
+ else
155
+ count = 1
156
+ variant = args.first
157
+ end
158
+ else
159
+ count = args[0]
160
+ variant = args[1]
161
+ end
162
+
163
+ label_prefix = kwargs[:label_prefix]
164
+ [count, variant&.to_sym, label_prefix]
165
+ end
166
+
167
+ def build_attributes(&block)
168
+ builder = AttributeBuilder.new(self)
169
+ builder.instance_eval(&block) if block
170
+ builder.attributes
171
+ end
172
+
173
+ def merge_attributes(base, variant)
174
+ base.merge(variant)
175
+ end
176
+
177
+ def grouped_generations
178
+ grouped = {}
179
+
180
+ @generations.each do |generation|
181
+ variant = generation.variant
182
+ label = generation.label_prefix || default_label_prefix(variant)
183
+ key = [label, variant]
184
+ entry = grouped[key]
185
+ if entry
186
+ entry[:count] += generation.count
187
+ else
188
+ grouped[key] = {
189
+ count: generation.count,
190
+ source_file: generation.source_file,
191
+ source_line: generation.source_line
192
+ }
193
+ end
194
+ end
195
+
196
+ grouped.keys.sort_by { |label, variant| [label.to_s, variant.to_s] }.map do |label, variant|
197
+ entry = grouped[[label, variant]]
198
+ {
199
+ label: label,
200
+ variant: variant,
201
+ count: entry[:count],
202
+ source_file: entry[:source_file],
203
+ source_line: entry[:source_line]
204
+ }
205
+ end
206
+ end
207
+
208
+ def fixture_label_for(label, index, count)
209
+ return label.to_s if count == 1
210
+ "#{label}_#{index}"
211
+ end
212
+
213
+ def resolved_base_attributes(seen = [])
214
+ if seen.include?(name)
215
+ raise ArgumentError, "Circular include_vial detected: #{(seen + [name]).join(' -> ')}"
216
+ end
217
+
218
+ seen << name
219
+ attrs = {}
220
+
221
+ @includes.each do |include_entry|
222
+ included = Vial.registry[include_entry.name]
223
+ raise ArgumentError, "Unknown include_vial target: #{include_entry.name}" unless included
224
+
225
+ attrs = attrs.merge(included.__send__(:resolved_base_attributes, seen))
226
+ import_sequences_from(included)
227
+ attrs = attrs.merge(include_entry.overrides)
228
+ end
229
+
230
+ attrs = attrs.merge(@base_attributes)
231
+ seen.pop
232
+ attrs
233
+ end
234
+
235
+ def import_sequences_from(definition)
236
+ definition.sequence_definitions.each do |name, seq_def|
237
+ @sequence_defs[name] ||= seq_def
238
+ end
239
+ end
240
+
241
+ def build_sequences
242
+ @sequence_defs.transform_values do |definition|
243
+ Sequence.new(start: definition.start, &definition.block)
244
+ end
245
+ end
246
+
247
+ def resolve_values(attributes, label:, variant:, index:)
248
+ context = RecordContext.new(self, label: label, variant: variant, index: index)
249
+ attributes.transform_values do |value|
250
+ resolve_value(value, context)
251
+ end
252
+ end
253
+
254
+ def default_label_prefix(variant)
255
+ variant ? variant.to_s : 'base'
256
+ end
257
+
258
+ def default_record_type(name)
259
+ base = name.to_s.split('/').last
260
+ ActiveSupport::Inflector.singularize(base)
261
+ end
262
+
263
+ def normalize_name(name)
264
+ name.to_s.gsub('__', '/')
265
+ end
266
+
267
+ def normalize_identity_part(value)
268
+ value.to_s.downcase
269
+ end
270
+
271
+ def derived_id_for(label:, variant_stack:, index:, salt: 0)
272
+ variants = Array(variant_stack)
273
+ parts = [@name, @record_type, label] + variants + [index]
274
+ normalized = parts.map { |part| normalize_identity_part(part) }.join('::')
275
+ normalized = "#{normalized}::#{salt}" if salt.positive?
276
+
277
+ hash_value = Zlib.crc32(normalized)
278
+ @id_base + (hash_value % @id_range)
279
+ end
280
+
281
+ def resolve_value(value, context)
282
+ case value
283
+ when ExplicitId
284
+ resolved = resolve_value(value.value, context)
285
+ ExplicitId.new(value: resolved, source_file: value.source_file, source_line: value.source_line)
286
+ when SequenceRef
287
+ sequence = @sequences && @sequences[value.name]
288
+ raise ArgumentError, "Unknown sequence: #{value.name}" unless sequence
289
+ sequence.next_value
290
+ when CallableValue
291
+ resolve_value(value.call(context), context)
292
+ else
293
+ value
294
+ end
295
+ end
296
+
297
+ def split_attributes(attributes)
298
+ static = {}
299
+ dynamic = []
300
+
301
+ attributes.each do |key, value|
302
+ if dynamic_attribute?(value)
303
+ dynamic << [key, value]
304
+ else
305
+ static[key] = value
306
+ end
307
+ end
308
+
309
+ [static, dynamic]
310
+ end
311
+
312
+ def dynamic_attribute?(value)
313
+ case value
314
+ in ExplicitId(value:)
315
+ dynamic_attribute?(value)
316
+ in SequenceRef | CallableValue
317
+ true
318
+ else
319
+ false
320
+ end
321
+ end
322
+
323
+ class AttributeBuilder
324
+ attr_reader :attributes
325
+
326
+ def initialize(definition)
327
+ @definition = definition
328
+ @attributes = {}
329
+ end
330
+
331
+ def id(value)
332
+ loc = caller_locations(1, 1).first
333
+ source_file = loc&.absolute_path || loc&.path
334
+ @attributes[@definition.primary_key] = ExplicitId.new(
335
+ value: value,
336
+ source_file: source_file,
337
+ source_line: loc&.lineno
338
+ )
339
+ end
340
+
341
+ def erb(source)
342
+ Erb.new(source: source)
343
+ end
344
+
345
+ def method_missing(name, *args, &block)
346
+ if block
347
+ @attributes[name.to_s] = CallableValue.new(&block)
348
+ return
349
+ end
350
+
351
+ if args.length != 1
352
+ raise ArgumentError, "Expected a single value for #{name}"
353
+ end
354
+
355
+ if name.to_s == @definition.primary_key
356
+ loc = caller_locations(1, 1).first
357
+ source_file = loc&.absolute_path || loc&.path
358
+ @attributes[name.to_s] = ExplicitId.new(
359
+ value: args.first,
360
+ source_file: source_file,
361
+ source_line: loc&.lineno
362
+ )
363
+ else
364
+ @attributes[name.to_s] = args.first
365
+ end
366
+ end
367
+
368
+ def respond_to_missing?(*_args)
369
+ true
370
+ end
371
+
372
+ def sequence(name, start: 1, &block)
373
+ @definition.sequence(name, start: start, &block)
374
+ end
375
+ end
376
+
377
+ class IncludeOverrideBuilder
378
+ attr_reader :attributes
379
+
380
+ def initialize(definition)
381
+ @definition = definition
382
+ @attributes = {}
383
+ end
384
+
385
+ def erb(source)
386
+ Erb.new(source: source)
387
+ end
388
+
389
+ def id(value)
390
+ loc = caller_locations(1, 1).first
391
+ source_file = loc&.absolute_path || loc&.path
392
+ @attributes[@definition.primary_key] = ExplicitId.new(
393
+ value: value,
394
+ source_file: source_file,
395
+ source_line: loc&.lineno
396
+ )
397
+ end
398
+
399
+ def override(name, value = nil, &block)
400
+ if block
401
+ if name.to_s == @definition.primary_key
402
+ loc = caller_locations(1, 1).first
403
+ source_file = loc&.absolute_path || loc&.path
404
+ @attributes[name.to_s] = ExplicitId.new(
405
+ value: CallableValue.new(&block),
406
+ source_file: source_file,
407
+ source_line: loc&.lineno
408
+ )
409
+ else
410
+ @attributes[name.to_s] = CallableValue.new(&block)
411
+ end
412
+ return
413
+ end
414
+
415
+ if name.to_s == @definition.primary_key
416
+ loc = caller_locations(1, 1).first
417
+ source_file = loc&.absolute_path || loc&.path
418
+ @attributes[name.to_s] = ExplicitId.new(
419
+ value: value,
420
+ source_file: source_file,
421
+ source_line: loc&.lineno
422
+ )
423
+ else
424
+ @attributes[name.to_s] = value
425
+ end
426
+ end
427
+
428
+ def sequence(name, start: 1, &block)
429
+ @definition.sequence(name, start: start, &block)
430
+ end
431
+ end
432
+
433
+ class CallableValue
434
+ def initialize(&block)
435
+ @block = block
436
+ end
437
+
438
+ def call(context)
439
+ @block.call(context)
440
+ end
441
+ end
442
+
443
+ class RecordContext
444
+ attr_reader :label, :variant, :index
445
+
446
+ def initialize(definition, label:, variant:, index:)
447
+ @definition = definition
448
+ @label = label
449
+ @variant = variant
450
+ @index = index
451
+ end
452
+
453
+ def sequence(name)
454
+ @definition.sequence(name)
455
+ end
456
+
457
+ def erb(source)
458
+ Erb.new(source: source)
459
+ end
460
+ end
461
+ end
462
+ end
data/lib/vial/dsl.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vial
4
+ module DSL
5
+ def vial(name, **options, &block)
6
+ Vial.define(name, **options, &block)
7
+ end
8
+ end
9
+ end
data/lib/vial/erb.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vial
4
+ Erb = Data.define(:source)
5
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vial
4
+ module ExplainId
5
+ module_function
6
+
7
+ def parse(query)
8
+ query = query.to_s.strip
9
+ match = query.match(/\[(\d+)\]\s*\z/)
10
+ index = match ? match[1].to_i : 1
11
+ query = query.sub(/\[(\d+)\]\s*\z/, '')
12
+
13
+ parts = query.split('.').map(&:strip).reject(&:empty?)
14
+ raise ArgumentError, "Invalid explain_id query: #{query}" if parts.length < 2
15
+
16
+ vial_name = normalize_vial_name(parts.shift)
17
+ label = parts.shift
18
+ variants = parts
19
+
20
+ [vial_name, label, variants, index]
21
+ end
22
+
23
+ def normalize_vial_name(name)
24
+ name.to_s.gsub('__', '/')
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vial
4
+ ExplicitId = Data.define(:value, :source_file, :source_line)
5
+ end