mendix-ruby-bridge 0.1.1

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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/.mxcli-version +2 -0
  3. data/LICENSE +21 -0
  4. data/README.md +77 -0
  5. data/bin/git-mendix +4 -0
  6. data/bin/mendix-apply +145 -0
  7. data/bin/mendix-desktop +26 -0
  8. data/bin/mendix-git +121 -0
  9. data/bin/mendix-ruby +580 -0
  10. data/bin/mxcli +20 -0
  11. data/bin/setup-tools +25 -0
  12. data/lib/mendix_bridge/backend_server.rb +1404 -0
  13. data/lib/mendix_bridge/change_planner.rb +594 -0
  14. data/lib/mendix_bridge/config.rb +89 -0
  15. data/lib/mendix_bridge/dependency_index.rb +188 -0
  16. data/lib/mendix_bridge/desktop_app.rb +121 -0
  17. data/lib/mendix_bridge/document_parser.rb +120 -0
  18. data/lib/mendix_bridge/domain_parser.rb +103 -0
  19. data/lib/mendix_bridge/dsl.rb +540 -0
  20. data/lib/mendix_bridge/enumeration_parser.rb +32 -0
  21. data/lib/mendix_bridge/git_workflow.rb +321 -0
  22. data/lib/mendix_bridge/html_viewer.rb +672 -0
  23. data/lib/mendix_bridge/importer.rb +415 -0
  24. data/lib/mendix_bridge/inventory.rb +93 -0
  25. data/lib/mendix_bridge/mdl_generator.rb +343 -0
  26. data/lib/mendix_bridge/microflow_parser.rb +131 -0
  27. data/lib/mendix_bridge/migration.rb +290 -0
  28. data/lib/mendix_bridge/migration_executor.rb +234 -0
  29. data/lib/mendix_bridge/model.rb +204 -0
  30. data/lib/mendix_bridge/page_parser.rb +95 -0
  31. data/lib/mendix_bridge/presenter.rb +35 -0
  32. data/lib/mendix_bridge/project_creator.rb +181 -0
  33. data/lib/mendix_bridge/ruby_inventory_generator.rb +63 -0
  34. data/lib/mendix_bridge/security_parser.rb +72 -0
  35. data/lib/mendix_bridge/snapshot_diff.rb +58 -0
  36. data/lib/mendix_bridge/validator.rb +46 -0
  37. data/lib/mendix_bridge/version.rb +5 -0
  38. data/lib/mendix_bridge/visual_entity_plan.rb +176 -0
  39. data/lib/mendix_bridge.rb +127 -0
  40. data/share/applications/mendix-ruby-bridge.desktop +12 -0
  41. data/share/icons/hicolor/128x128/apps/mendix-ruby-bridge.png +0 -0
  42. data/share/icons/hicolor/256x256/apps/mendix-ruby-bridge.png +0 -0
  43. data/share/icons/hicolor/32x32/apps/mendix-ruby-bridge.png +0 -0
  44. data/share/icons/hicolor/48x48/apps/mendix-ruby-bridge.png +0 -0
  45. data/share/icons/hicolor/512x512/apps/mendix-ruby-bridge.png +0 -0
  46. data/share/icons/hicolor/64x64/apps/mendix-ruby-bridge.png +0 -0
  47. data/web/dist/apple-touch-icon.png +0 -0
  48. data/web/dist/assets/index-BO6iPT1P.css +1 -0
  49. data/web/dist/assets/index-JGODw81W.js +27 -0
  50. data/web/dist/brand/mendix-ruby-bridge.png +0 -0
  51. data/web/dist/favicon.png +0 -0
  52. data/web/dist/icons.svg +24 -0
  53. data/web/dist/index.html +17 -0
  54. metadata +179 -0
@@ -0,0 +1,415 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "time"
6
+
7
+ module MendixBridge
8
+ class ImportError < StandardError; end
9
+
10
+ class Importer
11
+ SNAPSHOT_FILE = "inventory/project-tree.json"
12
+ ELEMENT_DETAILS_FILE = "inventory/element-details.json"
13
+ METADATA_FILE = "mendix-project.json"
14
+ DEPENDENCIES_FILE = "inventory/dependencies.json"
15
+
16
+ def initialize(mxcli:)
17
+ @mxcli = File.expand_path(mxcli)
18
+ end
19
+
20
+ def import(project_file, output_dir)
21
+ project_file = File.expand_path(project_file)
22
+ output_dir = File.expand_path(output_dir)
23
+
24
+ raise ImportError, "Mendix project does not exist: #{project_file}" unless File.file?(project_file)
25
+ raise ImportError, "mxcli is not executable: #{@mxcli}" unless File.executable?(@mxcli)
26
+
27
+ stdout, stderr, status = Open3.capture3(
28
+ @mxcli, "--json", "-p", project_file, "project-tree"
29
+ )
30
+ unless status.success?
31
+ raise ImportError, "mxcli could not read the project: #{stderr.strip}"
32
+ end
33
+
34
+ inventory = Inventory.from_json(stdout)
35
+ element_details, warnings = describe_elements(project_file, inventory)
36
+ inventory = Inventory.from_json(stdout, details: element_details)
37
+ write_project(output_dir, project_file, inventory)
38
+ [inventory, warnings]
39
+ rescue JSON::ParserError => error
40
+ raise ImportError, "mxcli returned invalid project-tree JSON: #{error.message}"
41
+ end
42
+
43
+ private
44
+
45
+ def write_project(output_dir, project_file, inventory)
46
+ FileUtils.mkdir_p(File.join(output_dir, "inventory"))
47
+ FileUtils.rm_f(File.join(output_dir, "inventory", "domain-details.json"))
48
+
49
+ File.write(
50
+ File.join(output_dir, SNAPSHOT_FILE),
51
+ "#{JSON.pretty_generate(inventory.tree)}\n"
52
+ )
53
+ File.write(
54
+ File.join(output_dir, ELEMENT_DETAILS_FILE),
55
+ "#{JSON.pretty_generate(element_details(inventory))}\n"
56
+ )
57
+ File.write(
58
+ File.join(output_dir, METADATA_FILE),
59
+ "#{JSON.pretty_generate(metadata(project_file, inventory))}\n"
60
+ )
61
+ File.write(
62
+ File.join(output_dir, DEPENDENCIES_FILE),
63
+ "#{JSON.pretty_generate(DependencyIndex.new(inventory).to_h)}\n"
64
+ )
65
+ write_once(File.join(output_dir, "Gemfile"), gemfile)
66
+ write_once(File.join(output_dir, "Mendixfile.rb"), mendixfile)
67
+ write_once(File.join(output_dir, "README.md"), readme(project_file))
68
+ write_generated_modules(output_dir, inventory)
69
+ write_generated_microflows(output_dir, inventory)
70
+ write_generated_nanoflows(output_dir, inventory)
71
+ write_generated_enumerations(output_dir, inventory)
72
+ write_generated_pages(output_dir, inventory)
73
+ write_generated_project_documents(output_dir, inventory)
74
+ write_generated_security(output_dir, inventory)
75
+ end
76
+
77
+ def metadata(project_file, inventory)
78
+ {
79
+ schema_version: 1,
80
+ source_project: project_file,
81
+ imported_at: Time.now.iso8601,
82
+ snapshot: SNAPSHOT_FILE,
83
+ element_count: inventory.elements.length,
84
+ types: inventory.types
85
+ }
86
+ end
87
+
88
+ def write_once(path, content)
89
+ File.write(path, content) unless File.exist?(path)
90
+ end
91
+
92
+ # One `mxcli describe` process per element, so a full refresh on a real
93
+ # project spawns hundreds of them — run a small worker pool. Open3 blocks
94
+ # in IO/waitpid, which releases the GVL, so threads give near-linear
95
+ # speedup here.
96
+ DESCRIBE_WORKERS = 8
97
+
98
+ def describe_elements(project_file, inventory)
99
+ details = {}
100
+ warnings = []
101
+ mutex = Mutex.new
102
+
103
+ queue = Queue.new
104
+ inventory.elements
105
+ .select do |element|
106
+ %w[
107
+ entity association enumeration microflow nanoflow page
108
+ constant javaaction layout snippet importmapping exportmapping navprofile
109
+ projectsecurity modulerole userrole
110
+ ].include?(element.type)
111
+ end
112
+ .each { |element| queue << element }
113
+ queue.close
114
+
115
+ workers = Array.new(DESCRIBE_WORKERS) do
116
+ Thread.new do
117
+ while (element = queue.pop)
118
+ describe_type, describe_name = describe_target(element)
119
+ begin
120
+ stdout, stderr, status = Open3.capture3(
121
+ @mxcli,
122
+ "--json",
123
+ "-p", project_file,
124
+ "describe", describe_type, describe_name
125
+ )
126
+
127
+ unless status.success?
128
+ mutex.synchronize { warnings << "#{element.qualified_name}: #{stderr.strip}" }
129
+ next
130
+ end
131
+
132
+ parsed = parse_description(element, JSON.parse(stdout))
133
+ mutex.synchronize { details[element.qualified_name] = parsed }
134
+ rescue JSON::ParserError => error
135
+ mutex.synchronize { warnings << "#{element.qualified_name}: invalid JSON (#{error.message})" }
136
+ end
137
+ end
138
+ end
139
+ end
140
+ workers.each(&:join)
141
+
142
+ [details, warnings]
143
+ end
144
+
145
+ def parse_description(element, description)
146
+ if %w[microflow nanoflow].include?(element.type)
147
+ MicroflowParser.parse(description)
148
+ elsif element.type == "enumeration"
149
+ EnumerationParser.parse(description)
150
+ elsif %w[
151
+ constant javaaction layout snippet importmapping exportmapping navprofile
152
+ ].include?(element.type)
153
+ DocumentParser.parse(element.type, description)
154
+ elsif element.type == "page"
155
+ PageParser.parse(description)
156
+ elsif %w[projectsecurity modulerole userrole].include?(element.type)
157
+ SecurityParser.parse(element.type, description)
158
+ else
159
+ DomainParser.parse(description)
160
+ end
161
+ end
162
+
163
+ def describe_target(element)
164
+ case element.type
165
+ when "importmapping", "exportmapping"
166
+ [element.type, element.label]
167
+ when "navprofile"
168
+ ["navigation", element.qualified_name]
169
+ else
170
+ [element.type, element.qualified_name]
171
+ end
172
+ end
173
+
174
+ def element_details(inventory)
175
+ inventory.elements.each_with_object({}) do |element, result|
176
+ if element.qualified_name && element.details
177
+ result[element.qualified_name] = element.details
178
+ end
179
+ end
180
+ end
181
+
182
+ def write_generated_modules(output_dir, inventory)
183
+ generated_dir = File.join(output_dir, "generated", "modules")
184
+ FileUtils.mkdir_p(generated_dir)
185
+ FileUtils.rm_f(Dir[File.join(generated_dir, "*.rb")])
186
+
187
+ RubyInventoryGenerator.generate(inventory).each do |module_name, domain|
188
+ path = File.join(
189
+ generated_dir,
190
+ "#{RubyInventoryGenerator.file_name(module_name)}.rb"
191
+ )
192
+ File.write(path, RubyInventoryGenerator.ruby_source(module_name, domain))
193
+ end
194
+ end
195
+
196
+ def write_generated_microflows(output_dir, inventory)
197
+ generated_dir = File.join(output_dir, "generated", "microflows")
198
+ FileUtils.mkdir_p(generated_dir)
199
+ FileUtils.rm_f(Dir[File.join(generated_dir, "*.rb")])
200
+
201
+ inventory.modules.each do |app_module|
202
+ module_name = app_module.qualified_name
203
+ microflows = inventory.elements
204
+ .select do |element|
205
+ element.type == "microflow" &&
206
+ element.qualified_name&.start_with?("#{module_name}.")
207
+ end
208
+ .map do |element|
209
+ {
210
+ "name" => element.label,
211
+ "qualified_name" => element.qualified_name,
212
+ "details" => element.details
213
+ }
214
+ end
215
+ next if microflows.empty?
216
+
217
+ path = File.join(
218
+ generated_dir,
219
+ "#{RubyInventoryGenerator.file_name(module_name)}.rb"
220
+ )
221
+ File.write(
222
+ path,
223
+ RubyInventoryGenerator.ruby_source(
224
+ "#{module_name}Microflows",
225
+ "microflows" => microflows
226
+ )
227
+ )
228
+ end
229
+ end
230
+
231
+ def write_generated_nanoflows(output_dir, inventory)
232
+ write_generated_documents(
233
+ output_dir,
234
+ inventory,
235
+ type: "nanoflow",
236
+ directory: "nanoflows"
237
+ )
238
+ end
239
+
240
+ def write_generated_enumerations(output_dir, inventory)
241
+ write_generated_documents(
242
+ output_dir,
243
+ inventory,
244
+ type: "enumeration",
245
+ directory: "enumerations"
246
+ )
247
+ end
248
+
249
+ def write_generated_pages(output_dir, inventory)
250
+ write_generated_documents(output_dir, inventory, type: "page", directory: "pages")
251
+ end
252
+
253
+ def write_generated_project_documents(output_dir, inventory)
254
+ {
255
+ "constant" => "constants",
256
+ "javaaction" => "java_actions",
257
+ "layout" => "layouts",
258
+ "snippet" => "snippets",
259
+ "importmapping" => "import_mappings",
260
+ "exportmapping" => "export_mappings"
261
+ }.each do |type, directory|
262
+ write_generated_documents(output_dir, inventory, type:, directory:)
263
+ end
264
+
265
+ write_generated_navigation(output_dir, inventory)
266
+ end
267
+
268
+ def write_generated_navigation(output_dir, inventory)
269
+ generated_dir = File.join(output_dir, "generated", "navigation")
270
+ FileUtils.mkdir_p(generated_dir)
271
+ FileUtils.rm_f(Dir[File.join(generated_dir, "*.rb")])
272
+
273
+ profiles = inventory.of_type("navprofile").map do |element|
274
+ {
275
+ "name" => element.label,
276
+ "qualified_name" => element.qualified_name,
277
+ "details" => element.details
278
+ }
279
+ end
280
+ return if profiles.empty?
281
+
282
+ File.write(
283
+ File.join(generated_dir, "project.rb"),
284
+ RubyInventoryGenerator.ruby_source(
285
+ "Navigation",
286
+ "navigation_profiles" => profiles
287
+ )
288
+ )
289
+ end
290
+
291
+ def write_generated_documents(output_dir, inventory, type:, directory:)
292
+ generated_dir = File.join(output_dir, "generated", directory)
293
+ FileUtils.mkdir_p(generated_dir)
294
+ FileUtils.rm_f(Dir[File.join(generated_dir, "*.rb")])
295
+
296
+ inventory.modules.each do |app_module|
297
+ module_name = app_module.qualified_name
298
+ documents = inventory.elements
299
+ .select do |element|
300
+ element.type == type &&
301
+ element.qualified_name&.start_with?("#{module_name}.")
302
+ end
303
+ .map do |element|
304
+ {
305
+ "name" => element.label,
306
+ "qualified_name" => element.qualified_name,
307
+ "details" => element.details
308
+ }
309
+ end
310
+ next if documents.empty?
311
+
312
+ path = File.join(
313
+ generated_dir,
314
+ "#{RubyInventoryGenerator.file_name(module_name)}.rb"
315
+ )
316
+ File.write(
317
+ path,
318
+ RubyInventoryGenerator.ruby_source(
319
+ "#{module_name}#{type.capitalize}s",
320
+ "#{type}s" => documents
321
+ )
322
+ )
323
+ end
324
+ end
325
+
326
+ def write_generated_security(output_dir, inventory)
327
+ generated_dir = File.join(output_dir, "generated", "security")
328
+ FileUtils.mkdir_p(generated_dir)
329
+ FileUtils.rm_f(Dir[File.join(generated_dir, "*.rb")])
330
+
331
+ security_elements = inventory.elements
332
+ .select { |element| %w[projectsecurity modulerole userrole].include?(element.type) }
333
+ .map do |element|
334
+ {
335
+ "name" => element.qualified_name || element.label,
336
+ "type" => element.type,
337
+ "details" => element.details
338
+ }
339
+ end
340
+ entity_access = inventory.of_type("entity").filter_map do |entity|
341
+ rules = entity.details&.fetch("access_rules", [])
342
+ next if rules.empty?
343
+
344
+ { "entity" => entity.qualified_name, "rules" => rules }
345
+ end
346
+
347
+ source = RubyInventoryGenerator.ruby_source(
348
+ "Security",
349
+ "security_elements" => security_elements,
350
+ "entity_access" => entity_access
351
+ )
352
+ File.write(File.join(generated_dir, "project.rb"), source)
353
+ end
354
+
355
+ def mendixfile
356
+ <<~RUBY
357
+ # frozen_string_literal: true
358
+
359
+ require "mendix_bridge"
360
+
361
+ MENDIX_PROJECT = MendixBridge::Inventory.load(
362
+ File.expand_path("inventory/project-tree.json", __dir__)
363
+ )
364
+ RUBY
365
+ end
366
+
367
+ def gemfile
368
+ bridge_dir = File.dirname(File.dirname(@mxcli))
369
+
370
+ <<~RUBY
371
+ source "https://rubygems.org"
372
+
373
+ gem "mendix-ruby-bridge", path: #{bridge_dir.inspect}
374
+ RUBY
375
+ end
376
+
377
+ def readme(project_file)
378
+ <<~MARKDOWN
379
+ # Imported Mendix project
380
+
381
+ Read-only Ruby inventory imported from:
382
+
383
+ `#{project_file}`
384
+
385
+ Load it from Ruby:
386
+
387
+ ```ruby
388
+ require_relative "Mendixfile"
389
+
390
+ project = MENDIX_PROJECT
391
+ project.modules
392
+ project.of_type("microflow")
393
+ project.search("Customer")
394
+ project.find("MyFirstModule.Customer")
395
+ ```
396
+
397
+ Generated domain-model files are under `generated/modules/`; generated
398
+ microflow files are under `generated/microflows/`; nanoflows are under
399
+ `generated/nanoflows/`; enumerations are under
400
+ `generated/enumerations/`; generated pages are under `generated/pages/`;
401
+ the consolidated security view is under `generated/security/`. Every
402
+ interpreted element retains its original MDL plus normalized details.
403
+
404
+ Explore interactively:
405
+
406
+ ```sh
407
+ bundle exec irb -r ./Mendixfile
408
+ ```
409
+
410
+ Refresh the snapshot by running `mendix-ruby import` again with
411
+ `--force`. Importing and querying do not modify the `.mpr` project.
412
+ MARKDOWN
413
+ end
414
+ end
415
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ class Inventory
5
+ Element = Data.define(:label, :type, :qualified_name, :path, :raw, :details) do
6
+ def module_name
7
+ qualified_name&.split(".", 2)&.first
8
+ end
9
+
10
+ def to_h
11
+ {
12
+ label:,
13
+ type:,
14
+ qualified_name:,
15
+ path:,
16
+ raw:,
17
+ details:
18
+ }.compact
19
+ end
20
+ end
21
+
22
+ attr_reader :tree, :elements
23
+
24
+ def self.from_json(json, details: {})
25
+ new(JSON.parse(json), details:)
26
+ end
27
+
28
+ def self.load(path, details_path: nil)
29
+ details_path ||= begin
30
+ directory = File.dirname(path)
31
+ current = File.join(directory, "element-details.json")
32
+ File.file?(current) ? current : File.join(directory, "domain-details.json")
33
+ end
34
+ details = File.file?(details_path) ? JSON.parse(File.read(details_path)) : {}
35
+ from_json(File.read(path), details:)
36
+ end
37
+
38
+ def initialize(tree, details: {})
39
+ raise ArgumentError, "inventory root must be an array" unless tree.is_a?(Array)
40
+
41
+ @tree = tree
42
+ @details = details
43
+ @elements = flatten(tree).freeze
44
+ end
45
+
46
+ def search(query)
47
+ needle = query.to_s.downcase
48
+ elements.select do |element|
49
+ [element.label, element.qualified_name, element.type]
50
+ .compact
51
+ .any? { |value| value.downcase.include?(needle) }
52
+ end
53
+ end
54
+
55
+ def of_type(type)
56
+ expected = type.to_s.downcase
57
+ elements.select { |element| element.type&.downcase == expected }
58
+ end
59
+
60
+ def modules
61
+ of_type("module")
62
+ end
63
+
64
+ def find(qualified_name)
65
+ elements.find { |element| element.qualified_name == qualified_name }
66
+ end
67
+
68
+ def types
69
+ elements.each_with_object(Hash.new(0)) do |element, counts|
70
+ counts[element.type] += 1 if element.type
71
+ end.sort.to_h
72
+ end
73
+
74
+ private
75
+
76
+ def flatten(nodes, parents = [])
77
+ nodes.flat_map do |node|
78
+ label = node["label"]
79
+ path = [*parents, label].compact
80
+ element = Element.new(
81
+ label:,
82
+ type: node["type"],
83
+ qualified_name: node["qualifiedName"],
84
+ path:,
85
+ raw: node,
86
+ details: @details[node["qualifiedName"]]
87
+ )
88
+
89
+ [element, *flatten(node.fetch("children", []), path)]
90
+ end
91
+ end
92
+ end
93
+ end