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,474 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Mxrb
6
+ module Runtime
7
+ module Native
8
+ # Graph interpretation intentionally keeps semantic dispatch together.
9
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity
10
+ # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
11
+ ObjectValue = Data.define(:entity, :id, :members)
12
+
13
+ # Transactional in-memory persistence used by the Ruby model Runtime.
14
+ class Store
15
+ def initialize
16
+ @records = Hash.new { |records, entity| records[entity] = [] }
17
+ end
18
+
19
+ def create(entity)
20
+ ObjectValue.new(entity:, id: SecureRandom.uuid, members: {}).tap do |object|
21
+ @records[entity] << object
22
+ end
23
+ end
24
+
25
+ def retrieve(entity) = @records[entity].dup
26
+
27
+ def delete(value)
28
+ Array(value).each { @records[_1.entity].delete(_1) }
29
+ end
30
+
31
+ def count(entity, predicate = nil)
32
+ values = retrieve(entity)
33
+ predicate ? values.count { predicate.call(_1) } : values.size
34
+ end
35
+
36
+ def snapshot
37
+ @records.to_h do |entity, values|
38
+ [entity, values.map { ObjectValue.new(entity: _1.entity, id: _1.id, members: _1.members.dup) }]
39
+ end
40
+ end
41
+
42
+ def restore(snapshot)
43
+ @records.clear
44
+ snapshot.each { |entity, values| @records[entity] = values }
45
+ end
46
+ end
47
+
48
+ # Deliberately small Mendix-expression evaluator. Unsupported syntax is
49
+ # rejected rather than guessed, preserving deterministic test semantics.
50
+ class Expression
51
+ COMPARISONS = {
52
+ '=' => ->(left, right) { left == right },
53
+ '!=' => ->(left, right) { left != right },
54
+ '>' => ->(left, right) { left > right },
55
+ '<' => ->(left, right) { left < right },
56
+ '>=' => ->(left, right) { left >= right },
57
+ '<=' => ->(left, right) { left <= right }
58
+ }.freeze
59
+
60
+ def evaluate(source, variables, node: nil)
61
+ text = strip_outer_parentheses(unwrap(source).strip)
62
+ return nil if text.empty? || text == 'empty'
63
+ return true if text.casecmp?('true')
64
+ return false if text.casecmp?('false')
65
+ return text[1...-1].gsub("''", "'") if quoted?(text)
66
+ return Integer(text) if text.match?(/\A-?\d+\z/)
67
+ return Float(text) if text.match?(/\A-?\d+\.\d+\z/)
68
+ return variable(text, variables) if text.match?(%r{\A\$[A-Za-z_]\w*(?:/[A-Za-z_][\w.]*)?\z})
69
+ return node_member(text, node) if node && bare_reference?(text)
70
+
71
+ logical(text, variables) { |part| evaluate(part, variables, node:) }
72
+ rescue ArgumentError, TypeError
73
+ raise NativeRuntimeError, "unsupported Mendix expression: #{source.inspect}"
74
+ end
75
+
76
+ private
77
+
78
+ def unwrap(source)
79
+ source.is_a?(Hash) ? (source['Value'] || source['Expression'] || '') : source.to_s
80
+ end
81
+
82
+ def quoted?(text)
83
+ text.length >= 2 && text.start_with?("'") && text.end_with?("'")
84
+ end
85
+
86
+ def strip_outer_parentheses(text)
87
+ text = text[1...-1].strip while text.start_with?('(') && matching_parenthesis(text, 0) == text.length - 1
88
+ text
89
+ end
90
+
91
+ def matching_parenthesis(text, opening)
92
+ depth = 0
93
+ quoted = false
94
+ text.each_char.with_index do |character, index|
95
+ next if index < opening
96
+
97
+ quoted = !quoted if character == "'"
98
+ next if quoted
99
+
100
+ depth += 1 if character == '('
101
+ depth -= 1 if character == ')'
102
+ return index if depth.zero?
103
+ end
104
+ nil
105
+ end
106
+
107
+ def variable(text, variables)
108
+ name, member = text.delete_prefix('$').split('/', 2)
109
+ value = variables.fetch(name) do
110
+ raise NativeRuntimeError, "unknown variable $#{name}"
111
+ end
112
+ return value unless member
113
+
114
+ raise NativeRuntimeError, "$#{name} is not an object" unless value.is_a?(ObjectValue)
115
+
116
+ value.members[member.split('.').last]
117
+ end
118
+
119
+ # An attribute reference inside an XPath predicate, e.g. `Name` or
120
+ # `Clinic.Animal/Name`, resolved against the current candidate object.
121
+ def bare_reference?(text)
122
+ text.match?(%r{\A[A-Za-z_]\w*(?:/[A-Za-z_][\w.]*)?\z})
123
+ end
124
+
125
+ def node_member(text, node)
126
+ node.members[text.split('/').last.split('.').last]
127
+ end
128
+
129
+ def logical(text, variables, &block)
130
+ split = split_operator(text, /\s+or\s+/i)
131
+ return split.any? { yield(_1) } if split.size > 1
132
+
133
+ split = split_operator(text, /\s+and\s+/i)
134
+ return split.all? { yield(_1) } if split.size > 1
135
+
136
+ comparison(text, variables, &block)
137
+ end
138
+
139
+ def comparison(text, _variables)
140
+ match = text.match(/\A\(?(.+?)\)?\s*(=|!=|>=|<=|>|<)\s*\(?(.+?)\)?\z/)
141
+ raise ArgumentError unless match
142
+
143
+ left = yield(match[1])
144
+ right = yield(match[3])
145
+ COMPARISONS.fetch(match[2]).call(left, right)
146
+ end
147
+
148
+ def split_operator(text, operator)
149
+ text.split(operator)
150
+ end
151
+ end
152
+
153
+ # Executes the graph and core data activities directly from the MPR.
154
+ class Interpreter
155
+ attr_reader :store, :log
156
+
157
+ def initialize(project, store: Store.new)
158
+ @project = project
159
+ @store = store
160
+ @expression = Expression.new
161
+ @log = []
162
+ @flows = project.modules.flat_map do |mod|
163
+ mod.microflows.map { ["#{mod.name}.#{_1.name}", _1] }
164
+ end.to_h
165
+ end
166
+
167
+ def call(name, arguments = {})
168
+ flow = @flows[name]
169
+ raise NativeRuntimeError, "microflow #{name} not found" unless flow
170
+
171
+ snapshot = store.snapshot
172
+ execute(flow, normalize_arguments(flow, arguments))
173
+ rescue StandardError
174
+ store.restore(snapshot) if snapshot
175
+ raise
176
+ end
177
+
178
+ # Counts stored objects of an entity, optionally narrowed by an XPath
179
+ # constraint. Used by the functional Executor's count expectations.
180
+ def count(entity, xpath = nil)
181
+ filter_by_xpath(store.retrieve(entity.to_s), xpath.to_s, {}).size
182
+ end
183
+
184
+ private
185
+
186
+ def execute(flow, variables)
187
+ objects = flow.objects.to_h { [identifier(_1), _1] }
188
+ outgoing = flow.flows.reject { _1['IsErrorHandler'] == true }
189
+ .group_by { identifier(_1['OriginPointer']) }
190
+ current = flow.objects.find { _1['$Type'] == 'Microflows$StartEvent' }
191
+ raise NativeRuntimeError, "microflow #{flow.name} has no start event" unless current
192
+
193
+ 10_000.times do
194
+ type = current['$Type']
195
+ return @expression.evaluate(current['ReturnValue'], variables) if type == 'Microflows$EndEvent'
196
+
197
+ execute_action(current['Action'], variables) if type == 'Microflows$ActionActivity'
198
+ edge = select_edge(current, outgoing[identifier(current)] || [], variables)
199
+ raise NativeRuntimeError, "microflow #{flow.name} stops at #{type}" unless edge
200
+
201
+ current = objects[identifier(edge['DestinationPointer'])]
202
+ raise NativeRuntimeError, 'sequence flow points to a missing object' unless current
203
+ end
204
+ raise NativeRuntimeError, "microflow #{flow.name} exceeded 10000 steps"
205
+ end
206
+
207
+ def select_edge(object, edges, variables)
208
+ return edges.first unless %w[Microflows$ExclusiveSplit Microflows$InheritanceSplit].include?(object['$Type'])
209
+
210
+ value = split_value(object, variables)
211
+ edges.find { flow_case(_1) == value.to_s } || edges.find { flow_case(_1).empty? }
212
+ end
213
+
214
+ def split_value(object, variables)
215
+ if object['$Type'] == 'Microflows$InheritanceSplit'
216
+ value = variables.fetch(object['SplitVariableName'].to_s)
217
+ value.respond_to?(:entity) ? value.entity : value.class.name
218
+ else
219
+ @expression.evaluate(object.dig('SplitCondition', 'Expression'), variables)
220
+ end
221
+ end
222
+
223
+ def flow_case(edge)
224
+ values = items(edge['CaseValues'])
225
+ value = values.first || edge['NewCaseValue'] || {}
226
+ value['$Type'] == 'Microflows$NoCase' ? '' : value['Value'].to_s
227
+ end
228
+
229
+ def execute_action(action, variables)
230
+ raise NativeRuntimeError, 'action activity has no action' unless action.is_a?(Hash)
231
+
232
+ handler = action['$Type'].to_s.delete_prefix('Microflows$').delete_suffix('Action')
233
+ method = "action_#{underscore(handler)}"
234
+ raise NativeRuntimeError, "unsupported activity #{action['$Type']}" unless respond_to?(method, true)
235
+
236
+ send(method, action, variables)
237
+ end
238
+
239
+ def action_create_change(action, variables)
240
+ object = store.create(action['Entity'].to_s)
241
+ apply_changes(object, action['Items'], variables)
242
+ variables[action['VariableName'].to_s] = object
243
+ end
244
+
245
+ def action_change(action, variables)
246
+ object = variables.fetch(action['ChangeVariableName'].to_s)
247
+ apply_changes(object, action['Items'], variables)
248
+ end
249
+
250
+ def action_create_variable(action, variables)
251
+ variables[action['VariableName'].to_s] = @expression.evaluate(action['InitialValue'], variables)
252
+ end
253
+
254
+ def action_change_variable(action, variables)
255
+ variables[action['ChangeVariableName'].to_s] = @expression.evaluate(action['Value'], variables)
256
+ end
257
+
258
+ def action_retrieve(action, variables)
259
+ source = action['RetrieveSource'] || {}
260
+ unless source['$Type'] == 'Microflows$DatabaseRetrieveSource'
261
+ raise NativeRuntimeError, "unsupported retrieve source #{source['$Type']}"
262
+ end
263
+
264
+ values = filter_by_xpath(store.retrieve(source['Entity'].to_s), source['XpathConstraint'].to_s, variables)
265
+ values = sort_values(values, items(source.dig('NewSortings', 'Sortings')))
266
+ range = source['Range'] || {}
267
+ limit = @expression.evaluate(range['LimitExpression'], variables)
268
+ values = values.first(limit) if limit.is_a?(Integer) && limit.positive?
269
+ variables[action['ResultVariableName'].to_s] = range['SingleObject'] == true ? values.first : values
270
+ end
271
+
272
+ # Narrows a value set by a Mendix XPath constraint. Only attribute
273
+ # predicates (comparisons, and/or, boolean shorthand) are understood;
274
+ # anything else is rejected by the expression evaluator rather than
275
+ # silently ignored.
276
+ def filter_by_xpath(values, xpath, variables)
277
+ predicate = xpath_predicate(xpath)
278
+ return values if predicate.empty?
279
+
280
+ values.select { @expression.evaluate(predicate, variables, node: _1) }
281
+ end
282
+
283
+ def xpath_predicate(xpath)
284
+ text = xpath.strip
285
+ return '' if text.empty?
286
+
287
+ groups = text.scan(/\[([^\[\]]*)\]/).flatten.map(&:strip).reject(&:empty?)
288
+ unless groups.any? && text.gsub(/\[[^\[\]]*\]/, '').strip.empty?
289
+ raise NativeRuntimeError, "unsupported native XPath constraint: #{xpath.inspect}"
290
+ end
291
+
292
+ groups.map { "(#{_1})" }.join(' and ')
293
+ end
294
+
295
+ def sort_values(values, sortings)
296
+ return values if sortings.empty?
297
+
298
+ keys = sortings.map { [sort_attribute(_1), descending?(_1)] }
299
+ values.sort { |left, right| compare_by_keys(left, right, keys) }
300
+ end
301
+
302
+ def compare_by_keys(left, right, keys)
303
+ keys.each do |attribute, descending|
304
+ comparison = compare_members(left.members[attribute], right.members[attribute])
305
+ comparison = -comparison if descending
306
+ return comparison unless comparison.zero?
307
+ end
308
+ 0
309
+ end
310
+
311
+ def compare_members(left, right)
312
+ return 0 if left.nil? && right.nil?
313
+ return 1 if left.nil?
314
+ return -1 if right.nil?
315
+
316
+ (left <=> right) || raise(NativeRuntimeError, "cannot sort #{left.inspect} and #{right.inspect}")
317
+ end
318
+
319
+ def sort_attribute(sorting)
320
+ path = (sorting['AttributePath'] || sorting.dig('AttributeRef', 'Attribute')).to_s
321
+ raise NativeRuntimeError, 'native retrieve sorting requires an attribute' if path.empty?
322
+
323
+ path.split(%r{[./]}).last
324
+ end
325
+
326
+ def descending?(sorting)
327
+ sorting['SortOrder'].to_s.casecmp?('Descending')
328
+ end
329
+
330
+ def action_aggregate(action, variables)
331
+ values = Array(variables.fetch(action['AggregateVariableName'].to_s))
332
+ attribute = action['Attribute'].to_s.split('.').last.to_s
333
+ members = attribute.empty? ? values : values.map { _1.members[attribute] }
334
+ result = aggregate(action['AggregateFunction'], members)
335
+ variables[action['VariableName'].to_s] = result
336
+ end
337
+
338
+ def action_microflow_call(action, variables)
339
+ call_doc = action['MicroflowCall'] || {}
340
+ arguments = items(call_doc['ParameterMappings']).to_h do |mapping|
341
+ [mapping['Parameter'].to_s.split('.').last, @expression.evaluate(mapping['Argument'], variables)]
342
+ end
343
+ result = call(call_doc['Microflow'].to_s, arguments)
344
+ variables[action['ResultVariableName'].to_s] = result if action['UseReturnVariable'] == true
345
+ end
346
+
347
+ def action_commit(_action, _variables); end
348
+
349
+ def action_delete(action, variables)
350
+ store.delete(variables.fetch(action['DeleteVariableName'].to_s))
351
+ end
352
+
353
+ def action_log_message(action, variables)
354
+ @log << render_template(action['MessageTemplate'], variables)
355
+ end
356
+
357
+ def action_cast(_action, _variables); end
358
+
359
+ def aggregate(function, values)
360
+ case function.to_s.downcase
361
+ when 'count' then values.size
362
+ when 'sum' then values.compact.sum
363
+ when 'minimum', 'min' then values.compact.min
364
+ when 'maximum', 'max' then values.compact.max
365
+ when 'average', 'avg' then values.empty? ? nil : values.compact.sum.to_f / values.compact.size
366
+ else raise NativeRuntimeError, "unsupported aggregate #{function}"
367
+ end
368
+ end
369
+
370
+ def apply_changes(object, values, variables)
371
+ items(values).each do |item|
372
+ member = (item['Attribute'].to_s.empty? ? item['Association'] : item['Attribute']).to_s
373
+ name = member.split(%r{[./]}).last
374
+ object.members[name] = @expression.evaluate(item['Value'], variables)
375
+ end
376
+ end
377
+
378
+ def render_template(template, variables)
379
+ text = template&.dig('Text').to_s
380
+ items(template&.dig('Parameters')).each_with_index do |parameter, index|
381
+ text = text.gsub("{#{index + 1}}", @expression.evaluate(parameter['Expression'], variables).to_s)
382
+ end
383
+ text
384
+ end
385
+
386
+ def normalize_arguments(flow, arguments)
387
+ names = flow.parameters.filter_map { _1['Name'] if _1.is_a?(Hash) }
388
+ names.to_h do |name|
389
+ [name, arguments.fetch(name) { raise NativeRuntimeError, "missing argument #{name}" }]
390
+ end
391
+ end
392
+
393
+ def items(value)
394
+ IO::BsonCodec.parse_array(value)[:items]
395
+ rescue StandardError
396
+ Array(value).drop(value.is_a?(Array) && value.first.is_a?(Integer) ? 1 : 0)
397
+ end
398
+
399
+ def identifier(value)
400
+ IO::BsonCodec.extract_id(value.is_a?(Hash) ? value['$ID'] : value).to_s
401
+ end
402
+
403
+ def underscore(value)
404
+ value.gsub(/([a-z\d])([A-Z])/, '\\1_\\2').downcase
405
+ end
406
+ end
407
+
408
+ # Runs existing functional definitions against the Ruby interpreter.
409
+ class Executor
410
+ def initialize(project_path, definition, output: nil, clock: Process.method(:clock_gettime), **)
411
+ @project_path = File.expand_path(project_path)
412
+ @definition = definition
413
+ @output = output
414
+ @clock = clock
415
+ end
416
+
417
+ def run
418
+ started = monotonic_time
419
+ project = Model::Project.open(@project_path)
420
+ interpreter = Interpreter.new(project)
421
+ tests = @definition.tests.map { run_test(interpreter, _1) }
422
+ transcript = tests.map { "[MXRB_TEST] #{_1.passed? ? 'PASS' : 'FAIL'} #{_1.name}" }.join("\n")
423
+ transcript = "#{transcript}\n[MXRB_TEST] DONE\n"
424
+ @output&.write(transcript)
425
+ Execution.new(
426
+ Functional::Result.new(tests.freeze, true), 'MXRB native validation passed',
427
+ 'Ruby microflow interpreter', transcript, monotonic_time - started
428
+ )
429
+ ensure
430
+ project&.close
431
+ end
432
+
433
+ private
434
+
435
+ def run_test(interpreter, test)
436
+ interpreter.call(test.setup.target, evaluated_arguments(test.setup.arguments)) if test.setup
437
+ actual = interpreter.call(test.target, evaluated_arguments(test.arguments))
438
+ failures = expectations(interpreter, test, actual)
439
+ interpreter.call(test.cleanup.target, evaluated_arguments(test.cleanup.arguments)) if test.cleanup
440
+ Functional::TestResult.new(test.name, failures.empty?, failures.empty? ? 'passed' : failures.join('; '))
441
+ rescue StandardError => e
442
+ Functional::TestResult.new(test.name, false, e.message)
443
+ end
444
+
445
+ def expectations(interpreter, test, actual)
446
+ failures = []
447
+ if test.expected_return
448
+ expected = Expression.new.evaluate(test.expected_return, {})
449
+ failures << "return #{actual.inspect}, expected #{expected.inspect}" unless actual == expected
450
+ end
451
+ test.counts.each do |expectation|
452
+ actual_count = interpreter.count(expectation.entity, expectation.xpath)
453
+ unless actual_count == expectation.equals
454
+ failures << "#{expectation.entity} count #{actual_count}, expected #{expectation.equals}"
455
+ end
456
+ end
457
+ failures
458
+ end
459
+
460
+ def evaluated_arguments(arguments)
461
+ arguments.to_h.transform_values { Expression.new.evaluate(_1, {}) }
462
+ end
463
+
464
+ def monotonic_time
465
+ @clock.call(Process::CLOCK_MONOTONIC)
466
+ rescue ArgumentError
467
+ @clock.call
468
+ end
469
+ end
470
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity
471
+ # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity
472
+ end
473
+ end
474
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+
6
+ module Mxrb
7
+ module Runtime
8
+ # Read-only query-plan connection to an explicitly configured SQL Server
9
+ # deployment. Credentials are passed through SQLCMDPASSWORD, never argv.
10
+ class SqlServerDatabase # rubocop:disable Metrics/ClassLength
11
+ MUTATING_SQL = /\b(?:ALTER|CREATE|DELETE|DROP|EXEC(?:UTE)?|INSERT|INTO|MERGE|TRUNCATE|UPDATE)\b/i
12
+
13
+ # rubocop:disable Metrics/ParameterLists
14
+ def initialize(server:, database:, user:, password: ENV['MXRB_SQLSERVER_PASSWORD'],
15
+ sqlcmd: 'sqlcmd', runner: nil)
16
+ @server = required(server, 'SQL Server host')
17
+ @database = required(database, 'SQL Server database')
18
+ @user = required(user, 'SQL Server user')
19
+ @password = required(password, 'MXRB_SQLSERVER_PASSWORD')
20
+ @sqlcmd = sqlcmd
21
+ @runner = runner || method(:capture)
22
+ end
23
+ # rubocop:enable Metrics/ParameterLists
24
+
25
+ def explain(sql, analyze: false)
26
+ statement = read_only_statement(sql)
27
+ mode = analyze ? 'STATISTICS XML' : 'SHOWPLAN_XML'
28
+ output = run!("SET #{mode} ON; #{statement}; SET #{mode} OFF;")
29
+ Oql::SqlServerPlanAnalyzer.new.analyze(extract_showplan(output))
30
+ end
31
+
32
+ def workload(limit: 20)
33
+ maximum = Integer(limit)
34
+ raise ArgumentError, 'workload limit must be between 1 and 1000' unless (1..1000).cover?(maximum)
35
+
36
+ Oql::SqlServerWorkloadAnalyzer.new.analyze(
37
+ query_rows: query_workload(maximum), table_rows: table_workload,
38
+ index_rows: index_workload
39
+ )
40
+ end
41
+
42
+ private
43
+
44
+ def query_workload(limit) # rubocop:disable Metrics/MethodLength
45
+ run_json(<<~SQL)
46
+ SELECT TOP (#{limit})
47
+ CONVERT(varchar(66), qs.query_hash, 1) AS query_hash,
48
+ SUBSTRING(st.text, (qs.statement_start_offset / 2) + 1,
49
+ ((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text)
50
+ ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2) + 1) AS query_text,
51
+ qs.execution_count, qs.total_elapsed_time, qs.total_rows,
52
+ qs.total_logical_reads, qs.total_physical_reads
53
+ FROM sys.dm_exec_query_stats qs
54
+ CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
55
+ ORDER BY qs.total_elapsed_time DESC FOR JSON PATH
56
+ SQL
57
+ end
58
+
59
+ def table_workload
60
+ run_json(<<~SQL)
61
+ SELECT CONCAT(OBJECT_SCHEMA_NAME(i.object_id), '.', OBJECT_NAME(i.object_id)) AS relation,
62
+ SUM(COALESCE(s.user_scans, 0)) AS user_scans,
63
+ SUM(COALESCE(s.user_seeks, 0)) AS user_seeks
64
+ FROM sys.indexes i
65
+ LEFT JOIN sys.dm_db_index_usage_stats s
66
+ ON s.database_id = DB_ID() AND s.object_id = i.object_id AND s.index_id = i.index_id
67
+ WHERE i.object_id > 0
68
+ GROUP BY i.object_id FOR JSON PATH
69
+ SQL
70
+ end
71
+
72
+ def index_workload # rubocop:disable Metrics/MethodLength
73
+ run_json(<<~SQL)
74
+ SELECT CONCAT(OBJECT_SCHEMA_NAME(i.object_id), '.', OBJECT_NAME(i.object_id)) AS relation,
75
+ i.name AS index_name, COALESCE(s.user_scans, 0) AS user_scans,
76
+ COALESCE(s.user_seeks, 0) AS user_seeks,
77
+ SUM(COALESCE(p.reserved_page_count, 0)) * 8192 AS index_bytes
78
+ FROM sys.indexes i
79
+ LEFT JOIN sys.dm_db_index_usage_stats s
80
+ ON s.database_id = DB_ID() AND s.object_id = i.object_id AND s.index_id = i.index_id
81
+ LEFT JOIN sys.dm_db_partition_stats p
82
+ ON p.object_id = i.object_id AND p.index_id = i.index_id
83
+ WHERE i.name IS NOT NULL
84
+ GROUP BY i.object_id, i.name, s.user_scans, s.user_seeks FOR JSON PATH
85
+ SQL
86
+ end
87
+
88
+ def run_json(sql)
89
+ source = run!(sql).strip
90
+ JSON.parse(source.empty? ? '[]' : source)
91
+ rescue JSON::ParserError => e
92
+ raise ToolchainError, "sqlcmd returned invalid workload JSON: #{e.message}"
93
+ end
94
+
95
+ def run!(sql)
96
+ environment = { 'SQLCMDPASSWORD' => @password }
97
+ command = [
98
+ @sqlcmd, '-S', @server, '-d', @database, '-U', @user,
99
+ '-C', '-b', '-h', '-1', '-W', '-y', '0', '-Q', sql
100
+ ]
101
+ output, error, status = @runner.call(environment, *command)
102
+ raise ToolchainError, "sqlcmd failed:\n#{output}#{error}" unless status.success?
103
+
104
+ output.to_s
105
+ end
106
+
107
+ def extract_showplan(output)
108
+ start = output.index('<ShowPlanXML')
109
+ finish = output.rindex('</ShowPlanXML>')
110
+ raise ToolchainError, 'sqlcmd returned no SHOWPLAN XML' unless start && finish
111
+
112
+ output[start..(finish + '</ShowPlanXML>'.length - 1)]
113
+ end
114
+
115
+ def read_only_statement(sql)
116
+ source = sql.to_s
117
+ raise ArgumentError, 'SQL contains a NUL byte' if source.include?("\0")
118
+
119
+ statement = source.strip
120
+ validate_read_only_statement!(statement)
121
+ statement
122
+ end
123
+
124
+ def validate_read_only_statement!(statement)
125
+ raise ArgumentError, 'SQL must not be empty' if statement.empty?
126
+ unless statement.match?(/\A(?:SELECT|WITH)\b/i) && !statement.include?(';')
127
+ raise ArgumentError, 'SQL Server plan analysis accepts one read-only SELECT or WITH statement'
128
+ end
129
+ return unless statement.match?(MUTATING_SQL)
130
+
131
+ raise ArgumentError, 'SQL Server plan analysis rejects mutating keywords, including writable CTEs'
132
+ end
133
+
134
+ def required(value, label)
135
+ text = value.to_s
136
+ raise ArgumentError, "#{label} is required" if text.empty? || text.include?("\0")
137
+
138
+ text
139
+ end
140
+
141
+ def capture(environment, *command)
142
+ Open3.capture3(environment, *command)
143
+ end
144
+ end
145
+ end
146
+ end