mxrb 0.1.0

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 (237) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.de-DE.md +108 -0
  4. data/README.md +172 -0
  5. data/README.pt-BR.md +109 -0
  6. data/bin/mxrb +1613 -0
  7. data/docs/README.md +10 -0
  8. data/docs/de-DE/README.md +24 -0
  9. data/docs/de-DE/architectural-patterns.md +26 -0
  10. data/docs/de-DE/architectural-standard.md +48 -0
  11. data/docs/de-DE/architecture.md +76 -0
  12. data/docs/de-DE/compiler.md +162 -0
  13. data/docs/de-DE/conventions.md +19 -0
  14. data/docs/de-DE/design-system.md +52 -0
  15. data/docs/de-DE/entity-dsl.md +53 -0
  16. data/docs/de-DE/native-runtime-quality-report.md +74 -0
  17. data/docs/de-DE/oql-sql.md +211 -0
  18. data/docs/de-DE/platform-api-integration.md +36 -0
  19. data/docs/de-DE/platform-operations.md +243 -0
  20. data/docs/de-DE/project-structure.md +32 -0
  21. data/docs/de-DE/ruby-first-roadmap.md +116 -0
  22. data/docs/de-DE/scaffolds.md +59 -0
  23. data/docs/de-DE/semantic-refactoring.md +56 -0
  24. data/docs/de-DE/team-server.md +33 -0
  25. data/docs/de-DE/validation-matrix.md +64 -0
  26. data/docs/de-DE/vetclinic-acceptance.md +24 -0
  27. data/docs/de-DE/writing.md +138 -0
  28. data/docs/en-US/README.md +24 -0
  29. data/docs/en-US/architectural-patterns.md +151 -0
  30. data/docs/en-US/architectural-standard.md +89 -0
  31. data/docs/en-US/architecture.md +170 -0
  32. data/docs/en-US/compiler.md +157 -0
  33. data/docs/en-US/conventions.md +82 -0
  34. data/docs/en-US/design-system.md +163 -0
  35. data/docs/en-US/entity-dsl.md +53 -0
  36. data/docs/en-US/native-runtime-quality-report.md +73 -0
  37. data/docs/en-US/oql-sql.md +201 -0
  38. data/docs/en-US/platform-api-integration.md +36 -0
  39. data/docs/en-US/platform-operations.md +244 -0
  40. data/docs/en-US/project-structure.md +49 -0
  41. data/docs/en-US/ruby-first-roadmap.md +186 -0
  42. data/docs/en-US/scaffolds.md +57 -0
  43. data/docs/en-US/semantic-refactoring.md +198 -0
  44. data/docs/en-US/team-server.md +32 -0
  45. data/docs/en-US/validation-matrix.md +257 -0
  46. data/docs/en-US/vetclinic-acceptance.md +23 -0
  47. data/docs/en-US/writing.md +655 -0
  48. data/docs/pt-BR/README.md +24 -0
  49. data/docs/pt-BR/architectural-patterns.md +25 -0
  50. data/docs/pt-BR/architectural-standard.md +533 -0
  51. data/docs/pt-BR/architecture.md +300 -0
  52. data/docs/pt-BR/compiler.md +162 -0
  53. data/docs/pt-BR/conventions.md +19 -0
  54. data/docs/pt-BR/design-system.md +65 -0
  55. data/docs/pt-BR/entity-dsl.md +116 -0
  56. data/docs/pt-BR/native-runtime-quality-report.md +73 -0
  57. data/docs/pt-BR/oql-sql.md +208 -0
  58. data/docs/pt-BR/platform-api-integration.md +36 -0
  59. data/docs/pt-BR/platform-operations.md +260 -0
  60. data/docs/pt-BR/project-structure.md +32 -0
  61. data/docs/pt-BR/ruby-first-roadmap.md +239 -0
  62. data/docs/pt-BR/scaffolds.md +104 -0
  63. data/docs/pt-BR/semantic-refactoring.md +55 -0
  64. data/docs/pt-BR/team-server.md +59 -0
  65. data/docs/pt-BR/validation-matrix.md +86 -0
  66. data/docs/pt-BR/vetclinic-acceptance.md +50 -0
  67. data/docs/pt-BR/writing.md +177 -0
  68. data/examples/sudoku_evaluation.rb +14 -0
  69. data/examples/sudoku_functional_test.rb +28 -0
  70. data/lib/mxrb/architecture/graph.rb +124 -0
  71. data/lib/mxrb/architecture/validator.rb +98 -0
  72. data/lib/mxrb/benchmark.rb +33 -0
  73. data/lib/mxrb/compare.rb +390 -0
  74. data/lib/mxrb/compiler/adapter.rb +96 -0
  75. data/lib/mxrb/compiler/artifact_document_compiler.rb +102 -0
  76. data/lib/mxrb/compiler/artifact_materializer.rb +53 -0
  77. data/lib/mxrb/compiler/client_model_materializer.rb +42 -0
  78. data/lib/mxrb/compiler/code_action_document_compiler.rb +64 -0
  79. data/lib/mxrb/compiler/code_action_materializer.rb +35 -0
  80. data/lib/mxrb/compiler/code_action_type_compiler.rb +32 -0
  81. data/lib/mxrb/compiler/combo_box_bundle_compiler.rb +314 -0
  82. data/lib/mxrb/compiler/compatibility_analyzer.rb +169 -0
  83. data/lib/mxrb/compiler/constants_materializer.rb +111 -0
  84. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +290 -0
  85. data/lib/mxrb/compiler/database_connector_action_compiler.rb +280 -0
  86. data/lib/mxrb/compiler/deployment_asset_copier.rb +60 -0
  87. data/lib/mxrb/compiler/deployment_bootstrapper.rb +208 -0
  88. data/lib/mxrb/compiler/deployment_materializer.rb +43 -0
  89. data/lib/mxrb/compiler/deployment_metadata.rb +64 -0
  90. data/lib/mxrb/compiler/domain_document_compiler.rb +153 -0
  91. data/lib/mxrb/compiler/domain_model_materializer.rb +35 -0
  92. data/lib/mxrb/compiler/domain_security_compiler.rb +82 -0
  93. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +190 -0
  94. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +281 -0
  95. data/lib/mxrb/compiler/image_bundle_compiler.rb +216 -0
  96. data/lib/mxrb/compiler/java_proxy_generator.rb +509 -0
  97. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +267 -0
  98. data/lib/mxrb/compiler/legacy_page_builder.rb +287 -0
  99. data/lib/mxrb/compiler/mda.rb +99 -0
  100. data/lib/mxrb/compiler/microflow_document_compiler.rb +85 -0
  101. data/lib/mxrb/compiler/microflow_materializer.rb +48 -0
  102. data/lib/mxrb/compiler/microflow_node_compiler.rb +250 -0
  103. data/lib/mxrb/compiler/model_package.rb +116 -0
  104. data/lib/mxrb/compiler/model_values.rb +67 -0
  105. data/lib/mxrb/compiler/nanoflow_program_compiler.rb +483 -0
  106. data/lib/mxrb/compiler/navigation_document_compiler.rb +93 -0
  107. data/lib/mxrb/compiler/packager.rb +100 -0
  108. data/lib/mxrb/compiler/page_bundle_builder.rb +56 -0
  109. data/lib/mxrb/compiler/page_bundle_compiler.rb +2004 -0
  110. data/lib/mxrb/compiler/page_document_compiler.rb +77 -0
  111. data/lib/mxrb/compiler/portable_packager.rb +356 -0
  112. data/lib/mxrb/compiler/project_jar_archive.rb +84 -0
  113. data/lib/mxrb/compiler/project_jar_builder.rb +121 -0
  114. data/lib/mxrb/compiler/project_materializer.rb +139 -0
  115. data/lib/mxrb/compiler/project_model_orderer.rb +52 -0
  116. data/lib/mxrb/compiler/runtime_data_types.rb +31 -0
  117. data/lib/mxrb/compiler/runtime_model_schema.rb +90 -0
  118. data/lib/mxrb/compiler/schemas/runtime-10.24.0.73019.json +1052 -0
  119. data/lib/mxrb/compiler/schemas/runtime-11.json +1215 -0
  120. data/lib/mxrb/compiler/schemas/runtime-6.10.8.json +1148 -0
  121. data/lib/mxrb/compiler/schemas/runtime-7.17.0.json +1144 -0
  122. data/lib/mxrb/compiler/schemas/runtime-7.5.0.json +1215 -0
  123. data/lib/mxrb/compiler/schemas/runtime-9.6.1.29396.json +868 -0
  124. data/lib/mxrb/compiler/schemas/system-model-10.24.0.73019.b64 +1135 -0
  125. data/lib/mxrb/compiler/schemas/system-model-11.12.1.b64 +815 -0
  126. data/lib/mxrb/compiler/schemas/system-model-6.10.8.b64 +497 -0
  127. data/lib/mxrb/compiler/schemas/system-model-7.17.0.b64 +519 -0
  128. data/lib/mxrb/compiler/schemas/system-model-7.5.0.b64 +504 -0
  129. data/lib/mxrb/compiler/schemas/system-model-9.6.1.29396.b64 +816 -0
  130. data/lib/mxrb/compiler/security_materializer.rb +104 -0
  131. data/lib/mxrb/compiler/settings_document_compiler.rb +45 -0
  132. data/lib/mxrb/compiler/settings_materializer.rb +25 -0
  133. data/lib/mxrb/compiler/source_model.rb +128 -0
  134. data/lib/mxrb/compiler/system_model_seed.rb +88 -0
  135. data/lib/mxrb/compiler/system_queue_materializer.rb +54 -0
  136. data/lib/mxrb/compiler/system_text_materializer.rb +49 -0
  137. data/lib/mxrb/compiler/translation_materializer.rb +95 -0
  138. data/lib/mxrb/compiler/web_bundle_builder.rb +155 -0
  139. data/lib/mxrb/compiler/web_list_data_source.rb +139 -0
  140. data/lib/mxrb/compiler/web_operation_compiler.rb +614 -0
  141. data/lib/mxrb/compiler/web_shell_materializer.rb +194 -0
  142. data/lib/mxrb/compiler/widget_package_extractor.rb +63 -0
  143. data/lib/mxrb/doctor.rb +116 -0
  144. data/lib/mxrb/dsl/builder.rb +1636 -0
  145. data/lib/mxrb/errors.rb +18 -0
  146. data/lib/mxrb/evaluation.rb +131 -0
  147. data/lib/mxrb/exporter.rb +1716 -0
  148. data/lib/mxrb/frontend/migrator.rb +775 -0
  149. data/lib/mxrb/functional.rb +307 -0
  150. data/lib/mxrb/github/annotator.rb +161 -0
  151. data/lib/mxrb/initializer.rb +257 -0
  152. data/lib/mxrb/integrity/validator.rb +136 -0
  153. data/lib/mxrb/io/bson_codec.rb +145 -0
  154. data/lib/mxrb/io/mpr_file.rb +558 -0
  155. data/lib/mxrb/io/mxunit_codec.rb +42 -0
  156. data/lib/mxrb/marketplace.rb +380 -0
  157. data/lib/mxrb/model/association.rb +98 -0
  158. data/lib/mxrb/model/attribute.rb +100 -0
  159. data/lib/mxrb/model/connector.rb +17 -0
  160. data/lib/mxrb/model/design_materializer.rb +133 -0
  161. data/lib/mxrb/model/design_migration.rb +126 -0
  162. data/lib/mxrb/model/design_system.rb +111 -0
  163. data/lib/mxrb/model/domain_model.rb +51 -0
  164. data/lib/mxrb/model/entity.rb +180 -0
  165. data/lib/mxrb/model/menu.rb +38 -0
  166. data/lib/mxrb/model/microflow.rb +100 -0
  167. data/lib/mxrb/model/module.rb +142 -0
  168. data/lib/mxrb/model/navigation.rb +127 -0
  169. data/lib/mxrb/model/page.rb +410 -0
  170. data/lib/mxrb/model/project.rb +187 -0
  171. data/lib/mxrb/model/unit.rb +48 -0
  172. data/lib/mxrb/module_initializer.rb +75 -0
  173. data/lib/mxrb/official_marketplace/content_api.rb +292 -0
  174. data/lib/mxrb/official_marketplace/dependency_resolver.rb +329 -0
  175. data/lib/mxrb/official_marketplace/lifecycle.rb +367 -0
  176. data/lib/mxrb/official_marketplace/module_package_importer.rb +318 -0
  177. data/lib/mxrb/official_marketplace/widget_package_installer.rb +414 -0
  178. data/lib/mxrb/official_marketplace.rb +720 -0
  179. data/lib/mxrb/oql/analyzer.rb +155 -0
  180. data/lib/mxrb/oql/index_advisor.rb +85 -0
  181. data/lib/mxrb/oql/plan_analyzer.rb +185 -0
  182. data/lib/mxrb/oql/server.rb +132 -0
  183. data/lib/mxrb/oql/sql_server_plan_analyzer.rb +190 -0
  184. data/lib/mxrb/oql/sql_server_workload_analyzer.rb +80 -0
  185. data/lib/mxrb/oql/workload_analyzer.rb +175 -0
  186. data/lib/mxrb/oql/workload_baseline.rb +60 -0
  187. data/lib/mxrb/oql.rb +424 -0
  188. data/lib/mxrb/project_lifecycle.rb +76 -0
  189. data/lib/mxrb/protocols/plan.rb +86 -0
  190. data/lib/mxrb/protocols.rb +123 -0
  191. data/lib/mxrb/runtime/database_workspace.rb +677 -0
  192. data/lib/mxrb/runtime/docker_executor.rb +41 -0
  193. data/lib/mxrb/runtime/docker_workspace.rb +29 -0
  194. data/lib/mxrb/runtime/executor.rb +169 -0
  195. data/lib/mxrb/runtime/java_locator.rb +26 -0
  196. data/lib/mxrb/runtime/native.rb +474 -0
  197. data/lib/mxrb/runtime/sql_server_database.rb +146 -0
  198. data/lib/mxrb/runtime/toolchain.rb +82 -0
  199. data/lib/mxrb/scaffold/cli.rb +113 -0
  200. data/lib/mxrb/scaffold/generator.rb +246 -0
  201. data/lib/mxrb/scaffold/help.rb +80 -0
  202. data/lib/mxrb/scaffold/page_templates.rb +54 -0
  203. data/lib/mxrb/scaffold/recipes.rb +198 -0
  204. data/lib/mxrb/scaffold/registry.rb +82 -0
  205. data/lib/mxrb/scaffold/templates.rb +723 -0
  206. data/lib/mxrb/scaffold/transaction.rb +84 -0
  207. data/lib/mxrb/schema/tables.rb +55 -0
  208. data/lib/mxrb/semantic/analyzer.rb +473 -0
  209. data/lib/mxrb/semantic/batch_plan.rb +68 -0
  210. data/lib/mxrb/semantic/domain_mutator.rb +419 -0
  211. data/lib/mxrb/semantic/embedder.rb +51 -0
  212. data/lib/mxrb/semantic/extractor.rb +290 -0
  213. data/lib/mxrb/semantic/index.rb +623 -0
  214. data/lib/mxrb/semantic/inliner.rb +216 -0
  215. data/lib/mxrb/semantic/mover.rb +153 -0
  216. data/lib/mxrb/semantic/onnx_embedder.rb +34 -0
  217. data/lib/mxrb/semantic/remover.rb +70 -0
  218. data/lib/mxrb/semantic/renamer.rb +211 -0
  219. data/lib/mxrb/semantic/tfidf_embedder.rb +46 -0
  220. data/lib/mxrb/semantic/vec_store.rb +75 -0
  221. data/lib/mxrb/team_server.rb +390 -0
  222. data/lib/mxrb/templates/project/10.24.0.73019.json +32 -0
  223. data/lib/mxrb/templates/project/11.12.1.json +32 -0
  224. data/lib/mxrb/templates/project/6.10.8.json +50 -0
  225. data/lib/mxrb/templates/project/7.17.0.json +50 -0
  226. data/lib/mxrb/templates/project/7.5.0.json +50 -0
  227. data/lib/mxrb/templates/project/9.6.1.29396.json +50 -0
  228. data/lib/mxrb/version.rb +5 -0
  229. data/lib/mxrb/widget_package.rb +338 -0
  230. data/lib/mxrb/widget_synchronizer.rb +31 -0
  231. data/lib/mxrb/writer.rb +3775 -0
  232. data/lib/mxrb.rb +188 -0
  233. data/marketplace/catalog.json +10 -0
  234. data/marketplace/modules/shared-kernel/domain/README.md +4 -0
  235. data/marketplace/modules/shared-kernel/module.rb +5 -0
  236. data/marketplace/modules/shared-kernel/mxrb-module.json +9 -0
  237. metadata +447 -0
@@ -0,0 +1,3775 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "json"
7
+ require "securerandom"
8
+ require "sqlite3"
9
+
10
+ module Mxrb
11
+ # Applies a DSL definition to a new or existing MPR. Names are used as the
12
+ # stable key, making repeated `mxrb generate` runs idempotent.
13
+ class Writer
14
+ GLYPH_ICON_CODES = {
15
+ 'home' => 57_377, 'pets' => 57_349, 'heart' => 57_349,
16
+ 'calendar' => 57_609, 'calendar_today' => 57_609,
17
+ 'user' => 57_352, 'search' => 57_347, 'settings' => 57_369,
18
+ 'trash' => 57_376, 'file' => 57_378, 'time' => 57_379,
19
+ 'shopping_cart' => 57_622
20
+ }.freeze
21
+
22
+ LEGACY_NAVIGATION_PROFILES = {
23
+ "Desktop" => "DesktopProfile",
24
+ "Tablet" => "TabletProfile",
25
+ "Phone" => "PhoneProfile",
26
+ "OfflinePhone" => "OfflinePhoneProfile",
27
+ "HybridPhone" => "HybridPhoneProfile6",
28
+ "HybridTablet" => "HybridTabletProfile6"
29
+ }.freeze
30
+
31
+ def initialize(path, definition)
32
+ @path = File.expand_path(path)
33
+ @definition = definition
34
+ end
35
+
36
+ def write!
37
+ create_project! unless File.exist?(@path)
38
+ mpr = IO::MprFile.open(@path)
39
+ mpr.transaction do
40
+ apply(mpr)
41
+ mpr.write_architecture_definition(@definition)
42
+ end
43
+ materialize_project_assets
44
+ materialize_design_system
45
+ self
46
+ ensure
47
+ mpr&.close
48
+ end
49
+
50
+ private
51
+
52
+ def materialize_design_system
53
+ design_system = @definition[:design_system]
54
+ return unless design_system
55
+
56
+ Model::DesignMaterializer.new(File.dirname(@path), design_system).materialize!
57
+ end
58
+
59
+ def materialize_project_assets
60
+ assets = @definition[:project_assets]
61
+ return unless assets
62
+
63
+ manifest = JSON.parse(File.read(assets.fetch(:manifest)))
64
+ source_root = File.realpath(assets.fetch(:root))
65
+ target_root = File.dirname(@path)
66
+ manifest.fetch("files").each do |entry|
67
+ temporary = nil
68
+ relative = safe_asset_path(entry.fetch("path"))
69
+ source = File.join(source_root, relative)
70
+ target = File.join(target_root, relative)
71
+ raise SerializationError, "project asset is missing: #{relative}" unless File.file?(source)
72
+ raise SerializationError, "project asset checksum mismatch: #{relative}" unless
73
+ Digest::SHA256.file(source).hexdigest == entry.fetch("sha256")
74
+ next if File.expand_path(source) == File.expand_path(target)
75
+
76
+ FileUtils.mkdir_p(File.dirname(target))
77
+ temporary = "#{target}.mxrb-#{Process.pid}"
78
+ FileUtils.cp(source, temporary)
79
+ FileUtils.mv(temporary, target)
80
+ ensure
81
+ FileUtils.rm_f(temporary) if temporary
82
+ end
83
+ end
84
+
85
+ def safe_asset_path(value)
86
+ path = Pathname.new(value.to_s)
87
+ clean = path.cleanpath.to_s
88
+ if path.absolute? || clean == ".." || clean.start_with?("../")
89
+ raise SerializationError, "unsafe project asset path: #{value}"
90
+ end
91
+
92
+ clean
93
+ end
94
+
95
+ def create_project!
96
+ FileUtils.mkdir_p(File.dirname(@path))
97
+ v2 = native_format_version == "v2"
98
+ db = SQLite3::Database.new(@path)
99
+ if v2
100
+ db.execute(<<~SQL)
101
+ CREATE TABLE _MetaData (
102
+ _FormatVersion INTEGER, _ProductVersion TEXT,
103
+ _BuildVersion TEXT, _SchemaHash TEXT
104
+ )
105
+ SQL
106
+ db.execute(<<~SQL)
107
+ CREATE TABLE Unit (
108
+ UnitID BLOB PRIMARY KEY NOT NULL, ContainerID BLOB,
109
+ ContainmentName TEXT, TreeConflict LONG,
110
+ ContentsHash TEXT, ContentsConflicts TEXT
111
+ )
112
+ SQL
113
+ db.execute(
114
+ "INSERT INTO _MetaData VALUES (2, ?, ?, '')",
115
+ [@definition.fetch(:version), @definition.fetch(:version)]
116
+ )
117
+ else
118
+ db.execute("CREATE TABLE _MetaData (_ProductVersion TEXT, _BuildVersion TEXT, _SchemaHash TEXT)")
119
+ db.execute(<<~SQL)
120
+ CREATE TABLE Unit (
121
+ UnitID BLOB PRIMARY KEY NOT NULL, ContainerID BLOB,
122
+ ContainmentName TEXT, TreeConflict LONG,
123
+ ContentsHash TEXT, ContentsConflicts TEXT, Contents BLOB
124
+ )
125
+ SQL
126
+ db.execute(
127
+ "INSERT INTO _MetaData VALUES (?, ?, '')",
128
+ [@definition.fetch(:version), @definition.fetch(:version)]
129
+ )
130
+ end
131
+ root_id = SecureRandom.uuid
132
+ doc = {
133
+ "$ID" => root_id,
134
+ "$Type" => "Projects$Project",
135
+ "IsSystemProject" => false
136
+ }
137
+ bytes = IO::BsonCodec.serialize(doc)
138
+ blob = IO::BsonCodec.uuid_to_blob(root_id)
139
+ contents_dir = File.join(File.dirname(@path), "mprcontents")
140
+ FileUtils.mkdir_p(contents_dir) if v2
141
+ if v2
142
+ db.execute(
143
+ "INSERT INTO Unit VALUES (?, ?, '', 0, ?, '')",
144
+ [blob, blob, IO::BsonCodec.contents_hash(bytes)]
145
+ )
146
+ else
147
+ db.execute(
148
+ "INSERT INTO Unit VALUES (?, ?, '', 0, ?, '', ?)",
149
+ [blob, blob, IO::BsonCodec.contents_hash(bytes), bytes]
150
+ )
151
+ end
152
+ IO::MxunitCodec.write_atomic(IO::MxunitCodec.path_for(contents_dir, root_id), bytes) if v2
153
+ ensure
154
+ db&.close
155
+ end
156
+
157
+ def native_format_version
158
+ path = @definition[:native_units_path]
159
+ return nil if path.to_s.empty? || !File.file?(path)
160
+
161
+ JSON.parse(File.read(path))["format_version"]
162
+ end
163
+
164
+ def apply(mpr)
165
+ root = mpr.root_unit
166
+ root_id = root.fetch("UnitID")
167
+ root_doc = mpr.parse_contents(root)
168
+ root_doc = {
169
+ "$ID" => root_doc["$ID"] || root_id,
170
+ "$Type" => "Projects$Project",
171
+ "IsSystemProject" => false
172
+ }
173
+ mpr.update_unit(root_id, root_doc)
174
+ native_units = load_native_units(@definition[:native_units_path])
175
+ native_units = apply_native_unit_overrides(
176
+ native_units, @definition.fetch(:native_unit_overrides, [])
177
+ )
178
+ apply_native_project_units(mpr, root_id, native_units)
179
+ apply_default_project_units(mpr, root_id)
180
+ ensure_project_documents(mpr, root_id)
181
+ @definition.fetch(:modules).each_with_index do |mod, index|
182
+ raw_module = find_named(mpr, "Modules", root_id, mod.fetch(:name))
183
+ existing_module = raw_module ? mpr.parse_contents(raw_module) : native_module_doc(native_units, mod.fetch(:name))
184
+ module_doc = module_doc(mod.fetch(:name), index, previous: existing_module)
185
+ module_id = raw_module&.fetch("UnitID") || mpr.insert_unit(
186
+ container_uuid: root_id,
187
+ containment_name: "Modules",
188
+ contents_doc: module_doc
189
+ )
190
+
191
+ mpr.update_unit(module_id, module_doc) if raw_module
192
+ apply_native_module_units(mpr, module_id, mod.fetch(:name), native_units)
193
+ write_native_documents(mpr, module_id, mod)
194
+ write_module_security(mpr, module_id, mod) if mod.key?(:module_roles)
195
+ write_domain_model(mpr, module_id, mod)
196
+ write_documents(mpr, module_id, mod)
197
+ end
198
+ write_project_security(mpr, root_id, @definition[:security]) if @definition[:security]
199
+ write_project_navigation(mpr, root_id, @definition[:navigation]) if @definition[:navigation]
200
+ end
201
+
202
+ def load_native_units(path)
203
+ return [] if path.to_s.empty? || !File.file?(path)
204
+
205
+ JSON.parse(File.read(path)).fetch("units", []).map do |unit|
206
+ unit.merge("doc" => IO::BsonCodec.parse(Base64.strict_decode64(unit.fetch("contents"))))
207
+ end
208
+ end
209
+
210
+ def apply_native_unit_overrides(native_units, overrides)
211
+ by_id = native_units.to_h { [_1["unit_id"], _1] }
212
+ overrides.each do |override|
213
+ attributes = override.transform_keys(&:to_s)
214
+ unit_id = attributes.fetch("unit_id")
215
+ current = by_id[unit_id]
216
+ replacement = (current || {}).merge(attributes)
217
+ replacement["name"] =
218
+ replacement.fetch("doc")["Name"] || replacement.fetch("doc")["name"] || ""
219
+ replacement["type"] = replacement.fetch("doc")["$Type"]
220
+ if current
221
+ current.replace(replacement)
222
+ else
223
+ native_units << replacement
224
+ by_id[unit_id] = replacement
225
+ end
226
+ end
227
+ native_units
228
+ end
229
+
230
+ def apply_native_project_units(mpr, root_id, native_units)
231
+ units = native_units.select { _1["module"].nil? && _1["containment"] != "Modules" }
232
+ apply_native_unit_tree(mpr, root_id, units)
233
+ end
234
+
235
+ def ensure_project_documents(mpr, root_id)
236
+ documents = [
237
+ ['ProjectDocuments', modern_navigation_doc({}, profiles: [])],
238
+ ['ProjectDocuments', project_security_doc({})]
239
+ ]
240
+ existing_types = mpr.children_of(root_id).map { mpr.parse_contents(_1)['$Type'] }
241
+ documents.each do |containment, doc|
242
+ next if existing_types.include?(doc.fetch('$Type'))
243
+
244
+ mpr.insert_unit(container_uuid: root_id, containment_name: containment, contents_doc: doc)
245
+ end
246
+ security_unit = mpr.children_of(root_id).find do |unit|
247
+ mpr.parse_contents(unit)['$Type'] == 'Security$ProjectSecurity'
248
+ end
249
+ return unless security_unit &&
250
+ array_items(mpr.parse_contents(security_unit)['UserRoles']).empty?
251
+
252
+ write_project_security(mpr, root_id, {})
253
+ end
254
+
255
+ def apply_default_project_units(mpr, root_id)
256
+ path = File.join(__dir__, 'templates', 'project', "#{@definition.fetch(:version)}.json")
257
+ return unless File.file?(path)
258
+
259
+ existing = mpr.children_of(root_id).map { mpr.parse_contents(_1)['$Type'] }
260
+ units = load_native_units(path).reject { existing.include?(_1.fetch('type')) }
261
+ units.each do |unit|
262
+ case unit.fetch('type')
263
+ when 'Settings$ProjectSettings'
264
+ sanitize_project_settings!(unit.fetch('doc'))
265
+ when 'Navigation$NavigationDocument'
266
+ sanitize_project_navigation!(unit.fetch('doc'))
267
+ end
268
+ end
269
+ apply_native_unit_tree(mpr, root_id, units)
270
+ end
271
+
272
+ def sanitize_project_navigation!(document)
273
+ profiles = array_items(document['Profiles'])
274
+ profiles += LEGACY_NAVIGATION_PROFILES.values.filter_map { document[_1] }
275
+ profiles.each do |profile|
276
+ # Icons stored in a donor project refer to image collections that are
277
+ # not part of a structural project template. The Ruby navigation DSL
278
+ # may set a new icon explicitly after the clean seed is installed.
279
+ profile['AppIcon'] = '' if profile.key?('AppIcon')
280
+ end
281
+ document
282
+ end
283
+
284
+ def sanitize_project_settings!(document)
285
+ array_items(document['Settings']).each do |setting|
286
+ case setting['$Type']
287
+ when 'Forms$WebUIProjectSettingsPart'
288
+ setting['EnableNewStringBehavior'] = true
289
+ setting['UseOptimizedClient'] = 'Yes' if @definition.fetch(:version).to_i == 10
290
+ setting['ThemeModuleName'] = '' if setting.key?('ThemeModuleName')
291
+ when 'Settings$ModelSettings'
292
+ # A project template is structural seed data, not an application
293
+ # baseline. Lifecycle callbacks from the donor project must never
294
+ # leak into a standalone Ruby-first project.
295
+ %w[AfterStartupMicroflow BeforeShutdownMicroflow HealthCheckMicroflow].each do |key|
296
+ setting[key] = '' if setting.key?(key)
297
+ end
298
+ when 'Settings$LanguageSettings'
299
+ default = setting['DefaultLanguageCode'].to_s
300
+ array_items(setting['Languages']).each do |language|
301
+ next if language['Code'].to_s == default
302
+
303
+ language['CheckCompleteness'] = false
304
+ end
305
+ end
306
+ end
307
+ document
308
+ end
309
+
310
+ def native_module_doc(native_units, module_name)
311
+ native_units.find do |unit|
312
+ unit["containment"] == "Modules" &&
313
+ (unit.dig("doc", "Name") || unit["name"]) == module_name
314
+ end&.fetch("doc", nil)
315
+ end
316
+
317
+ def apply_native_module_units(mpr, module_id, module_name, native_units)
318
+ units = native_units.select { _1["module"] == module_name }
319
+ apply_native_unit_tree(mpr, module_id, units)
320
+ end
321
+
322
+ def write_native_documents(mpr, module_id, mod)
323
+ mod.fetch(:native_documents, []).each do |document|
324
+ doc = document.fetch(:doc)
325
+ doc = legacy_layout_doc(doc) if doc['$Type'] == 'Forms$Layout' && legacy_layout?
326
+ upsert_native_unit(
327
+ mpr, module_id,
328
+ 'containment' => document.fetch(:containment), 'doc' => doc
329
+ )
330
+ end
331
+ end
332
+
333
+ def legacy_layout?
334
+ @definition.fetch(:version).to_i < 9
335
+ end
336
+
337
+ def legacy_layout_doc(source)
338
+ placeholder = {
339
+ '$ID' => SecureRandom.uuid, '$Type' => 'Forms$Placeholder',
340
+ 'Class' => '', 'Name' => 'Main', 'Style' => '', 'TabIndex' => 0
341
+ }
342
+ common = {
343
+ '$ID' => source['$ID'] || SecureRandom.uuid, '$Type' => 'Forms$Layout',
344
+ 'CanvasHeight' => source.fetch('CanvasHeight', 600),
345
+ 'CanvasWidth' => source.fetch('CanvasWidth', 800),
346
+ 'Class' => source.dig('Appearance', 'Class').to_s,
347
+ 'Documentation' => source.fetch('Documentation', ''), 'Excluded' => false,
348
+ 'FormCall' => nil, 'LayoutType' => 'Responsive', 'Name' => source.fetch('Name'),
349
+ 'Style' => source.dig('Appearance', 'Style').to_s
350
+ }
351
+ return common.merge('Widgets' => IO::BsonCodec.build_array([placeholder], marker: 2)) \
352
+ unless oldest_layout_contract?
353
+
354
+ common.merge(
355
+ 'AcceptPlaceholderName' => '', 'CancelPlaceholderName' => '',
356
+ 'MainPlaceholderName' => 'Main', 'UseMainPlaceholderForPopups' => false,
357
+ 'Widget' => placeholder
358
+ )
359
+ end
360
+
361
+ def oldest_layout_contract?
362
+ major, minor = @definition.fetch(:version).split('.').map(&:to_i)
363
+ major < 7 || (major == 7 && minor < 17)
364
+ end
365
+
366
+ def apply_native_unit_tree(mpr, target_root_id, units)
367
+ if units.any? { _1["unit_id"].to_s.empty? || _1["container_id"].to_s.empty? }
368
+ units.each { upsert_native_unit(mpr, target_root_id, _1) }
369
+ return
370
+ end
371
+
372
+ source_ids = units.map { _1.fetch("unit_id") }.to_h { [_1, true] }
373
+ external_parents = units.map { _1.fetch("container_id") }.reject { source_ids.key?(_1) }.uniq
374
+ mapped_containers = external_parents.to_h { [_1, target_root_id] }
375
+ pending = units.dup
376
+
377
+ until pending.empty?
378
+ ready, blocked = pending.partition { mapped_containers.key?(_1.fetch("container_id")) }
379
+ raise SerializationError, "native unit tree contains an unresolved container cycle" if ready.empty?
380
+
381
+ ready.each do |unit|
382
+ target_container = mapped_containers.fetch(unit.fetch("container_id"))
383
+ actual_id = upsert_native_unit(mpr, target_container, unit)
384
+ mapped_containers[unit.fetch("unit_id")] = actual_id
385
+ end
386
+ pending = blocked
387
+ end
388
+ end
389
+
390
+ def upsert_native_unit(mpr, container_id, unit)
391
+ doc = unit.fetch("doc")
392
+ name = doc["Name"] || doc["name"]
393
+ existing = if name.to_s.empty?
394
+ mpr.children_of(container_id).find { mpr.parse_contents(_1)["$Type"] == doc["$Type"] }
395
+ else
396
+ mpr.children_of(container_id).find do |raw|
397
+ existing_doc = mpr.parse_contents(raw)
398
+ (existing_doc["Name"] || existing_doc["name"]) == name && existing_doc["$Type"] == doc["$Type"]
399
+ end
400
+ end
401
+ if existing
402
+ current = mpr.parse_contents(existing)
403
+ preserved = {
404
+ "$ID" => current["$ID"] || existing.fetch("UnitID"),
405
+ "$Type" => doc["$Type"]
406
+ }.merge(current).merge(doc)
407
+ mpr.update_unit(existing.fetch("UnitID"), preserved)
408
+ existing.fetch("UnitID")
409
+ else
410
+ containment = unit.fetch("containment")
411
+ mpr.insert_unit(container_uuid: container_id, containment_name: containment, contents_doc: doc)
412
+ end
413
+ end
414
+
415
+ def write_project_security(mpr, root_id, security)
416
+ raw = mpr.children_of(root_id).find do |unit|
417
+ unit["ContainmentName"] == "ProjectDocuments" &&
418
+ mpr.parse_contents(unit)["$Type"] == "Security$ProjectSecurity"
419
+ end
420
+ doc = project_security_doc(security)
421
+ if raw
422
+ existing = mpr.parse_contents(raw)
423
+ # The native manifest remains authoritative for security properties
424
+ # that are not modeled by the Ruby DSL yet. Only replace the editable
425
+ # role collection and explicitly declared security level.
426
+ editable = {
427
+ "UserRoles" => doc.fetch("UserRoles"),
428
+ "AdminUserRole" => doc.fetch("AdminUserRole")
429
+ }
430
+ if existing.key?("UserRoles") && array_items(existing["UserRoles"]).empty?
431
+ # Project templates may contain demo users for modules and roles that
432
+ # are intentionally not copied into a new Ruby-first project. Once
433
+ # the empty role set is bootstrapped, those references must be
434
+ # removed as one atomic security repair.
435
+ editable["DemoUsers"] = doc.fetch("DemoUsers")
436
+ editable["EnableDemoUsers"] = doc.fetch("EnableDemoUsers")
437
+ editable["SecurityLevel"] = doc.fetch("SecurityLevel")
438
+ end
439
+ if security[:security_level]
440
+ editable["SecurityLevel"] = doc.fetch("SecurityLevel")
441
+ end
442
+ {
443
+ demo_users_enabled: "EnableDemoUsers",
444
+ guest_access_enabled: "EnableGuestAccess",
445
+ guest_user_role: "GuestUserRole",
446
+ sign_in_microflow: "SignInMicroflow",
447
+ password_policy: "PasswordPolicySettings"
448
+ }.each do |definition_key, native_key|
449
+ editable[native_key] = doc.fetch(native_key) unless security[definition_key].nil?
450
+ end
451
+ doc = existing.merge(editable)
452
+ doc["$ID"] = existing["$ID"] || raw.fetch("UnitID")
453
+ mpr.update_unit(raw.fetch("UnitID"), doc)
454
+ else
455
+ mpr.insert_unit(container_uuid: root_id, containment_name: "ProjectDocuments", contents_doc: doc)
456
+ end
457
+ end
458
+
459
+ def write_project_navigation(mpr, root_id, navigation)
460
+ raw = mpr.children_of(root_id).find do |unit|
461
+ mpr.parse_contents(unit)["$Type"] == "Navigation$NavigationDocument"
462
+ end
463
+ existing = raw ? mpr.parse_contents(raw) : {}
464
+ doc = if legacy_navigation?(existing)
465
+ legacy_navigation_doc(existing, navigation)
466
+ else
467
+ modern_navigation_doc(existing, navigation)
468
+ end
469
+ if raw
470
+ mpr.update_unit(raw.fetch("UnitID"), doc)
471
+ else
472
+ mpr.insert_unit(
473
+ container_uuid: root_id, containment_name: "ProjectDocuments", contents_doc: doc
474
+ )
475
+ end
476
+ end
477
+
478
+ def legacy_navigation?(document)
479
+ !document.key?("Profiles") &&
480
+ LEGACY_NAVIGATION_PROFILES.values.any? { document[_1].is_a?(Hash) }
481
+ end
482
+
483
+ def legacy_navigation_doc(existing, navigation)
484
+ navigation.fetch(:profiles, []).each_with_object(existing.dup) do |profile, document|
485
+ name = profile.fetch(:name).to_s
486
+ name = 'Desktop' if name == 'Responsive'
487
+ key = LEGACY_NAVIGATION_PROFILES.fetch(name)
488
+ document[key] = legacy_navigation_profile_doc(profile, previous: existing[key])
489
+ end
490
+ end
491
+
492
+ def legacy_navigation_profile_doc(profile, previous:)
493
+ previous ||= {}
494
+ role_homes = profile.fetch(:role_homes, {}).map do |role, page|
495
+ { role: role.to_s, page: page.to_s }
496
+ end + profile.fetch(:role_home_details, [])
497
+ title = profile.fetch(:app_title, {})
498
+ title = title.values.first.to_s if title.is_a?(Hash)
499
+ editable = {
500
+ 'HomePage' => navigation_home_doc(profile[:home_page], profile[:home_microflow]),
501
+ 'HomeItems' => IO::BsonCodec.build_array(role_homes.map { navigation_role_home_doc(_1) }, marker: 2),
502
+ 'Menu' => navigation_menu_doc(profile.fetch(:items, [])),
503
+ 'Enabled' => true,
504
+ 'ApplicationTitle' => title.to_s.empty? ? previous.fetch('ApplicationTitle', 'Mendix') : title.to_s
505
+ }
506
+ editable['Kind'] = profile[:kind].to_s unless profile[:kind].to_s.empty?
507
+ previous.merge(editable)
508
+ end
509
+
510
+ def modern_navigation_doc(existing, navigation)
511
+ existing_profiles = array_items(existing["Profiles"]).to_h { [_1["Name"].to_s, _1] }
512
+ profiles = navigation.fetch(:profiles, []).map do |profile|
513
+ navigation_profile_doc(profile, previous: existing_profiles[profile.fetch(:name).to_s])
514
+ end
515
+ existing.merge(
516
+ "$ID" => existing["$ID"] || SecureRandom.uuid,
517
+ "$Type" => "Navigation$NavigationDocument",
518
+ "Profiles" => IO::BsonCodec.build_array(profiles, marker: 2)
519
+ )
520
+ end
521
+
522
+ def navigation_profile_doc(profile, previous: nil)
523
+ previous ||= {}
524
+ role_homes = profile.fetch(:role_homes, {}).map do |role, page|
525
+ { role: role.to_s, page: page.to_s }
526
+ end + profile.fetch(:role_home_details, [])
527
+ editable = {
528
+ "Name" => profile.fetch(:name).to_s,
529
+ "HomePage" => navigation_home_doc(profile[:home_page], profile[:home_microflow]),
530
+ "HomeItems" => IO::BsonCodec.build_array(role_homes.map { navigation_role_home_doc(_1) }),
531
+ "Menu" => navigation_menu_doc(profile.fetch(:items, [])),
532
+ "OfflineEntityConfigs" => IO::BsonCodec.build_array([], marker: 3),
533
+ "ProgressiveWebAppSettings" => nil,
534
+ "NotFoundHomepage" => nil,
535
+ "ThrowPartialSyncError" => true
536
+ }
537
+ if !profile[:kind].to_s.empty?
538
+ editable["Kind"] = profile[:kind]
539
+ elsif profile[:offline]
540
+ editable["Kind"] = "Offline"
541
+ elsif previous.empty?
542
+ editable["Kind"] = "Responsive"
543
+ end
544
+ editable["AppIcon"] = profile[:app_icon] unless profile[:app_icon].nil?
545
+ unless profile.fetch(:app_title, {}).empty?
546
+ editable["AppTitle"] = translated_text_doc(profile.fetch(:app_title))
547
+ end
548
+ if profile[:sign_in_page]
549
+ editable["LoginPageSettings"] = {
550
+ "$ID" => SecureRandom.uuid,
551
+ "$Type" => "Forms$FormSettings",
552
+ "Form" => profile.fetch(:sign_in_page),
553
+ "ParameterMappings" => IO::BsonCodec.build_array([], marker: 2),
554
+ "TitleOverride" => nil
555
+ }
556
+ elsif previous.empty?
557
+ editable["LoginPageSettings"] = {
558
+ "$ID" => SecureRandom.uuid,
559
+ "$Type" => "Forms$FormSettings",
560
+ "Form" => "",
561
+ "ParameterMappings" => IO::BsonCodec.build_array([], marker: 2),
562
+ "TitleOverride" => nil
563
+ }
564
+ end
565
+ {
566
+ "$ID" => previous["$ID"] || SecureRandom.uuid,
567
+ "$Type" => previous["$Type"] || "Navigation$NavigationProfile"
568
+ }.merge(previous).merge(editable)
569
+ end
570
+
571
+ def navigation_home_doc(page, microflow)
572
+ {
573
+ "$ID" => SecureRandom.uuid,
574
+ "$Type" => "Navigation$HomePage",
575
+ "Microflow" => microflow.to_s,
576
+ "Page" => page.to_s
577
+ }
578
+ end
579
+
580
+ def navigation_role_home_doc(home)
581
+ {
582
+ "$ID" => SecureRandom.uuid,
583
+ "$Type" => "Navigation$RoleBasedHomePage",
584
+ "UserRole" => home.fetch(:role).to_s,
585
+ "Page" => home[:page],
586
+ "Microflow" => home[:microflow]
587
+ }
588
+ end
589
+
590
+ def navigation_menu_doc(items)
591
+ {
592
+ "$ID" => SecureRandom.uuid,
593
+ "$Type" => "Menus$MenuItemCollection",
594
+ "Items" => IO::BsonCodec.build_array(items.map { navigation_menu_item_doc(_1) })
595
+ }
596
+ end
597
+
598
+ def navigation_menu_item_doc(item)
599
+ action = if item[:page]
600
+ form_action_doc(item.fetch(:page))
601
+ elsif item[:microflow]
602
+ navigation_microflow_action_doc(item.fetch(:microflow))
603
+ else
604
+ { "$ID" => SecureRandom.uuid, "$Type" => "Forms$NoAction" }
605
+ end
606
+ {
607
+ "$ID" => SecureRandom.uuid,
608
+ "$Type" => "Menus$MenuItem",
609
+ "Caption" => translated_text_doc(item.fetch(:caption)),
610
+ "Action" => action,
611
+ "Icon" => glyph_icon_doc(item[:icon]),
612
+ "Items" => IO::BsonCodec.build_array(
613
+ item.fetch(:items, []).map { navigation_menu_item_doc(_1) }
614
+ )
615
+ }
616
+ end
617
+
618
+ def glyph_icon_doc(icon)
619
+ return unless icon
620
+
621
+ code = icon.is_a?(Integer) ? icon : GLYPH_ICON_CODES[icon.to_s.downcase]
622
+ raise ArgumentError, "unsupported navigation icon #{icon.inspect}" unless code
623
+
624
+ { '$ID' => SecureRandom.uuid, '$Type' => 'Forms$GlyphIcon', 'Code' => code }
625
+ end
626
+
627
+ def navigation_microflow_action_doc(microflow)
628
+ {
629
+ "$ID" => SecureRandom.uuid,
630
+ "$Type" => "Forms$MicroflowAction",
631
+ "MicroflowSettings" => {
632
+ "$ID" => SecureRandom.uuid,
633
+ "$Type" => "Forms$MicroflowSettings",
634
+ "Microflow" => microflow
635
+ }
636
+ }
637
+ end
638
+
639
+ def translated_text_doc(translations)
640
+ {
641
+ "$ID" => SecureRandom.uuid,
642
+ "$Type" => "Texts$Text",
643
+ "Items" => IO::BsonCodec.build_array(translations.map do |locale, value|
644
+ {
645
+ "$ID" => SecureRandom.uuid,
646
+ "$Type" => "Texts$Translation",
647
+ "LanguageCode" => locale.to_s,
648
+ "Text" => value.to_s
649
+ }
650
+ end)
651
+ }
652
+ end
653
+
654
+ def write_module_security(mpr, module_id, mod)
655
+ raw = mpr.children_of(module_id).find { _1["ContainmentName"] == "ModuleSecurity" }
656
+ return if raw.nil? && mod.fetch(:module_roles, []).empty? && @definition.fetch(:version).to_i >= 10
657
+
658
+ doc = module_security_doc(
659
+ mod.fetch(:module_roles, []), legacy: @definition.fetch(:version).to_i < 10
660
+ )
661
+ if raw
662
+ existing = mpr.parse_contents(raw)
663
+ doc = existing.merge(doc)
664
+ doc["$ID"] = existing["$ID"] || raw.fetch("UnitID")
665
+ mpr.update_unit(raw.fetch("UnitID"), doc)
666
+ else
667
+ mpr.insert_unit(container_uuid: module_id, containment_name: "ModuleSecurity", contents_doc: doc)
668
+ end
669
+ end
670
+
671
+ def write_domain_model(mpr, module_id, mod)
672
+ raw = mpr.units_by_containment("DomainModel").find { _1["ContainerID"] == module_id }
673
+ existing = raw ? mpr.parse_contents(raw) : {}
674
+ entities_key = native_key(existing, "entities", "Entities")
675
+ associations_key = native_key(existing, "associations", "Associations")
676
+ cross_associations_key = native_key(existing, "crossAssociations", "CrossAssociations")
677
+ annotations_key = native_key(existing, "annotations", "Annotations")
678
+ existing_entities = array_items(existing[entities_key]).to_h do |entity|
679
+ [entity["name"] || entity["Name"], entity]
680
+ end
681
+
682
+ access_associations = association_access_by_entity(mod)
683
+ entities = mod.fetch(:entities).map.with_index do |entity, index|
684
+ entity_doc(
685
+ entity, mod.fetch(:name), existing_entities[entity.fetch(:name)], index,
686
+ access_associations: access_associations.fetch(entity.fetch(:name), [])
687
+ )
688
+ end
689
+ entity_ids = entities.to_h do |entity|
690
+ [entity['name'] || entity.fetch('Name'), entity.fetch('$ID')]
691
+ end
692
+ @all_entity_ids ||= {}
693
+ entity_ids.each { |name, id| @all_entity_ids["#{mod.fetch(:name)}.#{name}"] = id }
694
+ existing_associations = array_items(existing[associations_key]).to_h do |association|
695
+ [association["Name"], association]
696
+ end
697
+ existing_cross_associations = array_items(existing[cross_associations_key]).to_h do |association|
698
+ [association["Name"], association]
699
+ end
700
+ associations = existing_associations.values
701
+ cross_associations = existing_cross_associations.values
702
+ mod.fetch(:entities).flat_map do |entity|
703
+ entity.fetch(:associations, []).map do |association|
704
+ target_module, target_name = association_target(association.fetch(:target), mod.fetch(:name))
705
+ if target_module == mod.fetch(:name)
706
+ generated = association_doc(
707
+ association,
708
+ from_id: entity_ids.fetch(entity.fetch(:name)),
709
+ to_id: entity_ids.fetch(target_name) {
710
+ raise ArgumentError, "unknown association target #{association.fetch(:target).inspect}"
711
+ },
712
+ previous: existing_associations[association.fetch(:name)]
713
+ )
714
+ associations = associations.reject { _1["Name"] == generated["Name"] } + [generated]
715
+ cross_associations = cross_associations.reject { _1["Name"] == generated["Name"] }
716
+ else
717
+ generated = cross_association_doc(
718
+ association,
719
+ from_id: entity_ids.fetch(entity.fetch(:name)),
720
+ target: "#{target_module}.#{target_name}",
721
+ previous: existing_cross_associations[association.fetch(:name)]
722
+ )
723
+ cross_associations = cross_associations.reject { _1["Name"] == generated["Name"] } + [generated]
724
+ associations = associations.reject { _1["Name"] == generated["Name"] }
725
+ end
726
+ end
727
+ end
728
+
729
+ doc = existing.merge(
730
+ "$ID" => raw&.fetch("UnitID", nil) || SecureRandom.uuid,
731
+ "$Type" => "DomainModels$DomainModel",
732
+ "Documentation" => existing.fetch("Documentation", "")
733
+ )
734
+ doc[entities_key] = IO::BsonCodec.build_array(entities)
735
+ doc[associations_key] = IO::BsonCodec.build_array(associations)
736
+ doc[cross_associations_key] = IO::BsonCodec.build_array(cross_associations)
737
+ doc[annotations_key] ||= IO::BsonCodec.build_array([])
738
+
739
+ if raw
740
+ mpr.update_unit(raw.fetch("UnitID"), doc)
741
+ else
742
+ mpr.insert_unit(container_uuid: module_id, containment_name: "DomainModel", contents_doc: doc)
743
+ end
744
+ end
745
+
746
+ def association_target(target, default_module)
747
+ value = target.to_s
748
+ value.include?('.') ? value.split('.', 2) : [default_module, value]
749
+ end
750
+
751
+ def association_access_by_entity(mod)
752
+ module_name = mod.fetch(:name)
753
+ mod.fetch(:entities).each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |entity, result|
754
+ entity.fetch(:associations, []).each do |association|
755
+ result[entity.fetch(:name)] << association.fetch(:name)
756
+ next unless association.fetch(:owner, :Default).to_sym == :Both &&
757
+ association.fetch(:type).to_sym == :Reference
758
+
759
+ target_module, target_name = association.fetch(:target).split('.', 2)
760
+ result[target_name] << association.fetch(:name) if target_module == module_name
761
+ end
762
+ end
763
+ end
764
+
765
+ def write_documents(mpr, module_id, mod)
766
+ existing = documents_by_name(mpr, module_id)
767
+
768
+ mod.fetch(:pages).each do |page|
769
+ upsert_document(
770
+ mpr, module_id, existing[page.fetch(:name)], page_doc(page, mod.fetch(:name))
771
+ )
772
+ end
773
+ mod.fetch(:microflows).each do |flow|
774
+ upsert_document(
775
+ mpr, module_id, existing[flow.fetch(:name)],
776
+ microflow_doc(flow, mod.fetch(:name))
777
+ )
778
+ end
779
+ mod.fetch(:nanoflows, []).each do |flow|
780
+ upsert_document(mpr, module_id, existing[flow.fetch(:name)], nanoflow_doc(flow))
781
+ end
782
+ mod.fetch(:menus, []).each do |menu|
783
+ upsert_document(mpr, module_id, existing[menu.fetch(:name)], menu_doc(menu))
784
+ end
785
+ mod.fetch(:enumerations, []).each do |enum|
786
+ upsert_document(mpr, module_id, existing[enum.fetch(:name)], enumeration_doc(enum))
787
+ end
788
+ mod.fetch(:constants, []).each do |constant|
789
+ upsert_document(mpr, module_id, existing[constant.fetch(:name)], constant_doc(constant))
790
+ end
791
+ mod.fetch(:scheduled_events, []).each do |event|
792
+ upsert_document(mpr, module_id, existing[event.fetch(:name)], scheduled_event_doc(event))
793
+ end
794
+ end
795
+
796
+ def upsert_document(mpr, module_id, candidates, doc)
797
+ raw = Array(candidates).find do |candidate|
798
+ mpr.parse_contents(candidate)["$Type"] == doc["$Type"]
799
+ end
800
+ if raw
801
+ existing = mpr.parse_contents(raw)
802
+ doc = merge_existing_document(existing, doc)
803
+ strip_internal_keys(doc)
804
+ doc["$ID"] = existing["$ID"] || raw.fetch("UnitID")
805
+ doc["$Type"] = existing["$Type"] || doc["$Type"]
806
+ mpr.update_unit(raw.fetch("UnitID"), doc)
807
+ else
808
+ strip_internal_keys(doc)
809
+ mpr.insert_unit(container_uuid: module_id, containment_name: "Documents", contents_doc: doc)
810
+ end
811
+ end
812
+
813
+ def merge_existing_document(existing, generated)
814
+ hydrate_pluggable_widgets!(generated, existing)
815
+ merged = existing.merge(generated)
816
+ case existing["$Type"]
817
+ when "Microflows$Microflow", "Microflows$Nanoflow"
818
+ preserve_keys(merged, existing, %w[
819
+ MicroflowParameterCollection MicroflowReturnType UseListParameterByReference
820
+ ])
821
+ if generated["__mxrb_preserve_native_body"]
822
+ preserve_keys(
823
+ merged, existing, %w[ObjectCollection Flows ReturnVariableName]
824
+ )
825
+ %w[
826
+ MicroflowParameterCollection MicroflowReturnType
827
+ UseListParameterByReference ReturnVariableName
828
+ ].each do |key|
829
+ merged.delete(key) unless existing.key?(key)
830
+ end
831
+ elsif generated["__mxrb_body_declared"]
832
+ preserve_flow_auxiliary_objects(merged, existing)
833
+ else
834
+ preserve_keys(merged, existing, %w[ObjectCollection Flows ReturnVariableName])
835
+ end
836
+ preserve_flow_metadata(merged, existing, generated)
837
+ preserve_allowed_roles(merged, existing, generated)
838
+ when /^Pages\$/
839
+ preserve_keys(merged, existing, %w[Widgets Parameters]) unless generated["__mxrb_deep_structure_declared"]
840
+ preserve_allowed_roles(merged, existing, generated)
841
+ when "Forms$Page"
842
+ unless generated["__mxrb_deep_structure_declared"]
843
+ preserve_keys(merged, existing, %w[
844
+ AllowedRoles Appearance Autofocus CanvasHeight CanvasWidth
845
+ Excluded MarkAsUsed Parameters PopupCloseAction Variables
846
+ ])
847
+ end
848
+ preserve_allowed_roles(merged, existing, generated)
849
+ else
850
+ merged
851
+ end
852
+ end
853
+
854
+ def strip_internal_keys(doc)
855
+ doc.delete("__mxrb_allowed_roles_declared")
856
+ doc.delete("__mxrb_body_declared")
857
+ doc.delete("__mxrb_preserve_native_body")
858
+ doc.delete("__mxrb_deep_structure_declared")
859
+ doc.delete("__mxrb_allow_concurrent_execution_declared")
860
+ doc.delete("__mxrb_mark_as_used_declared")
861
+ doc.delete("__mxrb_excluded_declared")
862
+ strip_nested_internal_keys(doc)
863
+ doc
864
+ end
865
+
866
+ def strip_nested_internal_keys(value)
867
+ case value
868
+ when Hash
869
+ value.delete_if { |key, _| key.to_s.start_with?("__mxrb_") }
870
+ value.each_value { strip_nested_internal_keys(_1) }
871
+ when Array
872
+ value.each { strip_nested_internal_keys(_1) }
873
+ end
874
+ end
875
+
876
+ def hydrate_pluggable_widgets!(generated, existing)
877
+ existing_widgets = custom_widgets(existing).to_h { [_1["Name"], _1] }
878
+ custom_widgets(generated).each do |widget|
879
+ options = widget.delete("__mxrb_widget_options")
880
+ next if options && complete_widget_definition?(widget)
881
+
882
+ baseline = existing_widgets[widget["Name"]]
883
+ next unless options && compatible_widget_baseline?(widget, baseline)
884
+
885
+ widget["Type"] = baseline["Type"]
886
+ widget["Object"] = baseline["Object"]
887
+ configure_data_grid2!(widget, options) if widget.dig("Type", "WidgetId") == data_grid2_descriptor[:id]
888
+ configure_combo_box!(widget, options) if widget.dig("Type", "WidgetId") == combo_box_descriptor[:id]
889
+ configure_pluggable_widget!(widget, options) if options[:__kind].to_sym == :pluggable_widget
890
+ end
891
+ end
892
+
893
+ def configure_pluggable_widget!(widget, options)
894
+ properties = custom_widget_properties(widget)
895
+ options.fetch(:properties, {}).each do |key, configured|
896
+ value = properties.dig(key.to_s, "Value")
897
+ next unless value
898
+
899
+ if configured.is_a?(Hash)
900
+ value.merge!(stringify_keys(configured))
901
+ elsif !configured.nil?
902
+ value["PrimitiveValue"] = configured.to_s
903
+ end
904
+ end
905
+ end
906
+
907
+ def stringify_keys(value)
908
+ case value
909
+ when Hash then value.to_h { |key, item| [key.to_s, stringify_keys(item)] }
910
+ when Array then value.map { stringify_keys(_1) }
911
+ else value
912
+ end
913
+ end
914
+
915
+ def custom_widgets(value, found = [])
916
+ case value
917
+ when Hash
918
+ found << value if value["$Type"] == "CustomWidgets$CustomWidget"
919
+ value.each_value { custom_widgets(_1, found) }
920
+ when Array
921
+ value.each { custom_widgets(_1, found) }
922
+ end
923
+ found
924
+ end
925
+
926
+ def compatible_widget_baseline?(generated, baseline)
927
+ return false unless baseline.is_a?(Hash)
928
+ return false unless generated.dig("Type", "WidgetId") == baseline.dig("Type", "WidgetId")
929
+
930
+ array_items(baseline.dig("Type", "ObjectType", "PropertyTypes")).any?
931
+ end
932
+
933
+ def complete_widget_definition?(widget)
934
+ array_items(widget.dig('Type', 'ObjectType', 'PropertyTypes')).any?
935
+ end
936
+
937
+ def configure_data_grid2!(widget, options)
938
+ properties = custom_widget_properties(widget)
939
+ %w[loadMoreButtonCaption singleSelectionColumnLabel clearSelectionButtonLabel].each do |key|
940
+ properties.dig(key, 'Value')['TextTemplate'] = nil if properties[key]
941
+ end
942
+ if properties['filterSectionTitle']
943
+ properties.dig('filterSectionTitle', 'Value')['TextTemplate'] = empty_client_template_doc
944
+ end
945
+ if options[:entity] && properties["datasource"]
946
+ properties["datasource"]["Value"]["DataSource"] = custom_xpath_source_doc(options[:entity])
947
+ end
948
+ return unless properties["columns"] && options[:columns]
949
+
950
+ column_type = properties["columns"].dig("ValueType", "ObjectType")
951
+ columns = Array(options[:columns]).map do |column|
952
+ data_grid2_column_doc(column_type, column, entity: options[:entity])
953
+ end
954
+ properties["columns"]["Value"]["Objects"] = IO::BsonCodec.build_array(columns, marker: 2)
955
+ end
956
+
957
+ def configure_combo_box!(widget, options)
958
+ properties = custom_widget_properties(widget)
959
+ set_widget_primitive(properties, "source", "context")
960
+ widget["LabelTemplate"] = client_template_doc(options[:caption])
961
+ if options[:__kind].to_sym == :drop_down
962
+ set_widget_primitive(properties, "optionsSourceType", "enumeration")
963
+ if properties["attributeEnumeration"]
964
+ properties.dig("attributeEnumeration", "Value")["AttributeRef"] =
965
+ attribute_ref_doc(options[:attribute])
966
+ end
967
+ else
968
+ set_widget_primitive(properties, "optionsSourceType", "association")
969
+ if properties["attributeAssociation"] && options[:attribute]
970
+ properties.dig("attributeAssociation", "Value")["EntityRef"] =
971
+ indirect_entity_ref_doc(options[:attribute])
972
+ end
973
+ if properties["optionsSourceAssociationDataSource"] && options[:attribute]
974
+ properties.dig("optionsSourceAssociationDataSource", "Value")["DataSource"] =
975
+ custom_xpath_source_doc(association_destination(options[:attribute]))
976
+ end
977
+ if properties["optionsSourceAssociationCaptionAttribute"] && options[:display_attribute]
978
+ properties.dig("optionsSourceAssociationCaptionAttribute", "Value")["AttributeRef"] =
979
+ attribute_ref_doc(options[:display_attribute])
980
+ end
981
+ end
982
+ end
983
+
984
+ def set_widget_primitive(properties, key, value)
985
+ properties.dig(key, "Value")["PrimitiveValue"] = value if properties[key]
986
+ end
987
+
988
+ def indirect_entity_ref_doc(association)
989
+ destination = association_destination(association)
990
+ {
991
+ "$ID" => SecureRandom.uuid, "$Type" => "DomainModels$IndirectEntityRef",
992
+ "Steps" => IO::BsonCodec.build_array([{
993
+ "$ID" => SecureRandom.uuid, "$Type" => "DomainModels$EntityRefStep",
994
+ "Association" => association.to_s, "DestinationEntity" => destination
995
+ }], marker: 2)
996
+ }
997
+ end
998
+
999
+ def association_destination(name)
1000
+ @definition.fetch(:modules, []).each do |mod|
1001
+ mod.fetch(:entities, []).each do |entity|
1002
+ association = entity.fetch(:associations, []).find do |candidate|
1003
+ candidate[:name].to_s == name.to_s ||
1004
+ "#{mod[:name]}.#{candidate[:name]}" == name.to_s
1005
+ end
1006
+ return association[:target].to_s if association
1007
+ end
1008
+ end
1009
+ ""
1010
+ end
1011
+
1012
+ def custom_widget_properties(widget)
1013
+ types = array_items(widget.dig("Type", "ObjectType", "PropertyTypes")).to_h do |type|
1014
+ [IO::BsonCodec.extract_id(type["$ID"]), type]
1015
+ end
1016
+ array_items(widget.dig("Object", "Properties")).to_h do |property|
1017
+ type = types[IO::BsonCodec.extract_id(property["TypePointer"])]
1018
+ [type && type["PropertyKey"], property.merge("ValueType" => type && type["ValueType"])]
1019
+ end.compact
1020
+ end
1021
+
1022
+ def custom_xpath_source_doc(entity)
1023
+ {
1024
+ "$ID" => SecureRandom.uuid, "$Type" => "CustomWidgets$CustomWidgetXPathSource",
1025
+ "EntityRef" => {
1026
+ "$ID" => SecureRandom.uuid, "$Type" => "DomainModels$DirectEntityRef",
1027
+ "Entity" => entity.to_s
1028
+ },
1029
+ "ForceFullObjects" => false,
1030
+ "SortBar" => {
1031
+ "$ID" => SecureRandom.uuid, "$Type" => "Forms$GridSortBar",
1032
+ "SortItems" => IO::BsonCodec.build_array([], marker: 2)
1033
+ },
1034
+ "SourceVariable" => nil, "XPathConstraint" => ""
1035
+ }
1036
+ end
1037
+
1038
+ def data_grid2_column_doc(object_type, column, entity: nil)
1039
+ attribute = column[:attribute]
1040
+ attribute = "#{entity}.#{attribute}" if entity && attribute && !attribute.include?('.')
1041
+ overrides = {
1042
+ "showContentAs" => { "PrimitiveValue" => "attribute" },
1043
+ "attribute" => { "AttributeRef" => attribute_ref_doc(attribute) },
1044
+ "header" => { "TextTemplate" => client_template_doc(column[:caption] || column[:name]) },
1045
+ "tooltip" => { "TextTemplate" => empty_client_template_doc },
1046
+ "filter" => { "TextTemplate" => empty_client_template_doc }
1047
+ }
1048
+ custom_widget_object_doc(object_type, overrides)
1049
+ end
1050
+
1051
+ def custom_widget_object_doc(object_type, overrides = {})
1052
+ properties = array_items(object_type["PropertyTypes"]).map do |property_type|
1053
+ value = custom_widget_value_doc(property_type.fetch("ValueType"))
1054
+ value.merge!(overrides.fetch(property_type["PropertyKey"], {}))
1055
+ {
1056
+ "$ID" => SecureRandom.uuid, "$Type" => "CustomWidgets$WidgetProperty",
1057
+ "TypePointer" => property_type.fetch("$ID"), "Value" => value
1058
+ }
1059
+ end
1060
+ {
1061
+ "$ID" => SecureRandom.uuid, "$Type" => "CustomWidgets$WidgetObject",
1062
+ "Properties" => IO::BsonCodec.build_array(properties, marker: 2),
1063
+ "TypePointer" => object_type.fetch("$ID")
1064
+ }
1065
+ end
1066
+
1067
+ def custom_widget_value_doc(value_type)
1068
+ doc = {
1069
+ "$ID" => SecureRandom.uuid, "$Type" => "CustomWidgets$WidgetValue",
1070
+ "Action" => no_action_doc(disabled: true), "AttributeRef" => nil, "DataSource" => nil,
1071
+ "EntityRef" => nil, "Expression" => "", "Form" => "", "Icon" => nil,
1072
+ "Image" => "", "Microflow" => "", "Nanoflow" => "",
1073
+ "Objects" => IO::BsonCodec.build_array([], marker: 2),
1074
+ "PrimitiveValue" => "", "Selection" => "None",
1075
+ "SourceVariable" => nil, "TextTemplate" => nil, "TranslatableValue" => nil,
1076
+ "TypePointer" => value_type.fetch("$ID"),
1077
+ "Widgets" => IO::BsonCodec.build_array([], marker: 2), "XPathConstraint" => ""
1078
+ }
1079
+ default = value_type["DefaultValue"].to_s
1080
+ case value_type["Type"]
1081
+ when "Expression" then doc["Expression"] = default
1082
+ when "TextTemplate"
1083
+ doc["TextTemplate"] = client_template_doc(default) if value_type['Required'] || !default.empty?
1084
+ else doc["PrimitiveValue"] = default
1085
+ end
1086
+ doc
1087
+ end
1088
+
1089
+ def preserve_flow_metadata(merged, existing, generated)
1090
+ {
1091
+ "AllowConcurrentExecution" => "__mxrb_allow_concurrent_execution_declared",
1092
+ "MarkAsUsed" => "__mxrb_mark_as_used_declared",
1093
+ "Excluded" => "__mxrb_excluded_declared"
1094
+ }.each do |field, declaration|
1095
+ next if generated[declaration]
1096
+
1097
+ if existing.key?(field)
1098
+ merged[field] = existing[field]
1099
+ else
1100
+ merged.delete(field)
1101
+ end
1102
+ end
1103
+ merged
1104
+ end
1105
+
1106
+ def preserve_allowed_roles(merged, existing, generated)
1107
+ return merged if generated["__mxrb_allowed_roles_declared"]
1108
+
1109
+ merged["AllowedModuleRoles"] = existing["AllowedModuleRoles"] if existing.key?("AllowedModuleRoles")
1110
+ merged
1111
+ end
1112
+
1113
+ def preserve_keys(target, source, keys)
1114
+ keys.each do |key|
1115
+ target[key] = source[key] if source.key?(key)
1116
+ end
1117
+ target
1118
+ end
1119
+
1120
+ def preserve_flow_auxiliary_objects(target, source)
1121
+ original_collection = source["ObjectCollection"] || {}
1122
+ generated_collection = target["ObjectCollection"] || {}
1123
+ original_objects = array_items(original_collection["Objects"])
1124
+ generated_objects = array_items(generated_collection["Objects"])
1125
+ original_flows = array_items(
1126
+ source["Flows"] || original_collection["Flows"]
1127
+ )
1128
+ generated_flows = array_items(
1129
+ target["Flows"] || generated_collection["Flows"]
1130
+ )
1131
+
1132
+ original_objects.each_with_index do |object, index|
1133
+ next unless %w[
1134
+ Microflows$Annotation
1135
+ Microflows$MicroflowParameter
1136
+ ].include?(object["$Type"])
1137
+ if object["$Type"] == 'Microflows$MicroflowParameter' &&
1138
+ generated_objects.any? do |generated|
1139
+ generated["$Type"] == object["$Type"] && generated["Name"] == object["Name"]
1140
+ end
1141
+ next
1142
+ end
1143
+
1144
+ generated_objects.insert([index, generated_objects.size].min, object)
1145
+ end
1146
+
1147
+ original_editable = ordered_flow_objects(
1148
+ all_flow_objects(original_objects), original_flows
1149
+ ).reject { flow_auxiliary_object?(_1) }
1150
+ generated_editable = ordered_flow_objects(
1151
+ all_flow_objects(generated_objects), generated_flows
1152
+ ).reject { flow_auxiliary_object?(_1) }
1153
+ id_mapping = {}
1154
+ original_cursor = 0
1155
+ generated_editable.each do |generated_object|
1156
+ match_index = (original_cursor...original_editable.size).find do |index|
1157
+ flow_object_signature(original_editable[index]) ==
1158
+ flow_object_signature(generated_object)
1159
+ end
1160
+ next unless match_index
1161
+
1162
+ original_object = original_editable[match_index]
1163
+ original_cursor = match_index + 1
1164
+ generated_id = generated_object["$ID"]
1165
+ id_mapping[generated_id] = original_object["$ID"]
1166
+ preserve_flow_object_metadata(generated_object, original_object)
1167
+ end
1168
+
1169
+ generated_flows.each do |flow|
1170
+ flow["OriginPointer"] = id_mapping.fetch(flow["OriginPointer"], flow["OriginPointer"])
1171
+ flow["DestinationPointer"] = id_mapping.fetch(
1172
+ flow["DestinationPointer"], flow["DestinationPointer"]
1173
+ )
1174
+ end
1175
+ original_edges = original_flows.to_h { [flow_edge_signature(_1), _1] }
1176
+ generated_flows.map! do |flow|
1177
+ original_edges.fetch(flow_edge_signature(flow), flow)
1178
+ end
1179
+ generated_flows.concat(
1180
+ original_flows.select { _1["$Type"] == "Microflows$AnnotationFlow" }
1181
+ )
1182
+
1183
+ generated_collection["Objects"] = IO::BsonCodec.build_array(generated_objects)
1184
+ target["ObjectCollection"] = generated_collection
1185
+ target["Flows"] = IO::BsonCodec.build_array(generated_flows)
1186
+ target
1187
+ end
1188
+
1189
+ def preserve_flow_object_metadata(generated, original)
1190
+ original_id = original["$ID"]
1191
+ presentation_keys = %w[
1192
+ RelativeMiddlePoint Size Caption AutoGenerateCaption BackgroundColor
1193
+ Documentation Disabled ErrorHandlingType
1194
+ ]
1195
+ if generated["Action"].is_a?(Hash) && original["Action"].is_a?(Hash) &&
1196
+ generated["Action"]["$Type"] == original["Action"]["$Type"]
1197
+ generated["Action"] = deep_merge_flow_metadata(
1198
+ original["Action"], generated["Action"]
1199
+ )
1200
+ if original["Action"].key?("ErrorHandlingType")
1201
+ generated["Action"]["ErrorHandlingType"] = original["Action"]["ErrorHandlingType"]
1202
+ else
1203
+ generated["Action"].delete("ErrorHandlingType")
1204
+ end
1205
+ end
1206
+ presentation_keys.each do |key|
1207
+ if original.key?(key)
1208
+ generated[key] = original[key]
1209
+ else
1210
+ generated.delete(key)
1211
+ end
1212
+ end
1213
+ generated["$ID"] = original_id
1214
+ end
1215
+
1216
+ def deep_merge_flow_metadata(original, generated)
1217
+ original.merge(generated) do |_key, old_value, new_value|
1218
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
1219
+ deep_merge_flow_metadata(old_value, new_value)
1220
+ elsif old_value.is_a?(Array) && new_value.is_a?(Array) &&
1221
+ old_value.size == new_value.size
1222
+ old_value.zip(new_value).map do |old_item, new_item|
1223
+ if old_item.is_a?(Hash) && new_item.is_a?(Hash)
1224
+ deep_merge_flow_metadata(old_item, new_item)
1225
+ else
1226
+ new_item
1227
+ end
1228
+ end
1229
+ elsif new_value.nil?
1230
+ old_value
1231
+ else
1232
+ new_value
1233
+ end
1234
+ end
1235
+ end
1236
+
1237
+ def all_flow_objects(objects)
1238
+ objects.flat_map do |object|
1239
+ nested = array_items(object.dig("ObjectCollection", "Objects"))
1240
+ [object] + all_flow_objects(nested)
1241
+ end
1242
+ end
1243
+
1244
+ def ordered_flow_objects(objects, flows)
1245
+ by_id = objects.to_h { [_1["$ID"], _1] }
1246
+ incoming = flows.group_by { _1["DestinationPointer"] }
1247
+ outgoing = flows.group_by { _1["OriginPointer"] }
1248
+ roots = objects.select { _1["$Type"] == "Microflows$StartEvent" }
1249
+ roots += objects.reject { incoming.key?(_1["$ID"]) || roots.include?(_1) }
1250
+ result = []
1251
+ queue = roots
1252
+ until queue.empty?
1253
+ object = queue.shift
1254
+ next unless object && !result.include?(object)
1255
+
1256
+ result << object
1257
+ Array(outgoing[object["$ID"]]).each do |flow|
1258
+ queue << by_id[flow["DestinationPointer"]]
1259
+ end
1260
+ end
1261
+ result + (objects - result)
1262
+ end
1263
+
1264
+ def flow_auxiliary_object?(object)
1265
+ %w[Microflows$Annotation Microflows$MicroflowParameter].include?(object["$Type"])
1266
+ end
1267
+
1268
+ def flow_object_signature(object)
1269
+ [object["$Type"], object.dig("Action", "$Type")]
1270
+ end
1271
+
1272
+ def flow_edge_signature(flow)
1273
+ [
1274
+ flow["OriginPointer"], flow["DestinationPointer"],
1275
+ flow["IsErrorHandler"] == true,
1276
+ array_items(flow["CaseValues"]).first&.dig("$Type") ||
1277
+ flow.dig("NewCaseValue", "$Type"),
1278
+ array_items(flow["CaseValues"]).first&.dig("Value") ||
1279
+ flow.dig("NewCaseValue", "Value")
1280
+ ]
1281
+ end
1282
+
1283
+ def stable_id(*parts)
1284
+ hex = Digest::SHA1.hexdigest(parts.join(":"))
1285
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
1286
+ end
1287
+
1288
+ def find_named(mpr, containment, parent_id, name)
1289
+ mpr.units_by_containment(containment).find do |raw|
1290
+ raw["ContainerID"] == parent_id && mpr.parse_contents(raw)["Name"] == name
1291
+ end
1292
+ end
1293
+
1294
+ def documents_by_name(mpr, module_id)
1295
+ collect_documents(mpr, module_id).group_by do |raw|
1296
+ mpr.parse_contents(raw)["Name"]
1297
+ end
1298
+ end
1299
+
1300
+ def collect_documents(mpr, parent_id)
1301
+ mpr.children_of(parent_id).flat_map do |raw|
1302
+ case raw["ContainmentName"]
1303
+ when "Documents"
1304
+ [raw]
1305
+ when "Folders"
1306
+ collect_documents(mpr, raw.fetch("UnitID"))
1307
+ else
1308
+ []
1309
+ end
1310
+ end
1311
+ end
1312
+
1313
+ def module_doc(name, index, previous: nil)
1314
+ current = previous || {}
1315
+ current.merge(
1316
+ "$ID" => current["$ID"] || SecureRandom.uuid,
1317
+ "$Type" => current["$Type"] || "Projects$ModuleImpl",
1318
+ "AppStoreGuid" => current.fetch("AppStoreGuid", ""),
1319
+ "AppStoreVersion" => current.fetch("AppStoreVersion", ""),
1320
+ "AppStoreVersionGuid" => current.fetch("AppStoreVersionGuid", ""),
1321
+ "FromAppStore" => current.fetch("FromAppStore", false),
1322
+ "Name" => name,
1323
+ "NewSortIndex" => current.fetch("NewSortIndex", index.to_f)
1324
+ ).tap do |doc|
1325
+ doc.delete('SortIndex')
1326
+ doc.delete('ExportLevel')
1327
+ end
1328
+ end
1329
+
1330
+ def entity_doc(entity, module_name, previous, index, access_associations: [])
1331
+ id = previous&.dig("$ID") || SecureRandom.uuid
1332
+ attrs_key = native_key(previous, "attributes", "Attributes")
1333
+ rules_key = native_key(previous, "accessRules", "AccessRules")
1334
+ events_key = native_key(previous, "eventHandlers", "EventHandlers")
1335
+ indexes_key = native_key(previous, "indexes", "Indexes")
1336
+ validation_key = native_key(previous, "validationRules", "ValidationRules")
1337
+ generalization_key = native_existing_key(
1338
+ previous, 'generalization', 'Generalization', 'maybeGeneralization', 'MaybeGeneralization'
1339
+ ) || 'MaybeGeneralization'
1340
+ previous_attrs = array_items(previous&.dig(attrs_key)).to_h do |attribute|
1341
+ [attribute["name"] || attribute["Name"], attribute]
1342
+ end
1343
+ attrs = entity.fetch(:attributes).map do |attr|
1344
+ attribute_doc(attr, previous_attrs[attr.fetch(:name)])
1345
+ end
1346
+ attribute_ids = attrs.to_h { [_1['Name'] || _1.fetch('name'), _1.fetch('$ID')] }
1347
+ rules_declared = !entity[:access_rules].nil?
1348
+ access_rules = if rules_declared
1349
+ IO::BsonCodec.build_array(
1350
+ entity.fetch(:access_rules).map do |rule|
1351
+ access_rule_doc(
1352
+ rule, module_name, entity.fetch(:name),
1353
+ attributes: entity.fetch(:attributes).map { _1.fetch(:name) },
1354
+ associations: access_associations
1355
+ )
1356
+ end
1357
+ )
1358
+ else
1359
+ previous&.dig(rules_key) || IO::BsonCodec.build_array([])
1360
+ end
1361
+ doc = (previous || {}).merge(
1362
+ "$ID" => id, "$Type" => "DomainModels$EntityImpl",
1363
+ "Name" => entity.fetch(:name), "Documentation" => entity.fetch(:documentation, ""),
1364
+ "GUID" => previous&.dig("GUID") || SecureRandom.uuid,
1365
+ "Location" => previous&.dig("Location") || "#{(index % 4) * 220};#{(index / 4) * 160}",
1366
+ "Image" => previous&.fetch("Image", "") || "",
1367
+ "IsRemote" => previous&.fetch("IsRemote", false) || false,
1368
+ "RemoteSource" => previous&.fetch("RemoteSource", "") || ""
1369
+ )
1370
+ doc[attrs_key] = IO::BsonCodec.build_array(attrs)
1371
+ doc[rules_key] = access_rules
1372
+ doc[validation_key] = validation_rules_doc(
1373
+ entity, module_name, previous&.dig(validation_key)
1374
+ )
1375
+ if entity[:indexes].nil?
1376
+ doc[indexes_key] ||= IO::BsonCodec.build_array([])
1377
+ else
1378
+ doc[indexes_key] = IO::BsonCodec.build_array(
1379
+ entity.fetch(:indexes).map { index_doc(_1, attribute_ids:) }
1380
+ )
1381
+ end
1382
+ if entity[:lifecycle].nil?
1383
+ doc[events_key] ||= IO::BsonCodec.build_array([])
1384
+ else
1385
+ doc[events_key] = IO::BsonCodec.build_array(entity.fetch(:lifecycle).map { lifecycle_doc(_1) })
1386
+ end
1387
+ if entity[:generalization]
1388
+ doc[generalization_key] = generalization_doc(entity.fetch(:generalization))
1389
+ elsif entity[:system_members]
1390
+ doc[generalization_key] = no_generalization(
1391
+ entity.fetch(:persistable, true), **entity.fetch(:system_members)
1392
+ )
1393
+ elsif previous.nil? || previous.key?(generalization_key)
1394
+ doc[generalization_key] ||= no_generalization(entity.fetch(:persistable, true))
1395
+ end
1396
+ doc
1397
+ end
1398
+
1399
+ def attribute_doc(attr, previous)
1400
+ storage_type = Model::Attribute::TYPE_MAP.fetch(attr.fetch(:type))
1401
+ type_key = native_existing_key(previous, "type", "Type", "newType", "NewType") || "NewType"
1402
+ value_key = native_existing_key(previous, "value", "Value") || "Value"
1403
+ name_key = native_key(previous, "name", "Name")
1404
+ documentation_key = native_key(previous, "documentation", "Documentation")
1405
+ previous_type = previous&.dig(type_key)
1406
+ type_doc = if previous_type.is_a?(Hash) && previous_type["$Type"] == storage_type
1407
+ previous_type
1408
+ else
1409
+ { "$ID" => SecureRandom.uuid, "$Type" => storage_type }
1410
+ end
1411
+ type_doc = type_doc.merge("Enumeration" => attr[:enumeration].to_s) if attr[:enumeration]
1412
+ if attr[:length]
1413
+ length_key = native_key(previous_type, 'length', 'Length')
1414
+ type_doc = type_doc.merge(length_key => Integer(attr[:length]))
1415
+ end
1416
+ if attr.key?(:localize_date)
1417
+ localize_key = native_key(previous_type, 'localizeDate', 'LocalizeDate')
1418
+ type_doc = type_doc.merge(localize_key => (attr[:localize_date] == true))
1419
+ end
1420
+ previous_value = previous&.dig(value_key)
1421
+ value_doc = if previous_value && !attr.key?(:default)
1422
+ previous_value
1423
+ elsif previous_value.is_a?(Hash) && previous_value["$Type"] == "DomainModels$StoredValue"
1424
+ updated = previous_value.dup
1425
+ default_key = native_key(previous_value, "defaultValue", "DefaultValue")
1426
+ updated[default_key] = attr.fetch(:default, "").to_s
1427
+ updated
1428
+ else
1429
+ {
1430
+ "$ID" => SecureRandom.uuid, "$Type" => "DomainModels$StoredValue",
1431
+ "DefaultValue" => attr.fetch(:default, "").to_s
1432
+ }
1433
+ end
1434
+ doc = (previous || {}).merge(
1435
+ "$ID" => previous&.dig("$ID") || SecureRandom.uuid,
1436
+ "$Type" => "DomainModels$Attribute",
1437
+ "GUID" => previous&.dig("GUID") || SecureRandom.uuid
1438
+ )
1439
+ doc[name_key] = attr.fetch(:name)
1440
+ doc[documentation_key] = attr.fetch(:documentation, "")
1441
+ doc[type_key] = type_doc
1442
+ doc[value_key] = value_doc
1443
+ doc
1444
+ end
1445
+
1446
+ def native_key(hash, lower, upper)
1447
+ return upper unless hash
1448
+ return lower if hash.key?(lower)
1449
+ return upper if hash.key?(upper)
1450
+
1451
+ upper
1452
+ end
1453
+
1454
+ def native_existing_key(hash, *keys)
1455
+ return nil unless hash
1456
+
1457
+ keys.find { hash.key?(_1) }
1458
+ end
1459
+
1460
+ def no_generalization(persistable, owner: false, created_date: false,
1461
+ changed_date: false, changed_by: false)
1462
+ { "$ID" => SecureRandom.uuid, "$Type" => "DomainModels$NoGeneralization",
1463
+ "Persistable" => persistable, "HasChangedDateAttr" => changed_date,
1464
+ "HasCreatedDateAttr" => created_date, "HasOwnerAttr" => owner,
1465
+ "HasChangedByAttr" => changed_by }
1466
+ end
1467
+
1468
+ def generalization_doc(target)
1469
+ {
1470
+ '$ID' => SecureRandom.uuid, '$Type' => 'DomainModels$Generalization',
1471
+ 'Generalization' => target.to_s
1472
+ }
1473
+ end
1474
+
1475
+ def validation_rules_doc(entity, module_name, previous)
1476
+ existing = array_items(previous)
1477
+ declarations = entity.fetch(:attributes).select do |attribute|
1478
+ attribute.key?(:required) || attribute.key?(:unique)
1479
+ end
1480
+ return previous || IO::BsonCodec.build_array([]) if declarations.empty?
1481
+
1482
+ kept = existing.reject do |rule|
1483
+ declarations.any? { validation_declaration_matches?(_1, rule) }
1484
+ end
1485
+ generated = declarations.flat_map do |attribute|
1486
+ %i[required unique].filter_map do |kind|
1487
+ next unless attribute[kind] == true
1488
+
1489
+ existing.find { validation_rule_matches?(_1, attribute.fetch(:name), kind) } ||
1490
+ validation_rule_doc(module_name, entity.fetch(:name), attribute.fetch(:name), kind)
1491
+ end
1492
+ end
1493
+ IO::BsonCodec.build_array(kept + generated)
1494
+ end
1495
+
1496
+ def validation_declaration_matches?(attribute, rule)
1497
+ %i[required unique].any? do |kind|
1498
+ attribute.key?(kind) && validation_rule_matches?(rule, attribute.fetch(:name), kind)
1499
+ end
1500
+ end
1501
+
1502
+ def validation_rule_matches?(rule, attribute_name, kind)
1503
+ rule['Attribute'].to_s.split('.').last == attribute_name &&
1504
+ rule.dig('RuleInfo', '$Type').to_s.end_with?("#{kind.to_s.capitalize}RuleInfo")
1505
+ end
1506
+
1507
+ def validation_rule_doc(module_name, entity_name, attribute_name, kind)
1508
+ {
1509
+ '$ID' => SecureRandom.uuid, '$Type' => 'DomainModels$ValidationRule',
1510
+ 'Attribute' => "#{module_name}.#{entity_name}.#{attribute_name}",
1511
+ 'Message' => {
1512
+ '$ID' => SecureRandom.uuid, '$Type' => 'Texts$Text',
1513
+ 'Items' => IO::BsonCodec.build_array([{
1514
+ '$ID' => SecureRandom.uuid, '$Type' => 'Texts$Translation',
1515
+ 'LanguageCode' => 'en_US',
1516
+ 'Text' => "#{attribute_name} #{kind == :required ? 'is required' : 'must be unique'}"
1517
+ }])
1518
+ },
1519
+ 'RuleInfo' => {
1520
+ '$ID' => SecureRandom.uuid,
1521
+ '$Type' => "DomainModels$#{kind.to_s.capitalize}RuleInfo"
1522
+ }
1523
+ }
1524
+ end
1525
+
1526
+ def index_doc(index, attribute_ids:)
1527
+ members = index.fetch(:attributes).zip(index.fetch(:ascending)).map do |attribute, ascending|
1528
+ {
1529
+ '$ID' => SecureRandom.uuid, '$Type' => 'DomainModels$IndexedAttribute',
1530
+ 'Type' => 'Normal',
1531
+ 'AttributePointer' => binary_uuid(attribute_ids.fetch(attribute)),
1532
+ 'AssociationPointer' => binary_uuid('00000000-0000-0000-0000-000000000000'),
1533
+ 'Ascending' => ascending
1534
+ }
1535
+ end
1536
+ id = SecureRandom.uuid
1537
+ {
1538
+ '$ID' => id, '$Type' => 'DomainModels$EntityIndex',
1539
+ 'GUID' => id,
1540
+ 'Attributes' => IO::BsonCodec.build_array(members, marker: 2),
1541
+ 'IncludeInOffline' => index.fetch(:include_offline, false)
1542
+ }
1543
+ end
1544
+
1545
+ def binary_uuid(value)
1546
+ return value if value.is_a?(BSON::Binary)
1547
+
1548
+ BSON::Binary.new(IO::BsonCodec.uuid_to_blob(value), :generic)
1549
+ end
1550
+
1551
+ def access_rule_doc(rule, module_name, entity_name, attributes: [], associations: [])
1552
+ read = rule.fetch(:read, :none)
1553
+ write = rule.fetch(:write, :none)
1554
+ default_rights = rule[:default_rights] || access_default_rights(read, write)
1555
+ members = if rule[:members]
1556
+ exact_access_member_docs(rule.fetch(:members), module_name, entity_name)
1557
+ else
1558
+ access_member_docs(
1559
+ read, write, module_name, entity_name, attributes:, associations:
1560
+ )
1561
+ end
1562
+ {
1563
+ "$ID" => SecureRandom.uuid,
1564
+ "$Type" => "DomainModels$AccessRule",
1565
+ "Documentation" => "",
1566
+ "AllowedModuleRoles" => IO::BsonCodec.build_array(rule.fetch(:roles), marker: 1),
1567
+ "AllowCreate" => rule.fetch(:create, false),
1568
+ "AllowDelete" => rule.fetch(:delete, false),
1569
+ "DefaultMemberAccessRights" => default_rights,
1570
+ "MemberAccesses" => IO::BsonCodec.build_array(members),
1571
+ "XPathConstraint" => rule.fetch(:xpath, "")
1572
+ }
1573
+ end
1574
+
1575
+ def exact_access_member_docs(members, module_name, entity_name)
1576
+ members.map do |member|
1577
+ association = member.fetch(:kind, :attribute).to_sym == :association
1578
+ name = member.fetch(:name).to_s
1579
+ reference = member[:reference].to_s
1580
+ {
1581
+ "$ID" => SecureRandom.uuid,
1582
+ "$Type" => "DomainModels$MemberAccess",
1583
+ "Association" => association ? qualified_member(reference, "#{module_name}.#{name}") : "",
1584
+ "Attribute" => association ? "" : qualified_member(reference, "#{module_name}.#{entity_name}.#{name}"),
1585
+ "AccessRights" => member.fetch(:rights).to_s
1586
+ }
1587
+ end
1588
+ end
1589
+
1590
+ def qualified_member(reference, fallback)
1591
+ reference.empty? ? fallback : reference
1592
+ end
1593
+
1594
+ def access_default_rights(read, write)
1595
+ return "ReadWrite" if write == :all
1596
+ return "ReadOnly" if read == :all
1597
+ "None"
1598
+ end
1599
+
1600
+ def access_member_docs(read, write, module_name, entity_name, attributes: [], associations: [])
1601
+ explicit_writes = write.is_a?(Array) ? write.map(&:to_s) : []
1602
+ explicit_reads = read.is_a?(Array) ? read.map(&:to_s) : []
1603
+ all_members = (attributes + associations + explicit_reads + explicit_writes).map(&:to_s).uniq
1604
+ all_members.map do |member|
1605
+ rights = if write == :all || explicit_writes.include?(member)
1606
+ 'ReadWrite'
1607
+ elsif read == :all || explicit_reads.include?(member)
1608
+ 'ReadOnly'
1609
+ else
1610
+ 'None'
1611
+ end
1612
+ association = associations.map(&:to_s).include?(member)
1613
+ {
1614
+ "$ID" => SecureRandom.uuid,
1615
+ "$Type" => "DomainModels$MemberAccess",
1616
+ "Association" => association ? "#{module_name}.#{member}" : "",
1617
+ "Attribute" => association ? "" : "#{module_name}.#{entity_name}.#{member}",
1618
+ "AccessRights" => rights
1619
+ }
1620
+ end
1621
+ end
1622
+
1623
+ def association_doc(association, from_id:, to_id:, previous:)
1624
+ doc = (previous || {}).merge(
1625
+ "$ID" => previous&.dig("$ID") || SecureRandom.uuid,
1626
+ "$Type" => "DomainModels$Association",
1627
+ "Name" => association.fetch(:name),
1628
+ "Documentation" => association.fetch(:documentation, ''),
1629
+ "ParentPointer" => from_id,
1630
+ "ChildPointer" => to_id,
1631
+ "ParentConnection" => "0;50",
1632
+ "ChildConnection" => "100;50",
1633
+ "GUID" => previous&.dig("GUID") || SecureRandom.uuid,
1634
+ "Type" => association.fetch(:type).to_s,
1635
+ "Owner" => association.fetch(:owner, :Default).to_s,
1636
+ "StorageFormat" => association[:storage_format]&.to_s ||
1637
+ (association.fetch(:type) == :ReferenceSet ? "Table" : "Column")
1638
+ )
1639
+ behavior = previous&.dig('DeleteBehavior') || previous&.dig('deleteBehavior') || {}
1640
+ parent_key = native_key(behavior, 'parentDeleteBehavior', 'ParentDeleteBehavior')
1641
+ child_key = native_key(behavior, 'childDeleteBehavior', 'ChildDeleteBehavior')
1642
+ parent_error_key = native_key(behavior, 'parentErrorMessage', 'ParentErrorMessage')
1643
+ child_error_key = native_key(behavior, 'childErrorMessage', 'ChildErrorMessage')
1644
+ behavior = behavior.merge(
1645
+ '$ID' => behavior['$ID'] || SecureRandom.uuid,
1646
+ '$Type' => 'DomainModels$DeleteBehavior',
1647
+ parent_key => association.fetch(:parent_delete, :DeleteMeButKeepReferences).to_s,
1648
+ child_key => association.fetch(:child_delete, :DeleteMeButKeepReferences).to_s,
1649
+ parent_error_key => behavior[parent_error_key], child_error_key => behavior[child_error_key]
1650
+ )
1651
+ doc[native_key(previous, 'deleteBehavior', 'DeleteBehavior')] = behavior
1652
+ doc
1653
+ end
1654
+
1655
+ def cross_association_doc(association, from_id:, target:, previous:)
1656
+ doc = association_doc(
1657
+ association, from_id:, to_id: nil, previous:
1658
+ ).merge(
1659
+ '$Type' => 'DomainModels$CrossAssociation',
1660
+ 'Child' => target,
1661
+ 'ParentPointer' => from_id
1662
+ )
1663
+ doc.delete('ChildPointer')
1664
+ doc.delete('ParentConnection')
1665
+ doc.delete('ChildConnection')
1666
+ doc
1667
+ end
1668
+
1669
+ def lifecycle_doc(callback)
1670
+ event, moment = callback.fetch(:event).to_s.split("_", 2)
1671
+ {
1672
+ "$ID" => SecureRandom.uuid,
1673
+ "$Type" => "DomainModels$EventHandler",
1674
+ "Event" => event == "before" || event == "after" ? moment.capitalize : event.capitalize,
1675
+ "Moment" => event.capitalize,
1676
+ "Microflow" => callback.fetch(:handler),
1677
+ "PassEventObject" => true,
1678
+ "RaiseErrorOnFalse" => event == "before"
1679
+ }
1680
+ end
1681
+
1682
+ def page_doc(page, module_name = nil)
1683
+ if page[:deep_structure].is_a?(Hash)
1684
+ return page[:deep_structure].merge(
1685
+ "$ID" => SecureRandom.uuid,
1686
+ "Name" => page.fetch(:name),
1687
+ "__mxrb_allowed_roles_declared" => !page[:allowed_roles].nil?,
1688
+ "__mxrb_deep_structure_declared" => true
1689
+ )
1690
+ end
1691
+
1692
+ context_entity = page_context_entity(page, module_name)
1693
+ widgets = page.fetch(:widgets, []).map { widget_doc(_1, context_entity:) }
1694
+ # Backwards-compatible page-level bindings target widgets by name.
1695
+ page.fetch(:events, []).each do |event|
1696
+ next unless event[:target]
1697
+ target = widgets.find { _1["Name"] == event[:target].to_s }
1698
+ target[event_property(event.fetch(:event))] = client_action_doc(event) if target
1699
+ end
1700
+ content = if page[:data_source]
1701
+ [data_view_doc(page.fetch(:data_source), widgets)]
1702
+ else
1703
+ widgets
1704
+ end
1705
+ content = content.map { legacy_widget_tree(_1) } if oldest_layout_contract?
1706
+ roles_declared = !page[:allowed_roles].nil?
1707
+ argument = {
1708
+ "$ID" => SecureRandom.uuid, "$Type" => "Forms$FormCallArgument",
1709
+ "Parameter" => "#{page.fetch(:layout)}.Main"
1710
+ }
1711
+ if oldest_layout_contract?
1712
+ argument['Widget'] = legacy_single_widget(content)
1713
+ else
1714
+ argument['Widgets'] = IO::BsonCodec.build_array(content, marker: 2)
1715
+ end
1716
+ form_call = {
1717
+ "$ID" => SecureRandom.uuid, "$Type" => "Forms$LayoutCall",
1718
+ "Arguments" => IO::BsonCodec.build_array([argument], marker: 2),
1719
+ "Form" => page.fetch(:layout)
1720
+ }
1721
+ doc = { "$ID" => SecureRandom.uuid, "$Type" => "Forms$Page", "Name" => page.fetch(:name),
1722
+ "Documentation" => "", "Url" => "", "FormCall" => form_call,
1723
+ "Title" => text_doc(page.fetch(:title)), "MarkAsUsed" => false, "Excluded" => false,
1724
+ "AllowedModuleRoles" => IO::BsonCodec.build_array(Array(page[:allowed_roles]), marker: 1),
1725
+ "__mxrb_allowed_roles_declared" => roles_declared,
1726
+ "__mxrb_deep_structure_declared" => false,
1727
+ "Parameters" => IO::BsonCodec.build_array([]),
1728
+ "PopupWidth" => page.fetch(:popup) ? 600 : 0,
1729
+ "PopupHeight" => page.fetch(:popup) ? 400 : 0,
1730
+ "PopupResizable" => page.fetch(:popup), "ExportLevel" => "Hidden" }
1731
+ doc
1732
+ end
1733
+
1734
+ def legacy_widget_tree(value)
1735
+ transformed = value.transform_values do |child|
1736
+ case child
1737
+ when Hash then legacy_widget_tree(child)
1738
+ when Array then child.map { _1.is_a?(Hash) ? legacy_widget_tree(_1) : _1 }
1739
+ else child
1740
+ end
1741
+ end
1742
+ singular = {
1743
+ 'Forms$DivContainer' => [%w[Widgets Widget]],
1744
+ 'Forms$DataView' => [%w[Widgets Widget], %w[FooterWidgets FooterWidget]],
1745
+ 'Forms$TabPage' => [%w[Widgets Widget]]
1746
+ }.fetch(transformed['$Type'], [])
1747
+ singular.each do |plural, single|
1748
+ next unless transformed.key?(plural)
1749
+
1750
+ transformed[single] = legacy_single_widget(array_items(transformed.delete(plural)))
1751
+ end
1752
+ transformed
1753
+ end
1754
+
1755
+ def legacy_single_widget(widgets)
1756
+ return nil if widgets.empty?
1757
+ return widgets.first if widgets.one?
1758
+
1759
+ {
1760
+ '$ID' => SecureRandom.uuid, '$Type' => 'Forms$VerticalFlow',
1761
+ 'Widgets' => IO::BsonCodec.build_array(widgets, marker: 2),
1762
+ 'Name' => 'verticalFlow', 'Class' => '', 'Style' => '', 'TabIndex' => 0
1763
+ }
1764
+ end
1765
+
1766
+ def menu_doc(menu)
1767
+ if menu[:deep_structure].is_a?(Hash)
1768
+ return menu[:deep_structure].merge(
1769
+ "$ID" => SecureRandom.uuid,
1770
+ "Name" => menu.fetch(:name)
1771
+ )
1772
+ end
1773
+
1774
+ {
1775
+ "$ID" => SecureRandom.uuid,
1776
+ "$Type" => "Menus$MenuDocument",
1777
+ "Name" => menu.fetch(:name),
1778
+ "Documentation" => "",
1779
+ "Excluded" => false,
1780
+ "ItemCollection" => {
1781
+ "$ID" => SecureRandom.uuid,
1782
+ "$Type" => "Menus$MenuItemCollection",
1783
+ "Items" => IO::BsonCodec.build_array(menu.fetch(:items, []).map { menu_item_doc(_1) })
1784
+ }
1785
+ }
1786
+ end
1787
+
1788
+ CONSTANT_TYPE_MAP = {
1789
+ string: "DataTypes$StringType", integer: "DataTypes$IntegerType",
1790
+ boolean: "DataTypes$BooleanType", decimal: "DataTypes$DecimalType",
1791
+ datetime: "DataTypes$DateTimeType"
1792
+ }.freeze
1793
+
1794
+ SCHEDULED_EVENT_INTERVAL_MAP = {
1795
+ milliseconds: "Millisecond", seconds: "Second", minutes: "Minute",
1796
+ hours: "Hour", days: "Day", weeks: "Week", months: "Month", years: "Year"
1797
+ }.freeze
1798
+
1799
+ def enumeration_doc(enum)
1800
+ values = Array(enum.fetch(:values, [])).map do |val|
1801
+ {
1802
+ "$ID" => SecureRandom.uuid,
1803
+ "$Type" => "Enumerations$EnumerationValue",
1804
+ "Name" => val.fetch(:name),
1805
+ "Caption" => { "$ID" => SecureRandom.uuid, "$Type" => "Texts$Text", "Items" => [] },
1806
+ "Image" => "",
1807
+ "ExportLevel" => "Hidden"
1808
+ }.tap do |value|
1809
+ value["Caption"]["Items"] = IO::BsonCodec.build_array([{
1810
+ "$ID" => SecureRandom.uuid, "$Type" => "Texts$Translation",
1811
+ "LanguageCode" => "en_US", "Text" => val[:caption] || val.fetch(:name)
1812
+ }])
1813
+ end
1814
+ end
1815
+ {
1816
+ "$ID" => SecureRandom.uuid,
1817
+ "$Type" => "Enumerations$Enumeration",
1818
+ "Name" => enum.fetch(:name),
1819
+ "Documentation" => enum.fetch(:documentation, ""),
1820
+ "ExportLevel" => "Hidden",
1821
+ "Values" => IO::BsonCodec.build_array(values)
1822
+ }
1823
+ end
1824
+
1825
+ def constant_doc(constant)
1826
+ type_sym = constant.fetch(:type).to_sym
1827
+ type_str = CONSTANT_TYPE_MAP.fetch(type_sym) do
1828
+ raise ArgumentError, "unsupported constant type #{type_sym.inspect}; " \
1829
+ "use one of: #{CONSTANT_TYPE_MAP.keys.join(', ')}"
1830
+ end
1831
+ {
1832
+ "$ID" => SecureRandom.uuid,
1833
+ "$Type" => "Constants$Constant",
1834
+ "Name" => constant.fetch(:name),
1835
+ "Documentation" => constant.fetch(:documentation, ""),
1836
+ "ExportLevel" => "Hidden",
1837
+ "Type" => { "$ID" => SecureRandom.uuid, "$Type" => type_str },
1838
+ "DefaultValue" => constant.fetch(:value, "").to_s
1839
+ }
1840
+ end
1841
+
1842
+ def scheduled_event_doc(event)
1843
+ unit_sym = event.fetch(:unit).to_sym
1844
+ interval_type = SCHEDULED_EVENT_INTERVAL_MAP.fetch(unit_sym) do
1845
+ raise ArgumentError, "unsupported scheduled event unit #{unit_sym.inspect}; " \
1846
+ "use one of: #{SCHEDULED_EVENT_INTERVAL_MAP.keys.join(', ')}"
1847
+ end
1848
+ {
1849
+ "$ID" => SecureRandom.uuid,
1850
+ "$Type" => "ScheduledEvents$ScheduledEvent",
1851
+ "Name" => event.fetch(:name),
1852
+ "Documentation" => event.fetch(:documentation, ""),
1853
+ "ExportLevel" => "Hidden",
1854
+ "Microflow" => event.fetch(:microflow),
1855
+ "StartDateTime" => Time.utc(2000, 1, 1),
1856
+ "TimeZone" => "UTC",
1857
+ "Schedule" => scheduled_event_schedule_doc(event),
1858
+ "OnOverlap" => "SkipNext",
1859
+ "Enabled" => event.fetch(:enabled, true),
1860
+ "IntervalType" => interval_type,
1861
+ "Interval" => event.fetch(:interval, 1)
1862
+ }
1863
+ end
1864
+
1865
+ def scheduled_event_schedule_doc(event)
1866
+ interval = Integer(event.fetch(:interval, 1))
1867
+ case event.fetch(:unit).to_sym
1868
+ when :minutes
1869
+ { '$ID' => SecureRandom.uuid, '$Type' => 'ScheduledEvents$MinuteSchedule',
1870
+ 'Multiplier' => interval }
1871
+ when :hours
1872
+ { '$ID' => SecureRandom.uuid, '$Type' => 'ScheduledEvents$HourSchedule',
1873
+ 'Multiplier' => interval, 'MinuteOffset' => 0 }
1874
+ when :days
1875
+ raise ArgumentError, 'day schedules support interval: 1' unless interval == 1
1876
+
1877
+ { '$ID' => SecureRandom.uuid, '$Type' => 'ScheduledEvents$DaySchedule',
1878
+ 'HourOfDay' => 0, 'MinuteOfHour' => 0 }
1879
+ else
1880
+ raise ArgumentError, 'modern schedules support minutes, hours, or days'
1881
+ end
1882
+ end
1883
+
1884
+ def project_security_doc(security)
1885
+ role_definitions = security.fetch(:user_roles, [])
1886
+ if role_definitions.empty?
1887
+ role_definitions = [
1888
+ { name: "Administrator", admin: true, module_roles: [] }
1889
+ ]
1890
+ end
1891
+ roles = role_definitions.map { user_role_doc(_1) }
1892
+ default_admin = role_definitions.find { _1[:admin] == true }&.fetch(:name, nil)
1893
+ default_admin ||= roles.first.fetch("Name")
1894
+ password_policy = {
1895
+ "$ID" => SecureRandom.uuid,
1896
+ "$Type" => "Security$PasswordPolicySettings",
1897
+ "MinimumLength" => 6,
1898
+ "RequireDigit" => true,
1899
+ "RequireMixedCase" => true,
1900
+ "RequireSymbol" => false
1901
+ }
1902
+ security.fetch(:password_policy, {}).to_h.each do |key, value|
1903
+ native_key = {
1904
+ minimum_length: "MinimumLength",
1905
+ require_digit: "RequireDigit",
1906
+ require_mixed_case: "RequireMixedCase",
1907
+ require_symbol: "RequireSymbol"
1908
+ }.fetch(key.to_sym, key.to_s)
1909
+ password_policy[native_key] = value
1910
+ end
1911
+ {
1912
+ "$ID" => SecureRandom.uuid,
1913
+ "$Type" => "Security$ProjectSecurity",
1914
+ "SecurityLevel" => security[:security_level] || "CheckNothing",
1915
+ "CheckSecurity" => true,
1916
+ "AdminUserName" => "MxAdmin",
1917
+ "AdminPassword" => "1",
1918
+ "AdminUserRole" => security[:admin_user_role] || default_admin,
1919
+ "EnableDemoUsers" => security.fetch(:demo_users_enabled, false) == true,
1920
+ "EnableGuestAccess" => security.fetch(:guest_access_enabled, false) == true,
1921
+ "GuestUserRole" => security[:guest_user_role].to_s,
1922
+ "SignInMicroflow" => security[:sign_in_microflow].to_s,
1923
+ "StrictMode" => false,
1924
+ "StrictPageUrlCheck" => true,
1925
+ "UserRoles" => IO::BsonCodec.build_array(roles, marker: 2),
1926
+ "DemoUsers" => IO::BsonCodec.build_array([], marker: 2),
1927
+ "FileDocumentAccess" => access_container("Security$FileDocumentAccessRuleContainer"),
1928
+ "ImageAccess" => access_container("Security$ImageAccessRuleContainer"),
1929
+ "PasswordPolicySettings" => password_policy
1930
+ }
1931
+ end
1932
+
1933
+ def user_role_doc(role)
1934
+ {
1935
+ "$ID" => SecureRandom.uuid,
1936
+ "$Type" => "Security$UserRole",
1937
+ "Name" => role.fetch(:name),
1938
+ "Description" => "",
1939
+ "CheckSecurity" => true,
1940
+ "GUID" => BSON::Binary.new(IO::BsonCodec.uuid_to_blob(SecureRandom.uuid)),
1941
+ "ManageableRoles" => IO::BsonCodec.build_array([], marker: 1),
1942
+ "ManageAllRoles" => role[:admin] == true,
1943
+ "ManageUsersWithoutRoles" => false,
1944
+ "ModuleRoles" => IO::BsonCodec.build_array(role.fetch(:module_roles, []), marker: 1)
1945
+ }
1946
+ end
1947
+
1948
+ def module_security_doc(roles, legacy: false)
1949
+ {
1950
+ "$ID" => SecureRandom.uuid,
1951
+ "$Type" => "Security$ModuleSecurity",
1952
+ "ModuleRoles" => IO::BsonCodec.build_array(
1953
+ roles.map { module_role_doc(_1) }, marker: legacy ? 3 : 2
1954
+ )
1955
+ }
1956
+ end
1957
+
1958
+ def module_role_doc(role)
1959
+ {
1960
+ "$ID" => SecureRandom.uuid,
1961
+ "$Type" => "Security$ModuleRole",
1962
+ "Name" => role.fetch(:name),
1963
+ "Description" => role.fetch(:description, "")
1964
+ }
1965
+ end
1966
+
1967
+ def access_container(type)
1968
+ {
1969
+ "$ID" => SecureRandom.uuid,
1970
+ "$Type" => type,
1971
+ "AccessRules" => IO::BsonCodec.build_array([])
1972
+ }
1973
+ end
1974
+
1975
+ def menu_item_doc(item)
1976
+ {
1977
+ "$ID" => SecureRandom.uuid,
1978
+ "$Type" => "Menus$MenuItem",
1979
+ "Caption" => text_doc(item.fetch(:caption)),
1980
+ "Action" => item[:page] ? form_action_doc(item.fetch(:page)) : no_action_doc,
1981
+ "Icon" => nil,
1982
+ "Items" => IO::BsonCodec.build_array(item.fetch(:items, []).map { menu_item_doc(_1) })
1983
+ }
1984
+ end
1985
+
1986
+ def form_action_doc(page)
1987
+ {
1988
+ "$ID" => SecureRandom.uuid,
1989
+ "$Type" => "Forms$FormAction",
1990
+ "DisabledDuringExecution" => false,
1991
+ "FormSettings" => {
1992
+ "$ID" => SecureRandom.uuid,
1993
+ "$Type" => "Forms$FormSettings",
1994
+ "Form" => page.to_s,
1995
+ "ParameterMappings" => IO::BsonCodec.build_array([], marker: 2),
1996
+ "TitleOverride" => nil
1997
+ },
1998
+ "NumberOfPagesToClose2" => "",
1999
+ "PagesForSpecializations" => IO::BsonCodec.build_array([], marker: 2)
2000
+ }
2001
+ end
2002
+
2003
+ def no_action_doc(disabled: false)
2004
+ {
2005
+ "$ID" => SecureRandom.uuid, "$Type" => "Forms$NoAction",
2006
+ "DisabledDuringExecution" => disabled
2007
+ }
2008
+ end
2009
+
2010
+ def widget_doc(widget = nil, context_entity: nil, **keyword_widget)
2011
+ widget ||= keyword_widget
2012
+ type = widget.fetch(:type)
2013
+ widget = qualify_page_widget_attribute(widget, context_entity)
2014
+
2015
+ if type == :snippet
2016
+ return snippet_call_doc(widget)
2017
+ end
2018
+ return pluggable_widget_doc(widget, data_grid2_descriptor) if type == :data_grid
2019
+ if %i[drop_down reference_selector].include?(type)
2020
+ return pluggable_widget_doc(widget, combo_box_descriptor)
2021
+ end
2022
+ if type == :pluggable_widget
2023
+ options = widget.fetch(:options)
2024
+ return pluggable_widget_doc(widget, {
2025
+ id: options.fetch(:widget_id), name: options.fetch(:widget_name),
2026
+ studio_category: "Custom", studio_pro_category: "Custom"
2027
+ })
2028
+ end
2029
+ if type == :native_widget
2030
+ options = widget.fetch(:options)
2031
+ return deep_copy(options.fetch(:deep_structure)).merge(
2032
+ "$ID" => SecureRandom.uuid, "$Type" => options.fetch(:native_type),
2033
+ "Name" => widget.fetch(:name)
2034
+ )
2035
+ end
2036
+
2037
+ options = widget.fetch(:options, {})
2038
+ doc = {
2039
+ "$ID" => SecureRandom.uuid,
2040
+ "$Type" => widget_storage_type(type),
2041
+ "Name" => widget.fetch(:name),
2042
+ "Appearance" => appearance_doc(options.fetch(:class, "")),
2043
+ "Class" => "",
2044
+ "Style" => ""
2045
+ }
2046
+ doc["AttributePath"] = options[:attribute].to_s if options[:attribute]
2047
+ doc["LabelText"] = text_doc(options[:caption]) if input_widget?(type) && options.key?(:caption)
2048
+ doc["Caption"] = text_doc(options[:caption]) if type == :button
2049
+ doc["Content"] = client_template_doc(options[:caption]) if type == :text
2050
+ doc.merge!(modern_widget_properties(type, options))
2051
+
2052
+ if type == :tab_control
2053
+ pages = tab_pages(options[:tabs])
2054
+ doc["TabPages"] = IO::BsonCodec.build_array(pages)
2055
+ doc["DefaultPagePointer"] = pages.first&.fetch("$ID", nil)
2056
+ end
2057
+
2058
+ if type == :container
2059
+ children = Array(widget[:children]).map { widget_doc(_1, context_entity:) }
2060
+ doc["Widgets"] = IO::BsonCodec.build_array(children)
2061
+ doc["Class"] = options[:class].to_s if options[:class]
2062
+ end
2063
+
2064
+ widget.fetch(:events, []).each do |event|
2065
+ doc[event_property(event.fetch(:event))] = client_action_doc(event)
2066
+ end
2067
+ doc
2068
+ end
2069
+
2070
+ def qualify_page_widget_attribute(widget, context_entity)
2071
+ type = widget.fetch(:type).to_sym
2072
+ return widget unless input_widget?(type) || type == :drop_down
2073
+
2074
+ attribute = widget.dig(:options, :attribute)
2075
+ return widget unless attribute
2076
+
2077
+ qualified = qualified_attribute_identifier(attribute, context_entity)
2078
+ options = widget.fetch(:options, {}).merge(attribute: qualified)
2079
+ widget.merge(options:)
2080
+ end
2081
+
2082
+ def page_context_entity(page, module_name)
2083
+ source = page[:data_source]
2084
+ entity = flow_return_entity(source&.dig(:name), module_name)
2085
+ return entity if entity
2086
+
2087
+ attributes = simple_page_attributes(page.fetch(:widgets, []))
2088
+ return if attributes.empty?
2089
+
2090
+ matching = module_entities(module_name).select do |candidate|
2091
+ names = candidate.fetch(:attributes, []).map { _1.fetch(:name).to_s }
2092
+ (attributes - names).empty?
2093
+ end
2094
+ return unless matching.one?
2095
+
2096
+ "#{module_name}.#{matching.first.fetch(:name)}"
2097
+ end
2098
+
2099
+ def simple_page_attributes(widgets)
2100
+ Array(widgets).flat_map do |widget|
2101
+ type = widget.fetch(:type).to_sym
2102
+ attribute = widget.dig(:options, :attribute).to_s
2103
+ own = if (input_widget?(type) || type == :drop_down) &&
2104
+ !attribute.empty? && !attribute.include?('.') && !attribute.include?('/')
2105
+ [attribute]
2106
+ else
2107
+ []
2108
+ end
2109
+ own + simple_page_attributes(widget[:children])
2110
+ end.uniq
2111
+ end
2112
+
2113
+ def flow_return_entity(name, module_name)
2114
+ return if name.to_s.empty?
2115
+
2116
+ owner_name, flow_name = qualified_artifact_parts(name, module_name)
2117
+ owner = @definition.fetch(:modules, []).find { _1.fetch(:name).to_s == owner_name }
2118
+ flow = Array(owner&.fetch(:microflows, [])).find { _1.fetch(:name).to_s == flow_name }
2119
+ return unless flow
2120
+
2121
+ entity = object_entity_type(flow[:return_type], owner_name)
2122
+ return entity if entity
2123
+
2124
+ variables = flow_variable_entities(flow[:body], flow[:parameters], owner_name)
2125
+ variables[flow[:return_variable_name].to_s]
2126
+ end
2127
+
2128
+ def qualified_artifact_parts(name, module_name)
2129
+ value = name.to_s
2130
+ value.include?('.') ? value.split('.', 2) : [module_name.to_s, value]
2131
+ end
2132
+
2133
+ def module_entities(module_name)
2134
+ mod = @definition.fetch(:modules, []).find { _1.fetch(:name).to_s == module_name.to_s }
2135
+ Array(mod&.fetch(:entities, []))
2136
+ end
2137
+
2138
+ def deep_copy(value)
2139
+ case value
2140
+ when Hash then value.to_h { |key, item| [key, deep_copy(item)] }
2141
+ when Array then value.map { deep_copy(_1) }
2142
+ else value
2143
+ end
2144
+ end
2145
+
2146
+ def snippet_call_doc(widget)
2147
+ {
2148
+ "$ID" => SecureRandom.uuid,
2149
+ "$Type" => "Forms$SnippetCallWidget",
2150
+ "Name" => widget.fetch(:name),
2151
+ "Appearance" => appearance_doc, "TabIndex" => 0,
2152
+ "FormCall" => {
2153
+ "$ID" => SecureRandom.uuid,
2154
+ "$Type" => "Forms$SnippetCall",
2155
+ "Form" => widget.dig(:options, :snippet).to_s,
2156
+ "ParameterMappings" => IO::BsonCodec.build_array([], marker: 2)
2157
+ }
2158
+ }
2159
+ end
2160
+
2161
+ def input_widget?(type)
2162
+ %i[text_box number_input text_area check_box date_picker]
2163
+ .include?(type.to_sym)
2164
+ end
2165
+
2166
+ def widget_storage_type(type)
2167
+ {
2168
+ button: "Forms$ActionButton",
2169
+ text_box: "Forms$TextBox",
2170
+ number_input: "Forms$TextBox",
2171
+ text_area: "Forms$TextArea",
2172
+ check_box: "Forms$CheckBox",
2173
+ date_picker: "Forms$DatePicker",
2174
+ text: "Forms$DynamicText",
2175
+ tab_control: "Forms$TabControl",
2176
+ container: "Forms$DivContainer"
2177
+ }.fetch(type.to_sym)
2178
+ end
2179
+
2180
+ def data_grid2_descriptor
2181
+ {
2182
+ id: "com.mendix.widget.web.datagrid.Datagrid", name: "Data grid 2",
2183
+ studio_category: "Data Containers", studio_pro_category: "Data containers"
2184
+ }
2185
+ end
2186
+
2187
+ def combo_box_descriptor
2188
+ {
2189
+ id: "com.mendix.widget.web.combobox.Combobox", name: "Combo box",
2190
+ studio_category: "Input Widgets", studio_pro_category: "Input widgets"
2191
+ }
2192
+ end
2193
+
2194
+ def pluggable_widget_doc(widget, descriptor)
2195
+ definition = WidgetPackage.find(File.dirname(@path), descriptor.fetch(:id))
2196
+ if definition
2197
+ type, object = WidgetPackage.template(definition)
2198
+ else
2199
+ object_type_id = SecureRandom.uuid
2200
+ object = {
2201
+ "$ID" => SecureRandom.uuid, "$Type" => "CustomWidgets$WidgetObject",
2202
+ "Properties" => IO::BsonCodec.build_array([], marker: 2),
2203
+ "TypePointer" => object_type_id
2204
+ }
2205
+ type = {
2206
+ "$ID" => SecureRandom.uuid, "$Type" => "CustomWidgets$CustomWidgetType",
2207
+ "HelpUrl" => "",
2208
+ "ObjectType" => {
2209
+ "$ID" => object_type_id, "$Type" => "CustomWidgets$WidgetObjectType",
2210
+ "PropertyTypes" => IO::BsonCodec.build_array([], marker: 2)
2211
+ },
2212
+ "OfflineCapable" => true, "StudioCategory" => descriptor.fetch(:studio_category),
2213
+ "StudioProCategory" => descriptor.fetch(:studio_pro_category),
2214
+ "SupportedPlatform" => "Web", "WidgetDescription" => "",
2215
+ "WidgetId" => descriptor.fetch(:id), "WidgetName" => descriptor.fetch(:name),
2216
+ "WidgetNeedsEntityContext" => false, "WidgetPluginWidget" => true
2217
+ }
2218
+ end
2219
+ doc = {
2220
+ "$ID" => SecureRandom.uuid, "$Type" => "CustomWidgets$CustomWidget",
2221
+ "Appearance" => appearance_doc(widget.dig(:options, :class).to_s),
2222
+ "ConditionalEditabilitySettings" => nil, "ConditionalVisibilitySettings" => nil,
2223
+ "Editable" => "Always", "LabelTemplate" => nil, "Name" => widget.fetch(:name),
2224
+ "Object" => object,
2225
+ "TabIndex" => 0,
2226
+ "Type" => type,
2227
+ "__mxrb_widget_options" => widget.fetch(:options, {}).merge(__kind: widget.fetch(:type))
2228
+ }
2229
+ options = doc.fetch('__mxrb_widget_options')
2230
+ configure_data_grid2!(doc, options) if descriptor.fetch(:id) == data_grid2_descriptor[:id]
2231
+ configure_combo_box!(doc, options) if descriptor.fetch(:id) == combo_box_descriptor[:id]
2232
+ configure_pluggable_widget!(doc, options) if widget.fetch(:type) == :pluggable_widget
2233
+ doc
2234
+ end
2235
+
2236
+ def modern_widget_properties(type, options)
2237
+ case type.to_sym
2238
+ when :text
2239
+ {
2240
+ "ConditionalVisibilitySettings" => nil, "NativeAccessibilitySettings" => nil,
2241
+ "NativeTextStyle" => "Text", "RenderMode" => "Text", "TabIndex" => 0
2242
+ }
2243
+ when :container
2244
+ {
2245
+ "ConditionalVisibilitySettings" => nil, "NativeAccessibilitySettings" => nil,
2246
+ "OnClickAction" => no_action_doc(disabled: true), "RenderMode" => "Div",
2247
+ "ScreenReaderHidden" => false, "TabIndex" => 0
2248
+ }
2249
+ when :button
2250
+ {
2251
+ "Action" => no_action_doc(disabled: true), "AriaRole" => "Button", "ButtonStyle" => "Default",
2252
+ "CaptionTemplate" => client_template_doc(options[:caption]),
2253
+ "ConditionalVisibilitySettings" => nil, "Icon" => nil,
2254
+ "NativeAccessibilitySettings" => nil, "RenderType" => "Button", "TabIndex" => 0,
2255
+ "Tooltip" => text_doc("")
2256
+ }
2257
+ when :text_box, :number_input then text_box_properties(options)
2258
+ when :text_area then text_area_properties(options)
2259
+ when :check_box then check_box_properties(options)
2260
+ when :date_picker then date_picker_properties(options)
2261
+ when :tab_control
2262
+ {
2263
+ "ActivePageAttributeRef" => nil, "ActivePageOnChangeAction" => no_action_doc,
2264
+ "ActivePageSourceVariable" => nil, "ConditionalVisibilitySettings" => nil,
2265
+ "TabIndex" => 0
2266
+ }
2267
+ else {}
2268
+ end
2269
+ end
2270
+
2271
+ def text_box_properties(options)
2272
+ editable_widget_properties(options).merge(
2273
+ "Autocomplete" => true, "AutocompletePurpose" => "On", "AutoFocus" => false,
2274
+ "FormattingInfo" => formatting_info_doc, "InputMask" => "", "IsPasswordBox" => false,
2275
+ "KeyboardType" => "Default", "MaxLengthCode" => -1,
2276
+ "OnEnterKeyPressAction" => no_action_doc, "PlaceholderTemplate" => client_template_doc(""),
2277
+ "SubmitBehaviour" => "OnEndEditing", "SubmitOnInputDelay" => 300
2278
+ )
2279
+ end
2280
+
2281
+ def text_area_properties(options)
2282
+ editable_widget_properties(options).merge(
2283
+ "AutoFocus" => false, "CounterMessage" => text_doc(""),
2284
+ "MaxLengthCode" => -1, "NumberOfLines" => options.fetch(:lines, 5),
2285
+ "PlaceholderTemplate" => client_template_doc("")
2286
+ )
2287
+ end
2288
+
2289
+ def check_box_properties(options)
2290
+ editable_widget_properties(options).merge(
2291
+ "LabelPosition" => "Default", "NativeRenderMode" => "Switch"
2292
+ )
2293
+ end
2294
+
2295
+ def date_picker_properties(options)
2296
+ editable_widget_properties(options).merge(
2297
+ "AutoFocus" => false, "FormattingInfo" => formatting_info_doc,
2298
+ "PlaceholderTemplate" => client_template_doc("")
2299
+ )
2300
+ end
2301
+
2302
+ def editable_widget_properties(options)
2303
+ {
2304
+ "AriaRequired" => false, "AttributeRef" => attribute_ref_doc(options[:attribute]),
2305
+ "ConditionalEditabilitySettings" => nil, "ConditionalVisibilitySettings" => nil,
2306
+ "Editable" => "Always", "LabelTemplate" => client_template_doc(options[:caption]),
2307
+ "NativeAccessibilitySettings" => nil,
2308
+ "OnChangeAction" => no_action_doc, "OnEnterAction" => no_action_doc,
2309
+ "OnLeaveAction" => no_action_doc, "ReadOnlyStyle" => "Inherit",
2310
+ "ScreenReaderLabel" => nil, "SourceVariable" => nil, "TabIndex" => 0,
2311
+ "Validation" => widget_validation_doc
2312
+ }
2313
+ end
2314
+
2315
+ def attribute_ref_doc(attribute, entity: nil)
2316
+ identifier = qualified_attribute_identifier(attribute, entity)
2317
+ return nil unless identifier
2318
+
2319
+ {
2320
+ "$ID" => SecureRandom.uuid, "$Type" => "DomainModels$AttributeRef",
2321
+ "Attribute" => identifier, "EntityRef" => nil
2322
+ }
2323
+ end
2324
+
2325
+ def qualified_attribute_identifier(attribute, entity = nil)
2326
+ value = attribute.to_s
2327
+ return if value.empty?
2328
+
2329
+ value = value.tr('/', '.')
2330
+ return value if value.split('.').size >= 3
2331
+ return unless entity
2332
+
2333
+ entity_name = qualified_entity_name(entity)
2334
+ return unless entity_name
2335
+ if value.include?('.') && value.split('.').first == entity_name.split('.').last
2336
+ return "#{entity_name.split('.').first}.#{value}"
2337
+ end
2338
+ return if value.include?('.')
2339
+
2340
+ "#{entity_name}.#{value}"
2341
+ end
2342
+
2343
+ def qualified_entity_name(entity, module_name = nil)
2344
+ value = entity.to_s
2345
+ return if value.empty?
2346
+ return value if value.include?('.')
2347
+ return unless module_name
2348
+
2349
+ "#{module_name}.#{value}"
2350
+ end
2351
+
2352
+ def formatting_info_doc
2353
+ {
2354
+ "$ID" => SecureRandom.uuid, "$Type" => "Forms$FormattingInfo",
2355
+ "CustomDateFormat" => "", "DateFormat" => "Date", "DecimalPrecision" => 2,
2356
+ "EnumFormat" => "Text", "GroupDigits" => false
2357
+ }
2358
+ end
2359
+
2360
+ def widget_validation_doc
2361
+ {
2362
+ "$ID" => SecureRandom.uuid, "$Type" => "Forms$WidgetValidation",
2363
+ "Expression" => "", "Message" => text_doc("")
2364
+ }
2365
+ end
2366
+
2367
+ def tab_pages(tabs)
2368
+ Array(tabs).map do |tab|
2369
+ {
2370
+ "$ID" => SecureRandom.uuid,
2371
+ "$Type" => "Forms$TabPage",
2372
+ "Name" => tab.fetch(:name),
2373
+ "Caption" => text_doc(tab[:caption].to_s),
2374
+ "Widgets" => IO::BsonCodec.build_array(
2375
+ Array(tab[:widgets]).map { widget_doc(_1) }, marker: 2
2376
+ )
2377
+ }
2378
+ end
2379
+ end
2380
+
2381
+ def event_property(event)
2382
+ {
2383
+ on_change: "OnChangeAction", on_click: "Action",
2384
+ on_enter: "OnEnterAction", on_leave: "OnLeaveAction",
2385
+ on_submit: "Action", on_load: "OnLoadAction"
2386
+ }.fetch(event.to_sym)
2387
+ end
2388
+
2389
+ def client_action_doc(event)
2390
+ case event.fetch(:kind).to_sym
2391
+ when :action
2392
+ native_action_doc(event.fetch(:handler))
2393
+ when :nanoflow
2394
+ {
2395
+ "$ID" => SecureRandom.uuid,
2396
+ "$Type" => "Forms$CallNanoflowClientAction",
2397
+ "Nanoflow" => event.fetch(:handler),
2398
+ "ConfirmationInfo" => nil, "DisabledDuringExecution" => true,
2399
+ "OutputMappings" => IO::BsonCodec.build_array([], marker: 3),
2400
+ "ParameterMappings" => IO::BsonCodec.build_array([], marker: 2),
2401
+ "ProgressBar" => "None", "ProgressMessage" => nil
2402
+ }
2403
+ else
2404
+ {
2405
+ "$ID" => SecureRandom.uuid,
2406
+ "$Type" => "Forms$MicroflowAction",
2407
+ "MicroflowSettings" => {
2408
+ "$ID" => SecureRandom.uuid,
2409
+ "$Type" => "Forms$MicroflowSettings",
2410
+ "Microflow" => event.fetch(:handler),
2411
+ "Asynchronous" => false, "ConfirmationInfo" => nil,
2412
+ "FormValidations" => "All",
2413
+ "OutputMappings" => IO::BsonCodec.build_array([], marker: 3),
2414
+ "ParameterMappings" => IO::BsonCodec.build_array([], marker: 2),
2415
+ "ProgressBar" => "None", "ProgressMessage" => nil
2416
+ },
2417
+ "DisabledDuringExecution" => true
2418
+ }
2419
+ end
2420
+ end
2421
+
2422
+ def native_action_doc(handler)
2423
+ case handler.to_s
2424
+ when "save_changes"
2425
+ {
2426
+ "$ID" => SecureRandom.uuid,
2427
+ "$Type" => "Forms$SaveChangesClientAction",
2428
+ "DisabledDuringExecution" => true,
2429
+ "ClosePage" => true,
2430
+ "SyncAutomatically" => false
2431
+ }
2432
+ when "cancel_changes"
2433
+ {
2434
+ "$ID" => SecureRandom.uuid,
2435
+ "$Type" => "Forms$CancelChangesClientAction",
2436
+ "DisabledDuringExecution" => true,
2437
+ "ClosePage" => true
2438
+ }
2439
+ when "delete"
2440
+ {
2441
+ "$ID" => SecureRandom.uuid,
2442
+ "$Type" => "Forms$DeleteClientAction",
2443
+ "ClosePage" => false, "DisabledDuringExecution" => true,
2444
+ "SourceVariable" => nil
2445
+ }
2446
+ when "close_page"
2447
+ {
2448
+ "$ID" => SecureRandom.uuid,
2449
+ "$Type" => "Forms$ClosePageClientAction",
2450
+ "DisabledDuringExecution" => true, "NumberOfPagesToClose" => ""
2451
+ }
2452
+ else
2453
+ raise ArgumentError, "unsupported native action #{handler.inspect}"
2454
+ end
2455
+ end
2456
+
2457
+ def data_view_doc(source, widgets)
2458
+ {
2459
+ "$ID" => SecureRandom.uuid,
2460
+ "$Type" => "Forms$DataView",
2461
+ "Name" => "dataView",
2462
+ "Appearance" => appearance_doc, "ConditionalEditabilitySettings" => nil,
2463
+ "ConditionalVisibilitySettings" => nil, "DataSource" => data_view_source_doc(source),
2464
+ "Editability" => "Always", "FooterWidgets" => IO::BsonCodec.build_array([], marker: 2),
2465
+ "LabelWidth" => 0, "NoEntityMessage" => text_doc(""), "ReadOnlyStyle" => "Control",
2466
+ "ShowFooter" => false, "TabIndex" => 0,
2467
+ "Widgets" => IO::BsonCodec.build_array(widgets, marker: 2)
2468
+ }
2469
+ end
2470
+
2471
+ def data_view_source_doc(source)
2472
+ if source.fetch(:kind).to_sym == :nanoflow
2473
+ {
2474
+ "$ID" => SecureRandom.uuid, "$Type" => "Forms$NanoflowSource",
2475
+ "ForceFullObjects" => false, "Nanoflow" => source.fetch(:name),
2476
+ "ParameterMappings" => IO::BsonCodec.build_array([], marker: 2)
2477
+ }
2478
+ else
2479
+ {
2480
+ "$ID" => SecureRandom.uuid, "$Type" => "Forms$MicroflowSource",
2481
+ "ForceFullObjects" => false,
2482
+ "MicroflowSettings" => client_microflow_settings_doc(source.fetch(:name))
2483
+ }
2484
+ end
2485
+ end
2486
+
2487
+ def client_microflow_settings_doc(name)
2488
+ {
2489
+ "$ID" => SecureRandom.uuid, "$Type" => "Forms$MicroflowSettings",
2490
+ "Asynchronous" => false, "ConfirmationInfo" => nil, "FormValidations" => "All",
2491
+ "Microflow" => name, "OutputMappings" => IO::BsonCodec.build_array([], marker: 3),
2492
+ "ParameterMappings" => IO::BsonCodec.build_array([], marker: 2),
2493
+ "ProgressBar" => "None", "ProgressMessage" => nil
2494
+ }
2495
+ end
2496
+
2497
+ def microflow_doc(flow, module_name = nil)
2498
+ flow_name = flow.fetch(:name)
2499
+ params = flow.fetch(:parameters).map do |param|
2500
+ { "$ID" => stable_id(flow_name, "parameter", param.fetch(:name)),
2501
+ "$Type" => "Microflows$MicroflowParameter",
2502
+ "DefaultValue" => "", "Documentation" => "",
2503
+ "HasVariableNameBeenChanged" => false, "IsRequired" => true,
2504
+ "Name" => param.fetch(:name), "RelativeMiddlePoint" => "0;0",
2505
+ "Size" => "30;30",
2506
+ "VariableType" => microflow_data_type_doc(param.fetch(:type), module_name) }
2507
+ end
2508
+ roles_declared = !flow[:allowed_roles].nil?
2509
+ body_declared = !flow[:body].nil? || !flow[:return_expression].nil?
2510
+ return_var_name = flow[:return_variable_name] || "ReturnValue"
2511
+ allow_concurrent = flow[:allow_concurrent_execution]
2512
+ mark_as_used = flow[:mark_as_used]
2513
+ excluded = flow[:excluded]
2514
+
2515
+ body = qualify_flow_member_references(flow[:body], flow[:parameters], module_name)
2516
+ graph = build_microflow_graph(
2517
+ body, flow[:return_expression] || flow[:return_variable_name]
2518
+ )
2519
+ object_collection = {
2520
+ "$ID" => stable_id(flow_name, "object_collection"),
2521
+ "$Type" => "Microflows$MicroflowObjectCollection",
2522
+ "Objects" => IO::BsonCodec.build_array(params + graph[:objects])
2523
+ }
2524
+
2525
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$Microflow",
2526
+ "Name" => flow_name, "Documentation" => flow.fetch(:documentation, ""),
2527
+ "ReturnVariableName" => return_var_name,
2528
+ "AllowConcurrentExecution" => allow_concurrent.nil? ? true : allow_concurrent,
2529
+ "MarkAsUsed" => mark_as_used.nil? ? false : mark_as_used,
2530
+ "Excluded" => excluded.nil? ? false : excluded,
2531
+ "AllowedModuleRoles" => IO::BsonCodec.build_array(Array(flow[:allowed_roles]), marker: 1),
2532
+ "__mxrb_allowed_roles_declared" => roles_declared,
2533
+ "__mxrb_body_declared" => body_declared,
2534
+ "__mxrb_preserve_native_body" => flow[:preserve_native_body] == true,
2535
+ "__mxrb_allow_concurrent_execution_declared" => !allow_concurrent.nil?,
2536
+ "__mxrb_mark_as_used_declared" => !mark_as_used.nil?,
2537
+ "__mxrb_excluded_declared" => !excluded.nil?,
2538
+ "MicroflowReturnType" => microflow_data_type_doc(flow[:return_type], module_name),
2539
+ "ObjectCollection" => object_collection,
2540
+ "Flows" => IO::BsonCodec.build_array(graph[:flows]) }
2541
+ end
2542
+
2543
+ def microflow_data_type_doc(type, module_name)
2544
+ if type.is_a?(Hash)
2545
+ return type.merge("$ID" => stable_id("data_type", module_name, type.to_s))
2546
+ end
2547
+
2548
+ name = type.to_s
2549
+ native = case name.downcase
2550
+ when "", "void", "nil" then "DataTypes$VoidType"
2551
+ when "boolean", "bool" then "DataTypes$BooleanType"
2552
+ when "string" then "DataTypes$StringType"
2553
+ when "integer" then "DataTypes$IntegerType"
2554
+ when "long" then "DataTypes$LongType"
2555
+ when "decimal" then "DataTypes$DecimalType"
2556
+ when "float" then "DataTypes$FloatType"
2557
+ when "datetime", "date_time" then "DataTypes$DateTimeType"
2558
+ else
2559
+ "DataTypes$ObjectType"
2560
+ end
2561
+ doc = { "$ID" => stable_id("data_type", module_name, name), "$Type" => native }
2562
+ if native == "DataTypes$ObjectType"
2563
+ doc["Entity"] = name.include?(".") ? name : "#{module_name}.#{name}"
2564
+ end
2565
+ doc
2566
+ end
2567
+
2568
+ def qualify_flow_member_references(body, parameters, module_name)
2569
+ variables = parameter_entity_types(parameters, module_name)
2570
+ qualify_activity_members(Array(body), variables, module_name).first
2571
+ end
2572
+
2573
+ def flow_variable_entities(body, parameters, module_name)
2574
+ variables = parameter_entity_types(parameters, module_name)
2575
+ qualify_activity_members(Array(body), variables, module_name).last
2576
+ end
2577
+
2578
+ def parameter_entity_types(parameters, module_name)
2579
+ Array(parameters).each_with_object({}) do |parameter, result|
2580
+ entity = object_entity_type(parameter[:type], module_name)
2581
+ result[parameter.fetch(:name).to_s] = entity if entity
2582
+ end
2583
+ end
2584
+
2585
+ def object_entity_type(type, module_name)
2586
+ if type.is_a?(Hash)
2587
+ native = type['$Type'] || type[:'$Type']
2588
+ return unless native == 'DataTypes$ObjectType'
2589
+
2590
+ return qualified_entity_name(type['Entity'] || type[:Entity], module_name)
2591
+ end
2592
+
2593
+ value = type.to_s
2594
+ primitive = %w[
2595
+ void nil boolean bool string integer long decimal float datetime date_time
2596
+ ]
2597
+ return if value.empty? || primitive.include?(value.downcase) || value.include?('$')
2598
+
2599
+ qualified_entity_name(value, module_name)
2600
+ end
2601
+
2602
+ def qualify_activity_members(activities, inherited_variables, module_name)
2603
+ variables = inherited_variables.dup
2604
+ qualified = Array(activities).map do |activity|
2605
+ item = activity.dup
2606
+ type = item.fetch(:type).to_sym
2607
+ case type
2608
+ when :create_object, :retrieve_objects
2609
+ entity = qualified_entity_name(item[:entity], module_name)
2610
+ item[:entity] = entity if entity
2611
+ variables[item[:variable].to_s] = entity if entity
2612
+ when :retrieve_association
2613
+ entity = association_destination(item[:association])
2614
+ variables[item[:variable].to_s] = entity unless entity.to_s.empty?
2615
+ when :change_object
2616
+ item[:entity] ||= variables[item[:variable].to_s]
2617
+ when :decision
2618
+ item = qualify_decision_activity(item, variables, module_name)
2619
+ when :inheritance_decision
2620
+ item[:branches] = item.fetch(:branches).transform_values do |branch|
2621
+ qualify_activity_members(branch, variables, module_name).first
2622
+ end
2623
+ when :loop_over
2624
+ loop_variables = variables.dup
2625
+ iterator_entity = variables[item[:variable].to_s]
2626
+ loop_variables[item[:iterator].to_s] = iterator_entity if iterator_entity
2627
+ item[:activities] = qualify_activity_members(
2628
+ item[:activities], loop_variables, module_name
2629
+ ).first
2630
+ when :while_loop, :rescue_all
2631
+ item[:activities] = qualify_activity_members(
2632
+ item[:activities], variables, module_name
2633
+ ).first
2634
+ end
2635
+ item
2636
+ end
2637
+ [qualified, variables]
2638
+ end
2639
+
2640
+ def qualify_decision_activity(activity, variables, module_name)
2641
+ item = activity.dup
2642
+ if item[:branches]
2643
+ item[:branches] = item.fetch(:branches).transform_values do |branch|
2644
+ qualify_activity_members(branch, variables, module_name).first
2645
+ end
2646
+ else
2647
+ item[:true_branch] = qualify_activity_members(
2648
+ item[:true_branch], variables, module_name
2649
+ ).first
2650
+ item[:false_branch] = qualify_activity_members(
2651
+ item[:false_branch], variables, module_name
2652
+ ).first
2653
+ end
2654
+ item
2655
+ end
2656
+
2657
+ def build_microflow_graph(body, return_expression)
2658
+ objects = []
2659
+ flows = []
2660
+
2661
+ start_id = SecureRandom.uuid
2662
+ objects << flow_object_doc(start_id, "Microflows$StartEvent", 50, 100, "20;20")
2663
+
2664
+ prev_id = start_id
2665
+ x = 190
2666
+
2667
+ # Separate rescue_all from regular items (rescue_all must be last)
2668
+ main_items = Array(body).reject { _1[:type].to_sym == :rescue_all }
2669
+ rescue_block = Array(body).find { _1[:type].to_sym == :rescue_all }
2670
+
2671
+ main_items.each_with_index do |activity, i|
2672
+ is_last = i == main_items.size - 1
2673
+ error_type = (rescue_block && is_last) ? "Custom" : "None"
2674
+ prev_id, x = process_activity(activity, prev_id, objects, flows, x, 100, error_type: error_type)
2675
+ end
2676
+
2677
+ last_main_id = prev_id
2678
+
2679
+ end_id = SecureRandom.uuid
2680
+ end_value = return_expression.to_s
2681
+ if prev_id
2682
+ objects << flow_object_doc(end_id, "Microflows$EndEvent", x, 100, "20;20").merge(
2683
+ "Documentation" => "", "ReturnValue" => end_value
2684
+ )
2685
+ flows << sequence_flow_doc(prev_id, end_id)
2686
+ end
2687
+
2688
+ if rescue_block
2689
+ x_err = 190
2690
+ y_err = 250
2691
+ err_pid = last_main_id
2692
+ terminal = false
2693
+ Array(rescue_block[:activities]).each_with_index do |act, i|
2694
+ act_id = SecureRandom.uuid
2695
+ object = case act[:type].to_sym
2696
+ when :return_event
2697
+ terminal = true
2698
+ flow_object_doc(
2699
+ act_id, "Microflows$EndEvent", x_err, y_err, "20;20"
2700
+ ).merge("Documentation" => "", "ReturnValue" => act[:expression].to_s)
2701
+ when :error_event
2702
+ terminal = true
2703
+ flow_object_doc(act_id, "Microflows$ErrorEvent", x_err, y_err, "20;20")
2704
+ when :continue_event
2705
+ terminal = true
2706
+ flow_object_doc(act_id, "Microflows$ContinueEvent", x_err, y_err, "20;20")
2707
+ else
2708
+ build_activity(act, act_id, x_err, y_err)
2709
+ end
2710
+ objects << object
2711
+ if i == 0
2712
+ error_flow = sequence_flow_doc(err_pid, act_id)
2713
+ mark_error_flow!(error_flow)
2714
+ flows << error_flow
2715
+ else
2716
+ flows << sequence_flow_doc(err_pid, act_id)
2717
+ end
2718
+ err_pid = act_id
2719
+ x_err += 140
2720
+ end
2721
+ unless terminal
2722
+ err_end_id = SecureRandom.uuid
2723
+ objects << flow_object_doc(
2724
+ err_end_id, "Microflows$EndEvent", x_err, y_err, "20;20"
2725
+ ).merge("Documentation" => "", "ReturnValue" => "")
2726
+ flows << sequence_flow_doc(err_pid, err_end_id)
2727
+ end
2728
+ end
2729
+
2730
+ { objects: objects, flows: flows }
2731
+ end
2732
+
2733
+ def process_activity(activity, prev_id, objects, flows, x, y, error_type: "None")
2734
+ case activity[:type].to_sym
2735
+ when :decision
2736
+ process_decision(activity, prev_id, objects, flows, x, y)
2737
+ when :inheritance_decision
2738
+ process_inheritance_decision(activity, prev_id, objects, flows, x, y)
2739
+ when :loop_over, :while_loop
2740
+ act_id = SecureRandom.uuid
2741
+ objects << loop_activity_doc(activity, act_id, x, y, flows)
2742
+ flows << sequence_flow_doc(prev_id, act_id) if prev_id
2743
+ [act_id, x + 140]
2744
+ when :return_event, :error_event, :continue_event
2745
+ act_id = SecureRandom.uuid
2746
+ type = {
2747
+ return_event: "Microflows$EndEvent",
2748
+ error_event: "Microflows$ErrorEvent",
2749
+ continue_event: "Microflows$ContinueEvent"
2750
+ }.fetch(activity[:type].to_sym)
2751
+ object = flow_object_doc(act_id, type, x, y, "20;20")
2752
+ if activity[:type].to_sym == :return_event
2753
+ object["Documentation"] = ""
2754
+ object["ReturnValue"] = activity[:expression].to_s
2755
+ end
2756
+ objects << object
2757
+ flows << sequence_flow_doc(prev_id, act_id) if prev_id
2758
+ [nil, x + 140]
2759
+ else
2760
+ act_id = SecureRandom.uuid
2761
+ objects << build_activity(activity, act_id, x, y, error_type: error_type)
2762
+ flows << sequence_flow_doc(prev_id, act_id) if prev_id
2763
+ [act_id, x + 140]
2764
+ end
2765
+ end
2766
+
2767
+ def process_decision(activity, prev_id, objects, flows, x, y)
2768
+ split_id = SecureRandom.uuid
2769
+ branches = activity[:branches] || {
2770
+ true => Array(activity[:true_branch]),
2771
+ false => Array(activity[:false_branch])
2772
+ }
2773
+
2774
+ objects << flow_object_doc(
2775
+ split_id, "Microflows$ExclusiveSplit", x, y, "90;60"
2776
+ ).merge(
2777
+ "SplitCondition" => split_condition_doc(activity[:condition]),
2778
+ "Caption" => activity[:condition],
2779
+ "ErrorHandlingType" => "Rollback",
2780
+ "Documentation" => "")
2781
+ flows << sequence_flow_doc(prev_id, split_id) if prev_id
2782
+
2783
+ branch_width = [branches.values.map(&:size).max.to_i, 1].max
2784
+ x_branch = x + 140
2785
+ x_merge = x + 140 * (branch_width + 1)
2786
+ results = branches.each_with_index.map do |(case_value, activities), index|
2787
+ process_decision_branch(
2788
+ Array(activities), split_id, case_value,
2789
+ objects, flows, x_branch, y + index * 150
2790
+ )
2791
+ end
2792
+ return [nil, x_merge + 140] if results.all? { _1[:terminal] }
2793
+
2794
+ merge_id = SecureRandom.uuid
2795
+ objects << flow_object_doc(
2796
+ merge_id, "Microflows$ExclusiveMerge", x_merge, y, "40;40"
2797
+ )
2798
+ results.each do |result|
2799
+ if result[:first].nil?
2800
+ flows << decision_flow_doc(split_id, merge_id, result[:case])
2801
+ elsif !result[:terminal]
2802
+ flows << sequence_flow_doc(result[:last], merge_id)
2803
+ end
2804
+ end
2805
+ [merge_id, x_merge + 140]
2806
+ end
2807
+
2808
+ def split_condition_doc(condition)
2809
+ unless condition.is_a?(Hash)
2810
+ return {
2811
+ "$ID" => SecureRandom.uuid,
2812
+ "$Type" => "Microflows$ExpressionSplitCondition",
2813
+ "Expression" => condition
2814
+ }
2815
+ end
2816
+
2817
+ mappings = condition.fetch(:pass, {}).map do |parameter, argument|
2818
+ {
2819
+ "$ID" => SecureRandom.uuid,
2820
+ "$Type" => "Microflows$RuleCallParameterMapping",
2821
+ "Parameter" => parameter.to_s,
2822
+ "Argument" => member_value_expr(argument)
2823
+ }
2824
+ end
2825
+ {
2826
+ "$ID" => SecureRandom.uuid,
2827
+ "$Type" => "Microflows$RuleSplitCondition",
2828
+ "RuleCall" => {
2829
+ "$ID" => SecureRandom.uuid,
2830
+ "$Type" => "Microflows$RuleCall",
2831
+ "Microflow" => condition[:rule].to_s,
2832
+ "ParameterMappings" => IO::BsonCodec.build_array(mappings, marker: 2)
2833
+ }
2834
+ }
2835
+ end
2836
+
2837
+ def process_decision_branch(activities, split_id, case_value, objects, flows, x, y)
2838
+ first = nil
2839
+ previous = nil
2840
+ terminal = false
2841
+ activities.each do |activity|
2842
+ break if terminal
2843
+
2844
+ if activity[:type].to_sym == :rescue_all
2845
+ build_rescue_branch(
2846
+ previous, activity[:activities], objects, flows, x, y + 120
2847
+ )
2848
+ next
2849
+ end
2850
+
2851
+ before = objects.size
2852
+ next_id, next_x = process_activity(
2853
+ activity, previous, objects, flows, x, y
2854
+ )
2855
+ created_first = objects[before]&.dig("$ID")
2856
+ first ||= created_first
2857
+ unless previous
2858
+ flows << decision_flow_doc(split_id, created_first, case_value)
2859
+ end
2860
+ terminal = next_id.nil?
2861
+ previous = next_id
2862
+ x = next_x
2863
+ end
2864
+ { first: first, last: previous, terminal: terminal, case: case_value }
2865
+ end
2866
+
2867
+ def build_rescue_branch(origin_id, activities, objects, flows, x, y)
2868
+ return unless origin_id
2869
+
2870
+ origin = objects.find { _1["$ID"] == origin_id }
2871
+ if origin&.dig("Action").is_a?(Hash)
2872
+ origin["Action"]["ErrorHandlingType"] = custom_error_handling_type
2873
+ end
2874
+ previous = nil
2875
+ Array(activities).each do |activity|
2876
+ before = objects.size
2877
+ next_id, x = process_activity(
2878
+ activity, previous, objects, flows, x, y
2879
+ )
2880
+ created_first = objects[before]&.dig("$ID")
2881
+ unless previous
2882
+ error_flow = sequence_flow_doc(origin_id, created_first)
2883
+ mark_error_flow!(error_flow)
2884
+ flows << error_flow
2885
+ end
2886
+ previous = next_id
2887
+ break unless previous
2888
+ end
2889
+ end
2890
+
2891
+ def process_inheritance_decision(activity, prev_id, objects, flows, x, y)
2892
+ split_id = SecureRandom.uuid
2893
+ branches = activity.fetch(:branches)
2894
+ objects << flow_object_doc(
2895
+ split_id, "Microflows$InheritanceSplit", x, y, "60;40"
2896
+ ).merge(
2897
+ "Caption" => "", "Documentation" => "",
2898
+ "SplitVariableName" => activity[:variable]
2899
+ )
2900
+ flows << sequence_flow_doc(prev_id, split_id) if prev_id
2901
+ width = [branches.values.map(&:size).max.to_i, 1].max
2902
+ x_merge = x + 140 * (width + 1)
2903
+ results = branches.each_with_index.map do |(case_value, activities), index|
2904
+ process_decision_branch(
2905
+ Array(activities), split_id, case_value,
2906
+ objects, flows, x + 140, y + index * 150
2907
+ ).tap { _1[:case_kind] = :inheritance }
2908
+ end
2909
+ # Replace the just-created enumeration case documents with inheritance
2910
+ # cases. Their edge endpoints remain unchanged.
2911
+ flows.select { _1["OriginPointer"] == split_id }.each do |flow|
2912
+ set_flow_case(flow, flow_case_raw_value(flow), kind: :inheritance)
2913
+ end
2914
+ return [nil, x_merge + 140] if results.all? { _1[:terminal] }
2915
+
2916
+ merge_id = SecureRandom.uuid
2917
+ objects << flow_object_doc(
2918
+ merge_id, "Microflows$ExclusiveMerge", x_merge, y, "40;40"
2919
+ )
2920
+ results.each do |result|
2921
+ if result[:first].nil?
2922
+ flow = decision_flow_doc(split_id, merge_id, result[:case])
2923
+ set_flow_case(flow, result[:case], kind: :inheritance)
2924
+ flows << flow
2925
+ elsif !result[:terminal]
2926
+ flows << sequence_flow_doc(result[:last], merge_id)
2927
+ end
2928
+ end
2929
+ [merge_id, x_merge + 140]
2930
+ end
2931
+
2932
+ def loop_activity_doc(activity, id, x, y, all_flows)
2933
+ inner_objs = []
2934
+ i_prev = nil
2935
+ i_x = 50
2936
+ started = false
2937
+ Array(activity[:activities]).each do |act|
2938
+ break if started && i_prev.nil?
2939
+
2940
+ i_prev, i_x = process_activity(
2941
+ act, i_prev, inner_objs, all_flows, i_x, 100
2942
+ )
2943
+ started = true
2944
+ end
2945
+
2946
+ source = if activity[:type].to_sym == :while_loop
2947
+ {
2948
+ "$ID" => SecureRandom.uuid,
2949
+ "$Type" => "Microflows$WhileLoopCondition",
2950
+ "WhileExpression" => activity[:condition]
2951
+ }
2952
+ else
2953
+ {
2954
+ "$ID" => SecureRandom.uuid,
2955
+ "$Type" => "Microflows$IterableList",
2956
+ "ListVariableName" => activity[:variable],
2957
+ "VariableName" => activity[:iterator]
2958
+ }
2959
+ end
2960
+ doc = flow_object_doc(id, "Microflows$LoopedActivity", x, y, "300;200").merge(
2961
+ "ErrorHandlingType" => "Rollback",
2962
+ "ObjectCollection" => {
2963
+ "$ID" => SecureRandom.uuid, "$Type" => "Microflows$MicroflowObjectCollection",
2964
+ "Objects" => IO::BsonCodec.build_array(inner_objs)
2965
+ })
2966
+ major = @definition.fetch(:version).to_s.split(".").first.to_i
2967
+ if major < 8 && activity[:type].to_sym == :loop_over
2968
+ doc["ListVariableName"] = activity[:variable]
2969
+ doc["IteratorVariableName"] = activity[:iterator]
2970
+ doc["Documentation"] = ""
2971
+ else
2972
+ doc["LoopSource"] = source
2973
+ end
2974
+ doc
2975
+ end
2976
+
2977
+ def decision_flow_doc(split_id, to_id, value)
2978
+ sequence_flow_doc(split_id, to_id, case_value: value)
2979
+ end
2980
+
2981
+ def flow_case_raw_value(flow)
2982
+ array_items(flow["CaseValues"]).first&.dig("Value") ||
2983
+ flow.dig("NewCaseValue", "Value") || ""
2984
+ end
2985
+
2986
+ def set_flow_case(flow, value, kind:)
2987
+ case_doc = {
2988
+ "$ID" => SecureRandom.uuid,
2989
+ "$Type" => kind == :inheritance ?
2990
+ "Microflows$InheritanceCase" : "Microflows$EnumerationCase",
2991
+ "Value" => value.to_s
2992
+ }
2993
+ major = @definition.fetch(:version).to_s.split(".").first.to_i
2994
+ if major >= 10
2995
+ flow["CaseValues"] = IO::BsonCodec.build_array([case_doc], marker: 2)
2996
+ flow.delete("NewCaseValue")
2997
+ else
2998
+ flow["NewCaseValue"] = case_doc
2999
+ flow.delete("CaseValues")
3000
+ end
3001
+ flow
3002
+ end
3003
+
3004
+ def sequence_flow_doc(from_id, to_id, case_value: nil)
3005
+ major = @definition.fetch(:version).to_s.split(".").first.to_i
3006
+ case_doc = {
3007
+ "$ID" => SecureRandom.uuid,
3008
+ "$Type" => case_value.nil? ? "Microflows$NoCase" : "Microflows$EnumerationCase"
3009
+ }
3010
+ case_doc["Value"] = case_value.to_s unless case_value.nil?
3011
+ doc = {
3012
+ "$ID" => SecureRandom.uuid, "$Type" => "Microflows$SequenceFlow",
3013
+ "OriginPointer" => from_id, "DestinationPointer" => to_id,
3014
+ "OriginConnectionIndex" => 1, "DestinationConnectionIndex" => 3,
3015
+ "IsErrorHandler" => false
3016
+ }
3017
+ if major >= 10
3018
+ doc["Line"] = {
3019
+ "$ID" => SecureRandom.uuid, "$Type" => "Microflows$BezierCurve",
3020
+ "OriginControlVector" => "0;0", "DestinationControlVector" => "0;0"
3021
+ }
3022
+ doc["CaseValues"] = IO::BsonCodec.build_array([case_doc], marker: 2)
3023
+ else
3024
+ doc["OriginBezierVector"] = "30;0"
3025
+ doc["DestinationBezierVector"] = "-30;0"
3026
+ doc["NewCaseValue"] = case_doc
3027
+ end
3028
+ doc
3029
+ end
3030
+
3031
+ def mark_error_flow!(flow)
3032
+ flow["IsErrorHandler"] = true
3033
+ flow["OriginConnectionIndex"] = 2
3034
+ flow["DestinationConnectionIndex"] = 0
3035
+ if flow["Line"]
3036
+ flow["Line"]["OriginControlVector"] = "0;30"
3037
+ flow["Line"]["DestinationControlVector"] = "0;-15"
3038
+ else
3039
+ flow["OriginBezierVector"] = "0;30"
3040
+ flow["DestinationBezierVector"] = "0;-15"
3041
+ end
3042
+ flow
3043
+ end
3044
+
3045
+ def build_activity(activity, id, x, y, error_type: "None")
3046
+ action = activity_action_doc(activity)
3047
+ doc = flow_object_doc(id, "Microflows$ActionActivity", x, y, "120;60").merge(
3048
+ "Documentation" => "",
3049
+ "AutoGenerateCaption" => true, "BackgroundColor" => "Default",
3050
+ "Caption" => "Activity", "Action" => action
3051
+ )
3052
+ action["ErrorHandlingType"] = custom_error_handling_type if error_type == "Custom"
3053
+ doc
3054
+ end
3055
+
3056
+ def custom_error_handling_type
3057
+ major = @definition.fetch(:version).to_s.split(".").first.to_i
3058
+ major >= 9 ? "CustomWithoutRollBack" : "Custom"
3059
+ end
3060
+
3061
+ def flow_object_doc(id, type, x, y, size)
3062
+ {
3063
+ "$ID" => id, "$Type" => type,
3064
+ "RelativeMiddlePoint" => "#{x};#{y}", "Size" => size
3065
+ }
3066
+ end
3067
+
3068
+ def activity_action_doc(activity)
3069
+ case activity[:type].to_sym
3070
+ when :create_object
3071
+ commit = if activity[:commit] == true
3072
+ activity[:with_events] == false ? "YesWithoutEvents" : "Yes"
3073
+ else
3074
+ "No"
3075
+ end
3076
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$CreateChangeAction",
3077
+ "Commit" => commit,
3078
+ "Entity" => activity[:entity], "ErrorHandlingType" => "Rollback",
3079
+ "Items" => IO::BsonCodec.build_array(
3080
+ Array(activity[:members]).map { change_action_item_doc(_1, entity: activity[:entity]) },
3081
+ marker: 2
3082
+ ),
3083
+ "RefreshInClient" => activity[:refresh] == true,
3084
+ "VariableName" => activity[:variable] }
3085
+ when :change_object
3086
+ commit = if activity[:commit] == true
3087
+ activity[:with_events] == false ? "YesWithoutEvents" : "Yes"
3088
+ else
3089
+ "No"
3090
+ end
3091
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$ChangeAction",
3092
+ "ChangeVariableName" => activity[:variable],
3093
+ "Commit" => commit,
3094
+ "ErrorHandlingType" => "Rollback",
3095
+ "Items" => IO::BsonCodec.build_array(
3096
+ Array(activity[:members]).map do |member|
3097
+ change_action_item_doc(member, entity: activity[:entity])
3098
+ end, marker: 2
3099
+ ),
3100
+ "RefreshInClient" => activity[:refresh] == true }
3101
+ when :retrieve_objects
3102
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$RetrieveAction",
3103
+ "ErrorHandlingType" => "Rollback",
3104
+ "ResultVariableName" => activity[:variable],
3105
+ "RetrieveSource" => {
3106
+ "$ID" => SecureRandom.uuid, "$Type" => "Microflows$DatabaseRetrieveSource",
3107
+ "Entity" => activity[:entity],
3108
+ "NewSortings" => {
3109
+ "$ID" => SecureRandom.uuid, "$Type" => "Microflows$SortingsList",
3110
+ "Sortings" => IO::BsonCodec.build_array(
3111
+ Array(activity[:sortings]).map { retrieve_sorting_doc(_1) }, marker: 2
3112
+ )
3113
+ },
3114
+ "Range" => retrieve_range_doc(activity),
3115
+ "XpathConstraint" => activity[:xpath] || ""
3116
+ } }
3117
+ when :retrieve_association
3118
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$RetrieveAction",
3119
+ "ErrorHandlingType" => "Rollback",
3120
+ "ResultVariableName" => activity[:variable],
3121
+ "RetrieveSource" => {
3122
+ "$ID" => SecureRandom.uuid,
3123
+ "$Type" => "Microflows$AssociationRetrieveSource",
3124
+ "AssociationId" => activity[:association],
3125
+ "StartVariableName" => activity[:start_variable]
3126
+ } }
3127
+ when :commit
3128
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$CommitAction",
3129
+ "CommitVariableName" => activity[:variable],
3130
+ "ErrorHandlingType" => "Rollback",
3131
+ "RefreshInClient" => activity[:refresh] == true,
3132
+ "WithEvents" => activity[:with_events] == true }
3133
+ when :delete_object
3134
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$DeleteAction",
3135
+ "DeleteVariableName" => activity[:variable],
3136
+ "ErrorHandlingType" => "Rollback",
3137
+ "RefreshInClient" => activity[:refresh] == true }
3138
+ when :call_microflow
3139
+ major = @definition.fetch(:version).to_s.split(".").first.to_i
3140
+ result_name = activity[:result_name] || activity[:variable].to_s
3141
+ mappings = Array(activity[:mappings]).map do |m|
3142
+ parameter = m[:param].to_s
3143
+ parameter = "#{activity[:name]}.#{parameter}" unless parameter.include?('.')
3144
+ mapping = {
3145
+ "$ID" => SecureRandom.uuid,
3146
+ "$Type" => "Microflows$MicroflowCallParameterMapping",
3147
+ "Parameter" => parameter,
3148
+ "Argument" => member_value_expr(m[:value])
3149
+ }
3150
+ mapping["ArgumentModel"] = no_expression_doc if major.between?(6, 10)
3151
+ mapping
3152
+ end
3153
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$MicroflowCallAction",
3154
+ "ErrorHandlingType" => "Rollback",
3155
+ "UseReturnVariable" => activity[:use_return] == true,
3156
+ "ResultVariableName" => result_name,
3157
+ "MicroflowCall" => {
3158
+ "$ID" => SecureRandom.uuid, "$Type" => "Microflows$MicroflowCall",
3159
+ "Microflow" => activity[:name],
3160
+ "ParameterMappings" => IO::BsonCodec.build_array(mappings, marker: 2)
3161
+ }.tap { |call| call["Queue"] = "" if major.between?(8, 9) } }
3162
+ when :create_variable
3163
+ doc = { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$CreateVariableAction",
3164
+ "ErrorHandlingType" => "Rollback",
3165
+ "InitialValue" => member_value_expr(activity[:value]),
3166
+ "InitialValueModel" => no_expression_doc,
3167
+ "VariableName" => activity[:variable] }
3168
+ doc["VariableType"] = variable_type_doc(activity[:variable_type]) if activity[:variable_type]
3169
+ doc
3170
+ when :change_variable
3171
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$ChangeVariableAction",
3172
+ "ChangeVariableName" => activity[:variable],
3173
+ "ErrorHandlingType" => "Rollback",
3174
+ "Value" => member_value_expr(activity[:value]),
3175
+ "ValueModel" => no_expression_doc }
3176
+ when :show_message
3177
+ translations = activity[:translations] || { "en_US" => activity[:text] }
3178
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$ShowMessageAction",
3179
+ "Blocking" => activity[:blocking] == true,
3180
+ "ErrorHandlingType" => "Rollback",
3181
+ "Template" => {
3182
+ "$ID" => SecureRandom.uuid, "$Type" => "Microflows$TextTemplate",
3183
+ "Parameters" => IO::BsonCodec.build_array(
3184
+ template_parameter_docs(activity[:parameters]), marker: 2
3185
+ ),
3186
+ "Text" => localized_text_doc(translations)
3187
+ },
3188
+ "Type" => activity[:message_type].to_s.capitalize }
3189
+ when :log_message
3190
+ major = @definition.fetch(:version).to_s.split('.').first.to_i
3191
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$LogMessageAction",
3192
+ "ErrorHandlingType" => "Rollback",
3193
+ "IncludeLatestStackTrace" => activity[:include_stack] == true,
3194
+ "Level" => activity[:level].to_s.capitalize,
3195
+ "MessageTemplate" => {
3196
+ "$ID" => SecureRandom.uuid, "$Type" => "Microflows$StringTemplate",
3197
+ "Parameters" => IO::BsonCodec.build_array(
3198
+ template_parameter_docs(activity[:parameters]), marker: 2
3199
+ ),
3200
+ "Text" => activity[:message]
3201
+ },
3202
+ "Node" => activity[:node] || "'MXRB'" }.tap do |document|
3203
+ document['NodeModel'] = no_expression_doc if major.between?(6, 10)
3204
+ end
3205
+ when :show_page
3206
+ show_form_action_doc(activity)
3207
+ when :close_page
3208
+ close_form_action_doc(activity)
3209
+ when :call_java
3210
+ java_action_call_doc(activity)
3211
+ when :call_javascript
3212
+ javascript_action_call_doc(activity)
3213
+ when :call_nanoflow
3214
+ nanoflow_call_doc(activity)
3215
+ when :call_app_service
3216
+ app_service_call_doc(activity)
3217
+ when :aggregate
3218
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$AggregateAction",
3219
+ "AggregateFunction" => mendix_enum(activity[:function]),
3220
+ "AggregateVariableName" => activity[:variable],
3221
+ "Attribute" => activity[:attribute].to_s,
3222
+ "ErrorHandlingType" => "Rollback",
3223
+ "VariableName" => activity[:output] }
3224
+ when :rollback
3225
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$RollbackAction",
3226
+ "ErrorHandlingType" => "Rollback",
3227
+ "RefreshInClient" => activity[:refresh] == true,
3228
+ "RollbackVariableName" => activity[:variable] }
3229
+ when :cast
3230
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$CastAction",
3231
+ "ErrorHandlingType" => "Rollback",
3232
+ "VariableName" => activity[:variable] }
3233
+ when :create_list
3234
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$CreateListAction",
3235
+ "Entity" => activity[:entity], "ErrorHandlingType" => "Rollback",
3236
+ "VariableName" => activity[:variable] }
3237
+ when :list_operation
3238
+ operation = {
3239
+ "$ID" => SecureRandom.uuid,
3240
+ "$Type" => "Microflows$#{mendix_enum(activity[:operation])}",
3241
+ "ListName" => activity[:variable]
3242
+ }
3243
+ operation["SecondListOrObjectName"] = activity[:second] if activity[:second]
3244
+ operation["Expression"] = activity[:expression] if activity[:expression]
3245
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$ListOperationsAction",
3246
+ "ErrorHandlingType" => "Rollback",
3247
+ "NewOperation" => operation,
3248
+ "ResultVariableName" => activity[:output] }
3249
+ when :change_list
3250
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$ChangeListAction",
3251
+ "ChangeVariableName" => activity[:variable],
3252
+ "ErrorHandlingType" => "Rollback",
3253
+ "Type" => mendix_enum(activity[:action]),
3254
+ "Value" => member_value_expr(activity[:value]) }
3255
+ when :validation_feedback
3256
+ validation_feedback_action_doc(activity)
3257
+ when :call_rest
3258
+ rest_call_action_doc(activity)
3259
+ end
3260
+ end
3261
+
3262
+ def rest_call_action_doc(activity)
3263
+ {
3264
+ "$ID" => SecureRandom.uuid,
3265
+ "$Type" => "Microflows$RestCallAction",
3266
+ "ErrorHandlingType" => mendix_enum(activity[:error]),
3267
+ "ErrorResultHandlingType" => mendix_enum(activity[:error_result]),
3268
+ "HttpConfiguration" => {
3269
+ "$ID" => SecureRandom.uuid,
3270
+ "$Type" => "Microflows$HttpConfiguration",
3271
+ "ClientCertificate" => "",
3272
+ "CustomLocation" => "",
3273
+ "CustomLocationTemplate" => {
3274
+ "$ID" => SecureRandom.uuid,
3275
+ "$Type" => "Microflows$StringTemplate",
3276
+ "Parameters" => IO::BsonCodec.build_array(
3277
+ template_parameter_docs(activity[:location_parameters]), marker: 2
3278
+ ),
3279
+ "Text" => activity[:location]
3280
+ },
3281
+ "HttpAuthenticationPassword" => "",
3282
+ "HttpAuthenticationUserName" => "",
3283
+ "HttpHeaderEntries" => IO::BsonCodec.build_array(
3284
+ activity[:headers].map do |key, value|
3285
+ {
3286
+ "$ID" => SecureRandom.uuid,
3287
+ "$Type" => "Microflows$HttpHeaderEntry",
3288
+ "Key" => key, "Value" => value
3289
+ }
3290
+ end
3291
+ ),
3292
+ "HttpMethod" => mendix_enum(activity[:method]),
3293
+ "OverrideLocation" => true,
3294
+ "UseHttpAuthentication" => false
3295
+ },
3296
+ "ProxyConfiguration" => nil,
3297
+ "RequestHandling" => {
3298
+ "$ID" => SecureRandom.uuid,
3299
+ "$Type" => "Microflows$MappingRequestHandling",
3300
+ "ContentType" => "Json",
3301
+ "MappingId" => activity[:request_mapping].to_s,
3302
+ "MappingVariableName" => activity[:request_variable].to_s
3303
+ },
3304
+ "RequestHandlingType" => "Mapping",
3305
+ "RequestProxyType" => "DefaultProxy",
3306
+ "ResultHandling" => rest_result_handling_doc(activity),
3307
+ "ResultHandlingType" => "Mapping",
3308
+ "TimeOutExpression" => activity[:timeout].to_s,
3309
+ "UseRequestTimeOut" => !activity[:timeout].to_s.empty?
3310
+ }
3311
+ end
3312
+
3313
+ def rest_result_handling_doc(activity)
3314
+ {
3315
+ "$ID" => SecureRandom.uuid,
3316
+ "$Type" => "Microflows$ResultHandling",
3317
+ "Bind" => !activity[:variable].to_s.empty?,
3318
+ "ImportMappingCall" => {
3319
+ "$ID" => SecureRandom.uuid,
3320
+ "$Type" => "Microflows$ImportMappingCall",
3321
+ "Commit" => mendix_enum(activity[:commit]),
3322
+ "ContentType" => "Json",
3323
+ "ForceSingleOccurrence" => false,
3324
+ "ObjectHandlingBackup" => "Create",
3325
+ "ParameterVariableName" => "",
3326
+ "Range" => {
3327
+ "$ID" => SecureRandom.uuid,
3328
+ "$Type" => "Microflows$ConstantRange",
3329
+ "SingleObject" => false
3330
+ },
3331
+ "ReturnValueMapping" => activity[:result_mapping].to_s
3332
+ },
3333
+ "ResultVariableName" => activity[:variable].to_s,
3334
+ "VariableType" => {
3335
+ "$ID" => SecureRandom.uuid,
3336
+ "$Type" => "DataTypes$ObjectType",
3337
+ "Entity" => activity[:result_entity].to_s
3338
+ }
3339
+ }
3340
+ end
3341
+
3342
+ def show_form_action_doc(activity)
3343
+ major = @definition.fetch(:version).to_s.split(".").first.to_i
3344
+ settings = {
3345
+ "$ID" => SecureRandom.uuid,
3346
+ "$Type" => "Forms$FormSettings",
3347
+ "Form" => activity[:page]
3348
+ }
3349
+ if major >= 11
3350
+ settings["ParameterMappings"] = IO::BsonCodec.build_array(
3351
+ Array(activity[:mappings]).map { page_parameter_mapping_doc(_1) }, marker: 2
3352
+ )
3353
+ settings["TitleOverride"] = page_title_template_doc(activity[:title])
3354
+ elsif major >= 8
3355
+ settings["TitleOverride"] = activity[:title] ?
3356
+ localized_text_doc(activity[:title]) : nil
3357
+ else
3358
+ settings["FormTitle"] = activity[:title] ?
3359
+ localized_text_doc(activity[:title]) : nil
3360
+ settings["Location"] = mendix_enum(activity[:location] || "Content")
3361
+ end
3362
+
3363
+ doc = {
3364
+ "$ID" => SecureRandom.uuid,
3365
+ "$Type" => "Microflows$ShowFormAction",
3366
+ "ErrorHandlingType" => "Rollback",
3367
+ "FormSettings" => settings
3368
+ }
3369
+ doc["FormObjectVariable"] = activity[:variable].to_s unless major >= 11 || activity[:variable].to_s.empty?
3370
+ doc["NumberOfPagesToClose"] = activity[:close_pages].to_s if major >= 8
3371
+ doc
3372
+ end
3373
+
3374
+ def page_title_template_doc(translations)
3375
+ return nil unless translations
3376
+
3377
+ {
3378
+ "$ID" => SecureRandom.uuid,
3379
+ "$Type" => "Microflows$TextTemplate",
3380
+ "Text" => localized_text_doc(translations),
3381
+ "Parameters" => IO::BsonCodec.build_array([], marker: 2)
3382
+ }
3383
+ end
3384
+
3385
+ def retrieve_range_doc(activity)
3386
+ limit = activity[:limit]
3387
+ if limit
3388
+ {
3389
+ "$ID" => SecureRandom.uuid,
3390
+ "$Type" => "Microflows$CustomRange",
3391
+ "LimitExpression" => limit.to_s,
3392
+ "OffsetExpression" => ""
3393
+ }
3394
+ else
3395
+ {
3396
+ "$ID" => SecureRandom.uuid,
3397
+ "$Type" => "Microflows$ConstantRange",
3398
+ "SingleObject" => activity[:single] == true
3399
+ }
3400
+ end
3401
+ end
3402
+
3403
+ def retrieve_sorting_doc(sorting)
3404
+ attribute, order = Array(sorting)
3405
+ major, minor = @definition.fetch(:version).to_s.split(".").map(&:to_i)
3406
+ doc = {
3407
+ "$ID" => SecureRandom.uuid,
3408
+ "$Type" => "Microflows$RetrieveSorting",
3409
+ "SortOrder" => mendix_enum(order || "Ascending")
3410
+ }
3411
+ if major > 7 || (major == 7 && minor >= 11)
3412
+ doc["AttributeRef"] = {
3413
+ "$ID" => SecureRandom.uuid,
3414
+ "$Type" => "DomainModels$AttributeRef",
3415
+ "Attribute" => attribute.to_s,
3416
+ "EntityRef" => nil
3417
+ }
3418
+ else
3419
+ doc["AttributePath"] = attribute.to_s
3420
+ end
3421
+ doc
3422
+ end
3423
+
3424
+ def close_form_action_doc(activity)
3425
+ major = @definition.fetch(:version).to_s.split(".").first.to_i
3426
+ doc = {
3427
+ "$ID" => SecureRandom.uuid,
3428
+ "$Type" => "Microflows$CloseFormAction",
3429
+ "ErrorHandlingType" => "Rollback"
3430
+ }
3431
+ doc["NumberOfPagesToClose"] = activity[:count].to_s if major >= 8
3432
+ doc
3433
+ end
3434
+
3435
+ def page_parameter_mapping_doc(mapping)
3436
+ {
3437
+ "$ID" => SecureRandom.uuid,
3438
+ "$Type" => "Forms$PageParameterMapping",
3439
+ "Argument" => member_value_expr(mapping[:value]),
3440
+ "Parameter" => mapping[:parameter],
3441
+ "Variable" => {
3442
+ "$ID" => SecureRandom.uuid,
3443
+ "$Type" => "Forms$PageVariable",
3444
+ "PageParameter" => "",
3445
+ "SnippetParameter" => "",
3446
+ "UseAllPages" => false,
3447
+ "Widget" => ""
3448
+ }
3449
+ }
3450
+ end
3451
+
3452
+ def mendix_enum(value)
3453
+ value.to_s.split("_").map(&:capitalize).join
3454
+ end
3455
+
3456
+ def java_action_call_doc(activity)
3457
+ major, minor = @definition.fetch(:version).to_s.split(".").map(&:to_i)
3458
+ result_name = activity[:result_name] || activity[:variable].to_s
3459
+ value_type = major >= 8 ? "Microflows$BasicCodeActionParameterValue" :
3460
+ "Microflows$BasicJavaActionParameterValue"
3461
+ mappings = Array(activity[:mappings]).map do |mapping|
3462
+ value = code_action_parameter_doc(
3463
+ mapping[:value], basic_type: value_type, code: false
3464
+ )
3465
+ if value["Argument"] &&
3466
+ (major.between?(8, 10) || (major == 7 && minor >= 11))
3467
+ value["ArgumentModel"] = no_expression_doc
3468
+ end
3469
+ {
3470
+ "$ID" => SecureRandom.uuid,
3471
+ "$Type" => "Microflows$JavaActionParameterMapping",
3472
+ "Parameter" => mapping[:param],
3473
+ "Value" => value
3474
+ }
3475
+ end
3476
+ doc = {
3477
+ "$ID" => SecureRandom.uuid,
3478
+ "$Type" => "Microflows$JavaActionCallAction",
3479
+ "ErrorHandlingType" => "Rollback",
3480
+ "JavaAction" => activity[:name],
3481
+ "ParameterMappings" => IO::BsonCodec.build_array(mappings, marker: 2),
3482
+ "ResultVariableName" => result_name
3483
+ }
3484
+ doc["UseReturnVariable"] = activity[:use_return] == true if major >= 8 || (major == 7 && minor >= 11)
3485
+ doc["QueueSettings"] = nil if major >= 10
3486
+ doc["Queue"] = "" if major.between?(8, 9)
3487
+ doc
3488
+ end
3489
+
3490
+ def javascript_action_call_doc(activity)
3491
+ result_name = activity[:result_name] || activity[:variable].to_s
3492
+ mappings = Array(activity[:mappings]).map do |mapping|
3493
+ {
3494
+ "$ID" => SecureRandom.uuid,
3495
+ "$Type" => "Microflows$JavaScriptActionParameterMapping",
3496
+ "Parameter" => mapping[:param],
3497
+ "ParameterValue" => code_action_parameter_doc(
3498
+ mapping[:value],
3499
+ basic_type: "Microflows$BasicCodeActionParameterValue",
3500
+ code: true
3501
+ )
3502
+ }
3503
+ end
3504
+ {
3505
+ "$ID" => SecureRandom.uuid,
3506
+ "$Type" => "Microflows$JavaScriptActionCallAction",
3507
+ "ErrorHandlingType" => "Abort",
3508
+ "JavaScriptAction" => activity[:name],
3509
+ "OutputVariableName" => result_name,
3510
+ "ParameterMappings" => IO::BsonCodec.build_array(mappings, marker: 2),
3511
+ "UseReturnVariable" => activity[:use_return] == true
3512
+ }
3513
+ end
3514
+
3515
+ def code_action_parameter_doc(value, basic_type:, code:)
3516
+ unless value.is_a?(Hash) && value[:kind]
3517
+ return {
3518
+ "$ID" => SecureRandom.uuid,
3519
+ "$Type" => basic_type,
3520
+ "Argument" => member_value_expr(value)
3521
+ }
3522
+ end
3523
+
3524
+ kind = value[:kind].to_sym
3525
+ suffix = code ? "CodeActionParameterValue" : "JavaActionParameterValue"
3526
+ prefix, field = case kind
3527
+ when :entity then ["EntityType", "Entity"]
3528
+ when :microflow then ["Microflow", "Microflow"]
3529
+ when :import_mapping then ["ImportMapping", "ImportMapping"]
3530
+ when :export_mapping then ["ExportMapping", "ExportMapping"]
3531
+ else
3532
+ return value[:value] if value[:value].is_a?(Hash)
3533
+ ["Basic", "Argument"]
3534
+ end
3535
+ {
3536
+ "$ID" => SecureRandom.uuid,
3537
+ "$Type" => "Microflows$#{prefix}#{suffix}",
3538
+ field => value[:value]
3539
+ }
3540
+ end
3541
+
3542
+ def nanoflow_call_doc(activity)
3543
+ result_name = activity[:result_name] || activity[:variable].to_s
3544
+ mappings = Array(activity[:mappings]).map do |mapping|
3545
+ {
3546
+ "$ID" => SecureRandom.uuid,
3547
+ "$Type" => "Microflows$NanoflowCallParameterMapping",
3548
+ "Argument" => member_value_expr(mapping[:value]),
3549
+ "Parameter" => mapping[:param]
3550
+ }
3551
+ end
3552
+ {
3553
+ "$ID" => SecureRandom.uuid,
3554
+ "$Type" => "Microflows$NanoflowCallAction",
3555
+ "ErrorHandlingType" => "Abort",
3556
+ "NanoflowCall" => {
3557
+ "$ID" => SecureRandom.uuid,
3558
+ "$Type" => "Microflows$NanoflowCall",
3559
+ "Nanoflow" => activity[:name],
3560
+ "ParameterMappings" => IO::BsonCodec.build_array(mappings, marker: 2)
3561
+ },
3562
+ "OutputVariableName" => result_name,
3563
+ "UseReturnVariable" => activity[:use_return] == true
3564
+ }
3565
+ end
3566
+
3567
+ def app_service_call_doc(activity)
3568
+ result_name = activity[:result_name] || activity[:variable].to_s
3569
+ mappings = Array(activity[:mappings]).map do |mapping|
3570
+ {
3571
+ "$ID" => SecureRandom.uuid,
3572
+ "$Type" => "AppServices$AppServiceActionParameterMapping",
3573
+ "Argument" => member_value_expr(mapping[:value]),
3574
+ "Parameter" => mapping[:param]
3575
+ }
3576
+ end
3577
+ {
3578
+ "$ID" => SecureRandom.uuid,
3579
+ "$Type" => "Microflows$AppServiceCallAction",
3580
+ "AppServiceAction" => activity[:name],
3581
+ "ErrorHandlingType" => "Rollback",
3582
+ "ParameterMappings" => IO::BsonCodec.build_array(mappings, marker: 2),
3583
+ "ResultVariableName" => result_name,
3584
+ "UseReturnVariable" => activity[:use_return] == true
3585
+ }
3586
+ end
3587
+
3588
+ def validation_feedback_action_doc(activity)
3589
+ translations = activity[:translations].map do |language, text|
3590
+ {
3591
+ "$ID" => SecureRandom.uuid,
3592
+ "$Type" => "Texts$Translation",
3593
+ "LanguageCode" => language,
3594
+ "Text" => text
3595
+ }
3596
+ end
3597
+ parameters = Array(activity[:parameters]).map do |expression|
3598
+ {
3599
+ "$ID" => SecureRandom.uuid,
3600
+ "$Type" => "Microflows$TemplateParameter",
3601
+ "Expression" => member_value_expr(expression),
3602
+ "ExpressionModel" => no_expression_doc
3603
+ }
3604
+ end
3605
+ {
3606
+ "$ID" => SecureRandom.uuid,
3607
+ "$Type" => "Microflows$ValidationFeedbackAction",
3608
+ "Association" => activity[:association].to_s,
3609
+ "Attribute" => activity[:attribute].to_s,
3610
+ "ErrorHandlingType" => mendix_enum(activity[:error]),
3611
+ "FeedbackTemplate" => {
3612
+ "$ID" => SecureRandom.uuid,
3613
+ "$Type" => "Microflows$TextTemplate",
3614
+ "Parameters" => IO::BsonCodec.build_array(parameters, marker: 2),
3615
+ "Text" => {
3616
+ "$ID" => SecureRandom.uuid,
3617
+ "$Type" => "Texts$Text",
3618
+ "Items" => IO::BsonCodec.build_array(translations)
3619
+ }
3620
+ },
3621
+ "ValidationVariableName" => activity[:variable]
3622
+ }
3623
+ end
3624
+
3625
+ def no_expression_doc
3626
+ { "$ID" => SecureRandom.uuid, "$Type" => "Expressions$NoExpression" }
3627
+ end
3628
+
3629
+ def template_parameter_docs(parameters)
3630
+ Array(parameters).map do |expression|
3631
+ {
3632
+ "$ID" => SecureRandom.uuid,
3633
+ "$Type" => "Microflows$TemplateParameter",
3634
+ "Expression" => member_value_expr(expression),
3635
+ "ExpressionModel" => no_expression_doc
3636
+ }
3637
+ end
3638
+ end
3639
+
3640
+ def localized_text_doc(translations)
3641
+ {
3642
+ "$ID" => SecureRandom.uuid,
3643
+ "$Type" => "Texts$Text",
3644
+ "Items" => IO::BsonCodec.build_array(
3645
+ translations.map do |language, text|
3646
+ {
3647
+ "$ID" => SecureRandom.uuid,
3648
+ "$Type" => "Texts$Translation",
3649
+ "LanguageCode" => language.to_s,
3650
+ "Text" => text.to_s
3651
+ }
3652
+ end
3653
+ )
3654
+ }
3655
+ end
3656
+
3657
+ def variable_type_doc(type)
3658
+ native_type = type.to_s
3659
+ unless native_type.include?("$")
3660
+ type_name = native_type.split("_").map(&:capitalize).join
3661
+ type_name = "DateTime" if type_name == "Datetime"
3662
+ native_type = "DataTypes$#{type_name}Type"
3663
+ end
3664
+ { "$ID" => SecureRandom.uuid, "$Type" => native_type }
3665
+ end
3666
+
3667
+ def change_action_item_doc(member, entity: nil)
3668
+ association = qualified_association_identifier(member[:association], entity)
3669
+ attribute = qualified_attribute_identifier(member[:attribute], entity)
3670
+ if member[:association] && !association
3671
+ raise SerializationError,
3672
+ "cannot qualify association #{member[:association].inspect} without an object entity"
3673
+ end
3674
+ if member[:attribute] && !attribute
3675
+ raise SerializationError,
3676
+ "cannot qualify attribute #{member[:attribute].inspect} without an object entity"
3677
+ end
3678
+ { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$ChangeActionItem",
3679
+ "Association" => association.to_s,
3680
+ "Attribute" => attribute.to_s,
3681
+ "Type" => mendix_enum(member[:operation] || "Set"),
3682
+ "Value" => member_value_expr(member[:value]),
3683
+ "ValueModel" => no_expression_doc }
3684
+ end
3685
+
3686
+ def qualified_association_identifier(association, entity = nil)
3687
+ value = association.to_s
3688
+ return if value.empty?
3689
+
3690
+ value = value.tr('/', '.')
3691
+ return value if value.include?('.')
3692
+
3693
+ entity_name = qualified_entity_name(entity)
3694
+ return unless entity_name
3695
+
3696
+ "#{entity_name.split('.').first}.#{value}"
3697
+ end
3698
+
3699
+ def member_value_expr(value)
3700
+ case value
3701
+ when Symbol then "$#{value}"
3702
+ when Integer, Float then value.to_s
3703
+ when true, false then value.to_s
3704
+ when nil then ""
3705
+ else value.to_s
3706
+ end
3707
+ end
3708
+
3709
+ def nanoflow_doc(flow)
3710
+ doc = microflow_doc(flow)
3711
+ normalize_nanoflow_error_handling!(doc)
3712
+ doc.merge(
3713
+ "$Type" => "Microflows$Nanoflow",
3714
+ "AllowConcurrentExecution" => nil,
3715
+ "UseListParameterByReference" => true
3716
+ ).compact
3717
+ end
3718
+
3719
+ def normalize_nanoflow_error_handling!(value)
3720
+ case value
3721
+ when Hash
3722
+ value["ErrorHandlingType"] = "Abort" if value["ErrorHandlingType"] == "Rollback"
3723
+ value.each_value { normalize_nanoflow_error_handling!(_1) }
3724
+ when Array
3725
+ value.each { normalize_nanoflow_error_handling!(_1) }
3726
+ end
3727
+ value
3728
+ end
3729
+
3730
+ def text_doc(text)
3731
+ { "$ID" => SecureRandom.uuid, "$Type" => "Texts$Text",
3732
+ "Items" => IO::BsonCodec.build_array([
3733
+ { "$ID" => SecureRandom.uuid, "$Type" => "Texts$Translation",
3734
+ "LanguageCode" => "en_US", "Text" => text }
3735
+ ]) }
3736
+ end
3737
+
3738
+ def client_template_doc(text)
3739
+ {
3740
+ "$ID" => SecureRandom.uuid, "$Type" => "Forms$ClientTemplate",
3741
+ "Fallback" => text_doc(""),
3742
+ "Parameters" => IO::BsonCodec.build_array([], marker: 2),
3743
+ "Template" => text_doc(text.to_s)
3744
+ }
3745
+ end
3746
+
3747
+ def empty_client_template_doc
3748
+ {
3749
+ '$ID' => SecureRandom.uuid, '$Type' => 'Forms$ClientTemplate',
3750
+ 'Fallback' => {
3751
+ '$ID' => SecureRandom.uuid, '$Type' => 'Texts$Text',
3752
+ 'Items' => IO::BsonCodec.build_array([])
3753
+ },
3754
+ 'Parameters' => IO::BsonCodec.build_array([], marker: 2),
3755
+ 'Template' => {
3756
+ '$ID' => SecureRandom.uuid, '$Type' => 'Texts$Text',
3757
+ 'Items' => IO::BsonCodec.build_array([])
3758
+ }
3759
+ }
3760
+ end
3761
+
3762
+ def appearance_doc(class_name = "")
3763
+ {
3764
+ "$ID" => SecureRandom.uuid, "$Type" => "Forms$Appearance",
3765
+ "Class" => class_name.to_s,
3766
+ "DesignProperties" => IO::BsonCodec.build_array([]),
3767
+ "DynamicClasses" => "", "Style" => ""
3768
+ }
3769
+ end
3770
+
3771
+ def array_items(value)
3772
+ IO::BsonCodec.parse_array(value)[:items]
3773
+ end
3774
+ end
3775
+ end