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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxrb
4
+ module Oql
5
+ # Lightweight positional analysis for OQL and SQL source. This deliberately
6
+ # stays independent from Translator's tokenizer: findings preserve the
7
+ # original fragment for editor highlighting.
8
+ # rubocop:disable Metrics/ClassLength
9
+ class Analyzer
10
+ DIALECTS = Translator::DIALECTS
11
+ LIKE = /\bLIKE\s+'(?<pattern>(?:''|[^'])*)'/i
12
+ WHERE = /\bWHERE\b(?<body>.*?)(?=\b(?:GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|OFFSET|UNION)\b|\z)/im
13
+ FUNCTION = /\b(?:LOWER|UPPER|CAST)\s*\([^)]*\)/i
14
+ FROM = /\bFROM\b(?<body>.*?)(?=\b(?:WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|OFFSET|UNION)\b|\z)/im
15
+ SELECT = /\bSELECT\b(?:\s+DISTINCT)?\s+(?<body>.*?)(?=\bFROM\b)/im
16
+ STAR = %r{(?<!\()(?:\b[A-Za-z_][A-Za-z0-9_$]*\s*/\s*)?\*}
17
+
18
+ def initialize(dialect: :postgresql)
19
+ @dialect = dialect.to_sym
20
+ raise ArgumentError, "unsupported analysis dialect #{@dialect.inspect}" \
21
+ unless DIALECTS.include?(@dialect)
22
+ end
23
+
24
+ def analyze(query)
25
+ source = query.oql.to_s
26
+ findings = [
27
+ *like_findings(source),
28
+ *function_findings(source),
29
+ *cartesian_findings(source),
30
+ *select_star_findings(source)
31
+ ].freeze
32
+ AnalysisReport.new(query, findings)
33
+ end
34
+
35
+ def analyze_source(source, language: :oql)
36
+ analyze(ad_hoc_query(source, language))
37
+ end
38
+
39
+ def analyze_sql(source) = analyze_source(source, language: :sql)
40
+
41
+ private
42
+
43
+ def ad_hoc_query(source, language)
44
+ kind = language.to_sym
45
+ raise ArgumentError, "unsupported analysis language #{language.inspect}" \
46
+ unless %i[oql sql].include?(kind)
47
+
48
+ text = source.to_s.freeze
49
+ Query.new(
50
+ 'adhoc', 'AdHoc', 'AdHoc', kind, text, nil, [].freeze,
51
+ kind == :sql ? 'Sql$AdHoc' : 'Oql$AdHoc', Translator.parameters(text)
52
+ )
53
+ end
54
+
55
+ def like_findings(source)
56
+ source.to_enum(:scan, LIKE).filter_map do
57
+ match = Regexp.last_match
58
+ classify_like(match[:pattern], match[0])
59
+ end
60
+ end
61
+
62
+ def classify_like(pattern, fragment)
63
+ if pattern.start_with?('%') && pattern.end_with?('%')
64
+ finding(:like_both_wildcard, :warning, fragment)
65
+ elsif pattern.start_with?('%')
66
+ finding(:like_leading_wildcard, :error, fragment)
67
+ elsif pattern.end_with?('%')
68
+ finding(:like_trailing_only, :hint, fragment)
69
+ end
70
+ end
71
+
72
+ def function_findings(source)
73
+ source.to_enum(:scan, WHERE).flat_map do
74
+ body = Regexp.last_match[:body]
75
+ body.to_enum(:scan, FUNCTION).map do
76
+ finding(:function_in_where, :warning, Regexp.last_match[0])
77
+ end
78
+ end
79
+ end
80
+
81
+ def cartesian_findings(source)
82
+ source.to_enum(:scan, FROM).filter_map do
83
+ match = Regexp.last_match
84
+ body = match[:body]
85
+ finding(:cartesian_join, :error, match[0].strip) \
86
+ if body.include?(',') && !body.match?(/\bJOIN\b/i)
87
+ end
88
+ end
89
+
90
+ def select_star_findings(source)
91
+ source.to_enum(:scan, SELECT).flat_map do
92
+ body = Regexp.last_match[:body]
93
+ body.to_enum(:scan, STAR).map do
94
+ finding(:select_star, :hint, Regexp.last_match[0])
95
+ end
96
+ end
97
+ end
98
+
99
+ def finding(rule, severity, fragment)
100
+ definition = definitions.fetch(rule)
101
+ Finding.new(rule, severity, fragment.freeze, definition[:message], definition[:suggestions])
102
+ end
103
+
104
+ # Keeping messages and dialect alternatives together prevents a rule
105
+ # from being emitted without an actionable replacement.
106
+ # rubocop:disable Metrics/MethodLength
107
+ def definitions
108
+ @definitions ||= {
109
+ like_leading_wildcard: definition(
110
+ 'A leading wildcard prevents ordinary index seeks.',
111
+ postgresql: 'Use pg_trgm with a GIN/GiST index or full-text search.',
112
+ sql_server: 'Use CONTAINS with a full-text index.',
113
+ ansi: 'Redesign the predicate or add a dedicated search index.'
114
+ ),
115
+ like_both_wildcard: definition(
116
+ 'Wildcards on both sides usually force a full scan.',
117
+ postgresql: "Use column % 'term' with the pg_trgm extension and an index.",
118
+ sql_server: "Use CONTAINS(column, 'term') with a full-text index.",
119
+ ansi: 'Use a search-specific index or redesign the lookup.'
120
+ ),
121
+ like_trailing_only: definition(
122
+ 'A trailing-only wildcard can use an index in many configurations.',
123
+ postgresql: 'Keep the prefix search; confirm operator class, collation, and index use.',
124
+ sql_server: 'Keep the prefix search; confirm collation and the execution plan.',
125
+ ansi: 'Keep the prefix search, but verify collation and index behavior.'
126
+ ),
127
+ function_in_where: definition(
128
+ 'Applying a function to a filtered column can make the predicate non-sargable.',
129
+ postgresql: 'Prefer ILIKE where appropriate or create a matching functional index.',
130
+ sql_server: 'Prefer a case-insensitive collation or an indexed computed column.',
131
+ ansi: 'Normalize data or compare against a separately indexed normalized column.'
132
+ ),
133
+ cartesian_join: definition(
134
+ 'Comma-separated entities without an explicit JOIN risk a Cartesian product.',
135
+ postgresql: 'Use an explicit JOIN with an ON predicate.',
136
+ sql_server: 'Use an explicit JOIN with an ON predicate.',
137
+ ansi: 'Use an explicit JOIN with an ON predicate.'
138
+ ),
139
+ select_star: definition(
140
+ 'Selecting every column increases transfer and couples callers to schema changes.',
141
+ postgresql: 'Project only the columns required by the caller.',
142
+ sql_server: 'Project only the columns required by the caller.',
143
+ ansi: 'Project only the columns required by the caller.'
144
+ )
145
+ }.freeze
146
+ end
147
+ # rubocop:enable Metrics/MethodLength
148
+
149
+ def definition(message, **suggestions)
150
+ { message: message.freeze, suggestions: suggestions.freeze }.freeze
151
+ end
152
+ end
153
+ # rubocop:enable Metrics/ClassLength
154
+ end
155
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxrb
4
+ module Oql
5
+ IndexCandidate = Data.define(:relation, :columns, :confidence, :query_ids, :reason)
6
+ IndexAdvice = Data.define(:candidates, :redundant_indexes)
7
+
8
+ # Produces hypotheses only when cumulative workload supplies repeatable evidence.
9
+ class IndexAdvisor
10
+ TABLE_PATTERN = /\b(?:FROM|JOIN)\s+["\[]?([\w$]+)["\]]?/i
11
+ PREDICATE_PATTERN = /\b([A-Za-z_]\w*)\s*(?:=|<|>|<=|>=|LIKE|IN\s*\()/i
12
+
13
+ def analyze(report)
14
+ evidence = collect_evidence(report.queries)
15
+ candidates = candidates_for(evidence, pressured_relations(report))
16
+ IndexAdvice.new(candidates.freeze, redundant(report.index_stats).freeze)
17
+ end
18
+
19
+ private
20
+
21
+ def collect_evidence(queries)
22
+ evidence = Hash.new { |hash, key| hash[key] = [] }
23
+ queries.each do |query|
24
+ relations(query.query).product(predicates(query.query)).each do |relation, column|
25
+ evidence[[relation.downcase, column.downcase]] << query
26
+ end
27
+ end
28
+ evidence
29
+ end
30
+
31
+ def pressured_relations(report)
32
+ report.findings.select { _1.rule == :table_sequential_pressure }
33
+ .map { _1.subject.to_s.split('.').last.downcase }
34
+ end
35
+
36
+ def candidates_for(evidence, pressured)
37
+ evidence.filter_map do |(relation, column), queries|
38
+ next unless pressured.include?(relation)
39
+ next unless sufficient_evidence?(queries)
40
+
41
+ IndexCandidate.new(
42
+ relation, [column].freeze, confidence(queries), queries.map(&:query_id).uniq.freeze,
43
+ 'Repeated filtered workload coincides with cumulative sequential-scan pressure.'
44
+ )
45
+ end
46
+ end
47
+
48
+ def sufficient_evidence?(queries)
49
+ queries.map(&:query_id).uniq.size >= 2 || queries.sum(&:total_time_ms) >= 1_000
50
+ end
51
+
52
+ def relations(sql) = sql.to_s.scan(TABLE_PATTERN).flatten.uniq
53
+ def predicates(sql) = sql.to_s.scan(PREDICATE_PATTERN).flatten.uniq
54
+
55
+ def confidence(queries)
56
+ return :high if queries.map(&:query_id).uniq.size >= 3 || queries.sum(&:total_time_ms) >= 5_000
57
+
58
+ :medium
59
+ end
60
+
61
+ def redundant(indexes)
62
+ indexes.filter_map { index_signature(_1) }.group_by(&:first).values.flat_map do |entries|
63
+ entries.combination(2).filter_map { redundant_pair(_1, _2) }
64
+ end
65
+ end
66
+
67
+ def index_signature(index)
68
+ columns = index['indexdef'].to_s[/\(([^)]+)\)/, 1]
69
+ return unless columns
70
+
71
+ [[index['schemaname'], index['relname']], [index['indexrelname'], normalize(columns)]]
72
+ end
73
+
74
+ def redundant_pair(left, right)
75
+ left_name, left_columns = left.last
76
+ right_name, right_columns = right.last
77
+ [left_name, right_name].freeze if left_columns == right_columns
78
+ end
79
+
80
+ def normalize(columns)
81
+ columns.downcase.gsub(/["\[\]\s]/, '')
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxrb
4
+ module Oql
5
+ PlanFinding = Data.define(
6
+ :rule, :severity, :node_type, :relation, :message, :suggestion, :metrics, :indexes
7
+ )
8
+
9
+ PlanReport = Data.define(
10
+ :engine, :analyzed, :planning_time_ms, :execution_time_ms, :total_cost, :findings, :raw
11
+ ) do
12
+ def clean? = findings.none? { _1.severity == :warning }
13
+ def warnings? = !clean?
14
+ def analyzed? = analyzed
15
+ end
16
+
17
+ # Turns PostgreSQL EXPLAIN JSON into conservative, actionable diagnostics.
18
+ # A sequential scan is not automatically an error: small-table scans are
19
+ # often cheaper than an index lookup, so estimates and measured rows decide
20
+ # whether MXRB emits a hint or a warning.
21
+ class PlanAnalyzer # rubocop:disable Metrics/ClassLength
22
+ LARGE_ROWS = 1_000
23
+ HIGH_COST = 1_000.0
24
+ MISESTIMATE_RATIO = 10.0
25
+ METRIC_KEYS = [
26
+ 'Plan Rows', 'Actual Rows', 'Actual Loops', 'Rows Removed by Filter',
27
+ 'Startup Cost', 'Total Cost', 'Actual Total Time', 'Sort Space Used', 'Sort Space Type'
28
+ ].freeze
29
+
30
+ def initialize(indexes: [])
31
+ @indexes = Array(indexes).freeze
32
+ end
33
+
34
+ def analyze(payload, analyzed: false)
35
+ envelope = Array(payload).first
36
+ raise ArgumentError, 'invalid PostgreSQL EXPLAIN JSON' unless envelope.is_a?(Hash)
37
+
38
+ root = envelope['Plan']
39
+ raise ArgumentError, 'PostgreSQL EXPLAIN JSON has no Plan' unless root.is_a?(Hash)
40
+
41
+ findings = walk(root).flat_map { findings_for(_1) }.freeze
42
+ PlanReport.new(
43
+ :postgresql, analyzed, envelope['Planning Time'], envelope['Execution Time'],
44
+ root['Total Cost'], findings, payload
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ def walk(node)
51
+ [node, *Array(node['Plans']).flat_map { walk(_1) }]
52
+ end
53
+
54
+ def findings_for(node)
55
+ [
56
+ sequential_scan(node), filter_discard(node), cardinality_misestimation(node),
57
+ nested_loop(node), disk_sort(node)
58
+ ].compact
59
+ end
60
+
61
+ def sequential_scan(node)
62
+ return unless node['Node Type'] == 'Seq Scan'
63
+
64
+ large = relevant_rows(node) >= LARGE_ROWS || number(node['Total Cost']) >= HIGH_COST
65
+ indexes = indexes_for(node)
66
+ PlanFinding.new(
67
+ :sequential_scan, large ? :warning : :hint, 'Seq Scan', qualified_relation(node),
68
+ scan_message(large), scan_suggestion(node, indexes, large),
69
+ node_metrics(node).freeze, indexes.freeze
70
+ )
71
+ end
72
+
73
+ def scan_message(large)
74
+ return 'A large sequential scan may dominate this query.' if large
75
+
76
+ 'A sequential scan was chosen; it may be optimal for a small relation.'
77
+ end
78
+
79
+ def filter_discard(node)
80
+ removed = number(node['Rows Removed by Filter'])
81
+ return unless removed >= LARGE_ROWS && removed > number(node['Actual Rows'])
82
+
83
+ finding(
84
+ :filter_discard, node,
85
+ 'The filter discarded more rows than it returned.',
86
+ 'Review predicate selectivity and whether a matching index can filter before heap access.'
87
+ )
88
+ end
89
+
90
+ def cardinality_misestimation(node)
91
+ estimated = number(node['Plan Rows'])
92
+ actual = number(node['Actual Rows'])
93
+ return if estimated.zero? || actual.zero?
94
+ return unless [estimated, actual].max >= 100 && ratio(estimated, actual) >= MISESTIMATE_RATIO
95
+
96
+ finding(
97
+ :cardinality_misestimation, node,
98
+ 'Estimated and actual row counts differ by at least 10x.',
99
+ 'Run ANALYZE after representative data changes and review extended statistics for correlated columns.'
100
+ )
101
+ end
102
+
103
+ def nested_loop(node)
104
+ return unless node['Node Type'] == 'Nested Loop'
105
+
106
+ rows = relevant_rows(node) * [number(node['Actual Loops']), 1].max
107
+ return unless rows >= 10_000
108
+
109
+ finding(
110
+ :high_volume_nested_loop, node,
111
+ 'A nested loop processes a high estimated or measured row volume.',
112
+ 'Check join cardinality and indexes on the inner join keys; compare hash or merge join plans.'
113
+ )
114
+ end
115
+
116
+ def disk_sort(node)
117
+ disk = node['Sort Space Type'] == 'Disk' || node['Sort Method'].to_s.start_with?('external')
118
+ return unless node['Node Type'] == 'Sort' && disk
119
+
120
+ finding(
121
+ :disk_sort, node,
122
+ 'The sort spilled to disk.',
123
+ 'Reduce sorted rows, use an order-compatible index, or review work_mem for this workload.'
124
+ )
125
+ end
126
+
127
+ def finding(rule, node, message, suggestion)
128
+ PlanFinding.new(
129
+ rule, :warning, node['Node Type'], qualified_relation(node), message, suggestion,
130
+ node_metrics(node).freeze, indexes_for(node).freeze
131
+ )
132
+ end
133
+
134
+ def node_metrics(node)
135
+ METRIC_KEYS.to_h { [_1.downcase.tr(' ', '_').to_sym, node[_1]] }.compact
136
+ end
137
+
138
+ def relevant_rows(node)
139
+ [number(node['Plan Rows']), number(node['Actual Rows'])].max
140
+ end
141
+
142
+ def ratio(left, right)
143
+ [left, right].max / [left, right].min
144
+ end
145
+
146
+ def number(value)
147
+ value ? value.to_f : 0.0
148
+ end
149
+
150
+ def qualified_relation(node)
151
+ relation = node['Relation Name']
152
+ return unless relation
153
+
154
+ schema = node['Schema']
155
+ schema ? "#{schema}.#{relation}" : relation
156
+ end
157
+
158
+ def indexes_for(node)
159
+ relation = node['Relation Name']
160
+ schema = node['Schema']
161
+ return [] unless relation
162
+
163
+ @indexes.filter_map do |index|
164
+ next unless index['tablename'] == relation
165
+ next if schema && index['schemaname'] != schema
166
+
167
+ { name: index['indexname'], definition: index['indexdef'] }.freeze
168
+ end
169
+ end
170
+
171
+ def scan_suggestion(node, indexes, large)
172
+ return 'No change is implied; compare with an index plan as data grows.' unless large
173
+
174
+ filter = node['Filter']
175
+ prefix = filter ? "Review the filter #{filter.inspect}. " : 'Review selective predicates. '
176
+ suffix = if indexes.empty?
177
+ 'No existing index was found for this relation.'
178
+ else
179
+ 'Compare predicates with the listed existing indexes.'
180
+ end
181
+ prefix + suffix
182
+ end
183
+ end # rubocop:enable Metrics/ClassLength
184
+ end
185
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'webrick'
5
+
6
+ module Mxrb
7
+ module Oql
8
+ # Loopback-only JSON API for read-only queries against a synchronized
9
+ # Mendix Runtime database.
10
+ class Server
11
+ MAX_BODY_BYTES = 1_048_576
12
+
13
+ attr_reader :host, :port
14
+
15
+ # rubocop:disable Metrics/ParameterLists
16
+ def initialize(project_path, host: '127.0.0.1', port: 4567, database_port: 55_432,
17
+ workspace: nil, logger: nil)
18
+ @host = host
19
+ raise ArgumentError, 'the query server must bind to a loopback address' \
20
+ unless %w[127.0.0.1 ::1 localhost].include?(@host)
21
+
22
+ @port = Integer(port)
23
+ @workspace = workspace || Runtime::DatabaseWorkspace.new(project_path, port: database_port)
24
+ @logger = logger || WEBrick::Log.new(File::NULL)
25
+ end
26
+ # rubocop:enable Metrics/ParameterLists
27
+
28
+ def start(prepare: true)
29
+ @workspace.up if prepare
30
+ @server = WEBrick::HTTPServer.new(
31
+ BindAddress: @host, Port: @port, Logger: @logger, AccessLog: []
32
+ )
33
+ @server.mount_proc('/query') { |request, response| dispatch(request, response) }
34
+ yield(@server) if block_given?
35
+ @server.start
36
+ end
37
+
38
+ def shutdown = @server&.shutdown
39
+
40
+ # rubocop:disable Metrics/MethodLength
41
+ def execute(payload)
42
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ sql, warnings, params = query_sql(payload)
44
+ rows = @workspace.query_rows(sql, params:)
45
+ {
46
+ ok: true, rows:, row_count: rows.size, elapsed_ms: elapsed_ms(started),
47
+ warnings:
48
+ }
49
+ rescue ArgumentError, JSON::ParserError => e
50
+ error_payload(e, started, 'invalid_request')
51
+ rescue StandardError => e
52
+ error_payload(e, started, 'query_failed')
53
+ end
54
+ # rubocop:enable Metrics/MethodLength
55
+
56
+ private
57
+
58
+ # rubocop:disable Metrics/MethodLength
59
+ def dispatch(request, response)
60
+ if request.request_method != 'POST'
61
+ return render(response, 405, ok: false, error: {
62
+ code: 'method_not_allowed', message: 'use POST /query'
63
+ })
64
+ end
65
+ if request.body.to_s.bytesize > MAX_BODY_BYTES
66
+ return render(response, 413, ok: false, error: {
67
+ code: 'request_too_large', message: 'request body exceeds 1 MiB'
68
+ })
69
+ end
70
+
71
+ payload = JSON.parse(request.body.to_s)
72
+ result = execute(payload)
73
+ render(response, result[:ok] ? 200 : error_status(result), result)
74
+ rescue JSON::ParserError => e
75
+ render(response, 400, ok: false, error: {
76
+ code: 'invalid_json', message: e.message
77
+ })
78
+ end
79
+ # rubocop:enable Metrics/MethodLength
80
+
81
+ # rubocop:disable Metrics/AbcSize
82
+ def query_sql(payload)
83
+ raise ArgumentError, 'JSON body must be an object' unless payload.is_a?(Hash)
84
+
85
+ sql = payload['sql']
86
+ oql = payload['oql']
87
+ raise ArgumentError, 'provide exactly one of sql or oql' if sql.to_s.empty? == oql.to_s.empty?
88
+
89
+ params = payload.fetch('params', {})
90
+ raise ArgumentError, 'params must be a JSON object' unless params.is_a?(Hash)
91
+
92
+ return [sql, [], {}] unless sql.to_s.empty?
93
+
94
+ translate_oql(oql, params)
95
+ end
96
+
97
+ def translate_oql(oql, params)
98
+ view = Translator.new(dialect: :postgresql).translate_source(oql)
99
+ raise ArgumentError, view.warnings.join('; ') unless view.supported?
100
+
101
+ expected = view.parameters.map(&:to_s).sort
102
+ provided = params.keys.map(&:to_s).sort
103
+ raise ArgumentError, "params must match OQL parameters: #{expected.join(', ')}" \
104
+ unless provided == expected
105
+
106
+ [view.sql, view.warnings, params]
107
+ end
108
+ # rubocop:enable Metrics/AbcSize
109
+
110
+ def elapsed_ms(started)
111
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(3)
112
+ end
113
+
114
+ def error_payload(error, started, code)
115
+ {
116
+ ok: false, error: { code:, message: error.message },
117
+ elapsed_ms: elapsed_ms(started)
118
+ }
119
+ end
120
+
121
+ def error_status(result)
122
+ result.dig(:error, :code) == 'invalid_request' ? 400 : 422
123
+ end
124
+
125
+ def render(response, status, payload)
126
+ response.status = status
127
+ response['Content-Type'] = 'application/json; charset=utf-8'
128
+ response.body = JSON.generate(payload)
129
+ end
130
+ end
131
+ end
132
+ end