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,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ module Migration
5
+ Operation = Data.define(:action, :type, :name, :options) do
6
+ def to_h
7
+ { action:, type:, name:, options: }.compact
8
+ end
9
+ end
10
+
11
+ Plan = Data.define(:name, :operations) do
12
+ def to_h
13
+ { name:, operations: operations.map(&:to_h) }
14
+ end
15
+ end
16
+
17
+ class Builder
18
+ RENAMABLE_TYPES = %i[
19
+ entity microflow nanoflow page enumeration association constant module
20
+ ].freeze
21
+ DROPPABLE_TYPES = %i[
22
+ entity microflow nanoflow page enumeration association constant
23
+ ].freeze
24
+
25
+ def self.build(name, &block)
26
+ builder = new(name)
27
+ builder.instance_eval(&block)
28
+ builder.result
29
+ end
30
+
31
+ def initialize(name)
32
+ @name = name.to_s
33
+ @operations = []
34
+ end
35
+
36
+ def rename(type, name, to:)
37
+ type = checked_type(type, RENAMABLE_TYPES, "rename")
38
+ name = type == "module" ? identifier!(name) : qualified!(name)
39
+ add("rename", type, name, "to" => identifier!(to))
40
+ end
41
+
42
+ def drop(type, name)
43
+ type = checked_type(type, DROPPABLE_TYPES, "drop")
44
+ add("drop", type, qualified!(name))
45
+ end
46
+
47
+ def rename_attribute(entity, name, to:)
48
+ entity = qualified!(entity)
49
+ name = identifier!(name)
50
+ add(
51
+ "rename_attribute",
52
+ "attribute",
53
+ "#{entity}.#{name}",
54
+ "entity" => entity,
55
+ "attribute" => name,
56
+ "to" => identifier!(to)
57
+ )
58
+ end
59
+
60
+ def drop_attribute(entity, name)
61
+ entity = qualified!(entity)
62
+ name = identifier!(name)
63
+ add(
64
+ "drop_attribute",
65
+ "attribute",
66
+ "#{entity}.#{name}",
67
+ "entity" => entity,
68
+ "attribute" => name
69
+ )
70
+ end
71
+
72
+ def rename_enumeration_value(enumeration, name, to:)
73
+ enumeration = qualified!(enumeration)
74
+ name = identifier!(name)
75
+ add(
76
+ "rename_enumeration_value",
77
+ "enumeration_value",
78
+ "#{enumeration}.#{name}",
79
+ "enumeration" => enumeration,
80
+ "value" => name,
81
+ "to" => identifier!(to)
82
+ )
83
+ end
84
+
85
+ def drop_enumeration_value(enumeration, name)
86
+ enumeration = qualified!(enumeration)
87
+ name = identifier!(name)
88
+ add(
89
+ "drop_enumeration_value",
90
+ "enumeration_value",
91
+ "#{enumeration}.#{name}",
92
+ "enumeration" => enumeration,
93
+ "value" => name
94
+ )
95
+ end
96
+
97
+ def revoke_access(entity, role:)
98
+ entity = qualified!(entity)
99
+ role = qualified!(role)
100
+ add(
101
+ "revoke_access",
102
+ "entity_access",
103
+ "#{entity}:#{role}",
104
+ "entity" => entity,
105
+ "role" => role
106
+ )
107
+ end
108
+
109
+ def create_module(name)
110
+ add("create_module", "module", identifier!(name))
111
+ end
112
+
113
+ # Re-declares an existing association in place (UUID preserved by mxcli's
114
+ # CREATE OR MODIFY). Requires the full desired shape, not a delta.
115
+ def alter_association(name, from:, to:, type:, owner: "Default")
116
+ type = type.to_s
117
+ raise ArgumentError, "association type must be Reference or ReferenceSet" unless
118
+ %w[Reference ReferenceSet].include?(type)
119
+ owner = owner.to_s
120
+ raise ArgumentError, "association owner must be Default or Both" unless
121
+ %w[Default Both].include?(owner)
122
+
123
+ add(
124
+ "alter_association",
125
+ "association",
126
+ qualified!(name),
127
+ "from" => qualified!(from),
128
+ "to" => qualified!(to),
129
+ "type" => type,
130
+ "owner" => owner
131
+ )
132
+ end
133
+
134
+ # Changing an attribute's type has no in-place ALTER in MDL: it is an
135
+ # explicit DROP + ADD, which discards the column's data. That is why it
136
+ # lives here and not in the declarative apply.
137
+ def retype_attribute(entity, name, to:)
138
+ entity = qualified!(entity)
139
+ name = identifier!(name)
140
+ type = to.to_s.strip
141
+ raise ArgumentError, "retype target type cannot be empty" if type.empty?
142
+
143
+ add(
144
+ "retype_attribute",
145
+ "attribute",
146
+ "#{entity}.#{name}",
147
+ "entity" => entity,
148
+ "attribute" => name,
149
+ "type" => type
150
+ )
151
+ end
152
+
153
+ def alter_module_role(role, description:)
154
+ raise ArgumentError, "module role description cannot be empty" if
155
+ description.nil? || description.to_s.empty?
156
+
157
+ add(
158
+ "alter_module_role",
159
+ "modulerole",
160
+ qualified!(role),
161
+ "description" => description.to_s
162
+ )
163
+ end
164
+
165
+ def alter_user_role(name, module_roles:, manage_all_roles: false)
166
+ roles = Array(module_roles).map { |role| qualified!(role) }
167
+ raise ArgumentError, "user role requires at least one module role" if roles.empty?
168
+
169
+ add(
170
+ "alter_user_role",
171
+ "userrole",
172
+ identifier!(name),
173
+ "module_roles" => roles,
174
+ "manage_all_roles" => manage_all_roles ? true : false
175
+ )
176
+ end
177
+
178
+ def result
179
+ raise ArgumentError, "migration #{@name} requires at least one operation" if
180
+ @operations.empty?
181
+
182
+ Plan.new(name: @name, operations: @operations)
183
+ end
184
+
185
+ private
186
+
187
+ def checked_type(type, allowed, action)
188
+ type = type.to_sym
189
+ return type.to_s if allowed.include?(type)
190
+
191
+ raise ArgumentError,
192
+ "unsupported #{action} type #{type.inspect}; expected: #{allowed.join(', ')}"
193
+ end
194
+
195
+ def add(action, type, name, options = nil)
196
+ name = name.to_s
197
+ raise ArgumentError, "migration target cannot be empty" if name.empty?
198
+
199
+ @operations << Operation.new(action:, type:, name:, options:)
200
+ end
201
+
202
+ def identifier!(value)
203
+ value = value.to_s
204
+ return value if value.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
205
+
206
+ raise ArgumentError, "invalid Mendix identifier: #{value.inspect}"
207
+ end
208
+
209
+ def qualified!(value)
210
+ value = value.to_s
211
+ parts = value.split(".")
212
+ return value if parts.length == 2 && parts.all? do |part|
213
+ part.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
214
+ end
215
+
216
+ raise ArgumentError, "expected qualified Mendix name: #{value.inspect}"
217
+ end
218
+ end
219
+
220
+ class Generator
221
+ DROP_TYPES = {
222
+ "entity" => "ENTITY",
223
+ "microflow" => "MICROFLOW",
224
+ "nanoflow" => "NANOFLOW",
225
+ "page" => "PAGE",
226
+ "enumeration" => "ENUMERATION",
227
+ "association" => "ASSOCIATION",
228
+ "constant" => "CONSTANT"
229
+ }.freeze
230
+
231
+ def self.mdl(plan)
232
+ plan.operations.filter_map { |operation| statement(operation) }.join("\n")
233
+ end
234
+
235
+ def self.rename_operations(plan)
236
+ plan.operations.select { |operation| operation.action == "rename" }
237
+ end
238
+
239
+ def self.statement(operation)
240
+ options = operation.options || {}
241
+ case operation.action
242
+ when "rename" then nil
243
+ when "drop"
244
+ "DROP #{DROP_TYPES.fetch(operation.type)} #{operation.name};"
245
+ when "rename_attribute"
246
+ "ALTER ENTITY #{options.fetch('entity')} RENAME ATTRIBUTE " \
247
+ "#{options.fetch('attribute')} TO #{options.fetch('to')};"
248
+ when "drop_attribute"
249
+ "ALTER ENTITY #{options.fetch('entity')} DROP ATTRIBUTE " \
250
+ "#{options.fetch('attribute')};"
251
+ when "rename_enumeration_value"
252
+ "ALTER ENUMERATION #{options.fetch('enumeration')} RENAME VALUE " \
253
+ "#{options.fetch('value')} TO #{options.fetch('to')};"
254
+ when "drop_enumeration_value"
255
+ "ALTER ENUMERATION #{options.fetch('enumeration')} DROP VALUE " \
256
+ "#{options.fetch('value')};"
257
+ when "revoke_access"
258
+ "REVOKE #{options.fetch('role')} ON #{options.fetch('entity')};"
259
+ when "create_module"
260
+ "CREATE MODULE #{operation.name};"
261
+ when "alter_association"
262
+ "CREATE OR MODIFY ASSOCIATION #{operation.name}\n" \
263
+ " FROM #{options.fetch('from')} TO #{options.fetch('to')}\n" \
264
+ " TYPE #{options.fetch('type')}\n" \
265
+ " OWNER #{options.fetch('owner')};"
266
+ when "retype_attribute"
267
+ "ALTER ENTITY #{options.fetch('entity')} DROP ATTRIBUTE " \
268
+ "#{options.fetch('attribute')};\n" \
269
+ "ALTER ENTITY #{options.fetch('entity')} ADD ATTRIBUTE " \
270
+ "#{options.fetch('attribute')}: #{options.fetch('type')};"
271
+ when "alter_module_role"
272
+ "ALTER MODULE ROLE #{operation.name} " \
273
+ "DESCRIPTION '#{escape(options.fetch('description'))}';"
274
+ when "alter_user_role"
275
+ management = options["manage_all_roles"] ? " MANAGE ALL ROLES" : ""
276
+ "ALTER USER ROLE #{operation.name} " \
277
+ "(#{options.fetch('module_roles').join(', ')})#{management};"
278
+ else
279
+ raise ArgumentError, "unsupported migration action: #{operation.action}"
280
+ end
281
+ end
282
+
283
+ def self.escape(value)
284
+ value.to_s.gsub("'", "''")
285
+ end
286
+
287
+ private_class_method :statement, :escape
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+ require "securerandom"
7
+ require "tempfile"
8
+ require "time"
9
+
10
+ module MendixBridge
11
+ class MigrationError < StandardError; end
12
+
13
+ class MigrationExecutor
14
+ Result = Data.define(:backup_dir, :operations)
15
+
16
+ def initialize(mxcli:)
17
+ @mxcli = File.expand_path(mxcli)
18
+ end
19
+
20
+ def apply(plan, project_file:, confirmation:, studio_closed:)
21
+ project_file = File.expand_path(project_file)
22
+ validate!(plan, project_file, confirmation, studio_closed)
23
+ project_dir = File.dirname(project_file)
24
+ ensure_clean_git!(project_dir)
25
+ validate_operations!(plan, project_file)
26
+ backup_dir = create_backup(plan, project_file)
27
+
28
+ begin
29
+ apply_renames!(plan, project_file)
30
+ apply_mdl!(plan, project_file)
31
+ check_project!(project_file)
32
+ rescue StandardError => error
33
+ begin
34
+ restore_backup!(backup_dir, project_file)
35
+ check_project!(project_file)
36
+ rescue StandardError => restore_error
37
+ raise MigrationError,
38
+ "migration failed and automatic restore also failed; " \
39
+ "backup is #{backup_dir}: #{restore_error.message}"
40
+ end
41
+ raise MigrationError,
42
+ "migration failed and backup was restored: #{error.message}"
43
+ end
44
+
45
+ Result.new(backup_dir:, operations: plan.operations.length)
46
+ end
47
+
48
+ def restore(backup_dir, project_file:, confirmation:, studio_closed:)
49
+ backup_dir = File.expand_path(backup_dir)
50
+ project_file = File.expand_path(project_file)
51
+ manifest = read_manifest(backup_dir)
52
+ expected = File.basename(backup_dir)
53
+ raise MigrationError, "confirmation must exactly match #{expected}" unless
54
+ confirmation == expected
55
+ raise MigrationError, "--studio-closed is required" unless studio_closed
56
+ unless File.expand_path(manifest.fetch("project_file")) == project_file
57
+ raise MigrationError, "backup belongs to another Mendix project"
58
+ end
59
+
60
+ restore_backup!(backup_dir, project_file)
61
+ check_project!(project_file)
62
+ true
63
+ end
64
+
65
+ private
66
+
67
+ def validate!(plan, project_file, confirmation, studio_closed)
68
+ raise MigrationError, "Mendix project does not exist: #{project_file}" unless
69
+ File.file?(project_file)
70
+ raise MigrationError, "mxcli is not executable: #{@mxcli}" unless
71
+ File.executable?(@mxcli)
72
+ raise MigrationError, "--studio-closed is required" unless studio_closed
73
+ unless confirmation == plan.name
74
+ raise MigrationError, "confirmation must exactly match migration name #{plan.name}"
75
+ end
76
+ end
77
+
78
+ def ensure_clean_git!(project_dir)
79
+ root = run!(
80
+ "git", "-C", project_dir, "rev-parse", "--show-toplevel",
81
+ error_prefix: "Mendix project must be inside a Git repository"
82
+ ).strip
83
+ status = run!(
84
+ "git", "-C", root, "status", "--porcelain",
85
+ error_prefix: "could not inspect Git status"
86
+ )
87
+ raise MigrationError, "target repository has uncommitted changes" unless status.empty?
88
+ end
89
+
90
+ def validate_operations!(plan, project_file)
91
+ Migration::Generator.rename_operations(plan).each do |operation|
92
+ options = operation.options.fetch("to")
93
+ run!(
94
+ @mxcli,
95
+ "rename",
96
+ "-p", project_file,
97
+ operation.type,
98
+ operation.name,
99
+ options,
100
+ "--dry-run",
101
+ error_prefix: "rename preview failed for #{operation.name}"
102
+ )
103
+ end
104
+
105
+ mdl = Migration::Generator.mdl(plan)
106
+ return if mdl.empty?
107
+
108
+ with_mdl(mdl) do |path|
109
+ run!(
110
+ @mxcli,
111
+ "check", path,
112
+ error_prefix: "migration MDL is invalid"
113
+ )
114
+ end
115
+ end
116
+
117
+ def create_backup(plan, project_file)
118
+ project_dir = File.dirname(project_file)
119
+ id = "#{Time.now.utc.strftime('%Y%m%dT%H%M%SZ')}-" \
120
+ "#{safe_name(plan.name)}-#{SecureRandom.hex(3)}"
121
+ backup_root = run!(
122
+ "git",
123
+ "-C", project_dir,
124
+ "rev-parse",
125
+ "--path-format=absolute",
126
+ "--git-path", "mendix-ruby-backups",
127
+ error_prefix: "could not locate Git backup storage"
128
+ ).strip
129
+ backup_dir = File.join(backup_root, id)
130
+ FileUtils.mkdir_p(backup_dir)
131
+ FileUtils.cp(project_file, File.join(backup_dir, File.basename(project_file)))
132
+ contents = File.join(project_dir, "mprcontents")
133
+ FileUtils.cp_r(contents, File.join(backup_dir, "mprcontents")) if Dir.exist?(contents)
134
+ File.write(
135
+ File.join(backup_dir, "manifest.json"),
136
+ "#{JSON.pretty_generate(
137
+ "migration" => plan.name,
138
+ "project_file" => project_file,
139
+ "created_at" => Time.now.utc.iso8601,
140
+ "operations" => plan.operations.map(&:to_h)
141
+ )}\n"
142
+ )
143
+ backup_dir
144
+ end
145
+
146
+ def apply_renames!(plan, project_file)
147
+ Migration::Generator.rename_operations(plan).each do |operation|
148
+ run!(
149
+ @mxcli,
150
+ "rename",
151
+ "-p", project_file,
152
+ operation.type,
153
+ operation.name,
154
+ operation.options.fetch("to"),
155
+ error_prefix: "could not rename #{operation.name}"
156
+ )
157
+ end
158
+ end
159
+
160
+ def apply_mdl!(plan, project_file)
161
+ mdl = Migration::Generator.mdl(plan)
162
+ return if mdl.empty?
163
+
164
+ with_mdl(mdl) do |path|
165
+ run!(
166
+ @mxcli,
167
+ "exec", path,
168
+ "-p", project_file,
169
+ error_prefix: "could not apply migration MDL"
170
+ )
171
+ end
172
+ end
173
+
174
+ def check_project!(project_file)
175
+ version_file = File.join(File.dirname(project_file), ".mendix-version")
176
+ raise MigrationError, "missing #{version_file}" unless File.file?(version_file)
177
+
178
+ version = File.read(version_file).strip
179
+ mx = File.join(Dir.home, ".mxcli", "mxbuild", version, "modeler", "mx")
180
+ raise MigrationError, "Mendix mx #{version} is not installed" unless File.executable?(mx)
181
+
182
+ run!(
183
+ mx,
184
+ "check",
185
+ project_file,
186
+ chdir: File.dirname(project_file),
187
+ error_prefix: "official Mendix consistency check failed"
188
+ )
189
+ end
190
+
191
+ def restore_backup!(backup_dir, project_file)
192
+ manifest = read_manifest(backup_dir)
193
+ source_project = File.join(backup_dir, File.basename(manifest.fetch("project_file")))
194
+ FileUtils.cp(source_project, project_file)
195
+
196
+ project_contents = File.join(File.dirname(project_file), "mprcontents")
197
+ backup_contents = File.join(backup_dir, "mprcontents")
198
+ FileUtils.rm_rf(project_contents)
199
+ FileUtils.cp_r(backup_contents, project_contents) if Dir.exist?(backup_contents)
200
+ end
201
+
202
+ def read_manifest(backup_dir)
203
+ path = File.join(backup_dir, "manifest.json")
204
+ raise MigrationError, "invalid migration backup: #{backup_dir}" unless File.file?(path)
205
+
206
+ JSON.parse(File.read(path))
207
+ end
208
+
209
+ def safe_name(name)
210
+ name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
211
+ end
212
+
213
+ def with_mdl(mdl)
214
+ Tempfile.create(["mendix-ruby-migration-", ".mdl"]) do |file|
215
+ file.write(mdl)
216
+ file.flush
217
+ yield file.path
218
+ end
219
+ end
220
+
221
+ def run!(*command, chdir: nil, error_prefix:)
222
+ stdout, stderr, status = if chdir
223
+ Open3.capture3(*command, chdir:)
224
+ else
225
+ Open3.capture3(*command)
226
+ end
227
+ return stdout if status.success?
228
+
229
+ detail = stderr.strip
230
+ detail = stdout.strip if detail.empty?
231
+ raise MigrationError, "#{error_prefix}: #{detail}"
232
+ end
233
+ end
234
+ end