etna 0.1.12 → 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 (61) 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 +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 +345 -0
  19. data/lib/etna/clients/magma/models.rb +579 -0
  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 +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/directed_graph.rb +56 -0
  47. data/lib/etna/environment_scoped.rb +19 -0
  48. data/lib/etna/generate_autocompletion_script.rb +130 -0
  49. data/lib/etna/hmac.rb +1 -0
  50. data/lib/etna/json_serializable_struct.rb +37 -0
  51. data/lib/etna/logger.rb +15 -1
  52. data/lib/etna/multipart_serializable_nested_hash.rb +50 -0
  53. data/lib/etna/parse_body.rb +1 -1
  54. data/lib/etna/route.rb +1 -1
  55. data/lib/etna/server.rb +3 -0
  56. data/lib/etna/spec/vcr.rb +98 -0
  57. data/lib/etna/templates/attribute_actions_template.json +43 -0
  58. data/lib/etna/test_auth.rb +4 -2
  59. data/lib/etna/user.rb +11 -1
  60. data/lib/helpers.rb +81 -0
  61. metadata +70 -7
@@ -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,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
@@ -0,0 +1,579 @@
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 all_linked_model_names
372
+ models = [ self.parent, ] + build_attributes.all.map { |v| v.link_model_name }
373
+ models.select { |m| !m.nil? }.uniq
374
+ end
375
+ end
376
+
377
+ class Attributes
378
+ attr_reader :raw
379
+
380
+ def initialize(raw = {})
381
+ @raw = raw
382
+ end
383
+
384
+ def attribute_keys
385
+ raw.keys
386
+ end
387
+
388
+ def attribute(attribute_key)
389
+ return nil unless raw.include?(attribute_key)
390
+ Attribute.new(raw[attribute_key])
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
400
+ end
401
+
402
+ class Attribute
403
+ attr_reader :raw
404
+
405
+ def initialize(raw = {})
406
+ @raw = raw
407
+ end
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
+
419
+ def name
420
+ @raw['name'] || ""
421
+ end
422
+
423
+ def name=(val)
424
+ @raw['name'] = val
425
+ end
426
+
427
+ def attribute_name
428
+ @raw['attribute_name'] || ""
429
+ end
430
+
431
+ def attribute_name=(val)
432
+ @raw['attribute_name'] = val
433
+ end
434
+
435
+ def attribute_type
436
+ @raw['attribute_type'] && AttributeType.new(@raw['attribute_type'])
437
+ end
438
+
439
+ def attribute_type=(val)
440
+ val = val.to_s if val
441
+ @raw['attribute_type'] = val
442
+ end
443
+
444
+ def 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
549
+ end
550
+ end
551
+
552
+ class AttributeType < String
553
+ include Enum
554
+ STRING = AttributeType.new("string")
555
+ DATE_TIME = AttributeType.new("date_time")
556
+ BOOLEAN = AttributeType.new("boolean")
557
+ CHILD = AttributeType.new("child")
558
+ COLLECTION = AttributeType.new("collection")
559
+ FILE = AttributeType.new("file")
560
+ FLOAT = AttributeType.new("float")
561
+ IDENTIFIER = AttributeType.new("identifier")
562
+ IMAGE = AttributeType.new("image")
563
+ INTEGER = AttributeType.new("integer")
564
+ LINK = AttributeType.new("link")
565
+ MATCH = AttributeType.new("match")
566
+ MATRIX = AttributeType.new("matrix")
567
+ PARENT = AttributeType.new("parent")
568
+ TABLE = AttributeType.new("table")
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
577
+ end
578
+ end
579
+ end