etna 0.1.19 → 0.1.20

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e4db6a6c798917fe8a1e96d7f02a2d604d259d3e2da08f083899655b2638b55
4
- data.tar.gz: 989a69710cad57444da6ac526fc4728e706e0b4c891b720a8307b8207949453d
3
+ metadata.gz: 66704260fa8f715e91ccb8469029e93b2b0f4574af76f2700dd7001b0d7e3bbe
4
+ data.tar.gz: d16a63b2302f4933741eaade615cd001f615de2ba4bd67774b0c77785a920ace
5
5
  SHA512:
6
- metadata.gz: bb98337dd93af44ae0bda211a413178f25ba6ad08b39c34dde855d6288ee291f26b8c629b67f24e2be56667405a93662308dca804fcf2b70aefbd41da3c8ea13
7
- data.tar.gz: aed1a1f77965ef30eb67bffe021547fb92ad03b836dcd940bd98c55020309bee568981f058e5365580d29462f03dd41efb645c3b3f5575682d06e9af927e51a1
6
+ metadata.gz: 23158f8c57ebb498b2055354d05ccada416b8d74c4b5b9e894d261467ff28591b757bfef9e6ea3d37016c465a68bdb179a76621c23a861d4247f560a84925d58
7
+ data.tar.gz: 4fb492dae70d2ae361e6270b7d8178f8544ecbd388e09721a3550246ea17a2f99aea1ec466f783cdfc592d80e502a320ce17015d44757039266b4f4394e0df28
@@ -136,13 +136,87 @@ all_flag_completion_names="$all_flag_completion_names "
136
136
  string_flag_completion_names="$string_flag_completion_names "
137
137
  while [[ "$#" != "0" ]]; do
138
138
  if [[ "$#" == "1" ]]; then
139
- all_completion_names="help update_from_csv"
139
+ all_completion_names="create_file_linking_csv help update_from_csv"
140
140
  all_completion_names="$all_completion_names $all_flag_completion_names"
141
141
  if [[ -z "$(echo $all_completion_names | xargs)" ]]; then
142
142
  return
143
143
  fi
144
144
  COMPREPLY=($(compgen -W "$all_completion_names" -- "$1"))
145
145
  return
146
+ elif [[ "$1" == "create_file_linking_csv" ]]; then
147
+ shift
148
+ if [[ "$#" == "1" ]]; then
149
+ all_completion_names="__project_name__"
150
+ if [[ -z "$(echo $all_completion_names | xargs)" ]]; then
151
+ return
152
+ fi
153
+ COMPREPLY=($(compgen -W "$all_completion_names" -- "$1"))
154
+ return
155
+ fi
156
+ shift
157
+ if [[ "$#" == "1" ]]; then
158
+ all_completion_names="__bucket_name__"
159
+ if [[ -z "$(echo $all_completion_names | xargs)" ]]; then
160
+ return
161
+ fi
162
+ COMPREPLY=($(compgen -W "$all_completion_names" -- "$1"))
163
+ return
164
+ fi
165
+ shift
166
+ if [[ "$#" == "1" ]]; then
167
+ all_completion_names="__attribute_name__"
168
+ if [[ -z "$(echo $all_completion_names | xargs)" ]]; then
169
+ return
170
+ fi
171
+ COMPREPLY=($(compgen -W "$all_completion_names" -- "$1"))
172
+ return
173
+ fi
174
+ shift
175
+ if [[ "$#" == "1" ]]; then
176
+ all_completion_names="__extension__"
177
+ if [[ -z "$(echo $all_completion_names | xargs)" ]]; then
178
+ return
179
+ fi
180
+ COMPREPLY=($(compgen -W "$all_completion_names" -- "$1"))
181
+ return
182
+ fi
183
+ shift
184
+ all_flag_completion_names="$all_flag_completion_names --collection --file --regex --folder "
185
+ string_flag_completion_names="$string_flag_completion_names --file --regex --folder "
186
+ declare _completions_for_file="__file__"
187
+ declare _completions_for_regex="__regex__"
188
+ declare _completions_for_folder="__folder__"
189
+ while [[ "$#" != "0" ]]; do
190
+ if [[ "$#" == "1" ]]; then
191
+ all_completion_names=""
192
+ all_completion_names="$all_completion_names $all_flag_completion_names"
193
+ if [[ -z "$(echo $all_completion_names | xargs)" ]]; then
194
+ return
195
+ fi
196
+ COMPREPLY=($(compgen -W "$all_completion_names" -- "$1"))
197
+ return
198
+ elif [[ -z "$(echo $all_flag_completion_names | xargs)" ]]; then
199
+ return
200
+ elif [[ "$all_flag_completion_names" =~ $1\ ]]; then
201
+ all_flag_completion_names="${all_flag_completion_names//$1\ /}"
202
+ a=$1
203
+ shift
204
+ if [[ "$string_flag_completion_names" =~ $a\ ]]; then
205
+ if [[ "$#" == "1" ]]; then
206
+ a="${a//--/}"
207
+ a="${a//-/_}"
208
+ i="_completions_for_$a"
209
+ all_completion_names="${!i}"
210
+ COMPREPLY=($(compgen -W "$all_completion_names" -- "$1"))
211
+ return
212
+ fi
213
+ shift
214
+ fi
215
+ else
216
+ return
217
+ fi
218
+ done
219
+ return
146
220
  elif [[ "$1" == "help" ]]; then
147
221
  shift
148
222
  all_flag_completion_names="$all_flag_completion_names "
@@ -207,8 +281,9 @@ COMPREPLY=($(compgen -W "$all_completion_names" -- "$1"))
207
281
  return
208
282
  fi
209
283
  shift
210
- all_flag_completion_names="$all_flag_completion_names "
211
- string_flag_completion_names="$string_flag_completion_names "
284
+ all_flag_completion_names="$all_flag_completion_names --json-values --hole-value "
285
+ string_flag_completion_names="$string_flag_completion_names --hole-value "
286
+ declare _completions_for_hole_value="__hole_value__"
212
287
  while [[ "$#" != "0" ]]; do
213
288
  if [[ "$#" == "1" ]]; then
214
289
  all_completion_names=""
@@ -56,7 +56,7 @@ class EtnaApp
56
56
  def execute(host, ignore_ssl: false)
57
57
  polyphemus_client ||= Etna::Clients::Polyphemus.new(
58
58
  host: host,
59
- token: token,
59
+ token: token(ignore_environment: true),
60
60
  ignore_ssl: ignore_ssl)
61
61
  workflow = Etna::Clients::Polyphemus::SetConfigurationWorkflow.new(
62
62
  polyphemus_client: polyphemus_client,
@@ -143,7 +143,7 @@ class EtnaApp
143
143
  tf = Tempfile.new
144
144
 
145
145
  begin
146
- File.open(tf.path, 'wb') { |f| workflow.write_models_template_csv(f, project_name, target_model) }
146
+ File.open(tf.path, 'wb') { |f| workflow.write_models_template_csv(project_name, target_model, io: f) }
147
147
  FileUtils.cp(tf.path, file)
148
148
  ensure
149
149
  tf.close!
@@ -247,23 +247,58 @@ class EtnaApp
247
247
  include WithEtnaClients
248
248
  include WithLogger
249
249
 
250
+ boolean_flags << '--json-values'
251
+ string_flags << '--hole-value'
252
+
250
253
  def magma_crud
251
254
  @magma_crud ||= Etna::Clients::Magma::MagmaCrudWorkflow.new(
252
255
  magma_client: magma_client,
253
256
  project_name: @project_name)
254
257
  end
255
258
 
256
- def execute(project_name, model_name, filepath)
259
+ def execute(project_name, model_name, filepath, hole_value: '_', json_values: false)
257
260
  @project_name = project_name
258
261
 
259
262
  update_attributes_workflow = Etna::Clients::Magma::UpdateAttributesFromCsvWorkflowSingleModel.new(
260
263
  magma_crud: magma_crud,
261
264
  project_name: project_name,
262
265
  model_name: model_name,
263
- filepath: filepath)
266
+ filepath: filepath,
267
+ hole_value: hole_value,
268
+ json_values: json_values)
264
269
  update_attributes_workflow.update_attributes
265
270
  end
266
271
  end
272
+
273
+ class CreateFileLinkingCsv < Etna::Command
274
+ include WithEtnaClients
275
+
276
+ string_flags << '--file'
277
+ string_flags << '--regex'
278
+ string_flags << '--folder'
279
+ boolean_flags << '--collection'
280
+
281
+ def execute(project_name, bucket_name, attribute_name, extension, collection: false, regex: "**/*/(?<identifier>.+)\\.#{extension}$", folder: "", file: "#{project_name}_#{attribute_name}.csv")
282
+ if folder.start_with?("/")
283
+ folder = folder.slice(1..-1)
284
+ end
285
+
286
+ regex = Regexp.new(regex)
287
+
288
+ workflow = Etna::Clients::Magma::SimpleFileLinkingWorkflow.new(
289
+ metis_client: metis_client,
290
+ project_name: project_name,
291
+ bucket_name: bucket_name,
292
+ folder: folder,
293
+ extension: extension,
294
+ attribute_name: attribute_name,
295
+ regex: regex,
296
+ file_collection: collection,
297
+ )
298
+
299
+ workflow.write_csv_io(filename: file)
300
+ end
301
+ end
267
302
  end
268
303
  end
269
304
  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