etna 0.1.27 → 0.1.28

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.
data/lib/etna.rb CHANGED
@@ -20,6 +20,7 @@ require_relative './etna/csvs'
20
20
  require_relative './etna/environment_scoped'
21
21
  require_relative './etna/filesystem'
22
22
  require_relative './etna/formatting'
23
+ require_relative './etna/cwl'
23
24
 
24
25
  class EtnaApp
25
26
  include Etna::Application
@@ -1 +1,2 @@
1
- require_relative 'formatting/models_csv'
1
+ require_relative 'formatting/models_csv'
2
+ require_relative 'formatting/models_odm_xml'
@@ -0,0 +1,293 @@
1
+ require 'nokogiri'
2
+
3
+ module Etna
4
+ module Clients
5
+ class Magma
6
+ module ModelsOdmXml
7
+ end
8
+ end
9
+ end
10
+ end
11
+
12
+ module Etna
13
+ module Clients
14
+ class Magma
15
+ module ModelsOdmXml
16
+ module Prettify
17
+ def prettify(name)
18
+ name.split('_').map(&:capitalize).join(' ')
19
+ end
20
+
21
+ def shorten(name)
22
+ name.gsub('_', '').capitalize
23
+ end
24
+ end
25
+
26
+ class Exporter
27
+ include Prettify
28
+
29
+ attr_reader :project_name, :models
30
+
31
+ def initialize(project_name:, models:)
32
+ @project_name = project_name
33
+ @models = models
34
+ end
35
+
36
+ def data_type_map
37
+ @data_type_map ||= begin
38
+ map = {}
39
+ map[Etna::Clients::Magma::AttributeType::STRING] = 'text'
40
+ map[Etna::Clients::Magma::AttributeType::DATE_TIME] = 'date'
41
+ map[Etna::Clients::Magma::AttributeType::BOOLEAN] = 'text'
42
+ map[Etna::Clients::Magma::AttributeType::FLOAT] = 'float'
43
+ map[Etna::Clients::Magma::AttributeType::INTEGER] = 'integer'
44
+
45
+ map
46
+ end
47
+ end
48
+
49
+ def redcap_field_type_map
50
+ @redcap_field_type_map ||= begin
51
+ map = {}
52
+ map[Etna::Clients::Magma::AttributeType::STRING] = 'textarea'
53
+ map[Etna::Clients::Magma::AttributeType::DATE_TIME] = 'text'
54
+ map[Etna::Clients::Magma::AttributeType::BOOLEAN] = 'radio'
55
+ map[Etna::Clients::Magma::AttributeType::FLOAT] = 'text'
56
+ map[Etna::Clients::Magma::AttributeType::INTEGER] = 'text'
57
+
58
+ map
59
+ end
60
+ end
61
+
62
+ def redcap_text_validation_map
63
+ @redcap_text_validation_map ||= begin
64
+ map = {}
65
+ map[Etna::Clients::Magma::AttributeType::DATE_TIME] = 'date_mdy'
66
+ map[Etna::Clients::Magma::AttributeType::FLOAT] = 'float'
67
+ map[Etna::Clients::Magma::AttributeType::INTEGER] = 'int'
68
+
69
+ map
70
+ end
71
+ end
72
+
73
+ def write_models(output_io: nil, filename: nil)
74
+ @document = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
75
+ xml.ODM(odm_headers) do
76
+ xml.Study(OID: "Project.#{shorten(project_name)}") do
77
+ end
78
+ global_variables(xml)
79
+ metadata(xml)
80
+ end
81
+ end
82
+
83
+ @document.to_xml
84
+ end
85
+
86
+ def odm_headers
87
+ {
88
+ xmlns: "http://www.cdisc.org/ns/odm/v1.3",
89
+ 'xmlns:ds': "http://www.w3.org/2000/09/xmldsig#",
90
+ 'xmlns:xsi': "http://www.w3.org/2001/XMLSchema-instance",
91
+ 'xmlns:redcap': "https://projectredcap.org",
92
+ 'xsi:schemaLocation': "http://www.cdisc.org/ns/odm/v1.3 schema/odm/ODM1-3-1.xsd",
93
+ ODMVersion: "1.3.2",
94
+ FileOID: "000-00-0000",
95
+ FileType: "Snapshot",
96
+ Description: project_name,
97
+ AsOfDateTime: DateTime.now,
98
+ CreationDateTime: DateTime.now,
99
+ SourceSystem: "Magma",
100
+ SourceSystemVersion: DateTime.now
101
+ }
102
+ end
103
+
104
+ def global_variables(xml)
105
+ # Includes general metadata about the project, as well as
106
+ # declarations of all repeating instruments,
107
+ # which seem like Timepoints to me.
108
+ # NOTE:
109
+ # <redcap:Purpose>0</redcap:Purpose>
110
+ # 0 = Practice / just for fun
111
+ # 1 = Operational Support
112
+ # 2 = Research
113
+ # 3 = Quality Improvement
114
+ # 4 = Other
115
+
116
+ xml.GlobalVariables do
117
+ xml.StudyName "#{project_name}"
118
+ xml.StudyDescription "#{project_name} - Data Library integration project"
119
+ xml.ProtocolName "#{project_name}"
120
+ xml.send('redcap:RecordAutonumberingEnabled', 1)
121
+ xml.send('redcap:CustomRecordLabel')
122
+ xml.send('redcap:SecondaryUniqueField')
123
+ xml.send('redcap:SchedulingEnabled', 0)
124
+ xml.send('redcap:SurveysEnabled', 0)
125
+ xml.send('redcap:SurveyInvitationEmailField')
126
+ xml.send('redcap:Purpose', 2) # 2 == research
127
+ xml.send('redcap:PurposeOther')
128
+ xml.send('redcap:ProjectNotes', "Used to easily ingest clinical data for #{project_name} into the Data Library.")
129
+ xml.send('redcap:MissingDataCodes')
130
+
131
+ repeating_instruments(xml)
132
+ end
133
+ end
134
+
135
+ def repeating_attribute_types
136
+ # We don't have a good indicator for what is a repeating
137
+ # attribute for REDCap...
138
+ # Will this work for models without timepoint, that go
139
+ # straight from subject -> sample?
140
+ models.model('subject').template.attributes.all.select do |attribute|
141
+ Etna::Clients::Magma::AttributeType::COLLECTION == attribute.attribute_type
142
+ end
143
+ end
144
+
145
+ def clinical_dictionaries
146
+ # Our indicator for if something needs a REDCap form will be any
147
+ # model with a dictionary.
148
+ models.all.select do |model|
149
+ model.template.raw['dictionary']
150
+ end.map do |model|
151
+ model.template.dictionary
152
+ end
153
+ end
154
+
155
+ def repeating_instruments(xml)
156
+ # Now we get into repeating instruments and events.
157
+ # From a Magma model perspective, this should be
158
+ # Timepoint that hangs off of
159
+ # a Subject model.
160
+ xml.send('redcap:RepeatingInstrumentsAndEvents') do
161
+ repeating_attribute_types.map do |repeating_attribute|
162
+ write_repeating_instrument_xml(xml, repeating_attribute)
163
+ end
164
+ end
165
+ end
166
+
167
+ def write_repeating_instrument_xml(xml, instrument)
168
+ node = xml.send('redcap:RepeatingInstrument')
169
+ node['redcap:UniqueEventName'] = 'event_1_arm_1'
170
+ node['redcap:RepeatInstrument'] = instrument.attribute_name
171
+ node['redcap:CustomLabel'] = instrument.display_name || instrument.attribute_name.capitalize
172
+
173
+ node
174
+ end
175
+
176
+ def metadata(xml)
177
+ # Includes form and field definitions
178
+ xml.MetaDataVersion(
179
+ OID: "Metadata.#{shorten(project_name)}_#{DateTime.now}",
180
+ Name: project_name,
181
+ 'redcap:RecordIdField': 'record_id'
182
+ ) do
183
+ clinical_dictionaries.map do |dictionary|
184
+ # Each Magma dictionary needs a FormDef, with
185
+ # ItemGroupRef children for each form page (?).
186
+ # Each ItemGroupRef requires a corresponding ItemGroupDef
187
+ # with ItemRef children for each input (?).
188
+ # Each ItemRef requires a correspdonding ItemDef
189
+ # that defines the label and type, and includes
190
+ # <Question> as a label (?).
191
+ # Option validations are present as a CodeList (with
192
+ # CodeListItem children).
193
+ write_form_def(xml, dictionary)
194
+ write_item_group_def(xml, dictionary)
195
+ write_item_def(xml, dictionary)
196
+ write_code_list(xml, dictionary)
197
+ end
198
+ end
199
+ end
200
+
201
+ def write_form_def(xml, dictionary)
202
+ xml.FormDef(
203
+ OID: "Form.#{dictionary.model_name}",
204
+ Name: dictionary.model_name.capitalize,
205
+ Repeating: "No",
206
+ 'redcap:FormName': dictionary.model_name
207
+ ) do
208
+ # Assume a single item group
209
+ xml.ItemGroupRef(
210
+ ItemGroupOID: "#{dictionary.model_name}.attributes",
211
+ Mandatory: "No"
212
+ ) do
213
+ end
214
+ end
215
+ end
216
+
217
+ def write_item_group_def(xml, dictionary)
218
+ xml.ItemGroupDef(
219
+ OID: "#{dictionary.model_name}.attributes",
220
+ Name: "#{dictionary.model_name.capitalize} Attributes",
221
+ Repeating: "No"
222
+ ) do
223
+ dictionary.attributes.keys.map do |attribute_name|
224
+ xml.ItemRef(
225
+ ItemOID: "#{dictionary.model_name}.#{attribute_name}", # Does this need to be unique across all items?
226
+ Mandatory: "No",
227
+ 'redcap:Variable': attribute_name # Does this need to be unique?
228
+ ) do
229
+ end
230
+ end
231
+ end
232
+ end
233
+
234
+ def write_item_def(xml, dictionary)
235
+ model_attributes = models.model(dictionary.model_name).template.attributes
236
+ dictionary.attributes.keys.map do |attribute_name|
237
+ attribute = model_attributes.attribute(attribute_name)
238
+ attribute_type = attribute.attribute_type
239
+ params = {
240
+ OID: "#{dictionary.model_name}.#{attribute_name}", # Does this need to be unique across all items?
241
+ Name: attribute_name,
242
+ DataType: data_type_map[attribute_type] || 'text',
243
+ 'redcap:Variable': attribute_name,
244
+ 'redcap:FieldType': redcap_field_type_map[attribute_type] || 'text',
245
+ Length: '999' # How could we infer shorter values?
246
+ }
247
+
248
+ params['redcap:TextValidationType'] = redcap_text_validation_map[attribute_type] if redcap_text_validation_map[attribute_type]
249
+ params['redcap:FieldNote'] = attribute.desc if attribute.desc
250
+ xml.ItemDef(params) do
251
+ xml.Question do
252
+ xml.send('TranslatedText', attribute_name.capitalize)
253
+ end
254
+
255
+ if attribute.validation && Etna::Clients::Magma::AttributeValidationType::ARRAY == attribute.validation['type']
256
+ xml.CodeListRef(
257
+ CodeListOID: "#{dictionary.model_name}.#{attribute_name}.choices"
258
+ ) do
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
264
+
265
+ def write_code_list(xml, dictionary)
266
+ model_attributes = models.model(dictionary.model_name).template.attributes
267
+ dictionary.attributes.keys.map do |attribute_name|
268
+ attribute = model_attributes.attribute(attribute_name)
269
+ if attribute.validation && Etna::Clients::Magma::AttributeValidationType::ARRAY == attribute.validation['type']
270
+ xml.CodeList(
271
+ OID: "#{dictionary.model_name}.#{attribute_name}.choices",
272
+ Name: "#{attribute_name}",
273
+ DataType: "text",
274
+ 'redcap:Variable': attribute_name
275
+ ) do
276
+ attribute.validation['value'].map do |option|
277
+ xml.CodeListItem(
278
+ CodedValue: option
279
+ ) do
280
+ xml.Decode() do
281
+ xml.send('TranslatedText', option)
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end
@@ -97,8 +97,9 @@ module Etna
97
97
 
98
98
  dest_file = File.join(dest_dir, metadata_file_name(record_name: record[template.identifier], record_model_name: template.name, ext: '.json'))
99
99
  filesystem.mkdir_p(File.dirname(dest_file))
100
- filesystem.with_writeable(dest_file, "w") do |io|
101
- io.write(record_to_serialize.to_json)
100
+ json = record_to_serialize.to_json
101
+ filesystem.with_writeable(dest_file, "w", size_hint: json.bytes.length) do |io|
102
+ io.write(json)
102
103
  end
103
104
  end
104
105
 
data/lib/etna/command.rb CHANGED
@@ -39,6 +39,10 @@ module Etna
39
39
  def string_flags
40
40
  @string_flags ||= []
41
41
  end
42
+
43
+ def multi_flags
44
+ @multi_flags ||= []
45
+ end
42
46
  end
43
47
 
44
48
  def flag_as_parameter(flag)
@@ -69,6 +73,12 @@ module Etna
69
73
  else
70
74
  flags[arg_name] = args.shift
71
75
  end
76
+ elsif self.class.multi_flags.include?(next_arg)
77
+ if args.empty?
78
+ raise "flag #{next_arg} requires an argument"
79
+ else
80
+ (flags[arg_name] ||= []) << args.shift
81
+ end
72
82
  elsif !found_non_flag
73
83
  raise "#{program_name} does not recognize flag #{next_arg}"
74
84
  else
data/lib/etna/cwl.rb ADDED
@@ -0,0 +1,697 @@
1
+ require 'yaml'
2
+
3
+ module Etna
4
+ class Cwl
5
+ FIELD_LOADERS = {}
6
+
7
+ def initialize(attributes)
8
+ @attributes = attributes
9
+ end
10
+
11
+ def self.loader
12
+ Etna::Cwl::RecordLoader.new(self)
13
+ end
14
+
15
+ def self.as_json(obj)
16
+ if obj.is_a?(Cwl)
17
+ as_json(obj.instance_variable_get(:@attributes))
18
+ elsif obj.is_a?(Hash)
19
+ {}.tap do |result|
20
+ obj.each do |k, v|
21
+ result[k] = as_json(v)
22
+ end
23
+ end
24
+ elsif obj.is_a?(Array)
25
+ obj.map { |v| as_json(v) }
26
+ else
27
+ obj
28
+ end
29
+ end
30
+
31
+ def as_json
32
+ self.class.as_json(@attributes)
33
+ end
34
+
35
+ class Loader
36
+ def load(val)
37
+ raise "Unimplemented"
38
+ end
39
+
40
+ def optional
41
+ OptionalLoader.new(self)
42
+ end
43
+
44
+ def map(&block)
45
+ FunctorMapLoader.new(self, &block)
46
+ end
47
+
48
+ def as_mapped_array(id_key = nil, value_key = nil)
49
+ MapLoader.new(self.as_array, id_key, value_key)
50
+ end
51
+
52
+ def or(*alternatives)
53
+ UnionLoader.new(self, *alternatives)
54
+ end
55
+
56
+ def as_array
57
+ ArrayLoader.new(self)
58
+ end
59
+ end
60
+
61
+ class AnyLoader < Loader
62
+ def load(val)
63
+ val
64
+ end
65
+
66
+ ANY = AnyLoader.new
67
+ end
68
+
69
+ class PrimitiveLoader < Loader
70
+ def initialize(name, type)
71
+ @name = name
72
+ @type = type
73
+ end
74
+
75
+ def load(val)
76
+ unless val.is_a?(@type)
77
+ raise "Unexpected val #{val.inspect} for #{@name} type"
78
+ end
79
+
80
+ val
81
+ end
82
+
83
+ def name
84
+ @name
85
+ end
86
+
87
+ def self.find_primitive_type_loader(type_name)
88
+ constants.each do |c|
89
+ c = const_get(c)
90
+ if c.is_a?(Loader)
91
+ return c if c.name == type_name
92
+ end
93
+ end
94
+ end
95
+
96
+ STRING = PrimitiveLoader.new('string', String)
97
+ INT = PrimitiveLoader.new('int', Integer)
98
+ LONG = PrimitiveLoader.new('long', Integer)
99
+ FLOAT = PrimitiveLoader.new('float', Float)
100
+ DOUBLE = PrimitiveLoader.new('double', Float)
101
+ NULL = PrimitiveLoader.new('null', NilClass)
102
+
103
+ class BooleanLoader < Loader
104
+ def name
105
+ 'boolean'
106
+ end
107
+
108
+ def load(val)
109
+ raise "Invalid value #{val.inspect} for boolean" unless val.instance_of?(TrueClass) || val.instance_of?(FalseClass)
110
+ val
111
+ end
112
+ end
113
+
114
+ BOOLEAN = BooleanLoader.new
115
+ end
116
+
117
+ class SourceLoader < Loader
118
+ # Resolves a string of the forms "a-primary-identifier" or "step_name/output" into
119
+ # [:primary_inputs, "a-primary-identifier"] or
120
+ # ["step_name", "output"] respectively
121
+ def load(val)
122
+ parts = []
123
+
124
+ if val.is_a?(Symbol)
125
+ val = val.to_s
126
+ end
127
+
128
+ if val.is_a?(Array)
129
+ parts = PrimitiveLoader::STRING.as_array.load(val)
130
+ elsif val.is_a?(String)
131
+ parts = val.split('/', max = 2)
132
+ end
133
+
134
+ if parts.length == 1
135
+ return [:primary_inputs, parts[0]]
136
+ elsif parts.length == 2
137
+ return parts
138
+ end
139
+
140
+ raise "Unexpected value for source #{val.inspect}"
141
+ end
142
+ end
143
+
144
+ class StrictMapLoader < Loader
145
+ def initialize(items, keys)
146
+ @items = items
147
+ @keys = keys
148
+ end
149
+
150
+ def load(val)
151
+ if val.is_a?(Hash)
152
+ val.map do |k, v|
153
+ [@keys.load(k), @items.load(v)]
154
+ end.to_h
155
+ else
156
+ raise "Unexpected val #{val.inspect} for hash"
157
+ end
158
+ end
159
+ end
160
+
161
+ class MapLoader < Loader
162
+ def initialize(items, idKey = nil, valueKey = nil)
163
+ @items = items
164
+ @idKey = idKey
165
+ @valueKey = valueKey
166
+ end
167
+
168
+ def load(val)
169
+ if val.is_a?(Hash)
170
+ val = [].tap do |result|
171
+ errors = {}
172
+ val.keys.sort.each do |k|
173
+ begin
174
+ v = val[k]
175
+ if v.is_a?(Hash)
176
+ v[@idKey] = k
177
+ else
178
+ v = {@idKey => k, @valueKey => v}
179
+ end
180
+
181
+ result << v
182
+ rescue => e
183
+ errors[k] = e.to_s
184
+ end
185
+ end
186
+
187
+ unless errors.empty?
188
+ raise errors.map { |k, v| "#{k}: #{v}" }.join("\n")
189
+ end
190
+ end
191
+ end
192
+
193
+ @items.load(val)
194
+ end
195
+ end
196
+
197
+ class ArrayLoader < Loader
198
+ def initialize(items)
199
+ @items = items
200
+ end
201
+
202
+ def load(val)
203
+ unless val.is_a?(Array)
204
+ raise "Unexpected val #{val.inspect} for array"
205
+ end
206
+
207
+ [].tap do |result|
208
+ errors = []
209
+ val.each do |item|
210
+ begin
211
+ loaded = Cwl.load_item(item, UnionLoader.new(self, @items))
212
+ if loaded.is_a?(Array)
213
+ result.push(*loaded)
214
+ else
215
+ result << loaded
216
+ end
217
+ rescue => e
218
+ errors << e.to_s
219
+ end
220
+ end
221
+
222
+ unless errors.empty?
223
+ raise errors.join("\n")
224
+ end
225
+ end
226
+ end
227
+ end
228
+
229
+ class EnumLoader < Loader
230
+ def initialize(*options)
231
+ @options = options
232
+ end
233
+
234
+ def load(val)
235
+ if @options.include?(val)
236
+ return val
237
+ end
238
+
239
+ raise "Value #{val.inspect} does not belong to one of (#{@options.join(', ')})"
240
+ end
241
+
242
+ PRIMITIVE_TYPE = EnumLoader.new("null", "boolean", "int", "long", "float", "double", "string")
243
+ NOMINAL_TYPE = EnumLoader.new("File")
244
+ end
245
+
246
+ class OptionalLoader < Loader
247
+ def initialize(inner_loader)
248
+ @inner_loader = inner_loader
249
+ end
250
+
251
+ def load(val)
252
+ if val.nil?
253
+ return nil
254
+ end
255
+
256
+ @inner_loader.load(val)
257
+ end
258
+ end
259
+
260
+ class RecordLoader < Loader
261
+ def initialize(klass, field_loaders = nil)
262
+ @klass = klass
263
+ @field_loaders = field_loaders
264
+ end
265
+
266
+ def field_loaders
267
+ @field_loaders || @klass::FIELD_LOADERS
268
+ end
269
+
270
+ def load(val)
271
+ unless val.is_a?(Hash)
272
+ raise "Unexpected value #{val.inspect} for type #{@klass.name}"
273
+ end
274
+
275
+ errors = {}
276
+ @klass.new({}.tap do |result|
277
+ field_loaders.each do |field_sym, loader|
278
+ field_str = field_sym.to_s
279
+ begin
280
+ result[field_str] = loader.load(val[field_str])
281
+ rescue => e
282
+ errors[field_str] = e.to_s
283
+ end
284
+ end
285
+
286
+ unless errors.empty?
287
+ raise errors.map { |k, e| "#{k}: #{e}" }.join(',')
288
+ end
289
+ end)
290
+ end
291
+ end
292
+
293
+ class NeverLoader < Loader
294
+ def load(val)
295
+ raise "This feature is not supported"
296
+ end
297
+
298
+ UNSUPPORTED = NeverLoader.new.optional
299
+ end
300
+
301
+ class UnionLoader < Loader
302
+ def initialize(*alternatives)
303
+ @alternatives = alternatives
304
+ end
305
+
306
+ def load(val)
307
+ errors = []
308
+ @alternatives.each do |loader|
309
+ begin
310
+ return loader.load(val)
311
+ rescue => e
312
+ errors << e.to_s
313
+ end
314
+ end
315
+
316
+ raise errors.join(", ")
317
+ end
318
+ end
319
+
320
+ class ArrayType < Cwl
321
+ class InnerLoader < Loader
322
+ def load(val)
323
+ RecordLoader.new(ArrayType, {
324
+ type: EnumLoader.new("array"),
325
+ items: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
326
+ }).load(val)
327
+ end
328
+ end
329
+
330
+ def type_loader
331
+ loader = RecordType::Field.type_loader(@attributes['items'])
332
+ return nil if loader.nil?
333
+ @type_loader ||= ArrayLoader.new(loader)
334
+ end
335
+ end
336
+
337
+ class EnumType < Cwl
338
+ class InnerLoader < Loader
339
+ def load(val)
340
+ RecordLoader.new(EnumType, {
341
+ type: EnumLoader.new("enum"),
342
+ symbols: PrimitiveLoader::STRING.as_array,
343
+ }).load(val)
344
+ end
345
+ end
346
+
347
+ def type_loader
348
+ @type_loader ||= EnumLoader.new(*@attributes['symbols'])
349
+ end
350
+ end
351
+
352
+ class RecordType < Cwl
353
+ class RecordTypeLoader
354
+ def load(val)
355
+ RecordLoader.new(RecordType, {
356
+ type: EnumLoader.new("record"),
357
+ fields: Field::FieldLoader.new.as_mapped_array('name', 'type')
358
+ }).load(val)
359
+ end
360
+ end
361
+
362
+ class Record
363
+ def self.new(h)
364
+ h
365
+ end
366
+ end
367
+
368
+ def type_loader
369
+ @type_loader ||= begin
370
+ record_class = Class.new(Record)
371
+ RecordLoader.new(record_class, @attributes['fields'].map do |field|
372
+ loader = Field.type_loader(field.type)
373
+ return nil if loader.nil?
374
+ [field.name.to_sym, loader]
375
+ end.to_h)
376
+ end
377
+ end
378
+
379
+ class Field < Cwl
380
+ class FieldLoader < Loader
381
+ def load(val)
382
+ RecordLoader.new(Field, {
383
+ name: PrimitiveLoader::STRING,
384
+ type: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
385
+ doc: PrimitiveLoader::STRING.optional,
386
+ }).load(val)
387
+ end
388
+ end
389
+
390
+ def name
391
+ @attributes['name']
392
+ end
393
+
394
+ def type
395
+ @attributes['type']
396
+ end
397
+
398
+ def type_loader
399
+ self.class.type_loader(self.type)
400
+ end
401
+
402
+ def self.type_loader(type)
403
+ case type
404
+ when Array
405
+ type_loaders = type.map { |t| Field.type_loader(t) }
406
+ return nil if type_loaders.any?(&:nil?)
407
+ UnionLoader.new(*type_loaders)
408
+ when EnumType
409
+ type.type_loader
410
+ when RecordType
411
+ type.type_loader
412
+ when ArrayType
413
+ type.type_loader
414
+ when String
415
+ PrimitiveLoader.find_primitive_type_loader(type)
416
+ else
417
+ raise "Could not determine loader for type #{type.inspect}"
418
+ end
419
+ end
420
+ end
421
+ end
422
+
423
+ class FunctorMapLoader < Loader
424
+ def initialize(inner, &block)
425
+ @block = block
426
+ @inner = inner
427
+ end
428
+
429
+ def load(val)
430
+ @block.call(@inner.load(val))
431
+ end
432
+ end
433
+
434
+ # Prepares a unique set of structured nominal types for an inner
435
+ # loading of types
436
+ class TypedDSLLoader < Loader
437
+ def initialize(inner)
438
+ @inner = inner
439
+ end
440
+
441
+ REGEX = /^([^\[?]+)(\[\])?(\?)?$/
442
+
443
+ def resolve(val)
444
+ m = REGEX.match(val)
445
+
446
+ unless m.nil?
447
+ type = m[1]
448
+ unless m[2].nil?
449
+ type = {'type' => 'array', 'items' => type}
450
+ end
451
+ unless m[3].nil?
452
+ type = ["null", type]
453
+ end
454
+
455
+ return type
456
+ end
457
+
458
+ val
459
+ end
460
+
461
+ def load(val)
462
+ if val.is_a?(Array)
463
+ @inner.load(val.map do |item|
464
+ item.is_a?(String) ? resolve(item) : item
465
+ end)
466
+ elsif val.is_a?(String)
467
+ @inner.load(resolve(val))
468
+ else
469
+ @inner.load(val)
470
+ end
471
+ end
472
+
473
+ OUTER_TYPE_LOADER = TypedDSLLoader.new(
474
+ UnionLoader.new(
475
+ RecordType::RecordTypeLoader.new,
476
+ ArrayType::InnerLoader.new,
477
+ EnumType::InnerLoader.new,
478
+ EnumLoader::PRIMITIVE_TYPE,
479
+ EnumLoader::NOMINAL_TYPE,
480
+ )
481
+ )
482
+
483
+ WITH_UNIONS_TYPE_LOADER = TypedDSLLoader.new(
484
+ UnionLoader.new(
485
+ OUTER_TYPE_LOADER,
486
+ ArrayLoader.new(OUTER_TYPE_LOADER),
487
+ )
488
+ )
489
+ end
490
+
491
+ def self.load_item(val, field_type)
492
+ if val.is_a?(Hash)
493
+ if val.include?("$import")
494
+ raise "$import expressions are not yet supported"
495
+ elsif val.include?("$include")
496
+ raise "$include expressions are not yet supported"
497
+ end
498
+ end
499
+
500
+ return field_type.load(val)
501
+ end
502
+
503
+ class InputParameter < Cwl
504
+ FIELD_LOADERS = {
505
+ id: PrimitiveLoader::STRING.optional,
506
+ label: PrimitiveLoader::STRING.optional,
507
+ secondaryFiles: NeverLoader::UNSUPPORTED,
508
+ streamable: NeverLoader::UNSUPPORTED,
509
+ loadContents: NeverLoader::UNSUPPORTED,
510
+ loadListing: NeverLoader::UNSUPPORTED,
511
+ valueFrom: NeverLoader::UNSUPPORTED,
512
+ doc: PrimitiveLoader::STRING.optional,
513
+
514
+ type: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
515
+ default: AnyLoader::ANY.optional,
516
+ format: PrimitiveLoader::STRING.optional,
517
+ }
518
+
519
+ def default
520
+ default = @attributes['default']
521
+ return nil unless default
522
+ RecordType::Field.type_loader(@attributes['type'])&.load(default)
523
+ end
524
+ end
525
+
526
+ class OutputParameter < Cwl
527
+ FIELD_LOADERS = {
528
+ id: PrimitiveLoader::STRING.optional,
529
+ label: PrimitiveLoader::STRING.optional,
530
+ secondaryFiles: NeverLoader::UNSUPPORTED,
531
+ streamable: NeverLoader::UNSUPPORTED,
532
+ doc: PrimitiveLoader::STRING.optional,
533
+
534
+ outputBinding: NeverLoader::UNSUPPORTED,
535
+ type: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
536
+ format: PrimitiveLoader::STRING.optional,
537
+ }
538
+ end
539
+
540
+ class WorkflowOutputParameter < Cwl
541
+ FIELD_LOADERS = {
542
+ id: PrimitiveLoader::STRING,
543
+ label: PrimitiveLoader::STRING.optional,
544
+ secondaryFiles: NeverLoader::UNSUPPORTED,
545
+ streamable: NeverLoader::UNSUPPORTED,
546
+ linkMerge: NeverLoader::UNSUPPORTED,
547
+ pickValue: NeverLoader::UNSUPPORTED,
548
+ doc: PrimitiveLoader::STRING.optional,
549
+
550
+ outputSource: SourceLoader.new,
551
+ type: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
552
+ format: PrimitiveLoader::STRING.optional,
553
+ }
554
+
555
+ def outputSource
556
+ @attributes['outputSource']
557
+ end
558
+
559
+ def id
560
+ @attributes['id']
561
+ end
562
+ end
563
+
564
+ class WorkflowInputParameter < Cwl
565
+ FIELD_LOADERS = {
566
+ id: PrimitiveLoader::STRING,
567
+ label: PrimitiveLoader::STRING.optional,
568
+ secondaryFiles: NeverLoader::UNSUPPORTED,
569
+ streamable: NeverLoader::UNSUPPORTED,
570
+ loadContents: NeverLoader::UNSUPPORTED,
571
+ loadListing: NeverLoader::UNSUPPORTED,
572
+ doc: PrimitiveLoader::STRING.optional,
573
+ inputBinding: NeverLoader::UNSUPPORTED,
574
+
575
+ default: AnyLoader::ANY,
576
+ type: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
577
+ format: PrimitiveLoader::STRING.optional,
578
+ }
579
+
580
+ def id
581
+ @attributes['id']
582
+ end
583
+
584
+ def default
585
+ @attributes['default']
586
+ end
587
+ end
588
+
589
+ class StepOutput < Cwl
590
+ FIELD_LOADERS = {
591
+ id: PrimitiveLoader::STRING,
592
+ }
593
+
594
+ def id
595
+ @attributes['id']
596
+ end
597
+ end
598
+
599
+
600
+ class StepInput < Cwl
601
+ FIELD_LOADERS = {
602
+ id: PrimitiveLoader::STRING.optional,
603
+ source: SourceLoader.new.optional,
604
+ label: PrimitiveLoader::STRING.optional,
605
+ linkMerge: NeverLoader::UNSUPPORTED,
606
+ pickValue: NeverLoader::UNSUPPORTED,
607
+ loadContents: NeverLoader::UNSUPPORTED,
608
+ loadListing: NeverLoader::UNSUPPORTED,
609
+ valueFrom: NeverLoader::UNSUPPORTED,
610
+ default: AnyLoader::ANY.optional,
611
+ }
612
+
613
+ def id
614
+ @attributes['id']
615
+ end
616
+
617
+ def source
618
+ @attributes['source']
619
+ end
620
+ end
621
+
622
+ class Operation < Cwl
623
+ FIELD_LOADERS = {
624
+ id: PrimitiveLoader::STRING.optional,
625
+ label: PrimitiveLoader::STRING.optional,
626
+ doc: PrimitiveLoader::STRING.optional,
627
+ requirements: NeverLoader::UNSUPPORTED,
628
+ hints: NeverLoader::UNSUPPORTED,
629
+ cwlVersion: EnumLoader.new("v1.0", "v1.1", "v1.2").optional,
630
+ intent: NeverLoader::UNSUPPORTED,
631
+ class: EnumLoader.new("Operation"),
632
+ inputs: InputParameter.loader.as_mapped_array('id', 'type'),
633
+ outputs: OutputParameter.loader.as_mapped_array('id', 'type'),
634
+ }
635
+
636
+ def id
637
+ @attributes['id']
638
+ end
639
+ end
640
+
641
+ class Step < Cwl
642
+ FIELD_LOADERS = {
643
+ id: PrimitiveLoader::STRING.optional,
644
+ label: PrimitiveLoader::STRING.optional,
645
+ doc: PrimitiveLoader::STRING.optional,
646
+ in: StepInput.loader.as_mapped_array('id', 'source'),
647
+ out: StepOutput.loader.or(PrimitiveLoader::STRING.map { |id| StepOutput.loader.load({'id' => id}) }).as_array,
648
+ requirements: NeverLoader::UNSUPPORTED,
649
+ hints: NeverLoader::UNSUPPORTED,
650
+ run: PrimitiveLoader::STRING.map { |id| Operation.loader.load({'id' => id, 'class' => 'Operation', 'inputs' => [], 'outputs' => []}) }.or(Operation.loader),
651
+ when: NeverLoader::UNSUPPORTED,
652
+ scatter: NeverLoader::UNSUPPORTED,
653
+ scatterMethod: NeverLoader::UNSUPPORTED,
654
+ }
655
+
656
+ def id
657
+ @attributes['id']
658
+ end
659
+
660
+ def in
661
+ @attributes['in']
662
+ end
663
+
664
+ def out
665
+ @attributes['out']
666
+ end
667
+ end
668
+
669
+ class Workflow < Cwl
670
+ FIELD_LOADERS = {
671
+ id: PrimitiveLoader::STRING.optional,
672
+ label: PrimitiveLoader::STRING.optional,
673
+ doc: PrimitiveLoader::STRING.optional,
674
+ requirements: NeverLoader::UNSUPPORTED,
675
+ hints: NeverLoader::UNSUPPORTED,
676
+ intent: NeverLoader::UNSUPPORTED,
677
+ class: EnumLoader.new("Workflow"),
678
+ cwlVersion: EnumLoader.new("v1.0", "v1.1", "v1.2"),
679
+ inputs: WorkflowInputParameter.loader.as_mapped_array('id', 'type'),
680
+ outputs: WorkflowOutputParameter.loader.as_mapped_array('id', 'type'),
681
+ steps: Step.loader.as_mapped_array('id', 'source')
682
+ }
683
+
684
+ def inputs
685
+ @attributes['inputs']
686
+ end
687
+
688
+ def outputs
689
+ @attributes['outputs']
690
+ end
691
+
692
+ def steps
693
+ @attributes['steps']
694
+ end
695
+ end
696
+ end
697
+ end