rails-mmd 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/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +82 -0
- data/SECURITY.md +40 -0
- data/docs/active-record-association-support.md +37 -0
- data/docs/assets/branding/rails-mmd-social-preview.jpg +0 -0
- data/docs/usage.md +126 -0
- data/exe/rails-mmd +6 -0
- data/fixtures/schemas/diagnostics/valid/catalog.json +39 -0
- data/lib/rails_mmd/artifact_ref_sanitizer.rb +59 -0
- data/lib/rails_mmd/attribute_types.rb +29 -0
- data/lib/rails_mmd/canonical_json.rb +19 -0
- data/lib/rails_mmd/cli.rb +86 -0
- data/lib/rails_mmd/config.rb +249 -0
- data/lib/rails_mmd/constant_resolver.rb +33 -0
- data/lib/rails_mmd/diagnostic_factory.rb +122 -0
- data/lib/rails_mmd/domain_resolver.rb +97 -0
- data/lib/rails_mmd/exit_policy.rb +27 -0
- data/lib/rails_mmd/generate_command.rb +116 -0
- data/lib/rails_mmd/generation_pipeline.rb +109 -0
- data/lib/rails_mmd/ir_builder.rb +122 -0
- data/lib/rails_mmd/mermaid_serializer.rb +168 -0
- data/lib/rails_mmd/metadata_shapes.rb +55 -0
- data/lib/rails_mmd/model_inventory.rb +144 -0
- data/lib/rails_mmd/ordering.rb +25 -0
- data/lib/rails_mmd/output_directory.rb +192 -0
- data/lib/rails_mmd/publisher.rb +166 -0
- data/lib/rails_mmd/rails_loader.rb +96 -0
- data/lib/rails_mmd/redactor.rb +143 -0
- data/lib/rails_mmd/relationship_builder.rb +982 -0
- data/lib/rails_mmd/render_plan_builder.rb +192 -0
- data/lib/rails_mmd/safe_token_assigner.rb +130 -0
- data/lib/rails_mmd/schema_probe.rb +376 -0
- data/lib/rails_mmd/schema_validator.rb +136 -0
- data/lib/rails_mmd/subject_ids.rb +17 -0
- data/lib/rails_mmd/tooling_tasks.rb +47 -0
- data/lib/rails_mmd/verification_runner.rb +143 -0
- data/lib/rails_mmd/version.rb +5 -0
- data/lib/rails_mmd.rb +15 -0
- data/schemas/config.schema.json +54 -0
- data/schemas/diagnostics.schema.json +418 -0
- data/schemas/ir.schema.json +84 -0
- data/schemas/render_plan.schema.json +136 -0
- metadata +135 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_mmd/constant_resolver'
|
|
4
|
+
require 'rails_mmd/domain_resolver'
|
|
5
|
+
require 'rails_mmd/diagnostic_factory'
|
|
6
|
+
require 'rails_mmd/ir_builder'
|
|
7
|
+
require 'rails_mmd/mermaid_serializer'
|
|
8
|
+
require 'rails_mmd/model_inventory'
|
|
9
|
+
require 'rails_mmd/relationship_builder'
|
|
10
|
+
require 'rails_mmd/render_plan_builder'
|
|
11
|
+
require 'rails_mmd/redactor'
|
|
12
|
+
require 'rails_mmd/schema_probe'
|
|
13
|
+
|
|
14
|
+
module RailsMmd
|
|
15
|
+
# Builds every selected-domain artifact behind one in-process interface.
|
|
16
|
+
class GenerationPipeline
|
|
17
|
+
Result = Struct.new(:diagnostics, :artifacts, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
def initialize(active_record_base: nil, constant_resolver: ConstantResolver.new, redactor: Redactor.new,
|
|
20
|
+
render_plan_builder: nil,
|
|
21
|
+
mermaid_serializer: nil)
|
|
22
|
+
@active_record_base = active_record_base
|
|
23
|
+
@constant_resolver = ConstantResolver.wrap(constant_resolver)
|
|
24
|
+
@redactor = redactor
|
|
25
|
+
@diagnostic_factory = DiagnosticFactory.new(redactor: redactor)
|
|
26
|
+
@render_plan_builder = render_plan_builder || RenderPlanBuilder.new(redactor: redactor)
|
|
27
|
+
@mermaid_serializer = mermaid_serializer || MermaidSerializer.new(diagnostics: @diagnostic_factory)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def generate(config:)
|
|
31
|
+
domain_resolution, schema_probe_result, relationship_result, ir_payload = build_stage_results(config)
|
|
32
|
+
diagnostics = domain_resolution.diagnostics + schema_probe_result.diagnostics + relationship_result.diagnostics
|
|
33
|
+
artifacts = render_artifacts(config, ir_payload, diagnostics)
|
|
34
|
+
Result.new(diagnostics: diagnostics, artifacts: artifacts)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :active_record_base, :constant_resolver, :diagnostic_factory, :mermaid_serializer, :redactor,
|
|
40
|
+
:render_plan_builder
|
|
41
|
+
|
|
42
|
+
def build_stage_results(config)
|
|
43
|
+
domain_resolution = resolve_domains(config)
|
|
44
|
+
schema_probe_result = probe_domains(domain_resolution)
|
|
45
|
+
relationship_result = build_relationships(schema_probe_result)
|
|
46
|
+
ir_payload = build_ir(config, schema_probe_result, relationship_result)
|
|
47
|
+
[domain_resolution, schema_probe_result, relationship_result, ir_payload]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_domains(config)
|
|
51
|
+
inventory = ModelInventory.new(
|
|
52
|
+
active_record_base: resolved_active_record_base, constant_resolver: constant_resolver, redactor: redactor
|
|
53
|
+
)
|
|
54
|
+
DomainResolver.new(diagnostics: diagnostic_factory).resolve(config: config, inventory_records: inventory.records)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def probe_domains(domain_resolution)
|
|
58
|
+
SchemaProbe.new(constant_resolver: constant_resolver, diagnostics: diagnostic_factory, redactor: redactor)
|
|
59
|
+
.probe(domains: domain_resolution.domains)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_relationships(schema_probe_result)
|
|
63
|
+
RelationshipBuilder.new(constant_resolver: constant_resolver, diagnostics: diagnostic_factory)
|
|
64
|
+
.build(domains: schema_probe_result.domains)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_ir(config, schema_probe_result, relationship_result)
|
|
68
|
+
IrBuilder.new(attributes: config.output.attributes).build(
|
|
69
|
+
domains: schema_probe_result.domains, relationship_domains: relationship_result.domains
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def render_artifacts(config, ir_payload, diagnostics)
|
|
74
|
+
ir_payload.domains.to_h do |domain|
|
|
75
|
+
[domain.domain_id, render_domain_artifacts(config, domain, diagnostics)]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_domain_artifacts(config, domain, diagnostics)
|
|
80
|
+
artifact_kinds(config).each_with_object({}) do |kind, artifacts|
|
|
81
|
+
render_plan_result = render_plan_for(config, domain, kind, diagnostics)
|
|
82
|
+
diagnostics.concat(render_plan_result.diagnostics)
|
|
83
|
+
serialization_result = mermaid_serializer.serialize(render_plan: render_plan_result.payload)
|
|
84
|
+
diagnostics.concat(serialization_result.diagnostics)
|
|
85
|
+
next unless serialization_result.diagnostics.empty?
|
|
86
|
+
|
|
87
|
+
artifacts[kind] = { render_plan: render_plan_result.payload, mermaid: serialization_result.text }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render_plan_for(config, domain, kind, diagnostics)
|
|
92
|
+
render_plan_builder.build(
|
|
93
|
+
ir: domain.payload,
|
|
94
|
+
artifact_kind: kind,
|
|
95
|
+
direction: config.output.direction,
|
|
96
|
+
attributes: config.output.attributes,
|
|
97
|
+
available_diagnostic_ids: diagnostics.map { |diagnostic| diagnostic.fetch('diagnostic_id') }
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def artifact_kinds(config)
|
|
102
|
+
config.output.format == 'both' ? %w[er class] : [config.output.format]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def resolved_active_record_base
|
|
106
|
+
active_record_base || Object.const_get(:ActiveRecord).const_get(:Base)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_mmd/canonical_json'
|
|
4
|
+
|
|
5
|
+
module RailsMmd
|
|
6
|
+
# Normalizes selected schema entities and relationships into closed IR payloads.
|
|
7
|
+
class IrBuilder
|
|
8
|
+
DomainResult = Struct.new(:domain_id, :payload, keyword_init: true)
|
|
9
|
+
Result = Struct.new(:domains, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def initialize(attributes: :keys)
|
|
12
|
+
@attributes = attributes.to_sym
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build(domains:, relationship_domains:)
|
|
16
|
+
relationships_by_domain = relationship_domains.to_h { |domain| [domain.domain_id, domain] }
|
|
17
|
+
results = domains.map do |domain|
|
|
18
|
+
relationship_domain = relationships_by_domain.fetch(domain.domain_id, nil)
|
|
19
|
+
DomainResult.new(domain_id: domain.domain_id, payload: payload_for(domain, relationship_domain))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Result.new(domains: results)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :attributes
|
|
28
|
+
|
|
29
|
+
def payload_for(domain, relationship_domain)
|
|
30
|
+
payload = payload_without_digest(domain, relationship_domain)
|
|
31
|
+
payload.merge('digest_sha256' => CanonicalJson.digest_sha256(payload))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def payload_without_digest(domain, relationship_domain)
|
|
35
|
+
{
|
|
36
|
+
'schema_version' => 1,
|
|
37
|
+
'domain_id' => domain.domain_id,
|
|
38
|
+
'entities' => entities_payload(domain, relationship_domain),
|
|
39
|
+
'relationships' => relationships_payload(relationship_domain),
|
|
40
|
+
'diagnostic_ids' => diagnostic_ids(domain, relationship_domain),
|
|
41
|
+
'digest_sha256' => nil
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def entities_payload(domain, relationship_domain)
|
|
46
|
+
payloads = domain.entities.map { |entity| entity_payload(entity, relationship_domain) }
|
|
47
|
+
|
|
48
|
+
payloads.sort_by { |entity| entity.fetch('entity_id') }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def entity_payload(entity, relationship_domain)
|
|
52
|
+
{
|
|
53
|
+
'entity_id' => entity_id(entity),
|
|
54
|
+
'ruby_constant' => entity.ruby_constant,
|
|
55
|
+
'table_name' => entity.table_name,
|
|
56
|
+
'attributes' => attributes_payload(entity, relationship_domain)
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def attributes_payload(entity, relationship_domain)
|
|
61
|
+
return [] if attributes == :none
|
|
62
|
+
|
|
63
|
+
names = [[entity.primary_key, 'primary_key']]
|
|
64
|
+
names.concat(foreign_key_names(entity, relationship_domain).map { |name| [name, 'foreign_key'] })
|
|
65
|
+
payloads = names.compact.uniq(&:first).map do |name, role|
|
|
66
|
+
{ 'attribute_id' => "#{entity_id(entity)}/attributes/#{name}", 'name' => name, 'role' => role,
|
|
67
|
+
'type' => column_type(entity, name) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
payloads.sort_by do |attribute|
|
|
71
|
+
[attribute.fetch('role') == 'primary_key' ? 0 : 1, attribute.fetch('attribute_id')]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def foreign_key_names(entity, relationship_domain)
|
|
76
|
+
return [] unless relationship_domain
|
|
77
|
+
|
|
78
|
+
relationship_domain.relationships.flat_map do |relationship|
|
|
79
|
+
next [] unless relationship.owner_entity_id == entity_id(entity)
|
|
80
|
+
|
|
81
|
+
[relationship.foreign_key_column, relationship.foreign_type_column].compact
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def column_type(entity, name)
|
|
86
|
+
entity.columns.find { |column| column.name == name }&.type&.to_s || 'unknown'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def relationships_payload(relationship_domain)
|
|
90
|
+
return [] unless relationship_domain
|
|
91
|
+
|
|
92
|
+
payloads = relationship_domain.relationships.map { |relationship| relationship_payload(relationship) }
|
|
93
|
+
|
|
94
|
+
payloads.sort_by { |relationship| relationship.fetch('relationship_id') }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def relationship_payload(relationship)
|
|
98
|
+
{
|
|
99
|
+
'relationship_id' => relationship.relationship_id,
|
|
100
|
+
'owner_entity_id' => relationship.owner_entity_id,
|
|
101
|
+
'target_entity_id' => relationship.target_entity_id,
|
|
102
|
+
'association_name' => relationship.association_name,
|
|
103
|
+
'owner_cardinality' => relationship.owner_cardinality,
|
|
104
|
+
'target_cardinality' => relationship.target_cardinality
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def diagnostic_ids(domain, relationship_domain)
|
|
109
|
+
ids = domain.diagnostics.map { |diagnostic| diagnostic.fetch('diagnostic_id') }
|
|
110
|
+
if relationship_domain
|
|
111
|
+
ids.concat(relationship_domain.diagnostics.map do |diagnostic|
|
|
112
|
+
diagnostic.fetch('diagnostic_id')
|
|
113
|
+
end)
|
|
114
|
+
end
|
|
115
|
+
ids.uniq.sort
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def entity_id(entity)
|
|
119
|
+
"entities/#{entity.table_name}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_mmd/diagnostic_factory'
|
|
4
|
+
require 'rails_mmd/schema_validator'
|
|
5
|
+
|
|
6
|
+
module RailsMmd
|
|
7
|
+
# Serializes renderer-only plans into deterministic P0 Mermaid text.
|
|
8
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
9
|
+
class MermaidSerializer
|
|
10
|
+
Result = Struct.new(:text, :diagnostics, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
DOMAIN_ID_PATTERN = /\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/
|
|
13
|
+
ARTIFACT_KINDS = %w[er class].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(diagnostics: DiagnosticFactory.new, schema_validator: SchemaValidator.new)
|
|
16
|
+
@diagnostic_factory = diagnostics
|
|
17
|
+
@schema_validator = schema_validator
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def serialize(render_plan:)
|
|
21
|
+
schema_validator.validate_render_plan!(render_plan)
|
|
22
|
+
Result.new(text: text_for(render_plan), diagnostics: [])
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
Result.new(text: nil, diagnostics: [serialization_diagnostic(render_plan, e)])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :diagnostic_factory, :schema_validator
|
|
30
|
+
|
|
31
|
+
def text_for(render_plan)
|
|
32
|
+
return er_text(render_plan) if render_plan.fetch('artifact_kind') == 'er'
|
|
33
|
+
|
|
34
|
+
class_text(render_plan)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def er_text(render_plan)
|
|
38
|
+
lines = header_lines('erDiagram', render_plan) + comment_lines(render_plan)
|
|
39
|
+
lines.concat(er_body_lines(render_plan))
|
|
40
|
+
"#{lines.join("\n")}\n"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def er_body_lines(render_plan)
|
|
44
|
+
if render_plan.fetch('entities').any? { |entity| entity.fetch('attributes').any? }
|
|
45
|
+
return er_relationship_lines(render_plan) +
|
|
46
|
+
render_plan.fetch('entities').flat_map { |entity| er_entity_lines(entity) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
render_plan.fetch('entities').map { |entity| " #{entity.fetch('safe_token')}" } +
|
|
50
|
+
er_relationship_lines(render_plan)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def class_text(render_plan)
|
|
54
|
+
lines = header_lines('classDiagram', render_plan) + comment_lines(render_plan)
|
|
55
|
+
lines.concat(render_plan.fetch('entities').flat_map { |entity| class_entity_lines(entity) })
|
|
56
|
+
lines.concat(class_relationship_lines(render_plan))
|
|
57
|
+
"#{lines.join("\n")}\n"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def header_lines(diagram, render_plan)
|
|
61
|
+
[
|
|
62
|
+
diagram,
|
|
63
|
+
" direction #{render_plan.fetch('direction')}"
|
|
64
|
+
]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def comment_lines(render_plan)
|
|
68
|
+
render_plan.fetch('comments').map { |comment| " %% #{line_text(comment.fetch('text'), field: 'comment text')}" }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def er_relationship_lines(render_plan)
|
|
72
|
+
render_plan.fetch('relationships').map do |relationship|
|
|
73
|
+
" #{relationship.fetch('owner_safe_token')} #{relationship.fetch('er_left_marker')}.." \
|
|
74
|
+
"#{relationship.fetch('er_right_marker')} #{relationship.fetch('target_safe_token')} : " \
|
|
75
|
+
"#{non_empty_label(relationship)}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def class_relationship_lines(render_plan)
|
|
80
|
+
render_plan.fetch('relationships').map do |relationship|
|
|
81
|
+
" #{relationship.fetch('owner_safe_token')} \"#{relationship.fetch('class_owner_multiplicity')}\" " \
|
|
82
|
+
"--> \"#{relationship.fetch('class_target_multiplicity')}\" " \
|
|
83
|
+
"#{relationship.fetch('target_safe_token')} : #{non_empty_label(relationship)}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def er_entity_lines(entity)
|
|
88
|
+
return [" #{entity.fetch('safe_token')}"] if entity.fetch('attributes').empty?
|
|
89
|
+
|
|
90
|
+
[
|
|
91
|
+
" #{entity.fetch('safe_token')} {",
|
|
92
|
+
*entity.fetch('attributes').map { |attribute| " #{er_attribute_line(attribute)}" },
|
|
93
|
+
' }'
|
|
94
|
+
]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def class_entity_lines(entity)
|
|
98
|
+
return [" class #{entity.fetch('safe_token')}"] if entity.fetch('attributes').empty?
|
|
99
|
+
|
|
100
|
+
[
|
|
101
|
+
" class #{entity.fetch('safe_token')} {",
|
|
102
|
+
*entity.fetch('attributes').map { |attribute| " #{class_attribute_line(attribute)}" },
|
|
103
|
+
' }'
|
|
104
|
+
]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def er_attribute_line(attribute)
|
|
108
|
+
[
|
|
109
|
+
attribute.fetch('type'),
|
|
110
|
+
line_text(attribute.fetch('label'), field: 'attribute label'),
|
|
111
|
+
attribute['key_marker']
|
|
112
|
+
].compact.join(' ')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def class_attribute_line(attribute)
|
|
116
|
+
"+#{attribute.fetch('type')} #{line_text(attribute.fetch('label'), field: 'attribute label')}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def non_empty_label(relationship)
|
|
120
|
+
label = line_text(relationship.fetch('label'), field: 'relationship label')
|
|
121
|
+
raise ArgumentError, 'relationship label is required' if label.empty?
|
|
122
|
+
|
|
123
|
+
label
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def line_text(value, field:)
|
|
127
|
+
text = value.to_s
|
|
128
|
+
raise ArgumentError, "#{field} must be single-line" if text.match?(/[\r\n]/)
|
|
129
|
+
|
|
130
|
+
text
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def serialization_diagnostic(render_plan, exception)
|
|
134
|
+
diagnostic_factory.build(
|
|
135
|
+
code: 'MERMAID_SERIALIZATION_FAILED',
|
|
136
|
+
message: 'serialization failed',
|
|
137
|
+
subject_id: "#{safe_domain_id(render_plan)}:#{safe_artifact_kind(render_plan)}",
|
|
138
|
+
metadata: serialization_metadata(render_plan, exception)
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def serialization_metadata(render_plan, exception)
|
|
143
|
+
{
|
|
144
|
+
artifact_kind: safe_artifact_kind(render_plan),
|
|
145
|
+
domain_id: safe_domain_id(render_plan),
|
|
146
|
+
reason: exception.message
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def safe_artifact_kind(render_plan)
|
|
151
|
+
return 'er' unless render_plan.is_a?(Hash)
|
|
152
|
+
|
|
153
|
+
artifact_kind = render_plan.fetch('artifact_kind', 'er').to_s
|
|
154
|
+
return artifact_kind if ARTIFACT_KINDS.include?(artifact_kind)
|
|
155
|
+
|
|
156
|
+
'er'
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def safe_domain_id(render_plan)
|
|
160
|
+
return 'global' unless render_plan.is_a?(Hash)
|
|
161
|
+
|
|
162
|
+
domain_id = render_plan.fetch('domain_id', 'global').to_s
|
|
163
|
+
return domain_id if domain_id.match?(DOMAIN_ID_PATTERN)
|
|
164
|
+
|
|
165
|
+
'global'
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'json_schemer'
|
|
5
|
+
|
|
6
|
+
module RailsMmd
|
|
7
|
+
# Reads diagnostic metadata contracts from the diagnostics JSON Schema.
|
|
8
|
+
class MetadataShapes
|
|
9
|
+
def initialize(schema_path:)
|
|
10
|
+
@schema_path = schema_path
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch(code)
|
|
14
|
+
shapes.fetch(code)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def properties
|
|
18
|
+
@properties ||= shapes.values.each_with_object({}) do |shape, output|
|
|
19
|
+
output.merge!(shape.fetch('properties'))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate!(code, metadata)
|
|
24
|
+
errors = schema(code).validate(metadata).to_a
|
|
25
|
+
return if errors.empty?
|
|
26
|
+
|
|
27
|
+
raise ArgumentError, "invalid diagnostic metadata for #{code}: #{errors.first.fetch('data_pointer')}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :schema_path
|
|
33
|
+
|
|
34
|
+
def shapes
|
|
35
|
+
@shapes ||= metadata_rules.to_h do |rule|
|
|
36
|
+
code = rule.dig('if', 'properties', 'code', 'const')
|
|
37
|
+
ref = rule.dig('then', 'properties', 'metadata', '$ref')
|
|
38
|
+
[code, definitions.fetch(ref.split('/').last)]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def metadata_rules
|
|
43
|
+
definitions.values.select { |definition| definition.dig('then', 'properties', 'metadata', '$ref') }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def schema(code)
|
|
47
|
+
@schemas ||= {}
|
|
48
|
+
@schemas[code] ||= JSONSchemer.schema(fetch(code).merge('$defs' => definitions))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def definitions
|
|
52
|
+
@definitions ||= JSON.parse(schema_path.read).fetch('$defs')
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_mmd/canonical_json'
|
|
4
|
+
require 'rails_mmd/constant_resolver'
|
|
5
|
+
require 'rails_mmd/redactor'
|
|
6
|
+
|
|
7
|
+
module RailsMmd
|
|
8
|
+
# Builds schema-free ActiveRecord model inventory records.
|
|
9
|
+
# rubocop:disable-next Metrics/ClassLength, Metrics/MethodLength
|
|
10
|
+
class ModelInventory
|
|
11
|
+
Record = Struct.new(
|
|
12
|
+
:ruby_constant,
|
|
13
|
+
:abstract_class,
|
|
14
|
+
:base_class,
|
|
15
|
+
:table_name,
|
|
16
|
+
:connection_context_id,
|
|
17
|
+
:renderable,
|
|
18
|
+
:renderability_reason,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
)
|
|
21
|
+
CONNECTION_CONTEXT_KEYS = %w[name role shard adapter database host port username].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(active_record_base:, constant_resolver: ConstantResolver.new,
|
|
24
|
+
redactor: Redactor.new)
|
|
25
|
+
@active_record_base = active_record_base
|
|
26
|
+
@constant_resolver = ConstantResolver.wrap(constant_resolver)
|
|
27
|
+
@redactor = redactor
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def records
|
|
31
|
+
active_record_base.descendants.filter_map do |model|
|
|
32
|
+
next unless constant_backed_model?(model)
|
|
33
|
+
|
|
34
|
+
build_record(model)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :active_record_base, :constant_resolver, :redactor
|
|
41
|
+
|
|
42
|
+
def constant_backed_model?(model)
|
|
43
|
+
model_name = model.name
|
|
44
|
+
return false unless model_name.is_a?(String) && !model_name.empty?
|
|
45
|
+
|
|
46
|
+
constant_resolver.resolve(model_name).equal?(model)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_record(model)
|
|
50
|
+
table_name, table_error = table_name_for(model)
|
|
51
|
+
connection_context_id, connection_context_error = connection_context_id_for(model)
|
|
52
|
+
abstract = abstract_class?(model)
|
|
53
|
+
base_model = base_class(model)
|
|
54
|
+
renderability_reason = renderability_reason(model, abstract, base_model, table_error, connection_context_error)
|
|
55
|
+
|
|
56
|
+
Record.new(
|
|
57
|
+
ruby_constant: model.name,
|
|
58
|
+
abstract_class: abstract,
|
|
59
|
+
base_class: base_model&.name,
|
|
60
|
+
table_name: table_name,
|
|
61
|
+
connection_context_id: connection_context_id,
|
|
62
|
+
renderable: renderability_reason.nil?,
|
|
63
|
+
renderability_reason: renderability_reason
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def abstract_class?(model)
|
|
68
|
+
model.respond_to?(:abstract_class?) && model.abstract_class?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def base_class(model)
|
|
72
|
+
model.respond_to?(:base_class) ? model.base_class : model
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def table_name_for(model)
|
|
76
|
+
[model.table_name, nil]
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
[nil, e]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def renderability_reason(model, abstract, base_model, table_error, connection_context_error)
|
|
82
|
+
return 'abstract_class' if abstract
|
|
83
|
+
return 'sti_subclass' unless base_model.equal?(model)
|
|
84
|
+
return "table_name_unavailable: #{redactor.sanitize(table_error.message)}" if table_error
|
|
85
|
+
if connection_context_error
|
|
86
|
+
return "connection_context_unavailable: #{redactor.sanitize(connection_context_error.message)}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def connection_context_id_for(model)
|
|
93
|
+
[CanonicalJson.dump(connection_context(model)), nil]
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
[CanonicalJson.dump('model' => model.name), e]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def connection_context(model)
|
|
99
|
+
if model.respond_to?(:connection_context)
|
|
100
|
+
sanitize_context(model.connection_context)
|
|
101
|
+
elsif model.respond_to?(:connection_db_config)
|
|
102
|
+
context_from_db_config(model)
|
|
103
|
+
else
|
|
104
|
+
{ 'model' => model.name }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def context_from_db_config(model)
|
|
109
|
+
db_config = model.connection_db_config
|
|
110
|
+
{
|
|
111
|
+
'name' => connection_context_value(db_config, :name),
|
|
112
|
+
'role' => connection_context_value(model, :current_role) || 'default',
|
|
113
|
+
'shard' => connection_context_value(model, :current_shard) || 'default',
|
|
114
|
+
'adapter' => connection_context_value(db_config, :adapter),
|
|
115
|
+
'database' => connection_context_value(db_config, :database),
|
|
116
|
+
'host' => connection_context_value(db_config, :host),
|
|
117
|
+
'port' => connection_context_value(db_config, :port),
|
|
118
|
+
'username' => connection_context_value(db_config, :username)
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def sanitize_context(context)
|
|
123
|
+
raw_context = context.to_h.transform_keys(&:to_s)
|
|
124
|
+
CONNECTION_CONTEXT_KEYS.to_h do |key|
|
|
125
|
+
[key, sanitize_context_value(raw_context.fetch(key, default_context_value(key)))]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def sanitize_context_value(value)
|
|
130
|
+
value
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def default_context_value(key)
|
|
134
|
+
%w[role shard].include?(key) ? 'default' : nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def connection_context_value(connection_source, method_name)
|
|
138
|
+
return unless connection_source.respond_to?(method_name)
|
|
139
|
+
|
|
140
|
+
context_value = connection_source.public_send(method_name)
|
|
141
|
+
sanitize_context_value(context_value)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMmd
|
|
4
|
+
# Stable ordering helpers used by later pipeline slices.
|
|
5
|
+
module Ordering
|
|
6
|
+
SEVERITY_RANK = { 'fatal' => 0, 'error' => 1, 'warning' => 2 }.freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def sort_by_key(records, key)
|
|
11
|
+
records.sort_by { |record| record.fetch(key) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def sort_diagnostics(records)
|
|
15
|
+
records.sort_by do |diagnostic|
|
|
16
|
+
[
|
|
17
|
+
SEVERITY_RANK.fetch(diagnostic.fetch('severity')),
|
|
18
|
+
diagnostic.fetch('code'),
|
|
19
|
+
diagnostic.fetch('subject_id').to_s,
|
|
20
|
+
diagnostic.fetch('diagnostic_id')
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|