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,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
|
data/lib/vial/config.rb
ADDED
|
@@ -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
data/lib/vial/erb.rb
ADDED
|
@@ -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
|