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,594 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MendixBridge
|
|
4
|
+
class ChangePlanner
|
|
5
|
+
Operation = Data.define(:action, :type, :name, :changes, :reason) do
|
|
6
|
+
def to_h
|
|
7
|
+
{ action:, type:, name:, changes:, reason: }.compact
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
Result = Data.define(:operations, :preserved_undeclared) do
|
|
12
|
+
def counts
|
|
13
|
+
operations.each_with_object(Hash.new(0)) do |operation, result|
|
|
14
|
+
result[operation.action] += 1
|
|
15
|
+
end.sort.to_h
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def blocked?
|
|
19
|
+
operations.any? { |operation| operation.action == "blocked" }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_h
|
|
23
|
+
{
|
|
24
|
+
operations: operations.map(&:to_h),
|
|
25
|
+
counts:,
|
|
26
|
+
preserved_undeclared:
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
TYPE_NAMES = MDLGenerator::TYPE_NAMES
|
|
32
|
+
|
|
33
|
+
def self.plan(model, inventory)
|
|
34
|
+
new(model, inventory).plan
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(model, inventory)
|
|
38
|
+
@model = model
|
|
39
|
+
@inventory = inventory
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def plan
|
|
43
|
+
operations = @model.modules.flat_map { |app_module| plan_module(app_module) }
|
|
44
|
+
operations.concat(@model.user_roles.map { |role| plan_user_role(role) })
|
|
45
|
+
operations << plan_project_security(@model.project_security) if @model.project_security
|
|
46
|
+
operations.concat(
|
|
47
|
+
@model.navigation_profiles.map { |profile| plan_navigation(profile) }
|
|
48
|
+
)
|
|
49
|
+
declared = operations.map { |operation| "#{operation.type}:#{operation.name}" }.to_set
|
|
50
|
+
managed_types = %w[
|
|
51
|
+
entity association enumeration microflow page
|
|
52
|
+
modulerole userrole projectsecurity
|
|
53
|
+
navprofile
|
|
54
|
+
]
|
|
55
|
+
preserved = @inventory.elements.count do |element|
|
|
56
|
+
managed_types.include?(element.type) &&
|
|
57
|
+
!declared.include?("#{element.type}:#{element.qualified_name}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Result.new(operations:, preserved_undeclared: preserved)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def plan_module(app_module)
|
|
66
|
+
unless @inventory.modules.any? { |existing| existing.qualified_name == app_module.name }
|
|
67
|
+
return [
|
|
68
|
+
Operation.new(
|
|
69
|
+
action: "blocked",
|
|
70
|
+
type: "module",
|
|
71
|
+
name: app_module.name,
|
|
72
|
+
changes: nil,
|
|
73
|
+
reason: "target module does not exist; module creation is not supported"
|
|
74
|
+
)
|
|
75
|
+
]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
enumeration_operations = app_module.enumerations.map do |enumeration|
|
|
79
|
+
plan_enumeration(app_module, enumeration)
|
|
80
|
+
end
|
|
81
|
+
microflow_operations = app_module.microflows.map do |microflow|
|
|
82
|
+
plan_microflow(app_module, microflow)
|
|
83
|
+
end
|
|
84
|
+
page_operations = app_module.pages.map do |page|
|
|
85
|
+
plan_page(app_module, page)
|
|
86
|
+
end
|
|
87
|
+
module_role_operations = app_module.module_roles.map do |role|
|
|
88
|
+
plan_module_role(app_module, role)
|
|
89
|
+
end
|
|
90
|
+
entity_operations = app_module.entities.flat_map do |entity|
|
|
91
|
+
[plan_entity(app_module, entity), *entity.associations.map do |association|
|
|
92
|
+
plan_association(app_module, entity, association)
|
|
93
|
+
end]
|
|
94
|
+
end
|
|
95
|
+
module_role_operations + enumeration_operations + entity_operations +
|
|
96
|
+
microflow_operations + page_operations
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def plan_module_role(app_module, role)
|
|
100
|
+
name = "#{app_module.name}.#{role.name}"
|
|
101
|
+
existing = @inventory.find(name)
|
|
102
|
+
desired = { "description" => role.description }.compact
|
|
103
|
+
return Operation.new(
|
|
104
|
+
action: "create",
|
|
105
|
+
type: "modulerole",
|
|
106
|
+
name:,
|
|
107
|
+
changes: desired,
|
|
108
|
+
reason: nil
|
|
109
|
+
) unless existing
|
|
110
|
+
|
|
111
|
+
details = existing.details
|
|
112
|
+
unless existing.type == "modulerole" && details&.fetch("parse_status", nil) == "parsed"
|
|
113
|
+
return Operation.new(
|
|
114
|
+
action: "blocked",
|
|
115
|
+
type: "modulerole",
|
|
116
|
+
name:,
|
|
117
|
+
changes: nil,
|
|
118
|
+
reason: "existing module role does not have parsed semantic details"
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
current = { "description" => details["description"] }.compact
|
|
123
|
+
return Operation.new(
|
|
124
|
+
action: "keep",
|
|
125
|
+
type: "modulerole",
|
|
126
|
+
name:,
|
|
127
|
+
changes: nil,
|
|
128
|
+
reason: nil
|
|
129
|
+
) if current == desired
|
|
130
|
+
|
|
131
|
+
Operation.new(
|
|
132
|
+
action: "blocked",
|
|
133
|
+
type: "modulerole",
|
|
134
|
+
name:,
|
|
135
|
+
changes: { "from" => current, "to" => desired },
|
|
136
|
+
reason: "modifying an existing module role is not supported safely"
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def plan_user_role(role)
|
|
141
|
+
existing = @inventory.find(role.name)
|
|
142
|
+
desired = {
|
|
143
|
+
"module_roles" => role.module_roles,
|
|
144
|
+
"manage_all_roles" => role.manage_all_roles
|
|
145
|
+
}
|
|
146
|
+
return Operation.new(
|
|
147
|
+
action: "create",
|
|
148
|
+
type: "userrole",
|
|
149
|
+
name: role.name,
|
|
150
|
+
changes: desired,
|
|
151
|
+
reason: nil
|
|
152
|
+
) unless existing
|
|
153
|
+
|
|
154
|
+
details = existing.details
|
|
155
|
+
unless existing.type == "userrole" && details&.fetch("parse_status", nil) == "parsed"
|
|
156
|
+
return Operation.new(
|
|
157
|
+
action: "blocked",
|
|
158
|
+
type: "userrole",
|
|
159
|
+
name: role.name,
|
|
160
|
+
changes: nil,
|
|
161
|
+
reason: "existing user role does not have parsed semantic details"
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
current = details.slice("module_roles", "manage_all_roles")
|
|
166
|
+
return Operation.new(
|
|
167
|
+
action: "keep",
|
|
168
|
+
type: "userrole",
|
|
169
|
+
name: role.name,
|
|
170
|
+
changes: nil,
|
|
171
|
+
reason: nil
|
|
172
|
+
) if current == desired
|
|
173
|
+
|
|
174
|
+
Operation.new(
|
|
175
|
+
action: "blocked",
|
|
176
|
+
type: "userrole",
|
|
177
|
+
name: role.name,
|
|
178
|
+
changes: { "from" => current, "to" => desired },
|
|
179
|
+
reason: "modifying an existing user role requires explicit ALTER support"
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def plan_project_security(security)
|
|
184
|
+
existing = @inventory.find("ProjectSecurity")
|
|
185
|
+
desired = {
|
|
186
|
+
"SecurityLevel" => security.level.capitalize,
|
|
187
|
+
"DemoUsersEnabled" => security.demo_users
|
|
188
|
+
}
|
|
189
|
+
unless existing&.details&.fetch("parse_status", nil) == "parsed"
|
|
190
|
+
return Operation.new(
|
|
191
|
+
action: "blocked",
|
|
192
|
+
type: "projectsecurity",
|
|
193
|
+
name: "ProjectSecurity",
|
|
194
|
+
changes: nil,
|
|
195
|
+
reason: "project security does not have parsed semantic details"
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
current = existing.details.fetch("settings").slice(*desired.keys)
|
|
200
|
+
if current == desired
|
|
201
|
+
Operation.new(
|
|
202
|
+
action: "keep",
|
|
203
|
+
type: "projectsecurity",
|
|
204
|
+
name: "ProjectSecurity",
|
|
205
|
+
changes: nil,
|
|
206
|
+
reason: nil
|
|
207
|
+
)
|
|
208
|
+
else
|
|
209
|
+
Operation.new(
|
|
210
|
+
action: "modify",
|
|
211
|
+
type: "projectsecurity",
|
|
212
|
+
name: "ProjectSecurity",
|
|
213
|
+
changes: { "from" => current, "to" => desired },
|
|
214
|
+
reason: nil
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def plan_navigation(profile)
|
|
220
|
+
existing = @inventory.find(profile.name)
|
|
221
|
+
mdl = MDLGenerator.new(
|
|
222
|
+
Model::App.new(
|
|
223
|
+
name: "NavigationPlan",
|
|
224
|
+
version: nil,
|
|
225
|
+
modules: [],
|
|
226
|
+
user_roles: [],
|
|
227
|
+
project_security: nil,
|
|
228
|
+
navigation_profiles: [profile]
|
|
229
|
+
)
|
|
230
|
+
).generate
|
|
231
|
+
desired = DocumentParser.parse("navprofile", "mdl" => mdl)
|
|
232
|
+
comparable_keys = %w[
|
|
233
|
+
home_page role_home_pages login_page not_found_page menu_groups menu_items
|
|
234
|
+
]
|
|
235
|
+
desired = desired.slice(*comparable_keys)
|
|
236
|
+
|
|
237
|
+
return Operation.new(
|
|
238
|
+
action: "create",
|
|
239
|
+
type: "navprofile",
|
|
240
|
+
name: profile.name,
|
|
241
|
+
changes: desired,
|
|
242
|
+
reason: nil
|
|
243
|
+
) unless existing
|
|
244
|
+
|
|
245
|
+
details = existing.details
|
|
246
|
+
unless existing.type == "navprofile" && details&.fetch("parse_status", nil) == "parsed"
|
|
247
|
+
return Operation.new(
|
|
248
|
+
action: "blocked",
|
|
249
|
+
type: "navprofile",
|
|
250
|
+
name: profile.name,
|
|
251
|
+
changes: nil,
|
|
252
|
+
reason: "existing navigation profile does not have parsed semantic details"
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
current = details.slice(*comparable_keys)
|
|
257
|
+
if current == desired
|
|
258
|
+
Operation.new(
|
|
259
|
+
action: "keep",
|
|
260
|
+
type: "navprofile",
|
|
261
|
+
name: profile.name,
|
|
262
|
+
changes: nil,
|
|
263
|
+
reason: nil
|
|
264
|
+
)
|
|
265
|
+
else
|
|
266
|
+
Operation.new(
|
|
267
|
+
action: "modify",
|
|
268
|
+
type: "navprofile",
|
|
269
|
+
name: profile.name,
|
|
270
|
+
changes: { "from" => current, "to" => desired },
|
|
271
|
+
reason: nil
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def plan_page(app_module, page)
|
|
277
|
+
name = "#{app_module.name}.#{page.name}"
|
|
278
|
+
existing = @inventory.find(name)
|
|
279
|
+
desired = PageParser.parse(
|
|
280
|
+
"mdl" => MDLGenerator.page_statement(app_module, page)
|
|
281
|
+
)
|
|
282
|
+
comparable_keys = %w[
|
|
283
|
+
title layout folder parameters widgets data_sources actions
|
|
284
|
+
microflow_calls nanoflow_calls page_links attributes view_roles
|
|
285
|
+
]
|
|
286
|
+
desired = desired.slice(*comparable_keys)
|
|
287
|
+
|
|
288
|
+
return Operation.new(
|
|
289
|
+
action: "create",
|
|
290
|
+
type: "page",
|
|
291
|
+
name:,
|
|
292
|
+
changes: desired,
|
|
293
|
+
reason: nil
|
|
294
|
+
) unless existing
|
|
295
|
+
|
|
296
|
+
details = existing.details
|
|
297
|
+
unless existing.type == "page" && details&.fetch("parse_status", nil) == "parsed"
|
|
298
|
+
return Operation.new(
|
|
299
|
+
action: "blocked",
|
|
300
|
+
type: "page",
|
|
301
|
+
name:,
|
|
302
|
+
changes: nil,
|
|
303
|
+
reason: "existing page does not have parsed semantic details"
|
|
304
|
+
)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
current = details.slice(*comparable_keys)
|
|
308
|
+
if current == desired
|
|
309
|
+
Operation.new(action: "keep", type: "page", name:, changes: nil, reason: nil)
|
|
310
|
+
else
|
|
311
|
+
Operation.new(
|
|
312
|
+
action: "modify",
|
|
313
|
+
type: "page",
|
|
314
|
+
name:,
|
|
315
|
+
changes: { "from" => current, "to" => desired },
|
|
316
|
+
reason: nil
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def plan_microflow(app_module, microflow)
|
|
322
|
+
name = "#{app_module.name}.#{microflow.name}"
|
|
323
|
+
existing = @inventory.find(name)
|
|
324
|
+
desired = MicroflowParser.parse(
|
|
325
|
+
"mdl" => MDLGenerator.microflow_statement(app_module, microflow)
|
|
326
|
+
)
|
|
327
|
+
comparable_keys = %w[
|
|
328
|
+
parameters return_type folder activities calls execute_roles
|
|
329
|
+
]
|
|
330
|
+
desired = desired.slice(*comparable_keys)
|
|
331
|
+
|
|
332
|
+
return Operation.new(
|
|
333
|
+
action: "create",
|
|
334
|
+
type: "microflow",
|
|
335
|
+
name:,
|
|
336
|
+
changes: desired,
|
|
337
|
+
reason: nil
|
|
338
|
+
) unless existing
|
|
339
|
+
|
|
340
|
+
details = existing.details
|
|
341
|
+
unless existing.type == "microflow" && details&.fetch("parse_status", nil) == "parsed"
|
|
342
|
+
return Operation.new(
|
|
343
|
+
action: "blocked",
|
|
344
|
+
type: "microflow",
|
|
345
|
+
name:,
|
|
346
|
+
changes: nil,
|
|
347
|
+
reason: "existing microflow does not have parsed semantic details"
|
|
348
|
+
)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
current = details.slice(*comparable_keys)
|
|
352
|
+
if current == desired
|
|
353
|
+
Operation.new(action: "keep", type: "microflow", name:, changes: nil, reason: nil)
|
|
354
|
+
else
|
|
355
|
+
Operation.new(
|
|
356
|
+
action: "modify",
|
|
357
|
+
type: "microflow",
|
|
358
|
+
name:,
|
|
359
|
+
changes: { "from" => current, "to" => desired },
|
|
360
|
+
reason: nil
|
|
361
|
+
)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def plan_enumeration(app_module, enumeration)
|
|
366
|
+
name = "#{app_module.name}.#{enumeration.name}"
|
|
367
|
+
existing = @inventory.find(name)
|
|
368
|
+
desired = {
|
|
369
|
+
"folder" => enumeration.folder,
|
|
370
|
+
"values" => enumeration.values.map do |value|
|
|
371
|
+
{ "name" => value.name, "caption" => value.caption }
|
|
372
|
+
end
|
|
373
|
+
}.compact
|
|
374
|
+
return Operation.new(
|
|
375
|
+
action: "create",
|
|
376
|
+
type: "enumeration",
|
|
377
|
+
name:,
|
|
378
|
+
changes: desired,
|
|
379
|
+
reason: nil
|
|
380
|
+
) unless existing
|
|
381
|
+
|
|
382
|
+
details = existing.details
|
|
383
|
+
unless existing.type == "enumeration" && details&.fetch("parse_status", nil) == "parsed"
|
|
384
|
+
return Operation.new(
|
|
385
|
+
action: "blocked",
|
|
386
|
+
type: "enumeration",
|
|
387
|
+
name:,
|
|
388
|
+
changes: nil,
|
|
389
|
+
reason: "existing enumeration does not have parsed semantic details"
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
current = {
|
|
394
|
+
"folder" => details["folder"],
|
|
395
|
+
"values" => details.fetch("values", [])
|
|
396
|
+
}.compact
|
|
397
|
+
return Operation.new(
|
|
398
|
+
action: "keep",
|
|
399
|
+
type: "enumeration",
|
|
400
|
+
name:,
|
|
401
|
+
changes: nil,
|
|
402
|
+
reason: nil
|
|
403
|
+
) if current == desired
|
|
404
|
+
|
|
405
|
+
current_names = current.fetch("values").map { |value| value.fetch("name") }
|
|
406
|
+
desired_names = desired.fetch("values").map { |value| value.fetch("name") }
|
|
407
|
+
removed = current_names - desired_names
|
|
408
|
+
if removed.any?
|
|
409
|
+
return Operation.new(
|
|
410
|
+
action: "blocked",
|
|
411
|
+
type: "enumeration",
|
|
412
|
+
name:,
|
|
413
|
+
changes: { "from" => current, "to" => desired, "removed_values" => removed },
|
|
414
|
+
reason: "enumeration value removal requires explicit migration support"
|
|
415
|
+
)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
Operation.new(
|
|
419
|
+
action: "modify",
|
|
420
|
+
type: "enumeration",
|
|
421
|
+
name:,
|
|
422
|
+
changes: { "from" => current, "to" => desired },
|
|
423
|
+
reason: nil
|
|
424
|
+
)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def plan_entity(app_module, entity)
|
|
428
|
+
name = "#{app_module.name}.#{entity.name}"
|
|
429
|
+
existing = @inventory.find(name)
|
|
430
|
+
return Operation.new(action: "create", type: "entity", name:, changes: nil, reason: nil) unless existing
|
|
431
|
+
|
|
432
|
+
details = existing.details
|
|
433
|
+
unless existing.type == "entity" && details&.fetch("parse_status", nil) == "parsed"
|
|
434
|
+
return Operation.new(
|
|
435
|
+
action: "blocked",
|
|
436
|
+
type: "entity",
|
|
437
|
+
name:,
|
|
438
|
+
changes: nil,
|
|
439
|
+
reason: "existing entity does not have parsed semantic details"
|
|
440
|
+
)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
changes = entity_changes(entity, details)
|
|
444
|
+
unsafe_reasons = unsafe_entity_reasons(changes)
|
|
445
|
+
if unsafe_reasons.any?
|
|
446
|
+
Operation.new(
|
|
447
|
+
action: "blocked",
|
|
448
|
+
type: "entity",
|
|
449
|
+
name:,
|
|
450
|
+
changes:,
|
|
451
|
+
reason: unsafe_reasons.join("; ")
|
|
452
|
+
)
|
|
453
|
+
elsif changes.empty?
|
|
454
|
+
Operation.new(action: "keep", type: "entity", name:, changes: nil, reason: nil)
|
|
455
|
+
else
|
|
456
|
+
Operation.new(action: "modify", type: "entity", name:, changes:, reason: nil)
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def entity_changes(entity, details)
|
|
461
|
+
changes = {}
|
|
462
|
+
changes["persistable"] = {
|
|
463
|
+
"from" => details["persistable"],
|
|
464
|
+
"to" => entity.persistable
|
|
465
|
+
} if details["persistable"] != entity.persistable
|
|
466
|
+
|
|
467
|
+
current = details.fetch("attributes", []).to_h { |attribute| [attribute.fetch("name"), attribute] }
|
|
468
|
+
desired = entity.attributes.to_h { |attribute| [attribute.name, desired_attribute(attribute)] }
|
|
469
|
+
|
|
470
|
+
added = desired.keys - current.keys
|
|
471
|
+
removed = current.keys - desired.keys
|
|
472
|
+
modified = (current.keys & desired.keys).filter_map do |name|
|
|
473
|
+
before = comparable_attribute(current.fetch(name))
|
|
474
|
+
after = comparable_attribute(desired.fetch(name))
|
|
475
|
+
{ "name" => name, "from" => before, "to" => after } unless before == after
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
changes["added_attributes"] = added.map { |name| desired.fetch(name) } unless added.empty?
|
|
479
|
+
changes["modified_attributes"] = modified unless modified.empty?
|
|
480
|
+
changes["removed_attributes"] = removed unless removed.empty?
|
|
481
|
+
|
|
482
|
+
current_access = details.fetch("access_rules", []).map do |rule|
|
|
483
|
+
comparable_access_rule(rule)
|
|
484
|
+
end
|
|
485
|
+
desired_access = entity.access_rules.map do |rule|
|
|
486
|
+
comparable_access_rule(rule.to_h.transform_keys(&:to_s))
|
|
487
|
+
end
|
|
488
|
+
current_by_role = current_access.to_h { |rule| [rule.fetch("role"), rule] }
|
|
489
|
+
desired_by_role = desired_access.to_h { |rule| [rule.fetch("role"), rule] }
|
|
490
|
+
added_roles = desired_by_role.keys - current_by_role.keys
|
|
491
|
+
removed_roles = current_by_role.keys - desired_by_role.keys
|
|
492
|
+
modified_roles = (current_by_role.keys & desired_by_role.keys).filter_map do |role|
|
|
493
|
+
before = current_by_role.fetch(role)
|
|
494
|
+
after = desired_by_role.fetch(role)
|
|
495
|
+
{ "role" => role, "from" => before, "to" => after } unless before == after
|
|
496
|
+
end
|
|
497
|
+
changes["added_access_rules"] =
|
|
498
|
+
added_roles.map { |role| desired_by_role.fetch(role) } unless added_roles.empty?
|
|
499
|
+
changes["modified_access_rules"] = modified_roles unless modified_roles.empty?
|
|
500
|
+
changes["removed_access_rules"] = removed_roles unless removed_roles.empty?
|
|
501
|
+
changes
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def unsafe_entity_reasons(changes)
|
|
505
|
+
reasons = []
|
|
506
|
+
if changes["removed_attributes"]&.any?
|
|
507
|
+
reasons << "attribute removal requires explicit destructive-change support"
|
|
508
|
+
end
|
|
509
|
+
if changes["removed_access_rules"]&.any?
|
|
510
|
+
reasons << "entity access removal requires explicit REVOKE support"
|
|
511
|
+
end
|
|
512
|
+
if changes.key?("persistable")
|
|
513
|
+
reasons << "changing entity persistence requires explicit migration support"
|
|
514
|
+
end
|
|
515
|
+
if changes.fetch("modified_attributes", []).any? do |change|
|
|
516
|
+
change.dig("from", "type") != change.dig("to", "type")
|
|
517
|
+
end
|
|
518
|
+
reasons << "changing an attribute type requires explicit migration support"
|
|
519
|
+
end
|
|
520
|
+
if changes.fetch("added_attributes", []).any? do |attribute|
|
|
521
|
+
attribute["required"] && !attribute.key?("default")
|
|
522
|
+
end
|
|
523
|
+
reasons << "adding a required attribute without a default may invalidate existing data"
|
|
524
|
+
end
|
|
525
|
+
reasons
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def plan_association(app_module, entity, association)
|
|
529
|
+
name = "#{app_module.name}.#{association.name}"
|
|
530
|
+
existing = @inventory.find(name)
|
|
531
|
+
desired = {
|
|
532
|
+
"from" => "#{app_module.name}.#{entity.name}",
|
|
533
|
+
"to" => association.target,
|
|
534
|
+
"association_type" => association.cardinality == "many" ? "ReferenceSet" : "Reference",
|
|
535
|
+
"owner" => association.owner == "both" ? "Both" : "Default"
|
|
536
|
+
}
|
|
537
|
+
return Operation.new(action: "create", type: "association", name:, changes: desired, reason: nil) unless existing
|
|
538
|
+
|
|
539
|
+
current = existing.details&.slice(*desired.keys)
|
|
540
|
+
unless existing.type == "association" && current
|
|
541
|
+
return Operation.new(
|
|
542
|
+
action: "blocked",
|
|
543
|
+
type: "association",
|
|
544
|
+
name:,
|
|
545
|
+
changes: nil,
|
|
546
|
+
reason: "existing association does not have parsed semantic details"
|
|
547
|
+
)
|
|
548
|
+
end
|
|
549
|
+
return Operation.new(action: "keep", type: "association", name:, changes: nil, reason: nil) if current == desired
|
|
550
|
+
|
|
551
|
+
Operation.new(
|
|
552
|
+
action: "blocked",
|
|
553
|
+
type: "association",
|
|
554
|
+
name:,
|
|
555
|
+
changes: { "from" => current, "to" => desired },
|
|
556
|
+
reason: "modifying an existing association is not supported safely"
|
|
557
|
+
)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def desired_attribute(attribute)
|
|
561
|
+
{
|
|
562
|
+
"name" => attribute.name,
|
|
563
|
+
"type" => attribute_type(attribute),
|
|
564
|
+
"required" => attribute.required,
|
|
565
|
+
"default" => default_value(attribute.default)
|
|
566
|
+
}.compact
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def comparable_access_rule(rule)
|
|
570
|
+
rule.slice("role", "create", "delete", "read", "write", "xpath")
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def comparable_attribute(attribute)
|
|
574
|
+
attribute.slice("name", "type", "required", "default")
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def attribute_type(attribute)
|
|
578
|
+
return "Enumeration(#{attribute.options.fetch(:enumeration)})" if attribute.type == "enumeration"
|
|
579
|
+
return "String(#{attribute.options[:length] || 'unlimited'})" if attribute.type == "string"
|
|
580
|
+
|
|
581
|
+
TYPE_NAMES.fetch(attribute.type)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def default_value(value)
|
|
585
|
+
case value
|
|
586
|
+
when nil then nil
|
|
587
|
+
when true then "true"
|
|
588
|
+
when false then "false"
|
|
589
|
+
when Numeric then value.to_s
|
|
590
|
+
when String then "'#{value.gsub("'", "''")}'"
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module MendixBridge
|
|
7
|
+
class ConfigError < StandardError; end
|
|
8
|
+
|
|
9
|
+
class Config
|
|
10
|
+
FILE_NAME = ".mendix-ruby.yml"
|
|
11
|
+
KEYS = %w[project inventory model].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :path
|
|
14
|
+
|
|
15
|
+
def self.find(start_dir = Dir.pwd)
|
|
16
|
+
directory = Pathname.new(File.expand_path(start_dir))
|
|
17
|
+
loop do
|
|
18
|
+
candidate = directory.join(FILE_NAME)
|
|
19
|
+
return load(candidate.to_s) if candidate.file?
|
|
20
|
+
break if directory.root?
|
|
21
|
+
|
|
22
|
+
directory = directory.parent
|
|
23
|
+
end
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.load(path)
|
|
28
|
+
path = File.expand_path(path)
|
|
29
|
+
data = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
|
|
30
|
+
raise ConfigError, "configuration root must be a mapping: #{path}" unless
|
|
31
|
+
data.is_a?(Hash)
|
|
32
|
+
raise ConfigError, "unsupported configuration version" unless data["version"] == 1
|
|
33
|
+
|
|
34
|
+
unknown = data.keys - ["version", *KEYS]
|
|
35
|
+
raise ConfigError, "unknown configuration keys: #{unknown.join(', ')}" unless
|
|
36
|
+
unknown.empty?
|
|
37
|
+
|
|
38
|
+
new(path, data)
|
|
39
|
+
rescue Psych::Exception => error
|
|
40
|
+
raise ConfigError, "invalid configuration #{path}: #{error.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.write(path, project:, inventory:, model:, force: false)
|
|
44
|
+
path = File.expand_path(path)
|
|
45
|
+
raise ConfigError, "configuration already exists: #{path}" if
|
|
46
|
+
File.exist?(path) && !force
|
|
47
|
+
|
|
48
|
+
base = File.dirname(path)
|
|
49
|
+
data = {
|
|
50
|
+
"version" => 1,
|
|
51
|
+
"project" => relative_path(project, base),
|
|
52
|
+
"inventory" => relative_path(inventory, base),
|
|
53
|
+
"model" => relative_path(model, base)
|
|
54
|
+
}
|
|
55
|
+
File.write(path, YAML.dump(data))
|
|
56
|
+
load(path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(path, data)
|
|
60
|
+
@path = path
|
|
61
|
+
@data = data
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
KEYS.each do |key|
|
|
65
|
+
define_method(key) do
|
|
66
|
+
value = @data[key]
|
|
67
|
+
value && File.expand_path(value, File.dirname(path))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_h
|
|
72
|
+
{
|
|
73
|
+
"path" => path,
|
|
74
|
+
"project" => project,
|
|
75
|
+
"inventory" => inventory,
|
|
76
|
+
"model" => model
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.relative_path(value, base)
|
|
81
|
+
Pathname.new(File.expand_path(value)).relative_path_from(
|
|
82
|
+
Pathname.new(File.expand_path(base))
|
|
83
|
+
).to_s
|
|
84
|
+
rescue ArgumentError
|
|
85
|
+
File.expand_path(value)
|
|
86
|
+
end
|
|
87
|
+
private_class_method :relative_path
|
|
88
|
+
end
|
|
89
|
+
end
|