mxrb 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. checksums.yaml +4 -4
  2. data/bin/mxrb +458 -10
  3. data/docs/de-DE/README.md +1 -0
  4. data/docs/de-DE/oql-sql.md +42 -0
  5. data/docs/de-DE/project-structure.md +201 -2
  6. data/docs/de-DE/runtime-ruby.md +104 -0
  7. data/docs/de-DE/runtime-schema-migrations.md +44 -0
  8. data/docs/de-DE/validation-matrix.md +42 -4
  9. data/docs/en-US/README.md +1 -0
  10. data/docs/en-US/oql-sql.md +41 -0
  11. data/docs/en-US/project-structure.md +192 -2
  12. data/docs/en-US/runtime-ruby.md +100 -0
  13. data/docs/en-US/runtime-schema-migrations.md +41 -0
  14. data/docs/en-US/validation-matrix.md +39 -4
  15. data/docs/pt-BR/README.md +1 -0
  16. data/docs/pt-BR/entity-dsl.md +8 -0
  17. data/docs/pt-BR/oql-sql.md +41 -0
  18. data/docs/pt-BR/project-structure.md +197 -2
  19. data/docs/pt-BR/runtime-ruby.md +108 -0
  20. data/docs/pt-BR/runtime-schema-migrations.md +40 -0
  21. data/docs/pt-BR/validation-matrix.md +42 -5
  22. data/docs/pt-BR/writing.md +28 -0
  23. data/lib/mxrb/cli/help.rb +426 -0
  24. data/lib/mxrb/cli/release.rb +137 -0
  25. data/lib/mxrb/compiler/compatibility_analyzer.rb +1 -1
  26. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +8 -1
  27. data/lib/mxrb/compiler/domain_document_compiler.rb +3 -0
  28. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +11 -1
  29. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +76 -19
  30. data/lib/mxrb/compiler/legacy_custom_widget_compiler.rb +159 -0
  31. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +43 -7
  32. data/lib/mxrb/compiler/legacy_page_builder.rb +1248 -23
  33. data/lib/mxrb/compiler/packager.rb +27 -13
  34. data/lib/mxrb/compiler/page_bundle_compiler.rb +13 -4
  35. data/lib/mxrb/compiler/portable_packager.rb +16 -11
  36. data/lib/mxrb/compiler/web_bundle_builder.rb +5 -1
  37. data/lib/mxrb/compiler/web_shell_materializer.rb +42 -2
  38. data/lib/mxrb/compiler/widget_package_extractor.rb +3 -0
  39. data/lib/mxrb/domain_diagram/lifecycle.rb +272 -0
  40. data/lib/mxrb/domain_diagram.rb +409 -0
  41. data/lib/mxrb/dsl/builder.rb +62 -11
  42. data/lib/mxrb/environment.rb +226 -0
  43. data/lib/mxrb/exporter.rb +265 -45
  44. data/lib/mxrb/http/server.rb +107 -0
  45. data/lib/mxrb/initializer.rb +80 -9
  46. data/lib/mxrb/integrity/validator.rb +32 -7
  47. data/lib/mxrb/io/bson_codec.rb +42 -1
  48. data/lib/mxrb/io/mpr_file.rb +146 -3
  49. data/lib/mxrb/model/attribute.rb +3 -0
  50. data/lib/mxrb/model/entity.rb +44 -2
  51. data/lib/mxrb/model/microflow.rb +3 -1
  52. data/lib/mxrb/model/module.rb +71 -0
  53. data/lib/mxrb/model/page.rb +126 -15
  54. data/lib/mxrb/model/project.rb +3 -0
  55. data/lib/mxrb/modeler/catalog.rb +178 -0
  56. data/lib/mxrb/official_marketplace/module_package_importer.rb +23 -12
  57. data/lib/mxrb/official_marketplace/widget_package_installer.rb +15 -11
  58. data/lib/mxrb/official_marketplace.rb +9 -3
  59. data/lib/mxrb/oql/reverse_translator.rb +394 -0
  60. data/lib/mxrb/oql/server.rb +4 -8
  61. data/lib/mxrb/oql.rb +32 -4
  62. data/lib/mxrb/progress.rb +242 -0
  63. data/lib/mxrb/project_lifecycle.rb +5 -2
  64. data/lib/mxrb/ruby_app/exporter.rb +1749 -0
  65. data/lib/mxrb/ruby_app/preset.rb +506 -0
  66. data/lib/mxrb/ruby_app/session_manager.rb +122 -0
  67. data/lib/mxrb/ruby_app.rb +1361 -0
  68. data/lib/mxrb/runtime/access_control.rb +408 -0
  69. data/lib/mxrb/runtime/native.rb +1015 -93
  70. data/lib/mxrb/runtime/scheduler.rb +396 -0
  71. data/lib/mxrb/runtime/schema_migrator.rb +598 -0
  72. data/lib/mxrb/runtime/shared_store.rb +335 -0
  73. data/lib/mxrb/runtime/sqlite_store.rb +729 -0
  74. data/lib/mxrb/scaffold/generator.rb +11 -3
  75. data/lib/mxrb/team_server.rb +10 -7
  76. data/lib/mxrb/uml/activity_diagram.rb +118 -0
  77. data/lib/mxrb/uml/class_diagram.rb +118 -0
  78. data/lib/mxrb/uml/sequence_diagram.rb +112 -0
  79. data/lib/mxrb/uml/server.rb +146 -0
  80. data/lib/mxrb/uml/support.rb +35 -0
  81. data/lib/mxrb/version.rb +1 -1
  82. data/lib/mxrb/web_ui/assets/abnfDiagram-N423BO3Z-C4JD3cEC.js +1 -0
  83. data/lib/mxrb/web_ui/assets/arc-3Z53kgFp.js +1 -0
  84. data/lib/mxrb/web_ui/assets/architecture-TIHT7OUA-BStBTLea.js +1 -0
  85. data/lib/mxrb/web_ui/assets/architectureDiagram-T3A2C74G-DBwqnM4J.js +36 -0
  86. data/lib/mxrb/web_ui/assets/array-BifhSqXX.js +1 -0
  87. data/lib/mxrb/web_ui/assets/blockDiagram-VBNYF7ZC-DLtuHt-j.js +132 -0
  88. data/lib/mxrb/web_ui/assets/c4Diagram-5PPSVZJV-ConSk_pe.js +10 -0
  89. data/lib/mxrb/web_ui/assets/channel-BhPvD7Dt.js +1 -0
  90. data/lib/mxrb/web_ui/assets/chunk-2GRJ4B5K-D2HpWsf_.js +1 -0
  91. data/lib/mxrb/web_ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  92. data/lib/mxrb/web_ui/assets/chunk-4I5QYGJK-CiIdNwaF.js +1 -0
  93. data/lib/mxrb/web_ui/assets/chunk-5RXB4S5H-Bu_AaEkE.js +231 -0
  94. data/lib/mxrb/web_ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  95. data/lib/mxrb/web_ui/assets/chunk-6Q2QTUOP-wQkNmt_X.js +88 -0
  96. data/lib/mxrb/web_ui/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  97. data/lib/mxrb/web_ui/assets/chunk-GF5L2VYU-G8MmiBzU.js +206 -0
  98. data/lib/mxrb/web_ui/assets/chunk-I66GZJ75-CulcrLPh.js +127 -0
  99. data/lib/mxrb/web_ui/assets/chunk-JQJVKLGR-ukjzcdlL.js +156 -0
  100. data/lib/mxrb/web_ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  101. data/lib/mxrb/web_ui/assets/chunk-KBJHAD2P-Wm35lYov.js +1 -0
  102. data/lib/mxrb/web_ui/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
  103. data/lib/mxrb/web_ui/assets/chunk-NSK5VX7P-D2nYrCY4.js +2 -0
  104. data/lib/mxrb/web_ui/assets/chunk-QR6OTTB3-BrdIzg4Q.js +62 -0
  105. data/lib/mxrb/web_ui/assets/chunk-RYQCIY6F-CCfZMcW6.js +1 -0
  106. data/lib/mxrb/web_ui/assets/chunk-UBXNYLIW-BPEVYsFK.js +1 -0
  107. data/lib/mxrb/web_ui/assets/chunk-W5SLKNZC-2E6OPhA4.js +1 -0
  108. data/lib/mxrb/web_ui/assets/chunk-WRU74C26-CBIvOyWp.js +70 -0
  109. data/lib/mxrb/web_ui/assets/chunk-XXDRQBXY-pH58XAyl.js +1 -0
  110. data/lib/mxrb/web_ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  111. data/lib/mxrb/web_ui/assets/classDiagram-JCYQIIEL-xiqo4D89.js +1 -0
  112. data/lib/mxrb/web_ui/assets/classDiagram-v2-OCEON4UE-xiqo4D89.js +1 -0
  113. data/lib/mxrb/web_ui/assets/cose-bilkent-JH36ORCC-t_flPpii.js +1 -0
  114. data/lib/mxrb/web_ui/assets/cynefin-VYW2F7L2-BIqktTEv.js +1 -0
  115. data/lib/mxrb/web_ui/assets/cynefinDiagram-MW4NZA55-C2d--lut.js +62 -0
  116. data/lib/mxrb/web_ui/assets/cytoscape.esm-B-NFISlW.js +321 -0
  117. data/lib/mxrb/web_ui/assets/dagre-Buvkdvvj.js +1 -0
  118. data/lib/mxrb/web_ui/assets/dagre-VZM6K2ZE-CSz2_tb5.js +4 -0
  119. data/lib/mxrb/web_ui/assets/defaultLocale-BFoDCU3G.js +1 -0
  120. data/lib/mxrb/web_ui/assets/diagram-7IWD3JNH-DCt7u7V_.js +30 -0
  121. data/lib/mxrb/web_ui/assets/diagram-B4RE2ZJO-DqZBqkMu.js +3 -0
  122. data/lib/mxrb/web_ui/assets/diagram-LBJQPF4R-ClIYbIsI.js +24 -0
  123. data/lib/mxrb/web_ui/assets/diagram-Q27KOJAE-DFxhvthJ.js +24 -0
  124. data/lib/mxrb/web_ui/assets/diagram-UB23O5K3-BSUTXptv.js +41 -0
  125. data/lib/mxrb/web_ui/assets/dist-D2qOEbeJ.js +1 -0
  126. data/lib/mxrb/web_ui/assets/domain-mGOZ1KCG.css +1 -0
  127. data/lib/mxrb/web_ui/assets/domain-qsjXhy4v.js +1 -0
  128. data/lib/mxrb/web_ui/assets/ebnfDiagram-BXEA7PRR-B5reICEi.js +1 -0
  129. data/lib/mxrb/web_ui/assets/erDiagram-JOGREHBK-Co5Mxn2p.js +85 -0
  130. data/lib/mxrb/web_ui/assets/eventmodeling-45OFAUF4-C1ylhSks.js +1 -0
  131. data/lib/mxrb/web_ui/assets/flowDiagram-UKHOOZJN-CHXaJIaS.js +1 -0
  132. data/lib/mxrb/web_ui/assets/ganttDiagram-PKOTCBZU-DPPPaWaz.js +292 -0
  133. data/lib/mxrb/web_ui/assets/gitGraph-TEB2WS4Q-qS77ywA9.js +1 -0
  134. data/lib/mxrb/web_ui/assets/gitGraphDiagram-DS77QQ5N-DpAv6-BT.js +106 -0
  135. data/lib/mxrb/web_ui/assets/graphlib-DS17s2tU.js +1 -0
  136. data/lib/mxrb/web_ui/assets/info-DKCQHKI2-C9VD1Y4e.js +1 -0
  137. data/lib/mxrb/web_ui/assets/infoDiagram-6WML65LV-Bk1kbcCy.js +2 -0
  138. data/lib/mxrb/web_ui/assets/init-C-OQMol4.js +1 -0
  139. data/lib/mxrb/web_ui/assets/ishikawaDiagram-WSZJBQD7-0gcISBFR.js +70 -0
  140. data/lib/mxrb/web_ui/assets/journeyDiagram-NVQOT4AX-Dt0RQnlh.js +139 -0
  141. data/lib/mxrb/web_ui/assets/jsx-runtime-DV0a5kSb.js +9 -0
  142. data/lib/mxrb/web_ui/assets/kanban-definition-27J2QSJJ-DIPQM7w9.js +89 -0
  143. data/lib/mxrb/web_ui/assets/katex-CXMH3UgJ.js +257 -0
  144. data/lib/mxrb/web_ui/assets/line-C0rYD-tL.js +1 -0
  145. data/lib/mxrb/web_ui/assets/linear-BPPxWorj.js +1 -0
  146. data/lib/mxrb/web_ui/assets/map-BaFkSB1l.js +1 -0
  147. data/lib/mxrb/web_ui/assets/mermaid-parser.core-B_UPzTxa.js +7 -0
  148. data/lib/mxrb/web_ui/assets/mindmap-definition-FAOFIHXS-CdJRSMuk.js +96 -0
  149. data/lib/mxrb/web_ui/assets/modeler-BNqHthwk.css +1 -0
  150. data/lib/mxrb/web_ui/assets/modeler-CQmqflbg.js +1 -0
  151. data/lib/mxrb/web_ui/assets/ordinal-BDEzSJ7C.js +1 -0
  152. data/lib/mxrb/web_ui/assets/packet-7NZHBO7P-CwQ4NSBs.js +1 -0
  153. data/lib/mxrb/web_ui/assets/path-fybaL0A-.js +1 -0
  154. data/lib/mxrb/web_ui/assets/pegDiagram-VL7TDLO6-D40S7ppx.js +1 -0
  155. data/lib/mxrb/web_ui/assets/pie-RZYD4A2V-CJroHMKh.js +1 -0
  156. data/lib/mxrb/web_ui/assets/pieDiagram-7S7Q4E2Y-QB54__BV.js +39 -0
  157. data/lib/mxrb/web_ui/assets/quadrantDiagram-CIZ2JOQS-DBhDS6ag.js +7 -0
  158. data/lib/mxrb/web_ui/assets/radar-I7S5WNFK-DdIUwd2d.js +1 -0
  159. data/lib/mxrb/web_ui/assets/railroad-3IZDKUUU-B1Lw6Xyq.js +1 -0
  160. data/lib/mxrb/web_ui/assets/railroad-abnf-AHOZXSZD-CQgQoeak.js +1 -0
  161. data/lib/mxrb/web_ui/assets/railroad-ebnf-EBAXGLYW-D7qEQ2x8.js +1 -0
  162. data/lib/mxrb/web_ui/assets/railroad-peg-LSFZ7HO6-C005Vxbb.js +1 -0
  163. data/lib/mxrb/web_ui/assets/railroadDiagram-AXF67PYL-BBP2qk7M.js +1 -0
  164. data/lib/mxrb/web_ui/assets/requirementDiagram-LRYGKXZP-D2cLsAoI.js +84 -0
  165. data/lib/mxrb/web_ui/assets/rough.esm-Dy-Kn_BL.js +1 -0
  166. data/lib/mxrb/web_ui/assets/sankeyDiagram-W5VNT64P-Bpi3FiP2.js +40 -0
  167. data/lib/mxrb/web_ui/assets/sequenceDiagram-SI44F4Z6-DCRcDlVe.js +162 -0
  168. data/lib/mxrb/web_ui/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  169. data/lib/mxrb/web_ui/assets/src-CvxfUak2.js +1 -0
  170. data/lib/mxrb/web_ui/assets/stateDiagram-OKZ733FA-C2GCEoxl.js +1 -0
  171. data/lib/mxrb/web_ui/assets/stateDiagram-v2-UEYNNEHI-D2gWgedh.js +1 -0
  172. data/lib/mxrb/web_ui/assets/swimlanes-SLNWSIFB-CRI9irEW.js +2 -0
  173. data/lib/mxrb/web_ui/assets/swimlanesDiagram-ULZ7WXOC-C5Wb_-hZ.js +8 -0
  174. data/lib/mxrb/web_ui/assets/theme-_mxc7BdY.css +1 -0
  175. data/lib/mxrb/web_ui/assets/timeline-definition-Z64GVDOM-B1-mNg-h.js +120 -0
  176. data/lib/mxrb/web_ui/assets/treeView-QDETBFTQ-7CTvIwKq.js +1 -0
  177. data/lib/mxrb/web_ui/assets/treemap-6X3UGDF4-CtL22pq5.js +1 -0
  178. data/lib/mxrb/web_ui/assets/uml-C3KAFGTq.js +51 -0
  179. data/lib/mxrb/web_ui/assets/uml-Dn-qd01X.css +1 -0
  180. data/lib/mxrb/web_ui/assets/vennDiagram-T6HMQDX7-BzVnUEAy.js +34 -0
  181. data/lib/mxrb/web_ui/assets/wardley-OPB4EBWU-JNi-QNag.js +1 -0
  182. data/lib/mxrb/web_ui/assets/wardleyDiagram-T6FBY63Y-BUwmAQ-B.js +78 -0
  183. data/lib/mxrb/web_ui/assets/xychartDiagram-ELKLHX3M-DEQS8sKp.js +7 -0
  184. data/lib/mxrb/web_ui/domain.html +15 -0
  185. data/lib/mxrb/web_ui/modeler.html +16 -0
  186. data/lib/mxrb/web_ui/uml.html +31 -0
  187. data/lib/mxrb/web_ui.rb +89 -0
  188. data/lib/mxrb/widget_certification.rb +208 -0
  189. data/lib/mxrb/writer.rb +278 -41
  190. data/lib/mxrb.rb +38 -8
  191. metadata +157 -5
@@ -1,31 +1,75 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
+ require 'strscan'
5
+ require 'json'
6
+ require 'monitor'
7
+ require 'net/http'
8
+ require 'uri'
4
9
 
5
10
  module Mxrb
6
11
  module Runtime
7
12
  module Native
8
13
  # Graph interpretation intentionally keeps semantic dispatch together.
9
- # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity
14
+ # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/ClassLength, Metrics/CyclomaticComplexity
10
15
  # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
16
+ # rubocop:disable Metrics/ParameterLists
11
17
  ObjectValue = Data.define(:entity, :id, :members)
12
18
 
13
19
  # Transactional in-memory persistence used by the Ruby model Runtime.
14
20
  class Store
15
- def initialize
21
+ LIFECYCLE_EVENTS = %i[
22
+ before_create after_create before_update after_update
23
+ before_commit after_commit before_delete after_delete
24
+ ].freeze
25
+
26
+ def initialize(defaults: {}, hooks: {}, transient_entities: [])
16
27
  @records = Hash.new { |records, entity| records[entity] = [] }
28
+ @defaults = defaults
29
+ @committed = {}
30
+ @hooks = hooks
31
+ @transient_entities = Array(transient_entities).map(&:to_s).to_h { [_1, true] }.freeze
32
+ end
33
+
34
+ def on(entity, event, &callback)
35
+ raise ArgumentError, "unknown lifecycle event #{event}" unless LIFECYCLE_EVENTS.include?(event.to_sym)
36
+ raise ArgumentError, 'lifecycle callback is required' unless callback
37
+
38
+ @hooks[entity.to_s] ||= {}
39
+ @hooks[entity.to_s][event.to_sym] ||= []
40
+ @hooks[entity.to_s][event.to_sym] << callback
41
+ self
17
42
  end
18
43
 
19
- def create(entity)
20
- ObjectValue.new(entity:, id: SecureRandom.uuid, members: {}).tap do |object|
44
+ def create(entity, events: true)
45
+ raise ArgumentError, 'events must be boolean' unless [true, false].include?(events)
46
+
47
+ members = @defaults.fetch(entity, {}).dup
48
+ ObjectValue.new(entity:, id: SecureRandom.uuid, members:).tap do |object|
21
49
  @records[entity] << object
22
50
  end
23
51
  end
24
52
 
25
53
  def retrieve(entity) = @records[entity].dup
26
54
 
27
- def delete(value)
28
- Array(value).each { @records[_1.entity].delete(_1) }
55
+ def find(entity, id) = @records[entity].find { _1.id == id.to_s }
56
+
57
+ def retrieve_association(association, start)
58
+ member = association.to_s.split('.').last
59
+ direct = Array(start&.members&.[](association) || start&.members&.[](member))
60
+ inverse = @records.values.flatten.select do |object|
61
+ Array(object.members[association] || object.members[member]).include?(start)
62
+ end
63
+ (direct + inverse).uniq
64
+ end
65
+
66
+ def delete(value, events: true)
67
+ Array(value).each do |object|
68
+ run_hooks(:before_delete, object) if events
69
+ @records[object.entity].delete(object)
70
+ @committed.delete(record_key(object))
71
+ run_hooks(:after_delete, object) if events
72
+ end
29
73
  end
30
74
 
31
75
  def count(entity, predicate = nil)
@@ -34,43 +78,149 @@ module Mxrb
34
78
  end
35
79
 
36
80
  def snapshot
37
- @records.to_h do |entity, values|
81
+ {
82
+ records: copy_records(@records),
83
+ committed: @committed.transform_values(&:dup)
84
+ }
85
+ end
86
+
87
+ def restore(snapshot)
88
+ @records.clear
89
+ snapshot.fetch(:records).each { |entity, values| @records[entity] = values }
90
+ @committed = snapshot.fetch(:committed).transform_values(&:dup)
91
+ end
92
+
93
+ def transaction
94
+ state = snapshot
95
+ result = yield
96
+ discard_uncommitted
97
+ result
98
+ rescue StandardError
99
+ restore(state)
100
+ raise
101
+ end
102
+
103
+ def commit(value, events: true)
104
+ Array(value).each do |object|
105
+ creating = !@committed.key?(record_key(object))
106
+ run_hooks(creating ? :before_create : :before_update, object) if events
107
+ run_hooks(:before_commit, object) if events
108
+ @committed[record_key(object)] = object.members.dup
109
+ run_hooks(:after_commit, object) if events
110
+ run_hooks(creating ? :after_create : :after_update, object) if events
111
+ end
112
+ value
113
+ end
114
+
115
+ def rollback(value)
116
+ Array(value).each do |object|
117
+ persisted = @committed[record_key(object)]
118
+ if persisted
119
+ object.members.replace(persisted.dup)
120
+ else
121
+ @records[object.entity].delete(object)
122
+ end
123
+ end
124
+ value
125
+ end
126
+
127
+ def close; end
128
+
129
+ private
130
+
131
+ def copy_records(records)
132
+ records.to_h do |entity, values|
38
133
  [entity, values.map { ObjectValue.new(entity: _1.entity, id: _1.id, members: _1.members.dup) }]
39
134
  end
40
135
  end
41
136
 
42
- def restore(snapshot)
137
+ # A root microflow is a unit of work. Persistent values only enter the
138
+ # durable record set through commit; returned uncommitted values remain
139
+ # valid Ruby objects, but are detached from subsequent retrieves.
140
+ # Non-persistent entities deliberately remain session-local in memory.
141
+ def discard_uncommitted
142
+ transient = @records.filter_map do |entity, values|
143
+ [entity, values.dup] if transient_entity?(entity)
144
+ end.to_h
43
145
  @records.clear
44
- snapshot.each { |entity, values| @records[entity] = values }
146
+ transient.each { |entity, values| @records[entity] = values }
147
+ @committed.each do |(entity, id), members|
148
+ next if transient_entity?(entity)
149
+
150
+ @records[entity] << ObjectValue.new(entity:, id:, members: members.dup)
151
+ end
152
+ end
153
+
154
+ def transient_entity?(entity) = @transient_entities.key?(entity.to_s)
155
+
156
+ def record_key(object) = [object.entity, object.id]
157
+
158
+ def run_hooks(event, object)
159
+ raise ArgumentError, "unknown lifecycle event #{event}" unless LIFECYCLE_EVENTS.include?(event)
160
+
161
+ Array(@hooks.dig(object.entity, event)).each { _1.call(object) }
45
162
  end
46
163
  end
47
164
 
48
165
  # Deliberately small Mendix-expression evaluator. Unsupported syntax is
49
166
  # rejected rather than guessed, preserving deterministic test semantics.
50
167
  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
168
+ def initialize
169
+ @token_cache = {}
170
+ end
59
171
 
60
172
  def evaluate(source, variables, node: nil)
61
- text = strip_outer_parentheses(unwrap(source).strip)
62
- return nil if text.empty? || text == 'empty'
173
+ text = unwrap(source).strip
174
+ return nil if text.empty?
175
+
176
+ tokens = (@token_cache[text] ||= Lexer.new(text).tokens.freeze)
177
+ Parser.new(tokens, self, variables, node).parse
178
+ rescue ArgumentError, TypeError, KeyError
179
+ raise NativeRuntimeError, "unsupported Mendix expression: #{source.inspect}"
180
+ end
181
+
182
+ def resolve_variable(text, variables)
183
+ name, member = text.delete_prefix('$').split('/', 2)
184
+ value = variables.fetch(name) do
185
+ raise NativeRuntimeError, "unknown variable $#{name}"
186
+ end
187
+ return value unless member
188
+
189
+ raise NativeRuntimeError, "$#{name} is not an object" unless value.is_a?(ObjectValue)
190
+
191
+ value.members[member.split('.').last]
192
+ end
193
+
194
+ def resolve_identifier(text, node)
63
195
  return true if text.casecmp?('true')
64
196
  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})
197
+ return nil if text.casecmp?('empty')
198
+
199
+ if node && bare_reference?(text)
200
+ member = text.split('/').last.split('.').last
201
+ return node.members[member] if node.members.key?(member)
202
+ end
203
+ return text if text.match?(/\A[A-Za-z_]\w*\.[A-Za-z_]\w*\.[A-Za-z_]\w*\z/)
69
204
  return node_member(text, node) if node && bare_reference?(text)
70
205
 
71
- logical(text, variables) { |part| evaluate(part, variables, node:) }
72
- rescue ArgumentError, TypeError
73
- raise NativeRuntimeError, "unsupported Mendix expression: #{source.inspect}"
206
+ raise ArgumentError, text
207
+ end
208
+
209
+ def invoke(name, arguments)
210
+ case name.downcase
211
+ when 'tostring' then mendix_string(arguments.fetch(0))
212
+ when 'parseinteger' then Integer(arguments.fetch(0))
213
+ when 'parsedecimal' then Float(arguments.fetch(0))
214
+ when 'round' then arguments.fetch(0).round
215
+ when 'random' then Random.rand
216
+ when 'substring' then substring(*arguments)
217
+ when 'find' then arguments.fetch(0).to_s.index(arguments.fetch(1).to_s) || -1
218
+ when 'formatdatetime' then format_datetime(*arguments)
219
+ when 'contains' then arguments.fetch(0).to_s.include?(arguments.fetch(1).to_s)
220
+ when 'startswith' then arguments.fetch(0).to_s.start_with?(arguments.fetch(1).to_s)
221
+ when 'endswith' then arguments.fetch(0).to_s.end_with?(arguments.fetch(1).to_s)
222
+ else raise ArgumentError, name
223
+ end
74
224
  end
75
225
 
76
226
  private
@@ -79,15 +229,6 @@ module Mxrb
79
229
  source.is_a?(Hash) ? (source['Value'] || source['Expression'] || '') : source.to_s
80
230
  end
81
231
 
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
232
  def matching_parenthesis(text, opening)
92
233
  depth = 0
93
234
  quoted = false
@@ -104,104 +245,513 @@ module Mxrb
104
245
  nil
105
246
  end
106
247
 
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
248
  # An attribute reference inside an XPath predicate, e.g. `Name` or
120
249
  # `Clinic.Animal/Name`, resolved against the current candidate object.
121
250
  def bare_reference?(text)
122
- text.match?(%r{\A[A-Za-z_]\w*(?:/[A-Za-z_][\w.]*)?\z})
251
+ text.match?(%r{\A[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:/[A-Za-z_][\w.]*)?\z})
123
252
  end
124
253
 
125
254
  def node_member(text, node)
126
255
  node.members[text.split('/').last.split('.').last]
127
256
  end
128
257
 
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
258
+ def mendix_string(value)
259
+ return 'true' if value == true
260
+ return 'false' if value == false
132
261
 
133
- split = split_operator(text, /\s+and\s+/i)
134
- return split.all? { yield(_1) } if split.size > 1
262
+ value.to_s
263
+ end
135
264
 
136
- comparison(text, variables, &block)
265
+ def substring(value, start, length = nil)
266
+ result = length.nil? ? value.to_s.slice(start..) : value.to_s.slice(start, length)
267
+ result.to_s
137
268
  end
138
269
 
139
- def comparison(text, _variables)
140
- match = text.match(/\A\(?(.+?)\)?\s*(=|!=|>=|<=|>|<)\s*\(?(.+?)\)?\z/)
141
- raise ArgumentError unless match
270
+ def format_datetime(value, pattern)
271
+ format = pattern.to_s.gsub('yyyy', '%Y').gsub('MMM', '%b').gsub('EEE', '%a')
272
+ .gsub('MM', '%m').gsub('dd', '%d').gsub('HH', '%H')
273
+ .gsub('mm', '%M').gsub('ss', '%S')
274
+ value.strftime(format)
275
+ end
142
276
 
143
- left = yield(match[1])
144
- right = yield(match[3])
145
- COMPARISONS.fetch(match[2]).call(left, right)
277
+ # Recursive-descent parser for the expression subset used by native
278
+ # microflows. It deliberately accepts syntax, not arbitrary Ruby code.
279
+ class Parser
280
+ COMPARISONS = {
281
+ '=' => ->(left, right) { left == right },
282
+ '!=' => ->(left, right) { left != right },
283
+ '>' => ->(left, right) { left > right },
284
+ '<' => ->(left, right) { left < right },
285
+ '>=' => ->(left, right) { left >= right },
286
+ '<=' => ->(left, right) { left <= right }
287
+ }.freeze
288
+
289
+ def initialize(tokens, expression, variables, node)
290
+ @tokens = tokens
291
+ @index = 0
292
+ @expression = expression
293
+ @variables = variables
294
+ @node = node
295
+ advance
296
+ end
297
+
298
+ def parse
299
+ value = parse_or
300
+ raise ArgumentError unless @token == :eof
301
+
302
+ value
303
+ end
304
+
305
+ private
306
+
307
+ def parse_or
308
+ left = parse_and
309
+ while accept_word('or')
310
+ right = parse_and
311
+ left = truthy?(left) || truthy?(right)
312
+ end
313
+ left
314
+ end
315
+
316
+ def parse_and
317
+ left = parse_comparison
318
+ while accept_word('and')
319
+ right = parse_comparison
320
+ left = truthy?(left) && truthy?(right)
321
+ end
322
+ left
323
+ end
324
+
325
+ def parse_comparison
326
+ left = parse_addition
327
+ while @token == :operator && COMPARISONS.key?(@value)
328
+ operator = consume(:operator)
329
+ left = COMPARISONS.fetch(operator).call(left, parse_addition)
330
+ end
331
+ left
332
+ end
333
+
334
+ def parse_addition
335
+ left = parse_multiplication
336
+ while @token == :operator && %w[+ -].include?(@value)
337
+ operator = consume(:operator)
338
+ right = parse_multiplication
339
+ left = operator == '+' ? left + right : left - right
340
+ end
341
+ left
342
+ end
343
+
344
+ def parse_multiplication
345
+ left = parse_unary
346
+ while @token == :operator && %w[* /].include?(@value)
347
+ operator = consume(:operator)
348
+ right = parse_unary
349
+ left = operator == '*' ? left * right : left / right
350
+ end
351
+ left
352
+ end
353
+
354
+ def parse_unary
355
+ return !parse_unary if accept_word('not')
356
+ return -parse_unary if accept_operator('-')
357
+
358
+ parse_primary
359
+ end
360
+
361
+ def parse_primary
362
+ return consume(@token) if %i[number string].include?(@token)
363
+
364
+ if @token == :datetime
365
+ consume(:datetime)
366
+ return Time.now
367
+ end
368
+ return @expression.resolve_variable(consume(:variable), @variables) if @token == :variable
369
+ return grouped if accept(:left_parenthesis)
370
+ return identifier if @token == :identifier
371
+
372
+ raise ArgumentError, @value.to_s
373
+ end
374
+
375
+ def grouped
376
+ value = parse_or
377
+ consume(:right_parenthesis)
378
+ value
379
+ end
380
+
381
+ def identifier
382
+ name = consume(:identifier)
383
+ return @expression.resolve_identifier(name, @node) unless accept(:left_parenthesis)
384
+
385
+ arguments = []
386
+ unless accept(:right_parenthesis)
387
+ loop do
388
+ arguments << parse_or
389
+ break if accept(:right_parenthesis)
390
+
391
+ consume(:comma)
392
+ end
393
+ end
394
+ @expression.invoke(name, arguments)
395
+ end
396
+
397
+ def accept_word(word)
398
+ return false unless @token == :identifier && @value.casecmp?(word)
399
+
400
+ advance
401
+ true
402
+ end
403
+
404
+ def accept_operator(operator)
405
+ return false unless @token == :operator && @value == operator
406
+
407
+ advance
408
+ true
409
+ end
410
+
411
+ def accept(token)
412
+ return false unless @token == token
413
+
414
+ advance
415
+ true
416
+ end
417
+
418
+ def consume(token)
419
+ raise ArgumentError, "expected #{token}" unless @token == token
420
+
421
+ value = @value
422
+ advance
423
+ value
424
+ end
425
+
426
+ def advance
427
+ @token, @value = @tokens[@index] || [:eof, nil]
428
+ @index += 1
429
+ end
430
+
431
+ def truthy?(value)
432
+ value != false && !value.nil?
433
+ end
146
434
  end
147
435
 
148
- def split_operator(text, operator)
149
- text.split(operator)
436
+ # Tokenization is cached by Expression so tight microflow loops do not
437
+ # repeatedly scan the same static Mendix source string.
438
+ class Lexer
439
+ def initialize(text)
440
+ @scanner = StringScanner.new(text)
441
+ end
442
+
443
+ def tokens
444
+ result = []
445
+ loop do
446
+ @scanner.skip(/\s+/)
447
+ token = next_token
448
+ result << token
449
+ return result if token.first == :eof
450
+ end
451
+ end
452
+
453
+ private
454
+
455
+ def next_token
456
+ return [:eof, nil] if @scanner.eos?
457
+ return [:datetime, nil] if @scanner.scan(/\[%CurrentDateTime%\]/i)
458
+ return [:string, scan_string] if @scanner.peek(1) == "'"
459
+ return [:number, number(@scanner.scan(/\d+(?:\.\d+)?/))] if @scanner.check(/\d/)
460
+ return [:variable, @scanner.scan(%r{\$[A-Za-z_]\w*(?:/[A-Za-z_][\w.]*)?})] if @scanner.check(/\$/)
461
+ return [:identifier, @scanner.scan(/[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*/)] if @scanner.check(/[A-Za-z_]/)
462
+ return [:operator, @scanner.scan(%r{!=|>=|<=|=|>|<|\+|-|\*|/})] if @scanner.check(%r{[!><=+*/-]})
463
+
464
+ punctuation
465
+ end
466
+
467
+ def scan_string
468
+ @scanner.getch
469
+ value = +''
470
+ until @scanner.eos?
471
+ character = @scanner.getch
472
+ unless character == "'"
473
+ value << character
474
+ next
475
+ end
476
+
477
+ return value unless @scanner.peek(1) == "'"
478
+
479
+ @scanner.getch
480
+ value << "'"
481
+ end
482
+ raise ArgumentError, 'unterminated string'
483
+ end
484
+
485
+ def number(value)
486
+ value.include?('.') ? Float(value) : Integer(value)
487
+ end
488
+
489
+ def punctuation
490
+ character = @scanner.getch
491
+ token = { '(' => :left_parenthesis, ')' => :right_parenthesis, ',' => :comma }[character]
492
+ raise ArgumentError, character unless token
493
+
494
+ [token, character]
495
+ end
150
496
  end
151
497
  end
152
498
 
153
499
  # Executes the graph and core data activities directly from the MPR.
154
500
  class Interpreter
155
- attr_reader :store, :log
501
+ attr_reader :store, :log, :effects
156
502
 
157
- def initialize(project, store: Store.new)
503
+ def initialize(project, store: nil, adapters: {}, java_custom_actions: {}, http: nil, policy: nil)
158
504
  @project = project
159
- @store = store
160
505
  @expression = Expression.new
161
506
  @log = []
507
+ @effects = []
508
+ @call_depth = 0
509
+ @call_monitor = Monitor.new
510
+ @collection_plans = {
511
+ true => {}.compare_by_identity,
512
+ false => {}.compare_by_identity
513
+ }
514
+ @identifier_cache = {}.compare_by_identity
515
+ @flow_case_cache = {}.compare_by_identity
516
+ @adapters = adapters.transform_keys(&:to_sym)
517
+ @java_custom_actions = java_custom_actions.to_h.transform_keys(&:to_s).freeze
518
+ @java_action_parameter_names = {}
519
+ @http = http || method(:http_request)
520
+ @policy = policy
521
+ @security_context = nil
522
+ @apply_entity_access = false
162
523
  @flows = project.modules.flat_map do |mod|
163
524
  mod.microflows.map { ["#{mod.name}.#{_1.name}", _1] }
164
525
  end.to_h
526
+ entity_names = project.modules.flat_map do |mod|
527
+ mod.entities.map { [_1.id.to_s, "#{mod.name}.#{_1.name}"] }
528
+ end.to_h
529
+ defaults = project.modules.flat_map do |mod|
530
+ mod.entities.map do |entity|
531
+ ["#{mod.name}.#{entity.name}", entity.attributes.to_h do |attribute|
532
+ [attribute.name, attribute_default(attribute)]
533
+ end]
534
+ end
535
+ end.to_h
536
+ transient_entities = project.modules.flat_map do |mod|
537
+ mod.entities.select { _1.persistable == false }.map { "#{mod.name}.#{_1.name}" }
538
+ end
539
+ @store = store || Store.new(defaults:, transient_entities:)
540
+ register_model_lifecycle
541
+ @associations = project.modules.flat_map do |mod|
542
+ mod.associations.map do |association|
543
+ ["#{mod.name}.#{association.name}", {
544
+ type: association.association_type,
545
+ from: entity_names[association.from_entity_id.to_s]
546
+ }]
547
+ end
548
+ end.to_h
549
+ end
550
+
551
+ def call(name, arguments = nil, context: nil, **keyword_arguments)
552
+ (@call_monitor ||= Monitor.new).synchronize do
553
+ execute_call(name, arguments, context:, **keyword_arguments)
554
+ end
555
+ end
556
+
557
+ def clear_effects! = @effects.clear
558
+
559
+ # Counts stored objects of an entity, optionally narrowed by an XPath
560
+ # constraint. Used by the functional Executor's count expectations.
561
+ def count(entity, xpath = nil)
562
+ filter_by_xpath(store.retrieve(entity.to_s), xpath.to_s, {}).size
165
563
  end
166
564
 
167
- def call(name, arguments = {})
565
+ private
566
+
567
+ def execute_call(name, arguments = nil, context: nil, **keyword_arguments)
568
+ arguments = keyword_arguments if arguments.nil?
569
+ arguments = arguments.to_h.merge(keyword_arguments) unless keyword_arguments.empty?
570
+ previous_context = @security_context
571
+ previous_apply_entity_access = @apply_entity_access
572
+ root_call = @call_depth.zero?
573
+ @effects = [] if root_call
574
+ @call_depth += 1
168
575
  flow = @flows[name]
169
576
  raise NativeRuntimeError, "microflow #{name} not found" unless flow
170
577
 
171
- snapshot = store.snapshot
172
- execute(flow, normalize_arguments(flow, arguments))
578
+ @security_context = context unless context.nil?
579
+ @apply_entity_access = flow.respond_to?(:apply_entity_access) && flow.apply_entity_access == true
580
+ normalized = normalize_arguments(flow, arguments)
581
+ if root_call && store.respond_to?(:transaction)
582
+ store.transaction { execute(flow, normalized) }
583
+ else
584
+ snapshot = store.snapshot if root_call
585
+ execute(flow, normalized)
586
+ end
173
587
  rescue StandardError
174
588
  store.restore(snapshot) if snapshot
175
589
  raise
590
+ ensure
591
+ @security_context = previous_context
592
+ @apply_entity_access = previous_apply_entity_access
593
+ @call_depth -= 1
176
594
  end
177
595
 
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
596
+ def attribute_default(attribute)
597
+ value = attribute.default_value.to_s
598
+ case attribute.type
599
+ when :boolean then value.casecmp?('true')
600
+ when :integer, :long, :autonumber then value.empty? ? nil : Integer(value)
601
+ when :decimal then value.empty? ? nil : Float(value)
602
+ when :string then value
603
+ else value.empty? ? nil : value
604
+ end
182
605
  end
183
606
 
184
- private
607
+ def register_model_lifecycle
608
+ return unless store.respond_to?(:on)
609
+
610
+ @project.modules.each do |mod|
611
+ mod.entities.each do |entity|
612
+ qualified = "#{mod.name}.#{entity.name}"
613
+ Array(entity.respond_to?(:lifecycle) ? entity.lifecycle : nil).each do |callback|
614
+ event = callback.fetch(:event).to_sym
615
+ next unless Store::LIFECYCLE_EVENTS.include?(event)
616
+
617
+ handler = qualify_flow(mod.name, callback.fetch(:handler))
618
+ store.on(qualified, event) do |object|
619
+ result = call(handler, lifecycle_arguments(handler, object, callback))
620
+ if callback[:raise_error_on_false] && result == false
621
+ raise NativeRuntimeError, "entity lifecycle #{handler} rejected #{qualified}"
622
+ end
623
+ end
624
+ end
625
+ end
626
+ end
627
+ end
628
+
629
+ def qualify_flow(module_name, name)
630
+ value = name.to_s
631
+ value.include?('.') ? value : "#{module_name}.#{value}"
632
+ end
633
+
634
+ def lifecycle_arguments(handler, object, callback)
635
+ return {} unless callback.fetch(:pass_event_object, true)
636
+
637
+ flow = @flows[handler]
638
+ parameter = flow&.parameters&.find { _1.is_a?(Hash) }
639
+ name = parameter && parameter['Name'].to_s
640
+ name.to_s.empty? ? {} : { name => object }
641
+ end
185
642
 
186
643
  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
644
+ outgoing = flow.flows.group_by { identifier(_1['OriginPointer']) }
645
+ result = execute_collection(flow.objects, outgoing, variables, label: "microflow #{flow.name}", root: true)
646
+ result&.last
647
+ end
648
+
649
+ def execute_collection(collection, outgoing, variables, label:, root: false)
650
+ plan = collection_plan(collection, outgoing, root:)
651
+ objects = plan.fetch(:objects)
652
+ current = plan.fetch(:entry)
653
+ raise NativeRuntimeError, "#{label} has no start event" unless current
192
654
 
193
655
  10_000.times do
194
656
  type = current['$Type']
195
- return @expression.evaluate(current['ReturnValue'], variables) if type == 'Microflows$EndEvent'
657
+ return [:return, @expression.evaluate(current['ReturnValue'], variables)] if type == 'Microflows$EndEvent'
658
+ return [:break, nil] if type == 'Microflows$BreakEvent'
659
+ return [:continue, nil] if type == 'Microflows$ContinueEvent'
660
+
661
+ if type == 'Microflows$ErrorEvent'
662
+ raise NativeRuntimeError, @expression.evaluate(current['ErrorExpression'], variables).to_s
663
+ end
196
664
 
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
665
+ edges = outgoing[identifier(current)] || []
666
+ error_edge = edges.find { _1['IsErrorHandler'] == true }
667
+ action_snapshot = nil
668
+ begin
669
+ if type == 'Microflows$ActionActivity' && error_edge &&
670
+ current.dig('Action', 'ErrorHandlingType') == 'Rollback'
671
+ action_snapshot = store.snapshot
672
+ end
673
+ execute_action(current['Action'], variables) if type == 'Microflows$ActionActivity'
674
+ execute_loop(current, outgoing, variables, label:) if type == 'Microflows$LoopedActivity'
675
+ edge = select_edge(current, edges.reject { _1['IsErrorHandler'] == true }, variables)
676
+ rescue StandardError => e
677
+ edge = error_edge
678
+ raise unless edge
679
+
680
+ if action_snapshot && current.dig('Action', 'ErrorHandlingType') == 'Rollback'
681
+ store.restore(action_snapshot)
682
+ end
683
+ variables['latestError'] = e
684
+ end
685
+ return [:complete, nil] unless edge || root
686
+ raise NativeRuntimeError, "#{label} stops at #{type}" unless edge
200
687
 
201
- current = objects[identifier(edge['DestinationPointer'])]
688
+ destination = identifier(edge['DestinationPointer'])
689
+ current = objects[destination]
690
+ return [:complete, nil] unless current || root
202
691
  raise NativeRuntimeError, 'sequence flow points to a missing object' unless current
203
692
  end
204
- raise NativeRuntimeError, "microflow #{flow.name} exceeded 10000 steps"
693
+ raise NativeRuntimeError, "#{label} exceeded 10000 steps"
694
+ end
695
+
696
+ def collection_plan(collection, outgoing, root:)
697
+ plans = @collection_plans.fetch(root)
698
+ plans[collection] ||= begin
699
+ list = items(collection).select { _1.is_a?(Hash) }
700
+ objects = list.to_h { [identifier(_1), _1] }
701
+ { objects:, entry: collection_entry(list, objects, outgoing, root:) }.freeze
702
+ end
703
+ end
704
+
705
+ def collection_entry(list, objects, outgoing, root:)
706
+ start = list.find { _1['$Type'] == 'Microflows$StartEvent' }
707
+ return start if start || root
708
+
709
+ destinations = objects.keys.to_h { [_1, false] }
710
+ objects.each_key do |origin|
711
+ Array(outgoing[origin]).each do |edge|
712
+ destination = identifier(edge['DestinationPointer'])
713
+ destinations[destination] = true if destinations.key?(destination)
714
+ end
715
+ end
716
+ list.find do |object|
717
+ !destinations[identifier(object)] && executable_object?(object)
718
+ end
719
+ end
720
+
721
+ def executable_object?(object)
722
+ !%w[Microflows$Annotation Microflows$MicroflowParameter].include?(object['$Type'])
723
+ end
724
+
725
+ def execute_loop(activity, outgoing, variables, label:)
726
+ source = activity['LoopSource'] || {}
727
+ nested = activity.dig('ObjectCollection', 'Objects')
728
+ case source['$Type']
729
+ when 'Microflows$WhileLoopCondition'
730
+ execute_while_loop(source, nested, outgoing, variables, label:)
731
+ when 'Microflows$IterableList'
732
+ execute_iterable_loop(source, nested, outgoing, variables, label:)
733
+ else
734
+ raise NativeRuntimeError, "unsupported loop source #{source['$Type']}"
735
+ end
736
+ end
737
+
738
+ def execute_while_loop(source, nested, outgoing, variables, label:)
739
+ 10_000.times do
740
+ return unless @expression.evaluate(source['WhileExpression'], variables)
741
+
742
+ result = execute_collection(nested, outgoing, variables, label: "#{label} loop")
743
+ return if result&.first == :break
744
+ end
745
+ raise NativeRuntimeError, "#{label} loop exceeded 10000 iterations"
746
+ end
747
+
748
+ def execute_iterable_loop(source, nested, outgoing, variables, label:)
749
+ values = Array(variables.fetch(source['ListVariableName'].to_s))
750
+ values.each do |value|
751
+ variables[source['VariableName'].to_s] = value
752
+ result = execute_collection(nested, outgoing, variables, label: "#{label} loop")
753
+ break if result&.first == :break
754
+ end
205
755
  end
206
756
 
207
757
  def select_edge(object, edges, variables)
@@ -221,9 +771,11 @@ module Mxrb
221
771
  end
222
772
 
223
773
  def flow_case(edge)
224
- values = items(edge['CaseValues'])
225
- value = values.first || edge['NewCaseValue'] || {}
226
- value['$Type'] == 'Microflows$NoCase' ? '' : value['Value'].to_s
774
+ @flow_case_cache[edge] ||= begin
775
+ values = items(edge['CaseValues'])
776
+ value = values.first || edge['NewCaseValue'] || {}
777
+ value['$Type'] == 'Microflows$NoCase' ? '' : value['Value'].to_s
778
+ end
227
779
  end
228
780
 
229
781
  def execute_action(action, variables)
@@ -237,15 +789,22 @@ module Mxrb
237
789
  end
238
790
 
239
791
  def action_create_change(action, variables)
240
- object = store.create(action['Entity'].to_s)
792
+ entity = action['Entity'].to_s
793
+ authorize_entity!(entity, :create)
794
+ object = store.create(entity, events: action_events(action))
241
795
  apply_changes(object, action['Items'], variables)
242
796
  variables[action['VariableName'].to_s] = object
797
+ commit_for_action(action, object)
243
798
  end
244
799
 
245
800
  def action_change(action, variables)
246
801
  object = variables.fetch(action['ChangeVariableName'].to_s)
802
+ authorize_entity!(object.entity, :write, record: object)
247
803
  apply_changes(object, action['Items'], variables)
804
+ commit_for_action(action, object)
248
805
  end
806
+ alias action_create_object action_create_change
807
+ alias action_change_object action_change
249
808
 
250
809
  def action_create_variable(action, variables)
251
810
  variables[action['VariableName'].to_s] = @expression.evaluate(action['InitialValue'], variables)
@@ -257,11 +816,16 @@ module Mxrb
257
816
 
258
817
  def action_retrieve(action, variables)
259
818
  source = action['RetrieveSource'] || {}
819
+ if source['$Type'] == 'Microflows$AssociationRetrieveSource'
820
+ return action_association_retrieve(action, source, variables)
821
+ end
260
822
  unless source['$Type'] == 'Microflows$DatabaseRetrieveSource'
261
823
  raise NativeRuntimeError, "unsupported retrieve source #{source['$Type']}"
262
824
  end
263
825
 
264
- values = filter_by_xpath(store.retrieve(source['Entity'].to_s), source['XpathConstraint'].to_s, variables)
826
+ entity = source['Entity'].to_s
827
+ values = filter_by_xpath(store.retrieve(entity), source['XpathConstraint'].to_s, variables)
828
+ values = @policy.filter_readable(entity, values, context: @security_context) if enforce_entity_access?
265
829
  values = sort_values(values, items(source.dig('NewSortings', 'Sortings')))
266
830
  range = source['Range'] || {}
267
831
  limit = @expression.evaluate(range['LimitExpression'], variables)
@@ -269,6 +833,25 @@ module Mxrb
269
833
  variables[action['ResultVariableName'].to_s] = range['SingleObject'] == true ? values.first : values
270
834
  end
271
835
 
836
+ def action_association_retrieve(action, source, variables)
837
+ variable = source['StartVariableName'].to_s
838
+ association = source['AssociationId'].to_s.split('.').last
839
+ if variable.empty? || association.empty?
840
+ raise NativeRuntimeError, "unsupported retrieve source #{source['$Type']}"
841
+ end
842
+
843
+ start = variables.fetch(variable)
844
+ values = store.retrieve_association(association, start)
845
+ if enforce_entity_access?
846
+ values = Array(values).select do |value|
847
+ @policy.entity_allowed?(value.entity, action: :read, context: @security_context, record: value)
848
+ end
849
+ end
850
+ definition = @associations[source['AssociationId'].to_s]
851
+ values = values.first if definition&.fetch(:type) == :Reference && definition[:from] == start.entity
852
+ variables[action['ResultVariableName'].to_s] = values
853
+ end
854
+
272
855
  # Narrows a value set by a Mendix XPath constraint. Only attribute
273
856
  # predicates (comparisons, and/or, boolean shorthand) are understood;
274
857
  # anything else is rejected by the expression evaluator rather than
@@ -344,10 +927,50 @@ module Mxrb
344
927
  variables[action['ResultVariableName'].to_s] = result if action['UseReturnVariable'] == true
345
928
  end
346
929
 
347
- def action_commit(_action, _variables); end
930
+ def action_commit(action, variables)
931
+ variable = action['CommitVariableName'].to_s
932
+ return if variable.empty?
933
+
934
+ value = variables.fetch(variable)
935
+ Array(value).each { authorize_entity!(_1.entity, :write, record: _1) } if enforce_entity_access?
936
+ store.commit(value, events: action.fetch('WithEvents', true) != false) if store.respond_to?(:commit)
937
+ end
938
+ alias action_commit_objects action_commit
939
+
940
+ def commit_for_action(action, value)
941
+ setting = action['Commit'].is_a?(Hash) ? action['Commit']['Value'] : action['Commit']
942
+ return if setting.to_s.empty? || setting.to_s.casecmp?('No')
943
+
944
+ store.commit(value, events: !setting.to_s.match?(/WithoutEvents/i))
945
+ end
946
+
947
+ def action_events(action)
948
+ setting = action['Commit'].is_a?(Hash) ? action['Commit']['Value'] : action['Commit']
949
+ !setting.to_s.match?(/WithoutEvents/i)
950
+ end
951
+
952
+ def action_create_list(action, variables)
953
+ variables[action['VariableName'].to_s] = []
954
+ end
955
+
956
+ def action_change_list(action, variables)
957
+ list = variables.fetch(action['ChangeVariableName'].to_s)
958
+ value = @expression.evaluate(action['Value'], variables)
959
+ case action['Type'].to_s.downcase
960
+ when 'add' then list << value unless list.include?(value)
961
+ when 'remove' then list.delete(value)
962
+ when 'clear' then list.clear
963
+ else raise NativeRuntimeError, "unsupported list change #{action['Type']}"
964
+ end
965
+ end
348
966
 
349
967
  def action_delete(action, variables)
350
- store.delete(variables.fetch(action['DeleteVariableName'].to_s))
968
+ value = variables.fetch(action['DeleteVariableName'].to_s)
969
+ Array(value).each { authorize_entity!(_1.entity, :delete, record: _1) } if enforce_entity_access?
970
+ store.delete(
971
+ value,
972
+ events: action.fetch('WithEvents', true) != false
973
+ )
351
974
  end
352
975
 
353
976
  def action_log_message(action, variables)
@@ -356,6 +979,274 @@ module Mxrb
356
979
 
357
980
  def action_cast(_action, _variables); end
358
981
 
982
+ def action_rollback(action, variables)
983
+ value = variables.fetch(action['RollbackVariableName'].to_s)
984
+ store.rollback(value) if store.respond_to?(:rollback)
985
+ end
986
+
987
+ def action_show_message(action, variables)
988
+ template = action['Template'] || {}
989
+ @effects << {
990
+ type: 'show_message', level: action['Type'].to_s.downcase,
991
+ blocking: action['Blocking'] == true,
992
+ message: render_text_template(template, variables)
993
+ }
994
+ end
995
+
996
+ def action_validation_feedback(action, variables)
997
+ object = variables.fetch(action['ValidationVariableName'].to_s)
998
+ member = (action['Attribute'].to_s.empty? ? action['Association'] : action['Attribute']).to_s
999
+ @effects << {
1000
+ type: 'validation_feedback', object_id: object.id,
1001
+ member: member.split(%r{[./]}).last,
1002
+ message: render_text_template(action['FeedbackTemplate'] || {}, variables)
1003
+ }
1004
+ end
1005
+
1006
+ def action_list_operations(action, variables)
1007
+ operation = action['NewOperation'] || {}
1008
+ list = Array(variables.fetch(operation['ListName'].to_s))
1009
+ second = variables[operation['SecondListOrObjectName'].to_s]
1010
+ result = list_operation(operation['$Type'], list, second, operation['Expression'], variables)
1011
+ variables[action['ResultVariableName'].to_s] = result
1012
+ end
1013
+
1014
+ def action_java_action_call(action, variables)
1015
+ name = action['JavaAction'].to_s
1016
+ adapter = @java_custom_actions[name]
1017
+ unless adapter
1018
+ raise NativeRuntimeError,
1019
+ "Java Custom Action #{name.empty? ? '(missing name)' : name} is not registered; " \
1020
+ 'register an explicit Ruby adapter with ' \
1021
+ 'Mxrb::RubyApp::Registry.register_java_custom_action'
1022
+ end
1023
+
1024
+ result = adapter.call(java_action_arguments(name, action, variables).freeze)
1025
+ result_name = (action['ResultVariableName'] || action['OutputVariableName']).to_s
1026
+ use_return = action.key?('UseReturnVariable') ? action['UseReturnVariable'] == true : !result_name.empty?
1027
+ variables[result_name] = result if use_return && !result_name.empty?
1028
+ result
1029
+ end
1030
+ alias action_java_action action_java_action_call
1031
+ alias action_basic_java action_java_action_call
1032
+ alias action_basic_code action_java_action_call
1033
+ alias action_entity_type_java action_java_action_call
1034
+ alias action_microflow_java action_java_action_call
1035
+
1036
+ def java_action_arguments(name, action, variables)
1037
+ parameter_names = java_action_parameter_names(name)
1038
+ items(action['ParameterMappings']).to_h do |mapping|
1039
+ reference = identifier(mapping['Parameter'])
1040
+ parameter_name = parameter_names.fetch(reference, reference.to_s.split('.').last)
1041
+ if parameter_name.to_s.empty?
1042
+ raise NativeRuntimeError, "Java Custom Action #{name} has an unnamed parameter mapping"
1043
+ end
1044
+
1045
+ [parameter_name, java_action_argument(name, parameter_name, mapping['Value'], variables)]
1046
+ end
1047
+ end
1048
+
1049
+ def java_action_argument(action_name, parameter_name, value, variables)
1050
+ value ||= {}
1051
+ type = value['$Type'].to_s.delete_prefix('Microflows$')
1052
+ case type
1053
+ when 'BasicJavaActionParameterValue', 'BasicCodeActionParameterValue'
1054
+ @expression.evaluate(value['Argument'], variables)
1055
+ when 'EntityTypeJavaActionParameterValue'
1056
+ value['Entity'].to_s
1057
+ when 'MicroflowJavaActionParameterValue'
1058
+ value['Microflow'].to_s
1059
+ when 'ImportMappingJavaActionParameterValue'
1060
+ value['ImportMapping'].to_s
1061
+ when 'ExportMappingJavaActionParameterValue'
1062
+ value['ExportMapping'].to_s
1063
+ else
1064
+ raise NativeRuntimeError,
1065
+ "Java Custom Action #{action_name} parameter #{parameter_name} " \
1066
+ "uses unsupported mapping #{value['$Type'].inspect}"
1067
+ end
1068
+ end
1069
+
1070
+ def java_action_parameter_names(name)
1071
+ @java_action_parameter_names[name] ||= begin
1072
+ artifact = @project.find_artifact(name, kind: :java_action)
1073
+ document = artifact && @project.parse_bson(@project.raw_unit(artifact.unit_id))
1074
+ items(document&.[]('Parameters')).to_h do |parameter|
1075
+ [identifier(parameter['$ID']), parameter['Name'].to_s]
1076
+ end
1077
+ end
1078
+ end
1079
+
1080
+ def action_java_script_action_call(action, variables)
1081
+ client_action(:javascript, action['JavaScriptAction'], action, variables,
1082
+ result_name: action['OutputVariableName'])
1083
+ end
1084
+
1085
+ def action_nanoflow_call(action, variables)
1086
+ call = action['NanoflowCall'] || {}
1087
+ client_action(:nanoflow, call['Nanoflow'], call, variables,
1088
+ result_name: action['OutputVariableName'])
1089
+ end
1090
+
1091
+ def action_app_service_call(action, variables)
1092
+ invoke_adapter(:app_service, action['AppServiceAction'], action, variables,
1093
+ result_name: action['ResultVariableName'])
1094
+ end
1095
+
1096
+ def action_call_web_service(action, variables)
1097
+ invoke_adapter(
1098
+ :web_service, action['WebServiceCall'] || action['Operation'], action, variables,
1099
+ result_name: action['ResultVariableName']
1100
+ )
1101
+ end
1102
+
1103
+ def action_import_xml(action, variables)
1104
+ invoke_adapter(
1105
+ :import_xml, action['ImportMapping'], action, variables,
1106
+ result_name: action['ResultVariableName']
1107
+ )
1108
+ end
1109
+
1110
+ def action_generate_document(action, variables)
1111
+ invoke_adapter(
1112
+ :document, action['DocumentTemplate'], action, variables,
1113
+ result_name: action['ResultVariableName']
1114
+ )
1115
+ end
1116
+
1117
+ def action_download_file(action, variables)
1118
+ @effects << {
1119
+ type: :download_file,
1120
+ value: variables[action['FileDocumentVariableName'].to_s],
1121
+ show_in_browser: action['ShowFileInBrowser'] == true
1122
+ }
1123
+ end
1124
+
1125
+ def action_show_home_page(_action, _variables)
1126
+ @effects << { type: :show_home_page }
1127
+ end
1128
+
1129
+ def action_empty(_action, _variables); end
1130
+
1131
+ def action_import_mapping_java(action, variables)
1132
+ invoke_adapter(
1133
+ :import_mapping, action['ImportMapping'], action, variables,
1134
+ result_name: action['ResultVariableName']
1135
+ )
1136
+ end
1137
+
1138
+ def action_export_mapping_java(action, variables)
1139
+ invoke_adapter(
1140
+ :export_mapping, action['ExportMapping'], action, variables,
1141
+ result_name: action['ResultVariableName']
1142
+ )
1143
+ end
1144
+
1145
+ def action_rest_call(action, variables)
1146
+ configuration = action['HttpConfiguration'] || {}
1147
+ location = render_template(configuration['CustomLocationTemplate'], variables)
1148
+ headers = items(configuration['HttpHeaderEntries']).to_h do |entry|
1149
+ [entry['Key'].to_s, @expression.evaluate(entry['Value'], variables).to_s]
1150
+ rescue NativeRuntimeError
1151
+ [entry['Key'].to_s, entry['Value'].to_s]
1152
+ end
1153
+ request = action['RequestHandling'] || {}
1154
+ body_value = variables[request['MappingVariableName'].to_s]
1155
+ body = body_value.nil? ? nil : JSON.generate(runtime_json(body_value))
1156
+ timeout = @expression.evaluate(action['TimeOutExpression'], variables) if action['UseRequestTimeOut'] == true
1157
+ response = @http.call(configuration['HttpMethod'].to_s, location, headers, body, timeout)
1158
+ unless response.code.to_i.between?(200, 299)
1159
+ raise NativeRuntimeError, "REST call returned HTTP #{response.code}"
1160
+ end
1161
+
1162
+ bind_rest_result(action['ResultHandling'] || {}, response.body.to_s, variables)
1163
+ rescue URI::InvalidURIError, JSON::ParserError => e
1164
+ raise NativeRuntimeError, "REST call failed: #{e.message}"
1165
+ end
1166
+
1167
+ def action_show_form(action, variables)
1168
+ settings = action['FormSettings'] || {}
1169
+ arguments = items(settings['ParameterMappings']).to_h do |mapping|
1170
+ [mapping['Parameter'].to_s, @expression.evaluate(mapping['Argument'], variables)]
1171
+ end
1172
+ @effects << { type: 'open_page', page: settings['Form'].to_s, arguments: }
1173
+ end
1174
+
1175
+ def action_close_form(_action, _variables)
1176
+ @effects << { type: 'close_page' }
1177
+ end
1178
+
1179
+ def list_operation(type, list, second, expression, variables)
1180
+ operation = type.to_s.delete_prefix('Microflows$').downcase
1181
+ case operation
1182
+ when 'head' then list.first
1183
+ when 'tail' then list.drop(1)
1184
+ when 'find' then list.find { @expression.evaluate(expression, variables, node: _1) }
1185
+ when 'filter' then list.select { @expression.evaluate(expression, variables, node: _1) }
1186
+ when 'sort' then sort_values(list, items(second))
1187
+ when 'union' then (list + Array(second)).uniq
1188
+ when 'intersect' then list & Array(second)
1189
+ when 'subtract' then list - Array(second)
1190
+ when 'contains' then list.include?(second)
1191
+ else raise NativeRuntimeError, "unsupported list operation #{type}"
1192
+ end
1193
+ end
1194
+
1195
+ def client_action(kind, name, document, variables, result_name:)
1196
+ return invoke_adapter(kind, name, document, variables, result_name:) if @adapters[kind]
1197
+
1198
+ @effects << { type: kind.to_s, name: name.to_s }
1199
+ variables[result_name.to_s] = nil unless result_name.to_s.empty?
1200
+ end
1201
+
1202
+ def invoke_adapter(kind, name, document, variables, result_name:)
1203
+ adapter = @adapters[kind] or raise NativeRuntimeError, "#{kind} adapter is not configured"
1204
+ result = adapter.call(name.to_s, document, variables)
1205
+ variables[result_name.to_s] = result unless result_name.to_s.empty?
1206
+ result
1207
+ end
1208
+
1209
+ def bind_rest_result(handling, body, variables)
1210
+ return unless handling['Bind'] == true
1211
+
1212
+ result = JSON.parse(body)
1213
+ entity = handling.dig('VariableType', 'Entity').to_s
1214
+ result = rest_object(entity, result) unless entity.empty?
1215
+ variables[handling['ResultVariableName'].to_s] = result
1216
+ end
1217
+
1218
+ def rest_object(entity, value)
1219
+ authorize_entity!(entity, :create)
1220
+ object = store.create(entity)
1221
+ value.to_h.each do |key, child|
1222
+ authorize_entity!(entity, :write, member: key, record: object)
1223
+ object.members[key.to_s] = child
1224
+ end
1225
+ object
1226
+ end
1227
+
1228
+ def runtime_json(value)
1229
+ case value
1230
+ when ObjectValue then value.members.transform_values { runtime_json(_1) }
1231
+ when Hash then value.transform_values { runtime_json(_1) }
1232
+ when Array then value.map { runtime_json(_1) }
1233
+ else value
1234
+ end
1235
+ end
1236
+
1237
+ def http_request(method, location, headers, body, timeout)
1238
+ uri = URI.parse(location)
1239
+ request_class = Net::HTTP.const_get(method.to_s.downcase.capitalize)
1240
+ request = request_class.new(uri)
1241
+ headers.each { request[_1] = _2 }
1242
+ request.body = body if body
1243
+ Net::HTTP.start(
1244
+ uri.host, uri.port, use_ssl: uri.scheme == 'https',
1245
+ open_timeout: timeout || 10,
1246
+ read_timeout: timeout || 30
1247
+ ) { _1.request(request) }
1248
+ end
1249
+
359
1250
  def aggregate(function, values)
360
1251
  case function.to_s.downcase
361
1252
  when 'count' then values.size
@@ -371,10 +1262,21 @@ module Mxrb
371
1262
  items(values).each do |item|
372
1263
  member = (item['Attribute'].to_s.empty? ? item['Association'] : item['Attribute']).to_s
373
1264
  name = member.split(%r{[./]}).last
1265
+ authorize_entity!(object.entity, :write, member: name, record: object)
374
1266
  object.members[name] = @expression.evaluate(item['Value'], variables)
375
1267
  end
376
1268
  end
377
1269
 
1270
+ def enforce_entity_access? = @apply_entity_access && @policy && @security_context
1271
+
1272
+ def authorize_entity!(entity, action, member: nil, record: nil)
1273
+ return true unless enforce_entity_access?
1274
+
1275
+ @policy.authorize!(
1276
+ entity, kind: :entity, action:, context: @security_context, member:, record:
1277
+ )
1278
+ end
1279
+
378
1280
  def render_template(template, variables)
379
1281
  text = template&.dig('Text').to_s
380
1282
  items(template&.dig('Parameters')).each_with_index do |parameter, index|
@@ -383,6 +1285,21 @@ module Mxrb
383
1285
  text
384
1286
  end
385
1287
 
1288
+ def render_text_template(template, variables)
1289
+ text_doc = template&.dig('Text')
1290
+ text = if text_doc.is_a?(Hash)
1291
+ translation = items(text_doc['Items']).find { _1['LanguageCode'] == 'en_US' } ||
1292
+ items(text_doc['Items']).first
1293
+ translation&.fetch('Text', '').to_s
1294
+ else
1295
+ text_doc.to_s
1296
+ end
1297
+ items(template&.dig('Parameters')).each_with_index do |parameter, index|
1298
+ text = text.gsub("{#{index + 1}}", @expression.evaluate(parameter['Expression'], variables).to_s)
1299
+ end
1300
+ text
1301
+ end
1302
+
386
1303
  def normalize_arguments(flow, arguments)
387
1304
  names = flow.parameters.filter_map { _1['Name'] if _1.is_a?(Hash) }
388
1305
  names.to_h do |name|
@@ -397,7 +1314,11 @@ module Mxrb
397
1314
  end
398
1315
 
399
1316
  def identifier(value)
400
- IO::BsonCodec.extract_id(value.is_a?(Hash) ? value['$ID'] : value).to_s
1317
+ return value if value.is_a?(String)
1318
+
1319
+ @identifier_cache[value] ||= IO::BsonCodec.extract_id(
1320
+ value.is_a?(Hash) ? value['$ID'] : value
1321
+ ).to_s
401
1322
  end
402
1323
 
403
1324
  def underscore(value)
@@ -467,8 +1388,9 @@ module Mxrb
467
1388
  @clock.call
468
1389
  end
469
1390
  end
470
- # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity
1391
+ # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/ClassLength, Metrics/CyclomaticComplexity
471
1392
  # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity
1393
+ # rubocop:enable Metrics/ParameterLists
472
1394
  end
473
1395
  end
474
1396
  end