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,677 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'csv'
5
+ require 'fileutils'
6
+ require 'json'
7
+ require 'open3'
8
+ require 'securerandom'
9
+
10
+ module Mxrb
11
+ module Runtime
12
+ DatabaseInfo = Data.define(
13
+ :project_id, :state_dir, :database_container, :runtime_container,
14
+ :host, :port, :database, :reader_user, :running, :model_fingerprint,
15
+ :runtime_url
16
+ )
17
+
18
+ # Materializes a private PostgreSQL database for one MPR. The Mendix Runtime
19
+ # owns schema synchronization; analyst access uses a separate read-only role.
20
+ # rubocop:disable Metrics/ClassLength
21
+ class DatabaseWorkspace
22
+ POSTGRES_IMAGE = 'postgres:13-alpine'
23
+ DATABASE = 'mxrb'
24
+ OWNER = 'mxrb_runtime'
25
+ READER = 'mxrb_reader'
26
+ NATIVE_INPUT_DIRECTORIES = %w[
27
+ mprcontents deployment javasource javascriptsource userlib vendorlib
28
+ resources widgets theme theme-cache themesource
29
+ ].freeze
30
+
31
+ def initialize(project_path, state_dir: nil, port: 55_432, runtime_port: 18_080, # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
32
+ runner: nil, sleeper: nil)
33
+ @project_path = File.expand_path(project_path)
34
+ @project_id = Digest::SHA256.hexdigest(@project_path)[0, 12]
35
+ @state_dir = File.expand_path(state_dir || default_state_dir)
36
+ @port = Integer(port)
37
+ @runtime_port = Integer(runtime_port)
38
+ raise ArgumentError, 'database port must be between 1 and 65535' unless (1..65_535).cover?(@port)
39
+ raise ArgumentError, 'runtime port must be between 1 and 65535' unless (1..65_535).cover?(@runtime_port)
40
+
41
+ @runner = runner || method(:capture)
42
+ @sleeper = sleeper || Kernel.method(:sleep)
43
+ @plan = Toolchain.new(@project_path).plan
44
+ end
45
+
46
+ def up(force_build: false)
47
+ validate!
48
+ prepare_state
49
+ refresh_runtime_package(force_build)
50
+ prepare_database!
51
+ configure_reader!
52
+ restart_runtime!
53
+ wait_for_runtime!
54
+ configure_reader!
55
+ info(running: true)
56
+ end
57
+
58
+ def sync = up(force_build: true)
59
+
60
+ def down
61
+ stop_container(runtime_container)
62
+ stop_container(database_container)
63
+ info(running: false)
64
+ end
65
+
66
+ def destroy(confirm: false)
67
+ raise ArgumentError, 'destroy requires explicit confirmation' unless confirm
68
+
69
+ remove_container(runtime_container)
70
+ remove_container(database_container)
71
+ remove_owned_resource('volume', database_volume)
72
+ remove_owned_resource('network', network_name)
73
+ FileUtils.rm_rf(@state_dir)
74
+ info(running: false)
75
+ end
76
+
77
+ def status
78
+ info(running: container_running?(database_container) && container_running?(runtime_container))
79
+ end
80
+
81
+ # Keeping the transaction envelope next to role selection makes the
82
+ # read-only default auditable.
83
+ # rubocop:disable Metrics/MethodLength
84
+ def query(sql, write: false)
85
+ raise ArgumentError, 'SQL must not be empty' if sql.to_s.strip.empty?
86
+ raise ArgumentError, 'SQL contains a NUL byte' if sql.include?("\0")
87
+
88
+ configure_reader! unless write
89
+ role = write ? OWNER : READER
90
+ command = [
91
+ 'docker', 'exec', database_container,
92
+ 'psql', '--no-psqlrc', '--set', 'ON_ERROR_STOP=1',
93
+ '--username', role, '--dbname', DATABASE
94
+ ]
95
+ command.concat(['--command', 'BEGIN READ ONLY']) unless write
96
+ command.concat(['--command', sql])
97
+ command.concat(['--command', 'COMMIT']) unless write
98
+ run!(*command)
99
+ end
100
+ # rubocop:enable Metrics/MethodLength
101
+
102
+ def query_rows(sql, params: {})
103
+ statement = read_only_statement(sql)
104
+ statement, variables = bind_parameters(statement, params)
105
+
106
+ configure_reader!
107
+ output = run!(*query_rows_command(statement, variables))
108
+ CSV.parse(output, headers: true).map(&:to_h).freeze
109
+ end
110
+
111
+ # Uses PostgreSQL's machine-readable planner output. ANALYZE is opt-in
112
+ # because it executes the SELECT, although the analyst role remains in a
113
+ # read-only transaction and cannot mutate application data.
114
+ def explain(sql, analyze: false)
115
+ statement = read_only_statement(sql)
116
+ configure_reader!
117
+ payload = JSON.parse(run_explain(statement, analyze).strip)
118
+ Oql::PlanAnalyzer.new(indexes: index_catalog).analyze(payload, analyzed: analyze)
119
+ rescue JSON::ParserError => e
120
+ raise ToolchainError, "PostgreSQL returned invalid EXPLAIN JSON: #{e.message}"
121
+ end
122
+
123
+ def workload(limit: 20)
124
+ maximum = Integer(limit)
125
+ raise ArgumentError, 'workload limit must be between 1 and 1000' unless (1..1000).cover?(maximum)
126
+
127
+ Oql::WorkloadAnalyzer.new.analyze(
128
+ query_rows: workload_queries(maximum),
129
+ table_rows: workload_tables,
130
+ index_rows: workload_indexes
131
+ )
132
+ end
133
+
134
+ def index_advice(limit: 50)
135
+ Oql::IndexAdvisor.new.analyze(workload(limit:))
136
+ end
137
+
138
+ def shell_command(write: false)
139
+ role = write ? OWNER : READER
140
+ [
141
+ 'docker', 'exec', '-it', database_container,
142
+ 'psql', '--no-psqlrc', '--username', role, '--dbname', DATABASE
143
+ ].freeze
144
+ end
145
+
146
+ def connection_url
147
+ secret = credentials.fetch('reader_password')
148
+ "postgresql://#{READER}:#{secret}@127.0.0.1:#{@port}/#{DATABASE}"
149
+ end
150
+
151
+ private
152
+
153
+ def bind_parameters(statement, params)
154
+ values = params.to_h.transform_keys(&:to_s)
155
+ return [statement, []] if values.empty?
156
+
157
+ validate_parameter_names!(values, statement)
158
+ bound = statement.gsub(/(?<!:):([A-Za-z][A-Za-z0-9_]*)/) do
159
+ name = Regexp.last_match(1)
160
+ values[name].nil? ? 'NULL' : ":'mxrb_#{name}'"
161
+ end
162
+ [bound, parameter_arguments(values)]
163
+ end
164
+
165
+ def validate_parameter_names!(values, statement)
166
+ invalid = values.keys.reject { _1.match?(/\A[A-Za-z][A-Za-z0-9_]*\z/) }
167
+ expected = statement.scan(/(?<!:):([A-Za-z][A-Za-z0-9_]*)/).flatten.uniq
168
+ parameter_error!('invalid parameter names', invalid)
169
+ parameter_error!('missing query parameter', expected - values.keys)
170
+ parameter_error!('unused query parameters', values.keys - expected)
171
+ end
172
+
173
+ def parameter_error!(label, names)
174
+ raise ArgumentError, "#{label}: #{names.join(', ')}" unless names.empty?
175
+ end
176
+
177
+ def parameter_arguments(values)
178
+ values.reject { |_name, value| value.nil? }.flat_map do |name, value|
179
+ raise ArgumentError, "unsupported parameter value for #{name}" unless parameter_scalar?(value)
180
+
181
+ ['--set', "mxrb_#{name}=#{value}"]
182
+ end
183
+ end
184
+
185
+ def parameter_scalar?(value)
186
+ value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
187
+ end
188
+
189
+ def query_rows_command(statement, variables)
190
+ [
191
+ 'docker', 'exec', database_container,
192
+ 'psql', '--no-psqlrc', '--set', 'ON_ERROR_STOP=1', *variables,
193
+ '--username', READER, '--dbname', DATABASE,
194
+ '--csv', '--quiet', '--command', "COPY (#{statement}) TO STDOUT WITH CSV HEADER"
195
+ ]
196
+ end
197
+
198
+ def prepare_database!
199
+ ensure_network!
200
+ ensure_database!
201
+ configure_monitoring!
202
+ end
203
+
204
+ def run_explain(statement, analyze)
205
+ run!(
206
+ 'docker', 'exec', database_container,
207
+ 'psql', '--no-psqlrc', '--set', 'ON_ERROR_STOP=1',
208
+ '--username', READER, '--dbname', DATABASE, '--tuples-only', '--no-align', '--quiet',
209
+ '--command', explain_sql(statement, analyze)
210
+ )
211
+ end
212
+
213
+ def read_only_statement(sql)
214
+ source = sql.to_s
215
+ raise ArgumentError, 'SQL contains a NUL byte' if source.include?("\0")
216
+
217
+ statement = source.strip
218
+ raise ArgumentError, 'SQL must not be empty' if statement.empty?
219
+ unless statement.match?(/\A(?:SELECT|WITH)\b/i) && !statement.include?(';')
220
+ raise ArgumentError, 'online queries must be one read-only SELECT or WITH statement'
221
+ end
222
+
223
+ statement
224
+ end
225
+
226
+ def explain_sql(statement, analyze)
227
+ options = ['FORMAT JSON', 'COSTS TRUE', 'VERBOSE TRUE', 'SETTINGS TRUE']
228
+ options.concat(['ANALYZE TRUE', 'BUFFERS TRUE', 'TIMING TRUE']) if analyze
229
+ "EXPLAIN (#{options.join(', ')}) #{statement}"
230
+ end
231
+
232
+ def index_catalog
233
+ query_rows(<<~SQL)
234
+ SELECT schemaname, tablename, indexname, indexdef
235
+ FROM pg_indexes
236
+ WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
237
+ ORDER BY schemaname, tablename, indexname
238
+ SQL
239
+ end
240
+
241
+ def refresh_runtime_package(force)
242
+ return unless force || stale_runtime?
243
+
244
+ remove_container(runtime_container)
245
+ build_runtime!
246
+ end
247
+
248
+ def validate!
249
+ raise ArgumentError, "#{@project_path}: file not found" unless File.file?(@project_path)
250
+ raise ToolchainError, "Mendix Runtime #{@plan.runtime_path} is unavailable" unless @plan.available?
251
+
252
+ run!('docker', 'version', '--format', '{{.Server.Version}}')
253
+ end
254
+
255
+ def prepare_state
256
+ FileUtils.mkdir_p(@state_dir)
257
+ File.chmod(0o700, @state_dir)
258
+ credentials
259
+ end
260
+
261
+ def credentials
262
+ @credentials ||= if File.file?(credentials_path)
263
+ JSON.parse(File.read(credentials_path))
264
+ else
265
+ create_credentials
266
+ end
267
+ end
268
+
269
+ def create_credentials
270
+ values = {
271
+ 'owner_password' => SecureRandom.hex(24),
272
+ 'reader_password' => SecureRandom.hex(24),
273
+ 'admin_password' => SecureRandom.hex(24)
274
+ }
275
+ FileUtils.mkdir_p(@state_dir)
276
+ File.write(credentials_path, JSON.pretty_generate(values))
277
+ File.chmod(0o600, credentials_path)
278
+ values
279
+ end
280
+
281
+ def stale_runtime?
282
+ !File.file?(runtime_marker) ||
283
+ JSON.parse(File.read(runtime_marker)).fetch('fingerprint') != model_fingerprint
284
+ rescue JSON::ParserError, KeyError
285
+ true
286
+ end
287
+
288
+ def build_runtime!
289
+ FileUtils.rm_rf(runtime_dir)
290
+ FileUtils.mkdir_p(build_dir)
291
+ package = File.join(build_dir, 'runtime.zip')
292
+ FileUtils.rm_f(package)
293
+ build_native_package(package)
294
+ FileUtils.mkdir_p(runtime_dir)
295
+ # The package is always authoritative for this generated directory.
296
+ # Some unzip builds can still observe files created by a just-removed
297
+ # bind-mounted Runtime and prompt on stdin unless overwrite is explicit.
298
+ run!('unzip', '-oq', package, '-d', runtime_dir)
299
+ File.write(runtime_marker, JSON.generate('fingerprint' => model_fingerprint))
300
+ end
301
+
302
+ def build_native_package(output)
303
+ Dir.mktmpdir('mxrb-db-build-', build_dir) do |root|
304
+ project = copy_native_input(root)
305
+ deployment = File.join(File.dirname(project), 'deployment')
306
+ compile_native(project, deployment, output)
307
+ end
308
+ rescue CompilationError => e
309
+ raise ToolchainError, "MXRB native database build failed: #{e.message}"
310
+ end
311
+
312
+ def compile_native(project, deployment, output)
313
+ Compiler::DeploymentMaterializer.new(
314
+ project, deployment:, mendix_home: @plan.toolchain_path
315
+ ).materialize
316
+ Compiler::ProjectJarBuilder.new(
317
+ project, deployment:, mendix_home: @plan.toolchain_path
318
+ ).build
319
+ compile_web(project, deployment)
320
+ Compiler::PortablePackager.new(
321
+ project, deployment:, mendix_home: @plan.toolchain_path
322
+ ).pack(output:, force: true)
323
+ end
324
+
325
+ def compile_web(project, deployment)
326
+ Compiler::WebBundleBuilder.new(
327
+ project, deployment:, mendix_home: @plan.toolchain_path
328
+ ).build
329
+ end
330
+
331
+ def copy_native_input(root)
332
+ destination = File.join(root, 'project')
333
+ FileUtils.mkdir_p(destination)
334
+ FileUtils.cp(@project_path, destination)
335
+ project_root = File.dirname(@project_path)
336
+ copy_native_directories(project_root, destination)
337
+ File.join(destination, File.basename(@project_path))
338
+ end
339
+
340
+ def copy_native_directories(project_root, destination)
341
+ NATIVE_INPUT_DIRECTORIES.each do |name|
342
+ source = File.join(project_root, name)
343
+ next unless File.directory?(source)
344
+ if Dir.glob(File.join(source, '**', '*'), File::FNM_DOTMATCH).any? { File.symlink?(_1) }
345
+ raise ToolchainError, "native database input contains a symlink: #{source}"
346
+ end
347
+
348
+ FileUtils.cp_r(source, destination)
349
+ end
350
+ end
351
+
352
+ def ensure_network!
353
+ if successful?('docker', 'network', 'inspect', network_name)
354
+ assert_owned_resource!('network', network_name)
355
+ return
356
+ end
357
+
358
+ run!('docker', 'network', 'create', '--label', ownership_label, network_name)
359
+ end
360
+
361
+ def ensure_database!
362
+ if container_exists?(database_container)
363
+ assert_owned_container!(database_container)
364
+ run!('docker', 'start', database_container) unless container_running?(database_container)
365
+ else
366
+ create_database_container!
367
+ end
368
+ wait_for_database!
369
+ ensure_application_database!
370
+ end
371
+
372
+ def ensure_application_database!
373
+ exists = run!(
374
+ 'docker', 'exec', database_container, 'psql', '--no-psqlrc', '--tuples-only',
375
+ '--no-align', '--username', OWNER, '--dbname', 'postgres', '--command',
376
+ "SELECT 1 FROM pg_database WHERE datname = '#{DATABASE}'"
377
+ ).strip == '1'
378
+ return if exists
379
+
380
+ create_application_database!
381
+ end
382
+
383
+ def create_application_database!
384
+ command = ['docker', 'exec', database_container, 'createdb', '--username', OWNER, DATABASE]
385
+ output, status = run_command(*command)
386
+ return if status.success? || output.include?('already exists')
387
+
388
+ raise ToolchainError, "docker failed:\n#{output}"
389
+ end
390
+
391
+ def create_database_container! # rubocop:disable Metrics/MethodLength
392
+ ensure_database_volume!
393
+ run!(
394
+ 'docker', 'run', '-d', '--name', database_container,
395
+ '--label', ownership_label, '--network', network_name,
396
+ '--mount', "type=volume,source=#{database_volume},target=/var/lib/postgresql/data",
397
+ '-p', "127.0.0.1:#{@port}:5432",
398
+ '-e', "POSTGRES_DB=#{DATABASE}", '-e', "POSTGRES_USER=#{OWNER}",
399
+ '-e', "POSTGRES_PASSWORD=#{credentials.fetch('owner_password')}",
400
+ POSTGRES_IMAGE, 'postgres',
401
+ '-c', 'shared_preload_libraries=pg_stat_statements',
402
+ '-c', 'track_io_timing=on'
403
+ )
404
+ end
405
+
406
+ def ensure_database_volume!
407
+ if successful?('docker', 'volume', 'inspect', database_volume)
408
+ assert_owned_resource!('volume', database_volume)
409
+ else
410
+ run!('docker', 'volume', 'create', '--label', ownership_label, database_volume)
411
+ end
412
+ end
413
+
414
+ def wait_for_database! # rubocop:disable Metrics/MethodLength
415
+ consecutive_ready = 0
416
+ 30.times do
417
+ ready = successful?(
418
+ 'docker', 'exec', database_container,
419
+ 'pg_isready', '--username', OWNER, '--dbname', DATABASE
420
+ )
421
+ consecutive_ready = ready ? consecutive_ready + 1 : 0
422
+ return if consecutive_ready >= 2
423
+
424
+ @sleeper.call(1)
425
+ end
426
+ raise ToolchainError, 'PostgreSQL did not become ready'
427
+ end
428
+
429
+ def configure_monitoring!
430
+ ensure_statistics_preload!
431
+ ensure_io_timing!
432
+ database_owner_sql('CREATE EXTENSION IF NOT EXISTS pg_stat_statements')
433
+ end
434
+
435
+ def ensure_statistics_preload!
436
+ libraries = database_scalar('SHOW shared_preload_libraries').split(',').map(&:strip)
437
+ return if libraries.include?('pg_stat_statements')
438
+
439
+ value = (libraries.reject(&:empty?) + ['pg_stat_statements']).join(',')
440
+ database_owner_sql("ALTER SYSTEM SET shared_preload_libraries = '#{value}'")
441
+ run!('docker', 'restart', database_container)
442
+ wait_for_database!
443
+ end
444
+
445
+ def ensure_io_timing!
446
+ return if database_scalar('SHOW track_io_timing') == 'on'
447
+
448
+ database_owner_sql('ALTER SYSTEM SET track_io_timing = on')
449
+ database_owner_sql('SELECT pg_reload_conf()')
450
+ end
451
+
452
+ def database_scalar(sql)
453
+ run!(
454
+ 'docker', 'exec', database_container, 'psql', '--no-psqlrc', '--tuples-only',
455
+ '--no-align', '--username', OWNER, '--dbname', DATABASE, '--command', sql
456
+ ).strip
457
+ end
458
+
459
+ def database_owner_sql(sql)
460
+ run!(
461
+ 'docker', 'exec', database_container, 'psql', '--no-psqlrc',
462
+ '--set', 'ON_ERROR_STOP=1', '--username', OWNER, '--dbname', DATABASE,
463
+ '--command', sql
464
+ )
465
+ end
466
+
467
+ def workload_queries(limit) # rubocop:disable Metrics/MethodLength
468
+ query_rows(<<~SQL)
469
+ SELECT queryid::text, calls, total_exec_time, mean_exec_time, rows,
470
+ shared_blks_hit, shared_blks_read, temp_blks_written,
471
+ blk_read_time, blk_write_time, query
472
+ FROM pg_stat_statements
473
+ WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
474
+ AND query NOT ILIKE '%pg_stat_statements%'
475
+ AND query ~* '^[[:space:]]*(SELECT|WITH)([[:space:]]|$)'
476
+ ORDER BY total_exec_time DESC
477
+ LIMIT #{limit}
478
+ SQL
479
+ end
480
+
481
+ def workload_tables
482
+ query_rows(<<~SQL)
483
+ SELECT schemaname, relname, seq_scan, seq_tup_read, idx_scan, n_live_tup,
484
+ last_analyze, last_autoanalyze
485
+ FROM pg_stat_user_tables
486
+ ORDER BY seq_tup_read DESC
487
+ SQL
488
+ end
489
+
490
+ def workload_indexes
491
+ query_rows(<<~SQL)
492
+ SELECT s.schemaname, s.relname, s.indexrelname, s.idx_scan,
493
+ s.idx_tup_read, s.idx_tup_fetch, pg_relation_size(s.indexrelid) AS index_bytes,
494
+ i.indisunique, i.indisprimary, pg_get_indexdef(s.indexrelid) AS indexdef
495
+ FROM pg_stat_user_indexes s
496
+ JOIN pg_index i ON i.indexrelid = s.indexrelid
497
+ ORDER BY index_bytes DESC
498
+ SQL
499
+ end
500
+
501
+ def wait_for_runtime!
502
+ 120.times do
503
+ output, status = run_command('docker', 'logs', runtime_container)
504
+ return if status.success? && output.include?('Mendix Runtime successfully started')
505
+ raise ToolchainError, "Mendix Runtime stopped during synchronization:\n#{output}" \
506
+ unless container_running?(runtime_container)
507
+
508
+ @sleeper.call(1)
509
+ end
510
+ raise ToolchainError, 'Mendix Runtime schema synchronization timed out'
511
+ end
512
+
513
+ # The grants form one atomic security policy and intentionally stay together.
514
+ # rubocop:disable Metrics/MethodLength
515
+ def configure_reader!
516
+ return if @reader_configured
517
+
518
+ password = credentials.fetch('reader_password')
519
+ sql = <<~SQL
520
+ BEGIN;
521
+ SELECT pg_advisory_xact_lock(hashtext('mxrb.configure_reader'));
522
+ DO $mxrb$ BEGIN
523
+ CREATE ROLE #{READER} LOGIN PASSWORD '#{password}';
524
+ EXCEPTION WHEN duplicate_object THEN
525
+ ALTER ROLE #{READER} LOGIN PASSWORD '#{password}';
526
+ END $mxrb$;
527
+ ALTER ROLE #{READER} SET default_transaction_read_only = on;
528
+ GRANT CONNECT ON DATABASE #{DATABASE} TO #{READER};
529
+ GRANT USAGE ON SCHEMA public TO #{READER};
530
+ GRANT SELECT ON ALL TABLES IN SCHEMA public TO #{READER};
531
+ GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO #{READER};
532
+ GRANT pg_read_all_stats TO #{READER};
533
+ ALTER DEFAULT PRIVILEGES FOR ROLE #{OWNER} IN SCHEMA public
534
+ GRANT SELECT ON TABLES TO #{READER};
535
+ ALTER DEFAULT PRIVILEGES FOR ROLE #{OWNER} IN SCHEMA public
536
+ GRANT SELECT ON SEQUENCES TO #{READER};
537
+ COMMIT;
538
+ SQL
539
+ run!(
540
+ 'docker', 'exec', database_container,
541
+ 'psql', '--no-psqlrc', '--set', 'ON_ERROR_STOP=1',
542
+ '--username', OWNER, '--dbname', DATABASE, '--command', sql
543
+ )
544
+ @reader_configured = true
545
+ end
546
+ # rubocop:enable Metrics/MethodLength
547
+
548
+ # Runtime database settings stay adjacent so no credential or safety
549
+ # override can be omitted from one execution path.
550
+ # rubocop:disable Metrics/MethodLength
551
+ def restart_runtime!
552
+ remove_container(runtime_container)
553
+ secret = credentials
554
+ run!(
555
+ 'docker', 'run', '-d', '--name', runtime_container,
556
+ '--label', ownership_label, '--network', network_name,
557
+ '--publish', "127.0.0.1:#{@runtime_port}:8080",
558
+ '--mount', bind_mount(runtime_dir, '/mendix', readonly: false),
559
+ '-e', 'HOME=/tmp', '-e', "M2EE_ADMIN_PASS=#{secret.fetch('admin_password')}",
560
+ '-e', "RUNTIME_ADMINUSER_PASSWORD=#{secret.fetch('admin_password')}",
561
+ '-e', 'RUNTIME_PARAMS_DATABASETYPE=POSTGRESQL',
562
+ '-e', "RUNTIME_PARAMS_DATABASEHOST=#{database_container}:5432",
563
+ '-e', "RUNTIME_PARAMS_DATABASEJDBCURL=jdbc:postgresql://#{database_container}:5432/#{DATABASE}",
564
+ '-e', "RUNTIME_PARAMS_DATABASENAME=#{DATABASE}",
565
+ '-e', "RUNTIME_PARAMS_DATABASEUSERNAME=#{OWNER}",
566
+ '-e', "RUNTIME_PARAMS_DATABASEPASSWORD=#{secret.fetch('owner_password')}",
567
+ '-e', 'RUNTIME_PARAMS_DATABASEUSESSL=false',
568
+ '-w', '/mendix', @plan.runtime_image, './bin/start', 'etc/Default'
569
+ )
570
+ end
571
+ # rubocop:enable Metrics/MethodLength
572
+
573
+ def stop_container(name)
574
+ return unless container_running?(name)
575
+
576
+ assert_owned_container!(name)
577
+ run!('docker', 'stop', name)
578
+ end
579
+
580
+ def remove_container(name)
581
+ return unless container_exists?(name)
582
+
583
+ assert_owned_container!(name)
584
+ run!('docker', 'rm', '-f', name)
585
+ end
586
+
587
+ def container_exists?(name)
588
+ successful?('docker', 'container', 'inspect', name)
589
+ end
590
+
591
+ def container_running?(name)
592
+ output, = run_command('docker', 'container', 'inspect', '--format', '{{.State.Running}}', name)
593
+ output.strip == 'true'
594
+ end
595
+
596
+ def assert_owned_container!(name)
597
+ assert_owned_resource!('container', name, '.Config.Labels')
598
+ end
599
+
600
+ def assert_owned_resource!(kind, name, labels_path = '.Labels')
601
+ output = run!(
602
+ 'docker', kind, 'inspect', '--format',
603
+ "{{ index #{labels_path} \"mxrb.project\" }}", name
604
+ )
605
+ return if output.strip == @project_id
606
+
607
+ raise ToolchainError, "refusing to modify unowned Docker #{kind} #{name}"
608
+ end
609
+
610
+ def remove_owned_resource(kind, name)
611
+ return unless successful?('docker', kind, 'inspect', name)
612
+
613
+ assert_owned_resource!(kind, name)
614
+ run!('docker', kind, 'rm', name)
615
+ end
616
+
617
+ def successful?(*command)
618
+ _output, status = run_command(*command)
619
+ status.success?
620
+ end
621
+
622
+ def run!(*command)
623
+ output, status = run_command(*command)
624
+ raise ToolchainError, "#{command.first} failed:\n#{output}" unless status.success?
625
+
626
+ output
627
+ end
628
+
629
+ def run_command(*command)
630
+ result = @runner.call(*command)
631
+ return [result[0].to_s + result[1].to_s, result[2]] if result.length == 3
632
+
633
+ [result[0].to_s, result[1]]
634
+ end
635
+
636
+ def capture(*command)
637
+ Open3.capture3(*command)
638
+ end
639
+
640
+ def info(running:)
641
+ DatabaseInfo.new(
642
+ @project_id, @state_dir, database_container, runtime_container,
643
+ '127.0.0.1', @port, DATABASE, READER, running, model_fingerprint,
644
+ "http://127.0.0.1:#{@runtime_port}/"
645
+ )
646
+ end
647
+
648
+ def model_fingerprint
649
+ @model_fingerprint ||= Mxrb.open(@project_path) do |project|
650
+ Semantic::Index.fingerprint(project)
651
+ end
652
+ end
653
+
654
+ def bind_mount(source, target, readonly:)
655
+ values = ['type=bind', "source=#{source}", "target=#{target}"]
656
+ values << 'readonly' if readonly
657
+ values.join(',')
658
+ end
659
+
660
+ def default_state_dir
661
+ root = ENV['XDG_STATE_HOME'] || File.join(Dir.home, '.local', 'state')
662
+ File.join(root, 'mxrb', 'databases', @project_id)
663
+ end
664
+
665
+ def credentials_path = File.join(@state_dir, 'credentials.json')
666
+ def runtime_marker = File.join(@state_dir, 'runtime.json')
667
+ def runtime_dir = File.join(@state_dir, 'runtime')
668
+ def build_dir = File.join(@state_dir, 'build')
669
+ def network_name = "mxrb-#{@project_id}"
670
+ def database_container = "mxrb-#{@project_id}-postgres"
671
+ def runtime_container = "mxrb-#{@project_id}-runtime"
672
+ def database_volume = "mxrb-#{@project_id}-data"
673
+ def ownership_label = "mxrb.project=#{@project_id}"
674
+ end
675
+ # rubocop:enable Metrics/ClassLength
676
+ end
677
+ end