mxrb 0.1.2 → 0.1.4

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 (191) hide show
  1. checksums.yaml +4 -4
  2. data/bin/mxrb +458 -10
  3. data/docs/de-DE/README.md +1 -0
  4. data/docs/de-DE/oql-sql.md +42 -0
  5. data/docs/de-DE/project-structure.md +201 -2
  6. data/docs/de-DE/runtime-ruby.md +104 -0
  7. data/docs/de-DE/runtime-schema-migrations.md +44 -0
  8. data/docs/de-DE/validation-matrix.md +42 -4
  9. data/docs/en-US/README.md +1 -0
  10. data/docs/en-US/oql-sql.md +41 -0
  11. data/docs/en-US/project-structure.md +192 -2
  12. data/docs/en-US/runtime-ruby.md +100 -0
  13. data/docs/en-US/runtime-schema-migrations.md +41 -0
  14. data/docs/en-US/validation-matrix.md +39 -4
  15. data/docs/pt-BR/README.md +1 -0
  16. data/docs/pt-BR/entity-dsl.md +8 -0
  17. data/docs/pt-BR/oql-sql.md +41 -0
  18. data/docs/pt-BR/project-structure.md +197 -2
  19. data/docs/pt-BR/runtime-ruby.md +108 -0
  20. data/docs/pt-BR/runtime-schema-migrations.md +40 -0
  21. data/docs/pt-BR/validation-matrix.md +42 -5
  22. data/docs/pt-BR/writing.md +28 -0
  23. data/lib/mxrb/cli/help.rb +426 -0
  24. data/lib/mxrb/cli/release.rb +137 -0
  25. data/lib/mxrb/compiler/compatibility_analyzer.rb +1 -1
  26. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +8 -1
  27. data/lib/mxrb/compiler/domain_document_compiler.rb +3 -0
  28. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +11 -1
  29. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +76 -19
  30. data/lib/mxrb/compiler/legacy_custom_widget_compiler.rb +159 -0
  31. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +43 -7
  32. data/lib/mxrb/compiler/legacy_page_builder.rb +1248 -23
  33. data/lib/mxrb/compiler/packager.rb +27 -13
  34. data/lib/mxrb/compiler/page_bundle_compiler.rb +13 -4
  35. data/lib/mxrb/compiler/portable_packager.rb +16 -11
  36. data/lib/mxrb/compiler/web_bundle_builder.rb +5 -1
  37. data/lib/mxrb/compiler/web_shell_materializer.rb +42 -2
  38. data/lib/mxrb/compiler/widget_package_extractor.rb +3 -0
  39. data/lib/mxrb/domain_diagram/lifecycle.rb +272 -0
  40. data/lib/mxrb/domain_diagram.rb +409 -0
  41. data/lib/mxrb/dsl/builder.rb +62 -11
  42. data/lib/mxrb/environment.rb +226 -0
  43. data/lib/mxrb/exporter.rb +265 -45
  44. data/lib/mxrb/http/server.rb +107 -0
  45. data/lib/mxrb/initializer.rb +80 -9
  46. data/lib/mxrb/integrity/validator.rb +32 -7
  47. data/lib/mxrb/io/bson_codec.rb +42 -1
  48. data/lib/mxrb/io/mpr_file.rb +146 -3
  49. data/lib/mxrb/model/attribute.rb +3 -0
  50. data/lib/mxrb/model/entity.rb +44 -2
  51. data/lib/mxrb/model/microflow.rb +3 -1
  52. data/lib/mxrb/model/module.rb +71 -0
  53. data/lib/mxrb/model/page.rb +126 -15
  54. data/lib/mxrb/model/project.rb +3 -0
  55. data/lib/mxrb/modeler/catalog.rb +178 -0
  56. data/lib/mxrb/official_marketplace/module_package_importer.rb +23 -12
  57. data/lib/mxrb/official_marketplace/widget_package_installer.rb +15 -11
  58. data/lib/mxrb/official_marketplace.rb +9 -3
  59. data/lib/mxrb/oql/reverse_translator.rb +394 -0
  60. data/lib/mxrb/oql/server.rb +4 -8
  61. data/lib/mxrb/oql.rb +32 -4
  62. data/lib/mxrb/progress.rb +242 -0
  63. data/lib/mxrb/project_lifecycle.rb +5 -2
  64. data/lib/mxrb/ruby_app/exporter.rb +1749 -0
  65. data/lib/mxrb/ruby_app/preset.rb +506 -0
  66. data/lib/mxrb/ruby_app/session_manager.rb +122 -0
  67. data/lib/mxrb/ruby_app.rb +1361 -0
  68. data/lib/mxrb/runtime/access_control.rb +408 -0
  69. data/lib/mxrb/runtime/native.rb +1015 -93
  70. data/lib/mxrb/runtime/scheduler.rb +396 -0
  71. data/lib/mxrb/runtime/schema_migrator.rb +598 -0
  72. data/lib/mxrb/runtime/shared_store.rb +335 -0
  73. data/lib/mxrb/runtime/sqlite_store.rb +729 -0
  74. data/lib/mxrb/scaffold/generator.rb +11 -3
  75. data/lib/mxrb/team_server.rb +10 -7
  76. data/lib/mxrb/uml/activity_diagram.rb +118 -0
  77. data/lib/mxrb/uml/class_diagram.rb +118 -0
  78. data/lib/mxrb/uml/sequence_diagram.rb +112 -0
  79. data/lib/mxrb/uml/server.rb +146 -0
  80. data/lib/mxrb/uml/support.rb +35 -0
  81. data/lib/mxrb/version.rb +1 -1
  82. data/lib/mxrb/web_ui/assets/abnfDiagram-N423BO3Z-C4JD3cEC.js +1 -0
  83. data/lib/mxrb/web_ui/assets/arc-3Z53kgFp.js +1 -0
  84. data/lib/mxrb/web_ui/assets/architecture-TIHT7OUA-BStBTLea.js +1 -0
  85. data/lib/mxrb/web_ui/assets/architectureDiagram-T3A2C74G-DBwqnM4J.js +36 -0
  86. data/lib/mxrb/web_ui/assets/array-BifhSqXX.js +1 -0
  87. data/lib/mxrb/web_ui/assets/blockDiagram-VBNYF7ZC-DLtuHt-j.js +132 -0
  88. data/lib/mxrb/web_ui/assets/c4Diagram-5PPSVZJV-ConSk_pe.js +10 -0
  89. data/lib/mxrb/web_ui/assets/channel-BhPvD7Dt.js +1 -0
  90. data/lib/mxrb/web_ui/assets/chunk-2GRJ4B5K-D2HpWsf_.js +1 -0
  91. data/lib/mxrb/web_ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  92. data/lib/mxrb/web_ui/assets/chunk-4I5QYGJK-CiIdNwaF.js +1 -0
  93. data/lib/mxrb/web_ui/assets/chunk-5RXB4S5H-Bu_AaEkE.js +231 -0
  94. data/lib/mxrb/web_ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  95. data/lib/mxrb/web_ui/assets/chunk-6Q2QTUOP-wQkNmt_X.js +88 -0
  96. data/lib/mxrb/web_ui/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  97. data/lib/mxrb/web_ui/assets/chunk-GF5L2VYU-G8MmiBzU.js +206 -0
  98. data/lib/mxrb/web_ui/assets/chunk-I66GZJ75-CulcrLPh.js +127 -0
  99. data/lib/mxrb/web_ui/assets/chunk-JQJVKLGR-ukjzcdlL.js +156 -0
  100. data/lib/mxrb/web_ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  101. data/lib/mxrb/web_ui/assets/chunk-KBJHAD2P-Wm35lYov.js +1 -0
  102. data/lib/mxrb/web_ui/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
  103. data/lib/mxrb/web_ui/assets/chunk-NSK5VX7P-D2nYrCY4.js +2 -0
  104. data/lib/mxrb/web_ui/assets/chunk-QR6OTTB3-BrdIzg4Q.js +62 -0
  105. data/lib/mxrb/web_ui/assets/chunk-RYQCIY6F-CCfZMcW6.js +1 -0
  106. data/lib/mxrb/web_ui/assets/chunk-UBXNYLIW-BPEVYsFK.js +1 -0
  107. data/lib/mxrb/web_ui/assets/chunk-W5SLKNZC-2E6OPhA4.js +1 -0
  108. data/lib/mxrb/web_ui/assets/chunk-WRU74C26-CBIvOyWp.js +70 -0
  109. data/lib/mxrb/web_ui/assets/chunk-XXDRQBXY-pH58XAyl.js +1 -0
  110. data/lib/mxrb/web_ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  111. data/lib/mxrb/web_ui/assets/classDiagram-JCYQIIEL-xiqo4D89.js +1 -0
  112. data/lib/mxrb/web_ui/assets/classDiagram-v2-OCEON4UE-xiqo4D89.js +1 -0
  113. data/lib/mxrb/web_ui/assets/cose-bilkent-JH36ORCC-t_flPpii.js +1 -0
  114. data/lib/mxrb/web_ui/assets/cynefin-VYW2F7L2-BIqktTEv.js +1 -0
  115. data/lib/mxrb/web_ui/assets/cynefinDiagram-MW4NZA55-C2d--lut.js +62 -0
  116. data/lib/mxrb/web_ui/assets/cytoscape.esm-B-NFISlW.js +321 -0
  117. data/lib/mxrb/web_ui/assets/dagre-Buvkdvvj.js +1 -0
  118. data/lib/mxrb/web_ui/assets/dagre-VZM6K2ZE-CSz2_tb5.js +4 -0
  119. data/lib/mxrb/web_ui/assets/defaultLocale-BFoDCU3G.js +1 -0
  120. data/lib/mxrb/web_ui/assets/diagram-7IWD3JNH-DCt7u7V_.js +30 -0
  121. data/lib/mxrb/web_ui/assets/diagram-B4RE2ZJO-DqZBqkMu.js +3 -0
  122. data/lib/mxrb/web_ui/assets/diagram-LBJQPF4R-ClIYbIsI.js +24 -0
  123. data/lib/mxrb/web_ui/assets/diagram-Q27KOJAE-DFxhvthJ.js +24 -0
  124. data/lib/mxrb/web_ui/assets/diagram-UB23O5K3-BSUTXptv.js +41 -0
  125. data/lib/mxrb/web_ui/assets/dist-D2qOEbeJ.js +1 -0
  126. data/lib/mxrb/web_ui/assets/domain-mGOZ1KCG.css +1 -0
  127. data/lib/mxrb/web_ui/assets/domain-qsjXhy4v.js +1 -0
  128. data/lib/mxrb/web_ui/assets/ebnfDiagram-BXEA7PRR-B5reICEi.js +1 -0
  129. data/lib/mxrb/web_ui/assets/erDiagram-JOGREHBK-Co5Mxn2p.js +85 -0
  130. data/lib/mxrb/web_ui/assets/eventmodeling-45OFAUF4-C1ylhSks.js +1 -0
  131. data/lib/mxrb/web_ui/assets/flowDiagram-UKHOOZJN-CHXaJIaS.js +1 -0
  132. data/lib/mxrb/web_ui/assets/ganttDiagram-PKOTCBZU-DPPPaWaz.js +292 -0
  133. data/lib/mxrb/web_ui/assets/gitGraph-TEB2WS4Q-qS77ywA9.js +1 -0
  134. data/lib/mxrb/web_ui/assets/gitGraphDiagram-DS77QQ5N-DpAv6-BT.js +106 -0
  135. data/lib/mxrb/web_ui/assets/graphlib-DS17s2tU.js +1 -0
  136. data/lib/mxrb/web_ui/assets/info-DKCQHKI2-C9VD1Y4e.js +1 -0
  137. data/lib/mxrb/web_ui/assets/infoDiagram-6WML65LV-Bk1kbcCy.js +2 -0
  138. data/lib/mxrb/web_ui/assets/init-C-OQMol4.js +1 -0
  139. data/lib/mxrb/web_ui/assets/ishikawaDiagram-WSZJBQD7-0gcISBFR.js +70 -0
  140. data/lib/mxrb/web_ui/assets/journeyDiagram-NVQOT4AX-Dt0RQnlh.js +139 -0
  141. data/lib/mxrb/web_ui/assets/jsx-runtime-DV0a5kSb.js +9 -0
  142. data/lib/mxrb/web_ui/assets/kanban-definition-27J2QSJJ-DIPQM7w9.js +89 -0
  143. data/lib/mxrb/web_ui/assets/katex-CXMH3UgJ.js +257 -0
  144. data/lib/mxrb/web_ui/assets/line-C0rYD-tL.js +1 -0
  145. data/lib/mxrb/web_ui/assets/linear-BPPxWorj.js +1 -0
  146. data/lib/mxrb/web_ui/assets/map-BaFkSB1l.js +1 -0
  147. data/lib/mxrb/web_ui/assets/mermaid-parser.core-B_UPzTxa.js +7 -0
  148. data/lib/mxrb/web_ui/assets/mindmap-definition-FAOFIHXS-CdJRSMuk.js +96 -0
  149. data/lib/mxrb/web_ui/assets/modeler-BNqHthwk.css +1 -0
  150. data/lib/mxrb/web_ui/assets/modeler-CQmqflbg.js +1 -0
  151. data/lib/mxrb/web_ui/assets/ordinal-BDEzSJ7C.js +1 -0
  152. data/lib/mxrb/web_ui/assets/packet-7NZHBO7P-CwQ4NSBs.js +1 -0
  153. data/lib/mxrb/web_ui/assets/path-fybaL0A-.js +1 -0
  154. data/lib/mxrb/web_ui/assets/pegDiagram-VL7TDLO6-D40S7ppx.js +1 -0
  155. data/lib/mxrb/web_ui/assets/pie-RZYD4A2V-CJroHMKh.js +1 -0
  156. data/lib/mxrb/web_ui/assets/pieDiagram-7S7Q4E2Y-QB54__BV.js +39 -0
  157. data/lib/mxrb/web_ui/assets/quadrantDiagram-CIZ2JOQS-DBhDS6ag.js +7 -0
  158. data/lib/mxrb/web_ui/assets/radar-I7S5WNFK-DdIUwd2d.js +1 -0
  159. data/lib/mxrb/web_ui/assets/railroad-3IZDKUUU-B1Lw6Xyq.js +1 -0
  160. data/lib/mxrb/web_ui/assets/railroad-abnf-AHOZXSZD-CQgQoeak.js +1 -0
  161. data/lib/mxrb/web_ui/assets/railroad-ebnf-EBAXGLYW-D7qEQ2x8.js +1 -0
  162. data/lib/mxrb/web_ui/assets/railroad-peg-LSFZ7HO6-C005Vxbb.js +1 -0
  163. data/lib/mxrb/web_ui/assets/railroadDiagram-AXF67PYL-BBP2qk7M.js +1 -0
  164. data/lib/mxrb/web_ui/assets/requirementDiagram-LRYGKXZP-D2cLsAoI.js +84 -0
  165. data/lib/mxrb/web_ui/assets/rough.esm-Dy-Kn_BL.js +1 -0
  166. data/lib/mxrb/web_ui/assets/sankeyDiagram-W5VNT64P-Bpi3FiP2.js +40 -0
  167. data/lib/mxrb/web_ui/assets/sequenceDiagram-SI44F4Z6-DCRcDlVe.js +162 -0
  168. data/lib/mxrb/web_ui/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  169. data/lib/mxrb/web_ui/assets/src-CvxfUak2.js +1 -0
  170. data/lib/mxrb/web_ui/assets/stateDiagram-OKZ733FA-C2GCEoxl.js +1 -0
  171. data/lib/mxrb/web_ui/assets/stateDiagram-v2-UEYNNEHI-D2gWgedh.js +1 -0
  172. data/lib/mxrb/web_ui/assets/swimlanes-SLNWSIFB-CRI9irEW.js +2 -0
  173. data/lib/mxrb/web_ui/assets/swimlanesDiagram-ULZ7WXOC-C5Wb_-hZ.js +8 -0
  174. data/lib/mxrb/web_ui/assets/theme-_mxc7BdY.css +1 -0
  175. data/lib/mxrb/web_ui/assets/timeline-definition-Z64GVDOM-B1-mNg-h.js +120 -0
  176. data/lib/mxrb/web_ui/assets/treeView-QDETBFTQ-7CTvIwKq.js +1 -0
  177. data/lib/mxrb/web_ui/assets/treemap-6X3UGDF4-CtL22pq5.js +1 -0
  178. data/lib/mxrb/web_ui/assets/uml-C3KAFGTq.js +51 -0
  179. data/lib/mxrb/web_ui/assets/uml-Dn-qd01X.css +1 -0
  180. data/lib/mxrb/web_ui/assets/vennDiagram-T6HMQDX7-BzVnUEAy.js +34 -0
  181. data/lib/mxrb/web_ui/assets/wardley-OPB4EBWU-JNi-QNag.js +1 -0
  182. data/lib/mxrb/web_ui/assets/wardleyDiagram-T6FBY63Y-BUwmAQ-B.js +78 -0
  183. data/lib/mxrb/web_ui/assets/xychartDiagram-ELKLHX3M-DEQS8sKp.js +7 -0
  184. data/lib/mxrb/web_ui/domain.html +15 -0
  185. data/lib/mxrb/web_ui/modeler.html +16 -0
  186. data/lib/mxrb/web_ui/uml.html +31 -0
  187. data/lib/mxrb/web_ui.rb +89 -0
  188. data/lib/mxrb/widget_certification.rb +208 -0
  189. data/lib/mxrb/writer.rb +278 -41
  190. data/lib/mxrb.rb +38 -8
  191. metadata +157 -5
@@ -0,0 +1,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'securerandom'
6
+ require 'uri'
7
+
8
+ require_relative 'web_ui'
9
+ require_relative 'http/server'
10
+
11
+ module Mxrb
12
+ # Browser ER diagram for Mendix domain models. It edits visual layout only:
13
+ # entity locations plus local and cross-module connection anchors.
14
+ # rubocop:disable Metrics
15
+ module DomainDiagram
16
+ ANCHORS = %w[north east south west].freeze
17
+ ANCHOR_VALUES = {
18
+ 'north' => '50;0', 'east' => '100;50',
19
+ 'south' => '50;100', 'west' => '0;50'
20
+ }.freeze
21
+ VALUE_ANCHORS = ANCHOR_VALUES.invert.freeze
22
+
23
+ # Builds a framework-neutral JSON projection from arbitrary Mendix modules.
24
+ class Document
25
+ def initialize(path, modules: [])
26
+ @path = File.expand_path(path)
27
+ @selected_modules = Array(modules).map(&:to_s)
28
+ end
29
+
30
+ def to_h
31
+ Model::Project.open(@path).then do |project|
32
+ selected = project.modules
33
+ unless @selected_modules.empty?
34
+ unknown = @selected_modules.uniq - selected.map(&:name)
35
+ raise ArgumentError, "unknown modules: #{unknown.join(', ')}" unless unknown.empty?
36
+
37
+ selected = selected.select { @selected_modules.include?(_1.name) }
38
+ end
39
+ anchor_overrides = project.mpr.domain_diagram_anchors
40
+ known = project.modules.each_with_object({}) do |mod, names|
41
+ mod.entities.each do |entity|
42
+ qualified = entity.qualified_name || "#{mod.name}.#{entity.name}"
43
+ names[entity.id] = qualified
44
+ names[qualified] = qualified
45
+ end
46
+ end
47
+ {
48
+ project: { name: project.name, mendix_version: project.mendix_version },
49
+ module_filter_applied: !@selected_modules.empty?,
50
+ modules: selected.filter_map { module_payload(_1, known, anchor_overrides) }
51
+ }
52
+ ensure
53
+ project&.close
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def module_payload(mod, known, anchor_overrides = {})
60
+ domain = mod.domain_model
61
+ return unless domain
62
+
63
+ raw = mod.mpr.parse_contents(mod.mpr.unit(domain.id))
64
+ association_docs = array(raw['associations'] || raw['Associations']) +
65
+ array(raw['crossAssociations'] || raw['CrossAssociations'])
66
+ association_by_id = association_docs.to_h { [IO::BsonCodec.extract_id(_1['$ID']), _1] }
67
+ {
68
+ id: mod.id, name: mod.name,
69
+ entities: domain.entities.map { entity_payload(_1, mod.name) },
70
+ associations: domain.associations.filter_map do |association|
71
+ association_payload(
72
+ association, association_by_id[association.id] || {}, known, anchor_overrides
73
+ )
74
+ end
75
+ }
76
+ end
77
+
78
+ def entity_payload(entity, module_name)
79
+ kind = if entity.respond_to?(:oql_view?) && entity.oql_view?
80
+ 'oql_view'
81
+ elsif entity.persistable == true
82
+ 'entity'
83
+ else
84
+ 'dto'
85
+ end
86
+ {
87
+ id: entity.id, name: entity.name,
88
+ qualified_name: entity.qualified_name || "#{module_name}.#{entity.name}",
89
+ module: module_name, kind:,
90
+ persistent: entity.persistable == true,
91
+ x: Integer(entity.location&.fetch(:x, 0) || 0),
92
+ y: Integer(entity.location&.fetch(:y, 0) || 0),
93
+ attributes: entity.attributes.map do |attribute|
94
+ {
95
+ name: attribute.name, type: attribute.type.to_s,
96
+ required: attribute.required == true, key: attribute.unique == true
97
+ }
98
+ end
99
+ }
100
+ end
101
+
102
+ def association_payload(association, raw, known, anchor_overrides = {})
103
+ from = known[association.from_entity_id]
104
+ to = known[association.to_entity_id]
105
+ return unless from && to
106
+
107
+ cross_module = raw['$Type'] == 'DomainModels$CrossAssociation'
108
+ override = anchor_overrides.fetch(association.id.to_s, {})
109
+
110
+ {
111
+ id: association.id, name: association.name, from:, to:,
112
+ type: association.association_type.to_s,
113
+ owner: association.owner.to_s,
114
+ source_anchor: override.fetch(
115
+ :source_anchor, anchor(raw['ParentConnection'], fallback: 'east')
116
+ ),
117
+ target_anchor: override.fetch(
118
+ :target_anchor, anchor(raw['ChildConnection'], fallback: 'west')
119
+ ),
120
+ cross_module:,
121
+ editable_anchors: true,
122
+ anchor_storage: cross_module ? 'mxrb' : 'native'
123
+ }
124
+ end
125
+
126
+ def anchor(value, fallback:)
127
+ VALUE_ANCHORS.fetch(value.to_s, fallback)
128
+ end
129
+
130
+ def array(value) = IO::BsonCodec.parse_array(value)[:items]
131
+ end
132
+
133
+ # Applies only audited visual fields to a copied MPR.
134
+ class LayoutWriter
135
+ def initialize(path)
136
+ @path = File.expand_path(path)
137
+ end
138
+
139
+ def apply!(payload)
140
+ modules = Array(payload.fetch('modules'))
141
+ mpr = IO::MprFile.open(@path, readonly: false)
142
+ changed = 0
143
+ metadata_anchors = []
144
+ mpr.transaction do
145
+ module_units = mpr.units_by_containment('Modules').to_h do |unit|
146
+ [mpr.parse_contents(unit)['Name'].to_s, unit]
147
+ end
148
+ modules.each do |layout|
149
+ unit = module_units[layout.fetch('name').to_s]
150
+ raise ValidationError, "domain module #{layout['name']} not found" unless unit
151
+
152
+ module_changed, module_metadata = apply_module(mpr, unit, layout)
153
+ changed += module_changed
154
+ metadata_anchors.concat(module_metadata)
155
+ end
156
+ changed += mpr.write_domain_diagram_anchors(metadata_anchors)
157
+ end
158
+ changed
159
+ ensure
160
+ mpr&.close
161
+ end
162
+
163
+ private
164
+
165
+ def apply_module(mpr, module_unit, layout)
166
+ domain_unit = mpr.children_of(module_unit.fetch('UnitID')).find do |unit|
167
+ unit['ContainmentName'] == 'DomainModel'
168
+ end
169
+ raise ValidationError, "domain model #{layout['name']} not found" unless domain_unit
170
+
171
+ doc = mpr.parse_contents(domain_unit)
172
+ changed = update_entities(doc, Array(layout['entities']))
173
+ association_changes, metadata_anchors = update_associations(
174
+ doc, Array(layout['associations'])
175
+ )
176
+ changed += association_changes
177
+ mpr.update_unit(domain_unit.fetch('UnitID'), doc) if changed.positive?
178
+ [changed, metadata_anchors]
179
+ end
180
+
181
+ def update_entities(doc, layouts)
182
+ by_id = layouts.to_h { [_1.fetch('id').to_s, _1] }
183
+ entities = array(doc['entities'] || doc['Entities'])
184
+ native_ids = entities.map { IO::BsonCodec.extract_id(_1['$ID']).to_s }
185
+ unknown = by_id.keys - native_ids
186
+ raise ValidationError, "unknown domain entities: #{unknown.join(', ')}" unless unknown.empty?
187
+
188
+ changed = entities.count do |entity|
189
+ layout = by_id[IO::BsonCodec.extract_id(entity['$ID']).to_s]
190
+ next false unless layout
191
+
192
+ x = bounded_integer(layout.fetch('x'), 'x')
193
+ y = bounded_integer(layout.fetch('y'), 'y')
194
+ key = entity.key?('Location') ? 'Location' : 'location'
195
+ value = entity[key].is_a?(Hash) ? { 'x' => x, 'y' => y } : "#{x};#{y}"
196
+ changed = entity[key] != value
197
+ entity[key] = value
198
+ changed
199
+ end
200
+ key = doc.key?('Entities') ? 'Entities' : 'entities'
201
+ doc[key] = IO::BsonCodec.build_array(entities)
202
+ changed
203
+ end
204
+
205
+ def update_associations(doc, layouts)
206
+ by_id = layouts.to_h { [_1.fetch('id').to_s, _1] }
207
+ key = %w[associations Associations].find { doc.key?(_1) } || 'associations'
208
+ cross_key = %w[crossAssociations CrossAssociations].find { doc.key?(_1) } ||
209
+ 'crossAssociations'
210
+ associations = array(doc[key])
211
+ cross_associations = array(doc[cross_key])
212
+ native_ids = associations.map { IO::BsonCodec.extract_id(_1['$ID']).to_s }
213
+ cross_ids = cross_associations.map { IO::BsonCodec.extract_id(_1['$ID']).to_s }
214
+ unknown = by_id.keys - native_ids - cross_ids
215
+ raise ValidationError, "unknown domain associations: #{unknown.join(', ')}" unless unknown.empty?
216
+
217
+ count = associations.count do |association|
218
+ layout = by_id[IO::BsonCodec.extract_id(association['$ID']).to_s]
219
+ next false unless layout
220
+
221
+ source = connection(layout.fetch('source_anchor'))
222
+ target = connection(layout.fetch('target_anchor'))
223
+ changed = association['ParentConnection'] != source || association['ChildConnection'] != target
224
+ association['ParentConnection'] = source
225
+ association['ChildConnection'] = target
226
+ changed
227
+ end
228
+ doc[key] = IO::BsonCodec.build_array(associations)
229
+ metadata = cross_ids.filter_map do |id|
230
+ layout = by_id[id]
231
+ next unless layout
232
+
233
+ {
234
+ id:, source_anchor: anchor_name(layout.fetch('source_anchor')),
235
+ target_anchor: anchor_name(layout.fetch('target_anchor'))
236
+ }
237
+ end
238
+ [count, metadata]
239
+ end
240
+
241
+ def bounded_integer(value, name)
242
+ number = Integer(value)
243
+ raise ValidationError, "#{name} is outside the diagram canvas" unless number.between?(-100_000, 100_000)
244
+
245
+ number
246
+ end
247
+
248
+ def connection(anchor)
249
+ ANCHOR_VALUES.fetch(anchor_name(anchor))
250
+ end
251
+
252
+ def anchor_name(anchor)
253
+ ANCHOR_VALUES.fetch(anchor.to_s) do
254
+ raise ValidationError, "unknown association anchor #{anchor.inspect}"
255
+ end
256
+ anchor.to_s
257
+ end
258
+
259
+ def array(value) = IO::BsonCodec.parse_array(value)[:items]
260
+ end
261
+
262
+ # Loopback-only server for the standalone editor.
263
+ class Server
264
+ MAX_BODY_BYTES = 2_097_152
265
+ LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
266
+
267
+ attr_reader :source, :output, :host, :port, :managed_paths
268
+
269
+ def initialize(source, output: nil, modules: [], host: '127.0.0.1', port: 4568, force: false,
270
+ reuse: false, lifecycle_token: nil)
271
+ @source = File.expand_path(source)
272
+ raise ArgumentError, "MPR not found: #{@source}" unless File.file?(@source)
273
+
274
+ @output = File.expand_path(output || default_output)
275
+ raise ArgumentError, 'output must be a safe copy, not the source MPR' if same_file_as_source?
276
+ raise ArgumentError, "output cannot be a directory: #{@output}" if File.directory?(@output)
277
+ raise ArgumentError, "output cannot be a symbolic link: #{@output}" if File.symlink?(@output)
278
+
279
+ @host = host.to_s
280
+ raise ArgumentError, 'domain model server must bind to loopback' unless LOOPBACK_HOSTS.include?(@host)
281
+
282
+ @port = Integer(port)
283
+ @modules = Array(modules)
284
+ @token = SecureRandom.hex(24)
285
+ @lifecycle_token = lifecycle_token
286
+ @managed_paths = [@output]
287
+ prepare_output(force:, reuse:)
288
+ end
289
+
290
+ def start
291
+ @server = Http::Server.new(host:, port:) { dispatch(_1, _2) }
292
+ @server.start { yield(_1) if block_given? }
293
+ end
294
+
295
+ def shutdown = @server&.shutdown
296
+
297
+ private
298
+
299
+ def default_output
300
+ File.join(File.dirname(source), "#{File.basename(source, '.mpr')}.domain-layout.mpr")
301
+ end
302
+
303
+ def prepare_output(force:, reuse:)
304
+ if reuse
305
+ raise ArgumentError, "output not found: #{output}" unless File.file?(output)
306
+
307
+ return
308
+ end
309
+
310
+ raise ArgumentError, "output already exists: #{output}; use --force" if File.exist?(output) && !force
311
+
312
+ validate_external_contents_destination!
313
+ FileUtils.mkdir_p(File.dirname(output))
314
+ FileUtils.cp(source, output)
315
+ copy_external_contents
316
+ end
317
+
318
+ def same_file_as_source?
319
+ source == output || (File.exist?(output) && File.identical?(source, output))
320
+ end
321
+
322
+ def validate_external_contents_destination!
323
+ source_contents = File.join(File.dirname(source), 'mprcontents')
324
+ return unless File.directory?(source_contents)
325
+
326
+ output_directory = File.dirname(output)
327
+ return if File.expand_path(output_directory) == File.expand_path(File.dirname(source))
328
+
329
+ target = File.join(output_directory, 'mprcontents')
330
+ return unless File.exist?(target) || File.symlink?(target)
331
+
332
+ raise ArgumentError, "external contents destination already exists: #{target}"
333
+ end
334
+
335
+ def copy_external_contents
336
+ source_contents = File.join(File.dirname(source), 'mprcontents')
337
+ return unless File.directory?(source_contents)
338
+
339
+ output_directory = File.dirname(output)
340
+ return if File.expand_path(output_directory) == File.expand_path(File.dirname(source))
341
+
342
+ target = File.join(output_directory, 'mprcontents')
343
+ FileUtils.cp_r(source_contents, output_directory)
344
+ @managed_paths << target
345
+ end
346
+
347
+ def dispatch(request, response)
348
+ if request.request_method == 'GET' && request.path == '/'
349
+ return render(response, 200, WebUi.page('domain'), 'text/html; charset=utf-8')
350
+ end
351
+
352
+ if request.request_method == 'GET' && request.path.start_with?('/assets/')
353
+ asset = WebUi.asset(request.path)
354
+ return render(response, 200, *asset) if asset
355
+
356
+ return json(response, 404, ok: false, error: 'not_found')
357
+ end
358
+
359
+ if request.request_method == 'GET' && request.path == '/api/diagram'
360
+ payload = Document.new(output, modules: @modules).to_h.merge(
361
+ output: output, csrf_token: @token
362
+ )
363
+ return json(response, 200, payload)
364
+ end
365
+ if request.path.start_with?('/api/admin/')
366
+ return json(response, 403, ok: false, error: 'forbidden') unless lifecycle_authorized?(request)
367
+ return json(response, 200, ok: true) if request.request_method == 'GET' && request.path == '/api/admin/status'
368
+
369
+ if request.request_method == 'POST' && request.path == '/api/admin/shutdown'
370
+ Thread.new { shutdown }
371
+ return json(response, 200, ok: true)
372
+ end
373
+ return json(response, 404, ok: false, error: 'not_found')
374
+ end
375
+ if request.request_method == 'POST' && request.path == '/api/layout'
376
+ raise ValidationError, 'invalid editor token' unless request['X-MXRB-Token'].to_s == @token
377
+
378
+ body = request.body.to_s
379
+ raise ValidationError, 'layout exceeds 2 MiB' if body.bytesize > MAX_BODY_BYTES
380
+
381
+ changed = LayoutWriter.new(output).apply!(JSON.parse(body))
382
+ return json(response, 200, ok: true, changed:, output:)
383
+ end
384
+ return json(response, 404, ok: false, error: 'not_found') if request.path.start_with?('/api/')
385
+
386
+ json(response, 404, ok: false, error: 'not_found')
387
+ rescue JSON::ParserError, ValidationError, KeyError, ArgumentError => e
388
+ json(response, 422, ok: false, error: e.message)
389
+ end
390
+
391
+ def lifecycle_authorized?(request)
392
+ @lifecycle_token && request['X-MXRB-Lifecycle-Token'].to_s == @lifecycle_token
393
+ end
394
+
395
+ def json(response, status, payload)
396
+ render(response, status, JSON.generate(payload), 'application/json; charset=utf-8')
397
+ end
398
+
399
+ def render(response, status, body, type)
400
+ response.status = status
401
+ response['Content-Type'] = type
402
+ response['Cache-Control'] = 'no-store'
403
+ response['X-Content-Type-Options'] = 'nosniff'
404
+ response.body = body
405
+ end
406
+ end
407
+ end
408
+ # rubocop:enable Metrics
409
+ end
@@ -85,8 +85,16 @@ module Mxrb
85
85
  _add_widget(:text, name, caption: caption || name.to_s, &block)
86
86
  end
87
87
 
88
- def data_grid(name, entity: nil, &block)
89
- _add_widget(:data_grid, name, entity: entity, &block)
88
+ def data_grid(name, entity: nil, selection: nil, &block)
89
+ _add_widget(:data_grid, name, entity: entity, selection: selection, &block)
90
+ end
91
+
92
+ # Gallery projections remain present beside an authoritative page
93
+ # deep_structure in exported projects. Accept the concise projection at
94
+ # every widget nesting level so arbitrary native pages can be evaluated
95
+ # and reconstructed from their lossless payload.
96
+ def gallery(name, entity: nil, &block)
97
+ _add_widget(:gallery, name, entity: entity, &block)
90
98
  end
91
99
 
92
100
  def tab_control(name, &block)
@@ -157,6 +165,8 @@ module Mxrb
157
165
 
158
166
  # Builds sub-widgets inside a data_grid column sub-items or inside a container.
159
167
  class WidgetBuilder
168
+ include WidgetDsl
169
+
160
170
  def initialize(type, name, **options)
161
171
  @type = type
162
172
  @name = name.to_s
@@ -166,12 +176,18 @@ module Mxrb
166
176
  @tabs = []
167
177
  @search_bar = nil
168
178
  @toolbar = nil
179
+ @children = []
180
+ @filters = []
169
181
  end
170
182
 
171
- def column(name, attribute: nil, caption: nil)
172
- @columns << { name: name.to_s, attribute: attribute&.to_s, caption: caption }.compact
183
+ def column(name, attribute: nil, caption: nil, filter: nil)
184
+ @columns << {
185
+ name: name.to_s, attribute: attribute&.to_s, caption:, filter:
186
+ }.compact
173
187
  end
174
188
 
189
+ def filter(widget) = @filters << widget
190
+
175
191
  def tab_page(name, caption: nil, &block)
176
192
  tab = TabPageBuilder.new(name, caption: caption)
177
193
  tab.instance_eval(&block) if block
@@ -191,8 +207,8 @@ module Mxrb
191
207
  end
192
208
 
193
209
  %i[on_change on_click on_enter on_leave].each do |event|
194
- define_method(event) do |microflow: nil, nanoflow: nil, action: nil|
195
- choices = { microflow: microflow, nanoflow: nanoflow, action: action }.compact
210
+ define_method(event) do |microflow: nil, nanoflow: nil, page: nil, action: nil|
211
+ choices = { microflow: microflow, nanoflow: nanoflow, page: page, action: action }.compact
196
212
  raise ArgumentError, "#{event} requires exactly one handler" unless choices.size == 1
197
213
  @events << { event: event, kind: choices.keys.first, handler: choices.values.first.to_s }
198
214
  end
@@ -204,8 +220,15 @@ module Mxrb
204
220
  options[:tabs] = @tabs unless @tabs.empty?
205
221
  options[:search_bar] = @search_bar if @search_bar
206
222
  options[:toolbar] = @toolbar if @toolbar
207
- { type: @type, name: @name, options: options, events: @events }
223
+ options[:filters] = @filters unless @filters.empty?
224
+ value = { type: @type, name: @name, options: options, events: @events }
225
+ value[:children] = @children unless @children.empty?
226
+ value
208
227
  end
228
+
229
+ private
230
+
231
+ def _widget_list = @children
209
232
  end
210
233
 
211
234
  class TabPageBuilder
@@ -315,6 +338,8 @@ module Mxrb
315
338
  class Builder
316
339
  include ConnectorDeclarations
317
340
 
341
+ attr_reader :path
342
+
318
343
  def initialize(path)
319
344
  @path = path
320
345
  @mendix_version = "10.18.0"
@@ -325,6 +350,7 @@ module Mxrb
325
350
  @native_units_path = nil
326
351
  @native_unit_overrides = []
327
352
  @project_assets = nil
353
+ @ruby_app_sources_path = nil
328
354
  end
329
355
 
330
356
  def mendix_version(v)
@@ -366,6 +392,10 @@ module Mxrb
366
392
  }
367
393
  end
368
394
 
395
+ def ruby_app_sources(path)
396
+ @ruby_app_sources_path = File.expand_path(path)
397
+ end
398
+
369
399
  def native_unit(unit_id, container_id:, containment:, deep_structure:, module_name: nil)
370
400
  raise ArgumentError, "deep_structure requires a Hash" unless deep_structure.is_a?(Hash)
371
401
 
@@ -421,6 +451,7 @@ module Mxrb
421
451
  design_system: @design_system,
422
452
  connectors: connector_requests.map(&:to_h),
423
453
  project_assets: @project_assets,
454
+ ruby_app_sources_path: @ruby_app_sources_path,
424
455
  native_units_path: @native_units_path,
425
456
  native_unit_overrides: @native_unit_overrides
426
457
  }
@@ -872,15 +903,21 @@ module Mxrb
872
903
  @scheduled_events << sb.to_h
873
904
  end
874
905
 
875
- def native_document(name, type:, deep_structure:, containment: 'Documents')
906
+ def native_document(name, type:, deep_structure:, containment: 'Documents',
907
+ unit_id: nil, container_id: nil)
876
908
  raise ArgumentError, 'deep_structure requires a Hash' unless deep_structure.is_a?(Hash)
877
909
 
878
910
  @native_documents << {
879
911
  name: name.to_s, type: type.to_s, containment: containment.to_s,
912
+ unit_id: unit_id&.to_s, container_id: container_id&.to_s,
880
913
  doc: { '$Type' => type.to_s, 'Name' => name.to_s }.merge(deep_structure)
881
914
  }
882
915
  end
883
916
 
917
+ def bson_binary(base64, subtype: :generic)
918
+ BSON::Binary.new(Base64.strict_decode64(base64), subtype.to_sym)
919
+ end
920
+
884
921
  # Native responsive application shell. It deliberately uses only Mendix
885
922
  # core widgets, so generated projects do not depend on Atlas Core merely
886
923
  # to display their navigation.
@@ -1070,6 +1107,7 @@ module Mxrb
1070
1107
  @generalization = nil
1071
1108
  @system_members = nil
1072
1109
  @indexes = nil
1110
+ @oql_view = nil
1073
1111
  end
1074
1112
 
1075
1113
  ATTR_TYPES.each do |type|
@@ -1081,6 +1119,16 @@ module Mxrb
1081
1119
  def non_persistent! = (@persistable = false)
1082
1120
  def documentation(d) = (@doc = d)
1083
1121
 
1122
+ # Marks an entity as an OQL-backed view. Modern Mendix projects keep the
1123
+ # query in a separate ViewEntitySourceDocument; older projects may keep
1124
+ # it directly on the embedded entity.
1125
+ def oql_view(source: nil, query: nil)
1126
+ raise ArgumentError, 'oql_view requires source or query' if source.nil? && query.nil?
1127
+
1128
+ @oql_view = { source: source&.to_s, query: query&.to_s }.compact
1129
+ @persistable = false
1130
+ end
1131
+
1084
1132
  def generalizes(entity)
1085
1133
  @generalization = entity.to_s
1086
1134
  end
@@ -1164,7 +1212,7 @@ module Mxrb
1164
1212
  name: @name, persistable: @persistable, documentation: @doc,
1165
1213
  attributes: @attributes, associations: @associations, lifecycle: @lifecycle,
1166
1214
  access_rules: @access_rules, generalization: @generalization,
1167
- system_members: @system_members, indexes: @indexes
1215
+ system_members: @system_members, indexes: @indexes, oql_view: @oql_view
1168
1216
  }
1169
1217
  end
1170
1218
 
@@ -1230,8 +1278,8 @@ module Mxrb
1230
1278
  end
1231
1279
 
1232
1280
  %i[on_change on_click on_submit on_load].each do |event|
1233
- define_method(event) do |target: nil, microflow: nil, nanoflow: nil, action: nil|
1234
- choices = { microflow: microflow, nanoflow: nanoflow, action: action }.compact
1281
+ define_method(event) do |target: nil, microflow: nil, nanoflow: nil, page: nil, action: nil|
1282
+ choices = { microflow: microflow, nanoflow: nanoflow, page: page, action: action }.compact
1235
1283
  raise ArgumentError, "#{event} requires exactly one handler" unless choices.size == 1
1236
1284
  @events << {
1237
1285
  event: event, target: target&.to_s,
@@ -1506,6 +1554,7 @@ module Mxrb
1506
1554
  @return_expression = nil
1507
1555
  @expected_body_fingerprint = nil
1508
1556
  @allow_concurrent_execution = nil
1557
+ @apply_entity_access = nil
1509
1558
  @mark_as_used = nil
1510
1559
  @excluded = nil
1511
1560
  end
@@ -1521,6 +1570,7 @@ module Mxrb
1521
1570
  def return_type(t) = (@return_type = t.to_s)
1522
1571
  def documentation(d) = (@doc = d)
1523
1572
  def allow_concurrent_execution(value = true) = (@allow_concurrent_execution = !!value)
1573
+ def apply_entity_access(value = true) = (@apply_entity_access = !!value)
1524
1574
  def mark_as_used(value = true) = (@mark_as_used = !!value)
1525
1575
  def excluded(value = true) = (@excluded = !!value)
1526
1576
 
@@ -1586,6 +1636,7 @@ module Mxrb
1586
1636
  body: @body, return_variable_name: @return_variable_name,
1587
1637
  return_expression: @return_expression,
1588
1638
  allow_concurrent_execution: @allow_concurrent_execution,
1639
+ apply_entity_access: @apply_entity_access,
1589
1640
  mark_as_used: @mark_as_used, excluded: @excluded,
1590
1641
  preserve_native_body: !@expected_body_fingerprint.nil? &&
1591
1642
  @expected_body_fingerprint == current_fingerprint