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,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ class DependencyIndex
5
+ Edge = Data.define(:from, :to, :kind, :path) do
6
+ def to_h
7
+ { from:, to:, kind:, path: }
8
+ end
9
+ end
10
+
11
+ UNQUALIFIED_REFERENCE_KEYS = %w[
12
+ included_in_user_roles manageable_roles
13
+ ].freeze
14
+
15
+ KIND_KEYS = {
16
+ "calls" => "call",
17
+ "microflow_calls" => "microflow_call",
18
+ "nanoflow_calls" => "nanoflow_call",
19
+ "javascript_action_calls" => "javascript_action_call",
20
+ "page_links" => "page_link",
21
+ "layout" => "layout",
22
+ "structure" => "mapping_structure",
23
+ "generalization" => "generalization",
24
+ "from" => "association_source",
25
+ "to" => "association_target",
26
+ "execute_roles" => "execute_role",
27
+ "view_roles" => "view_role",
28
+ "module_roles" => "module_role",
29
+ "role" => "security_role",
30
+ "home_page" => "navigation_home",
31
+ "login_page" => "navigation_login",
32
+ "not_found_page" => "navigation_not_found",
33
+ "target" => "target",
34
+ "type" => "type_reference"
35
+ }.freeze
36
+
37
+ attr_reader :edges
38
+
39
+ def self.load(path, inventory:)
40
+ data = JSON.parse(File.read(path))
41
+ edges = data.fetch("edges").map do |edge|
42
+ Edge.new(
43
+ from: edge.fetch("from"),
44
+ to: edge.fetch("to"),
45
+ kind: edge.fetch("kind"),
46
+ path: edge.fetch("path")
47
+ )
48
+ end
49
+ new(inventory, edges:)
50
+ end
51
+
52
+ def initialize(inventory, edges: nil)
53
+ @inventory = inventory
54
+ @names = inventory.elements.filter_map(&:qualified_name).to_set
55
+ @qualified_names = @names.select { |name| name.include?(".") }.to_set
56
+ @edges = (edges || build_edges).freeze
57
+ end
58
+
59
+ def dependencies(name, transitive: false)
60
+ traverse(name, direction: :forward, transitive:)
61
+ end
62
+
63
+ def dependents(name, transitive: false)
64
+ traverse(name, direction: :reverse, transitive:)
65
+ end
66
+
67
+ def callers(name, transitive: false)
68
+ select_edges(
69
+ dependents(name, transitive:),
70
+ %w[call microflow_call nanoflow_call javascript_action_call]
71
+ )
72
+ end
73
+
74
+ def callees(name, transitive: false)
75
+ select_edges(
76
+ dependencies(name, transitive:),
77
+ %w[call microflow_call nanoflow_call javascript_action_call]
78
+ )
79
+ end
80
+
81
+ def impact(name)
82
+ dependents(name, transitive: true)
83
+ end
84
+
85
+ def to_h
86
+ {
87
+ schema_version: 1,
88
+ nodes: @names.length,
89
+ edges: edges.map(&:to_h)
90
+ }
91
+ end
92
+
93
+ private
94
+
95
+ def build_edges
96
+ found = []
97
+ @inventory.elements.each do |element|
98
+ next unless element.qualified_name && element.details
99
+
100
+ walk(
101
+ element.details.reject { |key, _value| %w[mdl raw].include?(key) },
102
+ source: element.qualified_name,
103
+ path: [],
104
+ found:
105
+ )
106
+ scan_mdl(element, found)
107
+ end
108
+ found.uniq { |edge| [edge.from, edge.to, edge.kind, edge.path] }
109
+ .sort_by { |edge| [edge.from, edge.to, edge.kind, edge.path] }
110
+ end
111
+
112
+ def walk(value, source:, path:, found:)
113
+ case value
114
+ when Hash
115
+ value.each do |key, child|
116
+ walk(child, source:, path: [*path, key.to_s], found:)
117
+ end
118
+ when Array
119
+ value.each_with_index do |child, index|
120
+ walk(child, source:, path: [*path, index.to_s], found:)
121
+ end
122
+ when String
123
+ references(value, path.last).each do |target|
124
+ add_edge(source, target, path, found)
125
+ end
126
+ end
127
+ end
128
+
129
+ def references(value, key)
130
+ matches = value.scan(/[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*/)
131
+ .select { |name| @qualified_names.include?(name) }
132
+ if UNQUALIFIED_REFERENCE_KEYS.include?(key) && @names.include?(value)
133
+ matches << value
134
+ end
135
+ matches.uniq
136
+ end
137
+
138
+ def scan_mdl(element, found)
139
+ mdl = element.details["mdl"]
140
+ return unless mdl
141
+
142
+ references(mdl, nil).each do |target|
143
+ add_edge(element.qualified_name, target, ["mdl"], found, kind: "mdl_reference")
144
+ end
145
+ end
146
+
147
+ def add_edge(source, target, path, found, kind: nil)
148
+ return if source == target
149
+
150
+ key = path.reverse.find { |part| !part.match?(/\A\d+\z/) }
151
+ found << Edge.new(
152
+ from: source,
153
+ to: target,
154
+ kind: kind || KIND_KEYS.fetch(key, "reference"),
155
+ path: path.join(".")
156
+ )
157
+ end
158
+
159
+ def traverse(name, direction:, transitive:)
160
+ raise ArgumentError, "unknown inventory element: #{name}" unless @names.include?(name)
161
+
162
+ selected = []
163
+ frontier = [name]
164
+ visited = Set[name]
165
+ loop do
166
+ current = frontier.shift
167
+ break unless current
168
+
169
+ matches = edges.select do |edge|
170
+ direction == :forward ? edge.from == current : edge.to == current
171
+ end
172
+ matches.each do |edge|
173
+ selected << edge
174
+ neighbor = direction == :forward ? edge.to : edge.from
175
+ next unless transitive && visited.add?(neighbor)
176
+
177
+ frontier << neighbor
178
+ end
179
+ break unless transitive || frontier.any?
180
+ end
181
+ selected.uniq { |edge| [edge.from, edge.to, edge.kind, edge.path] }
182
+ end
183
+
184
+ def select_edges(selected, kinds)
185
+ selected.select { |edge| kinds.include?(edge.kind) }
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "timeout"
5
+
6
+ module MendixBridge
7
+ class DesktopApp
8
+ APP_TITLE = "Mendix Ruby Bridge"
9
+ WINDOW_WIDTH = 1280
10
+ WINDOW_HEIGHT = 800
11
+ READY_TIMEOUT = 15
12
+
13
+ def initialize(inventory_dir:, bridge_dir: nil)
14
+ @inventory_dir = File.expand_path(inventory_dir)
15
+ @bridge_dir = bridge_dir || File.expand_path("../..", __dir__)
16
+ @server_thread = nil
17
+ @backend = nil
18
+ end
19
+
20
+ def run
21
+ require_gtk!
22
+ configure_env!
23
+
24
+ port = free_port
25
+ start_backend(port)
26
+ wait_for_backend(port)
27
+
28
+ Gtk.init
29
+ window = build_window(port)
30
+ window.show_all
31
+ Gtk.main
32
+ ensure
33
+ @backend&.shutdown
34
+ @server_thread&.kill
35
+ end
36
+
37
+ private
38
+
39
+ def configure_env!
40
+ # WebKit2GTK crashes on Wayland with a protocol error; XWayland is stable.
41
+ # GPU compositing also fails on XWayland with some drivers (GBM buffer errors).
42
+ ENV["GDK_BACKEND"] ||= "x11"
43
+ ENV["WEBKIT_DISABLE_COMPOSITING_MODE"] ||= "1"
44
+ end
45
+
46
+ def require_gtk!
47
+ require "gtk3"
48
+ require "webkit2-gtk"
49
+ rescue LoadError => e
50
+ abort <<~MSG
51
+ mendix-desktop requires GTK3 and WebKit2 Ruby bindings.
52
+
53
+ Install them with:
54
+ gem install gtk3 webkit2-gtk
55
+
56
+ Or install the system packages:
57
+ Ubuntu/Debian: sudo apt install ruby-gtk3 gir1.2-webkit2-4.0
58
+ Arch/Omarchy: sudo pacman -S ruby-gtk3 webkit2gtk
59
+
60
+ Original error: #{e.message}
61
+ MSG
62
+ end
63
+
64
+ def free_port
65
+ server = TCPServer.new("127.0.0.1", 0)
66
+ port = server.addr[1]
67
+ server.close
68
+ port
69
+ end
70
+
71
+ def start_backend(port)
72
+ web_root = File.join(@bridge_dir, "web", "dist")
73
+ mxcli = File.join(@bridge_dir, "bin", "mxcli")
74
+
75
+ @server_thread = Thread.new do
76
+ @backend = BackendServer.new(
77
+ inventory_dir: @inventory_dir,
78
+ web_root: web_root,
79
+ mxcli: mxcli,
80
+ bind: "127.0.0.1",
81
+ port: port
82
+ )
83
+ @backend.start
84
+ rescue => e
85
+ warn "mendix-desktop: backend error: #{e.message}"
86
+ end
87
+ @server_thread.abort_on_exception = false
88
+ end
89
+
90
+ def wait_for_backend(port)
91
+ Timeout.timeout(READY_TIMEOUT) do
92
+ loop do
93
+ TCPSocket.new("127.0.0.1", port).close
94
+ break
95
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
96
+ sleep 0.1
97
+ end
98
+ end
99
+ rescue Timeout::Error
100
+ abort "mendix-desktop: backend did not start within #{READY_TIMEOUT}s"
101
+ end
102
+
103
+ def build_window(port)
104
+ window = Gtk::Window.new
105
+ window.title = APP_TITLE
106
+ window.set_default_size(WINDOW_WIDTH, WINDOW_HEIGHT)
107
+ window.set_window_position(:center)
108
+
109
+ webview = WebKit2Gtk::WebView.new
110
+ webview.load_uri("http://127.0.0.1:#{port}")
111
+
112
+ window.add(webview)
113
+ window.signal_connect("destroy") do
114
+ @backend&.shutdown
115
+ Gtk.main_quit
116
+ end
117
+
118
+ window
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ class DocumentParser
5
+ class << self
6
+ def parse(type, description)
7
+ mdl = description.fetch("mdl")
8
+ details = case type
9
+ when "constant" then parse_constant(mdl)
10
+ when "javaaction" then parse_java_action(mdl)
11
+ when "importmapping", "exportmapping" then parse_mapping(mdl)
12
+ when "layout", "snippet" then parse_ui_document(type, mdl)
13
+ when "navprofile" then parse_navigation(mdl)
14
+ else {}
15
+ end
16
+
17
+ details.merge("mdl" => mdl, "parse_status" => "parsed")
18
+ end
19
+
20
+ private
21
+
22
+ def parse_constant(mdl)
23
+ {
24
+ "data_type" => mdl[/^\s*type\s+(.+)$/i, 1]&.strip,
25
+ "default" => unquote(mdl[/^\s*default\s+(.+)$/i, 1]&.strip),
26
+ "folder" => quoted_setting(mdl, "folder")
27
+ }.compact
28
+ end
29
+
30
+ def parse_java_action(mdl)
31
+ header = mdl.match(
32
+ /\bjava\s+action\s+[\w.]+\s*\((?<parameters>.*?)\)\s*returns\s+(?<returns>\S+)/im
33
+ )
34
+ {
35
+ "parameters" => parse_action_parameters(header&.[](:parameters).to_s),
36
+ "return_type" => header&.[](:returns),
37
+ "source_available" => !mdl.include?("source not available")
38
+ }.compact
39
+ end
40
+
41
+ def parse_action_parameters(source)
42
+ source.lines.filter_map do |line|
43
+ match = line.strip.delete_suffix(",").match(
44
+ /\A(\w+):\s*(.+?)(?:\s+(not null))?(?:\s*--\s*(.*))?\z/i
45
+ )
46
+ next unless match
47
+
48
+ {
49
+ "name" => match[1],
50
+ "type" => match[2].strip,
51
+ "required" => !match[3].nil?,
52
+ "description" => match[4]
53
+ }.compact
54
+ end
55
+ end
56
+
57
+ def parse_mapping(mdl)
58
+ {
59
+ "direction" => mdl[/\bcreate\s+(import|export)\s+mapping\b/i, 1]&.downcase,
60
+ "structure" => mdl[/\bwith\s+(?:json|xml)\s+structure\s+([\w.]+)/i, 1],
61
+ "entities" => mdl.scan(
62
+ /^\s*(?:create\s+)?([\w.]+)\s*\{\s*$/
63
+ ).flatten.uniq,
64
+ "attribute_mappings" => mdl.scan(
65
+ /^\s*(\w+)\s*=\s*([\w.]+)\s*,?\s*$/
66
+ ).map { |attribute, member| { "attribute" => attribute, "member" => member } }
67
+ }.compact
68
+ end
69
+
70
+ def parse_ui_document(type, mdl)
71
+ {
72
+ "document_type" => type,
73
+ "layout_type" => mdl[/^--\s*Layout Type:\s*(.+)$/i, 1]&.strip,
74
+ "folder" => quoted_setting(mdl, "Folder"),
75
+ "studio_pro_only" => mdl.match?(/cannot be created via MDL/i)
76
+ }.compact
77
+ end
78
+
79
+ def parse_navigation(mdl)
80
+ default_home = mdl.lines.filter_map do |line|
81
+ match = line.match(/^\s*home\s+page\s+([\w.]+)(?:\s+for\s+[\w.]+)?/i)
82
+ match[1] if match && !line.match?(/\s+for\s+[\w.]+/i)
83
+ end.first
84
+ {
85
+ "kind" => mdl[/^--\s*Kind:\s*(.+)$/i, 1]&.strip,
86
+ "home_page" => default_home,
87
+ "role_home_pages" => mdl.scan(
88
+ /^\s*home\s+page\s+([\w.]+)\s+for\s+([\w.]+)/i
89
+ ).map { |page, role| { "role" => role, "page" => page } },
90
+ "login_page" => mdl[/^\s*login\s+page\s+([\w.]+)/i, 1],
91
+ "not_found_page" => mdl[/^\s*not\s+found\s+page\s+([\w.]+)/i, 1],
92
+ "menu_groups" => mdl.scan(
93
+ /^\s*menu\s+'((?:''|[^'])+)'\s*\(/i
94
+ ).flatten.map { |caption| caption.gsub("''", "'") },
95
+ "menu_items" => mdl.scan(
96
+ /^\s*menu\s+item\s+'((?:''|[^'])+)'\s+(page|microflow|nanoflow)\s+([\w.]+)\s*;/i
97
+ ).map do |caption, action, target|
98
+ {
99
+ "caption" => caption.gsub("''", "'"),
100
+ "action" => action.downcase,
101
+ "target" => target
102
+ }
103
+ end
104
+ }.compact
105
+ end
106
+
107
+ def quoted_setting(mdl, name)
108
+ value = mdl[/^\s*#{Regexp.escape(name)}\s+'((?:''|[^'])*)'/i, 1]
109
+ value&.gsub("''", "'")
110
+ end
111
+
112
+ def unquote(value)
113
+ return unless value
114
+ return value unless value.start_with?("'") && value.end_with?("'")
115
+
116
+ value[1...-1].gsub("''", "'")
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ class DomainParser
5
+ class << self
6
+ def parse(description)
7
+ case description.fetch("type")
8
+ when "entity" then parse_entity(description)
9
+ when "association" then parse_association(description)
10
+ else { "mdl" => description["mdl"] }
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def parse_entity(description)
17
+ mdl = description.fetch("mdl")
18
+ declaration = mdl.match(
19
+ /\b(?<kind>persistent|non-persistent|external)\s+entity\s+(?<name>[\w.]+)(?:\s+extends\s+(?<generalization>[\w.]+))?\s*\((?<body>.*?)\);/im
20
+ )
21
+ return { "mdl" => mdl, "parse_status" => "unsupported" } unless declaration
22
+
23
+ {
24
+ "kind" => declaration[:kind].downcase,
25
+ "persistable" => declaration[:kind].casecmp?("persistent"),
26
+ "generalization" => declaration[:generalization],
27
+ "attributes" => parse_attributes(declaration[:body]),
28
+ "access_rules" => parse_access_rules(mdl),
29
+ "mdl" => mdl,
30
+ "parse_status" => "parsed"
31
+ }
32
+ end
33
+
34
+ def parse_attributes(body)
35
+ body.lines.filter_map do |line|
36
+ declaration = line.strip.delete_suffix(",")
37
+ next if declaration.empty? || declaration.start_with?("@", "--")
38
+
39
+ match = declaration.match(/\A(?<name>\w+):\s*(?<rest>.+)\z/)
40
+ next unless match
41
+
42
+ rest = match[:rest]
43
+ default = rest[/\s+default\s+(.+)\z/i, 1]
44
+ type = rest.sub(/\s+default\s+.+\z/i, "").sub(/\s+not\s+null\z/i, "")
45
+
46
+ {
47
+ "name" => match[:name],
48
+ "type" => type,
49
+ "required" => rest.match?(/\s+not\s+null(?:\s|\z)/i),
50
+ "default" => default,
51
+ "declaration" => declaration
52
+ }.compact
53
+ end
54
+ end
55
+
56
+ def parse_association(description)
57
+ mdl = description.fetch("mdl")
58
+
59
+ {
60
+ "from" => mdl[/^from\s+(\S+)\s+to\s+\S+/i, 1],
61
+ "to" => mdl[/^from\s+\S+\s+to\s+(\S+)/i, 1],
62
+ "association_type" => mdl[/^type\s+([^;\s]+)/i, 1],
63
+ "owner" => mdl[/^owner\s+([^;\s]+)/i, 1],
64
+ "storage" => mdl[/^storage\s+([^;\s]+)/i, 1],
65
+ "delete_behavior" => mdl[/^delete_behavior\s+([^;]+)/i, 1],
66
+ "mdl" => mdl,
67
+ "parse_status" => "parsed"
68
+ }.compact
69
+ end
70
+
71
+ def parse_access_rules(mdl)
72
+ mdl.lines.filter_map do |line|
73
+ match = line.strip.match(
74
+ /\Agrant\s+(?<role>[\w.]+)\s+on\s+(?<entity>[\w.]+)\s+\((?<permissions>.*)\)(?:\s+where\s+'(?<xpath>.*)')?;\z/i
75
+ )
76
+ next unless match
77
+
78
+ permissions = match[:permissions]
79
+ {
80
+ "role" => match[:role],
81
+ "create" => permission?(permissions, "create"),
82
+ "delete" => permission?(permissions, "delete"),
83
+ "read" => attribute_permission(permissions, "read"),
84
+ "write" => attribute_permission(permissions, "write"),
85
+ "xpath" => match[:xpath]&.gsub("''", "'")
86
+ }.compact
87
+ end
88
+ end
89
+
90
+ def permission?(permissions, name)
91
+ permissions.match?(/(?:\A|,\s*)#{Regexp.escape(name)}(?:,|\z)/i)
92
+ end
93
+
94
+ def attribute_permission(permissions, name)
95
+ match = permissions.match(/\b#{Regexp.escape(name)}\s+(\*|\([^)]*\))/i)
96
+ return [] unless match
97
+ return "*" if match[1] == "*"
98
+
99
+ match[1].delete_prefix("(").delete_suffix(")").split(",").map(&:strip)
100
+ end
101
+ end
102
+ end
103
+ end