etna 0.1.18 → 0.1.23

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.
@@ -5,10 +5,6 @@ require 'tempfile'
5
5
  require_relative 'helpers'
6
6
  require 'yaml'
7
7
 
8
- # /bin/etna will confirm execution before running any command that includes this module.
9
- module RequireConfirmation
10
- end
11
-
12
8
  class EtnaApp
13
9
  def self.config_file_path
14
10
  File.join(Dir.home, 'etna.yml')
@@ -27,9 +23,9 @@ class EtnaApp
27
23
  elsif @config && @config.is_a?(Hash) && @config.keys.length == 1
28
24
  @config.keys.last.to_sym
29
25
  elsif @config && @config.is_a?(Hash) && @config.keys.length > 1
30
- raise "You have multiple environments configured, please specify your environment by adding --environment #{@config.keys.join("|")}"
26
+ :many
31
27
  else
32
- raise "You do not have a successfully configured environment, please run #{program_name} config set https://polyphemus.ucsf.edu"
28
+ :none
33
29
  end
34
30
  end
35
31
 
@@ -41,11 +37,8 @@ class EtnaApp
41
37
  include Etna::CommandExecutor
42
38
 
43
39
  class Show < Etna::Command
44
-
45
- boolean_flags << '--all'
46
-
47
- def execute(all: false)
48
- if all
40
+ def execute
41
+ if EtnaApp.instance.environment == :many
49
42
  File.open(EtnaApp.config_file_path, 'r') { |f| puts f.read }
50
43
  else
51
44
  puts "Current environment: #{EtnaApp.instance.environment}"
@@ -61,15 +54,16 @@ class EtnaApp
61
54
  boolean_flags << '--ignore-ssl'
62
55
 
63
56
  def execute(host, ignore_ssl: false)
64
- polyphemus_client ||= Etna::Clients::Polyphemus.new(
57
+ polyphemus_client = Etna::Clients::Polyphemus.new(
65
58
  host: host,
66
- token: token,
59
+ token: token(ignore_environment: true),
60
+ persistent: false,
67
61
  ignore_ssl: ignore_ssl)
68
62
  workflow = Etna::Clients::Polyphemus::SetConfigurationWorkflow.new(
69
63
  polyphemus_client: polyphemus_client,
70
64
  config_file: EtnaApp.config_file_path)
71
65
  config = workflow.update_configuration_file(ignore_ssl: ignore_ssl)
72
- logger.info("Updated #{config.environment} configuration from #{host}.")
66
+ logger&.info("Updated #{config.environment} configuration from #{host}.")
73
67
  end
74
68
 
75
69
  def setup(config)
@@ -150,7 +144,7 @@ class EtnaApp
150
144
  tf = Tempfile.new
151
145
 
152
146
  begin
153
- File.open(tf.path, 'wb') { |f| workflow.write_models_template_csv(f, project_name, target_model) }
147
+ File.open(tf.path, 'wb') { |f| workflow.write_models_template_csv(project_name, target_model, io: f) }
154
148
  FileUtils.cp(tf.path, file)
155
149
  ensure
156
150
  tf.close!
@@ -233,7 +227,7 @@ class EtnaApp
233
227
 
234
228
  @last_load = File.stat(file).mtime
235
229
  @changeset = File.open(file, 'r') do |f|
236
- workflow.prepare_changeset_from_csv(f) do |err|
230
+ workflow.prepare_changeset_from_csv(io: f) do |err|
237
231
  @errors << err
238
232
  end
239
233
  end
@@ -253,7 +247,9 @@ class EtnaApp
253
247
  class UpdateFromCsv < Etna::Command
254
248
  include WithEtnaClients
255
249
  include WithLogger
256
- include RequireConfirmation
250
+
251
+ boolean_flags << '--json-values'
252
+ string_flags << '--hole-value'
257
253
 
258
254
  def magma_crud
259
255
  @magma_crud ||= Etna::Clients::Magma::MagmaCrudWorkflow.new(
@@ -261,17 +257,92 @@ class EtnaApp
261
257
  project_name: @project_name)
262
258
  end
263
259
 
264
- def execute(project_name, model_name, filepath)
260
+ def execute(project_name, model_name, filepath, hole_value: '_', json_values: false)
265
261
  @project_name = project_name
266
262
 
267
263
  update_attributes_workflow = Etna::Clients::Magma::UpdateAttributesFromCsvWorkflowSingleModel.new(
268
264
  magma_crud: magma_crud,
269
265
  project_name: project_name,
270
266
  model_name: model_name,
271
- filepath: filepath)
267
+ filepath: filepath,
268
+ hole_value: hole_value,
269
+ json_values: json_values)
272
270
  update_attributes_workflow.update_attributes
273
271
  end
274
272
  end
273
+
274
+ class CreateFileLinkingCsv < Etna::Command
275
+ include WithEtnaClients
276
+
277
+ string_flags << '--file'
278
+ string_flags << '--regex'
279
+ string_flags << '--folder'
280
+ boolean_flags << '--collection'
281
+
282
+ def execute(project_name, bucket_name, attribute_name, extension, collection: false, regex: "**/*/(?<identifier>.+)\\.#{extension}$", folder: "", file: "#{project_name}_#{attribute_name}.csv")
283
+ if folder.start_with?("/")
284
+ folder = folder.slice(1..-1)
285
+ end
286
+
287
+ regex = Regexp.new(regex)
288
+
289
+ workflow = Etna::Clients::Magma::SimpleFileLinkingWorkflow.new(
290
+ metis_client: metis_client,
291
+ project_name: project_name,
292
+ bucket_name: bucket_name,
293
+ folder: folder,
294
+ extension: extension,
295
+ attribute_name: attribute_name,
296
+ regex: regex,
297
+ file_collection: collection,
298
+ )
299
+
300
+ workflow.write_csv_io(filename: file)
301
+ end
302
+ end
303
+
304
+ class LoadTableFromCsv < Etna::Command
305
+ include WithEtnaClients
306
+
307
+ boolean_flags << '--execute'
308
+
309
+ def execute(project_name, model_name, file_path, execute: false)
310
+ request = Etna::Clients::Magma::RetrievalRequest.new(project_name: project_name)
311
+ request.model_name = model_name
312
+ request.attribute_names = 'all'
313
+ request.record_names = 'all'
314
+ model = magma_client.retrieve(request).models.model(model_name)
315
+ model_parent_name = model.template.attributes.all.select do |attribute|
316
+ attribute.attribute_type == Etna::Clients::Magma::AttributeType::PARENT
317
+ end.first.name
318
+
319
+ other_attribute_names = model.template.attributes.all.reject do |attribute|
320
+ attribute.attribute_type == Etna::Clients::Magma::AttributeType::PARENT
321
+ end.map do |attribute|
322
+ attribute.name
323
+ end
324
+
325
+ # NOTE: This does not call ensure_parent currently because of MVIR1 consent--
326
+ # if the timepoint doesn't exist, the patient may be no study? (one example, at least)
327
+ update_request = Etna::Clients::Magma::UpdateRequest.new(project_name: project_name)
328
+
329
+ data = CSV.parse(File.read(file_path), headers: true)
330
+
331
+ data.by_row.each do |row|
332
+ revision = {}
333
+ other_attribute_names.each do |attribute_name|
334
+ revision[attribute_name] = row[attribute_name] unless row[attribute_name].nil?
335
+ end
336
+ update_request.append_table(model_parent_name, row[model_parent_name], model_name, revision)
337
+ end
338
+
339
+ puts update_request
340
+
341
+ if execute
342
+ magma_client.update_json(update_request)
343
+ end
344
+ end
345
+ end
275
346
  end
276
347
  end
277
348
  end
@@ -16,6 +16,7 @@ require_relative './etna/client'
16
16
  require_relative './etna/symbolize_params'
17
17
  require_relative './etna/spec'
18
18
  require_relative './etna/clients'
19
+ require_relative './etna/csvs'
19
20
  require_relative './etna/environment_scoped'
20
21
 
21
22
  class EtnaApp
@@ -1,342 +1,351 @@
1
1
  require 'ostruct'
2
+ require_relative '../../../csvs'
2
3
 
3
4
  module Etna
4
5
  module Clients
5
6
  class Magma
6
- class ModelsCsv
7
- COPY_OPTIONS_SENTINEL = '$copy_from_original$'
8
- COLUMNS = [
9
- :comments,
10
- :model_name, :identifier, :parent_model_name, :parent_link_type,
11
- :attribute_name,
12
- :new_attribute_name,
13
- :attribute_type,
14
- :link_model_name,
15
- :description,
16
- :display_name,
17
- :format_hint,
18
- :restricted,
19
- :read_only,
20
- :options,
21
- :match,
22
- :attribute_group,
23
- :hidden,
24
- :unique,
25
- :matrix_constant,
26
- ]
27
-
28
- COLUMN_AS_BOOLEAN = -> (s) { ['true', 't', 'y', 'yes'].include?(s.downcase) }
29
-
30
- COLUMNS_TO_ATTRIBUTES = {
31
- attribute_name: :attribute_name,
32
- attribute_type: [:attribute_type, -> (s) { AttributeType.new(s) }],
33
- link_model_name: :link_model_name,
34
- description: :desc,
35
- display_name: :display_name,
36
- format_hint: :format_hint,
37
- restricted: [:restricted, COLUMN_AS_BOOLEAN],
38
- read_only: [:read_only, COLUMN_AS_BOOLEAN],
39
- options: [:validation, -> (s) { {"type" => "Array", "value" => s.split(',').map(&:strip)} }],
40
- match: [:validation, -> (s) { {"type" => "Regexp", "value" => Regexp.new(s).source} }],
41
- attribute_group: :attribute_group,
42
- hidden: [:hidden, COLUMN_AS_BOOLEAN],
43
- unique: [:unique, COLUMN_AS_BOOLEAN],
44
- }
45
-
46
- def self.apply_csv_row(changeset = ModelsChangeset.new, row = {}, &err_block)
47
- changeset.tap do
48
- models = changeset.models
7
+ module ModelsCsv
8
+ module Prettify
9
+ def prettify(name)
10
+ name.split('_').map(&:capitalize).join(' ')
11
+ end
12
+ end
49
13
 
50
- if (matrix_constant = self.get_col_or_nil(row, :matrix_constant))
51
- if matrix_constant.start_with?(COPY_OPTIONS_SENTINEL)
52
- matrix_constant = matrix_constant.slice((COPY_OPTIONS_SENTINEL.length)..-1)
53
- changeset.last_matrix_constant = matrix_constant
54
- else
55
- (changeset.matrix_constants[changeset.last_matrix_constant] ||= []) << matrix_constant
56
- end
57
- end
14
+ class Exporter < Etna::CsvExporter
15
+ include Prettify
16
+
17
+ def initialize
18
+ super([
19
+ :comments,
20
+ :model_name, :identifier, :parent_model_name, :parent_link_type,
21
+ :attribute_name,
22
+ :new_attribute_name,
23
+ :attribute_type,
24
+ :link_model_name,
25
+ :description,
26
+ :display_name,
27
+ :format_hint,
28
+ :restricted,
29
+ :read_only,
30
+ :options,
31
+ :match,
32
+ :attribute_group,
33
+ :hidden,
34
+ :unique,
35
+ :matrix_constant,
36
+ ])
37
+ end
58
38
 
59
- template = if (model_name = self.get_col_or_nil(row, :model_name))
60
- changeset.last_model_key = model_name
61
- models.build_model(model_name).build_template.tap { |t| t.name = model_name }
62
- else
63
- last_model = changeset.last_model_key
64
- if last_model.nil?
65
- nil
66
- else
67
- models.model(last_model).build_template
68
- end
39
+ def write_models(models, model_keys = models.model_keys.sort, output_io: nil, filename: nil)
40
+ with_row_writeable(filename: filename, output_io: output_io) do |row_writeable|
41
+ write_model_rows(models, model_keys, row_writeable)
69
42
  end
43
+ end
70
44
 
71
- if (identifier = self.get_col_or_nil(row, :identifier))
72
- if template.nil?
73
- yield "Found identifier #{identifier} but no model_name had been given!"
74
- next
75
- end
76
- template.identifier = identifier
77
- end
45
+ def write_model_rows(models, model_keys, row_writeable)
46
+ ensure_parents(models, model_keys, row_writeable)
78
47
 
79
- if (parent_model_name = self.get_col_or_nil(row, :parent_model_name))
80
- self.process_parent_model_name(template: template, parent_model_name: parent_model_name, models: models, &err_block)
81
- end
48
+ matrix_constants = {}
49
+ model_keys.each { |model_name| each_model_row(models, model_name, matrix_constants, row_writeable) }
82
50
 
83
- if (parent_link_type = self.get_col_or_nil(row, :parent_link_type))
84
- self.process_parent_link_type(template: template, parent_link_type: parent_link_type, models: models, &err_block)
51
+ matrix_constants.each do |digest, options|
52
+ row_writeable.write
53
+ row_writeable.write(matrix_constant: COPY_OPTIONS_SENTINEL + digest)
54
+ options.each do |option|
55
+ row_writeable.write(matrix_constant: option)
56
+ end
85
57
  end
58
+ end
86
59
 
87
- if (attribute_name = self.get_col_or_nil(row, :attribute_name))
88
- self.process_attribute(template: template, attribute_name: attribute_name, models: models, row: row, &err_block)
89
- if (new_attribute_name = self.get_col_or_nil(row, :new_attribute_name))
90
- self.process_new_attribute_name(template: template, changeset: changeset, new_attribute_name: new_attribute_name, attribute_name: attribute_name, &err_block)
60
+ def ensure_parents(models, model_keys, row_writeable)
61
+ q = model_keys.dup
62
+ seen = Set.new
63
+
64
+ until q.empty?
65
+ model_key = q.shift
66
+ next if model_key.nil?
67
+ next if seen.include?(model_key)
68
+ seen.add(model_key)
69
+
70
+ # For models that are only part of the trunk, but not of the tree of model_keys,
71
+ # we still need their basic information (identifier / parent) for validation and for
72
+ # potentially creating the required tree dependencies to connect it to a remote tree.
73
+ unless model_keys.include?(model_key)
74
+ each_model_trunk_row(models, model_key, row_writeable)
91
75
  end
76
+
77
+ q.push(*models.model(model_key).template.all_linked_model_names)
92
78
  end
93
79
  end
94
- end
95
80
 
96
- def self.process_new_attribute_name(template:, changeset:, new_attribute_name:, attribute_name:, &err_block)
97
- renames = changeset.build_renames(template.name)
98
- if renames.include?(attribute_name) && renames[attribute_name] != new_attribute_name
99
- if block_given?
100
- yield "Found multiple new_attribute_name values for #{template.name}'s #{attribute_name}': #{new_attribute_name} or #{renames[attribute_name]}?"
81
+ def each_model_trunk_row(models, model_name, row_writeable)
82
+ return unless (model = models.model(model_name))
83
+
84
+ # Empty link for better visual separation
85
+ row_writeable.write
86
+ row_writeable.write(model_name: model_name)
87
+
88
+ if !model.template.parent.nil?
89
+ parent_model = models.model(model.template.parent)
90
+ reciprocal = models.find_reciprocal(model: model, link_model: parent_model)
91
+
92
+ row_writeable.write(
93
+ identifier: model.template.identifier,
94
+ parent_model_name: model.template.parent,
95
+ parent_link_type: reciprocal.attribute_type.to_s
96
+ )
97
+ else
98
+ row_writeable.write(
99
+ identifier: model.template.identifier,
100
+ )
101
101
  end
102
102
  end
103
103
 
104
- renames[attribute_name] = new_attribute_name
105
- end
104
+ def each_model_row(models, model_name, matrix_constants, row_writeable)
105
+ return unless (model = models.model(model_name))
106
106
 
107
- def self.process_parent_model_name(template:, parent_model_name:, models:, &err_block)
108
- if template.nil?
109
- yield "Found parent_model_name #{parent_model_name} but no model_name had been given!" if block_given?
110
- return
107
+ each_model_trunk_row(models, model_name, row_writeable)
108
+ model.template.attributes.all.each do |attribute|
109
+ each_attribute_row(models, model, attribute, matrix_constants, row_writeable)
110
+ end
111
111
  end
112
112
 
113
- if template.parent && !template.parent.empty? && template.parent != parent_model_name
114
- yield "Model #{template.name} was provided multiple parent_model_names: #{template.parent} and #{parent_model_name}" if block_given?
115
- end
113
+ def each_attribute_row(models, model, attribute, matrix_constants, row_writeable)
114
+ if attribute.attribute_type == Etna::Clients::Magma::AttributeType::IDENTIFIER
115
+ # Identifiers for models whose parent link type ends up being a table are non configurable, so we don't
116
+ # want to include them in the CSV.
117
+ if models.find_reciprocal(model: model, link_attribute_name: model.template.parent)&.attribute_type == Etna::Clients::Magma::AttributeType::TABLE
118
+ return
119
+ end
120
+ else
121
+ return unless Etna::Clients::Magma::AttributeValidator.valid_add_row_attribute_types.include?(attribute.attribute_type)
122
+ end
116
123
 
117
- template.parent = parent_model_name
124
+ options = attribute.options&.join(', ')
125
+ if attribute.attribute_type == Etna::Clients::Magma::AttributeType::MATRIX
126
+ # Matrix attribute validations are massive, and functional. For now, we don't support showing and editing
127
+ # them inline with this spreadsheet. In the future, I think we should possibly introduce the concept of
128
+ # CONSTANTS or Matrix Types that are managed separately.
129
+ options = options || ''
130
+ digest = Digest::MD5.hexdigest(options)
131
+ matrix_constants[digest] ||= options.split(',').map(&:strip)
118
132
 
119
- template.build_attributes.build_attribute(template.parent).tap do |parent_att|
120
- parent_att.name = parent_att.attribute_name = parent_model_name
121
- parent_att.attribute_type = AttributeType::PARENT
122
- parent_att.link_model_name = parent_model_name
123
- parent_att.desc = self.prettify(parent_model_name)
124
- parent_att.display_name = self.prettify(parent_model_name)
125
- end
126
- end
133
+ options = COPY_OPTIONS_SENTINEL + digest
134
+ end
127
135
 
128
- def self.process_parent_link_type(template:, parent_link_type:, models:, &err_block)
129
- if template.nil?
130
- yield "Found parent_link_type #{parent_link_type} but no model_name had been given!" if block_given?
131
- return
136
+ row_writeable.write(
137
+ attribute_name: attribute.name,
138
+ attribute_type: attribute.attribute_type,
139
+ link_model_name: attribute.link_model_name,
140
+ reciprocal_link_type: models.find_reciprocal(model: model, attribute: attribute)&.attribute_type,
141
+ description: attribute.desc,
142
+ display_name: attribute.display_name,
143
+ match: attribute.match,
144
+ format_hint: attribute.format_hint,
145
+ restricted: attribute.restricted,
146
+ read_only: attribute.read_only,
147
+ options: options,
148
+ attribute_group: attribute.attribute_group,
149
+ hidden: attribute.hidden,
150
+ unique: attribute.unique,
151
+ )
132
152
  end
153
+ end
133
154
 
134
- if template.parent.nil?
135
- yield "Found parent_link_type #{parent_link_type} but no parent_model_name had been given!" if block_given?
136
- return
137
- end
155
+ class ModelsChangeset < Struct.new(:models, :renames, :matrix_constants, keyword_init: true)
156
+ def initialize(*args)
157
+ super
138
158
 
139
- reciprocal = models.find_reciprocal(model_name: template.name, link_attribute_name: template.parent)
140
- if reciprocal && reciprocal.attribute_type.to_s != parent_link_type
141
- yield "Model #{template.name} was provided multiple parent_link_types: #{reciprocal.attribute_type} and #{parent_link_type}" if block_given?
159
+ self.models ||= Models.new
160
+ self.renames ||= {}
161
+ self.matrix_constants ||= {}
142
162
  end
143
163
 
144
- if reciprocal && reciprocal.attribute_name != template.name
145
- yield "Model #{template.name} is linked to #{template.parent}, but the reciprocal link is misnamed as '#{reciprocal.attribute_name}'." if block_given?
164
+ def build_renames(model_name)
165
+ renames[model_name] ||= {}
146
166
  end
167
+ end
147
168
 
148
- models.build_model(template.parent).tap do |parent_model|
149
- parent_model_attribute_by_model_name = parent_model.build_template.build_attributes.attribute(template.name)
150
- if parent_model_attribute_by_model_name && !reciprocal
151
- yield "Model #{template.parent} is linked as a parent to #{template.name}, but it already has an attribute named #{template.name} #{parent_model_attribute_by_model_name.raw}." if block_given?
152
- end
169
+ COPY_OPTIONS_SENTINEL = '$copy_from_original$'
153
170
 
154
- parent_model.build_template.build_attributes.build_attribute(template.name).tap do |attr|
155
- attr.attribute_name = attr.name = template.name
156
- attr.attribute_type = parent_link_type
157
- attr.link_model_name = template.name
158
- attr.desc = self.prettify(template.name)
159
- attr.display_name = self.prettify(template.name)
160
- end
171
+ class Importer < Etna::CsvImporter
172
+ include Prettify
173
+
174
+ # Columns of the row, _after format_row is called_, that should be applied to an attribute.
175
+ # This should line up with the attribute names _on the model itself_.
176
+ ATTRIBUTE_ROW_ENTRIES = [
177
+ :attribute_type,
178
+ :link_model_name, :desc,
179
+ :display_name, :format_hint,
180
+ :restricted, :read_only,
181
+ :validation, :attribute_group,
182
+ :hidden, :unique,
183
+ ]
184
+
185
+ def initialize
186
+ super(&method(:format_row))
161
187
  end
162
- end
163
188
 
164
- def self.process_attribute(template:, attribute_name:, models:, row:, &err_block)
165
- if template.nil?
166
- yield "Found attribute #{attribute_name} but no model_name had been given!" if block_given?
167
- return
189
+ def format_row(row)
190
+ replace_row_column(row, :attribute_type) { |s| AttributeType.new(s) }
191
+ replace_row_column(row, :desc) { row.delete(:description) }
192
+ replace_row_column(row, :restricted, &COLUMN_AS_BOOLEAN)
193
+ replace_row_column(row, :read_only, &COLUMN_AS_BOOLEAN)
194
+ replace_row_column(row, :options) { |s| {"type" => "Array", "value" => s.split(',').map(&:strip)} }
195
+ replace_row_column(row, :match) { |s| {"type" => "Regexp", "value" => Regexp.new(s).source} }
196
+ replace_row_column(row, :validation) { row[:options] || row[:match] }
197
+ replace_row_column(row, :hidden, &COLUMN_AS_BOOLEAN)
198
+ replace_row_column(row, :unique, &COLUMN_AS_BOOLEAN)
168
199
  end
169
200
 
170
- attributes = template.build_attributes
171
- existing_attribute = attributes.attribute(attribute_name)
172
- if existing_attribute
173
- if existing_attribute.attribute_type != AttributeType::COLLECTION
174
- yield "Attribute #{attribute_name} of model #{template.name} has duplicate definitions!" if block_given?
175
- end
201
+ def process_row(row_processor, changeset)
202
+ models = changeset.models
176
203
 
177
- attributes.raw.delete(attribute_name)
204
+ process_matrix_constants(changeset, row_processor)
205
+ process_model_config(models, row_processor)
206
+ process_parent_config(models, row_processor)
207
+ process_attribute_properties(changeset, row_processor)
178
208
  end
179
209
 
180
- attributes.build_attribute(attribute_name).tap do |att|
181
- COLUMNS_TO_ATTRIBUTES.each do |column, processor|
182
- next unless (value = self.get_col_or_nil(row, column))
183
- if processor.is_a?(Array)
184
- processor, f = processor
185
- value = f.call(value)
210
+ def prepare_changeset(filename: nil, input_io: nil, &validation_err_block)
211
+ ModelsChangeset.new.tap do |changeset|
212
+ context = {}
213
+ each_csv_row(filename: filename, input_io: input_io) do |row, lineno|
214
+ p = NestedRowProcessor.new(row, lineno, context)
215
+ process_row(p, changeset)
186
216
  end
187
217
 
188
- if !att.send(processor).nil? && !att.send(processor).empty?
189
- yield "Value for #{processor} on attribute #{attribute_name} has duplicate definitions!" if block_given?
190
- end
191
-
192
- att.send(:"#{processor}=", value)
193
- end
194
-
195
- if att.attribute_type == AttributeType::LINK && models.find_reciprocal(model_name: template.name, attribute: att).nil?
196
- models.build_model(att.link_model_name).build_template.build_attributes.build_attribute(template.name).tap do |rec_att|
197
- rec_att.attribute_name = template.name
198
- rec_att.display_name = self.prettify(template.name)
199
- rec_att.desc = self.prettify(template.name)
200
- rec_att.attribute_type = AttributeType::COLLECTION
201
- rec_att.link_model_name = template.name
218
+ # After configuring attributes, set defaults for certain attribute properties.
219
+ changeset.models.all.each do |model|
220
+ model.template.attributes.all.each do |attribute|
221
+ attribute.set_field_defaults!
222
+ end
202
223
  end
203
224
  end
204
-
205
- att.set_field_defaults!
225
+ rescue ImportError => e
226
+ validation_err_block.call(e.message)
206
227
  end
207
- end
208
228
 
209
- def self.prettify(name)
210
- name.split('_').map(&:capitalize).join(' ')
211
- end
212
229
 
213
- def self.get_col_or_nil(row, col)
214
- c = row[col]&.chomp
215
- return nil if c&.empty?
216
- c
217
- end
218
-
219
- def self.each_csv_row(models = Models.new, model_keys = models.model_keys.sort, &block)
220
- yield COLUMNS.map(&:to_s)
221
-
222
- self.ensure_parents(models, model_keys, &block)
223
- matrix_constants = {}
224
- model_keys.each { |model_name| self.each_model_row(models, model_name, matrix_constants, &block) }
230
+ private
225
231
 
226
- matrix_constants.each do |digest, options|
227
- yield row_from_columns
228
- yield row_from_columns(matrix_constant: COPY_OPTIONS_SENTINEL + digest)
229
- options.each do |option|
230
- yield row_from_columns(matrix_constant: option)
232
+ def process_model_config(models, row_processor)
233
+ row_processor.process(:model_name) do |model_name|
234
+ models.build_model(model_name).build_template.tap { |t| t.name = model_name }
231
235
  end
232
- end
233
- end
234
-
235
- def self.ensure_parents(models, model_keys, &block)
236
- q = model_keys.dup
237
- seen = Set.new
238
-
239
- until q.empty?
240
- model_key = q.shift
241
- next if model_key.nil?
242
- next if seen.include?(model_key)
243
- seen.add(model_key)
244
-
245
- # For models that are only part of the trunk, but not of the tree of model_keys,
246
- # we still need their basic information (identifier / parent) for validation and for
247
- # potentially creating the required tree dependencies to connect it to a remote tree.
248
- unless model_keys.include?(model_key)
249
- self.each_model_trunk_row(models, model_key, &block)
236
+ row_processor.process(:identifier, :model_name) do |identifier, template|
237
+ template.identifier = identifier
250
238
  end
251
-
252
- q.push(*models.model(model_key).template.all_linked_model_names)
253
239
  end
254
- end
255
240
 
256
- def self.each_model_trunk_row(models, model_name, &block)
257
- return unless (model = models.model(model_name))
241
+ def process_parent_config(models, row_processor)
242
+ row_processor.process(:parent_model_name, :model_name) do |parent_model_name, template|
243
+ if template.parent && !template.parent.empty? && template.parent != parent_model_name
244
+ raise ImportError.new("Model #{template.name} was provided multiple parent_model_names: #{template.parent} and #{parent_model_name}")
245
+ end
258
246
 
259
- # Empty link for better visual separation
260
- yield row_from_columns
261
- yield row_from_columns(model_name: model_name)
247
+ template.parent = parent_model_name
262
248
 
263
- unless model.template.parent.nil?
264
- parent_model = models.model(model.template.parent)
265
- reciprocal = models.find_reciprocal(model: model, link_model: parent_model)
249
+ template.build_attributes.build_attribute(template.parent).tap do |parent_att|
250
+ parent_att.name = parent_att.attribute_name = parent_model_name
251
+ parent_att.attribute_type = Etna::Clients::Magma::AttributeType::PARENT
252
+ parent_att.link_model_name = parent_model_name
253
+ parent_att.desc = prettify(parent_model_name)
254
+ parent_att.display_name = prettify(parent_model_name)
255
+ end
256
+ end
266
257
 
267
- yield row_from_columns(
268
- identifier: model.template.identifier,
269
- parent_model_name: model.template.parent,
270
- parent_link_type: reciprocal.attribute_type.to_s
271
- )
272
- else
273
- yield row_from_columns(
274
- identifier: model.template.identifier,
275
- )
276
- end
277
- end
258
+ row_processor.process(:parent_link_type, :model_name, :parent_model_name) do |parent_link_type, template|
259
+ reciprocal = models.find_reciprocal(model_name: template.name, link_attribute_name: template.parent)
260
+ if reciprocal && reciprocal.attribute_type.to_s != parent_link_type
261
+ raise ImportError.new("Model #{template.name} was provided multiple parent_link_types: #{reciprocal.attribute_type} and #{parent_link_type}")
262
+ end
278
263
 
279
- def self.each_model_row(models, model_name, matrix_constants, &block)
280
- return unless (model = models.model(model_name))
264
+ if reciprocal && reciprocal.attribute_name != template.name
265
+ raise ImportError.new("Model #{template.name} is linked to #{template.parent}, but the reciprocal link is misnamed as '#{reciprocal.attribute_name}'.")
266
+ end
281
267
 
282
- self.each_model_trunk_row(models, model_name, &block)
283
- model.template.attributes.all.each do |attribute|
284
- self.each_attribute_row(models, model, attribute, matrix_constants, &block)
268
+ models.build_model(template.parent).tap do |parent_model|
269
+ parent_model_attribute_by_model_name = parent_model.build_template.build_attributes.attribute(template.name)
270
+ if parent_model_attribute_by_model_name && !reciprocal
271
+ raise ImportError.new("Model #{template.parent} is linked as a parent to #{template.name}, but it already has an attribute named #{template.name} #{parent_model_attribute_by_model_name.raw}.")
272
+ end
273
+
274
+ parent_model.build_template.build_attributes.build_attribute(template.name).tap do |attr|
275
+ attr.attribute_name = attr.name = template.name
276
+ attr.attribute_type = parent_link_type
277
+ attr.link_model_name = template.name
278
+ attr.desc = prettify(template.name)
279
+ attr.display_name = prettify(template.name)
280
+ end
281
+ end
282
+ end
285
283
  end
286
- end
287
284
 
288
- def self.each_attribute_row(models = Models.new, model = Model.new, attribute = Attribute.new, matrix_constants = {}, &block)
289
- if attribute.attribute_type == AttributeType::IDENTIFIER
290
- # Identifiers for models whose parent link type ends up being a table are non configurable, so we don't
291
- # want to include them in the CSV.
292
- if models.find_reciprocal(model: model, link_attribute_name: model.template.parent)&.attribute_type == AttributeType::TABLE
293
- return
285
+ def process_matrix_constants(changeset, row_processor)
286
+ row_processor.process(:matrix_constant) do |matrix_constant|
287
+ # Lines that start with the sentinel are a distinct 'group'
288
+ if matrix_constant.start_with?(COPY_OPTIONS_SENTINEL)
289
+ matrix_constant = matrix_constant.slice((COPY_OPTIONS_SENTINEL.length)..-1)
290
+ changeset.matrix_constants[matrix_constant] = []
291
+ else
292
+ # Until the sentinel is seen again, all further entries belong to the set.
293
+ changeset.matrix_constants[changeset.matrix_constants.keys.last] << matrix_constant
294
+ end
295
+
296
+ matrix_constant
294
297
  end
295
- else
296
- return unless AttributeValidator.valid_add_row_attribute_types.include?(attribute.attribute_type)
297
298
  end
298
299
 
299
- options = attribute.options&.join(', ')
300
- if attribute.attribute_type == AttributeType::MATRIX
301
- # Matrix attribute validations are massive, and functional. For now, we don't support showing and editing
302
- # them inline with this spreadsheet. In the future, I think we should possibly introduce the concept of
303
- # CONSTANTS or Matrix Types that are managed separately.
304
- options = options || ''
305
- digest = Digest::MD5.hexdigest(options)
306
- matrix_constants[digest] ||= COLUMNS_TO_ATTRIBUTES[:options][1].call(options)["value"]
300
+ def process_attribute_properties(changeset, row_processor)
301
+ models = changeset.models
307
302
 
308
- options = COPY_OPTIONS_SENTINEL + digest
309
- end
303
+ row_processor.process(:attribute_name, :model_name) do |attribute_name, template|
304
+ attributes = template.build_attributes
310
305
 
311
- yield row_from_columns(
312
- attribute_name: attribute.name,
313
- attribute_type: attribute.attribute_type,
314
- link_model_name: attribute.link_model_name,
315
- reciprocal_link_type: models.find_reciprocal(model: model, attribute: attribute)&.attribute_type,
316
- description: attribute.desc,
317
- display_name: attribute.display_name,
318
- match: attribute.match,
319
- format_hint: attribute.format_hint,
320
- restricted: attribute.restricted,
321
- read_only: attribute.read_only,
322
- options: options,
323
- attribute_group: attribute.attribute_group,
324
- hidden: attribute.hidden,
325
- unique: attribute.unique,
326
- )
327
- end
306
+ existing_attribute = attributes.attribute(attribute_name)
307
+ if existing_attribute
308
+ if existing_attribute.attribute_type != Etna::Clients::Magma::AttributeType::COLLECTION
309
+ raise ImportError.new("Attribute #{attribute_name} of model #{template.name} has duplicate definitions!")
310
+ end
328
311
 
329
- def self.row_from_columns(**columns)
330
- COLUMNS.map { |c| (columns[c] || '').to_s }
331
- end
312
+ # Clear its definition; implicit attributes are overwritten by explicit definitions if they exist.
313
+ attributes.raw.delete(attribute_name)
314
+ end
332
315
 
333
- class ModelsChangeset < Struct.new(:models, :renames, :matrix_constants, :last_matrix_constant, :last_model_key, keyword_init: true)
334
- def initialize(*args)
335
- super
316
+ attributes.build_attribute(attribute_name).tap { |att| att.name = att.attribute_name = attribute_name }
317
+ end
336
318
 
337
- self.models ||= Models.new
338
- self.renames ||= {}
339
- self.matrix_constants ||= {}
319
+ row_processor.process(:new_attribute_name, :attribute_name, :model_name) do |new_attribute_name, att, template|
320
+ renames = changeset.build_renames(template.name)
321
+ if renames.include?(att.name) && renames[att.name] != new_attribute_name
322
+ raise ImportError.new("Found multiple new_attribute_name values for #{template.name}'s #{att.name}': #{new_attribute_name} or #{renames[att.name]}?")
323
+ end
324
+
325
+ renames[att.attribute_name] = new_attribute_name
326
+ end
327
+
328
+ ATTRIBUTE_ROW_ENTRIES.each do |prop_name|
329
+ row_processor.process(prop_name, :model_name, :attribute_name) do |value, template, att|
330
+ if att.raw.include?(prop_name.to_s)
331
+ raise ImportError.new("Value for #{prop_name} on attribute #{att.attribute_name} has duplicate definitions!")
332
+ end
333
+
334
+ att.send(:"#{prop_name}=", value)
335
+
336
+ if att.attribute_type && att.link_model_name
337
+ if att.attribute_type == Etna::Clients::Magma::AttributeType::LINK && models.find_reciprocal(model_name: template.name, attribute: att).nil?
338
+ models.build_model(att.link_model_name).build_template.build_attributes.build_attribute(template.name).tap do |rec_att|
339
+ rec_att.attribute_name = rec_att.name = template.name
340
+ rec_att.display_name = prettify(template.name)
341
+ rec_att.desc = prettify(template.name)
342
+ rec_att.attribute_type = Etna::Clients::Magma::AttributeType::COLLECTION
343
+ rec_att.link_model_name = template.name
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
340
349
  end
341
350
  end
342
351
  end