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.
- checksums.yaml +7 -0
- data/.mxcli-version +2 -0
- data/LICENSE +21 -0
- data/README.md +77 -0
- data/bin/git-mendix +4 -0
- data/bin/mendix-apply +145 -0
- data/bin/mendix-desktop +26 -0
- data/bin/mendix-git +121 -0
- data/bin/mendix-ruby +580 -0
- data/bin/mxcli +20 -0
- data/bin/setup-tools +25 -0
- data/lib/mendix_bridge/backend_server.rb +1404 -0
- data/lib/mendix_bridge/change_planner.rb +594 -0
- data/lib/mendix_bridge/config.rb +89 -0
- data/lib/mendix_bridge/dependency_index.rb +188 -0
- data/lib/mendix_bridge/desktop_app.rb +121 -0
- data/lib/mendix_bridge/document_parser.rb +120 -0
- data/lib/mendix_bridge/domain_parser.rb +103 -0
- data/lib/mendix_bridge/dsl.rb +540 -0
- data/lib/mendix_bridge/enumeration_parser.rb +32 -0
- data/lib/mendix_bridge/git_workflow.rb +321 -0
- data/lib/mendix_bridge/html_viewer.rb +672 -0
- data/lib/mendix_bridge/importer.rb +415 -0
- data/lib/mendix_bridge/inventory.rb +93 -0
- data/lib/mendix_bridge/mdl_generator.rb +343 -0
- data/lib/mendix_bridge/microflow_parser.rb +131 -0
- data/lib/mendix_bridge/migration.rb +290 -0
- data/lib/mendix_bridge/migration_executor.rb +234 -0
- data/lib/mendix_bridge/model.rb +204 -0
- data/lib/mendix_bridge/page_parser.rb +95 -0
- data/lib/mendix_bridge/presenter.rb +35 -0
- data/lib/mendix_bridge/project_creator.rb +181 -0
- data/lib/mendix_bridge/ruby_inventory_generator.rb +63 -0
- data/lib/mendix_bridge/security_parser.rb +72 -0
- data/lib/mendix_bridge/snapshot_diff.rb +58 -0
- data/lib/mendix_bridge/validator.rb +46 -0
- data/lib/mendix_bridge/version.rb +5 -0
- data/lib/mendix_bridge/visual_entity_plan.rb +176 -0
- data/lib/mendix_bridge.rb +127 -0
- data/share/applications/mendix-ruby-bridge.desktop +12 -0
- data/share/icons/hicolor/128x128/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/256x256/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/32x32/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/48x48/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/512x512/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/64x64/apps/mendix-ruby-bridge.png +0 -0
- data/web/dist/apple-touch-icon.png +0 -0
- data/web/dist/assets/index-BO6iPT1P.css +1 -0
- data/web/dist/assets/index-JGODw81W.js +27 -0
- data/web/dist/brand/mendix-ruby-bridge.png +0 -0
- data/web/dist/favicon.png +0 -0
- data/web/dist/icons.svg +24 -0
- data/web/dist/index.html +17 -0
- 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
|