etna 0.1.14 → 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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/bin/etna +18 -0
  3. data/etna.completion +1001 -0
  4. data/etna_app.completion +133 -0
  5. data/ext/completions/extconf.rb +20 -0
  6. data/lib/commands.rb +395 -0
  7. data/lib/etna.rb +7 -0
  8. data/lib/etna/application.rb +46 -22
  9. data/lib/etna/client.rb +82 -48
  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 +4 -0
  16. data/lib/etna/clients/magma/client.rb +80 -0
  17. data/lib/etna/clients/magma/formatting.rb +1 -0
  18. data/lib/etna/clients/magma/formatting/models_csv.rb +354 -0
  19. data/lib/etna/clients/magma/models.rb +630 -0
  20. data/lib/etna/clients/magma/workflows.rb +10 -0
  21. data/lib/etna/clients/magma/workflows/add_project_models_workflow.rb +67 -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 +123 -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 +452 -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 +246 -0
  33. data/lib/etna/clients/metis.rb +3 -0
  34. data/lib/etna/clients/metis/client.rb +239 -0
  35. data/lib/etna/clients/metis/models.rb +313 -0
  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 +243 -5
  45. data/lib/etna/controller.rb +4 -0
  46. data/lib/etna/csvs.rb +159 -0
  47. data/lib/etna/directed_graph.rb +56 -0
  48. data/lib/etna/environment_scoped.rb +19 -0
  49. data/lib/etna/errors.rb +6 -0
  50. data/lib/etna/generate_autocompletion_script.rb +131 -0
  51. data/lib/etna/json_serializable_struct.rb +37 -0
  52. data/lib/etna/logger.rb +24 -2
  53. data/lib/etna/multipart_serializable_nested_hash.rb +50 -0
  54. data/lib/etna/route.rb +1 -1
  55. data/lib/etna/server.rb +3 -0
  56. data/lib/etna/spec/vcr.rb +99 -0
  57. data/lib/etna/templates/attribute_actions_template.json +43 -0
  58. data/lib/etna/test_auth.rb +3 -1
  59. data/lib/etna/user.rb +11 -1
  60. data/lib/helpers.rb +90 -0
  61. metadata +70 -5
@@ -0,0 +1,4 @@
1
+ require_relative './magma/client'
2
+ require_relative './magma/models'
3
+ require_relative './magma/workflows'
4
+ require_relative './magma/formatting'
@@ -0,0 +1,80 @@
1
+ require 'net/http/persistent'
2
+ require 'net/http/post/multipart'
3
+ require 'singleton'
4
+ require_relative '../../client'
5
+ require_relative './models'
6
+
7
+ module Etna
8
+ module Clients
9
+ class Magma
10
+ attr_reader :host, :token, :ignore_ssl
11
+ def initialize(host:, token:, persistent: true, ignore_ssl: false)
12
+ raise 'Magma client configuration is missing host.' unless host
13
+ raise 'Magma client configuration is missing token.' unless token
14
+ @etna_client = ::Etna::Client.new(
15
+ host,
16
+ token,
17
+ routes_available: false,
18
+ persistent: persistent,
19
+ ignore_ssl: ignore_ssl)
20
+ @host = host
21
+ @token = token
22
+ @ignore_ssl = ignore_ssl
23
+ end
24
+
25
+ # This endpoint returns models and records by name:
26
+ # e.g. params:
27
+ # {
28
+ # model_name: "model_one", # or "all"
29
+ # record_names: [ "rn1", "rn2" ], # or "all",
30
+ # attribute_names: "all"
31
+ # }
32
+ def retrieve(retrieval_request = RetrievalRequest.new)
33
+ json = nil
34
+ @etna_client.post('/retrieve', retrieval_request) do |res|
35
+ json = JSON.parse(res.body)
36
+ end
37
+
38
+ RetrievalResponse.new(json)
39
+ end
40
+
41
+ # This 'query' end point is used to fetch data by graph query
42
+ # See question.rb for more detail
43
+ def query(query_request = QueryRequest.new)
44
+ json = nil
45
+ @etna_client.post('/query', query_request) do |res|
46
+ json = JSON.parse(res.body)
47
+ end
48
+
49
+ QueryResponse.new(json)
50
+ end
51
+
52
+ def update(update_request = UpdateRequest.new)
53
+ json = nil
54
+ @etna_client.multipart_post('/update', update_request.encode_multipart_content) do |res|
55
+ json = JSON.parse(res.body)
56
+ end
57
+
58
+ UpdateResponse.new(json)
59
+ end
60
+
61
+ def update_json(update_request = UpdateRequest.new)
62
+ json = nil
63
+ @etna_client.post('/update', update_request) do |res|
64
+ json = JSON.parse(res.body)
65
+ end
66
+
67
+ UpdateResponse.new(json)
68
+ end
69
+
70
+ def update_model(update_model_request = UpdateModelRequest.new)
71
+ json = nil
72
+ @etna_client.post('/update_model', update_model_request) do |res|
73
+ json = JSON.parse(res.body)
74
+ end
75
+
76
+ UpdateModelResponse.new(json)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1 @@
1
+ require_relative 'formatting/models_csv'
@@ -0,0 +1,354 @@
1
+ require 'ostruct'
2
+ require_relative '../../../csvs'
3
+
4
+ module Etna
5
+ module Clients
6
+ class Magma
7
+ module ModelsCsv
8
+ module Prettify
9
+ def prettify(name)
10
+ name.split('_').map(&:capitalize).join(' ')
11
+ end
12
+ end
13
+
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
38
+
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)
42
+ end
43
+ end
44
+
45
+ def write_model_rows(models, model_keys, row_writeable)
46
+ ensure_parents(models, model_keys, row_writeable)
47
+
48
+ matrix_constants = {}
49
+ model_keys.each { |model_name| each_model_row(models, model_name, matrix_constants, row_writeable) }
50
+
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
57
+ end
58
+ end
59
+
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)
75
+ end
76
+
77
+ q.push(*models.model(model_key).template.all_linked_model_names)
78
+ end
79
+ end
80
+
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
+ end
102
+ end
103
+
104
+ def each_model_row(models, model_name, matrix_constants, row_writeable)
105
+ return unless (model = models.model(model_name))
106
+
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
+ end
112
+
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
123
+
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)
132
+
133
+ options = COPY_OPTIONS_SENTINEL + digest
134
+ end
135
+
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
+ )
152
+ end
153
+ end
154
+
155
+ class ModelsChangeset < Struct.new(:models, :renames, :matrix_constants, keyword_init: true)
156
+ def initialize(*args)
157
+ super
158
+
159
+ self.models ||= Models.new
160
+ self.renames ||= {}
161
+ self.matrix_constants ||= {}
162
+ end
163
+
164
+ def build_renames(model_name)
165
+ renames[model_name] ||= {}
166
+ end
167
+ end
168
+
169
+ COPY_OPTIONS_SENTINEL = '$copy_from_original$'
170
+
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))
187
+ end
188
+
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)
199
+ end
200
+
201
+ def process_row(row_processor, changeset)
202
+ models = changeset.models
203
+
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)
208
+ end
209
+
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)
216
+ end
217
+
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
223
+ end
224
+ end
225
+ rescue ImportError => e
226
+ validation_err_block.call(e.message)
227
+ end
228
+
229
+
230
+ private
231
+
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 }
235
+ end
236
+ row_processor.process(:identifier, :model_name) do |identifier, template|
237
+ template.identifier = identifier
238
+ end
239
+ end
240
+
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
246
+
247
+ template.parent = parent_model_name
248
+
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
257
+
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
263
+
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
267
+
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
283
+ end
284
+
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
297
+ end
298
+ end
299
+
300
+ def process_attribute_properties(changeset, row_processor)
301
+ models = changeset.models
302
+
303
+ row_processor.process(:attribute_name, :model_name) do |attribute_name, template|
304
+ attributes = template.build_attributes
305
+
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
311
+
312
+ # Clear its definition; implicit attributes are overwritten by explicit definitions if they exist.
313
+ attributes.raw.delete(attribute_name)
314
+ end
315
+
316
+ attributes.build_attribute(attribute_name).tap { |att| att.name = att.attribute_name = attribute_name }
317
+ end
318
+
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
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,630 @@
1
+ require 'ostruct'
2
+ require_relative '../../json_serializable_struct'
3
+ require_relative '../../multipart_serializable_nested_hash'
4
+ require_relative '../../directed_graph'
5
+ require_relative '../enum'
6
+
7
+ # TODO: In the near future, I'd like to transition to specifying apis via SWAGGER and generating model stubs from the
8
+ # common definitions. For nowe I've written them out by hand here.
9
+ module Etna
10
+ module Clients
11
+ class Magma
12
+ class RetrievalRequest < Struct.new(:model_name, :attribute_names, :record_names, :project_name, :page, :page_size, :order, :filter, keyword_init: true)
13
+ include JsonSerializableStruct
14
+
15
+ def initialize(**params)
16
+ super({model_name: 'all', attribute_names: 'all', record_names: []}.update(params))
17
+ end
18
+ end
19
+
20
+ class QueryRequest < Struct.new(:query, :project_name, keyword_init: true)
21
+ include JsonSerializableStruct
22
+ end
23
+
24
+ class UpdateRequest < Struct.new(:revisions, :project_name, keyword_init: true)
25
+ include JsonSerializableStruct
26
+ include MultipartSerializableNestedHash
27
+
28
+ def initialize(**params)
29
+ super({revisions: {}}.update(params))
30
+ end
31
+
32
+ def update_revision(model_name, record_name, attrs)
33
+ revision = revisions[model_name] ||= {}
34
+ record = revision[record_name] ||= {}
35
+ record.update(attrs)
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
46
+ end
47
+
48
+ class UpdateModelRequest < Struct.new(:project_name, :actions, keyword_init: true)
49
+ include JsonSerializableStruct
50
+
51
+ def initialize(**params)
52
+ super({actions: []}.update(params))
53
+ end
54
+
55
+ def add_action(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))
65
+ end
66
+ end
67
+
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)
69
+ include JsonSerializableStruct
70
+
71
+ def initialize(**args)
72
+ super({action_name: 'add_attribute'}.update(args))
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
138
+ end
139
+
140
+ class AttributeValidation < Struct.new(:type, :value, :begin, :end, keyword_init: true)
141
+ include JsonSerializableStruct
142
+ end
143
+
144
+ class AttributeValidationType < String
145
+ include Enum
146
+ REGEXP = AttributeValidationType.new("Regexp")
147
+ ARRAY = AttributeValidationType.new("Array")
148
+ RANGE = AttributeValidationType.new("Range")
149
+ end
150
+
151
+ class RetrievalResponse
152
+ attr_reader :raw
153
+
154
+ def initialize(raw = {})
155
+ @raw = raw
156
+ end
157
+
158
+ def models
159
+ Models.new(raw['models'])
160
+ end
161
+ end
162
+
163
+ class UpdateModelResponse < RetrievalResponse
164
+ end
165
+
166
+ class QueryResponse
167
+ attr_reader :raw
168
+
169
+ def initialize(raw = {})
170
+ @raw = raw
171
+ end
172
+
173
+ def answer
174
+ raw['answer']
175
+ end
176
+
177
+ def format
178
+ raw['format']
179
+ end
180
+
181
+ def type
182
+ raw['type']
183
+ end
184
+ end
185
+
186
+ class UpdateResponse < RetrievalResponse
187
+ end
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
+
201
+ class Models
202
+ attr_reader :raw
203
+
204
+ def initialize(raw = {})
205
+ @raw = raw
206
+ end
207
+
208
+ def model_keys
209
+ raw.keys
210
+ end
211
+
212
+ def build_model(model_key)
213
+ Model.new(raw[model_key] ||= {})
214
+ end
215
+
216
+ def model(model_key)
217
+ return nil unless raw.include?(model_key)
218
+ Model.new(raw[model_key])
219
+ end
220
+
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)
245
+ graph = ::DirectedGraph.new
246
+
247
+ model_keys.sort.each do |model_name|
248
+ graph.add_connection(model(model_name).template.parent, model_name)
249
+
250
+ if include_casual_links
251
+ attributes = model(model_name).template.attributes
252
+ attributes.attribute_keys.each do |attribute_name|
253
+ attribute = attributes.attribute(attribute_name)
254
+
255
+ linked_model_name = attribute.link_model_name
256
+ if linked_model_name
257
+ if attribute.attribute_type == AttributeType::PARENT
258
+ graph.add_connection(linked_model_name, model_name)
259
+ elsif attribute.attribute_type == AttributeType::COLLECTION
260
+ graph.add_connection(model_name, linked_model_name)
261
+ elsif attribute.attribute_type == AttributeType::CHILD
262
+ graph.add_connection(model_name, linked_model_name)
263
+ elsif attribute.attribute_type == AttributeType::LINK
264
+ graph.add_connection(model_name, linked_model_name)
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
270
+
271
+ graph
272
+ end
273
+ end
274
+
275
+ class Model
276
+ attr_reader :raw
277
+
278
+ def initialize(raw = {})
279
+ @raw = raw
280
+ end
281
+
282
+ def build_template
283
+ Template.new(raw['template'] ||= {})
284
+ end
285
+
286
+ def documents
287
+ Documents.new(raw['documents'])
288
+ end
289
+
290
+ def template
291
+ Template.new(raw['template'])
292
+ end
293
+
294
+ def name
295
+ @raw.dig('template', 'name')
296
+ end
297
+
298
+ def count
299
+ raw['count'] || 0
300
+ end
301
+ end
302
+
303
+ class Documents
304
+ attr_reader :raw
305
+
306
+ def initialize(raw = {})
307
+ @raw = raw
308
+ end
309
+
310
+ def document_keys
311
+ raw.keys
312
+ end
313
+
314
+ def +(other)
315
+ Documents.new({}.update(raw).update(other.raw))
316
+ end
317
+
318
+ def document(document_key)
319
+ return nil unless raw.include?(document_key)
320
+ raw[document_key]
321
+ end
322
+ end
323
+
324
+ class Template
325
+ attr_reader :raw
326
+
327
+ def initialize(raw = {})
328
+ @raw = raw
329
+ end
330
+
331
+ def name
332
+ raw['name'] || ""
333
+ end
334
+
335
+ def name=(val)
336
+ raw['name'] = val.to_s
337
+ end
338
+
339
+ def identifier
340
+ raw['identifier'] || ""
341
+ end
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
+
355
+ def parent
356
+ raw['parent']
357
+ end
358
+
359
+ def parent=(val)
360
+ raw['parent'] = val
361
+ end
362
+
363
+ def attributes
364
+ Attributes.new(raw['attributes'] ||= {})
365
+ end
366
+
367
+ def build_attributes
368
+ Attributes.new(raw['attributes'] ||= {})
369
+ end
370
+
371
+ def dictionary
372
+ Dictionary.new(raw['dictionary'] ||= {})
373
+ end
374
+
375
+ def build_dictionary
376
+ Dictionary.new(raw['dictionary'] ||= {})
377
+ end
378
+
379
+ def all_linked_model_names
380
+ models = [ self.parent, ] + build_attributes.all.map { |v| v.link_model_name }
381
+ models.select { |m| !m.nil? }.uniq
382
+ end
383
+ end
384
+
385
+ class Dictionary
386
+ attr_reader :raw
387
+
388
+ def initialize(raw = {})
389
+ @raw = raw
390
+ end
391
+
392
+ def dictionary_keys
393
+ raw.keys
394
+ end
395
+
396
+ def dictionary_model
397
+ raw['dictionary_model']
398
+ end
399
+
400
+ def dictionary_model=(val)
401
+ @raw['dictionary_model'] = val
402
+ end
403
+
404
+ def model_name
405
+ raw['model_name']
406
+ end
407
+
408
+ def model_name=(val)
409
+ @raw['model_name'] = val
410
+ end
411
+
412
+ def attributes
413
+ raw['attributes']
414
+ end
415
+
416
+ def attributes=(val)
417
+ @raw['attributes'] = val
418
+ end
419
+ end
420
+
421
+ class Attributes
422
+ attr_reader :raw
423
+
424
+ def initialize(raw = {})
425
+ @raw = raw
426
+ end
427
+
428
+ def attribute_keys
429
+ raw.keys
430
+ end
431
+
432
+ def attribute(attribute_key)
433
+ return nil unless raw.include?(attribute_key)
434
+ Attribute.new(raw[attribute_key])
435
+ end
436
+
437
+ def build_attribute(key)
438
+ Attribute.new(raw[key] ||= {})
439
+ end
440
+
441
+ def all
442
+ raw.values.map { |r| Attribute.new(r) }
443
+ end
444
+ end
445
+
446
+ class Attribute
447
+ attr_reader :raw
448
+
449
+ def initialize(raw = {})
450
+ @raw = raw
451
+ end
452
+
453
+ # Sets certain attribute fields which are implicit, even when not set, to match server behavior.
454
+ def set_field_defaults!
455
+ @raw.replace({
456
+ 'hidden' => false,
457
+ 'read_only' => false,
458
+ 'restricted' => false,
459
+ 'validation' => nil,
460
+ }.merge(@raw))
461
+ end
462
+
463
+ def name
464
+ @raw['name'] || ""
465
+ end
466
+
467
+ def name=(val)
468
+ @raw['name'] = val
469
+ end
470
+
471
+ def attribute_name
472
+ @raw['attribute_name'] || ""
473
+ end
474
+
475
+ def attribute_name=(val)
476
+ @raw['attribute_name'] = val
477
+ end
478
+
479
+ def attribute_type
480
+ @raw['attribute_type'] && AttributeType.new(@raw['attribute_type'])
481
+ end
482
+
483
+ def is_project_name_reference?(model_name)
484
+ return true if model_name == 'project' && attribute_type == AttributeType::IDENTIFIER
485
+ return true if link_model_name == 'project'
486
+ false
487
+ end
488
+
489
+ def attribute_type=(val)
490
+ val = val.to_s if val
491
+ @raw['attribute_type'] = val
492
+ end
493
+
494
+ def link_model_name
495
+ @raw['link_model_name']
496
+ end
497
+
498
+ def link_model_name=(val)
499
+ @raw['link_model_name'] = val
500
+ end
501
+
502
+ def unique
503
+ raw['unique']
504
+ end
505
+
506
+ def unique=(val)
507
+ raw['unique'] = val
508
+ end
509
+
510
+ def desc
511
+ raw['desc']
512
+ end
513
+
514
+ def desc=(val)
515
+ @raw['desc'] = val
516
+ end
517
+
518
+ def display_name
519
+ raw['display_name']
520
+ end
521
+
522
+ def display_name=(val)
523
+ raw['display_name'] = val
524
+ end
525
+
526
+ def match
527
+ raw['match']
528
+ end
529
+
530
+ def restricted
531
+ raw['restricted']
532
+ end
533
+
534
+ def restricted=(val)
535
+ raw['restricted'] = val
536
+ end
537
+
538
+ def format_hint
539
+ raw['format_hint']
540
+ end
541
+
542
+ def format_hint=(val)
543
+ raw['format_hint'] = val
544
+ end
545
+
546
+ def read_only
547
+ raw['read_only']
548
+ end
549
+
550
+ def read_only=(val)
551
+ raw['read_only'] = val
552
+ end
553
+
554
+ def attribute_group
555
+ raw['attribute_group']
556
+ end
557
+
558
+ def attribute_group=(val)
559
+ raw['attribute_group'] = val
560
+ end
561
+
562
+ def hidden
563
+ raw['hidden']
564
+ end
565
+
566
+ def hidden=(val)
567
+ raw['hidden'] = val
568
+ end
569
+
570
+ def validation
571
+ raw['validation']
572
+ end
573
+
574
+ def validation=(val)
575
+ raw['validation'] = val
576
+ end
577
+
578
+ def options
579
+ raw['options']
580
+ end
581
+
582
+ # NOTE! The Attribute class returns description as desc, where as actions take it in as description.
583
+ # There are shortcut methods that try to handle this on the action class side of things. Ideally we would
584
+ # make this more consistent in the near future.
585
+ COPYABLE_ATTRIBUTE_ATTRIBUTES = [
586
+ :attribute_name, :attribute_type, :desc, :display_name, :format_hint,
587
+ :hidden, :link_model_name, :read_only, :attribute_group, :unique, :validation,
588
+ :restricted
589
+ ]
590
+
591
+ EDITABLE_ATTRIBUTE_ATTRIBUTES = UpdateAttributeAction.members & COPYABLE_ATTRIBUTE_ATTRIBUTES
592
+
593
+ def self.copy(source, dest, attributes: COPYABLE_ATTRIBUTE_ATTRIBUTES)
594
+ attributes.each do |attr_name|
595
+ next unless dest.respond_to?(:"#{attr_name}=")
596
+ source_v = source.send(attr_name)
597
+ dest.send(:"#{attr_name}=", source_v)
598
+ end
599
+ end
600
+ end
601
+
602
+ class AttributeType < String
603
+ include Enum
604
+ STRING = AttributeType.new("string")
605
+ DATE_TIME = AttributeType.new("date_time")
606
+ BOOLEAN = AttributeType.new("boolean")
607
+ CHILD = AttributeType.new("child")
608
+ COLLECTION = AttributeType.new("collection")
609
+ FILE = AttributeType.new("file")
610
+ FILE_COLLECTION = AttributeType.new("file_collection")
611
+ FLOAT = AttributeType.new("float")
612
+ IDENTIFIER = AttributeType.new("identifier")
613
+ IMAGE = AttributeType.new("image")
614
+ INTEGER = AttributeType.new("integer")
615
+ LINK = AttributeType.new("link")
616
+ MATCH = AttributeType.new("match")
617
+ MATRIX = AttributeType.new("matrix")
618
+ PARENT = AttributeType.new("parent")
619
+ TABLE = AttributeType.new("table")
620
+ end
621
+
622
+ class ParentLinkType < String
623
+ include Enum
624
+ CHILD = ParentLinkType.new("child")
625
+ COLLECTION = ParentLinkType.new("collection")
626
+ TABLE = ParentLinkType.new("table")
627
+ end
628
+ end
629
+ end
630
+ end