etna 0.1.16 → 0.1.18

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/bin/etna +63 -0
  3. data/etna.completion +926 -0
  4. data/etna_app.completion +133 -0
  5. data/ext/completions/extconf.rb +20 -0
  6. data/lib/commands.rb +368 -0
  7. data/lib/etna.rb +6 -0
  8. data/lib/etna/application.rb +38 -20
  9. data/lib/etna/client.rb +60 -29
  10. data/lib/etna/clients.rb +4 -0
  11. data/lib/etna/clients/enum.rb +9 -0
  12. data/lib/etna/clients/janus.rb +2 -0
  13. data/lib/etna/clients/janus/client.rb +73 -0
  14. data/lib/etna/clients/janus/models.rb +78 -0
  15. data/lib/etna/clients/magma.rb +2 -0
  16. data/lib/etna/clients/magma/client.rb +24 -9
  17. data/lib/etna/clients/magma/formatting.rb +1 -0
  18. data/lib/etna/clients/magma/formatting/models_csv.rb +345 -0
  19. data/lib/etna/clients/magma/models.rb +323 -9
  20. data/lib/etna/clients/magma/workflows.rb +10 -0
  21. data/lib/etna/clients/magma/workflows/add_project_models_workflow.rb +78 -0
  22. data/lib/etna/clients/magma/workflows/attribute_actions_from_json_workflow.rb +62 -0
  23. data/lib/etna/clients/magma/workflows/create_project_workflow.rb +117 -0
  24. data/lib/etna/clients/magma/workflows/crud_workflow.rb +85 -0
  25. data/lib/etna/clients/magma/workflows/ensure_containing_record_workflow.rb +44 -0
  26. data/lib/etna/clients/magma/workflows/file_attributes_blank_workflow.rb +68 -0
  27. data/lib/etna/clients/magma/workflows/file_linking_workflow.rb +115 -0
  28. data/lib/etna/clients/magma/workflows/json_converters.rb +81 -0
  29. data/lib/etna/clients/magma/workflows/json_validators.rb +447 -0
  30. data/lib/etna/clients/magma/workflows/model_synchronization_workflow.rb +306 -0
  31. data/lib/etna/clients/magma/workflows/record_synchronization_workflow.rb +63 -0
  32. data/lib/etna/clients/magma/workflows/update_attributes_from_csv_workflow.rb +178 -0
  33. data/lib/etna/clients/metis.rb +1 -0
  34. data/lib/etna/clients/metis/client.rb +207 -5
  35. data/lib/etna/clients/metis/models.rb +174 -3
  36. data/lib/etna/clients/metis/workflows.rb +2 -0
  37. data/lib/etna/clients/metis/workflows/metis_download_workflow.rb +37 -0
  38. data/lib/etna/clients/metis/workflows/metis_upload_workflow.rb +137 -0
  39. data/lib/etna/clients/polyphemus.rb +3 -0
  40. data/lib/etna/clients/polyphemus/client.rb +33 -0
  41. data/lib/etna/clients/polyphemus/models.rb +68 -0
  42. data/lib/etna/clients/polyphemus/workflows.rb +1 -0
  43. data/lib/etna/clients/polyphemus/workflows/set_configuration_workflow.rb +47 -0
  44. data/lib/etna/command.rb +235 -5
  45. data/lib/etna/controller.rb +4 -0
  46. data/lib/etna/environment_scoped.rb +19 -0
  47. data/lib/etna/generate_autocompletion_script.rb +130 -0
  48. data/lib/etna/json_serializable_struct.rb +6 -3
  49. data/lib/etna/logger.rb +0 -3
  50. data/lib/etna/multipart_serializable_nested_hash.rb +6 -1
  51. data/lib/etna/route.rb +1 -1
  52. data/lib/etna/spec/vcr.rb +98 -0
  53. data/lib/etna/templates/attribute_actions_template.json +43 -0
  54. data/lib/etna/test_auth.rb +3 -1
  55. data/lib/etna/user.rb +4 -0
  56. data/lib/helpers.rb +81 -0
  57. metadata +47 -7
@@ -0,0 +1 @@
1
+ require_relative 'formatting/models_csv'
@@ -0,0 +1,345 @@
1
+ require 'ostruct'
2
+
3
+ module Etna
4
+ module Clients
5
+ 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
49
+
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
58
+
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
69
+ end
70
+
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
78
+
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
82
+
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)
85
+ end
86
+
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)
91
+ end
92
+ end
93
+ end
94
+ end
95
+
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]}?"
101
+ end
102
+ end
103
+
104
+ renames[attribute_name] = new_attribute_name
105
+ end
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
111
+ end
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
116
+
117
+ template.parent = parent_model_name
118
+
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
127
+
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
132
+ end
133
+
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
138
+
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?
142
+ end
143
+
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?
146
+ end
147
+
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
153
+
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
161
+ end
162
+ end
163
+
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
168
+ end
169
+
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
176
+
177
+ attributes.raw.delete(attribute_name)
178
+ end
179
+
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)
186
+ end
187
+
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
202
+ end
203
+ end
204
+
205
+ att.set_field_defaults!
206
+ end
207
+ end
208
+
209
+ def self.prettify(name)
210
+ name.split('_').map(&:capitalize).join(' ')
211
+ end
212
+
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) }
225
+
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)
231
+ 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)
250
+ end
251
+
252
+ q.push(*models.model(model_key).template.all_linked_model_names)
253
+ end
254
+ end
255
+
256
+ def self.each_model_trunk_row(models, model_name, &block)
257
+ return unless (model = models.model(model_name))
258
+
259
+ # Empty link for better visual separation
260
+ yield row_from_columns
261
+ yield row_from_columns(model_name: model_name)
262
+
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)
266
+
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
278
+
279
+ def self.each_model_row(models, model_name, matrix_constants, &block)
280
+ return unless (model = models.model(model_name))
281
+
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)
285
+ end
286
+ end
287
+
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
294
+ end
295
+ else
296
+ return unless AttributeValidator.valid_add_row_attribute_types.include?(attribute.attribute_type)
297
+ end
298
+
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"]
307
+
308
+ options = COPY_OPTIONS_SENTINEL + digest
309
+ end
310
+
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
328
+
329
+ def self.row_from_columns(**columns)
330
+ COLUMNS.map { |c| (columns[c] || '').to_s }
331
+ end
332
+
333
+ class ModelsChangeset < Struct.new(:models, :renames, :matrix_constants, :last_matrix_constant, :last_model_key, keyword_init: true)
334
+ def initialize(*args)
335
+ super
336
+
337
+ self.models ||= Models.new
338
+ self.renames ||= {}
339
+ self.matrix_constants ||= {}
340
+ end
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
@@ -1,13 +1,15 @@
1
+ require 'ostruct'
1
2
  require_relative '../../json_serializable_struct'
2
3
  require_relative '../../multipart_serializable_nested_hash'
3
4
  require_relative '../../directed_graph'
5
+ require_relative '../enum'
4
6
 
5
7
  # TODO: In the near future, I'd like to transition to specifying apis via SWAGGER and generating model stubs from the
6
8
  # common definitions. For nowe I've written them out by hand here.
7
9
  module Etna
8
10
  module Clients
9
11
  class Magma
10
- class RetrievalRequest < Struct.new(:model_name, :attribute_names, :record_names, :project_name, keyword_init: true)
12
+ class RetrievalRequest < Struct.new(:model_name, :attribute_names, :record_names, :project_name, :page, :page_size, :order, :filter, keyword_init: true)
11
13
  include JsonSerializableStruct
12
14
 
13
15
  def initialize(**params)
@@ -27,11 +29,20 @@ module Etna
27
29
  super({revisions: {}}.update(params))
28
30
  end
29
31
 
30
- def update_revision(model_name, record_name, **attrs)
32
+ def update_revision(model_name, record_name, attrs)
31
33
  revision = revisions[model_name] ||= {}
32
34
  record = revision[record_name] ||= {}
33
35
  record.update(attrs)
34
36
  end
37
+
38
+ def append_table(parent_model_name, parent_record_name, model_name, attrs, attribute_name = model_name)
39
+ parent_revision = update_revision(parent_model_name, parent_record_name, {})
40
+ table = parent_revision[attribute_name] ||= []
41
+ id = "::#{model_name}#{(revisions[model_name] || {}).length + 1}"
42
+ table << id
43
+ update_revision(model_name, id, attrs)
44
+ id
45
+ end
35
46
  end
36
47
 
37
48
  class UpdateModelRequest < Struct.new(:project_name, :actions, keyword_init: true)
@@ -42,15 +53,88 @@ module Etna
42
53
  end
43
54
 
44
55
  def add_action(action)
45
- actions << action
56
+ actions << action
57
+ end
58
+ end
59
+
60
+ class AddModelAction < Struct.new(:action_name, :model_name, :parent_model_name, :parent_link_type, :identifier, keyword_init: true)
61
+ include JsonSerializableStruct
62
+
63
+ def initialize(**args)
64
+ super({action_name: 'add_model'}.update(args))
46
65
  end
47
66
  end
48
67
 
49
- class AddAttributeAction < Struct.new(:action_name, :model_name, :attribute_name, :type, :description, :display_name, :format_hint, :hidden, :index, :link_model_name, :read_only, :restricted, :unique, :validation, keyword_init: true)
68
+ class AddAttributeAction < Struct.new(:action_name, :model_name, :attribute_name, :type, :description, :display_name, :format_hint, :hidden, :index, :link_model_name, :read_only, :attribute_group, :restricted, :unique, :validation, keyword_init: true)
50
69
  include JsonSerializableStruct
70
+
51
71
  def initialize(**args)
52
72
  super({action_name: 'add_attribute'}.update(args))
53
73
  end
74
+
75
+ def attribute_type=(val)
76
+ self.type = val
77
+ end
78
+
79
+ def attribute_type
80
+ self.type
81
+ end
82
+
83
+ def desc=(val)
84
+ self.description = val
85
+ end
86
+
87
+ def desc
88
+ self.description
89
+ end
90
+ end
91
+
92
+ class AddLinkAction < Struct.new(:action_name, :links, keyword_init: true)
93
+ include JsonSerializableStruct
94
+
95
+ def initialize(**args)
96
+ super({action_name: 'add_link', links: []}.update(args))
97
+ end
98
+ end
99
+
100
+ class AddLinkDefinition < Struct.new(:type, :model_name, :attribute_name, keyword_init: true)
101
+ include JsonSerializableStruct
102
+ end
103
+
104
+ class AddProjectAction < Struct.new(:action_name, keyword_init: true)
105
+ include JsonSerializableStruct
106
+
107
+ def initialize(**args)
108
+ super({action_name: 'add_project'}.update(args))
109
+ end
110
+ end
111
+
112
+ class UpdateAttributeAction < Struct.new(:action_name, :model_name, :attribute_name, :description, :display_name, :format_hint, :hidden, :index, :link_model_name, :read_only, :attribute_group, :restricted, :unique, :validation, keyword_init: true)
113
+ include JsonSerializableStruct
114
+
115
+ def initialize(**args)
116
+ super({action_name: 'update_attribute'}.update(args))
117
+ end
118
+
119
+ def desc=(val)
120
+ self.description = val
121
+ end
122
+
123
+ def desc
124
+ self.description
125
+ end
126
+
127
+ def as_json
128
+ super(keep_nils: true)
129
+ end
130
+ end
131
+
132
+ class RenameAttributeAction < Struct.new(:action_name, :model_name, :attribute_name, :new_attribute_name, keyword_init: true)
133
+ include JsonSerializableStruct
134
+
135
+ def initialize(**args)
136
+ super({action_name: 'rename_attribute'}.update(args))
137
+ end
54
138
  end
55
139
 
56
140
  class AttributeValidation < Struct.new(:type, :value, :begin, :end, keyword_init: true)
@@ -58,6 +142,7 @@ module Etna
58
142
  end
59
143
 
60
144
  class AttributeValidationType < String
145
+ include Enum
61
146
  REGEXP = AttributeValidationType.new("Regexp")
62
147
  ARRAY = AttributeValidationType.new("Array")
63
148
  RANGE = AttributeValidationType.new("Range")
@@ -101,6 +186,18 @@ module Etna
101
186
  class UpdateResponse < RetrievalResponse
102
187
  end
103
188
 
189
+ class Project
190
+ attr_reader :raw
191
+
192
+ def initialize(raw = {})
193
+ @raw = raw
194
+ end
195
+
196
+ def models
197
+ Models.new(raw['models'])
198
+ end
199
+ end
200
+
104
201
  class Models
105
202
  attr_reader :raw
106
203
 
@@ -112,14 +209,42 @@ module Etna
112
209
  raw.keys
113
210
  end
114
211
 
212
+ def build_model(model_key)
213
+ Model.new(raw[model_key] ||= {})
214
+ end
215
+
115
216
  def model(model_key)
217
+ return nil unless raw.include?(model_key)
116
218
  Model.new(raw[model_key])
117
219
  end
118
220
 
119
- def to_directed_graph(include_casual_links=false)
221
+ def all
222
+ raw.values.map { |r| Model.new(r) }
223
+ end
224
+
225
+ def +(other)
226
+ raw_update = {}
227
+ raw_update[other.name] = other.raw
228
+ Models.new({}.update(raw).update(raw_update))
229
+ end
230
+
231
+ # Can look up reciprocal links by many means. At minimum, a source model or model name must be provided,
232
+ # and either a link_attribute_name, attribute, or link_model.
233
+ def find_reciprocal(
234
+ model_name: nil,
235
+ model: self.model(model_name),
236
+ link_attribute_name: nil,
237
+ attribute: model&.template&.attributes&.attribute(link_attribute_name),
238
+ link_model: self.model(attribute&.link_model_name)
239
+ )
240
+ return nil if model.nil? || model.name.nil?
241
+ link_model&.template&.attributes&.all&.find { |a| a.link_model_name == model.name }
242
+ end
243
+
244
+ def to_directed_graph(include_casual_links = false)
120
245
  graph = ::DirectedGraph.new
121
246
 
122
- model_keys.each do |model_name|
247
+ model_keys.sort.each do |model_name|
123
248
  graph.add_connection(model(model_name).template.parent, model_name)
124
249
 
125
250
  if include_casual_links
@@ -154,6 +279,10 @@ module Etna
154
279
  @raw = raw
155
280
  end
156
281
 
282
+ def build_template
283
+ Template.new(raw['template'] ||= {})
284
+ end
285
+
157
286
  def documents
158
287
  Documents.new(raw['documents'])
159
288
  end
@@ -161,6 +290,14 @@ module Etna
161
290
  def template
162
291
  Template.new(raw['template'])
163
292
  end
293
+
294
+ def name
295
+ @raw.dig('template', 'name')
296
+ end
297
+
298
+ def count
299
+ raw['count'] || 0
300
+ end
164
301
  end
165
302
 
166
303
  class Documents
@@ -174,7 +311,12 @@ module Etna
174
311
  raw.keys
175
312
  end
176
313
 
314
+ def +(other)
315
+ Documents.new({}.update(raw).update(other.raw))
316
+ end
317
+
177
318
  def document(document_key)
319
+ return nil unless raw.include?(document_key)
178
320
  raw[document_key]
179
321
  end
180
322
  end
@@ -190,16 +332,45 @@ module Etna
190
332
  raw['name'] || ""
191
333
  end
192
334
 
335
+ def name=(val)
336
+ raw['name'] = val.to_s
337
+ end
338
+
193
339
  def identifier
194
340
  raw['identifier'] || ""
195
341
  end
196
342
 
343
+ def version
344
+ raw['version'] || 0
345
+ end
346
+
347
+ def version=(val)
348
+ raw['version'] = val
349
+ end
350
+
351
+ def identifier=(val)
352
+ raw['identifier'] = val
353
+ end
354
+
197
355
  def parent
198
356
  raw['parent']
199
357
  end
200
358
 
359
+ def parent=(val)
360
+ raw['parent'] = val
361
+ end
362
+
201
363
  def attributes
202
- Attributes.new(raw['attributes'])
364
+ Attributes.new(raw['attributes'] ||= {})
365
+ end
366
+
367
+ def build_attributes
368
+ Attributes.new(raw['attributes'] ||= {})
369
+ end
370
+
371
+ def all_linked_model_names
372
+ models = [ self.parent, ] + build_attributes.all.map { |v| v.link_model_name }
373
+ models.select { |m| !m.nil? }.uniq
203
374
  end
204
375
  end
205
376
 
@@ -215,8 +386,17 @@ module Etna
215
386
  end
216
387
 
217
388
  def attribute(attribute_key)
389
+ return nil unless raw.include?(attribute_key)
218
390
  Attribute.new(raw[attribute_key])
219
391
  end
392
+
393
+ def build_attribute(key)
394
+ Attribute.new(raw[key] ||= {})
395
+ end
396
+
397
+ def all
398
+ raw.values.map { |r| Attribute.new(r) }
399
+ end
220
400
  end
221
401
 
222
402
  class Attribute
@@ -226,24 +406,151 @@ module Etna
226
406
  @raw = raw
227
407
  end
228
408
 
409
+ # Sets certain attribute fields which are implicit, even when not set, to match server behavior.
410
+ def set_field_defaults!
411
+ @raw.replace({
412
+ 'hidden' => false,
413
+ 'read_only' => false,
414
+ 'restricted' => false,
415
+ 'validation' => nil,
416
+ }.merge(@raw))
417
+ end
418
+
229
419
  def name
230
420
  @raw['name'] || ""
231
421
  end
232
422
 
423
+ def name=(val)
424
+ @raw['name'] = val
425
+ end
426
+
233
427
  def attribute_name
234
428
  @raw['attribute_name'] || ""
235
429
  end
236
430
 
431
+ def attribute_name=(val)
432
+ @raw['attribute_name'] = val
433
+ end
434
+
237
435
  def attribute_type
238
436
  @raw['attribute_type'] && AttributeType.new(@raw['attribute_type'])
239
437
  end
240
438
 
439
+ def attribute_type=(val)
440
+ val = val.to_s if val
441
+ @raw['attribute_type'] = val
442
+ end
443
+
241
444
  def link_model_name
242
- raw['link_model_name']
445
+ @raw['link_model_name']
446
+ end
447
+
448
+ def link_model_name=(val)
449
+ @raw['link_model_name'] = val
450
+ end
451
+
452
+ def unique
453
+ raw['unique']
454
+ end
455
+
456
+ def unique=(val)
457
+ raw['unique'] = val
458
+ end
459
+
460
+ def desc
461
+ raw['desc']
462
+ end
463
+
464
+ def desc=(val)
465
+ @raw['desc'] = val
466
+ end
467
+
468
+ def display_name
469
+ raw['display_name']
470
+ end
471
+
472
+ def display_name=(val)
473
+ raw['display_name'] = val
474
+ end
475
+
476
+ def match
477
+ raw['match']
478
+ end
479
+
480
+ def restricted
481
+ raw['restricted']
482
+ end
483
+
484
+ def restricted=(val)
485
+ raw['restricted'] = val
486
+ end
487
+
488
+ def format_hint
489
+ raw['format_hint']
490
+ end
491
+
492
+ def format_hint=(val)
493
+ raw['format_hint'] = val
494
+ end
495
+
496
+ def read_only
497
+ raw['read_only']
498
+ end
499
+
500
+ def read_only=(val)
501
+ raw['read_only'] = val
502
+ end
503
+
504
+ def attribute_group
505
+ raw['attribute_group']
506
+ end
507
+
508
+ def attribute_group=(val)
509
+ raw['attribute_group'] = val
510
+ end
511
+
512
+ def hidden
513
+ raw['hidden']
514
+ end
515
+
516
+ def hidden=(val)
517
+ raw['hidden'] = val
518
+ end
519
+
520
+ def validation
521
+ raw['validation']
522
+ end
523
+
524
+ def validation=(val)
525
+ raw['validation'] = val
526
+ end
527
+
528
+ def options
529
+ raw['options']
530
+ end
531
+
532
+ # NOTE! The Attribute class returns description as desc, where as actions take it in as description.
533
+ # There are shortcut methods that try to handle this on the action class side of things. Ideally we would
534
+ # make this more consistent in the near future.
535
+ COPYABLE_ATTRIBUTE_ATTRIBUTES = [
536
+ :attribute_name, :attribute_type, :desc, :display_name, :format_hint,
537
+ :hidden, :link_model_name, :read_only, :attribute_group, :unique, :validation,
538
+ :restricted
539
+ ]
540
+
541
+ EDITABLE_ATTRIBUTE_ATTRIBUTES = UpdateAttributeAction.members & COPYABLE_ATTRIBUTE_ATTRIBUTES
542
+
543
+ def self.copy(source, dest, attributes: COPYABLE_ATTRIBUTE_ATTRIBUTES)
544
+ attributes.each do |attr_name|
545
+ next unless dest.respond_to?(:"#{attr_name}=")
546
+ source_v = source.send(attr_name)
547
+ dest.send(:"#{attr_name}=", source_v)
548
+ end
243
549
  end
244
550
  end
245
551
 
246
552
  class AttributeType < String
553
+ include Enum
247
554
  STRING = AttributeType.new("string")
248
555
  DATE_TIME = AttributeType.new("date_time")
249
556
  BOOLEAN = AttributeType.new("boolean")
@@ -260,6 +567,13 @@ module Etna
260
567
  PARENT = AttributeType.new("parent")
261
568
  TABLE = AttributeType.new("table")
262
569
  end
570
+
571
+ class ParentLinkType < String
572
+ include Enum
573
+ CHILD = ParentLinkType.new("child")
574
+ COLLECTION = ParentLinkType.new("collection")
575
+ TABLE = ParentLinkType.new("table")
576
+ end
263
577
  end
264
578
  end
265
- end
579
+ end