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,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ module Model
5
+ Attribute = Data.define(:name, :type, :required, :default, :options) do
6
+ def to_h
7
+ { name:, type:, required:, default:, **options }.compact
8
+ end
9
+ end
10
+
11
+ Association = Data.define(:name, :target, :cardinality, :owner) do
12
+ def to_h
13
+ { name:, target:, cardinality:, owner: }.compact
14
+ end
15
+ end
16
+
17
+ AccessRule = Data.define(:role, :create, :delete, :read, :write, :xpath) do
18
+ def to_h
19
+ {
20
+ role:,
21
+ create:,
22
+ delete:,
23
+ read:,
24
+ write:,
25
+ xpath:
26
+ }.compact
27
+ end
28
+ end
29
+
30
+ Entity = Data.define(
31
+ :name,
32
+ :persistable,
33
+ :attributes,
34
+ :associations,
35
+ :access_rules
36
+ ) do
37
+ def to_h
38
+ {
39
+ name:,
40
+ persistable:,
41
+ attributes: attributes.map(&:to_h),
42
+ associations: associations.map(&:to_h),
43
+ access_rules: access_rules.map(&:to_h)
44
+ }
45
+ end
46
+ end
47
+
48
+ EnumerationValue = Data.define(:name, :caption) do
49
+ def to_h
50
+ { name:, caption: }
51
+ end
52
+ end
53
+
54
+ Enumeration = Data.define(:name, :folder, :values) do
55
+ def to_h
56
+ { name:, folder:, values: values.map(&:to_h) }.compact
57
+ end
58
+ end
59
+
60
+ FlowParameter = Data.define(:name, :type) do
61
+ def to_h
62
+ { name:, type: }
63
+ end
64
+ end
65
+
66
+ Microflow = Data.define(
67
+ :name,
68
+ :parameters,
69
+ :return_type,
70
+ :folder,
71
+ :body,
72
+ :execute_roles
73
+ ) do
74
+ def to_h
75
+ {
76
+ name:,
77
+ parameters: parameters.map(&:to_h),
78
+ return_type:,
79
+ folder:,
80
+ body:,
81
+ execute_roles:
82
+ }.compact
83
+ end
84
+ end
85
+
86
+ Page = Data.define(
87
+ :name,
88
+ :title,
89
+ :layout,
90
+ :folder,
91
+ :parameters,
92
+ :content,
93
+ :view_roles
94
+ ) do
95
+ def to_h
96
+ {
97
+ name:,
98
+ title:,
99
+ layout:,
100
+ folder:,
101
+ parameters: parameters.map(&:to_h),
102
+ content:,
103
+ view_roles:
104
+ }.compact
105
+ end
106
+ end
107
+
108
+ ModuleRole = Data.define(:name, :description) do
109
+ def to_h
110
+ { name:, description: }.compact
111
+ end
112
+ end
113
+
114
+ UserRole = Data.define(:name, :module_roles, :manage_all_roles) do
115
+ def to_h
116
+ { name:, module_roles:, manage_all_roles: }
117
+ end
118
+ end
119
+
120
+ ProjectSecurity = Data.define(:level, :demo_users) do
121
+ def to_h
122
+ { level:, demo_users: }
123
+ end
124
+ end
125
+
126
+ NavigationItem = Data.define(:caption, :action, :target) do
127
+ def to_h
128
+ { caption:, action:, target: }
129
+ end
130
+ end
131
+
132
+ NavigationMenu = Data.define(:caption, :items) do
133
+ def to_h
134
+ { caption:, items: items.map(&:to_h) }
135
+ end
136
+ end
137
+
138
+ RoleHomePage = Data.define(:role, :page) do
139
+ def to_h
140
+ { role:, page: }
141
+ end
142
+ end
143
+
144
+ NavigationProfile = Data.define(
145
+ :name,
146
+ :home_page,
147
+ :role_home_pages,
148
+ :login_page,
149
+ :not_found_page,
150
+ :items
151
+ ) do
152
+ def to_h
153
+ {
154
+ name:,
155
+ home_page:,
156
+ role_home_pages: role_home_pages.map(&:to_h),
157
+ login_page:,
158
+ not_found_page:,
159
+ items: items.map(&:to_h)
160
+ }.compact
161
+ end
162
+ end
163
+
164
+ AppModule = Data.define(
165
+ :name,
166
+ :entities,
167
+ :enumerations,
168
+ :microflows,
169
+ :pages,
170
+ :module_roles
171
+ ) do
172
+ def to_h
173
+ {
174
+ name:,
175
+ entities: entities.map(&:to_h),
176
+ enumerations: enumerations.map(&:to_h),
177
+ microflows: microflows.map(&:to_h),
178
+ pages: pages.map(&:to_h),
179
+ module_roles: module_roles.map(&:to_h)
180
+ }
181
+ end
182
+ end
183
+
184
+ App = Data.define(
185
+ :name,
186
+ :version,
187
+ :modules,
188
+ :user_roles,
189
+ :project_security,
190
+ :navigation_profiles
191
+ ) do
192
+ def to_h
193
+ {
194
+ schema_version: 1,
195
+ app: { name:, version: }.compact,
196
+ modules: modules.map(&:to_h),
197
+ user_roles: user_roles.map(&:to_h),
198
+ project_security: project_security&.to_h,
199
+ navigation_profiles: navigation_profiles.map(&:to_h)
200
+ }
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ class PageParser
5
+ class << self
6
+ def parse(description)
7
+ mdl = description.fetch("mdl")
8
+ header = mdl.match(
9
+ /\bpage\s+(?<name>[\w.]+)\s*\((?<settings>.*?)\)\s*\{/im
10
+ )
11
+ return { "mdl" => mdl, "parse_status" => "unsupported" } unless header
12
+
13
+ settings = header[:settings]
14
+ actions = mdl.scan(/\bAction:\s*([^,\n\)]+)/i).flatten.map(&:strip)
15
+ data_sources = mdl.scan(/\bDataSource:\s*([^,\n\)]+)/i).flatten.map(&:strip)
16
+
17
+ {
18
+ "title" => quoted_setting(settings, "Title"),
19
+ "layout" => scalar_setting(settings, "Layout"),
20
+ "folder" => quoted_setting(settings, "Folder"),
21
+ "parameters" => parse_parameters(settings),
22
+ "widgets" => parse_widgets(mdl),
23
+ "widget_types" => widget_counts(mdl),
24
+ "data_sources" => data_sources.uniq,
25
+ "actions" => actions.map { |action| parse_action(action) },
26
+ "microflow_calls" => referenced(actions, "microflow"),
27
+ "nanoflow_calls" => referenced(actions, "nanoflow"),
28
+ "page_links" => referenced(actions, "show_page"),
29
+ "attributes" => mdl.scan(/\bAttribute:\s*([^,\n\)]+)/i).flatten.map(&:strip).uniq,
30
+ "view_roles" => mdl.scan(
31
+ /^grant\s+view\s+on\s+page\s+[\w.]+\s+to\s+([\w.]+)/i
32
+ ).flatten,
33
+ "mdl" => mdl,
34
+ "parse_status" => "parsed"
35
+ }.compact
36
+ end
37
+
38
+ private
39
+
40
+ def quoted_setting(settings, name)
41
+ settings[/\b#{Regexp.escape(name)}:\s*'((?:''|[^'])*)'/i, 1]&.gsub("''", "'")
42
+ end
43
+
44
+ def scalar_setting(settings, name)
45
+ settings[/\b#{Regexp.escape(name)}:\s*([^,\n]+)/i, 1]&.strip
46
+ end
47
+
48
+ def parse_parameters(settings)
49
+ source = settings[/\bParams:\s*\{(.*?)\}/im, 1]
50
+ return [] unless source
51
+
52
+ source.scan(/\$(\w+):\s*([\w.]+)/).map do |name, type|
53
+ { "name" => name, "type" => type }
54
+ end
55
+ end
56
+
57
+ def parse_widgets(mdl)
58
+ mdl.lines.filter_map do |line|
59
+ stripped = line.strip
60
+ next if stripped.start_with?("--")
61
+
62
+ match = stripped.match(
63
+ /\A(?<type>[a-z][a-z0-9_]*)\s*(?<name>"[^"]+"|[A-Za-z_]\w*)?\s*(?<opening>\(|\{)/
64
+ )
65
+ next unless match
66
+ next if %w[create grant].include?(match[:type])
67
+
68
+ {
69
+ "type" => match[:type],
70
+ "name" => match[:name]&.delete_prefix('"')&.delete_suffix('"'),
71
+ "declaration" => stripped
72
+ }.compact
73
+ end
74
+ end
75
+
76
+ def widget_counts(mdl)
77
+ parse_widgets(mdl).each_with_object(Hash.new(0)) do |widget, counts|
78
+ counts[widget["type"]] += 1
79
+ end.sort.to_h
80
+ end
81
+
82
+ def parse_action(action)
83
+ kind, target = action.split(/\s+/, 2)
84
+ { "kind" => kind, "target" => target }.compact
85
+ end
86
+
87
+ def referenced(actions, kind)
88
+ actions.filter_map do |action|
89
+ match = action.match(/\A#{Regexp.escape(kind)}\s+([\w.]+)/i)
90
+ match[1] if match
91
+ end.uniq
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ class Presenter
5
+ def self.summary(element)
6
+ details = element.details&.reject { |key, _value| %w[mdl raw].include?(key) }
7
+ {
8
+ "name" => element.qualified_name || element.label,
9
+ "type" => element.type,
10
+ "path" => element.path,
11
+ "details" => details
12
+ }.compact
13
+ end
14
+
15
+ def self.search_result(element)
16
+ {
17
+ "name" => element.qualified_name || element.label,
18
+ "type" => element.type,
19
+ "path" => element.path.join(" / ")
20
+ }.compact
21
+ end
22
+
23
+ def self.text(element)
24
+ result = summary(element)
25
+ lines = [
26
+ "Name: #{result.fetch('name')}",
27
+ "Type: #{result.fetch('type')}",
28
+ "Path: #{element.path.join(' / ')}"
29
+ ]
30
+ details = result["details"]
31
+ lines << "Details:\n#{JSON.pretty_generate(details)}" if details && !details.empty?
32
+ lines.join("\n")
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "tempfile"
6
+
7
+ module MendixBridge
8
+ class ProjectCreationError < StandardError; end
9
+
10
+ class ProjectCreator
11
+ Result = Data.define(:project_dir, :project_file, :model_file, :git_initialized)
12
+
13
+ GITIGNORE = <<~IGNORE
14
+ deployment/
15
+ releases/
16
+ testresults/
17
+ *.mpr.bak
18
+ *.mpr.backup
19
+ .idea/
20
+ .vscode/
21
+ IGNORE
22
+
23
+ def initialize(mxcli:)
24
+ @mxcli = File.expand_path(mxcli)
25
+ end
26
+
27
+ def create(model, source_file:, output_dir:, version:, git: true)
28
+ source_file = File.expand_path(source_file)
29
+ output_dir = File.expand_path(output_dir)
30
+ validate_inputs!(source_file, output_dir, version)
31
+
32
+ run!(
33
+ @mxcli,
34
+ "new",
35
+ model.name,
36
+ "--version", version,
37
+ "--output-dir", output_dir,
38
+ "--skip-init",
39
+ error_prefix: "mxcli could not create the blank project"
40
+ )
41
+
42
+ project_file = locate_project_file(output_dir)
43
+ existing_modules = read_modules(project_file)
44
+ mdl = MendixBridge.compile(
45
+ model,
46
+ format: :mdl,
47
+ include_modules: true,
48
+ skip_modules: existing_modules
49
+ )
50
+ apply_model!(mdl, project_file)
51
+ check_project!(project_file, version)
52
+
53
+ model_file = File.join(output_dir, "app.rb")
54
+ FileUtils.cp(source_file, model_file)
55
+ File.write(File.join(output_dir, ".mendix-version"), "#{version}\n")
56
+ File.write(File.join(output_dir, ".gitignore"), GITIGNORE)
57
+ initialize_git!(output_dir) if git
58
+
59
+ Result.new(
60
+ project_dir: output_dir,
61
+ project_file:,
62
+ model_file:,
63
+ git_initialized: git
64
+ )
65
+ rescue ProjectCreationError
66
+ raise
67
+ rescue StandardError => error
68
+ raise ProjectCreationError, error.message
69
+ end
70
+
71
+ private
72
+
73
+ def validate_inputs!(source_file, output_dir, version)
74
+ raise ProjectCreationError, "Ruby model does not exist: #{source_file}" unless
75
+ File.file?(source_file)
76
+ raise ProjectCreationError, "mxcli is not executable: #{@mxcli}" unless
77
+ File.executable?(@mxcli)
78
+ raise ProjectCreationError, "output directory already exists: #{output_dir}" if
79
+ File.exist?(output_dir)
80
+ raise ProjectCreationError, "invalid Mendix version: #{version}" unless
81
+ version.to_s.match?(/\A\d+\.\d+\.\d+\z/)
82
+ end
83
+
84
+ def locate_project_file(output_dir)
85
+ projects = Dir[File.join(output_dir, "*.mpr")]
86
+ unless projects.length == 1
87
+ raise ProjectCreationError,
88
+ "expected one .mpr in #{output_dir}, found #{projects.length}"
89
+ end
90
+
91
+ projects.first
92
+ end
93
+
94
+ def read_modules(project_file)
95
+ output = run!(
96
+ @mxcli,
97
+ "-p", project_file,
98
+ "-c", "SHOW MODULES;",
99
+ error_prefix: "could not inspect modules in the blank project"
100
+ )
101
+ output.scan(/^\|\s+([A-Za-z_][A-Za-z0-9_]*)\s+\|/).flatten
102
+ .reject { |name| name == "Module" }
103
+ end
104
+
105
+ def apply_model!(mdl, project_file)
106
+ Tempfile.create(["mendix-ruby-new-", ".mdl"]) do |file|
107
+ file.write(mdl)
108
+ file.flush
109
+ run!(
110
+ @mxcli,
111
+ "check", file.path,
112
+ error_prefix: "generated project MDL is invalid"
113
+ )
114
+ run!(
115
+ @mxcli,
116
+ "exec", file.path,
117
+ "-p", project_file,
118
+ error_prefix: "mxcli could not apply the Ruby model"
119
+ )
120
+ end
121
+ end
122
+
123
+ def check_project!(project_file, version)
124
+ mx = File.join(
125
+ Dir.home,
126
+ ".mxcli",
127
+ "mxbuild",
128
+ version,
129
+ "modeler",
130
+ "mx"
131
+ )
132
+ raise ProjectCreationError, "Mendix mx #{version} is not installed: #{mx}" unless
133
+ File.executable?(mx)
134
+
135
+ run!(
136
+ mx,
137
+ "check",
138
+ project_file,
139
+ chdir: File.dirname(project_file),
140
+ error_prefix: "official Mendix consistency check failed"
141
+ )
142
+ end
143
+
144
+ def initialize_git!(output_dir)
145
+ run!(
146
+ "git",
147
+ "init",
148
+ "-b", "main",
149
+ chdir: output_dir,
150
+ error_prefix: "could not initialize Git"
151
+ )
152
+ run!(
153
+ "git",
154
+ "add",
155
+ ".",
156
+ chdir: output_dir,
157
+ error_prefix: "could not stage the initial project"
158
+ )
159
+ run!(
160
+ "git",
161
+ "commit",
162
+ "-m", "Initial Setup",
163
+ chdir: output_dir,
164
+ error_prefix: "could not create the initial Git commit"
165
+ )
166
+ end
167
+
168
+ def run!(*command, chdir: nil, error_prefix:)
169
+ stdout, stderr, status = if chdir
170
+ Open3.capture3(*command, chdir:)
171
+ else
172
+ Open3.capture3(*command)
173
+ end
174
+ return stdout if status.success?
175
+
176
+ detail = stderr.strip
177
+ detail = stdout.strip if detail.empty?
178
+ raise ProjectCreationError, "#{error_prefix}: #{detail}"
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ class RubyInventoryGenerator
5
+ def self.generate(inventory)
6
+ inventory.modules.each_with_object({}) do |app_module, result|
7
+ name = app_module.qualified_name
8
+ elements = inventory.elements.select do |element|
9
+ element.qualified_name&.start_with?("#{name}.") &&
10
+ %w[entity association].include?(element.type)
11
+ end
12
+ next if elements.empty?
13
+
14
+ result[name] = {
15
+ "entities" => elements.select { |element| element.type == "entity" }.map { |element| serialize(element) },
16
+ "associations" => elements.select { |element| element.type == "association" }.map { |element| serialize(element) }
17
+ }
18
+ end
19
+ end
20
+
21
+ def self.ruby_source(module_name, domain)
22
+ namespace = module_name.gsub(/[^A-Za-z0-9_]/, "_")
23
+ json = JSON.pretty_generate(domain)
24
+
25
+ <<~RUBY
26
+ # frozen_string_literal: true
27
+ # Generated by mendix-ruby import. Changes will be replaced by refresh.
28
+
29
+ require "json"
30
+
31
+ module MendixGenerated
32
+ module #{namespace}
33
+ DOMAIN_MODEL = JSON.parse(<<~JSON)
34
+ #{indent(json, 6)}
35
+ JSON
36
+ end
37
+ end
38
+ RUBY
39
+ end
40
+
41
+ def self.file_name(module_name)
42
+ module_name
43
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
44
+ .gsub(/[^A-Za-z0-9]+/, "_")
45
+ .downcase
46
+ end
47
+
48
+ def self.serialize(element)
49
+ {
50
+ "name" => element.label,
51
+ "qualified_name" => element.qualified_name,
52
+ "details" => element.details
53
+ }.compact
54
+ end
55
+
56
+ def self.indent(text, spaces)
57
+ prefix = " " * spaces
58
+ text.lines.map { |line| "#{prefix}#{line}" }.join
59
+ end
60
+
61
+ private_class_method :serialize, :indent
62
+ end
63
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ class SecurityParser
5
+ class << self
6
+ def parse(type, description)
7
+ case type
8
+ when "projectsecurity" then parse_project_security(description)
9
+ when "modulerole" then parse_module_role(description)
10
+ when "userrole" then parse_user_role(description)
11
+ else raise ArgumentError, "unsupported security element: #{type}"
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def parse_project_security(description)
18
+ settings = description.to_h do |entry|
19
+ [entry.fetch("Property"), typed_value(entry["Value"])]
20
+ end
21
+ {
22
+ "settings" => settings,
23
+ "parse_status" => "parsed",
24
+ "raw" => description
25
+ }
26
+ end
27
+
28
+ def parse_module_role(description)
29
+ mdl = description.fetch("mdl")
30
+ {
31
+ "description" => mdl[
32
+ /\bmodule\s+role\s+[\w.]+\s+description\s+'((?:''|[^'])*)'/i, 1
33
+ ]&.gsub("''", "'"),
34
+ "included_in_user_roles" => mdl[
35
+ /--\s*Included in user roles:\s*(.+)$/i, 1
36
+ ]&.split(",")&.map(&:strip) || [],
37
+ "mdl" => mdl,
38
+ "parse_status" => "parsed"
39
+ }
40
+ end
41
+
42
+ def parse_user_role(description)
43
+ mdl = description.fetch("mdl")
44
+ declaration = mdl.match(
45
+ /create\s+user\s+role\s+(?<name>\w+)\s*\((?<module_roles>.*?)\)(?<management>.*?);/im
46
+ )
47
+ return { "mdl" => mdl, "parse_status" => "unsupported" } unless declaration
48
+
49
+ management = declaration[:management].strip
50
+ {
51
+ "module_roles" => declaration[:module_roles].split(",").map(&:strip),
52
+ "manage_all_roles" => management.match?(/\bmanage\s+all\s+roles\b/i),
53
+ "manageable_roles" => management[
54
+ /\bmanage\s+roles\s*\((.*?)\)/i, 1
55
+ ]&.split(",")&.map(&:strip) || [],
56
+ "check_security" => mdl.match?(/--\s*Check security:\s*enabled/i),
57
+ "mdl" => mdl,
58
+ "parse_status" => "parsed"
59
+ }
60
+ end
61
+
62
+ def typed_value(value)
63
+ case value
64
+ when "true" then true
65
+ when "false" then false
66
+ when /\A\d+\z/ then value.to_i
67
+ else value
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ class SnapshotDiff
5
+ Result = Data.define(:added, :modified, :removed) do
6
+ def empty?
7
+ added.empty? && modified.empty? && removed.empty?
8
+ end
9
+
10
+ def to_h
11
+ { added:, modified:, removed: }
12
+ end
13
+ end
14
+
15
+ def self.compare(before, after)
16
+ old_elements = index(before)
17
+ new_elements = index(after)
18
+ old_keys = old_elements.keys
19
+ new_keys = new_elements.keys
20
+
21
+ Result.new(
22
+ added: describe(new_keys - old_keys, new_elements),
23
+ modified: describe(
24
+ (old_keys & new_keys).select do |key|
25
+ canonical(old_elements.fetch(key)) != canonical(new_elements.fetch(key))
26
+ end,
27
+ new_elements
28
+ ),
29
+ removed: describe(old_keys - new_keys, old_elements)
30
+ )
31
+ end
32
+
33
+ def self.index(inventory)
34
+ inventory.elements.to_h do |element|
35
+ identity = element.qualified_name || element.path.join("/")
36
+ ["#{element.type}:#{identity}", element]
37
+ end
38
+ end
39
+
40
+ def self.describe(keys, elements)
41
+ keys.sort.map do |key|
42
+ element = elements.fetch(key)
43
+ {
44
+ "id" => key,
45
+ "type" => element.type,
46
+ "name" => element.qualified_name || element.label,
47
+ "path" => element.path
48
+ }.compact
49
+ end
50
+ end
51
+
52
+ def self.canonical(element)
53
+ JSON.generate(element.to_h)
54
+ end
55
+
56
+ private_class_method :index, :describe, :canonical
57
+ end
58
+ end