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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.de-DE.md +108 -0
- data/README.md +172 -0
- data/README.pt-BR.md +109 -0
- data/bin/mxrb +1613 -0
- data/docs/README.md +10 -0
- data/docs/de-DE/README.md +24 -0
- data/docs/de-DE/architectural-patterns.md +26 -0
- data/docs/de-DE/architectural-standard.md +48 -0
- data/docs/de-DE/architecture.md +76 -0
- data/docs/de-DE/compiler.md +162 -0
- data/docs/de-DE/conventions.md +19 -0
- data/docs/de-DE/design-system.md +52 -0
- data/docs/de-DE/entity-dsl.md +53 -0
- data/docs/de-DE/native-runtime-quality-report.md +74 -0
- data/docs/de-DE/oql-sql.md +211 -0
- data/docs/de-DE/platform-api-integration.md +36 -0
- data/docs/de-DE/platform-operations.md +243 -0
- data/docs/de-DE/project-structure.md +32 -0
- data/docs/de-DE/ruby-first-roadmap.md +116 -0
- data/docs/de-DE/scaffolds.md +59 -0
- data/docs/de-DE/semantic-refactoring.md +56 -0
- data/docs/de-DE/team-server.md +33 -0
- data/docs/de-DE/validation-matrix.md +64 -0
- data/docs/de-DE/vetclinic-acceptance.md +24 -0
- data/docs/de-DE/writing.md +138 -0
- data/docs/en-US/README.md +24 -0
- data/docs/en-US/architectural-patterns.md +151 -0
- data/docs/en-US/architectural-standard.md +89 -0
- data/docs/en-US/architecture.md +170 -0
- data/docs/en-US/compiler.md +157 -0
- data/docs/en-US/conventions.md +82 -0
- data/docs/en-US/design-system.md +163 -0
- data/docs/en-US/entity-dsl.md +53 -0
- data/docs/en-US/native-runtime-quality-report.md +73 -0
- data/docs/en-US/oql-sql.md +201 -0
- data/docs/en-US/platform-api-integration.md +36 -0
- data/docs/en-US/platform-operations.md +244 -0
- data/docs/en-US/project-structure.md +49 -0
- data/docs/en-US/ruby-first-roadmap.md +186 -0
- data/docs/en-US/scaffolds.md +57 -0
- data/docs/en-US/semantic-refactoring.md +198 -0
- data/docs/en-US/team-server.md +32 -0
- data/docs/en-US/validation-matrix.md +257 -0
- data/docs/en-US/vetclinic-acceptance.md +23 -0
- data/docs/en-US/writing.md +655 -0
- data/docs/pt-BR/README.md +24 -0
- data/docs/pt-BR/architectural-patterns.md +25 -0
- data/docs/pt-BR/architectural-standard.md +533 -0
- data/docs/pt-BR/architecture.md +300 -0
- data/docs/pt-BR/compiler.md +162 -0
- data/docs/pt-BR/conventions.md +19 -0
- data/docs/pt-BR/design-system.md +65 -0
- data/docs/pt-BR/entity-dsl.md +116 -0
- data/docs/pt-BR/native-runtime-quality-report.md +73 -0
- data/docs/pt-BR/oql-sql.md +208 -0
- data/docs/pt-BR/platform-api-integration.md +36 -0
- data/docs/pt-BR/platform-operations.md +260 -0
- data/docs/pt-BR/project-structure.md +32 -0
- data/docs/pt-BR/ruby-first-roadmap.md +239 -0
- data/docs/pt-BR/scaffolds.md +104 -0
- data/docs/pt-BR/semantic-refactoring.md +55 -0
- data/docs/pt-BR/team-server.md +59 -0
- data/docs/pt-BR/validation-matrix.md +86 -0
- data/docs/pt-BR/vetclinic-acceptance.md +50 -0
- data/docs/pt-BR/writing.md +177 -0
- data/examples/sudoku_evaluation.rb +14 -0
- data/examples/sudoku_functional_test.rb +28 -0
- data/lib/mxrb/architecture/graph.rb +124 -0
- data/lib/mxrb/architecture/validator.rb +98 -0
- data/lib/mxrb/benchmark.rb +33 -0
- data/lib/mxrb/compare.rb +390 -0
- data/lib/mxrb/compiler/adapter.rb +96 -0
- data/lib/mxrb/compiler/artifact_document_compiler.rb +102 -0
- data/lib/mxrb/compiler/artifact_materializer.rb +53 -0
- data/lib/mxrb/compiler/client_model_materializer.rb +42 -0
- data/lib/mxrb/compiler/code_action_document_compiler.rb +64 -0
- data/lib/mxrb/compiler/code_action_materializer.rb +35 -0
- data/lib/mxrb/compiler/code_action_type_compiler.rb +32 -0
- data/lib/mxrb/compiler/combo_box_bundle_compiler.rb +314 -0
- data/lib/mxrb/compiler/compatibility_analyzer.rb +169 -0
- data/lib/mxrb/compiler/constants_materializer.rb +111 -0
- data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +290 -0
- data/lib/mxrb/compiler/database_connector_action_compiler.rb +280 -0
- data/lib/mxrb/compiler/deployment_asset_copier.rb +60 -0
- data/lib/mxrb/compiler/deployment_bootstrapper.rb +208 -0
- data/lib/mxrb/compiler/deployment_materializer.rb +43 -0
- data/lib/mxrb/compiler/deployment_metadata.rb +64 -0
- data/lib/mxrb/compiler/domain_document_compiler.rb +153 -0
- data/lib/mxrb/compiler/domain_model_materializer.rb +35 -0
- data/lib/mxrb/compiler/domain_security_compiler.rb +82 -0
- data/lib/mxrb/compiler/gallery_bundle_compiler.rb +190 -0
- data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +281 -0
- data/lib/mxrb/compiler/image_bundle_compiler.rb +216 -0
- data/lib/mxrb/compiler/java_proxy_generator.rb +509 -0
- data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +267 -0
- data/lib/mxrb/compiler/legacy_page_builder.rb +287 -0
- data/lib/mxrb/compiler/mda.rb +99 -0
- data/lib/mxrb/compiler/microflow_document_compiler.rb +85 -0
- data/lib/mxrb/compiler/microflow_materializer.rb +48 -0
- data/lib/mxrb/compiler/microflow_node_compiler.rb +250 -0
- data/lib/mxrb/compiler/model_package.rb +116 -0
- data/lib/mxrb/compiler/model_values.rb +67 -0
- data/lib/mxrb/compiler/nanoflow_program_compiler.rb +483 -0
- data/lib/mxrb/compiler/navigation_document_compiler.rb +93 -0
- data/lib/mxrb/compiler/packager.rb +100 -0
- data/lib/mxrb/compiler/page_bundle_builder.rb +56 -0
- data/lib/mxrb/compiler/page_bundle_compiler.rb +2004 -0
- data/lib/mxrb/compiler/page_document_compiler.rb +77 -0
- data/lib/mxrb/compiler/portable_packager.rb +356 -0
- data/lib/mxrb/compiler/project_jar_archive.rb +84 -0
- data/lib/mxrb/compiler/project_jar_builder.rb +121 -0
- data/lib/mxrb/compiler/project_materializer.rb +139 -0
- data/lib/mxrb/compiler/project_model_orderer.rb +52 -0
- data/lib/mxrb/compiler/runtime_data_types.rb +31 -0
- data/lib/mxrb/compiler/runtime_model_schema.rb +90 -0
- data/lib/mxrb/compiler/schemas/runtime-10.24.0.73019.json +1052 -0
- data/lib/mxrb/compiler/schemas/runtime-11.json +1215 -0
- data/lib/mxrb/compiler/schemas/runtime-6.10.8.json +1148 -0
- data/lib/mxrb/compiler/schemas/runtime-7.17.0.json +1144 -0
- data/lib/mxrb/compiler/schemas/runtime-7.5.0.json +1215 -0
- data/lib/mxrb/compiler/schemas/runtime-9.6.1.29396.json +868 -0
- data/lib/mxrb/compiler/schemas/system-model-10.24.0.73019.b64 +1135 -0
- data/lib/mxrb/compiler/schemas/system-model-11.12.1.b64 +815 -0
- data/lib/mxrb/compiler/schemas/system-model-6.10.8.b64 +497 -0
- data/lib/mxrb/compiler/schemas/system-model-7.17.0.b64 +519 -0
- data/lib/mxrb/compiler/schemas/system-model-7.5.0.b64 +504 -0
- data/lib/mxrb/compiler/schemas/system-model-9.6.1.29396.b64 +816 -0
- data/lib/mxrb/compiler/security_materializer.rb +104 -0
- data/lib/mxrb/compiler/settings_document_compiler.rb +45 -0
- data/lib/mxrb/compiler/settings_materializer.rb +25 -0
- data/lib/mxrb/compiler/source_model.rb +128 -0
- data/lib/mxrb/compiler/system_model_seed.rb +88 -0
- data/lib/mxrb/compiler/system_queue_materializer.rb +54 -0
- data/lib/mxrb/compiler/system_text_materializer.rb +49 -0
- data/lib/mxrb/compiler/translation_materializer.rb +95 -0
- data/lib/mxrb/compiler/web_bundle_builder.rb +155 -0
- data/lib/mxrb/compiler/web_list_data_source.rb +139 -0
- data/lib/mxrb/compiler/web_operation_compiler.rb +614 -0
- data/lib/mxrb/compiler/web_shell_materializer.rb +194 -0
- data/lib/mxrb/compiler/widget_package_extractor.rb +63 -0
- data/lib/mxrb/doctor.rb +116 -0
- data/lib/mxrb/dsl/builder.rb +1636 -0
- data/lib/mxrb/errors.rb +18 -0
- data/lib/mxrb/evaluation.rb +131 -0
- data/lib/mxrb/exporter.rb +1716 -0
- data/lib/mxrb/frontend/migrator.rb +775 -0
- data/lib/mxrb/functional.rb +307 -0
- data/lib/mxrb/github/annotator.rb +161 -0
- data/lib/mxrb/initializer.rb +257 -0
- data/lib/mxrb/integrity/validator.rb +136 -0
- data/lib/mxrb/io/bson_codec.rb +145 -0
- data/lib/mxrb/io/mpr_file.rb +558 -0
- data/lib/mxrb/io/mxunit_codec.rb +42 -0
- data/lib/mxrb/marketplace.rb +380 -0
- data/lib/mxrb/model/association.rb +98 -0
- data/lib/mxrb/model/attribute.rb +100 -0
- data/lib/mxrb/model/connector.rb +17 -0
- data/lib/mxrb/model/design_materializer.rb +133 -0
- data/lib/mxrb/model/design_migration.rb +126 -0
- data/lib/mxrb/model/design_system.rb +111 -0
- data/lib/mxrb/model/domain_model.rb +51 -0
- data/lib/mxrb/model/entity.rb +180 -0
- data/lib/mxrb/model/menu.rb +38 -0
- data/lib/mxrb/model/microflow.rb +100 -0
- data/lib/mxrb/model/module.rb +142 -0
- data/lib/mxrb/model/navigation.rb +127 -0
- data/lib/mxrb/model/page.rb +410 -0
- data/lib/mxrb/model/project.rb +187 -0
- data/lib/mxrb/model/unit.rb +48 -0
- data/lib/mxrb/module_initializer.rb +75 -0
- data/lib/mxrb/official_marketplace/content_api.rb +292 -0
- data/lib/mxrb/official_marketplace/dependency_resolver.rb +329 -0
- data/lib/mxrb/official_marketplace/lifecycle.rb +367 -0
- data/lib/mxrb/official_marketplace/module_package_importer.rb +318 -0
- data/lib/mxrb/official_marketplace/widget_package_installer.rb +414 -0
- data/lib/mxrb/official_marketplace.rb +720 -0
- data/lib/mxrb/oql/analyzer.rb +155 -0
- data/lib/mxrb/oql/index_advisor.rb +85 -0
- data/lib/mxrb/oql/plan_analyzer.rb +185 -0
- data/lib/mxrb/oql/server.rb +132 -0
- data/lib/mxrb/oql/sql_server_plan_analyzer.rb +190 -0
- data/lib/mxrb/oql/sql_server_workload_analyzer.rb +80 -0
- data/lib/mxrb/oql/workload_analyzer.rb +175 -0
- data/lib/mxrb/oql/workload_baseline.rb +60 -0
- data/lib/mxrb/oql.rb +424 -0
- data/lib/mxrb/project_lifecycle.rb +76 -0
- data/lib/mxrb/protocols/plan.rb +86 -0
- data/lib/mxrb/protocols.rb +123 -0
- data/lib/mxrb/runtime/database_workspace.rb +677 -0
- data/lib/mxrb/runtime/docker_executor.rb +41 -0
- data/lib/mxrb/runtime/docker_workspace.rb +29 -0
- data/lib/mxrb/runtime/executor.rb +169 -0
- data/lib/mxrb/runtime/java_locator.rb +26 -0
- data/lib/mxrb/runtime/native.rb +474 -0
- data/lib/mxrb/runtime/sql_server_database.rb +146 -0
- data/lib/mxrb/runtime/toolchain.rb +82 -0
- data/lib/mxrb/scaffold/cli.rb +113 -0
- data/lib/mxrb/scaffold/generator.rb +246 -0
- data/lib/mxrb/scaffold/help.rb +80 -0
- data/lib/mxrb/scaffold/page_templates.rb +54 -0
- data/lib/mxrb/scaffold/recipes.rb +198 -0
- data/lib/mxrb/scaffold/registry.rb +82 -0
- data/lib/mxrb/scaffold/templates.rb +723 -0
- data/lib/mxrb/scaffold/transaction.rb +84 -0
- data/lib/mxrb/schema/tables.rb +55 -0
- data/lib/mxrb/semantic/analyzer.rb +473 -0
- data/lib/mxrb/semantic/batch_plan.rb +68 -0
- data/lib/mxrb/semantic/domain_mutator.rb +419 -0
- data/lib/mxrb/semantic/embedder.rb +51 -0
- data/lib/mxrb/semantic/extractor.rb +290 -0
- data/lib/mxrb/semantic/index.rb +623 -0
- data/lib/mxrb/semantic/inliner.rb +216 -0
- data/lib/mxrb/semantic/mover.rb +153 -0
- data/lib/mxrb/semantic/onnx_embedder.rb +34 -0
- data/lib/mxrb/semantic/remover.rb +70 -0
- data/lib/mxrb/semantic/renamer.rb +211 -0
- data/lib/mxrb/semantic/tfidf_embedder.rb +46 -0
- data/lib/mxrb/semantic/vec_store.rb +75 -0
- data/lib/mxrb/team_server.rb +390 -0
- data/lib/mxrb/templates/project/10.24.0.73019.json +32 -0
- data/lib/mxrb/templates/project/11.12.1.json +32 -0
- data/lib/mxrb/templates/project/6.10.8.json +50 -0
- data/lib/mxrb/templates/project/7.17.0.json +50 -0
- data/lib/mxrb/templates/project/7.5.0.json +50 -0
- data/lib/mxrb/templates/project/9.6.1.29396.json +50 -0
- data/lib/mxrb/version.rb +5 -0
- data/lib/mxrb/widget_package.rb +338 -0
- data/lib/mxrb/widget_synchronizer.rb +31 -0
- data/lib/mxrb/writer.rb +3775 -0
- data/lib/mxrb.rb +188 -0
- data/marketplace/catalog.json +10 -0
- data/marketplace/modules/shared-kernel/domain/README.md +4 -0
- data/marketplace/modules/shared-kernel/module.rb +5 -0
- data/marketplace/modules/shared-kernel/mxrb-module.json +9 -0
- 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
|