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,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mxrb
|
|
4
|
+
module Scaffold
|
|
5
|
+
# Applies a set of file creations and replacements with rollback on failure.
|
|
6
|
+
class Transaction
|
|
7
|
+
Change = Data.define(:path, :content, :original, :mode)
|
|
8
|
+
|
|
9
|
+
attr_reader :created, :updated
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@changes = {}
|
|
13
|
+
@created = []
|
|
14
|
+
@updated = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def content(path)
|
|
18
|
+
return @changes.fetch(path).content if @changes.key?(path)
|
|
19
|
+
return File.binread(path) if File.file?(path)
|
|
20
|
+
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create(path, content)
|
|
25
|
+
abort "#{path}: file already exists" if File.exist?(path) || @changes.key?(path)
|
|
26
|
+
|
|
27
|
+
@created << path
|
|
28
|
+
@changes[path] = Change.new(path, content, nil, 0o644)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write(path, new_content)
|
|
32
|
+
original = content(path)
|
|
33
|
+
return create(path, new_content) unless original
|
|
34
|
+
return if original == new_content
|
|
35
|
+
|
|
36
|
+
@updated << path unless @updated.include?(path)
|
|
37
|
+
mode = File.stat(path).mode
|
|
38
|
+
@changes[path] = Change.new(path, new_content, File.binread(path), mode)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def commit
|
|
42
|
+
applied = []
|
|
43
|
+
@changes.each_value do |change|
|
|
44
|
+
apply(change)
|
|
45
|
+
applied << change
|
|
46
|
+
end
|
|
47
|
+
rescue StandardError
|
|
48
|
+
applied.reverse_each { rollback(_1) }
|
|
49
|
+
raise
|
|
50
|
+
ensure
|
|
51
|
+
cleanup_staging
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def apply(change)
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(change.path))
|
|
58
|
+
staging = staging_path(change.path)
|
|
59
|
+
@staging_paths ||= []
|
|
60
|
+
@staging_paths << staging
|
|
61
|
+
File.binwrite(staging, change.content)
|
|
62
|
+
File.chmod(change.mode, staging)
|
|
63
|
+
File.rename(staging, change.path)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def rollback(change)
|
|
67
|
+
if change.original
|
|
68
|
+
File.binwrite(change.path, change.original)
|
|
69
|
+
File.chmod(change.mode, change.path)
|
|
70
|
+
else
|
|
71
|
+
FileUtils.rm_f(change.path)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def staging_path(path)
|
|
76
|
+
File.join(File.dirname(path), ".#{File.basename(path)}.mxrb-#{Process.pid}-#{@changes.size}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cleanup_staging
|
|
80
|
+
Array(@staging_paths).each { FileUtils.rm_f(_1) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mxrb
|
|
4
|
+
module Schema
|
|
5
|
+
# Known .mpr SQLite tables and their purpose.
|
|
6
|
+
# Reverse-engineered from Mendix 9/10 projects + mxcli source inspection.
|
|
7
|
+
# Columns marked (?) are inferred / version-dependent.
|
|
8
|
+
TABLES = {
|
|
9
|
+
"_MetaData" => {
|
|
10
|
+
desc: "Single-row table with project-level metadata",
|
|
11
|
+
columns: %w[MetaDataID MendixVersion ProjectID ProjectName],
|
|
12
|
+
},
|
|
13
|
+
"Unit" => {
|
|
14
|
+
desc: "Every Mendix artefact (module, entity, page, microflow…) is a Unit",
|
|
15
|
+
columns: %w[UnitID ContainerID ContainmentName UnitTypeID ContentsHash Contents],
|
|
16
|
+
},
|
|
17
|
+
"UnitType" => {
|
|
18
|
+
desc: "Lookup table: numeric ID → qualified type name",
|
|
19
|
+
columns: %w[UnitTypeID Name],
|
|
20
|
+
},
|
|
21
|
+
"PreferredHash" => {
|
|
22
|
+
desc: "Stores preferred/last-known hash per unit for change detection",
|
|
23
|
+
columns: %w[UnitID Hash],
|
|
24
|
+
},
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Known UnitType names (qualified Mendix metamodel class names).
|
|
28
|
+
# These appear in UnitType.Name and drive how Contents is decoded.
|
|
29
|
+
UNIT_TYPES = %w[
|
|
30
|
+
Mxmodels.Projects.Project
|
|
31
|
+
Mxmodels.Projects.Module
|
|
32
|
+
Mxmodels.DomainModels.DomainModel
|
|
33
|
+
Mxmodels.DomainModels.Entity
|
|
34
|
+
Mxmodels.DomainModels.Attribute
|
|
35
|
+
Mxmodels.DomainModels.Association
|
|
36
|
+
Mxmodels.Pages.Page
|
|
37
|
+
Mxmodels.Pages.Layout
|
|
38
|
+
Mxmodels.Pages.Snippet
|
|
39
|
+
Mxmodels.Microflows.Microflow
|
|
40
|
+
Mxmodels.Microflows.Nanoflow
|
|
41
|
+
Mxmodels.Microflows.Rule
|
|
42
|
+
Mxmodels.Settings.ProjectSettings
|
|
43
|
+
Mxmodels.Settings.RuntimeSettings
|
|
44
|
+
Mxmodels.Security.ProjectSecurity
|
|
45
|
+
Mxmodels.Security.ModuleSecurity
|
|
46
|
+
Mxmodels.Texts.SystemTextCollection
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# Attribute types as used in the serialized Contents blob.
|
|
50
|
+
ATTRIBUTE_TYPES = %w[
|
|
51
|
+
AutoNumber Boolean Currency DateTime Decimal
|
|
52
|
+
Enum Float HashString Integer Long String
|
|
53
|
+
].freeze
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mxrb
|
|
4
|
+
module Semantic
|
|
5
|
+
Diagnostic = Data.define(:rule, :severity, :message, :artifacts, :metadata)
|
|
6
|
+
CallCycle = Data.define(:artifacts, :references)
|
|
7
|
+
ModuleDependency = Data.define(:from, :to, :references, :source_artifacts)
|
|
8
|
+
|
|
9
|
+
Report = Data.define(
|
|
10
|
+
:diagnostics, :unreferenced, :unresolved_references, :call_cycles,
|
|
11
|
+
:module_dependencies
|
|
12
|
+
) do
|
|
13
|
+
def errors = diagnostics.select { _1.severity == :error }
|
|
14
|
+
def warnings = diagnostics.select { _1.severity == :warning }
|
|
15
|
+
def valid? = errors.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Static analysis for an opened Mendix project. The result is plain,
|
|
19
|
+
# immutable Ruby data and can be filtered or extended without a query DSL.
|
|
20
|
+
class Analyzer
|
|
21
|
+
DEFAULT_UNREFERENCED_KINDS = %i[microflow nanoflow workflow page].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(project)
|
|
24
|
+
@project = project
|
|
25
|
+
@index = project.semantic_index
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def analyze(unreferenced_kinds: DEFAULT_UNREFERENCED_KINDS, rules: [])
|
|
29
|
+
cycles = call_cycles
|
|
30
|
+
unreferenced = unreferenced_artifacts(unreferenced_kinds)
|
|
31
|
+
unresolved = unresolved_references
|
|
32
|
+
diagnostics = cycle_diagnostics(cycles) +
|
|
33
|
+
unresolved_diagnostics(unresolved) +
|
|
34
|
+
unreferenced_diagnostics(unreferenced) +
|
|
35
|
+
quality_diagnostics +
|
|
36
|
+
custom_diagnostics(rules)
|
|
37
|
+
Report.new(
|
|
38
|
+
diagnostics.sort_by { [_1.severity == :error ? 0 : 1, _1.message] }.freeze,
|
|
39
|
+
unreferenced.freeze,
|
|
40
|
+
unresolved.freeze,
|
|
41
|
+
cycles.freeze,
|
|
42
|
+
module_dependencies.freeze
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def unreferenced_artifacts(kinds)
|
|
49
|
+
selected_kinds = kinds.map(&:to_sym)
|
|
50
|
+
incoming = @index.references.each_with_object(Set.new) { |ref, ids| ids << ref.target.id }
|
|
51
|
+
@index.artifacts.select do |artifact|
|
|
52
|
+
selected_kinds.include?(artifact.kind) &&
|
|
53
|
+
!incoming.include?(artifact.id) &&
|
|
54
|
+
!entry_point?(artifact)
|
|
55
|
+
end.sort_by(&:qualified_name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def entry_point?(artifact)
|
|
59
|
+
artifact.metadata[:excluded] == true ||
|
|
60
|
+
artifact.metadata[:mark_as_used] == true ||
|
|
61
|
+
artifact.metadata[:exposed] == true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def unresolved_references
|
|
65
|
+
return [] unless @index.respond_to?(:unresolved_references)
|
|
66
|
+
|
|
67
|
+
@index.unresolved_references.reject do |reference|
|
|
68
|
+
platform_reference?(reference) || reference.source.metadata[:excluded] == true
|
|
69
|
+
end.sort_by do |reference|
|
|
70
|
+
[reference.source.qualified_name, reference.path, reference.qualified_name]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def platform_reference?(reference)
|
|
75
|
+
reference.qualified_name.start_with?("System.")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def call_cycles
|
|
79
|
+
call_references = @index.references.select { _1.relation == :calls }
|
|
80
|
+
adjacency = Hash.new { |hash, key| hash[key] = [] }
|
|
81
|
+
artifacts = {}
|
|
82
|
+
call_references.each do |reference|
|
|
83
|
+
adjacency[reference.source.id] << reference.target.id
|
|
84
|
+
artifacts[reference.source.id] = reference.source
|
|
85
|
+
artifacts[reference.target.id] = reference.target
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
strongly_connected(adjacency, artifacts.keys).filter_map do |ids|
|
|
89
|
+
cyclic = ids.length > 1 || adjacency[ids.first].include?(ids.first)
|
|
90
|
+
next unless cyclic
|
|
91
|
+
|
|
92
|
+
id_set = ids.to_set
|
|
93
|
+
references = call_references.select do |reference|
|
|
94
|
+
id_set.include?(reference.source.id) && id_set.include?(reference.target.id)
|
|
95
|
+
end
|
|
96
|
+
CallCycle.new(
|
|
97
|
+
ids.map { artifacts.fetch(_1) }.sort_by(&:qualified_name).freeze,
|
|
98
|
+
references.freeze
|
|
99
|
+
)
|
|
100
|
+
end.sort_by { _1.artifacts.map(&:qualified_name) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def strongly_connected(adjacency, nodes)
|
|
104
|
+
index = 0
|
|
105
|
+
indexes = {}
|
|
106
|
+
low_links = {}
|
|
107
|
+
stack = []
|
|
108
|
+
on_stack = Set.new
|
|
109
|
+
components = []
|
|
110
|
+
|
|
111
|
+
visit = lambda do |node|
|
|
112
|
+
indexes[node] = index
|
|
113
|
+
low_links[node] = index
|
|
114
|
+
index += 1
|
|
115
|
+
stack << node
|
|
116
|
+
on_stack << node
|
|
117
|
+
|
|
118
|
+
adjacency[node].each do |target|
|
|
119
|
+
unless indexes.key?(target)
|
|
120
|
+
visit.call(target)
|
|
121
|
+
low_links[node] = [low_links[node], low_links[target]].min
|
|
122
|
+
next
|
|
123
|
+
end
|
|
124
|
+
low_links[node] = [low_links[node], indexes[target]].min if on_stack.include?(target)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
return unless low_links[node] == indexes[node]
|
|
128
|
+
|
|
129
|
+
component = []
|
|
130
|
+
loop do
|
|
131
|
+
member = stack.pop
|
|
132
|
+
on_stack.delete(member)
|
|
133
|
+
component << member
|
|
134
|
+
break if member == node
|
|
135
|
+
end
|
|
136
|
+
components << component
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
nodes.each { visit.call(_1) unless indexes.key?(_1) }
|
|
140
|
+
components
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def module_dependencies
|
|
144
|
+
grouped = @index.references.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |ref, groups|
|
|
145
|
+
from = ref.source.module_name
|
|
146
|
+
to = ref.target.module_name
|
|
147
|
+
groups[[from, to]] << ref if from && to && from != to
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
grouped.map do |(from, to), references|
|
|
151
|
+
ModuleDependency.new(
|
|
152
|
+
from, to, references.freeze,
|
|
153
|
+
references.map(&:source).uniq(&:id).sort_by(&:qualified_name).freeze
|
|
154
|
+
)
|
|
155
|
+
end.sort_by { [_1.from, _1.to] }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def cycle_diagnostics(cycles)
|
|
159
|
+
cycles.map do |cycle|
|
|
160
|
+
names = cycle.artifacts.map(&:qualified_name)
|
|
161
|
+
Diagnostic.new(
|
|
162
|
+
:call_cycle, :error,
|
|
163
|
+
"call cycle: #{names.join(' -> ')} -> #{names.first}",
|
|
164
|
+
cycle.artifacts, { references: cycle.references.size }.freeze
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def unreferenced_diagnostics(artifacts)
|
|
170
|
+
artifacts.map do |artifact|
|
|
171
|
+
Diagnostic.new(
|
|
172
|
+
:unreferenced, :warning,
|
|
173
|
+
"unreferenced #{artifact.kind}: #{artifact.qualified_name}",
|
|
174
|
+
[artifact].freeze, {}.freeze
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def unresolved_diagnostics(references)
|
|
180
|
+
references.map do |reference|
|
|
181
|
+
external = external_reference?(reference)
|
|
182
|
+
Diagnostic.new(
|
|
183
|
+
external ? :external_reference : :unresolved_reference,
|
|
184
|
+
external ? :warning : :error,
|
|
185
|
+
"#{reference.source.qualified_name} references #{external ? 'external' : 'missing'} " \
|
|
186
|
+
"#{reference.qualified_name} at #{reference.path.join('.')}",
|
|
187
|
+
[reference.source].freeze,
|
|
188
|
+
{
|
|
189
|
+
target: reference.qualified_name,
|
|
190
|
+
expected_kinds: reference.expected_kinds,
|
|
191
|
+
path: reference.path
|
|
192
|
+
}.freeze
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def external_reference?(reference)
|
|
198
|
+
target_module = reference.qualified_name.split(".").first
|
|
199
|
+
@index.artifacts.none? { _1.module_name == target_module }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def quality_diagnostics
|
|
203
|
+
security = project_security
|
|
204
|
+
definition = if @project.respond_to?(:architecture_definition)
|
|
205
|
+
@project.architecture_definition || {}
|
|
206
|
+
else
|
|
207
|
+
{}
|
|
208
|
+
end
|
|
209
|
+
definition = definition.merge(security: native_security_definition(security)) \
|
|
210
|
+
unless definition[:security]
|
|
211
|
+
persistent_access_diagnostics(security) +
|
|
212
|
+
secured_artifact_diagnostics(security) +
|
|
213
|
+
public_contract_diagnostics(definition) +
|
|
214
|
+
navigation_diagnostics(definition) +
|
|
215
|
+
duplicate_role_diagnostics(definition) +
|
|
216
|
+
design_system_diagnostics(definition)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def native_security_definition(security)
|
|
220
|
+
roles = IO::BsonCodec.parse_array(security&.fetch("UserRoles", nil))[:items]
|
|
221
|
+
{
|
|
222
|
+
user_roles: roles.map do |role|
|
|
223
|
+
{
|
|
224
|
+
name: role["Name"],
|
|
225
|
+
module_roles: IO::BsonCodec.parse_array(role["ModuleRoles"])[:items]
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def project_security
|
|
232
|
+
return nil unless @project.respond_to?(:all_units) && @project.respond_to?(:parse_bson)
|
|
233
|
+
|
|
234
|
+
raw = @project.all_units.find do |unit|
|
|
235
|
+
@project.parse_bson(unit)["$Type"] == "Security$ProjectSecurity"
|
|
236
|
+
end
|
|
237
|
+
raw && @project.parse_bson(raw)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def strict_security?(security)
|
|
241
|
+
security && security["SecurityLevel"] == "CheckEverything"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def persistent_access_diagnostics(security)
|
|
245
|
+
return [] unless strict_security?(security)
|
|
246
|
+
|
|
247
|
+
@index.artifacts.filter_map do |artifact|
|
|
248
|
+
next unless artifact.kind == :entity
|
|
249
|
+
|
|
250
|
+
entity = artifact.metadata[:model]
|
|
251
|
+
next unless entity
|
|
252
|
+
next unless entity.persistable != false && Array(entity.access_rules).empty?
|
|
253
|
+
|
|
254
|
+
Diagnostic.new(
|
|
255
|
+
:persistent_entity_without_access, :warning,
|
|
256
|
+
"persistent entity has no access rules: #{artifact.qualified_name}",
|
|
257
|
+
[artifact].freeze, {}.freeze
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def secured_artifact_diagnostics(security)
|
|
263
|
+
return [] unless strict_security?(security)
|
|
264
|
+
|
|
265
|
+
@index.artifacts.filter_map do |artifact|
|
|
266
|
+
next unless %i[page microflow nanoflow].include?(artifact.kind)
|
|
267
|
+
next if artifact.metadata[:excluded] == true
|
|
268
|
+
next unless Array(artifact.metadata[:allowed_roles]).empty?
|
|
269
|
+
|
|
270
|
+
Diagnostic.new(
|
|
271
|
+
:secured_artifact_without_roles, :warning,
|
|
272
|
+
"secured #{artifact.kind} has no allowed module roles: #{artifact.qualified_name}",
|
|
273
|
+
[artifact].freeze, {}.freeze
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def public_contract_diagnostics(definition)
|
|
279
|
+
Array(definition[:modules]).flat_map do |mod|
|
|
280
|
+
%i[microflows nanoflows repositories].flat_map do |collection|
|
|
281
|
+
Array(mod[collection]).filter_map do |item|
|
|
282
|
+
next unless item[:public] == true
|
|
283
|
+
next unless item[:documentation].to_s.empty?
|
|
284
|
+
|
|
285
|
+
artifact = @index.find("#{mod[:name]}.#{item[:name]}")
|
|
286
|
+
Diagnostic.new(
|
|
287
|
+
:public_contract_without_documentation, :warning,
|
|
288
|
+
"public contract has no documentation: #{mod[:name]}.#{item[:name]}",
|
|
289
|
+
Array(artifact).freeze, { kind: collection.to_s.sub(/s\z/, "").to_sym }.freeze
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def navigation_diagnostics(definition)
|
|
297
|
+
fallback = @project.navigation.to_h[:profiles] if @project.respond_to?(:navigation)
|
|
298
|
+
profiles = Array(definition.dig(:navigation, :profiles) || fallback)
|
|
299
|
+
duplicate_names = profiles.map { _1[:name].to_s }.tally
|
|
300
|
+
diagnostics = profiles.filter_map do |profile|
|
|
301
|
+
next unless duplicate_names.fetch(profile[:name].to_s) > 1
|
|
302
|
+
|
|
303
|
+
duplicate_names[profile[:name].to_s] = 0
|
|
304
|
+
Diagnostic.new(
|
|
305
|
+
:duplicate_navigation_profile, :error,
|
|
306
|
+
"duplicate navigation profile: #{profile[:name]}",
|
|
307
|
+
[].freeze, { profile: profile[:name] }.freeze
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
diagnostics + profiles.flat_map { profile_navigation_diagnostics(_1, definition) }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def profile_navigation_diagnostics(profile, definition)
|
|
314
|
+
targets = navigation_targets(profile)
|
|
315
|
+
missing = targets.filter_map do |field, target|
|
|
316
|
+
next if target.to_s.empty? || @index.find(target)
|
|
317
|
+
|
|
318
|
+
Diagnostic.new(
|
|
319
|
+
:missing_navigation_target, :error,
|
|
320
|
+
"navigation profile #{profile[:name]} references missing #{field}: #{target}",
|
|
321
|
+
[].freeze, { profile: profile[:name], field:, target: }.freeze
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
missing +
|
|
325
|
+
navigation_home_diagnostics(profile) +
|
|
326
|
+
navigation_role_diagnostics(profile, definition) +
|
|
327
|
+
navigation_role_access_diagnostics(profile, definition)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def navigation_targets(profile)
|
|
331
|
+
targets = profile.values_at(:home_page, :home_microflow, :sign_in_page, :menu)
|
|
332
|
+
.then { %i[home_page home_microflow sign_in_page menu].zip(_1).to_h }
|
|
333
|
+
role_homes = profile.fetch(:role_homes, {})
|
|
334
|
+
homes = role_homes.is_a?(Hash) ? role_homes.map { |role, page| { role:, page: } } : role_homes
|
|
335
|
+
homes += Array(profile[:role_home_details])
|
|
336
|
+
homes.each { targets["role_home:#{_1[:role]}"] = _1[:page] || _1[:microflow] }
|
|
337
|
+
targets
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def navigation_home_diagnostics(profile)
|
|
341
|
+
return [] if profile[:home_page] || profile[:home_microflow]
|
|
342
|
+
|
|
343
|
+
[Diagnostic.new(
|
|
344
|
+
:navigation_profile_without_home, :error,
|
|
345
|
+
"navigation profile has no default home: #{profile[:name]}",
|
|
346
|
+
[].freeze, { profile: profile[:name] }.freeze
|
|
347
|
+
)]
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def navigation_role_diagnostics(profile, definition)
|
|
351
|
+
known = Array(definition.dig(:security, :user_roles)).map { _1[:name].to_s }
|
|
352
|
+
role_homes = profile.fetch(:role_homes, {})
|
|
353
|
+
roles = if role_homes.is_a?(Hash)
|
|
354
|
+
role_homes.keys
|
|
355
|
+
else
|
|
356
|
+
role_homes.map { _1[:role] }
|
|
357
|
+
end
|
|
358
|
+
roles += Array(profile[:role_home_details]).map { _1[:role] }
|
|
359
|
+
roles.uniq.filter_map do |role|
|
|
360
|
+
next if known.include?(role.to_s)
|
|
361
|
+
|
|
362
|
+
Diagnostic.new(
|
|
363
|
+
:unknown_navigation_role, :error,
|
|
364
|
+
"navigation profile #{profile[:name]} uses unknown user role: #{role}",
|
|
365
|
+
[].freeze, { profile: profile[:name], role: role.to_s }.freeze
|
|
366
|
+
)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def navigation_role_access_diagnostics(profile, definition)
|
|
371
|
+
roles = Array(definition.dig(:security, :user_roles)).to_h do |role|
|
|
372
|
+
[role[:name].to_s, Array(role[:module_roles]).map(&:to_s)]
|
|
373
|
+
end
|
|
374
|
+
navigation_role_homes(profile).filter_map do |home|
|
|
375
|
+
target = @index.find(home[:page] || home[:microflow])
|
|
376
|
+
allowed = target && Array(target.metadata[:allowed_roles])
|
|
377
|
+
next if !target || allowed.empty? || !roles.key?(home[:role].to_s)
|
|
378
|
+
|
|
379
|
+
qualified = allowed.map do |role|
|
|
380
|
+
role.include?('.') ? role : "#{target.module_name}.#{role}"
|
|
381
|
+
end
|
|
382
|
+
next unless (qualified & roles.fetch(home[:role].to_s)).empty?
|
|
383
|
+
|
|
384
|
+
Diagnostic.new(
|
|
385
|
+
:inaccessible_navigation_home, :error,
|
|
386
|
+
"user role #{home[:role]} cannot access navigation home #{target.qualified_name}",
|
|
387
|
+
[target].freeze, { role: home[:role].to_s, target: target.qualified_name }.freeze
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def navigation_role_homes(profile)
|
|
393
|
+
homes = profile.fetch(:role_homes, {})
|
|
394
|
+
homes = homes.map { |role, page| { role:, page: } } if homes.is_a?(Hash)
|
|
395
|
+
homes + Array(profile[:role_home_details])
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def design_system_diagnostics(definition)
|
|
399
|
+
return [] unless @project.respond_to?(:design_system)
|
|
400
|
+
|
|
401
|
+
diagnostics = @project.design_system.unresolved_references.map do |entry|
|
|
402
|
+
token = entry.fetch(:token)
|
|
403
|
+
Diagnostic.new(
|
|
404
|
+
:unresolved_design_token, :error,
|
|
405
|
+
"#{token.path}:#{token.line} references missing design token #{entry.fetch(:reference)}",
|
|
406
|
+
[].freeze, entry.freeze
|
|
407
|
+
)
|
|
408
|
+
end
|
|
409
|
+
diagnostics.concat(contrast_diagnostics(definition))
|
|
410
|
+
return diagnostics unless definition.dig(:design_system, :forbid_literal_colors)
|
|
411
|
+
|
|
412
|
+
diagnostics + @project.design_system.literal_colors.map do |token|
|
|
413
|
+
Diagnostic.new(
|
|
414
|
+
:literal_design_color, :warning,
|
|
415
|
+
"#{token.path}:#{token.line} uses literal color #{token.value}",
|
|
416
|
+
[].freeze, { token: token.name, value: token.value }.freeze
|
|
417
|
+
)
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def contrast_diagnostics(definition)
|
|
422
|
+
design = definition[:design_system] || {}
|
|
423
|
+
tokens = Array(design[:tokens]) +
|
|
424
|
+
Array(design[:themes]).flat_map { Array(_1[:tokens]) }
|
|
425
|
+
values = tokens.to_h { [_1[:name].to_s, _1[:value].to_s] }
|
|
426
|
+
Array(design[:contrast_pairs]).filter_map do |pair|
|
|
427
|
+
foreground = values.fetch(pair[:foreground].to_s, pair[:foreground].to_s)
|
|
428
|
+
background = values.fetch(pair[:background].to_s, pair[:background].to_s)
|
|
429
|
+
ratio = @project.design_system.contrast_ratio(foreground, background)
|
|
430
|
+
required = pair[:level].to_s.downcase == "aaa" ? 7.0 : 4.5
|
|
431
|
+
next if ratio >= required
|
|
432
|
+
|
|
433
|
+
Diagnostic.new(
|
|
434
|
+
:insufficient_color_contrast, :error,
|
|
435
|
+
"color contrast #{ratio}:1 is below #{pair[:level].to_s.upcase} (#{required}:1)",
|
|
436
|
+
[].freeze, pair.merge(ratio:, required:).freeze
|
|
437
|
+
)
|
|
438
|
+
rescue ArgumentError
|
|
439
|
+
Diagnostic.new(
|
|
440
|
+
:invalid_contrast_color, :error,
|
|
441
|
+
"contrast pair uses a non-hex color: #{foreground} / #{background}",
|
|
442
|
+
[].freeze, pair.freeze
|
|
443
|
+
)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def duplicate_role_diagnostics(definition)
|
|
448
|
+
roles = Array(definition.dig(:security, :user_roles))
|
|
449
|
+
roles.filter_map do |role|
|
|
450
|
+
module_roles = Array(role[:module_roles])
|
|
451
|
+
duplicates = module_roles.tally.select { |_name, count| count > 1 }.keys.sort
|
|
452
|
+
next if duplicates.empty?
|
|
453
|
+
|
|
454
|
+
Diagnostic.new(
|
|
455
|
+
:duplicate_module_role, :warning,
|
|
456
|
+
"user role #{role[:name]} maps duplicate module roles: #{duplicates.join(', ')}",
|
|
457
|
+
[].freeze, { user_role: role[:name], module_roles: duplicates.freeze }.freeze
|
|
458
|
+
)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def custom_diagnostics(rules)
|
|
463
|
+
rules.flat_map do |rule|
|
|
464
|
+
diagnostics = Array(rule.call(@project, @index))
|
|
465
|
+
unless diagnostics.all? { _1.is_a?(Diagnostic) }
|
|
466
|
+
raise ArgumentError, "custom semantic rules must return Mxrb::Semantic::Diagnostic"
|
|
467
|
+
end
|
|
468
|
+
diagnostics
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Mxrb
|
|
6
|
+
module Semantic
|
|
7
|
+
# Wraps multiple plans into a single atomic operation with rollback on failure.
|
|
8
|
+
#
|
|
9
|
+
# Before applying, a backup of the MPR is taken using SQLite's VACUUM INTO.
|
|
10
|
+
# If any plan raises, the backup is restored and the project state is reset
|
|
11
|
+
# as if the batch never ran.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# plan1 = project.plan_rename("M.OldName", to: "M.NewName")
|
|
15
|
+
# plan2 = project.plan_add_attribute("M.NewName", name: :Slug, type: :string)
|
|
16
|
+
# project.batch_plan([plan1, plan2]).apply!
|
|
17
|
+
class BatchPlan
|
|
18
|
+
attr_reader :plans, :changes
|
|
19
|
+
|
|
20
|
+
def initialize(project:, plans:)
|
|
21
|
+
@project = project
|
|
22
|
+
@plans = plans.freeze
|
|
23
|
+
@applied = false
|
|
24
|
+
@changes = @plans.flat_map { |p| p.respond_to?(:changes) ? Array(p.changes) : [] }.freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def empty? = @plans.empty?
|
|
28
|
+
def applied? = @applied
|
|
29
|
+
|
|
30
|
+
def apply!
|
|
31
|
+
raise ArgumentError, "batch plan was already applied" if @applied
|
|
32
|
+
return self if @plans.empty?
|
|
33
|
+
|
|
34
|
+
backup_path = "#{@project.mpr.path}.mxrb_batch_backup"
|
|
35
|
+
@project.mpr.backup!(backup_path)
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
@plans.each(&:apply!)
|
|
39
|
+
@applied = true
|
|
40
|
+
rescue => error
|
|
41
|
+
idx = @plans.index { !_1.applied? }.to_i + 1
|
|
42
|
+
rollback_errors = rollback_applied_plans
|
|
43
|
+
@project.mpr.restore_from!(backup_path)
|
|
44
|
+
@project.refresh!
|
|
45
|
+
detail = rollback_errors.empty? ? "" : "; rollback failed: #{rollback_errors.join(', ')}"
|
|
46
|
+
raise BatchError,
|
|
47
|
+
"batch failed at plan #{idx} of #{@plans.size}: #{error.message}#{detail}"
|
|
48
|
+
ensure
|
|
49
|
+
FileUtils.rm_f(backup_path) rescue nil
|
|
50
|
+
end
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def rollback_applied_plans
|
|
57
|
+
@plans.reverse_each.filter_map do |plan|
|
|
58
|
+
next unless plan.respond_to?(:rollback!) && plan.applied?
|
|
59
|
+
|
|
60
|
+
plan.rollback!
|
|
61
|
+
nil
|
|
62
|
+
rescue StandardError => error
|
|
63
|
+
"#{plan.class}: #{error.message}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|